beta v0.2 dualcheck

This commit is contained in:
cancerai committed 2026-08-30 21:25:22 +08:00
1 parent c137299016
commit 1ec68bacf5
14 files changed
+1591 -359

No files matched your search

+142 -80
View File
@@ -1,50 +1,40 @@
// DualCheck API 封装
// 后端统一响应格式:{code:0, message, data}
// WebSocket 消息格式:{type, data, error}
import 'dart:convert';
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
/// 后端地址
const String _baseUrl = kIsWeb
? 'http://localhost:8080'
: 'http://127.0.0.1:8080';
/// WebSocket 地址
// ── URL ──
const String _baseUrl =
kIsWeb ? 'http://localhost:8080' : 'http://127.0.0.1:8080';
const String _wsBaseUrl =
kIsWeb ? 'ws://localhost:8080' : 'ws://127.0.0.1:8080';
class DualCheckApi {
DualCheckApi()
: _dio = Dio(BaseOptions(
baseUrl: _baseUrl,
connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(seconds: 10),
)) {
_dio.interceptors.add(InterceptorsWrapper(
onResponse: (response, handler) {
final data = response.data;
if (data is Map<String, dynamic> && data['code'] != 0) {
handler.reject(DioException(
requestOptions: response.requestOptions,
response: response,
type: DioExceptionType.badResponse,
error: _BizError(data['code'] as int, data['message'] as String),
));
return;
}
handler.next(response);
},
));
}
DualCheckApi._();
static final DualCheckApi instance = DualCheckApi._();
final Dio _dio;
final Dio _dio = Dio(BaseOptions(
baseUrl: _baseUrl,
contentType: 'application/json',
connectTimeout: const Duration(seconds: 5),
receiveTimeout: const Duration(seconds: 10),
sendTimeout: const Duration(seconds: 10),
))
..interceptors.add(_BizErrorInterceptor());
// ── Create ──
Future<CreateResp> createSession({required String nickname}) async {
Future<CreateResp> createSession({
required String nickname,
required String browserId,
}) async {
final res = await _dio.post<Map<String, dynamic>>(
'/api/v1/dual-check/create',
data: {'nickname': nickname},
data: {
'nickname': nickname,
'browser_id': browserId,
},
);
final d = res.data!['data'] as Map<String, dynamic>;
return CreateResp(
@@ -82,8 +72,9 @@ class DualCheckApi {
// ── CheckSession ──
Future<CheckResp> checkSession(String sessionId) async {
final res = await _dio.get<Map<String, dynamic>>(
'/api/v1/dual-check/check/$sessionId',
final res = await _dio.post<Map<String, dynamic>>(
'/api/v1/dual-check/check',
data: {'session_id': sessionId},
);
final d = res.data!['data'] as Map<String, dynamic>;
return CheckResp(
@@ -97,18 +88,42 @@ class DualCheckApi {
// ── GetSession 全量快照 ──
Future<SessionResp> getSession(String sessionId) async {
final res = await _dio.get<Map<String, dynamic>>(
'/api/v1/dual-check/session/$sessionId',
final res = await _dio.post<Map<String, dynamic>>(
'/api/v1/dual-check/session',
data: {'session_id': sessionId},
);
return parseSession(res.data!['data'] as Map<String, dynamic>);
}
// ── Join 参与者加入登记 ──
Future<Map<String, dynamic>> joinParticipant({
required String sessionId,
required String nickname,
}) async {
final res = await _dio.post<Map<String, dynamic>>(
'/api/v1/dual-check/join',
data: {'session_id': sessionId, 'nickname': nickname},
);
return res.data!['data'] as Map<String, dynamic>;
}
// ── SetResubmit 发起方切换"允许参与者重新提交" ──
Future<SessionResp> setResubmit(String sessionId, {required bool allow}) async {
final res = await _dio.post<Map<String, dynamic>>(
'/api/v1/dual-check/session/$sessionId/resubmit',
data: {'allow': allow},
'/api/v1/dual-check/resubmit',
data: {'session_id': sessionId, 'allow': allow},
);
return parseSession(res.data!['data'] as Map<String, dynamic>);
}
// ── SetIgnoreWs 发起方切换"忽略首尾空白" ──
Future<SessionResp> setIgnoreWs(String sessionId, {required bool ignore}) async {
final res = await _dio.post<Map<String, dynamic>>(
'/api/v1/dual-check/ignore-ws',
data: {'session_id': sessionId, 'ignore': ignore},
);
return parseSession(res.data!['data'] as Map<String, dynamic>);
}
@@ -116,7 +131,7 @@ class DualCheckApi {
// ── EndSession 发起方主动结束会话 ──
Future<void> endSession(String sessionId) async {
await _dio.post('/api/v1/dual-check/session/$sessionId/end');
await _dio.post('/api/v1/dual-check/end', data: {'session_id': sessionId});
}
static SessionResp parseSession(Map<String, dynamic> d) {
@@ -128,6 +143,7 @@ class DualCheckApi {
initiatorNickname: (d['initiator_nickname'] ?? '') as String,
initiatorReady: d['initiator_ready'] as bool,
allowResubmit: (d['allow_resubmit'] as bool?) ?? false,
ignoreWs: (d['ignore_ws'] as bool?) ?? false,
participants: parts,
ttlSeconds: d['ttl_seconds'] as int,
);
@@ -139,14 +155,57 @@ class DualCheckApi {
required String sessionId,
required String role,
required String nickname,
required String browserId,
}) {
return '$_wsBaseUrl/api/v1/dual-check/ws'
'?session_id=${Uri.encodeQueryComponent(sessionId)}'
'&role=$role'
'&nickname=${Uri.encodeQueryComponent(nickname)}';
'&nickname=${Uri.encodeQueryComponent(nickname)}'
'&browser_id=${Uri.encodeQueryComponent(browserId)}';
}
}
// ── Dio 拦截器:把后端业务错误转成 DioException ──
class _BizErrorInterceptor extends Interceptor {
@override
void onResponse(Response response, ResponseInterceptorHandler handler) {
final data = response.data;
if (data is Map<String, dynamic>) {
final code = data['code'];
if (code != null && code != 0) {
final msg = data['message']?.toString() ?? '请求失败';
handler.reject(DioException(
requestOptions: response.requestOptions,
response: response,
error: _BizError(code as int, msg),
type: DioExceptionType.badResponse,
));
return;
}
}
handler.next(response);
}
}
class _BizError {
final int code;
final String message;
const _BizError(this.code, this.message);
@override
String toString() => 'BusinessError($code): $message';
}
bool isBizError(DioException e) => e.error is _BizError;
int? bizErrorCode(DioException e) {
final err = e.error;
return err is _BizError ? err.code : null;
}
String bizErrorMessage(DioException e) {
final err = e.error;
return err is _BizError ? err.message : (e.message ?? '请求失败');
}
// ── 数据模型 ──
class CreateResp {
@@ -187,14 +246,12 @@ class ParticipantResp {
required this.submitted,
this.match,
});
factory ParticipantResp.fromMap(Map<String, dynamic> m) {
return ParticipantResp(
nickname: m['nickname'] as String,
joined: m['joined'] as bool? ?? true,
submitted: m['submitted'] as bool,
match: m['match'] as bool?,
);
}
factory ParticipantResp.fromMap(Map<String, dynamic> m) => ParticipantResp(
nickname: m['nickname'] as String,
joined: (m['joined'] as bool?) ?? true,
submitted: (m['submitted'] as bool?) ?? false,
match: m['match'] as bool?,
);
}
class SessionResp {
@@ -202,6 +259,7 @@ class SessionResp {
final String initiatorNickname;
final bool initiatorReady;
final bool allowResubmit;
final bool ignoreWs;
final List<ParticipantResp> participants;
final int ttlSeconds;
SessionResp({
@@ -209,45 +267,49 @@ class SessionResp {
required this.initiatorNickname,
required this.initiatorReady,
required this.allowResubmit,
required this.ignoreWs,
required this.participants,
required this.ttlSeconds,
});
SessionResp copyWith({
String? sessionId,
String? initiatorNickname,
bool? initiatorReady,
bool? allowResubmit,
bool? ignoreWs,
List<ParticipantResp>? participants,
int? ttlSeconds,
}) =>
SessionResp(
sessionId: sessionId ?? this.sessionId,
initiatorNickname: initiatorNickname ?? this.initiatorNickname,
initiatorReady: initiatorReady ?? this.initiatorReady,
allowResubmit: allowResubmit ?? this.allowResubmit,
ignoreWs: ignoreWs ?? this.ignoreWs,
participants: participants ?? this.participants,
ttlSeconds: ttlSeconds ?? this.ttlSeconds,
);
}
// ── WS 消息类型 ──
// ── WS 消息解析 ──
class WSMessage {
final String type;
final Map<String, dynamic> data;
final String? error;
final String error;
WSMessage({required this.type, required this.data, this.error = ''});
WSMessage({required this.type, required this.data, this.error});
factory WSMessage.fromJson(Map<String, dynamic> json) {
return WSMessage(
type: json['type'] as String,
data: (json['data'] as Map<String, dynamic>?) ?? const {},
error: json['error'] as String?,
);
factory WSMessage.fromJson(String raw) {
try {
final m = jsonDecode(raw) as Map<String, dynamic>;
return WSMessage(
type: m['type'] as String? ?? '',
data: (m['data'] as Map<String, dynamic>?) ?? {},
error: (m['error'] as String?) ?? '',
);
} catch (_) {
return WSMessage(type: '', data: {});
}
}
}
// ── 业务错误处理 ──
class _BizError implements Exception {
_BizError(this.code, this.message);
final int code;
final String message;
@override
String toString() => '[biz:$code] $message';
}
bool isBizError(DioException e) => e.error is _BizError;
int? bizErrorCode(DioException e) {
final err = e.error;
return err is _BizError ? err.code : null;
}
String bizErrorMessage(DioException e) {
final err = e.error;
return err is _BizError ? err.message : (e.message ?? '请求失败');
}
File diff suppressed because it is too large. Load diff
@@ -1,8 +1,13 @@
// 全局昵称设置
// 持久化到 SharedPreferences / localStorage,所有工具共享同一个昵称
// Web 平台自动监听 localStorage 变化 → 跨标签页实时同步
import 'dart:async';
import 'dart:html' as html show window, StorageEvent;
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:shared_preferences/shared_preferences.dart';
const _keyNickname = 'toolbox.nickname';
const _keyBrowserId = 'toolbox.browser_id';
class NicknameService {
NicknameService._();
@@ -10,8 +15,60 @@ class NicknameService {
SharedPreferences? _prefs;
/// 浏览器唯一 ID:同浏览器跨 tab 共享(localStorage 作用域),跨浏览器不同
/// 用于后端识别"同一个人",避免昵称冲突时自动改名
String? _browserId;
/// 昵称变化广播流:本地 setNickname 触发 + Web 上其他标签页修改也触发
final _streamController = StreamController<String>.broadcast();
Stream<String> get onChanged => _streamController.stream;
StreamSubscription? _webStorageSub;
Future<void> _ensureInit() async {
_prefs ??= await SharedPreferences.getInstance();
// 惰性生成 browserId:首次访问时若没有就生成 uuid 存起来
if (_browserId == null) {
final existing = _prefs!.getString(_keyBrowserId);
if (existing != null && existing.isNotEmpty) {
_browserId = existing;
} else {
_browserId = _genBrowserId();
await _prefs!.setString(_keyBrowserId, _browserId!);
}
}
}
/// 获取浏览器 ID(稳定值,同浏览器跨 tab 相同)
Future<String> get browserId async {
await _ensureInit();
return _browserId!;
}
/// 生成短 browserId:base62(0-9A-Za-z),去掉特殊字符方便 URL 使用
/// 12 字符足够唯一(62^12 ≈ 3e21 种可能)
String _genBrowserId() {
const chars = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';
final rand = DateTime.now().microsecond.hashCode;
final buf = StringBuffer();
int seed = rand ^ DateTime.now().millisecondsSinceEpoch;
for (int i = 0; i < 12; i++) {
seed = (seed * 1103515245 + 12345) & 0x7FFFFFFF;
buf.write(chars[seed % chars.length]);
}
return buf.toString();
}
/// 开启跨标签页同步(仅 Web 生效;非 Web 空操作)
/// 在 widget 的 initState 里调用一次即可
void enableCrossTabSync() {
if (!kIsWeb || _webStorageSub != null) return;
_webStorageSub = html.window.onStorage.listen((html.StorageEvent event) async {
if (event.key == _keyNickname) {
final n = await nickname;
_streamController.add(n);
}
});
}
/// 获取当前昵称,未设置时返回默认昵称
@@ -28,6 +85,12 @@ class NicknameService {
if (trimmed.isEmpty || trimmed.length > 32) return false;
await _ensureInit();
await _prefs!.setString(_keyNickname, trimmed);
_streamController.add(trimmed);
return true;
}
void dispose() {
_webStorageSub?.cancel();
_webStorageSub = null;
}
}