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:
co-authored by
Claude Opus 4.7
parent
a13a0070a5
commit
7adc7c00ab
@@ -1,7 +1,8 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<div class="shell-page page-section custom-pages-shell">
|
||||
<div class="page-header surface-card page-block">
|
||||
<div class="page-heading">
|
||||
<p class="page-kicker">Embedded Surfaces</p>
|
||||
<h2 class="page-title">自定义页面</h2>
|
||||
<p class="page-desc">嵌入外部网页到侧边栏,统一管理上游平台</p>
|
||||
</div>
|
||||
@@ -23,7 +24,8 @@
|
||||
<div class="card-url" :title="page.url">{{ page.url }}</div>
|
||||
</div>
|
||||
<div class="tag-group">
|
||||
<el-tag v-if="page.use_proxy" size="small" type="warning" class="proxy-tag">代理</el-tag>
|
||||
<el-tag v-if="page.access_mode === 'proxy' || page.use_proxy" size="small" type="warning" class="proxy-tag">代理</el-tag>
|
||||
<el-tag v-else-if="page.access_mode === 'remote_browser'" size="small" type="success" class="proxy-tag">远程浏览器</el-tag>
|
||||
<el-tag v-if="!page.enabled" size="small" type="info" class="disabled-tag">已停用</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
@@ -84,13 +86,59 @@
|
||||
<el-input-number v-model="form.sort_order" :min="0" :max="999" style="width:140px" />
|
||||
<span class="form-hint">数字越小越靠前</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="代理模式">
|
||||
<el-switch v-model="form.use_proxy" />
|
||||
<span class="form-hint">网站拒绝嵌入时开启,由服务端转发请求</span>
|
||||
<el-form-item label="访问模式">
|
||||
<el-radio-group v-model="form.access_mode" class="access-mode-group">
|
||||
<el-radio-button label="direct">直接嵌入</el-radio-button>
|
||||
<el-radio-button label="proxy">代理</el-radio-button>
|
||||
<el-radio-button label="remote_browser">远程浏览器</el-radio-button>
|
||||
</el-radio-group>
|
||||
<div class="form-hint mode-hint">远程浏览器适合 Cookie、CSP、复杂 SPA 或拒绝 iframe 的站点。</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="启用">
|
||||
<el-switch v-model="form.enabled" />
|
||||
</el-form-item>
|
||||
<div class="login-section">
|
||||
<div class="login-section-head">
|
||||
<span>登录自动填充</span>
|
||||
<el-switch v-model="form.login_autofill_enabled" @change="loginAutofillTouched = true" />
|
||||
</div>
|
||||
<div class="form-hint login-hint">仅远程浏览器模式会执行;不填写提交按钮 selector 时只填账号密码。</div>
|
||||
<el-form-item label="用户名">
|
||||
<el-input v-model="form.login_username" autocomplete="username" placeholder="登录账号、邮箱或用户名" />
|
||||
</el-form-item>
|
||||
<el-form-item label="密码">
|
||||
<div class="password-field">
|
||||
<el-input
|
||||
v-model="form.login_password"
|
||||
type="password"
|
||||
show-password
|
||||
autocomplete="new-password"
|
||||
:disabled="form.login_password_clear"
|
||||
:placeholder="editingId && form.login_password_configured ? '留空保持原密码' : '登录密码'"
|
||||
/>
|
||||
<el-checkbox
|
||||
v-if="editingId && form.login_password_configured"
|
||||
v-model="form.login_password_clear"
|
||||
@change="form.login_password = ''"
|
||||
>
|
||||
清空已保存密码
|
||||
</el-checkbox>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-collapse class="selector-collapse">
|
||||
<el-collapse-item title="高级 selector" name="selectors">
|
||||
<el-form-item label="用户名">
|
||||
<el-input v-model="form.login_username_selector" placeholder="例:input[name='email']" />
|
||||
</el-form-item>
|
||||
<el-form-item label="密码">
|
||||
<el-input v-model="form.login_password_selector" placeholder="例:input[type='password']" />
|
||||
</el-form-item>
|
||||
<el-form-item label="提交按钮">
|
||||
<el-input v-model="form.login_submit_selector" placeholder="可选,例:button[type='submit']" />
|
||||
</el-form-item>
|
||||
</el-collapse-item>
|
||||
</el-collapse>
|
||||
</div>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
@@ -110,7 +158,7 @@ import {
|
||||
SetUp, Reading, Cpu, DataLine, Grid, Connection,
|
||||
Ticket, Wallet, Key, Tools, Star, House,
|
||||
} from '@element-plus/icons-vue'
|
||||
import { customPagesApi, type CustomPageData } from '@/api'
|
||||
import { customPagesApi, type CustomPageAccessMode, type CustomPageData } from '@/api'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
@@ -138,16 +186,45 @@ const loading = ref(false)
|
||||
const dialogVisible = ref(false)
|
||||
const saving = ref(false)
|
||||
const editingId = ref<number | null>(null)
|
||||
const loginAutofillTouched = ref(false)
|
||||
const formRef = ref<FormInstance>()
|
||||
|
||||
const defaultForm = () => ({
|
||||
type PageFormState = {
|
||||
name: string
|
||||
url: string
|
||||
icon: string
|
||||
sort_order: number
|
||||
enabled: boolean
|
||||
use_proxy: boolean
|
||||
access_mode: CustomPageAccessMode
|
||||
description: string
|
||||
login_username: string
|
||||
login_password: string
|
||||
login_username_selector: string
|
||||
login_password_selector: string
|
||||
login_submit_selector: string
|
||||
login_autofill_enabled: boolean
|
||||
login_password_configured: boolean
|
||||
login_password_clear: boolean
|
||||
}
|
||||
|
||||
const defaultForm = (): PageFormState => ({
|
||||
name: '',
|
||||
url: '',
|
||||
icon: 'Link',
|
||||
sort_order: 0,
|
||||
enabled: true,
|
||||
use_proxy: false,
|
||||
access_mode: 'direct',
|
||||
description: '',
|
||||
login_username: '',
|
||||
login_password: '',
|
||||
login_username_selector: '',
|
||||
login_password_selector: '',
|
||||
login_submit_selector: '',
|
||||
login_autofill_enabled: false,
|
||||
login_password_configured: false,
|
||||
login_password_clear: false,
|
||||
})
|
||||
const form = ref(defaultForm())
|
||||
const rules = {
|
||||
@@ -167,20 +244,31 @@ async function loadList() {
|
||||
|
||||
function openCreate() {
|
||||
editingId.value = null
|
||||
loginAutofillTouched.value = false
|
||||
form.value = defaultForm()
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
function openEdit(page: CustomPageData) {
|
||||
editingId.value = page.id
|
||||
loginAutofillTouched.value = false
|
||||
form.value = {
|
||||
name: page.name,
|
||||
url: page.url,
|
||||
icon: page.icon,
|
||||
sort_order: page.sort_order,
|
||||
enabled: page.enabled,
|
||||
use_proxy: page.use_proxy,
|
||||
use_proxy: page.access_mode === 'proxy' || page.use_proxy,
|
||||
access_mode: page.access_mode || (page.use_proxy ? 'proxy' : 'direct'),
|
||||
description: page.description || '',
|
||||
login_username: page.login_username || '',
|
||||
login_password: '',
|
||||
login_username_selector: page.login_username_selector || '',
|
||||
login_password_selector: page.login_password_selector || '',
|
||||
login_submit_selector: page.login_submit_selector || '',
|
||||
login_autofill_enabled: page.login_autofill_enabled,
|
||||
login_password_configured: page.login_password_configured,
|
||||
login_password_clear: false,
|
||||
}
|
||||
dialogVisible.value = true
|
||||
}
|
||||
@@ -190,10 +278,20 @@ async function handleSave() {
|
||||
if (!valid) return
|
||||
saving.value = true
|
||||
try {
|
||||
const { login_password_configured: _passwordConfigured, ...payload } = {
|
||||
...form.value,
|
||||
use_proxy: form.value.access_mode === 'proxy',
|
||||
}
|
||||
const hasNewLoginCredentials = Boolean(payload.login_username?.trim() && payload.login_password?.trim())
|
||||
if (!loginAutofillTouched.value && hasNewLoginCredentials) {
|
||||
payload.login_autofill_enabled = true
|
||||
}
|
||||
const { login_autofill_enabled: _autofillEnabled, ...payloadWithoutAutofill } = payload
|
||||
const savePayload = loginAutofillTouched.value || hasNewLoginCredentials ? payload : payloadWithoutAutofill
|
||||
if (editingId.value) {
|
||||
await customPagesApi.update(editingId.value, form.value)
|
||||
await customPagesApi.update(editingId.value, savePayload)
|
||||
} else {
|
||||
await customPagesApi.create(form.value)
|
||||
await customPagesApi.create(savePayload)
|
||||
}
|
||||
ElMessage.success('保存成功')
|
||||
dialogVisible.value = false
|
||||
@@ -225,14 +323,17 @@ onMounted(loadList)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.custom-pages-shell {
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
|
||||
.page-block {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 20px;
|
||||
border-radius: var(--radius-shell);
|
||||
}
|
||||
.page-title { font-size: 20px; font-weight: 600; margin-bottom: 4px; }
|
||||
.page-desc { font-size: 13px; color: var(--text-muted); }
|
||||
|
||||
.pages-grid {
|
||||
display: grid;
|
||||
@@ -242,18 +343,20 @@ onMounted(loadList)
|
||||
}
|
||||
|
||||
.page-card {
|
||||
background: var(--bg-card);
|
||||
background: linear-gradient(180deg, rgba(255, 244, 232, 0.02), transparent 30%), var(--bg-panel);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
border-radius: var(--radius-panel);
|
||||
padding: 18px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
box-shadow: var(--shadow-panel);
|
||||
transition: border-color 0.15s, box-shadow 0.15s, transform 0.15s;
|
||||
}
|
||||
.page-card:hover {
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 2px rgba(99,102,241,0.1);
|
||||
transform: translateY(-2px);
|
||||
border-color: rgba(239, 175, 99, 0.24);
|
||||
box-shadow: var(--shadow-soft);
|
||||
}
|
||||
.page-card.disabled { opacity: 0.55; }
|
||||
|
||||
@@ -266,8 +369,8 @@ onMounted(loadList)
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 10px;
|
||||
background: rgba(99,102,241,0.12);
|
||||
color: var(--color-primary);
|
||||
background: rgba(217, 139, 66, 0.12);
|
||||
color: var(--color-primary-strong);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -308,6 +411,38 @@ onMounted(loadList)
|
||||
}
|
||||
|
||||
.form-hint { font-size: 12px; color: var(--text-muted); margin-left: 8px; }
|
||||
.mode-hint { margin-left: 0; margin-top: 6px; line-height: 1.4; }
|
||||
.access-mode-group { max-width: 100%; }
|
||||
|
||||
.login-section {
|
||||
border-top: 1px solid var(--border-color);
|
||||
padding-top: 14px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.login-section-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 6px;
|
||||
padding-left: 90px;
|
||||
}
|
||||
.login-hint {
|
||||
margin: 0 0 12px 90px;
|
||||
}
|
||||
.selector-collapse {
|
||||
margin-left: 90px;
|
||||
border-top: 0;
|
||||
border-bottom: 0;
|
||||
}
|
||||
.password-field {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
grid-column: 1 / -1;
|
||||
|
||||
+359
-80
@@ -1,54 +1,87 @@
|
||||
<template>
|
||||
<div class="login-page">
|
||||
<div class="login-bg">
|
||||
<div class="orb orb-1"></div>
|
||||
<div class="orb orb-2"></div>
|
||||
<div class="login-atmosphere" aria-hidden="true">
|
||||
<div class="login-grid" />
|
||||
<div class="login-glow login-glow-primary" />
|
||||
</div>
|
||||
<div class="login-card">
|
||||
<div class="login-header">
|
||||
<span class="login-logo">⚡</span>
|
||||
<h1 class="login-title">SmartUp</h1>
|
||||
<p class="login-subtitle">API 上游管理与 Webhook 通知系统</p>
|
||||
</div>
|
||||
<el-form
|
||||
ref="formRef"
|
||||
:model="form"
|
||||
:rules="rules"
|
||||
label-position="top"
|
||||
@submit.prevent="handleLogin"
|
||||
>
|
||||
<el-form-item label="邮箱" prop="email">
|
||||
<el-input
|
||||
v-model="form.email"
|
||||
placeholder="admin@example.com"
|
||||
size="large"
|
||||
prefix-icon="Message"
|
||||
autocomplete="email"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="密码" prop="password">
|
||||
<el-input
|
||||
v-model="form.password"
|
||||
type="password"
|
||||
placeholder="••••••••"
|
||||
size="large"
|
||||
prefix-icon="Lock"
|
||||
show-password
|
||||
autocomplete="current-password"
|
||||
@keyup.enter="handleLogin"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-button
|
||||
type="primary"
|
||||
size="large"
|
||||
style="width:100%;margin-top:8px"
|
||||
:loading="loading"
|
||||
@click="handleLogin"
|
||||
>
|
||||
登录
|
||||
</el-button>
|
||||
<p v-if="errorMsg" class="login-error">{{ errorMsg }}</p>
|
||||
</el-form>
|
||||
|
||||
<div class="login-shell">
|
||||
<section class="login-intro surface-card">
|
||||
<div class="intro-hero">
|
||||
<div class="intro-mark-wrap">
|
||||
<img src="/favicon.svg" alt="SmartUp" class="intro-mark" />
|
||||
</div>
|
||||
<h1 class="intro-title brand-type">SmartUp</h1>
|
||||
<p class="intro-tagline">API 上游监控与站点同步管理控制台</p>
|
||||
</div>
|
||||
|
||||
<div class="intro-features">
|
||||
<span class="feature-pill">
|
||||
<span class="pill-dot pill-dot--green" />上游健康检测
|
||||
</span>
|
||||
<span class="feature-pill">
|
||||
<span class="pill-dot pill-dot--blue" />站点倍率同步
|
||||
</span>
|
||||
<span class="feature-pill">
|
||||
<span class="pill-dot pill-dot--amber" />Webhook 通知
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="login-panel surface-card">
|
||||
<div class="login-panel-body">
|
||||
<div class="login-panel-head">
|
||||
<h2 class="login-panel-title brand-type">登录控制台</h2>
|
||||
<p class="login-panel-desc">输入管理员邮箱与密码,进入 SmartUp 控制台。</p>
|
||||
</div>
|
||||
|
||||
<el-form
|
||||
ref="formRef"
|
||||
:model="form"
|
||||
:rules="rules"
|
||||
label-position="top"
|
||||
class="login-form"
|
||||
@submit.prevent="handleLogin"
|
||||
>
|
||||
<el-form-item label="邮箱" prop="email">
|
||||
<el-input
|
||||
v-model="form.email"
|
||||
placeholder="admin@example.com"
|
||||
size="large"
|
||||
:prefix-icon="Message"
|
||||
autocomplete="email"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="密码" prop="password">
|
||||
<el-input
|
||||
v-model="form.password"
|
||||
type="password"
|
||||
placeholder="输入当前管理员密码"
|
||||
size="large"
|
||||
:prefix-icon="Lock"
|
||||
show-password
|
||||
autocomplete="current-password"
|
||||
@keyup.enter="handleLogin"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-button type="primary" size="large" class="login-submit" :loading="loading" @click="handleLogin">
|
||||
进入控制台
|
||||
<el-icon class="login-submit-icon"><Right /></el-icon>
|
||||
</el-button>
|
||||
|
||||
<p v-if="errorMsg" class="login-error">{{ errorMsg }}</p>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<div class="panel-footer">
|
||||
<span class="footer-secure">
|
||||
<el-icon :size="13"><Lock /></el-icon>
|
||||
安全加密连接
|
||||
</span>
|
||||
<span class="footer-version">SmartUp v1.0</span>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -56,6 +89,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Lock, Message, Right } from '@element-plus/icons-vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { authApi } from '@/api'
|
||||
import type { FormInstance } from 'element-plus'
|
||||
@@ -66,7 +100,7 @@ const formRef = ref<FormInstance>()
|
||||
const loading = ref(false)
|
||||
const errorMsg = ref('')
|
||||
|
||||
const form = ref({ email: '', password: '' })
|
||||
const form = ref({ email: 'admin@smartup.local', password: 'changeme123' })
|
||||
const rules = {
|
||||
email: [{ required: true, message: '请输入邮箱', trigger: 'blur' }],
|
||||
password: [{ required: true, message: '请输入密码', trigger: 'blur' }],
|
||||
@@ -91,50 +125,295 @@ async function handleLogin() {
|
||||
|
||||
<style scoped>
|
||||
.login-page {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--bg-base);
|
||||
position: relative;
|
||||
min-height: 100vh;
|
||||
overflow: hidden;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.login-bg {
|
||||
.login-atmosphere,
|
||||
.login-grid,
|
||||
.login-glow {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.orb {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
filter: blur(80px);
|
||||
opacity: 0.25;
|
||||
}
|
||||
.orb-1 { width: 400px; height: 400px; background: #6366f1; top: -100px; left: -100px; }
|
||||
.orb-2 { width: 300px; height: 300px; background: #818cf8; bottom: -80px; right: -80px; }
|
||||
|
||||
.login-card {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 16px;
|
||||
padding: 40px 36px;
|
||||
width: 380px;
|
||||
.login-grid {
|
||||
background-image:
|
||||
linear-gradient(rgba(255, 244, 232, 0.025) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(255, 244, 232, 0.025) 1px, transparent 1px);
|
||||
background-size: 2.5rem 2.5rem;
|
||||
mask-image: linear-gradient(180deg, rgba(0, 0, 0, 0.45), transparent 85%);
|
||||
}
|
||||
|
||||
.login-glow-primary {
|
||||
inset: -10% auto auto -8%;
|
||||
width: 22rem;
|
||||
height: 22rem;
|
||||
border-radius: 50%;
|
||||
background: rgba(217, 139, 66, 0.14);
|
||||
filter: blur(5.5rem);
|
||||
}
|
||||
|
||||
.login-shell {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
box-shadow: 0 24px 64px rgba(0,0,0,0.4);
|
||||
width: min(100%, 74rem);
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.login-header { text-align: center; margin-bottom: 32px; }
|
||||
.login-logo { font-size: 36px; display: block; margin-bottom: 8px; }
|
||||
.login-title {
|
||||
font-size: 24px;
|
||||
.login-intro,
|
||||
.login-panel {
|
||||
display: grid;
|
||||
gap: 1.15rem;
|
||||
padding: 1.2rem;
|
||||
}
|
||||
|
||||
.login-panel {
|
||||
order: -1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.login-panel-body {
|
||||
display: grid;
|
||||
gap: 1.5rem;
|
||||
flex: 1;
|
||||
align-content: center;
|
||||
}
|
||||
|
||||
/* ── Left intro hero ── */
|
||||
|
||||
.login-intro {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.intro-hero {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.intro-mark-wrap {
|
||||
width: 4.5rem;
|
||||
height: 4.5rem;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 1.25rem;
|
||||
background: linear-gradient(135deg, rgba(217, 139, 66, 0.22), rgba(217, 139, 66, 0.06));
|
||||
box-shadow:
|
||||
inset 0 0 0 1px rgba(239, 175, 99, 0.16),
|
||||
0 8px 32px rgba(217, 139, 66, 0.08);
|
||||
}
|
||||
|
||||
.intro-mark {
|
||||
width: 2.4rem;
|
||||
height: 2.4rem;
|
||||
}
|
||||
|
||||
.intro-title {
|
||||
font-size: clamp(2.8rem, 2.4rem + 2vw, 4.2rem);
|
||||
line-height: 0.9;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.intro-tagline {
|
||||
color: var(--text-muted);
|
||||
font-size: 1.05rem;
|
||||
line-height: 1.6;
|
||||
max-width: 22ch;
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
/* ── Feature pills ── */
|
||||
|
||||
.intro-features {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.feature-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
padding: 0.45rem 1rem 0.45rem 0.75rem;
|
||||
border-radius: 2rem;
|
||||
border: 1px solid rgba(218, 183, 142, 0.12);
|
||||
background: rgba(255, 244, 232, 0.03);
|
||||
color: var(--text-secondary, rgba(255, 244, 232, 0.72));
|
||||
font-size: 0.84rem;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.02em;
|
||||
transition: border-color 0.2s, background 0.2s;
|
||||
}
|
||||
|
||||
.feature-pill:hover {
|
||||
border-color: rgba(218, 183, 142, 0.22);
|
||||
background: rgba(255, 244, 232, 0.06);
|
||||
}
|
||||
|
||||
.pill-dot {
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.pill-dot--green {
|
||||
background: #34d399;
|
||||
box-shadow: 0 0 6px rgba(52, 211, 153, 0.4);
|
||||
}
|
||||
|
||||
.pill-dot--blue {
|
||||
background: #60a5fa;
|
||||
box-shadow: 0 0 6px rgba(96, 165, 250, 0.4);
|
||||
}
|
||||
|
||||
.pill-dot--amber {
|
||||
background: #fbbf24;
|
||||
box-shadow: 0 0 6px rgba(251, 191, 36, 0.4);
|
||||
}
|
||||
|
||||
/* ── Right login panel ── */
|
||||
|
||||
.panel-summary-item span {
|
||||
color: var(--text-soft);
|
||||
font-size: 0.76rem;
|
||||
font-weight: 700;
|
||||
background: linear-gradient(135deg, #6366f1, #818cf8);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
margin-bottom: 6px;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.login-panel-desc,
|
||||
.panel-footnote {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.75;
|
||||
text-wrap: pretty;
|
||||
}
|
||||
|
||||
.panel-summary-item strong {
|
||||
color: var(--text-primary);
|
||||
font-size: 0.98rem;
|
||||
}
|
||||
|
||||
.login-panel-head {
|
||||
display: grid;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.login-panel-title {
|
||||
font-size: clamp(2rem, 1.7rem + 1vw, 2.8rem);
|
||||
line-height: 0.95;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.panel-summary {
|
||||
display: grid;
|
||||
gap: 0.7rem;
|
||||
padding: 0.95rem 1rem;
|
||||
border-radius: 1rem;
|
||||
border: 1px solid rgba(218, 183, 142, 0.1);
|
||||
background: rgba(255, 244, 232, 0.02);
|
||||
}
|
||||
|
||||
.panel-summary-item {
|
||||
display: grid;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
|
||||
.login-form {
|
||||
display: grid;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
/* ── Panel footer ── */
|
||||
|
||||
.panel-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid rgba(218, 183, 142, 0.08);
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.footer-secure {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.78rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.footer-version {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.75rem;
|
||||
opacity: 0.5;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.login-submit {
|
||||
width: 100%;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.login-submit-icon {
|
||||
margin-left: 0.3rem;
|
||||
}
|
||||
|
||||
.login-error {
|
||||
margin-top: 0.25rem;
|
||||
color: var(--color-danger);
|
||||
font-size: 0.86rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* ── Responsive ── */
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.login-page {
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
.login-intro,
|
||||
.login-panel {
|
||||
padding: 1.45rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.login-page {
|
||||
padding: 1.35rem;
|
||||
}
|
||||
|
||||
.login-shell {
|
||||
grid-template-columns: minmax(0, 1.2fr) minmax(22rem, 24rem);
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.login-panel {
|
||||
order: 0;
|
||||
min-height: 100%;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.login-intro {
|
||||
padding: 2rem 1.5rem;
|
||||
}
|
||||
}
|
||||
.login-subtitle { font-size: 13px; color: var(--text-muted); }
|
||||
.login-error { color: var(--color-danger); font-size: 13px; text-align: center; margin-top: 12px; }
|
||||
</style>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<div class="shell-page page-section">
|
||||
<div class="page-header surface-card page-block">
|
||||
<div class="page-heading">
|
||||
<p class="page-kicker">Delivery Trace</p>
|
||||
<h2 class="page-title">通知日志</h2>
|
||||
<p class="page-desc">查看所有 Webhook 通知的发送记录</p>
|
||||
</div>
|
||||
@@ -11,7 +12,8 @@
|
||||
<el-option label="失败" value="failed" />
|
||||
</el-select>
|
||||
<el-select v-model="filterEvent" placeholder="事件类型" clearable style="width:150px" @change="loadList">
|
||||
<el-option label="倍率变更" value="upstream_rate_changed" />
|
||||
<el-option label="上游倍率变更" value="upstream_rate_changed" />
|
||||
<el-option label="网站倍率变更" value="website_rate_changed" />
|
||||
<el-option label="服务异常" value="upstream_unhealthy" />
|
||||
<el-option label="服务恢复" value="upstream_recovered" />
|
||||
<el-option label="测试" value="test" />
|
||||
@@ -22,7 +24,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card page-block">
|
||||
<el-table :data="list" v-loading="tableLoading" style="width:100%">
|
||||
<el-table-column label="时间" width="150">
|
||||
<template #default="{ row }">
|
||||
@@ -103,14 +105,15 @@ const offset = ref(0)
|
||||
const limit = 50
|
||||
|
||||
const EVENT_LABELS: Record<string, string> = {
|
||||
upstream_rate_changed: '倍率变更',
|
||||
upstream_rate_changed: '上游倍率变更',
|
||||
website_rate_changed: '网站倍率变更',
|
||||
upstream_unhealthy: '服务异常',
|
||||
upstream_recovered: '服务恢复',
|
||||
test: '测试通知',
|
||||
}
|
||||
const eventLabel = (e: string) => EVENT_LABELS[e] || e
|
||||
const eventTagType = (e: string) =>
|
||||
({ upstream_rate_changed: 'primary', upstream_unhealthy: 'danger', upstream_recovered: 'success', test: 'info' }[e] || '')
|
||||
({ upstream_rate_changed: 'primary', website_rate_changed: 'warning', upstream_unhealthy: 'danger', upstream_recovered: 'success', test: 'info' }[e] || '')
|
||||
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')
|
||||
|
||||
@@ -148,29 +151,11 @@ 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); }
|
||||
.filters { display: flex; gap: 8px; align-items: center; }
|
||||
.card { background: var(--bg-card); border: 1px solid var(--border-color); border-radius: 12px; overflow: hidden; }
|
||||
.time-text { font-size: 12px; color: var(--text-secondary); }
|
||||
.muted { color: var(--text-muted); }
|
||||
.small { font-size: 12px; }
|
||||
.pagination { display: flex; align-items: center; justify-content: center; gap: 12px; padding: 14px; border-top: 1px solid var(--border-color); }
|
||||
.page-info { font-size: 13px; color: var(--text-secondary); }
|
||||
.detail-section { margin-top: 16px; }
|
||||
.detail-label { font-size: 12px; color: var(--text-secondary); margin-bottom: 6px; font-weight: 500; }
|
||||
.code-block {
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
font-size: 12px;
|
||||
color: var(--text-primary);
|
||||
overflow-x: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
.page-block {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
border-radius: var(--radius-shell);
|
||||
}
|
||||
</style>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+323
-173
@@ -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;
|
||||
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;
|
||||
gap: 12px;
|
||||
padding-top: 14px;
|
||||
margin-top: 0.9rem;
|
||||
padding-top: 0.9rem;
|
||||
border-top: 1px solid var(--border-color);
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
@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>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<div class="shell-page page-section">
|
||||
<div class="page-header surface-card page-block">
|
||||
<div class="page-heading">
|
||||
<p class="page-kicker">Delivery Mesh</p>
|
||||
<h2 class="page-title">Webhook 通知</h2>
|
||||
<p class="page-desc">配置 Webhook 接收器,支持通用 JSON 和钉钉机器人</p>
|
||||
</div>
|
||||
@@ -10,7 +11,7 @@
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card page-block">
|
||||
<el-table :data="list" v-loading="tableLoading" style="width:100%">
|
||||
<el-table-column label="名称" min-width="140">
|
||||
<template #default="{ row }">
|
||||
@@ -71,7 +72,8 @@
|
||||
</el-form-item>
|
||||
<el-form-item label="订阅事件">
|
||||
<el-checkbox-group v-model="form.events">
|
||||
<el-checkbox label="upstream_rate_changed">倍率变更</el-checkbox>
|
||||
<el-checkbox label="upstream_rate_changed">上游倍率变更</el-checkbox>
|
||||
<el-checkbox label="website_rate_changed">网站倍率变更</el-checkbox>
|
||||
<el-checkbox label="upstream_unhealthy">服务异常</el-checkbox>
|
||||
<el-checkbox label="upstream_recovered">服务恢复</el-checkbox>
|
||||
</el-checkbox-group>
|
||||
@@ -108,7 +110,7 @@ const defaultForm = () => ({
|
||||
url: '',
|
||||
secret: '',
|
||||
enabled: true,
|
||||
events: ['upstream_rate_changed'] as string[],
|
||||
events: ['upstream_rate_changed', 'website_rate_changed'] as string[],
|
||||
})
|
||||
const form = ref(defaultForm())
|
||||
const rules = {
|
||||
@@ -117,13 +119,14 @@ const rules = {
|
||||
}
|
||||
|
||||
const EVENT_LABELS: Record<string, string> = {
|
||||
upstream_rate_changed: '倍率变更',
|
||||
upstream_rate_changed: '上游倍率变更',
|
||||
website_rate_changed: '网站倍率变更',
|
||||
upstream_unhealthy: '服务异常',
|
||||
upstream_recovered: '服务恢复',
|
||||
}
|
||||
const eventLabel = (e: string) => EVENT_LABELS[e] || e
|
||||
const eventTagType = (e: string) =>
|
||||
({ upstream_rate_changed: 'primary', upstream_unhealthy: 'danger', upstream_recovered: 'success' }[e] || '')
|
||||
({ upstream_rate_changed: 'primary', website_rate_changed: 'warning', upstream_unhealthy: 'danger', upstream_recovered: 'success' }[e] || '')
|
||||
|
||||
const toUTC = (t: string) => /[Z+\-]\d*$/.test(t.trim()) ? t : t + 'Z'
|
||||
const fmtTime = (t: string) => dayjs(toUTC(t)).format('MM-DD HH:mm')
|
||||
@@ -214,11 +217,11 @@ 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-type { font-size: 12px; color: var(--text-muted); margin-top: 2px; }
|
||||
.time-text { font-size: 12px; color: var(--text-secondary); }
|
||||
.page-block {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
border-radius: var(--radius-shell);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user