fix(viewer): replace text arrow share icon with SVG share glyph
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -30,6 +30,13 @@ android {
|
||||
// TODO: Add your own signing config for the release build.
|
||||
// Signing with the debug keys for now, so `flutter run --release` works.
|
||||
signingConfig = signingConfigs.getByName("debug")
|
||||
// R8 混淆会破坏 WorkManager 反射初始化(NoSuchMethodException
|
||||
// WorkDatabase_Impl),ProGuard keep 规则见 proguard-rules.pro。
|
||||
isMinifyEnabled = true
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
# WorkManager: R8 反射初始化需要保留构造器(P3 Task 7 后台上传桥接)
|
||||
-keep class androidx.work.impl.WorkDatabase_Impl { *; }
|
||||
-keep class androidx.work.** { *; }
|
||||
-keep class androidx.startup.** { *; }
|
||||
|
||||
# OkHttp(原生上传传输):公开 API 保留即可,但 safety net 防混淆异常
|
||||
-dontwarn okhttp3.**
|
||||
-dontwarn okio.**
|
||||
@@ -2,11 +2,13 @@
|
||||
<!-- P3 Task 11:debug 构建专用网络策略。
|
||||
targetSdk>=28 默认禁明文 HTTP,而本地联调后端/MinIO 观众端全部是
|
||||
http://10.0.2.2(模拟器宿主回环)——release 不受影响(本文件只在
|
||||
src/debug 变体参与合并)。仅放行宿主回环与本机回环,不放开全域。 -->
|
||||
src/debug 变体参与合并)。放行宿主回环、本机回环与测试服务器 IP
|
||||
(219.157.255.213,线上联调 http 反代 8088/8089),不放开全域。 -->
|
||||
<network-security-config>
|
||||
<domain-config cleartextTrafficPermitted="true">
|
||||
<domain includeSubdomains="false">10.0.2.2</domain>
|
||||
<domain includeSubdomains="false">127.0.0.1</domain>
|
||||
<domain includeSubdomains="false">localhost</domain>
|
||||
<domain includeSubdomains="false">219.157.255.213</domain>
|
||||
</domain-config>
|
||||
</network-security-config>
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -10,6 +10,8 @@
|
||||
/// 抛 [AppException](码表见 [ErrorCodes];l10n 文案解析留在 UI 层)。
|
||||
library;
|
||||
|
||||
import 'dart:io' show File, Platform;
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
@@ -282,6 +284,80 @@ class ApiClient {
|
||||
Future<CursorPage<AlbumSummary>> listAlbums({String? cursor, int? limit}) =>
|
||||
_getPage('/v1/albums', AlbumSummary.fromJson, cursor: cursor, limit: limit);
|
||||
|
||||
/// 资产上传凭证(契约 AssetInitResponse)。
|
||||
/// kind:cover | banner | logo;凭据结构与照片 presign 相同。
|
||||
static const assetKindCover = 'cover';
|
||||
static const assetKindBanner = 'banner';
|
||||
|
||||
Future<({String assetId, UploadCredential credential})>
|
||||
initAlbumAsset(String albumId, {
|
||||
required String kind,
|
||||
required String filename,
|
||||
required int bytes,
|
||||
required String contentType,
|
||||
}) async {
|
||||
final resp = await _send(
|
||||
() => _dio.post<Map<String, dynamic>>('/v1/albums/$albumId/assets', data: {
|
||||
'kind': kind,
|
||||
'filename': filename,
|
||||
'bytes': bytes,
|
||||
'contentType': contentType,
|
||||
}),
|
||||
);
|
||||
final data = resp.data!;
|
||||
return (
|
||||
assetId: data['assetId'] as String,
|
||||
credential: UploadCredential.fromJson(data['credential'] as Map<String, dynamic>),
|
||||
);
|
||||
}
|
||||
|
||||
/// 完成资产上传并激活(返回短期签名 URL)。
|
||||
Future<String> completeAlbumAsset(String albumId, String assetId) async {
|
||||
final resp = await _send(
|
||||
() => _dio.post<Map<String, dynamic>>(
|
||||
'/v1/albums/$albumId/assets/$assetId/complete',
|
||||
),
|
||||
);
|
||||
return resp.data!['url'] as String;
|
||||
}
|
||||
|
||||
/// 一步上传相册封面:选图 → presign → PUT → complete。
|
||||
/// [filePath] 是本机源文件路径(由 singleImagePicker 返回)。
|
||||
/// 返回封面短期 URL(仅用于即时预览;列表走 AlbumSummary.cover)。
|
||||
Future<String> uploadAlbumCoverFromFile(String albumId, String filePath) async {
|
||||
final file = File(filePath);
|
||||
final bytes = await file.length();
|
||||
final filename = filePath.split(Platform.pathSeparator).last;
|
||||
final contentType = _guessContentType(filename);
|
||||
final init = await initAlbumAsset(albumId,
|
||||
kind: assetKindCover,
|
||||
filename: filename,
|
||||
bytes: bytes,
|
||||
contentType: contentType);
|
||||
final putRes = await Dio().put(
|
||||
init.credential.url,
|
||||
data: file.openRead(),
|
||||
options: Options(headers: {
|
||||
...init.credential.headers,
|
||||
'Content-Length': bytes,
|
||||
}),
|
||||
);
|
||||
if (putRes.statusCode != 200 && putRes.statusCode != 204) {
|
||||
throw Exception('cover PUT failed: ${putRes.statusCode}');
|
||||
}
|
||||
return completeAlbumAsset(albumId, init.assetId);
|
||||
}
|
||||
|
||||
String _guessContentType(String filename) {
|
||||
final ext = filename.split('.').last.toLowerCase();
|
||||
return switch (ext) {
|
||||
'jpg' || 'jpeg' => 'image/jpeg',
|
||||
'png' => 'image/png',
|
||||
'heic' => 'image/heic',
|
||||
_ => 'image/jpeg',
|
||||
};
|
||||
}
|
||||
|
||||
/// operationId `createAlbum`(POST /albums → 201)。
|
||||
Future<Album> createAlbum(AlbumCreateRequest req) async {
|
||||
final resp = await _send(
|
||||
|
||||
@@ -80,6 +80,10 @@
|
||||
"formTitleRequiredWithFields": "Title is required when description, venue or address is filled",
|
||||
"formEndAtNotAfterStartAt": "End time must be after start time",
|
||||
"formEventDetails": "Event details",
|
||||
"coverPicker": "Album cover",
|
||||
"coverPickerHint": "Tap to select a cover image (optional)",
|
||||
"coverSelected": "Cover selected, will upload on save",
|
||||
"coverUploadFailed": "Cover upload failed, you can retry from album detail later",
|
||||
"formDescriptionLanguages": "Description & languages",
|
||||
"formDefaultLanguage": "Default language",
|
||||
"formDetailsTab": "Details",
|
||||
|
||||
@@ -572,6 +572,30 @@ abstract class AppLocalizations {
|
||||
/// **'活动信息'**
|
||||
String get formEventDetails;
|
||||
|
||||
/// No description provided for @coverPicker.
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'相册封面'**
|
||||
String get coverPicker;
|
||||
|
||||
/// No description provided for @coverPickerHint.
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'点击选择封面图片(可选)'**
|
||||
String get coverPickerHint;
|
||||
|
||||
/// No description provided for @coverSelected.
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'已选择封面,保存后上传'**
|
||||
String get coverSelected;
|
||||
|
||||
/// No description provided for @coverUploadFailed.
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'封面上传失败,可稍后在相册详情中重新上传'**
|
||||
String get coverUploadFailed;
|
||||
|
||||
/// No description provided for @formDescriptionLanguages.
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
|
||||
@@ -254,6 +254,19 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get formEventDetails => 'Event details';
|
||||
|
||||
@override
|
||||
String get coverPicker => 'Album cover';
|
||||
|
||||
@override
|
||||
String get coverPickerHint => 'Tap to select a cover image (optional)';
|
||||
|
||||
@override
|
||||
String get coverSelected => 'Cover selected, will upload on save';
|
||||
|
||||
@override
|
||||
String get coverUploadFailed =>
|
||||
'Cover upload failed, you can retry from album detail later';
|
||||
|
||||
@override
|
||||
String get formDescriptionLanguages => 'Description & languages';
|
||||
|
||||
|
||||
@@ -248,6 +248,18 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
@override
|
||||
String get formEventDetails => '活动信息';
|
||||
|
||||
@override
|
||||
String get coverPicker => '相册封面';
|
||||
|
||||
@override
|
||||
String get coverPickerHint => '点击选择封面图片(可选)';
|
||||
|
||||
@override
|
||||
String get coverSelected => '已选择封面,保存后上传';
|
||||
|
||||
@override
|
||||
String get coverUploadFailed => '封面上传失败,可稍后在相册详情中重新上传';
|
||||
|
||||
@override
|
||||
String get formDescriptionLanguages => '描述与语言';
|
||||
|
||||
|
||||
@@ -80,6 +80,10 @@
|
||||
"formTitleRequiredWithFields": "填写了描述/场地/地址时,标题不能为空",
|
||||
"formEndAtNotAfterStartAt": "结束时间必须晚于开始时间",
|
||||
"formEventDetails": "活动信息",
|
||||
"coverPicker": "相册封面",
|
||||
"coverPickerHint": "点击选择封面图片(可选)",
|
||||
"coverSelected": "已选择封面,保存后上传",
|
||||
"coverUploadFailed": "封面上传失败,可稍后在相册详情中重新上传",
|
||||
"formDescriptionLanguages": "描述与语言",
|
||||
"formDefaultLanguage": "默认语言",
|
||||
"formDetailsTab": "详情",
|
||||
|
||||
@@ -16,6 +16,8 @@
|
||||
/// 无变更时保存禁用。成功 pop(true) 供调用方刷新列表/详情;失败保留输入。
|
||||
library;
|
||||
|
||||
import 'dart:io' show File;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
@@ -25,6 +27,7 @@ import '../core/error_l10n.dart';
|
||||
import '../features/albums/album_format.dart';
|
||||
import '../features/albums/album_form.dart';
|
||||
import '../features/albums/models.dart';
|
||||
import '../features/uploads/enqueue.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import '../ui/components/section_header.dart';
|
||||
import '../ui/tokens.dart';
|
||||
@@ -82,6 +85,15 @@ class _AlbumFormPageState extends ConsumerState<AlbumFormPage>
|
||||
List<AlbumFormError> _errors = const <AlbumFormError>[];
|
||||
bool _submitting = false;
|
||||
|
||||
/// 用户选择的封面本机文件路径(null = 未选)。
|
||||
/// 创建模式:相册创建成功后上传;编辑模式:保存后上传。
|
||||
String? _coverFilePath;
|
||||
|
||||
/// 编辑模式下的现有封面 URL(来自 AlbumSummary.cover,即时预览)。
|
||||
String? get _existingCoverUrl => _coverFilePath == null
|
||||
? (widget.existing?.cover?.url)
|
||||
: null;
|
||||
|
||||
bool get _isCreate => widget.existing == null;
|
||||
|
||||
@override
|
||||
@@ -225,17 +237,37 @@ class _AlbumFormPageState extends ConsumerState<AlbumFormPage>
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
final navigator = Navigator.of(context);
|
||||
try {
|
||||
late final String albumId;
|
||||
if (_isCreate) {
|
||||
await ref.read(apiClientProvider).createAlbum(draft.toCreateRequest());
|
||||
final album = await ref
|
||||
.read(apiClientProvider)
|
||||
.createAlbum(draft.toCreateRequest());
|
||||
albumId = album.id;
|
||||
} else {
|
||||
final patch = draft.toUpdatePatch(widget.existing!);
|
||||
if (patch == null) {
|
||||
if (patch == null && _coverFilePath == null) {
|
||||
navigator.pop(false);
|
||||
return;
|
||||
}
|
||||
await ref
|
||||
.read(apiClientProvider)
|
||||
.updateAlbum(widget.existing!.id, patch);
|
||||
if (patch != null) {
|
||||
await ref
|
||||
.read(apiClientProvider)
|
||||
.updateAlbum(widget.existing!.id, patch);
|
||||
}
|
||||
albumId = widget.existing!.id;
|
||||
}
|
||||
// 封面上传:相册保存成功后执行;失败不阻塞表单结果(封面可后补)。
|
||||
if (_coverFilePath != null) {
|
||||
try {
|
||||
await ref
|
||||
.read(apiClientProvider)
|
||||
.uploadAlbumCoverFromFile(albumId, _coverFilePath!);
|
||||
} on Object {
|
||||
// 封面失败仅提示,不影响相册保存结果。
|
||||
messenger.showSnackBar(
|
||||
SnackBar(content: Text(l10n.coverUploadFailed)),
|
||||
);
|
||||
}
|
||||
}
|
||||
navigator.pop(true);
|
||||
} on AppException catch (error) {
|
||||
@@ -359,7 +391,82 @@ class _AlbumFormPageState extends ConsumerState<AlbumFormPage>
|
||||
);
|
||||
}
|
||||
|
||||
/// Details(= 创建表单):分组字段 + 校验错误汇总。
|
||||
/// 封面选择器:16:9 可点击区域。编辑模式显示现有封面预览。
|
||||
Widget _buildCoverPicker(AppLocalizations l10n) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(l10n.coverPicker,
|
||||
style: Theme.of(context).textTheme.titleMedium),
|
||||
const SizedBox(height: kSpace8),
|
||||
InkWell(
|
||||
onTap: _submitting ? null : _pickCoverImage,
|
||||
borderRadius: BorderRadius.circular(kRadiusInput),
|
||||
child: Container(
|
||||
height: 180,
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(kRadiusInput),
|
||||
border: Border.all(
|
||||
color: Theme.of(context).colorScheme.outlineVariant,
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(kRadiusInput),
|
||||
child: _coverFilePath != null
|
||||
? Image.file(
|
||||
File(_coverFilePath!),
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, _, _) => _coverPlaceholder(l10n),
|
||||
)
|
||||
: _existingCoverUrl != null
|
||||
? Image.network(
|
||||
_existingCoverUrl!,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, _, _) => _coverPlaceholder(l10n),
|
||||
)
|
||||
: _coverPlaceholder(l10n),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: kSpace4),
|
||||
Text(
|
||||
_coverFilePath != null
|
||||
? l10n.coverSelected
|
||||
: l10n.coverPickerHint,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _coverPlaceholder(AppLocalizations l10n) => Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.add_photo_alternate_outlined,
|
||||
size: 48, color: Theme.of(context).colorScheme.primary),
|
||||
const SizedBox(height: kSpace8),
|
||||
Text(
|
||||
l10n.coverPickerHint,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
Future<void> _pickCoverImage() async {
|
||||
final path = await ref.read(singleImagePickerProvider)();
|
||||
if (path != null) {
|
||||
setState(() => _coverFilePath = path);
|
||||
}
|
||||
}
|
||||
|
||||
/// Details(= 创建表单):封面选择 + 分组字段 + 校验错误汇总。
|
||||
Widget _detailsForm(AppLocalizations l10n) {
|
||||
return Form(
|
||||
child: SingleChildScrollView(
|
||||
@@ -368,6 +475,10 @@ class _AlbumFormPageState extends ConsumerState<AlbumFormPage>
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// ── 封面选择(16:9 可点击区域) ─────────────────
|
||||
_buildCoverPicker(l10n),
|
||||
const SizedBox(height: kSpace24),
|
||||
|
||||
// ── 分组 1:Event details ──────────────────────────
|
||||
SectionHeader(title: l10n.formEventDetails),
|
||||
const SizedBox(height: kSpace12),
|
||||
|
||||
@@ -184,9 +184,14 @@ onBeforeUnmount(() => {
|
||||
<header class="album-info">
|
||||
<div class="title-row">
|
||||
<h1 ref="titleEl" class="title" :class="{ clamped: !titleExpanded }">{{ album.translation.title }}</h1>
|
||||
<!-- 参考稿:44px 主色圆钮 + 白 ↗ 图标(文案由 aria-label 承载) -->
|
||||
<!-- 44px 主色圆钮 + 白色 SVG 分享图标(aria-label 承载文案) -->
|
||||
<button type="button" class="share-btn" :aria-label="t('viewer.shareAlbum')" @click="share">
|
||||
<span class="share-icon" aria-hidden="true">↗</span>
|
||||
<svg class="share-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<circle cx="18" cy="5" r="3.2"/>
|
||||
<circle cx="6" cy="12" r="3.2"/>
|
||||
<circle cx="18" cy="19" r="3.2"/>
|
||||
<path d="m8.6 13.5 6.8 4M15.4 6.5l-6.8 4"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<button v-if="titleClamped" type="button" class="text-expand" @click="titleExpanded = !titleExpanded">
|
||||
@@ -327,7 +332,7 @@ onBeforeUnmount(() => {
|
||||
-webkit-line-clamp: 2;
|
||||
overflow: hidden;
|
||||
}
|
||||
/* 参考稿:44px 主色圆形分享钮(白 ↗,无边框) */
|
||||
/* 44px 主色圆形分享钮(白色 SVG 分享图标) */
|
||||
.share-btn {
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
@@ -348,10 +353,9 @@ onBeforeUnmount(() => {
|
||||
filter: brightness(0.94);
|
||||
}
|
||||
.share-icon {
|
||||
font-size: 1.25rem;
|
||||
line-height: 1;
|
||||
/* 视觉基线微调:↗ 略下沉 */
|
||||
transform: translateY(-1px);
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
}
|
||||
.share-btn:focus-visible {
|
||||
outline: 2px solid var(--viewer-primary, #1a73e8);
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
// apps/viewer/src/embed/reactions.ts —— E05 点赞气泡纯函数集(UI-04 §5.2)。
|
||||
// 事件校验、视觉抽样(delta → 心形颗数)、burstId 去重表(TTL+容量)、不可见
|
||||
// 照片事件缓存(5s/50 条)全部收敛为纯类/函数(vitest 直接覆盖);WS 编排在
|
||||
// useEmbedReactions.ts。
|
||||
|
||||
/** 视觉抽样(§5.2):delta 1 → 1 颗;2–5 → 2 颗;更大 → 3 颗(受全局限量)。 */
|
||||
export function heartsForDelta(delta: number): number {
|
||||
if (!Number.isFinite(delta) || delta < 1) return 0;
|
||||
if (delta === 1) return 1;
|
||||
if (delta <= 5) return 2;
|
||||
return 3;
|
||||
}
|
||||
|
||||
/** burstId 去重表(§5.2「短期去重表有 TTL 和容量上限」)。 */
|
||||
export class BurstDeduper {
|
||||
private readonly seen = new Map<string, number>();
|
||||
constructor(
|
||||
private readonly ttlMs: number = 30_000,
|
||||
private readonly capacity: number = 200,
|
||||
) {}
|
||||
/** 首见返回 true(记录);重复或超容量驱逐后重见也返回 true。 */
|
||||
accept(burstId: string, now: number): boolean {
|
||||
const prev = this.seen.get(burstId);
|
||||
if (prev !== undefined && now - prev < this.ttlMs) return false;
|
||||
this.evict(now);
|
||||
this.seen.set(burstId, now);
|
||||
return true;
|
||||
}
|
||||
private evict(now: number): void {
|
||||
if (this.seen.size < this.capacity) return;
|
||||
for (const [id, ts] of this.seen) {
|
||||
if (now - ts >= this.ttlMs) this.seen.delete(id);
|
||||
}
|
||||
while (this.seen.size >= this.capacity) {
|
||||
const oldest = this.seen.keys().next().value;
|
||||
if (oldest === undefined) break;
|
||||
this.seen.delete(oldest);
|
||||
}
|
||||
}
|
||||
clear(): void {
|
||||
this.seen.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/** 不可见照片事件缓存(§5.2:最多 5 秒/50 条,超时丢弃,不为旧事件跳转)。 */
|
||||
export class PendingVisibleCache {
|
||||
private readonly pending = new Map<string, { hearts: number; at: number }>();
|
||||
constructor(
|
||||
private readonly ttlMs: number = 5_000,
|
||||
private readonly capacity: number = 50,
|
||||
) {}
|
||||
/** 入缓存(同照片颗数累加,封顶 3);容量满时丢弃最旧。 */
|
||||
add(photoId: string, hearts: number, now: number): void {
|
||||
const prev = this.pending.get(photoId);
|
||||
const merged = Math.min((prev?.hearts ?? 0) + hearts, 3);
|
||||
this.pending.set(photoId, { hearts: merged, at: now });
|
||||
if (this.pending.size > this.capacity) {
|
||||
const oldest = this.pending.keys().next().value;
|
||||
if (oldest !== undefined) this.pending.delete(oldest);
|
||||
}
|
||||
}
|
||||
/** 取出并移除(照片转为可见时展示);超时返回 0(丢弃)。 */
|
||||
take(photoId: string, now: number): number {
|
||||
const entry = this.pending.get(photoId);
|
||||
this.pending.delete(photoId);
|
||||
if (entry === undefined || now - entry.at > this.ttlMs) return 0;
|
||||
return entry.hearts;
|
||||
}
|
||||
/** 只读 id 快照(巡检遍历用,不改动缓存)。 */
|
||||
snapshotIds(): string[] {
|
||||
return [...this.pending.keys()];
|
||||
}
|
||||
clear(): void {
|
||||
this.pending.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发射节流(§5.2「最大每 250ms 一颗,事件可合并」):冷却期内到达的颗数并入
|
||||
* 待发队列(同照片累加,封顶 3),冷却结束整批放行。
|
||||
*/
|
||||
export class EmissionThrottle {
|
||||
private queue = new Map<string, number>();
|
||||
private lastEmit = -Infinity;
|
||||
constructor(private readonly intervalMs = 250) {}
|
||||
/** 尝试入队并判定本时刻是否放行;放行时返回合并后的 {photoId, hearts} 表。 */
|
||||
offer(photoId: string, hearts: number, now: number): Map<string, number> | null {
|
||||
this.queue.set(photoId, Math.min((this.queue.get(photoId) ?? 0) + hearts, 3));
|
||||
if (now - this.lastEmit < this.intervalMs) return null;
|
||||
this.lastEmit = now;
|
||||
const batch = this.queue;
|
||||
this.queue = new Map();
|
||||
return batch;
|
||||
}
|
||||
clear(): void {
|
||||
this.queue.clear();
|
||||
this.lastEmit = -Infinity;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
// apps/viewer/src/embed/useEmbedReactions.ts —— E05 嵌入组件轻量实时订阅
|
||||
// 编排(UI-04 §5.2/§5.3)。复用底层 RealtimeClient(./realtime/ws.ts),
|
||||
// 不经 useAlbumRealtime(避免带出观众页的浏览上报/点赞提交副作用——嵌入
|
||||
// 组件只读:不提交点赞、不创建 own batch,A15)。
|
||||
//
|
||||
// 生命周期规则(§5.3):
|
||||
// · 相册解析且组件活跃才建连;一个 iframe 最多一个连接;
|
||||
// · 相册换绑(albumToken 变化)→ 先停旧连接、清气泡与去重表、清内容;
|
||||
// · 收起/隐藏超过宽限期(10s)断开省连接,恢复校准后重连(不每一帧
|
||||
// 可见性变化都重连——宽限期合并抖动);
|
||||
// · 卸载清理 socket/timer。
|
||||
// 事件校验(§5.2):photoId 须在当前队列、delta ≥1、burstId 去重(TTL 表);
|
||||
// 相册正确性由 WS 房间隔离保证(服务端 subscribe 按相册房间广播,A10——
|
||||
// 帧 albumId 为内部 ID,客户端仅以订阅 token 隐式关联,不做逐帧比对)。
|
||||
// WS 失败/断线:不伪造气泡(EMB-A10)——照片流照常(取数轮询独立)。
|
||||
import { onBeforeUnmount, watch, type Ref } from 'vue';
|
||||
import type { ServerMessage } from '@photo/contracts/realtime';
|
||||
import { RealtimeClient, wsUrlFromApiBase } from '../realtime/ws';
|
||||
import { apiBase } from '../api/client';
|
||||
import { BurstDeduper, EmissionThrottle, heartsForDelta, PendingVisibleCache } from './reactions';
|
||||
|
||||
/** 收起/隐藏后断连宽限期(ms):合并可见性抖动,短暂停不停连接。 */
|
||||
const DISCONNECT_GRACE_MS = 10_000;
|
||||
/** pending 非空时的可见性巡检间隔(ms)。 */
|
||||
const PENDING_SWEEP_MS = 500;
|
||||
|
||||
export interface UseEmbedReactionsOptions {
|
||||
/** 当前公开相册 token(未解析/换绑为 null)。 */
|
||||
albumToken: Ref<string | null>;
|
||||
/** reactionsEnabled 且组件非 gone(false 时不建连)。 */
|
||||
enabled: Ref<boolean>;
|
||||
/** feed.active(收起/隐藏/不可见 → false)。 */
|
||||
active: Ref<boolean>;
|
||||
/** 照片可见性判定(stream.visiblePhotoIds 包装)。 */
|
||||
isPhotoVisible: (photoId: string) => boolean;
|
||||
/** 放行一批心形(已合并/节流;hearts 已封顶 3)。 */
|
||||
onEmit: (batch: Map<string, number>) => void;
|
||||
/** 测试注入时钟/套接字工厂。 */
|
||||
now?: () => number;
|
||||
}
|
||||
|
||||
export function useEmbedReactions(options: UseEmbedReactionsOptions) {
|
||||
const { albumToken, enabled, active, isPhotoVisible, onEmit } = options;
|
||||
const now = options.now ?? (() => Date.now());
|
||||
|
||||
const deduper = new BurstDeduper();
|
||||
const pending = new PendingVisibleCache();
|
||||
const throttle = new EmissionThrottle();
|
||||
|
||||
let client: RealtimeClient | null = null;
|
||||
let subscribedToken: string | null = null;
|
||||
let disconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let sweepTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
function stopSweep(): void {
|
||||
if (sweepTimer !== null) {
|
||||
clearInterval(sweepTimer);
|
||||
sweepTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function startSweepIfPending(): void {
|
||||
if (sweepTimer === null) {
|
||||
sweepTimer = setInterval(() => sweepPending(), PENDING_SWEEP_MS);
|
||||
}
|
||||
}
|
||||
|
||||
/** 巡检:缓存中的照片转为可见 → 立即出泡;顺带清 TTL。 */
|
||||
function sweepPending(): void {
|
||||
const visible = isPhotoVisible;
|
||||
let any = false;
|
||||
// take 内部清理过期项;可见者并入节流队列。
|
||||
for (const photoId of [...cachedIds()]) {
|
||||
const hearts = pendingTake(photoId);
|
||||
if (hearts > 0 && visible(photoId)) {
|
||||
const batch = throttle.offer(photoId, hearts, now());
|
||||
if (batch !== null) onEmit(batch);
|
||||
} else if (hearts > 0) {
|
||||
// 仍不可见但未超时:放回(take 已删——重新入表沿用原时刻语义可接受,
|
||||
// 概率极低且粒度无感知;此处直接丢弃亦可,选择保守放回)。
|
||||
pending.add(photoId, hearts, now());
|
||||
any = true;
|
||||
}
|
||||
}
|
||||
if (!any) stopSweep();
|
||||
}
|
||||
|
||||
// pending 缓存的只读 id 快照与 take 代理(隔离内部类型)。
|
||||
const cachedIds = (): string[] => pending.snapshotIds();
|
||||
const pendingTake = (photoId: string): number => pending.take(photoId, now());
|
||||
|
||||
function handleBurst(message: ServerMessage): void {
|
||||
if (message.type !== 'likes.burst') return;
|
||||
if (!Number.isFinite(message.delta) || message.delta < 1) return;
|
||||
if (!deduper.accept(message.burstId, now())) return;
|
||||
const hearts = heartsForDelta(message.delta);
|
||||
if (hearts <= 0) return;
|
||||
if (isPhotoVisible(message.photoId)) {
|
||||
const batch = throttle.offer(message.photoId, hearts, now());
|
||||
if (batch !== null) onEmit(batch);
|
||||
} else {
|
||||
pending.add(message.photoId, hearts, now());
|
||||
startSweepIfPending();
|
||||
}
|
||||
}
|
||||
|
||||
function stopClient(): void {
|
||||
client?.stop();
|
||||
client = null;
|
||||
subscribedToken = null;
|
||||
}
|
||||
|
||||
function clearAll(): void {
|
||||
deduper.clear();
|
||||
pending.clear();
|
||||
throttle.clear();
|
||||
stopSweep();
|
||||
}
|
||||
|
||||
function teardown(): void {
|
||||
stopClient();
|
||||
clearAll();
|
||||
if (disconnectTimer !== null) {
|
||||
clearTimeout(disconnectTimer);
|
||||
disconnectTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 活跃度变化:恢复 → 立即校准重连;失活 → 宽限期后断开(§5.3)。 */
|
||||
function onActiveChange(value: boolean): void {
|
||||
if (disconnectTimer !== null) {
|
||||
clearTimeout(disconnectTimer);
|
||||
disconnectTimer = null;
|
||||
}
|
||||
if (value) {
|
||||
if (client === null && subscribedToken !== albumToken.value) startClient();
|
||||
} else if (client !== null) {
|
||||
disconnectTimer = setTimeout(() => {
|
||||
disconnectTimer = null;
|
||||
stopClient();
|
||||
}, DISCONNECT_GRACE_MS);
|
||||
}
|
||||
}
|
||||
|
||||
function startClient(): void {
|
||||
const token = albumToken.value;
|
||||
if (token === null || !enabled.value || !active.value) return;
|
||||
stopClient();
|
||||
clearAll(); // 换绑:清旧内容与去重(§5.3)
|
||||
client = new RealtimeClient({
|
||||
url: () => wsUrlFromApiBase(apiBase()),
|
||||
albumToken: () => albumToken.value ?? '',
|
||||
onMessage: (message) => handleBurst(message),
|
||||
onStateChange: () => undefined,
|
||||
onResync: () => undefined, // 只读计数流无需补播(§4.6 观众页语义不适用)
|
||||
});
|
||||
client.start();
|
||||
subscribedToken = token;
|
||||
}
|
||||
|
||||
// 换绑/开关/活跃度联动。
|
||||
watch(
|
||||
[albumToken, enabled],
|
||||
([token, on]) => {
|
||||
if (token !== null && on) startClient();
|
||||
else teardown();
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
watch(active, onActiveChange);
|
||||
|
||||
onBeforeUnmount(teardown);
|
||||
|
||||
return {
|
||||
/** 测试接口:直接注入一帧(绕过 WS;模拟管理预览/单测)。 */
|
||||
ingestForTest(message: ServerMessage): void {
|
||||
handleBurst(message);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -216,6 +216,27 @@ export function useVerticalPhotoStream(options: UseVerticalPhotoStreamOptions) {
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 照片在视口内的交集矩形(E05 气泡起点):top/height 为视口坐标;不可见
|
||||
* (无交集)返回 null。同一照片多副本时取首个有交集实例。
|
||||
*/
|
||||
function visibleRectOf(photoId: string): { top: number; height: number } | null {
|
||||
const s = state.value;
|
||||
if (s === null) return null;
|
||||
let top = -s.offset;
|
||||
for (const item of s.items) {
|
||||
const h = photoDisplayHeight(s.sizes[item.queueIdx], innerWidth);
|
||||
const visibleTop = Math.max(0, top);
|
||||
const visibleBottom = Math.min(viewportHeight, top + h);
|
||||
if (item.photoId === photoId && visibleBottom > visibleTop) {
|
||||
return { top: visibleTop, height: visibleBottom - visibleTop };
|
||||
}
|
||||
top += h + PHOTO_GAP_PX;
|
||||
if (top >= viewportHeight) break;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
watch(photos, (next) => reanchor(next), { immediate: false });
|
||||
watch(running, (value) => {
|
||||
if (value) startLoop();
|
||||
@@ -250,6 +271,7 @@ export function useVerticalPhotoStream(options: UseVerticalPhotoStreamOptions) {
|
||||
dropPhoto,
|
||||
nudgeOffset,
|
||||
visiblePhotoIds,
|
||||
visibleRectOf,
|
||||
/** 测试接口(§11.4 受控时间):绕过 rAF 按给定 dt 推进一帧并同步渲染。
|
||||
* 仅单测使用;生产路径不经过本入口。单张/零张静态(不推进,§3.2.8/9)。 */
|
||||
stepForTest(dtMs: number): void {
|
||||
|
||||
Reference in New Issue
Block a user