Add remote browser pages and website sync

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

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
liumangmang
2026-05-15 15:43:58 +08:00
parent a13a0070a5
commit 7adc7c00ab
43 changed files with 6615 additions and 641 deletions
+3 -1
View File
@@ -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>
+13
View File
@@ -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

+159
View File
@@ -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
View File
@@ -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;
}
}
+624 -189
View File
@@ -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>
+1
View File
@@ -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') },
+161 -26
View File
@@ -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">远程浏览器适合 CookieCSP复杂 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
View File
@@ -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>
+16 -31
View File
@@ -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
View File
@@ -1,82 +1,148 @@
<template>
<div>
<!-- Header -->
<div class="page-header">
<div>
<div class="shell-page page-section upstreams-page">
<section class="page-header upstreams-hero surface-card">
<div class="page-heading">
<p class="page-kicker">Monitoring Matrix</p>
<h2 class="page-title">上游管理</h2>
<p class="page-desc">管理 API 上游服务支持多种认证方式和定时监听</p>
<p class="page-desc">
管理 API 上游服务认证方式与轮询策略这里优先展示健康度检测节奏和错误信号减少你在异常发生时的定位成本
</p>
</div>
<el-button type="primary" @click="openCreate">
<el-icon><Plus /></el-icon> 新增上游
</el-button>
</div>
<!-- Table -->
<div class="card">
<el-table :data="list" v-loading="tableLoading" row-key="id" style="width:100%">
<el-table-column label="名称" min-width="140">
<div class="toolbar-cluster hero-actions">
<el-button @click="loadList" :loading="tableLoading">
<el-icon><Refresh /></el-icon>
刷新列表
</el-button>
<el-button type="primary" @click="openCreate">
<el-icon><Plus /></el-icon>
新增上游
</el-button>
</div>
</section>
<section class="metric-grid">
<article class="surface-card metric-card">
<div class="metric-label">Total Sources</div>
<div class="metric-value">{{ metrics.total }}</div>
<p class="metric-note">当前纳管的上游节点总数</p>
</article>
<article class="surface-card metric-card">
<div class="metric-label">Healthy</div>
<div class="metric-value">{{ metrics.healthy }}</div>
<p class="metric-note">最近一次检测返回健康状态</p>
</article>
<article class="surface-card metric-card">
<div class="metric-label">Enabled</div>
<div class="metric-value">{{ metrics.enabled }}</div>
<p class="metric-note">已启用定时检测的上游节点</p>
</article>
<article class="surface-card metric-card">
<div class="metric-label">Attention</div>
<div class="metric-value">{{ metrics.unhealthy }}</div>
<p class="metric-note">需要处理错误或网络异常的节点</p>
</article>
</section>
<section class="surface-card data-stage">
<div class="section-header data-stage-head">
<div>
<div class="section-caption">Upstream Registry</div>
<h3 class="data-stage-title brand-type">检测与变更控制台</h3>
</div>
<p class="data-stage-note">点击详情可查看快照历史分组倍率与最近错误</p>
</div>
<el-table :data="list" v-loading="tableLoading" row-key="id" style="width: 100%">
<el-table-column label="来源" min-width="220">
<template #default="{ row }">
<div class="cell-name">{{ row.name }}</div>
<div class="cell-url">{{ row.base_url }}</div>
<div class="cell-url mono">{{ row.base_url }}</div>
</template>
</el-table-column>
<el-table-column label="状态" width="110">
<el-table-column label="状态" width="118">
<template #default="{ row }">
<span :class="['status-badge', row.last_status]">
<span class="dot"></span>
<span class="dot" />
{{ statusLabel(row.last_status) }}
</span>
</template>
</el-table-column>
<el-table-column label="启用" width="80">
<el-table-column label="启用" width="88" align="center">
<template #default="{ row }">
<el-switch v-model="row.enabled" @change="toggleEnabled(row)" />
</template>
</el-table-column>
<el-table-column label="检测间隔" width="100">
<template #default="{ row }">{{ row.check_interval_seconds }}s</template>
</el-table-column>
<el-table-column label="最近检测" min-width="145">
<el-table-column label="认证" width="132">
<template #default="{ row }">
<span v-if="row.last_checked_at" class="time-text">{{ fmtTime(row.last_checked_at) }}</span>
<span class="status-badge auth-badge">{{ authLabel(row.auth_type) }}</span>
</template>
</el-table-column>
<el-table-column label="检测间隔" width="112">
<template #default="{ row }">
<span class="mono time-inline">{{ row.check_interval_seconds }}s</span>
</template>
</el-table-column>
<el-table-column label="最近检测" min-width="168">
<template #default="{ row }">
<span v-if="row.last_checked_at" class="time-text mono">{{ fmtTime(row.last_checked_at) }}</span>
<span v-else class="muted">未检测</span>
</template>
</el-table-column>
<el-table-column label="最近错误" min-width="160">
<el-table-column label="最近错误" min-width="220">
<template #default="{ row }">
<el-tooltip v-if="row.last_error" :content="row.last_error" placement="top" :show-after="300">
<span class="error-text">{{ row.last_error.substring(0, 40) }}</span>
<span class="error-text">{{ shrinkError(row.last_error) }}</span>
</el-tooltip>
<span v-else class="muted"></span>
<span v-else class="muted">无异常</span>
</template>
</el-table-column>
<el-table-column label="操作" width="250">
<el-table-column label="操作" width="258" fixed="right">
<template #default="{ row }">
<div class="action-row">
<el-button size="small" text @click="openEdit(row)" title="编辑"><el-icon><Edit /></el-icon></el-button>
<el-button size="small" text @click="openEdit(row)" title="编辑">
<el-icon><Edit /></el-icon>
</el-button>
<el-button size="small" text type="success" @click="testUpstream(row)" :loading="row._testing">测试</el-button>
<el-button size="small" text type="primary" @click="checkNow(row)" :loading="row._checking">检测</el-button>
<el-button size="small" text type="info" @click="openDetail(row)">
<el-icon style="margin-right:2px"><List /></el-icon>详情
<el-icon><List /></el-icon>
详情
</el-button>
<el-button size="small" text type="danger" @click="confirmDelete(row)" title="删除">
<el-icon><Delete /></el-icon>
</el-button>
<el-button size="small" text type="danger" @click="confirmDelete(row)" title="删除"><el-icon><Delete /></el-icon></el-button>
</div>
</template>
</el-table-column>
</el-table>
</div>
</section>
<!-- ======= Create / Edit Drawer ======= -->
<el-drawer
v-model="drawerVisible"
:title="editingId ? '编辑上游' : '新增上游'"
size="480px"
destroy-on-close
>
<el-form ref="formRef" :model="form" :rules="rules" label-width="120px" label-position="top">
<el-form ref="formRef" :model="form" :rules="rules" label-position="top">
<el-form-item label="名称" prop="name">
<el-input v-model="form.name" placeholder="例:ai98pro" />
</el-form-item>
<el-form-item v-if="!editingId" label="系统类型(快捷配置)">
<el-select v-model="quickPlatform" @change="handlePlatformChange" style="width: 100%">
<el-option label="Sub2API" value="sub2api" />
<el-option label="New-API (管理员Key)" value="new-api" />
<el-option label="New-API (普通账号)" value="new-api-user" />
<el-option label="自定义" value="custom" />
</el-select>
</el-form-item>
<el-form-item label="Base URL" prop="base_url">
<el-input v-model="form.base_url" placeholder="https://example.com" />
</el-form-item>
@@ -84,7 +150,7 @@
<el-input v-model="form.api_prefix" placeholder="/api/v1" />
</el-form-item>
<el-form-item label="认证方式">
<el-select v-model="form.auth_type" style="width:100%">
<el-select v-model="form.auth_type" style="width: 100%">
<el-option label="无认证" value="none" />
<el-option label="Bearer Token" value="bearer" />
<el-option label="API Key" value="api_key" />
@@ -124,12 +190,12 @@
<el-row :gutter="12">
<el-col :span="12">
<el-form-item label="检测间隔(秒)">
<el-input-number v-model="form.check_interval_seconds" :min="60" style="width:100%" />
<el-input-number v-model="form.check_interval_seconds" :min="60" style="width: 100%" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="超时(秒)">
<el-input-number v-model="form.timeout_seconds" :min="5" :max="120" style="width:100%" />
<el-input-number v-model="form.timeout_seconds" :min="5" :max="120" style="width: 100%" />
</el-form-item>
</el-col>
</el-row>
@@ -143,7 +209,6 @@
</template>
</el-drawer>
<!-- ======= Detail Drawer ======= -->
<el-drawer
v-model="detailVisible"
:title="`检测详情 — ${detailUpstream?.name || ''}`"
@@ -151,34 +216,35 @@
destroy-on-close
@open="loadSnapshots"
>
<!-- Info cards -->
<div v-if="detailUpstream" class="info-cards">
<div class="info-card">
<div class="surface-card info-card">
<div class="info-label">状态</div>
<span :class="['status-badge', detailUpstream.last_status]">
<span class="dot"></span>{{ statusLabel(detailUpstream.last_status) }}
<span class="dot" />{{ statusLabel(detailUpstream.last_status) }}
</span>
</div>
<div class="info-card">
<div class="surface-card info-card">
<div class="info-label">最近检测</div>
<div class="info-value">{{ detailUpstream.last_checked_at ? fmtTimeFull(detailUpstream.last_checked_at) : '未检测' }}</div>
<div class="info-value mono">{{ detailUpstream.last_checked_at ? fmtTimeFull(detailUpstream.last_checked_at) : '未检测' }}</div>
</div>
<div class="info-card">
<div class="surface-card info-card">
<div class="info-label">检测间隔</div>
<div class="info-value">{{ detailUpstream.check_interval_seconds }}s</div>
<div class="info-value mono">{{ detailUpstream.check_interval_seconds }}s</div>
</div>
<div class="info-card">
<div class="surface-card info-card">
<div class="info-label">超时</div>
<div class="info-value">{{ detailUpstream.timeout_seconds }}s</div>
<div class="info-value mono">{{ detailUpstream.timeout_seconds }}s</div>
</div>
</div>
<div v-if="detailUpstream?.last_error" class="error-box">
<el-icon><Warning /></el-icon> {{ detailUpstream.last_error }}
</div>
<!-- Snapshot history -->
<div v-if="detailUpstream?.last_error" class="error-box">
<el-icon><Warning /></el-icon>
<span>{{ detailUpstream.last_error }}</span>
</div>
<div class="section-title">
<el-icon><Clock /></el-icon> 检测历史
<el-icon><Clock /></el-icon>
检测历史
<span class="section-sub">最近 {{ snapshots.length }} </span>
</div>
@@ -189,11 +255,10 @@
class="snap-item"
:class="{ expanded: expandedId === snap.id }"
>
<!-- Row header -->
<div class="snap-header" @click="toggleExpand(snap)">
<div class="snap-left">
<el-icon class="expand-icon"><ArrowRight /></el-icon>
<span class="snap-time">{{ fmtTimeFull(snap.captured_at) }}</span>
<span class="snap-time mono">{{ fmtTimeFull(snap.captured_at) }}</span>
</div>
<div class="snap-right">
<el-tag size="small" type="info">{{ snap.snapshot._groups_count }} 个分组</el-tag>
@@ -206,25 +271,24 @@
</div>
</div>
<!-- Expanded rate table -->
<div v-if="expandedId === snap.id" class="snap-body">
<el-table
:data="groupRows(snap.snapshot)"
size="small"
:header-cell-style="{ background: 'var(--bg-elevated)', color: 'var(--text-secondary)' }"
:cell-style="{ background: 'var(--bg-card)', color: 'var(--text-primary)' }"
:header-cell-style="{ background: 'rgba(255, 244, 232, 0.02)', color: 'var(--text-soft)' }"
:cell-style="{ background: 'transparent', color: 'var(--text-primary)' }"
>
<el-table-column prop="group_name" label="分组名称" min-width="140" />
<el-table-column prop="platform" label="平台" width="100" />
<el-table-column label="当前倍率" width="100">
<el-table-column prop="platform" label="平台" width="110" />
<el-table-column label="当前倍率" width="110">
<template #default="{ row }">
<span class="rate-value">{{ row.rate || '—' }}</span>
<span class="rate-value mono">{{ row.rate || '—' }}</span>
</template>
</el-table-column>
<el-table-column prop="default_rate" label="默认倍率" width="100" />
<el-table-column prop="override_rate" label="覆盖倍率" width="100">
<el-table-column prop="default_rate" label="默认倍率" width="110" />
<el-table-column prop="override_rate" label="覆盖倍率" width="110">
<template #default="{ row }">
<span v-if="row.override_rate" class="override-value">{{ row.override_rate }}</span>
<span v-if="row.override_rate" class="override-value mono">{{ row.override_rate }}</span>
<span v-else class="muted"></span>
</template>
</el-table-column>
@@ -237,7 +301,6 @@
</div>
</div>
<!-- Pagination -->
<div v-if="snapshots.length > 0 || snapshotOffset > 0" class="snap-pagination">
<el-button size="small" :disabled="snapshotOffset === 0" @click="prevSnapPage">上一页</el-button>
<span class="page-info"> {{ snapshotOffset / snapshotLimit + 1 }} </span>
@@ -252,13 +315,12 @@ import { ref, computed, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import type { FormInstance } from 'element-plus'
import dayjs from 'dayjs'
import { Refresh, Plus, Edit, List, Delete, Warning, Clock, ArrowRight } from '@element-plus/icons-vue'
import { upstreamsApi, type UpstreamData } from '@/api'
// ---- list state ----
const list = ref<(UpstreamData & { _testing?: boolean; _checking?: boolean })[]>([])
const tableLoading = ref(false)
// ---- create/edit drawer ----
const drawerVisible = ref(false)
const saving = ref(false)
const editingId = ref<number | null>(null)
@@ -282,7 +344,29 @@ const rules = {
base_url: [{ required: true, message: '请输入 Base URL', trigger: 'blur' }],
}
// ---- detail drawer ----
const quickPlatform = ref('sub2api')
function handlePlatformChange(val: string) {
if (val === 'sub2api') {
form.value.api_prefix = '/api/v1'
form.value.groups_endpoint = '/groups/available'
form.value.rate_endpoint = '/groups/rates'
form.value.auth_type = 'login_password'
form.value.auth_config.login_path = '/auth/login'
} else if (val === 'new-api') {
form.value.api_prefix = ''
form.value.groups_endpoint = '/api/group/'
form.value.rate_endpoint = '/api/option/?key=GroupRatio'
form.value.auth_type = 'bearer'
} else if (val === 'new-api-user') {
form.value.api_prefix = ''
form.value.groups_endpoint = '/api/user/self/groups'
form.value.rate_endpoint = '/api/user/self/groups'
form.value.auth_type = 'login_password'
form.value.auth_config.login_path = '/api/user/login'
}
}
const detailVisible = ref(false)
const detailUpstream = ref<UpstreamData | null>(null)
const snapshots = ref<any[]>([])
@@ -291,20 +375,28 @@ const expandedId = ref<number | null>(null)
const snapshotOffset = ref(0)
const snapshotLimit = 20
// ---- helpers ----
const metrics = computed(() => ({
total: list.value.length,
healthy: list.value.filter((item) => item.last_status === 'healthy').length,
enabled: list.value.filter((item) => item.enabled).length,
unhealthy: list.value.filter((item) => item.last_status === 'unhealthy').length,
}))
const statusLabel = (s: string) => ({ healthy: '健康', unhealthy: '异常', unknown: '未知' }[s] || s)
// Treat server timestamps as UTC (add Z if no timezone info present)
const toUTC = (t: string) => /[Z+\-]\d*$/.test(t.trim()) ? t : t + 'Z'
const authLabel = (s: string) => ({ none: '无认证', bearer: 'Bearer', api_key: 'API Key', login_password: '邮箱密码' }[s] || s)
const toUTC = (t: string) => /[Z+\-]\d*$/.test(t.trim()) ? t : `${t}Z`
const fmtTime = (t: string) => dayjs(toUTC(t)).format('MM-DD HH:mm:ss')
const fmtTimeFull = (t: string) => dayjs(toUTC(t)).format('YYYY-MM-DD HH:mm:ss')
function groupRows(snapshot: any) {
if (!snapshot?.groups) return []
return Object.values(snapshot.groups) as any[]
}
// ---- load list ----
function shrinkError(value: string) {
return value.length > 40 ? `${value.slice(0, 40)}` : value
}
async function loadList() {
tableLoading.value = true
try {
@@ -315,9 +407,9 @@ async function loadList() {
}
}
// ---- create / edit ----
function openCreate() {
editingId.value = null
quickPlatform.value = 'sub2api'
form.value = defaultForm()
drawerVisible.value = true
}
@@ -360,7 +452,6 @@ async function handleSave() {
}
}
// ---- toggle enabled ----
async function toggleEnabled(row: UpstreamData) {
try {
await upstreamsApi.update(row.id, { enabled: row.enabled })
@@ -371,7 +462,6 @@ async function toggleEnabled(row: UpstreamData) {
}
}
// ---- test / check-now ----
async function testUpstream(row: any) {
row._testing = true
try {
@@ -394,7 +484,6 @@ async function checkNow(row: any) {
}
}
// ---- detail drawer ----
function openDetail(row: UpstreamData) {
detailUpstream.value = row
snapshots.value = []
@@ -409,7 +498,6 @@ async function loadSnapshots() {
try {
const res = await upstreamsApi.listSnapshots(detailUpstream.value.id, snapshotLimit, snapshotOffset.value)
snapshots.value = res.data
// auto-expand the first (latest) snapshot
if (res.data.length > 0 && expandedId.value === null) {
expandedId.value = res.data[0].id
}
@@ -436,152 +524,214 @@ function nextSnapPage() {
loadSnapshots()
}
// ---- delete ----
async function confirmDelete(row: UpstreamData) {
try {
await ElMessageBox.confirm(`确认删除上游 "${row.name}" `, '删除确认', { type: 'warning' })
await upstreamsApi.delete(row.id)
ElMessage.success('已删除')
loadList()
} catch {}
} catch {
// noop
}
}
onMounted(loadList)
</script>
<style scoped>
.page-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 20px;
}
.page-title { font-size: 20px; font-weight: 600; margin-bottom: 4px; }
.page-desc { font-size: 13px; color: var(--text-muted); }
.card {
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 12px;
overflow: hidden;
}
.cell-name { font-weight: 500; font-size: 14px; }
.cell-url { font-size: 12px; color: var(--text-muted); margin-top: 2px; }
.muted { color: var(--text-muted); font-size: 12px; }
.time-text { font-size: 12px; color: var(--text-secondary); }
.error-text { font-size: 12px; color: var(--color-danger); cursor: pointer; }
.status-badge .dot {
width: 6px; height: 6px; border-radius: 50%; background: currentColor; display: inline-block;
}
.action-row {
display: flex;
align-items: center;
flex-wrap: nowrap;
gap: 0;
.upstreams-page {
padding-bottom: 1rem;
}
.upstreams-hero {
padding: 1.35rem;
border-radius: var(--radius-shell);
background:
radial-gradient(circle at top right, rgba(217, 139, 66, 0.14), transparent 24%),
linear-gradient(180deg, rgba(255, 244, 232, 0.03), transparent 28%),
var(--bg-panel);
}
.hero-actions {
align-self: flex-end;
}
.data-stage {
padding: 1rem;
}
.data-stage-head {
margin-bottom: 0.85rem;
}
.data-stage-title {
margin-top: 0.28rem;
font-size: clamp(1.4rem, 1.05rem + 0.8vw, 2rem);
font-weight: 800;
}
.data-stage-note {
color: var(--text-muted);
font-size: 0.86rem;
line-height: 1.6;
}
.auth-badge {
background: rgba(134, 183, 199, 0.12);
color: var(--color-info);
border-color: rgba(134, 183, 199, 0.18);
}
.time-inline {
color: var(--text-secondary);
font-size: 0.84rem;
}
/* Detail drawer */
.info-cards {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
margin-bottom: 16px;
grid-template-columns: 1fr;
gap: 0.85rem;
margin-bottom: 1rem;
}
.info-card {
background: var(--bg-elevated);
border: 1px solid var(--border-color);
border-radius: 10px;
padding: 12px 14px;
padding: 1rem;
}
.info-label { font-size: 11px; color: var(--text-muted); margin-bottom: 6px; text-transform: uppercase; letter-spacing: 0.5px; }
.info-value { font-size: 14px; font-weight: 500; color: var(--text-primary); }
.info-label {
margin-bottom: 0.45rem;
color: var(--text-soft);
font-size: 0.76rem;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.info-value {
font-size: 0.96rem;
color: var(--text-primary);
}
.error-box {
display: flex;
align-items: flex-start;
gap: 8px;
background: rgba(239,68,68,0.08);
border: 1px solid rgba(239,68,68,0.2);
border-radius: 8px;
padding: 10px 14px;
gap: 0.6rem;
margin-bottom: 1rem;
padding: 0.9rem 1rem;
border-radius: var(--radius-control);
color: var(--color-danger);
font-size: 13px;
margin-bottom: 16px;
background: rgba(221, 126, 114, 0.08);
border: 1px solid rgba(221, 126, 114, 0.16);
}
.section-title {
display: flex;
align-items: center;
gap: 6px;
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 12px;
gap: 0.45rem;
margin-bottom: 0.8rem;
font-size: 1rem;
font-weight: 700;
}
.section-sub {
color: var(--text-muted);
font-size: 0.78rem;
font-weight: 500;
}
.section-sub { font-size: 12px; color: var(--text-muted); font-weight: 400; margin-left: 4px; }
/* Snapshot list */
.snapshot-list {
display: flex;
flex-direction: column;
gap: 6px;
min-height: 60px;
display: grid;
gap: 0.7rem;
min-height: 4rem;
}
.snap-item {
border-radius: var(--radius-control);
border: 1px solid var(--border-color);
border-radius: 8px;
background: rgba(255, 244, 232, 0.02);
overflow: hidden;
transition: border-color 0.15s;
}
.snap-item.expanded { border-color: var(--color-primary); }
.snap-item.expanded {
border-color: rgba(239, 175, 99, 0.24);
}
.snap-header {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
padding: 10px 14px;
gap: 0.8rem;
padding: 0.95rem 1rem;
cursor: pointer;
background: var(--bg-elevated);
transition: background 0.12s;
}
.snap-header:hover { background: var(--bg-surface); }
.snap-left {
display: flex;
align-items: center;
gap: 8px;
}
.expand-icon {
font-size: 12px;
color: var(--text-muted);
transition: transform 0.2s;
}
.snap-item.expanded .expand-icon { transform: rotate(90deg); }
.snap-time { font-size: 13px; color: var(--text-primary); font-weight: 500; font-family: monospace; }
.snap-right { display: flex; align-items: center; gap: 6px; }
.snap-body {
border-top: 1px solid var(--border-color);
background: var(--bg-card);
}
.rate-value { font-weight: 600; color: var(--color-primary); font-family: monospace; }
.override-value { color: var(--color-warning); font-family: monospace; }
.empty-hint {
text-align: center;
padding: 40px;
color: var(--text-muted);
font-size: 13px;
}
.snap-left,
.snap-right,
.snap-pagination {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
padding-top: 14px;
border-top: 1px solid var(--border-color);
margin-top: 12px;
gap: 0.55rem;
}
.expand-icon {
color: var(--text-soft);
transition: transform var(--motion-fast) var(--ease-standard);
}
.snap-item.expanded .expand-icon {
transform: rotate(90deg);
}
.snap-time {
color: var(--text-secondary);
font-size: 0.85rem;
}
.snap-body {
padding: 0 0.35rem 0.35rem;
border-top: 1px solid var(--border-color);
}
.rate-value {
color: var(--color-primary-strong);
font-weight: 700;
}
.override-value {
color: var(--color-warning);
}
.snap-pagination {
justify-content: center;
margin-top: 0.9rem;
padding-top: 0.9rem;
border-top: 1px solid var(--border-color);
}
@media (min-width: 768px) {
.info-cards {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (min-width: 1200px) {
.info-cards {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
}
@media (max-width: 767px) {
.upstreams-hero,
.data-stage {
padding: 1rem;
}
.hero-actions {
width: 100%;
}
.hero-actions :deep(.el-button) {
flex: 1 1 100%;
}
}
.page-info { font-size: 13px; color: var(--text-secondary); }
</style>
+18 -15
View File
@@ -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>
+704
View File
@@ -0,0 +1,704 @@
<template>
<div class="shell-page page-section websites-page">
<div class="page-header surface-card page-block">
<div class="page-heading">
<p class="page-kicker">Sync Orchestration</p>
<h2 class="page-title">网站管理</h2>
<p class="page-desc">管理自己的 sub2api 网站并把网站分组倍率同步到上游监听结果</p>
</div>
<el-button type="primary" @click="openWebsiteCreate">
<el-icon><Plus /></el-icon> 新增网站
</el-button>
</div>
<div class="panel">
<div class="panel-head">
<div class="panel-title">网站</div>
<el-button size="small" text @click="loadAll">刷新</el-button>
</div>
<el-table :data="websites" v-loading="websiteLoading" row-key="id" style="width:100%">
<el-table-column label="名称" min-width="180">
<template #default="{ row }">
<div class="cell-name">{{ row.name }}</div>
<div class="cell-url">{{ row.base_url }}</div>
</template>
</el-table-column>
<el-table-column label="状态" width="110">
<template #default="{ row }">
<span :class="['status-badge', row.last_status]"><span class="dot" />{{ statusLabel(row.last_status) }}</span>
</template>
</el-table-column>
<el-table-column label="自动同步" width="100">
<template #default="{ row }">
<el-switch v-model="row.auto_sync_enabled" @change="toggleWebsiteSync(row)" />
</template>
</el-table-column>
<el-table-column label="超时" width="80">
<template #default="{ row }">{{ row.timeout_seconds }}s</template>
</el-table-column>
<el-table-column label="最近错误" min-width="180">
<template #default="{ row }">
<el-tooltip v-if="row.last_error" :content="row.last_error" placement="top" :show-after="300">
<span class="error-text">{{ row.last_error.substring(0, 42) }}</span>
</el-tooltip>
<span v-else class="muted"></span>
</template>
</el-table-column>
<el-table-column label="操作" width="200">
<template #default="{ row }">
<div class="action-row">
<el-tooltip content="查看分组" placement="top" :show-after="300">
<el-button size="small" circle text @click="selectWebsite(row)">
<el-icon><Grid /></el-icon>
</el-button>
</el-tooltip>
<el-tooltip content="编辑" placement="top" :show-after="300">
<el-button size="small" circle text @click="openWebsiteEdit(row)">
<el-icon><Edit /></el-icon>
</el-button>
</el-tooltip>
<el-tooltip content="连接测试" placement="top" :show-after="300">
<el-button size="small" circle text type="success" :loading="row._testing" @click="testWebsite(row)">
<el-icon v-if="!row._testing"><Connection /></el-icon>
</el-button>
</el-tooltip>
<el-tooltip content="新增绑定" placement="top" :show-after="300">
<el-button size="small" circle text type="primary" @click="openBindingCreate(row)">
<el-icon><Link /></el-icon>
</el-button>
</el-tooltip>
<el-tooltip content="删除" placement="top" :show-after="300">
<el-button size="small" circle text type="danger" @click="deleteWebsite(row)">
<el-icon><Delete /></el-icon>
</el-button>
</el-tooltip>
</div>
</template>
</el-table-column>
</el-table>
</div>
<div class="content-grid">
<div class="panel">
<div class="panel-head">
<div>
<div class="panel-title">我的网站分组</div>
<div class="panel-sub">{{ selectedWebsite?.name || '请选择网站' }}</div>
</div>
<el-button size="small" :disabled="!selectedWebsite" :loading="groupsLoading" @click="loadWebsiteGroups">拉取分组</el-button>
</div>
<el-table :data="websiteGroups" v-loading="groupsLoading" row-key="id" size="small" style="width:100%">
<el-table-column prop="name" label="分组" min-width="150" />
<el-table-column prop="id" label="ID" min-width="130" />
<el-table-column label="倍率" width="100">
<template #default="{ row }"><span class="rate-value">{{ row.rate_multiplier ?? '—' }}</span></template>
</el-table-column>
<el-table-column label="操作" width="90">
<template #default="{ row }">
<el-button size="small" text type="primary" :disabled="!selectedWebsite" @click="openBindingCreate(selectedWebsite, row)">绑定</el-button>
</template>
</el-table-column>
</el-table>
</div>
<div class="panel">
<div class="panel-head">
<div class="panel-title">分组绑定</div>
<el-button size="small" type="primary" :disabled="websites.length === 0" @click="openBindingCreate(selectedWebsite || websites[0])">新增绑定</el-button>
</div>
<div class="binding-list" v-loading="bindingLoading">
<div v-for="binding in bindings" :key="binding.id" class="binding-item">
<div class="binding-main">
<div class="binding-title">{{ binding.website_name }} / {{ binding.target_group_name || binding.target_group_id }}</div>
<div class="binding-meta">
{{ algorithmLabel(binding.algorithm) }} + {{ binding.percent }}% ·
{{ binding.source_groups.length }} 个上游分组
</div>
</div>
<div class="binding-actions">
<el-switch v-model="binding.enabled" @change="toggleBinding(binding)" />
<el-button size="small" text type="primary" :loading="binding._syncing" @click="syncBinding(binding)">同步</el-button>
<el-button size="small" text @click="openBindingEdit(binding)"><el-icon><Edit /></el-icon></el-button>
<el-button size="small" text type="danger" @click="deleteBinding(binding)"><el-icon><Delete /></el-icon></el-button>
</div>
</div>
<div v-if="!bindingLoading && bindings.length === 0" class="empty-hint">暂无绑定</div>
</div>
</div>
</div>
<div class="panel">
<div class="panel-head">
<div class="panel-title">最近同步结果</div>
<el-button size="small" text @click="loadLogs">刷新</el-button>
</div>
<el-table :data="logs" v-loading="logLoading" row-key="id" size="small" style="width:100%">
<el-table-column label="时间" width="150">
<template #default="{ row }">{{ fmtTime(row.created_at) }}</template>
</el-table-column>
<el-table-column label="目标分组" min-width="160">
<template #default="{ row }">{{ row.target_group_name || row.target_group_id }}</template>
</el-table-column>
<el-table-column label="倍率" width="130">
<template #default="{ row }">
<span class="rate-value">{{ row.old_rate ?? '—' }} {{ row.new_rate ?? '—' }}</span>
</template>
</el-table-column>
<el-table-column label="状态" width="90">
<template #default="{ row }">
<el-tag size="small" :type="row.status === 'success' ? 'success' : 'danger'">{{ row.status === 'success' ? '成功' : '失败' }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="message" label="结果" min-width="220" />
</el-table>
</div>
<el-drawer v-model="websiteDrawer" :title="editingWebsiteId ? '编辑网站' : '新增网站'" size="460px" destroy-on-close>
<el-form ref="websiteFormRef" :model="websiteForm" :rules="websiteRules" label-position="top">
<el-form-item label="名称" prop="name"><el-input v-model="websiteForm.name" placeholder="我的 sub2api" /></el-form-item>
<el-form-item label="Base URL" prop="base_url"><el-input v-model="websiteForm.base_url" placeholder="https://sub2api.example.com" /></el-form-item>
<el-form-item label="API Prefix"><el-input v-model="websiteForm.api_prefix" placeholder="/api/v1/admin" /></el-form-item>
<el-form-item label="认证方式">
<el-select v-model="websiteForm.auth_type" style="width:100%">
<el-option label="x-api-key" value="api_key" />
<el-option label="Bearer JWT" value="bearer" />
</el-select>
</el-form-item>
<template v-if="websiteForm.auth_type === 'api_key'">
<el-form-item label="Admin API Key"><el-input v-model="websiteForm.auth_config.key" type="password" show-password placeholder="admin-..." /></el-form-item>
<el-form-item label="Header"><el-input v-model="websiteForm.auth_config.header" placeholder="x-api-key" /></el-form-item>
</template>
<template v-else>
<el-form-item label="JWT"><el-input v-model="websiteForm.auth_config.token" type="password" show-password placeholder="***" /></el-form-item>
</template>
<el-row :gutter="12">
<el-col :span="12"><el-form-item label="分组接口"><el-input v-model="websiteForm.groups_endpoint" /></el-form-item></el-col>
<el-col :span="12"><el-form-item label="更新接口"><el-input v-model="websiteForm.group_update_endpoint" /></el-form-item></el-col>
</el-row>
<el-form-item label="超时(秒)"><el-input-number v-model="websiteForm.timeout_seconds" :min="5" :max="120" style="width:100%" /></el-form-item>
<div class="switch-line">
<span>启用网站</span><el-switch v-model="websiteForm.enabled" />
</div>
<div class="switch-line">
<span>启用自动同步</span><el-switch v-model="websiteForm.auto_sync_enabled" />
</div>
</el-form>
<template #footer>
<el-button @click="websiteDrawer = false">取消</el-button>
<el-button type="primary" :loading="savingWebsite" @click="saveWebsite">保存</el-button>
</template>
</el-drawer>
<el-drawer v-model="bindingDrawer" :title="editingBindingId ? '编辑绑定' : '新增绑定'" size="560px" destroy-on-close>
<el-form ref="bindingFormRef" :model="bindingForm" :rules="bindingRules" label-position="top">
<el-form-item label="目标网站">
<el-select v-model="bindingForm.website_id" style="width:100%" @change="onBindingWebsiteChange">
<el-option v-for="site in websites" :key="site.id" :label="site.name" :value="site.id" />
</el-select>
</el-form-item>
<el-form-item label="目标分组" prop="target_group_id">
<el-select v-model="bindingForm.target_group_id" filterable style="width:100%" @change="onTargetGroupChange">
<el-option v-for="group in bindingWebsiteGroups" :key="group.id" :label="`${group.name} (${group.rate_multiplier ?? '—'})`" :value="group.id" />
</el-select>
</el-form-item>
<el-form-item label="监听上游分组" prop="source_group_keys">
<el-select v-model="sourceGroupKeys" multiple filterable style="width:100%" placeholder="选择一个或多个上游分组" @change="onSourceGroupsChange">
<el-option v-for="item in upstreamGroupOptions" :key="item.key" :label="item.label" :value="item.key" />
</el-select>
</el-form-item>
<el-row :gutter="12">
<el-col :span="12">
<el-form-item label="算法">
<el-select v-model="bindingForm.algorithm" style="width:100%">
<el-option label="最高倍率 + 百分比" value="max_plus_percent" />
<el-option label="平均倍率 + 百分比" value="average_plus_percent" />
<el-option label="最低倍率 + 百分比" value="min_plus_percent" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="百分比">
<el-input-number v-model="bindingForm.percent" :min="0" :precision="2" style="width:100%" />
</el-form-item>
</el-col>
</el-row>
<el-form-item label="启用"><el-switch v-model="bindingForm.enabled" /></el-form-item>
</el-form>
<template #footer>
<el-button @click="bindingDrawer = false">取消</el-button>
<el-button type="primary" :loading="savingBinding" @click="saveBinding">保存</el-button>
</template>
</el-drawer>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import type { FormInstance } from 'element-plus'
import dayjs from 'dayjs'
import { Delete, Edit, Plus, Grid, Connection, Link } from '@element-plus/icons-vue'
import {
upstreamsApi,
websitesApi,
type BindingSourceGroup,
type GroupBindingData,
type GroupBindingForm,
type UpstreamData,
type WebsiteData,
type WebsiteForm,
type WebsiteGroup,
type WebsiteSyncLog,
} from '@/api'
const websites = ref<(WebsiteData & { _testing?: boolean })[]>([])
const upstreams = ref<UpstreamData[]>([])
const selectedWebsite = ref<WebsiteData | null>(null)
const websiteGroups = ref<WebsiteGroup[]>([])
const bindingWebsiteGroups = ref<WebsiteGroup[]>([])
const bindings = ref<(GroupBindingData & { _syncing?: boolean })[]>([])
const logs = ref<WebsiteSyncLog[]>([])
const snapshotsByUpstream = ref<Record<number, any[]>>({})
const websiteLoading = ref(false)
const groupsLoading = ref(false)
const bindingLoading = ref(false)
const logLoading = ref(false)
const statusLabel = (s: string) => ({ healthy: '健康', unhealthy: '异常', unknown: '未知' }[s] || s)
const algorithmLabel = (s: string) => ({ max_plus_percent: '最高倍率', average_plus_percent: '平均倍率', min_plus_percent: '最低倍率' }[s] || s)
const toUTC = (t: string) => /[Z+\-]\d*$/.test(t.trim()) ? t : t + 'Z'
const fmtTime = (t: string) => dayjs(toUTC(t)).format('MM-DD HH:mm:ss')
function defaultWebsiteForm(): WebsiteForm {
return {
name: '',
site_type: 'sub2api',
base_url: '',
api_prefix: '/api/v1/admin',
auth_type: 'api_key',
auth_config: { key: '', header: 'x-api-key' },
groups_endpoint: '/groups',
group_update_endpoint: '/groups/{id}',
enabled: true,
auto_sync_enabled: true,
timeout_seconds: 30,
}
}
const websiteDrawer = ref(false)
const savingWebsite = ref(false)
const editingWebsiteId = ref<number | null>(null)
const websiteFormRef = ref<FormInstance>()
const websiteForm = ref<WebsiteForm>(defaultWebsiteForm())
const websiteRules = {
name: [{ required: true, message: '请输入名称', trigger: 'blur' }],
base_url: [{ required: true, message: '请输入 Base URL', trigger: 'blur' }],
}
function defaultBindingForm(): GroupBindingForm {
return {
website_id: websites.value[0]?.id ?? 0,
target_group_id: '',
target_group_name: '',
source_groups: [],
percent: 0,
algorithm: 'max_plus_percent',
enabled: true,
}
}
const bindingDrawer = ref(false)
const savingBinding = ref(false)
const editingBindingId = ref<number | null>(null)
const bindingFormRef = ref<FormInstance>()
const bindingForm = ref<GroupBindingForm>(defaultBindingForm())
const sourceGroupKeys = ref<string[]>([])
const bindingRules = {
target_group_id: [{ required: true, message: '请选择目标分组', trigger: 'change' }],
}
const upstreamGroupOptions = computed(() => {
const rows: Array<{ key: string; label: string; source: BindingSourceGroup }> = []
for (const upstream of upstreams.value) {
const groups = snapshotsByUpstream.value[upstream.id] || []
for (const group of groups) {
const source = {
upstream_id: upstream.id,
upstream_name: upstream.name,
group_id: group.group_id,
group_name: group.group_name || group.group_id,
}
rows.push({
key: `${upstream.id}::${group.group_id}`,
label: `${upstream.name} / ${source.group_name} (${group.rate || '—'})`,
source,
})
}
}
return rows
})
async function loadWebsites() {
websiteLoading.value = true
try {
const res = await websitesApi.list()
websites.value = res.data
if (!selectedWebsite.value && res.data.length > 0) selectedWebsite.value = res.data[0]
} finally {
websiteLoading.value = false
}
}
async function loadUpstreamGroups() {
const upstreamRes = await upstreamsApi.list()
upstreams.value = upstreamRes.data
const entries: Record<number, any[]> = {}
await Promise.all(upstreams.value.map(async (upstream) => {
try {
const res = await upstreamsApi.latestSnapshot(upstream.id)
entries[upstream.id] = Object.values(res.data.snapshot?.groups || {})
} catch {
entries[upstream.id] = []
}
}))
snapshotsByUpstream.value = entries
}
async function loadWebsiteGroups() {
if (!selectedWebsite.value) return
groupsLoading.value = true
try {
const res = await websitesApi.groups(selectedWebsite.value.id)
websiteGroups.value = res.data
} catch (e: any) {
ElMessage.error(e.response?.data?.detail || '拉取分组失败')
} finally {
groupsLoading.value = false
}
}
async function loadBindingWebsiteGroups(websiteId: number) {
if (!websiteId) {
bindingWebsiteGroups.value = []
return
}
try {
const res = await websitesApi.groups(websiteId)
bindingWebsiteGroups.value = res.data
} catch {
bindingWebsiteGroups.value = []
}
}
async function loadBindings() {
bindingLoading.value = true
try {
const res = await websitesApi.listBindings()
bindings.value = res.data
} finally {
bindingLoading.value = false
}
}
async function loadLogs() {
logLoading.value = true
try {
const res = await websitesApi.logs({ limit: 50 })
logs.value = res.data
} finally {
logLoading.value = false
}
}
async function loadAll() {
await Promise.all([loadWebsites(), loadUpstreamGroups(), loadBindings(), loadLogs()])
if (selectedWebsite.value) await loadWebsiteGroups()
}
function selectWebsite(row: WebsiteData) {
selectedWebsite.value = row
loadWebsiteGroups()
}
function openWebsiteCreate() {
editingWebsiteId.value = null
websiteForm.value = defaultWebsiteForm()
websiteDrawer.value = true
}
function openWebsiteEdit(row: WebsiteData) {
editingWebsiteId.value = row.id
websiteForm.value = {
name: row.name,
site_type: row.site_type,
base_url: row.base_url,
api_prefix: row.api_prefix,
auth_type: row.auth_type,
auth_config: { ...row.auth_config_masked },
groups_endpoint: row.groups_endpoint,
group_update_endpoint: row.group_update_endpoint,
enabled: row.enabled,
auto_sync_enabled: row.auto_sync_enabled,
timeout_seconds: row.timeout_seconds,
}
websiteDrawer.value = true
}
async function saveWebsite() {
const valid = await websiteFormRef.value?.validate().catch(() => false)
if (!valid) return
savingWebsite.value = true
try {
if (editingWebsiteId.value) {
await websitesApi.update(editingWebsiteId.value, websiteForm.value)
ElMessage.success('保存成功')
} else {
await websitesApi.create(websiteForm.value)
ElMessage.success('创建成功')
}
websiteDrawer.value = false
await loadWebsites()
} catch (e: any) {
ElMessage.error(e.response?.data?.detail || '保存失败')
} finally {
savingWebsite.value = false
}
}
async function toggleWebsiteSync(row: WebsiteData) {
try {
await websitesApi.update(row.id, { auto_sync_enabled: row.auto_sync_enabled })
ElMessage.success(row.auto_sync_enabled ? '已启用自动同步' : '已停用自动同步')
} catch {
row.auto_sync_enabled = !row.auto_sync_enabled
ElMessage.error('操作失败')
}
}
async function testWebsite(row: WebsiteData & { _testing?: boolean }) {
row._testing = true
try {
const res = await websitesApi.test(row.id)
ElMessage[res.data.success ? 'success' : 'error'](res.data.detail || res.data.message)
await loadWebsites()
} finally {
row._testing = false
}
}
async function deleteWebsite(row: WebsiteData) {
try {
await ElMessageBox.confirm(`确认删除网站 "${row.name}"`, '删除确认', { type: 'warning' })
await websitesApi.delete(row.id)
ElMessage.success('已删除')
selectedWebsite.value = null
websiteGroups.value = []
await loadAll()
} catch {}
}
async function openBindingCreate(site?: WebsiteData | null, target?: WebsiteGroup) {
editingBindingId.value = null
bindingForm.value = defaultBindingForm()
if (site) bindingForm.value.website_id = site.id
await loadBindingWebsiteGroups(bindingForm.value.website_id)
if (target) {
bindingForm.value.target_group_id = target.id
bindingForm.value.target_group_name = target.name
}
sourceGroupKeys.value = []
bindingDrawer.value = true
}
async function openBindingEdit(row: GroupBindingData) {
editingBindingId.value = row.id
bindingForm.value = {
website_id: row.website_id,
target_group_id: row.target_group_id,
target_group_name: row.target_group_name,
source_groups: [...row.source_groups],
percent: row.percent,
algorithm: row.algorithm,
enabled: row.enabled,
}
sourceGroupKeys.value = row.source_groups.map(item => `${item.upstream_id}::${item.group_id}`)
await loadBindingWebsiteGroups(row.website_id)
bindingDrawer.value = true
}
async function onBindingWebsiteChange(value: number) {
bindingForm.value.target_group_id = ''
bindingForm.value.target_group_name = ''
await loadBindingWebsiteGroups(value)
}
function onTargetGroupChange(value: string) {
const group = bindingWebsiteGroups.value.find(item => item.id === value)
bindingForm.value.target_group_name = group?.name || value
}
function onSourceGroupsChange(values: string[]) {
const options = new Map(upstreamGroupOptions.value.map(item => [item.key, item.source]))
bindingForm.value.source_groups = values.map(value => options.get(value)).filter((item): item is BindingSourceGroup => Boolean(item))
}
async function saveBinding() {
if (!bindingForm.value.source_groups.length) {
ElMessage.error('请选择至少一个监听上游分组')
return
}
const valid = await bindingFormRef.value?.validate().catch(() => false)
if (!valid) return
savingBinding.value = true
try {
if (editingBindingId.value) {
await websitesApi.updateBinding(editingBindingId.value, bindingForm.value)
} else {
await websitesApi.createBinding(bindingForm.value)
}
ElMessage.success('保存成功,已尝试同步')
bindingDrawer.value = false
await Promise.all([loadBindings(), loadLogs()])
if (selectedWebsite.value?.id === bindingForm.value.website_id) await loadWebsiteGroups()
} catch (e: any) {
ElMessage.error(e.response?.data?.detail || '保存失败')
} finally {
savingBinding.value = false
}
}
async function toggleBinding(row: GroupBindingData) {
try {
await websitesApi.updateBinding(row.id, { enabled: row.enabled })
ElMessage.success(row.enabled ? '已启用绑定' : '已停用绑定')
} catch {
row.enabled = !row.enabled
ElMessage.error('操作失败')
}
}
async function syncBinding(row: GroupBindingData & { _syncing?: boolean }) {
row._syncing = true
try {
const res = await websitesApi.syncNow(row.id)
ElMessage[res.data.status === 'success' ? 'success' : 'error'](res.data.message)
await loadLogs()
if (selectedWebsite.value?.id === row.website_id) await loadWebsiteGroups()
} finally {
row._syncing = false
}
}
async function deleteBinding(row: GroupBindingData) {
try {
await ElMessageBox.confirm('确认删除该绑定?', '删除确认', { type: 'warning' })
await websitesApi.deleteBinding(row.id)
ElMessage.success('已删除')
await loadBindings()
} catch {}
}
onMounted(loadAll)
</script>
<style scoped>
.websites-page { display: flex; flex-direction: column; gap: 18px; padding-bottom: 1rem; }
.page-block {
padding: 1rem;
}
.page-header {
border-radius: var(--radius-shell);
}
.panel-head {
min-height: 48px;
padding: 12px 14px;
border-bottom: 1px solid var(--border-color);
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.panel-title { font-size: 14px; font-weight: 600; color: var(--text-primary); }
.panel-sub { font-size: 12px; color: var(--text-muted); margin-top: 2px; }
.content-grid {
display: grid;
grid-template-columns: 1fr;
gap: 18px;
}
.cell-name { font-weight: 500; font-size: 14px; }
.cell-url { font-size: 12px; color: var(--text-muted); margin-top: 2px; overflow-wrap: anywhere; }
.muted { color: var(--text-muted); font-size: 12px; }
.error-text { font-size: 12px; color: var(--color-danger); cursor: pointer; }
.rate-value { font-weight: 600; color: var(--color-primary-strong); font-family: monospace; }
.action-row {
display: flex;
align-items: center;
flex-wrap: nowrap;
gap: 4px;
}
.action-row .el-button.is-circle {
width: 28px;
height: 28px;
}
.binding-actions {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 4px;
}
.binding-list { min-height: 120px; }
.binding-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px 14px;
border-bottom: 1px solid var(--border-color);
}
.binding-item:last-child { border-bottom: none; }
.binding-main { min-width: 0; }
.binding-title {
color: var(--text-primary);
font-size: 13px;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.binding-meta { color: var(--text-muted); font-size: 12px; margin-top: 3px; }
.empty-hint {
text-align: center;
padding: 36px 16px;
color: var(--text-muted);
font-size: 13px;
}
.switch-line {
min-height: 36px;
display: flex;
align-items: center;
justify-content: space-between;
color: var(--text-secondary);
font-size: 13px;
}
@media (min-width: 1024px) {
.content-grid { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); }
}
@media (max-width: 768px) {
.page-header {
flex-direction: column;
align-items: stretch;
}
.page-header .el-button { min-height: 44px; }
.binding-item {
align-items: flex-start;
flex-direction: column;
}
.binding-actions { width: 100%; justify-content: flex-end; }
}
</style>