|
@@ -0,0 +1,970 @@
|
|
|
|
|
+<template>
|
|
|
|
|
+ <div class="container">
|
|
|
|
|
+ <Breadcrumb :items="['menu.settings', 'menu.settings.proxyNodes']" />
|
|
|
|
|
+ <a-card class="general-card" :title="$t('menu.settings.proxyNodes')">
|
|
|
|
|
+ <a-space class="toolbar" wrap>
|
|
|
|
|
+ <a-input-search
|
|
|
|
|
+ v-model="keyword"
|
|
|
|
|
+ allow-clear
|
|
|
|
|
+ placeholder="搜索节点 / 地址 / 平台账号"
|
|
|
|
|
+ style="width: 320px"
|
|
|
|
|
+ @search="search"
|
|
|
|
|
+ />
|
|
|
|
|
+ <a-select
|
|
|
|
|
+ v-model="statusFilter"
|
|
|
|
|
+ :options="statusOptions"
|
|
|
|
|
+ allow-clear
|
|
|
|
|
+ placeholder="全部状态"
|
|
|
|
|
+ style="width: 140px"
|
|
|
|
|
+ @change="search"
|
|
|
|
|
+ />
|
|
|
|
|
+ <a-button type="primary" @click="refreshCurrentPage">
|
|
|
|
|
+ <template #icon>
|
|
|
|
|
+ <icon-refresh />
|
|
|
|
|
+ </template>
|
|
|
|
|
+ 刷新
|
|
|
|
|
+ </a-button>
|
|
|
|
|
+ </a-space>
|
|
|
|
|
+
|
|
|
|
|
+ <div class="summary">
|
|
|
|
|
+ <a-tag color="blue"
|
|
|
|
|
+ >节点 {{ renderData.length }}/{{
|
|
|
|
|
+ pagination.total || 0
|
|
|
|
|
+ }}</a-tag
|
|
|
|
|
+ >
|
|
|
|
|
+ <a-tag color="green">启用 {{ summary.enabled }}</a-tag>
|
|
|
|
|
+ <a-tag>禁用 {{ summary.disabled }}</a-tag>
|
|
|
|
|
+ <a-tag color="green"
|
|
|
|
|
+ >在线账号 {{ summary.onlineAccounts }}</a-tag
|
|
|
|
|
+ >
|
|
|
|
|
+ <a-tag color="gray"
|
|
|
|
|
+ >离线账号 {{ summary.offlineAccounts }}</a-tag
|
|
|
|
|
+ >
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <a-table
|
|
|
|
|
+ row-key="id"
|
|
|
|
|
+ :loading="loading"
|
|
|
|
|
+ :data="renderData"
|
|
|
|
|
+ :pagination="pagination"
|
|
|
|
|
+ :scroll="{ x: 1600 }"
|
|
|
|
|
+ :row-class="getRowClass"
|
|
|
|
|
+ @page-change="onPageChange"
|
|
|
|
|
+ @page-size-change="onPageSizeChange"
|
|
|
|
|
+ >
|
|
|
|
|
+ <template #columns>
|
|
|
|
|
+ <a-table-column title="#" data-index="id" :width="70" />
|
|
|
|
|
+
|
|
|
|
|
+ <a-table-column title="节点" :width="280">
|
|
|
|
|
+ <template #cell="{ record }">
|
|
|
|
|
+ <div class="node-name-row">
|
|
|
|
|
+ <span
|
|
|
|
|
+ class="node-name copyable-text"
|
|
|
|
|
+ @click.stop="
|
|
|
|
|
+ copyValue(record.name, '节点名')
|
|
|
|
|
+ "
|
|
|
|
|
+ >
|
|
|
|
|
+ {{ record.name }}
|
|
|
|
|
+ </span>
|
|
|
|
|
+ <a-tooltip content="复制节点名">
|
|
|
|
|
+ <a-button
|
|
|
|
|
+ type="text"
|
|
|
|
|
+ size="mini"
|
|
|
|
|
+ class="copy-btn"
|
|
|
|
|
+ @click.stop="
|
|
|
|
|
+ copyValue(record.name, '节点名')
|
|
|
|
|
+ "
|
|
|
|
|
+ >
|
|
|
|
|
+ <template #icon>
|
|
|
|
|
+ <icon-copy />
|
|
|
|
|
+ </template>
|
|
|
|
|
+ </a-button>
|
|
|
|
|
+ </a-tooltip>
|
|
|
|
|
+ <a-tag
|
|
|
|
|
+ class="status-tag"
|
|
|
|
|
+ :color="record.status ? 'green' : 'gray'"
|
|
|
|
|
+ @click="toggleStatus(record)"
|
|
|
|
|
+ >
|
|
|
|
|
+ {{ getStatusTagText(record) }}
|
|
|
|
|
+ </a-tag>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div v-if="record.description" class="sub-text">
|
|
|
|
|
+ {{ record.description }}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </template>
|
|
|
|
|
+ </a-table-column>
|
|
|
|
|
+
|
|
|
|
|
+ <a-table-column title="代理地址" :width="300">
|
|
|
|
|
+ <template #cell="{ record }">
|
|
|
|
|
+ <div class="address-row">
|
|
|
|
|
+ <span
|
|
|
|
|
+ class="server-text copyable-text"
|
|
|
|
|
+ @click.stop="
|
|
|
|
|
+ copyValue(record.server, '内网代理地址')
|
|
|
|
|
+ "
|
|
|
|
|
+ >
|
|
|
|
|
+ {{ record.server }}
|
|
|
|
|
+ </span>
|
|
|
|
|
+ <a-tooltip content="复制内网代理地址">
|
|
|
|
|
+ <a-button
|
|
|
|
|
+ type="text"
|
|
|
|
|
+ size="mini"
|
|
|
|
|
+ class="copy-btn"
|
|
|
|
|
+ @click.stop="
|
|
|
|
|
+ copyValue(
|
|
|
|
|
+ record.server,
|
|
|
|
|
+ '内网代理地址'
|
|
|
|
|
+ )
|
|
|
|
|
+ "
|
|
|
|
|
+ >
|
|
|
|
|
+ <template #icon>
|
|
|
|
|
+ <icon-copy />
|
|
|
|
|
+ </template>
|
|
|
|
|
+ </a-button>
|
|
|
|
|
+ </a-tooltip>
|
|
|
|
|
+ <a-tooltip content="检测内网代理">
|
|
|
|
|
+ <a-button
|
|
|
|
|
+ type="text"
|
|
|
|
|
+ size="mini"
|
|
|
|
|
+ class="copy-btn"
|
|
|
|
|
+ :loading="
|
|
|
|
|
+ isChecking(record.id, 'internal')
|
|
|
|
|
+ "
|
|
|
|
|
+ @click.stop="
|
|
|
|
|
+ handleCheck(record, 'internal')
|
|
|
|
|
+ "
|
|
|
|
|
+ >
|
|
|
|
|
+ <template #icon>
|
|
|
|
|
+ <icon-check />
|
|
|
|
|
+ </template>
|
|
|
|
|
+ </a-button>
|
|
|
|
|
+ </a-tooltip>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="external-proxy-row">
|
|
|
|
|
+ <span class="sub-text">外网:</span>
|
|
|
|
|
+ <span
|
|
|
|
|
+ class="server-text external-proxy-text copyable-text"
|
|
|
|
|
+ @click.stop="
|
|
|
|
|
+ copyValue(
|
|
|
|
|
+ getExternalProxyAddress(record.id),
|
|
|
|
|
+ '外网代理地址'
|
|
|
|
|
+ )
|
|
|
|
|
+ "
|
|
|
|
|
+ >
|
|
|
|
|
+ {{ getExternalProxyAddress(record.id) }}
|
|
|
|
|
+ </span>
|
|
|
|
|
+ <a-tooltip content="复制外网代理地址">
|
|
|
|
|
+ <a-button
|
|
|
|
|
+ type="text"
|
|
|
|
|
+ size="mini"
|
|
|
|
|
+ class="copy-btn"
|
|
|
|
|
+ @click.stop="
|
|
|
|
|
+ copyValue(
|
|
|
|
|
+ getExternalProxyAddress(
|
|
|
|
|
+ record.id
|
|
|
|
|
+ ),
|
|
|
|
|
+ '外网代理地址'
|
|
|
|
|
+ )
|
|
|
|
|
+ "
|
|
|
|
|
+ >
|
|
|
|
|
+ <template #icon>
|
|
|
|
|
+ <icon-copy />
|
|
|
|
|
+ </template>
|
|
|
|
|
+ </a-button>
|
|
|
|
|
+ </a-tooltip>
|
|
|
|
|
+ <a-tooltip content="检测外网代理">
|
|
|
|
|
+ <a-button
|
|
|
|
|
+ type="text"
|
|
|
|
|
+ size="mini"
|
|
|
|
|
+ class="copy-btn"
|
|
|
|
|
+ :loading="
|
|
|
|
|
+ isChecking(record.id, 'external')
|
|
|
|
|
+ "
|
|
|
|
|
+ @click.stop="
|
|
|
|
|
+ handleCheck(record, 'external')
|
|
|
|
|
+ "
|
|
|
|
|
+ >
|
|
|
|
|
+ <template #icon>
|
|
|
|
|
+ <icon-check />
|
|
|
|
|
+ </template>
|
|
|
|
|
+ </a-button>
|
|
|
|
|
+ </a-tooltip>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="check-tag-row">
|
|
|
|
|
+ <a-tooltip
|
|
|
|
|
+ v-if="hasChecked(record, 'internal')"
|
|
|
|
|
+ >
|
|
|
|
|
+ <a-tag
|
|
|
|
|
+ size="small"
|
|
|
|
|
+ :color="
|
|
|
|
|
+ getCheckTagColor(
|
|
|
|
|
+ getCheckState(record).internal
|
|
|
|
|
+ )
|
|
|
|
|
+ "
|
|
|
|
|
+ >
|
|
|
|
|
+ {{
|
|
|
|
|
+ getCheckTagText(
|
|
|
|
|
+ '内网',
|
|
|
|
|
+ getCheckState(record).internal
|
|
|
|
|
+ )
|
|
|
|
|
+ }}
|
|
|
|
|
+ </a-tag>
|
|
|
|
|
+ <template #content>
|
|
|
|
|
+ <div class="check-tooltip">
|
|
|
|
|
+ <div class="check-tooltip-time">
|
|
|
|
|
+ {{
|
|
|
|
|
+ getCheckState(record)
|
|
|
|
|
+ .internal.checkedAt ||
|
|
|
|
|
+ '未检测'
|
|
|
|
|
+ }}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="check-tooltip-address">
|
|
|
|
|
+ {{
|
|
|
|
|
+ getCheckState(record)
|
|
|
|
|
+ .internal.address
|
|
|
|
|
+ }}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="check-tooltip-result">
|
|
|
|
|
+ {{
|
|
|
|
|
+ getCheckState(record)
|
|
|
|
|
+ .internal.result
|
|
|
|
|
+ }}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </template>
|
|
|
|
|
+ </a-tooltip>
|
|
|
|
|
+ <a-tooltip
|
|
|
|
|
+ v-if="hasChecked(record, 'external')"
|
|
|
|
|
+ >
|
|
|
|
|
+ <a-tag
|
|
|
|
|
+ size="small"
|
|
|
|
|
+ :color="
|
|
|
|
|
+ getCheckTagColor(
|
|
|
|
|
+ getCheckState(record).external
|
|
|
|
|
+ )
|
|
|
|
|
+ "
|
|
|
|
|
+ >
|
|
|
|
|
+ {{
|
|
|
|
|
+ getCheckTagText(
|
|
|
|
|
+ '外网',
|
|
|
|
|
+ getCheckState(record).external
|
|
|
|
|
+ )
|
|
|
|
|
+ }}
|
|
|
|
|
+ </a-tag>
|
|
|
|
|
+ <template #content>
|
|
|
|
|
+ <div class="check-tooltip">
|
|
|
|
|
+ <div class="check-tooltip-time">
|
|
|
|
|
+ {{
|
|
|
|
|
+ getCheckState(record)
|
|
|
|
|
+ .external.checkedAt ||
|
|
|
|
|
+ '未检测'
|
|
|
|
|
+ }}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="check-tooltip-address">
|
|
|
|
|
+ {{
|
|
|
|
|
+ getCheckState(record)
|
|
|
|
|
+ .external.address
|
|
|
|
|
+ }}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="check-tooltip-result">
|
|
|
|
|
+ {{
|
|
|
|
|
+ getCheckState(record)
|
|
|
|
|
+ .external.result
|
|
|
|
|
+ }}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </template>
|
|
|
|
|
+ </a-tooltip>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </template>
|
|
|
|
|
+ </a-table-column>
|
|
|
|
|
+
|
|
|
|
|
+ <a-table-column title="使用平台账号" :width="560">
|
|
|
|
|
+ <template #cell="{ record }">
|
|
|
|
|
+ <div class="account-summary">
|
|
|
|
|
+ <a-tag color="blue"
|
|
|
|
|
+ >总数 {{ record.accountCount }}</a-tag
|
|
|
|
|
+ >
|
|
|
|
|
+ <a-tag color="green"
|
|
|
|
|
+ >在线 {{ record.onlineAccountCount }}</a-tag
|
|
|
|
|
+ >
|
|
|
|
|
+ <a-tag color="gray"
|
|
|
|
|
+ >离线
|
|
|
|
|
+ {{ record.offlineAccountCount }}</a-tag
|
|
|
|
|
+ >
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <div
|
|
|
|
|
+ v-if="!record.accounts.length"
|
|
|
|
|
+ class="sub-text"
|
|
|
|
|
+ >
|
|
|
|
|
+ 暂无关联账号
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <div v-else class="account-list">
|
|
|
|
|
+ <div
|
|
|
|
|
+ v-for="account in getVisibleAccounts(
|
|
|
|
|
+ record
|
|
|
|
|
+ )"
|
|
|
|
|
+ :key="`${record.id}-${account.platform}-${account.id}`"
|
|
|
|
|
+ :class="[
|
|
|
|
|
+ 'account-chip',
|
|
|
|
|
+ { 'is-offline': !account.status },
|
|
|
|
|
+ ]"
|
|
|
|
|
+ >
|
|
|
|
|
+ <span
|
|
|
|
|
+ :class="[
|
|
|
|
|
+ 'platform',
|
|
|
|
|
+ `platform-${account.platform}`,
|
|
|
|
|
+ ]"
|
|
|
|
|
+ >
|
|
|
|
|
+ {{
|
|
|
|
|
+ platformLabelMap[
|
|
|
|
|
+ account.platform
|
|
|
|
|
+ ] || account.platform
|
|
|
|
|
+ }}
|
|
|
|
|
+ </span>
|
|
|
|
|
+ <span
|
|
|
|
|
+ class="account-copy-content copyable-text"
|
|
|
|
|
+ @click.stop="
|
|
|
|
|
+ copyValue(
|
|
|
|
|
+ getAccountCopyText(account),
|
|
|
|
|
+ '平台账号'
|
|
|
|
|
+ )
|
|
|
|
|
+ "
|
|
|
|
|
+ >
|
|
|
|
|
+ <span class="account-name">{{
|
|
|
|
|
+ account.name
|
|
|
|
|
+ }}</span>
|
|
|
|
|
+ <span
|
|
|
|
|
+ v-if="
|
|
|
|
|
+ shouldShowAccountCompany(
|
|
|
|
|
+ account
|
|
|
|
|
+ )
|
|
|
|
|
+ "
|
|
|
|
|
+ class="account-company"
|
|
|
|
|
+ >
|
|
|
|
|
+ {{ account.company }}
|
|
|
|
|
+ </span>
|
|
|
|
|
+ </span>
|
|
|
|
|
+ <a-tooltip content="复制平台账号">
|
|
|
|
|
+ <a-button
|
|
|
|
|
+ type="text"
|
|
|
|
|
+ size="mini"
|
|
|
|
|
+ class="copy-btn account-copy-btn"
|
|
|
|
|
+ @click.stop="
|
|
|
|
|
+ copyValue(
|
|
|
|
|
+ getAccountCopyText(account),
|
|
|
|
|
+ '平台账号'
|
|
|
|
|
+ )
|
|
|
|
|
+ "
|
|
|
|
|
+ >
|
|
|
|
|
+ <template #icon>
|
|
|
|
|
+ <icon-copy />
|
|
|
|
|
+ </template>
|
|
|
|
|
+ </a-button>
|
|
|
|
|
+ </a-tooltip>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <a-button
|
|
|
|
|
+ v-if="
|
|
|
|
|
+ record.accounts.length >
|
|
|
|
|
+ defaultVisibleAccountCount
|
|
|
|
|
+ "
|
|
|
|
|
+ type="text"
|
|
|
|
|
+ size="mini"
|
|
|
|
|
+ @click="toggleExpanded(record.id)"
|
|
|
|
|
+ >
|
|
|
|
|
+ {{
|
|
|
|
|
+ expandedIds.includes(record.id)
|
|
|
|
|
+ ? '收起'
|
|
|
|
|
+ : `展开 ${
|
|
|
|
|
+ record.accounts.length -
|
|
|
|
|
+ defaultVisibleAccountCount
|
|
|
|
|
+ } 个`
|
|
|
|
|
+ }}
|
|
|
|
|
+ </a-button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </template>
|
|
|
|
|
+ </a-table-column>
|
|
|
|
|
+
|
|
|
|
|
+ <a-table-column title="时间" :width="170">
|
|
|
|
|
+ <template #cell="{ record }">
|
|
|
|
|
+ <div class="sub-text">
|
|
|
|
|
+ <div
|
|
|
|
|
+ >添加:{{
|
|
|
|
|
+ formatTime(record.create_time)
|
|
|
|
|
+ }}</div
|
|
|
|
|
+ >
|
|
|
|
|
+ <div
|
|
|
|
|
+ >更新:{{
|
|
|
|
|
+ formatTime(record.last_time)
|
|
|
|
|
+ }}</div
|
|
|
|
|
+ >
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </template>
|
|
|
|
|
+ </a-table-column>
|
|
|
|
|
+ </template>
|
|
|
|
|
+ </a-table>
|
|
|
|
|
+ </a-card>
|
|
|
|
|
+ </div>
|
|
|
|
|
+</template>
|
|
|
|
|
+
|
|
|
|
|
+<script lang="ts" setup>
|
|
|
|
|
+ import { computed, reactive, ref } from 'vue';
|
|
|
|
|
+ import dayjs from 'dayjs';
|
|
|
|
|
+ import { Message, Modal } from '@arco-design/web-vue';
|
|
|
|
|
+ import useLoading from '@/hooks/loading';
|
|
|
|
|
+ import {
|
|
|
|
|
+ ProxyCheckChannelResult,
|
|
|
|
|
+ ProxyCheckTarget,
|
|
|
|
|
+ ProxyNodeListParams,
|
|
|
|
|
+ ProxyNodeRecord,
|
|
|
|
|
+ ProxyNodeUsageAccount,
|
|
|
|
|
+ checkProxyNode,
|
|
|
|
|
+ queryProxyNodeList,
|
|
|
|
|
+ updateProxyNode,
|
|
|
|
|
+ } from '@/api/proxy';
|
|
|
|
|
+
|
|
|
|
|
+ const defaultVisibleAccountCount = 8;
|
|
|
|
|
+
|
|
|
|
|
+ const platformLabelMap: Record<string, string> = {
|
|
|
|
|
+ tk: '淘宝',
|
|
|
|
|
+ jd: '京东',
|
|
|
|
|
+ pdd: '拼多多',
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ interface ProxyCheckChannelState extends ProxyCheckChannelResult {
|
|
|
|
|
+ checkedAt: string;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ interface ProxyCheckState {
|
|
|
|
|
+ internal: ProxyCheckChannelState;
|
|
|
|
|
+ external: ProxyCheckChannelState;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const { loading, setLoading } = useLoading(true);
|
|
|
|
|
+ const renderData = ref<ProxyNodeRecord[]>([]);
|
|
|
|
|
+ const keyword = ref('');
|
|
|
|
|
+ const statusFilter = ref('');
|
|
|
|
|
+ const expandedIds = ref<number[]>([]);
|
|
|
|
|
+ const checkingKeys = ref<string[]>([]);
|
|
|
|
|
+ const updatingIds = ref<number[]>([]);
|
|
|
|
|
+ const checkResults = ref<Record<number, ProxyCheckState>>({});
|
|
|
|
|
+
|
|
|
|
|
+ const statusOptions = [
|
|
|
|
|
+ { label: '全部状态', value: '' },
|
|
|
|
|
+ { label: '启用', value: 'enabled' },
|
|
|
|
|
+ { label: '禁用', value: 'disabled' },
|
|
|
|
|
+ ];
|
|
|
|
|
+
|
|
|
|
|
+ const basePagination = {
|
|
|
|
|
+ current: 1,
|
|
|
|
|
+ pageSize: 20,
|
|
|
|
|
+ };
|
|
|
|
|
+ const pagination = reactive({
|
|
|
|
|
+ ...basePagination,
|
|
|
|
|
+ total: 0,
|
|
|
|
|
+ showTotal: true,
|
|
|
|
|
+ showJumper: true,
|
|
|
|
|
+ showMore: true,
|
|
|
|
|
+ showPageSize: true,
|
|
|
|
|
+ pageSizeOptions: [10, 20, 50, 100],
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ const formatTime = (time?: string) => {
|
|
|
|
|
+ if (!time) return '-';
|
|
|
|
|
+ return dayjs(time).format('MM-DD HH:mm');
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ const getExternalProxyAddress = (id: number) =>
|
|
|
|
|
+ `bjapi.molilian.com:20${String(id).padStart(3, '0')}`;
|
|
|
|
|
+
|
|
|
|
|
+ const createDefaultCheckState = (
|
|
|
|
|
+ record: ProxyNodeRecord
|
|
|
|
|
+ ): ProxyCheckState => {
|
|
|
|
|
+ return {
|
|
|
|
|
+ internal: {
|
|
|
|
|
+ address: record.server,
|
|
|
|
|
+ success: false,
|
|
|
|
|
+ result: '未检测',
|
|
|
|
|
+ checkedAt: '',
|
|
|
|
|
+ },
|
|
|
|
|
+ external: {
|
|
|
|
|
+ address: getExternalProxyAddress(record.id),
|
|
|
|
|
+ success: false,
|
|
|
|
|
+ result: '未检测',
|
|
|
|
|
+ checkedAt: '',
|
|
|
|
|
+ },
|
|
|
|
|
+ };
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ const getCheckState = (record: ProxyNodeRecord): ProxyCheckState =>
|
|
|
|
|
+ checkResults.value[record.id] || createDefaultCheckState(record);
|
|
|
|
|
+
|
|
|
|
|
+ const hasChecked = (record: ProxyNodeRecord, target: ProxyCheckTarget) =>
|
|
|
|
|
+ !!getCheckState(record)[target].checkedAt;
|
|
|
|
|
+
|
|
|
|
|
+ const getCheckTagText = (
|
|
|
|
|
+ prefix: string,
|
|
|
|
|
+ result: ProxyCheckChannelResult
|
|
|
|
|
+ ) => {
|
|
|
|
|
+ if (result.result === '未检测') {
|
|
|
|
|
+ return `${prefix}未测`;
|
|
|
|
|
+ }
|
|
|
|
|
+ return `${prefix}${result.success ? 'OK' : 'Error'}`;
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ const getCheckTagColor = (result: ProxyCheckChannelResult) => {
|
|
|
|
|
+ if (result.result === '未检测') {
|
|
|
|
|
+ return 'gray';
|
|
|
|
|
+ }
|
|
|
|
|
+ return result.success ? 'green' : 'red';
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ const copyText = async (text: string) => {
|
|
|
|
|
+ if (navigator.clipboard?.writeText) {
|
|
|
|
|
+ await navigator.clipboard.writeText(text);
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const textarea = document.createElement('textarea');
|
|
|
|
|
+ textarea.value = text;
|
|
|
|
|
+ textarea.style.position = 'fixed';
|
|
|
|
|
+ textarea.style.opacity = '0';
|
|
|
|
|
+ document.body.appendChild(textarea);
|
|
|
|
|
+ textarea.select();
|
|
|
|
|
+ document.execCommand('copy');
|
|
|
|
|
+ document.body.removeChild(textarea);
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ const copyValue = async (text: string, label = '内容') => {
|
|
|
|
|
+ try {
|
|
|
|
|
+ await copyText(text);
|
|
|
|
|
+ Message.success(`已复制${label}:${text}`);
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ Message.error('复制失败');
|
|
|
|
|
+ }
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ const normalizeAccountCompareText = (value?: string) => {
|
|
|
|
|
+ if (!value) return '';
|
|
|
|
|
+
|
|
|
|
|
+ let normalized = value.trim();
|
|
|
|
|
+ try {
|
|
|
|
|
+ normalized = decodeURIComponent(normalized);
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ // Ignore invalid URI-encoded content and compare the raw text.
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ normalized = normalized.replace(
|
|
|
|
|
+ /\\u([0-9a-fA-F]{4})/g,
|
|
|
|
|
+ (_, hex: string) => String.fromCharCode(parseInt(hex, 16))
|
|
|
|
|
+ );
|
|
|
|
|
+
|
|
|
|
|
+ return normalized.trim().replace(/\s+/g, ' ').toLowerCase();
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ const shouldShowAccountCompany = (account: ProxyNodeUsageAccount) => {
|
|
|
|
|
+ if (!account.company) return false;
|
|
|
|
|
+ return (
|
|
|
|
|
+ normalizeAccountCompareText(account.company) !==
|
|
|
|
|
+ normalizeAccountCompareText(account.name)
|
|
|
|
|
+ );
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ const getAccountCopyText = (account: ProxyNodeUsageAccount) => {
|
|
|
|
|
+ const values = [account.name];
|
|
|
|
|
+ if (shouldShowAccountCompany(account)) {
|
|
|
|
|
+ values.push(account.company);
|
|
|
|
|
+ }
|
|
|
|
|
+ return values.filter(Boolean).join(' ');
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ const summary = computed(() => {
|
|
|
|
|
+ return renderData.value.reduce(
|
|
|
|
|
+ (acc, item) => {
|
|
|
|
|
+ if (item.status) acc.enabled += 1;
|
|
|
|
|
+ else acc.disabled += 1;
|
|
|
|
|
+ acc.onlineAccounts += item.onlineAccountCount;
|
|
|
|
|
+ acc.offlineAccounts += item.offlineAccountCount;
|
|
|
|
|
+ return acc;
|
|
|
|
|
+ },
|
|
|
|
|
+ {
|
|
|
|
|
+ enabled: 0,
|
|
|
|
|
+ disabled: 0,
|
|
|
|
|
+ onlineAccounts: 0,
|
|
|
|
|
+ offlineAccounts: 0,
|
|
|
|
|
+ }
|
|
|
|
|
+ );
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ const buildListParams = (
|
|
|
|
|
+ overrides: Partial<ProxyNodeListParams> = {}
|
|
|
|
|
+ ): ProxyNodeListParams => ({
|
|
|
|
|
+ current: pagination.current || 1,
|
|
|
|
|
+ pageSize: pagination.pageSize || basePagination.pageSize,
|
|
|
|
|
+ keyword: keyword.value.trim() || undefined,
|
|
|
|
|
+ status: statusFilter.value || undefined,
|
|
|
|
|
+ getTotal: true,
|
|
|
|
|
+ ...overrides,
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ const fetchData = async (params?: ProxyNodeListParams) => {
|
|
|
|
|
+ setLoading(true);
|
|
|
|
|
+ try {
|
|
|
|
|
+ const { data } = await queryProxyNodeList(
|
|
|
|
|
+ params || buildListParams()
|
|
|
|
|
+ );
|
|
|
|
|
+ renderData.value = data.list || [];
|
|
|
|
|
+ pagination.current = data.page || 1;
|
|
|
|
|
+ pagination.total = data.count || 0;
|
|
|
|
|
+ pagination.pageSize = data.size || pagination.pageSize;
|
|
|
|
|
+ expandedIds.value = expandedIds.value.filter((id) =>
|
|
|
|
|
+ renderData.value.some((item) => item.id === id)
|
|
|
|
|
+ );
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ Message.error('获取代理节点失败');
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ setLoading(false);
|
|
|
|
|
+ }
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ const search = () => {
|
|
|
|
|
+ fetchData(
|
|
|
|
|
+ buildListParams({
|
|
|
|
|
+ current: 1,
|
|
|
|
|
+ })
|
|
|
|
|
+ );
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ const refreshCurrentPage = () => {
|
|
|
|
|
+ fetchData(buildListParams());
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ const onPageChange = (current: number) => {
|
|
|
|
|
+ fetchData(
|
|
|
|
|
+ buildListParams({
|
|
|
|
|
+ current,
|
|
|
|
|
+ })
|
|
|
|
|
+ );
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ const onPageSizeChange = (pageSize: number) => {
|
|
|
|
|
+ pagination.pageSize = pageSize;
|
|
|
|
|
+ fetchData(
|
|
|
|
|
+ buildListParams({
|
|
|
|
|
+ current: 1,
|
|
|
|
|
+ pageSize,
|
|
|
|
|
+ })
|
|
|
|
|
+ );
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ const getVisibleAccounts = (record: ProxyNodeRecord) => {
|
|
|
|
|
+ if (expandedIds.value.includes(record.id)) {
|
|
|
|
|
+ return record.accounts;
|
|
|
|
|
+ }
|
|
|
|
|
+ return record.accounts.slice(0, defaultVisibleAccountCount);
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ const toggleExpanded = (id: number) => {
|
|
|
|
|
+ expandedIds.value = expandedIds.value.includes(id)
|
|
|
|
|
+ ? expandedIds.value.filter((item) => item !== id)
|
|
|
|
|
+ : [...expandedIds.value, id];
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ const addLoadingId = (ids: number[], id: number) =>
|
|
|
|
|
+ ids.includes(id) ? ids : [...ids, id];
|
|
|
|
|
+
|
|
|
|
|
+ const removeLoadingId = (ids: number[], id: number) =>
|
|
|
|
|
+ ids.filter((item) => item !== id);
|
|
|
|
|
+
|
|
|
|
|
+ const getCheckKey = (id: number, target: ProxyCheckTarget) =>
|
|
|
|
|
+ `${id}-${target}`;
|
|
|
|
|
+
|
|
|
|
|
+ const isChecking = (id: number, target: ProxyCheckTarget) =>
|
|
|
|
|
+ checkingKeys.value.includes(getCheckKey(id, target));
|
|
|
|
|
+
|
|
|
|
|
+ const addLoadingKey = (keys: string[], key: string) =>
|
|
|
|
|
+ keys.includes(key) ? keys : [...keys, key];
|
|
|
|
|
+
|
|
|
|
|
+ const removeLoadingKey = (keys: string[], key: string) =>
|
|
|
|
|
+ keys.filter((item) => item !== key);
|
|
|
|
|
+
|
|
|
|
|
+ const handleCheck = async (
|
|
|
|
|
+ record: ProxyNodeRecord,
|
|
|
|
|
+ target: ProxyCheckTarget
|
|
|
|
|
+ ) => {
|
|
|
|
|
+ const checkKey = getCheckKey(record.id, target);
|
|
|
|
|
+ checkingKeys.value = addLoadingKey(checkingKeys.value, checkKey);
|
|
|
|
|
+ try {
|
|
|
|
|
+ const { data } = await checkProxyNode(record.id, target);
|
|
|
|
|
+ if (!data.check) {
|
|
|
|
|
+ Message.warning(data.msg || '后端未返回检测结果');
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const currentState = getCheckState(record);
|
|
|
|
|
+ const checkedAt = dayjs().format('MM-DD HH:mm:ss');
|
|
|
|
|
+ checkResults.value = {
|
|
|
|
|
+ ...checkResults.value,
|
|
|
|
|
+ [record.id]: {
|
|
|
|
|
+ ...currentState,
|
|
|
|
|
+ [target]: {
|
|
|
|
|
+ ...data.check,
|
|
|
|
|
+ checkedAt,
|
|
|
|
|
+ },
|
|
|
|
|
+ },
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ const label = target === 'internal' ? '内网' : '外网';
|
|
|
|
|
+ if (data.check.success) {
|
|
|
|
|
+ Message.success(`${label}代理检测成功`);
|
|
|
|
|
+ } else {
|
|
|
|
|
+ Message.warning(
|
|
|
|
|
+ data.check.result || data.msg || `${label}代理检测失败`
|
|
|
|
|
+ );
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ Message.error('代理检测请求失败');
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ checkingKeys.value = removeLoadingKey(checkingKeys.value, checkKey);
|
|
|
|
|
+ }
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ const getStatusTagText = (record: ProxyNodeRecord) => {
|
|
|
|
|
+ if (updatingIds.value.includes(record.id)) {
|
|
|
|
|
+ return '更新中';
|
|
|
|
|
+ }
|
|
|
|
|
+ return record.status ? '启用' : '禁用';
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ const toggleStatus = (record: ProxyNodeRecord) => {
|
|
|
|
|
+ if (updatingIds.value.includes(record.id)) return;
|
|
|
|
|
+ const nextStatus = !record.status;
|
|
|
|
|
+ Modal.confirm({
|
|
|
|
|
+ title: '确认提示',
|
|
|
|
|
+ content: `确定要${nextStatus ? '启用' : '禁用'}节点 ${
|
|
|
|
|
+ record.name
|
|
|
|
|
+ } 吗?`,
|
|
|
|
|
+ okText: '确认',
|
|
|
|
|
+ cancelText: '取消',
|
|
|
|
|
+ async onOk() {
|
|
|
|
|
+ updatingIds.value = addLoadingId(updatingIds.value, record.id);
|
|
|
|
|
+ try {
|
|
|
|
|
+ const { data } = await updateProxyNode({
|
|
|
|
|
+ id: record.id,
|
|
|
|
|
+ name: 'status',
|
|
|
|
|
+ val: nextStatus,
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ if (data.success) {
|
|
|
|
|
+ Message.success(data.msg || '更新成功');
|
|
|
|
|
+ await fetchData();
|
|
|
|
|
+ } else {
|
|
|
|
|
+ Message.error(data.msg || '更新失败');
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ Message.error('更新失败');
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ updatingIds.value = removeLoadingId(
|
|
|
|
|
+ updatingIds.value,
|
|
|
|
|
+ record.id
|
|
|
|
|
+ );
|
|
|
|
|
+ }
|
|
|
|
|
+ },
|
|
|
|
|
+ });
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ const getRowClass = (record: ProxyNodeRecord) =>
|
|
|
|
|
+ record.status ? '' : 'proxy-row-disabled';
|
|
|
|
|
+
|
|
|
|
|
+ fetchData();
|
|
|
|
|
+</script>
|
|
|
|
|
+
|
|
|
|
|
+<style scoped lang="less">
|
|
|
|
|
+ .container {
|
|
|
|
|
+ padding: 0 20px 20px 20px;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .toolbar {
|
|
|
|
|
+ margin-bottom: 16px;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .summary {
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ flex-wrap: wrap;
|
|
|
|
|
+ gap: 8px;
|
|
|
|
|
+ margin-bottom: 16px;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .node-name-row {
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ flex-wrap: wrap;
|
|
|
|
|
+ gap: 8px;
|
|
|
|
|
+ align-items: center;
|
|
|
|
|
+ margin-bottom: 4px;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .node-name {
|
|
|
|
|
+ font-weight: 600;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .server-text {
|
|
|
|
|
+ font-family: Menlo, Monaco, Consolas, monospace;
|
|
|
|
|
+ word-break: break-all;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .address-row,
|
|
|
|
|
+ .external-proxy-row {
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ gap: 4px;
|
|
|
|
|
+ align-items: center;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .external-proxy-row {
|
|
|
|
|
+ margin-top: 4px;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .check-tag-row {
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ flex-wrap: wrap;
|
|
|
|
|
+ gap: 8px;
|
|
|
|
|
+ margin-top: 8px;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .external-proxy-text {
|
|
|
|
|
+ color: var(--color-text-1);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .copy-btn {
|
|
|
|
|
+ padding: 0 4px;
|
|
|
|
|
+ color: var(--color-text-3);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .status-tag {
|
|
|
|
|
+ cursor: pointer;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .copyable-text {
|
|
|
|
|
+ cursor: pointer;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .copyable-text:hover {
|
|
|
|
|
+ color: rgb(var(--arcoblue-6));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .account-copy-btn {
|
|
|
|
|
+ margin-left: 2px;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .sub-text {
|
|
|
|
|
+ color: var(--color-text-3);
|
|
|
|
|
+ font-size: 12px;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .account-summary {
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ flex-wrap: wrap;
|
|
|
|
|
+ gap: 8px;
|
|
|
|
|
+ margin-bottom: 8px;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .account-list {
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ flex-wrap: wrap;
|
|
|
|
|
+ gap: 8px;
|
|
|
|
|
+ align-items: center;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .account-chip {
|
|
|
|
|
+ display: inline-flex;
|
|
|
|
|
+ gap: 6px;
|
|
|
|
|
+ align-items: center;
|
|
|
|
|
+ padding: 4px 10px;
|
|
|
|
|
+ border-radius: 999px;
|
|
|
|
|
+ background: var(--color-fill-2);
|
|
|
|
|
+ color: var(--color-text-1);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .account-chip.is-offline {
|
|
|
|
|
+ background: var(--color-fill-1);
|
|
|
|
|
+ color: var(--color-text-3);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .platform {
|
|
|
|
|
+ display: inline-flex;
|
|
|
|
|
+ align-items: center;
|
|
|
|
|
+ height: 20px;
|
|
|
|
|
+ padding: 0 6px;
|
|
|
|
|
+ border-radius: 999px;
|
|
|
|
|
+ font-size: 12px;
|
|
|
|
|
+ line-height: 20px;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .platform-tk {
|
|
|
|
|
+ background: #fff1b8;
|
|
|
|
|
+ color: #ad6800;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .platform-jd {
|
|
|
|
|
+ background: #ffece8;
|
|
|
|
|
+ color: #c73631;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .platform-pdd {
|
|
|
|
|
+ background: #e8ffea;
|
|
|
|
|
+ color: #00b42a;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .account-name {
|
|
|
|
|
+ font-weight: 500;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .account-copy-content {
|
|
|
|
|
+ display: inline-flex;
|
|
|
|
|
+ gap: 6px;
|
|
|
|
|
+ align-items: center;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .account-company {
|
|
|
|
|
+ font-size: 12px;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .check-tooltip {
|
|
|
|
|
+ max-width: 360px;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .check-tooltip-time {
|
|
|
|
|
+ margin-bottom: 4px;
|
|
|
|
|
+ color: var(--color-text-3);
|
|
|
|
|
+ font-size: 12px;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .check-tooltip-address {
|
|
|
|
|
+ margin-bottom: 4px;
|
|
|
|
|
+ word-break: break-all;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .check-tooltip-result {
|
|
|
|
|
+ white-space: pre-wrap;
|
|
|
|
|
+ word-break: break-all;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .proxy-row-disabled :deep(td) {
|
|
|
|
|
+ background: var(--color-fill-1);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @media (max-width: 768px) {
|
|
|
|
|
+ .toolbar {
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ width: 100%;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .toolbar :deep(.arco-input-wrapper),
|
|
|
|
|
+ .toolbar :deep(.arco-select-view),
|
|
|
|
|
+ .toolbar :deep(.arco-btn) {
|
|
|
|
|
+ width: 100% !important;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+</style>
|