feat.完善了时钟轻应用,为启动器提供了多语言支持

This commit is contained in:
lincube
2026-05-18 12:26:23 +08:00
parent 93758fc083
commit b6d820a320
63 changed files with 4581 additions and 342 deletions

View File

@@ -1097,6 +1097,40 @@
"clock.settings.second_mode_label": "Second Hand",
"clock.second_mode.tick": "Tick",
"clock.second_mode.sweep": "Sweep",
"clockairapp.title": "Clock",
"clockairapp.subtitle": "World clock, stopwatch and timer",
"clockairapp.tab.world": "World",
"clockairapp.tab.stopwatch": "Stopwatch",
"clockairapp.tab.timer": "Timer",
"clockairapp.tab.settings": "Settings",
"clockairapp.world.local": "Local time",
"clockairapp.world.add": "Add",
"clockairapp.world.search": "Search city or time zone",
"clockairapp.world.count": "{0} cities",
"clockairapp.action.start": "Start",
"clockairapp.action.pause": "Pause",
"clockairapp.action.reset": "Reset",
"clockairapp.action.remove": "Remove",
"clockairapp.action.move_up": "Move up",
"clockairapp.action.move_down": "Move down",
"clockairapp.stopwatch.hint": "Lap timing stays in this window session.",
"clockairapp.stopwatch.lap": "Lap",
"clockairapp.stopwatch.lap_format": "Lap {0} {1}",
"clockairapp.timer.hint": "Choose a preset or enter custom minutes.",
"clockairapp.timer.apply": "Apply",
"clockairapp.timer.minutes": "Minutes",
"clockairapp.timer.finished": "Timer finished",
"clockairapp.timer.duration_status": "Duration {0}",
"clockairapp.timer.invalid": "Enter a valid minute value.",
"clockairapp.settings.title": "Clock settings",
"clockairapp.settings.time_format": "Time format",
"clockairapp.settings.startup_tab": "Startup page",
"clockairapp.settings.show_seconds": "Show seconds",
"clockairapp.settings.activate_timer": "Activate window when timer finishes",
"clockairapp.settings.time_format.system": "Follow system",
"clockairapp.settings.time_format.24h": "24-hour",
"clockairapp.settings.time_format.12h": "12-hour",
"clockairapp.settings.startup.last": "Last used",
"poetry.widget.loading_content": "Loading poetry...",
"poetry.widget.loading_author": "Loading...",
"poetry.widget.fetch_failed": "Poetry fetch failed",

View File

@@ -811,6 +811,40 @@
"desktop_clock.settings.second_mode_label": "秒針",
"clock.second_mode.tick": "ティック",
"clock.second_mode.sweep": "スイープ",
"clockairapp.title": "時計",
"clockairapp.subtitle": "世界時計、ストップウォッチ、タイマー",
"clockairapp.tab.world": "世界時計",
"clockairapp.tab.stopwatch": "ストップウォッチ",
"clockairapp.tab.timer": "タイマー",
"clockairapp.tab.settings": "設定",
"clockairapp.world.local": "ローカル時刻",
"clockairapp.world.add": "追加",
"clockairapp.world.search": "都市またはタイムゾーンを検索",
"clockairapp.world.count": "{0} 都市",
"clockairapp.action.start": "開始",
"clockairapp.action.pause": "一時停止",
"clockairapp.action.reset": "リセット",
"clockairapp.action.remove": "削除",
"clockairapp.action.move_up": "上へ",
"clockairapp.action.move_down": "下へ",
"clockairapp.stopwatch.hint": "ラップは現在のウィンドウセッション内に保持されます。",
"clockairapp.stopwatch.lap": "ラップ",
"clockairapp.stopwatch.lap_format": "ラップ {0} {1}",
"clockairapp.timer.hint": "プリセットを選ぶか、分数を入力します。",
"clockairapp.timer.apply": "適用",
"clockairapp.timer.minutes": "分",
"clockairapp.timer.finished": "タイマー終了",
"clockairapp.timer.duration_status": "時間 {0}",
"clockairapp.timer.invalid": "有効な分数を入力してください。",
"clockairapp.settings.title": "時計設定",
"clockairapp.settings.time_format": "時刻形式",
"clockairapp.settings.startup_tab": "起動ページ",
"clockairapp.settings.show_seconds": "秒を表示",
"clockairapp.settings.activate_timer": "タイマー終了時にウィンドウを前面へ",
"clockairapp.settings.time_format.system": "システムに従う",
"clockairapp.settings.time_format.24h": "24時間",
"clockairapp.settings.time_format.12h": "12時間",
"clockairapp.settings.startup.last": "前回使用",
"poetry.widget.loading_content": "詩を読み込み中...",
"poetry.widget.loading_author": "読み込み中...",
"poetry.widget.fetch_failed": "詩の取得に失敗しました",

View File

@@ -857,6 +857,40 @@
"desktop_clock.settings.second_mode_label": "초침 방식",
"clock.second_mode.tick": "똑딱이",
"clock.second_mode.sweep": "스윕",
"clockairapp.title": "시계",
"clockairapp.subtitle": "세계 시계, 스톱워치, 타이머",
"clockairapp.tab.world": "세계 시계",
"clockairapp.tab.stopwatch": "스톱워치",
"clockairapp.tab.timer": "타이머",
"clockairapp.tab.settings": "설정",
"clockairapp.world.local": "현지 시간",
"clockairapp.world.add": "추가",
"clockairapp.world.search": "도시 또는 시간대 검색",
"clockairapp.world.count": "{0}개 도시",
"clockairapp.action.start": "시작",
"clockairapp.action.pause": "일시정지",
"clockairapp.action.reset": "초기화",
"clockairapp.action.remove": "삭제",
"clockairapp.action.move_up": "위로",
"clockairapp.action.move_down": "아래로",
"clockairapp.stopwatch.hint": "랩 기록은 현재 창 세션에만 유지됩니다.",
"clockairapp.stopwatch.lap": "랩",
"clockairapp.stopwatch.lap_format": "랩 {0} {1}",
"clockairapp.timer.hint": "프리셋을 선택하거나 사용자 지정 분을 입력하세요.",
"clockairapp.timer.apply": "적용",
"clockairapp.timer.minutes": "분",
"clockairapp.timer.finished": "타이머 종료",
"clockairapp.timer.duration_status": "시간 {0}",
"clockairapp.timer.invalid": "올바른 분 값을 입력하세요.",
"clockairapp.settings.title": "시계 설정",
"clockairapp.settings.time_format": "시간 형식",
"clockairapp.settings.startup_tab": "시작 페이지",
"clockairapp.settings.show_seconds": "초 표시",
"clockairapp.settings.activate_timer": "타이머 종료 시 창 활성화",
"clockairapp.settings.time_format.system": "시스템 설정 따르기",
"clockairapp.settings.time_format.24h": "24시간",
"clockairapp.settings.time_format.12h": "12시간",
"clockairapp.settings.startup.last": "마지막 사용",
"poetry.widget.loading_content": "시 불러오는 중",
"poetry.widget.loading_author": "로딩 중",
"poetry.widget.fetch_failed": "시 가져오기 실패",

View File

@@ -1027,6 +1027,40 @@
"clock.settings.second_mode_label": "秒针方式",
"clock.second_mode.tick": "跳针",
"clock.second_mode.sweep": "扫针",
"clockairapp.title": "时钟",
"clockairapp.subtitle": "世界时钟、秒表和计时器",
"clockairapp.tab.world": "世界时钟",
"clockairapp.tab.stopwatch": "秒表",
"clockairapp.tab.timer": "计时器",
"clockairapp.tab.settings": "设置",
"clockairapp.world.local": "本地时间",
"clockairapp.world.add": "添加",
"clockairapp.world.search": "搜索城市或时区",
"clockairapp.world.count": "{0} 个城市",
"clockairapp.action.start": "开始",
"clockairapp.action.pause": "暂停",
"clockairapp.action.reset": "重置",
"clockairapp.action.remove": "移除",
"clockairapp.action.move_up": "上移",
"clockairapp.action.move_down": "下移",
"clockairapp.stopwatch.hint": "计次记录仅保留在当前窗口会话中。",
"clockairapp.stopwatch.lap": "计次",
"clockairapp.stopwatch.lap_format": "计次 {0} {1}",
"clockairapp.timer.hint": "选择预设时长,或输入自定义分钟数。",
"clockairapp.timer.apply": "应用",
"clockairapp.timer.minutes": "分钟",
"clockairapp.timer.finished": "计时结束",
"clockairapp.timer.duration_status": "时长 {0}",
"clockairapp.timer.invalid": "请输入有效的分钟数。",
"clockairapp.settings.title": "时钟设置",
"clockairapp.settings.time_format": "时间格式",
"clockairapp.settings.startup_tab": "启动页面",
"clockairapp.settings.show_seconds": "显示秒数",
"clockairapp.settings.activate_timer": "计时结束时激活窗口",
"clockairapp.settings.time_format.system": "跟随系统",
"clockairapp.settings.time_format.24h": "24 小时制",
"clockairapp.settings.time_format.12h": "12 小时制",
"clockairapp.settings.startup.last": "上次使用",
"poetry.widget.loading_content": "正在加载诗词",
"poetry.widget.loading_author": "加载中",
"poetry.widget.fetch_failed": "诗词获取失败",

View File

@@ -64,6 +64,11 @@ internal sealed class AirAppLauncherService : IAirAppLauncherService
internal static string BuildSingleInstanceKey(string appId, string? sourceComponentId, string? sourcePlacementId)
{
var normalizedAppId = string.IsNullOrWhiteSpace(appId) ? "unknown" : appId.Trim();
if (string.Equals(normalizedAppId, WorldClockAppId, StringComparison.OrdinalIgnoreCase))
{
return $"{normalizedAppId}:clock-suite:global";
}
var normalizedComponentId = string.IsNullOrWhiteSpace(sourceComponentId) ? "none" : sourceComponentId.Trim();
var normalizedPlacementId = string.IsNullOrWhiteSpace(sourcePlacementId) ? "none" : sourcePlacementId.Trim();
return $"{normalizedAppId}:{normalizedComponentId}:{normalizedPlacementId}";

View File

@@ -0,0 +1,62 @@
using System.Collections.Generic;
using System.Linq;
using LanMountainDesktop.Services;
namespace LanMountainDesktop.Services.ClockAirApp;
public sealed class ClockAirAppSettingsSnapshot
{
public string TimeFormatMode { get; set; } = ClockAirAppTimeFormatMode.System;
public bool ShowSeconds { get; set; } = true;
public string StartupTab { get; set; } = ClockAirAppTabIds.Last;
public string LastSelectedTab { get; set; } = ClockAirAppTabIds.WorldClock;
public bool ActivateOnTimerFinished { get; set; } = true;
public List<string> WorldClockTimeZoneIds { get; set; } =
[
"China Standard Time",
"GMT Standard Time",
"AUS Eastern Standard Time",
"Eastern Standard Time"
];
public ClockAirAppSettingsSnapshot Clone()
{
return new ClockAirAppSettingsSnapshot
{
TimeFormatMode = ClockAirAppTimeFormatMode.Normalize(TimeFormatMode),
ShowSeconds = ShowSeconds,
StartupTab = ClockAirAppTabIds.Normalize(StartupTab, ClockAirAppTabIds.Last),
LastSelectedTab = ClockAirAppTabIds.Normalize(LastSelectedTab),
ActivateOnTimerFinished = ActivateOnTimerFinished,
WorldClockTimeZoneIds = WorldClockTimeZoneIds is { Count: > 0 }
? new List<string>(WorldClockTimeZoneIds.Where(static id => !string.IsNullOrWhiteSpace(id)).Select(static id => id.Trim()))
: []
};
}
public static ClockAirAppSettingsSnapshot Normalize(ClockAirAppSettingsSnapshot? snapshot)
{
var normalized = (snapshot ?? new ClockAirAppSettingsSnapshot()).Clone();
if (normalized.WorldClockTimeZoneIds.Count == 0)
{
normalized.WorldClockTimeZoneIds =
[
"China Standard Time",
"GMT Standard Time",
"AUS Eastern Standard Time",
"Eastern Standard Time"
];
}
normalized.WorldClockTimeZoneIds = normalized.WorldClockTimeZoneIds
.Select(static id => WorldClockTimeZoneCatalog.ResolveTimeZoneOrLocal(id).Id)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
return normalized;
}
}

View File

@@ -0,0 +1,67 @@
using System;
using System.IO;
using System.Text.Json;
using LanMountainDesktop.Services;
namespace LanMountainDesktop.Services.ClockAirApp;
public sealed class ClockAirAppSettingsStore
{
private static readonly JsonSerializerOptions SerializerOptions = new()
{
WriteIndented = true
};
private readonly string _settingsPath;
public ClockAirAppSettingsStore()
: this(Path.Combine(AppDataPathProvider.GetDataRoot(), "AirApps", "Clock", "settings.json"))
{
}
public ClockAirAppSettingsStore(string settingsPath)
{
_settingsPath = settingsPath;
}
public string SettingsPath => _settingsPath;
public ClockAirAppSettingsSnapshot Load()
{
try
{
if (!File.Exists(_settingsPath))
{
return ClockAirAppSettingsSnapshot.Normalize(null);
}
var json = File.ReadAllText(_settingsPath);
var snapshot = JsonSerializer.Deserialize<ClockAirAppSettingsSnapshot>(json, SerializerOptions);
return ClockAirAppSettingsSnapshot.Normalize(snapshot);
}
catch (Exception ex)
{
AppLogger.Warn("ClockAirApp", $"Failed to load clock Air APP settings from '{_settingsPath}'.", ex);
return ClockAirAppSettingsSnapshot.Normalize(null);
}
}
public void Save(ClockAirAppSettingsSnapshot snapshot)
{
var normalized = ClockAirAppSettingsSnapshot.Normalize(snapshot);
try
{
var directory = Path.GetDirectoryName(_settingsPath);
if (!string.IsNullOrWhiteSpace(directory))
{
Directory.CreateDirectory(directory);
}
File.WriteAllText(_settingsPath, JsonSerializer.Serialize(normalized, SerializerOptions));
}
catch (Exception ex)
{
AppLogger.Warn("ClockAirApp", $"Failed to save clock Air APP settings to '{_settingsPath}'.", ex);
}
}
}

View File

@@ -0,0 +1,62 @@
using System;
using System.Collections.Generic;
namespace LanMountainDesktop.Services.ClockAirApp;
public sealed class ClockAirAppStopwatchState
{
private readonly List<TimeSpan> _laps = [];
private TimeSpan _elapsedBeforeRun = TimeSpan.Zero;
private DateTimeOffset? _startedAt;
public bool IsRunning => _startedAt.HasValue;
public IReadOnlyList<TimeSpan> Laps => _laps;
public TimeSpan GetElapsed(DateTimeOffset now)
{
return _startedAt.HasValue
? _elapsedBeforeRun + (now - _startedAt.Value)
: _elapsedBeforeRun;
}
public void StartOrResume(DateTimeOffset now)
{
if (_startedAt.HasValue)
{
return;
}
_startedAt = now;
}
public void Pause(DateTimeOffset now)
{
if (!_startedAt.HasValue)
{
return;
}
_elapsedBeforeRun = GetElapsed(now);
_startedAt = null;
}
public TimeSpan AddLap(DateTimeOffset now)
{
var elapsed = GetElapsed(now);
_laps.Insert(0, elapsed);
if (_laps.Count > 50)
{
_laps.RemoveRange(50, _laps.Count - 50);
}
return elapsed;
}
public void Reset()
{
_elapsedBeforeRun = TimeSpan.Zero;
_startedAt = null;
_laps.Clear();
}
}

View File

@@ -0,0 +1,23 @@
namespace LanMountainDesktop.Services.ClockAirApp;
public static class ClockAirAppTabIds
{
public const string Last = "last";
public const string WorldClock = "world";
public const string Stopwatch = "stopwatch";
public const string Timer = "timer";
public const string Settings = "settings";
public static string Normalize(string? value, string fallback = WorldClock)
{
return value?.Trim().ToLowerInvariant() switch
{
Last => Last,
WorldClock => WorldClock,
Stopwatch => Stopwatch,
Timer => Timer,
Settings => Settings,
_ => fallback
};
}
}

View File

@@ -0,0 +1,18 @@
namespace LanMountainDesktop.Services.ClockAirApp;
public static class ClockAirAppTimeFormatMode
{
public const string System = "system";
public const string TwentyFourHour = "24h";
public const string TwelveHour = "12h";
public static string Normalize(string? value)
{
return value?.Trim().ToLowerInvariant() switch
{
TwentyFourHour => TwentyFourHour,
TwelveHour => TwelveHour,
_ => System
};
}
}

View File

@@ -0,0 +1,152 @@
using System;
using System.Collections.Generic;
using System.Globalization;
namespace LanMountainDesktop.Services.ClockAirApp;
public static class ClockAirAppTimeFormatter
{
private static readonly IReadOnlyDictionary<string, IReadOnlyDictionary<string, string>> CityNames =
new Dictionary<string, IReadOnlyDictionary<string, string>>(StringComparer.OrdinalIgnoreCase)
{
["zh-CN"] = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["China Standard Time"] = "北京",
["Asia/Shanghai"] = "北京",
["GMT Standard Time"] = "伦敦",
["Europe/London"] = "伦敦",
["AUS Eastern Standard Time"] = "悉尼",
["Australia/Sydney"] = "悉尼",
["Eastern Standard Time"] = "纽约",
["America/New_York"] = "纽约",
["Tokyo Standard Time"] = "东京",
["Asia/Tokyo"] = "东京",
["UTC"] = "UTC",
["Etc/UTC"] = "UTC"
},
["en-US"] = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["China Standard Time"] = "Beijing",
["Asia/Shanghai"] = "Beijing",
["GMT Standard Time"] = "London",
["Europe/London"] = "London",
["AUS Eastern Standard Time"] = "Sydney",
["Australia/Sydney"] = "Sydney",
["Eastern Standard Time"] = "New York",
["America/New_York"] = "New York",
["Tokyo Standard Time"] = "Tokyo",
["Asia/Tokyo"] = "Tokyo",
["UTC"] = "UTC",
["Etc/UTC"] = "UTC"
},
["ja-JP"] = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["China Standard Time"] = "北京",
["Asia/Shanghai"] = "北京",
["GMT Standard Time"] = "ロンドン",
["Europe/London"] = "ロンドン",
["AUS Eastern Standard Time"] = "シドニー",
["Australia/Sydney"] = "シドニー",
["Eastern Standard Time"] = "ニューヨーク",
["America/New_York"] = "ニューヨーク",
["Tokyo Standard Time"] = "東京",
["Asia/Tokyo"] = "東京",
["UTC"] = "UTC",
["Etc/UTC"] = "UTC"
},
["ko-KR"] = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["China Standard Time"] = "베이징",
["Asia/Shanghai"] = "베이징",
["GMT Standard Time"] = "런던",
["Europe/London"] = "런던",
["AUS Eastern Standard Time"] = "시드니",
["Australia/Sydney"] = "시드니",
["Eastern Standard Time"] = "뉴욕",
["America/New_York"] = "뉴욕",
["Tokyo Standard Time"] = "도쿄",
["Asia/Tokyo"] = "도쿄",
["UTC"] = "UTC",
["Etc/UTC"] = "UTC"
}
};
public static string FormatTime(DateTime time, ClockAirAppSettingsSnapshot settings, CultureInfo culture)
{
var use24Hour = UseTwentyFourHourClock(settings.TimeFormatMode, culture);
var showSeconds = settings.ShowSeconds;
var format = use24Hour
? showSeconds ? "HH:mm:ss" : "HH:mm"
: showSeconds ? "h:mm:ss tt" : "h:mm tt";
return time.ToString(format, culture);
}
public static string FormatDuration(TimeSpan duration, bool includeMilliseconds = false)
{
if (duration < TimeSpan.Zero)
{
duration = TimeSpan.Zero;
}
var totalHours = (int)duration.TotalHours;
return includeMilliseconds
? string.Create(CultureInfo.InvariantCulture, $"{totalHours:D2}:{duration.Minutes:D2}:{duration.Seconds:D2}.{duration.Milliseconds / 10:D2}")
: string.Create(CultureInfo.InvariantCulture, $"{totalHours:D2}:{duration.Minutes:D2}:{duration.Seconds:D2}");
}
public static string FormatUtcOffset(TimeSpan offset)
{
var sign = offset >= TimeSpan.Zero ? "+" : "-";
var totalMinutes = Math.Abs((int)Math.Round(offset.TotalMinutes));
var hours = totalMinutes / 60;
var minutes = totalMinutes % 60;
return $"UTC{sign}{hours:D2}:{minutes:D2}";
}
public static string ResolveCityName(TimeZoneInfo timeZone, string languageCode)
{
var normalizedLanguage = NormalizeLanguage(languageCode);
if (CityNames.TryGetValue(normalizedLanguage, out var cityNames) &&
cityNames.TryGetValue(timeZone.Id, out var cityName))
{
return cityName;
}
var normalized = timeZone.Id;
var slashIndex = normalized.LastIndexOf('/');
if (slashIndex >= 0 && slashIndex < normalized.Length - 1)
{
normalized = normalized[(slashIndex + 1)..];
}
normalized = normalized.Replace('_', ' ').Trim();
normalized = normalized
.Replace("Standard Time", string.Empty, StringComparison.OrdinalIgnoreCase)
.Replace("Daylight Time", string.Empty, StringComparison.OrdinalIgnoreCase)
.Replace("Time", string.Empty, StringComparison.OrdinalIgnoreCase)
.Trim();
return string.IsNullOrWhiteSpace(normalized) ? timeZone.Id : normalized;
}
public static bool UseTwentyFourHourClock(string? timeFormatMode, CultureInfo culture)
{
return ClockAirAppTimeFormatMode.Normalize(timeFormatMode) switch
{
ClockAirAppTimeFormatMode.TwentyFourHour => true,
ClockAirAppTimeFormatMode.TwelveHour => false,
_ => !culture.DateTimeFormat.ShortTimePattern.Contains('h')
};
}
private static string NormalizeLanguage(string? languageCode)
{
return languageCode?.Trim().ToLowerInvariant() switch
{
"en" or "en-us" => "en-US",
"ja" or "ja-jp" => "ja-JP",
"ko" or "ko-kr" => "ko-KR",
_ => "zh-CN"
};
}
}

View File

@@ -0,0 +1,90 @@
using System;
namespace LanMountainDesktop.Services.ClockAirApp;
public sealed class ClockAirAppTimerState
{
private TimeSpan _duration = TimeSpan.FromMinutes(5);
private TimeSpan _remainingBeforeRun = TimeSpan.FromMinutes(5);
private DateTimeOffset? _startedAt;
public TimeSpan Duration => _duration;
public bool IsRunning => _startedAt.HasValue;
public bool IsCompleted { get; private set; }
public TimeSpan GetRemaining(DateTimeOffset now)
{
if (!_startedAt.HasValue)
{
return _remainingBeforeRun < TimeSpan.Zero ? TimeSpan.Zero : _remainingBeforeRun;
}
var remaining = _remainingBeforeRun - (now - _startedAt.Value);
return remaining < TimeSpan.Zero ? TimeSpan.Zero : remaining;
}
public void SetDuration(TimeSpan duration)
{
if (duration <= TimeSpan.Zero)
{
duration = TimeSpan.FromMinutes(1);
}
_duration = duration;
Reset();
}
public void StartOrResume(DateTimeOffset now)
{
if (_startedAt.HasValue)
{
return;
}
if (_remainingBeforeRun <= TimeSpan.Zero || IsCompleted)
{
_remainingBeforeRun = _duration;
IsCompleted = false;
}
_startedAt = now;
}
public void Pause(DateTimeOffset now)
{
if (!_startedAt.HasValue)
{
return;
}
_remainingBeforeRun = GetRemaining(now);
_startedAt = null;
}
public void Reset()
{
_remainingBeforeRun = _duration;
_startedAt = null;
IsCompleted = false;
}
public bool Update(DateTimeOffset now)
{
if (!_startedAt.HasValue || GetRemaining(now) > TimeSpan.Zero)
{
return false;
}
_remainingBeforeRun = TimeSpan.Zero;
_startedAt = null;
if (IsCompleted)
{
return false;
}
IsCompleted = true;
return true;
}
}