Files
jiu/client/lib/core/api/api_client.dart
T
wangjia c1ed81dfab feat: 财务结清、酒行信息、库存备注编辑、标签溯源、入库必填校验
后端
- 新增 shop handler:GET/PUT /shop/info(管理员权限)
- 新增 finance CloseByRef:按单据 ref_type+ref_id 结清账款
- 新增 inventory UpdateRemark:PUT /inventory/:id/remark
- 入库/出库审批自动生成财务应付/应收记录(去除金额>0限制)
- 种子数据 S001-S003 补充真实门店信息

前端
- 设置页新增「酒行信息」Tab,管理员可编辑门店名称/地址/电话/负责人
- 入库单列表新增结清按钮(含确认弹窗),出库单同步
- 入库表单:规格、系列、生产日期、供应商、商品名称改为提交必填
- 入库/出库列表新增入库时间、出库时间、创建时间列
- 商品标签标题改为读取 shop 表门店名,扫码文案改为「扫码溯源 · TRACE」
- 标签页脚显示门店地址和电话(从 API 读取,不再依赖编译时 dart-define)
- 库存备注支持点击编辑,超4字截断显示+Hover展示全文
- ApiClient 新增 patch() 方法(已改用 PUT 规避 CORS)

文档
- 新增 docs/user-manual.md 完整用户操作手册(12章)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 14:05:41 +08:00

135 lines
4.6 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../auth/auth_state.dart';
import '../config/app_config.dart';
import '../../providers/connectivity_provider.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 apiClientProvider = Provider<ApiClient>((ref) {
// 只监听登录/登出,不监听 token 内容变化。
// token refresh 只改 accessTokenisLoggedIn 不变,provider 不重建,
// 避免旧 Dio 被 close 后还在执行 fetch 导致 "adapter was closed"。
ref.watch(authStateProvider.select((s) => s.isLoggedIn));
final user = ref.read(authStateProvider).user;
final client = ApiClient(
token: user?.accessToken,
refreshToken: user?.refreshToken,
onTokenRefreshed: (newToken) {
ref.read(authStateProvider.notifier).updateAccessToken(newToken);
},
onAuthFailed: () {
if (ref.read(authStateProvider).isLoggedIn) {
ref.read(authStateProvider.notifier).logout();
}
},
onConnectionError: () {
ref.read(connectivityProvider.notifier).forceCheck();
},
);
ref.onDispose(client.dispose);
return client;
});
class ApiClient {
late final Dio _dio;
bool _disposed = false;
ApiClient({
String? token,
String? refreshToken,
void Function(String newToken)? onTokenRefreshed,
void Function()? onAuthFailed,
void Function()? onConnectionError,
}) {
_dio = Dio(BaseOptions(
baseUrl: AppConfig.apiBaseUrl,
connectTimeout: const Duration(seconds: 5),
receiveTimeout: const Duration(seconds: 15),
headers: {
if (token != null) 'Authorization': 'Bearer $token',
},
));
// 网络错误 + 401 拦截器
_dio.interceptors.add(
InterceptorsWrapper(
onError: (DioException e, ErrorInterceptorHandler handler) async {
if (_disposed) return handler.next(e);
// 网络层错误(连接拒绝、超时等)→ 立即触发连通性检测
final isNetworkError = e.type == DioExceptionType.connectionError ||
e.type == DioExceptionType.connectionTimeout ||
e.type == DioExceptionType.receiveTimeout ||
e.type == DioExceptionType.sendTimeout;
if (isNetworkError) {
onConnectionError?.call();
return handler.next(e);
}
if (e.response?.statusCode == 401 && (refreshToken ?? '').isNotEmpty) {
debugPrint('[ApiClient] got 401 on ${e.requestOptions.path}, trying refresh...');
try {
final resp = await _publicDio.post('/auth/refresh', data: {
'refresh_token': refreshToken,
});
final newToken = resp.data['data']['access_token'] as String;
_dio.options.headers['Authorization'] = 'Bearer $newToken';
if (!_disposed) onTokenRefreshed?.call(newToken);
final opts = e.requestOptions;
opts.headers['Authorization'] = 'Bearer $newToken';
final retryResp = await _dio.fetch(opts);
return handler.resolve(retryResp);
} catch (refreshErr) {
debugPrint('[ApiClient] refresh failed: $refreshErr');
if (!_disposed) onAuthFailed?.call();
}
} else if (e.response?.statusCode == 401) {
debugPrint('[ApiClient] got 401 on ${e.requestOptions.path}, no refresh token');
}
return handler.next(e);
},
),
);
}
/// 取消所有进行中的请求,标记实例为已废弃
void dispose() {
_disposed = true;
_dio.close(force: true);
}
Future<Response> get(String path, {Map<String, dynamic>? params}) =>
_dio.get(path, queryParameters: params);
Future<List<int>> getBytes(String path) async {
final resp = await _dio.get<List<int>>(path,
options: Options(responseType: ResponseType.bytes));
return resp.data ?? [];
}
Future<Response> post(String path, {dynamic data}) =>
_dio.post(path, data: data);
Future<Response> put(String path, {dynamic data}) =>
_dio.put(path, data: data);
Future<Response> patch(String path, {dynamic data}) =>
_dio.patch(path, data: data);
Future<Response> delete(String path) => _dio.delete(path);
}
/// Unauthenticated client for login/refresh
class PublicApiClient {
static Future<Response> post(String path, {dynamic data}) =>
_publicDio.post(path, data: data);
}