feat(client): 网络请求重试 + 离线提示可感知重试按钮

修复①请求级重试:新增 RetryInterceptor,网络层失败重试 3 次间隔递增
1s→2s→4s;GET 全重试,写操作仅在连接未建立时重试以防重复提交。
connectTimeout 5s→8s。

修复②连通性:启动首检改退避重试 4s→8s→12s,根治跨境冷启动假离线;
新增检测中状态 + retry();登录页/全局离线提示加 NetworkRetryButton,
重试中转圈、结果 SnackBar 反馈。

121 个测试通过,analyze 无 error。todo #52。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-06-16 14:42:23 +08:00
parent 3f3aa41121
commit 018180de8c
8 changed files with 269 additions and 150 deletions
+16 -6
View File
@@ -5,13 +5,20 @@ import '../auth/auth_state.dart';
import '../config/app_config.dart';
import '../errors/error_reporter.dart';
import '../../providers/connectivity_provider.dart';
import 'retry_interceptor.dart';
/// Public Dio instance for unauthenticated calls (login / refresh)
final _publicDio = Dio(BaseOptions(
baseUrl: AppConfig.apiBaseUrl,
connectTimeout: const Duration(seconds: 5),
receiveTimeout: const Duration(seconds: 15),
));
final _publicDio = _buildPublicDio();
Dio _buildPublicDio() {
final dio = Dio(BaseOptions(
baseUrl: AppConfig.apiBaseUrl,
connectTimeout: const Duration(seconds: 8),
receiveTimeout: const Duration(seconds: 15),
));
dio.interceptors.add(RetryInterceptor(dio));
return dio;
}
final apiClientProvider = Provider<ApiClient>((ref) {
// 只监听登录/登出,不监听 token 内容变化。
@@ -52,13 +59,16 @@ class ApiClient {
}) {
_dio = Dio(BaseOptions(
baseUrl: AppConfig.apiBaseUrl,
connectTimeout: const Duration(seconds: 5),
connectTimeout: const Duration(seconds: 8),
receiveTimeout: const Duration(seconds: 15),
headers: {
if (token != null) 'Authorization': 'Bearer $token',
},
));
// 网络层错误自动重试(须在错误处理拦截器之前,先重试再走 401/上报逻辑)
_dio.interceptors.add(RetryInterceptor(_dio));
// 网络错误 + 401 拦截器
_dio.interceptors.add(
InterceptorsWrapper(
@@ -0,0 +1,63 @@
import 'package:dio/dio.dart';
/// 网络层错误自动重试拦截器。
///
/// 仅对「网络层」失败(连接超时 / 连接失败 / 读写超时)重试,**不**对带响应的
/// HTTP 错误(4xx/5xx 业务错误)重试。重试 3 次,间隔递增(1s → 2s → 4s)。
///
/// 安全策略(避免重复入库/出库等副作用):
/// - GET:幂等,所有网络错误都重试。
/// - POST/PUT/PATCH/DELETE:仅在「连接尚未建立」(connectTimeout / connectionError
/// 服务器还没收到请求)时重试;receiveTimeout/sendTimeout 时服务器可能已处理,
/// 不重试,避免重复提交。
class RetryInterceptor extends Interceptor {
final Dio dio;
final List<Duration> delays;
RetryInterceptor(
this.dio, {
this.delays = const [
Duration(seconds: 1),
Duration(seconds: 2),
Duration(seconds: 4),
],
});
static const _attemptKey = 'retry_attempt';
bool _retriable(DioException e) {
final method = (e.requestOptions.method).toUpperCase();
final isIdempotent = method == 'GET' || method == 'HEAD';
switch (e.type) {
case DioExceptionType.connectionTimeout:
case DioExceptionType.connectionError:
// 连接未建立 → 服务器未收到 → 任何方法都可安全重试
return true;
case DioExceptionType.receiveTimeout:
case DioExceptionType.sendTimeout:
// 服务器可能已处理 → 仅幂等方法重试
return isIdempotent;
default:
return false;
}
}
@override
void onError(DioException err, ErrorInterceptorHandler handler) async {
final attempt = (err.requestOptions.extra[_attemptKey] as int?) ?? 0;
if (_retriable(err) && attempt < delays.length) {
await Future.delayed(delays[attempt]);
err.requestOptions.extra[_attemptKey] = attempt + 1;
try {
final resp = await dio.fetch(err.requestOptions);
return handler.resolve(resp);
} on DioException catch (e) {
return handler.next(e);
} catch (_) {
return handler.next(err);
}
}
return handler.next(err);
}
}
+63 -10
View File
@@ -7,34 +7,55 @@ import '../core/config/app_config.dart';
/// 数据 provider 通过 watch 此值实现网络恢复后自动刷新。
final networkRecoveryCountProvider = StateProvider<int>((ref) => 0);
/// 是否正在进行一次「带重试的连通性检测」(供 UI 显示「重试中…」转圈)。
final connectivityCheckingProvider = StateProvider<bool>((ref) => false);
final connectivityProvider =
StateNotifierProvider<ConnectivityNotifier, bool>((ref) {
return ConnectivityNotifier(
onRecovered: () {
ref.read(networkRecoveryCountProvider.notifier).update((s) => s + 1);
},
onCheckingChanged: (checking) {
ref.read(connectivityCheckingProvider.notifier).state = checking;
},
);
});
class ConnectivityNotifier extends StateNotifier<bool> {
final void Function()? onRecovered;
final void Function(bool checking)? onCheckingChanged;
ConnectivityNotifier({this.onRecovered, bool skipInit = false}) : super(true) {
ConnectivityNotifier({
this.onRecovered,
this.onCheckingChanged,
bool skipInit = false,
}) : super(true) {
if (!skipInit) {
_check();
// 启动首检走「带重试」:跨境冷启动 DNS+TLS 握手较慢,单次 4s 易误判离线,
// 退避重试 4s→8s→12s 可显著降低首屏假离线。
_check(withRetry: true);
_startOnlineTimer();
}
}
Timer? _timer;
// 独立轻量 Dio短超时,无拦截器
// 独立轻量 Dio:无拦截器。连接预算用 Future.timeout 逐次控制,
// 故 BaseOptions 超时放宽到 15s 作为兜底。
final _dio = Dio(BaseOptions(
connectTimeout: const Duration(seconds: 3),
receiveTimeout: const Duration(seconds: 3),
connectTimeout: const Duration(seconds: 15),
receiveTimeout: const Duration(seconds: 15),
));
/// 在线时:每 30 秒检测一次
/// 每次尝试的超时预算,逐次拉长(越来越长)。
static const _budgets = <Duration>[
Duration(seconds: 4),
Duration(seconds: 8),
Duration(seconds: 12),
];
/// 在线时:每 30 秒轻量检测一次(单次,不重试)
void _startOnlineTimer() {
_timer?.cancel();
_timer = Timer.periodic(const Duration(seconds: 30), (_) => _check());
@@ -46,25 +67,57 @@ class ConnectivityNotifier extends StateNotifier<bool> {
_timer = Timer.periodic(const Duration(minutes: 1), (_) => _check());
}
/// 立即触发一次检测(供外部调用,如 API 请求失败 / 启动时)
/// 立即触发一次检测(供外部调用,如 API 请求失败 / 启动时)。单次,不重试。
Future<void> forceCheck() => _check();
Future<void> _check() async {
/// 用户手动重试:带退避重试,并对外广播「检测中」状态,返回最终是否在线。
Future<bool> retry() async {
onCheckingChanged?.call(true);
try {
await _dio.get(AppConfig.healthUrl);
return await _check(withRetry: true);
} finally {
onCheckingChanged?.call(false);
}
}
/// 单次 ping /health[budget] 为本次超时预算。
Future<bool> _ping(Duration budget) async {
try {
await _dio.get(AppConfig.healthUrl).timeout(budget);
return true;
} catch (_) {
return false;
}
}
/// 连通性检测。[withRetry]=true 时按 [_budgets] 退避重试,间隔递增。
Future<bool> _check({bool withRetry = false}) async {
final budgets = withRetry ? _budgets : const [Duration(seconds: 4)];
var ok = false;
for (var i = 0; i < budgets.length; i++) {
ok = await _ping(budgets[i]);
if (ok) break;
// 最后一次失败后不再等待;中间失败按递增间隔退避(0.6s, 1.2s…)
if (i < budgets.length - 1) {
await Future.delayed(Duration(milliseconds: 600 * (i + 1)));
}
}
if (ok) {
if (!state) {
// 离线 → 在线:切回高频检测,广播恢复事件
state = true;
onRecovered?.call();
_startOnlineTimer();
}
} catch (_) {
} else {
if (state) {
// 在线 → 离线:切换为低频嗅探
state = false;
_startOfflineTimer();
}
}
return ok;
}
@override
@@ -11,6 +11,7 @@ import '../../core/theme/app_theme.dart';
import '../../core/storage/login_history.dart';
import '../../providers/connectivity_provider.dart';
import '../../repositories/auth_repository.dart';
import '../../widgets/network_retry_button.dart';
class LoginScreen extends ConsumerStatefulWidget {
const LoginScreen({super.key});
@@ -426,6 +427,8 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
fontSize: 13),
),
),
NetworkRetryButton(
foreground: Color(0xFF5D4037)),
],
),
),
+10 -6
View File
@@ -14,6 +14,7 @@ import '../../providers/shop_provider.dart';
import '../../providers/update_provider.dart';
import '../../core/update/app_updater.dart';
import '../../providers/license_provider.dart';
import '../../widgets/network_retry_button.dart';
class AppShell extends ConsumerStatefulWidget {
final Widget child;
@@ -382,13 +383,16 @@ class _AppShellState extends ConsumerState<AppShell> {
Icon(Icons.wifi_off,
size: 16, color: Colors.white),
SizedBox(width: 8),
Text(
'网络连接已断开 · 当前显示离线缓存数据,恢复后将自动刷新',
style: TextStyle(
color: Colors.white,
fontSize: 13,
fontWeight: FontWeight.w500),
Expanded(
child: Text(
'网络连接已断开 · 当前显示离线缓存数据,恢复后将自动刷新',
style: TextStyle(
color: Colors.white,
fontSize: 13,
fontWeight: FontWeight.w500),
),
),
NetworkRetryButton(),
],
),
),
@@ -0,0 +1,65 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../core/theme/app_theme.dart';
import '../providers/connectivity_provider.dart';
/// 离线提示里的「重试」按钮:
/// - 检测中显示转圈 +「重试中…」(过程可感知)
/// - 重试结束用 SnackBar 反馈成功/失败(结果可感知)
///
/// [foreground] 适配不同底色(红色横幅用白字,琥珀框用深色)。
class NetworkRetryButton extends ConsumerWidget {
final Color foreground;
const NetworkRetryButton({super.key, this.foreground = Colors.white});
@override
Widget build(BuildContext context, WidgetRef ref) {
final checking = ref.watch(connectivityCheckingProvider);
return TextButton(
onPressed: checking
? null
: () async {
final ok =
await ref.read(connectivityProvider.notifier).retry();
if (!context.mounted) return;
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(SnackBar(
content: Text(ok ? '已重新连接服务器' : '仍无法连接服务器,请稍后重试'),
backgroundColor: ok ? AppTheme.success : AppTheme.danger,
duration: const Duration(seconds: 2),
behavior: SnackBarBehavior.floating,
));
},
style: TextButton.styleFrom(
foregroundColor: foreground,
minimumSize: const Size(0, 32),
padding: const EdgeInsets.symmetric(horizontal: 10),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (checking)
SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(foreground),
),
)
else
Icon(Icons.refresh, size: 16, color: foreground),
const SizedBox(width: 6),
Text(
checking ? '重试中…' : '重试',
style: TextStyle(
color: foreground, fontSize: 13, fontWeight: FontWeight.w600),
),
],
),
);
}
}
+34 -126
View File
@@ -131,37 +131,6 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
}
.reject-date { color: #ef9999; font-size: 12px; }
/* ── 确认闸(方案/改动说明)── */
.gate-block { margin: 10px 0 4px; padding: 10px 14px; border-radius: 8px; font-size: 13px; }
.gate-pending { background: #fff7ed; border: 1px solid #fdba74; }
.gate-granted { background: #f0fdf4; border: 1px solid #bbf7d0; }
.gate-info { background: #f1f5f9; border: 1px solid #e2e8f0; }
.gate-head { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-bottom: 4px; }
.gate-badge { padding: 2px 9px; border-radius: 10px; font-size: 11.5px; font-weight: 700; white-space: nowrap; }
.gate-badge.pending { background: #f97316; color: #fff; }
.gate-badge.granted { background: #22c55e; color: #fff; }
.gate-badge.info { background: #94a3b8; color: #fff; }
.gate-kind { font-size: 12px; color: #52606d; }
.gate-date { font-size: 12px; color: #8aa3c4; margin-left: auto; }
.gate-note { color: #52606d; margin: 4px 0; white-space: pre-wrap; }
.gate-ref { font-size: 12.5px; color: #52606d; margin-top: 4px; }
.gate-note code, .gate-ref code {
background: #fff; padding: 1px 5px; border-radius: 3px;
font-family: "JetBrains Mono", monospace; font-size: 12px; color: #b91c1c;
}
.approve-btn {
margin-top: 8px; padding: 5px 14px; border-radius: 6px; border: none;
background: #16a34a; color: #fff; font-size: 12.5px; font-weight: 600; cursor: pointer;
transition: background .15s;
}
.approve-btn:hover { background: #15803d; }
.approve-cmd { margin-top: 10px; padding: 10px 12px; background: #1e293b; border-radius: 8px; }
.approve-cmd-label { font-size: 12px; color: #94a3b8; margin-bottom: 6px; }
.approve-cmd-code { font-family: "JetBrains Mono", monospace; font-size: 13px; color: #86efac; display: block; word-break: break-all; }
.approve-copy-btn { margin-top: 8px; padding: 4px 12px; border-radius: 6px; background: #334155; color: #e2e8f0; border: none; font-size: 12px; cursor: pointer; }
.approve-copy-btn:hover { background: #475569; }
.stat-pill.gate-stat { background: #f97316; }
/* ── 子任务区 ── */
.subtask-block {
margin-top: 12px; padding: 10px 14px;
@@ -252,14 +221,13 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
<header>
<div class="wrap">
<h1>酒库管理系统 — 项目 TODO</h1>
<div class="header-meta">生成于 2026-06-14 · 真相源 todo/todo.json</div>
<div class="header-meta">生成于 2026-06-16 · 真相源 todo/todo.json</div>
<div class="stats">
<div class="stat-pill"><strong>47</strong>全部</div>
<div class="stat-pill"><strong>48</strong>全部</div>
<div class="stat-pill"><strong>1</strong>待开始</div>
<div class="stat-pill"><strong>0</strong>开发中</div>
<div class="stat-pill"><strong>32</strong>待验收</div>
<div class="stat-pill"><strong>33</strong>待验收</div>
<div class="stat-pill"><strong>14</strong>已验收</div>
</div>
</div>
</header>
@@ -326,7 +294,6 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
<div class="item-desc">路线已定:CI 构建签名 IPA → 上传 TestFlight。前置(用户侧)Apple Developer Program($99/年)+App Store Connect 建 App(com.yanmei.jiu)+三件套凭证(.p12/.mobileprovision/.p8)+7个 Forgejo secrets+TestFlight 公开链接填 version.yaml。代码缺口(我方)compile-ios.sh 需在 flutter build ipa 前向 Release.xcconfig 注入 manual 签名(CODE_SIGN_STYLE/DEVELOPMENT_TEAM/PROVISIONING_PROFILE_SPECIFIER/CODE_SIGN_IDENTITY),否则 archive 阶段因 Automatic 无 team 必失败。详见计划 luminous-hugging-platypus.md。暂不调度。</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="iOS">iOS</span> <span class="tag t-tag" data-tag="CI/CD">CI/CD</span></div>
<div class="item-meta">
@@ -351,7 +318,7 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
</div>
<div class="section-block" id="section-done">
<div class="section-title st-done" data-toggle="done">
🔍 待验收 <span class="s-count">32</span>
🔍 待验收 <span class="s-count">33</span>
<span class="s-arrow">▴ 收起</span>
</div>
<div class="section-list-wrap " id="list-wrap-done">
@@ -376,7 +343,6 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
<div class="item-desc">新建入库单的商品行增加产地、保质期、储存方式三个字典下拉选项(已有字典数据)。必填项(商品名、数量等)正常显示;选填项默认隐藏,点击展开按钮后在下一行展示。</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="前端">前端</span> <span class="tag t-tag" data-tag="iOS">iOS</span> <span class="tag t-tag" data-tag="Android">Android</span></div>
<div class="item-meta">
@@ -405,7 +371,6 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
<div class="item-desc">stock.go ApproveStockOut 中库存总量 SUM 在 FOR UPDATE 加锁前执行,存在 check-then-act 窗口,高并发下可超扣。需将预检 SUM 也放入事务并在加锁后执行</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="后端">后端</span></div>
<div class="item-meta">
@@ -434,7 +399,6 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
<div class="item-desc">partner.go/product_attr.go/warehouse.go 等多处用 db.Save(&amp;object) 更新全字段,若中间件被绕过会造成 shop_id 被覆盖。改为 db.Model(&amp;x).Where(&quot;id=? AND shop_id=?&quot;).Updates(fields) 白名单模式</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="后端">后端</span></div>
<div class="item-meta">
@@ -463,7 +427,6 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
<div class="item-desc">出库审批若 DB 断连,Rollback 后库存正确但财务记录可能未落地。需添加对账脚本:SUM(InventoryLog.quantity by direction) == SUM(Inventories.quantity),及告警机制</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="后端">后端</span></div>
<div class="item-meta">
@@ -492,7 +455,6 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
<div class="item-desc">service/stock.go:312 DB查询失败时sums为空,所有商品have=0,合法出库审核被全部拒绝</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="后端">后端</span></div>
<div class="item-meta">
@@ -521,7 +483,6 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
<div class="item-desc">v1.0.27~v1.0.32 连续 6 个版本 CI 全部'失败',但日志显示所有 job 实际构建成功(🏁 Job succeeded),是 act_runner 最终状态上报丢失(Failed to write EOT),被 Forgejo 僵尸任务清理标为 failure。生产环境因此停留在 v1.0.266/8)。需排查 relay/GOAWAY race 或升级 act_runner/Forgejo。</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="CI/CD">CI/CD</span></div>
<div class="item-meta">
@@ -550,7 +511,6 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
<div class="item-desc">当前 product_detail_screen.dart 显示内容过于简单。需先做页面设计(UI 方案),内容要求是公开扫码页的超集:包含商品基本信息、参数(产地/保质期/储存方式)、价格、描述文档、图片、库存批次、入库记录等管理侧专属字段。另:商品公开链接展示为可点击超链接(点击可跳转公开扫码页)。</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="前端">前端</span> <span class="tag t-tag" data-tag="iOS">iOS</span> <span class="tag t-tag" data-tag="Android">Android</span></div>
<div class="item-meta">
@@ -579,7 +539,6 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
<div class="item-desc">Windows 和 macOS 客户端启动时检测是否已有实例运行;若有,则激活已有窗口并退出新进程。Flutter desktop 可通过 window_single_instance 或 dart:io 锁文件实现。</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="Windows">Windows</span> <span class="tag t-tag" data-tag="mac">mac</span></div>
<div class="item-meta">
@@ -608,7 +567,6 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
<div class="item-desc">licenses.device_id 已标记 deprecated 但激活/解绑逻辑仍在使用它,license_devices 表未被完整采用。需将 Activate/Deactivate 逻辑迁移至 license_devices,并写数据迁移脚本</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="后端">后端</span> <span class="tag t-tag" data-tag="数据库">数据库</span></div>
<div class="item-meta">
@@ -637,7 +595,6 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
<div class="item-desc">checkInventory 实现在 handler/stock_out.go 中而非 service 层,导致单元测试困难、逻辑无法复用。应移至 StockService 或 InventoryService</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="后端">后端</span></div>
<div class="item-meta">
@@ -666,7 +623,6 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
<div class="item-desc">各 handler 返回格式不一致:有的 {error: err.Error()},有的 {error: 硬编码字符串},有的直接 struct。前端解析困难。需定义 ErrorResponse struct 和 RespondError/RespondSuccess 辅助函数统一调用</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="后端">后端</span></div>
<div class="item-meta">
@@ -695,7 +651,6 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
<div class="item-desc">config.go 默认 CORSOrigin=&quot;*&quot;,注释提示生产需改但无代码强制。应在 main.go 中添加:server.mode!=debug 且 CORSOrigin==&quot;*&quot; 时 log.Fatal 拒绝启动</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="后端">后端</span></div>
<div class="item-meta">
@@ -724,7 +679,6 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
<div class="item-desc">license.go 中私钥未配置只打 log 并跳过,导致授权功能失效但程序正常运行。应改为 log.Fatal,确保运维人员知晓配置缺失</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="后端">后端</span></div>
<div class="item-meta">
@@ -753,7 +707,6 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
<div class="item-desc">inventory.go 限制 pageSize 最大 500,其他 handler 无此限制,前端可传 pageSize=10000 打满 DB。创建 util.ValidatePageSize(n) 统一处理</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="后端">后端</span></div>
<div class="item-meta">
@@ -782,7 +735,6 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
<div class="item-desc">finance.go:36 和 inventory.go:130 漏加 ValidatePageSize#37 未覆盖这两个 handler,可被任意用户触发全表扫描</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="后端">后端</span></div>
<div class="item-meta">
@@ -811,7 +763,6 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
<div class="item-desc">admin.go:122-123 invQuery/logQuery Scan错误被丢弃;invQuery失败→差异消失显示全部正常;logQuery失败→所有库存飘红为差异</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="后端">后端</span></div>
<div class="item-meta">
@@ -840,7 +791,6 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
<div class="item-desc">handler/license.go:67-70 两个bug1.devs,_ 错误被_吞掉DB故障时deviceCount=02.ListDevices(shopID)包含该店所有license设备而非当前license</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="后端">后端</span></div>
<div class="item-meta">
@@ -869,7 +819,6 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
<div class="item-desc">license.go 的 LicenseKey 是 size:2048+uniqueIndexutf8mb4 下 8192 字节超 MySQL InnoDB 索引上限,全新环境 AutoMigrate 必失败(线上已手动建 license_key(768) 前缀索引绕过)。需在 model 层固化:缩短列或 GORM 指定索引前缀长度,并同步 schema.sql。</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="后端">后端</span> <span class="tag t-tag" data-tag="数据库">数据库</span></div>
<div class="item-meta">
@@ -879,6 +828,34 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
</div>
</li>
<li class="todo-card s-done"
data-id="52"
data-level="mid"
data-status="done"
data-tier=""
data-tags="">
<div class="card-header">
<span class="item-title">网络请求加重试(3次间隔递增)+离线提示带可感知的重试按钮</span>
<div class="card-badges">
<span class="tag status-badge s-done">待验收</span>
<span class="tag t-high">重要</span>
<button class="reject-btn" data-id="52" data-title="网络请求加重试(3次间隔递增)+离线提示带可感知的重试按钮">拒绝验收</button>
</div>
</div>
<div class="card-footer">
<div class="tag-row"></div>
<div class="item-meta">
<span class="meta-date">🕐 2026-06-16</span>
</div>
</div>
</li>
<li class="todo-card s-done"
data-id="2"
data-level="low"
@@ -898,7 +875,6 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
<div class="item-desc">现为沪ICP备2026000000号(全0),上线前必须替换真实备案号。</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="Web">Web</span></div>
<div class="item-meta">
@@ -926,7 +902,6 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
</div>
<div class="item-desc">完整方案设计,涵盖:界面设计与交互、前端实现方案、后端接口与数据模型、安全策略(防盗用、防滥用、授权校验)等。需产出设计文档后再进入开发阶段。</div>
<div class="subtask-block">
<div class="subtask-header">
<span class="subtask-label">子任务</span>
@@ -1010,7 +985,6 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
<div class="item-desc">1. 客户端「关于我们」页面的「打开文档」「更新日志」链接指向需更新为正确地址。2. Web 营销站新增版本更新页面(/changelog 或 /download/),从 CHANGELOG.md 自动渲染,展示所有历史版本更新记录。</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="前端">前端</span> <span class="tag t-tag" data-tag="Web">Web</span> <span class="tag t-tag" data-tag="文档">文档</span></div>
<div class="item-meta">
@@ -1039,7 +1013,6 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
<div class="item-desc">当前注册成功页和网站联系支持入口引导用户「发邮件」,体验较弱。升级为扫码加客服微信,同时梳理相关文案措辞,使表达更专业、有亲和力。</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="前端">前端</span> <span class="tag t-tag" data-tag="Web">Web</span></div>
<div class="item-meta">
@@ -1068,7 +1041,6 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
<div class="item-desc">在营销网站(jiu.51yanmei.com)补充服务条款(Terms of Service)和隐私政策(Privacy Policy)页面,注册页底部添加链接。最低优先级,待其他功能稳定后处理。</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="Web">Web</span> <span class="tag t-tag" data-tag="文档">文档</span></div>
<div class="item-meta">
@@ -1097,7 +1069,6 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
<div class="item-desc">窄屏(手机)模式下,导航 Drawer 当前需点击汉堡按钮打开,需支持从屏幕左边缘向右滑动手势唤出菜单,符合移动端操作习惯。涉及 app_shell.dart 的 Drawer 配置。</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="前端">前端</span> <span class="tag t-tag" data-tag="iOS">iOS</span> <span class="tag t-tag" data-tag="Android">Android</span></div>
<div class="item-meta">
@@ -1126,7 +1097,6 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
<div class="item-desc">当前入库单详情的商品明细以横向表格展示,字段多时在窄屏上挤压严重。改为每件商品独占一个卡片区块,字段竖向排列(商品名、规格、数量、单价、产地、保质期等各占一行),信息层次清晰,移动端阅读体验更好。宽屏可保持表格,窄屏自动切换为卡片流(复用 MobileListCard 模式)。</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="前端">前端</span> <span class="tag t-tag" data-tag="iOS">iOS</span> <span class="tag t-tag" data-tag="Android">Android</span></div>
<div class="item-meta">
@@ -1155,7 +1125,6 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
<div class="item-desc">main.go 每次启动执行 backfillPinyin 全表扫描,数据量大后拖慢启动。改为 go run ./cmd/migrate-pinyin 一次性迁移,后续新数据靠写入时自动生成</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="后端">后端</span></div>
<div class="item-meta">
@@ -1184,7 +1153,6 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
<div class="item-desc">main.go 中硬编码 MaxIdleConns=10, MaxOpenConns=100,无法运维调优。改为读取 config(viper),并在文档中说明推荐值</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="后端">后端</span></div>
<div class="item-meta">
@@ -1213,7 +1181,6 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
<div class="item-desc">inventories 表存了 product_name/warehouse_name/supplier_name 等,既可能是冗余(与 products JOIN 重复),也可能是历史快照(入库时锁定)。需明确语义并在 model 注释中说明;若是快照,应只在写入时固定,不随主表变化</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="后端">后端</span> <span class="tag t-tag" data-tag="数据库">数据库</span></div>
<div class="item-meta">
@@ -1242,7 +1209,6 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
<div class="item-desc">service/license.go:70 s.db.Model(&amp;existing).Update()失败时仍返回nil error,设备名未更新但调用方不知道</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="后端">后端</span></div>
<div class="item-meta">
@@ -1271,7 +1237,6 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
<div class="item-desc">service/license.go:102 WHERE id=? AND is_active=1 应加 AND shop_id=? 防御数据异常时跨租户泄露</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="后端">后端</span></div>
<div class="item-meta">
@@ -1300,7 +1265,6 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
<div class="item-desc">#34 只创建了util/response.go,没有将任何handler迁移过去,实际错误格式仍不统一,需完成迁移</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="后端">后端</span></div>
<div class="item-meta">
@@ -1329,7 +1293,6 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
<div class="item-desc">service/license.go:24 ErrDeviceMismatch定义后从未返回,多设备迁移(#32)后已无单设备约束</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="后端">后端</span></div>
<div class="item-meta">
@@ -1368,7 +1331,6 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
<div class="item-desc">Flutter _AuthenticityCard 读取 batch['quantity'] 但后端 public.go batchData 未返回 quantity。另 product code/barcode 未暴露,参数卡无法显示商品编码。handler/public.go GetProduct</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="后端">后端</span> <span class="tag t-tag" data-tag="iOS">iOS</span> <span class="tag t-tag" data-tag="Android">Android</span></div>
<div class="item-meta">
@@ -1397,7 +1359,6 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
<div class="item-desc">提示「忘记门店编码可在「系统设置→关于」查看」,需与实际客户端菜单路径一致。</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="前端">前端</span> <span class="tag t-tag" data-tag="Web">Web</span></div>
<div class="item-meta">
@@ -1426,7 +1387,6 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
<div class="item-desc">njk 站点用 footer.njk 读变量,功能页把页脚硬编码各写一份,改一处不同步。根因同「两套模板」。</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="前端">前端</span> <span class="tag t-tag" data-tag="Web">Web</span></div>
<div class="item-meta">
@@ -1455,7 +1415,6 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
<div class="item-desc">用 window.location.origin+'/api/v1/public/register',要求同源部署;分离部署时指错。需确认部署形态。</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="前端">前端</span> <span class="tag t-tag" data-tag="Web">Web</span></div>
<div class="item-meta">
@@ -1484,7 +1443,6 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
<div class="item-desc">products_screen.dart 基础数据管理页增加产地/保质期/储存方式/描述文档 4 个维护 Tab,复用现有 option 维护组件(增删改查);描述文档 Tab 含 title+content 多行输入</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="前端">前端</span> <span class="tag t-tag" data-tag="iOS">iOS</span> <span class="tag t-tag" data-tag="Android">Android</span></div>
<div class="item-meta">
@@ -1513,7 +1471,6 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
<div class="item-desc">public_product_screen.dart _Footer 底部两个链接无 url/action,点击静默。需设计方案:跳转反馈表单、弹出联系方式、或发邮件。line:1353-1355</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="前端">前端</span> <span class="tag t-tag" data-tag="iOS">iOS</span> <span class="tag t-tag" data-tag="Android">Android</span></div>
<div class="item-meta">
@@ -1542,7 +1499,6 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
<div class="item-desc">product model 有 sale_price 字段但 public.go GetProduct 未返回。需:1) 后端 batchData/productData 加 sale_price2) Flutter _TitleBlock 或单独区块展示建议零售价。client: public_product_screen.dart, backend: handler/public.go</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="前端">前端</span> <span class="tag t-tag" data-tag="后端">后端</span> <span class="tag t-tag" data-tag="iOS">iOS</span> <span class="tag t-tag" data-tag="Android">Android</span></div>
<div class="item-meta">
@@ -1571,7 +1527,6 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
<div class="item-desc">PublicProductScreen _ShopCard「添加门店微信」按钮 onPressed: () {} 为空。需设计方案:门店维护微信号字段 or 二维码图片,扫码页展示。涉及后端 shop model 扩展 + 前端展示。client/lib/screens/public/public_product_screen.dart:1255</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="前端">前端</span> <span class="tag t-tag" data-tag="iOS">iOS</span> <span class="tag t-tag" data-tag="Android">Android</span></div>
<div class="item-meta">
@@ -1600,7 +1555,6 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
<div class="item-desc">PublicProductScreen _ShopCard 底部按钮 onPressed: () {} 为空,需实现跳转至该门店商品列表页。client/lib/screens/public/public_product_screen.dart:1240</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="前端">前端</span> <span class="tag t-tag" data-tag="iOS">iOS</span> <span class="tag t-tag" data-tag="Android">Android</span></div>
<div class="item-meta">
@@ -1629,7 +1583,6 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
<div class="item-desc">stock_in_form_screen.dart 生产日期字段附近增加产地/保质期/储存方式/描述文档 4 个下拉选择(数据来自字典 List 接口);FindOrCreate/Update 接受并写入 4 个 id;前端新增对应 API client 方法</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="前端">前端</span> <span class="tag t-tag" data-tag="iOS">iOS</span> <span class="tag t-tag" data-tag="Android">Android</span></div>
<div class="item-meta">
@@ -1658,7 +1611,6 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
<div class="item-desc">public.go GetProduct Preload 4 张新表 + 三级回退(描述文档→旧Description→通用中性兜底)+ 默认值常量(保质期/储存方式);Flutter public_product_screen.dart _ParamsCard/_DescriptionSection 改读真实数据,移除茅台 mock</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="后端">后端</span> <span class="tag t-tag" data-tag="前端">前端</span> <span class="tag t-tag" data-tag="iOS">iOS</span> <span class="tag t-tag" data-tag="Android">Android</span></div>
<div class="item-meta">
@@ -1687,7 +1639,6 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
<div class="item-desc">新增 product_origin_options/product_shelf_life_options/product_storage_options/product_description_docs 4 张字典表(TenantBase+shop_id 隔离);Product 增加 4 个可空外键;AutoMigrate 注册;handler/product_attr.go CRUD16 个方法);router.go 追加 4 组路由在 /product-options/ 下</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="后端">后端</span> <span class="tag t-tag" data-tag="数据库">数据库</span></div>
<div class="item-meta">
@@ -1716,7 +1667,6 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
<div class="item-desc">features/scan.html 直接复制,不经 njk 模板,页脚/备案/CSS 都得手动同步。建议长期改造为模板化。</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="前端">前端</span> <span class="tag t-tag" data-tag="Web">Web</span></div>
<div class="item-meta">
@@ -1745,7 +1695,6 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
<div class="item-desc">主站 njk 用 color.css+style.css,功能页用 colors_and_type.css,导航/页脚各写一份,维护分裂。建议合并到一套,功能页走 base.njk 模板。</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="前端">前端</span> <span class="tag t-tag" data-tag="Web">Web</span></div>
<div class="item-meta">
@@ -1787,16 +1736,6 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
let curTier = 'all';
let curTags = new Set();
// 通过本地服务(http)打开时,按钮直接写库;用 file:// 直接打开则回退为「复制命令」
const SERVED = location.protocol === 'http:' || location.protocol === 'https:';
function postAction(url, payload) {
return fetch(url, {
method: 'POST',
headers: payload ? { 'Content-Type': 'application/json' } : undefined,
body: payload ? JSON.stringify(payload) : undefined,
}).then(r => r.json());
}
function applyFilter() {
document.querySelectorAll('.todo-card').forEach(card => {
const lvl = card.dataset.level;
@@ -1917,13 +1856,8 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
reasonInput.focus();
return;
}
if (SERVED) {
postAction('/api/reject/' + rejectId, { reason })
.then(d => { if (d.ok) { location.reload(); } else { alert('拒绝失败:' + (d.error || '')); } })
.catch(() => alert('请求失败,确认本地服务是否在运行'));
return;
}
const cmd = '/todo reject ' + rejectId + ' ' + JSON.stringify(reason);
const escaped = reason.replace(/\/g, '\\').replace(/"/g, '\"');
const cmd = 'node todo/todo.mjs reject ' + rejectId + ' --reason "' + escaped + '"';
cmdText.textContent = cmd;
cmdWrap.style.display = 'block';
document.getElementById('reject-confirm-btn').style.display = 'none';
@@ -1941,32 +1875,6 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
copyBtn.textContent = '已复制 ';
});
});
// ── 确认闸:服务模式直接写库;file:// 模式回退为展开命令 ──
document.querySelectorAll('.approve-btn').forEach(btn => {
btn.addEventListener('click', e => {
e.stopPropagation();
const id = btn.dataset.id;
if (SERVED) {
btn.disabled = true; btn.textContent = '确认中…';
postAction('/api/approve/' + id)
.then(d => {
if (d.ok) { location.reload(); }
else { btn.disabled = false; btn.textContent = '确认执行 ✓'; alert('确认失败:' + (d.error || '')); }
})
.catch(() => { btn.disabled = false; btn.textContent = '确认执行 ✓'; alert('请求失败,确认本地服务是否在运行'); });
} else {
const box = document.getElementById('approve-cmd-' + id);
if (box) box.style.display = box.style.display === 'none' ? 'block' : 'none';
}
});
});
document.querySelectorAll('.approve-copy-btn').forEach(btn => {
btn.addEventListener('click', e => {
e.stopPropagation();
navigator.clipboard.writeText(btn.dataset.cmd).then(() => { btn.textContent = '已复制 ✓'; });
});
});
})();
</script>
</body>
+15 -2
View File
@@ -1,9 +1,9 @@
{
"meta": {
"title": "酒库管理系统 — 项目 TODO",
"updated_at": "2026-06-14T00:13:09.458Z"
"updated_at": "2026-06-16T06:42:15.640Z"
},
"seq": 51,
"seq": 52,
"items": [
{
"id": 1,
@@ -816,6 +816,19 @@
"done": false,
"completed_at": null,
"version": null
},
{
"id": 52,
"title": "网络请求加重试(3次间隔递增)+离线提示带可感知的重试按钮",
"desc": null,
"level": "mid",
"tier": null,
"tags": [],
"status": "done",
"created_at": "2026-06-16T06:39:13.652Z",
"done": false,
"completed_at": null,
"version": null
}
]
}