mirror of
https://github.com/CzBiX/qb-web.git
synced 2026-04-13 18:01:17 +08:00
Add info dialog
This commit is contained in:
21
src/Api.ts
21
src/Api.ts
@@ -93,6 +93,27 @@ class Api {
|
||||
return this.actionTorrents('setCategory', hashes, {category});
|
||||
}
|
||||
|
||||
public getTorrentTracker(hash: string) {
|
||||
const params = {
|
||||
hash,
|
||||
};
|
||||
|
||||
return this.axios.get('/torrents/trackers', {
|
||||
params,
|
||||
}).then(this.handleResponse);
|
||||
}
|
||||
|
||||
public getTorrentPeers(hash: string, rid?: number) {
|
||||
const params = {
|
||||
hash,
|
||||
rid,
|
||||
};
|
||||
|
||||
return this.axios.get('/sync/torrentPeers', {
|
||||
params,
|
||||
}).then(this.handleResponse);
|
||||
}
|
||||
|
||||
private actionTorrents(action: string, hashes: string[], extra?: any) {
|
||||
const params: any = {
|
||||
hashes: hashes.join('|'),
|
||||
|
||||
@@ -55,7 +55,7 @@ import LoginForm from './components/LoginForm.vue';
|
||||
import MainToolbar from './components/MainToolbar.vue';
|
||||
import Torrents from './components/Torrents.vue';
|
||||
import AppFooter from './components/Footer.vue';
|
||||
import LogsDialog from './components/LogsDialog.vue';
|
||||
import LogsDialog from './components/dialogs/LogsDialog.vue';
|
||||
import { api } from './Api';
|
||||
import { mapActions, mapGetters, mapState, mapMutations } from 'vuex';
|
||||
import Axios, { AxiosError } from 'axios';
|
||||
@@ -76,7 +76,6 @@ export default Vue.extend({
|
||||
return {
|
||||
needAuth: false,
|
||||
drawer: true,
|
||||
phoneLayout: false,
|
||||
drawerOptions: {
|
||||
showLogs: false,
|
||||
},
|
||||
@@ -84,8 +83,6 @@ export default Vue.extend({
|
||||
};
|
||||
},
|
||||
async created() {
|
||||
this.phoneLayout = this.$vuetify.breakpoint.xsOnly;
|
||||
|
||||
await this.getInitData();
|
||||
},
|
||||
beforeDestroy() {
|
||||
@@ -100,6 +97,9 @@ export default Vue.extend({
|
||||
'preferences',
|
||||
]),
|
||||
...mapGetters(['config']),
|
||||
phoneLayout() {
|
||||
return this.$vuetify.breakpoint.xsOnly;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
...mapMutations([
|
||||
|
||||
@@ -29,6 +29,9 @@
|
||||
<v-icon>mdi-pause</v-icon>
|
||||
</v-btn>
|
||||
<v-divider vertical inset />
|
||||
<v-btn icon @click="showInfo" title="Info" v-if="selectedRows.length <= 4">
|
||||
<v-icon>mdi-alert-circle</v-icon>
|
||||
</v-btn>
|
||||
<v-menu offset-y>
|
||||
<template v-slot:activator="{ on }">
|
||||
<v-btn icon v-on="on" title="Category">
|
||||
@@ -104,7 +107,7 @@
|
||||
:color="row.item.state | stateColor(true)"
|
||||
class="text-xs-center ma-0"
|
||||
>
|
||||
<span :class="row.item.progress | progressColorClass">{{ row.item.progress | progressText }}</span>
|
||||
<span :class="row.item.progress | progressColorClass">{{ row.item.progress | progress }}</span>
|
||||
</v-progress-linear>
|
||||
</td>
|
||||
<td>{{ row.item.state }}</td>
|
||||
@@ -123,13 +126,15 @@
|
||||
</v-data-table>
|
||||
</v-layout>
|
||||
|
||||
<confirm-delete-dialog v-if="deleteDialog" v-model="deleteDialog" :torrents="selectedRows" />
|
||||
<confirm-delete-dialog v-if="toDelete.length" v-model="toDelete" />
|
||||
<info-dialog v-if="toShowInfo.length" v-model="toShowInfo" :tab="infoTab" @change="infoTab = $event" />
|
||||
</v-container>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import ConfirmDeleteDialog from './dialogs/ConfirmDeleteDialog.vue';
|
||||
import InfoDialog from './dialogs/InfoDialog.vue';
|
||||
import { mapState, mapGetters, mapMutations } from 'vuex';
|
||||
import _ from 'lodash';
|
||||
import { api } from '../Api';
|
||||
@@ -218,6 +223,7 @@ export default Vue.extend({
|
||||
|
||||
components: {
|
||||
ConfirmDeleteDialog,
|
||||
InfoDialog,
|
||||
},
|
||||
|
||||
data() {
|
||||
@@ -238,7 +244,9 @@ export default Vue.extend({
|
||||
return {
|
||||
headers,
|
||||
selectedRows: [],
|
||||
deleteDialog: false,
|
||||
toDelete: [],
|
||||
toShowInfo: [],
|
||||
infoTab: null,
|
||||
pagination: null,
|
||||
};
|
||||
},
|
||||
@@ -290,9 +298,6 @@ export default Vue.extend({
|
||||
},
|
||||
|
||||
filters: {
|
||||
progressText(progress: number) {
|
||||
return Math.floor(progress * 100) + '%';
|
||||
},
|
||||
progressColorClass(progress: number) {
|
||||
const color = progress >= 0.5 ? 'white' : 'black';
|
||||
return color + '--text';
|
||||
@@ -323,7 +328,10 @@ export default Vue.extend({
|
||||
'updateConfig',
|
||||
]),
|
||||
confirmDelete() {
|
||||
this.deleteDialog = true;
|
||||
this.toDelete = this.selectedRows;
|
||||
},
|
||||
showInfo() {
|
||||
this.toShowInfo = this.selectedRows;
|
||||
},
|
||||
async resumeTorrents() {
|
||||
await api.resumeTorrents(this.selectedHashes);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<v-dialog :value="value" @input="$emit('input', $event)" width="40em">
|
||||
<v-dialog :value="true" @input="closeDialog" :fullscreen="phoneLayout" width="40em">
|
||||
<v-card>
|
||||
<v-card-title
|
||||
class="headline grey lighten-4"
|
||||
@@ -18,7 +18,7 @@
|
||||
<v-checkbox
|
||||
v-model="deleteFiles"
|
||||
prepend-icon="mdi-file-cancel"
|
||||
label="Also delete the files on the hard disk"
|
||||
label="Also delete files"
|
||||
/>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
@@ -43,18 +43,26 @@ import { api } from '@/Api';
|
||||
|
||||
export default Vue.extend({
|
||||
props: {
|
||||
torrents: Array,
|
||||
value: Boolean,
|
||||
value: Array,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
deleteFiles: false,
|
||||
submitting: false,
|
||||
torrents: [],
|
||||
};
|
||||
},
|
||||
created() {
|
||||
this.torrents = this.value;
|
||||
},
|
||||
computed: {
|
||||
phoneLayout() {
|
||||
return this.$vuetify.breakpoint.xsOnly;
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
closeDialog() {
|
||||
this.$emit('input', false);
|
||||
this.$emit('input', []);
|
||||
},
|
||||
async submit() {
|
||||
if (this.submitting) {
|
||||
@@ -76,4 +84,15 @@ export default Vue.extend({
|
||||
.torrents {
|
||||
overflow: auto;
|
||||
}
|
||||
.v-dialog--fullscreen {
|
||||
.v-card__text {
|
||||
padding-bottom: 52px;
|
||||
}
|
||||
|
||||
.v-card__actions {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
149
src/components/dialogs/InfoDialog.vue
Normal file
149
src/components/dialogs/InfoDialog.vue
Normal file
@@ -0,0 +1,149 @@
|
||||
<template>
|
||||
<v-dialog
|
||||
:value="true"
|
||||
@input="closeDialog"
|
||||
:width="dialogWidth"
|
||||
:fullscreen="phoneLayout"
|
||||
>
|
||||
<v-card>
|
||||
<v-card-title
|
||||
class="headline grey lighten-4"
|
||||
>
|
||||
<v-icon class="mr-2">mdi-alert-circle</v-icon>
|
||||
<span>Info</span>
|
||||
</v-card-title>
|
||||
<v-card-text>
|
||||
<v-tabs v-model="mTab">
|
||||
<!-- <v-tab>
|
||||
General
|
||||
</v-tab> -->
|
||||
<v-tab href="#trackers">
|
||||
Trackers
|
||||
</v-tab>
|
||||
<v-tab href="#peers">
|
||||
Peers
|
||||
</v-tab>
|
||||
<!-- <v-tab>
|
||||
Content
|
||||
</v-tab> -->
|
||||
</v-tabs>
|
||||
<v-tabs-items :value="mTab" touchless>
|
||||
<v-tab-item value="trackers">
|
||||
<panel
|
||||
v-for="torrent in torrents"
|
||||
:key="torrent.hash"
|
||||
:title="torrent.name"
|
||||
:single="torrents.length === 1"
|
||||
>
|
||||
<trackers
|
||||
:hash="torrent.hash"
|
||||
:isActive="mTab === 'trackers'"
|
||||
/>
|
||||
</panel>
|
||||
</v-tab-item>
|
||||
<v-tab-item value="peers">
|
||||
<panel
|
||||
v-for="torrent in torrents"
|
||||
:key="torrent.hash"
|
||||
:title="torrent.name"
|
||||
:single="torrents.length === 1"
|
||||
>
|
||||
<peers
|
||||
:hash="torrent.hash"
|
||||
:isActive="mTab === 'peers'"
|
||||
/>
|
||||
</panel>
|
||||
</v-tab-item>
|
||||
</v-tabs-items>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn flat @click="closeDialog">Close</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import _ from 'lodash';
|
||||
import Vue from 'vue'
|
||||
import Trackers from './Trackers.vue';
|
||||
import Peers from './Peers.vue';
|
||||
import Panel from './Panel.vue';
|
||||
|
||||
export default Vue.extend({
|
||||
components: {
|
||||
Trackers,
|
||||
Peers,
|
||||
Panel,
|
||||
},
|
||||
|
||||
props: {
|
||||
value: Array,
|
||||
tab: String,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
torrents: [],
|
||||
mTab: null,
|
||||
};
|
||||
},
|
||||
created() {
|
||||
this.torrents = this.value;
|
||||
this.mTab = this.tab;
|
||||
},
|
||||
computed: {
|
||||
phoneLayout() {
|
||||
return this.$vuetify.breakpoint.xsOnly;
|
||||
},
|
||||
dialogWidth() {
|
||||
return this.phoneLayout ? '100%' : '80%';
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
closeDialog() {
|
||||
this.$emit('input', []);
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
mTab(v) {
|
||||
this.$emit('change', v);
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
::v-deep .v-dialog {
|
||||
max-width: 1100px;
|
||||
}
|
||||
|
||||
::v-deep .v-datatable thead th, ::v-deep .v-datatable tbody td {
|
||||
padding: 0 2px !important;
|
||||
width: auto;
|
||||
height: auto;
|
||||
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
|
||||
&:first-child {
|
||||
padding: 0 0 0 8px !important;
|
||||
}
|
||||
&:last-child {
|
||||
padding-right: 8px !important;
|
||||
}
|
||||
}
|
||||
|
||||
.v-dialog--fullscreen {
|
||||
.v-card__text {
|
||||
padding-bottom: 52px;
|
||||
}
|
||||
|
||||
.v-card__actions {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<v-dialog :value="value" @input="$emit('input', $event)" scrollable :width="dialogWidth">
|
||||
<v-dialog :value="value" @input="$emit('input', $event)" scrollable :fullscreen="phoneLayout" :width="dialogWidth">
|
||||
<v-card>
|
||||
<v-card-title
|
||||
class="headline grey lighten-4"
|
||||
@@ -29,17 +29,19 @@
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue'
|
||||
import { api } from '../Api';
|
||||
import { sleep } from '../utils';
|
||||
import { api } from '@/Api';
|
||||
import { sleep } from '@/utils';
|
||||
import Taskable from '@/mixins/taskable';
|
||||
|
||||
export default Vue.extend({
|
||||
mixins: [Taskable],
|
||||
|
||||
props: {
|
||||
value: Boolean,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
logs: [],
|
||||
task: 0,
|
||||
destory: false,
|
||||
};
|
||||
},
|
||||
filters: {
|
||||
@@ -66,6 +68,9 @@ export default Vue.extend({
|
||||
dialogWidth() {
|
||||
return this.$vuetify.breakpoint.smAndDown ? '100%' : '70%';
|
||||
},
|
||||
phoneLayout() {
|
||||
return this.$vuetify.breakpoint.xsOnly;
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
closeDialog() {
|
||||
@@ -93,12 +98,6 @@ export default Vue.extend({
|
||||
async created() {
|
||||
await this.getLogs();
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.destory = true;
|
||||
if (this.task) {
|
||||
clearTimeout(this.task);
|
||||
}
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
41
src/components/dialogs/Panel.vue
Normal file
41
src/components/dialogs/Panel.vue
Normal file
@@ -0,0 +1,41 @@
|
||||
<template>
|
||||
<fieldset class="panel" v-if="!single">
|
||||
<legend v-text="title" />
|
||||
<div class="inner">
|
||||
<slot />
|
||||
</div>
|
||||
</fieldset>
|
||||
<div v-else>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue'
|
||||
export default Vue.extend({
|
||||
props: {
|
||||
single: Boolean,
|
||||
title: String,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.panel {
|
||||
margin-top: 1em;
|
||||
|
||||
.inner {
|
||||
max-height: 500px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
|
||||
fieldset {
|
||||
border-width: 1px;
|
||||
|
||||
legend {
|
||||
margin-left: 1em;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
133
src/components/dialogs/Peers.vue
Normal file
133
src/components/dialogs/Peers.vue
Normal file
@@ -0,0 +1,133 @@
|
||||
<template>
|
||||
<v-data-table
|
||||
:headers="headers"
|
||||
:items="peers"
|
||||
:hide-actions="true"
|
||||
>
|
||||
<template v-slot:items="row">
|
||||
<td class="ip">
|
||||
<img
|
||||
v-if="isWindows"
|
||||
class="country-flag"
|
||||
:title="row.item.country"
|
||||
:alt="codeToFlag(row.item.country_code).char"
|
||||
:src="codeToFlag(row.item.country_code).url"
|
||||
/>
|
||||
<template v-else>
|
||||
{{ codeToFlag(row.item.country_code).char }}
|
||||
</template>
|
||||
{{ row.item.ip }}
|
||||
<span class="grey--text">:{{ row.item.port }}</span>
|
||||
</td>
|
||||
<td>{{ row.item.connection }}</td>
|
||||
<td :title="row.item.flags_desc">{{ row.item.flags }}</td>
|
||||
<td>{{ row.item.client }}</td>
|
||||
<td>{{ row.item.progress | progress }}</td>
|
||||
<td>{{ row.item.dl_speed | size }}/s</td>
|
||||
<td>{{ row.item.up_speed | size }}/s</td>
|
||||
<td>{{ row.item.downloaded | size }}</td>
|
||||
<td>{{ row.item.uploaded | size }}</td>
|
||||
<td>{{ row.item.relevance | progress }}</td>
|
||||
<td>{{ row.item.files }}</td>
|
||||
</template>
|
||||
</v-data-table>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import _ from 'lodash';
|
||||
import Vue from 'vue';
|
||||
import { codeToFlag, isWindows } from '../../utils';
|
||||
import Taskable from '@/mixins/taskable';
|
||||
import { api } from '../../Api';
|
||||
|
||||
export default Vue.extend({
|
||||
mixins: [Taskable],
|
||||
|
||||
props: {
|
||||
hash: String,
|
||||
isActive: Boolean,
|
||||
},
|
||||
data() {
|
||||
const headers = [
|
||||
{ text: 'IP', value: 'ip' },
|
||||
{ text: 'Connection', value: 'connection' },
|
||||
{ text: 'Flags', value: 'flags' },
|
||||
{ text: 'Client', value: 'client' },
|
||||
{ text: 'Progress', value: 'progress' },
|
||||
{ text: 'DL Speed', value: 'dl_speed' },
|
||||
{ text: 'UP Speed', value: 'up_speed' },
|
||||
{ text: 'Downloaded', value: 'downloaded' },
|
||||
{ text: 'Uploaded', value: 'uploaded' },
|
||||
{ text: 'Relevance', value: 'relevance' },
|
||||
{ text: 'Files', value: 'files' },
|
||||
];
|
||||
|
||||
return {
|
||||
headers,
|
||||
peersObj: null,
|
||||
rid: null,
|
||||
isWindows,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
peers() {
|
||||
return _.map(this.peersObj, (value, key) => {
|
||||
return _.merge({}, value, { key });
|
||||
});
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
codeToFlag(code: string) {
|
||||
return codeToFlag(code);
|
||||
},
|
||||
async getPeers() {
|
||||
const resp = await api.getTorrentPeers(this.hash, this.rid);
|
||||
this.rid = resp.rid;
|
||||
|
||||
if (resp.full_update) {
|
||||
this.peersObj = resp.peers;
|
||||
} else {
|
||||
const tmp: any = _.cloneDeep(this.peersObj);
|
||||
if (resp.peers_removed) {
|
||||
for (const key of resp.peers_removed) {
|
||||
delete tmp[key];
|
||||
}
|
||||
}
|
||||
this.peersObj = _.merge(tmp, resp.peers);
|
||||
}
|
||||
|
||||
if (!this.isActive || this.destroy) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.task = setTimeout(this.getPeers, 2000);
|
||||
},
|
||||
},
|
||||
async created() {
|
||||
if (this.isActive) {
|
||||
await this.getPeers();
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
async isActive(v) {
|
||||
if (v) {
|
||||
await this.getPeers();
|
||||
} else {
|
||||
this.cancelTask();
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
::v-deep .ip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.country-flag {
|
||||
width: 1.5em;
|
||||
margin-right: 0.5em;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
94
src/components/dialogs/Trackers.vue
Normal file
94
src/components/dialogs/Trackers.vue
Normal file
@@ -0,0 +1,94 @@
|
||||
<template>
|
||||
<v-data-table
|
||||
:headers="headers"
|
||||
:items="trackers"
|
||||
:hide-actions="true"
|
||||
>
|
||||
<template v-slot:items="row">
|
||||
<td>{{ row.item.tier }}</td>
|
||||
<td>{{ row.item.url }}</td>
|
||||
<td>{{ row.item.status | formatTrackerStatus }}</td>
|
||||
<td>{{ row.item.num_peers | formatTrackerNum }}</td>
|
||||
<td>{{ row.item.num_seeds | formatTrackerNum }}</td>
|
||||
<td>{{ row.item.num_leeches | formatTrackerNum }}</td>
|
||||
<td>{{ row.item.num_downloaded | formatTrackerNum }}</td>
|
||||
<td>{{ row.item.msg }}</td>
|
||||
</template>
|
||||
</v-data-table>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue'
|
||||
import { api } from '../../Api';
|
||||
import Taskable from '@/mixins/taskable';
|
||||
|
||||
export default Vue.extend({
|
||||
mixins: [Taskable],
|
||||
|
||||
props: {
|
||||
hash: String,
|
||||
isActive: Boolean,
|
||||
},
|
||||
data() {
|
||||
const headers = [
|
||||
{ text: '#', value: 'tier' },
|
||||
{ text: 'URL', value: 'url' },
|
||||
{ text: 'Status', value: 'status' },
|
||||
{ text: 'Peers', value: 'num_peers' },
|
||||
{ text: 'Seeds', value: 'num_seeds' },
|
||||
{ text: 'Leeches', value: 'num_leeches' },
|
||||
{ text: 'Downloaded', value: 'num_downloaded' },
|
||||
{ text: 'Message', value: 'msg' },
|
||||
];
|
||||
|
||||
return {
|
||||
headers,
|
||||
trackers: [],
|
||||
};
|
||||
},
|
||||
filters: {
|
||||
formatTrackerStatus(status: number) {
|
||||
const map = [
|
||||
'Disabled',
|
||||
'Contacted',
|
||||
'Working',
|
||||
'Not working',
|
||||
'Not contacted',
|
||||
];
|
||||
|
||||
return map[status];
|
||||
},
|
||||
formatTrackerNum(num: number) {
|
||||
if (num === -1) {
|
||||
return 'N/A';
|
||||
}
|
||||
|
||||
return num.toString();
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
async getTracker() {
|
||||
this.trackers = await api.getTorrentTracker(this.hash);
|
||||
if (!this.isActive || this.destroy) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.task = setTimeout(this.getTracker, 5000);
|
||||
},
|
||||
},
|
||||
async created() {
|
||||
if (this.isActive) {
|
||||
await this.getTracker();
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
async isActive(v) {
|
||||
if (v) {
|
||||
await this.getTracker();
|
||||
} else {
|
||||
this.cancelTask();
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
</script>
|
||||
@@ -16,6 +16,7 @@ export function formatSize(value: number) {
|
||||
}
|
||||
|
||||
Vue.filter('formatSize', formatSize);
|
||||
Vue.filter('size', formatSize);
|
||||
|
||||
export interface DurationOptions {
|
||||
dayLimit?: number;
|
||||
@@ -86,6 +87,12 @@ Vue.filter('formatTimestamp', (timestamp: number) => {
|
||||
export function formatAsDuration(date: number, options?: DurationOptions) {
|
||||
const duration = (Date.now() / 1000) - date;
|
||||
return formatDuration(duration, options);
|
||||
};
|
||||
}
|
||||
|
||||
Vue.filter('formatAsDuration', formatAsDuration);
|
||||
|
||||
export function formatProgress(progress: number) {
|
||||
return Math.floor(progress * 100) + '%';
|
||||
}
|
||||
|
||||
Vue.filter('progress', formatProgress);
|
||||
|
||||
22
src/mixins/taskable.ts
Normal file
22
src/mixins/taskable.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import Vue from 'vue';
|
||||
|
||||
export default Vue.extend({
|
||||
data() {
|
||||
return {
|
||||
destory: false,
|
||||
task: 0,
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
cancelTask() {
|
||||
if (this.task) {
|
||||
clearTimeout(this.task);
|
||||
this.task = 0;
|
||||
}
|
||||
},
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.destory = true;
|
||||
this.cancelTask();
|
||||
},
|
||||
});
|
||||
17
src/utils.ts
17
src/utils.ts
@@ -46,3 +46,20 @@ export function torrentIsState(type: StateType, state: string) {
|
||||
export function sleep(ms: number) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
export function codeToFlag(code: string) {
|
||||
const magicNumber = 0x1F1A5;
|
||||
|
||||
code = code.toUpperCase();
|
||||
const codePoints = [...code].map((c) => magicNumber + c.charCodeAt(0));
|
||||
const char = String.fromCodePoint(...codePoints);
|
||||
const url = 'https://cdn.jsdelivr.net/npm/twemoji/2/svg/' +
|
||||
`${codePoints[0].toString(16)}-${codePoints[1].toString(16)}.svg`;
|
||||
|
||||
return {
|
||||
char,
|
||||
url,
|
||||
};
|
||||
}
|
||||
|
||||
export const isWindows = navigator.userAgent.includes('Windows');
|
||||
|
||||
Reference in New Issue
Block a user