305 lines
8.3 KiB
Vue
Raw Normal View History

<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>