init: init proj
This commit is contained in:
12
web/index.html
Normal file
12
web/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Proxy Station</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
25
web/package.json
Normal file
25
web/package.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "@proxy-station/web",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"typecheck": "vue-tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource/ibm-plex-mono": "^5.1.0",
|
||||
"@proxy-station/shared": "workspace:*",
|
||||
"naive-ui": "^2.40.4",
|
||||
"pinia": "^2.3.0",
|
||||
"vue": "^3.5.13",
|
||||
"vue-router": "^4.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.2.1",
|
||||
"typescript": "^5.7.2",
|
||||
"vite": "^6.0.6",
|
||||
"vue-tsc": "^2.2.0"
|
||||
}
|
||||
}
|
||||
189
web/src/App.vue
Normal file
189
web/src/App.vue
Normal file
@@ -0,0 +1,189 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { NConfigProvider, NMessageProvider, NDialogProvider } from 'naive-ui';
|
||||
import { themeOverrides } from './theme';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
const navItems = [
|
||||
{ path: '/nodes', label: '节点' },
|
||||
{ path: '/groups', label: '策略组' },
|
||||
{ path: '/rules', label: '规则' },
|
||||
{ path: '/subscriptions', label: '订阅' },
|
||||
{ path: '/settings', label: '设置' },
|
||||
];
|
||||
|
||||
const active = computed(() => route.path);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NConfigProvider :theme-overrides="themeOverrides">
|
||||
<NMessageProvider>
|
||||
<NDialogProvider>
|
||||
<div class="shell">
|
||||
<header class="topbar">
|
||||
<div class="topbar-inner">
|
||||
<div class="brand">
|
||||
<span class="brand-name">Proxy Station</span>
|
||||
<span class="brand-strip"><i class="strip-direct" /><i class="strip-warp" /></span>
|
||||
</div>
|
||||
<nav class="nav">
|
||||
<button
|
||||
v-for="item in navItems"
|
||||
:key="item.path"
|
||||
class="nav-item"
|
||||
:class="{ active: active.startsWith(item.path) }"
|
||||
@click="router.push(item.path)"
|
||||
>
|
||||
{{ item.label }}
|
||||
</button>
|
||||
</nav>
|
||||
<div class="legend">
|
||||
<span class="legend-item"><i class="lamp lamp-direct" />直连出口</span>
|
||||
<span class="legend-item"><i class="lamp lamp-warp" />WARP 出口</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<main class="content">
|
||||
<RouterView />
|
||||
</main>
|
||||
</div>
|
||||
</NDialogProvider>
|
||||
</NMessageProvider>
|
||||
</NConfigProvider>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.shell {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
background: var(--panel);
|
||||
border-bottom: 1px solid var(--line);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.topbar-inner {
|
||||
max-width: 1240px;
|
||||
margin: 0 auto;
|
||||
padding: 0 32px;
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 36px;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
padding: 12px 0;
|
||||
}
|
||||
|
||||
.brand-name {
|
||||
font-size: 16px;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.01em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 品牌下的线路色条:站名牌的双色带 */
|
||||
.brand-strip {
|
||||
display: flex;
|
||||
height: 3px;
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.brand-strip i {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.strip-direct {
|
||||
background: var(--direct);
|
||||
}
|
||||
|
||||
.strip-warp {
|
||||
background: var(--warp);
|
||||
}
|
||||
|
||||
.nav {
|
||||
display: flex;
|
||||
gap: 26px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* 选中 = 粗下划线(站台线条),不加底不加边 */
|
||||
.nav-item {
|
||||
appearance: none;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
font: 600 14px var(--font-body);
|
||||
padding: 0 2px;
|
||||
border-bottom: 3px solid transparent;
|
||||
border-top: 3px solid transparent;
|
||||
cursor: pointer;
|
||||
transition: color 0.1s;
|
||||
}
|
||||
|
||||
.nav-item:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.nav-item.active {
|
||||
color: var(--ink);
|
||||
border-bottom-color: var(--ink);
|
||||
}
|
||||
|
||||
.legend {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.legend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.content {
|
||||
max-width: 1240px;
|
||||
margin: 0 auto;
|
||||
padding: 28px 32px 64px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.topbar-inner {
|
||||
padding: 0 16px;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.brand-name {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.nav {
|
||||
gap: 16px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.legend {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 16px 16px 48px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
44
web/src/api/client.ts
Normal file
44
web/src/api/client.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
const ADMIN_TOKEN_KEY = 'proxy-station:admin-token';
|
||||
|
||||
export function getAdminToken(): string {
|
||||
return localStorage.getItem(ADMIN_TOKEN_KEY) || '';
|
||||
}
|
||||
|
||||
export function setAdminToken(token: string) {
|
||||
if (token) localStorage.setItem(ADMIN_TOKEN_KEY, token);
|
||||
else localStorage.removeItem(ADMIN_TOKEN_KEY);
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
public status: number,
|
||||
message: string
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
async function request<T>(method: string, path: string, body?: unknown): Promise<T> {
|
||||
const headers: Record<string, string> = {};
|
||||
const token = getAdminToken();
|
||||
if (token) headers.Authorization = `Bearer ${token}`;
|
||||
if (body !== undefined) headers['content-type'] = 'application/json';
|
||||
const res = await fetch(path, {
|
||||
method,
|
||||
headers,
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
});
|
||||
const data = await res.json().catch(() => null);
|
||||
if (!res.ok) {
|
||||
throw new ApiError(res.status, (data as any)?.error || `请求失败(${res.status})`);
|
||||
}
|
||||
return data as T;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
get: <T>(path: string) => request<T>('GET', path),
|
||||
post: <T>(path: string, body?: unknown) => request<T>('POST', path, body),
|
||||
put: <T>(path: string, body?: unknown) => request<T>('PUT', path, body),
|
||||
patch: <T>(path: string, body?: unknown) => request<T>('PATCH', path, body),
|
||||
delete: <T>(path: string) => request<T>('DELETE', path),
|
||||
};
|
||||
62
web/src/components/EgressBadge.vue
Normal file
62
web/src/components/EgressBadge.vue
Normal file
@@ -0,0 +1,62 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import type { Egress } from '@proxy-station/shared';
|
||||
|
||||
const props = withDefaults(defineProps<{ egress: Egress; compact?: boolean }>(), { compact: false });
|
||||
|
||||
const label = computed(
|
||||
() => ({ direct: '直连', warp: 'WARP', unknown: '未标注' })[props.egress]
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- compact:空间紧张处只用语义圆点,颜色即信息 -->
|
||||
<span v-if="compact" class="dot-only" :title="label">
|
||||
<i class="lamp" :class="{
|
||||
'lamp-direct': egress === 'direct',
|
||||
'lamp-warp': egress === 'warp',
|
||||
'lamp-off': egress === 'unknown',
|
||||
}" />
|
||||
</span>
|
||||
<span v-else class="badge" :class="`badge-${egress}`"><i class="lamp" :class="{
|
||||
'lamp-direct': egress === 'direct',
|
||||
'lamp-warp': egress === 'warp',
|
||||
'lamp-off': egress === 'unknown',
|
||||
}" />{{ label }}</span>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 导视胶囊标签:淡色底 + 语义圆点 */
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
padding: 3px 10px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.badge-direct {
|
||||
background: var(--direct-dim);
|
||||
color: var(--direct);
|
||||
}
|
||||
|
||||
.badge-warp {
|
||||
background: var(--warp-dim);
|
||||
color: var(--warp);
|
||||
}
|
||||
|
||||
.badge-unknown {
|
||||
background: #eeeeea;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.dot-only {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
121
web/src/components/ImportModal.vue
Normal file
121
web/src/components/ImportModal.vue
Normal file
@@ -0,0 +1,121 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { NModal, NInput, NButton, NSpace, NTag, useMessage } from 'naive-ui';
|
||||
import { useNodesStore, type ImportResult } from '../stores/nodes';
|
||||
|
||||
const props = defineProps<{ show: boolean }>();
|
||||
const emit = defineEmits<{ (e: 'update:show', v: boolean): void; (e: 'imported'): void }>();
|
||||
|
||||
const store = useNodesStore();
|
||||
const message = useMessage();
|
||||
const text = ref('');
|
||||
const preview = ref<ImportResult | null>(null);
|
||||
const busy = ref(false);
|
||||
|
||||
async function doPreview() {
|
||||
if (!text.value.trim()) return;
|
||||
busy.value = true;
|
||||
try {
|
||||
preview.value = await store.importText(text.value, false);
|
||||
} catch (e: any) {
|
||||
message.error(e.message);
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function doCommit() {
|
||||
busy.value = true;
|
||||
try {
|
||||
const result = await store.importText(text.value, true);
|
||||
message.success(`已导入 ${result.succeeded} 个节点`);
|
||||
text.value = '';
|
||||
preview.value = null;
|
||||
emit('imported');
|
||||
emit('update:show', false);
|
||||
} catch (e: any) {
|
||||
message.error(e.message);
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function close() {
|
||||
preview.value = null;
|
||||
emit('update:show', false);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NModal :show="show" preset="card" title="批量导入分享链接" style="max-width: 680px" @update:show="close">
|
||||
<p class="hint">每行一条链接,支持 vmess:// trojan:// ss:// hysteria2://。重名节点自动加后缀。</p>
|
||||
<NInput
|
||||
v-model:value="text"
|
||||
type="textarea"
|
||||
:rows="8"
|
||||
class="mono"
|
||||
placeholder="vmess://… trojan://…"
|
||||
@input="preview = null"
|
||||
/>
|
||||
|
||||
<div v-if="preview" class="preview">
|
||||
<div v-for="(item, i) in preview.items" :key="i" class="preview-row">
|
||||
<NTag :type="item.ok ? 'success' : 'error'" size="small">{{ item.ok ? '可导入' : '失败' }}</NTag>
|
||||
<span v-if="item.ok" class="preview-name">
|
||||
{{ item.node!.name }}
|
||||
<span class="mono preview-detail">{{ item.node!.protocol }} · {{ item.node!.server }}:{{ item.node!.port }}</span>
|
||||
</span>
|
||||
<span v-else class="preview-error">{{ item.error }}<span class="mono preview-detail">{{ item.uri.slice(0, 48) }}…</span></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<NSpace justify="end">
|
||||
<NButton @click="close">取消</NButton>
|
||||
<NButton :loading="busy" :disabled="!text.trim()" @click="doPreview">解析预览</NButton>
|
||||
<NButton
|
||||
type="primary"
|
||||
:loading="busy"
|
||||
:disabled="!preview || preview.succeeded === 0"
|
||||
@click="doCommit"
|
||||
>
|
||||
导入 {{ preview?.succeeded ?? 0 }} 个节点
|
||||
</NButton>
|
||||
</NSpace>
|
||||
</template>
|
||||
</NModal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.hint {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.preview {
|
||||
margin-top: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
max-height: 220px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.preview-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.preview-detail {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.preview-error {
|
||||
color: var(--danger);
|
||||
}
|
||||
</style>
|
||||
184
web/src/components/NodeFormModal.vue
Normal file
184
web/src/components/NodeFormModal.vue
Normal file
@@ -0,0 +1,184 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, watch } from 'vue';
|
||||
import {
|
||||
NModal,
|
||||
NForm,
|
||||
NFormItem,
|
||||
NInput,
|
||||
NInputNumber,
|
||||
NSelect,
|
||||
NSwitch,
|
||||
NButton,
|
||||
NSpace,
|
||||
useMessage,
|
||||
} from 'naive-ui';
|
||||
import { nodeInputSchema, PROTOCOLS, type NodeDto, type NodeInput } from '@proxy-station/shared';
|
||||
|
||||
const props = defineProps<{ show: boolean; node?: NodeDto | null }>();
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:show', v: boolean): void;
|
||||
(e: 'submit', input: NodeInput, id?: string): void;
|
||||
(e: 'delete', id: string): void;
|
||||
}>();
|
||||
|
||||
const message = useMessage();
|
||||
|
||||
const blank = (): NodeInput => ({
|
||||
name: '',
|
||||
protocol: 'vmess',
|
||||
server: '',
|
||||
port: 443,
|
||||
network: 'ws',
|
||||
wsPath: '/',
|
||||
wsHost: null,
|
||||
tls: true,
|
||||
sni: null,
|
||||
skipCertVerify: false,
|
||||
uuid: null,
|
||||
alterId: 0,
|
||||
security: 'auto',
|
||||
password: null,
|
||||
method: 'chacha20-poly1305',
|
||||
obfsType: null,
|
||||
obfsPassword: null,
|
||||
region: null,
|
||||
tags: [],
|
||||
enabled: true,
|
||||
sortOrder: 0,
|
||||
});
|
||||
|
||||
const form = reactive<NodeInput>(blank());
|
||||
|
||||
watch(
|
||||
() => props.show,
|
||||
(show) => {
|
||||
if (!show) return;
|
||||
Object.assign(form, blank(), props.node ?? {});
|
||||
}
|
||||
);
|
||||
|
||||
const isEdit = computed(() => !!props.node);
|
||||
const protocolOptions = PROTOCOLS.map((p) => ({ label: p, value: p }));
|
||||
const tagOptions = [
|
||||
{ label: 'direct(直连出口)', value: 'direct' },
|
||||
{ label: 'warp(WARP 出口)', value: 'warp' },
|
||||
];
|
||||
|
||||
const showWs = computed(() => form.protocol !== 'hysteria2');
|
||||
const showVmess = computed(() => form.protocol === 'vmess');
|
||||
const showPassword = computed(() => form.protocol !== 'vmess');
|
||||
const showSs = computed(() => form.protocol === 'shadowsocks');
|
||||
const showHy2 = computed(() => form.protocol === 'hysteria2');
|
||||
|
||||
function submit() {
|
||||
const result = nodeInputSchema.safeParse({ ...form });
|
||||
if (!result.success) {
|
||||
message.error(result.error.issues[0]?.message || '表单校验失败');
|
||||
return;
|
||||
}
|
||||
emit('submit', { ...form }, props.node?.id);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NModal
|
||||
:show="show"
|
||||
preset="card"
|
||||
:title="isEdit ? `编辑节点 · ${node?.name}` : '添加节点'"
|
||||
style="max-width: 560px"
|
||||
@update:show="emit('update:show', $event)"
|
||||
>
|
||||
<NForm label-placement="left" label-width="92">
|
||||
<NFormItem label="名称" required>
|
||||
<NInput v-model:value="form.name" placeholder="如 SG-vmess-warp" />
|
||||
</NFormItem>
|
||||
<NFormItem label="协议" required>
|
||||
<NSelect v-model:value="form.protocol" :options="protocolOptions" :disabled="isEdit" />
|
||||
</NFormItem>
|
||||
<NFormItem label="服务器" required>
|
||||
<NInput v-model:value="form.server" placeholder="域名或 IP" class="mono" />
|
||||
</NFormItem>
|
||||
<NFormItem label="端口" required>
|
||||
<NInputNumber v-model:value="form.port" :min="1" :max="65535" style="width: 100%" />
|
||||
</NFormItem>
|
||||
|
||||
<template v-if="showVmess">
|
||||
<NFormItem label="UUID" required>
|
||||
<NInput v-model:value="form.uuid" class="mono" />
|
||||
</NFormItem>
|
||||
</template>
|
||||
<template v-if="showPassword">
|
||||
<NFormItem label="密码" required>
|
||||
<NInput v-model:value="form.password" type="password" show-password-on="click" class="mono" />
|
||||
</NFormItem>
|
||||
</template>
|
||||
<template v-if="showSs">
|
||||
<NFormItem label="加密方式" required>
|
||||
<NInput v-model:value="form.method" placeholder="chacha20-poly1305" class="mono" />
|
||||
</NFormItem>
|
||||
</template>
|
||||
<template v-if="showHy2">
|
||||
<NFormItem label="混淆">
|
||||
<NSelect
|
||||
v-model:value="form.obfsType"
|
||||
:options="[
|
||||
{ label: '无', value: null as any },
|
||||
{ label: 'salamander', value: 'salamander' },
|
||||
]"
|
||||
/>
|
||||
</NFormItem>
|
||||
<NFormItem v-if="form.obfsType === 'salamander'" label="混淆密码" required>
|
||||
<NInput v-model:value="form.obfsPassword" type="password" show-password-on="click" class="mono" />
|
||||
</NFormItem>
|
||||
</template>
|
||||
|
||||
<template v-if="showWs">
|
||||
<NFormItem label="传输">
|
||||
<NSelect
|
||||
v-model:value="form.network"
|
||||
:options="[
|
||||
{ label: 'TCP', value: 'tcp' },
|
||||
{ label: 'WebSocket', value: 'ws' },
|
||||
]"
|
||||
/>
|
||||
</NFormItem>
|
||||
<template v-if="form.network === 'ws'">
|
||||
<NFormItem label="WS 路径" required>
|
||||
<NInput v-model:value="form.wsPath" placeholder="/xxxxxx" class="mono" />
|
||||
</NFormItem>
|
||||
<NFormItem label="WS Host">
|
||||
<NInput v-model:value="form.wsHost" placeholder="默认与服务器相同" class="mono" />
|
||||
</NFormItem>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<NFormItem label="TLS">
|
||||
<NSwitch v-model:value="form.tls" :disabled="form.protocol === 'hysteria2'" />
|
||||
</NFormItem>
|
||||
<NFormItem v-if="form.tls" label="SNI">
|
||||
<NInput v-model:value="form.sni" placeholder="默认与服务器相同" class="mono" />
|
||||
</NFormItem>
|
||||
|
||||
<NFormItem label="出口标签">
|
||||
<NSelect v-model:value="form.tags" multiple :options="tagOptions" placeholder="direct / warp,用于线路矩阵归位" />
|
||||
</NFormItem>
|
||||
<NFormItem label="站点代号">
|
||||
<NInput v-model:value="form.region" placeholder="如 SG/LA/RN,留空则按域名推断" />
|
||||
</NFormItem>
|
||||
<NFormItem label="启用">
|
||||
<NSwitch v-model:value="form.enabled" />
|
||||
</NFormItem>
|
||||
</NForm>
|
||||
|
||||
<template #footer>
|
||||
<NSpace justify="space-between">
|
||||
<NButton v-if="isEdit" type="error" quaternary @click="emit('delete', node!.id)">删除节点</NButton>
|
||||
<span v-else />
|
||||
<NSpace>
|
||||
<NButton @click="emit('update:show', false)">取消</NButton>
|
||||
<NButton type="primary" @click="submit">{{ isEdit ? '保存' : '添加' }}</NButton>
|
||||
</NSpace>
|
||||
</NSpace>
|
||||
</template>
|
||||
</NModal>
|
||||
</template>
|
||||
51
web/src/components/ProtoDot.vue
Normal file
51
web/src/components/ProtoDot.vue
Normal file
@@ -0,0 +1,51 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import type { Protocol } from '@proxy-station/shared';
|
||||
|
||||
const props = defineProps<{ protocol: Protocol }>();
|
||||
|
||||
const LETTERS: Record<Protocol, string> = {
|
||||
vmess: 'V',
|
||||
trojan: 'T',
|
||||
shadowsocks: 'S',
|
||||
hysteria2: 'H',
|
||||
};
|
||||
|
||||
const letter = computed(() => LETTERS[props.protocol]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="proto-dot" :class="`proto-${protocol}`" :title="protocol">{{ letter }}</span>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 线路色圆标:导视系统的线路标识 */
|
||||
.proto-dot {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.proto-vmess {
|
||||
background: var(--proto-vmess);
|
||||
}
|
||||
|
||||
.proto-trojan {
|
||||
background: var(--proto-trojan);
|
||||
}
|
||||
|
||||
.proto-shadowsocks {
|
||||
background: var(--proto-shadowsocks);
|
||||
}
|
||||
|
||||
.proto-hysteria2 {
|
||||
background: var(--proto-hysteria2);
|
||||
}
|
||||
</style>
|
||||
91
web/src/components/RulesetPreviewDrawer.vue
Normal file
91
web/src/components/RulesetPreviewDrawer.vue
Normal file
@@ -0,0 +1,91 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue';
|
||||
import { NButton, NDrawer, NDrawerContent, NSpace, NSpin, NTag, useMessage } from 'naive-ui';
|
||||
import type { RuleDto } from '@proxy-station/shared';
|
||||
import { api } from '../api/client';
|
||||
|
||||
const props = defineProps<{ show: boolean; rule: RuleDto | null }>();
|
||||
const emit = defineEmits<{ (e: 'update:show', v: boolean): void }>();
|
||||
|
||||
const message = useMessage();
|
||||
const content = ref('');
|
||||
const lineCount = ref(0);
|
||||
const fetchedAt = ref(0);
|
||||
const stale = ref(false);
|
||||
const loading = ref(false);
|
||||
|
||||
async function load(refresh = false) {
|
||||
if (!props.rule?.value) return;
|
||||
loading.value = true;
|
||||
try {
|
||||
const result = await api.get<{ content: string; fetchedAt: number; stale: boolean; lineCount: number }>(
|
||||
`/api/rulesets/${props.rule.value}/content${refresh ? '?refresh=1' : ''}`
|
||||
);
|
||||
content.value = result.content;
|
||||
lineCount.value = result.lineCount;
|
||||
fetchedAt.value = result.fetchedAt;
|
||||
stale.value = result.stale;
|
||||
} catch (e: any) {
|
||||
message.error(e.message);
|
||||
content.value = '';
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.show,
|
||||
(show) => {
|
||||
if (show) load();
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NDrawer :show="show" :width="560" @update:show="emit('update:show', $event)">
|
||||
<NDrawerContent :title="rule?.rulesetName ?? '规则集预览'" closable>
|
||||
<template #header>
|
||||
<NSpace align="center" size="small">
|
||||
<span>{{ rule?.rulesetName }}</span>
|
||||
<NTag v-if="lineCount" size="small" :bordered="false">{{ lineCount }} 条</NTag>
|
||||
<NTag v-if="stale" size="small" type="warning" :bordered="false">缓存已过期,上游暂不可达</NTag>
|
||||
</NSpace>
|
||||
</template>
|
||||
<div class="meta">
|
||||
<code class="mono url">{{ rule?.rulesetUrl }}</code>
|
||||
<NSpace size="small" style="margin: 8px 0">
|
||||
<span v-if="fetchedAt" class="fetched">拉取于 {{ new Date(fetchedAt).toLocaleString() }}</span>
|
||||
<NButton size="tiny" secondary :loading="loading" @click="load(true)">刷新</NButton>
|
||||
</NSpace>
|
||||
</div>
|
||||
<NSpin :show="loading">
|
||||
<pre class="content mono">{{ content || '(无内容)' }}</pre>
|
||||
</NSpin>
|
||||
</NDrawerContent>
|
||||
</NDrawer>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.url {
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.fetched {
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.content {
|
||||
font-size: 11px;
|
||||
line-height: 1.6;
|
||||
background: var(--bg);
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
max-height: calc(100vh - 220px);
|
||||
overflow: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
</style>
|
||||
236
web/src/components/TokenFormModal.vue
Normal file
236
web/src/components/TokenFormModal.vue
Normal file
@@ -0,0 +1,236 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { NButton, NCheckbox, NInput, NModal, NSpace, useMessage } from 'naive-ui';
|
||||
import { nodeEgress, type NodeDto, type TokenDto } from '@proxy-station/shared';
|
||||
import { stationOf } from './stations';
|
||||
import ProtoDot from './ProtoDot.vue';
|
||||
import EgressBadge from './EgressBadge.vue';
|
||||
import { nodeDisplayName } from '../utils/nodeName';
|
||||
|
||||
const props = defineProps<{ show: boolean; token?: TokenDto | null; nodes: NodeDto[] }>();
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:show', v: boolean): void;
|
||||
(e: 'submit', payload: { name: string; nodeIds: string[] }, id?: string): void;
|
||||
}>();
|
||||
|
||||
const message = useMessage();
|
||||
const name = ref('');
|
||||
const selected = ref<Set<string>>(new Set());
|
||||
|
||||
const isEdit = computed(() => !!props.token);
|
||||
const enabledNodes = computed(() => props.nodes.filter((n) => n.enabled));
|
||||
|
||||
/** 按站点分组,与线路矩阵一致 */
|
||||
const stations = computed(() => {
|
||||
const map = new Map<string, NodeDto[]>();
|
||||
for (const n of enabledNodes.value) {
|
||||
const key = stationOf(n);
|
||||
if (!map.has(key)) map.set(key, []);
|
||||
map.get(key)!.push(n);
|
||||
}
|
||||
return [...map.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([code, list]) => ({ code, nodes: list }));
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.show,
|
||||
(show) => {
|
||||
if (!show) return;
|
||||
name.value = props.token?.name ?? '';
|
||||
// 空数组语义是「全部」,编辑时展开成全选,用户能直观看到勾选状态
|
||||
const ids = props.token?.nodeIds ?? [];
|
||||
selected.value = new Set(ids.length ? ids : enabledNodes.value.map((n) => n.id));
|
||||
}
|
||||
);
|
||||
|
||||
const allSelected = computed(() => selected.value.size === enabledNodes.value.length);
|
||||
const selectedCount = computed(() => selected.value.size);
|
||||
|
||||
function toggleNode(id: string, checked: boolean) {
|
||||
const next = new Set(selected.value);
|
||||
if (checked) next.add(id);
|
||||
else next.delete(id);
|
||||
selected.value = next;
|
||||
}
|
||||
|
||||
function toggleStation(code: string, checked: boolean) {
|
||||
const next = new Set(selected.value);
|
||||
const ids = stations.value.find((s) => s.code === code)!.nodes.map((n) => n.id);
|
||||
for (const id of ids) {
|
||||
if (checked) next.add(id);
|
||||
else next.delete(id);
|
||||
}
|
||||
selected.value = next;
|
||||
}
|
||||
|
||||
function stationState(code: string): { all: boolean; some: boolean } {
|
||||
const ids = stations.value.find((s) => s.code === code)!.nodes.map((n) => n.id);
|
||||
const hit = ids.filter((id) => selected.value.has(id)).length;
|
||||
return { all: hit === ids.length, some: hit > 0 && hit < ids.length };
|
||||
}
|
||||
|
||||
function toggleAll(checked: boolean) {
|
||||
selected.value = checked ? new Set(enabledNodes.value.map((n) => n.id)) : new Set();
|
||||
}
|
||||
|
||||
function submit() {
|
||||
if (!name.value.trim()) {
|
||||
message.error('请填写订阅名称');
|
||||
return;
|
||||
}
|
||||
if (selected.value.size === 0) {
|
||||
message.error('至少选择一个节点');
|
||||
return;
|
||||
}
|
||||
// 全选时存空数组:语义为「不限制」,之后新增的节点会自动包含进来
|
||||
const nodeIds = allSelected.value ? [] : [...selected.value];
|
||||
emit('submit', { name: name.value.trim(), nodeIds }, props.token?.id);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NModal
|
||||
:show="show"
|
||||
preset="card"
|
||||
:title="isEdit ? `编辑订阅 · ${token?.name}` : '新建订阅'"
|
||||
style="max-width: 620px"
|
||||
@update:show="emit('update:show', $event)"
|
||||
>
|
||||
<div class="field">
|
||||
<label class="field-label">订阅名称</label>
|
||||
<NInput v-model:value="name" placeholder="设备或用户名,如 kid-macbook / 朋友-A" />
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<div class="field-head">
|
||||
<label class="field-label">可用节点</label>
|
||||
<div class="field-actions">
|
||||
<span class="count">已选 {{ selectedCount }} / {{ enabledNodes.length }}</span>
|
||||
<NButton size="tiny" quaternary @click="toggleAll(true)">全选</NButton>
|
||||
<NButton size="tiny" quaternary @click="toggleAll(false)">清空</NButton>
|
||||
</div>
|
||||
</div>
|
||||
<p class="field-hint">
|
||||
只有勾选的节点会下发给这个订阅。全选时后续新增的节点会自动包含进来。
|
||||
</p>
|
||||
|
||||
<div class="stations">
|
||||
<section v-for="st in stations" :key="st.code" class="station">
|
||||
<NCheckbox
|
||||
:checked="stationState(st.code).all"
|
||||
:indeterminate="stationState(st.code).some"
|
||||
@update:checked="(v: boolean) => toggleStation(st.code, v)"
|
||||
>
|
||||
<span class="station-code">{{ st.code }}</span>
|
||||
<span class="station-count">{{ st.nodes.length }} 条</span>
|
||||
</NCheckbox>
|
||||
<div class="node-list">
|
||||
<NCheckbox
|
||||
v-for="n in st.nodes"
|
||||
:key="n.id"
|
||||
:checked="selected.has(n.id)"
|
||||
class="node-item"
|
||||
@update:checked="(v: boolean) => toggleNode(n.id, v)"
|
||||
>
|
||||
<span class="node-line">
|
||||
<ProtoDot :protocol="n.protocol" />
|
||||
<span class="node-name">{{ nodeDisplayName(n) }}</span>
|
||||
<EgressBadge :egress="nodeEgress(n)" />
|
||||
</span>
|
||||
</NCheckbox>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<NSpace justify="end">
|
||||
<NButton @click="emit('update:show', false)">取消</NButton>
|
||||
<NButton type="primary" @click="submit">{{ isEdit ? '保存' : '创建' }}</NButton>
|
||||
</NSpace>
|
||||
</template>
|
||||
</NModal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.field {
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.field-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.field-label {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.field-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.count {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.field-hint {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
|
||||
.stations {
|
||||
max-height: 380px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
padding: 12px 14px;
|
||||
}
|
||||
|
||||
.station + .station {
|
||||
margin-top: 14px;
|
||||
padding-top: 14px;
|
||||
border-top: 1px solid var(--line-soft);
|
||||
}
|
||||
|
||||
.station-code {
|
||||
font-weight: 700;
|
||||
font-size: 13px;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.station-count {
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.node-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
margin: 6px 0 0 24px;
|
||||
}
|
||||
|
||||
.node-item {
|
||||
padding: 3px 0;
|
||||
}
|
||||
|
||||
.node-line {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.node-name {
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
23
web/src/components/stations.ts
Normal file
23
web/src/components/stations.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import type { NodeDto } from '@proxy-station/shared';
|
||||
|
||||
/**
|
||||
* 站点归组:优先 region 字段,否则从域名猜测(cc.sg.example.com → SG),
|
||||
* 最后回落到 server 本身。
|
||||
*/
|
||||
export function stationOf(node: NodeDto): string {
|
||||
if (node.region) return node.region.toUpperCase();
|
||||
const parts = node.server.split('.');
|
||||
if (parts.length >= 4 && parts[1].length <= 4) return parts[1].toUpperCase();
|
||||
return node.server;
|
||||
}
|
||||
|
||||
/** 站点展示名:国旗 + 国家 + 站点代号 */
|
||||
const STATION_LABELS: Record<string, string> = {
|
||||
SG: '🇸🇬 SG AWS',
|
||||
LA: '🇺🇸 US LA',
|
||||
RN: '🇺🇸 US RN',
|
||||
};
|
||||
|
||||
export function stationLabel(code: string): string {
|
||||
return STATION_LABELS[code] ?? code;
|
||||
}
|
||||
10
web/src/main.ts
Normal file
10
web/src/main.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { createApp } from 'vue';
|
||||
import { createPinia } from 'pinia';
|
||||
import App from './App.vue';
|
||||
import { router } from './router';
|
||||
|
||||
import '@fontsource/ibm-plex-mono/400.css';
|
||||
import '@fontsource/ibm-plex-mono/500.css';
|
||||
import './styles/tokens.css';
|
||||
|
||||
createApp(App).use(createPinia()).use(router).mount('#app');
|
||||
353
web/src/pages/GroupsPage.vue
Normal file
353
web/src/pages/GroupsPage.vue
Normal file
@@ -0,0 +1,353 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import {
|
||||
NButton,
|
||||
NCard,
|
||||
NForm,
|
||||
NFormItem,
|
||||
NInput,
|
||||
NInputNumber,
|
||||
NModal,
|
||||
NSelect,
|
||||
NSpace,
|
||||
NSwitch,
|
||||
NTag,
|
||||
useDialog,
|
||||
useMessage,
|
||||
} from 'naive-ui';
|
||||
import { nodeEgress, type GroupDto, type GroupInput } from '@proxy-station/shared';
|
||||
import { useGroupsStore } from '../stores/groups';
|
||||
import { useNodesStore } from '../stores/nodes';
|
||||
import EgressBadge from '../components/EgressBadge.vue';
|
||||
import { nodeDisplayName } from '../utils/nodeName';
|
||||
|
||||
const store = useGroupsStore();
|
||||
const nodesStore = useNodesStore();
|
||||
const message = useMessage();
|
||||
const dialog = useDialog();
|
||||
|
||||
onMounted(() => {
|
||||
store.fetchAll();
|
||||
nodesStore.fetchAll();
|
||||
});
|
||||
|
||||
const showForm = ref(false);
|
||||
const editing = ref<GroupDto | null>(null);
|
||||
const form = ref<GroupInput>(blankForm());
|
||||
const memberSelection = ref<string[]>([]);
|
||||
|
||||
function blankForm(): GroupInput {
|
||||
return {
|
||||
name: '',
|
||||
type: 'select',
|
||||
testUrl: null,
|
||||
interval: null,
|
||||
tolerance: null,
|
||||
filterRegex: null,
|
||||
includeAllNodes: false,
|
||||
sortOrder: 0,
|
||||
members: [],
|
||||
};
|
||||
}
|
||||
|
||||
/** 成员选项:builtin + 其他组 + 节点,用前缀区分 */
|
||||
const memberOptions = computed(() => [
|
||||
{ type: 'group' as const, label: '— 内置策略 —', key: 'h1', children: ['DIRECT', 'REJECT'].map((b) => ({ label: b, value: `builtin:${b}` })) },
|
||||
{
|
||||
type: 'group' as const,
|
||||
label: '— 策略组 —',
|
||||
key: 'h2',
|
||||
children: store.groups.filter((g) => g.id !== editing.value?.id).map((g) => ({ label: g.name, value: `group:${g.name}` })),
|
||||
},
|
||||
{
|
||||
type: 'group' as const,
|
||||
label: '— 节点 —',
|
||||
key: 'h3',
|
||||
children: nodesStore.nodes.map((n) => ({ label: n.name, value: `node:${n.id}` })),
|
||||
},
|
||||
]);
|
||||
|
||||
function openCreate() {
|
||||
editing.value = null;
|
||||
form.value = blankForm();
|
||||
memberSelection.value = [];
|
||||
showForm.value = true;
|
||||
}
|
||||
|
||||
function openEdit(group: GroupDto) {
|
||||
editing.value = group;
|
||||
form.value = {
|
||||
name: group.name,
|
||||
type: group.type,
|
||||
testUrl: group.testUrl,
|
||||
interval: group.interval,
|
||||
tolerance: group.tolerance,
|
||||
filterRegex: group.filterRegex,
|
||||
includeAllNodes: group.includeAllNodes,
|
||||
sortOrder: group.sortOrder,
|
||||
members: [],
|
||||
};
|
||||
memberSelection.value = group.members.map((m) =>
|
||||
m.kind === 'node' ? `node:${m.nodeId}` : m.kind === 'builtin' ? `builtin:${m.refName}` : `group:${m.refName}`
|
||||
);
|
||||
showForm.value = true;
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
form.value.members = memberSelection.value.map((v) => {
|
||||
const [kind, ...rest] = v.split(':');
|
||||
const ref = rest.join(':');
|
||||
return kind === 'node' ? { kind: 'node' as const, nodeId: ref } : { kind: kind as 'group' | 'builtin', refName: ref };
|
||||
});
|
||||
try {
|
||||
if (editing.value) await store.update(editing.value.id, form.value);
|
||||
else await store.create(form.value);
|
||||
showForm.value = false;
|
||||
message.success('已保存');
|
||||
} catch (e: any) {
|
||||
message.error(e.message);
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDelete(group: GroupDto) {
|
||||
dialog.warning({
|
||||
title: '删除策略组',
|
||||
content: `确认删除「${group.name}」?引用它的规则策略不会自动更新。`,
|
||||
positiveText: '删除',
|
||||
negativeText: '取消',
|
||||
onPositiveClick: () => store.remove(group.id),
|
||||
});
|
||||
}
|
||||
|
||||
/** 排序:客户端订阅里的策略组顺序即此顺序 */
|
||||
async function move(group: GroupDto, dir: -1 | 1) {
|
||||
const ids = store.groups.map((g) => g.id);
|
||||
const i = ids.indexOf(group.id);
|
||||
const j = i + dir;
|
||||
if (j < 0 || j >= ids.length) return;
|
||||
[ids[i], ids[j]] = [ids[j], ids[i]];
|
||||
await store.reorder(ids);
|
||||
}
|
||||
|
||||
/** regex 组实时预览:当前节点里有哪些会被圈中 */
|
||||
function matchedNodes(group: GroupDto) {
|
||||
if (!group.filterRegex) return [];
|
||||
try {
|
||||
const re = new RegExp(group.filterRegex);
|
||||
return nodesStore.nodes.filter((n) => re.test(n.name));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function memberLabel(m: GroupDto['members'][number]): string {
|
||||
if (m.kind === 'node') return nodesStore.nodes.find((n) => n.id === m.nodeId)?.name ?? '(已删除节点)';
|
||||
return m.refName ?? '';
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-head">
|
||||
<h1 class="display page-title">策略组</h1>
|
||||
<NButton type="primary" @click="openCreate">添加策略组</NButton>
|
||||
</div>
|
||||
|
||||
<p class="hint">顺序即客户端里策略组的排列顺序;停用的组不会下发(引用它的规则一并跳过)。</p>
|
||||
<div class="group-grid">
|
||||
<NCard v-for="(g, i) in store.groups" :key="g.id" size="small" class="group-card" :class="{ off: !g.enabled }">
|
||||
<template #header>
|
||||
<span class="group-order mono">{{ String(i + 1).padStart(2, '0') }}</span>
|
||||
<span class="group-name">{{ g.name }}</span>
|
||||
<NTag size="small" :bordered="false" style="margin-left: 8px">{{ g.type }}</NTag>
|
||||
</template>
|
||||
<template #header-extra>
|
||||
<NSpace size="small" align="center">
|
||||
<NButton size="tiny" quaternary :disabled="i === 0" @click="move(g, -1)">↑</NButton>
|
||||
<NButton size="tiny" quaternary :disabled="i === store.groups.length - 1" @click="move(g, 1)">↓</NButton>
|
||||
<NSwitch size="small" :value="g.enabled" @update:value="(v: boolean) => store.toggle(g.id, v)" />
|
||||
<NButton size="tiny" quaternary @click="openEdit(g)">编辑</NButton>
|
||||
<NButton v-if="g.source === 'custom'" size="tiny" quaternary type="error" @click="confirmDelete(g)">删除</NButton>
|
||||
</NSpace>
|
||||
</template>
|
||||
|
||||
<div v-if="g.includeAllNodes" class="group-line">
|
||||
<span class="group-label">收纳</span>全部启用节点
|
||||
</div>
|
||||
<div v-if="g.filterRegex" class="group-line">
|
||||
<span class="group-label">筛选</span>
|
||||
<span class="mono regex">{{ g.filterRegex.length > 48 ? g.filterRegex.slice(0, 48) + '…' : g.filterRegex }}</span>
|
||||
</div>
|
||||
<div v-if="g.filterRegex" class="group-line group-line-block">
|
||||
<div v-if="matchedNodes(g).length" class="matched-wrap">
|
||||
<div class="matched-count"><span class="group-label">命中</span>{{ matchedNodes(g).length }} 个节点</div>
|
||||
<div class="matched-list">
|
||||
<div v-for="n in matchedNodes(g)" :key="n.id" class="matched-row" :title="n.name">
|
||||
<EgressBadge :egress="nodeEgress(n)" compact />
|
||||
<span class="matched-name">{{ nodeDisplayName(n) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span v-else class="muted"><span class="group-label">命中</span>当前没有节点名匹配</span>
|
||||
</div>
|
||||
<div v-if="g.members.length" class="group-line">
|
||||
<span class="group-label">成员</span>
|
||||
<span class="members">{{ g.members.map(memberLabel).join('、') }}</span>
|
||||
</div>
|
||||
</NCard>
|
||||
</div>
|
||||
|
||||
<NModal
|
||||
:show="showForm"
|
||||
preset="card"
|
||||
:title="editing ? `编辑策略组 · ${editing.name}` : '添加策略组'"
|
||||
style="max-width: 560px"
|
||||
@update:show="showForm = $event"
|
||||
>
|
||||
<NForm label-placement="left" label-width="96">
|
||||
<NFormItem label="名称" required>
|
||||
<NInput v-model:value="form.name" />
|
||||
</NFormItem>
|
||||
<NFormItem label="类型">
|
||||
<NSelect
|
||||
v-model:value="form.type"
|
||||
:options="['select', 'url-test', 'fallback'].map((t) => ({ label: t, value: t }))"
|
||||
/>
|
||||
</NFormItem>
|
||||
<NFormItem label="成员(有序)">
|
||||
<NSelect v-model:value="memberSelection" multiple filterable :options="memberOptions" placeholder="选择节点、组或内置策略" />
|
||||
</NFormItem>
|
||||
<NFormItem label="收纳全部节点">
|
||||
<NSwitch v-model:value="form.includeAllNodes" />
|
||||
</NFormItem>
|
||||
<NFormItem label="名称筛选正则">
|
||||
<NInput v-model:value="form.filterRegex" placeholder="如 SG|Singapore(配合收纳全部节点或成员组)" class="mono" />
|
||||
</NFormItem>
|
||||
<template v-if="form.type !== 'select'">
|
||||
<NFormItem label="测速 URL">
|
||||
<NInput v-model:value="form.testUrl" placeholder="http://cp.cloudflare.com/generate_204" class="mono" />
|
||||
</NFormItem>
|
||||
<NFormItem label="间隔(秒)">
|
||||
<NInputNumber v-model:value="form.interval" :min="10" style="width: 100%" />
|
||||
</NFormItem>
|
||||
<NFormItem label="容差(ms)">
|
||||
<NInputNumber v-model:value="form.tolerance" :min="0" style="width: 100%" />
|
||||
</NFormItem>
|
||||
</template>
|
||||
</NForm>
|
||||
<template #footer>
|
||||
<NSpace justify="end">
|
||||
<NButton @click="showForm = false">取消</NButton>
|
||||
<NButton type="primary" @click="submit">保存</NButton>
|
||||
</NSpace>
|
||||
</template>
|
||||
</NModal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 20px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.hint {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
margin: -12px 0 16px;
|
||||
}
|
||||
|
||||
/* 同行卡片等宽等高 */
|
||||
.group-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.group-card.off {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.group-order {
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.group-name {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.group-line {
|
||||
font-size: 12px;
|
||||
margin-bottom: 6px;
|
||||
line-height: 1.7;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
.group-label {
|
||||
color: var(--muted);
|
||||
margin-right: 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.regex {
|
||||
color: var(--warp);
|
||||
font-size: 11px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
/* 命中清单:一行一个,节点名与出口标签左右对齐 */
|
||||
.matched-wrap {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* 命中区整块换行,让清单占满卡片宽度 */
|
||||
.group-line-block {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.matched-count {
|
||||
color: var(--muted);
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
|
||||
/* 全部铺开不滚动;两列等宽,无左缩进 */
|
||||
.matched-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0 12px;
|
||||
}
|
||||
|
||||
.matched-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 2.5px 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.matched-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.members {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--muted);
|
||||
}
|
||||
</style>
|
||||
300
web/src/pages/NodesPage.vue
Normal file
300
web/src/pages/NodesPage.vue
Normal file
@@ -0,0 +1,300 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, h, onMounted, ref } from 'vue';
|
||||
import {
|
||||
NButton,
|
||||
NDataTable,
|
||||
NEmpty,
|
||||
NSpace,
|
||||
NSwitch,
|
||||
useDialog,
|
||||
useMessage,
|
||||
type DataTableColumns,
|
||||
} from 'naive-ui';
|
||||
import { nodeEgress, type NodeDto, type NodeInput, type Protocol } from '@proxy-station/shared';
|
||||
import { useNodesStore } from '../stores/nodes';
|
||||
import { stationLabel, stationOf } from '../components/stations';
|
||||
import EgressBadge from '../components/EgressBadge.vue';
|
||||
import ProtoDot from '../components/ProtoDot.vue';
|
||||
import NodeFormModal from '../components/NodeFormModal.vue';
|
||||
import ImportModal from '../components/ImportModal.vue';
|
||||
import { copyText } from '../utils/clipboard';
|
||||
import { nodeDisplayName } from '../utils/nodeName';
|
||||
|
||||
const store = useNodesStore();
|
||||
const message = useMessage();
|
||||
const dialog = useDialog();
|
||||
|
||||
const showForm = ref(false);
|
||||
const showImport = ref(false);
|
||||
const editing = ref<NodeDto | null>(null);
|
||||
|
||||
onMounted(() => store.fetchAll());
|
||||
|
||||
const PROTOCOL_ORDER: Protocol[] = ['vmess', 'trojan', 'shadowsocks', 'hysteria2'];
|
||||
const EGRESS_ORDER = { direct: 0, warp: 1, unknown: 2 };
|
||||
|
||||
/** 站点展示域名:去掉子域首段(cc.sg.example.com → sg.example.com) */
|
||||
function baseDomain(server: string): string {
|
||||
const parts = server.split('.');
|
||||
return parts.length >= 4 ? parts.slice(1).join('.') : server;
|
||||
}
|
||||
|
||||
/** 按站点分段,段内先按协议、再按出口排序 */
|
||||
const sections = computed(() => {
|
||||
const byStation = new Map<string, NodeDto[]>();
|
||||
for (const n of store.nodes) {
|
||||
const key = stationOf(n);
|
||||
if (!byStation.has(key)) byStation.set(key, []);
|
||||
byStation.get(key)!.push(n);
|
||||
}
|
||||
return [...byStation.entries()]
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([code, list]) => ({
|
||||
code,
|
||||
label: stationLabel(code),
|
||||
domain: baseDomain(list[0].server),
|
||||
nodes: [...list].sort(
|
||||
(a, b) =>
|
||||
PROTOCOL_ORDER.indexOf(a.protocol) - PROTOCOL_ORDER.indexOf(b.protocol) ||
|
||||
EGRESS_ORDER[nodeEgress(a)] - EGRESS_ORDER[nodeEgress(b)]
|
||||
),
|
||||
}));
|
||||
});
|
||||
|
||||
function openCreate() {
|
||||
editing.value = null;
|
||||
showForm.value = true;
|
||||
}
|
||||
|
||||
function openEdit(node: NodeDto) {
|
||||
editing.value = node;
|
||||
showForm.value = true;
|
||||
}
|
||||
|
||||
async function handleSubmit(input: NodeInput, id?: string) {
|
||||
try {
|
||||
if (id) await store.update(id, input);
|
||||
else await store.create(input);
|
||||
showForm.value = false;
|
||||
message.success(id ? '已保存' : '已添加');
|
||||
} catch (e: any) {
|
||||
message.error(e.message);
|
||||
}
|
||||
}
|
||||
|
||||
function handleDelete(id: string) {
|
||||
const node = store.nodes.find((n) => n.id === id);
|
||||
dialog.warning({
|
||||
title: '删除节点',
|
||||
content: `确认删除「${node?.name}」?引用它的策略组成员会一并移除。`,
|
||||
positiveText: '删除',
|
||||
negativeText: '取消',
|
||||
onPositiveClick: async () => {
|
||||
await store.remove(id);
|
||||
showForm.value = false;
|
||||
message.success('已删除');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function copyUri(node: NodeDto) {
|
||||
try {
|
||||
const uri = await store.exportUri(node.id);
|
||||
if (await copyText(uri)) message.success('分享链接已复制');
|
||||
else message.warning('浏览器阻止了自动复制,请改用 HTTPS 或 localhost 访问');
|
||||
} catch (e: any) {
|
||||
message.error(e.message);
|
||||
}
|
||||
}
|
||||
|
||||
/** 用分享链接唤起本机已安装的客户端,把这条线路导进去 */
|
||||
async function connect(node: NodeDto) {
|
||||
try {
|
||||
const uri = await store.exportUri(node.id);
|
||||
window.location.href = uri;
|
||||
} catch (e: any) {
|
||||
message.error(e.message);
|
||||
}
|
||||
}
|
||||
|
||||
const columns = computed<DataTableColumns<NodeDto>>(() => [
|
||||
{
|
||||
title: '名称',
|
||||
key: 'name',
|
||||
ellipsis: { tooltip: true },
|
||||
// 出口词由「出口」列承担,这里隐去避免重复
|
||||
render: (row) => nodeDisplayName(row),
|
||||
},
|
||||
{
|
||||
title: '协议',
|
||||
key: 'protocol',
|
||||
width: 148,
|
||||
render: (row) =>
|
||||
h('span', { class: 'proto-cell' }, [
|
||||
h(ProtoDot, { protocol: row.protocol }),
|
||||
h('span', {}, row.protocol),
|
||||
]),
|
||||
},
|
||||
{
|
||||
title: '出口',
|
||||
key: 'egress',
|
||||
width: 92,
|
||||
render: (row) => h(EgressBadge, { egress: nodeEgress(row) }),
|
||||
},
|
||||
{
|
||||
title: '服务器',
|
||||
key: 'server',
|
||||
ellipsis: { tooltip: true },
|
||||
render: (row) => h('span', { class: 'mono cell-mono' }, `${row.server}:${row.port}`),
|
||||
},
|
||||
{
|
||||
title: '路径',
|
||||
key: 'wsPath',
|
||||
width: 108,
|
||||
render: (row) => h('span', { class: 'mono cell-mono' }, row.wsPath ?? '—'),
|
||||
},
|
||||
{
|
||||
title: '启用',
|
||||
key: 'enabled',
|
||||
width: 72,
|
||||
render: (row) =>
|
||||
h(NSwitch, {
|
||||
size: 'small',
|
||||
value: row.enabled,
|
||||
onUpdateValue: (v: boolean) => store.toggle(row.id, v),
|
||||
}),
|
||||
},
|
||||
{
|
||||
title: '',
|
||||
key: 'actions',
|
||||
width: 176,
|
||||
render: (row) =>
|
||||
h(NSpace, { size: 2, wrap: false }, () => [
|
||||
h(NButton, { size: 'tiny', quaternary: true, onClick: () => copyUri(row) }, () => '复制'),
|
||||
h(NButton, { size: 'tiny', quaternary: true, onClick: () => connect(row) }, () => '连接'),
|
||||
h(NButton, { size: 'tiny', quaternary: true, onClick: () => openEdit(row) }, () => '编辑'),
|
||||
]),
|
||||
},
|
||||
]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-head">
|
||||
<h1 class="display page-title">节点</h1>
|
||||
<NSpace>
|
||||
<NButton ghost @click="showImport = true">批量导入</NButton>
|
||||
<NButton type="primary" @click="openCreate">添加节点</NButton>
|
||||
</NSpace>
|
||||
</div>
|
||||
|
||||
<NEmpty
|
||||
v-if="!store.loading && store.nodes.length === 0"
|
||||
description="还没有节点。粘贴分享链接批量导入,或手动添加。"
|
||||
style="margin-top: 80px"
|
||||
>
|
||||
<template #extra>
|
||||
<NButton type="primary" @click="showImport = true">批量导入</NButton>
|
||||
</template>
|
||||
</NEmpty>
|
||||
|
||||
<section v-for="st in sections" :key="st.code" class="station">
|
||||
<header class="station-head">
|
||||
<span class="station-name display">{{ st.label }}</span>
|
||||
<span class="station-domain mono">{{ st.domain }}</span>
|
||||
<span class="station-count">{{ st.nodes.length }} 条线路</span>
|
||||
</header>
|
||||
<div class="strip"><i class="strip-direct" /><i class="strip-warp" /></div>
|
||||
<NDataTable
|
||||
:columns="columns"
|
||||
:data="st.nodes"
|
||||
:bordered="false"
|
||||
:single-line="false"
|
||||
size="small"
|
||||
:row-key="(n: NodeDto) => n.id"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<NodeFormModal v-model:show="showForm" :node="editing" @submit="handleSubmit" @delete="handleDelete" />
|
||||
<ImportModal v-model:show="showImport" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 20px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.station + .station {
|
||||
margin-top: 26px;
|
||||
}
|
||||
|
||||
.station-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 12px;
|
||||
padding: 0 2px 9px;
|
||||
}
|
||||
|
||||
.station-name {
|
||||
font-size: 17px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.station-domain {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.station-count {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 站牌双色条:琥珀 = 直连,青 = WARP */
|
||||
.strip {
|
||||
display: flex;
|
||||
height: 3px;
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.strip i {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.strip-direct {
|
||||
background: var(--direct);
|
||||
}
|
||||
|
||||
.strip-warp {
|
||||
background: var(--warp);
|
||||
}
|
||||
|
||||
:deep(.proto-cell) {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
:deep(.cell-mono) {
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
266
web/src/pages/RulesPage.vue
Normal file
266
web/src/pages/RulesPage.vue
Normal file
@@ -0,0 +1,266 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, h, onMounted, ref } from 'vue';
|
||||
import {
|
||||
NButton,
|
||||
NDataTable,
|
||||
NModal,
|
||||
NForm,
|
||||
NFormItem,
|
||||
NInput,
|
||||
NSelect,
|
||||
NSpace,
|
||||
NSwitch,
|
||||
NTag,
|
||||
useDialog,
|
||||
useMessage,
|
||||
type DataTableColumns,
|
||||
} from 'naive-ui';
|
||||
import { RULE_TYPES, type RuleDto, type RuleInput } from '@proxy-station/shared';
|
||||
import { useRulesStore } from '../stores/rules';
|
||||
import { useGroupsStore } from '../stores/groups';
|
||||
import RulesetPreviewDrawer from '../components/RulesetPreviewDrawer.vue';
|
||||
|
||||
const store = useRulesStore();
|
||||
const groupsStore = useGroupsStore();
|
||||
const message = useMessage();
|
||||
const dialog = useDialog();
|
||||
|
||||
onMounted(() => {
|
||||
store.fetchAll();
|
||||
groupsStore.fetchAll();
|
||||
});
|
||||
|
||||
const showForm = ref(false);
|
||||
const editing = ref<RuleDto | null>(null);
|
||||
const previewRule = ref<RuleDto | null>(null);
|
||||
const showPreview = ref(false);
|
||||
|
||||
function openPreview(rule: RuleDto) {
|
||||
previewRule.value = rule;
|
||||
showPreview.value = true;
|
||||
}
|
||||
const form = ref<RuleInput>({ type: 'DOMAIN-SUFFIX', value: '', policy: 'Proxy', params: [], enabled: true });
|
||||
|
||||
const policyOptions = computed(() => [
|
||||
...['DIRECT', 'REJECT', 'REJECT-DROP', 'REJECT-NO-DROP'].map((p) => ({ label: p, value: p })),
|
||||
...groupsStore.groups.map((g) => ({ label: g.name, value: g.name })),
|
||||
]);
|
||||
|
||||
const typeOptions = RULE_TYPES.filter((t) => t !== 'RULE-SET' && t !== 'DOMAIN-SET' && t !== 'FINAL').map((t) => ({
|
||||
label: t,
|
||||
value: t,
|
||||
}));
|
||||
|
||||
function openCreate() {
|
||||
editing.value = null;
|
||||
form.value = { type: 'DOMAIN-SUFFIX', value: '', policy: 'Proxy', params: [], enabled: true };
|
||||
showForm.value = true;
|
||||
}
|
||||
|
||||
function openEdit(rule: RuleDto) {
|
||||
editing.value = rule;
|
||||
form.value = {
|
||||
type: rule.type as RuleInput['type'],
|
||||
value: rule.value,
|
||||
policy: rule.policy,
|
||||
params: rule.params,
|
||||
enabled: rule.enabled,
|
||||
};
|
||||
showForm.value = true;
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
try {
|
||||
if (editing.value) await store.update(editing.value.id, form.value);
|
||||
else await store.create(form.value);
|
||||
showForm.value = false;
|
||||
message.success('已保存');
|
||||
} catch (e: any) {
|
||||
message.error(e.message);
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDelete(rule: RuleDto) {
|
||||
dialog.warning({
|
||||
title: '删除规则',
|
||||
content: `确认删除这条 ${rule.type} 规则?`,
|
||||
positiveText: '删除',
|
||||
negativeText: '取消',
|
||||
onPositiveClick: () => store.remove(rule.id),
|
||||
});
|
||||
}
|
||||
|
||||
function confirmReset() {
|
||||
dialog.warning({
|
||||
title: '重置为模板规则',
|
||||
content: '模板来源的规则将恢复为初始状态,你添加的自定义规则会保留。继续?',
|
||||
positiveText: '重置',
|
||||
negativeText: '取消',
|
||||
onPositiveClick: async () => {
|
||||
await store.reset();
|
||||
message.success('已重置');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function move(rule: RuleDto, dir: -1 | 1) {
|
||||
const ids = store.rules.map((r) => r.id);
|
||||
const i = ids.indexOf(rule.id);
|
||||
const j = i + dir;
|
||||
if (j < 0 || j >= ids.length) return;
|
||||
[ids[i], ids[j]] = [ids[j], ids[i]];
|
||||
await store.reorder(ids);
|
||||
}
|
||||
|
||||
async function changePolicy(rule: RuleDto, policy: string) {
|
||||
await store.update(rule.id, {
|
||||
type: rule.type as RuleInput['type'],
|
||||
value: rule.value,
|
||||
policy,
|
||||
params: rule.params,
|
||||
enabled: rule.enabled,
|
||||
});
|
||||
}
|
||||
|
||||
const columns = computed<DataTableColumns<RuleDto>>(() => [
|
||||
{ title: '#', key: 'seq', width: 56, render: (_row, i) => h('span', { class: 'mono', style: 'color:var(--muted);font-size:12px' }, String(i + 1)) },
|
||||
{
|
||||
title: '类型',
|
||||
key: 'type',
|
||||
width: 130,
|
||||
render: (row) =>
|
||||
h(
|
||||
NTag,
|
||||
{ size: 'small', bordered: false, type: row.type === 'RULE-SET' || row.type === 'DOMAIN-SET' ? 'info' : 'default' },
|
||||
() => row.type
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '匹配值',
|
||||
key: 'value',
|
||||
ellipsis: { tooltip: true },
|
||||
render: (row) =>
|
||||
row.rulesetName
|
||||
? h('span', { style: 'cursor:pointer', onClick: () => openPreview(row), title: '点击预览规则集内容' }, [
|
||||
h('span', { style: 'text-decoration:underline dotted var(--muted)' }, row.rulesetName),
|
||||
h('span', { class: 'mono', style: 'color:var(--muted);font-size:11px;margin-left:8px' }, row.rulesetUrl ?? ''),
|
||||
])
|
||||
: h('span', { class: 'mono', style: 'font-size:12px' }, row.value ?? '—'),
|
||||
},
|
||||
{
|
||||
title: '策略',
|
||||
key: 'policy',
|
||||
width: 170,
|
||||
render: (row) =>
|
||||
h(NSelect, {
|
||||
size: 'small',
|
||||
value: row.policy,
|
||||
options: policyOptions.value,
|
||||
consistentMenuWidth: false,
|
||||
onUpdateValue: (v: string) => changePolicy(row, v),
|
||||
}),
|
||||
},
|
||||
{
|
||||
title: '来源',
|
||||
key: 'source',
|
||||
width: 76,
|
||||
render: (row) => h('span', { style: 'color:var(--muted);font-size:12px' }, row.source === 'template' ? '模板' : '自定义'),
|
||||
},
|
||||
{
|
||||
title: '启用',
|
||||
key: 'enabled',
|
||||
width: 70,
|
||||
render: (row) => h(NSwitch, { size: 'small', value: row.enabled, onUpdateValue: (v: boolean) => store.toggle(row.id, v) }),
|
||||
},
|
||||
{
|
||||
title: '',
|
||||
key: 'actions',
|
||||
width: 170,
|
||||
render: (row, i) =>
|
||||
h(NSpace, { size: 2 }, () => [
|
||||
h(NButton, { size: 'tiny', quaternary: true, disabled: i === 0, onClick: () => move(row, -1) }, () => '↑'),
|
||||
h(NButton, { size: 'tiny', quaternary: true, disabled: i === store.rules.length - 1, onClick: () => move(row, 1) }, () => '↓'),
|
||||
...(row.type !== 'RULE-SET' && row.type !== 'DOMAIN-SET' && row.type !== 'FINAL'
|
||||
? [h(NButton, { size: 'tiny', quaternary: true, onClick: () => openEdit(row) }, () => '编辑')]
|
||||
: []),
|
||||
...(row.source === 'custom'
|
||||
? [h(NButton, { size: 'tiny', quaternary: true, type: 'error', onClick: () => confirmDelete(row) }, () => '删除')]
|
||||
: []),
|
||||
]),
|
||||
},
|
||||
]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-head">
|
||||
<h1 class="display page-title">规则</h1>
|
||||
<NSpace>
|
||||
<NButton ghost @click="confirmReset">重置为模板</NButton>
|
||||
<NButton type="primary" @click="openCreate">添加规则</NButton>
|
||||
</NSpace>
|
||||
</div>
|
||||
<p class="hint">规则自上而下匹配。RULE-SET/DOMAIN-SET 引用外部规则集,点击名称可预览内容;末尾 FINAL 是兜底策略。</p>
|
||||
|
||||
<NDataTable
|
||||
:columns="columns"
|
||||
:data="store.rules"
|
||||
:loading="store.loading"
|
||||
:bordered="false"
|
||||
size="small"
|
||||
:row-key="(r: RuleDto) => r.id"
|
||||
:max-height="640"
|
||||
virtual-scroll
|
||||
/>
|
||||
|
||||
<RulesetPreviewDrawer v-model:show="showPreview" :rule="previewRule" />
|
||||
|
||||
<NModal
|
||||
:show="showForm"
|
||||
preset="card"
|
||||
:title="editing ? '编辑规则' : '添加自定义规则'"
|
||||
style="max-width: 480px"
|
||||
@update:show="showForm = $event"
|
||||
>
|
||||
<NForm label-placement="left" label-width="72">
|
||||
<NFormItem label="类型">
|
||||
<NSelect v-model:value="form.type" :options="typeOptions" />
|
||||
</NFormItem>
|
||||
<NFormItem label="匹配值">
|
||||
<NInput v-model:value="form.value" placeholder="如 example.com / 1.2.3.0/24" class="mono" />
|
||||
</NFormItem>
|
||||
<NFormItem label="策略">
|
||||
<NSelect v-model:value="form.policy" :options="policyOptions" filterable />
|
||||
</NFormItem>
|
||||
</NForm>
|
||||
<template #footer>
|
||||
<NSpace justify="end">
|
||||
<NButton @click="showForm = false">取消</NButton>
|
||||
<NButton type="primary" @click="submit">保存</NButton>
|
||||
</NSpace>
|
||||
</template>
|
||||
</NModal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 20px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.hint {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
margin: 0 0 16px;
|
||||
}
|
||||
</style>
|
||||
92
web/src/pages/SettingsPage.vue
Normal file
92
web/src/pages/SettingsPage.vue
Normal file
@@ -0,0 +1,92 @@
|
||||
<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 { 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 () => {
|
||||
await store.fetchAll();
|
||||
});
|
||||
|
||||
watch(
|
||||
() => store.settings,
|
||||
(s) => {
|
||||
if (!s) return;
|
||||
sections.value = { ...s.surgeSections };
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
async function saveSections() {
|
||||
await store.saveSettings({ surgeSections: sections.value });
|
||||
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 不使用这些内容。
|
||||
</p>
|
||||
<NTabs v-model:value="activeSection" type="line" size="small">
|
||||
<NTabPane v-for="(content, name) in sections" :key="name" :name="String(name)" :tab="String(name)">
|
||||
<NInput
|
||||
v-model:value="sections[String(name)]"
|
||||
type="textarea"
|
||||
:rows="16"
|
||||
class="mono section-editor"
|
||||
/>
|
||||
</NTabPane>
|
||||
</NTabs>
|
||||
<NButton type="primary" style="margin-top: 12px" @click="saveSections">保存段落</NButton>
|
||||
</NCard>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page-title {
|
||||
font-size: 20px;
|
||||
margin: 0 0 20px;
|
||||
}
|
||||
|
||||
.block {
|
||||
margin-bottom: 16px;
|
||||
max-width: 860px;
|
||||
}
|
||||
|
||||
.hint {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
|
||||
.section-editor :deep(textarea) {
|
||||
font-family: var(--font-mono) !important;
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
404
web/src/pages/SubscriptionsPage.vue
Normal file
404
web/src/pages/SubscriptionsPage.vue
Normal file
@@ -0,0 +1,404 @@
|
||||
<script setup lang="ts">
|
||||
import { nextTick, onMounted, ref, watch } from 'vue';
|
||||
import { NButton, NPopover, NSpace, NSwitch, useDialog, useMessage } from 'naive-ui';
|
||||
import { SHADOWROCKET_SUB_NAME, type TokenDto } from '@proxy-station/shared';
|
||||
import { useSubscriptionsStore } from '../stores/subscriptions';
|
||||
import { useNodesStore } from '../stores/nodes';
|
||||
import { copyText } from '../utils/clipboard';
|
||||
import TokenFormModal from '../components/TokenFormModal.vue';
|
||||
|
||||
const store = useSubscriptionsStore();
|
||||
const nodesStore = useNodesStore();
|
||||
const message = useMessage();
|
||||
const dialog = useDialog();
|
||||
|
||||
onMounted(() => {
|
||||
store.fetchAll();
|
||||
nodesStore.fetchAll();
|
||||
});
|
||||
|
||||
const showForm = ref(false);
|
||||
const editing = ref<TokenDto | null>(null);
|
||||
|
||||
function openCreate() {
|
||||
editing.value = null;
|
||||
showForm.value = true;
|
||||
}
|
||||
|
||||
function openEdit(token: TokenDto) {
|
||||
editing.value = token;
|
||||
showForm.value = true;
|
||||
}
|
||||
|
||||
async function handleSubmit(payload: { name: string; nodeIds: string[] }, id?: string) {
|
||||
try {
|
||||
if (id) await store.updateToken(id, payload);
|
||||
else await store.createToken(payload);
|
||||
showForm.value = false;
|
||||
message.success(id ? '已保存' : '订阅已创建');
|
||||
} catch (e: any) {
|
||||
message.error(e.message);
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDelete(token: TokenDto) {
|
||||
dialog.warning({
|
||||
title: '删除订阅',
|
||||
content: `删除「${token.name}」后,使用该 URL 的客户端将无法再更新配置。`,
|
||||
positiveText: '删除',
|
||||
negativeText: '取消',
|
||||
onPositiveClick: () => store.removeToken(token.id),
|
||||
});
|
||||
}
|
||||
|
||||
/** 订阅完整 URL:当前页面 origin + 后端返回的路径 */
|
||||
function fullUrl(path: string): string {
|
||||
return `${window.location.origin}${path}`;
|
||||
}
|
||||
|
||||
/** 节点范围文案:空数组表示不限制 */
|
||||
function scopeLabel(t: TokenDto): string {
|
||||
const total = nodesStore.nodes.filter((n) => n.enabled).length;
|
||||
return t.nodeIds.length === 0 ? `全部节点 · ${total}` : `${t.nodeIds.length} / ${total} 节点`;
|
||||
}
|
||||
|
||||
async function copy(text: string) {
|
||||
if (await copyText(text)) {
|
||||
message.success('已复制');
|
||||
} else {
|
||||
manualCopyText.value = text;
|
||||
message.warning('浏览器阻止了自动复制,请手动复制下方链接');
|
||||
}
|
||||
}
|
||||
|
||||
/** 自动复制失败时的兜底展示 */
|
||||
const manualCopyText = ref('');
|
||||
const manualCopyInput = ref<HTMLInputElement | null>(null);
|
||||
|
||||
watch(manualCopyText, async (v) => {
|
||||
if (!v) return;
|
||||
await nextTick();
|
||||
manualCopyInput.value?.focus();
|
||||
manualCopyInput.value?.select();
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-head">
|
||||
<h1 class="display page-title">订阅</h1>
|
||||
<NButton type="primary" @click="openCreate">新建订阅</NButton>
|
||||
</div>
|
||||
|
||||
<div class="token-grid">
|
||||
<article v-for="t in store.tokens" :key="t.id" class="token" :class="{ off: !t.enabled }">
|
||||
<header class="token-head">
|
||||
<div class="token-title">
|
||||
<span class="token-name">{{ t.name }}</span>
|
||||
<span class="token-scope">{{ scopeLabel(t) }}</span>
|
||||
</div>
|
||||
<NSpace size="small" align="center">
|
||||
<NSwitch size="small" :value="t.enabled" @update:value="(v: boolean) => store.toggleToken(t.id, v)" />
|
||||
<NButton size="tiny" quaternary @click="openEdit(t)">编辑</NButton>
|
||||
<NButton size="tiny" quaternary type="error" @click="confirmDelete(t)">删除</NButton>
|
||||
</NSpace>
|
||||
</header>
|
||||
|
||||
<div class="rows">
|
||||
<div class="row">
|
||||
<span class="row-client">Surge<span class="row-os">macOS</span></span>
|
||||
<code class="mono row-url">{{ fullUrl(t.urls.surge) }}</code>
|
||||
<NButton size="tiny" quaternary @click="copy(fullUrl(t.urls.surge))">复制</NButton>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<span class="row-client">
|
||||
ShadowRocket<span class="row-os">iOS</span>
|
||||
<NPopover trigger="hover" placement="bottom-start" style="max-width: 320px">
|
||||
<template #trigger>
|
||||
<button class="info" aria-label="导入说明">?</button>
|
||||
</template>
|
||||
<p class="pop-title">分两步导入</p>
|
||||
<p class="pop-body">
|
||||
① 在 App 的「订阅」里添加节点链接,并把订阅命名为
|
||||
<code class="mono pop-code">{{ SHADOWROCKET_SUB_NAME }}</code>。
|
||||
配置文件按这个名字引用节点,名字必须完全一致。
|
||||
</p>
|
||||
<p class="pop-body">② 在「配置」里添加规则链接并启用。</p>
|
||||
</NPopover>
|
||||
</span>
|
||||
<div class="row-split">
|
||||
<div class="split-line">
|
||||
<span class="split-tag">节点</span>
|
||||
<code class="mono row-url">{{ fullUrl(t.urls.shadowrocket) }}</code>
|
||||
<NButton size="tiny" quaternary @click="copy(fullUrl(t.urls.shadowrocket))">复制</NButton>
|
||||
</div>
|
||||
<div class="split-line">
|
||||
<span class="split-tag">规则</span>
|
||||
<code class="mono row-url">{{ fullUrl(t.urls.shadowrocketConf) }}</code>
|
||||
<NButton size="tiny" quaternary @click="copy(fullUrl(t.urls.shadowrocketConf))">复制</NButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<span class="row-client">ClashMeta<span class="row-os">Android</span></span>
|
||||
<code class="mono row-url">{{ fullUrl(t.urls.clash) }}</code>
|
||||
<NButton size="tiny" quaternary @click="copy(fullUrl(t.urls.clash))">复制</NButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="token-foot">
|
||||
最近访问 {{ t.lastAccessAt ? new Date(t.lastAccessAt).toLocaleString() : '从未' }} · 共 {{ t.accessCount }} 次
|
||||
</footer>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<!-- 自动复制失败时的兜底:全选好的输入框,长按或 Cmd+C 即可 -->
|
||||
<div v-if="manualCopyText" class="manual-copy">
|
||||
<span class="manual-copy-label">手动复制这条链接:</span>
|
||||
<input
|
||||
ref="manualCopyInput"
|
||||
class="mono manual-copy-input"
|
||||
:value="manualCopyText"
|
||||
readonly
|
||||
@focus="($event.target as HTMLInputElement).select()"
|
||||
/>
|
||||
<NButton size="tiny" quaternary @click="manualCopyText = ''">关闭</NButton>
|
||||
</div>
|
||||
|
||||
<TokenFormModal v-model:show="showForm" :token="editing" :nodes="nodesStore.nodes" @submit="handleSubmit" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 20px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.token-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(560px, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.token {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.token.off {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.token-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 13px 18px;
|
||||
border-bottom: 1px solid var(--line-soft);
|
||||
}
|
||||
|
||||
.token-title {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.token-name {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* 节点范围徽标 */
|
||||
.token-scope {
|
||||
font-size: 11px;
|
||||
color: var(--warp);
|
||||
background: var(--warp-dim);
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.rows {
|
||||
padding: 6px 18px 10px;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.row + .row {
|
||||
border-top: 1px solid var(--line-soft);
|
||||
}
|
||||
|
||||
.row-client {
|
||||
width: 128px;
|
||||
flex-shrink: 0;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.row-os {
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.info {
|
||||
appearance: none;
|
||||
border: 1px solid var(--line);
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex: 0 0 16px; /* 不参与 flex 收缩,否则会被压成椭圆 */
|
||||
box-sizing: border-box;
|
||||
border-radius: 50%;
|
||||
font-size: 10px;
|
||||
line-height: 1;
|
||||
cursor: help;
|
||||
padding: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.info:hover {
|
||||
color: var(--text);
|
||||
border-color: var(--muted);
|
||||
}
|
||||
|
||||
.row-url {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 11px;
|
||||
color: var(--text);
|
||||
background: var(--bg);
|
||||
padding: 5px 9px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
/* ShadowRocket 的两条链接 */
|
||||
.row-split {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.split-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.split-tag {
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
width: 26px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.token-foot {
|
||||
padding: 9px 18px;
|
||||
border-top: 1px solid var(--line-soft);
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.pop-title {
|
||||
margin: 0 0 6px;
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.pop-body {
|
||||
margin: 0 0 6px;
|
||||
font-size: 12px;
|
||||
line-height: 1.7;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.pop-code {
|
||||
color: var(--text);
|
||||
background: var(--bg);
|
||||
padding: 1px 5px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.manual-copy {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-top: 14px;
|
||||
padding: 10px 14px;
|
||||
background: var(--direct-dim);
|
||||
border: 1px solid var(--direct);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.manual-copy-label {
|
||||
font-size: 12.5px;
|
||||
color: var(--direct);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.manual-copy-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 12px;
|
||||
padding: 6px 10px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
background: var(--panel);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.token-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.row {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.row-client {
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
13
web/src/router.ts
Normal file
13
web/src/router.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router';
|
||||
|
||||
export const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{ path: '/', redirect: '/nodes' },
|
||||
{ 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: '设置' } },
|
||||
],
|
||||
});
|
||||
41
web/src/stores/groups.ts
Normal file
41
web/src/stores/groups.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import type { GroupDto, GroupInput } from '@proxy-station/shared';
|
||||
import { api } from '../api/client';
|
||||
|
||||
export const useGroupsStore = defineStore('groups', {
|
||||
state: () => ({
|
||||
groups: [] as GroupDto[],
|
||||
loading: false,
|
||||
}),
|
||||
actions: {
|
||||
async fetchAll() {
|
||||
this.loading = true;
|
||||
try {
|
||||
this.groups = await api.get<GroupDto[]>('/api/groups');
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
async create(input: GroupInput) {
|
||||
await api.post('/api/groups', input);
|
||||
await this.fetchAll();
|
||||
},
|
||||
async update(id: string, input: GroupInput) {
|
||||
await api.put(`/api/groups/${id}`, input);
|
||||
await this.fetchAll();
|
||||
},
|
||||
async remove(id: string) {
|
||||
await api.delete(`/api/groups/${id}`);
|
||||
this.groups = this.groups.filter((g) => g.id !== id);
|
||||
},
|
||||
async toggle(id: string, enabled: boolean) {
|
||||
await api.patch(`/api/groups/${id}/enabled`, { enabled });
|
||||
const group = this.groups.find((g) => g.id === id);
|
||||
if (group) group.enabled = enabled;
|
||||
},
|
||||
async reorder(ids: string[]) {
|
||||
await api.put('/api/groups/reorder', { ids });
|
||||
await this.fetchAll();
|
||||
},
|
||||
},
|
||||
});
|
||||
53
web/src/stores/nodes.ts
Normal file
53
web/src/stores/nodes.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import type { NodeDto, NodeInput } from '@proxy-station/shared';
|
||||
import { api } from '../api/client';
|
||||
|
||||
export interface ImportResult {
|
||||
committed: boolean;
|
||||
total: number;
|
||||
succeeded: number;
|
||||
items: { ok: boolean; uri: string; error?: string; node?: Partial<NodeDto> & { name: string } }[];
|
||||
}
|
||||
|
||||
export const useNodesStore = defineStore('nodes', {
|
||||
state: () => ({
|
||||
nodes: [] as NodeDto[],
|
||||
loading: false,
|
||||
}),
|
||||
actions: {
|
||||
async fetchAll() {
|
||||
this.loading = true;
|
||||
try {
|
||||
this.nodes = await api.get<NodeDto[]>('/api/nodes');
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
async create(input: NodeInput) {
|
||||
await api.post('/api/nodes', input);
|
||||
await this.fetchAll();
|
||||
},
|
||||
async update(id: string, input: NodeInput) {
|
||||
await api.put(`/api/nodes/${id}`, input);
|
||||
await this.fetchAll();
|
||||
},
|
||||
async remove(id: string) {
|
||||
await api.delete(`/api/nodes/${id}`);
|
||||
this.nodes = this.nodes.filter((n) => n.id !== id);
|
||||
},
|
||||
async toggle(id: string, enabled: boolean) {
|
||||
await api.patch(`/api/nodes/${id}/enabled`, { enabled });
|
||||
const node = this.nodes.find((n) => n.id === id);
|
||||
if (node) node.enabled = enabled;
|
||||
},
|
||||
async importText(text: string, commit: boolean): Promise<ImportResult> {
|
||||
const result = await api.post<ImportResult>('/api/nodes/import', { text, commit });
|
||||
if (commit) await this.fetchAll();
|
||||
return result;
|
||||
},
|
||||
async exportUri(id: string): Promise<string> {
|
||||
const { uri } = await api.get<{ uri: string }>(`/api/nodes/${id}/uri`);
|
||||
return uri;
|
||||
},
|
||||
},
|
||||
});
|
||||
49
web/src/stores/rules.ts
Normal file
49
web/src/stores/rules.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import type { RuleDto, RuleInput, RulesetDto } from '@proxy-station/shared';
|
||||
import { api } from '../api/client';
|
||||
|
||||
export const useRulesStore = defineStore('rules', {
|
||||
state: () => ({
|
||||
rules: [] as RuleDto[],
|
||||
rulesets: [] as RulesetDto[],
|
||||
loading: false,
|
||||
}),
|
||||
actions: {
|
||||
async fetchAll() {
|
||||
this.loading = true;
|
||||
try {
|
||||
[this.rules, this.rulesets] = await Promise.all([
|
||||
api.get<RuleDto[]>('/api/rules'),
|
||||
api.get<RulesetDto[]>('/api/rulesets'),
|
||||
]);
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
async create(input: RuleInput) {
|
||||
await api.post('/api/rules', input);
|
||||
await this.fetchAll();
|
||||
},
|
||||
async update(id: string, input: RuleInput) {
|
||||
await api.put(`/api/rules/${id}`, input);
|
||||
await this.fetchAll();
|
||||
},
|
||||
async remove(id: string) {
|
||||
await api.delete(`/api/rules/${id}`);
|
||||
this.rules = this.rules.filter((r) => r.id !== id);
|
||||
},
|
||||
async toggle(id: string, enabled: boolean) {
|
||||
await api.patch(`/api/rules/${id}/enabled`, { enabled });
|
||||
const rule = this.rules.find((r) => r.id === id);
|
||||
if (rule) rule.enabled = enabled;
|
||||
},
|
||||
async reorder(ids: string[]) {
|
||||
await api.put('/api/rules/reorder', { ids });
|
||||
await this.fetchAll();
|
||||
},
|
||||
async reset() {
|
||||
await api.post('/api/rules/reset');
|
||||
await this.fetchAll();
|
||||
},
|
||||
},
|
||||
});
|
||||
49
web/src/stores/subscriptions.ts
Normal file
49
web/src/stores/subscriptions.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import type { TokenDto } from '@proxy-station/shared';
|
||||
import { api } from '../api/client';
|
||||
|
||||
export interface SettingsData {
|
||||
surgeSections: Record<string, string>;
|
||||
}
|
||||
|
||||
export const useSubscriptionsStore = defineStore('subscriptions', {
|
||||
state: () => ({
|
||||
tokens: [] as TokenDto[],
|
||||
settings: null as SettingsData | null,
|
||||
loading: false,
|
||||
}),
|
||||
actions: {
|
||||
async fetchAll() {
|
||||
this.loading = true;
|
||||
try {
|
||||
[this.tokens, this.settings] = await Promise.all([
|
||||
api.get<TokenDto[]>('/api/tokens'),
|
||||
api.get<SettingsData>('/api/settings'),
|
||||
]);
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
async createToken(payload: { name: string; nodeIds: string[] }) {
|
||||
await api.post('/api/tokens', payload);
|
||||
await this.fetchAll();
|
||||
},
|
||||
async updateToken(id: string, payload: { name: string; nodeIds: string[] }) {
|
||||
await api.put(`/api/tokens/${id}`, payload);
|
||||
await this.fetchAll();
|
||||
},
|
||||
async removeToken(id: string) {
|
||||
await api.delete(`/api/tokens/${id}`);
|
||||
this.tokens = this.tokens.filter((t) => t.id !== id);
|
||||
},
|
||||
async toggleToken(id: string, enabled: boolean) {
|
||||
await api.patch(`/api/tokens/${id}/enabled`, { enabled });
|
||||
const token = this.tokens.find((t) => t.id === id);
|
||||
if (token) token.enabled = enabled;
|
||||
},
|
||||
async saveSettings(patch: Partial<SettingsData>) {
|
||||
await api.put('/api/settings', patch);
|
||||
await this.fetchAll();
|
||||
},
|
||||
},
|
||||
});
|
||||
98
web/src/styles/tokens.css
Normal file
98
web/src/styles/tokens.css
Normal file
@@ -0,0 +1,98 @@
|
||||
/* 站牌导视(轨道交通导视系统):白底粗黑、线路色圆标、站名牌。
|
||||
颜色语义:琥珀 = direct 直连出口,青 = WARP 净出口;协议各有线路色。 */
|
||||
:root {
|
||||
--bg: #f7f7f4;
|
||||
--panel: #ffffff;
|
||||
--panel-raised: #ffffff;
|
||||
--line: #e2e2dd;
|
||||
--line-soft: #ecece8;
|
||||
--direct: #b26a00;
|
||||
--direct-dim: #faf1e2;
|
||||
--warp: #007a72;
|
||||
--warp-dim: #e2f2f0;
|
||||
--text: #212529;
|
||||
--muted: #6e7073;
|
||||
--danger: #c0392b;
|
||||
/* 重点色:深藏青(站名牌、主按钮、导航下划线),比纯黑柔和 */
|
||||
--ink: #2b3f5c;
|
||||
--ink-hover: #24354e;
|
||||
|
||||
/* 协议线路色(导视系统的线路标识) */
|
||||
--proto-vmess: #275ad1;
|
||||
--proto-trojan: #8348c7;
|
||||
--proto-shadowsocks: #2f7d3b;
|
||||
--proto-hysteria2: #c23d5d;
|
||||
|
||||
--font-body: -apple-system, 'PingFang SC', 'Helvetica Neue', 'Segoe UI', sans-serif;
|
||||
--font-mono: 'IBM Plex Mono', ui-monospace, 'SF Mono', Menlo, monospace;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: var(--font-body);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
/* 页面大标题:导视级粗黑 */
|
||||
.display {
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
/* 导视小标签:加粗 + 字距 */
|
||||
.label {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.egress-direct {
|
||||
color: var(--direct);
|
||||
}
|
||||
|
||||
.egress-warp {
|
||||
color: var(--warp);
|
||||
}
|
||||
|
||||
/* 语义圆点(图例、标签内) */
|
||||
.lamp {
|
||||
display: inline-block;
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.lamp-direct {
|
||||
background: var(--direct);
|
||||
}
|
||||
|
||||
.lamp-warp {
|
||||
background: var(--warp);
|
||||
}
|
||||
|
||||
.lamp-off {
|
||||
background: #b9bab5;
|
||||
}
|
||||
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--text);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
46
web/src/theme.ts
Normal file
46
web/src/theme.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import type { GlobalThemeOverrides } from 'naive-ui';
|
||||
|
||||
/* 站牌导视主题:白底粗黑 + 胶囊按钮 + 墨黑主按钮 */
|
||||
export const themeOverrides: GlobalThemeOverrides = {
|
||||
common: {
|
||||
bodyColor: '#f7f7f4',
|
||||
cardColor: '#ffffff',
|
||||
modalColor: '#ffffff',
|
||||
popoverColor: '#ffffff',
|
||||
tableColor: '#ffffff',
|
||||
tableHeaderColor: '#ffffff',
|
||||
borderColor: '#e2e2dd',
|
||||
dividerColor: '#ecece8',
|
||||
primaryColor: '#2b3f5c',
|
||||
primaryColorHover: '#24354e',
|
||||
primaryColorPressed: '#1d2b40',
|
||||
primaryColorSuppl: '#2b3f5c',
|
||||
infoColor: '#275ad1',
|
||||
successColor: '#007a72',
|
||||
warningColor: '#b26a00',
|
||||
warningColorHover: '#9a5c00',
|
||||
errorColor: '#c0392b',
|
||||
textColorBase: '#212529',
|
||||
textColor1: '#212529',
|
||||
textColor2: '#3c3e42',
|
||||
textColor3: '#6e7073',
|
||||
fontFamily: "-apple-system, 'PingFang SC', 'Helvetica Neue', 'Segoe UI', sans-serif",
|
||||
fontFamilyMono: "'IBM Plex Mono', ui-monospace, 'SF Mono', Menlo, monospace",
|
||||
borderRadius: '10px',
|
||||
borderRadiusSmall: '6px',
|
||||
},
|
||||
Button: {
|
||||
fontWeight: '600',
|
||||
borderRadiusMedium: '999px',
|
||||
borderRadiusSmall: '999px',
|
||||
borderRadiusTiny: '999px',
|
||||
borderRadiusLarge: '999px',
|
||||
},
|
||||
Tag: {
|
||||
borderRadius: '999px',
|
||||
},
|
||||
DataTable: {
|
||||
thFontWeight: '700',
|
||||
thTextColor: '#6e7073',
|
||||
},
|
||||
};
|
||||
45
web/src/utils/clipboard.ts
Normal file
45
web/src/utils/clipboard.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* 复制文本到剪贴板。
|
||||
*
|
||||
* navigator.clipboard 仅在安全上下文(HTTPS 或 localhost)可用,
|
||||
* 通过局域网 IP 以 HTTP 访问时它是 undefined,此时回落到 execCommand。
|
||||
*/
|
||||
export async function copyText(text: string): Promise<boolean> {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return true;
|
||||
} catch {
|
||||
// 继续尝试回落方案
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = text;
|
||||
// 置于视口内但不可见,否则 iOS Safari 不会执行复制
|
||||
ta.setAttribute('readonly', '');
|
||||
ta.style.position = 'fixed';
|
||||
ta.style.top = '0';
|
||||
ta.style.left = '0';
|
||||
ta.style.width = '1px';
|
||||
ta.style.height = '1px';
|
||||
ta.style.padding = '0';
|
||||
ta.style.border = 'none';
|
||||
ta.style.outline = 'none';
|
||||
ta.style.boxShadow = 'none';
|
||||
ta.style.background = 'transparent';
|
||||
ta.style.opacity = '0';
|
||||
document.body.appendChild(ta);
|
||||
|
||||
ta.focus();
|
||||
ta.select();
|
||||
ta.setSelectionRange(0, text.length); // iOS 需要显式指定范围
|
||||
|
||||
const ok = document.execCommand('copy');
|
||||
document.body.removeChild(ta);
|
||||
return ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
12
web/src/utils/nodeName.ts
Normal file
12
web/src/utils/nodeName.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import type { NodeDto } from '@proxy-station/shared';
|
||||
|
||||
/** 名字末尾的出口词:Direct / Warp / 直连 / WARP,前面可以是空格、点号或连字符 */
|
||||
const TRAILING_EGRESS = /[\s·・_-]+(direct|warp|直连)\s*$/iu;
|
||||
|
||||
/**
|
||||
* 界面上与出口标签并排时的节点名:剥掉名字里的出口词,避免和标签重复。
|
||||
* 只影响显示——下发给客户端的名字保持原样,用户要靠它区分同协议的两条线路。
|
||||
*/
|
||||
export function nodeDisplayName(node: Pick<NodeDto, 'name'>): string {
|
||||
return node.name.replace(TRAILING_EGRESS, '').trim();
|
||||
}
|
||||
13
web/tsconfig.json
Normal file
13
web/tsconfig.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true,
|
||||
"jsx": "preserve",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"]
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.vue"]
|
||||
}
|
||||
16
web/vite.config.ts
Normal file
16
web/vite.config.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import vue from '@vitejs/plugin-vue';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
server: {
|
||||
port: 5173,
|
||||
// 监听 0.0.0.0:局域网设备可访问开发中的管理界面
|
||||
host: true,
|
||||
proxy: {
|
||||
'/api': 'http://localhost:3000',
|
||||
// 精确到 /sub/,避免把前端路由 /subscriptions 也代理走
|
||||
'^/sub/': 'http://localhost:3000',
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user