实现合并源功能的智能结果合并,适配自动匹配优选配置和前端使用 (#167)
* envs.js:为平台排序支持合并源 同时更改MERGE_SOURCE_PAIRS为multi-select,为后续前端更新做准备 * dandan-api.js:更新平台排序相关函数,更改排序配置决定函数适配复杂的合并源场景 应该大概没有删减什么,新的决定函数是命中计数权重算法 * merge-util.js:合并逻辑增加主源轮替功能,实现结果智能合并,不再硬性要求主源 a&b&c&d合并,a无结果拿b、c、d对比合并,b无结果拿c、d…… * components.css.js:增加合并模式相关样式 * systemsettings.js:增加multi-select合并模式相关逻辑 * README.md:更新源合并配置说明 * .env.example:更新源合并相关说明 * animeko.js:增加自动降级服务器地址 * animeko.js:降低相似度阈值到0.6(撤回更改) * animeko.js:支持剧集翻页、过滤结果前先清洗文本 * merge-util.js:健壮对比函数 1.修复“犬夜叉 超越时代的思念”意外与“犬夜叉”匹配,新增主副标题的结构检测。 2.修复"犬夜叉 超越时代的思念(2001)【动漫】from youku",不能与"犬夜叉 穿越时代的思念(2001)【剧场版】from dandan"匹配,新增主副标题如果匹配上可以无视剧场版标识冲突。 3.新增验证合并覆盖率是否合规,如果成功合并集远远小于总集则视为异常驳回关联。 让我看看还有什么问题.jpg * animeko.js:得利于清洗文本,过滤使用的相似度阀值可以提高到0.9 * bilibili.js:条目原名也扔给common-util工具对比(修复使用条目原名搜索出来的结果会被过滤掉) 话说我在做animeko源的时候完全忘了有这个工具… 改天重构调用一下吧 * animeko.js:修复清洗文本太极端的问题 * merge-util.js:调整主副标题结构冲突为扣分而不是抛弃,合并覆盖率验证豁免animeko 没想到意外这么快来,"判处勇者刑(2026)【动漫】from bilibili" 与 "判处勇者刑 惩罚勇者9004队刑务纪录(2026)【TV动画】from animeko" 只是翻译问题但是因为主副标题冲突被禁止合并了,然后因为animeko通常包含未放送集数(并且无法获取状态)所以需要豁免合并覆盖率 * README.md:手动同步上游 * merge-util.js:增加季度信息的统一映射 震惊之前竟然没做 * animeko.js:还原更改,挪到新的PR专项优化 * bilibili.js:还原更改,挪到新的PR专项优化 * merge-util.js:全方面优化,支持未匹配上的集也返回 燃尽了,有一点点堆成屎山了之后再重构吧先把逻辑全部做出来确保准确率()感谢“间谍过家家”这个番,几乎全平台有,发现了大量平台差异与季度差异 * merge-util.js:优化集提取正则 * dandan-api.js:修复当评分相同时后遍历到的结果会无条件覆盖先遍历到的结果 * dandan-api.js:去除没匹上就跳过检查集 * dandan-api.js:重新实现,同步修复季匹配错误问题函数 * merge-util.js:支持字母后缀的季度差分,例如小林家的龙女仆S、某科学的超电磁炮S、某科学的超电磁炮T
This commit is contained in:
1 parent
2d4f704ae1
commit
e84b3b01df
7 files changed
+2439
-581
No files matched your search
@@ -372,8 +372,8 @@ API 支持返回 Bilibili 标准 XML 格式的弹幕数据,通过查询参数
|
||||
| BILIBILI_COOKIE | 【可选】b站cookie(填入后能抓取完整弹幕和启用港澳台App接口),如 `buvid3=E2BCA ... eao6; theme-avatar-tip-show=SHOWED`,请自行通过浏览器或抓包工具抓取,热心网友测试后,弹幕获取实际最少只需取 `SESSDATA=xxxx` 字段,但如果需要使用港澳台区域稳定的App搜索接口还需要`bili_jct=xxxx`或`access_key=xxxx` 字段,不知道怎么获取cookie的,可以从工具 [cookie-butler](https://cookie-butler.do-u.me) 获取 |
|
||||
| YOUKU_CONCURRENCY | 【可选】youku弹幕请求并发数,用于加快youku弹幕请求速度,不填默认为`8`,最高`16` |
|
||||
| SOURCE_ORDER | 【可选】源排序,用于按源对返回资源的排序(注意:先后顺序会影响自动匹配最终的返回),默认是`360,vod,renren,hanjutv`,表示360数据排在最前,hanjutv数据排在最后,示例:`360,renren`:只返回360数据和renren数据,且360数据靠前;当前可选择的源字段有 `360,vod,tmdb,douban,tencent,youku,iqiyi,imgo,bilibili,sohu,leshi,xigua,renren,hanjutv,bahamut,dandan,animeko,custom` |
|
||||
| PLATFORM_ORDER | 【可选】自动匹配优选平台,按顺序优先返回指定平台弹幕,默认为空,即返回第一个满足条件的平台,示例:`bilibili1,qq`,表示如果有b站的播放源,则优先返回b站的弹幕,否则就返回腾讯的弹幕,两者都没有,则返回第一个满足条件的平台;当前可选择的平台字段有 `qiyi, bilibili1, imgo, youku, qq, sohu, leshi, xigua, renren, hanjutv, bahamut, dandan, animeko, custom` |
|
||||
| MERGE_SOURCE_PAIRS | 【可选】源合并配置,配置后将对应源合并同时一起获取弹幕返回,默认为空,格式是`主源字段&副源字段&副源字段`,示例:`imgo&iqiyi,dandan&bahamut&animeko`, 允许多组、允许同时存在、允许配置多个副源与主源合并,目前允许合并的源字段有`tencent,youku,iqiyi,imgo,bilibili,sohu,leshi,xigua,renren,hanjutv,bahamut,dandan,animeko` |
|
||||
| PLATFORM_ORDER | 【可选】自动匹配优选平台,按顺序优先返回指定平台弹幕,默认为空,即返回第一个满足条件的平台,示例:`bilibili1,qq`,表示如果有b站的播放源,则优先返回b站的弹幕,否则就返回腾讯的弹幕,两者都没有,则返回第一个满足条件的平台,当配置合并平台的时候为指定期望的合并源;当前可选择的平台字段有 `qiyi, bilibili1, imgo, youku, qq, sohu, leshi, xigua, renren, hanjutv, bahamut, dandan, animeko, custom` |
|
||||
| MERGE_SOURCE_PAIRS | 【可选】源合并配置,配置后将对应源合并同时一起获取弹幕返回,默认为空,格式是`源字段&源字段&源字段`,示例:`imgo&iqiyi,dandan&bahamut&animeko`, 允许多组、允许同时存在、允许多源,一组中第一个为主源其余为副源,副源往主源合并,主源如果没有结果会轮替下一个作为主源循环,目前允许合并的源字段有`tencent,youku,iqiyi,imgo,bilibili,sohu,leshi,xigua,renren,hanjutv,bahamut,dandan,animeko` |
|
||||
| EPISODE_TITLE_FILTER | 【可选】剧集标题正则过滤,按正则关键字对剧集或综艺的集标题进行过滤,适用于过滤一些预告或综艺非正式集,只支持match自动匹配,默认值如下 |
|
||||
| ENABLE_EPISODE_FILTER | 【可选】是否在手动选择接口中启用集标题过滤,默认为`false`(禁用),启用后 GET /api/v2/bangumi/{id} 和 GET /api/v2/search/anime 接口会过滤掉预告、花絮等特殊集,以及名称包含特殊关键词的动漫。 |
|
||||
| STRICT_TITLE_MATCH | 【可选】是否启用严格标题匹配模式,默认为`false`(宽松模糊匹配),启用后只匹配标题开头或完全匹配的结果。例如:搜索"遮天"时,`false`会匹配"古惑仔3之只手遮天",`true`只匹配"遮天"、"遮天 第一季"等。可选值:`true`、`false` |
|
||||
|
||||
+4
-3
@@ -81,14 +81,15 @@ SOURCE_ORDER=360,vod,renren,hanjutv
|
||||
|
||||
# 平台优先级排序(用于自动匹配)
|
||||
# 默认值:空(自动匹配)
|
||||
# 可选值:qiyi, bilibili1, imgo, youku, qq, sohu, leshi, xigua, renren, hanjutv, bahamut, dandan, animeko, custom
|
||||
# 可选值:qiyi, bilibili1, imgo, youku, qq, sohu, leshi, xigua, renren, hanjutv, bahamut, dandan, animeko, custom 或 qiyi&bilibili1&imgo,dandan&animeko
|
||||
# 说明:平台排序配置,可以配置自动匹配时的优选平台,当配置合并平台的时候可以指定期望的合并源
|
||||
# PLATFORM_ORDER=
|
||||
|
||||
# ==================== 源合并配置 ====================
|
||||
# 数据源合并配置(可选值:tencent, youku, iqiyi, imgo, bilibili, renren, hanjutv, bahamut, dandan, sohu, leshi, xigua, animeko)
|
||||
# 默认值:空
|
||||
# 说明:配置后将对应源合并同时一起获取弹幕返回,允许多组、允许一个源同时存在、允许配置多个副源与主源合并
|
||||
# 格式:主源字段&副源字段&副源字段
|
||||
# 说明:配置后将对应源合并同时一起获取弹幕返回,允许多组、允许同时存在、允许多源,一组中第一个为主源其余为副源,副源往主源合并,主源如果没有结果会轮替下一个作为主源循环
|
||||
# 格式:源字段&源字段&源字段
|
||||
# 示例:MERGE_SOURCE_PAIRS=imgo&iqiyi,dandan&bahamut&animeko
|
||||
# MERGE_SOURCE_PAIRS=
|
||||
|
||||
|
||||
+165
-76
@@ -398,16 +398,63 @@ function extractEpisodeNumberFromTitle(episodeTitle) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算平台匹配得分 (新增函数 - 用于支持合并源模糊匹配和杂质过滤)
|
||||
* @param {string} candidatePlatform 候选平台字符串 (e.g., "bilibili&dandan")
|
||||
* @param {string} targetPlatform 目标配置字符串 (e.g., "bilibili1&dandan")
|
||||
* @returns {number} 得分:越高越好,0表示不匹配
|
||||
*/
|
||||
function getPlatformMatchScore(candidatePlatform, targetPlatform) {
|
||||
if (!candidatePlatform || !targetPlatform) return 0;
|
||||
|
||||
// 预处理:按 & 分割,转小写,去空格
|
||||
const cParts = candidatePlatform.split('&').map(s => s.trim().toLowerCase()).filter(s => s);
|
||||
const tParts = targetPlatform.split('&').map(s => s.trim().toLowerCase()).filter(s => s);
|
||||
|
||||
let matchCount = 0;
|
||||
|
||||
// 计算交集:统计有多少个目标平台在候选平台中存在
|
||||
// 使用 includes 进行模糊匹配,解决部分平台名称差异问题
|
||||
for (const tPart of tParts) {
|
||||
const isFound = cParts.some(cPart =>
|
||||
cPart === tPart ||
|
||||
(cPart.includes(tPart) && tPart.length > 2) ||
|
||||
(tPart.includes(cPart) && cPart.length > 2)
|
||||
);
|
||||
if (isFound) {
|
||||
matchCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if (matchCount === 0) return 0;
|
||||
|
||||
// 评分公式:基于命中数计算权重,其次考虑候选长度(越短越好,即杂质越少分越高)
|
||||
// 示例: Target="bilibili"
|
||||
// Candidate="bilibili" -> Match=1, Len=1 -> 1000 - 1 = 999 (Best)
|
||||
// Candidate="animeko&bilibili" -> Match=1, Len=2 -> 1000 - 2 = 998 (Valid but lower score)
|
||||
return (matchCount * 1000) - cParts.length;
|
||||
}
|
||||
|
||||
// 辅助函数:从标题中提取来源平台列表 (新增函数 - 适配合并源标题格式)
|
||||
function extractPlatformFromTitle(title) {
|
||||
const match = title.match(/from\s+([a-zA-Z0-9&]+)/i);
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
// 根据集数匹配episode(优先使用集标题中的集数,其次使用episodeNumber,最后使用数组索引)
|
||||
function findEpisodeByNumber(filteredEpisodes, targetEpisode, platform = null) {
|
||||
if (!filteredEpisodes || filteredEpisodes.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 如果指定了平台,先过滤出该平台的集数
|
||||
// 如果指定了平台,先过滤出该平台的集数 (修改点:使用 getPlatformMatchScore 支持模糊匹配)
|
||||
let platformEpisodes = filteredEpisodes;
|
||||
if (platform) {
|
||||
platformEpisodes = filteredEpisodes.filter(ep => extractEpisodeTitle(ep.episodeTitle) === platform);
|
||||
platformEpisodes = filteredEpisodes.filter(ep => {
|
||||
const epTitlePlatform = extractEpisodeTitle(ep.episodeTitle);
|
||||
// 使用评分机制判断是否匹配,只要有分就保留
|
||||
return getPlatformMatchScore(epTitlePlatform, platform) > 0;
|
||||
});
|
||||
}
|
||||
|
||||
if (platformEpisodes.length === 0) {
|
||||
@@ -442,97 +489,139 @@ function findEpisodeByNumber(filteredEpisodes, targetEpisode, platform = null) {
|
||||
}
|
||||
|
||||
async function matchAniAndEp(season, episode, year, searchData, title, req, platform, preferAnimeId) {
|
||||
let resAnime;
|
||||
let resEpisode;
|
||||
if (season && episode) {
|
||||
// 判断剧集
|
||||
const normalizedTitle = normalizeSpaces(title);
|
||||
for (const anime of searchData.animes) {
|
||||
const animeIsNotPrefer =
|
||||
// 定义最佳匹配结果容器
|
||||
let bestRes = {
|
||||
anime: null,
|
||||
episode: null,
|
||||
score: -9999 // 初始分数为极低值
|
||||
};
|
||||
|
||||
const normalizedTitle = normalizeSpaces(title);
|
||||
|
||||
// 遍历所有搜索结果,寻找最佳匹配
|
||||
for (const anime of searchData.animes) {
|
||||
// 偏好过滤
|
||||
const animeIsNotPrefer =
|
||||
globals.rememberLastSelect &&
|
||||
preferAnimeId &&
|
||||
String(anime.bangumiId) !== String(preferAnimeId) &&
|
||||
String(anime.animeId) !== String(preferAnimeId);
|
||||
if (animeIsNotPrefer) continue;
|
||||
if (normalizeSpaces(anime.animeTitle).includes(normalizedTitle)) {
|
||||
// 年份匹配优先于季匹配
|
||||
if (!matchYear(anime, year)) {
|
||||
log("info", `Year mismatch: anime year ${extractYear(anime.animeTitle)} vs query year ${year}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
let originBangumiUrl = new URL(req.url.replace("/match", `bangumi/${anime.bangumiId}`));
|
||||
const bangumiRes = await getBangumi(originBangumiUrl.pathname);
|
||||
const bangumiData = await bangumiRes.json();
|
||||
log("info", "判断剧集", bangumiData);
|
||||
if (animeIsNotPrefer) continue;
|
||||
|
||||
// 过滤集标题正则条件的 episode
|
||||
let isMatch = false;
|
||||
|
||||
// 1. 标题/年份匹配检查
|
||||
if (season && episode) {
|
||||
// 剧集模式
|
||||
if (normalizeSpaces(anime.animeTitle).includes(normalizedTitle)) {
|
||||
// 年份匹配优先于季匹配
|
||||
if (!matchYear(anime, year)) {
|
||||
log("info", `Year mismatch: anime year ${extractYear(anime.animeTitle)} vs query year ${year}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 年份匹配通过后,再判断season
|
||||
const animeIsPrefer =
|
||||
globals.rememberLastSelect &&
|
||||
preferAnimeId &&
|
||||
(String(anime.bangumiId) === String(preferAnimeId) ||
|
||||
String(anime.animeId) === String(preferAnimeId));
|
||||
|
||||
if (matchSeason(anime, title, season) || animeIsPrefer) {
|
||||
isMatch = true;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 电影模式
|
||||
const animeTitle = anime.animeTitle.split("(")[0].trim();
|
||||
if (animeTitle === title) {
|
||||
// 年份匹配检查
|
||||
if (!matchYear(anime, year)) {
|
||||
log("info", `Year mismatch: anime year ${extractYear(anime.animeTitle)} vs query year ${year}`);
|
||||
continue;
|
||||
}
|
||||
isMatch = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!isMatch) continue;
|
||||
|
||||
// 2. 获取剧集详情 (无条件获取,确保数据完整性)
|
||||
let originBangumiUrl = new URL(req.url.replace("/match", `bangumi/${anime.bangumiId}`));
|
||||
const bangumiRes = await getBangumi(originBangumiUrl.pathname);
|
||||
const bangumiData = await bangumiRes.json();
|
||||
|
||||
// 输出匹配分数及原始数据日志
|
||||
log("info", "判断剧集", `Anime: ${anime.animeTitle}`);
|
||||
log("info", bangumiData);
|
||||
|
||||
let matchedEpisode = null;
|
||||
|
||||
if (season && episode) {
|
||||
// 剧集模式逻辑
|
||||
const filteredTmpEpisodes = bangumiData.bangumi.episodes.filter(episode => {
|
||||
return !globals.episodeTitleFilter.test(episode.episodeTitle);
|
||||
});
|
||||
|
||||
// 过滤集标题一致的 episode,且保留首次出现的集标题的 episode
|
||||
const filteredEpisodes = filterSameEpisodeTitle(filteredTmpEpisodes);
|
||||
|
||||
log("info", "过滤后的集标题", filteredEpisodes.map(episode => episode.episodeTitle));
|
||||
|
||||
// 年份匹配通过后,再判断season
|
||||
const animeIsPrefer =
|
||||
globals.rememberLastSelect &&
|
||||
preferAnimeId &&
|
||||
(String(anime.bangumiId) === String(preferAnimeId) ||
|
||||
String(anime.animeId) === String(preferAnimeId));
|
||||
if (matchSeason(anime, title, season) || animeIsPrefer) {
|
||||
// 使用新的集数匹配策略
|
||||
const matchedEpisode = findEpisodeByNumber(filteredEpisodes, episode, platform);
|
||||
if (matchedEpisode) {
|
||||
resEpisode = matchedEpisode;
|
||||
resAnime = anime;
|
||||
break;
|
||||
}
|
||||
// 匹配集数 (注意:findEpisodeByNumber 已增强支持模糊平台匹配)
|
||||
matchedEpisode = findEpisodeByNumber(filteredEpisodes, episode, platform);
|
||||
} else {
|
||||
// 电影模式逻辑
|
||||
if (bangumiData.bangumi.episodes.length > 0) {
|
||||
if (platform) {
|
||||
// 在剧集列表中寻找匹配特定平台的资源
|
||||
const targetEp = bangumiData.bangumi.episodes.find(ep => {
|
||||
const epTitlePlatform = extractEpisodeTitle(ep.episodeTitle);
|
||||
return getPlatformMatchScore(epTitlePlatform, platform) > 0;
|
||||
});
|
||||
|
||||
if (targetEp) {
|
||||
matchedEpisode = targetEp;
|
||||
}
|
||||
} else {
|
||||
matchedEpisode = bangumiData.bangumi.episodes[0];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 判断电影
|
||||
for (const anime of searchData.animes) {
|
||||
const animeIsNotPrefer =
|
||||
globals.rememberLastSelect &&
|
||||
preferAnimeId &&
|
||||
String(anime.bangumiId) !== String(preferAnimeId) &&
|
||||
String(anime.animeId) !== String(preferAnimeId);
|
||||
if (animeIsNotPrefer) continue;
|
||||
const animeTitle = anime.animeTitle.split("(")[0].trim();
|
||||
if (animeTitle === title) {
|
||||
// 年份匹配优先
|
||||
if (!matchYear(anime, year)) {
|
||||
log("info", `Year mismatch: anime year ${extractYear(anime.animeTitle)} vs query year ${year}`);
|
||||
continue;
|
||||
|
||||
// 3. 匹配结果处理与评分比较
|
||||
if (matchedEpisode) {
|
||||
// 计算当前匹配的得分
|
||||
const actualPlatform = extractPlatformFromTitle(anime.animeTitle) || anime.source;
|
||||
let currentScore = 0;
|
||||
|
||||
if (platform) {
|
||||
// 如果指定了平台偏好,计算匹配得分
|
||||
currentScore = getPlatformMatchScore(actualPlatform, platform);
|
||||
} else {
|
||||
// 如果没有指定平台偏好,默认为 1
|
||||
currentScore = 1;
|
||||
}
|
||||
|
||||
// 比较并更新最佳结果
|
||||
// 逻辑:如果有更好的分数,或者之前没有匹配到任何结果,则更新
|
||||
if (currentScore > bestRes.score) {
|
||||
bestRes = {
|
||||
anime: anime,
|
||||
episode: matchedEpisode,
|
||||
score: currentScore
|
||||
};
|
||||
}
|
||||
|
||||
// 如果没有指定平台偏好 (platform 为空),则保持原版行为:
|
||||
// 找到第一个符合条件的就立刻返回,不进行后续比较
|
||||
if (!platform) {
|
||||
break;
|
||||
}
|
||||
|
||||
let originBangumiUrl = new URL(req.url.replace("/match", `bangumi/${anime.bangumiId}`));
|
||||
const bangumiRes = await getBangumi(originBangumiUrl.pathname);
|
||||
const bangumiData = await bangumiRes.json();
|
||||
log("info", bangumiData);
|
||||
|
||||
if (platform) {
|
||||
const firstIndex = bangumiData.bangumi.episodes.findIndex(episode => extractEpisodeTitle(episode.episodeTitle) === platform);
|
||||
const indexCount = bangumiData.bangumi.episodes.filter(episode => extractEpisodeTitle(episode.episodeTitle) === platform).length;
|
||||
if (indexCount > 0) {
|
||||
resEpisode = bangumiData.bangumi.episodes[firstIndex];
|
||||
resAnime = anime;
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
if (bangumiData.bangumi.episodes.length > 0) {
|
||||
resEpisode = bangumiData.bangumi.episodes[0];
|
||||
resAnime = anime;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
// 如果指定了平台偏好,则继续循环查找是否有得分更高的源(最小杂质匹配)
|
||||
}
|
||||
}
|
||||
return {resEpisode, resAnime};
|
||||
|
||||
return { resEpisode: bestRes.episode, resAnime: bestRes.anime };
|
||||
}
|
||||
|
||||
async function fallbackMatchAniAndEp(searchData, req, season, episode, year, resEpisode, resAnime) {
|
||||
|
||||
@@ -123,13 +123,25 @@ export class Envs {
|
||||
|
||||
/**
|
||||
* 解析平台排序
|
||||
* 支持单个平台或通过&连接的组合平台(如 bilibili1&dandan)
|
||||
* @returns {Array} 平台排序数组
|
||||
*/
|
||||
static resolvePlatformOrder() {
|
||||
const orderArr = this.get('PLATFORM_ORDER', '', 'string')
|
||||
const rawOrder = this.get('PLATFORM_ORDER', '', 'string');
|
||||
|
||||
const orderArr = rawOrder
|
||||
.split(',')
|
||||
.map(s => s.trim())
|
||||
.filter(s => this.ALLOWED_PLATFORMS.includes(s));
|
||||
.filter(item => {
|
||||
if (!item) return false;
|
||||
// 如果包含 &,则分割校验每一部分是否有效
|
||||
if (item.includes('&')) {
|
||||
const parts = item.split('&').map(p => p.trim());
|
||||
return parts.every(p => this.ALLOWED_PLATFORMS.includes(p));
|
||||
}
|
||||
// 单个平台直接校验
|
||||
return this.ALLOWED_PLATFORMS.includes(item);
|
||||
});
|
||||
|
||||
this.accessedEnvVars.set('PLATFORM_ORDER', orderArr);
|
||||
|
||||
@@ -303,10 +315,10 @@ export class Envs {
|
||||
'VOD_REQUEST_TIMEOUT': { category: 'source', type: 'number', description: 'VOD请求超时时间,默认10000', min: 5000, max: 30000 },
|
||||
'BILIBILI_COOKIE': { category: 'source', type: 'text', description: 'B站Cookie' },
|
||||
'YOUKU_CONCURRENCY': { category: 'source', type: 'number', description: '优酷并发配置,默认8', min: 1, max: 16 },
|
||||
'MERGE_SOURCE_PAIRS': { category: 'source', type: 'text', description: '源合并配置,配置后将对应源合并同时一起获取弹幕返回,支持多源链式合并,第一个为主源。\n格式:源1&源2&源3,多组用逗号分隔。\n示例:dandan&animeko&bahamut, bilibili&animeko\n目前允许的源:tencent,youku,iqiyi,imgo,bilibili,sohu,leshi,xigua,renren,hanjutv,bahamut,dandan,animeko' },
|
||||
'MERGE_SOURCE_PAIRS': { category: 'source', type: 'multi-select', options: this.MERGE_ALLOWED_SOURCES, description: '源合并配置,配置后将对应源合并同时一起获取弹幕返回,允许多组,允许多源,一组中第一个为主源其余为副源,副源往主源合并,主源如果没有结果会轮替下一个作为主源。\n格式:源1&源2&源3,多组用逗号分隔。\n示例:dandan&animeko&bahamut,bilibili&animeko' },
|
||||
|
||||
// 匹配配置
|
||||
'PLATFORM_ORDER': { category: 'match', type: 'multi-select', options: this.ALLOWED_PLATFORMS, description: '平台排序配置' },
|
||||
'PLATFORM_ORDER': { category: 'match', type: 'multi-select', options: this.ALLOWED_PLATFORMS, description: '平台排序配置,可以配置自动匹配时的优选平台。\n当配置合并平台的时候,可以指定期望的合并源,\n示例:一个结果返回了“dandan&bilibili1&animeko”和“youku”时,\n当配置“youku”时返回“youku” \n当配置“dandan&animeko”时返回“dandan&bilibili1&animeko”' },
|
||||
'EPISODE_TITLE_FILTER': { category: 'match', type: 'text', description: '剧集标题过滤规则' },
|
||||
'ENABLE_EPISODE_FILTER': { category: 'match', type: 'boolean', description: '集标题过滤开关' },
|
||||
'STRICT_TITLE_MATCH': { category: 'match', type: 'boolean', description: '严格标题匹配模式' },
|
||||
|
||||
@@ -712,6 +712,7 @@ export const componentsCssContent = /* css */ `
|
||||
display: inline-block;
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
/* Bilibili Cookie 编辑器样式 */
|
||||
.bili-cookie-editor {
|
||||
display: flex;
|
||||
@@ -758,4 +759,185 @@ export const componentsCssContent = /* css */ `
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* 多选标签与合并模式相关样式 */
|
||||
.selected-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: #667eea;
|
||||
color: white;
|
||||
padding: 8px 12px;
|
||||
border-radius: 20px;
|
||||
cursor: move;
|
||||
user-select: none;
|
||||
transition: all 0.3s;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
white-space: normal;
|
||||
word-break: break-all;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.merge-mode-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.merge-mode-btn {
|
||||
padding: 6px 12px;
|
||||
background: #f8f9fa;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 20px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
transition: all 0.3s;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.merge-mode-btn.active {
|
||||
background: #e3f2fd;
|
||||
border-color: #2196f3;
|
||||
color: #2196f3;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.staging-area {
|
||||
display: none;
|
||||
background: #e3f2fd;
|
||||
border: 2px dashed #90caf9;
|
||||
border-radius: 8px;
|
||||
padding: 10px;
|
||||
margin-bottom: 15px;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
min-height: 52px;
|
||||
position: relative;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.staging-area.active {
|
||||
display: flex;
|
||||
animation: slideDown 0.3s;
|
||||
}
|
||||
|
||||
@keyframes slideDown {
|
||||
from { opacity: 0; transform: translateY(-10px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.staging-area::before {
|
||||
content: '合并组暂存区:';
|
||||
color: #1976d2;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
.staging-tag {
|
||||
background: white;
|
||||
color: #1976d2;
|
||||
border: 1px solid #bbdefb;
|
||||
padding: 4px 10px;
|
||||
border-radius: 15px;
|
||||
font-size: 13px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
cursor: move;
|
||||
user-select: none;
|
||||
max-width: 100%;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.staging-tag.drag-over {
|
||||
background: #bbdefb;
|
||||
border-color: #2196f3;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.staging-tag.dragging {
|
||||
opacity: 0.5;
|
||||
transform: scale(0.95);
|
||||
background: #e3f2fd;
|
||||
}
|
||||
|
||||
.staging-tag .remove-btn {
|
||||
color: #ef5350;
|
||||
cursor: pointer;
|
||||
font-weight: bold;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.staging-separator {
|
||||
color: #999;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.confirm-merge-btn {
|
||||
margin-left: auto;
|
||||
background: #4caf50;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 2px 5px rgba(0,0,0,0.2);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.confirm-merge-btn:hover {
|
||||
background: #43a047;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.confirm-merge-btn:disabled {
|
||||
background: #ccc;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.available-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.available-tag {
|
||||
padding: 6px 12px;
|
||||
background: #fff;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.available-tag:hover {
|
||||
background: #f0f0f0;
|
||||
border-color: #bbb;
|
||||
}
|
||||
|
||||
.available-tag.disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
background: #f5f5f5;
|
||||
color: #aaa;
|
||||
pointer-events: none;
|
||||
border-color: #eee;
|
||||
box-shadow: none;
|
||||
}
|
||||
`;
|
||||
@@ -1,5 +1,9 @@
|
||||
// language=JavaScript
|
||||
export const systemSettingsJsContent = /* javascript */ `
|
||||
// 全局变量定义
|
||||
let isMergeMode = false;
|
||||
let stagingTags = [];
|
||||
|
||||
// 显示清理缓存确认模态框
|
||||
function showClearCacheModal() {
|
||||
document.getElementById('clear-cache-modal').classList.add('active');
|
||||
@@ -384,6 +388,7 @@ function renderValueInput(item) {
|
||||
const container = document.getElementById('value-input-container');
|
||||
const type = item ? item.type : document.getElementById('value-type').value;
|
||||
const value = item ? item.value : '';
|
||||
const currentKey = item ? item.key : document.getElementById('env-key').value;
|
||||
|
||||
if (type === 'boolean') {
|
||||
// 布尔开关
|
||||
@@ -454,6 +459,13 @@ function renderValueInput(item) {
|
||||
// 确保value是字符串类型后再进行split操作
|
||||
const stringValue = typeof value === 'string' ? value : String(value || '');
|
||||
const selectedValues = stringValue ? stringValue.split(',').map(v => v.trim()).filter(v => v) : [];
|
||||
|
||||
// 检查是否为 SOURCE_ORDER,如果是则不显示合并模式
|
||||
const shouldShowMergeMode = currentKey === 'MERGE_SOURCE_PAIRS' || currentKey === 'PLATFORM_ORDER';
|
||||
|
||||
// 每次渲染时重置合并模式状态
|
||||
isMergeMode = false;
|
||||
stagingTags = [];
|
||||
|
||||
const optionsInput = item ? '' : \`
|
||||
<div class="form-group margin-bottom-15">
|
||||
@@ -475,12 +487,27 @@ function renderValueInput(item) {
|
||||
</div>
|
||||
\`).join('')}
|
||||
</div>
|
||||
|
||||
\${shouldShowMergeMode ? \`
|
||||
<div class="merge-mode-controls">
|
||||
<div class="merge-mode-btn" id="merge-mode-toggle" onclick="toggleMergeMode()">
|
||||
<span class="icon">🔗️</span> 开启合并模式
|
||||
</div>
|
||||
<div class="form-help" style="margin: 0; margin-left: 10px;">
|
||||
开启后点击下方选项将添加到暂存区,组合后点击 √ 确认
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="staging-area" id="staging-area">
|
||||
<button type="button" class="confirm-merge-btn" onclick="confirmMergeGroup()" title="确认添加该组">✓</button>
|
||||
</div>
|
||||
\` : ''}
|
||||
|
||||
<label>可选项 (点击添加)</label>
|
||||
<div class="available-tags" id="available-tags">
|
||||
\${options.map(opt => {
|
||||
const isSelected = selectedValues.includes(opt);
|
||||
return \`
|
||||
<div class="available-tag \${isSelected ? 'disabled' : ''}"
|
||||
<div class="available-tag"
|
||||
data-value="\${opt}" onclick="addSelectedTag(this)">
|
||||
\${opt}
|
||||
</div>
|
||||
@@ -491,6 +518,8 @@ function renderValueInput(item) {
|
||||
\`;
|
||||
|
||||
// 设置拖动事件
|
||||
// 立即执行一次状态检查,确保已选项变灰
|
||||
setTimeout(updateTagStates, 0);
|
||||
setupDragAndDrop();
|
||||
|
||||
} else if (type === 'map') {
|
||||
@@ -618,11 +647,71 @@ function updateTagOptions() {
|
||||
\`).join('');
|
||||
}
|
||||
|
||||
// 统一的状态检查函数
|
||||
function updateTagStates() {
|
||||
// 确保 DOM 元素存在,防止在渲染过程中被调用出错
|
||||
const keyInput = document.getElementById('env-key');
|
||||
if (!keyInput) return;
|
||||
|
||||
const currentKey = keyInput.value;
|
||||
const isMergeSourcePairs = currentKey === 'MERGE_SOURCE_PAIRS';
|
||||
|
||||
// 1. 获取当前暂存区中的Token (防止同组内重复)
|
||||
const stagingTokens = new Set(stagingTags);
|
||||
|
||||
// 2. 获取已确认的 Selected Tags (仅在非合并模式下需要检查)
|
||||
const selectedTagElements = Array.from(document.querySelectorAll('.selected-tag'));
|
||||
|
||||
// 3. 更新所有可选项的状态
|
||||
const availableTags = document.querySelectorAll('.available-tag');
|
||||
availableTags.forEach(tag => {
|
||||
const value = tag.dataset.value;
|
||||
let shouldDisable = false;
|
||||
|
||||
if (isMergeMode) {
|
||||
// [合并模式逻辑]
|
||||
// 只要不在当前的暂存区中,就可以选(允许 bilibili&a 和 bilibili&b)
|
||||
// 也就是说,我们完全不检查 selectedTagElements
|
||||
if (stagingTokens.has(value)) {
|
||||
shouldDisable = true;
|
||||
}
|
||||
} else {
|
||||
// [普通模式逻辑]
|
||||
// 只要已经被选了,就禁用 (精准匹配)
|
||||
const isAlreadySelected = selectedTagElements.some(el => el.dataset.value === value);
|
||||
if (isAlreadySelected) {
|
||||
shouldDisable = true;
|
||||
}
|
||||
|
||||
// 特殊情况:如果是 MERGE_SOURCE_PAIRS 但没开合并模式,且还没被选,也禁用(强迫用户开开关)
|
||||
if (isMergeSourcePairs && !isAlreadySelected) {
|
||||
shouldDisable = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldDisable) {
|
||||
tag.classList.add('disabled');
|
||||
} else {
|
||||
tag.classList.remove('disabled');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 添加已选标签
|
||||
function addSelectedTag(element) {
|
||||
if (element.classList.contains('disabled')) return;
|
||||
|
||||
const value = element.dataset.value;
|
||||
|
||||
if (isMergeMode) {
|
||||
if (!stagingTags.includes(value)) {
|
||||
stagingTags.push(value);
|
||||
renderStagingArea();
|
||||
updateTagStates(); // 立即更新状态 (该选项变灰,防止同组重复)
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (element.classList.contains('disabled')) return;
|
||||
|
||||
const container = document.getElementById('selected-tags');
|
||||
|
||||
// 移除empty类
|
||||
@@ -639,35 +728,21 @@ function addSelectedTag(element) {
|
||||
\`;
|
||||
|
||||
container.appendChild(tag);
|
||||
|
||||
// 禁用可选项
|
||||
element.classList.add('disabled');
|
||||
|
||||
// 重新设置拖动事件,确保新添加的标签也能拖动和删除
|
||||
updateTagStates(); // 立即更新状态
|
||||
setupDragAndDrop();
|
||||
}
|
||||
|
||||
// 移除已选标签
|
||||
function removeSelectedTag(button) {
|
||||
const tag = button.parentElement;
|
||||
const value = tag.dataset.value;
|
||||
const container = document.getElementById('selected-tags');
|
||||
|
||||
// 移除标签
|
||||
tag.remove();
|
||||
|
||||
// 如果没有标签了,添加empty类
|
||||
const container = document.getElementById('selected-tags');
|
||||
if (container.children.length === 0) {
|
||||
container.classList.add('empty');
|
||||
}
|
||||
|
||||
// 启用对应的可选项
|
||||
const availableTag = document.querySelector(\`.available-tag[data-value="\${value}"]\`);
|
||||
if (availableTag) {
|
||||
availableTag.classList.remove('disabled');
|
||||
}
|
||||
|
||||
// 重新设置拖动事件,确保其他标签仍然可以拖动
|
||||
updateTagStates(); // 移除后立即释放状态
|
||||
setupDragAndDrop();
|
||||
}
|
||||
|
||||
@@ -675,19 +750,275 @@ function removeSelectedTag(button) {
|
||||
function updateMultiOptions() {
|
||||
const input = document.getElementById('multi-options');
|
||||
const options = input.value.split(',').map(s => s.trim()).filter(s => s);
|
||||
const selectedValues = Array.from(document.querySelectorAll('.selected-tag'))
|
||||
.map(el => el.dataset.value);
|
||||
|
||||
const container = document.getElementById('available-tags');
|
||||
container.innerHTML = options.map(opt => {
|
||||
const isSelected = selectedValues.includes(opt);
|
||||
return \`
|
||||
<div class="available-tag \${isSelected ? 'disabled' : ''}"
|
||||
<div class="available-tag"
|
||||
data-value="\${opt}" onclick="addSelectedTag(this)">
|
||||
\${opt}
|
||||
</div>
|
||||
\`;
|
||||
}).join('');
|
||||
|
||||
updateTagStates(); // 初始化时更新状态
|
||||
}
|
||||
|
||||
// 切换合并模式
|
||||
function toggleMergeMode() {
|
||||
isMergeMode = !isMergeMode;
|
||||
const btn = document.getElementById('merge-mode-toggle');
|
||||
const stagingArea = document.getElementById('staging-area');
|
||||
|
||||
if (isMergeMode) {
|
||||
btn.classList.add('active');
|
||||
btn.innerHTML = '<span class="icon">⛓💥</span> 合并模式已开启,点击关闭';
|
||||
stagingArea.classList.add('active');
|
||||
renderStagingArea();
|
||||
} else {
|
||||
btn.classList.remove('active');
|
||||
btn.innerHTML = '<span class="icon">🔗️</span> 点击开启合并模式';
|
||||
stagingArea.classList.remove('active');
|
||||
stagingTags = [];
|
||||
}
|
||||
|
||||
// 切换模式时立即刷新所有可选项状态
|
||||
updateTagStates();
|
||||
}
|
||||
|
||||
// 渲染暂存区
|
||||
function renderStagingArea() {
|
||||
const container = document.getElementById('staging-area');
|
||||
const confirmBtn = container.querySelector('.confirm-merge-btn');
|
||||
|
||||
while (container.firstChild && container.firstChild !== confirmBtn) {
|
||||
container.removeChild(container.firstChild);
|
||||
}
|
||||
|
||||
if (stagingTags.length === 0) {
|
||||
const hint = document.createElement('span');
|
||||
hint.textContent = '请点击下方选项进行组合...';
|
||||
hint.style.color = '#666';
|
||||
hint.style.fontSize = '12px';
|
||||
container.insertBefore(hint, confirmBtn);
|
||||
confirmBtn.disabled = true;
|
||||
} else {
|
||||
stagingTags.forEach((tag, index) => {
|
||||
if (index > 0) {
|
||||
const sep = document.createElement('span');
|
||||
sep.className = 'staging-separator';
|
||||
sep.textContent = '&';
|
||||
container.insertBefore(sep, confirmBtn);
|
||||
}
|
||||
const tagEl = document.createElement('div');
|
||||
tagEl.className = 'staging-tag';
|
||||
tagEl.draggable = true;
|
||||
tagEl.dataset.value = tag;
|
||||
tagEl.dataset.index = index;
|
||||
tagEl.innerHTML = \`\${tag}<span class="remove-btn" onclick="removeFromStaging(\${index})">×</span>\`;
|
||||
container.insertBefore(tagEl, confirmBtn);
|
||||
});
|
||||
confirmBtn.disabled = false;
|
||||
setupStagingDragAndDrop();
|
||||
}
|
||||
}
|
||||
|
||||
// 从暂存区移除
|
||||
function removeFromStaging(index) {
|
||||
stagingTags.splice(index, 1);
|
||||
renderStagingArea();
|
||||
updateTagStates(); // 移除后刷新状态
|
||||
}
|
||||
|
||||
// 确认添加合并组
|
||||
function confirmMergeGroup() {
|
||||
if (stagingTags.length === 0) return;
|
||||
const groupValue = stagingTags.join('&');
|
||||
const container = document.getElementById('selected-tags');
|
||||
container.classList.remove('empty');
|
||||
|
||||
const tag = document.createElement('div');
|
||||
tag.className = 'selected-tag';
|
||||
tag.draggable = true;
|
||||
tag.dataset.value = groupValue;
|
||||
tag.innerHTML = \`<span class="tag-text">\${groupValue}</span><button type="button" class="remove-btn" onclick="removeSelectedTag(this)">×</button>\`;
|
||||
|
||||
container.appendChild(tag);
|
||||
setupDragAndDrop();
|
||||
|
||||
stagingTags = []; // 清空暂存区
|
||||
renderStagingArea();
|
||||
updateTagStates(); // 关键:确认后立即重新计算所有可选项的禁用状态 (重置为可用)
|
||||
}
|
||||
|
||||
// 设置暂存区拖放功能
|
||||
function setupStagingDragAndDrop() {
|
||||
const container = document.getElementById('staging-area');
|
||||
const tags = container.querySelectorAll('.staging-tag');
|
||||
|
||||
tags.forEach(tag => {
|
||||
tag.addEventListener('dragstart', handleStagingDragStart);
|
||||
tag.addEventListener('dragend', handleStagingDragEnd);
|
||||
tag.addEventListener('dragover', handleStagingDragOver);
|
||||
tag.addEventListener('drop', handleStagingDrop);
|
||||
tag.addEventListener('dragenter', handleStagingDragEnter);
|
||||
tag.addEventListener('dragleave', handleStagingDragLeave);
|
||||
|
||||
tag.addEventListener('touchstart', handleStagingTouchStart);
|
||||
tag.addEventListener('touchmove', handleStagingTouchMove);
|
||||
tag.addEventListener('touchend', handleStagingTouchEnd);
|
||||
});
|
||||
}
|
||||
|
||||
let stagingDraggedElement = null;
|
||||
|
||||
function handleStagingDragStart(e) {
|
||||
stagingDraggedElement = this;
|
||||
this.classList.add('dragging');
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
}
|
||||
|
||||
function handleStagingDragEnd(e) {
|
||||
this.classList.remove('dragging');
|
||||
document.querySelectorAll('.staging-tag').forEach(tag => {
|
||||
tag.classList.remove('drag-over');
|
||||
});
|
||||
}
|
||||
|
||||
function handleStagingDragOver(e) {
|
||||
if (e.preventDefault) {
|
||||
e.preventDefault();
|
||||
}
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
return false;
|
||||
}
|
||||
|
||||
function handleStagingDragEnter(e) {
|
||||
if (this !== stagingDraggedElement) {
|
||||
this.classList.add('drag-over');
|
||||
}
|
||||
}
|
||||
|
||||
function handleStagingDragLeave(e) {
|
||||
this.classList.remove('drag-over');
|
||||
}
|
||||
|
||||
function handleStagingDrop(e) {
|
||||
if (e.stopPropagation) {
|
||||
e.stopPropagation();
|
||||
}
|
||||
|
||||
if (stagingDraggedElement !== this) {
|
||||
const draggedIndex = parseInt(stagingDraggedElement.dataset.index);
|
||||
const targetIndex = parseInt(this.dataset.index);
|
||||
|
||||
const [movedItem] = stagingTags.splice(draggedIndex, 1);
|
||||
stagingTags.splice(targetIndex, 0, movedItem);
|
||||
|
||||
renderStagingArea();
|
||||
}
|
||||
|
||||
this.classList.remove('drag-over');
|
||||
return false;
|
||||
}
|
||||
|
||||
function handleStagingTouchStart(e) {
|
||||
if (e.target.classList.contains('remove-btn')) {
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
stagingDraggedElement = this;
|
||||
this.classList.add('dragging');
|
||||
|
||||
this.style.transform = 'rotate(5deg)';
|
||||
this.style.opacity = '0.8';
|
||||
this.style.zIndex = '1000';
|
||||
}
|
||||
|
||||
function handleStagingTouchMove(e) {
|
||||
if (!stagingDraggedElement) return;
|
||||
e.preventDefault();
|
||||
|
||||
const touch = e.touches[0];
|
||||
const elementRect = stagingDraggedElement.getBoundingClientRect();
|
||||
|
||||
if (!document.getElementById('staging-touch-drag-ghost')) {
|
||||
const ghostElement = stagingDraggedElement.cloneNode(true);
|
||||
ghostElement.id = 'staging-touch-drag-ghost';
|
||||
ghostElement.style.position = 'fixed';
|
||||
ghostElement.style.left = '0';
|
||||
ghostElement.style.top = '0';
|
||||
ghostElement.style.pointerEvents = 'none';
|
||||
ghostElement.style.zIndex = '9999';
|
||||
ghostElement.style.transform = 'translate(' + (touch.clientX - (elementRect.width / 2)) + 'px, ' + (touch.clientY - (elementRect.height / 2)) + 'px) rotate(5deg)';
|
||||
ghostElement.style.opacity = '0.8';
|
||||
ghostElement.style.boxSizing = 'border-box';
|
||||
ghostElement.style.width = elementRect.width + 'px';
|
||||
ghostElement.style.height = elementRect.height + 'px';
|
||||
document.body.appendChild(ghostElement);
|
||||
} else {
|
||||
const ghostElement = document.getElementById('staging-touch-drag-ghost');
|
||||
ghostElement.style.transform = 'translate(' + (touch.clientX - (elementRect.width / 2)) + 'px, ' + (touch.clientY - (elementRect.height / 2)) + 'px) rotate(5deg)';
|
||||
}
|
||||
|
||||
const container = document.getElementById('staging-area');
|
||||
const tags = Array.from(container.querySelectorAll('.staging-tag')).filter(tag => tag !== stagingDraggedElement);
|
||||
let targetElement = null;
|
||||
|
||||
for (const tag of tags) {
|
||||
const rect = tag.getBoundingClientRect();
|
||||
if (touch.clientX >= rect.left && touch.clientX <= rect.right &&
|
||||
touch.clientY >= rect.top && touch.clientY <= rect.bottom) {
|
||||
targetElement = tag;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
document.querySelectorAll('.staging-tag').forEach(tag => {
|
||||
if (tag !== stagingDraggedElement) {
|
||||
tag.classList.remove('drag-over');
|
||||
}
|
||||
});
|
||||
|
||||
if (targetElement) {
|
||||
targetElement.classList.add('drag-over');
|
||||
}
|
||||
}
|
||||
|
||||
function handleStagingTouchEnd(e) {
|
||||
if (!stagingDraggedElement) return;
|
||||
e.preventDefault();
|
||||
|
||||
const ghostElement = document.getElementById('staging-touch-drag-ghost');
|
||||
if (ghostElement) {
|
||||
document.body.removeChild(ghostElement);
|
||||
}
|
||||
|
||||
const touch = e.changedTouches[0];
|
||||
const targetElement = document.elementFromPoint(touch.clientX, touch.clientY);
|
||||
const targetTag = targetElement.closest('.staging-tag');
|
||||
|
||||
if (targetTag && targetTag !== stagingDraggedElement) {
|
||||
const draggedIndex = parseInt(stagingDraggedElement.dataset.index);
|
||||
const targetIndex = parseInt(targetTag.dataset.index);
|
||||
|
||||
const [movedItem] = stagingTags.splice(draggedIndex, 1);
|
||||
stagingTags.splice(targetIndex, 0, movedItem);
|
||||
|
||||
renderStagingArea();
|
||||
}
|
||||
|
||||
stagingDraggedElement.style.transform = '';
|
||||
stagingDraggedElement.style.opacity = '';
|
||||
stagingDraggedElement.style.zIndex = '';
|
||||
stagingDraggedElement.classList.remove('dragging');
|
||||
|
||||
document.querySelectorAll('.staging-tag').forEach(tag => {
|
||||
tag.classList.remove('drag-over');
|
||||
});
|
||||
|
||||
stagingDraggedElement = null;
|
||||
}
|
||||
|
||||
// 设置拖放功能
|
||||
@@ -1148,6 +1479,11 @@ document.getElementById('env-form').addEventListener('submit', async function(e)
|
||||
const options = Array.from(document.querySelectorAll('.tag-option')).map(el => el.dataset.value);
|
||||
itemData = { key, value, description, type, options };
|
||||
} else if (type === 'multi-select') {
|
||||
// 如果开启了合并模式,且暂存区还有内容,自动将其视为确认添加
|
||||
if (isMergeMode && stagingTags && stagingTags.length > 0) {
|
||||
confirmMergeGroup();
|
||||
}
|
||||
|
||||
const selectedTags = Array.from(document.querySelectorAll('.selected-tag'))
|
||||
.map(el => el.dataset.value);
|
||||
value = selectedTags.join(',');
|
||||
|
||||
+1709
-471
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user