功能完善

UI改进
This commit is contained in:
sansen
2026-07-30 14:30:21 +08:00
parent 7e391d9381
commit f5dce6efe5
18 changed files with 533 additions and 230 deletions
+16 -2
View File
@@ -3,8 +3,11 @@
<Header <Header
:currentView="currentView" :currentView="currentView"
:theme="theme" :theme="theme"
:activeTaskCount="activeTaskCount"
:showTaskDrawer="showTaskDrawer"
@navigate="navigate" @navigate="navigate"
@toggleTheme="toggleTheme" @toggleTheme="toggleTheme"
@toggleTaskDrawer="showTaskDrawer = !showTaskDrawer"
/> />
<div class="main-area"> <div class="main-area">
@@ -24,15 +27,15 @@
</KeepAlive> </KeepAlive>
<LogPage v-show="currentView === 'logs'" ref="logPageRef" /> <LogPage v-show="currentView === 'logs'" ref="logPageRef" />
</main> </main>
<TaskDrawer :tasks="tasks" @cancel="handleCancel" @remove="handleRemove" />
</div> </div>
<TaskDrawer :tasks="tasks" :show="showTaskDrawer" @cancel="handleCancel" @remove="handleRemove" @close="showTaskDrawer = false" />
</div> </div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import Header from './components/Header.vue' import Header from './components/Header.vue'
import Sidebar from './components/Sidebar.vue' import Sidebar from './components/Sidebar.vue'
import TaskDrawer from './components/TaskDrawer.vue' import TaskDrawer from './components/TaskDrawer.vue'
@@ -58,12 +61,23 @@ async function restoreTheme() {
document.documentElement.setAttribute('data-theme', theme.value) document.documentElement.setAttribute('data-theme', theme.value)
} }
const tasks = ref<Task[]>([]) const tasks = ref<Task[]>([])
const showTaskDrawer = ref(false)
const activeTaskCount = computed(() => tasks.value.filter(t => t.status === 'running' || t.status === 'pending').length)
const gpuInfo = ref<any[]>([]) const gpuInfo = ref<any[]>([])
const logPageRef = ref<InstanceType<typeof LogPage> | null>(null) const logPageRef = ref<InstanceType<typeof LogPage> | null>(null)
onMounted(() => { onMounted(() => {
// Suppress browser-native behaviours in the desktop webview
document.addEventListener('contextmenu', e => e.preventDefault()) document.addEventListener('contextmenu', e => e.preventDefault())
document.addEventListener('wheel', e => { if (e.ctrlKey) e.preventDefault() }, { passive: false })
document.addEventListener('dragover', e => e.preventDefault())
document.addEventListener('drop', e => e.preventDefault())
document.addEventListener('dragstart', e => e.preventDefault())
document.addEventListener('keydown', (e: KeyboardEvent) => {
// Block refresh: F5 / Ctrl+R / Ctrl+Shift+R
if (e.key === 'F5' || ((e.ctrlKey || e.metaKey) && e.key === 'r')) e.preventDefault()
})
restoreTheme() restoreTheme()
loadTasks() loadTasks()
loadGPUInfo() loadGPUInfo()
+1
View File
@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1785380202265" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="14723" xmlns:xlink="http://www.w3.org/1999/xlink"><path fill="currentColor" d="M509.184 84.992a427.008 427.008 0 0 1 85.888 8.704 38.4 38.4 0 0 1 1.92 74.496 207.104 207.104 0 1 0 252.864 252.864 38.4 38.4 0 0 1 74.752 1.92 424.256 424.256 0 1 1-415.424-337.984z m140.288 567.808a283.968 283.968 0 0 1-199.68-485.952 347.392 347.392 0 1 0 401.6 401.6 283.904 283.904 0 0 1-201.92 84.352z" p-id="14724"></path></svg>

After

Width:  |  Height:  |  Size: 665 B

+1
View File
@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1785380229847" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="16726" xmlns:xlink="http://www.w3.org/1999/xlink"><path fill="currentColor" d="M533.333333 853.333333a21.333333 21.333333 0 0 1 21.333334 21.333334v106.666666a21.333333 21.333333 0 0 1-21.333334 21.333334h-42.666666a21.333333 21.333333 0 0 1-21.333334-21.333334v-106.666666a21.333333 21.333333 0 0 1 21.333334-21.333334h42.666666z m-282.282666-100.949333l30.272 30.08a21.333333 21.333333 0 0 1 0.085333 30.165333L207.637333 886.826667a21.333333 21.333333 0 0 1-30.165333 0.085333l-30.250667-30.08a21.333333 21.333333 0 0 1-0.085333-30.165333l73.749333-74.176a21.333333 21.333333 0 0 1 30.165334-0.085334z m564.714666-8.682667l82.816 82.816a21.333333 21.333333 0 0 1 0 30.165334l-30.165333 30.165333a21.333333 21.333333 0 0 1-30.165333 0L755.413333 804.053333a21.333333 21.333333 0 0 1 0-30.165333l30.165334-30.165333a21.333333 21.333333 0 0 1 30.165333 0zM512.426667 256c153.173333 0 277.333333 124.16 277.333333 277.333333s-124.16 277.333333-277.333333 277.333334-277.333333-124.16-277.333334-277.333334 124.16-277.333333 277.333334-277.333333z m0 85.333333a192 192 0 1 0 0 384 192 192 0 0 0 0-384zM192 490.666667a21.333333 21.333333 0 0 1 21.333333 21.333333v42.666667a21.333333 21.333333 0 0 1-21.333333 21.333333H64a21.333333 21.333333 0 0 1-21.333333-21.333333v-42.666667a21.333333 21.333333 0 0 1 21.333333-21.333333h128z m768-21.333334a21.333333 21.333333 0 0 1 21.333333 21.333334v42.666666a21.333333 21.333333 0 0 1-21.333333 21.333334h-128a21.333333 21.333333 0 0 1-21.333333-21.333334v-42.666666a21.333333 21.333333 0 0 1 21.333333-21.333334h128zM846.08 160.704l30.250667 30.08a21.333333 21.333333 0 0 1 0.085333 30.165333l-82.56 83.050667a21.333333 21.333333 0 0 1-30.165333 0.085333l-30.250667-30.08a21.333333 21.333333 0 0 1-0.106667-30.186666l82.56-83.029334a21.333333 21.333333 0 0 1 30.186667-0.085333z m-616.746667 0.042667l73.962667 73.962666a21.333333 21.333333 0 0 1 0 30.165334l-30.165333 30.165333a21.333333 21.333333 0 0 1-30.165334 0l-73.962666-73.962667a21.333333 21.333333 0 0 1 0-30.165333l30.165333-30.165333a21.333333 21.333333 0 0 1 30.165333 0zM533.333333 42.666667a21.333333 21.333333 0 0 1 21.333334 21.333333v128a21.333333 21.333333 0 0 1-21.333334 21.333333h-42.666666a21.333333 21.333333 0 0 1-21.333334-21.333333V64a21.333333 21.333333 0 0 1 21.333334-21.333333h42.666666z" p-id="16727"></path></svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

+1
View File
@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1785378916571" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="2496" xmlns:xlink="http://www.w3.org/1999/xlink"><path d="M812.3 959.4H213.7c-81.6 0-148-66.4-148-148V212.9c0-81.6 66.4-148 148-148h598.5c81.6 0 148 66.4 148 148v598.5C960.3 893 893.9 959.4 812.3 959.4zM213.7 120.9c-50.7 0-92 41.3-92 92v598.5c0 50.7 41.3 92 92 92h598.5c50.7 0 92-41.3 92-92V212.9c0-50.7-41.3-92-92-92H213.7z" fill="currentColor" p-id="2497"></path></svg>

After

Width:  |  Height:  |  Size: 621 B

+1
View File
@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1785378927584" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="2651" xmlns:xlink="http://www.w3.org/1999/xlink"><path d="M929.8 528.1H93.5c-15.5 0-28-12.5-28-28s12.5-28 28-28h836.3c15.5 0 28 12.5 28 28s-12.5 28-28 28z" fill="currentColor" p-id="2652"></path></svg>

After

Width:  |  Height:  |  Size: 451 B

+1
View File
@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1785379007596" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="3117" xmlns:xlink="http://www.w3.org/1999/xlink"><path d="M812.2 65H351.6c-78.3 0-142.5 61.1-147.7 138.1-77 5.1-138.1 69.4-138.1 147.7v460.6c0 81.6 66.4 148 148 148h460.6c78.3 0 142.5-61.1 147.7-138.1 77-5.1 138.1-69.4 138.1-147.7V213c0-81.6-66.4-148-148-148z m-45.8 746.3c0 50.7-41.3 92-92 92H213.8c-50.7 0-92-41.3-92-92V350.7c0-50.7 41.3-92 92-92h460.6c50.7 0 92 41.3 92 92v460.6z m137.8-137.7c0 47.3-35.8 86.3-81.8 91.4V350.7c0-81.6-66.4-148-148-148H260.2c5.1-45.9 44.2-81.8 91.4-81.8h460.6c50.7 0 92 41.3 92 92v460.7z" fill="currentColor" p-id="3118"></path></svg>

After

Width:  |  Height:  |  Size: 818 B

+1
View File
@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1785378895955" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="2340" xmlns:xlink="http://www.w3.org/1999/xlink"><path d="M512.7 664.3c-82.9 0-150.4-67.4-150.4-150.4 0-82.9 67.4-150.4 150.4-150.4 82.9 0 150.4 67.4 150.4 150.4-0.1 83-67.5 150.4-150.4 150.4z m0-244.7c-52 0-94.4 42.3-94.4 94.4 0 52 42.3 94.4 94.4 94.4S607 566 607 514c0-52-42.3-94.4-94.3-94.4z" fill="currentColor" p-id="2341"></path><path d="M631.2 940.5c-15.2 0-30.1-6-41.2-17.3l-63.5-64.8c-4.1-4.2-9.5-6.5-15.4-6.5-5.8 0-11.3 2.3-15.3 6.4l-63.5 64.4c-17.4 17.6-44 22.2-66.2 11.4l-94.5-45.7c-22.2-10.8-35.2-34.5-32.2-59l11-90.1c0.7-5.8-0.9-11.5-4.5-16-3.6-4.6-8.8-7.4-14.6-8l-89.9-9.5c-24.6-2.6-44.8-20.5-50.2-44.6L67.7 558.8c-5.5-24.1 5-49 26-62l77.3-47.6c5-3.1 8.4-7.9 9.7-13.5 1.3-5.7 0.3-11.5-2.8-16.4L129.2 343c-13.3-20.8-11.9-47.8 3.5-67.1l65.5-82c15.4-19.3 41.4-26.7 64.7-18.3l85.4 30.7c5.5 2 11.4 1.7 16.6-0.9 5.2-2.5 9.2-7 11.1-12.5l29.2-85.6c8-23.4 29.9-39.1 54.6-39.1h105c24.7 0 46.7 15.7 54.6 39.1l29.6 86.8c1.9 5.5 5.8 9.9 11 12.5s11.1 2.8 16.6 0.9l86.1-30.6c23.3-8.3 49.2-0.8 64.6 18.5l65.2 82.3c15.3 19.4 16.7 46.3 3.3 67.1l-49.1 76.3c-3.2 4.9-4.2 10.7-2.9 16.4 1.3 5.7 4.7 10.5 9.7 13.6l76.8 47.7c21 13 31.4 38 25.8 62l-23.6 102.3a57.67 57.67 0 0 1-50.4 44.4l-90.3 9.2c-5.8 0.6-11 3.4-14.6 8-3.6 4.5-5.3 10.2-4.6 16l10.7 89.8c2.9 24.5-10.1 48.2-32.4 58.9l-94.7 45.4c-8.1 3.9-16.6 5.7-25 5.7zM511 795.9h0.1c21 0 40.6 8.3 55.3 23.3l63.5 64.8c0.5 0.5 1.3 0.7 2 0.4l94.7-45.4c0.7-0.3 1.1-1 1-1.8l-10.7-89.8c-2.5-20.8 3.4-41.3 16.5-57.6s31.8-26.5 52.7-28.7l90.3-9.2c0.7-0.1 1.3-0.6 1.5-1.3l23.6-102.3c0.2-0.7-0.1-1.5-0.8-1.9l-76.8-47.7c-17.8-11.1-30.2-28.4-34.8-48.8-4.6-20.4-0.9-41.4 10.5-59l49.1-76.3c0.4-0.6 0.4-1.4-0.1-2l-65.2-82.3c-0.5-0.6-1.2-0.8-1.9-0.6l-86.1 30.6c-19.7 7-40.9 5.9-59.7-3.2-18.8-9.1-32.9-25-39.7-44.8l-29.6-86.8c-0.2-0.7-0.9-1.2-1.6-1.2h-105c-0.7 0-1.4 0.5-1.6 1.2L429 211c-6.8 19.8-20.9 35.8-39.8 44.9-18.9 9.1-40.1 10.2-59.9 3.1l-85.4-30.7c-0.7-0.2-1.5 0-1.9 0.5l-65.5 82c-0.5 0.6-0.5 1.4-0.1 2l48.7 76.2c11.3 17.7 14.9 38.6 10.2 59.1-4.7 20.4-17.1 37.7-34.9 48.7l-77.3 47.6c-0.6 0.4-0.9 1.1-0.8 1.9l23.3 102.4c0.2 0.7 0.8 1.3 1.5 1.3l89.9 9.5c20.8 2.2 39.5 12.4 52.6 28.8 13 16.4 18.8 36.9 16.3 57.7l-11 90.1c-0.1 0.7 0.3 1.4 1 1.8l94.5 45.7c0.7 0.3 1.5 0.2 2-0.3l63.5-64.4c14.6-14.8 34.2-23 55.1-23z" fill="currentColor" p-id="2342"></path></svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

+1
View File
@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1785378934771" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="2806" xmlns:xlink="http://www.w3.org/1999/xlink"><path d="M96.6 915c-10.9-10.9-10.9-28.7 0-39.6L887 85c10.9-10.9 28.7-10.9 39.6 0 10.9 10.9 10.9 28.7 0 39.6L136.2 915c-10.9 10.9-28.6 10.9-39.6 0z" fill="currentColor" p-id="2807"></path><path d="M887 915L96.6 124.6c-10.9-10.9-10.9-28.7 0-39.6 10.9-10.9 28.7-10.9 39.6 0l790.4 790.4c10.9 10.9 10.9 28.7 0 39.6-10.9 10.9-28.6 10.9-39.6 0z" fill="currentColor" p-id="2808"></path></svg>

After

Width:  |  Height:  |  Size: 683 B

+1
View File
@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1785380061683" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="9491" xmlns:xlink="http://www.w3.org/1999/xlink"><path fill="currentColor" d="M832 64H192c-70.4 0-128 57.6-128 128v640c0 70.4 57.6 128 128 128h640c70.4 0 128-57.6 128-128V192c0-70.4-57.6-128-128-128z m64 768c0 38.4-25.6 64-64 64H192c-38.4 0-64-25.6-64-64V192c0-38.4 25.6-64 64-64h640c38.4 0 64 25.6 64 64v640z" p-id="9492"></path><path fill="currentColor" d="M332.8 537.6c-51.2 0-96 44.8-96 96s44.8 96 96 96 96-44.8 96-96S384 537.6 332.8 537.6z m0 128c-19.2 0-32-12.8-32-32s12.8-32 32-32 32 12.8 32 32-12.8 32-32 32zM403.2 281.6L307.2 377.6l-44.8-38.4c-12.8-12.8-32-12.8-44.8 0s-12.8 32 0 44.8l64 64c6.4 6.4 12.8 6.4 25.6 6.4s12.8 0 19.2-6.4L448 326.4c12.8-12.8 12.8-32 0-44.8s-32-12.8-44.8 0zM787.2 332.8h-256c-19.2 0-32 12.8-32 32s12.8 32 32 32h256c19.2 0 32-12.8 32-32s-12.8-32-32-32zM787.2 601.6h-256c-19.2 0-32 12.8-32 32s12.8 32 32 32h256c19.2 0 32-12.8 32-32s-12.8-32-32-32z" p-id="9493"></path></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

+1 -1
View File
@@ -1 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1785291728333" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="1480" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M861.257143 54.857143c27.428571 0 51.2 21.942857 53.028571 49.371428v446.171429h-69.485714V124.342857H142.628571v791.771429h316.342858V987.428571H126.171429c-27.428571 0-51.2-21.942857-53.028572-49.371428V107.885714C73.142857 78.628571 95.085714 56.685714 122.514286 54.857143H861.257143zM592.457143 499.2c3.657143 0 7.314286 1.828571 9.142857 3.657143L950.857143 725.942857c9.142857 5.485714 10.971429 16.457143 5.485714 25.6l-5.485714 5.485714-347.428572 221.257143c-9.142857 5.485714-20.114286 3.657143-25.6-5.485714-1.828571-3.657143-3.657143-5.485714-3.657142-9.142857V517.485714c0-10.971429 9.142857-18.285714 18.285714-18.285714z m54.857143 117.028571v245.028572L841.142857 738.742857l-193.828571-122.514286zM457.142857 548.571429c10.971429 0 18.285714 7.314286 18.285714 18.285714v36.571428c0 10.971429-7.314286 18.285714-18.285714 18.285715H274.285714c-10.971429 0-18.285714-7.314286-18.285714-18.285715v-36.571428c0-10.971429 7.314286-18.285714 18.285714-18.285714h182.857143z m292.571429-256c10.971429 0 18.285714 7.314286 18.285714 18.285714v36.571428c0 10.971429-7.314286 18.285714-18.285714 18.285715H274.285714c-10.971429 0-18.285714-7.314286-18.285714-18.285715v-36.571428c0-10.971429 7.314286-18.285714 18.285714-18.285714h475.428572z" fill="#1296db" p-id="1481"></path></svg> <?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1785291728333" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="1480" xmlns:xlink="http://www.w3.org/1999/xlink"><path d="M861.257143 54.857143c27.428571 0 51.2 21.942857 53.028571 49.371428v446.171429h-69.485714V124.342857H142.628571v791.771429h316.342858V987.428571H126.171429c-27.428571 0-51.2-21.942857-53.028572-49.371428V107.885714C73.142857 78.628571 95.085714 56.685714 122.514286 54.857143H861.257143zM592.457143 499.2c3.657143 0 7.314286 1.828571 9.142857 3.657143L950.857143 725.942857c9.142857 5.485714 10.971429 16.457143 5.485714 25.6l-5.485714 5.485714-347.428572 221.257143c-9.142857 5.485714-20.114286 3.657143-25.6-5.485714-1.828571-3.657143-3.657143-5.485714-3.657142-9.142857V517.485714c0-10.971429 9.142857-18.285714 18.285714-18.285714z m54.857143 117.028571v245.028572L841.142857 738.742857l-193.828571-122.514286zM457.142857 548.571429c10.971429 0 18.285714 7.314286 18.285714 18.285714v36.571428c0 10.971429-7.314286 18.285714-18.285714 18.285715H274.285714c-10.971429 0-18.285714-7.314286-18.285714-18.285715v-36.571428c0-10.971429 7.314286-18.285714 18.285714-18.285714h182.857143z m292.571429-256c10.971429 0 18.285714 7.314286 18.285714 18.285714v36.571428c0 10.971429-7.314286 18.285714-18.285714 18.285715H274.285714c-10.971429 0-18.285714-7.314286-18.285714-18.285715v-36.571428c0-10.971429 7.314286-18.285714 18.285714-18.285714h475.428572z" fill="#1296db" p-id="1481"></path></svg>

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

+72 -21
View File
@@ -1,48 +1,49 @@
<template> <template>
<header class="app-header" @dblclick="toggleMaximize"> <header class="app-header" @dblclick="toggleMaximize">
<div class="header-left" style="--wails-draggable: drag"> <div class="header-left" style="--wails-draggable: drag">
<img :src="logoSrc" class="app-logo" width="22" height="22" alt="" /> <span class="hdr-icon app-logo" v-html="logoSrc"></span>
<span class="app-name">FFmpeg GUI</span> <span class="app-name">FFmpeg GUI</span>
</div> </div>
<div class="header-center" style="--wails-draggable: drag"></div> <div class="header-center" style="--wails-draggable: drag"></div>
<div class="header-right" style="--wails-draggable: no-drag"> <div class="header-right" style="--wails-draggable: no-drag">
<button
class="btn-ghost header-btn task-btn"
:class="{ active: showTaskDrawer }"
title="任务列表"
@click="$emit('toggleTaskDrawer')"
>
<span class="hdr-icon" v-html="taskListIcon"></span>
<span v-if="activeTaskCount" class="task-badge">{{ activeTaskCount }}</span>
</button>
<button <button
class="btn-ghost header-btn" class="btn-ghost header-btn"
:class="{ active: currentView === 'settings' }" :class="{ active: currentView === 'settings' }"
title="设置" title="设置"
@click="$emit('navigate', 'settings')" @click="$emit('navigate', 'settings')"
> >
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <span class="hdr-icon" v-html="settingsIcon"></span>
<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>
</svg>
</button> </button>
<button <button
class="btn-ghost header-btn" class="btn-ghost header-btn"
:title="theme === 'dark' ? '亮色模式' : '暗色模式'" :title="theme === 'dark' ? '亮色模式' : '暗色模式'"
@click="$emit('toggleTheme')" @click="$emit('toggleTheme')"
> >
<svg v-if="theme === 'dark'" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"> <span v-if="theme === 'dark'" class="hdr-icon" v-html="lightModeIcon"></span>
<circle cx="12" cy="12" r="5"/><path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/> <span v-else class="hdr-icon" v-html="darkModeIcon"></span>
</svg>
<svg v-else width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
</svg>
</button> </button>
<div class="win-controls"> <div class="win-controls">
<button class="win-btn" title="最小化" @click="minimize"> <button class="win-btn" title="最小化" @click="minimize">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="5" y1="12" x2="19" y2="12"/></svg> <span class="hdr-icon" v-html="minimizeIcon"></span>
</button> </button>
<button class="win-btn" :title="maximised ? '还原' : '最大化'" @click="toggleMaximize"> <button class="win-btn" :title="maximised ? '还原' : '最大化'" @click="toggleMaximize">
<svg v-if="maximised" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <span v-if="maximised" class="hdr-icon" v-html="restoreIcon"></span>
<rect x="8" y="4" width="12" height="12" rx="1"/><path d="M5 16V7a1 1 0 0 1 1-1h8"/> <span v-else class="hdr-icon" v-html="maximizeIcon"></span>
</svg>
<svg v-else width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="4" y="4" width="16" height="16" rx="2"/></svg>
</button> </button>
<button class="win-btn win-btn-close" title="关闭" @click="closeWindow"> <button class="win-btn win-btn-close" title="关闭" @click="closeWindow">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg> <span class="hdr-icon" v-html="closeIcon"></span>
</button> </button>
</div> </div>
</div> </div>
@@ -50,22 +51,40 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from 'vue' import { ref, onMounted, onUnmounted } from 'vue'
import logoSrc from '../assets/icon/app-logo.svg' import logoSrc from '../assets/icon/app-logo.svg?raw'
import settingsIcon from '../assets/icon/Settings.svg?raw'
import taskListIcon from '../assets/icon/TaskList.svg?raw'
import minimizeIcon from '../assets/icon/Minimize.svg?raw'
import maximizeIcon from '../assets/icon/Maximize.svg?raw'
import restoreIcon from '../assets/icon/Normalmize.svg?raw'
import closeIcon from '../assets/icon/Shut down.svg?raw'
import darkModeIcon from '../assets/icon/DarkMode.svg?raw'
import lightModeIcon from '../assets/icon/LightMode.svg?raw'
defineProps<{ currentView: string; theme: string }>() defineProps<{ currentView: string; theme: string; activeTaskCount: number; showTaskDrawer: boolean }>()
defineEmits<{ defineEmits<{
navigate: [view: string] navigate: [view: string]
toggleTheme: [] toggleTheme: []
toggleTaskDrawer: []
}>() }>()
const maximised = ref(false) const maximised = ref(false)
onMounted(async () => { async function syncMaximisedState() {
try { try {
const app = (window as any).go?.main?.App const app = (window as any).go?.main?.App
if (app?.IsMaximised) maximised.value = await app.IsMaximised() if (app?.IsMaximised) maximised.value = await app.IsMaximised()
} catch {} } catch {}
}
onMounted(() => {
syncMaximisedState()
window.addEventListener('resize', syncMaximisedState)
})
onUnmounted(() => {
window.removeEventListener('resize', syncMaximisedState)
}) })
function minimize() { function minimize() {
@@ -108,7 +127,7 @@ function closeWindow() {
height: 100%; height: 100%;
} }
.app-logo { flex-shrink: 0; } .app-logo { width: 22px; height: 22px; flex-shrink: 0; }
.app-name { .app-name {
font-size: 15px; font-size: 15px;
font-weight: 600; font-weight: 600;
@@ -123,6 +142,20 @@ function closeWindow() {
padding-right: 2px; padding-right: 2px;
} }
/* Icon — inline SVG injected via v-html inherits button text color */
.hdr-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
}
.hdr-icon :deep(svg) {
width: 100%;
height: 100%;
display: block;
}
.header-btn { .header-btn {
color: var(--header-text-dim) !important; color: var(--header-text-dim) !important;
width: 32px; width: 32px;
@@ -136,6 +169,24 @@ function closeWindow() {
.header-btn:hover { color: var(--header-text) !important; background: var(--bg-hover) !important; } .header-btn:hover { color: var(--header-text) !important; background: var(--bg-hover) !important; }
.header-btn.active { color: var(--accent) !important; background: var(--accent-light) !important; } .header-btn.active { color: var(--accent) !important; background: var(--accent-light) !important; }
.task-btn { position: relative; }
.task-badge {
position: absolute;
top: -2px;
right: -2px;
min-width: 16px;
height: 16px;
padding: 0 4px;
font-size: 10px;
font-weight: 700;
line-height: 16px;
text-align: center;
color: #fff;
background: var(--danger, #e81123);
border-radius: 8px;
pointer-events: none;
}
/* Window controls */ /* Window controls */
.win-controls { .win-controls {
display: flex; display: flex;
+188 -162
View File
@@ -1,145 +1,149 @@
<template> <template>
<div class="task-drawer" :class="{ expanded }"> <Transition name="slide-right">
<!-- Collapsed bar --> <div v-if="show" class="task-overlay" @click.self="$emit('close')">
<div class="drawer-bar" @click="expanded = !expanded"> <div class="task-panel">
<div class="bar-left"> <!-- Title bar -->
<span class="bar-label">任务进度</span> <div class="panel-header">
<span v-if="runningTasks.length" class="bar-status running"> <span class="panel-title">任务列表</span>
{{ runningTasks.length }} 个运行中 速度{{ fmtFps(runningTasks[0].progress.fps) }} FPS <button class="btn-ghost panel-close" title="关闭" @click="$emit('close')">
</span> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
<span v-else class="bar-status idle">空闲</span> </button>
</div>
<div class="bar-right">
<span class="bar-count" v-if="taskCount">{{ taskCount }}</span>
<svg
class="bar-arrow"
:class="{ flipped: expanded }"
width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
>
<polyline points="18 15 12 9 6 15"/>
</svg>
</div>
</div>
<!-- Expanded panel -->
<div class="drawer-body" v-show="expanded">
<!-- Running tasks -->
<div v-for="t in runningTasks" :key="t.id" class="current-task">
<div class="current-task-header">
<span class="task-filename">{{ basename(t.inputFile) }}</span>
<div class="header-badges">
<span class="task-type-tag">{{ typeLabel(t) }}</span>
<span class="task-codec">{{ codecLabel(t) }}</span>
</div>
</div>
<div class="progress-section">
<div class="progress-bar">
<div class="progress-fill" :style="{ width: t.progress.percent + '%' }"></div>
</div>
<div class="progress-pct">{{ fmtPct(t.progress.percent) }}%</div>
</div>
<div class="task-stats">
<div class="stat">
<span class="stat-label">速度</span>
<span class="stat-value">{{ fmtFps(t.progress.fps) }} FPS</span>
</div>
<div class="stat">
<span class="stat-label">剩余时间</span>
<span class="stat-value">{{ t.progress.eta || speedLabel(t.progress.speed) || '—' }}</span>
</div>
<div class="stat">
<span class="stat-label">码率</span>
<span class="stat-value">{{ t.progress.bitrate || '—' }}</span>
</div>
</div>
<div class="current-task-actions">
<button class="btn-danger btn-sm" @click="$emit('cancel', t.id)">停止</button>
</div>
</div> </div>
<!-- Queued tasks --> <!-- Body -->
<div v-if="queuedTasks.length" class="queued-section"> <div class="panel-body">
<div class="queued-header">排队中 ({{ queuedTasks.length }})</div> <!-- Running tasks -->
<div v-for="t in queuedTasks" :key="t.id" class="queued-item"> <div v-for="t in runningTasks" :key="t.id" class="current-task">
<span class="queued-name">{{ basename(t.inputFile) }}</span> <div class="current-task-header">
<span class="queued-type">{{ typeLabel(t) }}</span> <span class="task-filename">{{ basename(t.inputFile) }}</span>
<button class="btn-ghost btn-sm" @click="$emit('cancel', t.id)" title="移除"> <div class="header-badges">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg> <span class="task-type-tag">{{ typeLabel(t) }}</span>
</button>
</div>
</div>
<!-- Done tasks -->
<div v-if="doneTasks.length" class="done-section">
<div class="queued-header">已完成 ({{ doneTasks.length }})</div>
<template v-for="t in doneTasks.slice(-10)" :key="t.id">
<div class="queued-item done" @click="toggleDoneDetail(t.id)">
<span class="queued-name">{{ basename(t.inputFile) }}</span>
<span class="queued-type">{{ typeLabel(t) }}</span>
<span :class="['done-badge', t.status]">{{ statusLabel(t) }}</span>
<svg
class="expand-arrow"
:class="{ rotated: expandedDone === t.id }"
width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
>
<polyline points="6 9 12 15 18 9"/>
</svg>
</div>
<div v-if="expandedDone === t.id" class="current-task done-detail">
<div class="current-task-header">
<span class="task-filename">{{ basename(t.inputFile) }}</span>
<span class="task-codec">{{ codecLabel(t) }}</span> <span class="task-codec">{{ codecLabel(t) }}</span>
</div> </div>
<div class="task-stats"> </div>
<div class="stat">
<span class="stat-label">输出</span> <div class="progress-section">
<span class="stat-value output-name">{{ basename(t.outputFile) }}</span> <div class="progress-bar">
</div> <div class="progress-fill" :style="{ width: t.progress.percent + '%' }"></div>
<div class="stat">
<span class="stat-label">类型</span>
<span class="stat-value">{{ typeLabel(t) }}</span>
</div>
<div class="stat">
<span class="stat-label">状态</span>
<span :class="['stat-value', 'status-' + t.status]">{{ statusLabel(t) }}</span>
</div>
</div> </div>
<div v-if="t.error" class="task-error"> <div class="progress-pct">{{ fmtPct(t.progress.percent) }}%</div>
<span class="error-label">错误信息</span> </div>
<pre class="error-msg">{{ t.error }}</pre>
<div class="task-stats">
<div class="stat">
<span class="stat-label">速度</span>
<span class="stat-value">{{ fmtFps(t.progress.fps) }} FPS</span>
</div> </div>
<div v-if="t.args?.length" class="task-args"> <div class="stat">
<span class="error-label">命令行</span> <span class="stat-label">剩余时间</span>
<pre class="error-msg">ffmpeg {{ t.args.join(' ') }}</pre> <span class="stat-value">{{ t.progress.eta || speedLabel(t.progress.speed) || '' }}</span>
</div> </div>
<div class="current-task-actions"> <div class="stat">
<button class="btn-ghost btn-sm" style="color: var(--text-dim)" @click.stop="$emit('remove', t.id)">移除记录</button> <span class="stat-label">码率</span>
</div> <span class="stat-value">{{ t.progress.bitrate || '—' }}</span>
<div class="task-time">
<span>{{ fmtTime(t.createdAt) }}</span>
<span v-if="t.completedAt"> {{ fmtTime(t.completedAt) }}</span>
</div> </div>
</div> </div>
</template>
<div class="current-task-actions">
<button class="btn-danger btn-sm" @click="$emit('cancel', t.id)">停止</button>
</div>
</div>
<!-- Queued tasks -->
<div v-if="queuedTasks.length" class="queued-section">
<div class="queued-header">排队中 ({{ queuedTasks.length }})</div>
<div v-for="t in queuedTasks" :key="t.id" class="queued-item">
<span class="queued-name">{{ basename(t.inputFile) }}</span>
<span class="queued-type">{{ typeLabel(t) }}</span>
<button class="btn-ghost btn-sm" @click="$emit('cancel', t.id)" title="移除">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
</div>
<!-- Done tasks -->
<div v-if="doneTasks.length" class="done-section">
<div class="queued-header">已完成 ({{ doneTasks.length }})</div>
<template v-for="t in doneTasks.slice(-20)" :key="t.id">
<div class="queued-item done" @click="toggleDoneDetail(t.id)">
<span class="queued-name">{{ basename(t.inputFile) }}</span>
<span class="queued-type">{{ typeLabel(t) }}</span>
<span :class="['done-badge', t.status]">{{ statusLabel(t) }}</span>
<svg
class="expand-arrow"
:class="{ rotated: expandedDone === t.id }"
width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
>
<polyline points="6 9 12 15 18 9"/>
</svg>
</div>
<div v-if="expandedDone === t.id" class="current-task done-detail">
<div class="current-task-header">
<span class="task-filename">{{ basename(t.inputFile) }}</span>
<span class="task-codec">{{ codecLabel(t) }}</span>
</div>
<div class="task-stats">
<div class="stat">
<span class="stat-label">输出</span>
<span class="stat-value output-name">{{ basename(t.outputFile) }}</span>
</div>
<div class="stat">
<span class="stat-label">类型</span>
<span class="stat-value">{{ typeLabel(t) }}</span>
</div>
<div class="stat">
<span class="stat-label">状态</span>
<span :class="['stat-value', 'status-' + t.status]">{{ statusLabel(t) }}</span>
</div>
</div>
<div v-if="t.error" class="task-error">
<span class="error-label">错误信息</span>
<pre class="error-msg">{{ t.error }}</pre>
</div>
<div v-if="t.args?.length" class="task-args">
<span class="error-label">命令行</span>
<pre class="error-msg">ffmpeg {{ t.args.join(' ') }}</pre>
</div>
<div class="current-task-actions">
<button class="btn-ghost btn-sm" style="color: var(--text-dim)" @click.stop="$emit('remove', t.id)">移除记录</button>
</div>
<div class="task-time">
<span>{{ fmtTime(t.createdAt) }}</span>
<span v-if="t.completedAt"> {{ fmtTime(t.completedAt) }}</span>
</div>
</div>
</template>
</div>
<div v-if="!taskCount" class="empty-hint">
暂无任务
</div>
</div> </div>
</div> </div>
</div> </div>
</Transition>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed } from 'vue' import { ref, computed, watch } from 'vue'
import type { Task } from '../types' import type { Task } from '../types'
const props = defineProps<{ tasks: Task[] }>() const props = defineProps<{ tasks: Task[]; show: boolean }>()
defineEmits<{ const emit = defineEmits<{
cancel: [id: string] cancel: [id: string]
remove: [id: string] remove: [id: string]
close: []
}>() }>()
const expanded = ref(false) watch(() => props.show, (val) => {
if (val) {
const onEsc = (e: KeyboardEvent) => {
if (e.key === 'Escape') { emit('close'); document.removeEventListener('keydown', onEsc) }
}
document.addEventListener('keydown', onEsc)
}
})
const expandedDone = ref<string | null>(null) const expandedDone = ref<string | null>(null)
function toggleDoneDetail(id: string) { function toggleDoneDetail(id: string) {
@@ -204,59 +208,80 @@ function fmtTime(iso?: string): string {
</script> </script>
<style scoped> <style scoped>
.task-drawer { /* Overlay — catches clicks outside the panel */
flex-shrink: 0; .task-overlay {
border-top: 1px solid var(--border); position: fixed;
background: var(--bg-card); inset: 0;
transition: height 0.25s ease; z-index: 20;
height: var(--drawer-collapsed);
}
.task-drawer.expanded {
height: var(--drawer-expanded);
} }
/* Collapsed bar */ /* Panel */
.drawer-bar { .task-panel {
height: var(--drawer-collapsed); position: fixed;
top: var(--header-height);
right: 0;
bottom: 0;
width: 380px;
max-width: calc(100vw - 200px);
background: var(--bg-card);
border-left: 1px solid var(--border);
display: flex;
flex-direction: column;
box-shadow: -4px 0 20px rgba(0, 0, 0, 0.1);
}
/* Title bar */
.panel-header {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
padding: 0 20px; padding: 0 16px;
cursor: pointer; height: 44px;
user-select: none; flex-shrink: 0;
border-bottom: 1px solid var(--border);
} }
.drawer-bar:hover { background: var(--bg-hover); } .panel-title { font-size: 14px; font-weight: 600; color: var(--text-primary); }
.panel-close {
.bar-left { display: flex; align-items: center; gap: 16px; } width: 28px; height: 28px; padding: 0;
.bar-label { font-size: 13px; font-weight: 600; color: var(--text-primary); } display: flex; align-items: center; justify-content: center;
.bar-status { font-size: 12px; color: var(--text-dim); } border-radius: 6px;
.bar-status.running { color: var(--accent); font-weight: 500; } color: var(--text-dim);
.bar-right { display: flex; align-items: center; gap: 8px; }
.bar-count {
font-size: 11px;
font-weight: 600;
background: var(--accent);
color: white;
min-width: 20px;
height: 18px;
border-radius: 9px;
display: flex;
align-items: center;
justify-content: center;
padding: 0 6px;
} }
.bar-arrow { color: var(--text-dim); transition: transform 0.2s; } .panel-close:hover { background: var(--bg-hover); color: var(--text-primary); }
.bar-arrow.flipped { transform: rotate(180deg); }
/* Body */ /* Body */
.drawer-body { .panel-body {
padding: 0 20px 16px; flex: 1;
overflow-y: auto;
padding: 16px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 16px; gap: 14px;
max-height: calc(var(--drawer-expanded) - var(--drawer-collapsed)); }
overflow-y: auto;
.empty-hint {
font-size: 13px;
color: var(--text-dim);
text-align: center;
padding: 40px 20px;
}
/* Slide transition */
.slide-right-enter-active,
.slide-right-leave-active {
transition: opacity 0.25s ease;
}
.slide-right-enter-active .task-panel,
.slide-right-leave-active .task-panel {
transition: transform 0.25s ease;
}
.slide-right-enter-from,
.slide-right-leave-to {
opacity: 0;
}
.slide-right-enter-from .task-panel,
.slide-right-leave-to .task-panel {
transform: translateX(100%);
} }
/* Current task */ /* Current task */
@@ -379,6 +404,7 @@ function fmtTime(iso?: string): string {
color: var(--text-dim); color: var(--text-dim);
flex-shrink: 0; flex-shrink: 0;
transition: transform 0.2s; transition: transform 0.2s;
cursor: pointer;
} }
.expand-arrow.rotated { transform: rotate(180deg); } .expand-arrow.rotated { transform: rotate(180deg); }
+72 -2
View File
@@ -110,7 +110,22 @@
<div class="card-header"><h3>编码参数</h3></div> <div class="card-header"><h3>编码参数</h3></div>
<div class="fields-row"> <div class="fields-row">
<div class="field"> <div class="field">
<label class="field-label">质量 / CRF (越小质量越高)</label> <label class="field-label">
质量 / CRF (越小质量越高)
<span class="tip-wrap">
<span class="tip-icon">?</span>
<span class="tip-popup">
<em class="tip-title">CRF / CQ 参考</em>
<span class="tip-row"><b>110</b> 视觉无损文件极大</span>
<span class="tip-row"><b>1517</b> 非常清晰</span>
<span class="tip-row"><b>1820</b> 优秀推荐</span>
<span class="tip-row"><b>2123</b> 良好默认 23</span>
<span class="tip-row"><b>2428</b> 可接受体积小</span>
<span class="tip-row"><b>30+</b> 明显损失</span>
<span class="tip-note">H.265/HEVC CQ 约省 3050% 体积</span>
</span>
</span>
</label>
<div class="crf-group"> <div class="crf-group">
<input type="range" min="1" max="51" v-model.number="encodeSettings.crf" /> <input type="range" min="1" max="51" v-model.number="encodeSettings.crf" />
<span class="crf-value">{{ encodeSettings.crf }}</span> <span class="crf-value">{{ encodeSettings.crf }}</span>
@@ -128,6 +143,22 @@
<option value="p6">P6</option> <option value="p6">P6</option>
<option value="p7">P7 最慢高画质</option> <option value="p7">P7 最慢高画质</option>
</template> </template>
<template v-else-if="isSvtAv1">
<option value="13">13 最快低画质</option>
<option value="12">12</option>
<option value="11">11</option>
<option value="10">10</option>
<option value="9">9</option>
<option value="8">8</option>
<option value="7">7 中等</option>
<option value="6">6</option>
<option value="5">5</option>
<option value="4">4</option>
<option value="3">3</option>
<option value="2">2</option>
<option value="1">1</option>
<option value="0">0 最慢高画质</option>
</template>
<template v-else> <template v-else>
<option value="ultrafast">ultrafast</option> <option value="ultrafast">ultrafast</option>
<option value="superfast">superfast</option> <option value="superfast">superfast</option>
@@ -262,6 +293,10 @@ const isHardwareCodec = computed(() => {
const c = encodeSettings.value.videoCodec const c = encodeSettings.value.videoCodec
return c.includes('nvenc') || c.includes('qsv') || c.includes('amf') return c.includes('nvenc') || c.includes('qsv') || c.includes('amf')
}) })
const isSvtAv1 = computed(() => {
const c = encodeSettings.value.videoCodec
return c.includes('svtav1') || c === 'libsvtav1'
})
// When switching between HW/software encoder, reset preset and hwaccel // When switching between HW/software encoder, reset preset and hwaccel
watch(() => encodeSettings.value.videoCodec, (codec) => { watch(() => encodeSettings.value.videoCodec, (codec) => {
@@ -274,8 +309,10 @@ watch(() => encodeSettings.value.videoCodec, (codec) => {
} else if (codec.includes('amf')) { } else if (codec.includes('amf')) {
if (!encodeSettings.value.preset.startsWith('p')) encodeSettings.value.preset = 'p4' if (!encodeSettings.value.preset.startsWith('p')) encodeSettings.value.preset = 'p4'
hwAccel.value = 'd3d11va' hwAccel.value = 'd3d11va'
} else if (codec.includes('svtav1') || codec === 'libsvtav1') {
if (!/^\d+$/.test(encodeSettings.value.preset)) encodeSettings.value.preset = '10'
} else { } else {
if (encodeSettings.value.preset.startsWith('p')) encodeSettings.value.preset = 'medium' if (encodeSettings.value.preset.startsWith('p') || /^\d+$/.test(encodeSettings.value.preset)) encodeSettings.value.preset = 'medium'
} }
}) })
@@ -389,6 +426,39 @@ function subTagLabel(s: StreamInfo): string {
.submit-area { display: flex; justify-content: flex-end; padding-top: 4px; } .submit-area { display: flex; justify-content: flex-end; padding-top: 4px; }
/* Tooltip */
.tip-wrap { position: relative; display: inline-flex; vertical-align: middle; }
.tip-icon {
display: inline-flex; align-items: center; justify-content: center;
width: 15px; height: 15px; border-radius: 50%;
background: var(--text-dim); color: #fff;
font-size: 10px; font-weight: 700; cursor: help;
margin-left: 3px; opacity: 0.6; transition: opacity 0.15s;
}
.tip-wrap:hover .tip-icon { opacity: 1; }
.tip-popup {
display: none; position: absolute; bottom: calc(100% + 8px); left: 50%;
transform: translateX(-50%); z-index: 50;
background: var(--bg-card); border: 1px solid var(--border);
border-radius: var(--radius-sm); padding: 10px 14px;
min-width: 260px; box-shadow: 0 4px 16px rgba(0,0,0,0.15);
flex-direction: column; gap: 4px; white-space: nowrap;
}
.tip-wrap:hover .tip-popup { display: flex; }
.tip-title {
font-style: normal; font-size: 13px; color: var(--text-primary);
margin-bottom: 6px; font-weight: 600;
}
.tip-row {
font-style: normal; font-size: 11px; color: var(--text-secondary);
display: flex; gap: 6px;
}
.tip-row b { color: var(--accent); min-width: 36px; font-weight: 600; }
.tip-note {
font-style: normal; font-size: 11px; color: var(--text-dim);
margin-top: 4px; padding-top: 4px; border-top: 1px solid var(--border-light);
}
.crf-group { display: flex; align-items: center; gap: 10px; } .crf-group { display: flex; align-items: center; gap: 10px; }
.crf-value { font-size: 16px; font-weight: 700; color: var(--accent); min-width: 28px; } .crf-value { font-size: 16px; font-weight: 700; color: var(--accent); min-width: 28px; }
input[type="range"] { accent-color: var(--accent); height: auto; padding: 0; box-shadow: none; flex: 1; } input[type="range"] { accent-color: var(--accent); height: auto; padding: 0; box-shadow: none; flex: 1; }
+73 -2
View File
@@ -56,6 +56,22 @@
<option value="p6">P6</option> <option value="p6">P6</option>
<option value="p7">P7 最慢高画质</option> <option value="p7">P7 最慢高画质</option>
</template> </template>
<template v-else-if="isSvtAv1">
<option value="13">13 最快低画质</option>
<option value="12">12</option>
<option value="11">11</option>
<option value="10">10</option>
<option value="9">9</option>
<option value="8">8</option>
<option value="7">7 中等</option>
<option value="6">6</option>
<option value="5">5</option>
<option value="4">4</option>
<option value="3">3</option>
<option value="2">2</option>
<option value="1">1</option>
<option value="0">0 最慢高画质</option>
</template>
<template v-else> <template v-else>
<option value="ultrafast">ultrafast</option> <option value="ultrafast">ultrafast</option>
<option value="superfast">superfast</option> <option value="superfast">superfast</option>
@@ -84,7 +100,22 @@
</select> </select>
</div> </div>
<div class="field" v-if="rateControl === 'crf'"> <div class="field" v-if="rateControl === 'crf'">
<label class="field-label">CRF / CQ (越小质量越高)</label> <label class="field-label">
CRF / CQ (越小质量越高)
<span class="tip-wrap">
<span class="tip-icon">?</span>
<span class="tip-popup">
<em class="tip-title">CRF / CQ 参考</em>
<span class="tip-row"><b>110</b> 视觉无损文件极大</span>
<span class="tip-row"><b>1517</b> 非常清晰</span>
<span class="tip-row"><b>1820</b> 优秀推荐</span>
<span class="tip-row"><b>2123</b> 良好默认 23</span>
<span class="tip-row"><b>2428</b> 可接受体积小</span>
<span class="tip-row"><b>30+</b> 明显损失</span>
<span class="tip-note">H.265/HEVC CQ 约省 3050% 体积</span>
</span>
</span>
</label>
<div class="crf-group"> <div class="crf-group">
<input type="range" min="1" max="51" v-model.number="encodeSettings.crf" <input type="range" min="1" max="51" v-model.number="encodeSettings.crf"
style="height:auto;padding:0;box-shadow:none;flex:1" /> style="height:auto;padding:0;box-shadow:none;flex:1" />
@@ -251,6 +282,10 @@ const isHardwareCodec = computed(() => {
const c = encodeSettings.value.videoCodec const c = encodeSettings.value.videoCodec
return c.includes('nvenc') || c.includes('qsv') || c.includes('amf') return c.includes('nvenc') || c.includes('qsv') || c.includes('amf')
}) })
const isSvtAv1 = computed(() => {
const c = encodeSettings.value.videoCodec
return c.includes('svtav1') || c === 'libsvtav1'
})
// When switching rate control mode, clear the unused field // When switching rate control mode, clear the unused field
watch(rateControl, (mode) => { watch(rateControl, (mode) => {
@@ -312,8 +347,11 @@ watch(() => encodeSettings.value.videoCodec, (codec) => {
} else if (codec.includes('amf')) { } else if (codec.includes('amf')) {
if (!encodeSettings.value.preset.startsWith('p')) encodeSettings.value.preset = 'p4' if (!encodeSettings.value.preset.startsWith('p')) encodeSettings.value.preset = 'p4'
hwAccel.value = 'd3d11va' hwAccel.value = 'd3d11va'
} else if (codec.includes('svtav1') || codec === 'libsvtav1') {
// SVT-AV1 uses numeric presets 013, not named presets
if (!/^\d+$/.test(encodeSettings.value.preset)) encodeSettings.value.preset = '10'
} else { } else {
if (encodeSettings.value.preset.startsWith('p')) encodeSettings.value.preset = 'medium' if (encodeSettings.value.preset.startsWith('p') || /^\d+$/.test(encodeSettings.value.preset)) encodeSettings.value.preset = 'medium'
// For software encoders, keep hwaccel as-is (user's choice for decode only) // For software encoders, keep hwaccel as-is (user's choice for decode only)
} }
}) })
@@ -382,6 +420,39 @@ function resetForm() {
} }
.stream-tag.dur { font-weight: 500; } .stream-tag.dur { font-weight: 500; }
/* Tooltip */
.tip-wrap { position: relative; display: inline-flex; vertical-align: middle; }
.tip-icon {
display: inline-flex; align-items: center; justify-content: center;
width: 15px; height: 15px; border-radius: 50%;
background: var(--text-dim); color: #fff;
font-size: 10px; font-weight: 700; cursor: help;
margin-left: 3px; opacity: 0.6; transition: opacity 0.15s;
}
.tip-wrap:hover .tip-icon { opacity: 1; }
.tip-popup {
display: none; position: absolute; bottom: calc(100% + 8px); left: 50%;
transform: translateX(-50%); z-index: 50;
background: var(--bg-card); border: 1px solid var(--border);
border-radius: var(--radius-sm); padding: 10px 14px;
min-width: 260px; box-shadow: 0 4px 16px rgba(0,0,0,0.15);
flex-direction: column; gap: 4px; white-space: nowrap;
}
.tip-wrap:hover .tip-popup { display: flex; }
.tip-title {
font-style: normal; font-size: 13px; color: var(--text-primary);
margin-bottom: 6px; font-weight: 600;
}
.tip-row {
font-style: normal; font-size: 11px; color: var(--text-secondary);
display: flex; gap: 6px;
}
.tip-row b { color: var(--accent); min-width: 36px; font-weight: 600; }
.tip-note {
font-style: normal; font-size: 11px; color: var(--text-dim);
margin-top: 4px; padding-top: 4px; border-top: 1px solid var(--border-light);
}
.crf-group { display: flex; align-items: center; gap: 10px; } .crf-group { display: flex; align-items: center; gap: 10px; }
.crf-value { font-size: 16px; font-weight: 700; color: var(--accent); min-width: 28px; } .crf-value { font-size: 16px; font-weight: 700; color: var(--accent); min-width: 28px; }
.range-hint { display: flex; justify-content: space-between; font-size: 11px; color: var(--text-dim); } .range-hint { display: flex; justify-content: space-between; font-size: 11px; color: var(--text-dim); }
+30 -17
View File
@@ -21,7 +21,7 @@
</div> </div>
<!-- Log content --> <!-- Log content -->
<div v-if="activeTab" class="log-body" ref="logBody"> <div v-if="activeTab" class="log-body" ref="logBody" @scroll="onLogScroll">
<div class="log-head"> <div class="log-head">
<span class="log-cmd">Command: ffmpeg {{ activeTask?.args?.join(' ') || '' }}</span> <span class="log-cmd">Command: ffmpeg {{ activeTask?.args?.join(' ') || '' }}</span>
<button class="btn-ghost btn-sm" @click="copyLogs" :title="copied ? '已复制' : '复制日志'"> <button class="btn-ghost btn-sm" @click="copyLogs" :title="copied ? '已复制' : '复制日志'">
@@ -71,8 +71,20 @@ async function copyLogs() {
} catch {} } catch {}
} }
// Auto-scroll on new lines // Auto-scroll: only when user is near the bottom
let autoScroll = true
function onLogScroll() {
const el = logBody.value
if (!el) return
// If user scrolled away from bottom (>50px), stop auto-following
autoScroll = el.scrollTop + el.clientHeight >= el.scrollHeight - 50
}
// Reset auto-scroll when switching tabs
watch(activeTab, () => { autoScroll = true })
watch(activeLogs, () => { watch(activeLogs, () => {
if (!autoScroll) return
nextTick(() => { nextTick(() => {
const el = logBody.value const el = logBody.value
if (el) el.scrollTop = el.scrollHeight if (el) el.scrollTop = el.scrollHeight
@@ -116,17 +128,17 @@ function lineClass(line: string): string {
// Exposed for App.vue to push log events // Exposed for App.vue to push log events
function upsertTask(t: Partial<TaskInfo> & { id: string }) { function upsertTask(t: Partial<TaskInfo> & { id: string }) {
const existing = tasks.value.find(x => x.id === t.id) const idx = tasks.value.findIndex(x => x.id === t.id)
if (existing) { if (idx !== -1) {
// Protect logs/args — they come via appendLog and the first task:updated const existing = tasks.value[idx]
// Replace to trigger reactivity; protect logs/args from being overwritten
const keepLogs = existing.logs const keepLogs = existing.logs
const keepArgs = existing.args const keepArgs = existing.args
Object.assign(existing, t) tasks.value[idx] = {
if (!t.logs || (Array.isArray(t.logs) && t.logs.length === 0)) { ...existing,
existing.logs = keepLogs ...t,
} logs: (!t.logs || t.logs.length === 0) ? keepLogs : t.logs,
if (!t.args || (Array.isArray(t.args) && t.args.length === 0)) { args: (!t.args || t.args.length === 0) ? keepArgs : t.args,
existing.args = keepArgs
} }
} else { } else {
tasks.value.push({ tasks.value.push({
@@ -144,14 +156,15 @@ function upsertTask(t: Partial<TaskInfo> & { id: string }) {
} }
function appendLog(taskId: string, line: string) { function appendLog(taskId: string, line: string) {
let t = tasks.value.find(x => x.id === taskId) const idx = tasks.value.findIndex(x => x.id === taskId)
if (!t) { if (idx === -1) {
// Log arrived before task:updated event — create entry now tasks.value.push({ id: taskId, inputFile: '', type: '', status: 'running', args: [], logs: [line] })
t = { id: taskId, inputFile: '', type: '', status: 'running', args: [], logs: [] }
tasks.value.push(t)
if (!activeTab.value) activeTab.value = taskId if (!activeTab.value) activeTab.value = taskId
return
} }
t.logs.push(line) // Replace object to trigger shallow-ref reactivity
const t = tasks.value[idx]
tasks.value[idx] = { ...t, logs: [...t.logs, line] }
} }
// Allow parent to call these // Allow parent to call these
+29 -10
View File
@@ -7,6 +7,7 @@ import (
"fmt" "fmt"
"io" "io"
"os/exec" "os/exec"
"strconv"
"strings" "strings"
"syscall" "syscall"
) )
@@ -141,6 +142,24 @@ func escapeFilterPath(path string) string {
return s return s
} }
// qualityArg maps a CRF-like quality value to encoder-specific arguments.
// Software encoders use -crf, NVENC uses -cq, QSV uses -global_quality,
// and AMF uses -qp_i/-qp_p for constant-quality encoding.
func qualityArg(codec string, crf int) []string {
v := strconv.Itoa(crf)
lower := strings.ToLower(codec)
switch {
case strings.Contains(lower, "nvenc"):
return []string{"-cq", v}
case strings.Contains(lower, "qsv"):
return []string{"-global_quality", v}
case strings.Contains(lower, "amf"):
return []string{"-qp_i", v, "-qp_p", v}
default:
return []string{"-crf", v}
}
}
func tailLines(s string, n int) string { func tailLines(s string, n int) string {
lines := strings.Split(s, "\n") lines := strings.Split(s, "\n")
if len(lines) > n { if len(lines) > n {
@@ -197,11 +216,11 @@ func BuildEncodeArgs(input string, output string, s EncodeSettings, hwAccel stri
args = append(args, "-i", input) args = append(args, "-i", input)
if s.HWEncoder != "" { codec := s.HWEncoder
args = append(args, "-c:v", s.HWEncoder) if codec == "" {
} else { codec = s.VideoCodec
args = append(args, "-c:v", s.VideoCodec)
} }
args = append(args, "-c:v", codec)
if s.Preset != "" { if s.Preset != "" {
// libx* uses named presets (fast, medium…), HW encoders use p1p7 // libx* uses named presets (fast, medium…), HW encoders use p1p7
@@ -209,7 +228,7 @@ func BuildEncodeArgs(input string, output string, s EncodeSettings, hwAccel stri
} }
if s.CRF > 0 { if s.CRF > 0 {
args = append(args, "-crf", fmt.Sprintf("%d", s.CRF)) args = append(args, qualityArg(codec, s.CRF)...)
} else if s.VideoBitrate != "" { } else if s.VideoBitrate != "" {
args = append(args, "-b:v", s.VideoBitrate) args = append(args, "-b:v", s.VideoBitrate)
} }
@@ -327,16 +346,16 @@ func BuildSubtitleArgs(input string, output string, s SubtitleSettings, encode E
args = append(args, "-vf", strings.Join(subFilters, ",")) args = append(args, "-vf", strings.Join(subFilters, ","))
} }
if encode.HWEncoder != "" { codec := encode.HWEncoder
args = append(args, "-c:v", encode.HWEncoder) if codec == "" {
} else { codec = encode.VideoCodec
args = append(args, "-c:v", encode.VideoCodec)
} }
args = append(args, "-c:v", codec)
if encode.Preset != "" { if encode.Preset != "" {
args = append(args, "-preset", encode.Preset) args = append(args, "-preset", encode.Preset)
} }
if encode.CRF > 0 { if encode.CRF > 0 {
args = append(args, "-crf", fmt.Sprintf("%d", encode.CRF)) args = append(args, qualityArg(codec, encode.CRF)...)
} else if encode.VideoBitrate != "" { } else if encode.VideoBitrate != "" {
args = append(args, "-b:v", encode.VideoBitrate) args = append(args, "-b:v", encode.VideoBitrate)
} }
+43 -12
View File
@@ -19,28 +19,59 @@ type Progress struct {
Percent float64 `json:"percent"` Percent float64 `json:"percent"`
} }
var progressRe = regexp.MustCompile( // kvPairRe matches a single key=value token from an ffmpeg progress line.
`frame=\s*(\d+)\s+fps=\s*([\d.]+)\s+q=\s*([\d.-]+)\s+(?:size=\s*(\S+)\s+)?time=\s*([\d:.]+)\s+bitrate=\s*(\S+)\s+speed=\s*(\S+)`, // Keys are alphanumeric (e.g. frame, dup, Lsize). Values are the contiguous
) // non-whitespace characters after the '=' — this handles N/A, kbits/s, times,
// and numeric values equally.
var kvPairRe = regexp.MustCompile(`(\w+)=\s*(\S+)`)
// parseProgressLine parses an ffmpeg stderr progress line into a Progress struct.
// Instead of relying on a fixed field order, it extracts every key=value pair
// and picks the ones we care about. This tolerates extra fields (dup, drop,
// Lsize vs size, etc.) that appear between standard ffmpeg releases.
func parseProgressLine(line string) (Progress, bool) { func parseProgressLine(line string) (Progress, bool) {
m := progressRe.FindStringSubmatch(line) pairs := kvPairRe.FindAllStringSubmatch(line, -1)
if m == nil { if len(pairs) == 0 {
return Progress{}, false return Progress{}, false
} }
frame, _ := strconv.ParseInt(m[1], 10, 64) kv := make(map[string]string, len(pairs))
fps, _ := strconv.ParseFloat(m[2], 64) for _, m := range pairs {
q, _ := strconv.ParseFloat(m[3], 64) kv[m[1]] = m[2]
}
// Frame is required — without it we don't have a real progress line.
frameStr, ok := kv["frame"]
if !ok {
return Progress{}, false
}
frame, _ := strconv.ParseInt(frameStr, 10, 64)
fps, _ := strconv.ParseFloat(kv["fps"], 64)
q, _ := strconv.ParseFloat(kv["q"], 64)
// ffmpeg emits either "size" or "Lsize" depending on context.
size := kv["size"]
if size == "" {
size = kv["Lsize"]
}
if size == "N/A" {
size = ""
}
bitrate := kv["bitrate"]
if bitrate == "N/A" {
bitrate = ""
}
return Progress{ return Progress{
Frame: frame, Frame: frame,
FPS: fps, FPS: fps,
Q: q, Q: q,
Size: m[4], Size: size,
Time: m[5], Time: kv["time"],
Bitrate: m[6], Bitrate: bitrate,
Speed: m[7], Speed: kv["speed"],
}, true }, true
} }
+1 -1
View File
@@ -203,7 +203,7 @@ func (m *Manager) runTask(t *Task) {
if sub.Source == "internal" { if sub.Source == "internal" {
tmpPath := t.OutputFile + fmt.Sprintf(".sub_%d_tmp.srt", i) tmpPath := t.OutputFile + fmt.Sprintf(".sub_%d_tmp.srt", i)
if _, err := m.exec.RunSync("-y", "-i", t.InputFile, if _, err := m.exec.RunSync("-y", "-i", t.InputFile,
"-map", fmt.Sprintf("0:s:%d", sub.Index), "-c:s", "srt", tmpPath); err != nil { "-map", fmt.Sprintf("0:%d", sub.Index), "-c:s", "srt", tmpPath); err != nil {
m.completeTask(t, false, fmt.Sprintf("提取字幕轨道 #%d 失败: %v", sub.Index, err)) m.completeTask(t, false, fmt.Sprintf("提取字幕轨道 #%d 失败: %v", sub.Index, err))
return return
} }