Files
OpenStats/Packages/OpenStatsKit/Sources/OpenStatsUI/Settings/AccountSettings.swift
T
gentpan b8db7eacae 归属地只用 cleanip.io:移除 MaxMind GeoLite2 与其他在线数据源
- 删掉 MaxMind .mmdb 读取器、本地数据库控制器与「IP 归属地数据库」设置区块,去掉「归属地数据源」选择及 ipapi.is / DB-IP / ipinfo.io 解析;PublicAddressLookup 只向 cleanip.io 查询,结果按 IP 记一小时,收到 429 当天不再请求
- 设置同步文档不再带 geoSource / geoIncludeCity / geoAutoUpdate;公网 IP 缓存键升到第 4 版,旧数据源的缓存不再读取
- 网络详情:IP 地址区块标题不再放 cleanip.io 字标,只留在纯净度区块;纯净度色带改为直角
- 删除 server/geoip 的 GeoLite2 同步脚本;README、架构说明、第三方声明、官网与隐私页、译文同步,清掉 53 条不再使用的翻译
2026-09-15 04:24:42 +05:00

237 lines
10 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import AccountSync
import AppKit
import Localization
import SwiftUI
/// 设置 · 账号与同步:登录、同步状态、首次登录的二选一、退出与删除
struct AccountSettings: View {
@Environment(AppModel.self) private var model
@State private var confirmingDelete = false
var body: some View {
let sync = model.sync
Group {
if let user = sync.user {
signedIn(user: user, sync: sync)
} else {
signedOut(sync: sync)
}
}
.onAppear { sync.pageOpened() }
}
// MARK: 未登录
@ViewBuilder
private func signedOut(sync: SyncController) -> some View {
SettingsGroup(caption: tr("账号")) {
GroupRow(showsDivider: false) {
VStack(alignment: .leading, spacing: DS.Space.s3) {
SettingRow(title: tr("登录以同步设置"),
subtitle: tr("换一台 Mac 登录后,菜单栏、外观、通知与快捷键等偏好会自动恢复。不登录也能使用全部功能。")) {
EmptyView()
}
HStack(spacing: DS.Space.s2) {
ForEach(sync.providers) { provider in
Button {
sync.signIn(with: provider)
} label: {
HStack(spacing: DS.Space.s2) {
ProviderLogo(provider: provider)
Text(tr("使用 \(provider.title) 登录"))
}
}
.buttonStyle(DSButtonStyle(kind: .secondary))
.disabled(sync.phase == .signingIn)
}
if sync.phase == .signingIn {
ProgressView().controlSize(.small)
Button(tr("取消")) { sync.cancelSignIn() }.buttonStyle(DSButtonStyle(kind: .ghost))
}
}
if case .failed(let message) = sync.phase {
InfoBanner(icon: "exclamationmark.triangle", text: message, tone: .error)
}
}
}
}
privacyGroup
}
// MARK: 已登录
@ViewBuilder
private func signedIn(user: SyncUser, sync: SyncController) -> some View {
SettingsGroup(caption: tr("账号")) {
GroupRow(showsDivider: false) {
HStack(spacing: DS.Space.s3) {
Avatar(url: user.avatar)
VStack(alignment: .leading, spacing: DS.Space.s1 / 2) {
Text(user.displayName).dsFont(.sm, weight: .medium).foregroundStyle(DS.Palette.textPrimary)
Text(accountSubtitle(user)).dsFont(.xs).foregroundStyle(DS.Palette.textSecondary)
}
Spacer(minLength: DS.Space.s4)
Button(tr("退出登录")) { sync.signOut() }
.buttonStyle(DSButtonStyle(kind: .secondary))
.disabled(sync.phase == .syncing)
}
}
}
SettingsGroup(caption: tr("同步")) {
GroupRow(showsDivider: false) {
SettingRow(title: statusTitle(sync), subtitle: statusSubtitle(sync)) {
Button(sync.phase == .syncing ? tr("正在同步…") : tr("立即同步")) { sync.syncNow() }
.buttonStyle(DSButtonStyle(kind: .secondary))
.disabled(sync.phase == .syncing || sync.conflict != nil)
}
}
if let conflict = sync.conflict {
GroupRow {
VStack(alignment: .leading, spacing: DS.Space.s3) {
InfoBanner(icon: "arrow.triangle.2.circlepath",
text: tr("云端已有 \(conflict.remoteDevice ?? tr("另一台 Mac")) 在 \(Self.relative(conflict.remoteUpdatedAt)) 保存的设置,和这台 Mac 上的不一样。要用哪一份?"),
tone: .warning)
HStack(spacing: DS.Space.s2) {
Spacer()
Button(tr("上传本机设置")) { sync.resolveConflict(useRemote: false) }
.buttonStyle(DSButtonStyle(kind: .secondary))
Button(tr("使用云端设置")) { sync.resolveConflict(useRemote: true) }
.buttonStyle(DSButtonStyle(kind: .primary))
}
}
}
}
if case .failed(let message) = sync.phase {
GroupRow { InfoBanner(icon: "exclamationmark.triangle", text: message, tone: .error) }
}
GroupRow {
Text(tr("同步的内容:菜单栏项目与风格、刷新频率、外观与语言、详情弹窗的区块、连接探测、通知、快捷键、风扇安全温度、合盖电量下限。当前页面、辅助工具与历史记录留在本机。"))
.dsFont(.xs)
.foregroundStyle(DS.Palette.textSecondary)
.fixedSize(horizontal: false, vertical: true)
}
}
SettingsGroup(caption: tr("数据")) {
GroupRow(showsDivider: false) {
SettingRow(title: tr("删除云端数据"), subtitle: tr("删除账号、所有登录方式与云端保存的设置;这台 Mac 上的设置保留")) {
Button(tr("删除…")) { confirmingDelete = true }
.buttonStyle(DSButtonStyle(kind: .secondary))
.disabled(sync.phase == .syncing)
.confirmationDialog(tr("删除云端数据?"), isPresented: $confirmingDelete, titleVisibility: .visible) {
Button(tr("删除"), role: .destructive) { sync.deleteAccount() }
Button(tr("取消"), role: .cancel) {}
} message: {
Text(tr("账号与云端设置会立即删除,无法恢复。"))
}
}
}
}
privacyGroup
}
private var privacyGroup: some View {
SettingsGroup(caption: tr("隐私")) {
GroupRow(showsDivider: false) {
SettingRow(title: tr("云端只保存邮箱、姓名与设置文档"),
subtitle: tr("服务器在 getopenstats.com,不上传任何监控数据;随时可以删除。")) {
Button(tr("隐私政策")) {
if let url = URL(string: "https://getopenstats.com/privacy.html") { NSWorkspace.shared.open(url) }
}
.buttonStyle(DSButtonStyle(kind: .ghost))
}
}
}
}
// MARK: 文案
private func accountSubtitle(_ user: SyncUser) -> String {
let providers = user.providers.compactMap(SyncProvider.init(rawValue:)).map(\.title).joined(separator: " / ")
let via = providers.isEmpty ? "" : tr("通过 \(providers) 登录")
guard let email = user.email, !email.isEmpty, email != user.displayName else { return via }
return via.isEmpty ? email : "\(email) · \(via)"
}
private func statusTitle(_ sync: SyncController) -> String {
switch sync.phase {
case .syncing: tr("正在同步")
case .failed: tr("同步失败")
default: sync.conflict != nil ? tr("等待选择") : tr("已开启同步")
}
}
private func statusSubtitle(_ sync: SyncController) -> String {
guard let date = sync.lastSyncedAt else { return tr("设置改动后 2 秒内上传;启动、唤醒与每 15 分钟检查一次云端") }
return tr("上次同步:\(Self.relative(date))")
}
private static func relative(_ date: Date) -> String {
date.formatted(.relative(presentation: .named).locale(L10n.locale))
}
}
/// 头像:圆形,取不到时用系统人像图标
private struct Avatar: View {
let url: URL?
var body: some View {
AsyncImage(url: url) { phase in
if let image = phase.image {
image.resizable().scaledToFill()
} else {
Image(systemName: "person.crop.circle")
.font(.system(size: DS.Space.s6))
.foregroundStyle(DS.Palette.textTertiary)
}
}
.frame(width: DS.Space.s8, height: DS.Space.s8)
.clipShape(Circle())
}
}
/// 登录按钮上的品牌标志,16 pt 行内尺寸。Google 用四色 G;Apple 与 GitHub 的标志本身只有单色版本,
/// 按品牌规范用文字色绘制,浅色模式下为黑、深色模式下为白
struct ProviderLogo: View {
let provider: SyncProvider
var size: CGFloat = DS.Size.iconInline
var body: some View {
Group {
switch provider {
case .apple:
Image(systemName: "apple.logo")
.font(.system(size: size - DS.Space.s1 / 2, weight: .medium))
.foregroundStyle(DS.Palette.textPrimary)
case .github:
if let image = LogoCache.shared.image(named: "github", template: true) {
Image(nsImage: image).resizable().scaledToFit().foregroundStyle(DS.Palette.textPrimary)
}
case .google:
if let image = LogoCache.shared.image(named: "google", template: false) {
Image(nsImage: image).resizable().scaledToFit()
}
}
}
.frame(width: size, height: size)
.accessibilityHidden(true)
}
}
/// 包内 Resources/Logos 下的 SVG:GitHub 标志来自 Octicons(MIT),Google 标志为其品牌资源
@MainActor
final class LogoCache {
static let shared = LogoCache()
private var images: [String: NSImage] = [:]
func image(named name: String, template: Bool) -> NSImage? {
if let image = images[name] { return image }
guard let url = Bundle.module.url(forResource: name, withExtension: "svg", subdirectory: "Logos"),
let image = NSImage(contentsOf: url) else { return nil }
image.isTemplate = template
images[name] = image
return image
}
}