iOS 第四批反馈:隔夜重登修复 + 通知/红点/日志/扫码登他端/主屏小组件 + 长条行交互统一
- #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。
This commit is contained in:
@@ -20,6 +20,8 @@ struct RootView: View
|
||||
{
|
||||
TabView(selection: $selection)
|
||||
{
|
||||
// 顺序按领域与频次分组(#1):文件域(传输 → 收到的文件)相邻,其次消息,最后管理区
|
||||
// (设备 → 设置)。传输 / 消息带未读红点(#5),对应页激活即清零。
|
||||
Tab(t("ios.tab.transfer"), systemImage: "arrow.up.arrow.down", value: "transfer")
|
||||
{
|
||||
NavigationStack
|
||||
@@ -27,11 +29,12 @@ struct RootView: View
|
||||
TransferListView()
|
||||
}
|
||||
}
|
||||
Tab(t("ios.tab.devices"), systemImage: "laptopcomputer.and.iphone", value: "devices")
|
||||
.badge(engine.unreadTransfers)
|
||||
Tab(t("ios.tab.files"), systemImage: "folder", value: "files")
|
||||
{
|
||||
NavigationStack
|
||||
{
|
||||
DeviceListView()
|
||||
ReceivedFilesView()
|
||||
}
|
||||
}
|
||||
Tab(t("ios.tab.messages"), systemImage: "message", value: "messages")
|
||||
@@ -41,11 +44,12 @@ struct RootView: View
|
||||
MessagesView()
|
||||
}
|
||||
}
|
||||
Tab(t("ios.tab.files"), systemImage: "folder", value: "files")
|
||||
.badge(engine.unreadMessages)
|
||||
Tab(t("ios.tab.devices"), systemImage: "laptopcomputer.and.iphone", value: "devices")
|
||||
{
|
||||
NavigationStack
|
||||
{
|
||||
ReceivedFilesView()
|
||||
DeviceListView()
|
||||
}
|
||||
}
|
||||
Tab(t("ios.tab.settings"), systemImage: "gearshape", value: "settings")
|
||||
@@ -58,6 +62,13 @@ struct RootView: View
|
||||
}
|
||||
.tabBarMinimizeBehavior(.onScrollDown)
|
||||
.tint(.cdropAccent)
|
||||
// 瞬时通知横幅覆盖在内容顶部(#2)。
|
||||
.overlay(alignment: .top)
|
||||
{
|
||||
NoticeOverlay()
|
||||
}
|
||||
// 标签切换时同步当前页给引擎,驱动未读清零(#5)。
|
||||
.onChange(of: selection) { engine.setActiveTab(selection) }
|
||||
.background
|
||||
{
|
||||
EngineWebView(controller: engine, auth: auth)
|
||||
@@ -95,6 +106,9 @@ struct TransferListView: View
|
||||
@State private var showDevicePicker = false
|
||||
@State private var showNoDevices = false
|
||||
@State private var showClearConfirm = false
|
||||
// 点按传输卡片进详情:不在 List 内用 NavigationLink(会出现 chevron 改变卡片观感),改经
|
||||
// navigationDestination(item:) 程序化推栈(按 sessionId,String 即 Hashable 标识),卡片保持原样。
|
||||
@State private var detailSession: String?
|
||||
|
||||
// 可发送目标:在线、且非本机(共用 EngineController.sendableDevices)。
|
||||
private var sendableDevices: [DeviceItem]
|
||||
@@ -104,44 +118,19 @@ struct TransferListView: View
|
||||
|
||||
var body: some View
|
||||
{
|
||||
ScrollView
|
||||
Group
|
||||
{
|
||||
if engine.transfers.isEmpty && engine.history.isEmpty
|
||||
{
|
||||
ContentUnavailableView(t("ios.transfer.empty"), systemImage: "tray")
|
||||
.padding(.top, 80)
|
||||
}
|
||||
else
|
||||
{
|
||||
VStack(spacing: 16)
|
||||
{
|
||||
if !engine.transfers.isEmpty
|
||||
{
|
||||
sectionHeader(t("home.transfer.active"))
|
||||
ForEach(engine.transfers)
|
||||
{ item in
|
||||
transferLink(item)
|
||||
}
|
||||
}
|
||||
if !engine.history.isEmpty
|
||||
{
|
||||
sectionHeader(t("home.transfer.history"))
|
||||
ForEach(engine.history)
|
||||
{ item in
|
||||
transferLink(item)
|
||||
.contextMenu
|
||||
{
|
||||
Button(role: .destructive)
|
||||
{ engine.deleteTransferRecord(item.sessionId) }
|
||||
label: { Label(t("transfer.action.delete"), systemImage: "trash") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
transferList
|
||||
}
|
||||
}
|
||||
.navigationTitle(t("ios.tab.transfer"))
|
||||
.navigationDestination(item: $detailSession) { sid in TransferDetailView(sessionId: sid) }
|
||||
.toolbar
|
||||
{
|
||||
if !engine.history.isEmpty
|
||||
@@ -201,6 +190,32 @@ struct TransferListView: View
|
||||
.alert(t("ios.send.noDevices"), isPresented: $showNoDevices) { }
|
||||
}
|
||||
|
||||
// List(而非 ScrollView):让传输记录与设备 / 文件等长条行操作逻辑一致——支持滑动删除 + 长按菜单。
|
||||
// 卡片观感经 .plain 列表 + 透明行背景 + 隐藏分隔线保留。抽成独立属性,避免 body 表达式过大致类型推断超时。
|
||||
private var transferList: some View
|
||||
{
|
||||
List
|
||||
{
|
||||
if !engine.transfers.isEmpty
|
||||
{
|
||||
Section
|
||||
{
|
||||
ForEach(engine.transfers) { transferRow($0, history: false) }
|
||||
}
|
||||
header: { sectionHeader(t("home.transfer.active")) }
|
||||
}
|
||||
if !engine.history.isEmpty
|
||||
{
|
||||
Section
|
||||
{
|
||||
ForEach(engine.history) { transferRow($0, history: true) }
|
||||
}
|
||||
header: { sectionHeader(t("home.transfer.history")) }
|
||||
}
|
||||
}
|
||||
.listStyle(.plain)
|
||||
}
|
||||
|
||||
private func sectionHeader(_ title: String) -> some View
|
||||
{
|
||||
HStack
|
||||
@@ -212,17 +227,36 @@ struct TransferListView: View
|
||||
}
|
||||
}
|
||||
|
||||
private func transferLink(_ item: TransferItem) -> some View
|
||||
// 传输行:卡片观感不变;点按进详情;历史记录支持滑动删除 + 长按删除(与设备 / 文件长条一致)。
|
||||
// 进行中的传输不挂删除手势(取消 / 切中继在详情页)。删除是真删除(无需二次确认),故析构滑动按钮
|
||||
// 的删除动画与实际一致、不会抖动。
|
||||
@ViewBuilder
|
||||
private func transferRow(_ item: TransferItem, history: Bool) -> some View
|
||||
{
|
||||
NavigationLink
|
||||
let card = TransferCardView(item: item)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture { detailSession = item.sessionId }
|
||||
.listRowInsets(EdgeInsets(top: 6, leading: 16, bottom: 6, trailing: 16))
|
||||
.listRowSeparator(.hidden)
|
||||
.listRowBackground(Color.clear)
|
||||
if history
|
||||
{
|
||||
TransferDetailView(sessionId: item.sessionId)
|
||||
card
|
||||
.swipeActions(edge: .trailing)
|
||||
{
|
||||
Button(role: .destructive) { engine.deleteTransferRecord(item.sessionId) }
|
||||
label: { Label(t("transfer.action.delete"), systemImage: "trash") }
|
||||
}
|
||||
.contextMenu
|
||||
{
|
||||
Button(role: .destructive) { engine.deleteTransferRecord(item.sessionId) }
|
||||
label: { Label(t("transfer.action.delete"), systemImage: "trash") }
|
||||
}
|
||||
}
|
||||
label:
|
||||
else
|
||||
{
|
||||
TransferCardView(item: item)
|
||||
card
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
private func startSend(to device: String)
|
||||
@@ -424,6 +458,7 @@ struct DeviceListView: View
|
||||
@Environment(EngineController.self) private var engine
|
||||
@State private var revokeTarget: DeviceItem?
|
||||
@State private var showRevoke = false
|
||||
@State private var showScanner = false
|
||||
|
||||
// 全部设备:含本机(本机标「本机」、不可移除自己——登出在设置页)。本机排最前。
|
||||
private var allDevices: [DeviceItem]
|
||||
@@ -451,18 +486,24 @@ struct DeviceListView: View
|
||||
{
|
||||
List
|
||||
{
|
||||
if !engine.deviceActionStatus.isEmpty
|
||||
{
|
||||
Text(engine.deviceActionStatus)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
ForEach(allDevices)
|
||||
{ dev in
|
||||
deviceRow(dev)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture { if !isSelf(dev) { revokeTarget = dev; showRevoke = true } }
|
||||
// 长条行统一操作逻辑:滑动 + 长按都能移除(点按不再删除——破坏性操作不应误触)。
|
||||
.swipeActions(edge: .trailing)
|
||||
{
|
||||
if !isSelf(dev)
|
||||
{
|
||||
// 不用 destructive 角色:「析构滑动按钮」会预演删除动画(行收起、下方上移),
|
||||
// 但移除需先弹二次确认、此刻并不真删 → 行随即复位造成抖动。改普通按钮 + 红色
|
||||
// 仅作视觉提示,消除抖动;真正删除发生在确认之后(设备离线即正常移除)。
|
||||
Button { revokeTarget = dev; showRevoke = true }
|
||||
label: { Label(t("ios.devices.remove"), systemImage: "trash") }
|
||||
.tint(.red)
|
||||
}
|
||||
}
|
||||
.contextMenu
|
||||
{
|
||||
if !isSelf(dev)
|
||||
{
|
||||
@@ -475,6 +516,19 @@ struct DeviceListView: View
|
||||
}
|
||||
}
|
||||
.navigationTitle(t("ios.tab.devices"))
|
||||
.toolbar
|
||||
{
|
||||
// 扫码登录他端(#8):扫另一台设备的登录二维码以批准其登入本账号。
|
||||
ToolbarItem(placement: .topBarTrailing)
|
||||
{
|
||||
Button { showScanner = true }
|
||||
label: { Label(t("ios.scan.entry"), systemImage: "qrcode.viewfinder") }
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showScanner)
|
||||
{
|
||||
ScanSheet { requestId, code in engine.fetchLoginRequest(requestId: requestId, code: code) }
|
||||
}
|
||||
.confirmationDialog(revokeTarget?.name ?? "", isPresented: $showRevoke, titleVisibility: .visible)
|
||||
{
|
||||
Button(t("ios.devices.remove"), role: .destructive)
|
||||
@@ -483,6 +537,40 @@ struct DeviceListView: View
|
||||
}
|
||||
Button(t("common.cancel"), role: .cancel) { }
|
||||
}
|
||||
// 扫码后拉到请求信息即弹批准确认(#8):展示对端设备名 / 类型 / 来源 IP,确认后才批准。
|
||||
.confirmationDialog(
|
||||
t("ios.scan.approveTitle"),
|
||||
isPresented: Binding(
|
||||
get: { engine.pendingLoginRequest != nil },
|
||||
set: { if !$0 { engine.cancelLoginRequest() } }),
|
||||
titleVisibility: .visible)
|
||||
{
|
||||
Button(t("ios.scan.approve")) { engine.approveLogin() }
|
||||
Button(t("ios.scan.deny"), role: .destructive) { engine.denyLogin() }
|
||||
Button(t("common.cancel"), role: .cancel) { engine.cancelLoginRequest() }
|
||||
}
|
||||
message:
|
||||
{
|
||||
if let req = engine.pendingLoginRequest
|
||||
{
|
||||
Text(loginRequestInfo(req))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 批准确认页展示的对端信息:设备名 · 类型,附来源 IP(次行)。
|
||||
private func loginRequestInfo(_ req: LoginRequestItem) -> String
|
||||
{
|
||||
var head: [String] = []
|
||||
if !req.deviceName.isEmpty { head.append(req.deviceName) }
|
||||
if !req.deviceType.isEmpty { head.append(req.deviceType) }
|
||||
var s = head.joined(separator: " · ")
|
||||
if !req.requestIp.isEmpty
|
||||
{
|
||||
if !s.isEmpty { s += "\n" }
|
||||
s += "\(t("ios.scan.requestIp")): \(req.requestIp)"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
private func deviceRow(_ dev: DeviceItem) -> some View
|
||||
@@ -537,12 +625,6 @@ struct SettingsView: View
|
||||
TextField(t("ios.settings.deviceName"), text: $deviceNameDraft)
|
||||
.submitLabel(.done)
|
||||
.onSubmit { commitRename() }
|
||||
if !engine.deviceActionStatus.isEmpty
|
||||
{
|
||||
Text(engine.deviceActionStatus)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Button(role: .destructive) { logout() }
|
||||
label:
|
||||
{
|
||||
@@ -566,17 +648,23 @@ struct SettingsView: View
|
||||
LabeledContent(t("ios.settings.signaling"), value: signalingState)
|
||||
LabeledContent(t("ios.settings.presenceEvents"), value: "\(engine.presenceCount)")
|
||||
LabeledContent(t("ios.settings.deviceCount"), value: "\(engine.devices.count)")
|
||||
if !engine.lastEngineLog.isEmpty
|
||||
// 日志独立界面(#3):进入查看最近 N 条引擎日志;副标题预览最新一条。
|
||||
NavigationLink
|
||||
{
|
||||
LogView()
|
||||
}
|
||||
label:
|
||||
{
|
||||
VStack(alignment: .leading, spacing: 2)
|
||||
{
|
||||
Text(t("ios.settings.lastLog"))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(engine.lastEngineLog)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.textSelection(.enabled)
|
||||
Text(t("ios.settings.viewLogs"))
|
||||
if !engine.lastEngineLog.isEmpty
|
||||
{
|
||||
Text(engine.lastEngineLog)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -586,12 +674,6 @@ struct SettingsView: View
|
||||
label: { Label(t("ios.clipboard.upload"), systemImage: "arrow.up.doc.on.clipboard") }
|
||||
Button { engine.pullClipboard() }
|
||||
label: { Label(t("ios.clipboard.pull"), systemImage: "arrow.down.doc") }
|
||||
if !engine.clipboardStatus.isEmpty
|
||||
{
|
||||
Text(engine.clipboardStatus)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
header:
|
||||
{
|
||||
@@ -800,14 +882,4 @@ private func deviceSymbol(_ type: String) -> String
|
||||
return "desktopcomputer"
|
||||
}
|
||||
|
||||
// 品牌主色(强约束③,与 web 主 accent 对齐):浅色 #644AC9 / 深色 #9580FF。后续接 Asset
|
||||
// Catalog Color Set 作单一真源;此处先以动态 UIColor 落地。
|
||||
extension Color
|
||||
{
|
||||
static let cdropAccent = Color(uiColor: UIColor
|
||||
{ trait in
|
||||
trait.userInterfaceStyle == .dark
|
||||
? UIColor(red: 0.584, green: 0.502, blue: 1.0, alpha: 1)
|
||||
: UIColor(red: 0.392, green: 0.290, blue: 0.788, alpha: 1)
|
||||
})
|
||||
}
|
||||
// 品牌主色 Color.cdropAccent 已移至 Shared/Theme.swift(主 app 与控件 / 小组件扩展共用单一真源)。
|
||||
|
||||
Reference in New Issue
Block a user