Initial commit

This commit is contained in:
liumangmang
2026-05-12 17:51:53 +08:00
commit b564ca4797
55 changed files with 6407 additions and 0 deletions
+18
View File
@@ -0,0 +1,18 @@
<template>
<router-view />
</template>
<script setup lang="ts">
import { onMounted } from 'vue'
import { useAuthStore } from '@/stores/auth'
import { useRouter } from 'vue-router'
const auth = useAuthStore()
const router = useRouter()
onMounted(() => {
if (!auth.token && router.currentRoute.value.meta.requiresAuth) {
router.push('/login')
}
})
</script>
+150
View File
@@ -0,0 +1,150 @@
import axios from 'axios'
import router from '@/router'
export const api = axios.create({
baseURL: '/',
timeout: 30000,
})
api.interceptors.response.use(
(r) => r,
(err) => {
if (err.response?.status === 401) {
localStorage.removeItem('smartup_token')
localStorage.removeItem('smartup_email')
router.push('/login')
}
return Promise.reject(err)
}
)
// ——— Auth ———
export const authApi = {
login: (email: string, password: string) =>
api.post<{ access_token: string }>('/api/auth/login', { email, password }),
me: () => api.get<{ email: string }>('/api/auth/me'),
}
// ——— Upstreams ———
export interface UpstreamData {
id: number
name: string
base_url: string
api_prefix: string
auth_type: string
auth_config_masked: Record<string, any>
rate_endpoint: string
groups_endpoint: string
enabled: boolean
check_interval_seconds: number
timeout_seconds: number
last_status: string
last_checked_at: string | null
last_error: string | null
created_at: string
updated_at: string
}
export interface UpstreamForm {
name: string
base_url: string
api_prefix: string
auth_type: string
auth_config: Record<string, any>
rate_endpoint: string
groups_endpoint: string
enabled: boolean
check_interval_seconds: number
timeout_seconds: number
}
export const upstreamsApi = {
list: () => api.get<UpstreamData[]>('/api/upstreams'),
create: (data: UpstreamForm) => api.post<UpstreamData>('/api/upstreams', data),
update: (id: number, data: Partial<UpstreamForm>) => api.put<UpstreamData>(`/api/upstreams/${id}`, data),
delete: (id: number) => api.delete(`/api/upstreams/${id}`),
test: (id: number) => api.post<{ success: boolean; message: string; detail?: string }>(`/api/upstreams/${id}/test`),
checkNow: (id: number) => api.post<{ success: boolean; message: string }>(`/api/upstreams/${id}/check-now`),
latestSnapshot: (id: number) => api.get(`/api/upstreams/${id}/snapshots/latest`),
listSnapshots: (id: number, limit = 20, offset = 0) =>
api.get<any[]>(`/api/upstreams/${id}/snapshots`, { params: { limit, offset } }),
}
// ——— Webhooks ———
export interface WebhookData {
id: number
name: string
type: string
url: string
secret_masked: string
enabled: boolean
events: string[]
created_at: string
updated_at: string
}
export interface WebhookForm {
name: string
type: string
url: string
secret: string
enabled: boolean
events: string[]
}
export const webhooksApi = {
list: () => api.get<WebhookData[]>('/api/webhooks'),
create: (data: WebhookForm) => api.post<WebhookData>('/api/webhooks', data),
update: (id: number, data: Partial<WebhookForm>) => api.put<WebhookData>(`/api/webhooks/${id}`, data),
delete: (id: number) => api.delete(`/api/webhooks/${id}`),
test: (id: number) => api.post<{ success: boolean; message: string }>(`/api/webhooks/${id}/test`),
}
// ——— Logs ———
export interface LogData {
id: number
webhook_config_id: number
webhook_name: string
event_type: string
payload: Record<string, any>
status: string
response_text: string | null
created_at: string
}
export const logsApi = {
list: (params?: { status?: string; event_type?: string; limit?: number; offset?: number }) =>
api.get<LogData[]>('/api/notification-logs', { params }),
}
// ——— Custom Pages ———
export interface CustomPageData {
id: number
name: string
url: string
icon: string
sort_order: number
enabled: boolean
use_proxy: boolean
description: string | null
created_at: string
updated_at: string
}
export interface CustomPageForm {
name: string
url: string
icon: string
sort_order: number
enabled: boolean
use_proxy: boolean
description?: string
}
export const customPagesApi = {
list: () => api.get<CustomPageData[]>('/api/custom-pages'),
listPublic: () => axios.get<CustomPageData[]>('/api/custom-pages/public'),
create: (data: CustomPageForm) => api.post<CustomPageData>('/api/custom-pages', data),
update: (id: number, data: Partial<CustomPageForm>) => api.put<CustomPageData>(`/api/custom-pages/${id}`, data),
delete: (id: number) => api.delete(`/api/custom-pages/${id}`),
}
+66
View File
@@ -0,0 +1,66 @@
/* SmartUp — Global CSS */
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
:root {
--bg-base: #0d1117;
--bg-surface: #161b22;
--bg-card: #1c2128;
--bg-elevated: #22272e;
--border-color: #30363d;
--text-primary: #e6edf3;
--text-secondary: #8b949e;
--text-muted: #6e7681;
--color-primary: #6366f1;
--color-primary-hover: #818cf8;
--color-success: #22c55e;
--color-danger: #ef4444;
--color-warning: #f59e0b;
--color-info: #38bdf8;
--sidebar-width: 220px;
--topbar-height: 56px;
}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html, body, #app {
height: 100%;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
background-color: var(--bg-base);
color: var(--text-primary);
-webkit-font-smoothing: antialiased;
}
/* Element Plus dark mode overrides */
.el-table { --el-table-bg-color: var(--bg-card); --el-table-tr-bg-color: var(--bg-card); --el-table-header-bg-color: var(--bg-elevated); --el-table-border-color: var(--border-color); --el-table-text-color: var(--text-primary); --el-table-header-text-color: var(--text-secondary); }
.el-dialog { --el-dialog-bg-color: var(--bg-surface); --el-dialog-border-color: var(--border-color); }
.el-drawer { --el-drawer-bg-color: var(--bg-surface); }
.el-form-item__label { color: var(--text-secondary); }
.el-input__wrapper { background-color: var(--bg-elevated) !important; box-shadow: 0 0 0 1px var(--border-color) inset !important; }
.el-input__inner { color: var(--text-primary) !important; }
.el-select-dropdown { background-color: var(--bg-elevated); border-color: var(--border-color); }
.el-select-dropdown__item { color: var(--text-primary); }
.el-select-dropdown__item.hover, .el-select-dropdown__item:hover { background-color: var(--bg-surface); }
.el-tag { border-radius: 6px; }
.el-button--primary { background-color: var(--color-primary); border-color: var(--color-primary); }
.el-button--primary:hover { background-color: var(--color-primary-hover); border-color: var(--color-primary-hover); }
/* Status badges */
.status-badge {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 2px 10px;
border-radius: 20px;
font-size: 12px;
font-weight: 500;
}
.status-badge.healthy { background: rgba(34,197,94,0.15); color: var(--color-success); }
.status-badge.unhealthy { background: rgba(239,68,68,0.15); color: var(--color-danger); }
.status-badge.unknown { background: rgba(110,118,129,0.2); color: var(--text-muted); }
.status-badge.enabled { background: rgba(99,102,241,0.15); color: var(--color-primary); }
.status-badge.disabled { background: rgba(110,118,129,0.15); color: var(--text-muted); }
/* Scrollbar */
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--border-color); border-radius: 3px; }
+287
View File
@@ -0,0 +1,287 @@
<template>
<div class="layout">
<!-- Sidebar -->
<aside class="sidebar">
<div class="sidebar-logo">
<span class="logo-icon"></span>
<span class="logo-text">SmartUp</span>
</div>
<nav class="sidebar-nav">
<!-- Fixed nav -->
<div class="nav-group-label">监控</div>
<router-link to="/upstreams" class="nav-item" active-class="active">
<el-icon><Connection /></el-icon>
<span>上游管理</span>
</router-link>
<router-link to="/webhooks" class="nav-item" active-class="active">
<el-icon><Bell /></el-icon>
<span>Webhook 通知</span>
</router-link>
<router-link to="/logs" class="nav-item" active-class="active">
<el-icon><Document /></el-icon>
<span>通知日志</span>
</router-link>
<!-- Divider -->
<div class="nav-divider" />
<!-- Custom pages section -->
<div class="nav-group-label">
自定义页面
<router-link to="/custom-pages" class="nav-manage-link" title="管理自定义页面">
<el-icon :size="12"><Setting /></el-icon>
</router-link>
</div>
<template v-if="customPages.length > 0">
<router-link
v-for="page in customPages"
:key="page.id"
:to="`/page/${page.id}`"
class="nav-item"
active-class="active"
>
<el-icon><component :is="iconMap[page.icon] || LinkIcon" /></el-icon>
<span class="nav-custom-label">{{ page.name }}</span>
</router-link>
</template>
<div v-else class="nav-empty-pages">
<router-link to="/custom-pages" class="add-page-link">
<el-icon><Plus /></el-icon> 添加页面
</router-link>
</div>
</nav>
</aside>
<!-- Main -->
<div class="main-wrap">
<!-- Topbar -->
<header class="topbar">
<div class="topbar-title">{{ pageTitle }}</div>
<div class="topbar-right">
<span class="user-email">
<el-icon><User /></el-icon>
{{ auth.email }}
</span>
<el-button size="small" text @click="handleLogout" style="color:var(--text-secondary)">
<el-icon><SwitchButton /></el-icon> 退出
</el-button>
</div>
</header>
<!-- Page content -->
<main class="page-content">
<router-view />
</main>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, markRaw } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import {
Link as LinkIcon, Plus, Setting,
Monitor, SetUp, Reading, Cpu, DataLine,
Grid, Ticket, Wallet, Key, Tools, Star, House,
} from '@element-plus/icons-vue'
import { customPagesApi, type CustomPageData } from '@/api'
const router = useRouter()
const route = useRoute()
const auth = useAuthStore()
// ---- icon map (must match CustomPages.vue) ----
const iconMap: Record<string, any> = {
Link: markRaw(LinkIcon), Monitor: markRaw(Monitor), SetUp: markRaw(SetUp),
Reading: markRaw(Reading), Cpu: markRaw(Cpu), DataLine: markRaw(DataLine),
Grid: markRaw(Grid), Ticket: markRaw(Ticket), Wallet: markRaw(Wallet),
Key: markRaw(Key), Tools: markRaw(Tools), Star: markRaw(Star), House: markRaw(House),
}
// ---- custom pages nav ----
const customPages = ref<CustomPageData[]>([])
async function loadCustomPages() {
try {
const res = await customPagesApi.list()
customPages.value = res.data.filter(p => p.enabled)
} catch { /* sidebar load failure is non-critical */ }
}
// Refresh when management page emits update event
function onPagesUpdated() { loadCustomPages() }
// ---- page title ----
const staticTitles: Record<string, string> = {
'/upstreams': '上游管理',
'/webhooks': 'Webhook 通知',
'/logs': '通知日志',
'/custom-pages': '自定义页面',
}
const pageTitle = computed(() => {
if (route.path.startsWith('/page/')) {
const id = Number(route.params.id)
const found = customPages.value.find(p => p.id === id)
return found ? found.name : '嵌入页面'
}
return staticTitles[route.path] || 'SmartUp'
})
function handleLogout() {
auth.clear()
router.push('/login')
}
onMounted(() => {
loadCustomPages()
window.addEventListener('custom-pages-updated', onPagesUpdated)
})
onUnmounted(() => {
window.removeEventListener('custom-pages-updated', onPagesUpdated)
})
</script>
<style scoped>
.layout {
display: flex;
height: 100vh;
overflow: hidden;
}
.sidebar {
width: var(--sidebar-width);
background: var(--bg-surface);
border-right: 1px solid var(--border-color);
display: flex;
flex-direction: column;
flex-shrink: 0;
overflow-y: auto;
overflow-x: hidden;
}
.sidebar-logo {
height: var(--topbar-height);
display: flex;
align-items: center;
gap: 10px;
padding: 0 20px;
border-bottom: 1px solid var(--border-color);
flex-shrink: 0;
position: sticky;
top: 0;
background: var(--bg-surface);
z-index: 1;
}
.logo-icon { font-size: 22px; }
.logo-text {
font-size: 18px;
font-weight: 700;
background: linear-gradient(135deg, #6366f1, #818cf8);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.sidebar-nav {
padding: 12px 8px;
display: flex;
flex-direction: column;
gap: 2px;
}
.nav-group-label {
font-size: 11px;
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.06em;
padding: 8px 14px 4px;
display: flex;
align-items: center;
justify-content: space-between;
}
.nav-manage-link {
color: var(--text-muted);
text-decoration: none;
display: flex;
align-items: center;
padding: 2px;
border-radius: 4px;
transition: color 0.15s, background 0.15s;
}
.nav-manage-link:hover { color: var(--color-primary); background: var(--bg-elevated); }
.nav-divider {
height: 1px;
background: var(--border-color);
margin: 8px 10px;
}
.nav-item {
display: flex;
align-items: center;
gap: 10px;
padding: 9px 14px;
border-radius: 8px;
color: var(--text-secondary);
text-decoration: none;
font-size: 13.5px;
font-weight: 500;
transition: all 0.15s ease;
}
.nav-item:hover { background: var(--bg-elevated); color: var(--text-primary); }
.nav-item.active { background: rgba(99,102,241,0.15); color: var(--color-primary); }
.nav-custom-label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.nav-empty-pages {
padding: 4px 8px;
}
.add-page-link {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 14px;
border-radius: 8px;
color: var(--text-muted);
text-decoration: none;
font-size: 13px;
border: 1px dashed var(--border-color);
transition: all 0.15s;
}
.add-page-link:hover { border-color: var(--color-primary); color: var(--color-primary); }
/* ---- Main wrap ---- */
.main-wrap {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.topbar {
height: var(--topbar-height);
background: var(--bg-surface);
border-bottom: 1px solid var(--border-color);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 24px;
flex-shrink: 0;
}
.topbar-title { font-size: 15px; font-weight: 600; color: var(--text-primary); }
.topbar-right { display: flex; align-items: center; gap: 12px; }
.user-email { display: flex; align-items: center; gap: 6px; font-size: 13px; color: var(--text-secondary); }
.page-content {
flex: 1;
overflow-y: auto;
padding: 24px;
}
</style>
+21
View File
@@ -0,0 +1,21 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import 'element-plus/theme-chalk/dark/css-vars.css'
import * as ElIcons from '@element-plus/icons-vue'
import App from './App.vue'
import router from './router'
import './assets/main.css'
const app = createApp(App)
// Register all Element Plus icons globally
for (const [name, component] of Object.entries(ElIcons)) {
app.component(name, component)
}
app.use(createPinia())
app.use(router)
app.use(ElementPlus)
app.mount('#app')
+40
View File
@@ -0,0 +1,40 @@
import { createRouter, createWebHistory } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
const router = createRouter({
history: createWebHistory(),
routes: [
{
path: '/login',
component: () => import('@/views/Login.vue'),
meta: { requiresAuth: false },
},
{
path: '/',
component: () => import('@/components/AppLayout.vue'),
meta: { requiresAuth: true },
redirect: '/upstreams',
children: [
{ path: 'upstreams', component: () => import('@/views/Upstreams.vue') },
{ path: 'webhooks', component: () => import('@/views/Webhooks.vue') },
{ path: 'logs', component: () => import('@/views/NotificationLogs.vue') },
{ path: 'custom-pages', component: () => import('@/views/CustomPages.vue') },
{ path: 'page/:id', component: () => import('@/views/PageViewer.vue') },
],
},
{ path: '/:pathMatch(.*)*', redirect: '/' },
],
})
router.beforeEach((to, _from, next) => {
const auth = useAuthStore()
if (to.meta.requiresAuth && !auth.token) {
next('/login')
} else if (to.path === '/login' && auth.token) {
next('/upstreams')
} else {
next()
}
})
export default router
+31
View File
@@ -0,0 +1,31 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { api } from '@/api'
export const useAuthStore = defineStore('auth', () => {
const token = ref<string>(localStorage.getItem('smartup_token') || '')
const email = ref<string>(localStorage.getItem('smartup_email') || '')
function setToken(t: string, e: string) {
token.value = t
email.value = e
localStorage.setItem('smartup_token', t)
localStorage.setItem('smartup_email', e)
api.defaults.headers.common['Authorization'] = `Bearer ${t}`
}
function clear() {
token.value = ''
email.value = ''
localStorage.removeItem('smartup_token')
localStorage.removeItem('smartup_email')
delete api.defaults.headers.common['Authorization']
}
// Restore token on load
if (token.value) {
api.defaults.headers.common['Authorization'] = `Bearer ${token.value}`
}
return { token, email, setToken, clear }
})
+325
View File
@@ -0,0 +1,325 @@
<template>
<div>
<div class="page-header">
<div>
<h2 class="page-title">自定义页面</h2>
<p class="page-desc">嵌入外部网页到侧边栏统一管理上游平台</p>
</div>
<el-button type="primary" @click="openCreate">
<el-icon><Plus /></el-icon> 添加页面
</el-button>
</div>
<!-- Cards grid -->
<div v-loading="loading" class="pages-grid">
<div v-for="page in list" :key="page.id" class="page-card" :class="{ disabled: !page.enabled }">
<!-- Icon + title -->
<div class="card-head">
<div class="card-icon">
<el-icon :size="22"><component :is="iconMap[page.icon] || LinkIcon" /></el-icon>
</div>
<div class="card-info">
<div class="card-name">{{ page.name }}</div>
<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.enabled" size="small" type="info" class="disabled-tag">已停用</el-tag>
</div>
</div>
<div v-if="page.description" class="card-desc">{{ page.description }}</div>
<!-- Actions -->
<div class="card-actions">
<el-button size="small" text type="primary" @click="openViewer(page)">
<el-icon><Monitor /></el-icon> 打开
</el-button>
<el-button size="small" text @click="openEdit(page)">
<el-icon><Edit /></el-icon> 编辑
</el-button>
<el-button size="small" text type="danger" @click="confirmDelete(page)">
<el-icon><Delete /></el-icon>
</el-button>
<div class="sort-hint">排序: {{ page.sort_order }}</div>
</div>
</div>
<div v-if="!loading && list.length === 0" class="empty-state">
<el-icon :size="48" class="empty-icon"><Monitor /></el-icon>
<p>还没有自定义页面</p>
<p class="empty-sub">添加后可在侧边栏快速访问上游管理平台</p>
<el-button type="primary" @click="openCreate">立即添加</el-button>
</div>
</div>
<!-- Create / Edit dialog -->
<el-dialog
v-model="dialogVisible"
:title="editingId ? '编辑页面' : '添加自定义页面'"
width="520px"
destroy-on-close
>
<el-form ref="formRef" :model="form" :rules="rules" label-width="90px">
<el-form-item label="名称" prop="name">
<el-input v-model="form.name" placeholder="例:ai98pro 管理台" />
</el-form-item>
<el-form-item label="网址 URL" prop="url">
<el-input v-model="form.url" placeholder="https://ai98pro.xyz/home" />
</el-form-item>
<el-form-item label="图标">
<el-select v-model="form.icon" style="width:100%">
<el-option v-for="(_, key) in iconMap" :key="key" :label="key" :value="key">
<div style="display:flex;align-items:center;gap:8px">
<el-icon><component :is="iconMap[key]" /></el-icon>
<span>{{ key }}</span>
</div>
</el-option>
</el-select>
</el-form-item>
<el-form-item label="描述">
<el-input v-model="form.description" placeholder="可选描述" type="textarea" :rows="2" />
</el-form-item>
<el-form-item label="排序">
<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>
<el-form-item label="启用">
<el-switch v-model="form.enabled" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="handleSave">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, markRaw } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import type { FormInstance } from 'element-plus'
import {
Link as LinkIcon, Monitor, Edit, Delete, Plus,
SetUp, Reading, Cpu, DataLine, Grid, Connection,
Ticket, Wallet, Key, Tools, Star, House,
} from '@element-plus/icons-vue'
import { customPagesApi, type CustomPageData } from '@/api'
const router = useRouter()
// ---- icon map ----
const iconMap: Record<string, any> = {
Link: markRaw(LinkIcon),
Monitor: markRaw(Monitor),
SetUp: markRaw(SetUp),
Reading: markRaw(Reading),
Cpu: markRaw(Cpu),
DataLine: markRaw(DataLine),
Grid: markRaw(Grid),
Connection: markRaw(Connection),
Ticket: markRaw(Ticket),
Wallet: markRaw(Wallet),
Key: markRaw(Key),
Tools: markRaw(Tools),
Star: markRaw(Star),
House: markRaw(House),
}
// ---- state ----
const list = ref<CustomPageData[]>([])
const loading = ref(false)
const dialogVisible = ref(false)
const saving = ref(false)
const editingId = ref<number | null>(null)
const formRef = ref<FormInstance>()
const defaultForm = () => ({
name: '',
url: '',
icon: 'Link',
sort_order: 0,
enabled: true,
use_proxy: false,
description: '',
})
const form = ref(defaultForm())
const rules = {
name: [{ required: true, message: '请输入页面名称', trigger: 'blur' }],
url: [{ required: true, message: '请输入网址', trigger: 'blur' }],
}
async function loadList() {
loading.value = true
try {
const res = await customPagesApi.list()
list.value = res.data
} finally {
loading.value = false
}
}
function openCreate() {
editingId.value = null
form.value = defaultForm()
dialogVisible.value = true
}
function openEdit(page: CustomPageData) {
editingId.value = page.id
form.value = {
name: page.name,
url: page.url,
icon: page.icon,
sort_order: page.sort_order,
enabled: page.enabled,
use_proxy: page.use_proxy,
description: page.description || '',
}
dialogVisible.value = true
}
async function handleSave() {
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) return
saving.value = true
try {
if (editingId.value) {
await customPagesApi.update(editingId.value, form.value)
} else {
await customPagesApi.create(form.value)
}
ElMessage.success('保存成功')
dialogVisible.value = false
loadList()
// Notify AppLayout to refresh its nav
window.dispatchEvent(new Event('custom-pages-updated'))
} catch (e: any) {
ElMessage.error(e.response?.data?.detail || '保存失败')
} finally {
saving.value = false
}
}
async function confirmDelete(page: CustomPageData) {
try {
await ElMessageBox.confirm(`确认删除 "${page.name}" `, '删除确认', { type: 'warning' })
await customPagesApi.delete(page.id)
ElMessage.success('已删除')
loadList()
window.dispatchEvent(new Event('custom-pages-updated'))
} catch {}
}
function openViewer(page: CustomPageData) {
router.push(`/page/${page.id}`)
}
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); }
.pages-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 16px;
min-height: 200px;
}
.page-card {
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 12px;
padding: 18px;
display: flex;
flex-direction: column;
gap: 10px;
transition: border-color 0.15s, box-shadow 0.15s;
}
.page-card:hover {
border-color: var(--color-primary);
box-shadow: 0 0 0 2px rgba(99,102,241,0.1);
}
.page-card.disabled { opacity: 0.55; }
.card-head {
display: flex;
align-items: center;
gap: 12px;
}
.card-icon {
width: 42px;
height: 42px;
border-radius: 10px;
background: rgba(99,102,241,0.12);
color: var(--color-primary);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.card-info { flex: 1; min-width: 0; }
.card-name { font-size: 14px; font-weight: 600; color: var(--text-primary); }
.card-url {
font-size: 11px;
color: var(--text-muted);
margin-top: 2px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.disabled-tag { flex-shrink: 0; }
.tag-group { display: flex; gap: 4px; flex-shrink: 0; }
.proxy-tag { flex-shrink: 0; }
.card-desc {
font-size: 12px;
color: var(--text-secondary);
line-height: 1.5;
}
.card-actions {
display: flex;
align-items: center;
gap: 4px;
border-top: 1px solid var(--border-color);
padding-top: 10px;
margin-top: 2px;
}
.sort-hint {
margin-left: auto;
font-size: 11px;
color: var(--text-muted);
}
.form-hint { font-size: 12px; color: var(--text-muted); margin-left: 8px; }
.empty-state {
grid-column: 1 / -1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 20px;
color: var(--text-muted);
gap: 8px;
text-align: center;
}
.empty-icon { color: var(--border-color); margin-bottom: 8px; }
.empty-sub { font-size: 13px; color: var(--text-muted); margin-bottom: 12px; }
</style>
+140
View File
@@ -0,0 +1,140 @@
<template>
<div class="login-page">
<div class="login-bg">
<div class="orb orb-1"></div>
<div class="orb orb-2"></div>
</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>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { authApi } from '@/api'
import type { FormInstance } from 'element-plus'
const router = useRouter()
const auth = useAuthStore()
const formRef = ref<FormInstance>()
const loading = ref(false)
const errorMsg = ref('')
const form = ref({ email: '', password: '' })
const rules = {
email: [{ required: true, message: '请输入邮箱', trigger: 'blur' }],
password: [{ required: true, message: '请输入密码', trigger: 'blur' }],
}
async function handleLogin() {
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) return
loading.value = true
errorMsg.value = ''
try {
const res = await authApi.login(form.value.email, form.value.password)
auth.setToken(res.data.access_token, form.value.email)
router.push('/upstreams')
} catch (e: any) {
errorMsg.value = e.response?.data?.detail || '登录失败,请检查账号密码'
} finally {
loading.value = false
}
}
</script>
<style scoped>
.login-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-base);
position: relative;
overflow: hidden;
}
.login-bg {
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;
position: relative;
z-index: 1;
box-shadow: 0 24px 64px rgba(0,0,0,0.4);
}
.login-header { text-align: center; margin-bottom: 32px; }
.login-logo { font-size: 36px; display: block; margin-bottom: 8px; }
.login-title {
font-size: 24px;
font-weight: 700;
background: linear-gradient(135deg, #6366f1, #818cf8);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
margin-bottom: 6px;
}
.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>
+176
View File
@@ -0,0 +1,176 @@
<template>
<div>
<div class="page-header">
<div>
<h2 class="page-title">通知日志</h2>
<p class="page-desc">查看所有 Webhook 通知的发送记录</p>
</div>
<div class="filters">
<el-select v-model="filterStatus" placeholder="状态" clearable style="width:110px" @change="loadList">
<el-option label="成功" value="success" />
<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_unhealthy" />
<el-option label="服务恢复" value="upstream_recovered" />
<el-option label="测试" value="test" />
</el-select>
<el-button @click="loadList" :loading="tableLoading">
<el-icon><Refresh /></el-icon>
</el-button>
</div>
</div>
<div class="card">
<el-table :data="list" v-loading="tableLoading" style="width:100%">
<el-table-column label="时间" width="150">
<template #default="{ row }">
<span class="time-text">{{ fmtTime(row.created_at) }}</span>
</template>
</el-table-column>
<el-table-column label="Webhook" width="140">
<template #default="{ row }">{{ row.webhook_name }}</template>
</el-table-column>
<el-table-column label="事件" width="140">
<template #default="{ row }">
<el-tag size="small" :type="eventTagType(row.event_type)">{{ eventLabel(row.event_type) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="状态" width="90">
<template #default="{ row }">
<span :class="['status-badge', row.status === 'success' ? 'healthy' : 'unhealthy']">
{{ row.status === 'success' ? '成功' : '失败' }}
</span>
</template>
</el-table-column>
<el-table-column label="响应摘要" min-width="200">
<template #default="{ row }">
<span class="muted small">{{ row.response_text?.substring(0, 80) || '—' }}</span>
</template>
</el-table-column>
<el-table-column label="详情" width="80" fixed="right">
<template #default="{ row }">
<el-button size="small" text @click="viewDetail(row)"><el-icon><View /></el-icon></el-button>
</template>
</el-table-column>
</el-table>
<div class="pagination">
<el-button :disabled="offset === 0" @click="prevPage" size="small">上一页</el-button>
<span class="page-info"> {{ offset / limit + 1 }} </span>
<el-button :disabled="list.length < limit" @click="nextPage" size="small">下一页</el-button>
</div>
</div>
<!-- Detail Dialog -->
<el-dialog v-model="detailVisible" title="通知详情" width="640px" destroy-on-close>
<div v-if="detailRow" class="detail-wrap">
<el-descriptions :column="2" border>
<el-descriptions-item label="时间">{{ fmtTime(detailRow.created_at) }}</el-descriptions-item>
<el-descriptions-item label="Webhook">{{ detailRow.webhook_name }}</el-descriptions-item>
<el-descriptions-item label="事件">{{ eventLabel(detailRow.event_type) }}</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag :type="detailRow.status === 'success' ? 'success' : 'danger'" size="small">
{{ detailRow.status === 'success' ? '成功' : '失败' }}
</el-tag>
</el-descriptions-item>
</el-descriptions>
<div class="detail-section">
<div class="detail-label">Payload</div>
<pre class="code-block">{{ JSON.stringify(detailRow.payload, null, 2) }}</pre>
</div>
<div v-if="detailRow.response_text" class="detail-section">
<div class="detail-label">响应</div>
<pre class="code-block">{{ detailRow.response_text }}</pre>
</div>
</div>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import dayjs from 'dayjs'
import { logsApi, type LogData } from '@/api'
const list = ref<LogData[]>([])
const tableLoading = ref(false)
const detailVisible = ref(false)
const detailRow = ref<LogData | null>(null)
const filterStatus = ref('')
const filterEvent = ref('')
const offset = ref(0)
const limit = 50
const EVENT_LABELS: Record<string, string> = {
upstream_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] || '')
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')
async function loadList() {
tableLoading.value = true
try {
const res = await logsApi.list({
status: filterStatus.value || undefined,
event_type: filterEvent.value || undefined,
limit,
offset: offset.value,
})
list.value = res.data
} finally {
tableLoading.value = false
}
}
function viewDetail(row: LogData) {
detailRow.value = row
detailVisible.value = true
}
function prevPage() {
offset.value = Math.max(0, offset.value - limit)
loadList()
}
function nextPage() {
offset.value += limit
loadList()
}
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;
}
</style>
+208
View File
@@ -0,0 +1,208 @@
<template>
<div class="viewer-wrap">
<!-- Toolbar -->
<div class="viewer-bar">
<el-button size="small" text @click="router.back()">
<el-icon><ArrowLeft /></el-icon> 返回
</el-button>
<div class="viewer-title">
<el-icon><component :is="pageIcon" /></el-icon>
<span>{{ page?.name || '...' }}</span>
<el-tag v-if="page?.use_proxy" size="small" type="warning" style="margin-left:4px">代理</el-tag>
</div>
<div class="viewer-url">{{ page?.url }}</div>
<div class="viewer-actions">
<el-tooltip content="在新标签页打开">
<el-button size="small" text @click="openExternal">
<el-icon><TopRight /></el-icon>
</el-button>
</el-tooltip>
<el-tooltip content="刷新">
<el-button size="small" text @click="reload">
<el-icon><Refresh /></el-icon>
</el-button>
</el-tooltip>
</div>
</div>
<!-- Loading overlay -->
<div v-if="iframeLoading" class="iframe-loading">
<el-icon class="spin" :size="32"><Loading /></el-icon>
<p>正在加载页面</p>
</div>
<!-- Error state (removed to let browser natively show iframe state) -->
<iframe
v-show="!iframeLoading"
ref="iframeRef"
:src="iframeSrc"
class="page-iframe"
@load="onLoad"
@error="onError"
/>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, watch, markRaw } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import {
ArrowLeft, TopRight, Refresh, Loading, WarningFilled,
Link as LinkIcon, Monitor, SetUp, Reading, Cpu, DataLine,
Grid, Connection, Ticket, Wallet, Key, Tools, Star, House,
} from '@element-plus/icons-vue'
import { customPagesApi, type CustomPageData } from '@/api'
import { useAuthStore } from '@/stores/auth'
const route = useRoute()
const router = useRouter()
const auth = useAuthStore()
const iconMap: Record<string, any> = {
Link: markRaw(LinkIcon), Monitor: markRaw(Monitor), SetUp: markRaw(SetUp),
Reading: markRaw(Reading), Cpu: markRaw(Cpu), DataLine: markRaw(DataLine),
Grid: markRaw(Grid), Connection: markRaw(Connection), Ticket: markRaw(Ticket),
Wallet: markRaw(Wallet), Key: markRaw(Key), Tools: markRaw(Tools),
Star: markRaw(Star), House: markRaw(House),
}
const page = ref<CustomPageData | null>(null)
const iframeRef = ref<HTMLIFrameElement>()
const iframeLoading = ref(true)
const loadError = ref(false)
const pageIcon = computed(() => iconMap[page.value?.icon || 'Link'] || LinkIcon)
// Build the iframe src: use backend proxy if use_proxy=true
// Pass JWT token as query param since iframe can't set Authorization header
const iframeSrc = computed(() => {
if (!page.value) return ''
if (page.value.use_proxy) {
const encoded = encodeURIComponent(page.value.url)
const token = encodeURIComponent(auth.token || '')
return `/api/custom-pages/frame-proxy?url=${encoded}&token=${token}`
}
return page.value.url
})
async function loadPage(id: number) {
iframeLoading.value = true
loadError.value = false
try {
const res = await customPagesApi.list()
page.value = res.data.find(p => p.id === id) || null
if (!page.value) {
ElMessage.error('页面不存在')
router.push('/custom-pages')
}
} catch {
ElMessage.error('加载失败')
}
}
function onLoad() {
iframeLoading.value = false
}
function onError() {
iframeLoading.value = false
}
function openExternal() {
if (page.value?.url) window.open(page.value.url, '_blank', 'noopener')
}
function reload() {
if (!iframeRef.value || !page.value) return
iframeLoading.value = true
loadError.value = false
iframeRef.value.src = iframeSrc.value
}
watch(() => route.params.id, (id) => {
if (id) loadPage(Number(id))
}, { immediate: false })
onMounted(() => {
loadPage(Number(route.params.id))
})
</script>
<style scoped>
.viewer-wrap {
/* Expand to full viewport minus topbar, ignoring parent padding */
position: fixed;
inset: 0;
top: var(--topbar-height);
left: var(--sidebar-width);
display: flex;
flex-direction: column;
background: var(--bg-base);
z-index: 10;
}
.viewer-bar {
height: 44px;
background: var(--bg-surface);
border-bottom: 1px solid var(--border-color);
display: flex;
align-items: center;
gap: 10px;
padding: 0 12px;
flex-shrink: 0;
}
.viewer-title {
display: flex;
align-items: center;
gap: 6px;
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
white-space: nowrap;
}
.viewer-url {
flex: 1;
font-size: 12px;
color: var(--text-muted);
background: var(--bg-elevated);
border-radius: 6px;
padding: 4px 10px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
}
.viewer-actions { display: flex; gap: 4px; }
.page-iframe {
flex: 1;
width: 100%;
border: none;
background: #fff;
}
.iframe-loading, .iframe-error {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
color: var(--text-muted);
}
.spin {
animation: spin 1s linear infinite;
color: var(--color-primary);
}
@keyframes spin { to { transform: rotate(360deg); } }
.error-icon { color: #f59e0b; }
.error-title { font-size: 16px; font-weight: 600; color: var(--text-primary); }
.error-sub { font-size: 13px; color: var(--text-muted); margin-bottom: 8px; }
</style>
+587
View File
@@ -0,0 +1,587 @@
<template>
<div>
<!-- Header -->
<div class="page-header">
<div>
<h2 class="page-title">上游管理</h2>
<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">
<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"></span>
{{ statusLabel(row.last_status) }}
</span>
</template>
</el-table-column>
<el-table-column label="启用" width="80">
<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">
<template #default="{ row }">
<span v-if="row.last_checked_at" class="time-text">{{ fmtTime(row.last_checked_at) }}</span>
<span v-else class="muted">未检测</span>
</template>
</el-table-column>
<el-table-column label="最近错误" min-width="160">
<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>
</el-tooltip>
<span v-else class="muted"></span>
</template>
</el-table-column>
<el-table-column label="操作" width="250">
<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 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-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>
<!-- ======= 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-item label="名称" prop="name">
<el-input v-model="form.name" placeholder="例:ai98pro" />
</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>
<el-form-item label="API Prefix">
<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-option label="无认证" value="none" />
<el-option label="Bearer Token" value="bearer" />
<el-option label="API Key" value="api_key" />
<el-option label="邮箱密码登录" value="login_password" />
</el-select>
</el-form-item>
<template v-if="form.auth_type === 'bearer'">
<el-form-item label="Bearer Token">
<el-input v-model="form.auth_config.token" type="password" show-password placeholder="***" />
</el-form-item>
</template>
<template v-else-if="form.auth_type === 'api_key'">
<el-form-item label="API Key">
<el-input v-model="form.auth_config.key" type="password" show-password placeholder="***" />
</el-form-item>
<el-form-item label="Header 名称">
<el-input v-model="form.auth_config.header" placeholder="Authorization" />
</el-form-item>
</template>
<template v-else-if="form.auth_type === 'login_password'">
<el-form-item label="登录邮箱">
<el-input v-model="form.auth_config.email" placeholder="admin@example.com" />
</el-form-item>
<el-form-item label="登录密码">
<el-input v-model="form.auth_config.password" type="password" show-password placeholder="***" />
</el-form-item>
<el-form-item label="登录接口路径">
<el-input v-model="form.auth_config.login_path" placeholder="/auth/login" />
</el-form-item>
</template>
<el-form-item label="分组接口">
<el-input v-model="form.groups_endpoint" placeholder="/groups/available" />
</el-form-item>
<el-form-item label="倍率接口">
<el-input v-model="form.rate_endpoint" placeholder="/groups/rates" />
</el-form-item>
<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-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-form-item>
</el-col>
</el-row>
<el-form-item label="启用">
<el-switch v-model="form.enabled" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="drawerVisible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="handleSave">保存</el-button>
</template>
</el-drawer>
<!-- ======= Detail Drawer ======= -->
<el-drawer
v-model="detailVisible"
:title="`检测详情 — ${detailUpstream?.name || ''}`"
size="700px"
destroy-on-close
@open="loadSnapshots"
>
<!-- Info cards -->
<div v-if="detailUpstream" class="info-cards">
<div class="info-card">
<div class="info-label">状态</div>
<span :class="['status-badge', detailUpstream.last_status]">
<span class="dot"></span>{{ statusLabel(detailUpstream.last_status) }}
</span>
</div>
<div class="info-card">
<div class="info-label">最近检测</div>
<div class="info-value">{{ detailUpstream.last_checked_at ? fmtTimeFull(detailUpstream.last_checked_at) : '未检测' }}</div>
</div>
<div class="info-card">
<div class="info-label">检测间隔</div>
<div class="info-value">{{ detailUpstream.check_interval_seconds }}s</div>
</div>
<div class="info-card">
<div class="info-label">超时</div>
<div class="info-value">{{ 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 class="section-title">
<el-icon><Clock /></el-icon> 检测历史
<span class="section-sub">最近 {{ snapshots.length }} </span>
</div>
<div v-loading="snapshotLoading" class="snapshot-list">
<div
v-for="snap in snapshots"
:key="snap.id"
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>
</div>
<div class="snap-right">
<el-tag size="small" type="info">{{ snap.snapshot._groups_count }} 个分组</el-tag>
<template v-if="snap.snapshot._changes_count !== null && snap.snapshot._changes_count !== undefined">
<el-tag size="small" :type="snap.snapshot._changes_count > 0 ? 'warning' : 'success'">
{{ snap.snapshot._changes_count > 0 ? `${snap.snapshot._changes_count} 处变化` : '无变化' }}
</el-tag>
</template>
<el-tag v-else size="small" type="primary">初始快照</el-tag>
</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)' }"
>
<el-table-column prop="group_name" label="分组名称" min-width="140" />
<el-table-column prop="platform" label="平台" width="100" />
<el-table-column label="当前倍率" width="100">
<template #default="{ row }">
<span class="rate-value">{{ 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">
<template #default="{ row }">
<span v-if="row.override_rate" class="override-value">{{ row.override_rate }}</span>
<span v-else class="muted"></span>
</template>
</el-table-column>
</el-table>
</div>
</div>
<div v-if="!snapshotLoading && snapshots.length === 0" class="empty-hint">
暂无检测历史请先触发立即检测
</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>
<el-button size="small" :disabled="snapshots.length < snapshotLimit" @click="nextSnapPage">下一页</el-button>
</div>
</el-drawer>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import type { FormInstance } from 'element-plus'
import dayjs from 'dayjs'
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)
const formRef = ref<FormInstance>()
const defaultForm = () => ({
name: '',
base_url: '',
api_prefix: '/api/v1',
auth_type: 'login_password',
auth_config: { email: '', password: '', login_path: '/auth/login' } as Record<string, any>,
rate_endpoint: '/groups/rates',
groups_endpoint: '/groups/available',
enabled: true,
check_interval_seconds: 600,
timeout_seconds: 30,
})
const form = ref(defaultForm())
const rules = {
name: [{ required: true, message: '请输入名称', trigger: 'blur' }],
base_url: [{ required: true, message: '请输入 Base URL', trigger: 'blur' }],
}
// ---- detail drawer ----
const detailVisible = ref(false)
const detailUpstream = ref<UpstreamData | null>(null)
const snapshots = ref<any[]>([])
const snapshotLoading = ref(false)
const expandedId = ref<number | null>(null)
const snapshotOffset = ref(0)
const snapshotLimit = 20
// ---- helpers ----
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 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 ----
async function loadList() {
tableLoading.value = true
try {
const res = await upstreamsApi.list()
list.value = res.data
} finally {
tableLoading.value = false
}
}
// ---- create / edit ----
function openCreate() {
editingId.value = null
form.value = defaultForm()
drawerVisible.value = true
}
function openEdit(row: UpstreamData) {
editingId.value = row.id
form.value = {
name: row.name,
base_url: row.base_url,
api_prefix: row.api_prefix,
auth_type: row.auth_type,
auth_config: { ...(row.auth_config_masked as Record<string, any>) },
rate_endpoint: row.rate_endpoint,
groups_endpoint: row.groups_endpoint,
enabled: row.enabled,
check_interval_seconds: row.check_interval_seconds,
timeout_seconds: row.timeout_seconds,
}
drawerVisible.value = true
}
async function handleSave() {
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) return
saving.value = true
try {
if (editingId.value) {
await upstreamsApi.update(editingId.value, form.value)
ElMessage.success('保存成功')
} else {
await upstreamsApi.create(form.value as any)
ElMessage.success('创建成功')
}
drawerVisible.value = false
loadList()
} catch (e: any) {
ElMessage.error(e.response?.data?.detail || '保存失败')
} finally {
saving.value = false
}
}
// ---- toggle enabled ----
async function toggleEnabled(row: UpstreamData) {
try {
await upstreamsApi.update(row.id, { enabled: row.enabled })
ElMessage.success(row.enabled ? '已启用' : '已停用')
} catch {
row.enabled = !row.enabled
ElMessage.error('操作失败')
}
}
// ---- test / check-now ----
async function testUpstream(row: any) {
row._testing = true
try {
const res = await upstreamsApi.test(row.id)
if (res.data.success) ElMessage.success(res.data.message)
else ElMessage.error(res.data.detail || res.data.message)
} finally {
row._testing = false
}
}
async function checkNow(row: any) {
row._checking = true
try {
const res = await upstreamsApi.checkNow(row.id)
ElMessage[res.data.success ? 'success' : 'error'](res.data.message)
loadList()
} finally {
row._checking = false
}
}
// ---- detail drawer ----
function openDetail(row: UpstreamData) {
detailUpstream.value = row
snapshots.value = []
snapshotOffset.value = 0
expandedId.value = null
detailVisible.value = true
}
async function loadSnapshots() {
if (!detailUpstream.value) return
snapshotLoading.value = true
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
}
} catch {
ElMessage.error('加载历史失败')
} finally {
snapshotLoading.value = false
}
}
function toggleExpand(snap: any) {
expandedId.value = expandedId.value === snap.id ? null : snap.id
}
function prevSnapPage() {
snapshotOffset.value = Math.max(0, snapshotOffset.value - snapshotLimit)
expandedId.value = null
loadSnapshots()
}
function nextSnapPage() {
snapshotOffset.value += snapshotLimit
expandedId.value = null
loadSnapshots()
}
// ---- delete ----
async function confirmDelete(row: UpstreamData) {
try {
await ElMessageBox.confirm(`确认删除上游 "${row.name}" `, '删除确认', { type: 'warning' })
await upstreamsApi.delete(row.id)
ElMessage.success('已删除')
loadList()
} catch {}
}
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;
}
/* Detail drawer */
.info-cards {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
margin-bottom: 16px;
}
.info-card {
background: var(--bg-elevated);
border: 1px solid var(--border-color);
border-radius: 10px;
padding: 12px 14px;
}
.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); }
.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;
color: var(--color-danger);
font-size: 13px;
margin-bottom: 16px;
}
.section-title {
display: flex;
align-items: center;
gap: 6px;
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 12px;
}
.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;
}
.snap-item {
border: 1px solid var(--border-color);
border-radius: 8px;
overflow: hidden;
transition: border-color 0.15s;
}
.snap-item.expanded { border-color: var(--color-primary); }
.snap-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 14px;
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-pagination {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
padding-top: 14px;
border-top: 1px solid var(--border-color);
margin-top: 12px;
}
.page-info { font-size: 13px; color: var(--text-secondary); }
</style>
+224
View File
@@ -0,0 +1,224 @@
<template>
<div>
<div class="page-header">
<div>
<h2 class="page-title">Webhook 通知</h2>
<p class="page-desc">配置 Webhook 接收器支持通用 JSON 和钉钉机器人</p>
</div>
<el-button type="primary" @click="openCreate">
<el-icon><Plus /></el-icon> 新增 Webhook
</el-button>
</div>
<div class="card">
<el-table :data="list" v-loading="tableLoading" style="width:100%">
<el-table-column label="名称" min-width="140">
<template #default="{ row }">
<div class="cell-name">{{ row.name }}</div>
<div class="cell-type">{{ row.type === 'dingtalk' ? '钉钉机器人' : '通用 JSON' }}</div>
</template>
</el-table-column>
<el-table-column label="启用" width="80">
<template #default="{ row }">
<el-switch v-model="row.enabled" @change="toggleEnabled(row)" />
</template>
</el-table-column>
<el-table-column label="订阅事件" min-width="220">
<template #default="{ row }">
<el-tag
v-for="ev in row.events"
:key="ev"
size="small"
style="margin:2px"
:type="eventTagType(ev)"
>{{ eventLabel(ev) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="创建时间" width="140">
<template #default="{ row }">
<span class="time-text">{{ fmtTime(row.created_at) }}</span>
</template>
</el-table-column>
<el-table-column label="操作" width="200" fixed="right">
<template #default="{ row }">
<el-button size="small" text @click="openEdit(row)"><el-icon><Edit /></el-icon></el-button>
<el-button size="small" text type="success" @click="testWebhook(row)" :loading="row._testing">
测试
</el-button>
<el-button size="small" text type="danger" @click="confirmDelete(row)"><el-icon><Delete /></el-icon></el-button>
</template>
</el-table-column>
</el-table>
</div>
<!-- Drawer -->
<el-drawer v-model="drawerVisible" :title="editingId ? '编辑 Webhook' : '新增 Webhook'" size="460px" destroy-on-close>
<el-form ref="formRef" :model="form" :rules="rules" label-position="top">
<el-form-item label="名称" prop="name">
<el-input v-model="form.name" placeholder="例:钉钉告警机器人" />
</el-form-item>
<el-form-item label="类型">
<el-select v-model="form.type" style="width:100%">
<el-option label="通用 JSON Webhook" value="generic" />
<el-option label="钉钉机器人" value="dingtalk" />
</el-select>
</el-form-item>
<el-form-item label="Webhook URL" prop="url">
<el-input v-model="form.url" placeholder="https://..." />
</el-form-item>
<el-form-item :label="form.type === 'dingtalk' ? '加签 Secret' : 'Secret(可选)'">
<el-input v-model="form.secret" type="password" show-password :placeholder="editingId ? '留空保持不变' : '可留空'" />
</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_unhealthy">服务异常</el-checkbox>
<el-checkbox label="upstream_recovered">服务恢复</el-checkbox>
</el-checkbox-group>
</el-form-item>
<el-form-item label="启用">
<el-switch v-model="form.enabled" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="drawerVisible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="handleSave">保存</el-button>
</template>
</el-drawer>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import type { FormInstance } from 'element-plus'
import dayjs from 'dayjs'
import { webhooksApi, type WebhookData } from '@/api'
const list = ref<(WebhookData & { _testing?: boolean })[]>([])
const tableLoading = ref(false)
const drawerVisible = ref(false)
const saving = ref(false)
const editingId = ref<number | null>(null)
const formRef = ref<FormInstance>()
const defaultForm = () => ({
name: '',
type: 'generic',
url: '',
secret: '',
enabled: true,
events: ['upstream_rate_changed'] as string[],
})
const form = ref(defaultForm())
const rules = {
name: [{ required: true, message: '请输入名称', trigger: 'blur' }],
url: [{ required: true, message: '请输入 URL', trigger: 'blur' }],
}
const EVENT_LABELS: Record<string, string> = {
upstream_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] || '')
const toUTC = (t: string) => /[Z+\-]\d*$/.test(t.trim()) ? t : t + 'Z'
const fmtTime = (t: string) => dayjs(toUTC(t)).format('MM-DD HH:mm')
async function loadList() {
tableLoading.value = true
try {
const res = await webhooksApi.list()
list.value = res.data
} finally {
tableLoading.value = false
}
}
function openCreate() {
editingId.value = null
form.value = defaultForm()
drawerVisible.value = true
}
function openEdit(row: WebhookData) {
editingId.value = row.id
form.value = {
name: row.name,
type: row.type,
url: row.url,
secret: '',
enabled: row.enabled,
events: [...row.events],
}
drawerVisible.value = true
}
async function handleSave() {
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) return
saving.value = true
try {
const payload: any = { ...form.value }
if (editingId.value) {
if (!payload.secret) delete payload.secret
await webhooksApi.update(editingId.value, payload)
ElMessage.success('保存成功')
} else {
await webhooksApi.create(payload)
ElMessage.success('创建成功')
}
drawerVisible.value = false
loadList()
} catch (e: any) {
ElMessage.error(e.response?.data?.detail || '保存失败')
} finally {
saving.value = false
}
}
async function toggleEnabled(row: WebhookData) {
try {
await webhooksApi.update(row.id, { enabled: row.enabled })
ElMessage.success(row.enabled ? '已启用' : '已停用')
} catch {
row.enabled = !row.enabled
ElMessage.error('操作失败')
}
}
async function testWebhook(row: any) {
row._testing = true
try {
const res = await webhooksApi.test(row.id)
ElMessage[res.data.success ? 'success' : 'error'](res.data.message)
} finally {
row._testing = false
}
}
async function confirmDelete(row: WebhookData) {
try {
await ElMessageBox.confirm(`确认删除 "${row.name}" `, '删除确认', { type: 'warning' })
await webhooksApi.delete(row.id)
ElMessage.success('已删除')
loadList()
} catch {}
}
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); }
</style>