352 lines
9.9 KiB
Vue
352 lines
9.9 KiB
Vue
<template>
|
|
<div class="page-container">
|
|
<PageHeader title="代理列表" :icon="Document" />
|
|
|
|
<el-card class="filter-card" shadow="hover">
|
|
<el-form :inline="true" :model="filterForm" class="form-row">
|
|
<el-form-item label="协议类型">
|
|
<el-select
|
|
v-model="filterForm.protocol"
|
|
placeholder="全部"
|
|
clearable
|
|
style="width: 120px"
|
|
@change="handleSearch"
|
|
>
|
|
<el-option label="全部" value="" />
|
|
<el-option label="HTTP" value="http" />
|
|
<el-option label="HTTPS" value="https" />
|
|
<el-option label="SOCKS4" value="socks4" />
|
|
<el-option label="SOCKS5" value="socks5" />
|
|
</el-select>
|
|
</el-form-item>
|
|
<el-form-item label="最低分数">
|
|
<el-input-number
|
|
v-model="filterForm.minScore"
|
|
:min="0"
|
|
:max="100"
|
|
style="width: 120px"
|
|
@change="handleSearch"
|
|
/>
|
|
</el-form-item>
|
|
<el-form-item label="排序方式">
|
|
<el-select v-model="filterForm.sortBy" style="width: 140px" @change="handleSearch">
|
|
<el-option label="更新时间" value="last_check" />
|
|
<el-option label="分数" value="score" />
|
|
</el-select>
|
|
</el-form-item>
|
|
</el-form>
|
|
</el-card>
|
|
|
|
<el-card class="table-card" shadow="hover">
|
|
<template #header>
|
|
<div class="card-header">
|
|
<span class="card-title">
|
|
<el-icon class="header-icon"><List /></el-icon>
|
|
代理详情
|
|
</span>
|
|
<div class="header-actions">
|
|
<el-button-group>
|
|
<el-button
|
|
type="danger"
|
|
@click="handleBatchDelete"
|
|
:disabled="selectedProxies.length === 0"
|
|
>
|
|
<el-icon class="btn-icon"><Delete /></el-icon>
|
|
批量删除
|
|
</el-button>
|
|
<el-dropdown trigger="click" @command="handleExport">
|
|
<el-button type="success">
|
|
<el-icon class="btn-icon"><Download /></el-icon>
|
|
导出
|
|
<el-icon class="el-icon--right"><ArrowDown /></el-icon>
|
|
</el-button>
|
|
<template #dropdown>
|
|
<el-dropdown-menu>
|
|
<el-dropdown-item command="txt">TXT格式</el-dropdown-item>
|
|
<el-dropdown-item command="csv">CSV格式</el-dropdown-item>
|
|
<el-dropdown-item command="json">JSON格式</el-dropdown-item>
|
|
</el-dropdown-menu>
|
|
</template>
|
|
</el-dropdown>
|
|
</el-button-group>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<el-table
|
|
:data="proxyStore.proxies"
|
|
style="width: 100%"
|
|
v-loading="proxyStore.loading"
|
|
@selection-change="handleSelectionChange"
|
|
:row-style="{ cursor: 'pointer' }"
|
|
empty-text="暂无数据"
|
|
>
|
|
<el-table-column type="selection" width="55" />
|
|
<el-table-column prop="ip" label="IP地址" width="150" />
|
|
<el-table-column prop="port" label="端口" width="100" />
|
|
<el-table-column prop="protocol" label="协议" width="100">
|
|
<template #default="{ row }">
|
|
<el-tag :type="getProtocolType(row.protocol)" effect="light" size="small">
|
|
{{ row.protocol.toUpperCase() }}
|
|
</el-tag>
|
|
</template>
|
|
</el-table-column>
|
|
<el-table-column prop="score" label="分数" width="100">
|
|
<template #default="{ row }">
|
|
<span class="score-value" :class="{ 'score-high': row.score >= 8, 'score-medium': row.score >= 5 && row.score < 8, 'score-low': row.score < 5 }">
|
|
{{ row.score || 0 }}
|
|
</span>
|
|
</template>
|
|
</el-table-column>
|
|
<el-table-column prop="last_check" label="最后检查时间" min-width="180">
|
|
<template #default="{ row }">
|
|
{{ formatDateTime(row.last_check) }}
|
|
</template>
|
|
</el-table-column>
|
|
<el-table-column label="操作" width="200" fixed="right">
|
|
<template #default="{ row }">
|
|
<el-button
|
|
type="primary"
|
|
size="small"
|
|
@click.stop="handleCopy(row)"
|
|
>
|
|
<el-icon class="btn-icon"><CopyDocument /></el-icon>
|
|
复制
|
|
</el-button>
|
|
<el-button
|
|
type="danger"
|
|
size="small"
|
|
@click.stop="handleDelete(row)"
|
|
>
|
|
<el-icon class="btn-icon"><Delete /></el-icon>
|
|
删除
|
|
</el-button>
|
|
</template>
|
|
</el-table-column>
|
|
</el-table>
|
|
|
|
<div class="pagination-wrapper">
|
|
<el-pagination
|
|
v-model:current-page="currentPage"
|
|
v-model:page-size="pageSize"
|
|
:page-sizes="PAGE_SIZE_OPTIONS"
|
|
:total="proxyStore.total"
|
|
layout="total, sizes, prev, pager, next, jumper"
|
|
@size-change="handleSizeChange"
|
|
@current-change="handleCurrentChange"
|
|
/>
|
|
</div>
|
|
</el-card>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, reactive, onMounted, onUnmounted } from 'vue'
|
|
import { ElMessage } from 'element-plus'
|
|
import { Document, ArrowDown, List, Delete, Download, CopyDocument } from '@element-plus/icons-vue'
|
|
import { useProxyStore } from '../stores/proxy'
|
|
import { formatDateTime } from '../utils/format'
|
|
import { confirmDelete, confirmBatchDelete } from '../utils/confirm'
|
|
import { copyProxy } from '../utils/clipboard'
|
|
import PageHeader from '../components/PageHeader.vue'
|
|
|
|
/** 分页选项 */
|
|
const PAGE_SIZE_OPTIONS = [10, 20, 50, 100]
|
|
/** 默认分页大小 */
|
|
const DEFAULT_PAGE_SIZE = 20
|
|
|
|
const proxyStore = useProxyStore()
|
|
|
|
// ==================== 状态 ====================
|
|
const currentPage = ref(1)
|
|
const pageSize = ref(DEFAULT_PAGE_SIZE)
|
|
const selectedProxies = ref([])
|
|
let abortController = null
|
|
|
|
const filterForm = reactive({
|
|
protocol: '',
|
|
minScore: 0,
|
|
sortBy: 'last_check',
|
|
sortOrder: 'DESC'
|
|
})
|
|
|
|
// ==================== 协议类型映射 ====================
|
|
const PROTOCOL_TYPE_MAP = {
|
|
http: 'info',
|
|
https: 'success',
|
|
socks4: 'warning',
|
|
socks5: 'primary'
|
|
}
|
|
|
|
function getProtocolType(protocol) {
|
|
return PROTOCOL_TYPE_MAP[protocol] || 'info'
|
|
}
|
|
|
|
// ==================== 数据获取 ====================
|
|
async function fetchProxies() {
|
|
// 取消之前的请求
|
|
if (abortController) {
|
|
abortController.abort()
|
|
}
|
|
abortController = new AbortController()
|
|
|
|
try {
|
|
const success = await proxyStore.fetchProxies({
|
|
page: currentPage.value,
|
|
page_size: pageSize.value,
|
|
protocol: filterForm.protocol || null,
|
|
min_score: filterForm.minScore,
|
|
sort_by: filterForm.sortBy,
|
|
sort_order: filterForm.sortOrder
|
|
}, abortController.signal)
|
|
|
|
if (!success) {
|
|
ElMessage.error('获取代理列表失败')
|
|
}
|
|
} catch (error) {
|
|
if (error.name === 'AbortError') {
|
|
// 用户主动取消,不提示错误
|
|
return
|
|
}
|
|
throw error
|
|
} finally {
|
|
abortController = null
|
|
}
|
|
}
|
|
|
|
// ==================== 事件处理 ====================
|
|
function handleSearch() {
|
|
currentPage.value = 1
|
|
fetchProxies()
|
|
}
|
|
|
|
function handleSelectionChange(selection) {
|
|
selectedProxies.value = selection.map(item => ({ ip: item.ip, port: item.port }))
|
|
}
|
|
|
|
async function handleCopy(proxy) {
|
|
await copyProxy(proxy)
|
|
}
|
|
|
|
async function handleDelete(proxy) {
|
|
const confirmed = await confirmDelete(`代理 ${proxy.ip}:${proxy.port}`)
|
|
if (!confirmed) return
|
|
|
|
const filters = {
|
|
protocol: filterForm.protocol || null,
|
|
min_score: filterForm.minScore,
|
|
sort_by: filterForm.sortBy,
|
|
sort_order: filterForm.sortOrder
|
|
}
|
|
const success = await proxyStore.deleteProxy(proxy.ip, proxy.port, currentPage.value, pageSize.value, filters)
|
|
if (success) {
|
|
ElMessage.success('删除成功')
|
|
}
|
|
}
|
|
|
|
async function handleBatchDelete() {
|
|
const count = selectedProxies.value.length
|
|
if (!count) return
|
|
|
|
const confirmed = await confirmBatchDelete(count, '代理')
|
|
if (!confirmed) return
|
|
|
|
const filters = {
|
|
protocol: filterForm.protocol || null,
|
|
min_score: filterForm.minScore,
|
|
sort_by: filterForm.sortBy,
|
|
sort_order: filterForm.sortOrder
|
|
}
|
|
const deletedCount = await proxyStore.batchDeleteProxies(selectedProxies.value, currentPage.value, pageSize.value, filters)
|
|
if (deletedCount > 0) {
|
|
ElMessage.success(`已删除 ${deletedCount} 个代理`)
|
|
selectedProxies.value = []
|
|
}
|
|
}
|
|
|
|
async function handleExport(format) {
|
|
const success = await proxyStore.exportProxies(format, filterForm.protocol || null)
|
|
if (success) {
|
|
ElMessage.success(`导出 ${format.toUpperCase()} 格式成功`)
|
|
}
|
|
}
|
|
|
|
function handleSizeChange(size) {
|
|
pageSize.value = size
|
|
currentPage.value = 1
|
|
fetchProxies()
|
|
}
|
|
|
|
function handleCurrentChange(page) {
|
|
currentPage.value = page
|
|
fetchProxies()
|
|
}
|
|
|
|
// ==================== 生命周期 ====================
|
|
onMounted(() => {
|
|
fetchProxies()
|
|
})
|
|
|
|
onUnmounted(() => {
|
|
if (abortController) {
|
|
abortController.abort()
|
|
abortController = null
|
|
}
|
|
})
|
|
</script>
|
|
|
|
<style scoped>
|
|
.filter-card {
|
|
margin-bottom: 20px;
|
|
background: var(--surface);
|
|
border: 1px solid var(--border);
|
|
border-radius: var(--radius-lg);
|
|
}
|
|
|
|
.table-card {
|
|
border-radius: var(--radius-lg);
|
|
background: var(--surface);
|
|
border: 1px solid var(--border);
|
|
}
|
|
|
|
.table-card:hover,
|
|
.filter-card:hover {
|
|
border-color: var(--border-light);
|
|
}
|
|
|
|
.header-icon {
|
|
margin-right: 8px;
|
|
color: var(--primary);
|
|
}
|
|
|
|
.header-actions {
|
|
display: flex;
|
|
gap: 10px;
|
|
}
|
|
|
|
.score-value {
|
|
font-weight: 600;
|
|
font-size: 14px;
|
|
}
|
|
|
|
.score-high {
|
|
color: var(--success);
|
|
}
|
|
|
|
.score-medium {
|
|
color: var(--warning);
|
|
}
|
|
|
|
.score-low {
|
|
color: var(--danger);
|
|
}
|
|
|
|
.pagination-wrapper {
|
|
display: flex;
|
|
justify-content: center;
|
|
margin-top: 20px;
|
|
padding-top: 20px;
|
|
border-top: 1px solid var(--border);
|
|
}
|
|
</style>
|