1b94df1604
- #7 隔夜被迫重新登录:auth.ts 加 refreshInFlight 单飞刷新。根因=broker 单次轮换 refresh token + 冷启并发 401(引擎 boot 同时 startHub/refreshSessionScope/healIOSIdentity/refreshICEServers,隔夜 access 已过期全部 401)各自用同一 refresh 刷新,首个轮换、其余 replay 失效旧 token → 401 invalid → forceLogout → 清 Keychain → 重登。单飞使并发 401 共享一次轮换。 - #2 通知系统重做:删 EngineController.deviceActionStatus/clipboardStatus 两字段及设备页/设置页内联灰字(串页、不清除);改瞬时横幅 NotificationBanner(NoticeOverlay 顶部覆盖,成功绿/失败红/信息中性、4s 自动消失、可点叉清除),设备移除/改名/剪贴板结果统一走 notify。 - #5 未读红点:EngineController unreadMessages/unreadTransfers/activeTab/seenIncomingTransfers + setActiveTab(进入对应 tab 清零)+ noteIncomingTransfer;RootView Tab .badge + onChange(selection) 同步当前页。 - #1 标签栏重排:传输 / 文件 / 消息 / 设备 / 设置(文件域相邻、设备归管理区近设置)。 - #3 日志独立界面:EngineController logs 环形缓冲(cap 300)+ clearLogs;新增 LogView(最近 N 条、分级配色、可选中、可清空),设置页引擎段「查看日志」NavigationLink。 - #8 扫码登录他端:iOS 此前仅能展示自身码被扫,缺扫描方批准能力。新增 ScannerView(VisionKit DataScannerViewController + 相机权限 + /link?r=&c= 校验);engine/main.ts 导入 qr.ts 加 fetchLoginRequest/approveLogin/denyLogin(qrRequest/qrApprove(full,persist)/qrDeny)+ 回报 loginRequest/loginApproved/loginDenied/loginFailed;EngineController pendingLoginRequest + 方法 + handleNotify;设备页工具栏「扫码登录设备」入口 + 批准确认(展示对端名/类型/IP)。 - #6 主屏小组件 + 应用外反馈:除控制中心两控件外,新增 CloudClipboardWidget(交互式主屏/锁屏小组件,上传/拉取按钮复用同一 AppIntent);新增 WidgetFeedback(App 外触发弹本地通知反馈);ClipboardIntents perform 加成功/失败/空/needApp 反馈。品牌色 Color.cdropAccent 从 RootView 移入 Shared/Theme(主 app 与扩展共用)。 - 长条行交互统一:设备 / 传输历史 / 收到的文件 一致为「点按打开 + 尾部滑动删除 + 长按菜单」。设备去掉「点按即删」、加长按移除;修滑动移除时「析构按钮预演删除动画致下方设备上移又复位」的抖动(改普通按钮 + 红色,真正删除在二次确认后);传输页 ScrollView→List 以获原生滑动删除,卡片观感经 .plain + 透明行背景 + 隐藏分隔线保留。 - i18n:3 locale 新增 ios.logs./ios.scan./ios.widget./ios.settings.viewLogs 等键并重生 iOS JSON。
81 lines
2.5 KiB
Swift
81 lines
2.5 KiB
Swift
import SwiftUI
|
||
|
||
// 瞬时通知横幅(#2):覆盖在标签内容顶部,醒目、状态分明(成功绿 / 失败红 / 信息中性)、自动消失
|
||
// (EngineController.notify 安排 4s 后移除)、可点叉手动清除。取代原先散落在设备页 / 设置页、串页显示
|
||
// 且永不清除的灰色小字(deviceActionStatus / clipboardStatus)。由 EngineController.notices 驱动。
|
||
struct NoticeOverlay: View
|
||
{
|
||
@Environment(EngineController.self) private var engine
|
||
|
||
var body: some View
|
||
{
|
||
VStack(spacing: 8)
|
||
{
|
||
ForEach(engine.notices)
|
||
{ notice in
|
||
NoticeBanner(notice: notice) { engine.dismissNotice(notice.id) }
|
||
.transition(.move(edge: .top).combined(with: .opacity))
|
||
}
|
||
}
|
||
.padding(.horizontal, 16)
|
||
.padding(.top, 8)
|
||
.animation(.spring(duration: 0.3), value: engine.notices)
|
||
// 无通知时不拦截下方内容的点击;有通知时仅横幅自身(含叉按钮)可点。
|
||
.allowsHitTesting(!engine.notices.isEmpty)
|
||
}
|
||
}
|
||
|
||
private struct NoticeBanner: View
|
||
{
|
||
let notice: Notice
|
||
let onDismiss: () -> Void
|
||
|
||
var body: some View
|
||
{
|
||
HStack(spacing: 10)
|
||
{
|
||
Image(systemName: symbol)
|
||
.font(.headline)
|
||
.foregroundStyle(tint)
|
||
Text(notice.text)
|
||
.font(.subheadline)
|
||
.foregroundStyle(.primary)
|
||
.lineLimit(2)
|
||
Spacer(minLength: 8)
|
||
Button { onDismiss() }
|
||
label:
|
||
{
|
||
Image(systemName: "xmark")
|
||
.font(.caption.weight(.bold))
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
.buttonStyle(.plain)
|
||
}
|
||
.padding(.vertical, 12)
|
||
.padding(.horizontal, 14)
|
||
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 14))
|
||
.overlay(RoundedRectangle(cornerRadius: 14).strokeBorder(tint.opacity(0.35), lineWidth: 1))
|
||
.shadow(color: .black.opacity(0.12), radius: 8, y: 2)
|
||
}
|
||
|
||
private var symbol: String
|
||
{
|
||
switch notice.kind
|
||
{
|
||
case .success: return "checkmark.circle.fill"
|
||
case .error: return "exclamationmark.triangle.fill"
|
||
case .info: return "info.circle.fill"
|
||
}
|
||
}
|
||
|
||
private var tint: Color
|
||
{
|
||
switch notice.kind
|
||
{
|
||
case .success: return .green
|
||
case .error: return .red
|
||
case .info: return .cdropAccent
|
||
}
|
||
}
|
||
}
|