Files
SmartUp/frontend/src/views/Upstreams.vue
T
liumangmang 6cc797f915 feat: remote browser login persistence + balance display + UI consistency
- Retain login state in remote browser profiles (don't delete on disconnect)
- Add GET /api/browser-sessions/{id}/clipboard for clipboard sync
- Add POST /api/browser-sessions/{id}/autofill-login for manual credential fill
- Add DELETE /api/browser-sessions/profiles/{custom_page_id} for login clear
- Add balance tracking with configurable divisor (balance_divisor)
- Health check on session reuse, idle TTL eviction, background cleanup
- Add first-frame watchdog (10s timeout) to prevent infinite loading
- Reconnect browser on active=true when session was closed
- UI: uniform text-only inline buttons (websites + upstreams pages)
- Fix page switch race with closingRemoteSessionPromise
2026-05-20 09:44:20 +08:00

1218 lines
38 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>
<div class="shell-page shell-page-fluid page-section upstreams-page">
<section class="page-header upstreams-hero surface-card">
<div class="page-heading">
<p class="page-kicker">Monitoring Matrix</p>
<h2 class="page-title">上游管理</h2>
<p class="page-desc">
管理 API 上游服务认证方式与轮询策略这里优先展示健康度检测节奏和错误信号减少你在异常发生时的定位成本
</p>
</div>
<div class="toolbar-cluster hero-actions">
<el-button @click="loadList" :loading="tableLoading">
<el-icon><Refresh /></el-icon>
刷新列表
</el-button>
<el-button type="primary" @click="openCreate">
<el-icon><Plus /></el-icon>
新增上游
</el-button>
</div>
</section>
<section class="metric-grid">
<article class="surface-card metric-card">
<div class="metric-label">Total Sources</div>
<div class="metric-value">{{ metrics.total }}</div>
<p class="metric-note">当前纳管的上游节点总数</p>
</article>
<article class="surface-card metric-card">
<div class="metric-label">Healthy</div>
<div class="metric-value">{{ metrics.healthy }}</div>
<p class="metric-note">最近一次检测返回健康状态</p>
</article>
<article class="surface-card metric-card">
<div class="metric-label">Enabled</div>
<div class="metric-value">{{ metrics.enabled }}</div>
<p class="metric-note">已启用定时检测的上游节点</p>
</article>
<article class="surface-card metric-card">
<div class="metric-label">Attention</div>
<div class="metric-value">{{ metrics.unhealthy }}</div>
<p class="metric-note">需要处理错误或网络异常的节点</p>
</article>
<article class="surface-card metric-card">
<div class="metric-label">Balance</div>
<div class="metric-value">{{ metrics.balanceCount }}</div>
<p class="metric-note">已配置余额接口的上游节点数</p>
</article>
</section>
<section class="upstreams-content">
<section class="surface-card data-stage">
<div class="section-header data-stage-head">
<div>
<div class="section-caption">Upstream Registry</div>
<h3 class="data-stage-title brand-type">检测与变更控制台</h3>
</div>
<p class="data-stage-note">点击详情可查看快照历史分组倍率与最近错误</p>
</div>
<el-table :data="list" v-loading="tableLoading" row-key="id" style="width: 100%">
<el-table-column label="来源" min-width="220">
<template #default="{ row }">
<div class="cell-name">{{ row.name }}</div>
<div class="cell-url mono">{{ row.base_url }}</div>
</template>
</el-table-column>
<el-table-column label="状态" width="118">
<template #default="{ row }">
<span :class="['status-badge', row.last_status]">
<span class="dot" />
{{ statusLabel(row.last_status) }}
</span>
</template>
</el-table-column>
<el-table-column label="余额" width="140" align="right">
<template #default="{ row }">
<span v-if="row.balance !== null && row.balance !== undefined" class="balance-value mono">
{{ formatBalance(row.balance) }}
</span>
<span v-else class="muted"></span>
</template>
</el-table-column>
<el-table-column label="启用" width="88" align="center">
<template #default="{ row }">
<el-switch v-model="row.enabled" @change="toggleEnabled(row)" />
</template>
</el-table-column>
<el-table-column label="认证" width="132">
<template #default="{ row }">
<span class="status-badge auth-badge">{{ authLabel(row.auth_type) }}</span>
</template>
</el-table-column>
<el-table-column label="检测间隔" width="112">
<template #default="{ row }">
<span class="mono time-inline">{{ row.check_interval_seconds }}s</span>
</template>
</el-table-column>
<el-table-column label="最近检测" min-width="168">
<template #default="{ row }">
<span v-if="row.last_checked_at" class="time-text mono">{{ fmtTime(row.last_checked_at) }}</span>
<span v-else class="muted">未检测</span>
</template>
</el-table-column>
<el-table-column label="最近错误" min-width="220">
<template #default="{ row }">
<el-tooltip v-if="row.last_error" :content="row.last_error" placement="top" :show-after="300">
<span class="error-text">{{ shrinkError(row.last_error) }}</span>
</el-tooltip>
<span v-else class="muted">无异常</span>
</template>
</el-table-column>
<el-table-column label="操作" width="258" fixed="right">
<template #default="{ row }">
<div class="action-row">
<el-button size="small" text @click="openEdit(row)" title="编辑">
<el-icon><Edit /></el-icon>
</el-button>
<el-button size="small" text @click="testUpstream(row)" :loading="row._testing">测试</el-button>
<el-button size="small" text @click="checkNow(row)" :loading="row._checking">检测</el-button>
<el-button size="small" text @click="openDetail(row)">
<el-icon><List /></el-icon>
详情
</el-button>
<el-button size="small" text type="danger" @click="confirmDelete(row)" title="删除">
<el-icon><Delete /></el-icon>
</el-button>
</div>
</template>
</el-table-column>
</el-table>
</section>
<aside class="upstreams-side surface-card">
<section class="side-section overview-section">
<div class="section-header insight-head">
<div>
<div class="section-caption">Runtime Snapshot</div>
<h3 class="insight-title">运行概览</h3>
</div>
<span class="insight-pill">健康率 {{ healthyRate }}%</span>
</div>
<div class="insight-grid">
<div class="insight-metric">
<span>异常节点</span>
<strong>{{ metrics.unhealthy }}</strong>
</div>
<div class="insight-metric">
<span>已启用</span>
<strong>{{ metrics.enabled }}</strong>
</div>
<div class="insight-metric">
<span>待检测</span>
<strong>{{ pendingChecks }}</strong>
</div>
<div class="insight-metric">
<span>最近检测</span>
<strong>{{ latestCheckedAt ? fmtTime(latestCheckedAt) : '—' }}</strong>
</div>
</div>
</section>
<section class="side-section">
<div class="section-header insight-head compact">
<div>
<div class="section-caption">Need Attention</div>
<h3 class="insight-title">待关注节点</h3>
</div>
</div>
<div v-if="attentionList.length > 0" class="feed-list">
<button
v-for="row in attentionList"
:key="row.id"
type="button"
class="feed-item"
@click="openDetail(row)"
>
<div class="feed-main">
<div class="feed-top">
<span class="feed-name">{{ row.name }}</span>
<span :class="['status-badge', row.last_status]">
<span class="dot" />{{ statusLabel(row.last_status) }}
</span>
</div>
<div class="feed-meta">{{ row.last_error ? shrinkError(row.last_error) : '最近状态异常,建议查看快照详情' }}</div>
</div>
<span class="feed-link">详情</span>
</button>
</div>
<div v-else class="empty-hint side-empty">当前没有需要关注的异常节点</div>
</section>
<section class="side-section">
<div class="section-header insight-head compact">
<div>
<div class="section-caption">Recent Activity</div>
<h3 class="insight-title">最近检测</h3>
</div>
</div>
<div v-if="recentChecks.length > 0" class="timeline-list">
<div v-for="row in recentChecks" :key="row.id" class="timeline-item">
<div class="timeline-main">
<div class="timeline-name">{{ row.name }}</div>
<div class="timeline-meta mono">{{ fmtTime(row.last_checked_at!) }}</div>
</div>
<el-button size="small" text @click="openDetail(row)">查看</el-button>
</div>
</div>
<div v-else class="empty-hint side-empty">还没有检测记录</div>
</section>
</aside>
</section>
<el-drawer
v-model="drawerVisible"
:title="editingId ? '编辑上游' : '新增上游'"
size="480px"
destroy-on-close
>
<el-form ref="formRef" :model="form" :rules="rules" label-position="top">
<el-form-item label="名称" prop="name">
<el-input v-model="form.name" placeholder="例:ai98pro" />
</el-form-item>
<el-form-item v-if="!editingId" label="系统类型(快捷配置)">
<el-select v-model="quickPlatform" @change="handlePlatformChange" style="width: 100%">
<el-option label="Sub2API" value="sub2api" />
<el-option label="New-API (管理员Key)" value="new-api" />
<el-option label="New-API (普通账号)" value="new-api-user" />
<el-option label="自定义" value="custom" />
</el-select>
</el-form-item>
<el-form-item label="Base URL" prop="base_url">
<el-input v-model="form.base_url" placeholder="https://example.com" />
</el-form-item>
<el-form-item label="API Prefix">
<el-input v-model="form.api_prefix" placeholder="/api/v1" />
</el-form-item>
<el-form-item label="认证方式">
<el-select v-model="form.auth_type" style="width: 100%">
<el-option label="无认证" value="none" />
<el-option label="Bearer Token" value="bearer" />
<el-option label="Cookie" value="cookie" />
<el-option label="API Key" value="api_key" />
<el-option label="邮箱密码登录" value="login_password" />
</el-select>
</el-form-item>
<template v-if="form.auth_type === 'bearer'">
<el-form-item label="Bearer Token">
<div class="auth-field-row">
<el-input v-model="form.auth_config.token" type="password" show-password placeholder="***" />
<el-button size="small" @click="openAuthCapture">
<el-icon><Pointer /></el-icon>
提取
</el-button>
</div>
</el-form-item>
</template>
<template v-else-if="form.auth_type === 'cookie'">
<el-form-item label="Cookie">
<div class="auth-field-row">
<el-input v-model="form.auth_config.cookie_string" type="password" show-password placeholder="name=value; name2=value2" />
<el-button size="small" @click="openAuthCapture">
<el-icon><Pointer /></el-icon>
提取
</el-button>
</div>
</el-form-item>
</template>
<template v-else-if="form.auth_type === 'api_key'">
<el-form-item label="API Key">
<el-input v-model="form.auth_config.key" type="password" show-password placeholder="***" />
</el-form-item>
<el-form-item label="Header 名称">
<el-input v-model="form.auth_config.header" placeholder="Authorization" />
</el-form-item>
</template>
<template v-else-if="form.auth_type === 'login_password'">
<el-form-item :label="form.auth_config.username_field === 'username' ? '登录账号' : '登录邮箱'">
<el-input v-model="form.auth_config.email" :placeholder="form.auth_config.username_field === 'username' ? 'admin' : 'admin@example.com'" />
</el-form-item>
<el-form-item label="登录密码">
<el-input v-model="form.auth_config.password" type="password" show-password placeholder="***" />
</el-form-item>
<el-form-item label="登录接口路径">
<el-input v-model="form.auth_config.login_path" placeholder="/auth/login" />
</el-form-item>
</template>
<el-form-item label="分组接口">
<el-input v-model="form.groups_endpoint" placeholder="/groups/available" />
</el-form-item>
<el-form-item label="倍率接口">
<el-input v-model="form.rate_endpoint" placeholder="/groups/rates" />
</el-form-item>
<el-form-item label="余额接口">
<el-input v-model="form.balance_endpoint" placeholder="留空则不获取余额,如 /auth/me" />
</el-form-item>
<el-form-item label="余额字段路径">
<el-input v-model="form.balance_response_path" placeholder="如 balance、data.quota" />
<div class="form-hint">JSON 点分路径例如 <code>balance</code> <code>data.quota</code></div>
</el-form-item>
<el-form-item label="余额除数">
<el-input-number v-model="form.balance_divisor" :min="1" :max="999999999" style="width: 100%" />
<div class="form-hint">原始值除以该数得到实际余额New-API <code>500000</code>Sub2API <code>1</code></div>
</el-form-item>
<el-row :gutter="12">
<el-col :span="12">
<el-form-item label="检测间隔(秒)">
<el-input-number v-model="form.check_interval_seconds" :min="60" style="width: 100%" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="超时(秒)">
<el-input-number v-model="form.timeout_seconds" :min="5" :max="120" style="width: 100%" />
</el-form-item>
</el-col>
</el-row>
<el-form-item label="启用">
<el-switch v-model="form.enabled" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="drawerVisible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="handleSave">保存</el-button>
</template>
</el-drawer>
<el-drawer
v-model="detailVisible"
:title="`检测详情 — ${detailUpstream?.name || ''}`"
size="700px"
destroy-on-close
@open="loadSnapshots"
>
<div v-if="detailUpstream" class="info-cards">
<div class="surface-card info-card">
<div class="info-label">状态</div>
<span :class="['status-badge', detailUpstream.last_status]">
<span class="dot" />{{ statusLabel(detailUpstream.last_status) }}
</span>
</div>
<div class="surface-card info-card" v-if="detailUpstream.balance !== null">
<div class="info-label">余额</div>
<div class="info-value mono">{{ formatBalance(detailUpstream.balance) }}</div>
</div>
<div class="surface-card info-card" v-if="detailUpstream.balance_updated_at">
<div class="info-label">余额更新于</div>
<div class="info-value mono">{{ fmtTimeFull(detailUpstream.balance_updated_at) }}</div>
</div>
<div class="surface-card info-card">
<div class="info-label">最近检测</div>
<div class="info-value mono">{{ detailUpstream.last_checked_at ? fmtTimeFull(detailUpstream.last_checked_at) : '未检测' }}</div>
</div>
<div class="surface-card info-card">
<div class="info-label">检测间隔</div>
<div class="info-value mono">{{ detailUpstream.check_interval_seconds }}s</div>
</div>
<div class="surface-card info-card">
<div class="info-label">超时</div>
<div class="info-value mono">{{ detailUpstream.timeout_seconds }}s</div>
</div>
</div>
<div v-if="detailUpstream?.last_error" class="error-box">
<el-icon><Warning /></el-icon>
<span>{{ detailUpstream.last_error }}</span>
</div>
<div class="section-title">
<el-icon><Clock /></el-icon>
检测历史
<span class="section-sub">最近 {{ snapshots.length }} </span>
</div>
<div v-loading="snapshotLoading" class="snapshot-list">
<div
v-for="snap in snapshots"
:key="snap.id"
class="snap-item"
:class="{ expanded: expandedId === snap.id }"
>
<div class="snap-header" @click="toggleExpand(snap)">
<div class="snap-left">
<el-icon class="expand-icon"><ArrowRight /></el-icon>
<span class="snap-time mono">{{ fmtTimeFull(snap.captured_at) }}</span>
</div>
<div class="snap-right">
<el-tag size="small" type="info">{{ snap.snapshot._groups_count }} 个分组</el-tag>
<template v-if="snap.snapshot._changes_count !== null && snap.snapshot._changes_count !== undefined">
<el-tag size="small" :type="snap.snapshot._changes_count > 0 ? 'warning' : 'success'">
{{ snap.snapshot._changes_count > 0 ? `${snap.snapshot._changes_count} 处变化` : '无变化' }}
</el-tag>
</template>
<el-tag v-else size="small" type="primary">初始快照</el-tag>
</div>
</div>
<div v-if="expandedId === snap.id" class="snap-body">
<el-table
:data="groupRows(snap.snapshot)"
size="small"
:header-cell-style="{ background: 'rgba(255, 244, 232, 0.02)', color: 'var(--text-soft)' }"
:cell-style="{ background: 'transparent', color: 'var(--text-primary)' }"
>
<el-table-column prop="group_name" label="分组名称" min-width="140" />
<el-table-column prop="platform" label="平台" width="110" />
<el-table-column label="当前倍率" width="110">
<template #default="{ row }">
<span class="rate-value mono">{{ row.rate || '—' }}</span>
</template>
</el-table-column>
<el-table-column prop="default_rate" label="默认倍率" width="110" />
<el-table-column prop="override_rate" label="覆盖倍率" width="110">
<template #default="{ row }">
<span v-if="row.override_rate" class="override-value mono">{{ row.override_rate }}</span>
<span v-else class="muted"></span>
</template>
</el-table-column>
</el-table>
</div>
</div>
<div v-if="!snapshotLoading && snapshots.length === 0" class="empty-hint">
暂无检测历史请先触发立即检测
</div>
</div>
<div v-if="snapshots.length > 0 || snapshotOffset > 0" class="snap-pagination">
<el-button size="small" :disabled="snapshotOffset === 0" @click="prevSnapPage">上一页</el-button>
<span class="page-info"> {{ snapshotOffset / snapshotLimit + 1 }} </span>
<el-button size="small" :disabled="snapshots.length < snapshotLimit" @click="nextSnapPage">下一页</el-button>
</div>
</el-drawer>
<AuthCaptureDialog
v-model="authCaptureVisible"
:initial-url="authCaptureInitialUrl"
@select="handleAuthCaptureSelect"
/>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import type { FormInstance } from 'element-plus'
import dayjs from 'dayjs'
import { Refresh, Plus, Edit, List, Delete, Warning, Clock, ArrowRight, Pointer } from '@element-plus/icons-vue'
import { upstreamsApi, type UpstreamData } from '@/api'
import AuthCaptureDialog from '@/components/AuthCaptureDialog.vue'
const list = ref<(UpstreamData & { _testing?: boolean; _checking?: boolean })[]>([])
const tableLoading = ref(false)
const drawerVisible = ref(false)
const saving = ref(false)
const editingId = ref<number | null>(null)
const formRef = ref<FormInstance>()
const defaultForm = () => ({
name: '',
base_url: '',
api_prefix: '/api/v1',
auth_type: 'login_password',
auth_config: { email: '', password: '', login_path: '/auth/login' } as Record<string, any>,
rate_endpoint: '/groups/rates',
groups_endpoint: '/groups/available',
enabled: true,
check_interval_seconds: 600,
timeout_seconds: 30,
balance_endpoint: '',
balance_response_path: '',
balance_divisor: 1.0,
})
const form = ref(defaultForm())
const rules = {
name: [{ required: true, message: '请输入名称', trigger: 'blur' }],
base_url: [{ required: true, message: '请输入 Base URL', trigger: 'blur' }],
}
const authCaptureVisible = ref(false)
const authCaptureInitialUrl = computed(() => {
const base = (form.value.base_url || '').replace(/\/+$/, '')
if (!base) return ''
return base + '/login'
})
function openAuthCapture() {
authCaptureVisible.value = true
}
function handleAuthCaptureSelect(candidate: { type: string; value: string; cookie_name?: string; cookie_value?: string; new_api_user?: string }) {
if (candidate.type === 'bearer_token') {
form.value.auth_type = 'bearer'
form.value.auth_config.token = candidate.value
ElMessage.success('已填入 Bearer Token')
} else if (candidate.type === 'cookie') {
form.value.auth_type = 'cookie'
form.value.auth_config.cookie_string = candidate.cookie_name && candidate.cookie_value
? `${candidate.cookie_name}=${candidate.cookie_value}`
: candidate.value
if (candidate.new_api_user) {
form.value.auth_config.new_api_user = candidate.new_api_user
form.value.api_prefix = ''
form.value.groups_endpoint = '/api/user/self/groups'
form.value.rate_endpoint = '/api/user/self/groups'
} else if (quickPlatform.value === 'new-api-user') {
form.value.api_prefix = ''
form.value.groups_endpoint = '/api/user/self/groups'
form.value.rate_endpoint = '/api/user/self/groups'
ElMessage.warning('已填入 Cookie,但未提取到 New-Api-User,请重新登录后再提取')
}
ElMessage.success('已填入 Cookie')
} else if (candidate.type === 'api_key') {
form.value.auth_type = 'api_key'
form.value.auth_config.key = candidate.value
form.value.auth_config.header = 'X-API-Key'
ElMessage.success('已填入 API Key')
} else if (candidate.type === 'credential') {
// Try to guess — if value starts with 'sk-', treat as bearer
if (candidate.value.startsWith('sk-')) {
form.value.auth_type = 'bearer'
form.value.auth_config.token = candidate.value
ElMessage.success('已填入 Bearer Token (sk-key)')
} else {
form.value.auth_type = 'bearer'
form.value.auth_config.token = candidate.value
ElMessage.success('已填入认证信息')
}
}
}
const quickPlatform = ref('sub2api')
function handlePlatformChange(val: string) {
if (val === 'sub2api') {
form.value.api_prefix = '/api/v1'
form.value.groups_endpoint = '/groups/available'
form.value.rate_endpoint = '/groups/rates'
form.value.auth_type = 'login_password'
form.value.auth_config.login_path = '/auth/login'
form.value.auth_config.username_field = 'email'
form.value.balance_endpoint = '/auth/me'
form.value.balance_response_path = 'data.balance'
} else if (val === 'new-api') {
form.value.api_prefix = ''
form.value.groups_endpoint = '/api/group/'
form.value.rate_endpoint = '/api/option/?key=GroupRatio'
form.value.auth_type = 'bearer'
form.value.balance_endpoint = '/api/user/self'
form.value.balance_response_path = 'data.quota'
form.value.balance_divisor = 500000
} else if (val === 'new-api-user') {
form.value.api_prefix = ''
form.value.groups_endpoint = '/api/user/self/groups'
form.value.rate_endpoint = '/api/user/self/groups'
form.value.auth_type = 'login_password'
form.value.auth_config.login_path = '/api/user/login'
form.value.auth_config.username_field = 'username'
form.value.balance_endpoint = '/api/user/self'
form.value.balance_response_path = 'data.quota'
form.value.balance_divisor = 500000
} else {
form.value.balance_endpoint = ''
form.value.balance_response_path = ''
}
}
const detailVisible = ref(false)
const detailUpstream = ref<UpstreamData | null>(null)
const snapshots = ref<any[]>([])
const snapshotLoading = ref(false)
const expandedId = ref<number | null>(null)
const snapshotOffset = ref(0)
const snapshotLimit = 20
const metrics = computed(() => ({
total: list.value.length,
healthy: list.value.filter((item) => item.last_status === 'healthy').length,
enabled: list.value.filter((item) => item.enabled).length,
unhealthy: list.value.filter((item) => item.last_status === 'unhealthy').length,
balanceCount: list.value.filter((item) => item.balance_endpoint).length,
}))
const healthyRate = computed(() => {
if (metrics.value.total === 0) return 0
return Math.round((metrics.value.healthy / metrics.value.total) * 100)
})
const pendingChecks = computed(() => list.value.filter((item) => !item.last_checked_at).length)
const latestCheckedAt = computed(() => {
const times = list.value
.map((item) => item.last_checked_at)
.filter((value): value is string => Boolean(value))
.sort((a, b) => new Date(toUTC(b)).getTime() - new Date(toUTC(a)).getTime())
return times[0] || ''
})
const attentionList = computed(() =>
list.value
.filter((item) => item.last_status === 'unhealthy' || item.last_error)
.slice(0, 4),
)
const recentChecks = computed(() =>
[...list.value]
.filter((item) => Boolean(item.last_checked_at))
.sort((a, b) => new Date(toUTC(b.last_checked_at as string)).getTime() - new Date(toUTC(a.last_checked_at as string)).getTime())
.slice(0, 5),
)
const statusLabel = (s: string) => ({ healthy: '健康', unhealthy: '异常', unknown: '未知' }[s] || s)
const authLabel = (s: string) => ({ none: '无认证', bearer: 'Bearer', cookie: 'Cookie', api_key: 'API Key', login_password: '邮箱密码' }[s] || s)
function formatBalance(value: number | null | undefined): string {
if (value === null || value === undefined) return '—'
return value.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
}
const toUTC = (t: string) => /[Z+\-]\d*$/.test(t.trim()) ? t : `${t}Z`
const fmtTime = (t: string) => dayjs(toUTC(t)).format('MM-DD HH:mm:ss')
const fmtTimeFull = (t: string) => dayjs(toUTC(t)).format('YYYY-MM-DD HH:mm:ss')
function groupRows(snapshot: any) {
if (!snapshot?.groups) return []
return Object.values(snapshot.groups) as any[]
}
function shrinkError(value: string) {
return value.length > 40 ? `${value.slice(0, 40)}` : value
}
async function loadList() {
tableLoading.value = true
try {
const res = await upstreamsApi.list()
list.value = res.data
} finally {
tableLoading.value = false
}
}
function openCreate() {
editingId.value = null
quickPlatform.value = 'sub2api'
form.value = defaultForm()
drawerVisible.value = true
}
function openEdit(row: UpstreamData) {
editingId.value = row.id
form.value = {
name: row.name,
base_url: row.base_url,
api_prefix: row.api_prefix,
auth_type: row.auth_type,
auth_config: { ...(row.auth_config_masked as Record<string, any>) },
rate_endpoint: row.rate_endpoint,
groups_endpoint: row.groups_endpoint,
enabled: row.enabled,
check_interval_seconds: row.check_interval_seconds,
timeout_seconds: row.timeout_seconds,
balance_endpoint: row.balance_endpoint || '',
balance_response_path: row.balance_response_path || '',
balance_divisor: row.balance_divisor ?? 1.0,
}
drawerVisible.value = true
}
async function handleSave() {
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) return
saving.value = true
try {
if (editingId.value) {
await upstreamsApi.update(editingId.value, form.value)
ElMessage.success('保存成功')
} else {
await upstreamsApi.create(form.value as any)
ElMessage.success('创建成功')
}
drawerVisible.value = false
loadList()
} catch (e: any) {
ElMessage.error(e.response?.data?.detail || '保存失败')
} finally {
saving.value = false
}
}
async function toggleEnabled(row: UpstreamData) {
try {
await upstreamsApi.update(row.id, { enabled: row.enabled })
ElMessage.success(row.enabled ? '已启用' : '已停用')
} catch {
row.enabled = !row.enabled
ElMessage.error('操作失败')
}
}
async function testUpstream(row: any) {
row._testing = true
try {
const res = await upstreamsApi.test(row.id)
if (res.data.success) ElMessage.success(res.data.message)
else ElMessage.error(res.data.detail || res.data.message)
await loadList()
} finally {
row._testing = false
}
}
async function checkNow(row: any) {
row._checking = true
try {
const res = await upstreamsApi.checkNow(row.id)
ElMessage[res.data.success ? 'success' : 'error'](res.data.message)
loadList()
} finally {
row._checking = false
}
}
function openDetail(row: UpstreamData) {
detailUpstream.value = row
snapshots.value = []
snapshotOffset.value = 0
expandedId.value = null
detailVisible.value = true
}
async function loadSnapshots() {
if (!detailUpstream.value) return
snapshotLoading.value = true
try {
const res = await upstreamsApi.listSnapshots(detailUpstream.value.id, snapshotLimit, snapshotOffset.value)
snapshots.value = res.data
if (res.data.length > 0 && expandedId.value === null) {
expandedId.value = res.data[0].id
}
} catch {
ElMessage.error('加载历史失败')
} finally {
snapshotLoading.value = false
}
}
function toggleExpand(snap: any) {
expandedId.value = expandedId.value === snap.id ? null : snap.id
}
function prevSnapPage() {
snapshotOffset.value = Math.max(0, snapshotOffset.value - snapshotLimit)
expandedId.value = null
loadSnapshots()
}
function nextSnapPage() {
snapshotOffset.value += snapshotLimit
expandedId.value = null
loadSnapshots()
}
async function confirmDelete(row: UpstreamData) {
try {
await ElMessageBox.confirm(`确认删除上游 "${row.name}" `, '删除确认', { type: 'warning' })
await upstreamsApi.delete(row.id)
ElMessage.success('已删除')
loadList()
} catch {
// noop
}
}
onMounted(loadList)
</script>
<style scoped>
.auth-field-row {
display: flex;
gap: 8px;
align-items: center;
}
.auth-field-row .el-input {
flex: 1;
}
.auth-field-row .el-button {
flex-shrink: 0;
}
.upstreams-page {
padding-bottom: 1rem;
}
.upstreams-hero {
align-items: center;
padding: 1.2rem 1.25rem;
min-height: 8.7rem;
border-radius: var(--radius-shell);
background:
radial-gradient(circle at top right, rgba(217, 139, 66, 0.14), transparent 24%),
linear-gradient(180deg, rgba(255, 244, 232, 0.03), transparent 28%),
var(--bg-panel);
}
.upstreams-hero .page-heading {
gap: 0.32rem;
}
.upstreams-hero .page-title {
font-size: clamp(1.95rem, 1.45rem + 1.2vw, 2.65rem);
}
.upstreams-hero .page-desc {
max-width: 50rem;
font-size: 0.9rem;
line-height: 1.55;
}
.hero-actions {
align-self: flex-end;
}
.upstreams-content {
display: grid;
grid-template-columns: minmax(0, 1.9fr) minmax(360px, 0.82fr);
gap: 1rem;
align-items: stretch;
}
.data-stage {
padding: 1rem;
min-width: 0;
}
.data-stage,
.upstreams-side {
min-height: 33rem;
}
.data-stage-head {
min-height: 4.65rem;
margin-bottom: 0.85rem;
}
.data-stage-title {
margin-top: 0.28rem;
font-size: clamp(1.4rem, 1.05rem + 0.8vw, 2rem);
font-weight: 800;
}
.data-stage-note {
color: var(--text-muted);
font-size: 0.86rem;
line-height: 1.6;
}
.upstreams-side {
display: grid;
align-content: start;
padding: 1rem;
min-width: 0;
}
.side-section {
padding-block: 1rem;
border-top: 1px solid var(--border-color);
}
.side-section:first-child {
padding-top: 0;
border-top: 0;
}
.side-section:last-child {
padding-bottom: 0;
}
.overview-section {
min-height: 12.4rem;
}
.insight-head {
min-height: 3.75rem;
margin-bottom: 0.9rem;
}
.insight-head.compact {
margin-bottom: 0.75rem;
}
.insight-title {
margin: 0.24rem 0 0;
font-size: 1.05rem;
font-weight: 700;
}
.insight-pill {
display: inline-flex;
align-items: center;
padding: 0.38rem 0.7rem;
border-radius: 999px;
background: rgba(239, 175, 99, 0.12);
border: 1px solid rgba(239, 175, 99, 0.18);
color: var(--color-primary-strong);
font-size: 0.76rem;
font-weight: 700;
}
.insight-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.8rem;
}
.insight-metric {
display: grid;
gap: 0.28rem;
padding: 0.85rem 0.9rem;
border-radius: var(--radius-control);
background: rgba(255, 244, 232, 0.03);
border: 1px solid rgba(255, 244, 232, 0.06);
}
.insight-metric span {
color: var(--text-muted);
font-size: 0.75rem;
}
.insight-metric strong {
color: var(--text-primary);
font-size: 1rem;
line-height: 1.35;
}
.feed-list,
.timeline-list {
display: grid;
gap: 0.7rem;
}
.feed-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.8rem;
width: 100%;
padding: 0.85rem 0.9rem;
border: 1px solid rgba(255, 244, 232, 0.06);
border-radius: var(--radius-control);
background: rgba(255, 244, 232, 0.02);
color: inherit;
text-align: left;
cursor: pointer;
}
.feed-item:hover {
border-color: rgba(239, 175, 99, 0.2);
background: rgba(255, 244, 232, 0.04);
}
.feed-main,
.timeline-main {
min-width: 0;
display: grid;
gap: 0.32rem;
}
.feed-top,
.timeline-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.7rem;
}
.feed-name,
.timeline-name {
color: var(--text-primary);
font-weight: 700;
}
.feed-meta,
.timeline-meta {
color: var(--text-muted);
font-size: 0.82rem;
line-height: 1.45;
}
.feed-link {
color: var(--color-primary-strong);
font-size: 0.8rem;
white-space: nowrap;
}
.side-empty {
min-height: auto;
padding: 0.6rem 0;
}
.auth-badge {
background: rgba(134, 183, 199, 0.12);
color: var(--color-info);
border-color: rgba(134, 183, 199, 0.18);
}
.time-inline {
color: var(--text-secondary);
font-size: 0.84rem;
}
.info-cards {
display: grid;
grid-template-columns: 1fr;
gap: 0.85rem;
margin-bottom: 1rem;
}
.info-card {
padding: 1rem;
}
.info-label {
margin-bottom: 0.45rem;
color: var(--text-soft);
font-size: 0.76rem;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.info-value {
font-size: 0.96rem;
color: var(--text-primary);
}
.error-box {
display: flex;
align-items: flex-start;
gap: 0.6rem;
margin-bottom: 1rem;
padding: 0.9rem 1rem;
border-radius: var(--radius-control);
color: var(--color-danger);
background: rgba(221, 126, 114, 0.08);
border: 1px solid rgba(221, 126, 114, 0.16);
}
.section-title {
display: flex;
align-items: center;
gap: 0.45rem;
margin-bottom: 0.8rem;
font-size: 1rem;
font-weight: 700;
}
.section-sub {
color: var(--text-muted);
font-size: 0.78rem;
font-weight: 500;
}
.snapshot-list {
display: grid;
gap: 0.7rem;
min-height: 4rem;
}
.snap-item {
border-radius: var(--radius-control);
border: 1px solid var(--border-color);
background: rgba(255, 244, 232, 0.02);
overflow: hidden;
}
.snap-item.expanded {
border-color: rgba(239, 175, 99, 0.24);
}
.snap-header {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 0.8rem;
padding: 0.95rem 1rem;
cursor: pointer;
}
.snap-left,
.snap-right,
.snap-pagination {
display: flex;
align-items: center;
gap: 0.55rem;
}
.expand-icon {
color: var(--text-soft);
transition: transform var(--motion-fast) var(--ease-standard);
}
.snap-item.expanded .expand-icon {
transform: rotate(90deg);
}
.snap-time {
color: var(--text-secondary);
font-size: 0.85rem;
}
.snap-body {
padding: 0 0.35rem 0.35rem;
border-top: 1px solid var(--border-color);
}
.rate-value {
color: var(--color-primary-strong);
font-weight: 700;
}
.override-value {
color: var(--color-warning);
}
.balance-value {
color: var(--color-success);
font-weight: 600;
}
.form-hint {
font-size: 0.75rem;
color: var(--text-soft);
margin-top: 0.25rem;
line-height: 1.4;
}
.form-hint code {
background: var(--bg-soft);
padding: 0 0.3em;
border-radius: 3px;
font-size: 0.85em;
}
.snap-pagination {
justify-content: center;
margin-top: 0.9rem;
padding-top: 0.9rem;
border-top: 1px solid var(--border-color);
}
@media (min-width: 768px) {
.info-cards {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 1199px) {
.upstreams-content {
grid-template-columns: 1fr;
}
}
@media (min-width: 1200px) {
.info-cards {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
}
@media (max-width: 767px) {
.upstreams-hero,
.data-stage,
.upstreams-side {
padding: 1rem;
}
.data-stage,
.upstreams-side {
min-height: 0;
}
.hero-actions {
width: 100%;
}
.hero-actions :deep(.el-button) {
flex: 1 1 100%;
}
.insight-grid {
grid-template-columns: 1fr;
}
.feed-item,
.timeline-item {
align-items: flex-start;
}
}
.action-row .el-button--danger {
--el-button-bg-color: transparent;
--el-button-border-color: transparent;
}
.action-row .el-button--danger:hover {
--el-button-bg-color: var(--el-color-danger-light-8, #fef0f0);
--el-button-border-color: var(--el-color-danger-light-5, #fbc4c4);
}
</style>