feat(ui): 新增 /api/cache/animes API 接口获取最近的 animes 缓存,应用到CUSTOM_MERGE_RULES与DANMU_OFFSET配置面板中辅助填写 (#318)

* feat(api): 新增处理获取最近animes缓存列表请求函数

* feat(api): 新增 `/api/cache/animes API` 接口

* feat(model): 新增 `mergedChildren` 与 `isHiddenChild` 参数

* feat(utils): 记录合并层级

* feat(ui): 新增最近数据与`animes`缓存面板样式

* feat(ui): 新增最近数据与`animes`缓存面板功能

* feat(md): 新增GET `/api/cache/animes`说明

* fix(ui): 修复合并结果由于长英文字符串导致前端网格布被撑破的 UI 错位问题

远古问题

* fix(ui): 调整换行逻辑提升可读性

* feat(ui): 给MERGE_SOURCE_PAIRS也添加最新数据面板,供合并参考

Add button to fetch and display recent data in the UI.
This commit is contained in:
Elegy233 authored and GitHub committed 2026-05-25 10:05:32 +08:00
1 parent 184f60e558
commit e6c292c463
7 files changed
+658 -6

No files matched your search

+2 -1
View File
@@ -58,9 +58,10 @@ LogVar 弹幕 API 服务器
- `GET /api/v2/comment/:commentId?format=json&duration=true`:获取指定弹幕评论;当 `duration=true` 且返回 JSON 时,会额外附带 `videoDuration` 字段,优先返回源站时长,拿不到时返回 `0`。
- `GET /api/v2/comment?url=${videoUrl}&format=json`:通过视频URL直接获取弹幕(兼容第三方弹幕服务器格式)。
- `POST /api/v2/segmentcomment?format=json`:通过comment接口返回体中的Segment类JSON数据获取单独一个分片的弹幕数据。
- `GET /api/logs`:获取最近的日志(最多 500 行,格式为 `[时间戳] 级别: 消息`)。
- `GET /api/v2/fongmi/danmaku?name={name}&episode={episode}`:FengMi影视api。
- `GET /danmaku/api/v2/fongmi/danmaku?name={name}&episode={episode}`:兼容FengMi影视api短路径。
- `GET /api/logs`:获取最近的日志(最多 500 行,格式为 `[时间戳] 级别: 消息`)。
- `GET /api/cache/animes`:获取最近的 animes 缓存。
- **弹幕格式输出**:支持 JSON 和 XML 两种格式输出,通过以下方式配置:
- 环境变量:`DANMU_OUTPUT_FORMAT=json|xml`(默认:json)
- 查询参数:`?format=xml` 或 `?format=json`(优先级最高)
+48
View File
@@ -251,3 +251,51 @@ export function handleReqRecords() {
return jsonResponse({ records, todayReqNum }, 200);
}
/**
* 处理获取最近 animes 缓存列表的请求
* @returns {Response} 包含格式化后番剧及子源集数的 JSON 响应
*/
export function handleCacheAnimes() {
try {
const localAnimes = [...(globals.animes || [])].reverse();
// 1. 预构建一个全局映射字典,提升查找效率
const fullAnimeMap = new Map();
localAnimes.forEach(a => {
if (a?.source && a?.animeId) {
fullAnimeMap.set(`${a.source}_${a.animeId}`, a);
}
});
// 2. 视图层数据适配
const formattedData = localAnimes
.filter(anime => !anime.isHiddenChild)
.map(anime => {
// 兼容实体类与普通对象
const animeJson = typeof anime.toJson === 'function' ? anime.toJson() : { ...anime };
const { links = [], mergedChildren = [], ...rest } = animeJson;
return {
...rest,
episodes: rest.episodeCount || rest.episodes || 1,
links,
mergedChildren: mergedChildren.map(child => {
const fullChild = fullAnimeMap.get(`${child.source}_${child.animeId}`);
const fullChildJson = typeof fullChild?.toJson === 'function' ? fullChild.toJson() : fullChild;
const fullLinks = (fullChildJson?.links?.length > 0) ? fullChildJson.links : (child.links || []);
return {
...child,
episodes: child.episodeCount || child.episodes || 1,
links: fullLinks
};
})
};
});
return jsonResponse({ success: true, data: formattedData }, 200);
} catch (error) {
log("error", `[server] Fetch cache animes failed: ${error.message}`);
return jsonResponse({ success: false, message: `获取缓存失败: ${error.message}` }, 500);
}
}
+5 -2
View File
@@ -6,7 +6,8 @@ import { validateType } from "../utils/common-util.js";
export class Anime {
constructor({ animeId = 111, bangumiId = "", animeTitle = "", type = "",
typeDescription = "", imageUrl = "", startDate = "", episodeCount = 1,
rating = 0, isFavorited = true, source = "", links = [] } = {}) {
rating = 0, isFavorited = true, source = "", links = [],
mergedChildren = [], isHiddenChild = false } = {}) {
// ---- 类型检查 ----
validateType(animeId, "number");
validateType(bangumiId, "string");
@@ -20,13 +21,15 @@ export class Anime {
validateType(isFavorited, "boolean");
validateType(source, "string");
validateType(links, "array");
validateType(mergedChildren, "array");
validateType(isHiddenChild, "boolean");
// 将 links 转换为 Link 实例数组
this.links = links.map(linkData => Link.fromJson(linkData));
// 直接解构并赋值给 this
Object.assign(this, { animeId, bangumiId, animeTitle, type, typeDescription, imageUrl, startDate,
episodeCount, rating, isFavorited, source });
episodeCount, rating, isFavorited, source, mergedChildren, isHiddenChild });
}
// ---- 静态方法:从 JSON 创建 Anime 对象 ----
+204
View File
@@ -691,6 +691,13 @@ export const componentsCssContent = /* css */ `
.anime-title {
margin: 8px 0 5px;
font-size: 12px;
word-break: normal; /* 保持正常的单词换行规则,优先在空格处断句以保证可读性 */
overflow-wrap: break-word; /* 当长字符串(如多源合并后缀)超出容器时,允许在其内部强制换行 */
display: -webkit-box; /* 启用 WebKit 特有的弹性伸缩盒模型 */
-webkit-line-clamp: 5; /* 限制文本块最多显示的行数为5行 */
-webkit-box-orient: vertical; /* 设置弹性盒子的子元素垂直排列 */
overflow: hidden; /* 隐藏超出容器设定高度范围的文本内容 */
text-overflow: ellipsis; /* 文本溢出时使用省略号(...)进行截断展示 */
}
.episode-list-container {
@@ -1726,4 +1733,201 @@ export const componentsCssContent = /* css */ `
gap: 8px;
justify-content: flex-end;
}
/* 最近数据与animes缓存面板样式 */
/* 面板容器 */
.recent-data-panel {
display: none;
max-height: 350px;
overflow-y: auto;
}
.anime-cache-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.anime-cache-card {
background: #fff;
border-radius: 6px;
border: 1px solid #eaeaea;
box-shadow: 0 1px 3px rgba(0,0,0,0.04);
overflow: hidden;
}
.anime-cache-card-body {
display: flex;
gap: 12px;
align-items: center;
padding: 10px;
}
.anime-cache-cover {
width: 44px;
height: 60px;
border-radius: 4px;
background-color: #e2e3e5;
background-size: cover;
background-position: center;
flex-shrink: 0;
box-shadow: 0 1px 4px rgba(0,0,0,0.15);
}
.anime-cache-info,
.anime-cache-child-info {
flex: 1;
min-width: 0;
}
.anime-cache-title {
font-weight: 600;
font-size: 13px;
line-height: 1.4;
margin-bottom: 4px;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
word-break: break-all;
}
.anime-cache-meta {
color: #888;
font-size: 11px;
}
.anime-cache-actions,
.anime-cache-child-actions {
display: flex;
flex-direction: column;
flex-shrink: 0;
}
.anime-cache-actions { gap: 6px; }
.btn-xs {
padding: 2px 6px;
font-size: 11px;
}
/* 卡片底部标签切换栏(Tab 风格) */
.anime-cache-footer {
display: flex;
gap: 8px;
padding: 8px 10px;
background: #fdfdfd;
border-top: 1px solid #f0f0f0;
}
.cache-badge {
font-size: 11px;
padding: 4px 10px;
border-radius: 12px;
cursor: pointer;
user-select: none;
display: inline-flex;
align-items: center;
font-weight: 500;
transition: all 0.2s;
}
.cache-badge.badge-sources { background: #e3f2fd; color: #0d47a1; border: 1px solid #bbdefb; }
.cache-badge.badge-episodes { background: #e8f5e9; color: #1b5e20; border: 1px solid #c8e6c9; }
.cache-badge.active { filter: brightness(0.9); box-shadow: inset 0 1px 3px rgba(0,0,0,0.1); }
/* 折叠列表容器 */
.merged-children-container,
.episodes-list-container {
display: none;
flex-direction: column;
gap: 6px;
padding: 8px 10px;
background: #fafafa;
border-top: 1px solid #f0f0f0;
}
.episodes-list-container {
max-height: 200px;
overflow-y: auto;
}
/* 子节点卡片 */
.anime-cache-child-item {
display: flex;
flex-direction: column;
background: #fff;
padding: 8px;
border-radius: 4px;
border: 1px dashed #ccc;
}
.anime-cache-child-main {
display: flex;
gap: 10px;
align-items: center;
width: 100%;
}
.anime-cache-child-cover {
width: 33px;
height: 45px;
border-radius: 3px;
background-color: #e2e3e5;
background-size: cover;
background-position: center;
flex-shrink: 0;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
.anime-cache-child-title {
font-weight: 500;
font-size: 12px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.anime-cache-child-actions { gap: 4px; }
/* 合并映射详情 */
.child-mapping-toggle {
font-size: 10px;
color: #666;
cursor: pointer;
margin-top: 8px;
padding-top: 6px;
border-top: 1px dashed #eee;
text-align: center;
}
.child-mapping-toggle:hover {
color: #0066cc;
}
.child-mapping-container {
display: none;
flex-direction: column;
gap: 4px;
margin-top: 6px;
background: #fdfdfd;
padding: 6px;
border-radius: 4px;
border: 1px solid #f0f0f0;
max-height: 150px;
overflow-y: auto;
}
.mapping-row {
font-size: 10px;
display: flex;
gap: 6px;
align-items: flex-start;
}
.mapping-status { flex-shrink: 0; font-weight: bold; }
.mapping-status.success { color: #28a745; }
.mapping-status.warning { color: #d39e00; }
.mapping-text { color: #555; word-break: break-all; }
`;
+374 -2
View File
@@ -522,6 +522,17 @@ function renderValueInput(item) {
}).join('')}
</div>
</div>
\${currentKey === 'MERGE_SOURCE_PAIRS' ? \`
<div style="margin-top: 15px; margin-bottom: 8px;">
<button type="button" class="btn btn-primary btn-sm" onclick="fetchAndShowRecentData()">
📊 查看最近数据
</button>
</div>
<div id="recent-data-panel" class="recent-data-panel">
<div id="recent-data-list"></div>
</div>
\` : ''}
\`;
// 设置拖动事件
@@ -614,10 +625,17 @@ function renderValueInput(item) {
container.innerHTML = \`
<label>变量值</label>
<textarea id="text-value" placeholder="格式:剧名:秒 或 剧名/S01:秒 或 剧名@来源:秒 或 剧名/S01/E01@来源%:秒" rows="\${rows}" class="text-monospace">\${value}</textarea>
<div style="margin-top: 8px;">
<div style="margin-top: 8px; display: flex; gap: 10px;">
<button type="button" class="btn btn-primary btn-sm" id="offset-rule-toggle" onclick="toggleOffsetRulePanel()">
添加规则
</button>
<button type="button" class="btn btn-primary btn-sm" onclick="fetchAndShowRecentData()">
📊 查看最近数据
</button>
</div>
<div id="recent-data-panel" class="recent-data-panel">
<div class="form-help" style="margin: 0 0 8px 0;">点击下方按钮可快捷填入规则表单:</div>
<div id="recent-data-list"></div>
</div>
<div id="offset-rule-panel" class="offset-rule-panel">
<div class="form-help" style="margin: 0 0 8px 0;">季和集不填则对所有季/集生效</div>
@@ -725,10 +743,17 @@ function renderValueInput(item) {
container.innerHTML = \`
<label>变量值</label>
<textarea id="text-value" placeholder="格式:副源 -> 主源 | 路由规则 或 副源 × 主源" rows="\${rows}" class="text-monospace">\${value || ''}</textarea>
<div style="margin-top: 8px;">
<div style="margin-top: 8px; display: flex; gap: 10px;">
<button type="button" class="btn btn-primary btn-sm" id="merge-rule-toggle" onclick="toggleMergeRulePanel()">
添加规则
</button>
<button type="button" class="btn btn-primary btn-sm" onclick="fetchAndShowRecentData()">
📊 查看最近数据
</button>
</div>
<div id="recent-data-panel" class="recent-data-panel">
<div class="form-help" style="margin: 0 0 8px 0;">点击下方按钮可快捷填入规则表单:</div>
<div id="recent-data-list"></div>
</div>
<div id="merge-rule-panel" class="offset-rule-panel">
<div class="offset-form-row">
@@ -2450,4 +2475,351 @@ async function verifyAiConnection() {
btn.disabled = false;
}
}
/* ========================================
最近数据与animes缓存面板功能
======================================== */
// 统一切换卡片内的折叠区域
function toggleCardSection(btnEl, targetSelector, openText, closeText) {
if (!btnEl) return;
const card = btnEl.closest('.anime-cache-card');
if (!card) return;
const container = card.querySelector(targetSelector);
if (!container) return;
const isHidden = window.getComputedStyle(container).display === 'none';
if (isHidden) {
container.style.display = 'flex';
btnEl.innerHTML = closeText;
btnEl.classList.add('active');
} else {
container.style.display = 'none';
btnEl.innerHTML = openText;
btnEl.classList.remove('active');
}
}
// 切换子节点内的映射详情
function toggleMapping(btnEl) {
if (!btnEl) return;
const parentItem = btnEl.closest('.anime-cache-child-item');
if (!parentItem) return;
const container = parentItem.querySelector('.child-mapping-container');
if (!container) return;
const isHidden = window.getComputedStyle(container).display === 'none';
if (isHidden) {
container.style.display = 'flex';
btnEl.innerHTML = '📊 收起映射详情';
btnEl.classList.add('active');
} else {
container.style.display = 'none';
btnEl.innerHTML = '📊 展开映射详情';
btnEl.classList.remove('active');
}
}
// 快捷数据面板业务逻辑
async function fetchAndShowRecentData() {
const panel = document.getElementById('recent-data-panel');
const listContainer = document.getElementById('recent-data-list');
if (!panel || !listContainer) return;
if (window.getComputedStyle(panel).display !== 'none') {
panel.style.display = 'none';
return;
}
panel.style.display = 'block';
listContainer.innerHTML = '<span class="loading-spinner-small"></span> 数据加载中...';
try {
const response = await fetch(buildApiUrl('/api/cache/animes', true));
const result = await response.json();
if (result.success && result.data && result.data.length > 0) {
renderAnimeCachePanel(result.data, listContainer);
} else {
listContainer.innerHTML = '<div class="text-gray font-size-12" style="padding: 10px 0;">缓存中暂无番剧数据,请先通过客户端请求弹幕接口以生成缓存。</div>';
}
} catch (error) {
listContainer.innerHTML = \`<div class="text-red font-size-12">请求失败: \${error.message}</div>\`;
}
}
// 渲染animes缓存面板 (含集数解析与映射详情)
function renderAnimeCachePanel(data, listContainer) {
const keyInput = document.getElementById('env-key');
if (!listContainer || !keyInput) return;
const currentKey = keyInput.value;
// 内部辅助函数:生成操作按钮
const generateButtons = (title, source) => {
if (currentKey === 'CUSTOM_MERGE_RULES') {
return \`
<button type="button" class="btn btn-sm btn-xs" onclick="fillMergeEntity('sec', '\${title}', '\${source}')">设为副</button>
<button type="button" class="btn btn-sm btn-primary btn-xs" onclick="fillMergeEntity('prim', '\${title}', '\${source}')">设为主</button>
\`;
} else if (currentKey === 'DANMU_OFFSET') {
return \`
<button type="button" class="btn btn-sm btn-primary btn-xs" onclick="fillOffsetEntity('\${title}', '\${source}')">填入</button>
\`;
}
return '';
};
// 内部辅助函数:清洗标题
const cleanTitleStr = (rawTitle) => rawTitle.replace(/\\s*from\\s+.*$/i, '').trim().replace(/'/g, '&apos;');
let html = '<div class="anime-cache-list">';
data.forEach(item => {
const cleanTitle = cleanTitleStr(item.animeTitle);
const coverStyle = item.imageUrl ? \`background-image: url('\${item.imageUrl}');\` : '';
// 1. 构建合并子源模块
let childrenHtml = '';
let childrenCount = 0;
if (item.mergedChildren && item.mergedChildren.length > 0) {
childrenCount = item.mergedChildren.length;
const childItems = item.mergedChildren.map(child => {
const childCleanTitle = cleanTitleStr(child.animeTitle);
const childCoverStyle = child.imageUrl ? \`background-image: url('\${child.imageUrl}');\` : '';
// 解析映射数据并按匹配状态排序
let mappingHtml = '';
if (item.links && item.links.length > 0) {
const parsedRows = [];
item.links.forEach((link, idx) => {
const urlStr = String(link.url || '');
const hasMainMatch = urlStr.includes(item.source + ':') || !urlStr.includes(':');
const hasChildMatch = urlStr.includes(child.source + ':');
if (!hasMainMatch && !hasChildMatch) return;
let mainSide = '(主源越界)';
if (hasMainMatch) {
const cleanMainEpTitle = (link.title || '未知剧集').replace(/^【.*?】\\s*/, '').replace(/'/g, '&apos;').replace(/"/g, '&quot;');
mainSide = \`【\${item.source}】\${cleanMainEpTitle}\`;
}
let childSide = '(副源缺失)';
let childNum = NaN;
if (hasChildMatch) {
const regex = new RegExp(child.source + ':([^$]+)');
const match = urlStr.match(regex);
let childTitleStr = '(源ID未知)';
if (match) {
const childId = match[1];
childTitleStr = \`(源ID: \${childId})\`;
if (child.links && child.links.length > 0) {
const childLink = child.links.find(l => String(l.url) === String(childId));
if (childLink && childLink.title) {
childTitleStr = childLink.title.replace(/^【.*?】\\s*/, '').replace(/'/g, '&apos;').replace(/"/g, '&quot;');
}
}
} else {
childTitleStr = (link.title || '').replace(/^【.*?】\\s*/, '').replace(/'/g, '&apos;').replace(/"/g, '&quot;');
}
childSide = \`【\${child.source}】\${childTitleStr}\`;
const numMatch = childTitleStr.match(/\\d+/);
if (numMatch) {
childNum = parseInt(numMatch[0], 10);
}
}
let rowHtml = '';
if (hasMainMatch && hasChildMatch) {
rowHtml = \`<div class="mapping-row"><span class="mapping-status success">✓ 匹配</span> <span class="mapping-text">\${mainSide} ↔ \${childSide}</span></div>\`;
} else {
rowHtml = \`<div class="mapping-row"><span class="mapping-status warning">✗ 落单</span> <span class="mapping-text">\${mainSide} ↔ \${childSide}</span></div>\`;
}
parsedRows.push({
originalIndex: idx,
hasChildMatch: hasChildMatch,
childNum: childNum,
html: rowHtml
});
});
const matchedRows = parsedRows.filter(r => r.hasChildMatch).sort((a, b) => {
if (!isNaN(a.childNum) && !isNaN(b.childNum)) {
return a.childNum - b.childNum;
}
return a.originalIndex - b.originalIndex;
});
const lonelyRows = parsedRows.filter(r => !r.hasChildMatch).sort((a, b) => a.originalIndex - b.originalIndex);
const finalRows = [...matchedRows, ...lonelyRows];
const mappingRowsHtml = finalRows.map(r => r.html).join('');
if (mappingRowsHtml) {
mappingHtml = \`
<div class="child-mapping-toggle" onclick="toggleMapping(this)">📊 展开映射详情</div>
<div class="child-mapping-container">
\${mappingRowsHtml}
</div>
\`;
}
}
return \`
<div class="anime-cache-child-item">
<div class="anime-cache-child-main">
<div class="anime-cache-child-cover" style="\${childCoverStyle}"></div>
<div class="anime-cache-child-info">
<div class="anime-cache-child-title" title="\${child.animeTitle}">\${childCleanTitle}</div>
<div class="anime-cache-meta">[\${child.source}] (\${child.episodes}集)</div>
</div>
<div class="anime-cache-child-actions">
\${generateButtons(childCleanTitle, child.source)}
</div>
</div>
\${mappingHtml}
</div>
\`;
}).join('');
childrenHtml = \`
<div class="merged-children-container">
\${childItems}
</div>
\`;
}
// 2. 构建剧集参考列表模块
let episodesHtml = '';
let episodesCount = 0;
if (item.links && item.links.length > 0) {
episodesCount = item.links.length;
const epItems = item.links.map(link => {
const safeTitle = link.title ? link.title.replace(/'/g, '&apos;').replace(/"/g, '&quot;') : '未知剧集';
return \`
<div class="anime-cache-child-item" style="padding: 6px;">
<div class="anime-cache-child-main">
<div class="anime-cache-child-info">
<div class="anime-cache-child-title" title="\${safeTitle}">\${safeTitle}</div>
</div>
</div>
</div>
\`;
}).join('');
episodesHtml = \`
<div class="episodes-list-container">
\${epItems}
</div>
\`;
}
// 3. 构建卡片底部专属切换栏 (Tab Bar)
let footerHtml = '';
if (childrenCount > 0 || episodesCount > 0) {
let badges = '';
if (episodesCount > 0) {
badges += \`<div class="cache-badge badge-episodes" onclick="toggleCardSection(this, '.episodes-list-container', '📺 \${episodesCount} 个剧集', '📺 收起剧集')">📺 \${episodesCount} 个剧集</div>\`;
}
if (childrenCount > 0) {
badges += \`<div class="cache-badge badge-sources" onclick="toggleCardSection(this, '.merged-children-container', '🔗 \${childrenCount} 个被合并源', '🔗 收起被合并源')">🔗 \${childrenCount} 个被合并源</div>\`;
}
footerHtml = \`<div class="anime-cache-footer">\${badges}</div>\`;
}
// 4. 组装完整卡片
html += \`
<div class="anime-cache-card">
<div class="anime-cache-card-body">
<div class="anime-cache-cover" style="\${coverStyle}"></div>
<div class="anime-cache-info">
<div class="anime-cache-title" title="\${item.animeTitle}">\${cleanTitle}</div>
<div class="anime-cache-meta">[\${item.source}] (\${item.episodes}集)</div>
</div>
<div class="anime-cache-actions">
\${generateButtons(cleanTitle, item.source)}
</div>
</div>
\${footerHtml}
\${childrenHtml}
\${episodesHtml}
</div>
\`;
});
html += '</div>';
listContainer.innerHTML = html;
}
/* ========================================
表单填充快捷操作功能
======================================== */
// 内部辅助函数:输入框视觉反馈统一处理
function applyInputFeedback(inputEl) {
if (!inputEl) return;
const oldBorder = inputEl.style.borderColor;
inputEl.style.borderColor = '#28a745';
setTimeout(() => inputEl.style.borderColor = oldBorder, 800);
inputEl.focus();
}
// 表单填充逻辑:合并映射表
function fillMergeEntity(type, title, source) {
const panel = document.getElementById('merge-rule-panel');
if (panel && window.getComputedStyle(panel).display === 'none') {
toggleMergeRulePanel();
}
const inputId = type === 'sec' ? 'merge-sec-entity' : 'merge-prim-entity';
const inputEl = document.getElementById(inputId);
if (inputEl) {
inputEl.value = \`\${title}@\${source}\`;
applyInputFeedback(inputEl);
setMergeFocus(type);
}
}
// 表单填充逻辑:弹幕偏移
function fillOffsetEntity(title, source) {
const panel = document.getElementById('offset-rule-panel');
if (panel && window.getComputedStyle(panel).display === 'none') {
toggleOffsetRulePanel();
}
// 视图层数据清洗:去除年份和类型后缀
const cleanTitle = title
.replace(/[\\u200B-\\u200F\\uFEFF]/g, '')
.replace(/\\s*[((〔\\[]\\s*[0-90-9]{4}\\s*年?\\s*[))〕\\]]/g, '')
.replace(/(.+?)\\s*【[^】]+】$/, '$1')
.trim();
const inputEl = document.getElementById('offset-anime');
if (inputEl) {
inputEl.value = cleanTitle;
// 基于 data-value 属性匹配并选中对应来源标签
const sourceTags = document.querySelectorAll('#offset-sources .offset-source-tag');
if (sourceTags.length > 0) {
sourceTags.forEach(el => el.classList.remove('selected'));
const targetTag = Array.from(sourceTags).find(el => el.dataset.value === source);
if (targetTag) targetTag.classList.add('selected');
}
applyInputFeedback(inputEl);
}
}
`;
+19
View File
@@ -2827,6 +2827,22 @@ async function processMergeTask(params) {
mappingEntries.sort((a, b) => a.idx - b.idx);
log("info", `${logPrefix} [${secSource}] 映射详情:\n${mappingEntries.map(e => e.text).join('\n')}`);
}
// ── 记录合并层级 ──────────────────────────────────────
derivedAnime.mergedChildren = derivedAnime.mergedChildren || [];
// 1. 将副源数据存入全新组合对象的 mergedChildren 集合中
if (!derivedAnime.mergedChildren.some(c => String(c.animeId) === String(match.animeId) && c.source === match.source)) {
derivedAnime.mergedChildren.push({
animeId: match.animeId,
animeTitle: match.animeTitle,
source: match.source,
episodes: derivedMatch.links ? derivedMatch.links.length : 0,
imageUrl: match.imageUrl || ''
});
}
// 2. 在全局缓存中将“被合并的原始副源”标记为隐藏
const cachedSec = globals.animes.find(a => String(a.animeId) === String(match.animeId) && a.source === match.source);
if (cachedSec) cachedSec.isHiddenChild = true;
// ── 合集进度双向写入 ──────────────────────────────────────
// 支持主源为合集与副源为合集两种情况,为链式关联的下一次切片铺路
@@ -2913,6 +2929,9 @@ async function processMergeTask(params) {
return null;
}
generatedSignatures.add(signature);
// 在全局缓存中将“被取代的原始主源”标记为隐藏
const cachedPrim = globals.animes.find(a => String(a.animeId) === String(pAnime.animeId) && a.source === pAnime.source);
if (cachedPrim) cachedPrim.isHiddenChild = true;
// 标记消费:合集 ID 仅标记全局消费(保留组内复用能力),普通 ID 标记两级消费
for (let i = 1; i < contentSignatureParts.length; i++) {
+6 -1
View File
@@ -8,7 +8,7 @@ import AIClient from './utils/ai-util.js';
import { initBangumiData } from "./utils/bangumi-data-util.js";
import { getBangumi, getComment, getCommentByUrl, getSegmentComment, matchAnime, searchAnime, searchEpisodes } from "./apis/dandan-api.js";
import { getFongmiDanmaku } from "./apis/clients/fongmi-api.js";
import { handleConfig, handleUI, handleLogs, handleClearLogs, handleDeploy, handleClearCache, handleReqRecords } from "./apis/system-api.js";
import { handleConfig, handleUI, handleLogs, handleClearLogs, handleDeploy, handleClearCache, handleReqRecords, handleCacheAnimes } from "./apis/system-api.js";
import { handleSetEnv, handleAddEnv, handleDelEnv, handleAiVerify } from "./apis/env-api.js";
import { Segment } from "./models/dandan-model.js"
import {
@@ -514,6 +514,11 @@ async function handleRequest(req, env, deployPlatform, clientIp, ctx) {
return handleDeploy();
}
// GET /api/cache/animes - 获取最近 animes 缓存
if (path === "/api/cache/animes" && method === "GET") {
return handleCacheAnimes();
}
// POST /api/cache/clear - 清理缓存
if (path === "/api/cache/clear" && method === "POST") {
return handleClearCache();