优化调整、BT下载(有bug)

This commit is contained in:
zhongluofeng
2026-08-27 18:25:45 +08:00
parent 4dd60f42a1
commit d21649c60e
34 changed files with 4991 additions and 198 deletions
+82 -7
View File
@@ -9,6 +9,7 @@ import type {
DownloadTask as BindDownloadTask,
DownloaderSettings as BindDownloaderSettings,
CheckUrlResult as BindCheckUrlResult,
TorrentInfo as BindTorrentInfo,
TaskStatus,
} from '@/lib/bindings'
@@ -19,11 +20,14 @@ const logger = createLogger('downloader')
export type DownloadTask = Required<BindDownloadTask>
export type DownloaderSettings = Required<BindDownloaderSettings>
export type CheckUrlResult = Required<BindCheckUrlResult>
export type TorrentInfo = Required<BindTorrentInfo>
// re-export:跨端类型统一由 bindings 提供,组件从本 store import 的路径保持不变
export type {
TaskStatus,
TaskProtocol,
Segment,
BtFileInfo,
DuplicateKind,
ExistingTaskInfo,
} from '@/lib/bindings'
@@ -71,6 +75,10 @@ export const useDownloaderStore = defineStore('downloader', () => {
let completeUnlisten: UnlistenFn | null = null
let addedUnlisten: UnlistenFn | null = null
let removedUnlisten: UnlistenFn | null = null
let inspectReadyUnlisten: UnlistenFn | null = null
/** 磁力元数据解析就绪回调(模块注册,用于弹文件勾选对话框) */
let btInspectReadyHandler: ((id: string) => void) | null = null
// ===== 任务列表 =====
const refreshTasks = async () => {
@@ -85,13 +93,26 @@ export const useDownloaderStore = defineStore('downloader', () => {
if (
freshTask.status === 'paused' ||
freshTask.status === 'complete' ||
freshTask.status === 'error'
freshTask.status === 'error' ||
freshTask.status === 'cancelled'
) {
return freshTask
}
const local = tasks.value.find(t => t.id === freshTask.id)
if (!local) return freshTask
return { ...local, status: freshTask.status, error: freshTask.error }
// 进度/速度沿用本地实时值;但元数据字段(文件名/文件列表/总大小/infohash
// 必须以后端为准 —— 这些只在后台解析完成后才就绪,本地快照在添加时是占位空值,
// 若沿用会导致"元数据已解析但勾选对话框仍无文件列表"(旧快照覆盖新元数据)
return {
...local,
status: freshTask.status,
error: freshTask.error,
filename: freshTask.filename,
btFiles: freshTask.btFiles,
btMetadataReady: freshTask.btMetadataReady,
infoHash: freshTask.infoHash,
totalSize: freshTask.totalSize,
}
})
tasks.value = merged
} catch (e) {
@@ -105,9 +126,15 @@ export const useDownloaderStore = defineStore('downloader', () => {
const task = tasks.value.find((t) => t.id === payload.id)
if (task) {
// 终态任务忽略迟到的进度事件(下载完成后 in-flight 事件可能把状态/进度回退)
if (task.status === 'complete' || task.status === 'error') return
// 已暂停任务忽略仍携带 active 的迟到事件(暂停瞬间发出的旧事件)
if (task.status === 'paused' && payload.status === 'active') return
if (task.status === 'complete' || task.status === 'error' || task.status === 'cancelled') return
// 已暂停任务忽略"停止瞬间残留的 active 心跳"(无速度且进度未变化的迟到事件)。
// 真正恢复下载后发来的 active(有速度或进度增长)必须放行,否则恢复后列表一直停留在暂停态
if (
task.status === 'paused' &&
payload.status === 'active' &&
payload.speed <= 0 &&
payload.completedSize <= task.completedSize
) return
task.completedSize = payload.completedSize
task.totalSize = payload.totalSize
task.speed = payload.speed
@@ -134,19 +161,32 @@ export const useDownloaderStore = defineStore('downloader', () => {
filename?: string,
dir?: string,
headers?: Record<string, string>,
autoRename = false
autoRename = false,
onlyFiles?: number[]
): Promise<string> => {
const id = await commands.downloaderAddTask(
url,
filename || null,
dir || null,
headers || null,
autoRename
autoRename,
onlyFiles || null
)
await refreshTasks()
return id
}
/** 解析磁力链 / .torrent,返回种子信息(文件勾选用) */
const inspect = async (input: string): Promise<TorrentInfo> => {
return await commands.downloaderInspect(input)
}
/** 磁力任务元数据解析成功后:设置勾选文件并开始下载 */
const selectBtFiles = async (id: string, onlyFiles: number[]) => {
await commands.downloaderSelectBtFiles(id, onlyFiles)
await refreshTasks()
}
/** 检查 URL 重复性并探测文件信息 */
const checkUrl = async (
url: string,
@@ -171,6 +211,18 @@ export const useDownloaderStore = defineStore('downloader', () => {
await refreshTasks()
}
/** 取消下载:置为已取消、清空进度并删除下载文件,但保留记录 */
const cancelTask = async (id: string) => {
await commands.downloaderCancelTask(id)
await refreshTasks()
}
/** 重新下载已取消/出错的任务 */
const redownload = async (id: string) => {
await commands.downloaderRedownload(id)
await refreshTasks()
}
// ===== 设置 =====
const loadSettings = async () => {
try {
@@ -250,6 +302,15 @@ export const useDownloaderStore = defineStore('downloader', () => {
refreshTasks()
})
}
if (!inspectReadyUnlisten) {
inspectReadyUnlisten = await listen<{ id: string }>('download-inspect-ready', async (e) => {
// 磁力元数据解析成功:先刷新任务(拿到文件列表),再通知模块弹文件勾选对话框。
// 必须 await —— 否则回调读取的 store.tasks 仍是旧快照(btFiles 为空),
// 导致勾选对话框无条目、默认选中空数组,进而在后端被 librqbit 秒判为"已完成 0%"
await refreshTasks()
btInspectReadyHandler?.(e.payload.id)
})
}
}
const stopEventListeners = () => {
@@ -269,6 +330,10 @@ export const useDownloaderStore = defineStore('downloader', () => {
removedUnlisten()
removedUnlisten = null
}
if (inspectReadyUnlisten) {
inspectReadyUnlisten()
inspectReadyUnlisten = null
}
}
// ===== 初始化 =====
@@ -281,6 +346,11 @@ export const useDownloaderStore = defineStore('downloader', () => {
// ===== 工具函数 =====
const openDir = (path: string) => commands.downloaderOpenDir(path)
/** 注册磁力元数据就绪回调(模块传入处理函数,替换式) */
const setBtInspectReadyHandler = (handler: ((id: string) => void) | null) => {
btInspectReadyHandler = handler
}
return {
// state
tasks,
@@ -290,10 +360,14 @@ export const useDownloaderStore = defineStore('downloader', () => {
// tasks
refreshTasks,
addTask,
inspect,
selectBtFiles,
checkUrl,
pauseTask,
resumeTask,
removeTask,
cancelTask,
redownload,
// settings
loadSettings,
saveSettings,
@@ -307,6 +381,7 @@ export const useDownloaderStore = defineStore('downloader', () => {
// init
init,
// utils
setBtInspectReadyHandler,
openDir
}
})
+182 -50
View File
@@ -261,14 +261,14 @@ function defaultOsdConfig(): OsdConfig {
labelLanguage: 'zh',
layout: 'single',
updateIntervalMs: 1000,
// 默认关闭点击穿透:关闭后左键可直接拖动悬浮窗
clickThrough: false,
// 默认开启点击穿透:悬浮窗不拦截鼠标,需拖动时临时关闭
clickThrough: true,
fontColor: '#ffffff',
fontOpacity: 100,
bgColor: 'transparent',
colorThemeEnabled: true,
colorTheme: { ...DEFAULT_COLOR_THEME },
fontStrokeEnabled: false,
fontStrokeEnabled: true,
fontStrokeWidth: 1,
fontStrokeColor: '#000000',
alert: { ...DEFAULT_ALERT_CONFIG, maxValues: { ...DEFAULT_ALERT_CONFIG.maxValues } },
@@ -583,11 +583,41 @@ export const useMonitorStore = defineStore('monitor', () => {
return autoStart.value
}
/** 设置"自动启动监控内核"开关 */
/** 关闭"自动启动监控内核"时暂存的 OSD 开关状态(开启自动启动时据此恢复) */
const OSD_PENDING_KEY = STORAGE_KEYS.monitorOsdPending
/** 待恢复的 OSD 开关状态:关闭自动启动时记录、开启时按记录恢复。持久化于 localStorage,跨重启有效。 */
let pendingOsdEnabled: boolean | null = null
try {
pendingOsdEnabled = JSON.parse(localStorage.getItem(OSD_PENDING_KEY) ?? 'null')
} catch { /* 忽略非法缓存,视为无恢复记录 */ }
/**
* 设置"自动启动监控内核"开关。
* 与 OSD 开关联动(OSD 开关本身可自由开关):
* - 开→关:记录当前 OSD 开关状态,再关闭 OSD(OSD 依赖内核随应用启动)
* - 关→开:若记录状态为开,则恢复 OSD 显示
*/
async function setAutoStart(enabled: boolean) {
try {
await invoke('monitor_set_auto_start', { enabled })
autoStart.value = enabled
if (!enabled) {
// 关闭时记录当前 OSD 状态,随后由 overlayEnabled watch 统一隐藏悬浮窗
pendingOsdEnabled = osdConfig.value.overlayEnabled
try { localStorage.setItem(OSD_PENDING_KEY, JSON.stringify(pendingOsdEnabled)) } catch { /* 忽略 */ }
osdConfig.value.overlayEnabled = false
saveOsdConfig(osdConfig.value)
} else {
// 开启时若有记录且曾为开,恢复 OSD 显示
if (pendingOsdEnabled === true) {
osdConfig.value.overlayEnabled = true
saveOsdConfig(osdConfig.value)
}
// 消费记录,避免下次开启再次恢复
pendingOsdEnabled = null
try { localStorage.removeItem(OSD_PENDING_KEY) } catch { /* 忽略 */ }
}
} catch (e) {
errorMsg.value = String(e)
logger.error('设置自动启动开关失败: ' + e)
@@ -758,67 +788,172 @@ export const useMonitorStore = defineStore('monitor', () => {
/** 根据显示项估算悬浮窗窗口尺寸(逻辑像素)
* single: 单行分组式,组间用 | 分隔,固定宽度数据列
* group: 分组横排,标题在上 + 数据列在下
* multiline: 多行,每组一行,标题 + 固定宽度数据列 */
* multiline: 多行,每组一行,标题 + 固定宽度数据列
*
* 宽度严格按 OsdWindow.vue 的渲染结构估算:
* - 标签/箭头里的 CJK 按全角宽度(≈fontSize),ASCII 按等宽(≈0.6em
* - 数值用 fmtFixedValue 的固定 pad 宽度、单位用 fmtFixedUnit 的文本
* - 计入各类 gap(组内 3px、single 组间 4px、group 组间 8px、项内 gap、单位 margin
* - 计入 osd-bar 左右 padding4*2
* 这样创建时的窗口宽度与真实内容一致,避免窗口小于内容而截断,
* 也避免因宽度估算偏差导致 computePositionFromPct 的位置百分比偏移。 */
function computeOsdWindowSize(
_itemCount: number,
layout: 'single' | 'group' | 'multiline',
fontSize: number,
_hasNetItem = false,
items?: OsdItem[],
items: OsdItem[] = [],
): { w: number; h: number } {
const charW = fontSize * 0.62
const charW = fontSize * 0.6 // 等宽 ASCII 字符宽(Cascadia/Consolas ≈0.6em
const cjkW = fontSize // CJK 全角字符宽
const barHPad = 8 // osd-bar 左右 padding 4*2
// 按硬件类型分组(与渲染逻辑一致)
const groupMap = new Map<string, OsdItem[]>()
if (items?.length) {
for (const item of items) {
let gkey: string
if (item.special === 'net-up' || item.special === 'net-down') gkey = 'network'
else if (item.groupId.startsWith('gpu')) gkey = 'gpu'
else gkey = item.groupId
if (!groupMap.has(gkey)) groupMap.set(gkey, [])
groupMap.get(gkey)!.push(item)
const isCjk = (ch: string) => {
const c = ch.codePointAt(0)!
return (
(c >= 0x2e80 && c <= 0x9fff) ||
(c >= 0x3000 && c <= 0x303f) ||
(c >= 0xff00 && c <= 0xffef) ||
(c >= 0xf900 && c <= 0xfaff)
)
}
// 字符串像素宽(CJK 全角,ASCII 等宽)
const textPx = (s: string) => {
let w = 0
for (const ch of s) w += isCjk(ch) ? cjkW : charW
return w
}
const showLabel = osdConfig.value?.showLabel !== false
const en = osdConfig.value?.labelLanguage === 'en'
const groupLabelText = (gkey: string): string => {
if (en) {
switch (gkey) {
case 'cpu': return 'CPU'
case 'gpu': return 'GPU'
case 'memory': return 'RAM'
case 'storage': return 'DISK'
case 'network': return 'NET'
case 'motherboard': return 'MB'
case 'battery': return 'BAT'
case 'psu': return 'PSU'
default: return gkey.toUpperCase().slice(0, 6)
}
}
switch (gkey) {
case 'cpu': return 'CPU'
case 'gpu': return 'GPU'
case 'memory': return '内存'
case 'storage': return '存储'
case 'network': return '网络'
case 'motherboard': return '主板'
case 'battery': return '电池'
case 'psu': return '电源'
default: return gkey
}
}
const groupCount = Math.max(1, groupMap.size)
// 每组数据列宽度:标签(6ch) + 各项(数值+单位+箭头/gap)
const groupWidths: number[] = []
for (const [, groupItems] of groupMap) {
const labelW = 6
const dataW = groupItems.reduce((sum, item) => {
const isNet = item.special === 'net-up' || item.special === 'net-down'
return sum + (isNet ? 11 : 8) + 1
}, 0)
groupWidths.push(labelW + dataW)
// 数值固定宽度(字符数,对应 fmtFixedValue 的 pad 宽度)
const numChars = (it: OsdItem): number => {
if (it.special === 'net-up' || it.special === 'net-down') return 6
switch (it.type) {
case 'load':
case 'level':
case 'temperature': return 3
case 'power':
case 'voltage': return 5
case 'clock':
case 'frequency': return 4
case 'fan': return 4
case 'data':
case 'smalldata': return 5
default: return 5
}
}
// 单位文本(对应 fmtFixedUnit;网速取最宽单位 MB/s 估算)
const showUnit = osdConfig.value?.showUnit !== false
const unitText = (it: OsdItem): string => {
if (it.special === 'net-up' || it.special === 'net-down') return showUnit ? 'MB/s' : ''
if (!showUnit) return ''
switch (it.type) {
case 'temperature': return '°C'
case 'load': return '%'
case 'power': return 'W'
case 'voltage': return 'V'
case 'fan': return 'RPM'
case 'clock':
case 'frequency': return 'MHz'
case 'data':
case 'smalldata': return 'GB'
case 'level': return '%'
default: return it.unit || ''
}
}
// 单一项像素宽:箭头 + 数值 + 单位 + 项内 gap(1px) + 单位 margin(1px)
const itemPx = (it: OsdItem): number => {
let w = it.special ? charW : 0 // 箭头
if (it.special) w += 1 // 箭头与数值 gap
w += numChars(it) * charW
const unit = unitText(it)
if (unit) w += textPx(unit) + 1 + 1 // 数值-单位 gap + 单位 left margin
return w
}
// 一个分组的像素宽:组内各子项 gap(3px) + 标签 + 各项
const groupWidthPx = (gkey: string, list: OsdItem[]): number => {
const labelW = showLabel ? textPx(groupLabelText(gkey)) : 0
const itemsW = list.reduce((s, it) => s + itemPx(it), 0)
const gapCount = list.length + (showLabel ? 1 : 0) - 1
return Math.ceil(labelW + itemsW + Math.max(0, gapCount) * 3)
}
// 构建分组(归一化 key,与渲染逻辑一致)
const groupMap = new Map<string, OsdItem[]>()
for (const it of items) {
let gkey = it.groupId
if (it.special === 'net-up' || it.special === 'net-down') gkey = 'network'
else if (it.groupId.startsWith('gpu')) gkey = 'gpu'
if (!groupMap.has(gkey)) groupMap.set(gkey, [])
groupMap.get(gkey)!.push(it)
}
const groupEntries = [...groupMap.entries()]
const groupCount = Math.max(1, groupEntries.length)
const gw = groupEntries.map(([k, list]) => groupWidthPx(k, list))
const lineH = Math.ceil(fontSize + 2)
if (layout === 'multiline') {
// 多行:取最宽行
const maxLineW = groupWidths.length ? Math.max(...groupWidths) : 10
const w = Math.ceil(maxLineW * charW + barHPad)
const lineH = Math.ceil(fontSize + 2)
const h = Math.ceil(groupCount * lineH + 6)
return { w: Math.max(120, w), h: Math.max(28, h) }
// 每行 = 标签(min 4ch) + 组内 gap(4px) + 各项;取最宽行
const labelMinW = 4 * charW
let maxLineW = 0
for (const [gkey, list] of groupEntries) {
const labelW = showLabel ? Math.max(textPx(groupLabelText(gkey)), labelMinW) : 0
const itemsW = list.reduce((s, it) => s + itemPx(it), 0)
const gapCount = list.length + (showLabel ? 1 : 0) - 1
maxLineW = Math.max(maxLineW, labelW + itemsW + Math.max(0, gapCount) * 4)
}
const w = Math.max(120, Math.ceil(maxLineW + barHPad))
const h = Math.max(28, Math.ceil(groupCount * lineH + 6))
return { w, h }
}
if (layout === 'group') {
// 分组横排:各组横排 + 标题行
const totalW = groupWidths.reduce((s, w) => s + w + 4, 0) + (groupCount - 1) * 4
const w = Math.ceil(totalW * charW + barHPad)
// 分组横排:各组横排,组间 gap(8px),每组含 padding(4*2)
const totalW = gw.reduce((s, w) => s + w, 0)
+ groupCount * 8 // 每组左右 padding 4*2
+ Math.max(0, groupCount - 1) * 8 // 组间 gap
const w = Math.max(120, Math.ceil(totalW + barHPad))
const titleH = Math.ceil(fontSize * 0.85) + 2
const dataH = Math.ceil(fontSize) + 2
const h = Math.ceil(titleH + dataH + 10)
return { w: Math.max(120, w), h: Math.max(40, h) }
const dataH = lineH
const h = Math.max(40, Math.ceil(titleH + dataH + 3 + 3))
return { w, h }
}
// single:单行分组式,各组横排 + 组间 | 分隔符(1ch)
const sepW = (groupCount - 1) * 1
const totalW = groupWidths.reduce((s, w) => s + w, 0) + sepW
const w = Math.ceil(totalW * charW + barHPad)
const h = Math.ceil(fontSize + 8)
return { w: Math.max(120, w), h: Math.max(28, h) }
// single:单行分组式,组间 | 分隔符 + 组间 gap(4px)
const sepW = (groupCount - 1) * (textPx('|') + 4)
const betweenW = Math.max(0, groupCount - 1) * 4
const w = Math.max(120, Math.ceil(gw.reduce((s, w) => s + w, 0) + sepW + betweenW + barHPad))
const h = Math.max(28, Math.ceil(fontSize + 8))
return { w, h }
}
/** 创建/显示悬浮窗窗口(默认置顶 + NoActivate + 点击穿透) */
@@ -1049,13 +1184,10 @@ export const useMonitorStore = defineStore('monitor', () => {
// stop 句柄存入 osdWatchStopsdispose 时统一释放,避免模块重挂载后重复注册
// OSD 开关变化时创建/隐藏悬浮窗
// 注意:开启 OSD 不再自动开启"应用启动时自动启动监控内核"。
// 二者保持独立(双向联动会导致:关自动启动→关 OSD→再开 OSD→自动启动又被强行打开)。
osdWatchStops.push(watch(() => osdConfig.value.overlayEnabled, (enabled) => {
if (enabled) {
// OSD 显示开启时自动开启"应用启动时自动启动监控内核",
// 使 OSD 持续显示不因重启而中断
if (!autoStart.value) {
setAutoStart(true).catch(e => logger.error('[OSD] 自动开启 autoStart 失败: ' + e))
}
// 开启时若显示项为空则不创建窗口
if (osdConfig.value.overlayItems.length === 0) return
ensureOverlayWindow().catch(e => logger.error('[OSD] 创建悬浮窗失败: ' + e))
+72 -1
View File
@@ -12,7 +12,8 @@ import {
type ProfileMeta,
type KernelInfo,
type KernelUpdateInfo,
type ProxyStatus
type ProxyStatus,
type TrafficSnapshot,
} from '@/lib/bindings'
// Rust 端结构体字段均带 serde(default),返回必完整;用 Required 收窄 bindings 的 optional
@@ -53,6 +54,32 @@ export interface ProxiesResponse {
proxies: Record<string, ProxyNode>
}
/** mihomo /connections 单条连接(完整字段以原始 JSON 为准,仅取前端用到的部分) */
export interface ProxyConnection {
id: string
chains?: string[]
rule?: string
rulePayload?: string
upload: number
download: number
start: string
metadata?: {
network?: string
type?: string
process?: string
host?: string
sourceIP?: string
sourcePort?: number
destinationIP?: string
destinationPort?: number
}
}
/** mihomo /connections 响应(原始 JSON,字段可能缺失) */
export interface ConnectionsResponse {
connections?: ProxyConnection[]
}
export interface MihomoVersion {
version: string
meta?: boolean
@@ -65,6 +92,10 @@ export const useProxyStore = defineStore('proxy', () => {
const proxies = ref<Record<string, ProxyNode>>({})
const settings = ref<FullProxySettings | null>(null)
const systemProxy = ref(false)
/** 实时流量快照(上传/下载速率、会话总量、活跃连接数) */
const traffic = ref<TrafficSnapshot | null>(null)
/** 当前活跃连接列表(仅连接页签需要时拉取) */
const connections = ref<ProxyConnection[] | null>(null)
/** 是否已完成首次加载(避免初始 null/false 导致闪烁误导状态) */
const initialized = ref(false)
@@ -209,6 +240,40 @@ export const useProxyStore = defineStore('proxy', () => {
}
}
// ---------- 流量 / 连接 ----------
/** 刷新实时流量快照(速率 + 会话总量 + 活跃连接数)。失败时保留上一次数据,避免抖动。 */
const refreshTraffic = async () => {
try {
traffic.value = await commands.proxyTraffic()
} catch {
/* mihomo 瞬时不可用(如重启)时保留上一次数据 */
}
}
/** 刷新当前活跃连接列表(原始 /connections)。供「连接」页签低频拉取。 */
const refreshConnections = async () => {
try {
const resp = await invoke<ConnectionsResponse>('proxy_get_connections')
connections.value = resp.connections ?? []
} catch {
/* mihomo 瞬时不可用(如重启)时保留上一次数据 */
}
}
/** 关闭指定连接 */
const closeConnection = async (id: string) => {
try {
await commands.proxyCloseConnection(id)
// 本地立即移除,无需等下一轮轮询
if (connections.value) {
connections.value = connections.value.filter((c) => c.id !== id)
}
} catch (e) {
logger.error('关闭连接失败: ' + e)
throw e
}
}
const saveSettings = async (s: FullProxySettings) => {
await commands.proxySaveSettings(s)
settings.value = s
@@ -487,6 +552,8 @@ export const useProxyStore = defineStore('proxy', () => {
proxies,
settings,
systemProxy,
traffic,
connections,
initialized,
installing,
installProgress,
@@ -516,6 +583,10 @@ export const useProxyStore = defineStore('proxy', () => {
setSystemProxy,
clearSystemProxy,
toggleSystemProxy,
// traffic & connections
refreshTraffic,
refreshConnections,
closeConnection,
// kernel update / install
checkKernelUpdate,
updateKernel,