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
This commit is contained in:
@@ -0,0 +1 @@
|
||||
registry=https://registry.npmmirror.com
|
||||
@@ -59,6 +59,11 @@ export interface UpstreamData {
|
||||
last_status: string
|
||||
last_checked_at: string | null
|
||||
last_error: string | null
|
||||
balance: number | null
|
||||
balance_updated_at: string | null
|
||||
balance_endpoint: string
|
||||
balance_response_path: string
|
||||
balance_divisor: number
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
@@ -74,6 +79,9 @@ export interface UpstreamForm {
|
||||
enabled: boolean
|
||||
check_interval_seconds: number
|
||||
timeout_seconds: number
|
||||
balance_endpoint: string
|
||||
balance_response_path: string
|
||||
balance_divisor: number
|
||||
}
|
||||
|
||||
export const upstreamsApi = {
|
||||
@@ -315,7 +323,10 @@ export const browserSessionsApi = {
|
||||
event: (id: string, data: BrowserEventPayload) =>
|
||||
api.post<BrowserSessionData>(`/api/browser-sessions/${id}/events`, data),
|
||||
selection: (id: string) => api.get<{ text: string }>(`/api/browser-sessions/${id}/selection`),
|
||||
clipboard: (id: string) => api.get<{ text?: string; error?: string }>(`/api/browser-sessions/${id}/clipboard`),
|
||||
close: (id: string) => api.delete(`/api/browser-sessions/${id}`),
|
||||
autofillLogin: (id: string) => api.post<{ success: boolean; message: string }>(`/api/browser-sessions/${id}/autofill-login`),
|
||||
clearProfile: (customPageId: number) => api.delete(`/api/browser-sessions/profiles/${customPageId}`),
|
||||
screenshotUrl: (id: string, token?: string) => {
|
||||
const params = new URLSearchParams({ t: String(Date.now()) })
|
||||
if (token) params.set('token', token)
|
||||
|
||||
@@ -13,7 +13,7 @@ const router = createRouter({
|
||||
path: '/',
|
||||
component: () => import('@/components/AppLayout.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
redirect: '/upstreams',
|
||||
redirect: '/websites',
|
||||
children: [
|
||||
{ path: 'upstreams', component: () => import('@/views/Upstreams.vue') },
|
||||
{ path: 'websites', component: () => import('@/views/Websites.vue') },
|
||||
@@ -32,7 +32,7 @@ router.beforeEach((to, _from, next) => {
|
||||
if (to.meta.requiresAuth && !auth.token) {
|
||||
next('/login')
|
||||
} else if (to.path === '/login' && auth.token) {
|
||||
next('/upstreams')
|
||||
next('/websites')
|
||||
} else {
|
||||
next()
|
||||
}
|
||||
|
||||
@@ -33,6 +33,21 @@
|
||||
<el-icon><Key /></el-icon>
|
||||
</el-button>
|
||||
</el-tooltip>
|
||||
<el-tooltip v-if="canAutofillLogin" content="填入账号密码">
|
||||
<el-button size="small" text type="primary" :loading="autofilling" @click="triggerAutofillLogin">
|
||||
<el-icon><EditPen /></el-icon>
|
||||
</el-button>
|
||||
</el-tooltip>
|
||||
<el-tooltip v-if="isRemoteBrowser && remoteSession" content="重建浏览器(保留登录态)">
|
||||
<el-button size="small" text @click="reconnectBrowser" :disabled="isStartingRemoteBrowser">
|
||||
<el-icon><Connection /></el-icon>
|
||||
</el-button>
|
||||
</el-tooltip>
|
||||
<el-tooltip v-if="isRemoteBrowser && remoteSession" content="清除登录态(删除 profile,需重新登录)">
|
||||
<el-button size="small" text type="danger" :loading="clearingProfile" @click="clearProfile">
|
||||
<el-icon><Delete /></el-icon>
|
||||
</el-button>
|
||||
</el-tooltip>
|
||||
<el-tooltip content="在新标签页打开">
|
||||
<el-button size="small" text @click="openExternal">
|
||||
<el-icon><TopRight /></el-icon>
|
||||
@@ -134,9 +149,9 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onBeforeUnmount, nextTick, watch, markRaw } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import {
|
||||
ArrowLeft, TopRight, Refresh, Loading, Back, Right, Warning, DocumentCopy,
|
||||
ArrowLeft, TopRight, Refresh, Loading, Back, Right, Warning, DocumentCopy, Delete, EditPen,
|
||||
Link as LinkIcon, Monitor, SetUp, Reading, Cpu, DataLine,
|
||||
Grid, Connection, Ticket, Wallet, Key, Tools, Star, House,
|
||||
} from '@element-plus/icons-vue'
|
||||
@@ -172,7 +187,10 @@ const remoteScreenshotUrl = ref('')
|
||||
const isStartingRemoteBrowser = ref(false)
|
||||
const isReconnectingRemoteBrowser = ref(false)
|
||||
const remoteErrorState = ref<RemoteBrowserErrorState | null>(null)
|
||||
let startRemoteBrowserPromise: Promise<void> | null = null
|
||||
const lastSyncedClipboard = ref('')
|
||||
const startRemoteBrowserPromises = new Map<number, Promise<void>>()
|
||||
let closingRemoteSessionPromise: Promise<void> | null = null
|
||||
let wsFirstFrameTimer: number | undefined
|
||||
let screenshotObjectUrl = ''
|
||||
let previousScreenshotObjectUrl = ''
|
||||
let pendingScreenshotBlob: Blob | null = null
|
||||
@@ -216,6 +234,13 @@ const pageIcon = computed(() => iconMap[page.value?.icon || 'Link'] || LinkIcon)
|
||||
const isRemoteBrowser = computed(() => page.value?.access_mode === 'remote_browser')
|
||||
const canRefreshAuth = computed(() => isRemoteBrowser.value && page.value?.linked_upstream_id && remoteSession.value)
|
||||
const refreshingAuth = ref(false)
|
||||
const clearingProfile = ref(false)
|
||||
const autofilling = ref(false)
|
||||
const canAutofillLogin = computed(() =>
|
||||
isRemoteBrowser.value && remoteSession.value &&
|
||||
page.value?.login_autofill_enabled && page.value?.login_username &&
|
||||
page.value?.login_password_configured
|
||||
)
|
||||
const effectivePageId = computed(() => props.pageId ?? Number(route.params.id))
|
||||
const embedded = computed(() => props.embedded)
|
||||
const showRemoteError = computed(() => Boolean(remoteErrorState.value) && !isStartingRemoteBrowser.value)
|
||||
@@ -236,10 +261,12 @@ async function loadPage(id: number) {
|
||||
clearRemoteError()
|
||||
try {
|
||||
const res = await customPagesApi.list()
|
||||
// Guard: page may have changed during async fetch
|
||||
if (effectivePageId.value !== id) return
|
||||
page.value = res.data.find(p => p.id === id) || null
|
||||
if (!page.value) {
|
||||
ElMessage.error('页面不存在')
|
||||
router.push('/custom-pages')
|
||||
if (effectivePageId.value === id) router.push('/custom-pages')
|
||||
return
|
||||
}
|
||||
if (isRemoteBrowser.value) {
|
||||
@@ -278,11 +305,16 @@ function reload() {
|
||||
}
|
||||
|
||||
async function startRemoteBrowser(options: { reconnect?: boolean } = {}) {
|
||||
if (startRemoteBrowserPromise) return startRemoteBrowserPromise
|
||||
startRemoteBrowserPromise = doStartRemoteBrowser(options).finally(() => {
|
||||
startRemoteBrowserPromise = null
|
||||
const pid = effectivePageId.value
|
||||
const existing = startRemoteBrowserPromises.get(pid)
|
||||
if (existing) return existing
|
||||
const promise = doStartRemoteBrowser(options).finally(() => {
|
||||
if (startRemoteBrowserPromises.get(pid) === promise) {
|
||||
startRemoteBrowserPromises.delete(pid)
|
||||
}
|
||||
})
|
||||
return startRemoteBrowserPromise
|
||||
startRemoteBrowserPromises.set(pid, promise)
|
||||
return promise
|
||||
}
|
||||
|
||||
async function doStartRemoteBrowser(options: { reconnect?: boolean }) {
|
||||
@@ -296,14 +328,23 @@ async function doStartRemoteBrowser(options: { reconnect?: boolean }) {
|
||||
clearRemoteError()
|
||||
await nextTick()
|
||||
stopRemoteWs()
|
||||
await closeRemoteSession()
|
||||
// Wait for any in-flight close to finish (avoids profile contention)
|
||||
if (closingRemoteSessionPromise) await closingRemoteSessionPromise
|
||||
// Capture the intended page id BEFORE the async request
|
||||
const requestedPageId = page.value.id
|
||||
try {
|
||||
const viewport = remoteViewport()
|
||||
const res = await browserSessionsApi.create({
|
||||
custom_page_id: page.value.id,
|
||||
custom_page_id: requestedPageId,
|
||||
width: viewport.width,
|
||||
height: viewport.height,
|
||||
})
|
||||
// Guard: page may have changed while we were creating.
|
||||
// Verify both the current route AND the backend-returned page match.
|
||||
if (effectivePageId.value !== requestedPageId || res.data.custom_page_id !== requestedPageId) {
|
||||
await browserSessionsApi.close(res.data.id).catch(() => {})
|
||||
return
|
||||
}
|
||||
remoteSession.value = res.data
|
||||
if (!options.reconnect) wsReconnectAttempts = 0
|
||||
if (props.active) {
|
||||
@@ -334,10 +375,21 @@ function connectRemoteWs() {
|
||||
|
||||
socket.onopen = () => {
|
||||
wsReconnectAttempts = 0
|
||||
// First-frame watchdog: if no screenshot arrives within 10s, show error
|
||||
wsFirstFrameTimer = window.setTimeout(() => {
|
||||
if (ws === socket) {
|
||||
handleRemoteSessionFailure(502, '远程浏览器无响应(首帧超时)')
|
||||
}
|
||||
}, 10000)
|
||||
}
|
||||
|
||||
socket.onmessage = (evt) => {
|
||||
if (evt.data instanceof Blob) {
|
||||
// Clear first-frame watchdog when first screenshot arrives
|
||||
if (wsFirstFrameTimer !== undefined) {
|
||||
window.clearTimeout(wsFirstFrameTimer)
|
||||
wsFirstFrameTimer = undefined
|
||||
}
|
||||
queueRemoteScreenshot(evt.data)
|
||||
return
|
||||
}
|
||||
@@ -381,6 +433,10 @@ function connectRemoteWs() {
|
||||
}
|
||||
|
||||
function stopRemoteWs() {
|
||||
if (wsFirstFrameTimer !== undefined) {
|
||||
window.clearTimeout(wsFirstFrameTimer)
|
||||
wsFirstFrameTimer = undefined
|
||||
}
|
||||
if (wsReconnectTimer !== undefined) {
|
||||
window.clearTimeout(wsReconnectTimer)
|
||||
wsReconnectTimer = undefined
|
||||
@@ -454,6 +510,28 @@ async function refreshAuth() {
|
||||
}
|
||||
}
|
||||
|
||||
async function triggerAutofillLogin() {
|
||||
const sid = remoteSession.value?.id
|
||||
const cpid = page.value?.id
|
||||
if (!sid) return
|
||||
autofilling.value = true
|
||||
try {
|
||||
const res = await browserSessionsApi.autofillLogin(sid)
|
||||
// Guard: page or session may have changed during async request
|
||||
if (remoteSession.value?.id !== sid || page.value?.id !== cpid) return
|
||||
if (res.data.success) {
|
||||
ElMessage.success(res.data.message)
|
||||
} else {
|
||||
ElMessage.warning(res.data.message)
|
||||
}
|
||||
} catch (e: any) {
|
||||
if (remoteSession.value?.id !== sid || page.value?.id !== cpid) return
|
||||
ElMessage.error(e.response?.data?.detail || '填入失败')
|
||||
} finally {
|
||||
autofilling.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function remoteViewport() {
|
||||
const rect = remoteFrameRef.value?.getBoundingClientRect()
|
||||
return {
|
||||
@@ -555,6 +633,7 @@ function onRemotePointerUp(event: PointerEvent) {
|
||||
remoteFrameRef.value?.releasePointerCapture?.(event.pointerId)
|
||||
if (!point) return
|
||||
sendRemoteEvent({ type: 'mouseup', ...point, button: activePointer.button })
|
||||
syncClipboard(remoteSession.value?.id)
|
||||
}
|
||||
|
||||
function onRemotePointerCancel(event: PointerEvent) {
|
||||
@@ -565,6 +644,7 @@ function onRemotePointerCancel(event: PointerEvent) {
|
||||
remoteFrameRef.value?.releasePointerCapture?.(event.pointerId)
|
||||
if (!point) return
|
||||
sendRemoteEvent({ type: 'mouseup', ...point, button: activePointer.button })
|
||||
syncClipboard(remoteSession.value?.id)
|
||||
}
|
||||
|
||||
function onRemoteWheel(event: WheelEvent) {
|
||||
@@ -639,12 +719,109 @@ function focusRemoteFrame() {
|
||||
remoteFrameRef.value?.focus()
|
||||
}
|
||||
|
||||
const CLIPBOARD_SYNC_DELAY_MS = 800
|
||||
|
||||
async function syncClipboard(capturedSessionId?: string) {
|
||||
// Must be called with a captured sessionId; if none, capture now
|
||||
const sid = capturedSessionId || remoteSession.value?.id
|
||||
const cpid = page.value?.id
|
||||
if (!sid) return
|
||||
await new Promise((r) => setTimeout(r, CLIPBOARD_SYNC_DELAY_MS))
|
||||
// Guard: session or page may have changed during delay
|
||||
if (remoteSession.value?.id !== sid || page.value?.id !== cpid) return
|
||||
try {
|
||||
const res = await browserSessionsApi.clipboard(sid)
|
||||
const data = res.data
|
||||
if (!data.text) {
|
||||
if (data.error && data.error !== '远程剪贴板为空') {
|
||||
console.debug('clipboard sync:', data.error)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (data.text === lastSyncedClipboard.value) return
|
||||
const text = data.text
|
||||
lastSyncedClipboard.value = text
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
ElMessage.success('已同步到本机剪贴板')
|
||||
} catch {
|
||||
// Browser blocked clipboard write — try execCommand fallback
|
||||
let copied = false
|
||||
const textarea = document.createElement('textarea')
|
||||
textarea.value = text
|
||||
textarea.style.position = 'fixed'
|
||||
textarea.style.opacity = '0'
|
||||
document.body.appendChild(textarea)
|
||||
textarea.select()
|
||||
try {
|
||||
copied = document.execCommand('copy')
|
||||
} catch {}
|
||||
document.body.removeChild(textarea)
|
||||
|
||||
if (copied) {
|
||||
ElMessage.success('已同步到本机剪贴板')
|
||||
} else {
|
||||
// Show dialog with selectable text as last resort
|
||||
ElMessageBox.alert(text, '远程剪贴板内容', {
|
||||
confirmButtonText: '已复制',
|
||||
type: 'info',
|
||||
dangerouslyUseHTMLString: false,
|
||||
message: `请手动复制下方内容:\n\n${text}`,
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Clipboard read failed — silently ignore (likely empty or permission denied)
|
||||
}
|
||||
}
|
||||
|
||||
async function clearProfile() {
|
||||
if (!page.value || !remoteSession.value) return
|
||||
try {
|
||||
clearingProfile.value = true
|
||||
await ElMessageBox.confirm('清除登录态后需要重新登录,确定继续?', '确认', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
})
|
||||
} catch {
|
||||
clearingProfile.value = false
|
||||
return
|
||||
}
|
||||
stopRemoteWs()
|
||||
try {
|
||||
await browserSessionsApi.clearProfile(page.value.id)
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e.response?.data?.detail || '清除登录态失败')
|
||||
clearingProfile.value = false
|
||||
return
|
||||
}
|
||||
remoteSession.value = null
|
||||
setRemoteScreenshotUrl('')
|
||||
clearingProfile.value = false
|
||||
startRemoteBrowser()
|
||||
}
|
||||
|
||||
async function closeRemoteSession() {
|
||||
if (!remoteSession.value) return
|
||||
if (!remoteSession.value) {
|
||||
// If already null but a close is in flight, wait for it
|
||||
if (closingRemoteSessionPromise) await closingRemoteSessionPromise
|
||||
return
|
||||
}
|
||||
const id = remoteSession.value.id
|
||||
remoteSession.value = null
|
||||
setRemoteScreenshotUrl('')
|
||||
await browserSessionsApi.close(id).catch(() => undefined)
|
||||
remoteErrorState.value = null
|
||||
iframeLoading.value = true
|
||||
const promise = browserSessionsApi.close(id).then<void | undefined>(() => undefined).catch(() => undefined)
|
||||
closingRemoteSessionPromise = promise
|
||||
try {
|
||||
await promise
|
||||
} finally {
|
||||
if (closingRemoteSessionPromise === promise) {
|
||||
closingRemoteSessionPromise = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function queueRemoteScreenshot(blob: Blob) {
|
||||
@@ -698,6 +875,13 @@ function retryRemoteBrowser() {
|
||||
startRemoteBrowser({ reconnect: true })
|
||||
}
|
||||
|
||||
/** Close current session and start fresh (login preserved via profile on disk). */
|
||||
async function reconnectBrowser() {
|
||||
stopRemoteWs()
|
||||
await closeRemoteSession()
|
||||
startRemoteBrowser()
|
||||
}
|
||||
|
||||
function clearRemoteError() {
|
||||
remoteErrorState.value = null
|
||||
}
|
||||
@@ -759,6 +943,16 @@ function mapRemoteBrowserError(status?: number, message?: string): RemoteBrowser
|
||||
}
|
||||
}
|
||||
|
||||
if (lowerDetail.includes('首帧超时')) {
|
||||
return {
|
||||
title: '远程浏览器无响应',
|
||||
description: '浏览器已启动但长时间未返回画面,可能是页面卡死、弹窗遮挡或加载过慢。',
|
||||
hint: '点击按钮可重建浏览器并刷新页面连接。',
|
||||
actionLabel: '重新创建会话',
|
||||
technicalDetail: detail,
|
||||
}
|
||||
}
|
||||
|
||||
if (status === 502) {
|
||||
return {
|
||||
title: '远程浏览器连接异常',
|
||||
@@ -792,27 +986,32 @@ async function handleRemoteSessionFailure(status: number | undefined, message: s
|
||||
setRemoteError(status, message || '远程浏览器会话已失效')
|
||||
}
|
||||
|
||||
watch(() => route.params.id, (id) => {
|
||||
watch(() => route.params.id, async (id) => {
|
||||
if (!props.pageId && id) {
|
||||
stopRemoteWs()
|
||||
closeRemoteSession()
|
||||
await closeRemoteSession()
|
||||
loadPage(Number(id))
|
||||
}
|
||||
}, { immediate: false })
|
||||
|
||||
watch(() => props.pageId, (id, oldId) => {
|
||||
watch(() => props.pageId, async (id, oldId) => {
|
||||
if (id && id !== oldId) {
|
||||
stopRemoteWs()
|
||||
closeRemoteSession()
|
||||
await closeRemoteSession()
|
||||
loadPage(id)
|
||||
}
|
||||
})
|
||||
|
||||
watch(() => props.active, (active) => {
|
||||
watch(() => props.active, async (active) => {
|
||||
if (!isRemoteBrowser.value) return
|
||||
if (active) {
|
||||
if (remoteSession.value && (!ws || ws.readyState !== WebSocket.OPEN)) {
|
||||
connectRemoteWs()
|
||||
if (remoteSession.value) {
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) {
|
||||
connectRemoteWs()
|
||||
}
|
||||
} else {
|
||||
// Session was closed while inactive — restart from profile
|
||||
await startRemoteBrowser()
|
||||
}
|
||||
nextTick(() => remoteFrameRef.value?.focus())
|
||||
} else {
|
||||
|
||||
@@ -42,6 +42,11 @@
|
||||
<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">
|
||||
@@ -71,6 +76,15 @@
|
||||
</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)" />
|
||||
@@ -111,9 +125,9 @@
|
||||
<el-button size="small" text @click="openEdit(row)" title="编辑">
|
||||
<el-icon><Edit /></el-icon>
|
||||
</el-button>
|
||||
<el-button size="small" text type="success" @click="testUpstream(row)" :loading="row._testing">测试</el-button>
|
||||
<el-button size="small" text type="primary" @click="checkNow(row)" :loading="row._checking">检测</el-button>
|
||||
<el-button size="small" text type="info" @click="openDetail(row)">
|
||||
<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>
|
||||
@@ -198,7 +212,7 @@
|
||||
<div class="timeline-name">{{ row.name }}</div>
|
||||
<div class="timeline-meta mono">{{ fmtTime(row.last_checked_at!) }}</div>
|
||||
</div>
|
||||
<el-button size="small" text type="primary" @click="openDetail(row)">查看</el-button>
|
||||
<el-button size="small" text @click="openDetail(row)">查看</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="empty-hint side-empty">还没有检测记录</div>
|
||||
@@ -286,6 +300,17 @@
|
||||
<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="检测间隔(秒)">
|
||||
@@ -322,6 +347,14 @@
|
||||
<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>
|
||||
@@ -443,6 +476,9 @@ const defaultForm = () => ({
|
||||
enabled: true,
|
||||
check_interval_seconds: 600,
|
||||
timeout_seconds: 30,
|
||||
balance_endpoint: '',
|
||||
balance_response_path: '',
|
||||
balance_divisor: 1.0,
|
||||
})
|
||||
const form = ref(defaultForm())
|
||||
const rules = {
|
||||
@@ -513,11 +549,16 @@ function handlePlatformChange(val: string) {
|
||||
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'
|
||||
@@ -525,6 +566,12 @@ function handlePlatformChange(val: string) {
|
||||
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 = ''
|
||||
}
|
||||
}
|
||||
|
||||
@@ -541,6 +588,7 @@ const metrics = computed(() => ({
|
||||
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(() => {
|
||||
@@ -573,6 +621,11 @@ const recentChecks = computed(() =>
|
||||
|
||||
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')
|
||||
@@ -616,6 +669,9 @@ function openEdit(row: UpstreamData) {
|
||||
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
|
||||
}
|
||||
@@ -1076,6 +1132,25 @@ onMounted(loadList)
|
||||
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;
|
||||
@@ -1130,4 +1205,13 @@ onMounted(loadList)
|
||||
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>
|
||||
|
||||
@@ -58,12 +58,12 @@
|
||||
</el-button>
|
||||
</el-tooltip>
|
||||
<el-tooltip content="连接测试" placement="top" :show-after="300">
|
||||
<el-button size="small" circle text type="success" :loading="row._testing" @click="testWebsite(row)">
|
||||
<el-button size="small" circle text :loading="row._testing" @click="testWebsite(row)">
|
||||
<el-icon v-if="!row._testing"><Connection /></el-icon>
|
||||
</el-button>
|
||||
</el-tooltip>
|
||||
<el-tooltip content="新增绑定" placement="top" :show-after="300">
|
||||
<el-button size="small" circle text type="primary" @click="openBindingCreate(row)">
|
||||
<el-button size="small" circle text @click="openBindingCreate(row)">
|
||||
<el-icon><Link /></el-icon>
|
||||
</el-button>
|
||||
</el-tooltip>
|
||||
@@ -95,7 +95,7 @@
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="90">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" text type="primary" :disabled="!selectedWebsite" @click="openBindingCreate(selectedWebsite, row)">绑定</el-button>
|
||||
<el-button size="small" text :disabled="!selectedWebsite" @click="openBindingCreate(selectedWebsite, row)">绑定</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
@@ -104,7 +104,7 @@
|
||||
<div class="panel">
|
||||
<div class="panel-head">
|
||||
<div class="panel-title">分组绑定</div>
|
||||
<el-button size="small" type="primary" :disabled="websites.length === 0" @click="openBindingCreate(selectedWebsite || websites[0])">新增绑定</el-button>
|
||||
<el-button size="small" text :disabled="websites.length === 0" @click="openBindingCreate(selectedWebsite || websites[0])">新增绑定</el-button>
|
||||
</div>
|
||||
<div class="binding-list" v-loading="bindingLoading">
|
||||
<div v-for="binding in bindings" :key="binding.id" class="binding-item">
|
||||
@@ -117,7 +117,7 @@
|
||||
</div>
|
||||
<div class="binding-actions">
|
||||
<el-switch v-model="binding.enabled" @change="toggleBinding(binding)" />
|
||||
<el-button size="small" text type="primary" :loading="binding._syncing" @click="syncBinding(binding)">同步</el-button>
|
||||
<el-button size="small" text :loading="binding._syncing" @click="syncBinding(binding)">同步</el-button>
|
||||
<el-button size="small" text @click="openBindingEdit(binding)"><el-icon><Edit /></el-icon></el-button>
|
||||
<el-button size="small" text type="danger" @click="deleteBinding(binding)"><el-icon><Delete /></el-icon></el-button>
|
||||
</div>
|
||||
@@ -647,12 +647,23 @@ onMounted(loadAll)
|
||||
height: 26px;
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.binding-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
.binding-actions .el-button--danger,
|
||||
.action-row .el-button--danger {
|
||||
--el-button-bg-color: transparent;
|
||||
--el-button-border-color: transparent;
|
||||
}
|
||||
.binding-actions .el-button--danger:hover,
|
||||
.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);
|
||||
}
|
||||
.binding-list { min-height: 120px; }
|
||||
.binding-item {
|
||||
display: flex;
|
||||
|
||||
Reference in New Issue
Block a user