Files
bettergi-scripts-web/src/App.vue
2025-04-26 15:09:55 +08:00

770 lines
26 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<a-layout>
<a-layout-content :style="{ padding: '20px 50px' }">
<a-space direction="vertical" size="large" fill>
<a-space>
<a-select v-model="selectedRepo" placeholder="选择脚本仓库" style="width: 320px" @change="fetchRepoData">
<a-option v-for="(repo, index) in repoOptions" :key="index" :value="repo.value">
{{ repo.label }}
</a-option>
</a-select>
<a-typography-text v-if="repoUpdateTime">
更新时间{{ repoUpdateTime }}
</a-typography-text>
</a-space>
<a-tabs v-if="repoData.length">
<a-tab-pane v-for="category in repoData" :key="category.name" :title="getCategoryDisplayName(category.name)">
<a-row :gutter="16">
<a-col :span="6" v-if="showTree(category)">
<!-- 添加搜索框 -->
<a-input-search v-model="treeSearchText" placeholder="搜索下方目录" style="margin-bottom: 8px;" allow-clear
@input="handleTreeSearch" @clear="handleTreeSearch" />
<!-- 添加滚动容器 -->
<div
style="height: calc(100vh - 200px); overflow-y: auto; overflow-x: hidden; border-right: 1px solid #e5e5e5;">
<a-tree :data="filteredTreeData[category.name] || getCategoryTree(category)"
:defaultExpandedKeys="getExpandedKeys(category)"
@select="(selectedKeys, event) => handleTreeSelect(selectedKeys, event, category.name)">
<template #extra="nodeData">
<a-button type="text" size="mini"
style="position: absolute; right: 8px; top: 6px; color: #3370ff;"
@click.stop="() => onTreeIconClick(nodeData)">
订阅
</a-button>
</template>
</a-tree>
</div>
</a-col>
<a-col :span="showTree(category) ? 18 : 24">
<a-space direction="vertical" size="medium" style="width: 100%;">
<a-row :gutter="16">
<a-col :span="6">
<a-input v-model="searchConditions[category.name].name" placeholder="搜索名称" allow-clear
@change="filterData(category.name)" />
</a-col>
<a-col :span="6">
<a-select v-model="searchConditions[category.name].author" placeholder="选择作者" style="width: 100%;"
allow-clear @change="filterData(category.name)">
<a-option v-for="author in getUniqueAuthors(category)" :key="author" :value="author">{{ author
}}</a-option>
</a-select>
</a-col>
<a-col :span="6">
<a-select v-model="searchConditions[category.name].tags" placeholder="选择标签" style="width: 100%;"
:filter-option="handleTagFilter" allow-clear @change="handleTagSelect(category.name)" multiple>
<a-option v-for="tag in getUniqueTags(category)" :key="tag" :value="tag">{{ tag }}</a-option>
</a-select>
</a-col>
<a-col :span="6">
<a-select v-model="searchConditions[category.name].etags" placeholder="排除标签" style="width: 100%;"
:filter-option="handleTagFilter" allow-clear @change="handleTagSelect(category.name)" multiple>
<a-option v-for="tag in getUniqueTags(category)" :key="tag" :value="tag">{{ tag }}</a-option>
</a-select>
</a-col>
</a-row>
<!-- 大盒子整体高度 calc(100vh - 200px) -->
<div style="height: calc(100vh - 200px); display: flex; flex-direction: column; flex: 1">
<!-- 表格内容区域占满剩余空间并启用滚动 -->
<div class="table-scroll-container" style="flex: 1; overflow-y: auto; overflow-x: hidden; padding-left: 16px;">
<!-- 表格内容 -->
<a-table :columns="columns" :data="filteredData[category.name].slice((currentPage - 1) * pageSize, currentPage*pageSize)" :pagination="false"
@page-size-change="handlePageSizeChange" :stickyHeader="true">
<template #name="{ record }">
<a-popover position="right" v-if="record.description" :content="record.description"
:mouseEnterDelay="0.5" :mouseLeaveDelay="0.2">
<span :ellipsis="{ rows: 1, showTooltip: false }">
{{ record.name }}
</span>
</a-popover>
<span v-else :ellipsis="{ rows: 1, showTooltip: true }">
{{ record.name }}
</span>
</template>
<template #tags="{ record }">
<a-space :style="{ flexWrap: 'wrap', rowGap: '8px' }">
<a-tag v-for="tag in record.tags" :key="tag" :color="getTagColor(tag)"
style="cursor: pointer" @click="handleTagClick(tag, category.name)" @contextmenu.prevent="handleTagRightClick(tag, category.name)">{{ tag }}</a-tag>
</a-space>
</template>
<template #operations="{ record }">
<a-space>
<a-button v-if="category.name !== 'pathing'" type="primary" size="mini"
@click="downloadScript(record)">
订阅
</a-button>
<a-button size="mini" @click="showDetails(record)">
详情
</a-button>
</a-space>
</template>
</a-table>
</div>
<!-- 分页器区域固定在大盒子底部 -->
<div style="margin-top: 10px;">
<a-pagination v-model:current="currentPage" :total="filteredData[category.name]?.length"
:pageSize="pageSize" :show-page-size="true" @change="handlePageChange" @page-size-change="handlePageSizeChange"
style="display: flex; align-items: center; justify-content: flex-end;" />
</div>
</div>
</a-space>
</a-col>
</a-row>
</a-tab-pane>
</a-tabs>
<a-empty v-else description="请选择一个仓库" />
</a-space>
</a-layout-content>
<a-drawer :visible="drawerVisible" @cancel="closeDrawer" @ok="closeDrawer" unmountOnClose :width="480">
<template #title>
脚本详情
</template>
<a-descriptions :data="drawerData" layout="vertical" bordered />
</a-drawer>
<!-- 添加加载模态框 -->
<a-modal :visible="loading" :footer="false" :closable="false" :mask-closable="false" :unmount-on-close="true">
<div style="text-align: center;">
<a-spin size="large" />
<p style="margin-top: 16px;">正在加载仓库数据...</p>
</div>
</a-modal>
</a-layout>
</template>
<script setup>
import { ref, onMounted, reactive, computed, h } from 'vue';
import { Message, Popover, Typography } from '@arco-design/web-vue';
import { useClipboard } from '@vueuse/core';
import { match } from 'pinyin-pro';
// 添加环境变量的引用
const mode = import.meta.env.VITE_MODE;
// 添加新的环境变量引用
const hiddenTabs = import.meta.env.VITE_HIDDEN_TABS ? import.meta.env.VITE_HIDDEN_TABS.split(',') : [];
const baseRepo = "https://raw.githubusercontent.com/babalae/bettergi-scripts-list/refs/heads/main/repo.json";
const mirrorUrls = [
"{0}",
"https://hub.gitmirror.com/{0}",
"https://ghproxy.cc/{0}",
"https://www.ghproxy.cc/{0}",
"https://ghproxy.cn/{0}",
"https://ghproxy.net/{0}",
"https://mirror.ghproxy.com/{0}"
];
const isPinyinMatch = (text, search) => {
const searchLower = search.toLowerCase();
const textLower = text.toLowerCase();
if (textLower.includes(searchLower)) {
return true;
}
const pinyinMatch = match(textLower, searchLower);
return !!pinyinMatch
};
// 添加树搜索相关的响应式变量
const treeSearchText = ref('');
const filteredTreeData = reactive({});
// 添加树搜索处理函数
const handleTreeSearch = () => {
repoData.value.forEach(category => {
if (showTree(category)) {
if (!treeSearchText.value) {
filteredTreeData[category.name] = getCategoryTree(category);
} else {
const searchText = treeSearchText.value.toLowerCase();
const originalTree = getCategoryTree(category);
filteredTreeData[category.name] = filterTreeNodes(originalTree, searchText);
}
}
});
};
// 添加树节点过滤函数
const filterTreeNodes = (nodes, searchText) => {
return nodes.map(node => {
const isSelfMatch = isPinyinMatch(node.title, searchText);
if (isSelfMatch) {
return { ...node };
}
const newNode = { ...node };
if (newNode.children) {
newNode.children = filterTreeNodes(newNode.children, searchText);
}
return (newNode.children?.length > 0) ? newNode : null;
}).filter(Boolean);
};
// 修改 repoOptions 的定义
/* const repoOptions = computed(() => {
if (mode === 'single') {
return [{ label: "BetterGI 本地仓库", value: "local" }];
} else {
return mirrorUrls.map((url, index) => ({
label: index === 0 ? "BetterGI 中央仓库" : `BetterGI 中央仓库 镜像 ${index}`,
value: url.replace("{0}", baseRepo)
}));
}
}); */
const repoOptions = computed(() => {
// 生成镜像选项列表
const mirrorOptions = mirrorUrls.map((url, index) => ({
label: index === 0 ? "BetterGI 中央仓库" : `镜像源 ${index}`,
value: url.replace("{0}", baseRepo)
}));
// 本地模式追加镜像选项
if (mode === 'single') {
return [
{ label: "BetterGI 本地仓库", value: "local" },
...mirrorOptions.map(opt => ({
...opt,
label: `在线仓库 ${opt.label}` // 添加前缀区分
}))
];
}
return mirrorOptions;
});
const selectedRepo = ref('');
// 修改 repoData 的定义为 computed 属性
const repoData = computed(() => {
return repoDataRaw.value.filter(category => !hiddenTabs.includes(category.name));
});
// 添加一个新的 ref 来存储原始数据
const repoDataRaw = ref([]);
const drawerVisible = ref(false);
const drawerData = ref([]);
const searchConditions = reactive({});
const filteredData = reactive({});
// 添加 loading 状态
const loading = ref(false);
// 添加新的响应式量
const repoUpdateTime = ref('');
const currentPage = ref(1);
const pageSize = ref(20);
const handlePageChange = (newPage) => {
currentPage.value = newPage;
const tableContainer = document.querySelector('.table-scroll-container');
if (tableContainer) {
tableContainer.scrollTop = 0;
}
};
const handlePageSizeChange = (newPageSize) => {
pageSize.value = newPageSize;
currentPage.value = 1;
const tableContainer = document.querySelector('.table-scroll-container');
if (tableContainer) {
tableContainer.scrollTop = 0;
}
};
const columns = [
{
title: '名称',
dataIndex: 'name',
slotName: 'name',
ellipsis: true,
tooltip: false // 关闭默认的 tooltip
},
{ title: '作者', dataIndex: 'author', width: 200 },
{ title: '版本', dataIndex: 'version', width: 100 },
{ title: '标签', dataIndex: 'tags', slotName: 'tags' },
{ title: '操作', slotName: 'operations' },
];
const GetRepoDataFromLocal = async () => {
const repoWebBridge = chrome.webview.hostObjects.repoWebBridge;
const jsonString = await repoWebBridge.GetRepoJson();
return JSON.parse(jsonString);
};
const fetchRepoData = async () => {
if (!selectedRepo.value) return;
loading.value = true;
// 清空现有数据
repoDataRaw.value = [];
repoUpdateTime.value = '';
treeSearchText.value = ''; // 清空树搜索文本
Object.keys(searchConditions).forEach(key => {
searchConditions[key] = {
name: '',
author: '',
tags: [],
etags: [],
path: ''
};
});
Object.keys(filteredData).forEach(key => {
filteredData[key] = [];
});
try {
let repoInfo;
/* if (mode === 'single') {
repoInfo = await GetRepoDataFromLocal();
} else {
const response = await fetch(selectedRepo.value);
repoInfo = await response.json();
} */
if (mode === 'single') {
if (selectedRepo.value === 'local') { // 根据选择的仓库判断
repoInfo = await GetRepoDataFromLocal();
} else {
const response = await fetch(selectedRepo.value);
repoInfo = await response.json();
}
} else {
const response = await fetch(selectedRepo.value);
repoInfo = await response.json();
}
// 从 indexes 中获取数据
repoDataRaw.value = repoInfo.indexes;
// 解析并设置更新时间
if (repoInfo.time) {
repoUpdateTime.value = formatDate(repoInfo.time);
}
// 为所有节点生成 path
repoDataRaw.value.forEach(category => generatePaths(category));
initializeSearchConditions();
// 初始化 tagColorMap
repoDataRaw.value.forEach(category => {
traverseCategory(category, (item) => {
if (Array.isArray(item.tags)) {
item.tags.forEach(tag => {
if (tag && typeof tag === 'string' && !tagColorMap[tag]) {
tagColorMap[tag] = getRandomColor();
}
});
}
});
});
} catch (error) {
Message.error('获取仓库数据失败,请尝试更换仓库');
console.error('Error fetching repo data:', error);
} finally {
loading.value = false;
}
};
// 新增函数:为所有节点生成 path
const generatePaths = (node, parentPath = '') => {
const currentPath = parentPath ? `${parentPath}/${node.name}` : node.name;
node.path = currentPath;
if (node.type === 'directory' && Array.isArray(node.children)) {
node.children.forEach(child => generatePaths(child, currentPath));
}
};
const traverseCategory = (category, callback) => {
if (category.type === 'file') {
callback(category);
} else if (category.type === 'directory' && Array.isArray(category.children)) {
if (category.name === 'js') {
category.children.forEach(child => {
if (child.type === 'directory') {
// 处理 JS 脚本
if (child.description && child.description.includes('~|~')) {
const [nameSuffix, newDescription] = child.description.split('~|~');
child.name = `${child.name} - ${nameSuffix.trim()}`;
child.description = newDescription.trim();
}
callback(child);
} else {
traverseCategory(child, callback);
}
});
} else {
category.children.forEach(child => traverseCategory(child, callback));
}
}
};
const getUniqueAuthors = (category) => {
const authors = new Set();
traverseCategory(category, (item) => {
if (item.author) authors.add(item.author);
});
return [...authors];
};
const getUniqueTags = (category) => {
const tags = new Set();
traverseCategory(category, (item) => {
if (Array.isArray(item.tags)) {
item.tags.forEach(tag => tags.add(tag));
}
});
return [...tags];
};
const filterData = (categoryName) => {
const category = repoDataRaw.value.find(cat => cat.name === categoryName);
const condition = searchConditions[categoryName];
const filtered = [];
traverseCategory(category, (item) => {
// 名称匹配:如果条件为空或项名称满足拼音匹配则认为匹配
const nameMatch = !condition.name || isPinyinMatch(item.name, condition.name);
// 作者匹配:如果条件为空或项作者与条件一致则认为匹配
const authorMatch = !condition.author || item.author === condition.author;
// 标签匹配:如果 condition.tags 为空或者 item.tags 包含条件中的所有标签,则认为匹配
const tagMatch = condition.tags.length === 0 || (Array.isArray(item.tags) && condition.tags.every(tag => item.tags.includes(tag)));
// 排除标签匹配:如果 condition.etags 有内容,则要求 item.tags 不包含其中任一标签
const etagMatch = condition.etags.length === 0 || (Array.isArray(item.tags) && condition.etags.every(excludeTag => !item.tags.includes(excludeTag)));
// 路径匹配:满足条件时 item.path 存在且以指定条件开头,但不完全等于该条件
const pathMatch = !condition.path || (item.path && item.path.startsWith(condition.path) && item.path !== condition.path);
// 当各条件均满足,且类型为 'file' 或(如果当前分类为 'js' 则允许 'directory')时,将该项加入过滤结果
if (nameMatch && authorMatch && tagMatch && etagMatch && pathMatch &&
(item.type === 'file' || (category.name === 'js' && item.type === 'directory'))) {
filtered.push(item);
}
});
filteredData[categoryName] = filtered;
};
const initializeSearchConditions = () => {
repoDataRaw.value.forEach(category => {
searchConditions[category.name] = {
name: '',
author: '',
tags: [],
etags: [],
path: ''
};
filteredData[category.name] = [];
traverseCategory(category, (item) => {
filteredData[category.name].push(item);
});
});
};
const getRandomColor = () => {
const letters = '0123456789ABCDEF';
let color = '#';
for (let i = 0; i < 6; i++) {
color += letters[Math.floor(Math.random() * 16)];
}
return color;
};
const tagColorMap = reactive({});
const getTagColor = (tag) => {
if (!tagColorMap[tag]) {
tagColorMap[tag] = getRandomColor();
}
return tagColorMap[tag];
};
const { copy } = useClipboard();
const downloadScript = async (script) => {
// 创建一个包含脚本路径的数组
const subscriptionData = [script.path];
// 将数组转换为 JSON 字串
const jsonString = JSON.stringify(subscriptionData);
const base64String = btoa(encodeURIComponent(jsonString));
// 创建完整的 URL
const fullUrl = `bettergi://script?import=${base64String}`;
/* if (mode === 'single') {
try {
await subscribeToLocal(fullUrl);
// Message.success(`已成功订阅 ${script.name}`);
} catch (error) {
console.error('订阅脚本失败:', error);
Message.error(`订阅 ${script.name} 失败`);
}
} else {
// 将完整的 URL 复制到剪贴板
copy(fullUrl).then(() => {
Message.success(`已将 ${script.name} 的订阅链接复制到剪贴板`);
}).catch((error) => {
console.error('复制到剪贴板失败:', error);
Message.error(`复制 ${script.name} 的订阅链接失败`);
});
} */
if (mode === 'single') {
if (selectedRepo.value === 'local') {
try {
await subscribeToLocal(fullUrl);
} catch (error) {
console.error('订阅失败:', error);
Message.error(`订阅失败: ${error.message}`);
}
} else {
copy(fullUrl).then(() => {
Message.success(`订阅链接已复制,回到地图追踪页面以继续导入`);
}).catch((error) => {
console.error('复制到剪贴板失败:', error);
Message.error(`复制 ${script.name} 的订阅链接失败`);
});
}
} else {
// 将完整的 URL 复制到剪贴板
copy(fullUrl).then(() => {
Message.success(`已将 ${script.name} 的订阅链接复制到剪贴板`);
}).catch((error) => {
console.error('复制到剪贴板失败:', error);
Message.error(`复制 ${script.name} 的订阅链接失败`);
});
}
};
const subscribeToLocal = async (url) => {
const repoWebBridge = chrome.webview.hostObjects.repoWebBridge;
await repoWebBridge.ImportUri(url);
};
const showDetails = (script) => {
drawerData.value = [
{ label: '名称', value: script.name },
{ label: '作者', value: script.author },
{ label: '版本', value: script.version },
{ label: '描述', value: script.description || '无描述' },
{ label: '标签', value: script.tags },
{ label: 'Hash', value: script.hash },
];
drawerVisible.value = true;
};
const closeDrawer = () => {
drawerVisible.value = false;
};
const getCategoryTree = (category) => {
const buildTree = (node, isRoot = false) => {
if (node.type === 'file') {
return null;
}
// 创建基本树节点
const treeNode = {
title: isRoot ? getCategoryDisplayName(node.name) : node.name,
key: node.path,
children: Array.isArray(node.children)
? node.children
.map(child => buildTree(child, false))
.filter(Boolean)
: undefined,
selectable: true
};
// 添加图标(原本地无法显示图标)
if (!isRoot) {
treeNode.icon = () => h('img', {
src: getIconUrl(node.name),
style: {
width: '22px',
height: '22px',
},
onError: (e) => {
e.target.style.display = 'none';
}
});
}
return treeNode;
};
return [buildTree(category, true)].filter(Boolean);
};
const showTree = (category) => {
return category.name === 'pathing';
};
const handleTreeSelect = (selectedKeys, event, categoryName) => {
const selectedNode = event.node;
searchConditions[categoryName].path = selectedNode.key;
filterData(categoryName);
};
const handleTagSelect = (categoryName) => {
filterData(categoryName);
};
const handleTagClick = (tag, categoryName) => {
if (!searchConditions[categoryName].tags.includes(tag)) {
searchConditions[categoryName].tags.push(tag);
} else {
searchConditions[categoryName].tags = searchConditions[categoryName].tags.filter(t => t !== tag);
}
filterData(categoryName);
}
const handleTagRightClick = (tag, categoryName) => {
if (!searchConditions[categoryName].etags.includes(tag)) {
searchConditions[categoryName].etags.push(tag);
} else {
searchConditions[categoryName].etags = searchConditions[categoryName].etags.filter(t => t !== tag);
}
filterData(categoryName);
}
const handleTagFilter = (value, option) => {
return isPinyinMatch(option.value, value);
};
// 添加类别名称映射
const categoryNameMap = {
'pathing': '地图追踪',
'js': 'JS脚本',
'keymouse': '键鼠脚本',
'combat': '战斗策略',
'tcg': '七圣召唤',
'onekey': '一键宏'
};
// 添加获取显示名称的函数
const getCategoryDisplayName = (name) => {
return categoryNameMap[name] || name;
};
const onTreeIconClick = (nodeData) => {
downloadScript({ name: nodeData.title, path: nodeData.key });
};
// 修改日期格式化函数
const formatDate = (timeString) => {
if (typeof timeString !== 'string' || timeString.length !== 14) {
return '无效的时间格式';
}
const year = timeString.slice(0, 4);
const month = timeString.slice(4, 6);
const day = timeString.slice(6, 8);
const hours = timeString.slice(8, 10);
const minutes = timeString.slice(10, 12);
const seconds = timeString.slice(12, 14);
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
};
// 在 script setup 部分添加图标 URL 处理函数
const getIconUrl = (tag) => {
const baseIconUrl = "https://raw.githubusercontent.com/babalae/bettergi-scripts-list/refs/heads/main/repo/pathing/";
const encodedTag = encodeURIComponent(tag);
const iconPath = `${baseIconUrl}${encodedTag}/icon.ico`;
// 使用当前选中的镜像URL格式
if (selectedRepo.value && selectedRepo.value !== 'local') {
const mirrorFormat = selectedRepo.value.split(baseRepo)[0];
return mirrorFormat + iconPath;
}
return iconPath;
};
// 添加一个新的函数来获取前两级节点的 keys
const getExpandedKeys = (category) => {
const keys = [];
// 添加根节点
keys.push(category.path);
// 添加第一级子节点
// if (Array.isArray(category.children)) {
// category.children.forEach(child => {
// if (child.path) {
// keys.push(child.path);
// }
// });
// }
return keys;
};
onMounted(() => {
// 默认选中第一个仓库
if (repoOptions.value.length > 0) {
selectedRepo.value = repoOptions.value[0].value;
fetchRepoData();
}
});
</script>
<style scoped>
.arco-tree {
background-color: var(--color-fill-2);
padding: 16px;
}
:deep(.arco-tree-node) {
position: relative;
padding-right: 80px !important; /* 给按钮留出空间 */
}
/* 添加图标相关样式 */
:deep(.arco-tree-node-title) {
display: flex;
align-items: center;
width: calc(100% - 60px); /* 留出按钮空间 */
min-width: 0; /* 允许文本截断 */
}
:deep(.arco-tree-node-title-text) {
white-space: normal !important;
word-break: break-word;
line-height: 1.5;
max-width: 100%;
flex: 1;
margin-right: 4px; /* 与按钮间距 */
}
:deep(.arco-tree-node-icon) {
margin-right: 4px;
}
/* 移除之前为表格单元格添加的样式 */
.arco-table-td {
max-width: none;
white-space: normal;
overflow: visible;
text-overflow: clip;
}
.footer {
position: fixed;
}
</style>