feat(widget): 完善日历与 IP 信息并新增今日 Token 小组件
This commit is contained in:
1 parent
014e7106d0
commit
f3f64113f8
18 files changed
+458
-60
No files matched your search
@@ -18,6 +18,11 @@ independently, so it remains useful when the app is not running.
|
||||
AI, IP, and calendar widgets instead read a credential-free App Group snapshot. The main app
|
||||
precomputes an eight-day holiday and almanac window; missing coverage is shown as unavailable,
|
||||
not guessed from weekdays.
|
||||
The daily Token widget receives only per-provider totals and the local day, never session logs;
|
||||
its displayed count resets at local midnight even if the main app has not refreshed yet.
|
||||
The tomorrow-work widget uses the official 2026 holiday schedule to distinguish statutory
|
||||
holidays, adjusted days off, ordinary weekends, and makeup workdays; later years need their
|
||||
own confirmed schedule before receiving those specific labels.
|
||||
|
||||
Things that are easy to get wrong and are handled on purpose:
|
||||
|
||||
|
||||
@@ -36,6 +36,11 @@ extension Translations {
|
||||
不上班 Day off
|
||||
暂无安排 No schedule data
|
||||
暂无法判断 Cannot determine yet
|
||||
工作日 Weekday
|
||||
周末 Weekend
|
||||
节假日 Public holiday
|
||||
调休补班 Makeup workday
|
||||
调休放假 Adjusted day off
|
||||
黄道日 Auspicious day
|
||||
黑道日 Inauspicious day
|
||||
查看今天的节日与黄历。 See today's festivals and almanac.
|
||||
|
||||
@@ -2,10 +2,13 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
暂无数据 لا توجد بيانات بعد
|
||||
主应用缓存 ذاكرة XStats المؤقتة
|
||||
今日消耗的 Token الرموز المستهلكة اليوم
|
||||
查看今天 Codex 和 Claude 的 Token 用量 عرض رموز Codex وClaude المستخدمة اليوم
|
||||
分 نقطة
|
||||
查看 Codex 和 Claude 的剩余额度与重置时间 عرض الحصة المتبقية لـ Codex وClaude ووقت إعادة التعيين
|
||||
显示主应用已查询的公网 IP 纯净度 عرض نقاء عنوان IP العام الذي استعلم عنه XStats
|
||||
显示主应用已查询的公网 IPv4 和 IPv6 عرض عنواني IPv4 وIPv6 العامين اللذين استعلم عنهما XStats
|
||||
查看公网 IP、归属地、ASN 和标记 عرض عنوان IP العام والموقع وASN والعلامات
|
||||
查看今天的日期或本月月历。 عرض تاريخ اليوم أو تقويم هذا الشهر.
|
||||
设备不支持 الجهاز غير مدعوم
|
||||
需要 macOS 15.1 或更新版本 يتطلب macOS 15.1 أو أحدث
|
||||
@@ -1402,6 +1405,11 @@ Sub2API 账号平台与所选来源不匹配 منصة حساب Sub2API لا تط
|
||||
不上班 يوم عطلة
|
||||
暂无安排 لا توجد بيانات
|
||||
暂无法判断 لا يمكن التحديد حاليًا
|
||||
工作日 يوم عمل
|
||||
周末 عطلة نهاية الأسبوع
|
||||
节假日 عطلة رسمية
|
||||
调休补班 يوم عمل تعويضي
|
||||
调休放假 إجازة تعويضية
|
||||
黄道日 يوم ميمون
|
||||
黑道日 يوم غير ميمون
|
||||
查看今天的节日与黄历。 اعرض مناسبات اليوم والتقويم التقليدي.
|
||||
|
||||
|
@@ -2,10 +2,13 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
暂无数据 Noch keine Daten
|
||||
主应用缓存 XStats-Cache
|
||||
今日消耗的 Token Heute verbrauchte Tokens
|
||||
查看今天 Codex 和 Claude 的 Token 用量 Heutigen Token-Verbrauch von Codex und Claude anzeigen
|
||||
分 Punkte
|
||||
查看 Codex 和 Claude 的剩余额度与重置时间 Verbleibendes Codex- und Claude-Kontingent mit Rücksetzzeit anzeigen
|
||||
显示主应用已查询的公网 IP 纯净度 Von XStats ermittelte IP-Reputation anzeigen
|
||||
显示主应用已查询的公网 IPv4 和 IPv6 Von XStats ermittelte öffentliche IPv4 und IPv6 anzeigen
|
||||
查看公网 IP、归属地、ASN 和标记 Öffentliche IP, Standort, ASN und Markierungen anzeigen
|
||||
查看今天的日期或本月月历。 Heutiges Datum oder Monatskalender anzeigen.
|
||||
设备不支持 Gerät nicht unterstützt
|
||||
需要 macOS 15.1 或更新版本 Erfordert macOS 15.1 oder neuer
|
||||
@@ -1402,6 +1405,11 @@ Sub2API 账号平台与所选来源不匹配 Die Plattform des Sub2API-Kontos st
|
||||
不上班 Frei
|
||||
暂无安排 Keine Daten
|
||||
暂无法判断 Derzeit nicht bestimmbar
|
||||
工作日 Werktag
|
||||
周末 Wochenende
|
||||
节假日 Feiertag
|
||||
调休补班 Ausgleichsarbeitstag
|
||||
调休放假 Freizeitausgleich
|
||||
黄道日 Günstiger Tag
|
||||
黑道日 Ungünstiger Tag
|
||||
查看今天的节日与黄历。 Heutige Feiertage und Almanach anzeigen.
|
||||
|
||||
|
@@ -2,10 +2,13 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
暂无数据 Aún no hay datos
|
||||
主应用缓存 Caché de XStats
|
||||
今日消耗的 Token Tokens consumidos hoy
|
||||
查看今天 Codex 和 Claude 的 Token 用量 Ver los tokens usados hoy por Codex y Claude
|
||||
分 puntos
|
||||
查看 Codex 和 Claude 的剩余额度与重置时间 Ver cuotas restantes de Codex y Claude y cuándo se restablecen
|
||||
显示主应用已查询的公网 IP 纯净度 Mostrar la reputación de IP consultada por XStats
|
||||
显示主应用已查询的公网 IPv4 和 IPv6 Mostrar las IPv4 e IPv6 públicas consultadas por XStats
|
||||
查看公网 IP、归属地、ASN 和标记 Ver la IP pública, ubicación, ASN y etiquetas
|
||||
查看今天的日期或本月月历。 Ver la fecha de hoy o el calendario del mes.
|
||||
设备不支持 Dispositivo no compatible
|
||||
需要 macOS 15.1 或更新版本 Requiere macOS 15.1 o posterior
|
||||
@@ -1402,6 +1405,11 @@ Sub2API 账号平台与所选来源不匹配 La plataforma de la cuenta de Sub2A
|
||||
不上班 Día libre
|
||||
暂无安排 Sin datos
|
||||
暂无法判断 Aún no se puede determinar
|
||||
工作日 Día laborable
|
||||
周末 Fin de semana
|
||||
节假日 Festivo
|
||||
调休补班 Jornada de compensación
|
||||
调休放假 Descanso compensatorio
|
||||
黄道日 Día propicio
|
||||
黑道日 Día desfavorable
|
||||
查看今天的节日与黄历。 Consulta las festividades y el almanaque de hoy.
|
||||
|
||||
|
@@ -2,10 +2,13 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
暂无数据 Aucune donnée pour le moment
|
||||
主应用缓存 Cache de XStats
|
||||
今日消耗的 Token Tokens consommés aujourd’hui
|
||||
查看今天 Codex 和 Claude 的 Token 用量 Afficher les tokens utilisés aujourd’hui par Codex et Claude
|
||||
分 points
|
||||
查看 Codex 和 Claude 的剩余额度与重置时间 Afficher les quotas restants de Codex et Claude et leur réinitialisation
|
||||
显示主应用已查询的公网 IP 纯净度 Afficher la réputation IP consultée par XStats
|
||||
显示主应用已查询的公网 IPv4 和 IPv6 Afficher les adresses IPv4 et IPv6 publiques consultées par XStats
|
||||
查看公网 IP、归属地、ASN 和标记 Voir l’IP publique, la localisation, l’ASN et les indicateurs
|
||||
查看今天的日期或本月月历。 Afficher la date du jour ou le calendrier du mois.
|
||||
设备不支持 Appareil non compatible
|
||||
需要 macOS 15.1 或更新版本 Nécessite macOS 15.1 ou une version ultérieure
|
||||
@@ -1402,6 +1405,11 @@ Sub2API 账号平台与所选来源不匹配 La plateforme du compte Sub2API ne
|
||||
不上班 Repos
|
||||
暂无安排 Aucune donnée
|
||||
暂无法判断 Impossible à déterminer pour le moment
|
||||
工作日 Jour ouvré
|
||||
周末 Week-end
|
||||
节假日 Jour férié
|
||||
调休补班 Travail de rattrapage
|
||||
调休放假 Repos compensatoire
|
||||
黄道日 Jour favorable
|
||||
黑道日 Jour défavorable
|
||||
查看今天的节日与黄历。 Consultez les fêtes et l'almanach du jour.
|
||||
|
||||
|
@@ -2,10 +2,13 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
暂无数据 データがありません
|
||||
主应用缓存 XStatsのキャッシュ
|
||||
今日消耗的 Token 今日使用した Token
|
||||
查看今天 Codex 和 Claude 的 Token 用量 今日の Codex と Claude の Token 使用量を表示
|
||||
分 点
|
||||
查看 Codex 和 Claude 的剩余额度与重置时间 CodexとClaudeの残り枠とリセット時刻を表示
|
||||
显示主应用已查询的公网 IP 纯净度 XStatsが取得した公開IPの信頼度を表示
|
||||
显示主应用已查询的公网 IPv4 和 IPv6 XStatsが取得した公開IPv4とIPv6を表示
|
||||
查看公网 IP、归属地、ASN 和标记 公開IP、所在地、ASN、タグを表示
|
||||
查看今天的日期或本月月历。 今日の日付または今月のカレンダーを表示します。
|
||||
设备不支持 このデバイスは非対応
|
||||
需要 macOS 15.1 或更新版本 macOS 15.1以降が必要
|
||||
@@ -1402,6 +1405,11 @@ Sub2API 账号平台与所选来源不匹配 Sub2API アカウントのプラッ
|
||||
不上班 休み
|
||||
暂无安排 予定データなし
|
||||
暂无法判断 判定できません
|
||||
工作日 平日
|
||||
周末 週末
|
||||
节假日 祝日
|
||||
调休补班 振替出勤
|
||||
调休放假 振替休日
|
||||
黄道日 吉日
|
||||
黑道日 凶日
|
||||
查看今天的节日与黄历。 今日の祝祭日と暦注を表示します。
|
||||
|
||||
|
@@ -2,10 +2,13 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
暂无数据 데이터 없음
|
||||
主应用缓存 XStats 캐시
|
||||
今日消耗的 Token 오늘 사용한 Token
|
||||
查看今天 Codex 和 Claude 的 Token 用量 오늘 Codex와 Claude의 Token 사용량 보기
|
||||
分 점
|
||||
查看 Codex 和 Claude 的剩余额度与重置时间 Codex와 Claude의 남은 한도와 초기화 시간 표시
|
||||
显示主应用已查询的公网 IP 纯净度 XStats가 조회한 공인 IP 신뢰도 표시
|
||||
显示主应用已查询的公网 IPv4 和 IPv6 XStats가 조회한 공인 IPv4 및 IPv6 표시
|
||||
查看公网 IP、归属地、ASN 和标记 공인 IP, 위치, ASN 및 태그 보기
|
||||
查看今天的日期或本月月历。 오늘 날짜 또는 이번 달 달력을 표시합니다.
|
||||
设备不支持 기기에서 지원하지 않음
|
||||
需要 macOS 15.1 或更新版本 macOS 15.1 이상 필요
|
||||
@@ -1402,6 +1405,11 @@ Sub2API 账号平台与所选来源不匹配 Sub2API 계정 플랫폼이 선택
|
||||
不上班 쉬는 날
|
||||
暂无安排 일정 데이터 없음
|
||||
暂无法判断 아직 판단할 수 없음
|
||||
工作日 평일
|
||||
周末 주말
|
||||
节假日 공휴일
|
||||
调休补班 대체 근무
|
||||
调休放假 대체 휴무
|
||||
黄道日 길일
|
||||
黑道日 흉일
|
||||
查看今天的节日与黄历。 오늘의 명절과 황력을 확인합니다.
|
||||
|
||||
|
@@ -3,10 +3,13 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later AND MIT
|
||||
暂无数据 暫無資料
|
||||
主应用缓存 主應用程式快取
|
||||
今日消耗的 Token 今日消耗的 Token
|
||||
查看今天 Codex 和 Claude 的 Token 用量 查看今天 Codex 和 Claude 的 Token 用量
|
||||
分 分
|
||||
查看 Codex 和 Claude 的剩余额度与重置时间 查看 Codex 和 Claude 的剩餘額度與重置時間
|
||||
显示主应用已查询的公网 IP 纯净度 顯示主應用程式已查詢的公網 IP 純淨度
|
||||
显示主应用已查询的公网 IPv4 和 IPv6 顯示主應用程式已查詢的公網 IPv4 和 IPv6
|
||||
查看公网 IP、归属地、ASN 和标记 查看公網 IP、歸屬地、ASN 和標記
|
||||
查看今天的日期或本月月历。 查看今天的日期或本月月曆。
|
||||
设备不支持 裝置不支援
|
||||
需要 macOS 15.1 或更新版本 需要 macOS 15.1 或更新版本
|
||||
@@ -1403,6 +1406,11 @@ Sub2API 账号平台与所选来源不匹配 Sub2API 帳號平台與所選來源
|
||||
不上班 不上班
|
||||
暂无安排 暫無安排
|
||||
暂无法判断 暫時無法判斷
|
||||
工作日 工作日
|
||||
周末 週末
|
||||
节假日 節假日
|
||||
调休补班 調休補班
|
||||
调休放假 調休放假
|
||||
黄道日 黃道日
|
||||
黑道日 黑道日
|
||||
查看今天的节日与黄历。 查看今天的節日與黃曆。
|
||||
|
||||
|
@@ -39,12 +39,15 @@ Codex / Claude Code 本机 Token 用量与订阅额度 Local Codex / Claude Code
|
||||
启用后读取本机会话日志,并使用本机登录凭据向 Codex / Claude 查询订阅额度。 When enabled, reads local session logs and uses local login credentials to query Codex / Claude subscription usage.
|
||||
统计本机会话 Token,并使用本机登录凭据自动查询 Codex / Claude 订阅额度。 Counts local session tokens and automatically queries Codex / Claude subscription usage with local login credentials.
|
||||
订阅额度 Subscription usage
|
||||
今日消耗的 Token Tokens used today
|
||||
查看今天 Codex 和 Claude 的 Token 用量 View today's Codex and Claude token usage
|
||||
暂无数据 No data yet
|
||||
主应用缓存 From XStats cache
|
||||
分 points
|
||||
查看 Codex 和 Claude 的剩余额度与重置时间 View remaining Codex and Claude quota and reset times
|
||||
显示主应用已查询的公网 IP 纯净度 Show IP cleanliness already checked by the main app
|
||||
显示主应用已查询的公网 IPv4 和 IPv6 Show public IPv4 and IPv6 already checked by the main app
|
||||
查看公网 IP、归属地、ASN 和标记 View public IP, location, ASN, and tags
|
||||
查看今天的日期或本月月历。 View today's date or this month's calendar.
|
||||
服务端用量 · 与本机 Token 统计不同 Server usage · separate from local token statistics
|
||||
重置: Resets:
|
||||
|
||||
@@ -3,17 +3,30 @@
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 跨进程共享的展示摘要,不包含账号凭据、请求内容或本机 Token 日志。
|
||||
/// 跨进程共享的展示摘要,不包含账号凭据、请求内容或本机会话日志。
|
||||
public struct WidgetSnapshot: Codable, Equatable, Sendable {
|
||||
public struct DailyTokenUsage: Codable, Equatable, Sendable {
|
||||
public let provider: String
|
||||
public let day: Date
|
||||
public let tokens: Int
|
||||
|
||||
public init(provider: String, day: Date, tokens: Int) {
|
||||
self.provider = provider
|
||||
self.day = day
|
||||
self.tokens = tokens
|
||||
}
|
||||
}
|
||||
|
||||
public struct CalendarSummary: Codable, Equatable, Sendable {
|
||||
public enum Schedule: String, Codable, Sendable {
|
||||
case work, dayOff, makeupWork, unknown
|
||||
case work, weekend, holiday, makeupWork, makeupDayOff, unknown
|
||||
case dayOff // 兼容旧版 App Group 摘要;新版会在下次同步时重算。
|
||||
|
||||
/// 数据未覆盖的年份不能按普通工作日推断,避免给出错误的二元答案。
|
||||
public var needsWork: Bool? {
|
||||
switch self {
|
||||
case .work, .makeupWork: true
|
||||
case .dayOff: false
|
||||
case .weekend, .holiday, .makeupDayOff, .dayOff: false
|
||||
case .unknown: nil
|
||||
}
|
||||
}
|
||||
@@ -68,17 +81,35 @@ public struct WidgetSnapshot: Codable, Equatable, Sendable {
|
||||
public let ip: String
|
||||
public let countryCode: String?
|
||||
public let city: String?
|
||||
public let region: String?
|
||||
public let cityEnglish: String?
|
||||
public let regionEnglish: String?
|
||||
public let asn: String?
|
||||
public let organization: String?
|
||||
public let isNative: Bool?
|
||||
public let ipType: String?
|
||||
/// nil 表示尚无风险查询结果;空数组表示已查询且未检出标记。
|
||||
public let riskFlags: [String]?
|
||||
public let purityScore: Int?
|
||||
public let purityGrade: String?
|
||||
|
||||
public init(family: String, ip: String, countryCode: String?, city: String?,
|
||||
organization: String?, purityScore: Int?, purityGrade: String?) {
|
||||
organization: String?, purityScore: Int?, purityGrade: String?,
|
||||
region: String? = nil, cityEnglish: String? = nil, regionEnglish: String? = nil,
|
||||
asn: String? = nil, isNative: Bool? = nil, ipType: String? = nil,
|
||||
riskFlags: [String]? = nil) {
|
||||
self.family = family
|
||||
self.ip = ip
|
||||
self.countryCode = countryCode
|
||||
self.city = city
|
||||
self.region = region
|
||||
self.cityEnglish = cityEnglish
|
||||
self.regionEnglish = regionEnglish
|
||||
self.asn = asn
|
||||
self.organization = organization
|
||||
self.isNative = isNative
|
||||
self.ipType = ipType
|
||||
self.riskFlags = riskFlags
|
||||
self.purityScore = purityScore
|
||||
self.purityGrade = purityGrade
|
||||
}
|
||||
@@ -86,6 +117,8 @@ public struct WidgetSnapshot: Codable, Equatable, Sendable {
|
||||
|
||||
public var aiEnabled: Bool
|
||||
public var quotas: [Quota]
|
||||
/// 仅共享每个来源的当日合计;可选以兼容旧版 App Group 摘要。
|
||||
public var dailyTokens: [DailyTokenUsage]?
|
||||
public var publicIPEnabled: Bool
|
||||
public var addresses: [Address]
|
||||
public var language: String
|
||||
@@ -94,12 +127,14 @@ public struct WidgetSnapshot: Codable, Equatable, Sendable {
|
||||
/// 可选以兼容旧版主应用写入的 App Group 摘要。
|
||||
public var calendarDays: [CalendarSummary]?
|
||||
|
||||
public init(aiEnabled: Bool = false, quotas: [Quota] = [], publicIPEnabled: Bool = false,
|
||||
public init(aiEnabled: Bool = false, quotas: [Quota] = [], dailyTokens: [DailyTokenUsage]? = nil,
|
||||
publicIPEnabled: Bool = false,
|
||||
addresses: [Address] = [], language: String = "system",
|
||||
calendarFirstWeekday: Int = 2, showsLunar: Bool = true,
|
||||
calendarDays: [CalendarSummary]? = nil) {
|
||||
self.aiEnabled = aiEnabled
|
||||
self.quotas = quotas
|
||||
self.dailyTokens = dailyTokens
|
||||
self.publicIPEnabled = publicIPEnabled
|
||||
self.addresses = addresses
|
||||
self.language = language
|
||||
@@ -114,6 +149,13 @@ public struct WidgetSnapshot: Codable, Equatable, Sendable {
|
||||
return quotas.filter { $0.resetsAt.map { $0 > date } ?? true }
|
||||
}
|
||||
|
||||
/// 旧日快照保留来源,但数值归零;Widget 不得把昨天的使用量当作今天的结果。
|
||||
public func todayTokens(for provider: String, at date: Date = .now,
|
||||
calendar: Calendar = .autoupdatingCurrent) -> Int? {
|
||||
guard aiEnabled, let usage = dailyTokens?.first(where: { $0.provider == provider }) else { return nil }
|
||||
return calendar.isDate(usage.day, inSameDayAs: date) ? usage.tokens : 0
|
||||
}
|
||||
|
||||
public var visibleAddresses: [Address] { publicIPEnabled ? addresses : [] }
|
||||
|
||||
public func calendarSummary(for date: Date, calendar: Calendar = .autoupdatingCurrent) -> CalendarSummary? {
|
||||
|
||||
@@ -347,6 +347,7 @@ public final class AppController: NSObject, NSApplicationDelegate {
|
||||
_ = model.settings.language
|
||||
_ = model.settings.calendarFirstWeekday
|
||||
_ = model.settings.calendarFeatures
|
||||
_ = model.aiUsage.states
|
||||
_ = model.aiUsage.quotaStates
|
||||
_ = model.network.publicResults
|
||||
} onChange: { [weak self] in
|
||||
@@ -366,7 +367,8 @@ public final class AppController: NSObject, NSApplicationDelegate {
|
||||
let todayKey = CalendarEngine.today(at: today)?.id
|
||||
// 多预备几天,Widget 午夜换日时无需唤醒主应用;主应用运行期间按小时补足窗口。
|
||||
let calendarDays: [WidgetSnapshot.CalendarSummary] = {
|
||||
if previous.calendarDays?.count == 8, previous.calendarDays?.first?.dateKey == todayKey {
|
||||
if previous.calendarDays?.count == 8, previous.calendarDays?.first?.dateKey == todayKey,
|
||||
previous.calendarDays?.contains(where: { $0.schedule == .dayOff }) == false {
|
||||
return previous.calendarDays ?? []
|
||||
}
|
||||
return (0..<8).compactMap { offset in
|
||||
@@ -385,6 +387,12 @@ public final class AppController: NSObject, NSApplicationDelegate {
|
||||
fetchedAt: quota.fetchedAt, isStale: state.isStale)
|
||||
}
|
||||
} : []
|
||||
let dailyTokens: [WidgetSnapshot.DailyTokenUsage]? = settings.aiUsageEnabled
|
||||
? AIProviderID.allCases.filter { settings.aiUsageSources.contains($0) }.compactMap { provider in
|
||||
guard let report = model.aiUsage.state(for: provider).snapshot?.localUsage else { return nil }
|
||||
let tokens = LocalUsageReport.total(report.selected(days: 1, now: today, calendar: calendar)).total
|
||||
return WidgetSnapshot.DailyTokenUsage(provider: provider.rawValue, day: today, tokens: tokens)
|
||||
} : nil
|
||||
let addresses: [WidgetSnapshot.Address] = settings.publicIPLookup
|
||||
? IPFamily.allCases.compactMap { family in
|
||||
guard let result = model.network.publicResults[family],
|
||||
@@ -392,16 +400,22 @@ public final class AppController: NSObject, NSApplicationDelegate {
|
||||
return WidgetSnapshot.Address(family: family.rawValue, ip: ip,
|
||||
countryCode: result.countryCode, city: result.city,
|
||||
organization: result.organization,
|
||||
purityScore: result.purity?.score, purityGrade: result.purity?.grade)
|
||||
purityScore: result.purity?.score, purityGrade: result.purity?.grade,
|
||||
region: result.region, cityEnglish: result.cityEnglish,
|
||||
regionEnglish: result.regionEnglish, asn: result.asn,
|
||||
isNative: result.isNative, ipType: result.ipType,
|
||||
riskFlags: result.risk?.flags)
|
||||
} : []
|
||||
let snapshot = WidgetSnapshot(aiEnabled: settings.aiUsageEnabled, quotas: quotas,
|
||||
dailyTokens: dailyTokens,
|
||||
publicIPEnabled: settings.publicIPLookup, addresses: addresses,
|
||||
language: settings.language.rawValue,
|
||||
calendarFirstWeekday: settings.calendarFirstWeekday,
|
||||
showsLunar: settings.calendarFeatures.contains(.lunar),
|
||||
calendarDays: calendarDays)
|
||||
guard snapshot != previous, widgetStore.save(snapshot) else { return }
|
||||
for kind in ["work.12306.xstats.widget.aiQuota", "work.12306.xstats.widget.ipPurity",
|
||||
for kind in ["work.12306.xstats.widget.aiQuota", "work.12306.xstats.widget.todayTokens",
|
||||
"work.12306.xstats.widget.ipPurity",
|
||||
"work.12306.xstats.widget.publicIP"] {
|
||||
WidgetCenter.shared.reloadTimelines(ofKind: kind)
|
||||
}
|
||||
|
||||
@@ -34,14 +34,38 @@ struct CalendarAlmanac: Identifiable {
|
||||
}
|
||||
|
||||
extension CalendarEngine {
|
||||
// 逐日类型依据国办发明电〔2025〕7号及 2024 年修订的《全国年节及纪念日放假办法》。
|
||||
// Tyme 只记录假期区间与补班,不区分法定日和调休放假;其他年份不套用此表。
|
||||
private static let statutoryHolidays2026: Set<String> = [
|
||||
"2026-01-01", "2026-02-16", "2026-02-17", "2026-02-18", "2026-02-19",
|
||||
"2026-04-05", "2026-05-01", "2026-05-02", "2026-06-19", "2026-09-25",
|
||||
"2026-10-01", "2026-10-02", "2026-10-03"
|
||||
]
|
||||
private static let adjustedDaysOff2026: Set<String> = [
|
||||
"2026-01-02", "2026-02-20", "2026-02-23", "2026-04-06",
|
||||
"2026-05-04", "2026-05-05", "2026-10-05", "2026-10-06", "2026-10-07"
|
||||
]
|
||||
|
||||
static func widgetSummary(for day: CalendarDay) -> WidgetSnapshot.CalendarSummary {
|
||||
let schedule: WidgetSnapshot.CalendarSummary.Schedule
|
||||
if !hasHolidayData(year: day.year) {
|
||||
schedule = .unknown
|
||||
} else if let holiday = day.holiday {
|
||||
schedule = holiday.isWork ? .makeupWork : .dayOff
|
||||
if holiday.isWork {
|
||||
schedule = .makeupWork
|
||||
} else if day.year == 2026 {
|
||||
if statutoryHolidays2026.contains(day.id) {
|
||||
schedule = .holiday
|
||||
} else if adjustedDaysOff2026.contains(day.id) {
|
||||
schedule = .makeupDayOff
|
||||
} else {
|
||||
schedule = day.isWeekend ? .weekend : .unknown
|
||||
}
|
||||
} else {
|
||||
schedule = .dayOff
|
||||
}
|
||||
} else {
|
||||
schedule = day.isWeekend ? .dayOff : .work
|
||||
schedule = day.isWeekend ? .weekend : .work
|
||||
}
|
||||
let almanac = almanac(for: day)
|
||||
return .init(dateKey: day.id, festivals: day.festivals,
|
||||
|
||||
@@ -52,9 +52,52 @@ import WidgetData
|
||||
#expect(try JSONDecoder().decode(WidgetSnapshot.self, from: JSONEncoder().encode(snapshot)) == snapshot)
|
||||
}
|
||||
|
||||
@Test func publicAddressSnapshotKeepsLocationASAndFlagsWithoutBreakingOldData() throws {
|
||||
let enriched = Data(#"{"family":"v4","ip":"203.0.113.8","countryCode":"CN","city":"上海","region":"上海","cityEnglish":"Shanghai","regionEnglish":"Shanghai","organization":"Example","purityScore":85,"purityGrade":"A","asn":"AS4134","isNative":true,"ipType":"Residential IP","riskFlags":["proxy"]}"#.utf8)
|
||||
let address = try JSONDecoder().decode(WidgetSnapshot.Address.self, from: enriched)
|
||||
let encoded = try JSONEncoder().encode(address)
|
||||
let fields = try #require(JSONSerialization.jsonObject(with: encoded) as? [String: Any])
|
||||
#expect(fields["region"] as? String == "上海")
|
||||
#expect(fields["cityEnglish"] as? String == "Shanghai")
|
||||
#expect(fields["regionEnglish"] as? String == "Shanghai")
|
||||
#expect(fields["asn"] as? String == "AS4134")
|
||||
#expect(fields["isNative"] as? Bool == true)
|
||||
#expect(fields["ipType"] as? String == "Residential IP")
|
||||
#expect(fields["riskFlags"] as? [String] == ["proxy"])
|
||||
|
||||
let old = Data(#"{"family":"v4","ip":"203.0.113.8","countryCode":"CN","city":null,"organization":null,"purityScore":null,"purityGrade":null}"#.utf8)
|
||||
#expect(try JSONDecoder().decode(WidgetSnapshot.Address.self, from: old).ip == "203.0.113.8")
|
||||
}
|
||||
|
||||
@Test func tomorrowWorkAnswerDoesNotGuessWithoutHolidayCoverage() {
|
||||
#expect(WidgetSnapshot.CalendarSummary.Schedule.work.needsWork == true)
|
||||
#expect(WidgetSnapshot.CalendarSummary.Schedule.makeupWork.needsWork == true)
|
||||
#expect(WidgetSnapshot.CalendarSummary.Schedule.weekend.needsWork == false)
|
||||
#expect(WidgetSnapshot.CalendarSummary.Schedule.holiday.needsWork == false)
|
||||
#expect(WidgetSnapshot.CalendarSummary.Schedule.makeupDayOff.needsWork == false)
|
||||
#expect(WidgetSnapshot.CalendarSummary.Schedule.dayOff.needsWork == false)
|
||||
#expect(WidgetSnapshot.CalendarSummary.Schedule.unknown.needsWork == nil)
|
||||
}
|
||||
|
||||
@Test func todayTokenUsageResetsAtLocalMidnightAndRespectsModuleSwitch() throws {
|
||||
let scannedDay = try #require(ISO8601DateFormatter().date(from: "2026-09-24T15:00:00Z"))
|
||||
let nextDay = try #require(ISO8601DateFormatter().date(from: "2026-09-24T16:00:00Z"))
|
||||
var shanghai = Calendar(identifier: .gregorian)
|
||||
shanghai.timeZone = try #require(TimeZone(identifier: "Asia/Shanghai"))
|
||||
let usage = WidgetSnapshot.DailyTokenUsage(provider: "codex", day: scannedDay, tokens: 12_345)
|
||||
var snapshot = WidgetSnapshot(aiEnabled: true, dailyTokens: [usage])
|
||||
|
||||
#expect(snapshot.todayTokens(for: "codex", at: scannedDay, calendar: shanghai) == 12_345)
|
||||
#expect(snapshot.todayTokens(for: "codex", at: nextDay, calendar: shanghai) == 0)
|
||||
#expect(snapshot.todayTokens(for: "claude", at: scannedDay, calendar: shanghai) == nil)
|
||||
let restored = try JSONDecoder().decode(WidgetSnapshot.self, from: JSONEncoder().encode(snapshot))
|
||||
#expect(restored.todayTokens(for: "codex", at: scannedDay, calendar: shanghai) == 12_345)
|
||||
snapshot.aiEnabled = false
|
||||
#expect(snapshot.todayTokens(for: "codex", at: scannedDay, calendar: shanghai) == nil)
|
||||
}
|
||||
|
||||
@Test func oldWidgetSnapshotDecodesWithoutDailyTokens() throws {
|
||||
let legacy = Data(#"{"aiEnabled":true,"quotas":[],"publicIPEnabled":false,"addresses":[],"language":"system","calendarFirstWeekday":2,"showsLunar":true}"#.utf8)
|
||||
let snapshot = try JSONDecoder().decode(WidgetSnapshot.self, from: legacy)
|
||||
#expect(snapshot.todayTokens(for: "codex") == nil)
|
||||
}
|
||||
@@ -41,17 +41,40 @@ struct CalendarTests {
|
||||
@Test func widgetSummaryKeepsTomorrowScheduleAndAlmanacSeparate() throws {
|
||||
let work = try #require(CalendarEngine.day(year: 2026, month: 9, day: 20, timeZone: zone))
|
||||
let holiday = try #require(CalendarEngine.day(year: 2026, month: 9, day: 25, timeZone: zone))
|
||||
let holidayExtension = try #require(CalendarEngine.day(year: 2026, month: 10, day: 2, timeZone: zone))
|
||||
let weekend = try #require(CalendarEngine.day(year: 2026, month: 9, day: 26, timeZone: zone))
|
||||
let adjustedDayOff = try #require(CalendarEngine.day(year: 2026, month: 10, day: 5, timeZone: zone))
|
||||
let ordinary = try #require(CalendarEngine.day(year: 2026, month: 9, day: 24, timeZone: zone))
|
||||
let unknown = try #require(CalendarEngine.day(year: 2027, month: 1, day: 4, timeZone: zone))
|
||||
|
||||
#expect(CalendarEngine.widgetSummary(for: work).schedule == .makeupWork)
|
||||
#expect(CalendarEngine.widgetSummary(for: holiday).schedule == .dayOff)
|
||||
#expect(CalendarEngine.widgetSummary(for: holiday).schedule.rawValue == "holiday")
|
||||
#expect(CalendarEngine.widgetSummary(for: holidayExtension).schedule.rawValue == "holiday")
|
||||
#expect(CalendarEngine.widgetSummary(for: weekend).schedule.rawValue == "weekend")
|
||||
#expect(CalendarEngine.widgetSummary(for: adjustedDayOff).schedule.rawValue == "makeupDayOff")
|
||||
#expect(CalendarEngine.widgetSummary(for: ordinary).schedule == .work)
|
||||
#expect(CalendarEngine.widgetSummary(for: unknown).schedule == .unknown)
|
||||
#expect(CalendarEngine.widgetSummary(for: holiday).festivals.contains("中秋节"))
|
||||
#expect(CalendarEngine.widgetSummary(for: holiday).recommends.isEmpty == false)
|
||||
}
|
||||
|
||||
@Test func official2026ScheduleSeparatesStatutoryWeekendAndAdjustedRest() throws {
|
||||
let cases: [(Int, Int, String)] = [
|
||||
(1, 2, "makeupDayOff"),
|
||||
(2, 18, "holiday"),
|
||||
(2, 20, "makeupDayOff"),
|
||||
(4, 5, "holiday"),
|
||||
(4, 6, "makeupDayOff"),
|
||||
(5, 2, "holiday"),
|
||||
(10, 3, "holiday"),
|
||||
(10, 4, "weekend")
|
||||
]
|
||||
for (month, day, expected) in cases {
|
||||
let date = try #require(CalendarEngine.day(year: 2026, month: month, day: day, timeZone: zone))
|
||||
#expect(CalendarEngine.widgetSummary(for: date).schedule.rawValue == expected)
|
||||
}
|
||||
}
|
||||
|
||||
@Test func termsAndSeasonalBoundaries() throws {
|
||||
#expect(CalendarEngine.day(year: 2026, month: 9, day: 23, timeZone: zone)?.solarTerm == "秋分")
|
||||
#expect(CalendarEngine.day(year: 2026, month: 9, day: 24, timeZone: zone)?.solarTerm == nil)
|
||||
|
||||
+37
-14
@@ -168,36 +168,59 @@ private struct TomorrowWorkWidgetView: View {
|
||||
let entry: CalendarWidgetEntry
|
||||
|
||||
private var needsWork: Bool? { entry.tomorrow?.schedule.needsWork }
|
||||
private var tomorrowDate: Date {
|
||||
Calendar.autoupdatingCurrent.date(byAdding: .day, value: 1, to: entry.date) ?? entry.date
|
||||
}
|
||||
|
||||
private var scheduleTitle: String? {
|
||||
guard let schedule = entry.tomorrow?.schedule else { return nil }
|
||||
return switch schedule {
|
||||
case .work: tr("工作日")
|
||||
case .weekend: tr("周末")
|
||||
case .holiday: tr("节假日")
|
||||
case .makeupWork: tr("调休补班")
|
||||
case .makeupDayOff: tr("调休放假")
|
||||
case .dayOff: tr("放假")
|
||||
case .unknown: nil
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(tr("明天上班吗"))
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
Text(tomorrowDate, format: .dateTime.month(.abbreviated).day().weekday(.abbreviated))
|
||||
.font(.subheadline.weight(.medium))
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.8)
|
||||
Spacer(minLength: 0)
|
||||
if let needsWork {
|
||||
VStack(spacing: 8) {
|
||||
Image(systemName: needsWork ? "checkmark.circle.fill" : "xmark.circle.fill")
|
||||
.font(.system(size: 46, weight: .medium))
|
||||
.symbolRenderingMode(.hierarchical)
|
||||
.foregroundStyle(needsWork ? .green : .primary)
|
||||
.accessibilityHidden(true)
|
||||
Text(tr(needsWork ? "上班" : "不上班"))
|
||||
.font(.system(size: 22, weight: .semibold, design: .rounded))
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.8)
|
||||
Text(tr(needsWork ? "上班" : "不上班"))
|
||||
.font(.system(size: 27, weight: .semibold, design: .rounded))
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.8)
|
||||
if let scheduleTitle {
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(scheduleTitle)
|
||||
if entry.tomorrow?.schedule == .holiday, let holidayName = entry.tomorrow?.holidayName {
|
||||
Text(tr(holidayName))
|
||||
}
|
||||
}
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.75)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
} else {
|
||||
Text(tr("暂无法判断"))
|
||||
.font(.caption.weight(.medium))
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
|
||||
.padding(16)
|
||||
.padding(14)
|
||||
.containerBackground(.background, for: .widget)
|
||||
.environment(\.locale, L10n.locale)
|
||||
.accessibilityElement(children: .combine)
|
||||
|
||||
+194
-35
@@ -18,9 +18,13 @@ struct SharedDataProvider: TimelineProvider {
|
||||
aiEnabled: true,
|
||||
quotas: [.init(provider: "codex", kind: "weekly", remainingPercent: 68,
|
||||
resetsAt: now.addingTimeInterval(2 * 24 * 3600), fetchedAt: now, isStale: false)],
|
||||
dailyTokens: [.init(provider: "codex", day: now, tokens: 28_400),
|
||||
.init(provider: "claude", day: now, tokens: 12_600)],
|
||||
publicIPEnabled: true,
|
||||
addresses: [.init(family: "v4", ip: "203.0.113.8", countryCode: "CN", city: nil,
|
||||
organization: nil, purityScore: 85, purityGrade: "A")]))
|
||||
addresses: [.init(family: "v4", ip: "203.0.113.8", countryCode: "CN", city: "上海",
|
||||
organization: "Example Network", purityScore: 85, purityGrade: "A",
|
||||
region: "上海", cityEnglish: "Shanghai", regionEnglish: "Shanghai",
|
||||
asn: "AS4134", isNative: true, ipType: "Residential IP", riskFlags: [])]))
|
||||
}
|
||||
|
||||
func getSnapshot(in context: Context, completion: @escaping (SharedDataEntry) -> Void) {
|
||||
@@ -33,7 +37,8 @@ struct SharedDataProvider: TimelineProvider {
|
||||
let nextReset = entry.snapshot.currentQuotas(at: entry.date)
|
||||
.compactMap(\.resetsAt).min().map { $0.addingTimeInterval(1) }
|
||||
let periodic = entry.date.addingTimeInterval(15 * 60)
|
||||
completion(Timeline(entries: [entry], policy: .after(min(nextReset ?? periodic, periodic))))
|
||||
let nextMidnight = Calendar.autoupdatingCurrent.dateInterval(of: .day, for: entry.date)?.end
|
||||
completion(Timeline(entries: [entry], policy: .after(min(nextReset ?? periodic, periodic, nextMidnight ?? periodic))))
|
||||
}
|
||||
|
||||
private func read() -> SharedDataEntry {
|
||||
@@ -145,6 +150,58 @@ struct AIQuotaWidget: Widget {
|
||||
}
|
||||
}
|
||||
|
||||
private struct TodayTokensWidgetView: View {
|
||||
let entry: SharedDataEntry
|
||||
|
||||
private var sources: [(name: String, tokens: Int)] {
|
||||
["codex", "claude"].compactMap { provider in
|
||||
guard let tokens = entry.snapshot.todayTokens(for: provider, at: entry.date) else { return nil }
|
||||
return (provider == "codex" ? "Codex" : "Claude", tokens)
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
WidgetHeading(title: tr("今日消耗的 Token"), symbol: "sparkles")
|
||||
if sources.isEmpty {
|
||||
WidgetEmptyState(enabled: entry.snapshot.aiEnabled)
|
||||
} else {
|
||||
let total = sources.reduce(0) { $0 + $1.tokens }
|
||||
Text(total.formatted(.number.notation(.compactName).locale(L10n.locale)))
|
||||
.font(.system(size: 36, weight: .semibold, design: .rounded))
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.7)
|
||||
.accessibilityLabel(total.formatted(.number.locale(L10n.locale)) + " Tokens")
|
||||
Spacer(minLength: 0)
|
||||
ForEach(sources, id: \.name) { source in
|
||||
HStack {
|
||||
Text(source.name)
|
||||
Spacer(minLength: 4)
|
||||
Text(source.tokens.formatted(.number.notation(.compactName).locale(L10n.locale)))
|
||||
.monospacedDigit()
|
||||
}
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.containerBackground(.background, for: .widget)
|
||||
.environment(\.locale, L10n.locale)
|
||||
}
|
||||
}
|
||||
|
||||
struct TodayTokensWidget: Widget {
|
||||
var body: some WidgetConfiguration {
|
||||
StaticConfiguration(kind: "work.12306.xstats.widget.todayTokens", provider: SharedDataProvider()) { entry in
|
||||
TodayTokensWidgetView(entry: entry)
|
||||
}
|
||||
.configurationDisplayName(tr("今日消耗的 Token"))
|
||||
.description(tr("查看今天 Codex 和 Claude 的 Token 用量"))
|
||||
.supportedFamilies([.systemSmall])
|
||||
}
|
||||
}
|
||||
|
||||
private struct IPPurityWidgetView: View {
|
||||
@Environment(\.widgetFamily) private var family
|
||||
let entry: SharedDataEntry
|
||||
@@ -158,9 +215,10 @@ private struct IPPurityWidgetView: View {
|
||||
WidgetHeading(title: tr("IP 纯净度"), symbol: "checkmark.shield")
|
||||
if let first = addresses.first {
|
||||
if family == .systemMedium {
|
||||
HStack(alignment: .top, spacing: 16) {
|
||||
ForEach(addresses, id: \.family) { address in purityColumn(address) }
|
||||
Spacer(minLength: 0)
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
ForEach(addresses, id: \.family) { address in
|
||||
purityRow(address, compact: addresses.count > 1)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
purityColumn(first)
|
||||
@@ -178,28 +236,68 @@ private struct IPPurityWidgetView: View {
|
||||
.environment(\.locale, L10n.locale)
|
||||
}
|
||||
|
||||
private func purityRow(_ address: WidgetSnapshot.Address, compact: Bool) -> some View {
|
||||
HStack(alignment: .top, spacing: 16) {
|
||||
score(for: address, compact: compact)
|
||||
.frame(width: 90, alignment: .leading)
|
||||
addressDetails(for: address, compact: compact)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
|
||||
private func purityColumn(_ address: WidgetSnapshot.Address) -> some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 4) {
|
||||
Text("\(address.purityScore ?? 0)")
|
||||
.font(.system(size: 36, weight: .semibold, design: .rounded))
|
||||
Text(tr("分"))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
if let grade = address.purityGrade {
|
||||
Text(grade).font(.caption.weight(.bold)).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
Text(address.family == "v4" ? "IPv4" : "IPv6")
|
||||
.font(.caption2.weight(.medium))
|
||||
.foregroundStyle(.secondary)
|
||||
Text(address.ip)
|
||||
.font(.caption.monospaced())
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.7)
|
||||
score(for: address, compact: false)
|
||||
addressDetails(for: address, compact: false)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
private func score(for address: WidgetSnapshot.Address, compact: Bool) -> some View {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 4) {
|
||||
Text("\(address.purityScore ?? 0)")
|
||||
.font(.system(size: compact ? 27 : 36, weight: .semibold, design: .rounded))
|
||||
Text(tr("分"))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
if let grade = address.purityGrade {
|
||||
Text(grade).font(.caption.weight(.bold)).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.lineLimit(1)
|
||||
}
|
||||
|
||||
private func addressDetails(for address: WidgetSnapshot.Address, compact: Bool) -> some View {
|
||||
VStack(alignment: .leading, spacing: compact ? 2 : 5) {
|
||||
if compact {
|
||||
HStack(spacing: 5) {
|
||||
Text(address.family == "v4" ? "IPv4" : "IPv6")
|
||||
.foregroundStyle(.secondary)
|
||||
Text(address.ip)
|
||||
.font(.caption2.monospaced())
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
}
|
||||
.font(.caption2)
|
||||
} else {
|
||||
Text(address.family == "v4" ? "IPv4" : "IPv6")
|
||||
.font(.caption2.weight(.medium))
|
||||
.foregroundStyle(.secondary)
|
||||
Text(address.ip)
|
||||
.font(.caption.monospaced())
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
.minimumScaleFactor(0.7)
|
||||
}
|
||||
let labels = ipWidgetTags(for: address)
|
||||
if !labels.isEmpty {
|
||||
Text(labels.joined(separator: " · "))
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct IPPurityWidget: Widget {
|
||||
@@ -222,37 +320,98 @@ private struct PublicIPWidgetView: View {
|
||||
WidgetHeading(title: tr("公网 IP"), symbol: "globe")
|
||||
if let first = entry.snapshot.visibleAddresses.first {
|
||||
if family == .systemMedium {
|
||||
ForEach(entry.snapshot.visibleAddresses, id: \.family) { address in addressRow(address) }
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
ForEach(entry.snapshot.visibleAddresses, id: \.family) { address in addressColumn(address) }
|
||||
}
|
||||
} else {
|
||||
addressRow(first)
|
||||
addressColumn(first)
|
||||
}
|
||||
} else {
|
||||
WidgetEmptyState(enabled: entry.snapshot.publicIPEnabled)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
Text(tr("主应用缓存"))
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
.padding()
|
||||
.containerBackground(.background, for: .widget)
|
||||
.environment(\.locale, L10n.locale)
|
||||
}
|
||||
|
||||
private func addressRow(_ address: WidgetSnapshot.Address) -> some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
private func addressColumn(_ address: WidgetSnapshot.Address) -> some View {
|
||||
VStack(alignment: .leading, spacing: 5) {
|
||||
Text(address.family == "v4" ? "IPv4" : "IPv6")
|
||||
.font(.caption.weight(.medium))
|
||||
.foregroundStyle(.secondary)
|
||||
Text(address.ip)
|
||||
.font(.system(size: family == .systemMedium ? 18 : 17, weight: .semibold, design: .monospaced))
|
||||
.font(.system(size: family == .systemMedium ? 15 : 18, weight: .semibold, design: .monospaced))
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.65)
|
||||
if let location = [address.countryCode, address.city].compactMap({ $0 }).first {
|
||||
Text(location).font(.caption2).foregroundStyle(.secondary)
|
||||
.truncationMode(.middle)
|
||||
.minimumScaleFactor(0.7)
|
||||
.help(address.ip)
|
||||
if let location = location(for: address) {
|
||||
Text(location)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
if let asn = address.asn {
|
||||
Text(asn)
|
||||
.font(.caption2.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
let tags = ipWidgetTags(for: address)
|
||||
if !tags.isEmpty {
|
||||
Text(tags.joined(separator: " · "))
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.75)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
private func location(for address: WidgetSnapshot.Address) -> String? {
|
||||
var parts: [String] = []
|
||||
if let code = address.countryCode {
|
||||
parts.append(L10n.locale.localizedString(forRegionCode: code) ?? code)
|
||||
}
|
||||
let region = L10n.usesEnglishNames ? (address.regionEnglish ?? address.region) : address.region
|
||||
let city = L10n.usesEnglishNames ? (address.cityEnglish ?? address.city) : address.city
|
||||
for part in [region, city].compactMap({ $0 }) where part != parts.last { parts.append(part) }
|
||||
return parts.isEmpty ? nil : parts.joined(separator: " · ")
|
||||
}
|
||||
}
|
||||
|
||||
private func ipWidgetTags(for address: WidgetSnapshot.Address) -> [String] {
|
||||
if let flags = address.riskFlags, !flags.isEmpty {
|
||||
return Array(flags.prefix(2)).map { flag in
|
||||
switch flag {
|
||||
case "vpn": "VPN"
|
||||
case "proxy": tr("代理")
|
||||
case "residentialProxy": tr("住宅代理")
|
||||
case "tor": "Tor"
|
||||
case "relay": tr("中继")
|
||||
case "datacenter": tr("机房")
|
||||
case "hosting": tr("托管")
|
||||
case "abuser": tr("滥用记录")
|
||||
default: flag
|
||||
}
|
||||
}
|
||||
}
|
||||
var labels: [String] = []
|
||||
if let isNative = address.isNative { labels.append(tr(isNative ? "原生 IP" : "广播 IP")) }
|
||||
if let ipType = address.ipType {
|
||||
switch ipType.lowercased() {
|
||||
case "residential ip": labels.append(tr("住宅 IP"))
|
||||
case "datacenter ip", "hosting ip": labels.append(tr("机房 IP"))
|
||||
case "mobile ip": labels.append(tr("移动网络 IP"))
|
||||
case "business ip": labels.append(tr("企业 IP"))
|
||||
default: break
|
||||
}
|
||||
}
|
||||
if labels.isEmpty, address.riskFlags != nil { labels.append(tr("未检出")) }
|
||||
return labels
|
||||
}
|
||||
|
||||
struct PublicIPWidget: Widget {
|
||||
@@ -261,7 +420,7 @@ struct PublicIPWidget: Widget {
|
||||
PublicIPWidgetView(entry: entry)
|
||||
}
|
||||
.configurationDisplayName(tr("公网 IP"))
|
||||
.description(tr("显示主应用已查询的公网 IPv4 和 IPv6"))
|
||||
.description(tr("查看公网 IP、归属地、ASN 和标记"))
|
||||
.supportedFamilies([.systemSmall, .systemMedium])
|
||||
}
|
||||
}
|
||||
@@ -212,6 +212,7 @@ struct XStatsWidgets: WidgetBundle {
|
||||
CalendarWidget()
|
||||
TomorrowWorkWidget()
|
||||
AIQuotaWidget()
|
||||
TodayTokensWidget()
|
||||
IPPurityWidget()
|
||||
PublicIPWidget()
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user