fix:一言API设置重复写入

This commit is contained in:
2026-04-04 22:16:32 +08:00
parent 1f00962c47
commit af19ffb736
2 changed files with 183 additions and 22 deletions
+163 -13
View File
@@ -1,9 +1,11 @@
using Hardcodet.Wpf.TaskbarNotification;
using Ink_Canvas.Helpers;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using OSVersionExtension;
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Diagnostics;
using System.IO;
using System.Linq;
@@ -1209,17 +1211,14 @@ namespace Ink_Canvas
return;
}
// 构建API URL,包含选中的分类参数
var categories = Settings.Appearance.HitokotoCategories;
if (categories == null || categories.Count == 0)
{
// 如果没有选中任何分类,默认全选
categories = new List<string> { "a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l" };
Settings.Appearance.HitokotoCategories = categories;
}
// 构建 API URL:仅用于本次请求;null/空列表时本地采用默认全部分类,不写回 Settings,避免启动或拉取一言时触发无意义的配置持久化
var stored = Settings.Appearance.HitokotoCategories;
var categoriesForRequest = (stored == null || stored.Count == 0)
? new List<string> { "a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l" }
: stored;
var urlBuilder = new StringBuilder("https://v1.hitokoto.cn/?encode=text");
foreach (var category in categories)
foreach (var category in categoriesForRequest)
{
urlBuilder.Append($"&c={category}");
}
@@ -1267,7 +1266,11 @@ namespace Ink_Canvas
private async void ComboBoxChickenSoupSource_SelectionChanged(object sender, RoutedEventArgs e)
{
if (!isLoaded) return;
Settings.Appearance.ChickenSoupSource = ComboBoxChickenSoupSource.SelectedIndex;
int idx = ComboBoxChickenSoupSource.SelectedIndex;
if (Settings.Appearance.ChickenSoupSource == idx)
return;
Settings.Appearance.ChickenSoupSource = idx;
if (BtnHitokotoCustomize != null)
{
@@ -1317,6 +1320,9 @@ namespace Ink_Canvas
// 存储各个分类的复选框
var categoryCheckBoxes = new Dictionary<string, CheckBox>();
var savedHitokoto = Settings.Appearance.HitokotoCategories;
bool implicitAllCategories = savedHitokoto == null || savedHitokoto.Count == 0;
// 创建分类复选框
foreach (var category in categories)
{
@@ -1326,7 +1332,7 @@ namespace Ink_Canvas
Tag = category.Key,
FontSize = 13,
FontFamily = new FontFamily("Microsoft YaHei UI"),
IsChecked = Settings.Appearance.HitokotoCategories.Contains(category.Key),
IsChecked = implicitAllCategories || savedHitokoto.Contains(category.Key),
Margin = new Thickness(0, 0, 0, 8)
};
categoryCheckBoxes[category.Key] = checkBox;
@@ -1335,7 +1341,7 @@ namespace Ink_Canvas
// 全选复选框逻辑
bool isUpdatingSelectAll = false;
selectAllCheckBox.IsChecked = Settings.Appearance.HitokotoCategories.Count == categories.Count;
selectAllCheckBox.IsChecked = implicitAllCategories || savedHitokoto.Count == categories.Count;
selectAllCheckBox.Checked += (s, args) =>
{
@@ -5137,14 +5143,142 @@ namespace Ink_Canvas
#endregion
/// <summary>
/// 将 JSON 树归一化后再比较:统一数值为 double、对象属性按名称排序、纯字符串数组按字典序排序(如 hitokotoCategories 顺序与文件不一致时仍视为相同)。
/// </summary>
private static JToken NormalizeJsonForSettingsCompare(JToken token)
{
if (token == null || token.Type == JTokenType.Null)
return JValue.CreateNull();
switch (token.Type)
{
case JTokenType.Integer:
case JTokenType.Float:
return new JValue(token.Value<double>());
case JTokenType.Date:
return new JValue(token.Value<DateTime>().ToUniversalTime().ToString("o", CultureInfo.InvariantCulture));
case JTokenType.Object:
var o = (JObject)token;
var sorted = new JObject();
foreach (var p in o.Properties().OrderBy(x => x.Name, StringComparer.Ordinal))
sorted[p.Name] = NormalizeJsonForSettingsCompare(p.Value);
return sorted;
case JTokenType.Array:
var arr = (JArray)token;
var items = arr.Select(NormalizeJsonForSettingsCompare).ToList();
if (items.Count > 0)
{
if (items.TrueForAll(x => x.Type == JTokenType.String))
return new JArray(items.Cast<JValue>().OrderBy(x => x.Value<string>(), StringComparer.Ordinal));
if (items.TrueForAll(x => x.Type == JTokenType.Integer || x.Type == JTokenType.Float))
return new JArray(items.OrderBy(x => x.Value<double>()));
}
return new JArray(items);
default:
return token.DeepClone();
}
}
private static bool SettingsFileContentSemanticallyEquals(string newJson, string existingJson)
{
if (string.IsNullOrWhiteSpace(existingJson))
return false;
var a = NormalizeJsonForSettingsCompare(JToken.Parse(newJson));
var b = NormalizeJsonForSettingsCompare(JToken.Parse(existingJson));
return JToken.DeepEquals(a, b);
}
/// <summary>一言 API 官方分类字母,顺序固定,与自定义对话框一致。</summary>
private static readonly string[] HitokotoCategoryCanonicalOrder =
{ "a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l" };
private static readonly HashSet<string> HitokotoCategoryKnownKeys =
new HashSet<string>(HitokotoCategoryCanonicalOrder, StringComparer.Ordinal);
/// <summary>
/// 将 <see cref="Appearance.HitokotoCategories"/> 规范为固定顺序并去重,避免 JSON 仅因数组顺序/重复项变化而反复重写。
/// null 或空列表表示「未持久化、运行时按全部分类」语义,不改为非空列表。
/// </summary>
private static void StabilizeAppearanceHitokotoCategories()
{
var appearance = Settings?.Appearance;
if (appearance == null)
return;
var list = appearance.HitokotoCategories;
if (list == null || list.Count == 0)
return;
var normalizedTokens = list
.Select(x => x?.Trim())
.Where(x => !string.IsNullOrEmpty(x))
.ToList();
var canonical = new List<string>();
foreach (var key in HitokotoCategoryCanonicalOrder)
{
if (normalizedTokens.Any(x => string.Equals(x, key, StringComparison.Ordinal)))
canonical.Add(key);
}
foreach (var key in normalizedTokens
.Where(x => !HitokotoCategoryKnownKeys.Contains(x))
.Distinct(StringComparer.Ordinal)
.OrderBy(x => x, StringComparer.Ordinal))
{
canonical.Add(key);
}
if (canonical.Count == 0)
return;
if (list.Count != canonical.Count || !list.SequenceEqual(canonical, StringComparer.Ordinal))
appearance.HitokotoCategories = canonical;
}
/// <summary>
/// 将当前内存中的 Settings 序列化为格式化的 JSON 并写入应用程序配置文件(位于 App.RootPath 下的 Configs 目录或根设置文件)。
/// </summary>
/// <remarks>
/// 在写入前会确保目标目录/文件具有写入权限(使用 ProcessProtectionManager)。任何写入失败或异常都会被吞掉,调用方不会收到异常抛出
/// 在写入前会确保目标目录/文件具有写入权限(使用 ProcessProtectionManager)。若与磁盘已有内容语义一致则跳过写入,避免启动或其它路径多次调用时重复刷盘
/// 在 LoadSettings 执行期间会延迟到加载结束再统一写盘,避免启动流程中多次保存。
/// 任何写入失败或异常都会被吞掉,调用方不会收到异常抛出。
/// </remarks>
private static int _settingsLoadReentrancyDepth;
private static bool _settingsSavePendingDuringLoad;
/// <summary>与 <see cref="EndDeferredSettingsSaveDuringLoad"/> 配对,在 LoadSettings 的 try/finally 中使用。</summary>
private static void BeginDeferredSettingsSaveDuringLoad()
{
_settingsLoadReentrancyDepth++;
}
private static void EndDeferredSettingsSaveDuringLoad()
{
if (_settingsLoadReentrancyDepth > 0)
_settingsLoadReentrancyDepth--;
if (_settingsLoadReentrancyDepth == 0 && _settingsSavePendingDuringLoad)
{
_settingsSavePendingDuringLoad = false;
SaveSettingsToFileCore();
}
}
public static void SaveSettingsToFile()
{
if (_settingsLoadReentrancyDepth > 0)
{
_settingsSavePendingDuringLoad = true;
return;
}
SaveSettingsToFileCore();
}
private static void SaveSettingsToFileCore()
{
StabilizeAppearanceHitokotoCategories();
var text = JsonConvert.SerializeObject(Settings, Formatting.Indented);
try
{
@@ -5155,6 +5289,22 @@ namespace Ink_Canvas
}
var path = App.RootPath + settingsFileName;
if (File.Exists(path))
{
try
{
string existing = File.ReadAllText(path);
if (existing.Length > 0 && existing[0] == '\uFEFF')
existing = existing.TrimStart('\uFEFF');
if (!string.IsNullOrWhiteSpace(existing) && SettingsFileContentSemanticallyEquals(text, existing))
return;
}
catch
{
// 无法比较或解析失败时仍写入,避免丢失修复机会
}
}
ProcessProtectionManager.WithWriteAccess(path, () => File.WriteAllText(path, text));
}
catch (Exception ex) { System.Diagnostics.Debug.WriteLine(ex); }
+20 -9
View File
@@ -37,6 +37,9 @@ namespace Ink_Canvas
/// <param name="skipAutoUpdateCheck">指示是否跳过自动更新检查;为 true 时不会在加载设置后执行自动更新检测。</param>
private void LoadSettings(bool isStartup = false, bool skipAutoUpdateCheck = false)
{
BeginDeferredSettingsSaveDuringLoad();
try
{
AppVersionTextBlock.Text = Assembly.GetExecutingAssembly().GetName().Version.ToString();
try
{
@@ -450,12 +453,6 @@ namespace Ink_Canvas
: Visibility.Collapsed;
}
// 初始化HitokotoCategories,如果为空则默认全选
if (Settings.Appearance.HitokotoCategories == null || Settings.Appearance.HitokotoCategories.Count == 0)
{
Settings.Appearance.HitokotoCategories = new List<string> { "a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l" };
}
ToggleSwitchEnableQuickPanel.IsOn = Settings.Appearance.IsShowQuickPanel;
ToggleSwitchEnableSplashScreen.IsOn = Settings.Appearance.EnableSplashScreen;
@@ -636,12 +633,14 @@ namespace Ink_Canvas
}
else
{
int prev = Settings.PowerPointSettings.PPTButtonsDisplayOption;
Settings.PowerPointSettings.PPTButtonsDisplayOption = 2222;
CheckboxEnableLBPPTButton.IsChecked = true;
CheckboxEnableRBPPTButton.IsChecked = true;
CheckboxEnableLSPPTButton.IsChecked = true;
CheckboxEnableRSPPTButton.IsChecked = true;
SaveSettingsToFile();
if (prev != 2222)
SaveSettingsToFile();
}
var sops = Settings.PowerPointSettings.PPTSButtonsOption.ToString();
@@ -655,11 +654,13 @@ namespace Ink_Canvas
}
else
{
int prev = Settings.PowerPointSettings.PPTSButtonsOption;
Settings.PowerPointSettings.PPTSButtonsOption = 221;
CheckboxSPPTDisplayPage.IsChecked = true;
CheckboxSPPTHalfOpacity.IsChecked = true;
CheckboxSPPTBlackBackground.IsChecked = false;
SaveSettingsToFile();
if (prev != 221)
SaveSettingsToFile();
}
var bops = Settings.PowerPointSettings.PPTBButtonsOption.ToString();
@@ -673,11 +674,13 @@ namespace Ink_Canvas
}
else
{
int prev = Settings.PowerPointSettings.PPTBButtonsOption;
Settings.PowerPointSettings.PPTBButtonsOption = 121;
CheckboxBPPTDisplayPage.IsChecked = false;
CheckboxBPPTHalfOpacity.IsChecked = true;
CheckboxBPPTBlackBackground.IsChecked = false;
SaveSettingsToFile();
if (prev != 121)
SaveSettingsToFile();
}
PPTButtonLeftPositionValueSlider.Value = Settings.PowerPointSettings.PPTLSButtonPosition;
@@ -1322,6 +1325,14 @@ namespace Ink_Canvas
// 刷新配置文件列表
try { RefreshConfigProfileList(); } catch (Exception ex) { LogHelper.WriteLogToFile($"刷新配置文件列表失败: {ex.Message}", LogHelper.LogType.Warning); }
// 一言分类数组固定为 a–l 顺序并去重,避免仅顺序/重复导致配置反复变化
StabilizeAppearanceHitokotoCategories();
}
finally
{
EndDeferredSettingsSaveDuringLoad();
}
}
/// <summary>