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:
liumangmang
2026-05-20 09:44:20 +08:00
parent 4c71148ff9
commit 6cc797f915
16 changed files with 773 additions and 52 deletions
+1
View File
@@ -0,0 +1 @@
registry=https://registry.npmmirror.com
+11
View File
@@ -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)
+2 -2
View File
@@ -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()
}
+218 -19
View File
@@ -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 {
+88 -4
View File
@@ -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>
+16 -5
View File
@@ -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;