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