305 lines
8.3 KiB
Vue
305 lines
8.3 KiB
Vue
<template>
|
||
<cl-crud ref="Crud">
|
||
<cl-row>
|
||
<cl-refresh-btn />
|
||
<cl-add-btn />
|
||
<cl-multi-delete-btn />
|
||
<el-button :icon="unmaskAll ? 'Hide' : 'View'" @click="unmaskAll = !unmaskAll">
|
||
{{ unmaskAll ? t('隐藏全部密码') : t('显示全部密码') }}
|
||
</el-button>
|
||
<cl-flex1 />
|
||
<cl-search-key :placeholder="t('搜索名称、IP、城市')" />
|
||
</cl-row>
|
||
|
||
<cl-row>
|
||
<cl-table ref="Table">
|
||
<!-- IP:端口号 + 出口 IP -->
|
||
<template #column-ipPort="{ scope }">
|
||
<div class="ip-cell">
|
||
<div class="ip-main">{{ scope.row.host }}:{{ scope.row.port }}</div>
|
||
<div v-if="scope.row.exitIp" class="ip-exit">出: {{ scope.row.exitIp }}</div>
|
||
</div>
|
||
</template>
|
||
|
||
<!-- 账号 | 密码 -->
|
||
<template #column-credential="{ scope }">
|
||
<div class="cred-cell">
|
||
<span>{{ scope.row.username || '-' }}</span>
|
||
<span class="cred-sep">|</span>
|
||
<span>{{ isUnmasked(scope.row.id) ? scope.row.password : maskPwd(scope.row.password) }}</span>
|
||
<el-icon class="cred-toggle" @click.stop="toggleUnmask(scope.row.id)">
|
||
<component :is="isUnmasked(scope.row.id) ? 'Hide' : 'View'" />
|
||
</el-icon>
|
||
</div>
|
||
</template>
|
||
|
||
<!-- 剩余时长 -->
|
||
<template #column-remainDays="{ scope }">
|
||
<el-tag v-if="scope.row.expiresAt" :type="remainTagType(scope.row.expiresAt)" size="small">
|
||
{{ remainText(scope.row.expiresAt) }}
|
||
</el-tag>
|
||
<span v-else class="text-gray">-</span>
|
||
</template>
|
||
|
||
<!-- 状态 -->
|
||
<template #column-status="{ scope }">
|
||
<el-tag :type="statusTagType(scope.row.status)" size="small">
|
||
{{ statusText(scope.row.status) }}
|
||
</el-tag>
|
||
</template>
|
||
</cl-table>
|
||
</cl-row>
|
||
|
||
<cl-row>
|
||
<cl-flex1 />
|
||
<cl-pagination />
|
||
</cl-row>
|
||
|
||
<cl-upsert ref="Upsert" />
|
||
</cl-crud>
|
||
</template>
|
||
|
||
<script lang="ts" setup>
|
||
defineOptions({ name: 'geo-proxies' });
|
||
|
||
import { ref, reactive } from 'vue';
|
||
import { useCrud, useTable, useUpsert } from '@cool-vue/crud';
|
||
import { useCool } from '/@/cool';
|
||
import { useI18n } from 'vue-i18n';
|
||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||
|
||
const { service } = useCool();
|
||
const { t } = useI18n();
|
||
|
||
// 密码遮罩状态
|
||
const unmaskAll = ref(false);
|
||
const unmaskRows = reactive<Record<number, boolean>>({});
|
||
|
||
function isUnmasked(id: number): boolean {
|
||
return unmaskAll.value || !!unmaskRows[id];
|
||
}
|
||
function toggleUnmask(id: number) {
|
||
unmaskRows[id] = !unmaskRows[id];
|
||
}
|
||
function maskPwd(pwd: string | undefined | null): string {
|
||
if (!pwd) return '-';
|
||
return '••••••';
|
||
}
|
||
|
||
// 剩余天数
|
||
function remainDays(expiresAt: string | Date): number {
|
||
const ms = new Date(expiresAt).getTime() - Date.now();
|
||
return Math.ceil(ms / 86400000);
|
||
}
|
||
function remainText(expiresAt: string | Date): string {
|
||
const d = remainDays(expiresAt);
|
||
if (d <= 0) return t('已过期');
|
||
return d + t('天');
|
||
}
|
||
function remainTagType(expiresAt: string | Date): 'success' | 'warning' | 'danger' {
|
||
const d = remainDays(expiresAt);
|
||
if (d <= 0) return 'danger';
|
||
if (d <= 7) return 'warning';
|
||
return 'success';
|
||
}
|
||
|
||
// 状态
|
||
function statusText(s: string): string {
|
||
const map: Record<string, string> = {
|
||
active: t('正常'),
|
||
expired: t('已过期'),
|
||
error: t('异常'),
|
||
unbound: t('未绑定'),
|
||
};
|
||
return map[s] || s || '-';
|
||
}
|
||
function statusTagType(s: string): 'success' | 'danger' | 'warning' | 'info' {
|
||
if (s === 'active') return 'success';
|
||
if (s === 'expired' || s === 'error') return 'danger';
|
||
if (s === 'unbound') return 'warning';
|
||
return 'info';
|
||
}
|
||
|
||
// 编辑表单
|
||
const Upsert = useUpsert({
|
||
dialog: { width: '600px' },
|
||
props: { labelWidth: '100px' },
|
||
items: [
|
||
{ label: t('名称'), prop: 'name', required: true, component: { name: 'el-input' } },
|
||
{
|
||
label: t('供应商'),
|
||
prop: 'provider',
|
||
required: true,
|
||
value: 'local',
|
||
component: {
|
||
name: 'el-select',
|
||
options: [
|
||
{ label: 'Local', value: 'local' },
|
||
{ label: '天启', value: 'tianqi' },
|
||
],
|
||
},
|
||
},
|
||
{
|
||
label: t('模式'),
|
||
prop: 'mode',
|
||
required: true,
|
||
value: 'local',
|
||
component: {
|
||
name: 'el-select',
|
||
options: [
|
||
{ label: '本地', value: 'local' },
|
||
{ label: '第三方', value: 'third_party' },
|
||
],
|
||
},
|
||
},
|
||
{
|
||
label: t('协议'),
|
||
prop: 'protocol',
|
||
value: 'http',
|
||
component: {
|
||
name: 'el-select',
|
||
options: [
|
||
{ label: 'HTTP', value: 'http' },
|
||
{ label: 'SOCKS5', value: 'socks5' },
|
||
],
|
||
},
|
||
},
|
||
{ label: t('主机'), prop: 'host', component: { name: 'el-input', props: { placeholder: '210.51.27.112' } } },
|
||
{
|
||
label: t('端口'),
|
||
prop: 'port',
|
||
component: { name: 'el-input-number', props: { min: 1, max: 65535, controlsPosition: 'right' } },
|
||
},
|
||
{ label: t('用户名'), prop: 'username', component: { name: 'el-input' } },
|
||
{ label: t('密码'), prop: 'password', component: { name: 'el-input', props: { showPassword: true } } },
|
||
{ label: t('出口IP'), prop: 'exitIp', component: { name: 'el-input', props: { placeholder: '可选,测试后自动填' } } },
|
||
{ label: t('开通城市'), prop: 'city', component: { name: 'el-input', props: { placeholder: '上海' } } },
|
||
{ label: t('套餐ID'), prop: 'packageId', component: { name: 'el-input' } },
|
||
{ label: t('地区'), prop: 'region', component: { name: 'el-input' } },
|
||
{ label: t('ISP'), prop: 'isp', component: { name: 'el-input' } },
|
||
{
|
||
label: t('过期时间'),
|
||
prop: 'expiresAt',
|
||
component: {
|
||
name: 'el-date-picker',
|
||
props: { type: 'datetime', valueFormat: 'YYYY-MM-DD HH:mm:ss', placeholder: '选择过期时间' },
|
||
},
|
||
},
|
||
],
|
||
});
|
||
|
||
// 表格
|
||
const Table = useTable({
|
||
contextMenu: ['refresh', 'edit', 'delete'],
|
||
columns: [
|
||
{ type: 'selection' },
|
||
{ label: 'ID', prop: 'id', width: 60 },
|
||
{ label: t('名称'), prop: 'name', minWidth: 120, showOverflowTooltip: true },
|
||
{ label: t('IP地址:端口号'), prop: 'ipPort', minWidth: 180 },
|
||
{ label: t('账号|密码'), prop: 'credential', minWidth: 220 },
|
||
{ label: t('开通城市'), prop: 'city', minWidth: 100 },
|
||
{ label: t('剩余时长'), prop: 'remainDays', minWidth: 110 },
|
||
{ label: t('所属套餐'), prop: 'packageId', minWidth: 180, showOverflowTooltip: true },
|
||
{ label: t('协议'), prop: 'protocol', minWidth: 80 },
|
||
{ label: t('状态'), prop: 'status', minWidth: 100 },
|
||
{ label: t('延迟(ms)'), prop: 'latencyMs', minWidth: 90 },
|
||
{ label: t('最后检测'), prop: 'lastCheckAt', minWidth: 160, sortable: 'custom' },
|
||
{
|
||
type: 'op',
|
||
width: 200,
|
||
buttons: [
|
||
{
|
||
label: t('测试'),
|
||
type: 'primary',
|
||
onClick({ scope }) {
|
||
testProxy(scope.row.id);
|
||
},
|
||
},
|
||
'edit',
|
||
'delete',
|
||
],
|
||
},
|
||
],
|
||
});
|
||
|
||
// CRUD
|
||
const Crud = useCrud(
|
||
{ service: service.geo.proxy_ip },
|
||
(app) => {
|
||
app.refresh();
|
||
}
|
||
);
|
||
|
||
// 测试 IP(入站+出站)
|
||
async function testProxy(id: number) {
|
||
const loading = ElMessage({ message: t('正在测试 IP...'), type: 'info', duration: 0 });
|
||
try {
|
||
const r: any = await service.request({
|
||
url: '/admin/geo/proxy_ip/test',
|
||
method: 'POST',
|
||
data: { id },
|
||
});
|
||
loading.close();
|
||
if (r?.ok) {
|
||
const lines = [
|
||
`✅ ${t('代理可用')}`,
|
||
`${t('延迟')}: ${r.latencyMs}ms`,
|
||
`${t('出口IP')}: ${r.exitIp}`,
|
||
`${t('入口IP')}: ${r.expectedIp}`,
|
||
r.ipMatch === false
|
||
? `⚠️ ${t('出口IP与入口IP不一致(住宅代理常见,正常)')}`
|
||
: `✓ ${t('出口IP匹配')}`,
|
||
];
|
||
ElMessageBox.alert(lines.join('\n'), t('测试结果'), { confirmButtonText: t('确定'), customStyle: { whiteSpace: 'pre-line' } });
|
||
} else {
|
||
ElMessageBox.alert(`❌ ${t('代理不可用')}\n${t('延迟')}: ${r?.latencyMs ?? '-'}ms\n${t('错误')}: ${r?.error || t('未知')}`, t('测试结果'), {
|
||
type: 'error',
|
||
confirmButtonText: t('确定'),
|
||
customStyle: { whiteSpace: 'pre-line' },
|
||
});
|
||
}
|
||
Crud.value?.refresh();
|
||
} catch (e: any) {
|
||
loading.close();
|
||
ElMessage.error(e?.message || t('测试失败'));
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<style lang="scss" scoped>
|
||
.ip-cell {
|
||
display: flex;
|
||
flex-direction: column;
|
||
line-height: 1.4;
|
||
.ip-main {
|
||
font-weight: 500;
|
||
}
|
||
.ip-exit {
|
||
font-size: 12px;
|
||
color: #909399;
|
||
}
|
||
}
|
||
|
||
.cred-cell {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
font-family: Consolas, Menlo, monospace;
|
||
.cred-sep {
|
||
color: #c0c4cc;
|
||
}
|
||
.cred-toggle {
|
||
cursor: pointer;
|
||
color: #409eff;
|
||
margin-left: 4px;
|
||
&:hover {
|
||
color: #66b1ff;
|
||
}
|
||
}
|
||
}
|
||
|
||
.text-gray {
|
||
color: #909399;
|
||
}
|
||
</style>
|