Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 106 additions & 8 deletions plugin/plugins/galgame_plugin/static/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -691,6 +691,8 @@ let autoRefreshIntervalMs = AUTO_REFRESH_INTERVAL_MS;
let activeInstallTab = 'rapidocr';
let settingsDirty = false;
let settingsSaveInFlight = false;
let pendingModeSelection = '';
let modeSaveRequestId = 0;
let settingsAutosaveTimer = null;
let flashTimer = null;
let flashToken = 0;
Expand Down Expand Up @@ -2953,6 +2955,8 @@ function renderInstallTaskState(kind) {

function renderPluginUnavailable(error) {
latestStatus = null;
pendingModeSelection = '';
updateModeSwitchControl('', { ready: false });
const pluginNotStarted = uiT('ui.diag.plugin_not_started.title', '插件尚未启动');
const message = error instanceof Error ? error.message : String(error || pluginNotStarted);
document.getElementById('summaryText').textContent = pluginNotStarted;
Expand Down Expand Up @@ -3197,17 +3201,74 @@ async function restoreTesseractInstallState() {
await restoreInstallState('tesseract');
}

function updateModeSwitchControl(currentMode, { ready = true } = {}) {
const modeSwitchEl = document.getElementById('modeSwitch');
if (!modeSwitchEl) {
return;
}
document.querySelectorAll('#modeSwitch .mode-btn').forEach((btn) => {
btn.classList.toggle('active', ready && btn.dataset.mode === currentMode);
btn.disabled = !ready;
btn.setAttribute('aria-disabled', ready ? 'false' : 'true');
});
modeSwitchEl.dataset.active = ready ? currentMode : '';
modeSwitchEl.dataset.ready = ready ? 'true' : 'false';
if (!ready) {
modeSwitchEl.style.removeProperty('--indicator-left');
modeSwitchEl.style.removeProperty('--indicator-width');
return;
}
const activeBtn = modeSwitchEl.querySelector('.mode-btn.active');
if (activeBtn) {
const sr = modeSwitchEl.getBoundingClientRect();
const br = activeBtn.getBoundingClientRect();
modeSwitchEl.style.setProperty('--indicator-left', `${br.left - sr.left}px`);
modeSwitchEl.style.setProperty('--indicator-width', `${br.width}px`);
}
}

function updateSummaryMode(currentMode) {
const summaryNode = document.getElementById('summaryText');
if (!summaryNode) {
return;
}
if (latestStatus) {
summaryNode.textContent = buildStatusSummaryText({
...latestStatus,
mode: currentMode,
});
return;
}
summaryNode.textContent = uiTf('ui.summary.mode_part', '模式:{mode}', {
mode: modeLabel(currentMode, currentMode),
});
}

function clearPendingModeSelection(mode) {
if (!pendingModeSelection || (mode && pendingModeSelection !== mode)) {
return;
}
pendingModeSelection = '';
if (latestStatus) {
renderStatus(latestStatus);
}
}

function renderStatus(status) {
latestStatus = status;
document.getElementById('summaryText').textContent = buildStatusSummaryText(status);
syncSettingsValue('modeSelect', status.mode || 'companion');
const statusMode = status.mode || 'companion';
if (pendingModeSelection && statusMode === pendingModeSelection) {
pendingModeSelection = '';
}
const currentMode = pendingModeSelection || statusMode;
syncSettingsValue('modeSelect', currentMode);
syncSettingsChecked('pushToggle', Boolean(status.push_notifications));
syncSettingsValue('advanceSpeedSelect', status.advance_speed || 'medium');

const currentMode = status.mode || 'companion';
document.querySelectorAll('#modeSwitch .mode-btn').forEach((btn) => {
btn.classList.toggle('active', btn.dataset.mode === currentMode);
document.getElementById('summaryText').textContent = buildStatusSummaryText({
...status,
mode: currentMode,
});
Comment on lines +3263 to 3270
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep modeSelect in sync with optimistic mode while pending

When pendingModeSelection is set, renderStatus renders the UI summary/switch using currentMode but the form control is still synchronized from status.mode earlier in the function, so the visible mode and #modeSelect can diverge during stale refreshes. In that window, any later saveMode() call (for example from changing speed/push settings or autosave) will read the stale #modeSelect value and send the previous mode back to galgame_set_mode, unintentionally reverting the user's just-selected mode.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 85d79be — verified the race trace:

  1. 用户点 mode-btn → modeSelect.value = NEW, pendingModeSelection = NEW, settingsDirty = true, saveMode() 派发
  2. saveMode 成功后 settingsDirty = false; settingsSaveInFlight = false; await refreshAll(...)
  3. During that await, 两个 flag 都已清掉 → 任何 polling refresh 抢跑 renderStatus 用 stale status, syncSettingsValue("modeSelect", STALE) 不再被 shouldPreserveSettingsControls() 挡 → #modeSelect.value 悄悄回到 STALE
  4. UI 因 pendingModeSelection 仍显示 NEW, 但 form control 已 stale → 下次 autosave (任何控件触发) 读到 stale modeSelect → 静默 revert

修法:把 currentMode = pendingModeSelection || statusMode 的计算前移到 syncSettingsValue 之前,syncSettingsValue("modeSelect", currentMode),让 form control 永远跟可见 UI / 乐观状态一致。这样即便上面的 race window 命中,sync 进 modeSelect 的也是 NEW(pending 还在)而不是 STALE。

updateModeSwitchControl(currentMode);
const currentSpeed = status.advance_speed || 'medium';
document.querySelectorAll('#speedSwitch .speed-btn').forEach((btn) => {
btn.classList.toggle('active', btn.dataset.speed === currentSpeed);
Expand Down Expand Up @@ -5613,6 +5674,7 @@ function scheduleSettingsAutosave() {
}

async function saveMode({ auto = false } = {}) {
let modeCommitted = false;
clearSettingsAutosaveTimer();
const mode = document.getElementById('modeSelect').value;
const pushNotifications = document.getElementById('pushToggle').checked;
Expand All @@ -5627,21 +5689,31 @@ async function saveMode({ auto = false } = {}) {
const visionMaxImagePx = Number(visionMaxRaw || 768);
if (!['auto', 'memory_reader', 'ocr_reader'].includes(readerMode)) {
setFlash(uiT('ui.flash.invalid_reader_mode', '文本读取模式无效。'), 'error');
clearPendingModeSelection(mode);
return;
}
if (!Number.isFinite(ocrPollInterval) || ocrPollInterval < 0.5 || ocrPollInterval > 10) {
setFlash(uiT('ui.flash.invalid_ocr_interval', 'OCR/DXcam 识别间隔必须在 0.5 到 10 秒之间。'), 'error');
clearPendingModeSelection(mode);
return;
}
if (!['interval', 'after_advance'].includes(ocrTriggerMode)) {
setFlash(uiT('ui.flash.invalid_ocr_trigger', 'OCR 触发方式无效。'), 'error');
clearPendingModeSelection(mode);
return;
}
if (!Number.isFinite(visionMaxImagePx) || visionMaxImagePx < 64 || visionMaxImagePx > 2048) {
setFlash(uiT('ui.flash.invalid_vision_max', 'Vision 最大边长必须在 64 到 2048 之间。'), 'error');
clearPendingModeSelection(mode);
return;
}
const requestId = ++modeSaveRequestId;
try {
if (mode && (!latestStatus || latestStatus.mode !== mode)) {
pendingModeSelection = mode;
updateModeSwitchControl(mode);
updateSummaryMode(mode);
}
settingsSaveInFlight = true;
updateSettingsDirtyHint(auto ? uiT('ui.pending.auto_saving', '正在自动保存...') : uiT('ui.pending.saving', '保存中...'));
setFlash(auto ? uiT('ui.flash.auto_saving_settings', '正在自动保存设置...') : uiT('ui.flash.saving_settings', '正在保存设置...'), 'info');
Expand All @@ -5651,6 +5723,7 @@ async function saveMode({ auto = false } = {}) {
advance_speed: advanceSpeed,
reader_mode: readerMode,
});
modeCommitted = true;
await callPlugin('galgame_set_ocr_timing', {
poll_interval_seconds: ocrPollInterval,
trigger_mode: ocrTriggerMode,
Expand All @@ -5660,16 +5733,34 @@ async function saveMode({ auto = false } = {}) {
vision_enabled: visionEnabled,
vision_max_image_px: Math.round(visionMaxImagePx),
});
if (requestId !== modeSaveRequestId) {
return;
}
setFlash(auto ? uiT('ui.flash.settings_auto_saved', '设置已自动保存') : uiT('ui.flash.settings_saved', '设置已保存'), 'success');
settingsDirty = false;
settingsSaveInFlight = false;
updateSettingsDirtyHint();
await refreshAll({ preserveFlash: true, forceInsights: true, forceRefresh: true });
} catch (error) {
if (requestId !== modeSaveRequestId) {
console.error('[galgame] stale saveMode error suppressed', error);
return;
}
if (modeCommitted) {
try {
await refreshAll({ preserveFlash: true, forceRefresh: true });
} catch (refreshError) {
console.error('[galgame] mode save reconcile refresh failed', refreshError);
}
} else {
clearPendingModeSelection(mode);
}
setFlash(error instanceof Error ? error.message : String(error), 'error');
} finally {
settingsSaveInFlight = false;
updateSettingsDirtyHint();
if (requestId === modeSaveRequestId) {
settingsSaveInFlight = false;
updateSettingsDirtyHint();
}
}
}

Expand Down Expand Up @@ -6750,11 +6841,18 @@ document.querySelectorAll('.install-tab').forEach((btn) => {

document.querySelectorAll('#modeSwitch .mode-btn').forEach((btn) => {
btn.addEventListener('click', () => {
const modeSwitchEl = document.getElementById('modeSwitch');
if (!modeSwitchEl || modeSwitchEl.dataset.ready !== 'true') {
return;
}
const mode = btn.dataset.mode;
const select = document.getElementById('modeSelect');
if (select && mode) {
select.value = mode;
select.dispatchEvent(new Event('change'));
pendingModeSelection = mode;
updateModeSwitchControl(mode);
updateSummaryMode(mode);
saveMode().catch((error) => { console.error('[galgame] async action failed', error); });
}
});
Expand Down
118 changes: 111 additions & 7 deletions plugin/plugins/galgame_plugin/static/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -1209,10 +1209,16 @@ button.active {
}

.flash-message {
margin-top: 14px;
position: fixed;
top: 18px;
right: 24px;
z-index: 1100;
width: min(420px, calc(100vw - 32px));
Comment thread
coderabbitai[bot] marked this conversation as resolved.
margin: 0;
padding: 12px 14px;
border-radius: var(--radius-sm);
font-size: 14px;
box-shadow: 0 12px 30px rgba(31, 42, 51, 0.14);
}

.flash-message.success {
Expand Down Expand Up @@ -1918,16 +1924,112 @@ body:not(.onboarding-active) #onboardingView {
align-items: center;
}

.mode-switch,
.speed-switch {
/* ---- mode-switch (pill segmented control) ---- */

.mode-switch {
position: relative;
display: flex;
gap: 2px;
padding: 4px;
border-radius: 999px;
background: rgba(31, 42, 51, 0.06);
isolation: isolate;
}

.mode-switch::before {
content: '';
position: absolute;
top: 4px;
left: var(--indicator-left, 76px);
width: var(--indicator-width, 72px);
height: calc(100% - 8px);
border-radius: 999px;
background: #fff;
box-shadow: 0 2px 8px rgba(31, 42, 51, 0.1), 0 1px 2px rgba(31, 42, 51, 0.06);
transition: left 300ms cubic-bezier(0.4, 0, 0.2, 1),
width 300ms cubic-bezier(0.4, 0, 0.2, 1);
opacity: 0;
z-index: 0;
pointer-events: none;
}

.mode-switch[data-ready="true"]::before {
opacity: 1;
}

.mode-btn {
position: relative;
padding: 8px 16px;
border-radius: 999px;
background: transparent;
color: var(--muted);
font-size: 13px;
border: none;
cursor: pointer;
transition: color 200ms ease;
z-index: 1;
white-space: nowrap;
-webkit-user-select: none;
user-select: none;
}

.mode-btn:hover {
color: var(--ink);
}

.mode-btn.active {
background: transparent;
box-shadow: none;
}

.mode-btn:active {
transform: scale(0.95);
transition: transform 100ms ease;
}

.mode-btn:focus-visible {
outline: 2px solid var(--brand);
outline-offset: 2px;
}

/* ---- Mode accents ---- */

.mode-btn[data-mode="silent"].active {
color: var(--muted);
}

.mode-switch[data-active="silent"]::before {
background: #f0f0f0;
box-shadow: 0 2px 8px rgba(31, 42, 51, 0.08);
}

.mode-btn[data-mode="companion"].active {
color: var(--brand-strong);
}

.mode-switch[data-active="companion"]::before {
background: rgba(14, 138, 124, 0.1);
box-shadow: 0 2px 8px rgba(14, 138, 124, 0.12);
}

.mode-btn[data-mode="choice_advisor"].active {
color: var(--accent);
}

.mode-switch[data-active="choice_advisor"]::before {
background: rgba(210, 95, 69, 0.08);
box-shadow: 0 2px 8px rgba(210, 95, 69, 0.12);
}

/* ---- speed-switch ---- */

.speed-switch {
gap: 4px;
padding: 4px;
border-radius: 999px;
background: rgba(31, 42, 51, 0.06);
}

.mode-btn,
.speed-btn {
padding: 8px 16px;
border-radius: 999px;
Expand All @@ -1939,12 +2041,10 @@ body:not(.onboarding-active) #onboardingView {
transition: background 150ms ease, color 150ms ease;
}

.mode-btn:hover,
.speed-btn:hover {
background: rgba(255, 255, 255, 0.5);
}

.mode-btn.active,
.speed-btn.active {
background: #fff;
color: var(--brand-strong);
Expand Down Expand Up @@ -2156,9 +2256,13 @@ body:not(.onboarding-active) #onboardingView {
align-items: stretch;
}

.mode-switch,
.mode-switch {
align-self: flex-start;
}

.speed-switch {
flex-direction: column;
align-self: stretch;
}

.primary-diagnosis-actions,
Expand Down
Loading