소스 검색

✨ feat(代理): 新增代理节点列表

dodo hold 3 달 전
부모
커밋
769b55b763
5개의 변경된 파일1055개의 추가작업 그리고 13개의 파일을 삭제
  1. 72 13
      src/api/proxy.ts
  2. 2 0
      src/locale/en-US/taoke.ts
  3. 1 0
      src/locale/zh-CN/taoke.ts
  4. 10 0
      src/router/routes/modules/settings.ts
  5. 970 0
      src/views/settings/proxyNodes.vue

+ 72 - 13
src/api/proxy.ts

@@ -1,21 +1,80 @@
 import axios from 'axios';
-import type { FormRes } from './base';
+import type { FormRes, ListRes } from './base';
 
-function ForwardAPI(url: string) {
-    return url;
+export interface ProxyNodeUsageAccount {
+    id: number;
+    platform: 'pdd' | 'jd' | 'tk' | string;
+    name: string;
+    company: string;
+    nodeName: string;
+    status: boolean;
 }
 
-export interface ProxyResponse extends FormRes {
-    // Add any specific fields that might be returned by the proxy API
-    success?: boolean;
-    msg?: string;
+export interface ProxyNodeRecord {
+    id: number;
+    status: boolean;
+    name: string;
+    description: string;
+    end_point: string;
+    type: string;
+    server: string;
+    username: string;
+    create_time: string;
+    last_time: string;
+    accountCount: number;
+    onlineAccountCount: number;
+    offlineAccountCount: number;
+    accounts: ProxyNodeUsageAccount[];
+}
+
+export interface ProxyNodeListParams {
+    current: number;
+    pageSize: number;
+    keyword?: string;
+    status?: string;
+    getTotal: boolean;
+}
+
+export interface ProxyNodeListData extends ListRes {
+    list: ProxyNodeRecord[];
+}
+
+export interface ProxyNodeUpdateModel {
+    id: number;
+    name: string;
+    val: any;
+}
+
+export interface ProxyCheckChannelResult {
+    address: string;
+    success: boolean;
+    result: string;
+}
+
+export type ProxyCheckTarget = 'internal' | 'external';
+
+export interface ProxyCheckResponse extends FormRes {
+    check?: ProxyCheckChannelResult;
+}
+
+export function queryProxyNodeList(data: ProxyNodeListParams) {
+    return axios.post<ProxyNodeListData>('/api/proxy/list', data);
+}
+
+export function updateProxyNode(data: ProxyNodeUpdateModel) {
+    return axios.post<FormRes>('/api/proxy/update', data);
+}
+
+export function checkProxyNode(id: number, target: ProxyCheckTarget) {
+    return axios.get<ProxyCheckResponse>(
+        `/api/proxy/check?id=${id}&target=${target}`
+    );
 }
 
-/**
- * Change the public IP for a specific node
- * @param nodeName The name of the node to change IP for
- */
 export function changePublicIpByName(nodeName: string) {
-    const url = ForwardAPI(`/api/Proxy/ChangePublicIpByName?nodename=${nodeName}`);
-    return axios.get<ProxyResponse>(url);
+    return axios.get<FormRes>(
+        `/api/proxy/changePublicIpByName?nodeName=${encodeURIComponent(
+            nodeName
+        )}`
+    );
 }

+ 2 - 0
src/locale/en-US/taoke.ts

@@ -2,7 +2,9 @@ export default {
     'menu.settings': 'Settings',
     'menu.settings.config': 'System Config',
     'menu.settings.xhsZlongReport': 'Multi-channel Forward Report',
+    'menu.settings.aliyun': 'Aliyun Resources',
     'menu.settings.apiAccount': 'API Account',
+    'menu.settings.proxyNodes': 'Proxy Nodes',
 
     'menu.apiAccount': 'API Account',
     'menu.apiAccount.update': 'Update Account',

+ 1 - 0
src/locale/zh-CN/taoke.ts

@@ -4,6 +4,7 @@ export default {
   'menu.settings.xhsZlongReport': '多渠道转发报表',
   'menu.settings.aliyun': '阿里云资源管理',
   'menu.settings.apiAccount': '接口授权管理',
+  'menu.settings.proxyNodes': '代理节点列表',
 
   'menu.apiAccount': '接口授权管理',
   'menu.apiAccount.update': '更新账号',

+ 10 - 0
src/router/routes/modules/settings.ts

@@ -62,6 +62,16 @@ const SETTINGS: AppRouteRecordRaw = {
         roles: ['*'],
       },
     },
+    {
+      path: 'proxy_nodes',
+      name: 'proxyNodes',
+      component: () => import('@/views/settings/proxyNodes.vue'),
+      meta: {
+        locale: 'menu.settings.proxyNodes',
+        requiresAuth: true,
+        roles: ['*'],
+      },
+    },
     {
       path: 'tracks',
       name: 'tracks',

+ 970 - 0
src/views/settings/proxyNodes.vue

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