Add remote browser pages and website sync

Enable managed remote browser custom pages with login autofill and add website sync workflows so external admin surfaces can be handled inside SmartUp.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
liumangmang
2026-05-15 15:43:58 +08:00
parent a13a0070a5
commit 7adc7c00ab
43 changed files with 6615 additions and 641 deletions
+325 -175
View File
@@ -1,82 +1,148 @@
<template>
<div>
<!-- Header -->
<div class="page-header">
<div>
<div class="shell-page 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>
<p class="page-desc">
管理 API 上游服务认证方式与轮询策略这里优先展示健康度检测节奏和错误信号减少你在异常发生时的定位成本
</p>
</div>
<el-button type="primary" @click="openCreate">
<el-icon><Plus /></el-icon> 新增上游
</el-button>
</div>
<!-- Table -->
<div class="card">
<el-table :data="list" v-loading="tableLoading" row-key="id" style="width:100%">
<el-table-column label="名称" min-width="140">
<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>
</section>
<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">{{ row.base_url }}</div>
<div class="cell-url mono">{{ row.base_url }}</div>
</template>
</el-table-column>
<el-table-column label="状态" width="110">
<el-table-column label="状态" width="118">
<template #default="{ row }">
<span :class="['status-badge', row.last_status]">
<span class="dot"></span>
<span class="dot" />
{{ statusLabel(row.last_status) }}
</span>
</template>
</el-table-column>
<el-table-column label="启用" width="80">
<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="100">
<template #default="{ row }">{{ row.check_interval_seconds }}s</template>
</el-table-column>
<el-table-column label="最近检测" min-width="145">
<el-table-column label="认证" width="132">
<template #default="{ row }">
<span v-if="row.last_checked_at" class="time-text">{{ fmtTime(row.last_checked_at) }}</span>
<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="160">
<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">{{ row.last_error.substring(0, 40) }}</span>
<span class="error-text">{{ shrinkError(row.last_error) }}</span>
</el-tooltip>
<span v-else class="muted"></span>
<span v-else class="muted">无异常</span>
</template>
</el-table-column>
<el-table-column label="操作" width="250">
<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="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-icon style="margin-right:2px"><List /></el-icon>详情
<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>
<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>
</div>
</section>
<!-- ======= Create / Edit Drawer ======= -->
<el-drawer
v-model="drawerVisible"
:title="editingId ? '编辑上游' : '新增上游'"
size="480px"
destroy-on-close
>
<el-form ref="formRef" :model="form" :rules="rules" label-width="120px" label-position="top">
<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>
@@ -84,7 +150,7 @@
<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-select v-model="form.auth_type" style="width: 100%">
<el-option label="无认证" value="none" />
<el-option label="Bearer Token" value="bearer" />
<el-option label="API Key" value="api_key" />
@@ -124,12 +190,12 @@
<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-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-input-number v-model="form.timeout_seconds" :min="5" :max="120" style="width: 100%" />
</el-form-item>
</el-col>
</el-row>
@@ -143,7 +209,6 @@
</template>
</el-drawer>
<!-- ======= Detail Drawer ======= -->
<el-drawer
v-model="detailVisible"
:title="`检测详情 — ${detailUpstream?.name || ''}`"
@@ -151,34 +216,35 @@
destroy-on-close
@open="loadSnapshots"
>
<!-- Info cards -->
<div v-if="detailUpstream" class="info-cards">
<div class="info-card">
<div class="surface-card info-card">
<div class="info-label">状态</div>
<span :class="['status-badge', detailUpstream.last_status]">
<span class="dot"></span>{{ statusLabel(detailUpstream.last_status) }}
<span class="dot" />{{ statusLabel(detailUpstream.last_status) }}
</span>
</div>
<div class="info-card">
<div class="surface-card info-card">
<div class="info-label">最近检测</div>
<div class="info-value">{{ detailUpstream.last_checked_at ? fmtTimeFull(detailUpstream.last_checked_at) : '未检测' }}</div>
<div class="info-value mono">{{ detailUpstream.last_checked_at ? fmtTimeFull(detailUpstream.last_checked_at) : '未检测' }}</div>
</div>
<div class="info-card">
<div class="surface-card info-card">
<div class="info-label">检测间隔</div>
<div class="info-value">{{ detailUpstream.check_interval_seconds }}s</div>
<div class="info-value mono">{{ detailUpstream.check_interval_seconds }}s</div>
</div>
<div class="info-card">
<div class="surface-card info-card">
<div class="info-label">超时</div>
<div class="info-value">{{ detailUpstream.timeout_seconds }}s</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> {{ detailUpstream.last_error }}
</div>
<!-- Snapshot history -->
<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> 检测历史
<el-icon><Clock /></el-icon>
检测历史
<span class="section-sub">最近 {{ snapshots.length }} </span>
</div>
@@ -189,11 +255,10 @@
class="snap-item"
:class="{ expanded: expandedId === snap.id }"
>
<!-- Row header -->
<div class="snap-header" @click="toggleExpand(snap)">
<div class="snap-left">
<el-icon class="expand-icon"><ArrowRight /></el-icon>
<span class="snap-time">{{ fmtTimeFull(snap.captured_at) }}</span>
<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>
@@ -206,25 +271,24 @@
</div>
</div>
<!-- Expanded rate table -->
<div v-if="expandedId === snap.id" class="snap-body">
<el-table
:data="groupRows(snap.snapshot)"
size="small"
:header-cell-style="{ background: 'var(--bg-elevated)', color: 'var(--text-secondary)' }"
:cell-style="{ background: 'var(--bg-card)', color: 'var(--text-primary)' }"
: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="100" />
<el-table-column label="当前倍率" width="100">
<el-table-column prop="platform" label="平台" width="110" />
<el-table-column label="当前倍率" width="110">
<template #default="{ row }">
<span class="rate-value">{{ row.rate || '—' }}</span>
<span class="rate-value mono">{{ row.rate || '—' }}</span>
</template>
</el-table-column>
<el-table-column prop="default_rate" label="默认倍率" width="100" />
<el-table-column prop="override_rate" label="覆盖倍率" width="100">
<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">{{ row.override_rate }}</span>
<span v-if="row.override_rate" class="override-value mono">{{ row.override_rate }}</span>
<span v-else class="muted"></span>
</template>
</el-table-column>
@@ -237,7 +301,6 @@
</div>
</div>
<!-- Pagination -->
<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>
@@ -252,13 +315,12 @@ 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 } from '@element-plus/icons-vue'
import { upstreamsApi, type UpstreamData } from '@/api'
// ---- list state ----
const list = ref<(UpstreamData & { _testing?: boolean; _checking?: boolean })[]>([])
const tableLoading = ref(false)
// ---- create/edit drawer ----
const drawerVisible = ref(false)
const saving = ref(false)
const editingId = ref<number | null>(null)
@@ -282,7 +344,29 @@ const rules = {
base_url: [{ required: true, message: '请输入 Base URL', trigger: 'blur' }],
}
// ---- detail drawer ----
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'
} 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'
} 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'
}
}
const detailVisible = ref(false)
const detailUpstream = ref<UpstreamData | null>(null)
const snapshots = ref<any[]>([])
@@ -291,20 +375,28 @@ const expandedId = ref<number | null>(null)
const snapshotOffset = ref(0)
const snapshotLimit = 20
// ---- helpers ----
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,
}))
const statusLabel = (s: string) => ({ healthy: '健康', unhealthy: '异常', unknown: '未知' }[s] || s)
// Treat server timestamps as UTC (add Z if no timezone info present)
const toUTC = (t: string) => /[Z+\-]\d*$/.test(t.trim()) ? t : t + 'Z'
const authLabel = (s: string) => ({ none: '无认证', bearer: 'Bearer', api_key: 'API Key', login_password: '邮箱密码' }[s] || s)
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[]
}
// ---- load list ----
function shrinkError(value: string) {
return value.length > 40 ? `${value.slice(0, 40)}` : value
}
async function loadList() {
tableLoading.value = true
try {
@@ -315,9 +407,9 @@ async function loadList() {
}
}
// ---- create / edit ----
function openCreate() {
editingId.value = null
quickPlatform.value = 'sub2api'
form.value = defaultForm()
drawerVisible.value = true
}
@@ -360,7 +452,6 @@ async function handleSave() {
}
}
// ---- toggle enabled ----
async function toggleEnabled(row: UpstreamData) {
try {
await upstreamsApi.update(row.id, { enabled: row.enabled })
@@ -371,7 +462,6 @@ async function toggleEnabled(row: UpstreamData) {
}
}
// ---- test / check-now ----
async function testUpstream(row: any) {
row._testing = true
try {
@@ -394,7 +484,6 @@ async function checkNow(row: any) {
}
}
// ---- detail drawer ----
function openDetail(row: UpstreamData) {
detailUpstream.value = row
snapshots.value = []
@@ -409,7 +498,6 @@ async function loadSnapshots() {
try {
const res = await upstreamsApi.listSnapshots(detailUpstream.value.id, snapshotLimit, snapshotOffset.value)
snapshots.value = res.data
// auto-expand the first (latest) snapshot
if (res.data.length > 0 && expandedId.value === null) {
expandedId.value = res.data[0].id
}
@@ -436,152 +524,214 @@ function nextSnapPage() {
loadSnapshots()
}
// ---- delete ----
async function confirmDelete(row: UpstreamData) {
try {
await ElMessageBox.confirm(`确认删除上游 "${row.name}" `, '删除确认', { type: 'warning' })
await upstreamsApi.delete(row.id)
ElMessage.success('已删除')
loadList()
} catch {}
} catch {
// noop
}
}
onMounted(loadList)
</script>
<style scoped>
.page-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 20px;
}
.page-title { font-size: 20px; font-weight: 600; margin-bottom: 4px; }
.page-desc { font-size: 13px; color: var(--text-muted); }
.card {
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 12px;
overflow: hidden;
}
.cell-name { font-weight: 500; font-size: 14px; }
.cell-url { font-size: 12px; color: var(--text-muted); margin-top: 2px; }
.muted { color: var(--text-muted); font-size: 12px; }
.time-text { font-size: 12px; color: var(--text-secondary); }
.error-text { font-size: 12px; color: var(--color-danger); cursor: pointer; }
.status-badge .dot {
width: 6px; height: 6px; border-radius: 50%; background: currentColor; display: inline-block;
}
.action-row {
display: flex;
align-items: center;
flex-wrap: nowrap;
gap: 0;
.upstreams-page {
padding-bottom: 1rem;
}
.upstreams-hero {
padding: 1.35rem;
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);
}
.hero-actions {
align-self: flex-end;
}
.data-stage {
padding: 1rem;
}
.data-stage-head {
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;
}
.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;
}
/* Detail drawer */
.info-cards {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
margin-bottom: 16px;
grid-template-columns: 1fr;
gap: 0.85rem;
margin-bottom: 1rem;
}
.info-card {
background: var(--bg-elevated);
border: 1px solid var(--border-color);
border-radius: 10px;
padding: 12px 14px;
padding: 1rem;
}
.info-label { font-size: 11px; color: var(--text-muted); margin-bottom: 6px; text-transform: uppercase; letter-spacing: 0.5px; }
.info-value { font-size: 14px; font-weight: 500; color: var(--text-primary); }
.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: 8px;
background: rgba(239,68,68,0.08);
border: 1px solid rgba(239,68,68,0.2);
border-radius: 8px;
padding: 10px 14px;
gap: 0.6rem;
margin-bottom: 1rem;
padding: 0.9rem 1rem;
border-radius: var(--radius-control);
color: var(--color-danger);
font-size: 13px;
margin-bottom: 16px;
background: rgba(221, 126, 114, 0.08);
border: 1px solid rgba(221, 126, 114, 0.16);
}
.section-title {
display: flex;
align-items: center;
gap: 6px;
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 12px;
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;
}
.section-sub { font-size: 12px; color: var(--text-muted); font-weight: 400; margin-left: 4px; }
/* Snapshot list */
.snapshot-list {
display: flex;
flex-direction: column;
gap: 6px;
min-height: 60px;
display: grid;
gap: 0.7rem;
min-height: 4rem;
}
.snap-item {
border-radius: var(--radius-control);
border: 1px solid var(--border-color);
border-radius: 8px;
background: rgba(255, 244, 232, 0.02);
overflow: hidden;
transition: border-color 0.15s;
}
.snap-item.expanded { border-color: var(--color-primary); }
.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;
padding: 10px 14px;
gap: 0.8rem;
padding: 0.95rem 1rem;
cursor: pointer;
background: var(--bg-elevated);
transition: background 0.12s;
}
.snap-header:hover { background: var(--bg-surface); }
.snap-left {
display: flex;
align-items: center;
gap: 8px;
}
.expand-icon {
font-size: 12px;
color: var(--text-muted);
transition: transform 0.2s;
}
.snap-item.expanded .expand-icon { transform: rotate(90deg); }
.snap-time { font-size: 13px; color: var(--text-primary); font-weight: 500; font-family: monospace; }
.snap-right { display: flex; align-items: center; gap: 6px; }
.snap-body {
border-top: 1px solid var(--border-color);
background: var(--bg-card);
}
.rate-value { font-weight: 600; color: var(--color-primary); font-family: monospace; }
.override-value { color: var(--color-warning); font-family: monospace; }
.empty-hint {
text-align: center;
padding: 40px;
color: var(--text-muted);
font-size: 13px;
}
.snap-left,
.snap-right,
.snap-pagination {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
padding-top: 14px;
border-top: 1px solid var(--border-color);
margin-top: 12px;
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);
}
.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 (min-width: 1200px) {
.info-cards {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
}
@media (max-width: 767px) {
.upstreams-hero,
.data-stage {
padding: 1rem;
}
.hero-actions {
width: 100%;
}
.hero-actions :deep(.el-button) {
flex: 1 1 100%;
}
}
.page-info { font-size: 13px; color: var(--text-secondary); }
</style>