Compare commits
10 Commits
3728eb93d9
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 53c25697a6 | |||
| b0a3515bf2 | |||
| 8c47a88c79 | |||
| 38b913b429 | |||
| 048732dfef | |||
| f88c1c170a | |||
| d0755aa466 | |||
| 07f81ff59b | |||
| 29204bc08a | |||
| a90cb9953e |
@@ -175,7 +175,7 @@
|
||||
async function searchUsers(prefix: string) {
|
||||
query.value = prefix
|
||||
options.value = []
|
||||
if (prefix.trim().length < 2) return
|
||||
if (prefix.trim().length < 1) return
|
||||
searching.value = true
|
||||
try {
|
||||
const result = await fetchLookupUsers(prefix.trim())
|
||||
|
||||
@@ -112,7 +112,7 @@
|
||||
async function searchUsers(prefix: string) {
|
||||
query.value = prefix
|
||||
options.value = []
|
||||
if (prefix.trim().length < 2) return
|
||||
if (prefix.trim().length < 1) return
|
||||
searching.value = true
|
||||
try {
|
||||
const result = await fetchLookupUsers(prefix.trim())
|
||||
|
||||
@@ -23,12 +23,14 @@
|
||||
/// 落库后由引擎在 initUpload 携带 force 跳过服务端查重(仅 owner/admin
|
||||
/// 生效,服务端裁决——协作者强传仍会被 409 拒绝并可见失败文案)。
|
||||
///
|
||||
/// 方案 A(选图点选拦截):[multiImagePickerProvider] 经
|
||||
/// 方案 A v2(选图点选拦截·硬封锁):[multiImagePickerProvider] 经
|
||||
/// AssetPickerConfig.selectPredicate 挂 [PickerDuplicateGate]——点选
|
||||
/// 「已上传到目标相册」的照片当场拦下(SnackBar 提示),再次点击同一张
|
||||
/// 强制选择;判定口径同 [checkDuplicatesUnified](本机 ∪ 服务器,当前
|
||||
/// 账号+相册,bytes 为主),查询失败一律放行(enqueue 弹窗 + init 409
|
||||
/// 兜底不变)。
|
||||
/// 「已上传到目标相册」的照片当场拦下且**再次点击也不放行**(SnackBar
|
||||
/// 提示)。判定**仅查本机 TaskStore 历史**(毫秒级——v1 的服务器单项
|
||||
/// 预检在弱网下把每次点选的勾选卡到最多 2s,是「选图不灵敏」的根因);
|
||||
/// 服务器查重退至入队时刻统一弹窗(checkDuplicatesUnified)+ init 409
|
||||
/// 兜底,跨设备/重装场景的重复仍在点上传时拦。已接受限制(用户拍板):
|
||||
/// 后台已删除的照片重传同一文件会被本机历史永远拦下(清数据/换设备)。
|
||||
library;
|
||||
|
||||
import 'dart:io';
|
||||
@@ -42,8 +44,7 @@ import 'package:path/path.dart' as p;
|
||||
import 'package:wechat_assets_picker/wechat_assets_picker.dart';
|
||||
|
||||
import '../../core/api_client.dart'
|
||||
show ApiClient, UploadDuplicateCheckItem, UploadDuplicateHit,
|
||||
apiClientProvider;
|
||||
show UploadDuplicateHit, apiClientProvider;
|
||||
import '../../core/notification_permission.dart';
|
||||
import '../../l10n/app_localizations.dart';
|
||||
import 'providers.dart';
|
||||
@@ -85,14 +86,13 @@ final singleImagePickerProvider = Provider<SingleImagePicker>(
|
||||
/// 张数;
|
||||
/// - 权限由 picker 内部请求;永久拒绝时 picker 自带 denied/limited 视图
|
||||
/// (非「返回空」);返回 null 仅发生在用户取消;
|
||||
/// - 方案 A(点选拦截):selectPredicate 挂 [PickerDuplicateGate]——已
|
||||
/// 上传到目标相册的照片点选当场拦下(再点一次强制选择)。
|
||||
/// - 方案 A v2(点选拦截·硬封锁):selectPredicate 挂 [PickerDuplicateGate]
|
||||
/// ——已上传到目标相册的照片点选当场拦下(再次点击不放行;仅本机判定)。
|
||||
final multiImagePickerProvider = Provider<MultiImagePicker>(
|
||||
(ref) => (context, {required accountId, required albumId}) async {
|
||||
// 拦截器按次构造:每次选图会话独立的判定缓存/放行集(不跨会话残留)。
|
||||
// 拦截器按次构造:每次选图会话独立的判定缓存(不跨会话残留)。
|
||||
final gate = PickerDuplicateGate(
|
||||
store: ref.read(taskStoreProvider),
|
||||
client: ref.read(apiClientProvider),
|
||||
accountId: accountId,
|
||||
albumId: albumId,
|
||||
);
|
||||
@@ -143,57 +143,49 @@ typedef PickerAssetFact = ({
|
||||
Future<int> Function() fileSize,
|
||||
});
|
||||
|
||||
/// 选图点选拦截器(方案 A):挂 wechat_assets_picker 的
|
||||
/// 选图点选拦截器(方案 A v2·硬封锁):挂 wechat_assets_picker 的
|
||||
/// `AssetPickerConfig.selectPredicate` 钩子(10.1.3 源码
|
||||
/// asset_picker_builder_delegate.dart:982——每次点选/取消时 await,返回
|
||||
/// false 静默不选)。
|
||||
/// false 静默不选;谓词返回前勾选不出现,故判定必须快)。
|
||||
///
|
||||
/// 判定口径与 [checkDuplicatesUnified] 一致(本机 TaskStore 历史 ∪ 服务器
|
||||
/// 单项预检;同账号+相册,filename+bytes / bytes);**任一半场查询异常按
|
||||
/// 该半场无命中处理,fileSize 失败等整体异常直接放行**——预检是体验优化
|
||||
/// 而非闸门,绝不因自身故障挡住选图(enqueue 弹窗 + init 409 兜底不变)。
|
||||
/// 服务器半场有 [serverCheckTimeout](默认 2s)时间帽——弱网下不让点选
|
||||
/// 静默挂到 dio 连接超时(10s)。本机命中短路服务器预检(点选瞬间延迟
|
||||
/// 考量;与批量 union 不短路不同)。
|
||||
/// v2 要点(2026-09-29 用户反馈驱动):
|
||||
/// - **仅本机判定**(同账号+相册+completed,filename+bytes)——毫秒级,
|
||||
/// 不走网络。v1 的服务器单项预检(2s 时间帽)在弱网下让每次点选卡
|
||||
/// 0.5~2s,配合「二次点击强制」活口,重复照片被连点强制选中,拦截
|
||||
/// 形同虚设。服务器查重移回入队时刻统一弹窗 + init 409 兜底。
|
||||
/// - **硬封锁**:本机命中的重复每次点击都拒选(无强制活口);提示文案
|
||||
/// 不再引导再点。
|
||||
/// - 判定缓存共享 in-flight Future(putIfAbsent)——同一照片连点只判定
|
||||
/// 一次;[_judge] 全捕获不抛错,缓存 Future 无失败态。
|
||||
/// - 查询异常(fileSize 失败/本机查询失败)一律放行——预检是体验优化
|
||||
/// 而非闸门;title 为空(iOS 常见)本机无从匹配 → 放行(跨设备判定
|
||||
/// 在入队弹窗)。
|
||||
///
|
||||
/// 重复 → 首次点击 false + SnackBar 提示(l10n `pickerDuplicateBlocked`,
|
||||
/// 经谓词 ctx 向上解析到根 ScaffoldMessenger——picker 页自带的 Scaffold
|
||||
/// 之上无 messenger;呈现前 clearSnackBars——快速拦多张不排队 N 条),
|
||||
/// 该 asset.id 记入放行集 → 二次点击 true(强制选择活口)。取消选择
|
||||
/// (selected=true)恒放行且不预热放行集。per-asset 判定缓存避免同一张
|
||||
/// 重复查询。
|
||||
/// 拦下时 SnackBar 提示(l10n `pickerDuplicateBlocked`,经谓词 ctx 向上
|
||||
/// 解析到根 ScaffoldMessenger——picker 页自带的 Scaffold 之上无
|
||||
/// messenger;呈现前 clearSnackBars——快速拦多张不排队 N 条)。取消选择
|
||||
/// (selected=true)恒放行。
|
||||
///
|
||||
/// 公开(计划原定私有类)仅为让 enqueue_test 直接构造注入替身。
|
||||
class PickerDuplicateGate {
|
||||
PickerDuplicateGate({
|
||||
required this.store,
|
||||
required this.client,
|
||||
required this.accountId,
|
||||
required this.albumId,
|
||||
this.serverCheckTimeout = const Duration(seconds: 2),
|
||||
});
|
||||
|
||||
/// 本机历史半场数据源(生产=taskStoreProvider)。
|
||||
final TaskStore store;
|
||||
|
||||
/// 服务器单项预检半场客户端(生产=apiClientProvider)。
|
||||
final ApiClient client;
|
||||
|
||||
/// 判定范围:账号(TaskStore 账号隔离硬守护的入参)。
|
||||
final String accountId;
|
||||
|
||||
/// 判定范围:目标相册(与入队目标一致)。
|
||||
final String albumId;
|
||||
|
||||
/// 服务器半场预检时间帽(默认 2s;可注入短值做测试)。超时=无命中,
|
||||
/// 同各半场失败降级放行;底层 dio 请求不取消(其自身超时照常兜底)。
|
||||
final Duration serverCheckTimeout;
|
||||
|
||||
/// per-asset 判定缓存(assetId → 是否重复):二次点击瞬回。
|
||||
final Map<String, bool> _verdicts = <String, bool>{};
|
||||
|
||||
/// 二次点击放行集(首次拦截时记入——escape hatch)。
|
||||
final Set<String> _forced = <String>{};
|
||||
/// per-asset 判定缓存(assetId → 判定 Future):in-flight 共享——
|
||||
/// 同一照片连点(判定未完成时再点)复用同一次判定;完成后秒回。
|
||||
final Map<String, Future<bool>> _verdicts = <String, Future<bool>>{};
|
||||
|
||||
/// 核心判定(测试直接驱动——绕开 AssetEntity 的平台通道 fileSize)。
|
||||
///
|
||||
@@ -204,57 +196,34 @@ class PickerDuplicateGate {
|
||||
bool selected,
|
||||
) async {
|
||||
if (selected) return true;
|
||||
if (_forced.contains(asset.id)) return true; // 二次点击:强制选择。
|
||||
final isDuplicate = _verdicts[asset.id] ?? await _judge(asset);
|
||||
_verdicts[asset.id] = isDuplicate;
|
||||
final isDuplicate = await _verdicts.putIfAbsent(
|
||||
asset.id,
|
||||
() => _judge(asset),
|
||||
);
|
||||
if (!isDuplicate) return true;
|
||||
_forced.add(asset.id);
|
||||
// 判定是异步的:picker 页可能在等待期间已被关闭(mounted=false,
|
||||
// 也就不会再有第二次点击)——跳过提示,不跨 async 间隙用 ctx。
|
||||
// 判定是异步的:picker 页可能在等待期间已被关闭(mounted=false)
|
||||
// ——跳过提示,不跨 async 间隙用 ctx。
|
||||
if (ctx.mounted) _notifyBlocked(ctx);
|
||||
return false;
|
||||
}
|
||||
|
||||
/// 判定一张是否重复(true=重复):本机 ∪ 服务器,异常降级放行。
|
||||
/// 判定一张是否重复(true=重复):仅本机历史,异常降级放行。
|
||||
Future<bool> _judge(PickerAssetFact asset) async {
|
||||
try {
|
||||
// 文件大小:两半场共用,取一次。
|
||||
final bytes = await asset.fileSize();
|
||||
|
||||
// 层 1:本机历史(同账号+相册+completed,filename+bytes)。
|
||||
final title = asset.title;
|
||||
if (title != null) {
|
||||
try {
|
||||
final completed = await store.listByAccount(
|
||||
accountId,
|
||||
albumId: albumId,
|
||||
states: const <UploadTaskState>{UploadTaskState.completed},
|
||||
);
|
||||
final hit = completed.any(
|
||||
(t) => t.filename == title && t.bytes == bytes,
|
||||
);
|
||||
if (hit) return true; // 本机命中短路服务器(点选延迟考量)。
|
||||
} on Object {
|
||||
// 本机查询失败:该半场按无命中,服务器半场仍判。
|
||||
}
|
||||
}
|
||||
|
||||
// 层 2:服务器单项预检(filename 仅元数据——服务端谓词只看 bytes;
|
||||
// title null 时传空串占位,判定不受影响)。时间帽内未回 = 无命中
|
||||
// ——弱网下点选不静默挂到 dio 连接超时(10s)。
|
||||
if (title == null) return false; // iOS 常见:本机无从匹配 → 放行。
|
||||
try {
|
||||
final hits = await client
|
||||
.checkUploadDuplicates(
|
||||
albumId,
|
||||
<UploadDuplicateCheckItem>[(filename: title ?? '', bytes: bytes)],
|
||||
)
|
||||
.timeout(
|
||||
serverCheckTimeout,
|
||||
onTimeout: () => const <UploadDuplicateHit>[],
|
||||
);
|
||||
return hits.isNotEmpty;
|
||||
final completed = await store.listByAccount(
|
||||
accountId,
|
||||
albumId: albumId,
|
||||
states: const <UploadTaskState>{UploadTaskState.completed},
|
||||
);
|
||||
return completed.any(
|
||||
(t) => t.filename == title && t.bytes == bytes,
|
||||
);
|
||||
} on Object {
|
||||
// 服务器预检失败/超时:按无命中(放行——预检故障不挡选图)。
|
||||
// 本机查询失败:放行(入队弹窗 + 409 兜底)。
|
||||
return false;
|
||||
}
|
||||
} on Object {
|
||||
|
||||
@@ -351,7 +351,7 @@
|
||||
}
|
||||
},
|
||||
"uploadPickNone": "No photos selected",
|
||||
"pickerDuplicateBlocked": "Already uploaded to this album — tap again to select anyway",
|
||||
"pickerDuplicateBlocked": "This photo has already been uploaded to this album",
|
||||
"albumManagePhotos": "Manage photos",
|
||||
"albumTakeFirstPhoto": "Take the first photo",
|
||||
"albumUploadDisabledHint": "Uploads are off for this album. New photos cannot be added right now",
|
||||
|
||||
@@ -1703,7 +1703,7 @@ abstract class AppLocalizations {
|
||||
/// No description provided for @pickerDuplicateBlocked.
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'这张已上传到该相册——再点一次可强制选择'**
|
||||
/// **'这张已上传到该相册,无法重复选择'**
|
||||
String get pickerDuplicateBlocked;
|
||||
|
||||
/// No description provided for @albumManagePhotos.
|
||||
|
||||
@@ -865,7 +865,7 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get pickerDuplicateBlocked =>
|
||||
'Already uploaded to this album — tap again to select anyway';
|
||||
'This photo has already been uploaded to this album';
|
||||
|
||||
@override
|
||||
String get albumManagePhotos => 'Manage photos';
|
||||
|
||||
@@ -835,7 +835,7 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
String get uploadPickNone => '未选择照片';
|
||||
|
||||
@override
|
||||
String get pickerDuplicateBlocked => '这张已上传到该相册——再点一次可强制选择';
|
||||
String get pickerDuplicateBlocked => '这张已上传到该相册,无法重复选择';
|
||||
|
||||
@override
|
||||
String get albumManagePhotos => '管理照片';
|
||||
|
||||
@@ -351,7 +351,7 @@
|
||||
}
|
||||
},
|
||||
"uploadPickNone": "未选择照片",
|
||||
"pickerDuplicateBlocked": "这张已上传到该相册——再点一次可强制选择",
|
||||
"pickerDuplicateBlocked": "这张已上传到该相册,无法重复选择",
|
||||
"albumManagePhotos": "管理照片",
|
||||
"albumTakeFirstPhoto": "拍摄第一张照片",
|
||||
"albumUploadDisabledHint": "相册已关闭上传,暂不能新增照片",
|
||||
|
||||
@@ -410,7 +410,9 @@ class _AlbumInviteSheetState extends ConsumerState<AlbumInviteSheet> {
|
||||
void _onSearchChanged(String text) {
|
||||
_debounce?.cancel();
|
||||
final query = text.trim();
|
||||
if (query.length < 2) {
|
||||
// 单字符即搜(2026-09-29 与服务端 1..64 bounds 对齐——中文姓氏单字);
|
||||
// 空串清空结果。
|
||||
if (query.isEmpty) {
|
||||
setState(() {
|
||||
_searching = false;
|
||||
_results = null;
|
||||
|
||||
@@ -12,7 +12,6 @@
|
||||
/// [PickerAssetFact] 记录绕开 AssetEntity.fileSize 的平台通道。
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
@@ -163,7 +162,7 @@ void main() {
|
||||
);
|
||||
});
|
||||
|
||||
group('选图点选拦截器 PickerDuplicateGate(方案 A)', () {
|
||||
group('选图点选拦截器 PickerDuplicateGate(v2 硬封锁:仅本机判定)', () {
|
||||
/// 宿主树:MaterialApp(含根 ScaffoldMessenger + l10n)——SnackBar 提示
|
||||
/// 与文案断言的载体;测试环境默认 en locale(同页级测试)。
|
||||
Future<void> pumpGateHost(
|
||||
@@ -198,18 +197,20 @@ void main() {
|
||||
fileSize: () async => bytes,
|
||||
);
|
||||
|
||||
/// 种子:同账号+相册的已完成任务(本机半场命中源;runAsync 区内调用)。
|
||||
/// 种子:同账号+相册的已完成任务(本机命中源;runAsync 区内调用)。
|
||||
Future<void> seedCompletedTask(
|
||||
TaskStore store,
|
||||
String filename,
|
||||
int bytes,
|
||||
) async {
|
||||
int bytes, {
|
||||
String accountId = 'alice',
|
||||
String albumId = 'alb-1',
|
||||
}) async {
|
||||
final seed = await store.create(
|
||||
UploadTaskDraft(
|
||||
id: 'task-seed',
|
||||
accountId: 'alice',
|
||||
albumId: 'alb-1',
|
||||
clientTaskId: 'ct-seed',
|
||||
id: 'task-seed-$accountId-$albumId',
|
||||
accountId: accountId,
|
||||
albumId: albumId,
|
||||
clientTaskId: 'ct-seed-$accountId-$albumId',
|
||||
sourceFilePath: '/tmp/seed.jpg',
|
||||
filename: filename,
|
||||
bytes: bytes,
|
||||
@@ -234,56 +235,41 @@ void main() {
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
testWidgets('① 本机命中 → 首次拒选+提示;二次点击强制放行;本机命中短路服务器预检',
|
||||
testWidgets('① 本机命中 → 每次点击均拒选(无强制活口)+ 提示不堆积',
|
||||
(tester) async {
|
||||
final session = FakeSessionStore()
|
||||
..session = Session(
|
||||
token: 'tok-1',
|
||||
expiresAt: DateTime.utc(2099, 1, 1),
|
||||
account: 'alice',
|
||||
);
|
||||
final store = TaskStore.memory();
|
||||
addTearDown(() => store.close());
|
||||
// 服务器半场打桩为空命中(若被调用则请求被记录——本机命中应短路)。
|
||||
final adapter = FakeAdapter(
|
||||
(o) => (200, {'duplicates': <Map<String, dynamic>>[]}),
|
||||
);
|
||||
final client = ApiClient(
|
||||
sessionStore: session,
|
||||
dio: Dio(BaseOptions(baseUrl: 'http://test.local'))
|
||||
..httpClientAdapter = adapter,
|
||||
);
|
||||
|
||||
late BuildContext ctx;
|
||||
await pumpGateHost(tester, (c) => ctx = c);
|
||||
final gate = PickerDuplicateGate(
|
||||
store: store,
|
||||
client: client,
|
||||
accountId: 'alice',
|
||||
albumId: 'alb-1',
|
||||
);
|
||||
final asset = gateAsset(bytes: kJpgBytes.length);
|
||||
|
||||
late bool first;
|
||||
late bool second;
|
||||
late List<bool> verdicts;
|
||||
await tester.runAsync(() async {
|
||||
await seedCompletedTask(store, 'pick1.jpg', kJpgBytes.length);
|
||||
first = await gate.decideFact(ctx, asset, false);
|
||||
second = await gate.decideFact(ctx, asset, false);
|
||||
verdicts = [
|
||||
await gate.decideFact(ctx, asset, false),
|
||||
await gate.decideFact(ctx, asset, false),
|
||||
await gate.decideFact(ctx, asset, false),
|
||||
];
|
||||
});
|
||||
|
||||
expect(first, isFalse, reason: '本机历史命中 → 首次点选拦下');
|
||||
expect(second, isTrue, reason: '二次点击同一张 → 强制选择(escape hatch)');
|
||||
expect(adapter.requests, isEmpty,
|
||||
reason: '本机命中短路服务器单项预检(点选延迟考量)');
|
||||
expect(
|
||||
verdicts,
|
||||
everyElement(isFalse),
|
||||
reason: 'v2 硬封锁:连点三次均拒选(二次点击强制已移除)',
|
||||
);
|
||||
|
||||
// 拦截提示:谓词 ctx 经根 messenger 呈现 SnackBar(en 文案)。
|
||||
// 拦截提示:谓词 ctx 经根 messenger 呈现 SnackBar(en 新文案)。
|
||||
await tester.pump();
|
||||
expect(find.byType(SnackBar), findsOneWidget);
|
||||
expect(
|
||||
find.text(
|
||||
'Already uploaded to this album — tap again to select anyway',
|
||||
),
|
||||
find.text('This photo has already been uploaded to this album'),
|
||||
findsOneWidget,
|
||||
);
|
||||
|
||||
@@ -299,286 +285,157 @@ void main() {
|
||||
expect(otherBlocked, isFalse, reason: '另一重复资产同样拦下');
|
||||
await tester.pumpAndSettle();
|
||||
expect(
|
||||
find.text(
|
||||
'Already uploaded to this album — tap again to select anyway',
|
||||
),
|
||||
find.text('This photo has already been uploaded to this album'),
|
||||
findsOneWidget,
|
||||
reason: '呈现前清旧条——当前仅一条提示',
|
||||
);
|
||||
ScaffoldMessenger.of(ctx).hideCurrentSnackBar();
|
||||
await tester.pumpAndSettle();
|
||||
expect(
|
||||
find.text(
|
||||
'Already uploaded to this album — tap again to select anyway',
|
||||
),
|
||||
find.text('This photo has already been uploaded to this album'),
|
||||
findsNothing,
|
||||
reason: '隐藏后无第二条在队列等待(不堆积)',
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('② 服务器单项命中 → 拒选(单项请求携带 filename+bytes)',
|
||||
(tester) async {
|
||||
final session = FakeSessionStore()
|
||||
..session = Session(
|
||||
token: 'tok-1',
|
||||
expiresAt: DateTime.utc(2099, 1, 1),
|
||||
account: 'alice',
|
||||
);
|
||||
testWidgets('② 非重复(bytes/文件名/相册/账号 任一不同)→ 放行', (
|
||||
tester,
|
||||
) async {
|
||||
final store = TaskStore.memory();
|
||||
addTearDown(() => store.close());
|
||||
// 本机无历史;服务器预检回 index=0 命中(本批仅此一项)。
|
||||
final adapter = FakeAdapter(
|
||||
(o) => (
|
||||
200,
|
||||
{
|
||||
'duplicates': [
|
||||
{
|
||||
'index': 0,
|
||||
'bytes': kJpgBytes.length,
|
||||
'uploadedAt': '2026-01-01T00:00:00Z',
|
||||
},
|
||||
],
|
||||
},
|
||||
),
|
||||
);
|
||||
final client = ApiClient(
|
||||
sessionStore: session,
|
||||
dio: Dio(BaseOptions(baseUrl: 'http://test.local'))
|
||||
..httpClientAdapter = adapter,
|
||||
);
|
||||
|
||||
late BuildContext ctx;
|
||||
await pumpGateHost(tester, (c) => ctx = c);
|
||||
final gate = PickerDuplicateGate(
|
||||
store: store,
|
||||
client: client,
|
||||
accountId: 'alice',
|
||||
albumId: 'alb-1',
|
||||
);
|
||||
|
||||
late bool first;
|
||||
await tester.runAsync(() async {
|
||||
first = await gate.decideFact(
|
||||
await seedCompletedTask(store, 'pick1.jpg', kJpgBytes.length);
|
||||
});
|
||||
|
||||
late bool diffBytes;
|
||||
late bool diffName;
|
||||
late bool diffAlbum;
|
||||
late bool diffAccount;
|
||||
await tester.runAsync(() async {
|
||||
diffBytes = await gate.decideFact(
|
||||
ctx,
|
||||
gateAsset(bytes: kJpgBytes.length),
|
||||
gateAsset(id: 'a-bytes', bytes: 12345),
|
||||
false,
|
||||
);
|
||||
diffName = await gate.decideFact(
|
||||
ctx,
|
||||
gateAsset(id: 'a-name', title: 'other.jpg', bytes: kJpgBytes.length),
|
||||
false,
|
||||
);
|
||||
// 判定范围(相册/账号)不同 → 各自构造 gate 验证隔离。
|
||||
diffAlbum = await PickerDuplicateGate(
|
||||
store: store,
|
||||
accountId: 'alice',
|
||||
albumId: 'alb-2',
|
||||
).decideFact(
|
||||
ctx,
|
||||
gateAsset(id: 'a-album', bytes: kJpgBytes.length),
|
||||
false,
|
||||
);
|
||||
diffAccount = await PickerDuplicateGate(
|
||||
store: store,
|
||||
accountId: 'bob',
|
||||
albumId: 'alb-1',
|
||||
).decideFact(
|
||||
ctx,
|
||||
gateAsset(id: 'a-account', bytes: kJpgBytes.length),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
expect(first, isFalse, reason: '服务器历史命中 → 拒选');
|
||||
expect(adapter.requests, hasLength(1), reason: '单项预检恰好一次');
|
||||
final body = adapter.requests.single.body as Map<String, dynamic>;
|
||||
expect(body['items'], hasLength(1));
|
||||
expect(diffBytes, isTrue, reason: '字节数不同 → 非重复');
|
||||
expect(diffName, isTrue, reason: '文件名不同 → 非重复');
|
||||
expect(diffAlbum, isTrue, reason: '其它相册的历史不算本相册重复');
|
||||
expect(diffAccount, isTrue, reason: '其它账号的历史不算本人重复');
|
||||
});
|
||||
|
||||
testWidgets('③ in-flight 共享:同一资产并发连点只判定一次', (tester) async {
|
||||
final store = TaskStore.memory();
|
||||
addTearDown(() => store.close());
|
||||
|
||||
late BuildContext ctx;
|
||||
await pumpGateHost(tester, (c) => ctx = c);
|
||||
final gate = PickerDuplicateGate(
|
||||
store: store,
|
||||
accountId: 'alice',
|
||||
albumId: 'alb-1',
|
||||
);
|
||||
var sizeCalls = 0;
|
||||
final asset = (
|
||||
id: 'asset-1',
|
||||
title: 'pick1.jpg' as String?,
|
||||
fileSize: () async {
|
||||
sizeCalls++;
|
||||
return kJpgBytes.length;
|
||||
},
|
||||
);
|
||||
|
||||
late List<bool> verdicts;
|
||||
await tester.runAsync(() async {
|
||||
await seedCompletedTask(store, 'pick1.jpg', kJpgBytes.length);
|
||||
verdicts = await Future.wait([
|
||||
gate.decideFact(ctx, asset, false),
|
||||
gate.decideFact(ctx, asset, false),
|
||||
]);
|
||||
});
|
||||
|
||||
expect(verdicts, everyElement(isFalse), reason: '并发两次均按重复拦下');
|
||||
expect(
|
||||
(body['items'] as List<Object?>).single,
|
||||
{'filename': 'pick1.jpg', 'bytes': kJpgBytes.length},
|
||||
reason: '请求条目 = asset.title + 文件字节数',
|
||||
sizeCalls,
|
||||
1,
|
||||
reason: '判定缓存共享 in-flight Future——连点只查一次(清偿登记遗留)',
|
||||
);
|
||||
await drainBlockedSnackBar(tester, ctx);
|
||||
});
|
||||
|
||||
testWidgets('③ 两路皆空 → 放行(判定缓存:二次调用不再发预检)',
|
||||
(tester) async {
|
||||
final session = FakeSessionStore()
|
||||
..session = Session(
|
||||
token: 'tok-1',
|
||||
expiresAt: DateTime.utc(2099, 1, 1),
|
||||
account: 'alice',
|
||||
);
|
||||
testWidgets('④ title 为空(iOS 场景)→ 放行(本机无从匹配)', (
|
||||
tester,
|
||||
) async {
|
||||
final store = TaskStore.memory();
|
||||
addTearDown(() => store.close());
|
||||
final adapter = FakeAdapter(
|
||||
(o) => (200, {'duplicates': <Map<String, dynamic>>[]}),
|
||||
);
|
||||
final client = ApiClient(
|
||||
sessionStore: session,
|
||||
dio: Dio(BaseOptions(baseUrl: 'http://test.local'))
|
||||
..httpClientAdapter = adapter,
|
||||
);
|
||||
|
||||
late BuildContext ctx;
|
||||
await pumpGateHost(tester, (c) => ctx = c);
|
||||
final gate = PickerDuplicateGate(
|
||||
store: store,
|
||||
client: client,
|
||||
accountId: 'alice',
|
||||
albumId: 'alb-1',
|
||||
);
|
||||
|
||||
late bool first;
|
||||
late bool second;
|
||||
await tester.runAsync(() async {
|
||||
first = await gate.decideFact(
|
||||
ctx,
|
||||
gateAsset(bytes: kJpgBytes.length),
|
||||
false,
|
||||
);
|
||||
second = await gate.decideFact(
|
||||
ctx,
|
||||
gateAsset(bytes: kJpgBytes.length),
|
||||
false,
|
||||
);
|
||||
await seedCompletedTask(store, 'pick1.jpg', kJpgBytes.length);
|
||||
});
|
||||
|
||||
expect(first, isTrue, reason: '本机/服务器均无历史 → 放行');
|
||||
expect(second, isTrue, reason: '缓存判定直接放行');
|
||||
expect(adapter.requests, hasLength(1),
|
||||
reason: '同一资产二次点选用缓存,不重复预检');
|
||||
});
|
||||
|
||||
testWidgets('④ 服务器预检异常 → 放行(预检故障不挡选图)', (tester) async {
|
||||
final session = FakeSessionStore()
|
||||
..session = Session(
|
||||
token: 'tok-1',
|
||||
expiresAt: DateTime.utc(2099, 1, 1),
|
||||
account: 'alice',
|
||||
);
|
||||
final store = TaskStore.memory();
|
||||
addTearDown(() => store.close());
|
||||
// 本机无历史 + 服务器半场抛错(网络/权限等任意异常)。
|
||||
final adapter = FakeAdapter(
|
||||
(_) => throw StateError('预检失败'),
|
||||
);
|
||||
final client = ApiClient(
|
||||
sessionStore: session,
|
||||
dio: Dio(BaseOptions(baseUrl: 'http://test.local'))
|
||||
..httpClientAdapter = adapter,
|
||||
);
|
||||
|
||||
late BuildContext ctx;
|
||||
await pumpGateHost(tester, (c) => ctx = c);
|
||||
final gate = PickerDuplicateGate(
|
||||
store: store,
|
||||
client: client,
|
||||
accountId: 'alice',
|
||||
albumId: 'alb-1',
|
||||
);
|
||||
|
||||
late bool verdict;
|
||||
await tester.runAsync(() async {
|
||||
verdict = await gate.decideFact(
|
||||
ctx,
|
||||
gateAsset(bytes: kJpgBytes.length),
|
||||
gateAsset(id: 'a-null', title: null, bytes: kJpgBytes.length),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
expect(verdict, isTrue,
|
||||
reason: '预检异常放行——enqueue 弹窗 + init 409 仍是兜底');
|
||||
expect(adapter.requests, hasLength(1));
|
||||
reason: 'title 空 → 本机半场无从匹配 → 放行(服务器判定在入队弹窗)');
|
||||
});
|
||||
|
||||
testWidgets('⑤ 取消选择恒放行(不预热放行集,不发起查询)', (tester) async {
|
||||
final session = FakeSessionStore()
|
||||
..session = Session(
|
||||
token: 'tok-1',
|
||||
expiresAt: DateTime.utc(2099, 1, 1),
|
||||
account: 'alice',
|
||||
);
|
||||
testWidgets('⑤ fileSize 获取失败 → 放行(判定故障不挡选图)', (
|
||||
tester,
|
||||
) async {
|
||||
final store = TaskStore.memory();
|
||||
addTearDown(() => store.close());
|
||||
final adapter = FakeAdapter(
|
||||
(o) => (200, {'duplicates': <Map<String, dynamic>>[]}),
|
||||
);
|
||||
final client = ApiClient(
|
||||
sessionStore: session,
|
||||
dio: Dio(BaseOptions(baseUrl: 'http://test.local'))
|
||||
..httpClientAdapter = adapter,
|
||||
);
|
||||
|
||||
late BuildContext ctx;
|
||||
await pumpGateHost(tester, (c) => ctx = c);
|
||||
final gate = PickerDuplicateGate(
|
||||
store: store,
|
||||
client: client,
|
||||
accountId: 'alice',
|
||||
albumId: 'alb-1',
|
||||
);
|
||||
final asset = gateAsset(bytes: kJpgBytes.length);
|
||||
|
||||
late bool deselect;
|
||||
late bool reselect;
|
||||
await tester.runAsync(() async {
|
||||
await seedCompletedTask(store, 'pick1.jpg', kJpgBytes.length);
|
||||
deselect = await gate.decideFact(ctx, asset, true);
|
||||
reselect = await gate.decideFact(ctx, asset, false);
|
||||
});
|
||||
|
||||
expect(deselect, isTrue, reason: '取消选择(selected=true)恒放行');
|
||||
expect(reselect, isFalse,
|
||||
reason: '取消选择不预热放行集——再点选仍按重复拦截');
|
||||
expect(adapter.requests, isEmpty,
|
||||
reason: '取消选择路径不发起任何查询(本机命中也短路服务器)');
|
||||
await drainBlockedSnackBar(tester, ctx);
|
||||
});
|
||||
|
||||
testWidgets('⑥ 服务器预检超时(注入 50ms 帽)→ 放行(时间帽=无命中)',
|
||||
(tester) async {
|
||||
final session = FakeSessionStore()
|
||||
..session = Session(
|
||||
token: 'tok-1',
|
||||
expiresAt: DateTime.utc(2099, 1, 1),
|
||||
account: 'alice',
|
||||
);
|
||||
final store = TaskStore.memory();
|
||||
addTearDown(() => store.close());
|
||||
// 永不回包的适配器:请求受理但响应挂起(模拟弱网下预检吊住——
|
||||
// 不等 dio 默认超时,靠注入的短时间帽放行)。
|
||||
final neverResponds = Completer<(int, Object?)>();
|
||||
final adapter = FakeAdapter((o) => neverResponds.future);
|
||||
final client = ApiClient(
|
||||
sessionStore: session,
|
||||
dio: Dio(BaseOptions(baseUrl: 'http://test.local'))
|
||||
..httpClientAdapter = adapter,
|
||||
);
|
||||
|
||||
late BuildContext ctx;
|
||||
await pumpGateHost(tester, (c) => ctx = c);
|
||||
final gate = PickerDuplicateGate(
|
||||
store: store,
|
||||
client: client,
|
||||
accountId: 'alice',
|
||||
albumId: 'alb-1',
|
||||
serverCheckTimeout: const Duration(milliseconds: 50),
|
||||
);
|
||||
|
||||
late bool verdict;
|
||||
await tester.runAsync(() async {
|
||||
verdict = await gate.decideFact(
|
||||
ctx,
|
||||
gateAsset(bytes: kJpgBytes.length),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
expect(verdict, isTrue, reason: '超时=无命中 → 放行(绝不吊起点选)');
|
||||
expect(adapter.requests, hasLength(1), reason: '预检确已发出(只是没回)');
|
||||
});
|
||||
|
||||
testWidgets('⑦ fileSize 获取失败 → 放行(预检故障不挡选图)',
|
||||
(tester) async {
|
||||
final session = FakeSessionStore()
|
||||
..session = Session(
|
||||
token: 'tok-1',
|
||||
expiresAt: DateTime.utc(2099, 1, 1),
|
||||
account: 'alice',
|
||||
);
|
||||
final store = TaskStore.memory();
|
||||
addTearDown(() => store.close());
|
||||
final adapter = FakeAdapter(
|
||||
(o) => (200, {'duplicates': <Map<String, dynamic>>[]}),
|
||||
);
|
||||
final client = ApiClient(
|
||||
sessionStore: session,
|
||||
dio: Dio(BaseOptions(baseUrl: 'http://test.local'))
|
||||
..httpClientAdapter = adapter,
|
||||
);
|
||||
|
||||
late BuildContext ctx;
|
||||
await pumpGateHost(tester, (c) => ctx = c);
|
||||
final gate = PickerDuplicateGate(
|
||||
store: store,
|
||||
client: client,
|
||||
accountId: 'alice',
|
||||
albumId: 'alb-1',
|
||||
);
|
||||
@@ -597,8 +454,41 @@ void main() {
|
||||
});
|
||||
|
||||
expect(verdict, isTrue, reason: 'fileSize 失败 → 放行');
|
||||
expect(adapter.requests, isEmpty,
|
||||
reason: 'fileSize 失败短路两半场——不发起任何查询');
|
||||
});
|
||||
|
||||
testWidgets('⑥ 取消选择恒放行且零查询;再点选仍按重复拦下', (tester) async {
|
||||
final store = TaskStore.memory();
|
||||
addTearDown(() => store.close());
|
||||
|
||||
late BuildContext ctx;
|
||||
await pumpGateHost(tester, (c) => ctx = c);
|
||||
final gate = PickerDuplicateGate(
|
||||
store: store,
|
||||
accountId: 'alice',
|
||||
albumId: 'alb-1',
|
||||
);
|
||||
var sizeCalls = 0;
|
||||
final asset = (
|
||||
id: 'asset-1',
|
||||
title: 'pick1.jpg' as String?,
|
||||
fileSize: () async {
|
||||
sizeCalls++;
|
||||
return kJpgBytes.length;
|
||||
},
|
||||
);
|
||||
|
||||
late bool deselect;
|
||||
late bool reselect;
|
||||
await tester.runAsync(() async {
|
||||
await seedCompletedTask(store, 'pick1.jpg', kJpgBytes.length);
|
||||
deselect = await gate.decideFact(ctx, asset, true);
|
||||
reselect = await gate.decideFact(ctx, asset, false);
|
||||
});
|
||||
|
||||
expect(deselect, isTrue, reason: '取消选择(selected=true)恒放行');
|
||||
expect(sizeCalls, 1, reason: '仅再点选触发一次判定;取消路径零查询');
|
||||
expect(reselect, isFalse, reason: '再点选仍按重复拦下(硬封锁)');
|
||||
await drainBlockedSnackBar(tester, ctx);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -144,7 +144,7 @@ void main() {
|
||||
expect(find.byIcon(Icons.person_add_outlined), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('邀请搜索防抖:<2 字符不请求;连续输入合并为最后一次(300ms)',
|
||||
testWidgets('邀请搜索防抖:单字符即请求;连续输入合并为最后一次(300ms)',
|
||||
(tester) async {
|
||||
final adapter = membersAdapter();
|
||||
await pumpMembers(tester, clientOf(adapter));
|
||||
@@ -152,15 +152,15 @@ void main() {
|
||||
await openInviteSheet(tester);
|
||||
final field = find.byType(TextField);
|
||||
|
||||
// 1 个字符:无请求。
|
||||
// 单字符即发请求(2026-09-29 与服务端 1..64 bounds 对齐——中文姓氏单字)。
|
||||
await tester.enterText(field, 's');
|
||||
await tester.pump(const Duration(milliseconds: 400));
|
||||
expect(
|
||||
adapter.requests.where((r) => r.path == '/v1/admin/users'),
|
||||
isEmpty,
|
||||
isNotEmpty,
|
||||
);
|
||||
|
||||
// 连续输入两次,防抖合并为最后一次。
|
||||
// 连续输入两次,防抖合并为最后一次:'st' 不产生独立请求,只有 'sta'。
|
||||
await tester.enterText(field, 'st');
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
await tester.enterText(field, 'sta');
|
||||
@@ -169,8 +169,12 @@ void main() {
|
||||
final lookupsRequests = adapter.requests
|
||||
.where((r) => r.path == '/v1/admin/users')
|
||||
.toList();
|
||||
expect(lookupsRequests, hasLength(1));
|
||||
expect(lookupsRequests.single.uri.queryParameters['accountPrefix'], 'sta');
|
||||
// 前段 's' 一条 + 合并后的 'sta' 一条(单字符即搜后基线为 2)。
|
||||
expect(lookupsRequests, hasLength(2));
|
||||
expect(
|
||||
lookupsRequests.map((r) => r.uri.queryParameters['accountPrefix']).toList(),
|
||||
['s', 'sta'],
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('邀请流:搜索命中 → Add → POST userId 载荷 → 已添加态 + 列表刷新',
|
||||
|
||||
@@ -31,13 +31,15 @@ function mountCarousel(ads: PublicAd[] = ADS): VueWrapper {
|
||||
}
|
||||
|
||||
function currentSrc(wrapper: VueWrapper): string | null {
|
||||
const img = wrapper.find('.ad-slide img').element as HTMLImageElement | undefined;
|
||||
// 全帧堆叠后当前帧=活跃帧(2026-09-29 淡入淡出改造)。
|
||||
const img = wrapper.find('.ad-slide.active img').element as HTMLImageElement | undefined;
|
||||
return img?.getAttribute('src') ?? null;
|
||||
}
|
||||
|
||||
/** 图加载失败(含现签 URL 过期)→ emit imageError(adId)(2026-09-29 裂图自愈)。 */
|
||||
function triggerImgError(wrapper: VueWrapper): void {
|
||||
wrapper.find('.ad-slide img').trigger('error');
|
||||
// 触发活跃帧的图错误(头/尾克隆帧常驻轨道,首个 .ad-slide 是头克隆)。
|
||||
wrapper.find('.ad-slide.active img').trigger('error');
|
||||
}
|
||||
|
||||
describe('AdCarousel', () => {
|
||||
@@ -80,7 +82,7 @@ describe('AdCarousel', () => {
|
||||
|
||||
it('opens the current ad link in a new tab via window.open', async () => {
|
||||
const wrapper = mountCarousel();
|
||||
await wrapper.get('.ad-slide').trigger('click');
|
||||
await wrapper.get('.ad-slide.active').trigger('click');
|
||||
expect(openSpy).toHaveBeenCalledWith('https://link.test/a', '_blank', 'noopener,noreferrer');
|
||||
});
|
||||
|
||||
@@ -127,4 +129,19 @@ describe('AdCarousel', () => {
|
||||
expect(emitted).toHaveLength(1);
|
||||
expect(emitted![0]).toEqual(['a']); // 当前帧(index 0)的 adId
|
||||
});
|
||||
|
||||
it('wraps last→first as one natural leftward slide then a silent snap', async () => {
|
||||
const wrapper = mountCarousel(ADS);
|
||||
await vi.advanceTimersByTimeAsync(8000); // a→b→c(真实末帧)
|
||||
expect(currentSrc(wrapper)).toBe('https://ad.test/c.jpg');
|
||||
expect(wrapper.find('.ad-slides').attributes('style')).toContain('translateX(-300%)');
|
||||
await vi.advanceTimersByTimeAsync(4000); // 环回:a 经尾克隆右→左滑入
|
||||
expect(currentSrc(wrapper)).toBe('https://ad.test/a.jpg'); // 活跃真实帧已回 a
|
||||
let track = wrapper.find('.ad-slides');
|
||||
expect(track.attributes('style')).toContain('translateX(-400%)'); // 视觉在尾克隆位
|
||||
expect(track.classes()).not.toContain('no-transition');
|
||||
await vi.advanceTimersByTimeAsync(600); // 过渡结束 → 关过渡瞬时回位
|
||||
track = wrapper.find('.ad-slides');
|
||||
expect(track.attributes('style')).toContain('translateX(-100%)'); // 等视觉真实首帧
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import type { PublicAd } from '../../api/client';
|
||||
import { nextIndex, swipeStep } from '../../ads/carousel';
|
||||
@@ -22,10 +22,77 @@ const emit = defineEmits<{ close: []; imageError: [adId: string] }>();
|
||||
const { t } = useI18n();
|
||||
|
||||
const current = ref(0);
|
||||
const currentAd = computed(() => props.ads[current.value] ?? null);
|
||||
/** 指针悬停/按住期间暂停(恢复时重开完整 4s 窗口,非冻结剩余时长)。 */
|
||||
const paused = ref(false);
|
||||
|
||||
// —— 无缝环回(2026-09-29 用户反馈:末帧→首帧也要右→左自然滑入)——
|
||||
// 轨道 = [末帧克隆, 真实帧…, 首帧克隆](仅多条时)。正进步进到尾克隆 /
|
||||
// 后退步进到头克隆都获得单帧宽的自然滑动;过渡结束(定时器兜底)后关过渡、
|
||||
// 瞬时跳回等视觉位的真实帧,再恢复过渡——观感连续无回卷。
|
||||
const SLIDE_MS = 450;
|
||||
/** 轨道渲染位:真实 index + 1(头克隆占位 0);克隆位 0 与 n+1 仅在环回瞬间。 */
|
||||
const trackPos = ref(1);
|
||||
/** 瞬时回位期间关闭过渡(transform 跳转不产生动画)。 */
|
||||
const noTransition = ref(false);
|
||||
/** 轨道元素(snapBack 强制重排用)。 */
|
||||
const trackEl = ref<HTMLElement | null>(null);
|
||||
let snapTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
/** 轨道帧序列:{ ad, real }——real 为真实 index,克隆帧为 null。 */
|
||||
const trackSlides = computed(() => {
|
||||
const n = props.ads.length;
|
||||
if (n <= 1) return props.ads.map((ad) => ({ ad, real: 0 }));
|
||||
return [
|
||||
{ ad: props.ads[n - 1]!, real: null },
|
||||
...props.ads.map((ad, real) => ({ ad, real })),
|
||||
{ ad: props.ads[0]!, real: null },
|
||||
];
|
||||
});
|
||||
|
||||
function clearSnapTimer(): void {
|
||||
if (snapTimer !== undefined) {
|
||||
clearTimeout(snapTimer);
|
||||
snapTimer = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async function snapBack(): Promise<void> {
|
||||
noTransition.value = true;
|
||||
trackPos.value = current.value + 1;
|
||||
await nextTick();
|
||||
// 强制重排 + 双 rAF:确保「无过渡 + 新 transform」先完成一次绘制,再恢复
|
||||
// 过渡。若在同一帧内直接恢复(浏览器样式计算时过渡仍在),回跳会变成可见
|
||||
// 的反向长滚(2026-09-29 线上反馈根因;测试假时钟下恰好掩盖)。
|
||||
void trackEl.value?.offsetWidth;
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
noTransition.value = false;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** 移动到真实 index(圆点/滑动/自动轮播共用):环回走克隆位 + 到位后回跳。 */
|
||||
function moveTo(index: number): void {
|
||||
clearSnapTimer();
|
||||
const n = props.ads.length;
|
||||
const from = current.value;
|
||||
let pos = index + 1;
|
||||
if (n > 1) {
|
||||
if (from === n - 1 && index === 0) pos = n + 1; // 末→首:走尾克隆(右→左滑入)
|
||||
else if (from === 0 && index === n - 1) pos = 0; // 首→末:走头克隆(右滑入)
|
||||
}
|
||||
current.value = index;
|
||||
trackPos.value = pos;
|
||||
if (pos === 0 || pos === n + 1) {
|
||||
// 过渡时长 + 缓冲后瞬时回位;期间用户再操作会经 clearSnapTimer 作废。
|
||||
snapTimer = setTimeout(() => {
|
||||
snapTimer = undefined;
|
||||
void snapBack();
|
||||
}, SLIDE_MS + 60);
|
||||
}
|
||||
syncRotation();
|
||||
}
|
||||
|
||||
let rotateTimer: ReturnType<typeof setInterval> | undefined;
|
||||
|
||||
function stopRotation(): void {
|
||||
@@ -40,31 +107,39 @@ function syncRotation(): void {
|
||||
stopRotation();
|
||||
if (props.ads.length <= 1 || paused.value) return;
|
||||
rotateTimer = setInterval(() => {
|
||||
current.value = nextIndex(props.ads.length, current.value);
|
||||
moveTo(nextIndex(props.ads.length, current.value));
|
||||
}, ROTATE_MS);
|
||||
}
|
||||
|
||||
/** 跳页(圆点/滑动共用):就位后重开完整 4s 窗口。 */
|
||||
function goTo(index: number): void {
|
||||
current.value = index;
|
||||
syncRotation();
|
||||
moveTo(index);
|
||||
}
|
||||
|
||||
function stepBy(step: 1 | -1): void {
|
||||
goTo(nextIndex(props.ads.length, current.value, step));
|
||||
moveTo(nextIndex(props.ads.length, current.value, step));
|
||||
}
|
||||
|
||||
/** 新广告列表(token/locale 重载整表替换)→ 回首项并重对齐轮播。 */
|
||||
watch(
|
||||
() => props.ads,
|
||||
() => {
|
||||
clearSnapTimer();
|
||||
noTransition.value = false;
|
||||
current.value = 0;
|
||||
trackPos.value = props.ads.length > 1 ? 1 : 0;
|
||||
syncRotation();
|
||||
},
|
||||
);
|
||||
watch(paused, syncRotation);
|
||||
onMounted(syncRotation);
|
||||
onBeforeUnmount(stopRotation);
|
||||
onMounted(() => {
|
||||
trackPos.value = props.ads.length > 1 ? 1 : 0;
|
||||
syncRotation();
|
||||
});
|
||||
onBeforeUnmount(() => {
|
||||
stopRotation();
|
||||
clearSnapTimer();
|
||||
});
|
||||
|
||||
let touchStart: { x: number; y: number } | null = null;
|
||||
|
||||
@@ -99,18 +174,33 @@ function openAd(ad: PublicAd): void {
|
||||
@pointerenter="paused = true" @pointerdown="paused = true" @pointerleave="paused = false"
|
||||
@touchstart="onTouchStart" @touchend="onTouchEnd"
|
||||
>
|
||||
<button
|
||||
v-if="currentAd !== null" type="button" class="ad-slide" :aria-label="t('ads.open')"
|
||||
@click="openAd(currentAd)"
|
||||
<!-- 轨道式滑动(2026-09-29 用户定稿:从右往左滑入,不做淡入淡出):全帧
|
||||
常驻轨道预加载(首轮切换不空白),track 整体 translateX;首尾克隆帧
|
||||
令环回也是单帧宽自然滑入(到位后关过渡瞬时回位,见 script);仅活跃
|
||||
真实帧可聚焦;错误上报只看活跃帧。 -->
|
||||
<div
|
||||
ref="trackEl"
|
||||
class="ad-slides" :class="{ 'no-transition': noTransition }"
|
||||
:style="{ transform: `translateX(-${trackPos * 100}%)` }"
|
||||
>
|
||||
<img
|
||||
:src="currentAd.imageUrl"
|
||||
:alt="t('ads.label')"
|
||||
loading="lazy"
|
||||
draggable="false"
|
||||
@error="currentAd !== null && emit('imageError', currentAd.adId)"
|
||||
<button
|
||||
v-for="(slide, i) in trackSlides" :key="`${slide.real ?? 'clone'}-${slide.ad.adId}-${i}`"
|
||||
type="button" class="ad-slide"
|
||||
:class="{ active: slide.real !== null && slide.real === current }"
|
||||
:aria-label="t('ads.open')"
|
||||
:aria-hidden="slide.real !== null && slide.real === current ? undefined : 'true'"
|
||||
:tabindex="slide.real !== null && slide.real === current ? 0 : -1"
|
||||
@click="openAd(slide.ad)"
|
||||
>
|
||||
</button>
|
||||
<img
|
||||
:src="slide.ad.imageUrl"
|
||||
:alt="t('ads.label')"
|
||||
decoding="async"
|
||||
draggable="false"
|
||||
@error="slide.real !== null && slide.real === current && emit('imageError', slide.ad.adId)"
|
||||
>
|
||||
</button>
|
||||
</div>
|
||||
<span class="ad-badge">{{ t('ads.label') }}</span>
|
||||
<button type="button" class="ad-close" :aria-label="t('ads.close')" @click="emit('close')">✕</button>
|
||||
<nav v-if="ads.length > 1" class="ad-dots" :aria-label="t('ads.dots')">
|
||||
@@ -130,7 +220,6 @@ function openAd(ad: PublicAd): void {
|
||||
.ad-carousel {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
/* aspect-ratio: 10 / 3; 建议素材 1200×360 */
|
||||
margin: 16px 0;
|
||||
overflow: hidden;
|
||||
border-radius: var(--viewer-radius-img);
|
||||
@@ -140,45 +229,61 @@ function openAd(ad: PublicAd): void {
|
||||
* ThumbnailStrip pan-x 的对偶(pan-y)。 */
|
||||
touch-action: pan-y;
|
||||
}
|
||||
.ad-slides {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
align-items: flex-start; /* 各帧自然高度(运营上传统一高度图);track 高=最高帧 */
|
||||
/* 从右往左滑动切换(下一帧自右侧滑入;后退/右滑自然反向)。 */
|
||||
transition: transform 0.45s ease;
|
||||
}
|
||||
/* 环回瞬时回位:克隆位→真实位等视觉跳转(无动画)。 */
|
||||
.ad-slides.no-transition {
|
||||
transition: none;
|
||||
}
|
||||
.ad-slide {
|
||||
flex: 0 0 100%;
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
/* 位移类动画尊重系统减动效偏好(HeartComponents 同规)。 */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.ad-slides {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
.ad-slide img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
height: auto;
|
||||
}
|
||||
.ad-badge {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
left: 8px;
|
||||
top: 2px;
|
||||
left: 1px;
|
||||
padding: 2px 10px;
|
||||
border-radius: var(--viewer-radius-pill);
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
font-size: 10px;
|
||||
line-height: 12px;
|
||||
color: var(--viewer-surface);
|
||||
background: color-mix(in srgb, var(--viewer-dark-bg) 72%, transparent);
|
||||
background: color-mix(in srgb, #686868 72%, transparent);
|
||||
pointer-events: none; /* 徽标不拦截 slide 点击 */
|
||||
}
|
||||
.ad-close {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width:24px; /* ≥44px 触达 */
|
||||
height: 24px;
|
||||
top: 2px;
|
||||
right: 1px;
|
||||
width:20px; /* ≥44px 触达 */
|
||||
height: 20px;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
font-size: 18px;
|
||||
font-size: 10px;
|
||||
line-height: 1;
|
||||
color: var(--viewer-surface);
|
||||
background: color-mix(in srgb, var(--viewer-dark-bg) 72%, transparent);
|
||||
background: color-mix(in srgb, #686868 72%, transparent);
|
||||
cursor: pointer;
|
||||
}
|
||||
.ad-dots {
|
||||
|
||||
@@ -367,7 +367,7 @@ watch(
|
||||
gap: 8px 20px;
|
||||
margin: 9px 0 0;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 1px solid var(--viewer-divider, #e5eaf1);
|
||||
/* border-bottom: 1px solid var(--viewer-divider, #e5eaf1); */
|
||||
color: var(--viewer-text-secondary, #647084);
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.5;
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
# 选图点选拦截 v2:硬封锁 + 仅本机判定(去服务器半场、去强制放行)
|
||||
|
||||
日期:2026-09-29 | 状态:用户拍板(方案 A:接受"服务端已删照片无法重传"限制)
|
||||
|
||||
## 背景与问题
|
||||
|
||||
v1(提交 7e5c550+0673f39)上线后两个实际问题:
|
||||
|
||||
1. **选图不灵敏**:`selectPredicate` 每次点选 await 判定,判定含服务器单项预检
|
||||
(时间帽 2s)。弱网下每次点新照片勾选延迟 0.5~2s,用户以为没点上就反复点
|
||||
(wechat_assets_picker `selectAsset` 源码:谓词返回前 `provider.selectAsset`
|
||||
不执行——勾选必然等判定)。
|
||||
2. **拦截形同虚设**:v1 设计了"二次点击强制选择"活口(escape hatch),配合上述
|
||||
反复点击习惯,重复照片多点几次必然被强制选中——用户明确要求**重复照片不能
|
||||
被选择**。
|
||||
|
||||
## 方案(v2)
|
||||
|
||||
### 1. 点选判定只查本机(治卡顿)
|
||||
|
||||
- `_judge` 仅保留本机 TaskStore 半场(同账号+相册+completed,filename+bytes),
|
||||
删除服务器单项预检及其 2s 时间帽——点选延迟从「最多 2s 网络」降为「毫秒级
|
||||
本地查询」。
|
||||
- 服务器查重不消失:**入队时刻统一弹窗**(checkDuplicatesUnified,一次批量
|
||||
≤500 张)照旧——跨设备/重装场景的重复仍在点上传时拦,服务端 409 兜底不变。
|
||||
- iOS 注意:`title` 为空(需 FilterOption.needTitle)时本机半场无从匹配 →
|
||||
放行;iOS 点选拦截实际不生效,跨设备判定依赖入队弹窗(与 v1 服务器半场
|
||||
行为的差异登记于此)。
|
||||
|
||||
### 2. 硬封锁(去强制放行)
|
||||
|
||||
- 删除 `_forced` 二次点击放行集:本机命中的重复照片**每次点击都拒选**,
|
||||
判定缓存命中即秒拦,提示文案改为「这张已上传到该相册,无法重复选择」
|
||||
(en: This photo has already been uploaded to this album),不再引导再点。
|
||||
- SnackBar clearSnackBars 防堆积行为保留。
|
||||
|
||||
### 3. 判定缓存升级为 in-flight Future 共享
|
||||
|
||||
- `_verdicts` 从 `Map<String, bool>` 改为 `Map<String, Future<bool>>`,
|
||||
`putIfAbsent` 并发去重——同一照片双击/连点只发起一次判定(清偿 9-29 评审
|
||||
登记 Minor 遗留)。`_judge` 内部全捕获不抛错,缓存 Future 无失败态。
|
||||
|
||||
## 已接受的限制(用户拍板 A)
|
||||
|
||||
- 照片在后台被删除后想重传同一文件:本机历史仍记「已传」→ 永远拦死。
|
||||
解决办法:清 App 数据或换设备(罕见场景,先接受;后续可加设置开关/服务端
|
||||
存活性免疫,未排期)。
|
||||
|
||||
## 构造签名变化
|
||||
|
||||
`PickerDuplicateGate` 删除 `client` 与 `serverCheckTimeout` 入参(点选路径无
|
||||
网络依赖);`multiImagePickerProvider` 相应不再注入 ApiClient。
|
||||
|
||||
## 测试(enqueue_test 拦截器组重写)
|
||||
|
||||
- 本机命中 → 拒选;**连续多次点击均拒选**(无强制路径);新提示文案;
|
||||
SnackBar 不堆积(第二个重复资产仅一条)。
|
||||
- 不同 bytes / filename / 相册 / 账号 → 放行。
|
||||
- in-flight 共享:并发两次 decideFact,fileSize 闭包仅被调用一次。
|
||||
- title=null(iOS)→ 放行;fileSize 失败 → 放行;取消选择(selected=true)
|
||||
恒放行且零查询(fileSize 计数为 0)。
|
||||
- v1 服务器相关用例(②④⑥)随服务器半场移除而删除。
|
||||
|
||||
## 影响面
|
||||
|
||||
`enqueue.dart`(gate + provider 注入)+ `enqueue_test.dart` + l10n 双语一处
|
||||
文案。不涉及服务端、契约、其它页面。
|
||||
@@ -1313,9 +1313,11 @@ paths:
|
||||
双模式统一端点(P4a 决策:与 P1b 账号前缀查询合一,响应统一
|
||||
AdminUserList——条目为 AdminUser 字段超集,管理端 UI 按场景裁剪展示):
|
||||
· 前缀模式(提供 accountPrefix):邀请协作者前置查询,沿用 P1b 语义
|
||||
——大小写敏感前缀匹配(服务端按二进制校对执行 LIKE prefix%)、
|
||||
仅「激活资格」用户(credentialStatus=active 且 sourceStatus≠disabled
|
||||
且 !localLocked,与成员添加的目标校验一致)、account 升序;
|
||||
——大小写敏感前缀匹配(服务端按二进制校对执行 LIKE prefix%,匹配
|
||||
account **或 displayName** 前缀——2026-09-29 扩展:V1.1 账号=手机号,
|
||||
运营按姓名搜人是自然预期)、仅「激活资格」用户(credentialStatus=
|
||||
active 且 sourceStatus≠disabled 且 !localLocked,与成员添加的目标
|
||||
校验一致)、account 升序;
|
||||
访问策略沿用 P1b 决策:任何已认证工作人员可用(邀请发起方不限于
|
||||
创建者/管理员,泄露面为已认证内部人员,风险可接受)。
|
||||
· 管理列表模式(未提供 accountPrefix):全量用户治理列表(游标分页,
|
||||
@@ -1328,10 +1330,10 @@ paths:
|
||||
parameters:
|
||||
- name: accountPrefix
|
||||
in: query
|
||||
description: 账号前缀(≥2 字符以防过宽匹配;提供即进入前缀模式)。
|
||||
description: 账号或姓名前缀(≥1 字符——中文姓氏单字搜索合理,2026-09-29 放宽;提供即进入前缀模式;大小写敏感——中文姓名无大小写语义)。
|
||||
schema:
|
||||
type: string
|
||||
minLength: 2
|
||||
minLength: 1
|
||||
maxLength: 64
|
||||
- name: query
|
||||
in: query
|
||||
|
||||
+6
-4
@@ -557,9 +557,11 @@ export interface paths {
|
||||
* @description 双模式统一端点(P4a 决策:与 P1b 账号前缀查询合一,响应统一
|
||||
* AdminUserList——条目为 AdminUser 字段超集,管理端 UI 按场景裁剪展示):
|
||||
* · 前缀模式(提供 accountPrefix):邀请协作者前置查询,沿用 P1b 语义
|
||||
* ——大小写敏感前缀匹配(服务端按二进制校对执行 LIKE prefix%)、
|
||||
* 仅「激活资格」用户(credentialStatus=active 且 sourceStatus≠disabled
|
||||
* 且 !localLocked,与成员添加的目标校验一致)、account 升序;
|
||||
* ——大小写敏感前缀匹配(服务端按二进制校对执行 LIKE prefix%,匹配
|
||||
* account **或 displayName** 前缀——2026-09-29 扩展:V1.1 账号=手机号,
|
||||
* 运营按姓名搜人是自然预期)、仅「激活资格」用户(credentialStatus=
|
||||
* active 且 sourceStatus≠disabled 且 !localLocked,与成员添加的目标
|
||||
* 校验一致)、account 升序;
|
||||
* 访问策略沿用 P1b 决策:任何已认证工作人员可用(邀请发起方不限于
|
||||
* 创建者/管理员,泄露面为已认证内部人员,风险可接受)。
|
||||
* · 管理列表模式(未提供 accountPrefix):全量用户治理列表(游标分页,
|
||||
@@ -4440,7 +4442,7 @@ export interface operations {
|
||||
listAdminUsers: {
|
||||
parameters: {
|
||||
query?: {
|
||||
/** @description 账号前缀(≥2 字符以防过宽匹配;提供即进入前缀模式)。 */
|
||||
/** @description 账号或姓名前缀(≥1 字符——中文姓氏单字搜索合理,2026-09-29 放宽;提供即进入前缀模式;大小写敏感——中文姓名无大小写语义)。 */
|
||||
accountPrefix?: string;
|
||||
/**
|
||||
* @description 管理列表过滤:账号或展示名前缀(大小写敏感二进制前缀匹配,同
|
||||
|
||||
@@ -37,12 +37,13 @@ import { UsersLookupService, type AdminUserListResponse } from './users-lookup.s
|
||||
export const DEFAULT_LIST_LIMIT = 20;
|
||||
export const MAX_LIST_LIMIT = 100;
|
||||
|
||||
/** accountPrefix 查询参数(前缀模式):2..64(契约 bounds)。 */
|
||||
/** accountPrefix 查询参数(前缀模式):1..64(契约 bounds;2026-09-29 自 2
|
||||
* 放宽——中文姓氏单字搜索合理,用户拍板)。 */
|
||||
export function parseAccountPrefix(raw: string | undefined): string {
|
||||
// 双模式收口后 undefined 不再到达此函数(分流在 listAdminUsers);防御性
|
||||
// 保留同一校验出口。空串/1 字符/超长 → 400。
|
||||
if (raw === undefined || raw.length < 2 || raw.length > 64) {
|
||||
throw validationFailed('accountPrefix 须为 2-64 字符');
|
||||
// 保留同一校验出口。空串/超长 → 400;单字符合法。
|
||||
if (raw === undefined || raw.length < 1 || raw.length > 64) {
|
||||
throw validationFailed('accountPrefix 须为 1-64 字符');
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
@@ -71,6 +71,10 @@ export class UsersLookupService {
|
||||
/**
|
||||
* 前缀查询(契约 listAdminUsers 前缀模式):激活资格用户、account 升序、
|
||||
* limit 截断;响应为统一 AdminUserList 形状(P4a)。
|
||||
* 2026-09-29 方案 A:前缀匹配扩展为 **account OR displayName**——V1.1 同步
|
||||
* 用户账号=手机号,运营按姓名搜人是自然预期(此前仅 account,姓名搜索恒空,
|
||||
* 线上成员添加「关联不到用户」根因);与管理列表 query 模式的 OR displayName
|
||||
* 对齐。大小写敏感(bin 校对)与资格过滤对两列同样生效。
|
||||
*/
|
||||
async lookup(
|
||||
accountPrefix: string,
|
||||
@@ -78,11 +82,13 @@ export class UsersLookupService {
|
||||
): Promise<AdminUserListResponse> {
|
||||
const pattern = `${escapeLikePrefix(accountPrefix)}%`;
|
||||
// 参数经 $queryRaw 模板绑定(无拼接注入面);LIMIT ? 为 MySQL 合法占位。
|
||||
// OR 两列必须括号分组——否则资格过滤会被短路。
|
||||
const rows = await this.prisma.$queryRaw<AdminUserRow[]>`
|
||||
SELECT id, account, displayName, role, sourceStatus, localLocked,
|
||||
credentialStatus, locale, createdAt
|
||||
FROM users
|
||||
WHERE account LIKE ${pattern} ESCAPE '!'
|
||||
WHERE (account LIKE ${pattern} ESCAPE '!'
|
||||
OR displayName LIKE ${pattern} ESCAPE '!')
|
||||
AND credentialStatus = 'active'
|
||||
AND sourceStatus <> 'disabled'
|
||||
AND localLocked = false
|
||||
|
||||
@@ -14,6 +14,10 @@ import { randomBytes } from 'node:crypto';
|
||||
import { type INestApplication } from '@nestjs/common';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import request from 'supertest';
|
||||
|
||||
// 冷 infra(Docker 重启后)下 beforeAll 引导可超 jest 缺省 5s——对齐
|
||||
// 同目录其它 e2e 的 120s(2026-09-29 两次冷启动误报教训)。
|
||||
jest.setTimeout(120_000);
|
||||
import { PrismaClient } from '@photo/db-cn';
|
||||
import { ErrorCodes } from '@photo/contracts';
|
||||
import { AppCnModule } from '../src/app-cn.module';
|
||||
|
||||
@@ -197,6 +197,38 @@ describe('账号查询 GET /admin/users (e2e)', () => {
|
||||
expect(percent.body.items).toEqual([]);
|
||||
});
|
||||
|
||||
// displayName 前缀并入(2026-09-29 方案 A:运营按姓名搜人——账号=手机号下
|
||||
// 姓名是运营唯一记得的标识;管理列表 query 模式此前已 OR displayName,
|
||||
// 前缀模式对齐)。语义:account 前缀 OR displayName 前缀,资格过滤/
|
||||
// account 升序/大小写敏感(bin 校对对两列同样生效)不变。
|
||||
it('displayName 前缀命中(account 不匹配也可查);两列命中并集按 account 升序', async () => {
|
||||
const byName = await lookup(staffToken, { accountPrefix: '爱丽' });
|
||||
expect(byName.status).toBe(200);
|
||||
expect(byName.body.items).toEqual([
|
||||
expect.objectContaining({ id: aliceId, account: 'alice', displayName: '爱丽' }),
|
||||
]);
|
||||
|
||||
// 前缀同时命中两列的多个用户:'批量' 命中 12 个 zz-look-* 的姓名,
|
||||
// account 升序 + limit 截断(limit:5 → 01..05)。
|
||||
const paged = await lookup(staffToken, { accountPrefix: '批量', limit: 5 });
|
||||
expect(paged.status).toBe(200);
|
||||
expect(paged.body.items.map((i: { account: string }) => i.account)).toEqual([
|
||||
'zz-look-01', 'zz-look-02', 'zz-look-03', 'zz-look-04', 'zz-look-05',
|
||||
]);
|
||||
});
|
||||
|
||||
it('displayName 命中同样受激活资格过滤(停用/待激活/锁定经姓名也查不到)', async () => {
|
||||
const disabled = await lookup(staffToken, { accountPrefix: '停用' });
|
||||
expect(disabled.status).toBe(200);
|
||||
expect(disabled.body.items).toEqual([]);
|
||||
const pending = await lookup(staffToken, { accountPrefix: '待激活' });
|
||||
expect(pending.status).toBe(200);
|
||||
expect(pending.body.items).toEqual([]);
|
||||
const locked = await lookup(staffToken, { accountPrefix: '锁定' });
|
||||
expect(locked.status).toBe(200);
|
||||
expect(locked.body.items).toEqual([]);
|
||||
});
|
||||
|
||||
it('accountPrefix 边界:缺失 → 双模式分流(staff 403 / admin 200 管理列表);1/65 字符 → 400;64 → 200 空', async () => {
|
||||
// Task 6 起缺失 accountPrefix 进入管理列表模式(仅 admin):staff 无前缀
|
||||
// → 403 AUTH_FORBIDDEN;admin → 200 管理列表(全量用户,含非激活态——
|
||||
@@ -226,9 +258,17 @@ describe('账号查询 GET /admin/users (e2e)', () => {
|
||||
expect(empty.status).toBe(400);
|
||||
expect(empty.body.code).toBe(ErrorCodes.VALIDATION_FAILED);
|
||||
|
||||
const short = await lookup(staffToken, { accountPrefix: 'a' });
|
||||
expect(short.status).toBe(400);
|
||||
expect(short.body.code).toBe(ErrorCodes.VALIDATION_FAILED);
|
||||
// 单字符已合法(2026-09-29 自 2 放宽——中文姓氏单字搜索):'a' 命中
|
||||
// alice/albert(displayName 前缀并入后 '爱' 亦单字可用)。
|
||||
const single = await lookup(staffToken, { accountPrefix: 'a' });
|
||||
expect(single.status).toBe(200);
|
||||
expect(single.body.items.map((i: { account: string }) => i.account)).toEqual([
|
||||
'albert',
|
||||
'alice',
|
||||
]);
|
||||
const singleName = await lookup(staffToken, { accountPrefix: '爱' });
|
||||
expect(singleName.status).toBe(200);
|
||||
expect(singleName.body.items).toHaveLength(1);
|
||||
|
||||
const long = await lookup(staffToken, { accountPrefix: 'a'.repeat(65) });
|
||||
expect(long.status).toBe(400);
|
||||
|
||||
Reference in New Issue
Block a user