Skip to content

Commit 9d2eada

Browse files
committed
feat: add button-based category and node ordering
1 parent e15e95f commit 9d2eada

6 files changed

Lines changed: 23 additions & 13 deletions

File tree

‎README.md‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
</p>
1111

1212
<p align="center">
13-
<img alt="Version" src="https://img.shields.io/badge/version-v3.4.0-2563eb?style=flat-square">
13+
<img alt="Version" src="https://img.shields.io/badge/version-v3.4.1-2563eb?style=flat-square">
1414
<img alt="Docker" src="https://img.shields.io/badge/Docker-single--container-2496ed?style=flat-square&logo=docker&logoColor=white">
1515
<img alt="FastAPI" src="https://img.shields.io/badge/FastAPI-0.115-009688?style=flat-square&logo=fastapi&logoColor=white">
1616
<img alt="Vue" src="https://img.shields.io/badge/Vue-3-42b883?style=flat-square&logo=vuedotjs&logoColor=white">
@@ -70,15 +70,15 @@ docker compose up -d --build
7070
### Docker CLI
7171

7272
```bash
73-
docker build -t sub-manager:v3.4.0 .
73+
docker build -t sub-manager:v3.4.1 .
7474

7575
docker run -d \
7676
--name sub-manager \
7777
--restart unless-stopped \
7878
-p 7777:7777 \
7979
-e TZ=Asia/Shanghai \
8080
-v /opt/sub-manager/data:/data \
81-
sub-manager:v3.4.0
81+
sub-manager:v3.4.1
8282
```
8383

8484
容器只暴露 `7777`,数据库、缓存、密钥和输出均位于 `/data`。重建或升级容器时必须保留该目录。

‎app/categories.py‎

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ def list_categories(subscription_id: int) -> list[dict[str, Any]]:
1717
item["source_ids"] = [int(x[0]) for x in conn.execute(
1818
"SELECT upstream_id FROM proxy_category_sources WHERE category_id=? ORDER BY upstream_id", (item["id"],))]
1919
item["node_keys"] = [str(x[0]) for x in conn.execute(
20-
"SELECT node_key FROM proxy_category_nodes WHERE category_id=? ORDER BY node_key", (item["id"],))]
20+
"SELECT node_key FROM proxy_category_nodes WHERE category_id=? ORDER BY rowid", (item["id"],))]
2121
return categories
2222

2323

@@ -91,9 +91,14 @@ def delete_category(subscription_id: int, category_id: int) -> None:
9191
def category_groups(subscription_id: int, nodes: list[Any]) -> list[dict[str, Any]]:
9292
result = []
9393
for category in list_categories(subscription_id):
94-
sources, keys = set(category["source_ids"]), set(category["node_keys"])
95-
names = [node.name for node in nodes if node.node_key in keys or (node.source_id is not None and node.source_id in sources)]
96-
names = list(dict.fromkeys(names))
94+
sources, keys = set(category["source_ids"]), list(category["node_keys"])
95+
by_key = {node.node_key: node for node in nodes}
96+
# Whole-source membership follows the global node order. Explicit
97+
# assignments not already covered by a source follow their saved order.
98+
selected = [node for node in nodes if node.source_id is not None and node.source_id in sources]
99+
selected_keys = {node.node_key for node in selected}
100+
selected.extend(by_key[key] for key in keys if key in by_key and key not in selected_keys)
101+
names = list(dict.fromkeys(node.name for node in selected))
97102
if names:
98103
label = (str(category["icon"]).strip() + " " if category["icon"] else "") + str(category["name"])
99104
result.append({"name": label, "type": "select", "proxies": names})

‎app/config.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
from pathlib import Path
55

66

7-
APP_VERSION = "3.4.0"
7+
APP_VERSION = "3.4.1"
88
DATA_DIR = Path(os.getenv("DATA_DIR", "/data"))
99
DB_PATH = DATA_DIR / "submanager.db"
1010
CACHE_DIR = DATA_DIR / "cache"

‎frontend/package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "sub-manager-web",
33
"private": true,
4-
"version": "3.4.0",
4+
"version": "3.4.1",
55
"type": "module",
66
"scripts": {"dev":"vite","build":"vue-tsc -b && vite build","test":"vitest run"},
77
"dependencies": {

‎frontend/src/style.css‎

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
<script setup lang="ts">
2-
import{computed,onMounted,ref}from'vue';import{useRoute,useRouter}from'vue-router';import{ElMessage,ElMessageBox}from'element-plus';import{ArrowLeft,FolderTree,GripVertical,Plus,Save,Trash2}from'lucide-vue-next';import{api}from'../api';import type{Group,NodeSnapshot}from'../types';
3-
type Category={id?:number;name:string;icon:string;sort_order?:number;source_ids:number[];node_keys:string[]};const route=useRoute(),router=useRouter(),id=Number(route.params.id),group=ref<Group|null>(null),nodes=ref<NodeSnapshot[]>([]),items=ref<Category[]>([]),selected=ref(0),saving=ref(false),sorting=ref(false),dragging=ref<number|null>(null),query=ref(''),protocol=ref('');const current=computed(()=>items.value[selected.value]);const filtered=computed(()=>nodes.value.filter(n=>(!query.value||n.final_name.toLowerCase().includes(query.value.toLowerCase())||n.source_name.toLowerCase().includes(query.value.toLowerCase()))&&(!protocol.value||n.protocol===protocol.value)));const protocols=computed(()=>[...new Set(nodes.value.map(x=>x.protocol))]);
2+
import{computed,onMounted,ref}from'vue';import{useRoute,useRouter}from'vue-router';import{ElMessage,ElMessageBox}from'element-plus';import{ArrowLeft,ArrowDown,ArrowUp,FolderTree,Plus,Save,Trash2}from'lucide-vue-next';import{api}from'../api';import type{Group,NodeSnapshot}from'../types';
3+
type Category={id?:number;name:string;icon:string;sort_order?:number;source_ids:number[];node_keys:string[]};const route=useRoute(),router=useRouter(),id=Number(route.params.id),group=ref<Group|null>(null),nodes=ref<NodeSnapshot[]>([]),items=ref<Category[]>([]),selected=ref(0),saving=ref(false),sorting=ref(false),query=ref(''),protocol=ref('');const current=computed(()=>items.value[selected.value]);const filtered=computed(()=>nodes.value.filter(n=>(!query.value||n.final_name.toLowerCase().includes(query.value.toLowerCase())||n.source_name.toLowerCase().includes(query.value.toLowerCase()))&&(!protocol.value||n.protocol===protocol.value)).sort((a,b)=>{const order=current.value?.node_keys||[];const ai=order.indexOf(a.node_key),bi=order.indexOf(b.node_key);return (ai<0?1e9:ai)-(bi<0?1e9:bi)}));const protocols=computed(()=>[...new Set(nodes.value.map(x=>x.protocol))]);
44
async function load(){group.value=await api(`/api/subscriptions/${id}`);nodes.value=(await api<any>(`/api/subscriptions/${id}/nodes`)).nodes;items.value=(await api<any>(`/api/subscriptions/${id}/categories`)).categories;if(!items.value.length)add()}
55
function add(){items.value.push({name:`分类 ${items.value.length+1}`,icon:'',source_ids:[],node_keys:[]});selected.value=items.value.length-1}function has(key:string){return current.value?.node_keys.includes(key)}function toggle(key:string,on:unknown){const list=current.value.node_keys;if(!!on&&!list.includes(key))list.push(key);if(!on)current.value.node_keys=list.filter(x=>x!==key)}async function save(){if(!current.value)return;saving.value=true;try{const path=current.value.id?`/api/subscriptions/${id}/categories/${current.value.id}`:`/api/subscriptions/${id}/categories`;const result:any=await api(path,{method:current.value.id?'PUT':'POST',body:JSON.stringify(current.value)});Object.assign(current.value,result.category);ElMessage.success('分类已保存')}catch(e:any){ElMessage.error(e.message)}finally{saving.value=false}}async function remove(){if(!current.value)return;if(!current.value.id){items.value.splice(selected.value,1);selected.value=Math.max(0,selected.value-1);return}try{await ElMessageBox.confirm(`删除分类“${current.value.name}”?不会删除节点。`,'删除分类',{type:'warning'});await api(`/api/subscriptions/${id}/categories/${current.value.id}`,{method:'DELETE'});items.value.splice(selected.value,1);selected.value=Math.max(0,selected.value-1)}catch{}}
6-
function startDrag(index:number){if(items.value[index]?.id)dragging.value=index}async function dropAt(to:number){const from=dragging.value;dragging.value=null;if(from===null||from===to||items.value.some(x=>!x.id))return;const active=current.value;const[moved]=items.value.splice(from,1);items.value.splice(to,0,moved);selected.value=Math.max(0,items.value.indexOf(active));sorting.value=true;try{const result:any=await api(`/api/subscriptions/${id}/categories/reorder`,{method:'PUT',body:JSON.stringify(items.value.map(x=>x.id))});items.value=result.categories;selected.value=Math.max(0,items.value.findIndex(x=>x.id===active?.id));ElMessage.success('分类顺序已保存,下次刷新同步到下游')}catch(e:any){ElMessage.error(e.message);await load()}finally{sorting.value=false}}
6+
async function reorderCategories(next:number[]){if(items.value.some(x=>!x.id))return;sorting.value=true;const active=current.value;try{const result:any=await api(`/api/subscriptions/${id}/categories/reorder`,{method:'PUT',body:JSON.stringify(next)});items.value=result.categories;selected.value=Math.max(0,items.value.findIndex(x=>x.id===active?.id));ElMessage.success('分类顺序已保存,下次刷新同步到下游')}catch(e:any){ElMessage.error(e.message);await load()}finally{sorting.value=false}}
7+
async function moveCategory(index:number,delta:number){const next=index+delta;if(next<0||next>=items.value.length||!items.value[index]?.id||items.value.some(x=>!x.id))return;const ids=items.value.map(x=>x.id as number);[ids[index],ids[next]]=[ids[next],ids[index]];await reorderCategories(ids)}
8+
function moveNodeInCategory(key:string,delta:number){if(!current.value)return;const index=current.value.node_keys.indexOf(key);const next=index+delta;if(index<0||next<0||next>=current.value.node_keys.length)return;const keys=current.value.node_keys.slice();[keys[index],keys[next]]=[keys[next],keys[index]];current.value.node_keys=keys}
9+
710
onMounted(load);
811
</script>
9-
<template><section class="page category-page"><header class="page-head"><div><button class="back" @click="router.back()"><ArrowLeft/></button><p class="eyebrow">PROXY CATEGORIES</p><h1>{{group?.name||'订阅组'}} · 分类管理</h1><p>整条订阅来源自动归类,或精确分配单个远程/手动节点;拖动左侧分类可调整下游顺序。</p></div><div class="head-actions"><el-button @click="add"><Plus/>新建分类</el-button><el-button type="primary" :loading="saving" @click="save"><Save/>保存当前分类</el-button></div></header><div class="category-layout"><aside v-loading="sorting" class="panel category-list"><article v-for="(c,i) in items" :key="c.id||i" :class="{active:selected===i,dragging:dragging===i}" :draggable="!!c.id" @dragstart="startDrag(i)" @dragend="dragging=null" @dragover.prevent @drop.prevent="dropAt(i)" @click="selected=i"><GripVertical class="drag-handle"/><span>{{c.icon||'📁'}}</span><div><b>{{c.name}}</b><small>{{c.source_ids.length}} 个来源 · {{c.node_keys.length}} 个单独节点</small></div></article><div class="category-help"><FolderTree/><b>下游结构</b><span>PROXY → 排序后的自定义分类 / 自动选择 / DIRECT / 全部真实节点</span></div></aside><main v-if="current" class="panel category-editor"><div class="category-title"><label>图标<el-input v-model="current.icon" maxlength="4" placeholder="🇺🇸"/></label><label>分类名称<el-input v-model="current.name" maxlength="40"/></label><el-button type="danger" plain @click="remove"><Trash2/>删除</el-button></div><section><h3>整条订阅来源</h3><p>勾选后,这条链接以后新增的节点也会自动进入当前分类。</p><el-checkbox-group v-model="current.source_ids" class="source-checks"><el-checkbox v-for="u in group?.upstreams||[]" :key="u.id" :value="u.id" border>{{u.name}} <small>{{u.node_count||0}} 节点</small></el-checkbox></el-checkbox-group></section><section><div class="node-filter"><div><h3>单独分配节点</h3><p>节点绑定稳定身份,流量和剩余天数变化不会脱离分类。</p></div><el-input v-model="query" placeholder="搜索节点或来源" clearable/><el-select v-model="protocol" placeholder="全部协议" clearable><el-option v-for="p in protocols" :key="p" :label="p.toUpperCase()" :value="p"/></el-select></div><div class="category-nodes"><label v-for="n in filtered" :key="n.node_key"><el-checkbox :model-value="has(n.node_key)" @change="toggle(n.node_key,$event)"/><span><b>{{n.final_name}}</b><small>{{n.source_name}} · {{n.protocol.toUpperCase()}}</small></span></label></div></section></main></div></section></template>
12+
<template><section class="page category-page"><header class="page-head"><div><button class="back" @click="router.back()"><ArrowLeft/></button><p class="eyebrow">PROXY CATEGORIES</p><h1>{{group?.name||'订阅组'}} · 分类管理</h1><p>整条订阅来源自动归类,或精确分配单个远程/手动节点;使用上下按钮调整分类和节点下游顺序。</p></div><div class="head-actions"><el-button @click="add"><Plus/>新建分类</el-button><el-button type="primary" :loading="saving" @click="save"><Save/>保存当前分类</el-button></div></header><div class="category-layout"><aside v-loading="sorting" class="panel category-list"><article v-for="(c,i) in items" :key="c.id||i" :class="{active:selected===i}" @click="selected=i"><div class="category-move"><button :disabled="i===0||!c.id||items.some(x=>!x.id)" title="上移" @click.stop="moveCategory(i,-1)"><ArrowUp/></button><button :disabled="i===items.length-1||!c.id||items.some(x=>!x.id)" title="下移" @click.stop="moveCategory(i,1)"><ArrowDown/></button></div><span>{{c.icon||'📁'}}</span><div><b>{{c.name}}</b><small>{{c.source_ids.length}} 个来源 · {{c.node_keys.length}} 个单独节点</small></div></article><div class="category-help"><FolderTree/><b>下游结构</b><span>PROXY → 排序后的自定义分类 / 自动选择 / DIRECT / 全部真实节点</span></div></aside><main v-if="current" class="panel category-editor"><div class="category-title"><label>图标<el-input v-model="current.icon" maxlength="4" placeholder="🇺🇸"/></label><label>分类名称<el-input v-model="current.name" maxlength="40"/></label><el-button type="danger" plain @click="remove"><Trash2/>删除</el-button></div><section><h3>整条订阅来源</h3><p>勾选后,这条链接以后新增的节点也会自动进入当前分类。</p><el-checkbox-group v-model="current.source_ids" class="source-checks"><el-checkbox v-for="u in group?.upstreams||[]" :key="u.id" :value="u.id" border>{{u.name}} <small>{{u.node_count||0}} 节点</small></el-checkbox></el-checkbox-group></section><section><div class="node-filter"><div><h3>单独分配节点</h3><p>节点绑定稳定身份,流量和剩余天数变化不会脱离分类。</p></div><el-input v-model="query" placeholder="搜索节点或来源" clearable/><el-select v-model="protocol" placeholder="全部协议" clearable><el-option v-for="p in protocols" :key="p" :label="p.toUpperCase()" :value="p"/></el-select></div><div class="category-nodes"><label v-for="n in filtered" :key="n.node_key"><el-checkbox :model-value="has(n.node_key)" @change="toggle(n.node_key,$event)"/><span><b>{{n.final_name}}</b><small>{{n.source_name}} · {{n.protocol.toUpperCase()}}</small></span><div v-if="has(n.node_key)" class="node-move"><button :disabled="current.node_keys.indexOf(n.node_key)===0" title="节点上移" @click.stop="moveNodeInCategory(n.node_key,-1)"><ArrowUp/></button><button :disabled="current.node_keys.indexOf(n.node_key)===current.node_keys.length-1" title="节点下移" @click.stop="moveNodeInCategory(n.node_key,1)"><ArrowDown/></button></div></label></div></section></main></div></section></template>

0 commit comments

Comments
 (0)