订阅列表可以调顺序
每行末尾两个箭头,鼠标划过那一行才显现。proxy-providers 是映射,顺序只 影响界面呈现,内核不看;交换的是两个用户订阅在映射里的位置,中间夹着的 链式副本留在原地——它们不在列表上,跟着挪只会把 diff 撑大。 顺序写在配置文件里,所以走保存链路,不是运行时动作。
This commit is contained in:
@@ -5,6 +5,7 @@ import {
|
|||||||
addFileSubscription,
|
addFileSubscription,
|
||||||
addSubscription,
|
addSubscription,
|
||||||
listSubscriptions,
|
listSubscriptions,
|
||||||
|
moveSubscription,
|
||||||
providerNames,
|
providerNames,
|
||||||
removeSubscription,
|
removeSubscription,
|
||||||
renameSubscription,
|
renameSubscription,
|
||||||
@@ -280,6 +281,48 @@ describe('删除前的引用检查', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('调整订阅顺序', () => {
|
||||||
|
/** 三个用户订阅,外加一份接在中间的订阅副本。 */
|
||||||
|
function three(): ConfigObject {
|
||||||
|
let data: ConfigObject = {}
|
||||||
|
for (const name of ['A', 'B', 'C']) data = addSubscription(data, name, `https://${name}.example`)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
it('上移和下移交换相邻两个订阅', () => {
|
||||||
|
expect(listSubscriptions(moveSubscription(three(), 'B', -1)).map((s) => s.name)).toEqual([
|
||||||
|
'B',
|
||||||
|
'A',
|
||||||
|
'C',
|
||||||
|
])
|
||||||
|
expect(listSubscriptions(moveSubscription(three(), 'B', 1)).map((s) => s.name)).toEqual([
|
||||||
|
'A',
|
||||||
|
'C',
|
||||||
|
'B',
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('到头了原样返回,配置对象不变', () => {
|
||||||
|
const data = three()
|
||||||
|
expect(moveSubscription(data, 'A', -1)).toBe(data)
|
||||||
|
expect(moveSubscription(data, 'C', 1)).toBe(data)
|
||||||
|
expect(moveSubscription(data, '不存在', 1)).toBe(data)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('夹在中间的订阅副本留在原地', () => {
|
||||||
|
const withCopy = applySetup(
|
||||||
|
{ ...three(), listeners: [{ name: 'test1', type: 'mixed', port: 7778 }] },
|
||||||
|
'test1',
|
||||||
|
{ proxy: { sources: ['B'], filter: null }, chain: { sources: ['B'], filter: null } },
|
||||||
|
)
|
||||||
|
const copy = chainProviderName('test1', 'B')
|
||||||
|
const before = Object.keys(withCopy['proxy-providers'] as ConfigObject).indexOf(copy)
|
||||||
|
const after = moveSubscription(withCopy, 'A', 1)
|
||||||
|
expect(listSubscriptions(after).map((s) => s.name)).toEqual(['B', 'A', 'C'])
|
||||||
|
expect(Object.keys(after['proxy-providers'] as ConfigObject).indexOf(copy)).toBe(before)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('缓存路径撞名', () => {
|
describe('缓存路径撞名', () => {
|
||||||
it('名字只差符号时净化成同一个路径,能认出来', () => {
|
it('名字只差符号时净化成同一个路径,能认出来', () => {
|
||||||
const data = addSubscription({}, '订阅 A', 'https://a.example/sub')
|
const data = addSubscription({}, '订阅 A', 'https://a.example/sub')
|
||||||
|
|||||||
@@ -238,6 +238,36 @@ export function renameSubscription(
|
|||||||
return next
|
return next
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把一个订阅在列表里上移或下移一位。
|
||||||
|
*
|
||||||
|
* 顺序只是呈现顺序,内核不看 `proxy-providers` 里条目的先后。交换的是两个用户订阅在
|
||||||
|
* 映射里的位置,中间夹着的副本留在原地——它们不在列表上,跟着挪只会把 diff 撑大。
|
||||||
|
* 已经在头(尾)还要上(下)移时原样返回,调用方据此不发起保存。
|
||||||
|
*/
|
||||||
|
export function moveSubscription(
|
||||||
|
data: ConfigObject,
|
||||||
|
name: string,
|
||||||
|
delta: -1 | 1,
|
||||||
|
): ConfigObject {
|
||||||
|
const entries = Object.entries(providersOf(data))
|
||||||
|
const keys = entries.map(([key]) => key)
|
||||||
|
const mine = keys.filter((key) => !isGenerated(key))
|
||||||
|
const at = mine.indexOf(name)
|
||||||
|
const target = mine[at + delta]
|
||||||
|
if (at < 0 || target === undefined) return data
|
||||||
|
|
||||||
|
const from = keys.indexOf(name)
|
||||||
|
const onto = keys.indexOf(target)
|
||||||
|
const order = [...entries]
|
||||||
|
order[from] = entries[onto]!
|
||||||
|
order[onto] = entries[from]!
|
||||||
|
|
||||||
|
const next = { ...data }
|
||||||
|
next['proxy-providers'] = Object.fromEntries(order)
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
/** 删除一个订阅。删到最后一个时把整个键去掉,不在文件里留空映射。 */
|
/** 删除一个订阅。删到最后一个时把整个键去掉,不在文件里留空映射。 */
|
||||||
export function removeSubscription(data: ConfigObject, name: string): ConfigObject {
|
export function removeSubscription(data: ConfigObject, name: string): ConfigObject {
|
||||||
const providers = { ...providersOf(data) }
|
const providers = { ...providersOf(data) }
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
/**
|
/**
|
||||||
* 订阅列表。每行一个订阅:名字、当前线路数、上次更新时间。
|
* 订阅列表。每行一个订阅:名字、当前线路数、上次更新时间,末尾是调顺序的两个箭头。
|
||||||
*
|
*
|
||||||
* 只管显示和选中,不碰任何数据。
|
* 只管显示、选中和「往哪边挪一位」,不碰任何数据。
|
||||||
*/
|
*/
|
||||||
|
import IconUp from '~icons/ri/arrow-up-line'
|
||||||
|
import IconDown from '~icons/ri/arrow-down-line'
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
/** 一行要显示的东西,由页面按配置和运行时数据拼好 */
|
/** 一行要显示的东西,由页面按配置和运行时数据拼好 */
|
||||||
@@ -14,9 +16,14 @@ defineProps<{
|
|||||||
/** 上次拉取成功的时刻。null 表示不知道或从没拉取过 */
|
/** 上次拉取成功的时刻。null 表示不知道或从没拉取过 */
|
||||||
updated: Date | null
|
updated: Date | null
|
||||||
}>
|
}>
|
||||||
|
/** 配置正在保存。调顺序要写配置文件,保存期间不能再点 */
|
||||||
|
saving: boolean
|
||||||
}>()
|
}>()
|
||||||
const selected = defineModel<string | null>('selected', { required: true })
|
const selected = defineModel<string | null>('selected', { required: true })
|
||||||
|
|
||||||
|
/** 把这个订阅在列表里挪一位。写配置文件,由页面走保存链路。 */
|
||||||
|
const emit = defineEmits<{ move: [name: string, delta: -1 | 1] }>()
|
||||||
|
|
||||||
const pad = (n: number) => String(n).padStart(2, '0')
|
const pad = (n: number) => String(n).padStart(2, '0')
|
||||||
|
|
||||||
function formatUpdated(at: Date): string {
|
function formatUpdated(at: Date): string {
|
||||||
@@ -26,12 +33,12 @@ function formatUpdated(at: Date): string {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<ol class="rows">
|
<ol class="rows">
|
||||||
<li v-for="row in rows" :key="row.name">
|
<li
|
||||||
<button
|
v-for="(row, i) in rows"
|
||||||
type="button"
|
:key="row.name"
|
||||||
:class="['line', selected === row.name && 'is-on']"
|
:class="['row', selected === row.name && 'is-on']"
|
||||||
@click="selected = row.name"
|
>
|
||||||
>
|
<button type="button" class="line" @click="selected = row.name">
|
||||||
<span class="name">{{ row.name }}</span>
|
<span class="name">{{ row.name }}</span>
|
||||||
<span class="lines mono">
|
<span class="lines mono">
|
||||||
<template v-if="row.lines !== null">{{ row.lines }} 条</template>
|
<template v-if="row.lines !== null">{{ row.lines }} 条</template>
|
||||||
@@ -39,6 +46,24 @@ function formatUpdated(at: Date): string {
|
|||||||
</span>
|
</span>
|
||||||
<span class="updated mono">{{ row.updated ? formatUpdated(row.updated) : '—' }}</span>
|
<span class="updated mono">{{ row.updated ? formatUpdated(row.updated) : '—' }}</span>
|
||||||
</button>
|
</button>
|
||||||
|
<span class="acts">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title="上移"
|
||||||
|
:disabled="saving || i === 0"
|
||||||
|
@click="emit('move', row.name, -1)"
|
||||||
|
>
|
||||||
|
<IconUp />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title="下移"
|
||||||
|
:disabled="saving || i === rows.length - 1"
|
||||||
|
@click="emit('move', row.name, 1)"
|
||||||
|
>
|
||||||
|
<IconDown />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
</li>
|
</li>
|
||||||
</ol>
|
</ol>
|
||||||
</template>
|
</template>
|
||||||
@@ -58,27 +83,34 @@ function formatUpdated(at: Date): string {
|
|||||||
border-top: 1px solid var(--c-border);
|
border-top: 1px solid var(--c-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding-right: var(--s-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.row:hover {
|
||||||
|
background: var(--c-surface-sunken);
|
||||||
|
}
|
||||||
|
|
||||||
|
.row.is-on {
|
||||||
|
background: var(--c-accent-soft);
|
||||||
|
}
|
||||||
|
|
||||||
.line {
|
.line {
|
||||||
width: 100%;
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr) 4rem 6.5rem;
|
grid-template-columns: minmax(0, 1fr) 4rem 6.5rem;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--s-3);
|
gap: var(--s-3);
|
||||||
padding: var(--s-3) var(--s-4);
|
padding: var(--s-3) var(--s-2) var(--s-3) var(--s-4);
|
||||||
border: none;
|
border: none;
|
||||||
background: none;
|
background: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
.line:hover {
|
|
||||||
background: var(--c-surface-sunken);
|
|
||||||
}
|
|
||||||
|
|
||||||
.line.is-on {
|
|
||||||
background: var(--c-accent-soft);
|
|
||||||
}
|
|
||||||
|
|
||||||
.name {
|
.name {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -93,4 +125,39 @@ function formatUpdated(at: Date): string {
|
|||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
color: var(--c-text-soft);
|
color: var(--c-text-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 平时不显示:调顺序不是常用动作,每行常驻两个箭头只是噪声。
|
||||||
|
隐藏用 opacity 而不是 display,位置一直占着,鼠标划过时右边一列才不会跳 */
|
||||||
|
.acts {
|
||||||
|
flex: none;
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 1px;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.15s ease;
|
||||||
|
}
|
||||||
|
.row:hover .acts,
|
||||||
|
.acts:focus-within {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.acts button {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--r-sm);
|
||||||
|
background: none;
|
||||||
|
color: var(--c-text-faint);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.acts button:hover:not(:disabled) {
|
||||||
|
background: var(--c-surface);
|
||||||
|
color: var(--c-text);
|
||||||
|
}
|
||||||
|
.acts button:disabled {
|
||||||
|
opacity: 0.35;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
addFileSubscription,
|
addFileSubscription,
|
||||||
addSubscription,
|
addSubscription,
|
||||||
listSubscriptions,
|
listSubscriptions,
|
||||||
|
moveSubscription,
|
||||||
providerNames,
|
providerNames,
|
||||||
removeSubscription,
|
removeSubscription,
|
||||||
renameSubscription,
|
renameSubscription,
|
||||||
@@ -272,6 +273,16 @@ async function check() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 把一个订阅在列表里挪一位。顺序在配置文件里,所以要走 commit()。 */
|
||||||
|
async function move(name: string, delta: -1 | 1) {
|
||||||
|
if (saving.value) return
|
||||||
|
const before = clone()
|
||||||
|
const next = moveSubscription(before, name, delta)
|
||||||
|
// 到头了 moveSubscription 原样返回,这时不该发起一次什么都没改的保存
|
||||||
|
if (next === before) return
|
||||||
|
await commit(next, '顺序已保存')
|
||||||
|
}
|
||||||
|
|
||||||
const refreshingAll = ref(false)
|
const refreshingAll = ref(false)
|
||||||
|
|
||||||
/** 挨个让内核重新拉取所有订阅。失败的点名,成功的不逐个报。 */
|
/** 挨个让内核重新拉取所有订阅。失败的点名,成功的不逐个报。 */
|
||||||
@@ -326,7 +337,12 @@ async function refreshAll() {
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="split">
|
<div class="split">
|
||||||
<ProviderList v-model:selected="selectedName" :rows="rows" />
|
<ProviderList
|
||||||
|
v-model:selected="selectedName"
|
||||||
|
:rows="rows"
|
||||||
|
:saving="saving"
|
||||||
|
@move="move"
|
||||||
|
/>
|
||||||
|
|
||||||
<SubscriptionDetail
|
<SubscriptionDetail
|
||||||
v-if="selected"
|
v-if="selected"
|
||||||
|
|||||||
Reference in New Issue
Block a user