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
+704
View File
@@ -0,0 +1,704 @@
<template>
<div class="shell-page page-section websites-page">
<div class="page-header surface-card page-block">
<div class="page-heading">
<p class="page-kicker">Sync Orchestration</p>
<h2 class="page-title">网站管理</h2>
<p class="page-desc">管理自己的 sub2api 网站并把网站分组倍率同步到上游监听结果</p>
</div>
<el-button type="primary" @click="openWebsiteCreate">
<el-icon><Plus /></el-icon> 新增网站
</el-button>
</div>
<div class="panel">
<div class="panel-head">
<div class="panel-title">网站</div>
<el-button size="small" text @click="loadAll">刷新</el-button>
</div>
<el-table :data="websites" v-loading="websiteLoading" row-key="id" style="width:100%">
<el-table-column label="名称" min-width="180">
<template #default="{ row }">
<div class="cell-name">{{ row.name }}</div>
<div class="cell-url">{{ row.base_url }}</div>
</template>
</el-table-column>
<el-table-column label="状态" width="110">
<template #default="{ row }">
<span :class="['status-badge', row.last_status]"><span class="dot" />{{ statusLabel(row.last_status) }}</span>
</template>
</el-table-column>
<el-table-column label="自动同步" width="100">
<template #default="{ row }">
<el-switch v-model="row.auto_sync_enabled" @change="toggleWebsiteSync(row)" />
</template>
</el-table-column>
<el-table-column label="超时" width="80">
<template #default="{ row }">{{ row.timeout_seconds }}s</template>
</el-table-column>
<el-table-column label="最近错误" min-width="180">
<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, 42) }}</span>
</el-tooltip>
<span v-else class="muted"></span>
</template>
</el-table-column>
<el-table-column label="操作" width="200">
<template #default="{ row }">
<div class="action-row">
<el-tooltip content="查看分组" placement="top" :show-after="300">
<el-button size="small" circle text @click="selectWebsite(row)">
<el-icon><Grid /></el-icon>
</el-button>
</el-tooltip>
<el-tooltip content="编辑" placement="top" :show-after="300">
<el-button size="small" circle text @click="openWebsiteEdit(row)">
<el-icon><Edit /></el-icon>
</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-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-icon><Link /></el-icon>
</el-button>
</el-tooltip>
<el-tooltip content="删除" placement="top" :show-after="300">
<el-button size="small" circle text type="danger" @click="deleteWebsite(row)">
<el-icon><Delete /></el-icon>
</el-button>
</el-tooltip>
</div>
</template>
</el-table-column>
</el-table>
</div>
<div class="content-grid">
<div class="panel">
<div class="panel-head">
<div>
<div class="panel-title">我的网站分组</div>
<div class="panel-sub">{{ selectedWebsite?.name || '请选择网站' }}</div>
</div>
<el-button size="small" :disabled="!selectedWebsite" :loading="groupsLoading" @click="loadWebsiteGroups">拉取分组</el-button>
</div>
<el-table :data="websiteGroups" v-loading="groupsLoading" row-key="id" size="small" style="width:100%">
<el-table-column prop="name" label="分组" min-width="150" />
<el-table-column prop="id" label="ID" min-width="130" />
<el-table-column label="倍率" width="100">
<template #default="{ row }"><span class="rate-value">{{ row.rate_multiplier ?? '—' }}</span></template>
</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>
</template>
</el-table-column>
</el-table>
</div>
<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>
</div>
<div class="binding-list" v-loading="bindingLoading">
<div v-for="binding in bindings" :key="binding.id" class="binding-item">
<div class="binding-main">
<div class="binding-title">{{ binding.website_name }} / {{ binding.target_group_name || binding.target_group_id }}</div>
<div class="binding-meta">
{{ algorithmLabel(binding.algorithm) }} + {{ binding.percent }}% ·
{{ binding.source_groups.length }} 个上游分组
</div>
</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 @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>
</div>
<div v-if="!bindingLoading && bindings.length === 0" class="empty-hint">暂无绑定</div>
</div>
</div>
</div>
<div class="panel">
<div class="panel-head">
<div class="panel-title">最近同步结果</div>
<el-button size="small" text @click="loadLogs">刷新</el-button>
</div>
<el-table :data="logs" v-loading="logLoading" row-key="id" size="small" style="width:100%">
<el-table-column label="时间" width="150">
<template #default="{ row }">{{ fmtTime(row.created_at) }}</template>
</el-table-column>
<el-table-column label="目标分组" min-width="160">
<template #default="{ row }">{{ row.target_group_name || row.target_group_id }}</template>
</el-table-column>
<el-table-column label="倍率" width="130">
<template #default="{ row }">
<span class="rate-value">{{ row.old_rate ?? '—' }} {{ row.new_rate ?? '—' }}</span>
</template>
</el-table-column>
<el-table-column label="状态" width="90">
<template #default="{ row }">
<el-tag size="small" :type="row.status === 'success' ? 'success' : 'danger'">{{ row.status === 'success' ? '成功' : '失败' }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="message" label="结果" min-width="220" />
</el-table>
</div>
<el-drawer v-model="websiteDrawer" :title="editingWebsiteId ? '编辑网站' : '新增网站'" size="460px" destroy-on-close>
<el-form ref="websiteFormRef" :model="websiteForm" :rules="websiteRules" label-position="top">
<el-form-item label="名称" prop="name"><el-input v-model="websiteForm.name" placeholder="我的 sub2api" /></el-form-item>
<el-form-item label="Base URL" prop="base_url"><el-input v-model="websiteForm.base_url" placeholder="https://sub2api.example.com" /></el-form-item>
<el-form-item label="API Prefix"><el-input v-model="websiteForm.api_prefix" placeholder="/api/v1/admin" /></el-form-item>
<el-form-item label="认证方式">
<el-select v-model="websiteForm.auth_type" style="width:100%">
<el-option label="x-api-key" value="api_key" />
<el-option label="Bearer JWT" value="bearer" />
</el-select>
</el-form-item>
<template v-if="websiteForm.auth_type === 'api_key'">
<el-form-item label="Admin API Key"><el-input v-model="websiteForm.auth_config.key" type="password" show-password placeholder="admin-..." /></el-form-item>
<el-form-item label="Header"><el-input v-model="websiteForm.auth_config.header" placeholder="x-api-key" /></el-form-item>
</template>
<template v-else>
<el-form-item label="JWT"><el-input v-model="websiteForm.auth_config.token" type="password" show-password placeholder="***" /></el-form-item>
</template>
<el-row :gutter="12">
<el-col :span="12"><el-form-item label="分组接口"><el-input v-model="websiteForm.groups_endpoint" /></el-form-item></el-col>
<el-col :span="12"><el-form-item label="更新接口"><el-input v-model="websiteForm.group_update_endpoint" /></el-form-item></el-col>
</el-row>
<el-form-item label="超时(秒)"><el-input-number v-model="websiteForm.timeout_seconds" :min="5" :max="120" style="width:100%" /></el-form-item>
<div class="switch-line">
<span>启用网站</span><el-switch v-model="websiteForm.enabled" />
</div>
<div class="switch-line">
<span>启用自动同步</span><el-switch v-model="websiteForm.auto_sync_enabled" />
</div>
</el-form>
<template #footer>
<el-button @click="websiteDrawer = false">取消</el-button>
<el-button type="primary" :loading="savingWebsite" @click="saveWebsite">保存</el-button>
</template>
</el-drawer>
<el-drawer v-model="bindingDrawer" :title="editingBindingId ? '编辑绑定' : '新增绑定'" size="560px" destroy-on-close>
<el-form ref="bindingFormRef" :model="bindingForm" :rules="bindingRules" label-position="top">
<el-form-item label="目标网站">
<el-select v-model="bindingForm.website_id" style="width:100%" @change="onBindingWebsiteChange">
<el-option v-for="site in websites" :key="site.id" :label="site.name" :value="site.id" />
</el-select>
</el-form-item>
<el-form-item label="目标分组" prop="target_group_id">
<el-select v-model="bindingForm.target_group_id" filterable style="width:100%" @change="onTargetGroupChange">
<el-option v-for="group in bindingWebsiteGroups" :key="group.id" :label="`${group.name} (${group.rate_multiplier ?? '—'})`" :value="group.id" />
</el-select>
</el-form-item>
<el-form-item label="监听上游分组" prop="source_group_keys">
<el-select v-model="sourceGroupKeys" multiple filterable style="width:100%" placeholder="选择一个或多个上游分组" @change="onSourceGroupsChange">
<el-option v-for="item in upstreamGroupOptions" :key="item.key" :label="item.label" :value="item.key" />
</el-select>
</el-form-item>
<el-row :gutter="12">
<el-col :span="12">
<el-form-item label="算法">
<el-select v-model="bindingForm.algorithm" style="width:100%">
<el-option label="最高倍率 + 百分比" value="max_plus_percent" />
<el-option label="平均倍率 + 百分比" value="average_plus_percent" />
<el-option label="最低倍率 + 百分比" value="min_plus_percent" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="百分比">
<el-input-number v-model="bindingForm.percent" :min="0" :precision="2" style="width:100%" />
</el-form-item>
</el-col>
</el-row>
<el-form-item label="启用"><el-switch v-model="bindingForm.enabled" /></el-form-item>
</el-form>
<template #footer>
<el-button @click="bindingDrawer = false">取消</el-button>
<el-button type="primary" :loading="savingBinding" @click="saveBinding">保存</el-button>
</template>
</el-drawer>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import type { FormInstance } from 'element-plus'
import dayjs from 'dayjs'
import { Delete, Edit, Plus, Grid, Connection, Link } from '@element-plus/icons-vue'
import {
upstreamsApi,
websitesApi,
type BindingSourceGroup,
type GroupBindingData,
type GroupBindingForm,
type UpstreamData,
type WebsiteData,
type WebsiteForm,
type WebsiteGroup,
type WebsiteSyncLog,
} from '@/api'
const websites = ref<(WebsiteData & { _testing?: boolean })[]>([])
const upstreams = ref<UpstreamData[]>([])
const selectedWebsite = ref<WebsiteData | null>(null)
const websiteGroups = ref<WebsiteGroup[]>([])
const bindingWebsiteGroups = ref<WebsiteGroup[]>([])
const bindings = ref<(GroupBindingData & { _syncing?: boolean })[]>([])
const logs = ref<WebsiteSyncLog[]>([])
const snapshotsByUpstream = ref<Record<number, any[]>>({})
const websiteLoading = ref(false)
const groupsLoading = ref(false)
const bindingLoading = ref(false)
const logLoading = ref(false)
const statusLabel = (s: string) => ({ healthy: '健康', unhealthy: '异常', unknown: '未知' }[s] || s)
const algorithmLabel = (s: string) => ({ max_plus_percent: '最高倍率', average_plus_percent: '平均倍率', min_plus_percent: '最低倍率' }[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')
function defaultWebsiteForm(): WebsiteForm {
return {
name: '',
site_type: 'sub2api',
base_url: '',
api_prefix: '/api/v1/admin',
auth_type: 'api_key',
auth_config: { key: '', header: 'x-api-key' },
groups_endpoint: '/groups',
group_update_endpoint: '/groups/{id}',
enabled: true,
auto_sync_enabled: true,
timeout_seconds: 30,
}
}
const websiteDrawer = ref(false)
const savingWebsite = ref(false)
const editingWebsiteId = ref<number | null>(null)
const websiteFormRef = ref<FormInstance>()
const websiteForm = ref<WebsiteForm>(defaultWebsiteForm())
const websiteRules = {
name: [{ required: true, message: '请输入名称', trigger: 'blur' }],
base_url: [{ required: true, message: '请输入 Base URL', trigger: 'blur' }],
}
function defaultBindingForm(): GroupBindingForm {
return {
website_id: websites.value[0]?.id ?? 0,
target_group_id: '',
target_group_name: '',
source_groups: [],
percent: 0,
algorithm: 'max_plus_percent',
enabled: true,
}
}
const bindingDrawer = ref(false)
const savingBinding = ref(false)
const editingBindingId = ref<number | null>(null)
const bindingFormRef = ref<FormInstance>()
const bindingForm = ref<GroupBindingForm>(defaultBindingForm())
const sourceGroupKeys = ref<string[]>([])
const bindingRules = {
target_group_id: [{ required: true, message: '请选择目标分组', trigger: 'change' }],
}
const upstreamGroupOptions = computed(() => {
const rows: Array<{ key: string; label: string; source: BindingSourceGroup }> = []
for (const upstream of upstreams.value) {
const groups = snapshotsByUpstream.value[upstream.id] || []
for (const group of groups) {
const source = {
upstream_id: upstream.id,
upstream_name: upstream.name,
group_id: group.group_id,
group_name: group.group_name || group.group_id,
}
rows.push({
key: `${upstream.id}::${group.group_id}`,
label: `${upstream.name} / ${source.group_name} (${group.rate || '—'})`,
source,
})
}
}
return rows
})
async function loadWebsites() {
websiteLoading.value = true
try {
const res = await websitesApi.list()
websites.value = res.data
if (!selectedWebsite.value && res.data.length > 0) selectedWebsite.value = res.data[0]
} finally {
websiteLoading.value = false
}
}
async function loadUpstreamGroups() {
const upstreamRes = await upstreamsApi.list()
upstreams.value = upstreamRes.data
const entries: Record<number, any[]> = {}
await Promise.all(upstreams.value.map(async (upstream) => {
try {
const res = await upstreamsApi.latestSnapshot(upstream.id)
entries[upstream.id] = Object.values(res.data.snapshot?.groups || {})
} catch {
entries[upstream.id] = []
}
}))
snapshotsByUpstream.value = entries
}
async function loadWebsiteGroups() {
if (!selectedWebsite.value) return
groupsLoading.value = true
try {
const res = await websitesApi.groups(selectedWebsite.value.id)
websiteGroups.value = res.data
} catch (e: any) {
ElMessage.error(e.response?.data?.detail || '拉取分组失败')
} finally {
groupsLoading.value = false
}
}
async function loadBindingWebsiteGroups(websiteId: number) {
if (!websiteId) {
bindingWebsiteGroups.value = []
return
}
try {
const res = await websitesApi.groups(websiteId)
bindingWebsiteGroups.value = res.data
} catch {
bindingWebsiteGroups.value = []
}
}
async function loadBindings() {
bindingLoading.value = true
try {
const res = await websitesApi.listBindings()
bindings.value = res.data
} finally {
bindingLoading.value = false
}
}
async function loadLogs() {
logLoading.value = true
try {
const res = await websitesApi.logs({ limit: 50 })
logs.value = res.data
} finally {
logLoading.value = false
}
}
async function loadAll() {
await Promise.all([loadWebsites(), loadUpstreamGroups(), loadBindings(), loadLogs()])
if (selectedWebsite.value) await loadWebsiteGroups()
}
function selectWebsite(row: WebsiteData) {
selectedWebsite.value = row
loadWebsiteGroups()
}
function openWebsiteCreate() {
editingWebsiteId.value = null
websiteForm.value = defaultWebsiteForm()
websiteDrawer.value = true
}
function openWebsiteEdit(row: WebsiteData) {
editingWebsiteId.value = row.id
websiteForm.value = {
name: row.name,
site_type: row.site_type,
base_url: row.base_url,
api_prefix: row.api_prefix,
auth_type: row.auth_type,
auth_config: { ...row.auth_config_masked },
groups_endpoint: row.groups_endpoint,
group_update_endpoint: row.group_update_endpoint,
enabled: row.enabled,
auto_sync_enabled: row.auto_sync_enabled,
timeout_seconds: row.timeout_seconds,
}
websiteDrawer.value = true
}
async function saveWebsite() {
const valid = await websiteFormRef.value?.validate().catch(() => false)
if (!valid) return
savingWebsite.value = true
try {
if (editingWebsiteId.value) {
await websitesApi.update(editingWebsiteId.value, websiteForm.value)
ElMessage.success('保存成功')
} else {
await websitesApi.create(websiteForm.value)
ElMessage.success('创建成功')
}
websiteDrawer.value = false
await loadWebsites()
} catch (e: any) {
ElMessage.error(e.response?.data?.detail || '保存失败')
} finally {
savingWebsite.value = false
}
}
async function toggleWebsiteSync(row: WebsiteData) {
try {
await websitesApi.update(row.id, { auto_sync_enabled: row.auto_sync_enabled })
ElMessage.success(row.auto_sync_enabled ? '已启用自动同步' : '已停用自动同步')
} catch {
row.auto_sync_enabled = !row.auto_sync_enabled
ElMessage.error('操作失败')
}
}
async function testWebsite(row: WebsiteData & { _testing?: boolean }) {
row._testing = true
try {
const res = await websitesApi.test(row.id)
ElMessage[res.data.success ? 'success' : 'error'](res.data.detail || res.data.message)
await loadWebsites()
} finally {
row._testing = false
}
}
async function deleteWebsite(row: WebsiteData) {
try {
await ElMessageBox.confirm(`确认删除网站 "${row.name}"`, '删除确认', { type: 'warning' })
await websitesApi.delete(row.id)
ElMessage.success('已删除')
selectedWebsite.value = null
websiteGroups.value = []
await loadAll()
} catch {}
}
async function openBindingCreate(site?: WebsiteData | null, target?: WebsiteGroup) {
editingBindingId.value = null
bindingForm.value = defaultBindingForm()
if (site) bindingForm.value.website_id = site.id
await loadBindingWebsiteGroups(bindingForm.value.website_id)
if (target) {
bindingForm.value.target_group_id = target.id
bindingForm.value.target_group_name = target.name
}
sourceGroupKeys.value = []
bindingDrawer.value = true
}
async function openBindingEdit(row: GroupBindingData) {
editingBindingId.value = row.id
bindingForm.value = {
website_id: row.website_id,
target_group_id: row.target_group_id,
target_group_name: row.target_group_name,
source_groups: [...row.source_groups],
percent: row.percent,
algorithm: row.algorithm,
enabled: row.enabled,
}
sourceGroupKeys.value = row.source_groups.map(item => `${item.upstream_id}::${item.group_id}`)
await loadBindingWebsiteGroups(row.website_id)
bindingDrawer.value = true
}
async function onBindingWebsiteChange(value: number) {
bindingForm.value.target_group_id = ''
bindingForm.value.target_group_name = ''
await loadBindingWebsiteGroups(value)
}
function onTargetGroupChange(value: string) {
const group = bindingWebsiteGroups.value.find(item => item.id === value)
bindingForm.value.target_group_name = group?.name || value
}
function onSourceGroupsChange(values: string[]) {
const options = new Map(upstreamGroupOptions.value.map(item => [item.key, item.source]))
bindingForm.value.source_groups = values.map(value => options.get(value)).filter((item): item is BindingSourceGroup => Boolean(item))
}
async function saveBinding() {
if (!bindingForm.value.source_groups.length) {
ElMessage.error('请选择至少一个监听上游分组')
return
}
const valid = await bindingFormRef.value?.validate().catch(() => false)
if (!valid) return
savingBinding.value = true
try {
if (editingBindingId.value) {
await websitesApi.updateBinding(editingBindingId.value, bindingForm.value)
} else {
await websitesApi.createBinding(bindingForm.value)
}
ElMessage.success('保存成功,已尝试同步')
bindingDrawer.value = false
await Promise.all([loadBindings(), loadLogs()])
if (selectedWebsite.value?.id === bindingForm.value.website_id) await loadWebsiteGroups()
} catch (e: any) {
ElMessage.error(e.response?.data?.detail || '保存失败')
} finally {
savingBinding.value = false
}
}
async function toggleBinding(row: GroupBindingData) {
try {
await websitesApi.updateBinding(row.id, { enabled: row.enabled })
ElMessage.success(row.enabled ? '已启用绑定' : '已停用绑定')
} catch {
row.enabled = !row.enabled
ElMessage.error('操作失败')
}
}
async function syncBinding(row: GroupBindingData & { _syncing?: boolean }) {
row._syncing = true
try {
const res = await websitesApi.syncNow(row.id)
ElMessage[res.data.status === 'success' ? 'success' : 'error'](res.data.message)
await loadLogs()
if (selectedWebsite.value?.id === row.website_id) await loadWebsiteGroups()
} finally {
row._syncing = false
}
}
async function deleteBinding(row: GroupBindingData) {
try {
await ElMessageBox.confirm('确认删除该绑定?', '删除确认', { type: 'warning' })
await websitesApi.deleteBinding(row.id)
ElMessage.success('已删除')
await loadBindings()
} catch {}
}
onMounted(loadAll)
</script>
<style scoped>
.websites-page { display: flex; flex-direction: column; gap: 18px; padding-bottom: 1rem; }
.page-block {
padding: 1rem;
}
.page-header {
border-radius: var(--radius-shell);
}
.panel-head {
min-height: 48px;
padding: 12px 14px;
border-bottom: 1px solid var(--border-color);
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.panel-title { font-size: 14px; font-weight: 600; color: var(--text-primary); }
.panel-sub { font-size: 12px; color: var(--text-muted); margin-top: 2px; }
.content-grid {
display: grid;
grid-template-columns: 1fr;
gap: 18px;
}
.cell-name { font-weight: 500; font-size: 14px; }
.cell-url { font-size: 12px; color: var(--text-muted); margin-top: 2px; overflow-wrap: anywhere; }
.muted { color: var(--text-muted); font-size: 12px; }
.error-text { font-size: 12px; color: var(--color-danger); cursor: pointer; }
.rate-value { font-weight: 600; color: var(--color-primary-strong); font-family: monospace; }
.action-row {
display: flex;
align-items: center;
flex-wrap: nowrap;
gap: 4px;
}
.action-row .el-button.is-circle {
width: 28px;
height: 28px;
}
.binding-actions {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 4px;
}
.binding-list { min-height: 120px; }
.binding-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px 14px;
border-bottom: 1px solid var(--border-color);
}
.binding-item:last-child { border-bottom: none; }
.binding-main { min-width: 0; }
.binding-title {
color: var(--text-primary);
font-size: 13px;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.binding-meta { color: var(--text-muted); font-size: 12px; margin-top: 3px; }
.empty-hint {
text-align: center;
padding: 36px 16px;
color: var(--text-muted);
font-size: 13px;
}
.switch-line {
min-height: 36px;
display: flex;
align-items: center;
justify-content: space-between;
color: var(--text-secondary);
font-size: 13px;
}
@media (min-width: 1024px) {
.content-grid { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); }
}
@media (max-width: 768px) {
.page-header {
flex-direction: column;
align-items: stretch;
}
.page-header .el-button { min-height: 44px; }
.binding-item {
align-items: flex-start;
flex-direction: column;
}
.binding-actions { width: 100%; justify-content: flex-end; }
}
</style>