diff --git a/server/src/index.ts b/server/src/index.ts index 778e8bb..003f59d 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -22,6 +22,8 @@ const app = new Hono(); app.get('/api/health', (c) => c.json({ ok: true, name: 'proxy-station' })); app.use('/api/*', adminAuth); +// 鉴权探针:能到这里说明已通过 adminAuth(token 正确,或未设 token 且来源为本机/局域网) +app.get('/api/auth/session', (c) => c.json({ ok: true, tokenRequired: !!env.adminToken })); app.route('/api/nodes', nodesRoute); app.route('/api/groups', groupsRoute); app.route('/api/rules', rulesRoute); diff --git a/web/src/App.vue b/web/src/App.vue index f0eeee4..104c9a9 100644 --- a/web/src/App.vue +++ b/web/src/App.vue @@ -3,9 +3,23 @@ import { computed } from 'vue'; import { useRoute, useRouter } from 'vue-router'; import { NConfigProvider, NMessageProvider, NDialogProvider } from 'naive-ui'; import { themeOverrides } from './theme'; +import { useAuthStore } from './stores/auth'; +import { UNAUTHORIZED_EVENT } from './api/client'; const route = useRoute(); const router = useRouter(); +const auth = useAuthStore(); + +// 任何接口回 401:本地 token 已失效,清掉并回登录页 +window.addEventListener(UNAUTHORIZED_EVENT, () => { + auth.invalidate(); + if (!route.meta.public) router.replace({ path: '/login', query: { redirect: route.fullPath } }); +}); + +function logout() { + auth.logout(); + router.replace('/login'); +} const navItems = [ { path: '/nodes', label: '节点' }, @@ -22,7 +36,8 @@ const active = computed(() => route.path); -
+ +
@@ -43,6 +58,7 @@ const active = computed(() => route.path);
直连出口 WARP 出口 +
@@ -156,6 +172,22 @@ const active = computed(() => route.path); white-space: nowrap; } +.logout { + appearance: none; + border: 1px solid var(--line); + background: transparent; + color: var(--muted); + font: 600 12px var(--font-body); + padding: 4px 12px; + border-radius: 999px; + cursor: pointer; +} + +.logout:hover { + color: var(--text); + border-color: var(--text); +} + .content { max-width: 1240px; margin: 0 auto; @@ -178,7 +210,7 @@ const active = computed(() => route.path); overflow-x: auto; } - .legend { + .legend-item { display: none; } diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 3f71020..69740a8 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -1,4 +1,5 @@ const ADMIN_TOKEN_KEY = 'proxy-station:admin-token'; +export const UNAUTHORIZED_EVENT = 'proxy-station:unauthorized'; export function getAdminToken(): string { return localStorage.getItem(ADMIN_TOKEN_KEY) || ''; @@ -30,6 +31,8 @@ async function request(method: string, path: string, body?: unknown): Promise }); const data = await res.json().catch(() => null); if (!res.ok) { + // token 失效或被更换:广播给路由层回登录页。用事件而不是直接 import router,避免 client → router → store → client 的循环依赖 + if (res.status === 401 && !path.startsWith('/api/auth/')) window.dispatchEvent(new CustomEvent(UNAUTHORIZED_EVENT)); throw new ApiError(res.status, (data as any)?.error || `请求失败(${res.status})`); } return data as T; diff --git a/web/src/pages/LoginPage.vue b/web/src/pages/LoginPage.vue new file mode 100644 index 0000000..fa2ad2d --- /dev/null +++ b/web/src/pages/LoginPage.vue @@ -0,0 +1,127 @@ + + + + + diff --git a/web/src/pages/SettingsPage.vue b/web/src/pages/SettingsPage.vue index 821c05d..320bb8d 100644 --- a/web/src/pages/SettingsPage.vue +++ b/web/src/pages/SettingsPage.vue @@ -1,14 +1,12 @@