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:
1 parent
184f60e558
commit
e6c292c463
7 files changed
+658
-6
No files matched your search
@@ -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`(优先级最高)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 对象 ----
|
||||
|
||||
@@ -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; }
|
||||
`;
|
||||
@@ -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, ''');
|
||||
|
||||
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, ''').replace(/"/g, '"');
|
||||
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, ''').replace(/"/g, '"');
|
||||
}
|
||||
}
|
||||
} else {
|
||||
childTitleStr = (link.title || '').replace(/^【.*?】\\s*/, '').replace(/'/g, ''').replace(/"/g, '"');
|
||||
}
|
||||
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, ''').replace(/"/g, '"') : '未知剧集';
|
||||
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);
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -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
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user