Files
res-downloader/frontend/src/views/index.vue
2025-07-25 15:43:22 +08:00

780 lines
21 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="h-full flex flex-col px-5 pt-5 overflow-y-auto [&::-webkit-scrollbar]:hidden">
<div class="pb-2 z-40" id="header">
<NSpace>
<NButton v-if="isProxy" secondary type="primary" @click.stop="close" style="--wails-draggable:no-drag">
{{ t("index.close_grab") }}
</NButton>
<NButton v-else tertiary type="tertiary" @click.stop="open" style="--wails-draggable:no-drag">
{{ t("index.open_grab") }}
</NButton>
<NSelect style="min-width: 100px;--wails-draggable:no-drag" :placeholder="t('index.grab_type')" v-model:value="resourcesType" multiple clearable
:max-tag-count="3" :options="classify"></NSelect>
<n-popconfirm
@positive-click="clear"
>
<template #trigger>
<NButton tertiary type="error" style="--wails-draggable:no-drag">
<template #icon>
<n-icon>
<TrashOutline/>
</n-icon>
</template>
{{ t("index.clear_list") }}
</NButton>
</template>
{{ t("index.clear_list_tip") }}
</n-popconfirm>
<NButtonGroup style="--wails-draggable:no-drag">
<NButton tertiary type="primary" @click.stop="batchDown">
<template #icon>
<n-icon>
<DownloadOutline/>
</n-icon>
</template>
{{ t('index.batch_download') }}
</NButton>
<NButton tertiary type="warning" @click.stop="batchExport">
<template #icon>
<n-icon>
<ArrowRedoCircleOutline/>
</n-icon>
</template>
{{ t('index.batch_export') }}
</NButton>
<NButton tertiary type="info" @click.stop="showImport=true">
<template #icon>
<n-icon>
<ServerOutline/>
</n-icon>
</template>
{{ t('index.batch_import') }}
</NButton>
</NButtonGroup>
</NSpace>
</div>
<div class="flex-1">
<NDataTable
:columns="columns"
:data="filteredData"
:bordered="false"
:max-height="tableHeight"
:row-key="rowKey"
:virtual-scroll="true"
:header-height="48"
:height-for-row="()=> 48"
:checked-row-keys="checkedRowKeysValue"
@update:checked-row-keys="handleCheck"
style="--wails-draggable:no-drag"
/>
</div>
<div class="flex justify-center items-center text-blue-400" id="bottom">
<span class="cursor-pointer px-2 py-1" @click="BrowserOpenURL(certUrl)">{{ t('footer.cert_download') }}</span>
<span class="cursor-pointer px-2 py-1" @click="BrowserOpenURL('https://github.com/putyy/res-downloader')">{{ t('footer.source_code') }}</span>
<span class="cursor-pointer px-2 py-1" @click="BrowserOpenURL('https://github.com/putyy/res-downloader/issues')">{{ t('footer.help') }}</span>
<span class="cursor-pointer px-2 py-1" @click="BrowserOpenURL('https://github.com/putyy/res-downloader/releases')">{{ t('footer.update_log') }}</span>
</div>
<Preview v-model:showModal="showPreviewRow" :previewRow="previewRow"/>
<ShowLoading :loadingText="loadingText" :isLoading="loading"/>
<ImportJson v-model:showModal="showImport" @submit="handleImport"/>
<Password v-model:showModal="showPassword" @submit="handlePassword"/>
</div>
</template>
<script lang="ts" setup>
import {NButton, NIcon, NImage, NInput, NSpace, NTooltip, NPopover} from "naive-ui"
import {computed, h, onMounted, ref, watch} from "vue"
import type {appType} from "@/types/app"
import type {DataTableRowKey, ImageRenderToolbarProps} from "naive-ui"
import Preview from "@/components/Preview.vue"
import ShowLoading from "@/components/ShowLoading.vue"
// @ts-ignore
import {getDecryptionArray} from '@/assets/js/decrypt.js'
import {useIndexStore} from "@/stores"
import appApi from "@/api/app"
import Action from "@/components/Action.vue"
import ActionDesc from "@/components/ActionDesc.vue"
import ImportJson from "@/components/ImportJson.vue"
import {useEventStore} from "@/stores/event"
import {BrowserOpenURL, ClipboardSetText} from "../../wailsjs/runtime"
import Password from "@/components/Password.vue"
import {useI18n} from 'vue-i18n'
import {
DownloadOutline,
ArrowRedoCircleOutline,
ServerOutline,
SearchOutline,
TrashOutline
} from "@vicons/ionicons5"
const {t} = useI18n()
const eventStore = useEventStore()
const isProxy = computed(() => {
return store.isProxy
})
const certUrl = computed(() => {
return store.baseUrl + "/api/cert"
})
const data = ref<any[]>([])
let filterClassify: string[] = []
const filteredData = computed(() => {
let result = data.value
if (filterClassify.length > 0) {
result = result.filter(item => filterClassify.includes(item.Classify))
}
if (descriptionSearchValue.value) {
result = result.filter(item => item.Description?.toLowerCase().includes(descriptionSearchValue.value.toLowerCase()))
}
return result
})
const store = useIndexStore()
const tableHeight = ref(800)
const resourcesType = ref<string[]>(["all"])
const classifyAlias: { [key: string]: any } = {
image: computed(() => t("index.image")),
audio: computed(() => t("index.audio")),
video: computed(() => t("index.video")),
m3u8: computed(() => t("index.m3u8")),
live: computed(() => t("index.live")),
xls: computed(() => t("index.xls")),
doc: computed(() => t("index.doc")),
pdf: computed(() => t("index.pdf")),
font: computed(() => t("index.font"))
}
const dwStatus = computed<any>(() => {
return {
ready: t("index.ready"),
running: t("index.running"),
error: t("index.error"),
done: t("index.done"),
handle: t("index.handle")
}
})
const maxConcurrentDownloads = computed(() => {
return store.globalConfig.DownNumber
})
const classify = ref([
{
value: "all",
label: computed(() => t("index.all")),
},
])
const descriptionSearchValue = ref("")
const columns = ref<any[]>([
{
type: "selection",
},
{
title: computed(() => t("index.domain")),
key: "Domain",
width: 80,
},
{
title: computed(() => t("index.type")),
key: "Classify",
width: 80,
filterOptions: computed(() => Array.from(classify.value).slice(1)),
filterMultiple: true,
filter: (value: string, row: appType.MediaInfo) => {
if (!filterClassify.includes(value)) filterClassify.push(value)
return !!~row.Classify.indexOf(String(value))
},
render: (row: appType.MediaInfo) => {
const item = classify.value.find(item => item.value === row.Classify)
return item ? item.label : row.Classify
}
},
{
title: computed(() => t("index.preview")),
key: "Url",
width: 80,
render: (row: appType.MediaInfo) => {
if (row.Classify === "image") {
return h(NImage, {
maxWidth: "80px",
lazy: true,
"render-toolbar": renderToolbar,
src: row.Url
})
}
return [
h(
NButton,
{
strong: true,
tertiary: true,
type: "info",
size: "small",
style: {
margin: "2px"
},
onClick: () => {
if (row.Classify === "audio" || row.Classify === "video" || row.Classify === "m3u8" || row.Classify === "live") {
previewRow.value = row
showPreviewRow.value = true
}
}
},
{
default: () => {
if (row.Classify === "audio" || row.Classify === "video" || row.Classify === "m3u8" || row.Classify === "live") {
return t("index.preview")
}
return t("index.preview_tip")
}
}
),
]
}
},
{
title: computed(() => t("index.status")),
key: "Status",
width: 80,
render: (row: appType.MediaInfo, index: number) => {
return h(
NButton,
{
tertiary: true,
type: row.Status === "done" ? "success" : "info",
size: "small",
style: {
margin: "2px"
},
onClick: () => {
if (row.SavePath && row.Status === "done") {
appApi.openFolder({filePath: row.SavePath})
} else if (row.Status === "ready") {
download(row, index)
}
}
},
{
default: () => {
return row.Status === "running" ? row.SavePath : dwStatus.value[row.Status as keyof typeof dwStatus]
}
}
)
}
},
{
title: () => h('div', {class: 'flex items-center'}, [
t('index.description'),
h(NPopover, {
trigger: 'click',
placement: 'bottom',
showArrow: true,
}, {
trigger: () => h(NIcon, {
size: "18",
class: "ml-1 text-gray-500 cursor-pointer",
onClick: (e: MouseEvent) => e.stopPropagation()
}, h(SearchOutline)),
default: () => h('div', {class: 'p-2 w-64'}, [
h(NInput, {
value: descriptionSearchValue.value,
'onUpdate:value': (val: string) => descriptionSearchValue.value = val,
placeholder: t('index.search_description'),
clearable: true
}, {
prefix: () => h(NIcon, {component: SearchOutline})
})
])
})
]),
key: "Description",
width: 150,
render: (row: appType.MediaInfo, index: number) => {
const d = h("div", {class: "ellipsis-2",}, row.Description)
return h(NTooltip, {trigger: 'hover', placement: 'top'}, {
trigger: () => d,
default: () => d
})
}
},
{
title: computed(() => t("index.resource_size")),
key: "Size",
width: 120,
},
{
title: computed(() => t("index.save_path")),
key: "SavePath",
render(row: appType.MediaInfo, index: number) {
return h("a",
{
href: "javascript:;",
class: "ellipsis-2",
style: {
color: "#5a95d0"
},
onClick: () => {
if (row.SavePath && row.Status === "done") {
appApi.openFolder({filePath: row.SavePath})
}
}
},
row.Status === "running" ? "" : row.SavePath
)
}
},
{
key: "actions",
width: 130,
render(row: appType.MediaInfo, index: number) {
return h(Action, {key: index, row: row, index: index, onAction: dataAction})
},
title() {
return h(ActionDesc)
}
}
])
const checkedRowKeysValue = ref<DataTableRowKey[]>([])
const showPreviewRow = ref(false)
const previewRow = ref<appType.MediaInfo>()
const loading = ref(false)
const loadingText = ref("")
const showImport = ref(false)
const showPassword = ref(false)
const downloadQueue = ref<appType.MediaInfo[]>([])
let activeDownloads = 0
let isOpenProxy = false
onMounted(() => {
try {
window.addEventListener("resize", () => {
resetTableHeight()
})
loading.value = true
handleInstall().then((is: boolean) => {
loading.value = false
})
} catch (e) {
window.$message?.error(JSON.stringify(e), {duration: 5000})
}
buildClassify()
const temp = localStorage.getItem("resources-type")
if (temp) {
resourcesType.value = JSON.parse(temp).res
} else {
appApi.setType(resourcesType.value)
}
const cache = localStorage.getItem("resources-data")
if (cache) {
data.value = JSON.parse(cache)
}
resetTableHeight()
eventStore.addHandle({
type: "newResources",
event: (res: appType.MediaInfo) => {
data.value.push(res)
cacheData()
}
})
eventStore.addHandle({
type: "downloadProgress",
event: (res: { Id: string, SavePath: string, Status: string, Message: string }) => {
switch (res.Status) {
case "running":
updateItem(res.Id, item => {
item.SavePath = res.Message
item.Status = 'running'
})
break
case "done":
updateItem(res.Id, item => {
item.SavePath = res.SavePath
item.Status = 'done'
})
cacheData()
checkQueue()
break
case "error":
updateItem(res.Id, item => {
item.SavePath = res.Message
item.Status = 'error'
})
cacheData()
checkQueue()
break
}
}
})
})
watch(() => {
return store.globalConfig.MimeMap
}, () => {
buildClassify()
})
watch(resourcesType, (n, o) => {
localStorage.setItem("resources-type", JSON.stringify({res: resourcesType.value}))
appApi.setType(resourcesType.value)
})
const updateItem = (id: string, updater: (item: any) => void)=>{
const item = data.value.find(i => i.Id === id)
if (item) updater(item)
}
function cacheData() {
localStorage.setItem("resources-data", JSON.stringify(data.value))
}
const resetTableHeight = () => {
try {
const headerHeight = document.getElementById("header")?.offsetHeight || 0
const bottomHeight = document.getElementById("bottom")?.offsetHeight || 0
// @ts-ignore
const theadHeight = document.getElementsByClassName("n-data-table-thead")[0]?.offsetHeight || 0
const height = document.documentElement.clientHeight || window.innerHeight
tableHeight.value = height - headerHeight - bottomHeight - theadHeight - 20
} catch (e) {
console.log(e)
}
}
const buildClassify = () => {
const mimeMap = store.globalConfig.MimeMap ?? {}
const seen = new Set()
classify.value = [
{value: "all", label: computed(() => t("index.all"))},
...Object.values(mimeMap)
.filter(({Type}) => {
if (seen.has(Type)) return false
seen.add(Type)
return true
})
.map(({Type}) => ({
value: Type,
label: classifyAlias[Type] ?? Type,
})),
]
}
const dataAction = (row: appType.MediaInfo, index: number, type: string) => {
switch (type) {
case "down":
download(row, index)
break
case "copy":
ClipboardSetText(row.Url).then((is: boolean) => {
if (is) {
window?.$message?.success(t("common.copy_success"))
} else {
window?.$message?.error(t("common.copy_fail"))
}
})
break
case "json":
ClipboardSetText(encodeURIComponent(JSON.stringify(row))).then((is: boolean) => {
if (is) {
window?.$message?.success(t("common.copy_success"))
} else {
window?.$message?.error(t("common.copy_fail"))
}
})
break
case "open":
BrowserOpenURL(row.Url)
break
case "decode":
decodeWxFile(row, index)
break
case "delete":
appApi.delete({sign: row.UrlSign}).then(() => {
data.value.splice(index, 1)
cacheData()
})
break
}
}
const renderToolbar = ({nodes}: ImageRenderToolbarProps) => {
return [
nodes.rotateCounterclockwise,
nodes.rotateClockwise,
nodes.resizeToOriginalSize,
nodes.zoomOut,
nodes.zoomIn,
nodes.close
]
}
const rowKey = (row: appType.MediaInfo) => {
return row.Id
}
const handleCheck = (rowKeys: DataTableRowKey[]) => {
checkedRowKeysValue.value = rowKeys
}
const batchDown = async () => {
if (checkedRowKeysValue.value.length <= 0) {
window?.$message?.error(t("index.use_data"))
return
}
if (!store.globalConfig.SaveDirectory) {
window?.$message?.error(t("index.save_path_empty"))
return
}
data.value.forEach((item, index) => {
if (checkedRowKeysValue.value.includes(item.Id) && item.Classify !== 'live' && item.Classify !== 'm3u8') {
download(item, index)
}
})
checkedRowKeysValue.value = []
}
const batchExport = () => {
if (checkedRowKeysValue.value.length <= 0) {
window?.$message?.error(t("index.use_data"))
return
}
if (!store.globalConfig.SaveDirectory) {
window?.$message?.error(t("index.save_path_empty"))
return
}
loadingText.value = t("common.loading")
loading.value = true
const jsonData = data.value
.filter(item => checkedRowKeysValue.value.includes(item.Id))
.map(item => encodeURIComponent(JSON.stringify(item)))
appApi.batchExport({content: jsonData.join("\n")}).then((res: appType.Res) => {
loading.value = false
if (res.code === 0) {
window?.$message?.error(res.message)
return
}
window?.$message?.success(t("index.import_success"))
window?.$message?.info(t("index.save_path") + "" + res.data?.file_name, {
duration: 5000
})
})
}
const uint8ArrayToBase64 = (bytes: any) => {
return window.btoa(Array.from(bytes, (byte: any) => String.fromCharCode(byte)).join(''))
}
const download = (row: appType.MediaInfo, index: number) => {
if (!store.globalConfig.SaveDirectory) {
window?.$message?.error(t("index.save_path_empty"))
return
}
if (data.value.some(item => item.Id === row.Id && item.Status === "running")) {
return
}
if (downloadQueue.value.some(item => item.Id === row.Id || item.Url === row.Url)) {
return
}
if (activeDownloads >= maxConcurrentDownloads.value) {
downloadQueue.value.push(row)
window?.$message?.info((row.Description ? `「${row.Description}」` : "")
+ t("index.download_queued", {count: downloadQueue.value.length}))
return
}
startDownload(row, index)
}
const startDownload = (row: appType.MediaInfo, index: number) => {
activeDownloads++
const decodeStr = row.DecodeKey
? uint8ArrayToBase64(getDecryptionArray(row.DecodeKey))
: ""
appApi.download({...row, decodeStr}).then((res: appType.Res) => {
if (res.code === 0) {
window?.$message?.error(res.message)
}
}).finally(() => {
activeDownloads--
checkQueue()
})
}
const checkQueue = () => {
if (downloadQueue.value.length > 0 && activeDownloads < maxConcurrentDownloads.value) {
const nextItem = downloadQueue.value.shift()
if (nextItem) {
const index = data.value.findIndex(item => item.Id === nextItem.Id)
if (index !== -1) {
startDownload(nextItem, index)
}
}
}
}
const open = () => {
isOpenProxy = true
store.openProxy().then((res: appType.Res) => {
if (res.code === 1) {
return
}
if (["darwin", "linux"].includes(store.envInfo.platform)) {
showPassword.value = true
} else {
window.$message?.error(res.message)
}
})
}
const close = () => {
store.unsetProxy()
}
const clear = () => {
if (checkedRowKeysValue.value.length > 0) {
let newData = [] as any[]
data.value.forEach((item, index) => {
if (checkedRowKeysValue.value.includes(item.Id)) {
appApi.delete({sign: item.UrlSign})
} else {
newData.push(item)
}
})
data.value = newData
checkedRowKeysValue.value = []
cacheData()
return
}
data.value = []
localStorage.setItem("resources-data", "")
appApi.clear()
}
const decodeWxFile = (row: appType.MediaInfo, index: number) => {
if (!row.DecodeKey) {
window?.$message?.error(t("index.video_decode_no"))
return
}
appApi.openFileDialog().then((res: appType.Res) => {
if (res.code === 0) {
window?.$message?.error(res.message)
return
}
if (res.data.file) {
loadingText.value = t("index.video_decode_loading")
loading.value = true
appApi.wxFileDecode({
...row,
filename: res.data.file,
decodeStr: uint8ArrayToBase64(getDecryptionArray(row.DecodeKey))
}).then((res: appType.Res) => {
loading.value = false
if (res.code === 0) {
window?.$message?.error(res.message)
return
}
data.value[index].SavePath = res.data.save_path
data.value[index].Status = "done"
cacheData()
window?.$message?.success(t("index.video_decode_success"))
})
}
})
}
const handleImport = (content: string) => {
if (!content) {
window?.$message?.error(t("view.import_empty"))
return
}
let newItems = [] as any[]
content.split("\n").forEach((line, index) => {
try {
let res = JSON.parse(decodeURIComponent(line))
if (res && res?.Id) {
res.Id = res.Id + Math.floor(Math.random() * 100000)
res.SavePath = ""
res.Status = "ready"
newItems.push(res)
}
} catch (e) {
console.log(e)
}
})
if (newItems.length > 0) {
data.value = [...newItems, ...data.value]
}
cacheData()
showImport.value = false
}
const handlePassword = async (password: string, isCache: boolean) => {
const res = await appApi.setSystemPassword({password, isCache})
if (res.code === 0) {
window.$message?.error(res.message)
return
}
if (isOpenProxy) {
showPassword.value = false
store.openProxy()
return
}
handleInstall().then((is: boolean) => {
if (is) {
showPassword.value = false
}
})
}
const handleInstall = async () => {
isOpenProxy = false
const res = await appApi.install()
if (res.code === 1) {
store.globalConfig.AutoProxy && store.openProxy()
return true
}
window.$message?.error(res.message, {duration: 5000})
if (store.envInfo.platform === "windows" && res.message.includes("Access is denied")) {
window.$message?.error(t("index.win_install_tip"))
} else if (["darwin", "linux"].includes(store.envInfo.platform)) {
showPassword.value = true
}
return false
}
</script>
<style>
.ellipsis-2 {
display: -webkit-box;
overflow: hidden;
text-overflow: ellipsis;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
</style>