feat: 管理界面改为 token 登录门禁
此前不填 token 也能进入各页面,只是没有内容。新增受鉴权保护的 /api/auth/session 探针与登录页,路由守卫在进入任何非登录页前先探针, 未通过则跳登录页并记住原目标;接口返回 401 时自动清 token 回登录页; 顶栏加退出按钮,撤掉设置页里的 token 卡片。本机未设 ADMIN_TOKEN 时 探针直接通过,开发体验不变。 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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);
|
||||
<NConfigProvider :theme-overrides="themeOverrides">
|
||||
<NMessageProvider>
|
||||
<NDialogProvider>
|
||||
<div class="shell">
|
||||
<RouterView v-if="route.meta.public" />
|
||||
<div v-else class="shell">
|
||||
<header class="topbar">
|
||||
<div class="topbar-inner">
|
||||
<div class="brand">
|
||||
@@ -43,6 +58,7 @@ const active = computed(() => route.path);
|
||||
<div class="legend">
|
||||
<span class="legend-item"><i class="lamp lamp-direct" />直连出口</span>
|
||||
<span class="legend-item"><i class="lamp lamp-warp" />WARP 出口</span>
|
||||
<button v-if="auth.tokenRequired" class="logout" @click="logout">退出</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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<T>(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;
|
||||
|
||||
127
web/src/pages/LoginPage.vue
Normal file
127
web/src/pages/LoginPage.vue
Normal file
@@ -0,0 +1,127 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { NButton, NInput } from 'naive-ui';
|
||||
import { useAuthStore } from '../stores/auth';
|
||||
|
||||
const auth = useAuthStore();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
const token = ref('');
|
||||
const busy = ref(false);
|
||||
const error = ref(auth.lastError);
|
||||
|
||||
async function submit() {
|
||||
if (!token.value.trim() || busy.value) return;
|
||||
busy.value = true;
|
||||
error.value = '';
|
||||
const ok = await auth.login(token.value);
|
||||
busy.value = false;
|
||||
if (ok) {
|
||||
const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : '/';
|
||||
router.replace(redirect.startsWith('/') ? redirect : '/');
|
||||
} else {
|
||||
error.value = auth.lastError || 'token 不正确';
|
||||
token.value = '';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="gate">
|
||||
<form class="card" @submit.prevent="submit">
|
||||
<div class="brand">
|
||||
<span class="brand-name display">Proxy Station</span>
|
||||
<span class="brand-strip"><i class="strip-direct" /><i class="strip-warp" /></span>
|
||||
</div>
|
||||
<p class="label">管理入口</p>
|
||||
<p class="hint">填入服务端 <code>ADMIN_TOKEN</code> 进入。token 保存在本浏览器,退出时清除。</p>
|
||||
<NInput
|
||||
v-model:value="token"
|
||||
type="password"
|
||||
show-password-on="click"
|
||||
placeholder="ADMIN_TOKEN"
|
||||
class="mono"
|
||||
size="large"
|
||||
:disabled="busy"
|
||||
autofocus
|
||||
@keyup.enter="submit"
|
||||
/>
|
||||
<p v-if="error" class="error" role="alert">{{ error }}</p>
|
||||
<NButton type="primary" size="large" block attr-type="submit" :loading="busy" :disabled="!token.trim()">进入</NButton>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.gate {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.card {
|
||||
width: 100%;
|
||||
max-width: 380px;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
padding: 28px 28px 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.brand-name {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.brand-strip {
|
||||
display: flex;
|
||||
height: 3px;
|
||||
width: 72px;
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.brand-strip i {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.strip-direct {
|
||||
background: var(--direct);
|
||||
}
|
||||
|
||||
.strip-warp {
|
||||
background: var(--warp);
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.hint code {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.error {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: var(--danger);
|
||||
}
|
||||
</style>
|
||||
@@ -1,14 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref, watch } from 'vue';
|
||||
import { NButton, NCard, NInput, NSpace, NTabPane, NTabs, useMessage } from 'naive-ui';
|
||||
import { getAdminToken, setAdminToken } from '../api/client';
|
||||
import { NButton, NCard, NInput, NTabPane, NTabs, useMessage } from 'naive-ui';
|
||||
import { useSubscriptionsStore } from '../stores/subscriptions';
|
||||
|
||||
const store = useSubscriptionsStore();
|
||||
const message = useMessage();
|
||||
|
||||
const sections = ref<Record<string, string>>({});
|
||||
const adminToken = ref(getAdminToken());
|
||||
const activeSection = ref('General');
|
||||
|
||||
onMounted(async () => {
|
||||
@@ -29,26 +27,12 @@ async function saveSections() {
|
||||
message.success('已保存');
|
||||
}
|
||||
|
||||
function saveAdminToken() {
|
||||
setAdminToken(adminToken.value.trim());
|
||||
message.success(adminToken.value.trim() ? '已保存到本浏览器' : '已清除');
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<h1 class="display page-title">设置</h1>
|
||||
|
||||
<NCard title="管理鉴权" size="small" class="block">
|
||||
<p class="hint">
|
||||
服务端设置了 ADMIN_TOKEN 环境变量时,管理接口需要携带同样的 token。此处保存的值仅存在本浏览器 localStorage。
|
||||
</p>
|
||||
<NSpace>
|
||||
<NInput v-model:value="adminToken" type="password" show-password-on="click" placeholder="ADMIN_TOKEN" class="mono" style="width: 320px" />
|
||||
<NButton @click="saveAdminToken">保存</NButton>
|
||||
</NSpace>
|
||||
</NCard>
|
||||
|
||||
<NCard title="Surge 原文段落" size="small" class="block">
|
||||
<p class="hint">
|
||||
这些段落按原文透传进 Surge 配置([General]、[MITM] 等),此处直接编辑文本。ShadowRocket/ClashMeta 不使用这些内容。
|
||||
|
||||
@@ -1,13 +1,28 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router';
|
||||
import { useAuthStore } from './stores/auth';
|
||||
|
||||
export const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{ path: '/', redirect: '/nodes' },
|
||||
{ path: '/login', component: () => import('./pages/LoginPage.vue'), meta: { title: '登录', public: true } },
|
||||
{ path: '/nodes', component: () => import('./pages/NodesPage.vue'), meta: { title: '节点' } },
|
||||
{ path: '/groups', component: () => import('./pages/GroupsPage.vue'), meta: { title: '策略组' } },
|
||||
{ path: '/rules', component: () => import('./pages/RulesPage.vue'), meta: { title: '规则' } },
|
||||
{ path: '/subscriptions', component: () => import('./pages/SubscriptionsPage.vue'), meta: { title: '订阅' } },
|
||||
{ path: '/settings', component: () => import('./pages/SettingsPage.vue'), meta: { title: '设置' } },
|
||||
{ path: '/:pathMatch(.*)*', redirect: '/nodes' },
|
||||
],
|
||||
});
|
||||
|
||||
// 任何非公开页面进入前都要先通过一次探针;直接在地址栏敲子页面路径同样会被拦到登录页
|
||||
router.beforeEach(async (to) => {
|
||||
const auth = useAuthStore();
|
||||
if (to.meta.public) {
|
||||
if (auth.status === 'unknown') await auth.check();
|
||||
return auth.status === 'ok' ? { path: '/' } : true;
|
||||
}
|
||||
if (auth.status !== 'ok') await auth.check();
|
||||
if (auth.status === 'ok') return true;
|
||||
return { path: '/login', query: to.fullPath !== '/' ? { redirect: to.fullPath } : {} };
|
||||
});
|
||||
|
||||
62
web/src/stores/auth.ts
Normal file
62
web/src/stores/auth.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { ref } from 'vue';
|
||||
import { api, ApiError, getAdminToken, setAdminToken } from '../api/client';
|
||||
|
||||
interface SessionDto {
|
||||
ok: boolean;
|
||||
tokenRequired: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 登录态只存在内存里:每次刷新都重新探针一次 /api/auth/session。
|
||||
* token 本身仍存 localStorage(刷新后免重登),探针失败就清掉它。
|
||||
*/
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
const status = ref<'unknown' | 'ok' | 'denied'>('unknown');
|
||||
const tokenRequired = ref(false);
|
||||
const lastError = ref('');
|
||||
|
||||
async function check(): Promise<boolean> {
|
||||
try {
|
||||
const s = await api.get<SessionDto>('/api/auth/session');
|
||||
tokenRequired.value = s.tokenRequired;
|
||||
status.value = 'ok';
|
||||
lastError.value = '';
|
||||
return true;
|
||||
} catch (e) {
|
||||
status.value = 'denied';
|
||||
if (e instanceof ApiError) {
|
||||
// 401 = 需要 token;403 = 服务端未设 token 且来源不是本机/局域网,填 token 也无济于事
|
||||
tokenRequired.value = e.status === 401;
|
||||
lastError.value = e.status === 401 ? '' : e.message;
|
||||
} else {
|
||||
lastError.value = '无法连接服务端';
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function login(token: string): Promise<boolean> {
|
||||
setAdminToken(token.trim());
|
||||
const ok = await check();
|
||||
if (!ok) {
|
||||
setAdminToken('');
|
||||
if (tokenRequired.value) lastError.value = 'token 不正确';
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
|
||||
function logout() {
|
||||
setAdminToken('');
|
||||
status.value = 'denied';
|
||||
}
|
||||
|
||||
/** 收到任何接口的 401 时调用:本地 token 已失效,回到未登录态 */
|
||||
function invalidate() {
|
||||
if (getAdminToken()) setAdminToken('');
|
||||
status.value = 'denied';
|
||||
tokenRequired.value = true;
|
||||
}
|
||||
|
||||
return { status, tokenRequired, lastError, check, login, logout, invalidate };
|
||||
});
|
||||
Reference in New Issue
Block a user