feat: one-click upstream auth refresh from custom page viewer

- Add linked_upstream_id to CustomPage model with DB migration
- New POST /api/custom-pages/{pid}/refresh-auth endpoint extracts
  credentials from active remote browser and updates linked upstream
- PageViewer toolbar shows key icon button when page has linked upstream
- CustomPages form adds upstream dropdown for remote_browser pages
- Auth capture extracts New-Api-User from localStorage uid/user/self API
- Upstream client sends New-Api-User header in cookie auth mode
- Fix auth capture dialog: transparent background, field persistence,
  login URL defaults to base_url/login, focus on click for keyboard input
- Fix upstream test ASCII encoding with non-header characters validation

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
SmartUp Developer
2026-05-19 09:27:14 +08:00
co-authored by Claude Opus 4.7
parent 7cb0ff1608
commit 4c71148ff9
13 changed files with 462 additions and 53 deletions
+14 -3
View File
@@ -97,6 +97,12 @@
<el-form-item label="启用">
<el-switch v-model="form.enabled" />
</el-form-item>
<el-form-item v-if="form.access_mode === 'remote_browser'" label="关联上游">
<el-select v-model="form.linked_upstream_id" clearable placeholder="选择要一键刷新凭证的上游" style="width:100%">
<el-option v-for="u in upstreamList" :key="u.id" :label="`${u.name} (${u.base_url})`" :value="u.id" />
</el-select>
<div class="form-hint">关联后可在页面查看器中一键刷新该上游的认证凭证</div>
</el-form-item>
<div class="login-section">
<div class="login-section-head">
<span>登录自动填充</span>
@@ -158,7 +164,7 @@ import {
SetUp, Reading, Cpu, DataLine, Grid, Connection,
Ticket, Wallet, Key, Tools, Star, House,
} from '@element-plus/icons-vue'
import { customPagesApi, type CustomPageAccessMode, type CustomPageData } from '@/api'
import { customPagesApi, upstreamsApi, type CustomPageAccessMode, type CustomPageData, type UpstreamData } from '@/api'
const router = useRouter()
@@ -182,6 +188,7 @@ const iconMap: Record<string, any> = {
// ---- state ----
const list = ref<CustomPageData[]>([])
const upstreamList = ref<UpstreamData[]>([])
const loading = ref(false)
const dialogVisible = ref(false)
const saving = ref(false)
@@ -206,6 +213,7 @@ type PageFormState = {
login_autofill_enabled: boolean
login_password_configured: boolean
login_password_clear: boolean
linked_upstream_id: number | null
}
const defaultForm = (): PageFormState => ({
@@ -225,6 +233,7 @@ const defaultForm = (): PageFormState => ({
login_autofill_enabled: false,
login_password_configured: false,
login_password_clear: false,
linked_upstream_id: null,
})
const form = ref(defaultForm())
const rules = {
@@ -235,8 +244,9 @@ const rules = {
async function loadList() {
loading.value = true
try {
const res = await customPagesApi.list()
list.value = res.data
const [pagesRes, upstreamsRes] = await Promise.all([customPagesApi.list(), upstreamsApi.list()])
list.value = pagesRes.data
upstreamList.value = upstreamsRes.data
} finally {
loading.value = false
}
@@ -269,6 +279,7 @@ function openEdit(page: CustomPageData) {
login_autofill_enabled: page.login_autofill_enabled,
login_password_configured: page.login_password_configured,
login_password_clear: false,
linked_upstream_id: page.linked_upstream_id ?? null,
}
dialogVisible.value = true
}
+24
View File
@@ -28,6 +28,11 @@
<el-icon><Right /></el-icon>
</el-button>
</el-tooltip>
<el-tooltip v-if="canRefreshAuth" content="一键刷新上游凭证">
<el-button size="small" text type="warning" :loading="refreshingAuth" @click="refreshAuth">
<el-icon><Key /></el-icon>
</el-button>
</el-tooltip>
<el-tooltip content="在新标签页打开">
<el-button size="small" text @click="openExternal">
<el-icon><TopRight /></el-icon>
@@ -209,6 +214,8 @@ type RemoteBrowserErrorState = {
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 effectivePageId = computed(() => props.pageId ?? Number(route.params.id))
const embedded = computed(() => props.embedded)
const showRemoteError = computed(() => Boolean(remoteErrorState.value) && !isStartingRemoteBrowser.value)
@@ -430,6 +437,23 @@ async function copyRemoteSelection() {
}
}
async function refreshAuth() {
if (!page.value) return
refreshingAuth.value = true
try {
const res = await customPagesApi.refreshAuth(page.value.id)
if (res.data.success) {
ElMessage.success(res.data.message || '凭证已刷新')
} else {
ElMessage.warning(res.data.message || '刷新失败')
}
} catch (e: any) {
ElMessage.error(e.response?.data?.detail || '刷新凭证失败')
} finally {
refreshingAuth.value = false
}
}
function remoteViewport() {
const rect = remoteFrameRef.value?.getBoundingClientRect()
return {
+23 -4
View File
@@ -270,8 +270,8 @@
</el-form-item>
</template>
<template v-else-if="form.auth_type === 'login_password'">
<el-form-item label="登录邮箱">
<el-input v-model="form.auth_config.email" placeholder="admin@example.com" />
<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="***" />
@@ -409,7 +409,7 @@
<AuthCaptureDialog
v-model="authCaptureVisible"
:initial-url="form.base_url"
:initial-url="authCaptureInitialUrl"
@select="handleAuthCaptureSelect"
/>
</div>
@@ -452,11 +452,17 @@ const rules = {
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 }) {
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
@@ -466,6 +472,17 @@ function handleAuthCaptureSelect(candidate: { type: string; value: string; cooki
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'
@@ -495,6 +512,7 @@ function handlePlatformChange(val: string) {
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'
} else if (val === 'new-api') {
form.value.api_prefix = ''
form.value.groups_endpoint = '/api/group/'
@@ -506,6 +524,7 @@ function handlePlatformChange(val: string) {
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'
}
}