mirror of
https://github.com/gotson/komga.git
synced 2025-01-09 04:08:00 +08:00
feat(webui): history view
This commit is contained in:
parent
88f7f57a5d
commit
f8bea23b2a
@ -42,10 +42,10 @@
|
||||
},
|
||||
"book_card": {
|
||||
"error": "Error",
|
||||
"no_release_date": "No release date",
|
||||
"unknown": "To be analyzed",
|
||||
"unread": "Unread",
|
||||
"unsupported": "Unsupported",
|
||||
"no_release_date": "No release date"
|
||||
"unsupported": "Unsupported"
|
||||
},
|
||||
"book_import": {
|
||||
"button_browse": "Browse",
|
||||
@ -563,6 +563,13 @@
|
||||
"HARDLINK": "Hardlink/Copy Files",
|
||||
"MOVE": "Move Files"
|
||||
},
|
||||
"historical_event_type": {
|
||||
"BookConverted": "Book converted",
|
||||
"BookFileDeleted": "Book file deleted",
|
||||
"BookImported": "Book imported",
|
||||
"DuplicatePageDeleted": "Duplicate page deleted",
|
||||
"SeriesFolderDeleted": "Series folder deleted"
|
||||
},
|
||||
"media_status": {
|
||||
"ERROR": "Error",
|
||||
"OUTDATED": "Outdated",
|
||||
@ -642,6 +649,16 @@
|
||||
"filter": "filter",
|
||||
"sort": "sort"
|
||||
},
|
||||
"history": {
|
||||
"header": {
|
||||
"book": "Book",
|
||||
"date": "Date",
|
||||
"details": "Details",
|
||||
"series": "Series",
|
||||
"type": "Type"
|
||||
},
|
||||
"title": "History"
|
||||
},
|
||||
"home": {
|
||||
"theme": "Theme",
|
||||
"translation": "Translation"
|
||||
|
@ -27,6 +27,7 @@ import komgaOauth2 from './plugins/komga-oauth2.plugin'
|
||||
import komgaLogin from './plugins/komga-login.plugin'
|
||||
import komgaPageHashes from './plugins/komga-pagehashes.plugin'
|
||||
import komgaMetrics from './plugins/komga-metrics.plugin'
|
||||
import komgaHistory from './plugins/komga-history.plugin'
|
||||
import vuetify from './plugins/vuetify'
|
||||
import logger from './plugins/logger.plugin'
|
||||
import './public-path'
|
||||
@ -70,6 +71,7 @@ Vue.use(komgaOauth2, {http: Vue.prototype.$http})
|
||||
Vue.use(komgaLogin, {http: Vue.prototype.$http})
|
||||
Vue.use(komgaPageHashes, {http: Vue.prototype.$http})
|
||||
Vue.use(komgaMetrics, {http: Vue.prototype.$http})
|
||||
Vue.use(komgaHistory, {http: Vue.prototype.$http})
|
||||
|
||||
|
||||
Vue.config.productionTip = false
|
||||
|
17
komga-webui/src/plugins/komga-history.plugin.ts
Normal file
17
komga-webui/src/plugins/komga-history.plugin.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import {AxiosInstance} from 'axios'
|
||||
import _Vue from 'vue'
|
||||
import KomgaHistoryService from '@/services/komga-history.service'
|
||||
|
||||
export default {
|
||||
install(
|
||||
Vue: typeof _Vue,
|
||||
{http}: { http: AxiosInstance }) {
|
||||
Vue.prototype.$komgaHistory = new KomgaHistoryService(http)
|
||||
},
|
||||
}
|
||||
|
||||
declare module 'vue/types/vue' {
|
||||
interface Vue {
|
||||
$komgaHistory: KomgaHistoryService;
|
||||
}
|
||||
}
|
@ -128,6 +128,12 @@ const router = new Router({
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '/history',
|
||||
name: 'history',
|
||||
component: () => import(/* webpackChunkName: "history" */ './views/HistoryView.vue'),
|
||||
},
|
||||
|
||||
{
|
||||
path: '/account',
|
||||
name: 'account',
|
||||
|
29
komga-webui/src/services/komga-history.service.ts
Normal file
29
komga-webui/src/services/komga-history.service.ts
Normal file
@ -0,0 +1,29 @@
|
||||
import {AxiosInstance} from 'axios'
|
||||
import {HistoricalEventDto} from '@/types/komga-history'
|
||||
|
||||
const qs = require('qs')
|
||||
|
||||
const API_HISTORY = '/api/v1/history'
|
||||
|
||||
export default class KomgaHistoryService {
|
||||
private http: AxiosInstance
|
||||
|
||||
constructor(http: AxiosInstance) {
|
||||
this.http = http
|
||||
}
|
||||
|
||||
async getAll(pageRequest?: PageRequest): Promise<Page<HistoricalEventDto>> {
|
||||
try {
|
||||
return (await this.http.get(API_HISTORY, {
|
||||
params: pageRequest,
|
||||
paramsSerializer: params => qs.stringify(params, {indices: false}),
|
||||
})).data
|
||||
} catch (e) {
|
||||
let msg = 'An error occurred while trying to retrieve historical events'
|
||||
if (e.response.data.message) {
|
||||
msg += `: ${e.response.data.message}`
|
||||
}
|
||||
throw new Error(msg)
|
||||
}
|
||||
}
|
||||
}
|
7
komga-webui/src/types/komga-history.ts
Normal file
7
komga-webui/src/types/komga-history.ts
Normal file
@ -0,0 +1,7 @@
|
||||
export interface HistoricalEventDto {
|
||||
type: string,
|
||||
timestamp: string,
|
||||
bookId?: string,
|
||||
seriesId?: string,
|
||||
properties: Record<string, string>[],
|
||||
}
|
221
komga-webui/src/views/HistoryView.vue
Normal file
221
komga-webui/src/views/HistoryView.vue
Normal file
@ -0,0 +1,221 @@
|
||||
<template>
|
||||
<v-container fluid class="pa-6">
|
||||
<v-data-table
|
||||
:headers="headers"
|
||||
:items="items"
|
||||
:options.sync="options"
|
||||
:server-items-length="totalElements"
|
||||
:loading="loading"
|
||||
sort-by="timestamp"
|
||||
:sort-desc="true"
|
||||
multi-sort
|
||||
class="elevation-1"
|
||||
:footer-props="{
|
||||
itemsPerPageOptions: [20, 50, 100]
|
||||
}"
|
||||
>
|
||||
<template v-slot:item.type="{ item }">
|
||||
<v-icon :title="$t(`enums.historical_event_type.${item.type}`)">{{ getIcon(item.type) }}</v-icon>
|
||||
</template>
|
||||
|
||||
<template v-slot:item.seriesId="{ item }">
|
||||
<router-link v-if="getSeries(item.seriesId)"
|
||||
:to="{name: 'browse-series', params: {seriesId: item.seriesId}}"
|
||||
class="link-underline"
|
||||
>{{ getSeries(item.seriesId).metadata.title }}
|
||||
</router-link>
|
||||
<template v-else>{{ item.seriesId }}</template>
|
||||
</template>
|
||||
|
||||
<template v-slot:item.bookId="{ item }">
|
||||
<router-link v-if="getBook(item.bookId)"
|
||||
:to="{name: 'browse-book', params: {bookId: item.bookId}}"
|
||||
class="link-underline"
|
||||
>{{ getBook(item.bookId).metadata.title }}
|
||||
</router-link>
|
||||
<template v-else>{{ item.bookId }}</template>
|
||||
</template>
|
||||
|
||||
<template v-slot:item.timestamp="{ item }">
|
||||
{{
|
||||
new Intl.DateTimeFormat($i18n.locale, {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short'
|
||||
}).format(new Date(item.timestamp))
|
||||
}}
|
||||
</template>
|
||||
|
||||
<template v-slot:item.properties="{ item }">
|
||||
<v-btn icon small @click="showDetails(item)">
|
||||
<v-icon small>mdi-information</v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
|
||||
<template v-slot:footer.prepend>
|
||||
<v-btn icon @click="loadData">
|
||||
<v-icon>mdi-refresh</v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
|
||||
</v-data-table>
|
||||
|
||||
<v-dialog
|
||||
v-model="dialogDetails"
|
||||
scrollable
|
||||
>
|
||||
<v-card v-if="dialogDetailsItem">
|
||||
<v-card-title>{{ $t(`enums.historical_event_type.${dialogDetailsItem.type}`) }}</v-card-title>
|
||||
<v-card-text>
|
||||
<v-simple-table>
|
||||
<tbody>
|
||||
<tr v-for="[key, value] in Object.entries(dialogDetailsItem.properties)" :key="key">
|
||||
<td class="text-capitalize font-weight-bold">{{ key }}</td>
|
||||
<td>{{ value }}</td>
|
||||
</tr>
|
||||
<tr v-if="getPageHash(dialogDetailsItem)">
|
||||
<td class="font-weight-bold">Page</td>
|
||||
<td>
|
||||
<v-img
|
||||
width="200"
|
||||
height="300"
|
||||
contain
|
||||
:src="pageHashKnownThumbnailUrl(getPageHash(dialogDetailsItem))"
|
||||
style="cursor: zoom-in"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</v-simple-table>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer/>
|
||||
<v-btn @click="dialogDetails = false" text>{{ $t('common.close') }}</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</v-container>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue'
|
||||
import {HistoricalEventDto} from '@/types/komga-history'
|
||||
import {SeriesDto} from '@/types/komga-series'
|
||||
import {BookDto} from '@/types/komga-books'
|
||||
import {pageHashKnownThumbnailUrl} from '@/functions/urls'
|
||||
import {PageHashKnownDto} from '@/types/komga-pagehashes'
|
||||
|
||||
export default Vue.extend({
|
||||
name: 'HistoryView',
|
||||
data: function () {
|
||||
return {
|
||||
pageHashKnownThumbnailUrl,
|
||||
items: [] as HistoricalEventDto[],
|
||||
totalElements: 0,
|
||||
loading: true,
|
||||
options: {} as any,
|
||||
dialogDetails: false,
|
||||
dialogDetailsItem: undefined as HistoricalEventDto | undefined,
|
||||
seriesCache: [] as SeriesDto[],
|
||||
seriesCacheNotFound: [] as string[],
|
||||
booksCache: [] as BookDto[],
|
||||
booksCacheNotFound: [] as string[],
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
options: {
|
||||
handler() {
|
||||
this.loadData()
|
||||
},
|
||||
deep: true,
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
headers(): object[] {
|
||||
return [
|
||||
{text: this.$t('history.header.type').toString(), value: 'type'},
|
||||
{text: this.$t('history.header.series').toString(), value: 'seriesId'},
|
||||
{text: this.$t('history.header.book').toString(), value: 'bookId'},
|
||||
{text: this.$t('history.header.date').toString(), value: 'timestamp'},
|
||||
{text: this.$t('history.header.details').toString(), value: 'properties', sortable: false},
|
||||
]
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
getPageHash(item: HistoricalEventDto): PageHashKnownDto | undefined {
|
||||
if (item.type !== 'DuplicatePageDeleted') return undefined
|
||||
return {
|
||||
hash: item.properties['page file hash' as any],
|
||||
size: item.properties['page file size' as any],
|
||||
mediaType: item.properties['page media type' as any],
|
||||
} as any
|
||||
},
|
||||
getSeries(seriesId: string): SeriesDto | undefined {
|
||||
return this.seriesCache.find(x => x.id === seriesId)
|
||||
},
|
||||
getBook(bookId: string): BookDto | undefined {
|
||||
return this.booksCache.find(x => x.id === bookId)
|
||||
},
|
||||
showDetails(item: HistoricalEventDto) {
|
||||
this.dialogDetailsItem = item
|
||||
this.dialogDetails = true
|
||||
},
|
||||
getIcon(type: string): string {
|
||||
switch (type) {
|
||||
case 'BookFileDeleted':
|
||||
return 'mdi-file-remove'
|
||||
case 'SeriesFolderDeleted':
|
||||
return 'mdi-folder-remove'
|
||||
case 'DuplicatePageDeleted':
|
||||
return 'mdi-book-minus'
|
||||
case 'BookConverted':
|
||||
return 'mdi-archive-refresh'
|
||||
case 'BookImported':
|
||||
return 'mdi-import'
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
},
|
||||
async loadData() {
|
||||
this.loading = true
|
||||
|
||||
const {sortBy, sortDesc, page, itemsPerPage} = this.options
|
||||
|
||||
const pageRequest = {
|
||||
page: page - 1,
|
||||
size: itemsPerPage,
|
||||
sort: [],
|
||||
} as PageRequest
|
||||
|
||||
for (let i = 0; i < sortBy.length; i++) {
|
||||
pageRequest.sort!!.push(`${sortBy[i]},${sortDesc[i] ? 'desc' : 'asc'}`)
|
||||
}
|
||||
|
||||
const itemsPage = await this.$komgaHistory.getAll(pageRequest)
|
||||
this.totalElements = itemsPage.totalElements
|
||||
this.items = itemsPage.content
|
||||
|
||||
for (const seriesId of new Set(this.items.map(x => x.seriesId))) {
|
||||
if (seriesId && !this.seriesCacheNotFound.includes(seriesId) && !this.getSeries(seriesId)) {
|
||||
this.$komgaSeries.getOneSeries(seriesId)
|
||||
.then(s => this.seriesCache.push(s))
|
||||
.catch(() => this.seriesCacheNotFound.push(seriesId))
|
||||
}
|
||||
}
|
||||
|
||||
for (const bookId of new Set(this.items.map(x => x.bookId))) {
|
||||
if (bookId && !this.booksCacheNotFound.includes(bookId) && !this.getBook(bookId)) {
|
||||
this.$komgaBooks.getBook(bookId)
|
||||
.then(b => this.booksCache.push(b))
|
||||
.catch(() => this.booksCacheNotFound.push(bookId))
|
||||
}
|
||||
}
|
||||
|
||||
this.loading = false
|
||||
},
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
@ -105,6 +105,15 @@
|
||||
</v-list-item-content>
|
||||
</v-list-item>
|
||||
|
||||
<v-list-item :to="{name: 'history'}" v-if="isAdmin">
|
||||
<v-list-item-icon>
|
||||
<v-icon>mdi-clock-time-four-outline</v-icon>
|
||||
</v-list-item-icon>
|
||||
<v-list-item-content>
|
||||
<v-list-item-title>{{ $t('history.title') }}</v-list-item-title>
|
||||
</v-list-item-content>
|
||||
</v-list-item>
|
||||
|
||||
<v-list-item :to="{name: 'settings'}" v-if="isAdmin">
|
||||
<v-list-item-action>
|
||||
<v-icon>mdi-cog</v-icon>
|
||||
|
Loading…
Reference in New Issue
Block a user