feat: 清理缓存支持按项勾选清理 (#441)

* feat: 清理缓存支持按项勾选清理

上游原版 handleClearCache() 不区分具体项,调用即一次性重置全部内存缓存(animes、episodeIds、episodeNum、lastSelectMap、searchCache、commentCache、requestHistory 及 Bangumi Data)。

本提交改为按项勾选清理:
- apis/system-api.js:handleClearCache 接收可选请求体 { items: string[] },按 clearActions 映射逐项重置;请求记录(reqRecords)与今日计数(todayReqNum)归入「请求历史记录」项连带清理;未提供请求体或解析失败时回退为全清,兼容旧路由与测试的无参调用。未知键经 key in clearActions 过滤,无注入面。
- worker.js:POST /api/cache/clear 透传请求体。
- ui/template.js:清理缓存弹窗改为 8 项可勾选清单,含全选/全不选与已选计数。
- ui/js/systemsettings.js:新增 selectAllCacheItems、updateCacheClearCount;confirmClearCache 收集勾选项发送 {items},空选拦截。
- ui/css/components.css.js:新增缓存弹窗与通用复选框样式。

收藏(favoriteCache)按上游契约不在清理范围内,worker.test.js「清理缓存保留收藏」断言仍然成立。

* feat: 优化通用复选框样式与偏移设置交互

承接清理缓存弹窗的通用复选框,继续完善前端交互(均基于上游原版):
- ui/css/components.css.js:通用复选框由 accent-color 改为 appearance 自绘,以 --theme-check-color 控制勾选色(浅色恒白、暗色恒黑,规避浅强调色在浅色模式得黑勾);以 body[data-theme] 前缀将特异性提升至 3002,压过主题规则 themes.css.js:191 对 .form-group input 背景与边框的重设(2002),使位于 .form-group 内的偏移百分比复选框与缓存复选框表现一致;补 padding:0 避免继承 .form-group input 内边距导致长方形。
- ui/css/themes.css.js:浅色与暗色模式分别定义 --theme-check-color。
- ui/js/systemsettings.js:偏移百分比复选框标签去除 white-space:nowrap,窄屏长文案可换行,复选框不被压缩。

* test: 新增 handleClearCache 按项清理测试

覆盖 handleClearCache 的全部关键路径:
- 按项选清:单选一项只清该项,其余缓存保留
- 非法键过滤:不在 clearActions 的键被忽略,不触发任何清理动作
- requestHistory 项同时清空 reqRecords 并重置 todayReqNum 为 0
- episodeNum 重置为 10001
- 全清(无 req 或畸形 body 非数组)保留 favorites
- 空数组 items 不清理任何项
- bangumiData 作为合法键被识别且独立清理

全部子测试在 node --test 下通过,整文件 36/36 通过。

* fix: 修复清理缓存原型键误放行并优化弹窗提示与计数样式

清理缓存功能:修复 handleClearCache 的原型键过滤缺陷,并优化弹窗提示与计数视觉。

- apis/system-api.js:handleClearCache 的 items 过滤由 'key in clearActions' 改为 Object.prototype.hasOwnProperty.call(clearActions, key),排除 __proto__/constructor 等原型链键误放行导致整次清理失败(success:false)。
- ui/template.js:提示文案改为「请勾选需要清理的缓存项:」,采用上游风格的全可见度清晰提示。
- ui/css/components.css.js:.cache-clear-hint 字号 13px 增至 16px、颜色由灰改为正文色,与全选/全不选按钮形成清晰层级;.cache-clear-count 增加淡中性全圆角状态标签底色,平衡左右视觉重量且刻意不像按钮。
- worker.test.js:新增原型键回归测试,固化上述修复。

* fix: 统一清理缓存项标签大小写并调整项顺序

清理缓存弹窗:统一缓存项标签写法并调整为预期顺序。

- ui/template.js:动画元数据缓存标签由「动画元数据缓存 (Bangumi Data)」改为「动画元数据缓存 (bangumiData)」,与另 7 项括号展示真实键名的写法(animes / episodeIds / episodeNum / lastSelectMap)统一,便于 UI 与代码键名对照。
- 清理项顺序调整为:搜索结果缓存、弹幕内容缓存、请求历史记录、动漫搜索缓存、动画元数据缓存、剧集ID缓存、剧集编号缓存、最后选择映射缓存。
- 各 checkbox 的 value 与 handleClearCache 的 clearActions 键名一致,无功能影响;仓库其余「Bangumi Data」均为数据源专有名词,不涉及此改动。
This commit is contained in:
Elegy233 authored and GitHub committed 2026-08-15 07:58:50 +08:00
1 parent 65ab6d0658
commit 00efe4a815
7 files changed
+364 -60

No files matched your search

+68 -42
View File
@@ -169,39 +169,62 @@ export function handleClearLogs() {
/**
* 处理清理缓存的请求
* @returns {Response} 表示操作成功的响应
* @param {Request} [req] 可选请求体 { items: string[] },用于指定待清理项;未提供时清理全部已知项
* @returns {Response} 表示操作结果的响应
*/
export async function handleClearCache() {
try {
// 清理 globals 中的缓存数据
globals.animes = [];
globals.episodeIds = [];
globals.episodeNum = 10001; // 重置为初始值
globals.lastSelectMap = new Map(); // 重新创建 Map 对象
globals.reqRecords = []; // 清空请求记录
globals.todayReqNum = 0; // 重置今日请求次数
export async function handleClearCache(req) {
// 各清理项对应的重置逻辑;请求记录与今日计数归入「请求历史记录」项
const clearActions = {
animes: () => { globals.animes = []; },
episodeIds: () => { globals.episodeIds = []; },
episodeNum: () => { globals.episodeNum = 10001; }, // 重置为初始值
lastSelectMap: () => { globals.lastSelectMap = new Map(); }, // 重新创建 Map 对象
// 清理搜索和弹幕缓存
globals.searchCache = new Map();
globals.commentCache = new Map();
globals.requestHistory = new Map();
try {
// 清理 Bangumi-Data 内存与磁盘缓存
clearBangumiDataCache(true);
// 触发异步数据重载
if (globals.useBangumiData) {
initBangumiData(globals.deployPlatform, false).catch(e => {
log("warn", `[system] [server] Bangumi-Data background reload failed: ${e.message}`);
});
searchCache: () => { globals.searchCache = new Map(); },
commentCache: () => { globals.commentCache = new Map(); },
requestHistory: () => {
globals.requestHistory = new Map();
globals.reqRecords = []; // 清空请求记录
globals.todayReqNum = 0; // 重置今日请求次数
},
bangumiData: () => {
try {
clearBangumiDataCache(true); // 清理 Bangumi-Data 内存与磁盘缓存
if (globals.useBangumiData) {
// 触发异步数据重载
initBangumiData(globals.deployPlatform, false).catch(e => {
log("warn", `[system] [server] Bangumi-Data background reload failed: ${e.message}`);
});
}
} catch (e) {
log("error", `[system] [server] Failed to clear Bangumi-Data cache: ${e.message}`);
}
} catch (e) {
log("error", `[system] [server] Failed to clear Bangumi-Data cache: ${e.message}`);
}
};
const allItems = Object.keys(clearActions);
let effectiveItems;
if (req) {
let parsed = null;
try {
parsed = await req.json();
} catch (e) {
parsed = null;
}
const items = parsed && Array.isArray(parsed.items) ? parsed.items : null;
// 仅保留自身属性键,排除 __proto__ 等原型链键,避免误放行导致整次清理失败
effectiveItems = items ? items.filter(key => Object.prototype.hasOwnProperty.call(clearActions, key)) : allItems;
} else {
effectiveItems = allItems;
}
try {
for (const key of effectiveItems) {
clearActions[key]();
}
log("info", `[system] [server] Memory cache cleared successfully`);
// 同步清理本地缓存和Redis缓存
try {
// 如果本地缓存有效,更新本地缓存
@@ -213,7 +236,7 @@ export async function handleClearCache() {
} catch (localError) {
log("warn", `[system] [server] Local cache may not be available: ${localError.message}`);
}
try {
// 如果Redis有效,更新Redis缓存
if (globals.redisValid) {
@@ -235,19 +258,22 @@ export async function handleClearCache() {
} catch (redisError) {
log("warn", `[system] [server] LocalRedis may not be available: ${redisError.message}`);
}
log("info", `[system] [server] All caches cleared successfully`);
return jsonResponse({ success: true, message: "Cache cleared successfully", clearedItems: {
animes: 0,
episodeIds: 0,
episodeNum: 10001,
lastSelectMap: 0,
searchCache: 0,
commentCache: 0,
requestHistory: 0,
reqRecords: 0,
todayReqNum: 0
}}, 200);
const clearedItems = {};
for (const key of effectiveItems) {
if (key === "requestHistory") {
clearedItems.requestHistory = 0;
clearedItems.reqRecords = 0;
clearedItems.todayReqNum = 0;
} else if (key === "episodeNum") {
clearedItems.episodeNum = 10001;
} else {
clearedItems[key] = 0;
}
}
log("info", `[system] [server] Selected caches cleared successfully`);
return jsonResponse({ success: true, message: "Cache cleared successfully", clearedItems }, 200);
} catch (error) {
log("error", `[system] [server] Cache clear failed: ${error.message}`);
return jsonResponse({ success: false, message: `Cache clear failed: ${error.message}` }, 500);
+120
View File
@@ -296,6 +296,17 @@ body[data-theme] .favorite-schedule-btn:disabled:hover {
font-size: 12px;
}
.btn-secondary {
background: var(--theme-panel-strong);
color: var(--theme-text);
border: 1px solid var(--theme-border);
}
.btn-secondary:hover {
border-color: var(--theme-accent);
color: var(--theme-accent);
}
/* ============ 配置预览 ============ */
.preview-description {
color: var(--theme-muted);
@@ -959,6 +970,115 @@ body.modal-open {
font-size: 13px;
}
/* 通用复选框:appearance 自绘控制勾选色;body[data-theme] 前缀将特异性提至 3002,压过 .form-group input 的背景/边框(2002)与尺寸/内边距(1001) */
body[data-theme] input[type="checkbox"].app-checkbox {
width: 16px;
height: 16px;
margin: 0;
padding: 0;
flex-shrink: 0;
vertical-align: middle;
cursor: pointer;
-webkit-appearance: none;
appearance: none;
position: relative;
background: var(--theme-panel-bg);
border: 1px solid var(--theme-muted);
border-radius: 3px;
}
body[data-theme] input[type="checkbox"].app-checkbox:checked {
background: var(--theme-accent);
border-color: var(--theme-accent);
}
body[data-theme] input[type="checkbox"].app-checkbox:checked::after {
content: "";
position: absolute;
left: 5px;
top: 1px;
width: 4px;
height: 9px;
border: solid var(--theme-check-color);
border-width: 0 2px 2px 0;
transform: rotate(45deg);
}
body[data-theme] input[type="checkbox"].app-checkbox:focus-visible {
outline: 2px solid var(--theme-accent-soft);
outline-offset: 1px;
}
.cache-clear-hint {
margin: 0 0 12px;
font-size: 16px;
color: var(--theme-text);
}
.cache-clear-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
}
.cache-clear-count {
font-size: 12px;
font-weight: 500;
color: var(--theme-muted);
background: color-mix(in srgb, var(--theme-muted) 10%, transparent); /* 状态标签底色,非按钮 */
padding: 5px 10px;
border-radius: 999px;
}
.cache-clear-actions {
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
}
.cache-clear-actions .btn {
flex-shrink: 0;
white-space: nowrap;
min-width: 64px;
}
.cache-clear-options {
display: flex;
flex-direction: column;
margin: 0;
border: 1px solid var(--theme-border);
border-radius: var(--app-radius-card-sm);
overflow: hidden;
background: var(--theme-panel-bg);
}
.cache-clear-item {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
cursor: pointer;
user-select: none;
padding: 9px 12px;
border-bottom: 1px solid var(--theme-border);
}
.cache-clear-item:last-child {
border-bottom: none;
}
.cache-clear-item:hover {
background: var(--theme-panel-strong);
}
.cache-clear-note {
margin: 12px 0 0;
font-size: 12px;
color: var(--theme-muted);
}
.warning-box {
background: rgba(240, 160, 96, 0.10);
border-left: 3px solid #f0a060;
+2
View File
@@ -40,6 +40,7 @@ body {
--theme-code-bg: #1a1b24;
--theme-code-text: #e2e4ea;
--theme-link: #8cb48c;
--theme-check-color: #ffffff;
color-scheme: light;
background: var(--theme-page-bg);
color: var(--theme-text);
@@ -112,6 +113,7 @@ body[data-color-scheme="dark"] {
--theme-input-bg: rgba(255, 255, 255, 0.07);
--theme-code-bg: #111118;
--theme-code-text: #e2e4ea;
--theme-check-color: #000000;
color-scheme: dark;
}
+32 -4
View File
@@ -309,9 +309,28 @@ async function importSystemConfigFile(file) {
// 显示清理缓存确认模态框
function showClearCacheModal() {
selectAllCacheItems(true); // 每次打开恢复默认全选
document.getElementById('clear-cache-modal').classList.add('active');
}
// 批量勾选或取消勾选所有缓存项,并同步已选数量
function selectAllCacheItems(checked) {
document.querySelectorAll('#clear-cache-modal input[name="cacheItem"]').forEach(cb => {
cb.checked = checked;
});
updateCacheClearCount();
}
// 刷新清理缓存弹窗中已勾选项的数量统计
function updateCacheClearCount() {
const all = document.querySelectorAll('#clear-cache-modal input[name="cacheItem"]');
const checked = document.querySelectorAll('#clear-cache-modal input[name="cacheItem"]:checked');
const countEl = document.getElementById('cache-clear-count');
if (countEl) {
countEl.textContent = '已选 ' + checked.length + ' / ' + all.length;
}
}
// 隐藏清理缓存确认模态框
function hideClearCacheModal() {
document.getElementById('clear-cache-modal').classList.remove('active');
@@ -319,6 +338,14 @@ function hideClearCacheModal() {
// 确认清理缓存
async function confirmClearCache() {
// 收集勾选的缓存项
const checkboxes = document.querySelectorAll('#clear-cache-modal input[name="cacheItem"]:checked');
const items = Array.from(checkboxes).map(cb => cb.value);
if (items.length === 0) {
customAlert('请至少选择一项要清理的缓存');
return;
}
// 检查部署平台配置
const configCheck = await checkDeployPlatformConfig();
if (!configCheck.success) {
@@ -332,12 +359,13 @@ async function confirmClearCache() {
addLog('开始清理缓存', 'info');
try {
// 调用真实的清理缓存API
// 调用真实的清理缓存API,附带勾选的待清理项
const response = await fetch(buildApiUrl('/api/cache/clear', true), { // 使用admin token
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
},
body: JSON.stringify({ items })
});
const result = await response.json();
@@ -973,9 +1001,9 @@ function renderValueInput(item) {
</div>
</div>
<div style="margin-bottom: 10px; display: flex; align-items: center; width: 100%;">
<label class="offset-label" style="display: flex; align-items: center; gap: 8px; cursor: pointer; margin: 0; white-space: nowrap;">
<label class="offset-label" style="display: flex; align-items: center; gap: 8px; cursor: pointer; margin: 0;">
启用百分比模式(按视频时长缩放全部弹幕时间)
<input type="checkbox" id="offset-use-percent" style="width: 16px; height: 16px; margin: 0; flex-shrink: 0;">
<input type="checkbox" id="offset-use-percent" class="app-checkbox">
</label>
</div>
\${offsetSources.length > 0 ? \`
+20 -13
View File
@@ -318,22 +318,29 @@ export const HTML_TEMPLATE = /* html */ `
<div class="modal" id="clear-cache-modal">
<div class="modal-content">
<div class="modal-header">
<h3>确认清理缓存</h3>
<h3>选择要清理的缓存</h3>
<button class="close-btn" onclick="hideClearCacheModal()">&times;</button>
</div>
<div class="modal-body">
<p style="margin-bottom: 20px;">确定要清理所有缓存吗?这将清除:</p>
<ul class="confirmation-list">
<li>动漫搜索缓存 (animes)</li>
<li>剧集ID缓存 (episodeIds)</li>
<li>剧集编号缓存 (episodeNum)</li>
<li>最后选择映射缓存 (lastSelectMap)</li>
<li>动画元数据缓存 (Bangumi Data)</li>
<li>搜索结果缓存</li>
<li>弹幕内容缓存</li>
<li>请求历史记录</li>
</ul>
<p style="color: #666; margin-top: 20px;">清理后可能需要重新登录</p>
<p class="cache-clear-hint">请勾选需要清理的缓存项:</p>
<div class="cache-clear-toolbar">
<span class="cache-clear-count" id="cache-clear-count"></span>
<div class="cache-clear-actions">
<button type="button" class="btn btn-secondary btn-sm" onclick="selectAllCacheItems(true)">全选</button>
<button type="button" class="btn btn-secondary btn-sm" onclick="selectAllCacheItems(false)">全不选</button>
</div>
</div>
<div class="cache-clear-options">
<label class="cache-clear-item"><input type="checkbox" class="app-checkbox" name="cacheItem" value="searchCache" checked onchange="updateCacheClearCount()"> 搜索结果缓存</label>
<label class="cache-clear-item"><input type="checkbox" class="app-checkbox" name="cacheItem" value="commentCache" checked onchange="updateCacheClearCount()"> 弹幕内容缓存</label>
<label class="cache-clear-item"><input type="checkbox" class="app-checkbox" name="cacheItem" value="requestHistory" checked onchange="updateCacheClearCount()"> 请求历史记录</label>
<label class="cache-clear-item"><input type="checkbox" class="app-checkbox" name="cacheItem" value="animes" checked onchange="updateCacheClearCount()"> 动漫搜索缓存 (animes)</label>
<label class="cache-clear-item"><input type="checkbox" class="app-checkbox" name="cacheItem" value="bangumiData" checked onchange="updateCacheClearCount()"> 动画元数据缓存 (bangumiData)</label>
<label class="cache-clear-item"><input type="checkbox" class="app-checkbox" name="cacheItem" value="episodeIds" checked onchange="updateCacheClearCount()"> 剧集ID缓存 (episodeIds)</label>
<label class="cache-clear-item"><input type="checkbox" class="app-checkbox" name="cacheItem" value="episodeNum" checked onchange="updateCacheClearCount()"> 剧集编号缓存 (episodeNum)</label>
<label class="cache-clear-item"><input type="checkbox" class="app-checkbox" name="cacheItem" value="lastSelectMap" checked onchange="updateCacheClearCount()"> 最后选择映射缓存 (lastSelectMap)</label>
</div>
<p class="cache-clear-note">清理后可能需要重新登录</p>
</div>
<div class="modal-footer">
<button class="btn btn-success" onclick="confirmClearCache()">确认清理</button>
+1 -1
View File
@@ -592,7 +592,7 @@ async function handleRequest(req, env, deployPlatform, clientIp, ctx) {
// POST /api/cache/clear - 清理缓存
if (path === "/api/cache/clear" && method === "POST") {
return handleClearCache();
return handleClearCache(req);
}
// ========== Cookie 管理 API ==========
+121
View File
@@ -1004,6 +1004,127 @@ test('worker.js API endpoints', async (t) => {
assert.doesNotThrow(() => new Function(previewJsContent));
assert.match(previewJsContent, /AUTO_MATCH_MAPPING_TABLE/);
});
await t.test('handleClearCache clears only the selected cache items', async t => {
// 各清理项对应的全局状态种子;favorites 不在清理范围内,用于验证不被误清
const seed = () => {
Globals.animes = [{ id: 1 }];
Globals.episodeIds = ['ep1'];
Globals.episodeNum = 50000;
Globals.lastSelectMap = new Map([['k', {}]]);
Globals.searchCache = new Map([['k', {}]]);
Globals.commentCache = new Map([['k', {}]]);
Globals.requestHistory = new Map([['ip', []]]);
Globals.reqRecords = [{ a: 1 }];
Globals.todayReqNum = 42;
Globals.favoriteCache = new Map([['fav', {}]]);
Globals.useBangumiData = false;
};
await t.test('single item clears only that item', async () => {
seed();
const res = await handleClearCache({ json: async () => ({ items: ['animes'] }) });
const body = await parseResponse(res);
assert.equal(body.success, true);
assert.equal(body.clearedItems.animes, 0);
assert.equal(Globals.animes.length, 0);
assert.equal(Globals.episodeIds.length, 1);
assert.equal(Globals.lastSelectMap.size, 1);
assert.equal(Globals.searchCache.size, 1);
assert.equal(Globals.commentCache.size, 1);
assert.equal(Globals.requestHistory.size, 1);
});
await t.test('invalid keys are filtered out and do not throw', async () => {
seed();
const res = await handleClearCache({ json: async () => ({ items: ['animes', 'notARealKey', 'animesX'] }) });
const body = await parseResponse(res);
assert.equal(body.success, true);
assert.equal(Globals.animes.length, 0);
assert.equal(Globals.searchCache.size, 1);
assert.equal(Globals.commentCache.size, 1);
});
await t.test('requestHistory folds reqRecords and todayReqNum', async () => {
seed();
const res = await handleClearCache({ json: async () => ({ items: ['requestHistory'] }) });
const body = await parseResponse(res);
assert.equal(body.success, true);
assert.equal(body.clearedItems.requestHistory, 0);
assert.equal(body.clearedItems.reqRecords, 0);
assert.equal(body.clearedItems.todayReqNum, 0);
assert.equal(Globals.requestHistory.size, 0);
assert.deepEqual(Globals.reqRecords, []);
assert.equal(Globals.todayReqNum, 0);
assert.equal(Globals.animes.length, 1);
});
await t.test('episodeNum resets to the initial value 10001', async () => {
seed();
const res = await handleClearCache({ json: async () => ({ items: ['episodeNum'] }) });
const body = await parseResponse(res);
assert.equal(body.success, true);
assert.equal(body.clearedItems.episodeNum, 10001);
assert.equal(Globals.episodeNum, 10001);
assert.equal(Globals.animes.length, 1);
});
await t.test('favorites are preserved across full clear', async () => {
seed();
const res = await handleClearCache();
const body = await parseResponse(res);
assert.equal(body.success, true);
assert.equal(Globals.favoriteCache.size, 1);
assert.equal(Globals.animes.length, 0);
assert.equal(Globals.episodeIds.length, 0);
assert.equal(Globals.lastSelectMap.size, 0);
assert.equal(Globals.searchCache.size, 0);
assert.equal(Globals.commentCache.size, 0);
assert.equal(Globals.requestHistory.size, 0);
assert.equal(Globals.todayReqNum, 0);
assert.deepEqual(Globals.reqRecords, []);
});
await t.test('empty items array clears nothing', async () => {
seed();
const res = await handleClearCache({ json: async () => ({ items: [] }) });
const body = await parseResponse(res);
assert.equal(body.success, true);
assert.equal(Globals.animes.length, 1);
assert.equal(Globals.searchCache.size, 1);
assert.equal(Globals.commentCache.size, 1);
});
await t.test('malformed body (non-array items) triggers full clear', async () => {
seed();
const res = await handleClearCache({ json: async () => ({ items: 'animes' }) });
const body = await parseResponse(res);
assert.equal(body.success, true);
assert.equal(Globals.animes.length, 0);
assert.equal(Globals.searchCache.size, 0);
});
await t.test('bangumiData is a recognized key and isolated from other caches', async () => {
seed();
const res = await handleClearCache({ json: async () => ({ items: ['bangumiData'] }) });
const body = await parseResponse(res);
assert.equal(body.success, true);
assert.equal(body.clearedItems.bangumiData, 0);
assert.equal(Globals.animes.length, 1);
assert.equal(Globals.searchCache.size, 1);
});
await t.test('prototype keys like __proto__ are rejected and do not break the clear', async () => {
seed();
const res = await handleClearCache({ json: async () => ({ items: ['animes', '__proto__', 'constructor', 'animes'] }) });
const body = await parseResponse(res);
assert.equal(body.success, true);
assert.equal(Globals.animes.length, 0);
assert.equal(Globals.searchCache.size, 1);
assert.equal(Globals.commentCache.size, 1);
});
});
});
// await t.test('GET /api/v2/comment/:id?format=json&duration=true should return segment duration and reuse comment cache', async () => {