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:
+3
-1
@@ -5,9 +5,11 @@
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="description" content="SmartUp — API 上游管理与 Webhook 通知系统" />
|
||||
<meta name="theme-color" content="#17120f" />
|
||||
<title>SmartUp 管理后台</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Alegreya+Sans+SC:wght@500;700;800&family=Noto+Sans+SC:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="64" height="64" rx="14" fill="#0d1117"/>
|
||||
<path d="M36.4 6L14 37.2H30.6L27.6 58L50 26.8H33.4L36.4 6Z" fill="#facc15"/>
|
||||
<path d="M36.4 6L14 37.2H30.6L27.6 58L50 26.8H33.4L36.4 6Z" fill="url(#bolt-highlight)" fill-opacity="0.55"/>
|
||||
<path d="M36.4 6L14 37.2H30.6L27.6 58L50 26.8H33.4L36.4 6Z" stroke="#fde047" stroke-width="2" stroke-linejoin="round"/>
|
||||
<defs>
|
||||
<linearGradient id="bolt-highlight" x1="18" y1="9" x2="46" y2="56" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#fef08a"/>
|
||||
<stop offset="0.55" stop-color="#facc15"/>
|
||||
<stop offset="1" stop-color="#f59e0b"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 746 B |
@@ -70,6 +70,111 @@ export const upstreamsApi = {
|
||||
api.get<any[]>(`/api/upstreams/${id}/snapshots`, { params: { limit, offset } }),
|
||||
}
|
||||
|
||||
// ——— Websites ———
|
||||
export interface WebsiteData {
|
||||
id: number
|
||||
name: string
|
||||
site_type: string
|
||||
base_url: string
|
||||
api_prefix: string
|
||||
auth_type: string
|
||||
auth_config_masked: Record<string, any>
|
||||
groups_endpoint: string
|
||||
group_update_endpoint: string
|
||||
enabled: boolean
|
||||
auto_sync_enabled: boolean
|
||||
timeout_seconds: number
|
||||
last_status: string
|
||||
last_checked_at: string | null
|
||||
last_error: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface WebsiteForm {
|
||||
name: string
|
||||
site_type: string
|
||||
base_url: string
|
||||
api_prefix: string
|
||||
auth_type: string
|
||||
auth_config: Record<string, any>
|
||||
groups_endpoint: string
|
||||
group_update_endpoint: string
|
||||
enabled: boolean
|
||||
auto_sync_enabled: boolean
|
||||
timeout_seconds: number
|
||||
}
|
||||
|
||||
export interface WebsiteGroup {
|
||||
id: string
|
||||
name: string
|
||||
rate_multiplier: string | null
|
||||
raw: Record<string, any>
|
||||
}
|
||||
|
||||
export interface BindingSourceGroup {
|
||||
upstream_id: number
|
||||
group_id: string
|
||||
upstream_name: string
|
||||
group_name: string
|
||||
}
|
||||
|
||||
export interface GroupBindingData {
|
||||
id: number
|
||||
website_id: number
|
||||
website_name: string
|
||||
target_group_id: string
|
||||
target_group_name: string
|
||||
source_groups: BindingSourceGroup[]
|
||||
percent: number
|
||||
algorithm: string
|
||||
enabled: boolean
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface GroupBindingForm {
|
||||
website_id: number
|
||||
target_group_id: string
|
||||
target_group_name: string
|
||||
source_groups: BindingSourceGroup[]
|
||||
percent: number
|
||||
algorithm: string
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
export interface WebsiteSyncLog {
|
||||
id: number
|
||||
website_id: number
|
||||
binding_id: number | null
|
||||
target_group_id: string
|
||||
target_group_name: string
|
||||
algorithm: string
|
||||
percent: number
|
||||
source_rates: Array<Record<string, any>>
|
||||
old_rate: string | null
|
||||
new_rate: string | null
|
||||
status: string
|
||||
message: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export const websitesApi = {
|
||||
list: () => api.get<WebsiteData[]>('/api/websites'),
|
||||
create: (data: WebsiteForm) => api.post<WebsiteData>('/api/websites', data),
|
||||
update: (id: number, data: Partial<WebsiteForm>) => api.put<WebsiteData>(`/api/websites/${id}`, data),
|
||||
delete: (id: number) => api.delete(`/api/websites/${id}`),
|
||||
test: (id: number) => api.post<{ success: boolean; message: string; detail?: string }>(`/api/websites/${id}/test`),
|
||||
groups: (id: number) => api.get<WebsiteGroup[]>(`/api/websites/${id}/groups`),
|
||||
listBindings: () => api.get<GroupBindingData[]>('/api/group-bindings'),
|
||||
createBinding: (data: GroupBindingForm) => api.post<GroupBindingData>('/api/group-bindings', data),
|
||||
updateBinding: (id: number, data: Partial<GroupBindingForm>) => api.put<GroupBindingData>(`/api/group-bindings/${id}`, data),
|
||||
deleteBinding: (id: number) => api.delete(`/api/group-bindings/${id}`),
|
||||
syncNow: (id: number) => api.post<WebsiteSyncLog>(`/api/group-bindings/${id}/sync-now`),
|
||||
logs: (params?: { website_id?: number; binding_id?: number; limit?: number; offset?: number }) =>
|
||||
api.get<WebsiteSyncLog[]>('/api/website-sync-logs', { params }),
|
||||
}
|
||||
|
||||
// ——— Webhooks ———
|
||||
export interface WebhookData {
|
||||
id: number
|
||||
@@ -118,6 +223,8 @@ export const logsApi = {
|
||||
}
|
||||
|
||||
// ——— Custom Pages ———
|
||||
export type CustomPageAccessMode = 'direct' | 'proxy' | 'remote_browser'
|
||||
|
||||
export interface CustomPageData {
|
||||
id: number
|
||||
name: string
|
||||
@@ -126,7 +233,14 @@ export interface CustomPageData {
|
||||
sort_order: number
|
||||
enabled: boolean
|
||||
use_proxy: boolean
|
||||
access_mode: CustomPageAccessMode
|
||||
description: string | null
|
||||
login_username: string | null
|
||||
login_username_selector: string | null
|
||||
login_password_selector: string | null
|
||||
login_submit_selector: string | null
|
||||
login_autofill_enabled: boolean
|
||||
login_password_configured: boolean
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
@@ -138,7 +252,15 @@ export interface CustomPageForm {
|
||||
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_clear?: boolean
|
||||
}
|
||||
|
||||
export const customPagesApi = {
|
||||
@@ -148,3 +270,40 @@ export const customPagesApi = {
|
||||
update: (id: number, data: Partial<CustomPageForm>) => api.put<CustomPageData>(`/api/custom-pages/${id}`, data),
|
||||
delete: (id: number) => api.delete(`/api/custom-pages/${id}`),
|
||||
}
|
||||
|
||||
// ——— Remote browser sessions ———
|
||||
export interface BrowserSessionData {
|
||||
id: string
|
||||
custom_page_id: number
|
||||
url: string
|
||||
title: string
|
||||
}
|
||||
|
||||
export type BrowserEventPayload =
|
||||
| { type: 'click' | 'dblclick' | 'mousemove' | 'mousedown' | 'mouseup'; x: number; y: number; button?: 'left' | 'right' | 'middle' }
|
||||
| { type: 'type'; text: string }
|
||||
| { type: 'key'; key: string }
|
||||
| { type: 'scroll'; delta_x: number; delta_y: number; x?: number; y?: number }
|
||||
| { type: 'reload' | 'back' | 'forward' }
|
||||
| { type: 'resize'; width: number; height: number }
|
||||
|
||||
export const browserSessionsApi = {
|
||||
create: (data: { custom_page_id: number; width: number; height: number }) =>
|
||||
api.post<BrowserSessionData>('/api/browser-sessions', data),
|
||||
get: (id: string) => api.get<BrowserSessionData>(`/api/browser-sessions/${id}`),
|
||||
event: (id: string, data: BrowserEventPayload) =>
|
||||
api.post<BrowserSessionData>(`/api/browser-sessions/${id}/events`, data),
|
||||
close: (id: string) => api.delete(`/api/browser-sessions/${id}`),
|
||||
screenshotUrl: (id: string, token?: string) => {
|
||||
const params = new URLSearchParams({ t: String(Date.now()) })
|
||||
if (token) params.set('token', token)
|
||||
return `/api/browser-sessions/${id}/screenshot?${params.toString()}`
|
||||
},
|
||||
/** Build a WebSocket URL for the streaming endpoint. */
|
||||
wsUrl: (id: string, token?: string) => {
|
||||
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
const params = new URLSearchParams()
|
||||
if (token) params.set('token', token)
|
||||
return `${proto}//${location.host}/api/browser-sessions/${id}/ws?${params.toString()}`
|
||||
},
|
||||
}
|
||||
|
||||
+579
-50
@@ -1,66 +1,595 @@
|
||||
/* SmartUp — Global CSS */
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
|
||||
@import url('https://fonts.googleapis.com/css2?family=Alegreya+Sans+SC:wght@500;700;800&family=Noto+Sans+SC:wght@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;
|
||||
color-scheme: dark;
|
||||
|
||||
--bg-base: #120f0d;
|
||||
--bg-canvas: #17120f;
|
||||
--bg-surface: rgba(31, 24, 20, 0.92);
|
||||
--bg-surface-strong: rgba(38, 29, 24, 0.96);
|
||||
--bg-panel: rgba(44, 34, 28, 0.88);
|
||||
--bg-panel-soft: rgba(61, 47, 39, 0.42);
|
||||
--bg-elevated: rgba(78, 60, 50, 0.56);
|
||||
--border-color: rgba(218, 183, 142, 0.18);
|
||||
--border-strong: rgba(226, 192, 151, 0.34);
|
||||
--text-primary: #f5ede5;
|
||||
--text-secondary: #d8c2ac;
|
||||
--text-muted: #a08772;
|
||||
--text-soft: #8a7566;
|
||||
--color-primary: #d98b42;
|
||||
--color-primary-strong: #efaf63;
|
||||
--color-primary-soft: rgba(217, 139, 66, 0.18);
|
||||
--color-success: #7bc38f;
|
||||
--color-danger: #dd7e72;
|
||||
--color-warning: #d6aa58;
|
||||
--color-info: #86b7c7;
|
||||
--shadow-soft: 0 18px 60px rgba(0, 0, 0, 0.28);
|
||||
--shadow-panel: 0 20px 40px rgba(0, 0, 0, 0.18), inset 0 1px 0 rgba(255, 244, 232, 0.04);
|
||||
--radius-shell: 24px;
|
||||
--radius-panel: 18px;
|
||||
--radius-control: 12px;
|
||||
--radius-pill: 999px;
|
||||
--sidebar-width: 18.5rem;
|
||||
--topbar-height: 4.5rem;
|
||||
--shell-padding: clamp(1rem, 1.2vw + 0.8rem, 1.5rem);
|
||||
--content-max: 96rem;
|
||||
--ease-standard: cubic-bezier(0.22, 1, 0.36, 1);
|
||||
--motion-fast: 180ms;
|
||||
--motion-base: 240ms;
|
||||
}
|
||||
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
*, *::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);
|
||||
html {
|
||||
font-size: 16px;
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(217, 139, 66, 0.15), transparent 28%),
|
||||
radial-gradient(circle at bottom right, rgba(134, 183, 199, 0.08), transparent 24%),
|
||||
linear-gradient(180deg, #1a1410 0%, var(--bg-base) 50%, #0f0b09 100%);
|
||||
}
|
||||
|
||||
body,
|
||||
#app {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Noto Sans SC', 'PingFang SC', 'Microsoft YaHei', sans-serif;
|
||||
color: var(--text-primary);
|
||||
background: transparent;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
/* 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); }
|
||||
button,
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
img {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.brand-type {
|
||||
font-family: 'Alegreya Sans SC', 'Noto Sans SC', sans-serif;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.shell-page {
|
||||
width: min(100%, var(--content-max));
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.page-section {
|
||||
display: grid;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.page-header,
|
||||
.section-header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.page-heading {
|
||||
display: grid;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.page-kicker {
|
||||
color: var(--color-primary-strong);
|
||||
font-size: 0.76rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-family: 'Alegreya Sans SC', 'Noto Sans SC', sans-serif;
|
||||
font-size: clamp(2rem, 1.4rem + 1.6vw, 3.05rem);
|
||||
line-height: 0.95;
|
||||
font-weight: 800;
|
||||
text-wrap: pretty;
|
||||
}
|
||||
|
||||
.page-desc {
|
||||
max-width: 62ch;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.98rem;
|
||||
line-height: 1.7;
|
||||
text-wrap: pretty;
|
||||
}
|
||||
|
||||
.surface-card,
|
||||
.card,
|
||||
.panel {
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 244, 232, 0.02), transparent 45%),
|
||||
var(--bg-panel);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-panel);
|
||||
box-shadow: var(--shadow-panel);
|
||||
backdrop-filter: blur(14px);
|
||||
}
|
||||
|
||||
.section-caption {
|
||||
color: var(--text-soft);
|
||||
font-size: 0.82rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.metric-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
.metric-card {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
padding: 1rem 1.05rem;
|
||||
}
|
||||
|
||||
.metric-card::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: auto 1rem 0.9rem auto;
|
||||
width: 3.5rem;
|
||||
height: 3.5rem;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle, rgba(217, 139, 66, 0.18), transparent 70%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.metric-label {
|
||||
color: var(--text-soft);
|
||||
font-size: 0.78rem;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.metric-value {
|
||||
margin-top: 0.45rem;
|
||||
font-family: 'Alegreya Sans SC', 'Noto Sans SC', sans-serif;
|
||||
font-size: clamp(1.8rem, 1.2rem + 1.3vw, 2.6rem);
|
||||
line-height: 1;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.metric-note {
|
||||
margin-top: 0.4rem;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.86rem;
|
||||
}
|
||||
|
||||
.toolbar-cluster,
|
||||
.filters,
|
||||
.action-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
/* Status badges */
|
||||
.status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 2px 10px;
|
||||
border-radius: 20px;
|
||||
font-size: 12px;
|
||||
gap: 0.45rem;
|
||||
min-height: 1.95rem;
|
||||
padding: 0.2rem 0.72rem;
|
||||
border-radius: var(--radius-pill);
|
||||
border: 1px solid transparent;
|
||||
font-size: 0.76rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.status-badge .dot {
|
||||
width: 0.45rem;
|
||||
height: 0.45rem;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
box-shadow: 0 0 0 0.25rem rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.status-badge.healthy {
|
||||
background: rgba(123, 195, 143, 0.12);
|
||||
color: var(--color-success);
|
||||
border-color: rgba(123, 195, 143, 0.22);
|
||||
}
|
||||
|
||||
.status-badge.unhealthy {
|
||||
background: rgba(221, 126, 114, 0.12);
|
||||
color: var(--color-danger);
|
||||
border-color: rgba(221, 126, 114, 0.22);
|
||||
}
|
||||
|
||||
.status-badge.unknown,
|
||||
.status-badge.disabled {
|
||||
background: rgba(160, 135, 114, 0.14);
|
||||
color: var(--text-muted);
|
||||
border-color: rgba(160, 135, 114, 0.2);
|
||||
}
|
||||
|
||||
.status-badge.enabled {
|
||||
background: var(--color-primary-soft);
|
||||
color: var(--color-primary-strong);
|
||||
border-color: rgba(239, 175, 99, 0.22);
|
||||
}
|
||||
|
||||
.time-text,
|
||||
.muted,
|
||||
.small {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.muted,
|
||||
.small {
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.cell-name {
|
||||
font-weight: 600;
|
||||
font-size: 0.96rem;
|
||||
}
|
||||
|
||||
.cell-url,
|
||||
.cell-type {
|
||||
margin-top: 0.22rem;
|
||||
color: var(--text-soft);
|
||||
font-size: 0.78rem;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.error-text {
|
||||
color: var(--color-danger);
|
||||
font-size: 0.82rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: 'SFMono-Regular', 'Consolas', 'Liberation Mono', monospace;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.85rem;
|
||||
padding: 1rem 1.2rem 1.1rem;
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.page-info {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.empty-hint {
|
||||
padding: 2rem 1rem;
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.el-config-provider,
|
||||
.el-overlay,
|
||||
.el-popper {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.el-button {
|
||||
min-height: 2.75rem;
|
||||
border-radius: var(--radius-control);
|
||||
font-weight: 600;
|
||||
transition:
|
||||
transform var(--motion-fast) var(--ease-standard),
|
||||
border-color var(--motion-fast) var(--ease-standard),
|
||||
background-color var(--motion-fast) var(--ease-standard),
|
||||
color var(--motion-fast) var(--ease-standard),
|
||||
box-shadow var(--motion-fast) var(--ease-standard);
|
||||
}
|
||||
|
||||
.el-button:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.el-button--default,
|
||||
.el-button--info {
|
||||
background: rgba(255, 244, 232, 0.03);
|
||||
border-color: var(--border-color);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.el-button--default:hover,
|
||||
.el-button--info:hover {
|
||||
border-color: var(--border-strong);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.el-button--primary {
|
||||
color: #20150d;
|
||||
background: linear-gradient(135deg, var(--color-primary) 0%, var(--color-primary-strong) 100%);
|
||||
border-color: transparent;
|
||||
box-shadow: 0 10px 24px rgba(217, 139, 66, 0.2);
|
||||
}
|
||||
|
||||
.el-button--primary:hover,
|
||||
.el-button--primary:focus-visible {
|
||||
background: linear-gradient(135deg, var(--color-primary-strong) 0%, #f2bf82 100%);
|
||||
color: #1d140d;
|
||||
}
|
||||
|
||||
.el-button.is-text,
|
||||
.el-button--text {
|
||||
min-height: 2.3rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.el-button.is-circle {
|
||||
min-width: 2.75rem;
|
||||
padding: 0.55rem;
|
||||
}
|
||||
|
||||
.el-input__wrapper,
|
||||
.el-textarea__inner,
|
||||
.el-select__wrapper,
|
||||
.el-input-number,
|
||||
.el-date-editor.el-input,
|
||||
.el-date-editor.el-input__wrapper {
|
||||
background: rgba(255, 244, 232, 0.02) !important;
|
||||
border-radius: var(--radius-control);
|
||||
box-shadow: inset 0 0 0 1px rgba(218, 183, 142, 0.14) !important;
|
||||
transition: box-shadow var(--motion-fast) var(--ease-standard), background-color var(--motion-fast) var(--ease-standard);
|
||||
}
|
||||
|
||||
.el-input__wrapper.is-focus,
|
||||
.el-select__wrapper.is-focused,
|
||||
.el-textarea__inner:focus,
|
||||
.el-input-number:focus-within {
|
||||
box-shadow: inset 0 0 0 1px rgba(239, 175, 99, 0.42), 0 0 0 0.22rem rgba(217, 139, 66, 0.12) !important;
|
||||
}
|
||||
|
||||
.el-input__inner,
|
||||
.el-textarea__inner,
|
||||
.el-select__selected-item,
|
||||
.el-form-item__label,
|
||||
.el-checkbox__label,
|
||||
.el-radio__label,
|
||||
.el-drawer__title,
|
||||
.el-dialog__title {
|
||||
color: var(--text-primary) !important;
|
||||
}
|
||||
|
||||
.el-form-item__label,
|
||||
.el-checkbox__label,
|
||||
.el-radio__label {
|
||||
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; }
|
||||
.el-checkbox__input.is-checked .el-checkbox__inner,
|
||||
.el-radio__input.is-checked .el-radio__inner,
|
||||
.el-switch.is-checked .el-switch__core {
|
||||
background-color: var(--color-primary) !important;
|
||||
border-color: var(--color-primary) !important;
|
||||
}
|
||||
|
||||
.el-switch__core {
|
||||
min-width: 2.9rem;
|
||||
}
|
||||
|
||||
.el-tag {
|
||||
border-radius: var(--radius-pill);
|
||||
border-color: transparent;
|
||||
padding-inline: 0.65rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.el-drawer,
|
||||
.el-dialog {
|
||||
--el-dialog-bg-color: var(--bg-surface-strong);
|
||||
--el-dialog-padding-primary: 1.4rem;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 244, 232, 0.03), transparent 20%),
|
||||
var(--bg-surface-strong);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: calc(var(--radius-panel) + 0.15rem) 0 0 calc(var(--radius-panel) + 0.15rem);
|
||||
color: var(--text-primary);
|
||||
box-shadow: var(--shadow-soft);
|
||||
}
|
||||
|
||||
.el-dialog {
|
||||
border-radius: calc(var(--radius-panel) + 0.15rem);
|
||||
}
|
||||
|
||||
.el-overlay-dialog {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.el-select-dropdown,
|
||||
.el-popper.is-light,
|
||||
.el-picker-panel,
|
||||
.el-message-box,
|
||||
.el-descriptions,
|
||||
.el-popconfirm {
|
||||
background: var(--bg-surface-strong) !important;
|
||||
border-color: var(--border-color) !important;
|
||||
color: var(--text-primary) !important;
|
||||
}
|
||||
|
||||
.el-select-dropdown__item,
|
||||
.el-picker-panel__content,
|
||||
.el-message-box__title,
|
||||
.el-message-box__message,
|
||||
.el-descriptions__label,
|
||||
.el-descriptions__content {
|
||||
color: var(--text-primary) !important;
|
||||
}
|
||||
|
||||
.el-select-dropdown__item.hover,
|
||||
.el-select-dropdown__item:hover,
|
||||
.el-select-dropdown__item.is-selected {
|
||||
background: rgba(255, 244, 232, 0.05) !important;
|
||||
}
|
||||
|
||||
.el-table {
|
||||
--el-table-bg-color: transparent;
|
||||
--el-table-tr-bg-color: transparent;
|
||||
--el-table-row-hover-bg-color: rgba(255, 244, 232, 0.03);
|
||||
--el-table-header-bg-color: rgba(255, 244, 232, 0.02);
|
||||
--el-table-border-color: rgba(218, 183, 142, 0.1);
|
||||
--el-table-text-color: var(--text-primary);
|
||||
--el-table-header-text-color: var(--text-soft);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.el-table th.el-table__cell {
|
||||
font-size: 0.73rem;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.el-table td.el-table__cell,
|
||||
.el-table th.el-table__cell {
|
||||
padding-block: 0.95rem;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.el-table tr {
|
||||
transition: background-color var(--motion-fast) var(--ease-standard);
|
||||
}
|
||||
|
||||
.el-table::before,
|
||||
.el-table__inner-wrapper::before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.el-checkbox-group,
|
||||
.el-radio-group {
|
||||
gap: 0.55rem;
|
||||
}
|
||||
|
||||
.el-loading-mask {
|
||||
background: rgba(18, 15, 13, 0.45);
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
|
||||
.detail-section {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.detail-label {
|
||||
margin-bottom: 0.45rem;
|
||||
color: var(--text-soft);
|
||||
font-size: 0.78rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.code-block {
|
||||
max-height: 18rem;
|
||||
overflow: auto;
|
||||
padding: 0.95rem 1rem;
|
||||
border-radius: var(--radius-control);
|
||||
background: rgba(17, 13, 11, 0.72);
|
||||
border: 1px solid rgba(218, 183, 142, 0.12);
|
||||
color: var(--text-secondary);
|
||||
font-family: 'SFMono-Regular', 'Consolas', 'Liberation Mono', monospace;
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.65;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 0.45rem;
|
||||
height: 0.45rem;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
border-radius: 999px;
|
||||
background: rgba(218, 183, 142, 0.24);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(218, 183, 142, 0.38);
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.metric-grid {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.metric-grid {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.page-title {
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.page-header,
|
||||
.section-header,
|
||||
.filters,
|
||||
.toolbar-cluster,
|
||||
.action-row {
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.filters > *,
|
||||
.toolbar-cluster > * {
|
||||
flex: 1 1 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after {
|
||||
animation: none !important;
|
||||
transition: none !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,132 +1,260 @@
|
||||
<template>
|
||||
<div class="layout">
|
||||
<!-- Sidebar -->
|
||||
<aside class="sidebar">
|
||||
<div class="sidebar-logo">
|
||||
<span class="logo-icon">⚡</span>
|
||||
<span class="logo-text">SmartUp</span>
|
||||
<div class="layout-shell">
|
||||
<div v-if="mobileNavOpen" class="sidebar-backdrop" @click="mobileNavOpen = false" />
|
||||
|
||||
<aside class="sidebar" :class="{ open: mobileNavOpen }">
|
||||
<div class="sidebar-brand">
|
||||
<div class="brand-mark-wrap">
|
||||
<img src="/favicon.svg" alt="SmartUp" class="brand-mark" />
|
||||
</div>
|
||||
<div class="brand-copy">
|
||||
<span class="brand-kicker">Control Console</span>
|
||||
<span class="brand-name brand-type">SmartUp</span>
|
||||
</div>
|
||||
<button type="button" class="sidebar-close" @click="mobileNavOpen = false" aria-label="关闭导航">
|
||||
<el-icon><Close /></el-icon>
|
||||
</button>
|
||||
</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" />
|
||||
<div class="sidebar-section">
|
||||
<div class="sidebar-section-title">监控中枢</div>
|
||||
<nav class="sidebar-nav">
|
||||
<router-link to="/upstreams" class="nav-item" active-class="active" @click="closeMobileNav">
|
||||
<span class="nav-icon"><el-icon><Connection /></el-icon></span>
|
||||
<span class="nav-copy">
|
||||
<strong>上游管理</strong>
|
||||
<small>轮询、健康度、倍率快照</small>
|
||||
</span>
|
||||
</router-link>
|
||||
<router-link to="/websites" class="nav-item" active-class="active" @click="closeMobileNav">
|
||||
<span class="nav-icon"><el-icon><OfficeBuilding /></el-icon></span>
|
||||
<span class="nav-copy">
|
||||
<strong>网站管理</strong>
|
||||
<small>目标站点、分组映射、自动同步</small>
|
||||
</span>
|
||||
</router-link>
|
||||
<router-link to="/webhooks" class="nav-item" active-class="active" @click="closeMobileNav">
|
||||
<span class="nav-icon"><el-icon><Bell /></el-icon></span>
|
||||
<span class="nav-copy">
|
||||
<strong>Webhook 通知</strong>
|
||||
<small>投递器、订阅事件、测试回路</small>
|
||||
</span>
|
||||
</router-link>
|
||||
<router-link to="/logs" class="nav-item" active-class="active" @click="closeMobileNav">
|
||||
<span class="nav-icon"><el-icon><Document /></el-icon></span>
|
||||
<span class="nav-copy">
|
||||
<strong>通知日志</strong>
|
||||
<small>响应记录、筛选、追踪异常</small>
|
||||
</span>
|
||||
</router-link>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<!-- 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>
|
||||
<div class="sidebar-divider" />
|
||||
|
||||
<div class="sidebar-section sidebar-section-pages">
|
||||
<div class="sidebar-section-head">
|
||||
<div>
|
||||
<div class="sidebar-section-title">自定义页面</div>
|
||||
<p class="sidebar-section-note">聚合外部控制台与嵌入页面</p>
|
||||
</div>
|
||||
<router-link to="/custom-pages" class="nav-manage-link" title="管理自定义页面" @click="closeMobileNav">
|
||||
<el-icon><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> 添加页面
|
||||
<div class="custom-page-stack">
|
||||
<template v-if="customPages.length > 0">
|
||||
<a
|
||||
v-for="page in customPages"
|
||||
:key="page.id"
|
||||
class="nav-item nav-item-custom"
|
||||
:class="{ active: isCustomPageActive(page.id) }"
|
||||
href="#"
|
||||
@click.prevent="openCustomPage(page)"
|
||||
>
|
||||
<span class="nav-icon"><el-icon><component :is="iconMap[page.icon] || LinkIcon" /></el-icon></span>
|
||||
<span class="nav-copy">
|
||||
<strong class="nav-custom-label">{{ page.name }}</strong>
|
||||
<small>{{ page.description || '外部控制台入口' }}</small>
|
||||
</span>
|
||||
</a>
|
||||
</template>
|
||||
<router-link v-else to="/custom-pages" class="add-page-link" @click="closeMobileNav">
|
||||
<el-icon><Plus /></el-icon>
|
||||
<span>添加页面</span>
|
||||
</router-link>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Main -->
|
||||
<div class="main-wrap">
|
||||
<!-- Topbar -->
|
||||
<header class="topbar">
|
||||
<div class="topbar-title">{{ pageTitle }}</div>
|
||||
<header class="topbar" :class="{ compact: customPageTabs.length > 0 && isCustomPageRoute }">
|
||||
<div class="topbar-main">
|
||||
<button type="button" class="mobile-menu" @click="mobileNavOpen = true" aria-label="打开导航">
|
||||
<el-icon><Operation /></el-icon>
|
||||
</button>
|
||||
</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> 退出
|
||||
<div class="topbar-status">
|
||||
<span class="status-badge enabled"><span class="dot" />在线控制台</span>
|
||||
<span class="user-email"><el-icon><User /></el-icon>{{ auth.email }}</span>
|
||||
</div>
|
||||
<el-button size="small" text @click="handleLogout">
|
||||
<el-icon><SwitchButton /></el-icon>
|
||||
退出
|
||||
</el-button>
|
||||
</div>
|
||||
</header>
|
||||
<!-- Page content -->
|
||||
<main class="page-content">
|
||||
<router-view />
|
||||
|
||||
<main class="page-content" :class="{ 'has-custom-tabs': customPageTabs.length > 0 && isCustomPageRoute }">
|
||||
<router-view v-slot="{ Component }">
|
||||
<component :is="Component" v-if="!isCustomPageRoute" />
|
||||
</router-view>
|
||||
|
||||
<div v-show="isCustomPageRoute && customPageTabs.length > 0" class="custom-tabs-shell">
|
||||
<div class="custom-tabs-bar">
|
||||
<button
|
||||
v-for="tab in customPageTabs"
|
||||
:key="tab.id"
|
||||
type="button"
|
||||
class="custom-tab"
|
||||
:class="{ active: tab.id === activeCustomPageId }"
|
||||
@click="activateCustomPage(tab.id)"
|
||||
>
|
||||
<el-icon><component :is="iconMap[tab.icon] || LinkIcon" /></el-icon>
|
||||
<span>{{ tab.name }}</span>
|
||||
<el-icon class="custom-tab-close" @click.stop="closeCustomPageTab(tab.id)"><Close /></el-icon>
|
||||
</button>
|
||||
</div>
|
||||
<div class="custom-tabs-content">
|
||||
<PageViewer
|
||||
v-for="tab in customPageTabs"
|
||||
:key="tab.id"
|
||||
:page-id="tab.id"
|
||||
:active="isCustomPageRoute && tab.id === activeCustomPageId"
|
||||
embedded
|
||||
v-show="tab.id === activeCustomPageId"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted, markRaw } from 'vue'
|
||||
import { ref, computed, onMounted, onUnmounted, watch, 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,
|
||||
Link as LinkIcon,
|
||||
Plus,
|
||||
Setting,
|
||||
Close,
|
||||
Monitor,
|
||||
SetUp,
|
||||
Reading,
|
||||
Cpu,
|
||||
DataLine,
|
||||
Grid,
|
||||
Ticket,
|
||||
Wallet,
|
||||
Key,
|
||||
Tools,
|
||||
Star,
|
||||
House,
|
||||
OfficeBuilding,
|
||||
Connection,
|
||||
Bell,
|
||||
Document,
|
||||
User,
|
||||
SwitchButton,
|
||||
Operation,
|
||||
} from '@element-plus/icons-vue'
|
||||
import { customPagesApi, type CustomPageData } from '@/api'
|
||||
import PageViewer from '@/views/PageViewer.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const auth = useAuthStore()
|
||||
const mobileNavOpen = ref(false)
|
||||
|
||||
// ---- 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),
|
||||
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[]>([])
|
||||
const customPageTabs = ref<CustomPageData[]>([])
|
||||
const activeCustomPageId = ref<number | null>(null)
|
||||
const isCustomPageRoute = computed(() => route.path.startsWith('/page/'))
|
||||
|
||||
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 : '嵌入页面'
|
||||
customPages.value = res.data.filter((page) => page.enabled)
|
||||
customPageTabs.value = customPageTabs.value
|
||||
.map((tab) => customPages.value.find((page) => page.id === tab.id))
|
||||
.filter((page): page is CustomPageData => Boolean(page))
|
||||
if (activeCustomPageId.value && !customPageTabs.value.some((tab) => tab.id === activeCustomPageId.value)) {
|
||||
activeCustomPageId.value = customPageTabs.value[0]?.id ?? null
|
||||
}
|
||||
} catch {
|
||||
// sidebar data is non-blocking
|
||||
}
|
||||
return staticTitles[route.path] || 'SmartUp'
|
||||
})
|
||||
}
|
||||
|
||||
function onPagesUpdated() {
|
||||
loadCustomPages()
|
||||
}
|
||||
|
||||
function closeMobileNav() {
|
||||
mobileNavOpen.value = false
|
||||
}
|
||||
|
||||
function openCustomPage(page: CustomPageData) {
|
||||
if (!customPageTabs.value.some((tab) => tab.id === page.id)) {
|
||||
customPageTabs.value.push(page)
|
||||
}
|
||||
closeMobileNav()
|
||||
activateCustomPage(page.id)
|
||||
}
|
||||
|
||||
function activateCustomPage(id: number) {
|
||||
activeCustomPageId.value = id
|
||||
if (route.path !== `/page/${id}`) router.push(`/page/${id}`)
|
||||
}
|
||||
|
||||
function closeCustomPageTab(id: number) {
|
||||
const index = customPageTabs.value.findIndex((tab) => tab.id === id)
|
||||
if (index === -1) return
|
||||
customPageTabs.value.splice(index, 1)
|
||||
if (activeCustomPageId.value !== id) return
|
||||
const next = customPageTabs.value[index] || customPageTabs.value[index - 1]
|
||||
if (next) {
|
||||
activateCustomPage(next.id)
|
||||
} else {
|
||||
activeCustomPageId.value = null
|
||||
router.push('/custom-pages')
|
||||
}
|
||||
}
|
||||
|
||||
function isCustomPageActive(id: number) {
|
||||
return isCustomPageRoute.value && activeCustomPageId.value === id
|
||||
}
|
||||
|
||||
function handleLogout() {
|
||||
auth.clear()
|
||||
@@ -137,151 +265,458 @@ onMounted(() => {
|
||||
loadCustomPages()
|
||||
window.addEventListener('custom-pages-updated', onPagesUpdated)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('custom-pages-updated', onPagesUpdated)
|
||||
})
|
||||
|
||||
watch([() => route.path, customPages], () => {
|
||||
mobileNavOpen.value = false
|
||||
if (!isCustomPageRoute.value) return
|
||||
const id = Number(route.params.id)
|
||||
if (!id || activeCustomPageId.value === id) return
|
||||
const page = customPages.value.find((item) => item.id === id)
|
||||
if (page) openCustomPage(page)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.layout {
|
||||
.layout-shell {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(217, 139, 66, 0.08), transparent 22%),
|
||||
radial-gradient(circle at bottom right, rgba(134, 183, 199, 0.05), transparent 18%);
|
||||
}
|
||||
|
||||
.sidebar-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(10, 8, 7, 0.56);
|
||||
backdrop-filter: blur(3px);
|
||||
z-index: 30;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: var(--sidebar-width);
|
||||
background: var(--bg-surface);
|
||||
border-right: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-shrink: 0;
|
||||
position: fixed;
|
||||
inset: 0 auto 0 0;
|
||||
width: min(86vw, 20rem);
|
||||
padding: 1rem;
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 1rem;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 244, 232, 0.03), transparent 20%),
|
||||
rgba(22, 17, 14, 0.96);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 0 calc(var(--radius-shell) + 0.4rem) calc(var(--radius-shell) + 0.4rem) 0;
|
||||
box-shadow: 1.5rem 0 3rem rgba(0, 0, 0, 0.28);
|
||||
transform: translateX(-104%);
|
||||
transition: transform var(--motion-base) var(--ease-standard);
|
||||
z-index: 40;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.sidebar-logo {
|
||||
height: var(--topbar-height);
|
||||
display: flex;
|
||||
.sidebar.open {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.sidebar-brand,
|
||||
.sidebar-section,
|
||||
.topbar,
|
||||
.custom-tabs-bar {
|
||||
border: 1px solid var(--border-color);
|
||||
box-shadow: var(--shadow-panel);
|
||||
backdrop-filter: blur(14px);
|
||||
}
|
||||
|
||||
.sidebar-brand {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
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;
|
||||
gap: 0.85rem;
|
||||
padding: 1rem;
|
||||
border-radius: 1.5rem;
|
||||
background: rgba(255, 244, 232, 0.03);
|
||||
}
|
||||
|
||||
.logo-icon { font-size: 22px; }
|
||||
.logo-text {
|
||||
font-size: 18px;
|
||||
.brand-mark-wrap {
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: var(--radius-pill);
|
||||
background: linear-gradient(180deg, rgba(217, 139, 66, 0.16), rgba(217, 139, 66, 0.04));
|
||||
box-shadow: inset 0 0 0 1px rgba(239, 175, 99, 0.16);
|
||||
}
|
||||
|
||||
.brand-mark {
|
||||
width: 1.7rem;
|
||||
height: 1.7rem;
|
||||
}
|
||||
|
||||
.brand-copy {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.brand-kicker,
|
||||
.topbar-kicker {
|
||||
color: var(--text-soft);
|
||||
font-size: 0.72rem;
|
||||
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);
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
padding: 8px 14px 4px;
|
||||
}
|
||||
|
||||
.brand-name {
|
||||
font-size: 1.6rem;
|
||||
font-weight: 800;
|
||||
line-height: 0.95;
|
||||
}
|
||||
|
||||
.sidebar-close,
|
||||
.mobile-menu {
|
||||
min-width: 2.75rem;
|
||||
min-height: 2.75rem;
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 0.95rem;
|
||||
background: rgba(255, 244, 232, 0.03);
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.sidebar-section {
|
||||
display: grid;
|
||||
gap: 0.85rem;
|
||||
padding: 1rem;
|
||||
border-radius: 1.5rem;
|
||||
background: rgba(255, 244, 232, 0.02);
|
||||
}
|
||||
|
||||
.sidebar-section-title {
|
||||
color: var(--text-soft);
|
||||
font-size: 0.74rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.sidebar-section-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
.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;
|
||||
.sidebar-section-note {
|
||||
margin-top: 0.35rem;
|
||||
color: var(--text-soft);
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.nav-manage-link:hover { color: var(--color-primary); background: var(--bg-elevated); }
|
||||
|
||||
.nav-divider {
|
||||
.sidebar-nav,
|
||||
.custom-page-stack {
|
||||
display: grid;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
|
||||
.sidebar-divider {
|
||||
height: 1px;
|
||||
background: var(--border-color);
|
||||
margin: 8px 10px;
|
||||
margin: 0 0.4rem;
|
||||
background: linear-gradient(90deg, transparent, var(--border-color), transparent);
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
display: flex;
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
align-items: flex-start;
|
||||
gap: 0.8rem;
|
||||
padding: 0.95rem 1rem;
|
||||
border-radius: 1rem;
|
||||
border: 1px solid transparent;
|
||||
text-decoration: none;
|
||||
color: var(--text-secondary);
|
||||
background: rgba(255, 244, 232, 0.015);
|
||||
transition:
|
||||
transform var(--motion-fast) var(--ease-standard),
|
||||
border-color var(--motion-fast) var(--ease-standard),
|
||||
background-color var(--motion-fast) var(--ease-standard),
|
||||
color var(--motion-fast) var(--ease-standard);
|
||||
}
|
||||
|
||||
.nav-item:hover {
|
||||
transform: translateY(-1px);
|
||||
border-color: rgba(239, 175, 99, 0.18);
|
||||
background: rgba(255, 244, 232, 0.04);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.nav-item.active {
|
||||
border-color: rgba(239, 175, 99, 0.24);
|
||||
background:
|
||||
linear-gradient(135deg, rgba(217, 139, 66, 0.18), rgba(217, 139, 66, 0.05)),
|
||||
rgba(255, 244, 232, 0.03);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.nav-icon {
|
||||
width: 2.2rem;
|
||||
height: 2.2rem;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: var(--radius-pill);
|
||||
background: rgba(255, 244, 232, 0.05);
|
||||
color: var(--color-primary-strong);
|
||||
}
|
||||
|
||||
.nav-copy {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 0.18rem;
|
||||
}
|
||||
|
||||
.nav-copy strong,
|
||||
.nav-custom-label {
|
||||
font-size: 0.93rem;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.nav-copy small {
|
||||
color: var(--text-soft);
|
||||
font-size: 0.76rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.nav-manage-link,
|
||||
.add-page-link {
|
||||
min-height: 2.75rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 9px 14px;
|
||||
border-radius: 8px;
|
||||
justify-content: center;
|
||||
gap: 0.45rem;
|
||||
padding: 0.7rem 0.95rem;
|
||||
border-radius: 0.95rem;
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-secondary);
|
||||
text-decoration: none;
|
||||
font-size: 13.5px;
|
||||
font-weight: 500;
|
||||
transition: all 0.15s ease;
|
||||
background: rgba(255, 244, 232, 0.02);
|
||||
}
|
||||
.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 {
|
||||
.main-wrap {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
padding: var(--shell-padding);
|
||||
}
|
||||
|
||||
.topbar {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
min-height: var(--topbar-height);
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
padding: 0.9rem 1rem;
|
||||
border-radius: var(--radius-shell);
|
||||
background: rgba(25, 19, 16, 0.7);
|
||||
}
|
||||
|
||||
.topbar:not(.compact) {
|
||||
width: min(100%, var(--content-max));
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.topbar.compact {
|
||||
min-height: 2.8rem;
|
||||
padding: 0.4rem 0.75rem;
|
||||
border-radius: calc(var(--radius-shell) - 0.35rem);
|
||||
}
|
||||
|
||||
.topbar-main,
|
||||
.topbar-right,
|
||||
.topbar-status,
|
||||
.user-email {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.8rem;
|
||||
}
|
||||
|
||||
.topbar-main {
|
||||
position: absolute;
|
||||
left: 1rem;
|
||||
}
|
||||
|
||||
.topbar-right {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.topbar-status {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.user-email {
|
||||
min-height: 2rem;
|
||||
padding: 0.25rem 0.7rem;
|
||||
border-radius: var(--radius-pill);
|
||||
background: rgba(255, 244, 232, 0.03);
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.86rem;
|
||||
}
|
||||
|
||||
.page-content {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 0.2rem;
|
||||
}
|
||||
|
||||
.page-content.has-custom-tabs {
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.custom-tabs-shell {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.custom-tabs-bar {
|
||||
min-height: 2.4rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.3rem 0.45rem;
|
||||
border-radius: calc(var(--radius-shell) - 0.45rem);
|
||||
background: rgba(25, 19, 16, 0.7);
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.custom-tab {
|
||||
min-height: 1.9rem;
|
||||
min-width: 6rem;
|
||||
max-width: 12rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0 0.65rem;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-pill);
|
||||
background: rgba(255, 244, 232, 0.02);
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.82rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.custom-tab span {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.nav-empty-pages {
|
||||
padding: 4px 8px;
|
||||
.custom-tab.active {
|
||||
background: linear-gradient(135deg, rgba(217, 139, 66, 0.16), rgba(217, 139, 66, 0.04));
|
||||
border-color: rgba(239, 175, 99, 0.18);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.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 {
|
||||
.custom-tab-close {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
padding: 0.1rem;
|
||||
border-radius: 0.35rem;
|
||||
}
|
||||
|
||||
.custom-tab-close:hover {
|
||||
background: rgba(221, 126, 114, 0.14);
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.custom-tabs-content {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
padding: 0.45rem;
|
||||
border-radius: var(--radius-shell);
|
||||
overflow: hidden;
|
||||
background: rgba(10, 8, 7, 0.16);
|
||||
}
|
||||
|
||||
.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); }
|
||||
@media (min-width: 1024px) {
|
||||
.layout-shell {
|
||||
gap: var(--shell-padding);
|
||||
padding: var(--shell-padding);
|
||||
}
|
||||
|
||||
.page-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 24px;
|
||||
.sidebar {
|
||||
position: sticky;
|
||||
top: var(--shell-padding);
|
||||
width: var(--sidebar-width);
|
||||
height: calc(100vh - (var(--shell-padding) * 2));
|
||||
border-radius: calc(var(--radius-shell) + 0.4rem);
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.main-wrap {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.sidebar-close,
|
||||
.mobile-menu,
|
||||
.sidebar-backdrop {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1023px) {
|
||||
.topbar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.topbar {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.topbar-main {
|
||||
position: static;
|
||||
}
|
||||
|
||||
.topbar-right,
|
||||
.topbar-status {
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.user-email {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.custom-tabs-bar {
|
||||
min-height: 3.7rem;
|
||||
}
|
||||
|
||||
.custom-tab {
|
||||
min-height: 2.9rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -16,6 +16,7 @@ const router = createRouter({
|
||||
redirect: '/upstreams',
|
||||
children: [
|
||||
{ path: 'upstreams', component: () => import('@/views/Upstreams.vue') },
|
||||
{ path: 'websites', component: () => import('@/views/Websites.vue') },
|
||||
{ path: 'webhooks', component: () => import('@/views/Webhooks.vue') },
|
||||
{ path: 'logs', component: () => import('@/views/NotificationLogs.vue') },
|
||||
{ path: 'custom-pages', component: () => import('@/views/CustomPages.vue') },
|
||||
|
||||
@@ -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>
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 20px;
|
||||
.custom-pages-shell {
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
|
||||
.page-block {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
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;
|
||||
|
||||
+360
-81
@@ -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;
|
||||
font-weight: 700;
|
||||
background: linear-gradient(135deg, #6366f1, #818cf8);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
margin-bottom: 6px;
|
||||
.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;
|
||||
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
+325
-175
@@ -1,82 +1,148 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- Header -->
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<div class="shell-page page-section upstreams-page">
|
||||
<section class="page-header upstreams-hero surface-card">
|
||||
<div class="page-heading">
|
||||
<p class="page-kicker">Monitoring Matrix</p>
|
||||
<h2 class="page-title">上游管理</h2>
|
||||
<p class="page-desc">管理 API 上游服务,支持多种认证方式和定时监听</p>
|
||||
<p class="page-desc">
|
||||
管理 API 上游服务、认证方式与轮询策略。这里优先展示健康度、检测节奏和错误信号,减少你在异常发生时的定位成本。
|
||||
</p>
|
||||
</div>
|
||||
<el-button type="primary" @click="openCreate">
|
||||
<el-icon><Plus /></el-icon> 新增上游
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<!-- Table -->
|
||||
<div class="card">
|
||||
<el-table :data="list" v-loading="tableLoading" row-key="id" style="width:100%">
|
||||
<el-table-column label="名称" min-width="140">
|
||||
<div class="toolbar-cluster hero-actions">
|
||||
<el-button @click="loadList" :loading="tableLoading">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
刷新列表
|
||||
</el-button>
|
||||
<el-button type="primary" @click="openCreate">
|
||||
<el-icon><Plus /></el-icon>
|
||||
新增上游
|
||||
</el-button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="metric-grid">
|
||||
<article class="surface-card metric-card">
|
||||
<div class="metric-label">Total Sources</div>
|
||||
<div class="metric-value">{{ metrics.total }}</div>
|
||||
<p class="metric-note">当前纳管的上游节点总数</p>
|
||||
</article>
|
||||
<article class="surface-card metric-card">
|
||||
<div class="metric-label">Healthy</div>
|
||||
<div class="metric-value">{{ metrics.healthy }}</div>
|
||||
<p class="metric-note">最近一次检测返回健康状态</p>
|
||||
</article>
|
||||
<article class="surface-card metric-card">
|
||||
<div class="metric-label">Enabled</div>
|
||||
<div class="metric-value">{{ metrics.enabled }}</div>
|
||||
<p class="metric-note">已启用定时检测的上游节点</p>
|
||||
</article>
|
||||
<article class="surface-card metric-card">
|
||||
<div class="metric-label">Attention</div>
|
||||
<div class="metric-value">{{ metrics.unhealthy }}</div>
|
||||
<p class="metric-note">需要处理错误或网络异常的节点</p>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="surface-card data-stage">
|
||||
<div class="section-header data-stage-head">
|
||||
<div>
|
||||
<div class="section-caption">Upstream Registry</div>
|
||||
<h3 class="data-stage-title brand-type">检测与变更控制台</h3>
|
||||
</div>
|
||||
<p class="data-stage-note">点击详情可查看快照历史、分组倍率与最近错误。</p>
|
||||
</div>
|
||||
|
||||
<el-table :data="list" v-loading="tableLoading" row-key="id" style="width: 100%">
|
||||
<el-table-column label="来源" min-width="220">
|
||||
<template #default="{ row }">
|
||||
<div class="cell-name">{{ row.name }}</div>
|
||||
<div class="cell-url">{{ row.base_url }}</div>
|
||||
<div class="cell-url mono">{{ row.base_url }}</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="110">
|
||||
|
||||
<el-table-column label="状态" width="118">
|
||||
<template #default="{ row }">
|
||||
<span :class="['status-badge', row.last_status]">
|
||||
<span class="dot"></span>
|
||||
<span class="dot" />
|
||||
{{ statusLabel(row.last_status) }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="启用" width="80">
|
||||
|
||||
<el-table-column label="启用" width="88" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-switch v-model="row.enabled" @change="toggleEnabled(row)" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="检测间隔" width="100">
|
||||
<template #default="{ row }">{{ row.check_interval_seconds }}s</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="最近检测" min-width="145">
|
||||
|
||||
<el-table-column label="认证" width="132">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.last_checked_at" class="time-text">{{ fmtTime(row.last_checked_at) }}</span>
|
||||
<span class="status-badge auth-badge">{{ authLabel(row.auth_type) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="检测间隔" width="112">
|
||||
<template #default="{ row }">
|
||||
<span class="mono time-inline">{{ row.check_interval_seconds }}s</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="最近检测" min-width="168">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.last_checked_at" class="time-text mono">{{ fmtTime(row.last_checked_at) }}</span>
|
||||
<span v-else class="muted">未检测</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="最近错误" min-width="160">
|
||||
|
||||
<el-table-column label="最近错误" min-width="220">
|
||||
<template #default="{ row }">
|
||||
<el-tooltip v-if="row.last_error" :content="row.last_error" placement="top" :show-after="300">
|
||||
<span class="error-text">{{ row.last_error.substring(0, 40) }}…</span>
|
||||
<span class="error-text">{{ shrinkError(row.last_error) }}</span>
|
||||
</el-tooltip>
|
||||
<span v-else class="muted">—</span>
|
||||
<span v-else class="muted">无异常</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="250">
|
||||
|
||||
<el-table-column label="操作" width="258" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<div class="action-row">
|
||||
<el-button size="small" text @click="openEdit(row)" title="编辑"><el-icon><Edit /></el-icon></el-button>
|
||||
<el-button size="small" text @click="openEdit(row)" title="编辑">
|
||||
<el-icon><Edit /></el-icon>
|
||||
</el-button>
|
||||
<el-button size="small" text type="success" @click="testUpstream(row)" :loading="row._testing">测试</el-button>
|
||||
<el-button size="small" text type="primary" @click="checkNow(row)" :loading="row._checking">检测</el-button>
|
||||
<el-button size="small" text type="info" @click="openDetail(row)">
|
||||
<el-icon style="margin-right:2px"><List /></el-icon>详情
|
||||
<el-icon><List /></el-icon>
|
||||
详情
|
||||
</el-button>
|
||||
<el-button size="small" text type="danger" @click="confirmDelete(row)" title="删除">
|
||||
<el-icon><Delete /></el-icon>
|
||||
</el-button>
|
||||
<el-button size="small" text type="danger" @click="confirmDelete(row)" title="删除"><el-icon><Delete /></el-icon></el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ======= Create / Edit Drawer ======= -->
|
||||
<el-drawer
|
||||
v-model="drawerVisible"
|
||||
:title="editingId ? '编辑上游' : '新增上游'"
|
||||
size="480px"
|
||||
destroy-on-close
|
||||
>
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="120px" label-position="top">
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-position="top">
|
||||
<el-form-item label="名称" prop="name">
|
||||
<el-input v-model="form.name" placeholder="例:ai98pro" />
|
||||
</el-form-item>
|
||||
<el-form-item v-if="!editingId" label="系统类型(快捷配置)">
|
||||
<el-select v-model="quickPlatform" @change="handlePlatformChange" style="width: 100%">
|
||||
<el-option label="Sub2API" value="sub2api" />
|
||||
<el-option label="New-API (管理员Key)" value="new-api" />
|
||||
<el-option label="New-API (普通账号)" value="new-api-user" />
|
||||
<el-option label="自定义" value="custom" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="Base URL" prop="base_url">
|
||||
<el-input v-model="form.base_url" placeholder="https://example.com" />
|
||||
</el-form-item>
|
||||
@@ -84,7 +150,7 @@
|
||||
<el-input v-model="form.api_prefix" placeholder="/api/v1" />
|
||||
</el-form-item>
|
||||
<el-form-item label="认证方式">
|
||||
<el-select v-model="form.auth_type" style="width:100%">
|
||||
<el-select v-model="form.auth_type" style="width: 100%">
|
||||
<el-option label="无认证" value="none" />
|
||||
<el-option label="Bearer Token" value="bearer" />
|
||||
<el-option label="API Key" value="api_key" />
|
||||
@@ -124,12 +190,12 @@
|
||||
<el-row :gutter="12">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="检测间隔(秒)">
|
||||
<el-input-number v-model="form.check_interval_seconds" :min="60" style="width:100%" />
|
||||
<el-input-number v-model="form.check_interval_seconds" :min="60" style="width: 100%" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="超时(秒)">
|
||||
<el-input-number v-model="form.timeout_seconds" :min="5" :max="120" style="width:100%" />
|
||||
<el-input-number v-model="form.timeout_seconds" :min="5" :max="120" style="width: 100%" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
@@ -143,7 +209,6 @@
|
||||
</template>
|
||||
</el-drawer>
|
||||
|
||||
<!-- ======= Detail Drawer ======= -->
|
||||
<el-drawer
|
||||
v-model="detailVisible"
|
||||
:title="`检测详情 — ${detailUpstream?.name || ''}`"
|
||||
@@ -151,34 +216,35 @@
|
||||
destroy-on-close
|
||||
@open="loadSnapshots"
|
||||
>
|
||||
<!-- Info cards -->
|
||||
<div v-if="detailUpstream" class="info-cards">
|
||||
<div class="info-card">
|
||||
<div class="surface-card info-card">
|
||||
<div class="info-label">状态</div>
|
||||
<span :class="['status-badge', detailUpstream.last_status]">
|
||||
<span class="dot"></span>{{ statusLabel(detailUpstream.last_status) }}
|
||||
<span class="dot" />{{ statusLabel(detailUpstream.last_status) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="info-card">
|
||||
<div class="surface-card info-card">
|
||||
<div class="info-label">最近检测</div>
|
||||
<div class="info-value">{{ detailUpstream.last_checked_at ? fmtTimeFull(detailUpstream.last_checked_at) : '未检测' }}</div>
|
||||
<div class="info-value mono">{{ detailUpstream.last_checked_at ? fmtTimeFull(detailUpstream.last_checked_at) : '未检测' }}</div>
|
||||
</div>
|
||||
<div class="info-card">
|
||||
<div class="surface-card info-card">
|
||||
<div class="info-label">检测间隔</div>
|
||||
<div class="info-value">{{ detailUpstream.check_interval_seconds }}s</div>
|
||||
<div class="info-value mono">{{ detailUpstream.check_interval_seconds }}s</div>
|
||||
</div>
|
||||
<div class="info-card">
|
||||
<div class="surface-card info-card">
|
||||
<div class="info-label">超时</div>
|
||||
<div class="info-value">{{ detailUpstream.timeout_seconds }}s</div>
|
||||
<div class="info-value mono">{{ detailUpstream.timeout_seconds }}s</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="detailUpstream?.last_error" class="error-box">
|
||||
<el-icon><Warning /></el-icon> {{ detailUpstream.last_error }}
|
||||
</div>
|
||||
|
||||
<!-- Snapshot history -->
|
||||
<div v-if="detailUpstream?.last_error" class="error-box">
|
||||
<el-icon><Warning /></el-icon>
|
||||
<span>{{ detailUpstream.last_error }}</span>
|
||||
</div>
|
||||
|
||||
<div class="section-title">
|
||||
<el-icon><Clock /></el-icon> 检测历史
|
||||
<el-icon><Clock /></el-icon>
|
||||
检测历史
|
||||
<span class="section-sub">最近 {{ snapshots.length }} 条</span>
|
||||
</div>
|
||||
|
||||
@@ -189,11 +255,10 @@
|
||||
class="snap-item"
|
||||
:class="{ expanded: expandedId === snap.id }"
|
||||
>
|
||||
<!-- Row header -->
|
||||
<div class="snap-header" @click="toggleExpand(snap)">
|
||||
<div class="snap-left">
|
||||
<el-icon class="expand-icon"><ArrowRight /></el-icon>
|
||||
<span class="snap-time">{{ fmtTimeFull(snap.captured_at) }}</span>
|
||||
<span class="snap-time mono">{{ fmtTimeFull(snap.captured_at) }}</span>
|
||||
</div>
|
||||
<div class="snap-right">
|
||||
<el-tag size="small" type="info">{{ snap.snapshot._groups_count }} 个分组</el-tag>
|
||||
@@ -206,25 +271,24 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Expanded rate table -->
|
||||
<div v-if="expandedId === snap.id" class="snap-body">
|
||||
<el-table
|
||||
:data="groupRows(snap.snapshot)"
|
||||
size="small"
|
||||
:header-cell-style="{ background: 'var(--bg-elevated)', color: 'var(--text-secondary)' }"
|
||||
:cell-style="{ background: 'var(--bg-card)', color: 'var(--text-primary)' }"
|
||||
:header-cell-style="{ background: 'rgba(255, 244, 232, 0.02)', color: 'var(--text-soft)' }"
|
||||
:cell-style="{ background: 'transparent', color: 'var(--text-primary)' }"
|
||||
>
|
||||
<el-table-column prop="group_name" label="分组名称" min-width="140" />
|
||||
<el-table-column prop="platform" label="平台" width="100" />
|
||||
<el-table-column label="当前倍率" width="100">
|
||||
<el-table-column prop="platform" label="平台" width="110" />
|
||||
<el-table-column label="当前倍率" width="110">
|
||||
<template #default="{ row }">
|
||||
<span class="rate-value">{{ row.rate || '—' }}</span>
|
||||
<span class="rate-value mono">{{ row.rate || '—' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="default_rate" label="默认倍率" width="100" />
|
||||
<el-table-column prop="override_rate" label="覆盖倍率" width="100">
|
||||
<el-table-column prop="default_rate" label="默认倍率" width="110" />
|
||||
<el-table-column prop="override_rate" label="覆盖倍率" width="110">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.override_rate" class="override-value">{{ row.override_rate }}</span>
|
||||
<span v-if="row.override_rate" class="override-value mono">{{ row.override_rate }}</span>
|
||||
<span v-else class="muted">—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
@@ -237,7 +301,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div v-if="snapshots.length > 0 || snapshotOffset > 0" class="snap-pagination">
|
||||
<el-button size="small" :disabled="snapshotOffset === 0" @click="prevSnapPage">上一页</el-button>
|
||||
<span class="page-info">第 {{ snapshotOffset / snapshotLimit + 1 }} 页</span>
|
||||
@@ -252,13 +315,12 @@ import { ref, computed, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import type { FormInstance } from 'element-plus'
|
||||
import dayjs from 'dayjs'
|
||||
import { Refresh, Plus, Edit, List, Delete, Warning, Clock, ArrowRight } from '@element-plus/icons-vue'
|
||||
import { upstreamsApi, type UpstreamData } from '@/api'
|
||||
|
||||
// ---- list state ----
|
||||
const list = ref<(UpstreamData & { _testing?: boolean; _checking?: boolean })[]>([])
|
||||
const tableLoading = ref(false)
|
||||
|
||||
// ---- create/edit drawer ----
|
||||
const drawerVisible = ref(false)
|
||||
const saving = ref(false)
|
||||
const editingId = ref<number | null>(null)
|
||||
@@ -282,7 +344,29 @@ const rules = {
|
||||
base_url: [{ required: true, message: '请输入 Base URL', trigger: 'blur' }],
|
||||
}
|
||||
|
||||
// ---- detail drawer ----
|
||||
const quickPlatform = ref('sub2api')
|
||||
|
||||
function handlePlatformChange(val: string) {
|
||||
if (val === 'sub2api') {
|
||||
form.value.api_prefix = '/api/v1'
|
||||
form.value.groups_endpoint = '/groups/available'
|
||||
form.value.rate_endpoint = '/groups/rates'
|
||||
form.value.auth_type = 'login_password'
|
||||
form.value.auth_config.login_path = '/auth/login'
|
||||
} else if (val === 'new-api') {
|
||||
form.value.api_prefix = ''
|
||||
form.value.groups_endpoint = '/api/group/'
|
||||
form.value.rate_endpoint = '/api/option/?key=GroupRatio'
|
||||
form.value.auth_type = 'bearer'
|
||||
} else if (val === 'new-api-user') {
|
||||
form.value.api_prefix = ''
|
||||
form.value.groups_endpoint = '/api/user/self/groups'
|
||||
form.value.rate_endpoint = '/api/user/self/groups'
|
||||
form.value.auth_type = 'login_password'
|
||||
form.value.auth_config.login_path = '/api/user/login'
|
||||
}
|
||||
}
|
||||
|
||||
const detailVisible = ref(false)
|
||||
const detailUpstream = ref<UpstreamData | null>(null)
|
||||
const snapshots = ref<any[]>([])
|
||||
@@ -291,20 +375,28 @@ const expandedId = ref<number | null>(null)
|
||||
const snapshotOffset = ref(0)
|
||||
const snapshotLimit = 20
|
||||
|
||||
// ---- helpers ----
|
||||
const metrics = computed(() => ({
|
||||
total: list.value.length,
|
||||
healthy: list.value.filter((item) => item.last_status === 'healthy').length,
|
||||
enabled: list.value.filter((item) => item.enabled).length,
|
||||
unhealthy: list.value.filter((item) => item.last_status === 'unhealthy').length,
|
||||
}))
|
||||
|
||||
const statusLabel = (s: string) => ({ healthy: '健康', unhealthy: '异常', unknown: '未知' }[s] || s)
|
||||
// Treat server timestamps as UTC (add Z if no timezone info present)
|
||||
const toUTC = (t: string) => /[Z+\-]\d*$/.test(t.trim()) ? t : t + 'Z'
|
||||
const authLabel = (s: string) => ({ none: '无认证', bearer: 'Bearer', api_key: 'API Key', login_password: '邮箱密码' }[s] || s)
|
||||
const toUTC = (t: string) => /[Z+\-]\d*$/.test(t.trim()) ? t : `${t}Z`
|
||||
const fmtTime = (t: string) => dayjs(toUTC(t)).format('MM-DD HH:mm:ss')
|
||||
const fmtTimeFull = (t: string) => dayjs(toUTC(t)).format('YYYY-MM-DD HH:mm:ss')
|
||||
|
||||
|
||||
function groupRows(snapshot: any) {
|
||||
if (!snapshot?.groups) return []
|
||||
return Object.values(snapshot.groups) as any[]
|
||||
}
|
||||
|
||||
// ---- load list ----
|
||||
function shrinkError(value: string) {
|
||||
return value.length > 40 ? `${value.slice(0, 40)}…` : value
|
||||
}
|
||||
|
||||
async function loadList() {
|
||||
tableLoading.value = true
|
||||
try {
|
||||
@@ -315,9 +407,9 @@ async function loadList() {
|
||||
}
|
||||
}
|
||||
|
||||
// ---- create / edit ----
|
||||
function openCreate() {
|
||||
editingId.value = null
|
||||
quickPlatform.value = 'sub2api'
|
||||
form.value = defaultForm()
|
||||
drawerVisible.value = true
|
||||
}
|
||||
@@ -360,7 +452,6 @@ async function handleSave() {
|
||||
}
|
||||
}
|
||||
|
||||
// ---- toggle enabled ----
|
||||
async function toggleEnabled(row: UpstreamData) {
|
||||
try {
|
||||
await upstreamsApi.update(row.id, { enabled: row.enabled })
|
||||
@@ -371,7 +462,6 @@ async function toggleEnabled(row: UpstreamData) {
|
||||
}
|
||||
}
|
||||
|
||||
// ---- test / check-now ----
|
||||
async function testUpstream(row: any) {
|
||||
row._testing = true
|
||||
try {
|
||||
@@ -394,7 +484,6 @@ async function checkNow(row: any) {
|
||||
}
|
||||
}
|
||||
|
||||
// ---- detail drawer ----
|
||||
function openDetail(row: UpstreamData) {
|
||||
detailUpstream.value = row
|
||||
snapshots.value = []
|
||||
@@ -409,7 +498,6 @@ async function loadSnapshots() {
|
||||
try {
|
||||
const res = await upstreamsApi.listSnapshots(detailUpstream.value.id, snapshotLimit, snapshotOffset.value)
|
||||
snapshots.value = res.data
|
||||
// auto-expand the first (latest) snapshot
|
||||
if (res.data.length > 0 && expandedId.value === null) {
|
||||
expandedId.value = res.data[0].id
|
||||
}
|
||||
@@ -436,152 +524,214 @@ function nextSnapPage() {
|
||||
loadSnapshots()
|
||||
}
|
||||
|
||||
// ---- delete ----
|
||||
async function confirmDelete(row: UpstreamData) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确认删除上游 "${row.name}" ?`, '删除确认', { type: 'warning' })
|
||||
await upstreamsApi.delete(row.id)
|
||||
ElMessage.success('已删除')
|
||||
loadList()
|
||||
} catch {}
|
||||
} catch {
|
||||
// noop
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadList)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.page-title { font-size: 20px; font-weight: 600; margin-bottom: 4px; }
|
||||
.page-desc { font-size: 13px; color: var(--text-muted); }
|
||||
.card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.cell-name { font-weight: 500; font-size: 14px; }
|
||||
.cell-url { font-size: 12px; color: var(--text-muted); margin-top: 2px; }
|
||||
.muted { color: var(--text-muted); font-size: 12px; }
|
||||
.time-text { font-size: 12px; color: var(--text-secondary); }
|
||||
.error-text { font-size: 12px; color: var(--color-danger); cursor: pointer; }
|
||||
.status-badge .dot {
|
||||
width: 6px; height: 6px; border-radius: 50%; background: currentColor; display: inline-block;
|
||||
}
|
||||
.action-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: nowrap;
|
||||
gap: 0;
|
||||
.upstreams-page {
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
|
||||
.upstreams-hero {
|
||||
padding: 1.35rem;
|
||||
border-radius: var(--radius-shell);
|
||||
background:
|
||||
radial-gradient(circle at top right, rgba(217, 139, 66, 0.14), transparent 24%),
|
||||
linear-gradient(180deg, rgba(255, 244, 232, 0.03), transparent 28%),
|
||||
var(--bg-panel);
|
||||
}
|
||||
|
||||
.hero-actions {
|
||||
align-self: flex-end;
|
||||
}
|
||||
|
||||
.data-stage {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.data-stage-head {
|
||||
margin-bottom: 0.85rem;
|
||||
}
|
||||
|
||||
.data-stage-title {
|
||||
margin-top: 0.28rem;
|
||||
font-size: clamp(1.4rem, 1.05rem + 0.8vw, 2rem);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.data-stage-note {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.86rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.auth-badge {
|
||||
background: rgba(134, 183, 199, 0.12);
|
||||
color: var(--color-info);
|
||||
border-color: rgba(134, 183, 199, 0.18);
|
||||
}
|
||||
|
||||
.time-inline {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.84rem;
|
||||
}
|
||||
|
||||
/* Detail drawer */
|
||||
.info-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0.85rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.info-card {
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 10px;
|
||||
padding: 12px 14px;
|
||||
padding: 1rem;
|
||||
}
|
||||
.info-label { font-size: 11px; color: var(--text-muted); margin-bottom: 6px; text-transform: uppercase; letter-spacing: 0.5px; }
|
||||
.info-value { font-size: 14px; font-weight: 500; color: var(--text-primary); }
|
||||
|
||||
.info-label {
|
||||
margin-bottom: 0.45rem;
|
||||
color: var(--text-soft);
|
||||
font-size: 0.76rem;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.info-value {
|
||||
font-size: 0.96rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.error-box {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
background: rgba(239,68,68,0.08);
|
||||
border: 1px solid rgba(239,68,68,0.2);
|
||||
border-radius: 8px;
|
||||
padding: 10px 14px;
|
||||
gap: 0.6rem;
|
||||
margin-bottom: 1rem;
|
||||
padding: 0.9rem 1rem;
|
||||
border-radius: var(--radius-control);
|
||||
color: var(--color-danger);
|
||||
font-size: 13px;
|
||||
margin-bottom: 16px;
|
||||
background: rgba(221, 126, 114, 0.08);
|
||||
border: 1px solid rgba(221, 126, 114, 0.16);
|
||||
}
|
||||
|
||||
.section-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 12px;
|
||||
gap: 0.45rem;
|
||||
margin-bottom: 0.8rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.section-sub {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
.section-sub { font-size: 12px; color: var(--text-muted); font-weight: 400; margin-left: 4px; }
|
||||
|
||||
/* Snapshot list */
|
||||
.snapshot-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
min-height: 60px;
|
||||
display: grid;
|
||||
gap: 0.7rem;
|
||||
min-height: 4rem;
|
||||
}
|
||||
|
||||
.snap-item {
|
||||
border-radius: var(--radius-control);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 244, 232, 0.02);
|
||||
overflow: hidden;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.snap-item.expanded { border-color: var(--color-primary); }
|
||||
|
||||
.snap-item.expanded {
|
||||
border-color: rgba(239, 175, 99, 0.24);
|
||||
}
|
||||
|
||||
.snap-header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 14px;
|
||||
gap: 0.8rem;
|
||||
padding: 0.95rem 1rem;
|
||||
cursor: pointer;
|
||||
background: var(--bg-elevated);
|
||||
transition: background 0.12s;
|
||||
}
|
||||
.snap-header:hover { background: var(--bg-surface); }
|
||||
|
||||
.snap-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.expand-icon {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
.snap-item.expanded .expand-icon { transform: rotate(90deg); }
|
||||
.snap-time { font-size: 13px; color: var(--text-primary); font-weight: 500; font-family: monospace; }
|
||||
|
||||
.snap-right { display: flex; align-items: center; gap: 6px; }
|
||||
|
||||
.snap-body {
|
||||
border-top: 1px solid var(--border-color);
|
||||
background: var(--bg-card);
|
||||
}
|
||||
|
||||
.rate-value { font-weight: 600; color: var(--color-primary); font-family: monospace; }
|
||||
.override-value { color: var(--color-warning); font-family: monospace; }
|
||||
|
||||
.empty-hint {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.snap-left,
|
||||
.snap-right,
|
||||
.snap-pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
padding-top: 14px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
margin-top: 12px;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
|
||||
.expand-icon {
|
||||
color: var(--text-soft);
|
||||
transition: transform var(--motion-fast) var(--ease-standard);
|
||||
}
|
||||
|
||||
.snap-item.expanded .expand-icon {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.snap-time {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.snap-body {
|
||||
padding: 0 0.35rem 0.35rem;
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.rate-value {
|
||||
color: var(--color-primary-strong);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.override-value {
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
.snap-pagination {
|
||||
justify-content: center;
|
||||
margin-top: 0.9rem;
|
||||
padding-top: 0.9rem;
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.info-cards {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1200px) {
|
||||
.info-cards {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.upstreams-hero,
|
||||
.data-stage {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.hero-actions {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.hero-actions :deep(.el-button) {
|
||||
flex: 1 1 100%;
|
||||
}
|
||||
}
|
||||
.page-info { font-size: 13px; color: var(--text-secondary); }
|
||||
</style>
|
||||
|
||||
@@ -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