305 lines
8.3 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>
<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>