init: init proj
This commit is contained in:
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>
|
||||
Reference in New Issue
Block a user