es lint fix

This commit is contained in:
thofx
2023-07-22 16:09:07 +08:00
parent 48749b29fe
commit 018778488a
80 changed files with 3995 additions and 2982 deletions

View File

@@ -3,186 +3,19 @@ module.exports = {
browser: true,
es2021: true,
},
extends: [
'@antfu/eslint-config-vue',
'plugin:vue/vue3-recommended',
'plugin:import/recommended',
'plugin:import/typescript',
'plugin:promise/recommended',
'plugin:sonarjs/recommended',
'plugin:@typescript-eslint/recommended',
// 'plugin:unicorn/recommended',
],
parser: 'vue-eslint-parser',
parserOptions: {
ecmaVersion: 13,
parser: '@typescript-eslint/parser',
sourceType: 'module',
},
plugins: [
'vue',
'@typescript-eslint',
'regex',
],
extends: ['@antfu/eslint-config-vue', 'plugin:sonarjs/recommended'],
ignorePatterns: ['src/@iconify/*.js', 'node_modules', 'dist', '*.d.ts'],
plugins: ['regex'],
rules: {
'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
// indentation (Already present in TypeScript)
'comma-spacing': ['error', { before: false, after: true }],
'key-spacing': ['error', { afterColon: true }],
'sonarjs/no-duplicate-string': 'warn',
'vue/first-attribute-linebreak': ['error', {
singleline: 'beside',
multiline: 'below',
}],
'antfu/top-level-function': 'off',
// indentation (Already present in TypeScript)
'indent': ['error', 2],
// Enforce trailing comma (Already present in TypeScript)
'comma-dangle': ['error', 'always-multiline'],
// Enforce consistent spacing inside braces of object (Already present in TypeScript)
'object-curly-spacing': ['error', 'always'],
// Disable max-len
'max-len': 'off',
// we don't want it
'semi': ['error', 'never'],
// add parens ony when required in arrow function
'arrow-parens': ['error', 'as-needed'],
// add new line above comment
'newline-before-return': 'error',
// add new line above comment
'lines-around-comment': [
'error',
{
beforeBlockComment: true,
beforeLineComment: true,
allowBlockStart: true,
allowClassStart: true,
allowObjectStart: true,
allowArrayStart: true,
},
],
// Ignore _ as unused variable
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_+$' }],
'array-element-newline': ['error', 'consistent'],
'array-bracket-newline': ['error', 'consistent'],
'vue/multi-word-component-names': 'off',
'padding-line-between-statements': [
'error',
{ blankLine: 'always', prev: 'expression', next: 'const' },
{ blankLine: 'always', prev: 'const', next: 'expression' },
{ blankLine: 'always', prev: 'multiline-const', next: '*' },
{ blankLine: 'always', prev: '*', next: 'multiline-const' },
],
// Plugin: eslint-plugin-import
'import/prefer-default-export': 'off',
'import/newline-after-import': ['error', { count: 1 }],
'no-restricted-imports': ['error', 'vuetify/components'],
// For omitting extension for ts files
'import/extensions': [
'error',
'ignorePackages',
{
js: 'never',
jsx: 'never',
ts: 'never',
tsx: 'never',
},
],
// ignore virtual files
'import/no-unresolved': [2, {
ignore: [
'~pages$',
'virtual:generated-layouts',
// Ignore vite's ?raw imports
'.*\?raw',
],
}],
// Thanks: https://stackoverflow.com/a/63961972/10796681
'no-shadow': 'off',
'@typescript-eslint/no-shadow': ['error'],
'@typescript-eslint/consistent-type-imports': 'error',
// Plugin: eslint-plugin-promise
'promise/always-return': 'off',
'promise/catch-or-return': 'off',
// ESLint plugin vue
'vue/block-tag-newline': 'error',
'vue/component-api-style': 'error',
'vue/component-name-in-template-casing': ['error', 'PascalCase', { registeredComponentsOnly: false }],
'vue/custom-event-name-casing': ['error', 'camelCase', {
ignores: [
'/^(click):[a-z]+((\d)|([A-Z0-9][a-z0-9]+))*([A-Z])?/',
],
}],
'vue/define-macros-order': 'error',
'vue/html-comment-content-newline': 'error',
'vue/html-comment-content-spacing': 'error',
'vue/html-comment-indent': 'error',
'vue/match-component-file-name': 'error',
'vue/no-child-content': 'error',
'vue/require-default-prop': 'off',
// NOTE this rule only supported in SFC, Users of the unplugin-vue-define-options should disable that rule: https://github.com/vuejs/eslint-plugin-vue/issues/1886
// 'vue/no-duplicate-attr-inheritance': 'error',
'vue/no-empty-component-block': 'error',
'vue/no-multiple-objects-in-class': 'error',
'vue/no-reserved-component-names': 'error',
'vue/no-template-target-blank': 'error',
'vue/no-useless-mustaches': 'error',
'vue/no-useless-v-bind': 'error',
'vue/padding-line-between-blocks': 'error',
'vue/prefer-separate-static-class': 'error',
'vue/prefer-true-attribute-shorthand': 'error',
'vue/v-on-function-call': 'error',
'vue/no-restricted-class': ['error', '/^(p|m)(l|r)-/'],
'vue/valid-v-slot': ['error', {
allowModifiers: true,
}],
// -- Extension Rules
'vue/no-irregular-whitespace': 'error',
'vue/template-curly-spacing': 'error',
// -- Sonarlint
'sonarjs/no-duplicate-string': 'off',
'sonarjs/no-nested-template-literals': 'off',
// -- Unicorn
// 'unicorn/filename-case': 'off',
// 'unicorn/prevent-abbreviations': ['error', {
// replacements: {
// props: false,
// },
// }],
// Internal Rules
'valid-appcardcode-code-prop': 'error',
'valid-appcardcode-demo-sfc': 'error',
// https://github.com/gmullerb/eslint-plugin-regex
'regex/invalid': [
'error',
@@ -213,23 +46,16 @@ module.exports = {
},
{
regex: 'useLayouts\\(',
message: '`useLayouts` composable is only allowed in @layouts & @core directory. Please use `useThemeConfig` composable instead.',
message:
'`useLayouts` composable is only allowed in @layouts & @core directory. Please use `useThemeConfig` composable instead.',
files: {
inspect: '^(?!.*(@core|@layouts)).*',
},
},
{
regex: 'import axios from \'axios\'',
replacement: 'import axios from \'@axios\'',
message: 'Use axios instances created in \'src/plugin/axios.ts\' instead of unconfigured axios',
files: {
ignore: '^.*plugins/axios.ts.*',
},
},
],
// Ignore files
'\.eslintrc\.js',
'.eslintrc.js',
],
},
settings: {

View File

@@ -1,20 +1,20 @@
{
"arrowParens": "avoid",
"bracketSpacing": true,
"htmlWhitespaceSensitivity": "css",
"insertPragma": false,
"jsxBracketSameLine": false,
"jsxSingleQuote": true,
"printWidth": 120,
"proseWrap": "preserve",
"quoteProps": "as-needed",
"requirePragma": false,
"semi": false,
"singleQuote": true,
"tabWidth": 2,
"trailingComma": "all",
"useTabs": false,
"vueIndentScriptAndStyle": false,
"endOfLine": "lf",
"singleAttributePerLine": true
}
"arrowParens": "avoid",
"bracketSpacing": true,
"htmlWhitespaceSensitivity": "css",
"insertPragma": false,
"jsxBracketSameLine": false,
"jsxSingleQuote": true,
"printWidth": 120,
"proseWrap": "preserve",
"quoteProps": "preserve",
"requirePragma": false,
"semi": false,
"singleQuote": true,
"tabWidth": 2,
"trailingComma": "all",
"useTabs": false,
"vueIndentScriptAndStyle": false,
"endOfLine": "lf",
"singleAttributePerLine": false
}

View File

@@ -1,6 +1,7 @@
{
"recommendations": [
"dbaeumer.vscode-eslint",
"esbenp.prettier-vscode",
"mgmcdermott.vscode-language-babel",
"editorconfig.editorconfig",
"xabikos.javascriptsnippets",
@@ -12,4 +13,4 @@
"cipchk.cssrem",
"matijao.vue-nuxt-snippets"
]
}
}

211
.vscode/settings.json vendored
View File

@@ -1,114 +1,107 @@
{
"editor.formatOnSave": true,
"javascript.updateImportsOnFileMove.enabled": "always",
"[javascript]": {
"editor.defaultFormatter": "dbaeumer.vscode-eslint"
"editor.formatOnSave": true,
"javascript.updateImportsOnFileMove.enabled": "always",
"editor.defaultFormatter": "esbenp.prettier-vscode",
"files.eol": "\n",
"[javascript]": {
"editor.formatOnSave": false
},
"[typescript]": {
"editor.formatOnSave": false
},
"[markdown]": {
"editor.defaultFormatter": "DavidAnson.vscode-markdownlint"
},
// SCSS
"[scss]": {
"editor.defaultFormatter": "stylelint.vscode-stylelint"
},
// JSON
"[json]": {
"editor.defaultFormatter": "vscode.json-language-features"
},
"[jsonc]": {
"editor.defaultFormatter": "vscode.json-language-features"
},
// Vue
"[vue]": {
"editor.formatOnSave": false
},
// Extension: Volar
"volar.preview.port": 3000,
"volar.completion.preferredTagNameCase": "pascal",
"editor.codeActionsOnSave": {
"source.fixAll.eslint": true,
"source.fixAll.stylelint": true
},
"eslint.alwaysShowStatus": true,
"eslint.format.enable": true,
// Extension: Stylelint
"stylelint.packageManager": "yarn",
"stylelint.validate": [
"css",
"scss",
"vue"
],
// Extension: Spell Checker
"cSpell.words": [
"Composables",
"Customizer",
"flagpack",
"Iconify",
"psudo",
"stylelint",
"touchless",
"triggerer",
"vuetify"
],
// Extension: Comment Anchors
"commentAnchors.tags.list": [
{
"tag": "",
"scope": "hidden",
// This color is taken from "Better Comments" Extension (?)
"highlightColor": "#3498DB",
"styleComment": true,
"isItalic": false
},
"[typescript]": {
"editor.defaultFormatter": "dbaeumer.vscode-eslint",
"editor.autoClosingBrackets": "always"
{
"tag": "👉",
"scope": "file",
// This color is taken from "Better Comments" Extension (*)
"highlightColor": "#98C379",
"styleComment": true,
"isItalic": false
},
"[markdown]": {
"editor.defaultFormatter": "DavidAnson.vscode-markdownlint"
},
// SCSS
"[scss]": {
"editor.defaultFormatter": "stylelint.vscode-stylelint"
},
// JSON
"[json]": {
"editor.defaultFormatter": "vscode.json-language-features"
},
"[jsonc]": {
"editor.defaultFormatter": "vscode.json-language-features"
},
// Vue
"[vue]": {
"editor.defaultFormatter": "Wscats.vue",
},
// Extension: Volar
"volar.preview.port": 3000,
"volar.completion.preferredTagNameCase": "pascal",
// Extension: ESLint
"eslint.options": {
"rulePaths": [
"eslint-internal-rules"
]
},
"editor.codeActionsOnSave": {
"source.fixAll.eslint": true,
"source.fixAll.stylelint": true,
"source.organizeImports": true,
},
"eslint.alwaysShowStatus": true,
"eslint.format.enable": true,
"eslint.packageManager": "yarn",
// Extension: Stylelint
"stylelint.packageManager": "yarn",
"stylelint.validate": [
"css",
"scss",
"vue"
{
"tag": "❗",
"scope": "hidden",
// This color is taken from "Better Comments" Extension (*)
"highlightColor": "#FF2D00",
"styleComment": true,
"isItalic": false
}
],
// Extension: fabiospampinato.vscode-highlight
"highlight.regexFlags": "gi",
"highlight.regexes": {
// We flaged this for enforcing logical CSS properties
"(100vh|translate|margin:|padding:|margin-left|margin-right|rotate|text-align|border-top|border-right|border-bottom|border-left|float|background-position|transform|width|height|top|left|bottom|right|float|clear|(p|m)(l|r)-|border-(start|end)-(start|end)-radius)": [
{
// "rangeBehavior": 1,
"borderWidth": "1px",
"borderColor": "tomato",
"borderStyle": "solid"
}
],
// Extension: Spell Checker
"cSpell.words": [
"Composables",
"Customizer",
"flagpack",
"Iconify",
"psudo",
"stylelint",
"touchless",
"triggerer",
"vuetify"
],
// Extension: Comment Anchors
"commentAnchors.tags.list": [
{
"tag": "",
"scope": "hidden",
// This color is taken from "Better Comments" Extension (?)
"highlightColor": "#3498DB",
"styleComment": true,
"isItalic": false,
},
{
"tag": "👉",
"scope": "file",
// This color is taken from "Better Comments" Extension (*)
"highlightColor": "#98C379",
"styleComment": true,
"isItalic": false
},
{
"tag": "❗",
"scope": "hidden",
// This color is taken from "Better Comments" Extension (*)
"highlightColor": "#FF2D00",
"styleComment": true,
"isItalic": false,
},
],
// Extension: fabiospampinato.vscode-highlight
"highlight.regexFlags": "gi",
"highlight.regexes": {
// We flaged this for enforcing logical CSS properties
"(100vh|translate|margin:|padding:|margin-left|margin-right|rotate|text-align|border-top|border-right|border-bottom|border-left|float|background-position|transform|width|height|top|left|bottom|right|float|clear|(p|m)(l|r)-|border-(start|end)-(start|end)-radius)": [
{
// "rangeBehavior": 1,
"borderWidth": "1px",
"borderColor": "tomato",
"borderStyle": "solid"
}
],
"(overflow-x:|overflow-y:)": [
{
// "rangeBehavior": 1,
"borderWidth": "1px",
"borderColor": "green",
"borderStyle": "solid"
}
]
},
"vue3snippets.enable-compile-vue-file-on-did-save-code": true
"(overflow-x:|overflow-y:)": [
{
// "rangeBehavior": 1,
"borderWidth": "1px",
"borderColor": "green",
"borderStyle": "solid"
}
]
},
"vue3snippets.enable-compile-vue-file-on-did-save-code": true
}

View File

@@ -7,7 +7,7 @@
"build": "vite build",
"preview": "vite preview --port 5050",
"typecheck": "vue-tsc --noEmit",
"lint": "eslint . -c .eslintrc.js --fix --rulesdir eslint-internal-rules/ --ext .ts,.js,.vue,.tsx,.jsx",
"lint": "eslint . -c .eslintrc.js --fix --ext .ts,.js,.vue,.tsx,.jsx",
"build:icons": "tsc -b src/@iconify && node src/@iconify/build-icons.js",
"postinstall": "npm run build:icons"
},

View File

@@ -1,15 +1,18 @@
<script lang="ts" setup>
// 定义触发的自定义事件
const emit = defineEmits(["click"]);
const emit = defineEmits(['click'])
// 按钮点击
const onClick = () => {
emit("click");
};
function onClick() {
emit('click')
}
</script>
<template>
<IconBtn class="absolute right-3 top-3" @click="onClick">
<IconBtn
class="absolute right-3 top-3"
@click="onClick"
>
<VIcon icon="mdi-close" />
</IconBtn>
</template>

View File

@@ -1,20 +1,26 @@
<script setup lang="ts">
interface Props {
errorCode?: string;
errorTitle?: string;
errorDescription?: string;
errorCode?: string
errorTitle?: string
errorDescription?: string
}
const props = defineProps<Props>();
const props = defineProps<Props>()
</script>
<template>
<div class="text-center mb-4">
<!-- 👉 Title and subtitle -->
<h1 v-if="props.errorCode" class="text-h1 font-weight-medium">
<h1
v-if="props.errorCode"
class="text-h1 font-weight-medium"
>
{{ props.errorCode }}
</h1>
<h5 v-if="props.errorTitle" class="text-h5 font-weight-medium mb-3">
<h5
v-if="props.errorTitle"
class="text-h5 font-weight-medium mb-3"
>
{{ props.errorTitle }}
</h5>
<p v-if="props.errorDescription">

View File

@@ -1,12 +1,8 @@
<template>
<div class="absolute top-0 right-0 flex items-center justify-between p-2">
<div class="pointer-events-none z-40 flex items-center">
<div
class="relative inline-flex whitespace-nowrap rounded-full border-gray-700 font-semibold leading-5 ring-gray-700"
>
<div
class="rounded-full bg-opacity-80 shadow-md w-5 border p-0 bg-green-500 border-green-400 ring-green-400 text-green-100"
>
<div class="relative inline-flex whitespace-nowrap rounded-full border-gray-700 font-semibold leading-5 ring-gray-700">
<div class="rounded-full bg-opacity-80 shadow-md w-5 border p-0 bg-green-500 border-green-400 ring-green-400 text-green-100">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
@@ -17,7 +13,7 @@
fill-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z"
clip-rule="evenodd"
></path>
/>
</svg>
</div>
</div>

View File

@@ -1,18 +1,25 @@
<script lang="ts" setup>
interface Props {
menuList?: unknown[];
itemProps?: boolean;
menuList?: unknown[]
itemProps?: boolean
}
const props = defineProps<Props>();
const props = defineProps<Props>()
</script>
<template>
<IconBtn>
<VIcon icon="mdi-dots-vertical" />
<VMenu v-if="props.menuList" activator="parent" close-on-content-click>
<VList :items="props.menuList" :item-props="props.itemProps" />
<VMenu
v-if="props.menuList"
activator="parent"
close-on-content-click
>
<VList
:items="props.menuList"
:item-props="props.itemProps"
/>
</VMenu>
</IconBtn>
</template>

View File

@@ -1,45 +1,45 @@
<script setup lang="ts">
import type { ThemeSwitcherTheme } from "@layouts/types";
import { ref, watch } from "vue";
import { useTheme } from "vuetify";
import { ref, watch } from 'vue'
import { useTheme } from 'vuetify'
import type { ThemeSwitcherTheme } from '@layouts/types'
const props = defineProps<{
themes: ThemeSwitcherTheme[];
}>();
themes: ThemeSwitcherTheme[]
}>()
const { name: themeName, global: globalTheme } = useTheme();
const { name: themeName, global: globalTheme } = useTheme()
const savedTheme = ref(localStorage.getItem("theme") ?? themeName);
const savedTheme = ref(localStorage.getItem('theme') ?? themeName)
const {
state: currentThemeName,
next: getNextThemeName,
index: currentThemeIndex,
} = useCycleList(
props.themes.map((t) => t.name),
{ initialValue: savedTheme.value }
);
props.themes.map(t => t.name),
{ initialValue: savedTheme.value },
)
const changeTheme = () => {
const nextTheme = getNextThemeName();
function changeTheme() {
const nextTheme = getNextThemeName()
globalTheme.name.value = nextTheme;
savedTheme.value = nextTheme;
localStorage.setItem("theme", nextTheme);
};
globalTheme.name.value = nextTheme
savedTheme.value = nextTheme
localStorage.setItem('theme', nextTheme)
}
// Update icon if theme is changed from other sources
watch(
() => globalTheme.name.value,
(val) => {
currentThemeName.value = val;
}
);
currentThemeName.value = val
},
)
// Apply saved theme on page load
onMounted(() => {
globalTheme.name.value = savedTheme.value;
});
globalTheme.name.value = savedTheme.value
})
</script>
<template>

View File

@@ -2,7 +2,7 @@ import type { ThemeInstance } from 'vuetify'
import { hexToRgb } from '@layouts/utils'
// 👉 Colors variables
const colorVariables = (themeColors: ThemeInstance['themes']['value']['colors']) => {
function colorVariables(themeColors: ThemeInstance['themes']['value']['colors']) {
const themeSecondaryTextColor = `rgba(${hexToRgb(themeColors.colors['on-surface'])},${themeColors.variables['medium-emphasis-opacity']})`
const themeDisabledTextColor = `rgba(${hexToRgb(themeColors.colors['on-surface'])},${themeColors.variables['disabled-opacity']})`
const themeBorderColor = `rgba(${hexToRgb(String(themeColors.variables['border-color']))},${themeColors.variables['border-opacity']})`
@@ -11,7 +11,7 @@ const colorVariables = (themeColors: ThemeInstance['themes']['value']['colors'])
return { themeSecondaryTextColor, themeDisabledTextColor, themeBorderColor, themePrimaryTextColor }
}
export const getScatterChartConfig = (themeColors: ThemeInstance['themes']['value']['colors']) => {
export function getScatterChartConfig(themeColors: ThemeInstance['themes']['value']['colors']) {
const scatterColors = {
series1: '#ff9f43',
series2: '#7367f0',
@@ -67,7 +67,7 @@ export const getScatterChartConfig = (themeColors: ThemeInstance['themes']['valu
},
}
}
export const getLineChartSimpleConfig = (themeColors: ThemeInstance['themes']['value']['colors']) => {
export function getLineChartSimpleConfig(themeColors: ThemeInstance['themes']['value']['colors']) {
const { themeBorderColor, themeDisabledTextColor } = colorVariables(themeColors)
return {
@@ -94,7 +94,7 @@ export const getLineChartSimpleConfig = (themeColors: ThemeInstance['themes']['v
},
},
tooltip: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
custom(data: any) {
return `<div class='bar-chart pa-2'>
<span>${data.series[data.seriesIndex][data.dataPointIndex]}%</span>
@@ -137,7 +137,7 @@ export const getLineChartSimpleConfig = (themeColors: ThemeInstance['themes']['v
}
}
export const getBarChartConfig = (themeColors: ThemeInstance['themes']['value']['colors']) => {
export function getBarChartConfig(themeColors: ThemeInstance['themes']['value']['colors']) {
const { themeBorderColor, themeDisabledTextColor } = colorVariables(themeColors)
return {
@@ -180,7 +180,7 @@ export const getBarChartConfig = (themeColors: ThemeInstance['themes']['value'][
}
}
export const getCandlestickChartConfig = (themeColors: ThemeInstance['themes']['value']['colors']) => {
export function getCandlestickChartConfig(themeColors: ThemeInstance['themes']['value']['colors']) {
const candlestickColors = {
series1: '#28c76f',
series2: '#ea5455',
@@ -231,7 +231,7 @@ export const getCandlestickChartConfig = (themeColors: ThemeInstance['themes']['
},
}
}
export const getRadialBarChartConfig = (themeColors: ThemeInstance['themes']['value']['colors']) => {
export function getRadialBarChartConfig(themeColors: ThemeInstance['themes']['value']['colors']) {
const radialBarColors = {
series1: '#fdd835',
series2: '#32baff',
@@ -282,7 +282,7 @@ export const getRadialBarChartConfig = (themeColors: ThemeInstance['themes']['va
fontSize: '1.125rem',
color: themePrimaryTextColor,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
formatter(w: { globals: { seriesTotals: any[]; series: string | any[] } }) {
const totalValue
= w.globals.seriesTotals.reduce((a: number, b: number) => {
@@ -307,7 +307,7 @@ export const getRadialBarChartConfig = (themeColors: ThemeInstance['themes']['va
}
}
export const getDonutChartConfig = (themeColors: ThemeInstance['themes']['value']['colors']) => {
export function getDonutChartConfig(themeColors: ThemeInstance['themes']['value']['colors']) {
const donutColors = {
series1: '#fdd835',
series2: '#00d4bd',
@@ -401,7 +401,7 @@ export const getDonutChartConfig = (themeColors: ThemeInstance['themes']['value'
}
}
export const getAreaChartSplineConfig = (themeColors: ThemeInstance['themes']['value']['colors']) => {
export function getAreaChartSplineConfig(themeColors: ThemeInstance['themes']['value']['colors']) {
const areaColors = {
series3: '#e0cffe',
series2: '#b992fe',
@@ -482,7 +482,7 @@ export const getAreaChartSplineConfig = (themeColors: ThemeInstance['themes']['v
}
}
export const getColumnChartConfig = (themeColors: ThemeInstance['themes']['value']['colors']) => {
export function getColumnChartConfig(themeColors: ThemeInstance['themes']['value']['colors']) {
const columnColors = {
series1: '#826af9',
series2: '#d2b0ff',
@@ -568,7 +568,7 @@ export const getColumnChartConfig = (themeColors: ThemeInstance['themes']['value
}
}
export const getHeatMapChartConfig = (themeColors: ThemeInstance['themes']['value']['colors']) => {
export function getHeatMapChartConfig(themeColors: ThemeInstance['themes']['value']['colors']) {
const { themeSecondaryTextColor, themeDisabledTextColor } = colorVariables(themeColors)
return {
@@ -627,7 +627,7 @@ export const getHeatMapChartConfig = (themeColors: ThemeInstance['themes']['valu
}
}
export const getRadarChartConfig = (themeColors: ThemeInstance['themes']['value']['colors']) => {
export function getRadarChartConfig(themeColors: ThemeInstance['themes']['value']['colors']) {
const radarColors = {
series1: '#9b88fa',
series2: '#ffa1a1',

View File

@@ -1,6 +1,6 @@
import { isToday } from './index'
export const avatarText = (value: string) => {
export function avatarText(value: string) {
if (!value)
return ''
const nameArray = value.split(' ')
@@ -9,7 +9,7 @@ export const avatarText = (value: string) => {
}
// TODO: Try to implement this: https://twitter.com/fireship_dev/status/1565424801216311297
export const kFormatter = (num: number) => {
export function kFormatter(num: number) {
const regex = /\B(?=(\d{3})+(?!\d))/g
return Math.abs(num) > 9999 ? `${Math.sign(num) * +((Math.abs(num) / 1000).toFixed(1))}k` : Math.abs(num).toFixed(0).replace(regex, ',')
@@ -22,7 +22,7 @@ export const kFormatter = (num: number) => {
* @param {String} value date to format
* @param {Intl.DateTimeFormatOptions} formatting Intl object to format with
*/
export const formatDate = (value: string, formatting: Intl.DateTimeFormatOptions = { month: 'short', day: 'numeric', year: 'numeric' }) => {
export function formatDate(value: string, formatting: Intl.DateTimeFormatOptions = { month: 'short', day: 'numeric', year: 'numeric' }) {
if (!value)
return value
@@ -35,7 +35,7 @@ export const formatDate = (value: string, formatting: Intl.DateTimeFormatOptions
* @param {String} value date to format
* @param {Boolean} toTimeForCurrentDay Shall convert to time if day is today/current
*/
export const formatDateToMonthShort = (value: string, toTimeForCurrentDay = true) => {
export function formatDateToMonthShort(value: string, toTimeForCurrentDay = true) {
const date = new Date(value)
let formatting: Record<string, string> = { month: 'short', day: 'numeric' }
@@ -51,50 +51,47 @@ export const prefixWithPlus = (value: number) => value > 0 ? `+${value}` : value
export const formatSeason = (value: string) => value ? `S${value.padStart(2, '0')}` : ''
// 格式化为xx[TGMK]B
export const formatFileSize = (bytes: number) => {
if (bytes < 0) {
throw new Error("字节数不能为负数。");
}
export function formatFileSize(bytes: number) {
if (bytes < 0)
throw new Error('字节数不能为负数。')
const units = ["B", "K", "M", "G", "T"];
let size = bytes;
let unitIndex = 0;
const units = ['B', 'K', 'M', 'G', 'T']
let size = bytes
let unitIndex = 0
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex++;
size /= 1024
unitIndex++
}
return `${size.toFixed(2)} ${units[unitIndex]}`;
return `${size.toFixed(2)} ${units[unitIndex]}`
}
// 将时间秒格式化为时分秒
export const formatSeconds = (seconds: number) => {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const remainingSeconds = seconds % 60;
export function formatSeconds(seconds: number) {
const hours = Math.floor(seconds / 3600)
const minutes = Math.floor((seconds % 3600) / 60)
const remainingSeconds = seconds % 60
let formattedTime = '';
let formattedTime = ''
if (hours > 0) {
formattedTime += `${hours}小时`;
}
if (hours > 0)
formattedTime += `${hours}小时`
if (minutes > 0) {
formattedTime += `${minutes}`;
}
if (minutes > 0)
formattedTime += `${minutes}`
if ((remainingSeconds > 0 || formattedTime === '') && hours <= 0) {
formattedTime += `${remainingSeconds}`;
}
if ((remainingSeconds > 0 || formattedTime === '') && hours <= 0)
formattedTime += `${remainingSeconds}`
return formattedTime;
return formattedTime
}
// YYYY-MM-DD 转化为Date
export const parseDate = (dateString: string): Date => {
if (!dateString) return new Date();
const [year, month, day] = dateString.split('-').map(Number);
return new Date(year, month - 1, day);
export function parseDate(dateString: string): Date {
if (!dateString)
return new Date()
const [year, month, day] = dateString.split('-').map(Number)
return new Date(year, month - 1, day)
}

View File

@@ -1,5 +1,5 @@
// 👉 IsEmpty
export const isEmpty = (value: unknown): boolean => {
export function isEmpty(value: unknown): boolean {
if (value === null || value === undefined || value === '')
return true
@@ -7,20 +7,21 @@ export const isEmpty = (value: unknown): boolean => {
}
// 👉 IsNullOrUndefined
export const isNullOrUndefined = (value: unknown): value is undefined | null => {
export function isNullOrUndefined(value: unknown): value is undefined | null {
return value === null || value === undefined
}
// 👉 IsEmptyArray
export const isEmptyArray = (arr: unknown): boolean => {
export function isEmptyArray(arr: unknown): boolean {
return Array.isArray(arr) && arr.length === 0
}
// 👉 IsObject
export const isObject = (obj: unknown): obj is Record<string, unknown> =>
obj !== null && !!obj && typeof obj === 'object' && !Array.isArray(obj)
export function isObject(obj: unknown): obj is Record<string, unknown> {
return obj !== null && !!obj && typeof obj === 'object' && !Array.isArray(obj)
}
export const isToday = (date: Date) => {
export function isToday(date: Date) {
const today = new Date()
return (
@@ -33,38 +34,42 @@ export const isToday = (date: Date) => {
}
// 计算时间差返回xx天xx小时xx分钟
export const calculateTimeDifference = (inputTime: string): string => {
export function calculateTimeDifference(inputTime: string): string {
if (!inputTime)
return ''
if (!inputTime) {
return '';
}
const inputDate = new Date(inputTime)
const currentDate = new Date()
const inputDate = new Date(inputTime);
const currentDate = new Date();
const timeDifference = currentDate.getTime() - inputDate.getTime();
const secondsDifference = Math.floor(timeDifference / 1000);
const timeDifference = currentDate.getTime() - inputDate.getTime()
const secondsDifference = Math.floor(timeDifference / 1000)
if (secondsDifference < 60) {
return `${secondsDifference}`;
} else if (secondsDifference < 3600) {
const minutes = Math.floor(secondsDifference / 60);
return `${minutes}分钟`;
} else if (secondsDifference < 86400) {
const hours = Math.floor(secondsDifference / 3600);
return `${hours}小时`;
} else {
const days = Math.floor(secondsDifference / 86400);
return `${days}`;
return `${secondsDifference}`
}
else if (secondsDifference < 3600) {
const minutes = Math.floor(secondsDifference / 60)
return `${minutes}分钟`
}
else if (secondsDifference < 86400) {
const hours = Math.floor(secondsDifference / 3600)
return `${hours}小时`
}
else {
const days = Math.floor(secondsDifference / 86400)
return `${days}`
}
}
// 判断一个数组subArray是不是在另一个数组mainArray中
export const isContained = (subArray: any[], mainArray: any[]): boolean => {
return subArray.every(element => mainArray.includes(element));
export function isContained(subArray: any[], mainArray: any[]): boolean {
return subArray.every(element => mainArray.includes(element))
}
// 判断两个数组是否存在交集
export const isIntersected = (array1: any[], array2: any[]): boolean => {
return array1.some(element => array2.includes(element));
export function isIntersected(array1: any[], array2: any[]): boolean {
return array1.some(element => array2.includes(element))
}

View File

@@ -250,6 +250,7 @@ const target = join(__dirname, 'icons-bundle.js');
// Export to JSON
const content = iconSet.export()
bundle += `addCollection(${JSON.stringify(content)});\n`
}
}
@@ -258,7 +259,7 @@ const target = join(__dirname, 'icons-bundle.js');
await fs.writeFile(target, bundle, 'utf8')
console.log(`Saved ${target} (${bundle.length} bytes)`)
})().catch(err => {
})().catch((err) => {
console.error(err)
})
@@ -274,7 +275,8 @@ function removeMetaData(iconSet: IconifyJSON) {
'prefixes',
'suffixes',
]
props.forEach(prop => {
props.forEach((prop) => {
delete iconSet[prop]
})
}
@@ -284,12 +286,14 @@ function removeMetaData(iconSet: IconifyJSON) {
*/
function organizeIconsList(icons: string[]): Record<string, string[]> {
const sorted: Record<string, string[]> = Object.create(null)
icons.forEach(icon => {
icons.forEach((icon) => {
const item = stringToIcon(icon)
if (!item)
return
const prefix = item.prefix
const prefixList = sorted[prefix]
? sorted[prefix]
: (sorted[prefix] = [])

View File

@@ -1,4 +1,3 @@
export { default as VerticalNavLayout } from './components/VerticalNavLayout.vue'
export { default as VerticalNavLink } from './components/VerticalNavLink.vue'
export { default as VerticalNavSectionTitle } from './components/VerticalNavSectionTitle.vue'

View File

@@ -28,12 +28,13 @@ watch(
() => route.path,
() => {
props.toggleIsOverlayNavActive(false)
})
},
)
const isVerticalNavScrolled = ref(false)
const updateIsVerticalNavScrolled = (val: boolean) => isVerticalNavScrolled.value = val
const updateIsVerticalNavScrolled = (val: boolean) => (isVerticalNavScrolled.value = val)
const handleNavScroll = (evt: Event) => {
function handleNavScroll(evt: Event) {
isVerticalNavScrolled.value = (evt.target as HTMLElement).scrollTop > 0
}
</script>
@@ -54,14 +55,8 @@ const handleNavScroll = (evt: Event) => {
<!-- 👉 Header -->
<div class="nav-header">
<slot name="nav-header">
<RouterLink
to="/"
class="app-logo d-flex align-center gap-x-3 app-title-wrapper"
>
<div
class="d-flex"
v-html="logo"
/>
<RouterLink to="/" class="app-logo d-flex align-center gap-x-3 app-title-wrapper">
<div class="d-flex" v-html="logo" />
<h1 class="font-weight-medium leading-normal text-xl">
MoviePilot
@@ -72,10 +67,7 @@ const handleNavScroll = (evt: Event) => {
<slot name="before-nav-items">
<div class="vertical-nav-items-shadow" />
</slot>
<slot
name="nav-items"
:update-is-vertical-nav-scrolled="updateIsVerticalNavScrolled"
>
<slot name="nav-items" :update-is-vertical-nav-scrolled="updateIsVerticalNavScrolled">
<PerfectScrollbar
tag="ul"
class="nav-items"
@@ -91,8 +83,8 @@ const handleNavScroll = (evt: Event) => {
</template>
<style lang="scss">
@use "@configured-variables" as variables;
@use "@layouts/styles/mixins";
@use '@configured-variables' as variables;
@use '@layouts/styles/mixins';
// 👉 Vertical Nav
.layout-vertical-nav {

View File

@@ -1,27 +1,27 @@
<script lang="ts">
import VerticalNav from "@layouts/components/VerticalNav.vue";
import { useDisplay } from "vuetify";
import { useDisplay } from 'vuetify'
import VerticalNav from '@layouts/components/VerticalNav.vue'
export default defineComponent({
setup(props, { slots }) {
const isOverlayNavActive = ref(false);
const isLayoutOverlayVisible = ref(false);
const toggleIsOverlayNavActive = useToggle(isOverlayNavActive);
const isOverlayNavActive = ref(false)
const isLayoutOverlayVisible = ref(false)
const toggleIsOverlayNavActive = useToggle(isOverlayNavActive)
const route = useRoute();
const { mdAndDown } = useDisplay();
const route = useRoute()
const { mdAndDown } = useDisplay()
// This is alternative to below two commented watcher
// We want to show overlay if overlay nav is visible and want to hide overlay if overlay is hidden and vice versa.
syncRef(isOverlayNavActive, isLayoutOverlayVisible);
syncRef(isOverlayNavActive, isLayoutOverlayVisible)
const scrollDistance = ref(window.scrollY);
const scrollDistance = ref(window.scrollY)
onMounted(() => {
window.addEventListener("scroll", () => {
scrollDistance.value = window.scrollY;
});
});
window.addEventListener('scroll', () => {
scrollDistance.value = window.scrollY
})
})
return () => {
// 👉 Vertical nav
@@ -29,63 +29,63 @@ export default defineComponent({
VerticalNav,
{ isOverlayNavActive: isOverlayNavActive.value, toggleIsOverlayNavActive },
{
"nav-header": () => slots["vertical-nav-header"]?.(),
"before-nav-items": () => slots["before-vertical-nav-items"]?.(),
default: () => slots["vertical-nav-content"]?.(),
"after-nav-items": () => slots["after-vertical-nav-items"]?.(),
}
);
'nav-header': () => slots['vertical-nav-header']?.(),
'before-nav-items': () => slots['before-vertical-nav-items']?.(),
'default': () => slots['vertical-nav-content']?.(),
'after-nav-items': () => slots['after-vertical-nav-items']?.(),
},
)
// 👉 Navbar
const navbar = h("header", { class: ["layout-navbar navbar-blur"] }, [
const navbar = h('header', { class: ['layout-navbar navbar-blur'] }, [
h(
"div",
{ class: "navbar-content-container" },
'div',
{ class: 'navbar-content-container' },
slots.navbar?.({
toggleVerticalOverlayNavActive: toggleIsOverlayNavActive,
})
}),
),
]);
])
const main = h(
"main",
{ class: "layout-page-content" },
h("div", { class: "page-content-container" }, slots.default?.())
);
'main',
{ class: 'layout-page-content' },
h('div', { class: 'page-content-container' }, slots.default?.()),
)
// 👉 Footer
const footer = h("footer", { class: "layout-footer" }, [
h("div", { class: "footer-content-container" }, slots.footer?.()),
]);
const footer = h('footer', { class: 'layout-footer' }, [
h('div', { class: 'footer-content-container' }, slots.footer?.()),
])
// 👉 Overlay
const layoutOverlay = h("div", {
class: ["layout-overlay", { visible: isLayoutOverlayVisible.value }],
const layoutOverlay = h('div', {
class: ['layout-overlay', { visible: isLayoutOverlayVisible.value }],
onClick: () => {
isLayoutOverlayVisible.value = !isLayoutOverlayVisible.value;
isLayoutOverlayVisible.value = !isLayoutOverlayVisible.value
},
});
})
return h(
"div",
'div',
{
class: [
"layout-wrapper layout-nav-type-vertical layout-navbar-static layout-footer-static layout-content-width-fluid",
"layout-navbar-fixed",
mdAndDown.value && "layout-overlay-nav",
'layout-wrapper layout-nav-type-vertical layout-navbar-static layout-footer-static layout-content-width-fluid',
'layout-navbar-fixed',
mdAndDown.value && 'layout-overlay-nav',
route.meta.layoutWrapperClasses,
scrollDistance.value && "window-scrolled",
scrollDistance.value && 'window-scrolled',
],
},
[
verticalNav,
h("div", { class: "layout-content-wrapper" }, [navbar, main, footer]),
h('div', { class: 'layout-content-wrapper' }, [navbar, main, footer]),
layoutOverlay,
]
);
};
],
)
}
},
});
})
</script>
<style lang="scss">

View File

@@ -24,7 +24,7 @@ export const getComputedNavLinkToProp = computed(() => (link: NavLink) => {
* @param hex
*/
export const hexToRgb = (hex: string) => {
export function hexToRgb(hex: string) {
// Expand shorthand form (e.g. "03F") to full form (e.g. "0033FF")
const shorthandRegex = /^#?([a-f\d])([a-f\d])([a-f\d])$/i

View File

@@ -1,4 +1,4 @@
import { ValidationRule } from 'vuetify/types/services/validation'
import type { ValidationRule } from 'vuetify/types/services/validation'
// 必输校验
export const requiredValidator: ValidationRule = (value: any) => !!value || '此项为必填项'

View File

@@ -1,67 +1,69 @@
<script lang="ts" setup>
import avatar1 from "@images/avatars/avatar-1.png";
import { useToast } from "vue-toast-notification";
import api from "./api";
import { User } from "./api/types";
import store from "./store";
import { useToast } from 'vue-toast-notification'
import api from './api'
import type { User } from './api/types'
import store from './store'
import avatar1 from '@images/avatars/avatar-1.png'
// 路由
const route = useRoute();
const route = useRoute()
// 提示框
const $toast = useToast();
const $toast = useToast()
// SSE持续接收消息
const startSSEMessager = () => {
const token = store.state.auth.token;
function startSSEMessager() {
const token = store.state.auth.token
if (token) {
const eventSource = new EventSource(
`${import.meta.env.VITE_API_BASE_URL}system/message?token=${token}`
);
eventSource.addEventListener("message", (event) => {
const message = event.data;
if (message) {
$toast.info(message);
}
});
`${import.meta.env.VITE_API_BASE_URL}system/message?token=${token}`,
)
eventSource.addEventListener('message', (event) => {
const message = event.data
if (message)
$toast.info(message)
})
onBeforeUnmount(() => {
eventSource.close();
});
eventSource.close()
})
}
};
}
// 当前用户信息
const accountInfo = ref<User>({
id: 0,
name: "",
password: "",
email: "",
name: '',
password: '',
email: '',
is_active: false,
is_superuser: false,
avatar: avatar1,
});
})
// 调用API加载当前用户数据
const loadAccountInfo = async () => {
async function loadAccountInfo() {
try {
const user: User = await api.get(`user/current`);
accountInfo.value = user;
if (!accountInfo.value.avatar) accountInfo.value.avatar = avatar1;
} catch (error) {
console.log(error);
const user: User = await api.get('user/current')
accountInfo.value = user
if (!accountInfo.value.avatar)
accountInfo.value.avatar = avatar1
}
};
catch (error) {
console.log(error)
}
}
// 页面加载时,加载当前用户数据
onMounted(() => {
loadAccountInfo();
startSSEMessager();
});
loadAccountInfo()
startSSEMessager()
})
// 提供给所有元素复用
provide("accountInfo", accountInfo);
provide('accountInfo', accountInfo)
</script>
<template>

View File

@@ -1,6 +1,6 @@
import axios from 'axios'
import router from '@/router'
import store from '@/store'
import axios from 'axios'
// 创建axios实例
const api = axios.create({
@@ -8,7 +8,7 @@ const api = axios.create({
})
// 添加请求拦截器
api.interceptors.request.use(config => {
api.interceptors.request.use((config) => {
// 在请求头中添加token
const token = store.state.auth.token
if (token)
@@ -18,9 +18,9 @@ api.interceptors.request.use(config => {
})
// 添加响应拦截器
api.interceptors.response.use(response => {
api.interceptors.response.use((response) => {
return response.data
}, error => {
}, (error) => {
if (!error.response) {
// 请求超时
return Promise.reject(error)
@@ -28,6 +28,7 @@ api.interceptors.response.use(response => {
else if (error.response.status === 403) {
// 清除登录状态信息
store.dispatch('auth/clearToken')
// token验证失败跳转到登录页面
router.push('/login')
}

View File

@@ -1,16 +1,16 @@
import NProgress from 'nprogress';
import 'nprogress/nprogress.css';
import NProgress from 'nprogress'
import 'nprogress/nprogress.css'
export function configureNProgress() {
NProgress.configure({
showSpinner: false
});
showSpinner: false,
})
}
export function startNProgress() {
NProgress.start();
NProgress.start()
}
export function doneNProgress() {
NProgress.done();
NProgress.done()
}

View File

@@ -1,493 +1,693 @@
// 订阅
export interface Subscribe {
id: number;
id: number
// 订阅名称
name: string;
name: string
// 订阅年份
year: string;
year: string
// 订阅类型 电影/电视剧
type: string;
type: string
// 搜索关键字
keyword?: string;
tmdbid: number;
doubanid?: string;
keyword?: string
tmdbid: number
doubanid?: string
// 季号
season?: number;
season?: number
// 海报
poster?: string;
poster?: string
// 背景图
backdrop?: string;
backdrop?: string
// 评分
vote?: number;
vote?: number
// 描述
description?: string;
description?: string
// 过滤规则
filter?: string;
filter?: string
// 包含
include?: string;
include?: string
// 排除
exclude?: string;
exclude?: string
// 总集数
total_episode?: number;
total_episode?: number
// 开始集数
start_episode?: number;
start_episode?: number
// 缺失集数
lack_episode?: number;
lack_episode?: number
// 附加信息
note?: string;
note?: string
// 状态N-新建, R-订阅中
state: string;
state: string
// 最后更新时间
last_update: string;
last_update: string
// 订阅用户
username: string;
username: string
// 订阅站点
sites: number[],
sites: number[]
}
// 历史记录
export interface TransferHistory {
// ID
id: number;
id: number
// 源目录
src?: string;
src?: string
// 目的目录
dest?: string;
dest?: string
// 转移模式link/copy/move/softlink
mode?: string;
mode?: string
// 类型:电影、电视剧
type?: string;
type?: string
// 二级分类
category?: string;
category?: string
// 标题
title?: string;
title?: string
// 年份
year?: string;
year?: string
// TMDBID
tmdbid?: number;
tmdbid?: number
// IMDBID
imdbid?: string;
imdbid?: string
// TVDBID
tvdbid?: number;
tvdbid?: number
// 豆瓣ID
doubanid?: string;
doubanid?: string
// 季Sxx
seasons?: string;
seasons?: string
// 集Exx
episodes?: string;
episodes?: string
// 海报
image?: string;
image?: string
// 下载器Hash
download_hash?: string;
download_hash?: string
// 状态 1-成功0-失败
status: boolean;
status: boolean
// 失败原因
errmsg?: string;
errmsg?: string
// 日期
date?: string;
date?: string
}
// 媒体信息
export interface MediaInfo {
// 类型 电影、电视剧
type?: string;
type?: string
// 媒体标题
title?: string;
title?: string
// 年份
year?: string;
year?: string
// 标题(年)
title_year?: string;
title_year?: string
// 季号
season?: number;
season?: number
// TMDB ID
tmdb_id?: number;
tmdb_id?: number
// IMDB ID
imdb_id?: string;
imdb_id?: string
// TVDB ID
tvdb_id?: string;
tvdb_id?: string
// 豆瓣ID
douban_id?: string;
douban_id?: string
// 媒体原语种
original_language?: string;
original_language?: string
// 媒体原发行标题
original_title?: string;
original_title?: string
// 媒体发行日期
release_date?: string;
release_date?: string
// 背景图片
backdrop_path?: string;
backdrop_path?: string
// 海报图片
poster_path?: string;
poster_path?: string
// 评分
vote_average: number;
vote_average: number
// 描述
overview?: string;
overview?: string
// 二级分类
category?: string;
category?: string
// 详情页面
detail_link?: string;
detail_link?: string
}
// TMDB季信息
export interface TmdbSeason {
// 上映日期
air_date?: string;
air_date?: string
// 总集数
episode_count?: number;
episode_count?: number
// 季名称
name?: string;
name?: string
// 描述
overview?: string;
overview?: string
// 海报
poster_path?: string;
poster_path?: string
// 季号
season_number?: number;
season_number?: number
// 评分
vote_average?: number;
vote_average?: number
}
// TMDB集信息
export interface TmdbEpisode {
// 上映日期
air_date?: string;
air_date?: string
// 集号
episode_number?: number;
episode_number?: number
// 剧集名称
name?: string;
name?: string
// 描述
overview?: string;
overview?: string
// 时长
runtime?: number;
runtime?: number
// 季号
season_number?: number;
season_number?: number
// 海报
still_path?: string;
still_path?: string
// 评分
vote_average?: number;
vote_average?: number
// 演职人员
crew: Object[];
crew: Object[]
// 嘉宾
guest_stars: Object[];
guest_stars: Object[]
}
// 站点
export interface Site {
// ID
id: number;
id: number
// 站点名称
name: string;
name: string
// 站点主域名Key
domain: string;
domain: string
// 站点地址
url: string;
url: string
// 站点优先级
pri?: number;
pri?: number
// RSS地址
rss?: string;
rss?: string
// Cookie
cookie?: string;
cookie?: string
// User-Agent
ua?: string;
ua?: string
// 是否使用代理
proxy?: number;
proxy?: number
// 过滤规则
filter?: string;
filter?: string
// 是否演染
render?: number;
render?: number
// 是否公开站点
public?: number;
public?: number
// 备注
note?: string;
note?: string
// 流控单位周期
limit_interval?: number;
limit_interval?: number
// 流控次数
limit_count?: number;
limit_count?: number
// 流控间隔
limit_seconds?: number;
limit_seconds?: number
// 是否启用
is_active: boolean;
is_active: boolean
}
// 正在下载
export interface DownloadingInfo {
// HASH
hash?: string;
hash?: string
// 种子名称
title?: string;
title?: string
// 识别后的名称
name?: string;
name?: string
// 年份
year?: string;
year?: string
// SXXEXX
season_episode?: string;
season_episode?: string
// 大小
size?: number;
size?: number
// 下载进 度
progress?: number;
progress?: number
// 状态
state?: string;
state?: string
// 下载速度
dlspeed?: string;
dlspeed?: string
// 上传速度
upspeed?: string;
upspeed?: string
// 媒体信息
media: { [key: string]: any };
media: { [key: string]: any }
}
// 缺失剧集信息
export interface NotExistMediaInfo {
// 季
season: number;
season: number
// 剧集列表
episodes: number[];
episodes: number[]
// 总集数
total_episodes: number;
total_episodes: number
// 开始集
start_episode: number;
start_episode: number
}
// 插件
export interface Plugin {
id?: string;
id?: string
// 插件名称
plugin_name?: string;
plugin_name?: string
// 插件描述
plugin_desc?: string;
plugin_desc?: string
// 插件图标
plugin_icon?: string;
plugin_icon?: string
// 主题色
plugin_color?: string;
plugin_color?: string
// 插件版本
plugin_version?: string;
plugin_version?: string
// 插件作者
plugin_author?: string;
plugin_author?: string
// 作者主页
author_url?: string;
author_url?: string
// 插件配置项ID前缀
plugin_config_prefix?: string;
plugin_config_prefix?: string
// 加载顺序
plugin_order?: number;
plugin_order?: number
// 可使用的用户级别
auth_level?: number;
auth_level?: number
// 是否已安装
installed?: boolean;
installed?: boolean
}
// 种子信息
export interface TorrentInfo {
// 站点ID
site?: number;
site?: number
// 站点名称
site_name?: string;
site_name?: string
// 站点Cookie
site_cookie?: string;
site_cookie?: string
// 站点UA
site_ua?: string;
site_ua?: string
// 站点是否使用代理
site_proxy: boolean;
site_proxy: boolean
// 站点优先级
site_order: number;
site_order: number
// 种子名称
title?: string;
title?: string
// 种子副标题
description?: string;
description?: string
// IMDB ID
imdbid: string;
imdbid: string
// 种子链接
enclosure?: string;
enclosure?: string
// 详情页面
page_url?: string;
page_url?: string
// 种子大小
size: number;
size: number
// 做种者
seeders: number;
seeders: number
// 下载者
peers: number;
peers: number
// 完成者
grabs: number;
grabs: number
// 发布时间
pubdate?: string;
pubdate?: string
// 已过时间
date_elapsed?: string;
date_elapsed?: string
// 上传因子
uploadvolumefactor: number;
uploadvolumefactor: number
// 下载因子
downloadvolumefactor: number;
downloadvolumefactor: number
// HR
hit_and_run: boolean;
hit_and_run: boolean
// 种子标签
labels: string[];
labels: string[]
// 种子优先级
pri_order: number;
pri_order: number
// 促销描述
volume_factor: string;
volume_factor: string
}
// 识别元数据
export interface MetaInfo {
// 是否处理的文件
isfile: boolean;
isfile: boolean
// 原字符串
org_string?: string;
org_string?: string
// 副标题
subtitle?: string;
subtitle?: string
// 类型 电影、电视剧
type: string;
type: string
// 识别的中文名
cn_name?: string;
cn_name?: string
// 识别的英文名
en_name?: string;
en_name?: string
// 年份
year?: string;
year?: string
// 总季数
total_seasons: number;
total_seasons: number
// 识别的开始季 数字
begin_season?: number;
begin_season?: number
// 识别的结束季 数字
end_season?: number;
end_season?: number
// 总集数
total_episodes: number;
total_episodes: number
// 识别的开始集
begin_episode?: number;
begin_episode?: number
// 识别的结束集
end_episode?: number;
end_episode?: number
// Partx Cd Dvd Disk Disc
part?: string;
part?: string
// 识别的资源类型
resource_type?: string;
resource_type?: string
// 识别的效果
resource_effect?: string;
resource_effect?: string
// 识别的分辨率
resource_pix?: string;
resource_pix?: string
// 识别的制作组/字幕组
resource_team?: string;
resource_team?: string
// 视频编码
video_encode?: string;
video_encode?: string
// 音频编码
audio_encode?: string;
audio_encode?: string
// 名称(自动中英文)
name: string;
name: string
// SXX-SXX
season: string;
season: string
// SXX-SXX 有季号才返回
sea: string;
sea: string
// begin_season 的数字电视剧没有季的返回1
season_seq: string;
season_seq: string
// 季的数组
season_list: number[];
season_list: number[]
// Exx-Exx
episode: string;
episode: string
// 集的数组
episode_list: number[];
episode_list: number[]
// ExxExx
episodes: string;
//xx-xx
episode_seqs: string;
episodes: string
// xx-xx
episode_seqs: string
// begin_episode 的数字
episode_seq: string;
episode_seq: string
// SxxExx
season_episode: string;
season_episode: string
// 资源类型字符串,含分辨率
resource_term: string;
resource_term: string
// 发布组/字幕组字符串
release_group: string;
release_group: string
// 视频编码
video_term: string;
video_term: string
// 音频编码
audio_term: string;
audio_term: string
// 资源类型+特效
edition: string;
edition: string
}
// 上下文信息
export interface Context {
// 元信息
meta_info: MetaInfo;
meta_info: MetaInfo
// 媒体信息
media_info: MediaInfo;
media_info: MediaInfo
// 种子信息
torrent_info: TorrentInfo;
torrent_info: TorrentInfo
}
// 用户信息
export interface User {
id: number;
name: string;
password: string;
email: string;
is_active: boolean;
is_superuser: boolean;
avatar: string;
id: number
name: string
password: string
email: string
is_active: boolean
is_superuser: boolean
avatar: string
}
// 存储空间
export interface Storage {
total_storage: number;
used_storage: number;
total_storage: number
used_storage: number
}
// 媒体统计
export interface MediaStatistic {
// 电影总数
movie_count: number;
movie_count: number
// 电视剧总数
tv_count: number;
tv_count: number
// 电视剧总集数
episode_count: number;
episode_count: number
// 用户数量
user_count: number;
user_count: number
}
// 后台进程
export interface Process {
// 进程ID
pid: number;
pid: number
// 进程名称
name: string;
name: string
// 进程状态
status: string;
status: string
// 进程启动时间
create_time: number;
create_time: number
// 进程运行时间
run_time: number;
run_time: number
// 进程CPU占用率
cpu: number;
cpu: number
// 进程内存占用
memory: number;
memory: number
}
// 下载器信息
export interface DownloaderInfo {
// 下载速度
download_speed: number;
download_speed: number
// 上传速度
upload_speed: number;
upload_speed: number
// 下载量
download_size: number;
download_size: number
// 上传量
upload_size: number;
upload_size: number
// 剩余空间
free_space: number;
free_space: number
}
// 定时服务信息
export interface ScheduleInfo {
// ID
id: string;
// 名称
name: string;
// 状态
status: string;
// 下次运行时间
next_run: string;
}
// ID
id: string
// 名称
name: string
// 状态
status: string
// 下次运行时间
next_run: string
}
// 消息通知
export interface NotificationSwitch {
// 消息类型
mtype: string;
mtype: string
// 开关
wechat: boolean;
telegram: boolean;
slack: boolean;
wechat: boolean
telegram: boolean
slack: boolean
}

View File

@@ -1,9 +1,11 @@
<script setup lang="ts">
import { useTheme } from 'vuetify'
import misc404 from '@images/pages/404.png'
import miscMaskDark from '@images/pages/misc-mask-dark.png'
import miscMaskLight from '@images/pages/misc-mask-light.png'
import tree from '@images/pages/tree.png'
import { useTheme } from 'vuetify'
const props = defineProps<Props>()
const vuetifyTheme = useTheme()
@@ -16,8 +18,6 @@ interface Props {
errorTitle?: string
errorDescription?: string
}
const props = defineProps<Props>()
</script>
<template>

View File

@@ -1,69 +1,74 @@
<script lang="ts" setup>
import api from "@/api";
import { DownloadingInfo } from "@/api/types";
import api from '@/api'
import type { DownloadingInfo } from '@/api/types'
// 输入参数
const props = defineProps({
info: Object as PropType<DownloadingInfo>,
});
})
// 是否显示卡片
const cardState = ref(true);
const cardState = ref(true)
// 进度条
const getPercentage = () => {
return props.info?.progress ?? 0;
};
function getPercentage() {
return props.info?.progress ?? 0
}
// 速度
const getSpeedText = () => {
return `${props.info?.upspeed}/s ↓ ${props.info?.dlspeed}/s`;
};
function getSpeedText() {
return `${props.info?.upspeed}/s ↓ ${props.info?.dlspeed}/s`
}
// 下载状态
const isDownloading = ref(props.info?.state === "downloading" ? true : false);
const isDownloading = ref(props.info?.state === 'downloading')
// 图片是否加载完成
const imageLoaded = ref(false);
const imageLoaded = ref(false)
// 图片加载完成响应
const imageLoadHandler = () => {
imageLoaded.value = true;
};
function imageLoadHandler() {
imageLoaded.value = true
}
// 计算文本类
const getTextClass = () => {
return imageLoaded.value ? "text-white" : "";
};
function getTextClass() {
return imageLoaded.value ? 'text-white' : ''
}
// 下载状态控制
const toggleDownload = async () => {
let operation = isDownloading.value ? "stop" : "start";
async function toggleDownload() {
const operation = isDownloading.value ? 'stop' : 'start'
try {
const result: { [key: string]: any } = await api.put(
`download/${props.info?.hash}/${operation}`
);
if (result.success) {
isDownloading.value = !isDownloading.value;
}
} catch (error) {
console.error(error);
`download/${props.info?.hash}/${operation}`,
)
if (result.success)
isDownloading.value = !isDownloading.value
}
};
catch (error) {
console.error(error)
}
}
// 删除下截
const deleteDownload = async () => {
async function deleteDownload() {
try {
await api.delete(`download/${props.info?.hash}`);
cardState.value = false;
} catch (error) {
console.error(error);
await api.delete(`download/${props.info?.hash}`)
cardState.value = false
}
};
catch (error) {
console.error(error)
}
}
</script>
<template>
<VCard :key="props.info?.hash" v-if="cardState">
<VCard
v-if="cardState"
:key="props.info?.hash"
>
<template #image>
<VImg
:src="props.info?.media.image"
@@ -74,18 +79,32 @@ const deleteDownload = async () => {
/>
</template>
<VCardTitle class="break-words whitespace-normal" :class="getTextClass()">
<VCardTitle
class="break-words whitespace-normal"
:class="getTextClass()"
>
{{ props.info?.media.title || props.info?.name }}
{{ props.info?.season_episode }}
</VCardTitle>
<VCardSubtitle class="break-words whitespace-normal" :class="getTextClass()">
<VCardSubtitle
class="break-words whitespace-normal"
:class="getTextClass()"
>
{{ props.info?.title }}
</VCardSubtitle>
<VCardText class="text-subtitle-1 pt-3 pb-1" :class="getTextClass()"> {{ getSpeedText() }} </VCardText>
<VCardText
class="text-subtitle-1 pt-3 pb-1"
:class="getTextClass()"
>
{{ getSpeedText() }}
</VCardText>
<VCardText v-if="getPercentage() > 0" :class="getTextClass()">
<VCardText
v-if="getPercentage() > 0"
:class="getTextClass()"
>
<VProgressLinear :model-value="getPercentage()" />
</VCardText>
@@ -93,7 +112,11 @@ const deleteDownload = async () => {
<VBtn @click="toggleDownload">
<span class="ms-2">{{ isDownloading ? "暂停" : "开始" }}</span>
</VBtn>
<VBtn color="error" icon="mdi-trash-can-outline" @click="deleteDownload" />
<VBtn
color="error"
icon="mdi-trash-can-outline"
@click="deleteDownload"
/>
</VCardActions>
</VCard>
</template>

View File

@@ -1,73 +1,77 @@
<script lang="ts" setup>
// 定义触发的自定义事件
const emit = defineEmits(["close", "changed"]);
// 输入参数
const props = defineProps({
pri: String,
rules: Array as PropType<string[]>,
width: String,
height: String,
});
})
// 定义触发的自定义事件
const emit = defineEmits(['close', 'changed'])
// 按钮点击
const onClose = () => {
emit("close");
};
function onClose() {
emit('close')
}
// 选项变化
const filtersChanged = (value: string[]) => {
emit("changed", props.pri, value);
};
function filtersChanged(value: string[]) {
emit('changed', props.pri, value)
}
// 过滤规则下拉框
const selectFilterOptions = ref<{ [key: string]: string }[]>([
{ title: "特效字幕", value: " SPECSUB " },
{ title: "中文字幕", value: " CNSUB " },
{ title: "分辨率: 4K", value: " 4K " },
{ title: "分辨率: 1080P", value: " 1080P " },
{ title: "分辨率: 720P", value: " 720P " },
{ title: "排除: 720P", value: " !720P " },
{ title: "质量: 蓝光原盘", value: " BLU " },
{ title: "排除: 蓝光原盘", value: " !BLU ", color: "error" },
{ title: "质量: REMUX", value: " REMUX " },
{ title: "排除: REMUX", value: " !REMUX ", color: "error" },
{ title: "质量: WEB-DL", value: " WEBDL " },
{ title: "排除: WEB-DL", value: " !WEBDL ", color: "error" },
{ title: "编码: H265", value: " H265 " },
{ title: "排除: H265", value: " !H265 ", color: "error" },
{ title: "编码: H264", value: " H264 " },
{ title: "排除: H264", value: " !H264 ", color: "error" },
{ title: "效果: 杜比视界", value: " DOLBY " },
{ title: "排除: 杜比视界", value: " !DOLBY ", color: "error" },
{ title: "效果: HDR", value: " HDR " },
{ title: "排除: HDR", value: " !HDR ", color: "error" },
{ title: "国语配音", value: " CNVOI ", color: "error" },
{ title: "排除: 国语配音", value: " !CNVOI ", color: "error" },
{ title: "促销: 免费", value: " FREE " },
]);
{ title: '特效字幕', value: ' SPECSUB ' },
{ title: '中文字幕', value: ' CNSUB ' },
{ title: '分辨率: 4K', value: ' 4K ' },
{ title: '分辨率: 1080P', value: ' 1080P ' },
{ title: '分辨率: 720P', value: ' 720P ' },
{ title: '排除: 720P', value: ' !720P ' },
{ title: '质量: 蓝光原盘', value: ' BLU ' },
{ title: '排除: 蓝光原盘', value: ' !BLU ', color: 'error' },
{ title: '质量: REMUX', value: ' REMUX ' },
{ title: '排除: REMUX', value: ' !REMUX ', color: 'error' },
{ title: '质量: WEB-DL', value: ' WEBDL ' },
{ title: '排除: WEB-DL', value: ' !WEBDL ', color: 'error' },
{ title: '编码: H265', value: ' H265 ' },
{ title: '排除: H265', value: ' !H265 ', color: 'error' },
{ title: '编码: H264', value: ' H264 ' },
{ title: '排除: H264', value: ' !H264 ', color: 'error' },
{ title: '效果: 杜比视界', value: ' DOLBY ' },
{ title: '排除: 杜比视界', value: ' !DOLBY ', color: 'error' },
{ title: '效果: HDR', value: ' HDR ' },
{ title: '排除: HDR', value: ' !HDR ', color: 'error' },
{ title: '国语配音', value: ' CNVOI ', color: 'error' },
{ title: '排除: 国语配音', value: ' !CNVOI ', color: 'error' },
{ title: '促销: 免费', value: ' FREE ' },
])
// 已选择的过滤规则
const selectedFilters = ref<string[]>(props.rules ?? []);
const selectedFilters = ref<string[]>(props.rules ?? [])
</script>
<template>
<VCard variant="tonal" :width="props.width" :height="props.height">
<VCard
variant="tonal"
:width="props.width"
:height="props.height"
>
<DialogCloseBtn @click="onClose" />
<VCardItem>
<VCardTitle>优先级 {{ props.pri }}</VCardTitle>
<VRow>
<VCol>
<VSelect
variant="underlined"
:key="props.pri"
v-model="selectedFilters"
variant="underlined"
:items="selectFilterOptions"
@update:modelValue="filtersChanged"
chips
label=""
multiple
>
</VSelect>
@update:modelValue="filtersChanged"
/>
</VCol>
</VRow>
</VCardItem>

View File

@@ -1,209 +1,225 @@
<script lang="ts" setup>
import { formatSeason } from "@/@core/utils/formatters";
import api from "@/api";
import { doneNProgress, startNProgress } from "@/api/nprogress";
import { MediaInfo, NotExistMediaInfo, Subscribe, TmdbSeason } from "@/api/types";
import router from "@/router";
import { useToast } from "vue-toast-notification";
import type { PropType } from 'vue'
import { useToast } from 'vue-toast-notification'
import { formatSeason } from '@/@core/utils/formatters'
import api from '@/api'
import { doneNProgress, startNProgress } from '@/api/nprogress'
import type { MediaInfo, NotExistMediaInfo, Subscribe, TmdbSeason } from '@/api/types'
import router from '@/router'
// 输入参数
const props = defineProps({
media: Object as PropType<MediaInfo>,
width: String,
height: String,
});
})
// 提示框
const $toast = useToast();
const $toast = useToast()
// 图片加载状态
const isImageLoaded = ref(false);
const isImageLoaded = ref(false)
// 图片加载失败
const imageLoadError = ref(false);
const imageLoadError = ref(false)
// TMDB识别标志
const tmdbFlag = ref(true);
const tmdbFlag = ref(true)
// 当前订阅状态
const isSubscribed = ref(false);
const isSubscribed = ref(false)
// 本地存在状态
const isExists = ref(false);
const isExists = ref(false)
// 各季缺失状态0-已存在 1-部分缺失 2-全部缺失
const seasonsNotExisted = ref<{ [key: number]: number }>({});
const seasonsNotExisted = ref<{ [key: number]: number }>({})
// 订阅季弹窗
const subscribeSeasonDialog = ref(false);
const subscribeSeasonDialog = ref(false)
// 季详情
const seasonInfos = ref<TmdbSeason[]>([]);
const seasonInfos = ref<TmdbSeason[]>([])
// 选中的订阅季
const seasonsSelected = ref<TmdbSeason[]>([])
// 订阅弹窗选择的多季
const subscribeSeasons = () => {
subscribeSeasonDialog.value = false;
function subscribeSeasons() {
subscribeSeasonDialog.value = false
seasonsSelected.value.forEach((season) => {
addSubscribe(season.season_number);
});
};
addSubscribe(season.season_number)
})
}
// 角标颜色
const getChipColor = (type: string) => {
if (type === "电影") {
return "border-blue-500 bg-blue-600";
} else if (type === "电视剧") {
return " bg-indigo-500 border-indigo-600";
} else {
return "border-purple-600 bg-purple-600";
}
};
function getChipColor(type: string) {
if (type === '电影')
return 'border-blue-500 bg-blue-600'
else if (type === '电视剧')
return ' bg-indigo-500 border-indigo-600'
else
return 'border-purple-600 bg-purple-600'
}
// 添加订阅处理
const handleAddSubscribe = async () => {
if (props.media?.type == "电视剧" && props.media?.tmdb_id) {
// eslint-disable-next-line sonarjs/cognitive-complexity
async function handleAddSubscribe() {
if (props.media?.type === '电视剧' && props.media?.tmdb_id) {
// TMDB电视剧
// 查询TMDB所有季信息
await getMediaSeasons();
await getMediaSeasons()
if (!seasonInfos.value) {
$toast.error(`${props.media?.title} 查询剧集信息失败!`);
return;
$toast.error(`${props.media?.title} 查询剧集信息失败!`)
return
}
// 检查各季的缺失状态
await checkSeasonsNotExists();
if (!tmdbFlag.value) {
return;
}
if (seasonInfos.value.length == 1) {
await checkSeasonsNotExists()
if (!tmdbFlag.value)
return
if (seasonInfos.value.length === 1) {
// 只有1季
if (!seasonsNotExisted.value[1]) {
// 已存在
$toast.warning(`${props.media?.title} 媒体库中已存在!`);
} else {
// 添加订阅
addSubscribe();
$toast.warning(`${props.media?.title} 媒体库中已存在!`)
}
} else {
else {
// 添加订阅
addSubscribe()
}
}
else {
// 弹出季选择列表,支持多选
subscribeSeasonDialog.value = true;
}
} else if (props.media?.type == "电视剧") {
// 豆瓣电视剧,只会有一季
let season = props.media?.season || 1;
// 检查缺失情况
await checkSeasonsNotExists();
if (!tmdbFlag.value) {
return;
}
if (!seasonsNotExisted.value[season]) {
// 已存在
$toast.warning(`${props.media?.title} 媒体库中已存在!`);
} else {
// 添加订阅
addSubscribe(season);
}
} else {
// 电影
let exists = await checkMovieExists();
if (exists) {
$toast.warning(`${props.media?.title} 媒体库中已存在!`);
} else {
addSubscribe();
subscribeSeasonDialog.value = true
}
}
};
else if (props.media?.type === '电视剧') {
// 豆瓣电视剧,只会有一季
const season = props.media?.season || 1
// 检查缺失情况
await checkSeasonsNotExists()
if (!tmdbFlag.value)
return
if (!seasonsNotExisted.value[season]) {
// 已存在
$toast.warning(`${props.media?.title} 媒体库中已存在!`)
}
else {
// 添加订阅
addSubscribe(season)
}
}
else {
// 电影
const exists = await checkMovieExists()
if (exists)
$toast.warning(`${props.media?.title} 媒体库中已存在!`)
else
addSubscribe()
}
}
// 调用API添加订阅电视剧的话需要指定季
const addSubscribe = async (season: number = 0) => {
async function addSubscribe(season = 0) {
// 开始处理
startNProgress();
startNProgress()
try {
const result: { [key: string]: any } = await api.post("subscribe", {
const result: { [key: string]: any } = await api.post('subscribe', {
name: props.media?.title,
type: props.media?.type,
year: props.media?.year,
tmdbid: props.media?.tmdb_id,
doubanid: props.media?.douban_id,
season: season,
});
season,
})
// 订阅状态
if (result.success) {
// 订阅成功
isSubscribed.value = true;
isSubscribed.value = true
}
// 提示
showSubscribeAddToast(
result.success,
props.media?.title ?? "",
props.media?.title ?? '',
season,
result.message
);
} catch (error) {
console.error(error);
result.message,
)
}
doneNProgress();
};
catch (error) {
console.error(error)
}
doneNProgress()
}
// 弹出添加订阅提示
const showSubscribeAddToast = (
result: boolean,
function showSubscribeAddToast(result: boolean,
title: string,
season: number,
message: string
) => {
if (season) {
title = `${title} ${formatSeason(season.toString())}`;
}
if (result) {
$toast.success(`${title} 添加订阅成功!`);
} else {
$toast.error(`${title} 添加订阅失败:${message}`);
}
};
message: string) {
if (season)
title = `${title} ${formatSeason(season.toString())}`
if (result)
$toast.success(`${title} 添加订阅成功!`)
else
$toast.error(`${title} 添加订阅失败:${message}`)
}
// 调用API取消订阅
const removeSubscribe = async () => {
async function removeSubscribe() {
// 开始处理
startNProgress();
startNProgress()
try {
let mediaid = props.media?.tmdb_id
const mediaid = props.media?.tmdb_id
? `tmdb:${props.media?.tmdb_id}`
: `douban:${props.media?.douban_id}`;
: `douban:${props.media?.douban_id}`
const result: { [key: string]: any } = await api.delete(
`subscribe/media/${mediaid}`,
{
params: {
season: props.media?.season,
},
}
);
},
)
if (result.success) {
isSubscribed.value = false;
$toast.success(`${props.media?.title} 已取消订阅!`);
} else {
$toast.error(`${props.media?.title} 取消订阅失败:${result.message}`);
isSubscribed.value = false
$toast.success(`${props.media?.title} 已取消订阅!`)
}
else {
$toast.error(`${props.media?.title} 取消订阅失败:${result.message}`)
}
} catch (error) {
console.error(error);
}
doneNProgress();
};
catch (error) {
console.error(error)
}
doneNProgress()
}
// 查询当前媒体是否已订阅
const handleCheckSubscribe = async () => {
async function handleCheckSubscribe() {
try {
const result = await checkSubscribe(props.media?.season);
if (result) {
isSubscribed.value = true;
}
} catch (error) {
console.error(error);
const result = await checkSubscribe(props.media?.season)
if (result)
isSubscribed.value = true
}
};
catch (error) {
console.error(error)
}
}
// 查询当前媒体是否已存在
const handleCheckExists = async () => {
async function handleCheckExists() {
try {
const result: {[key:string]: any} = await api.get(`media/exists`, {
const result: { [key: string]: any } = await api.get('media/exists', {
params: {
tmdbid: props.media?.tmdb_id,
title: props.media?.title,
@@ -211,143 +227,145 @@ const handleCheckExists = async () => {
season: props.media?.season,
mtype: props.media?.type,
},
});
if (result.success) {
isExists.value = true;
}
} catch (error) {
console.error(error);
})
if (result.success)
isExists.value = true
}
};
catch (error) {
console.error(error)
}
}
// 调用API检查是否已订阅电视剧需要指定季
const checkSubscribe = async (season: number = 0) => {
async function checkSubscribe(season = 0) {
try {
let mediaid = props.media?.tmdb_id
const mediaid = props.media?.tmdb_id
? `tmdb:${props.media?.tmdb_id}`
: `douban:${props.media?.douban_id}`;
: `douban:${props.media?.douban_id}`
const result: Subscribe = await api.get(`subscribe/media/${mediaid}`, {
params: {
season: season,
season,
},
});
return result.id || null;
} catch (error) {
console.error(error);
})
return result.id || null
}
return null;
};
catch (error) {
console.error(error)
}
return null
}
// 检查所有季的缺失状态
const checkSeasonsNotExists = async () => {
async function checkSeasonsNotExists() {
// 开始处理
startNProgress();
startNProgress()
try {
const result: NotExistMediaInfo[] = await api.post(`download/notexists`, props.media);
const result: NotExistMediaInfo[] = await api.post('download/notexists', props.media)
if (result) {
result.forEach((item) => {
// 0-已存在 1-部分缺失 2-全部缺失
let state = 0;
if (item.episodes.length == 0) {
state = 2;
} else if (item.episodes.length < item.total_episodes) {
state = 1;
}
seasonsNotExisted.value[item.season] = state;
});
let state = 0
if (item.episodes.length === 0)
state = 2
else if (item.episodes.length < item.total_episodes)
state = 1
seasonsNotExisted.value[item.season] = state
})
}
} catch (error) {
$toast.error(`${props.media?.title}无法识别TMDB媒体信息`);
tmdbFlag.value = false;
}
catch (error) {
$toast.error(`${props.media?.title}无法识别TMDB媒体信息`)
tmdbFlag.value = false
}
// 处理完成
doneNProgress();
};
doneNProgress()
}
// 检查电影是否存在
const checkMovieExists = async () => {
async function checkMovieExists() {
try {
const result: NotExistMediaInfo[] = await api.post(`download/notexists`, props.media);
if (!result || result.length === 0) {
// 没有缺失的就是存在
return true;
}
return false;
} catch (error) {
console.error(error);
const result: NotExistMediaInfo[] = await api.post('download/notexists', props.media)
return !result || result.length === 0
}
};
catch (error) {
console.error(error)
}
}
// 查询TMDB的所有季信息
const getMediaSeasons = async () => {
async function getMediaSeasons() {
try {
seasonInfos.value = await api.get(`tmdb/${props.media?.tmdb_id}/seasons`);
} catch (error) {
console.error(error);
seasonInfos.value = await api.get(`tmdb/${props.media?.tmdb_id}/seasons`)
}
};
catch (error) {
console.error(error)
}
}
// 爱心订阅按钮响应
const handleSubscribe = () => {
if (isSubscribed.value) {
removeSubscribe();
} else {
handleAddSubscribe();
}
};
function handleSubscribe() {
if (isSubscribed.value)
removeSubscribe()
else
handleAddSubscribe()
}
// 拼装详情页链接
const getDetailLink = () => {
let link = "";
if (props.media?.douban_id) {
link = `https://movie.douban.com/subject/${props.media?.douban_id}/`;
} else if (props.media?.type === "电影") {
link = `https://www.themoviedb.org/movie/${props.media?.tmdb_id}`;
} else if (props.media?.type === "电视剧") {
link = `https://www.themoviedb.org/tv/${props.media?.tmdb_id}`;
}
return link;
};
function getDetailLink() {
let link = ''
if (props.media?.douban_id)
link = `https://movie.douban.com/subject/${props.media?.douban_id}/`
else if (props.media?.type === '电影')
link = `https://www.themoviedb.org/movie/${props.media?.tmdb_id}`
else if (props.media?.type === '电视剧')
link = `https://www.themoviedb.org/tv/${props.media?.tmdb_id}`
return link
}
// 计算存在状态的颜色
const getExistColor = (season: number) => {
let state = seasonsNotExisted.value[season];
if (!state) {
return "success";
}
if (state === 1) {
return "warning";
} else if (state === 2) {
return "error";
} else {
return "success";
}
};
function getExistColor(season: number) {
const state = seasonsNotExisted.value[season]
if (!state)
return 'success'
if (state === 1)
return 'warning'
else if (state === 2)
return 'error'
else
return 'success'
}
// 计算存在状态的文本
const getExistText = (season: number) => {
let state = seasonsNotExisted.value[season];
if (!state) {
return "已存在";
}
if (state === 1) {
return "部分缺失";
} else if (state === 2) {
return "缺失";
} else {
return "已存在";
}
};
function getExistText(season: number) {
const state = seasonsNotExisted.value[season]
if (!state)
return '已存在'
if (state === 1)
return '部分缺失'
else if (state === 2)
return '缺失'
else
return '已存在'
}
// 打开详情页
const openDetailWindow = () => {
window.open(getDetailLink(), "_blank");
};
function openDetailWindow() {
window.open(getDetailLink(), '_blank')
}
// 开始搜索
const handleSearch = () => {
function handleSearch() {
router.push({
path: "/resource",
path: '/resource',
query: {
keyword: `${
props.media?.tmdb_id
@@ -356,34 +374,31 @@ const handleSearch = () => {
}`,
type: props.media?.type,
},
});
};
})
}
// 装载时检查是否已订阅
onBeforeMount(() => {
handleCheckSubscribe();
handleCheckExists();
});
handleCheckSubscribe()
handleCheckExists()
})
// 订阅季表头
const seasonsHeaders = [
{ title: "季", key: "title", sortable: false },
{ title: "集数", key: "episodes", sortable: false },
{ title: "评分", key: "vote", sortable: false },
{ title: "状态", key: "status", sortable: false },
];
// 选中的订阅季
const seasonsSelected = ref<TmdbSeason[]>([]);
{ title: '季', key: 'title', sortable: false },
{ title: '集数', key: 'episodes', sortable: false },
{ title: '评分', key: 'vote', sortable: false },
{ title: '状态', key: 'status', sortable: false },
]
// 计算图片地址
const getImgUrl = (url: string) => {
function getImgUrl(url: string) {
// 如果地址中包含douban则使用中转代理
if (url.includes("doubanio.com")) {
return `${import.meta.env.VITE_API_BASE_URL}douban/img/${encodeURIComponent(url)}`;
}
return url;
};
if (url.includes('doubanio.com'))
return `${import.meta.env.VITE_API_BASE_URL}douban/img/${encodeURIComponent(url)}`
return url
}
</script>
<template>
@@ -404,13 +419,13 @@ const getImgUrl = (url: string) => {
:src="getImgUrl(props.media?.poster_path || '')"
class="object-cover aspect-w-2 aspect-h-3"
:class="hover.isHovering ? 'on-hover' : ''"
cover
@load="isImageLoaded = true"
@error="imageLoadError = true"
cover
>
<template #placeholder>
<div class="relative animate-pulse bg-gray-300 w-full">
<div class="w-full h-full"></div>
<div class="w-full h-full" />
</div>
</template>
<!-- 类型角标 -->
@@ -423,12 +438,12 @@ const getImgUrl = (url: string) => {
{{ props.media?.type }}
</VChip>
<!-- 本地存在标识 -->
<ExistIcon v-if="isExists"/>
<ExistIcon v-if="isExists" />
<!-- 评分角标 -->
<VChip
v-if="props.media?.vote_average && !isExists"
variant="elevated"
size="small"
v-if="props.media?.vote_average && !isExists"
:class="getChipColor('rating')"
class="absolute right-2 top-2 bg-opacity-80 shadow-md text-white font-bold"
>
@@ -436,21 +451,23 @@ const getImgUrl = (url: string) => {
</VChip>
<!-- 详情 -->
<VCardText
class="w-full flex flex-col flex-wrap justify-end align-left text-white absolute bottom-0 cursor-pointer pa-2"
v-show="hover.isHovering || imageLoadError"
class="w-full flex flex-col flex-wrap justify-end align-left text-white absolute bottom-0 cursor-pointer pa-2"
@click.stop="openDetailWindow"
>
<span class="font-bold">{{ props.media?.year }}</span>
<h1
class="mb-1 text-white font-extrabold text-xl line-clamp-2 overflow-hidden text-ellipsis ..."
>
<h1 class="mb-1 text-white font-extrabold text-xl line-clamp-2 overflow-hidden text-ellipsis ...">
{{ props.media?.title }}
</h1>
<p class="leading-4 line-clamp-4 overflow-hidden text-ellipsis ...">
{{ props.media?.overview }}
</p>
<div class="flex align-center justify-between">
<IconBtn icon="mdi-magnify" color="white" @click.stop="handleSearch" />
<IconBtn
icon="mdi-magnify"
color="white"
@click.stop="handleSearch"
/>
<IconBtn
icon="mdi-heart"
:color="isSubscribed ? 'error' : 'white'"
@@ -484,39 +501,53 @@ const getImgUrl = (url: string) => {
height="auto"
>
<template #item.title="{ item }">
<span class="d-block whitespace-nowrap"
> {{ item.raw.season_number }}
</span></template
>
<span class="d-block whitespace-nowrap"> {{ item.raw.season_number }}
</span>
</template>
<template #item.episodes="{ item }">
<VChip variant="outlined" size="small">{{ item.raw.episode_count }}</VChip>
<VChip
variant="outlined"
size="small"
>
{{ item.raw.episode_count }}
</VChip>
</template>
<template #item.vote="{ item }">
{{ item.raw.vote_average }}
</template>
<template #item.status="{ item }">
<VChip
:color="getExistColor(item.raw.season_number)"
v-if="seasonsNotExisted"
:color="getExistColor(item.raw.season_number)"
flat
size="small"
>{{ getExistText(item.raw.season_number) }}</VChip
>
{{ getExistText(item.raw.season_number) }}
</VChip>
</template>
<template #no-data> 没有数据 </template>
<template #bottom></template>
<template #no-data>
没有数据
</template>
<template #bottom />
</VDataTable>
</VCardText>
<VCardActions>
<VBtn @click="subscribeSeasonDialog = false"> 取消 </VBtn>
<VBtn @click="subscribeSeasonDialog = false">
取消
</VBtn>
<VSpacer />
<VBtn @click="subscribeSeasons" @keydown.enter="subscribeSeasons"> 确定 </VBtn>
<VBtn
@click="subscribeSeasons"
@keydown.enter="subscribeSeasons"
>
确定
</VBtn>
</VCardActions>
</VCard>
</VDialog>
</template>
<style type="scss">
<style lang="scss">
.on-hover img {
@apply brightness-50;
}

View File

@@ -1,47 +1,63 @@
<script lang="ts" setup>
import api from "@/api";
import { Plugin } from "@/api/types";
import { useToast } from "vue-toast-notification";
// 定义触发的自定义事件
const emit = defineEmits(["install"]);
// 提示框
const $toast = useToast();
import { useToast } from 'vue-toast-notification'
import api from '@/api'
import type { Plugin } from '@/api/types'
// 输入参数
const props = defineProps({
plugin: Object as PropType<Plugin>,
width: String,
height: String,
});
})
// 定义触发的自定义事件
const emit = defineEmits(['install'])
// 提示框
const $toast = useToast()
// 安装插件
const installPlugin = async () => {
async function installPlugin() {
try {
const result: { [key: string]: any } = await api.get(
`plugin/install/${props.plugin?.id}`
);
`plugin/install/${props.plugin?.id}`,
)
if (result.success) {
$toast.success(`插件 ${props.plugin?.plugin_name} 安装成功!`);
$toast.success(`插件 ${props.plugin?.plugin_name} 安装成功!`)
// 通知父组件刷新
emit("install");
} else {
$toast.error(`插件 ${props.plugin?.plugin_name} 安装失败:${result.message}}`);
emit('install')
}
else {
$toast.error(`插件 ${props.plugin?.plugin_name} 安装失败:${result.message}}`)
}
} catch (error) {
console.error(error);
}
};
catch (error) {
console.error(error)
}
}
</script>
<template>
<VCard :width="props.width" :height="props.height" @click="installPlugin">
<VCard
:width="props.width"
:height="props.height"
@click="installPlugin"
>
<div
class="relative pa-4 text-center card-cover-blurred"
:style="{ background: `${props.plugin?.plugin_color}` }"
>
<VAvatar size="128" class="shadow">
<VImg :src="`/plugin/${props.plugin?.plugin_icon}`" aspect-ratio="4/3" cover />
<VAvatar
size="128"
class="shadow"
>
<VImg
:src="`/plugin/${props.plugin?.plugin_icon}`"
aspect-ratio="4/3"
cover
/>
</VAvatar>
</div>
<VCardTitle>{{ props.plugin?.plugin_name }}</VCardTitle>
@@ -50,13 +66,18 @@ const installPlugin = async () => {
{{ props.plugin?.plugin_desc }}
</VCardText>
<VCardText>
作者<a :href="props.plugin?.author_url" target="_blank" @click.stop>
作者<a
:href="props.plugin?.author_url"
target="_blank"
@click.stop
>
{{ props.plugin?.plugin_author }}
</a>
</VCardText>
</VCard>
</template>
<style type="scss" scoped>
<style lang="scss" scoped>
.card-cover-blurred::before {
position: absolute;
/* stylelint-disable-next-line property-no-vendor-prefix */

View File

@@ -1,62 +1,66 @@
<script lang="ts" setup>
import api from "@/api";
import { Plugin } from "@/api/types";
import { useToast } from "vue-toast-notification";
// 定义触发的自定义事件
const emit = defineEmits(["remove"]);
// 提示框
const $toast = useToast();
import { useToast } from 'vue-toast-notification'
import api from '@/api'
import type { Plugin } from '@/api/types'
// 输入参数
const props = defineProps({
plugin: Object as PropType<Plugin>,
width: String,
height: String,
});
})
// 定义触发的自定义事件
const emit = defineEmits(['remove'])
// 提示框
const $toast = useToast()
// 本身是否可见
const isVisible = ref(true);
const isVisible = ref(true)
// 卸载插件
const uninstallPlugin = async () => {
async function uninstallPlugin() {
try {
const result: { [key: string]: any } = await api.delete(`plugin/${props.plugin?.id}`);
const result: { [key: string]: any } = await api.delete(`plugin/${props.plugin?.id}`)
if (result.success) {
$toast.success(`插件 ${props.plugin?.plugin_name} 已卸载`);
$toast.success(`插件 ${props.plugin?.plugin_name} 已卸载`)
// 通知父组件刷新
emit("remove");
} else {
$toast.error(`插件 ${props.plugin?.plugin_name} 卸载失败:${result.message}}`);
emit('remove')
}
else {
$toast.error(`插件 ${props.plugin?.plugin_name} 卸载失败:${result.message}}`)
}
} catch (error) {
console.error(error);
}
};
catch (error) {
console.error(error)
}
}
// 显示插件详情
const showPluginInfo = () => {};
function showPluginInfo() {}
// 弹出菜单
const dropdownItems = ref([
{
title: "卸载",
title: '卸载',
value: 1,
props: {
prependIcon: "mdi-trash-can-outline",
color: "error",
prependIcon: 'mdi-trash-can-outline',
color: 'error',
click: uninstallPlugin,
},
},
]);
])
</script>
<template>
<VCard
v-if="isVisible"
:width="props.width"
:height="props.height"
@click="showPluginInfo"
v-if="isVisible"
>
<div
class="relative pa-4 text-center card-cover-blurred"
@@ -65,26 +69,36 @@ const dropdownItems = ref([
<div class="me-n3 absolute top-0 right-3">
<IconBtn>
<VIcon icon="mdi-dots-vertical" />
<VMenu activator="parent" close-on-content-click>
<VMenu
activator="parent"
close-on-content-click
>
<VList>
<VListItem
v-for="(item, i) in dropdownItems"
:key="i"
variant="plain"
:base-color="item.props.color"
:key="i"
@click="item.props.click"
>
<template #prepend>
<VIcon :icon="item.props.prependIcon"></VIcon>
<VIcon :icon="item.props.prependIcon" />
</template>
<VListItemTitle v-text="item.title"></VListItemTitle>
<VListItemTitle v-text="item.title" />
</VListItem>
</VList>
</VMenu>
</IconBtn>
</div>
<VAvatar size="128" class="shadow">
<VImg :src="`/plugin/${props.plugin?.plugin_icon}`" aspect-ratio="4/3" cover />
<VAvatar
size="128"
class="shadow"
>
<VImg
:src="`/plugin/${props.plugin?.plugin_icon}`"
aspect-ratio="4/3"
cover
/>
</VAvatar>
</div>
<VCardItem class="py-2">
@@ -95,7 +109,8 @@ const dropdownItems = ref([
</VCardText>
</VCard>
</template>
<style type="scss" scoped>
<style lang="scss" scoped>
.card-cover-blurred::before {
position: absolute;
/* stylelint-disable-next-line property-no-vendor-prefix */

View File

@@ -1,183 +1,207 @@
<script lang="ts" setup>
import { numberValidator, requiredValidator } from "@/@validators";
import api from "@/api";
import { Site } from "@/api/types";
import ExistIcon from "@core/components/ExistIcon.vue";
import { useToast } from "vue-toast-notification";
import type { PropType } from 'vue'
import { useToast } from 'vue-toast-notification'
import { numberValidator, requiredValidator } from '@/@validators'
import api from '@/api'
import type { Site } from '@/api/types'
import ExistIcon from '@core/components/ExistIcon.vue'
// 输入参数
const props = defineProps({
site: Object as PropType<Site>,
width: String,
height: String,
});
})
// 密码输入
const isPasswordVisible = ref(false);
const isPasswordVisible = ref(false)
// 图标
const siteIcon = ref<string>("");
const siteIcon = ref<string>('')
// 提示框
const $toast = useToast();
const $toast = useToast()
// 测试按钮文字
const testButtonText = ref("测试");
const testButtonText = ref('测试')
// 测试按钮可用性
const testButtonDisable = ref(false);
const testButtonDisable = ref(false)
// 更新按钮文字
const updateButtonText = ref("更新");
const updateButtonText = ref('更新')
// 更新按钮可用性
const updateButtonDisable = ref(false);
const updateButtonDisable = ref(false)
// 更新站点Cookie UA弹窗
const siteCookieDialog = ref(false);
const siteCookieDialog = ref(false)
// 站点编辑弹窗
const siteInfoDialog = ref(false);
const siteInfoDialog = ref(false)
// 用户名密码表单
const userPwForm = ref({
username: "",
password: "",
});
username: '',
password: '',
})
// 查询站点图标
const getSiteIcon = async () => {
async function getSiteIcon() {
try {
siteIcon.value = (await api.get("site/icon/" + props.site?.id)).data.icon;
} catch (error) {
console.error(error);
siteIcon.value = (await api.get(`site/icon/${props.site?.id}`)).data.icon
}
};
catch (error) {
console.error(error)
}
}
// 测试站点连通性
const testSite = async () => {
async function testSite() {
try {
testButtonText.value = "测试中 ...";
testButtonDisable.value = true;
const result: { [key: string]: any } = await api.get("site/test/" + props.site?.id);
if (result.success) {
$toast.success(`${props.site?.name} 连通性测试成功,可正常使用!`);
} else {
$toast.error(`${props.site?.name} 连通性测试失败:${result.message}`);
}
testButtonText.value = "测试";
testButtonDisable.value = false;
} catch (error) {
console.error(error);
testButtonText.value = '测试中 ...'
testButtonDisable.value = true
const result: { [key: string]: any } = await api.get(`site/test/${props.site?.id}`)
if (result.success)
$toast.success(`${props.site?.name} 连通性测试成功,可正常使用!`)
else
$toast.error(`${props.site?.name} 连通性测试失败:${result.message}`)
testButtonText.value = '测试'
testButtonDisable.value = false
}
};
catch (error) {
console.error(error)
}
}
// 打开更新站点Cookie UA弹窗
const handleSiteUpdate = async () => {
siteCookieDialog.value = true;
};
async function handleSiteUpdate() {
siteCookieDialog.value = true
}
// 打开站点编辑弹窗
const handleSiteInfo = async () => {
siteInfoDialog.value = true;
};
async function handleSiteInfo() {
siteInfoDialog.value = true
}
// 调用API更新站点Cookie UA
const updateSiteCookie = async () => {
async function updateSiteCookie() {
try {
if (!userPwForm.value.username || !userPwForm.value.password) {
return;
}
if (!userPwForm.value.username || !userPwForm.value.password)
return
// 更新按钮状态
siteCookieDialog.value = false;
updateButtonText.value = "更新中 ...";
updateButtonDisable.value = true;
siteCookieDialog.value = false
updateButtonText.value = '更新中 ...'
updateButtonDisable.value = true
const result: { [key: string]: any } = await api.get(
"site/cookie/" + props.site?.id,
`site/cookie/${props.site?.id}`,
{
params: {
username: userPwForm.value.username,
password: userPwForm.value.password,
},
}
);
if (result.success) {
$toast.success(`${props.site?.name} 更新Cookie & UA 成功!`);
} else {
$toast.error(`${props.site?.name} 更新失败:${result.message}`);
}
updateButtonText.value = "更新";
updateButtonDisable.value = false;
} catch (error) {
console.error(error);
}
};
},
)
// 调用API更新站点信息
const updateSiteInfo = async () => {
try {
// 更新按钮状态
siteInfoDialog.value = false;
if (result.success)
$toast.success(`${props.site?.name} 更新Cookie & UA 成功!`)
else
$toast.error(`${props.site?.name} 更新失败:${result.message}`)
const result: { [key: string]: any } = await api.put("site", siteForm);
if (result.success) {
$toast.success(`${props.site?.name} 更新成功!`);
} else {
$toast.error(`${props.site?.name} 更新失败:${result.message}`);
}
} catch (error) {
$toast.error(`${props.site?.name} 更新失败!`);
console.error(error);
updateButtonText.value = '更新'
updateButtonDisable.value = false
}
};
catch (error) {
console.error(error)
}
}
// 站点编辑表单数据
const siteForm = reactive({
// ID
id: props.site?.id,
// 站点名称
name: props.site?.name,
// 站点主域名Key
domain: props.site?.domain,
// 站点地址
url: props.site?.url,
// 站点优先级
pri: props.site?.pri,
// RSS地址
rss: props.site?.rss,
// Cookie
cookie: props.site?.cookie,
// User-Agent
ua: props.site?.ua,
// 是否使用代理
proxy: props.site?.proxy ? true : false,
proxy: !!props.site?.proxy,
// 过滤规则
filter: props.site?.filter,
// 是否演染
render: props.site?.render ? true : false,
render: !!props.site?.render,
// 是否公开站点
public: props.site?.public,
// 备注
note: props.site?.note,
// 流控单位周期
limit_interval: props.site?.limit_interval,
// 流控次数
limit_count: props.site?.limit_count,
// 流控间隔
limit_seconds: props.site?.limit_seconds,
// 是否启用
is_active: props.site?.is_active,
});
})
// 调用API更新站点信息
async function updateSiteInfo() {
try {
// 更新按钮状态
siteInfoDialog.value = false
const result: { [key: string]: any } = await api.put('site', siteForm)
if (result.success)
$toast.success(`${props.site?.name} 更新成功!`)
else
$toast.error(`${props.site?.name} 更新失败:${result.message}`)
}
catch (error) {
$toast.error(`${props.site?.name} 更新失败!`)
console.error(error)
}
}
// 状态下拉项
const statusItems = [
{ title: "启用", value: true },
{ title: "停用", value: false },
];
{ title: '启用', value: true },
{ title: '停用', value: false },
]
// 装载时查询站点图标
onMounted(() => {
getSiteIcon();
});
getSiteIcon()
})
</script>
<template>
@@ -189,37 +213,70 @@ onMounted(() => {
@click="siteInfoDialog = true"
>
<template #image>
<VAvatar class="absolute right-2 bottom-2 rounded" variant="flat" rounded="0">
<VAvatar
class="absolute right-2 bottom-2 rounded"
variant="flat"
rounded="0"
>
<VImg :src="siteIcon" />
</VAvatar>
</template>
<VCardItem>
<VCardTitle class="font-bold">{{ props.site?.name }}</VCardTitle>
<VCardTitle class="font-bold">
{{ props.site?.name }}
</VCardTitle>
<VCardSubtitle>{{ props.site?.url }}</VCardSubtitle>
</VCardItem>
<ExistIcon v-if="siteForm.is_active" />
<VCardText class="py-2">
<VTooltip text="浏览器仿真" v-if="siteForm.render">
<VTooltip
v-if="siteForm.render"
text="浏览器仿真"
>
<template #activator="{ props }">
<VIcon color="primary" class="me-2" v-bind="props" icon="mdi-apple-safari" />
<VIcon
color="primary"
class="me-2"
v-bind="props"
icon="mdi-apple-safari"
/>
</template>
</VTooltip>
<VTooltip text="代理" v-if="siteForm.proxy">
<VTooltip
v-if="siteForm.proxy"
text="代理"
>
<template #activator="{ props }">
<VIcon color="primary" class="me-2" v-bind="props" icon="mdi-network-outline" />
<VIcon
color="primary"
class="me-2"
v-bind="props"
icon="mdi-network-outline"
/>
</template>
</VTooltip>
<VTooltip text="流控" v-if="siteForm.limit_interval">
<VTooltip
v-if="siteForm.limit_interval"
text="流控"
>
<template #activator="{ props }">
<VIcon color="primary" class="me-2" v-bind="props" icon="mdi-speedometer" />
<VIcon
color="primary"
class="me-2"
v-bind="props"
icon="mdi-speedometer"
/>
</template>
</VTooltip>
<VTooltip text="过滤" v-if="siteForm.filter">
<VTooltip
v-if="siteForm.filter"
text="过滤"
>
<template #activator="{ props }">
<VIcon
color="primary"
@@ -233,44 +290,56 @@ onMounted(() => {
<VCardActions>
<VBtn
@click.stop="handleSiteUpdate"
:disabled="updateButtonDisable"
v-if="!props.site?.public"
:disabled="updateButtonDisable"
@click.stop="handleSiteUpdate"
>
<template #prepend>
<VIcon icon="mdi-refresh"></VIcon>
<VIcon icon="mdi-refresh" />
</template>
{{ updateButtonText }}
</VBtn>
<VBtn @click.stop="handleSiteInfo">
<template #prepend>
<VIcon icon="mdi-square-edit-outline"></VIcon>
<VIcon icon="mdi-square-edit-outline" />
</template>
编辑
</VBtn>
<VBtn @click.stop="testSite" :disabled="testButtonDisable">
<VBtn
:disabled="testButtonDisable"
@click.stop="testSite"
>
<template #prepend>
<VIcon icon="mdi-network-outline"></VIcon>
<VIcon icon="mdi-network-outline" />
</template>
{{ testButtonText }}
</VBtn>
</VCardActions>
</VCard>
<!-- 更新站点Cookie & UA弹窗 -->
<VDialog v-model="siteCookieDialog" max-width="600">
<VDialog
v-model="siteCookieDialog"
max-width="600"
>
<!-- Dialog Content -->
<VCard title="更新站点Cookie & UA">
<VCardText>
<VForm @submit.prevent="() => {}">
<VRow>
<VCol cols="12" md="6">
<VCol
cols="12"
md="6"
>
<VTextField
v-model="userPwForm.username"
label="用户名"
:rules="[requiredValidator]"
/>
</VCol>
<VCol cols="12" md="6">
<VCol
cols="12"
md="6"
>
<VTextField
v-model="userPwForm.password"
label="密码"
@@ -278,8 +347,8 @@ onMounted(() => {
:append-inner-icon="
isPasswordVisible ? 'mdi-eye-off-outline' : 'mdi-eye-outline'
"
@click:append-inner="isPasswordVisible = !isPasswordVisible"
:rules="[requiredValidator]"
@click:append-inner="isPasswordVisible = !isPasswordVisible"
@keydown.enter="updateSiteCookie"
/>
</VCol>
@@ -289,25 +358,38 @@ onMounted(() => {
<VCardActions>
<VSpacer />
<VBtn @click="updateSiteCookie"> 开始更新 </VBtn>
<VBtn @click="updateSiteCookie">
开始更新
</VBtn>
</VCardActions>
</VCard>
</VDialog>
<!-- 站点编辑弹窗 -->
<VDialog v-model="siteInfoDialog" max-width="1000" persistent scrollable>
<VDialog
v-model="siteInfoDialog"
max-width="1000"
persistent
scrollable
>
<!-- Dialog Content -->
<VCard :title="`编辑站点 - ${props.site?.name}`">
<VCardText>
<VForm @submit.prevent="() => {}">
<VRow>
<VCol cols="12" md="6">
<VCol
cols="12"
md="6"
>
<VTextField
v-model="siteForm.url"
label="站点地址"
:rules="[requiredValidator]"
/>
</VCol>
<VCol cols="12" md="3">
<VCol
cols="12"
md="3"
>
<VSelect
v-model="siteForm.pri"
label="优先级"
@@ -315,34 +397,56 @@ onMounted(() => {
:rules="[requiredValidator]"
/>
</VCol>
<VCol cols="12" md="3">
<VSelect v-model="siteForm.is_active" :items="statusItems" label="状态" />
<VCol
cols="12"
md="3"
>
<VSelect
v-model="siteForm.is_active"
:items="statusItems"
label="状态"
/>
</VCol>
</VRow>
<VRow>
<VCol cols="12">
<VTextarea v-model="siteForm.cookie" label="站点Cookie" />
<VTextarea
v-model="siteForm.cookie"
label="站点Cookie"
/>
</VCol>
<VCol cols="12">
<VTextField v-model="siteForm.ua" label="站点User-Agent" />
<VTextField
v-model="siteForm.ua"
label="站点User-Agent"
/>
</VCol>
</VRow>
<VRow>
<VCol cols="12" md="4">
<VCol
cols="12"
md="4"
>
<VTextField
v-model="siteForm.limit_interval"
label="单位周期(秒)"
:rules="[numberValidator]"
/>
</VCol>
<VCol cols="12" md="4">
<VCol
cols="12"
md="4"
>
<VTextField
v-model="siteForm.limit_seconds"
label="访问次数"
:rules="[numberValidator]"
/>
</VCol>
<VCol cols="12" md="4">
<VCol
cols="12"
md="4"
>
<VTextField
v-model="siteForm.limit_seconds"
label="访问间隔(秒)"
@@ -351,20 +455,36 @@ onMounted(() => {
</VCol>
</VRow>
<VRow>
<VCol cols="12" md="6">
<VSwitch v-model="siteForm.proxy" label="代理" />
<VCol
cols="12"
md="6"
>
<VSwitch
v-model="siteForm.proxy"
label="代理"
/>
</VCol>
<VCol cols="12" md="6">
<VSwitch v-model="siteForm.render" label="仿真" />
<VCol
cols="12"
md="6"
>
<VSwitch
v-model="siteForm.render"
label="仿真"
/>
</VCol>
</VRow>
</VForm>
</VCardText>
<VCardActions>
<VBtn @click="siteInfoDialog = false"> 取消 </VBtn>
<VBtn @click="siteInfoDialog = false">
取消
</VBtn>
<VSpacer />
<VBtn @click="updateSiteInfo"> 确定 </VBtn>
<VBtn @click="updateSiteInfo">
确定
</VBtn>
</VCardActions>
</VCard>
</VDialog>

View File

@@ -1,209 +1,225 @@
<script lang="ts" setup>
import { calculateTimeDifference } from "@/@core/utils";
import { formatSeason } from "@/@core/utils/formatters";
import { numberValidator } from "@/@validators";
import api from "@/api";
import { Site, Subscribe } from "@/api/types";
import { useToast } from "vue-toast-notification";
import { useToast } from 'vue-toast-notification'
import { calculateTimeDifference } from '@/@core/utils'
import { formatSeason } from '@/@core/utils/formatters'
import { numberValidator } from '@/@validators'
import api from '@/api'
import type { Site, Subscribe } from '@/api/types'
// 输入参数
const props = defineProps({
media: Object as PropType<Subscribe>,
});
})
// 提示框
const $toast = useToast();
const $toast = useToast()
// 是否显示卡片
const cardState = ref(true);
const cardState = ref(true)
// 图片是否加载完成
const imageLoaded = ref(false);
const imageLoaded = ref(false)
// 订阅弹窗
const subscribeInfoDialog = ref(false);
const subscribeInfoDialog = ref(false)
// 站点数据列表
const siteList = ref<Site[]>([]);
const siteList = ref<Site[]>([])
// 站点选择下载框
const selectSitesOptions = ref<{ [key: number]: string }[]>([]);
const selectSitesOptions = ref<{ [key: number]: string }[]>([])
// 订阅编辑表单
const subscribeForm = reactive({
id: props.media?.id,
// 搜索关键字
keyword: props.media?.keyword,
// 过滤规则
filter: props.media?.filter,
// 包含
include: props.media?.include,
// 排除
exclude: props.media?.exclude,
// 总集数
total_episode: props.media?.total_episode,
// 开始集数
start_episode: props.media?.start_episode,
// 订阅站点
sites: props.media?.sites,
})
// 上一次更新时间
const lastUpdateText = ref(
`${
props.media?.last_update
? `${calculateTimeDifference(props.media?.last_update || "")}`
: ""
}`
);
? `${calculateTimeDifference(props.media?.last_update || '')}`
: ''
}`,
)
// 图片加载完成响应
const imageLoadHandler = () => {
imageLoaded.value = true;
};
function imageLoadHandler() {
imageLoaded.value = true
}
// 根据 type 返回不同的图标
const getIcon = () => {
if (props.media?.type === "电影") {
return "mdi-movie";
} else if (props.media?.type === "电视剧") {
return "mdi-television-classic";
} else {
return "mdi-help-circle";
}
};
function getIcon() {
if (props.media?.type === '电影')
return 'mdi-movie'
else if (props.media?.type === '电视剧')
return 'mdi-television-classic'
else
return 'mdi-help-circle'
}
// 计算百分比
const getPercentage = () => {
if (props.media?.total_episode === 0) {
return 0;
}
function getPercentage() {
if (props.media?.total_episode === 0)
return 0
return Math.round(
(((props.media?.total_episode || 0) - (props.media?.lack_episode || 0)) /
(props.media?.total_episode || 1)) *
100
);
};
(((props.media?.total_episode || 0) - (props.media?.lack_episode || 0))
/ (props.media?.total_episode || 1))
* 100,
)
}
// 计算文本颜色
const getTextColor = () => {
return imageLoaded.value ? "white" : "";
};
function getTextColor() {
return imageLoaded.value ? 'white' : ''
}
// 计算文本类
const getTextClass = () => {
return imageLoaded.value ? "text-white" : "";
};
function getTextClass() {
return imageLoaded.value ? 'text-white' : ''
}
// 删除订阅
const removeSubscribe = async () => {
async function removeSubscribe() {
try {
const result: { [key: string]: any } = await api.delete(
`subscribe/${props.media?.id}`
);
if (result.success) {
cardState.value = false;
}
} catch (e) {
console.log(e);
`subscribe/${props.media?.id}`,
)
if (result.success)
cardState.value = false
}
};
catch (e) {
console.log(e)
}
}
// 搜索订阅
const searchSubscribe = async () => {
async function searchSubscribe() {
try {
const result: { [key: string]: any } = await api.get(
`subscribe/search/${props.media?.id}`
);
`subscribe/search/${props.media?.id}`,
)
// 提示
if (result.success) {
$toast.success(`${props.media?.name} 提交搜索请求成功!`);
}
} catch (e) {
console.log(e);
if (result.success)
$toast.success(`${props.media?.name} 提交搜索请求成功!`)
}
};
catch (e) {
console.log(e)
}
}
// 调用API修改订阅
const updateSubscribeInfo = async () => {
subscribeInfoDialog.value = false;
async function updateSubscribeInfo() {
subscribeInfoDialog.value = false
try {
const result: { [key: string]: any } = await api.put(`subscribe`, subscribeForm);
const result: { [key: string]: any } = await api.put('subscribe', subscribeForm)
// 提示
if (result.success) {
$toast.success(`${props.media?.name} 更新成功!`);
} else {
$toast.error(`${props.media?.name} 更新失败:${result.message}`);
}
} catch (e) {
console.log(e);
if (result.success)
$toast.success(`${props.media?.name} 更新成功!`)
else
$toast.error(`${props.media?.name} 更新失败:${result.message}`)
}
};
catch (e) {
console.log(e)
}
}
// 获取站点列表数据
const loadSites = async () => {
async function loadSites() {
try {
const data: Site[] = await api.get("site");
const data: Site[] = await api.get('site')
// 过滤站点,只有启用的站点才显示
siteList.value = data.filter((item) => item.is_active);
} catch (error) {
console.error(error);
siteList.value = data.filter(item => item.is_active)
}
};
catch (error) {
console.error(error)
}
}
// 获取站点列表选择框数据
const getSiteList = async () => {
async function getSiteList() {
// 加载订阅站点列表
if (!siteList.value.length) {
await loadSites();
}
if (!siteList.value.length)
await loadSites()
const maps = siteList.value.map((item) => {
return {
title: item.name,
value: item.id,
};
});
selectSitesOptions.value = maps.flat();
};
}
})
selectSitesOptions.value = maps.flat()
}
// 编辑订阅响应
const editSubscribeDialog = async () => {
await getSiteList();
subscribeInfoDialog.value = true;
};
async function editSubscribeDialog() {
await getSiteList()
subscribeInfoDialog.value = true
}
// 弹出菜单
const dropdownItems = ref([
{
title: "编辑",
title: '编辑',
value: 1,
props: {
prependIcon: "mdi-file-edit-outline",
prependIcon: 'mdi-file-edit-outline',
click: editSubscribeDialog,
},
},
{
title: "搜索",
title: '搜索',
value: 2,
props: {
prependIcon: "mdi-magnify",
prependIcon: 'mdi-magnify',
click: searchSubscribe,
},
},
{
title: "取消订阅",
title: '取消订阅',
value: 3,
props: {
prependIcon: "mdi-trash-can-outline",
color: "error",
prependIcon: 'mdi-trash-can-outline',
color: 'error',
click: removeSubscribe,
},
},
]);
// 订阅编辑表单
const subscribeForm = reactive({
id: props.media?.id,
// 搜索关键字
keyword: props.media?.keyword,
// 过滤规则
filter: props.media?.filter,
// 包含
include: props.media?.include,
// 排除
exclude: props.media?.exclude,
// 总集数
total_episode: props.media?.total_episode,
// 开始集数
start_episode: props.media?.start_episode,
// 订阅站点
sites: props.media?.sites,
});
])
</script>
<template>
<VCard :key="props.media?.id" v-if="cardState" @click="editSubscribeDialog">
<VCard
v-if="cardState"
:key="props.media?.id"
@click="editSubscribeDialog"
>
<template #image>
<VImg
:src="props.media?.backdrop || props.media?.poster"
@@ -215,7 +231,11 @@ const subscribeForm = reactive({
</template>
<VCardItem>
<template #prepend>
<VIcon size="1.9rem" :color="getTextColor()" :icon="getIcon()" />
<VIcon
size="1.9rem"
:color="getTextColor()"
:icon="getIcon()"
/>
</template>
<VCardTitle :class="getTextClass()">
{{ props.media?.name }}
@@ -224,20 +244,26 @@ const subscribeForm = reactive({
<template #append>
<div class="me-n3">
<IconBtn>
<VIcon icon="mdi-dots-vertical" :color="getTextColor()" />
<VMenu activator="parent" close-on-content-click>
<VIcon
icon="mdi-dots-vertical"
:color="getTextColor()"
/>
<VMenu
activator="parent"
close-on-content-click
>
<VList>
<VListItem
v-for="(item, i) in dropdownItems"
:key="i"
variant="plain"
:base-color="item.props.color"
:key="i"
@click="item.props.click"
>
<template #prepend>
<VIcon :icon="item.props.prependIcon"></VIcon>
<VIcon :icon="item.props.prependIcon" />
</template>
<VListItemTitle v-text="item.title"></VListItemTitle>
<VListItemTitle v-text="item.title" />
</VListItem>
</VList>
</VMenu>
@@ -247,51 +273,63 @@ const subscribeForm = reactive({
</VCardItem>
<VCardText>
<p class="clamp-text mb-0" :class="getTextClass()">
<p
class="clamp-text mb-0"
:class="getTextClass()"
>
{{ props.media?.description }}
</p>
</VCardText>
<VCardText class="d-flex justify-space-between align-center flex-wrap">
<div class="d-flex align-center">
<IconBtn icon="mdi-star" :color="getTextColor()" class="me-1" />
<span class="text-subtitle-2 me-4" :class="getTextClass()">{{
<IconBtn
icon="mdi-star"
:color="getTextColor()"
class="me-1"
/>
<span
class="text-subtitle-2 me-4"
:class="getTextClass()"
>{{
props.media?.vote
}}</span>
<IconBtn
v-if="props.media?.total_episode"
v-bind="props"
icon="mdi-progress-clock"
:color="getTextColor()"
class="me-1"
v-if="props.media?.total_episode"
/>
<span
v-if="props.media?.season"
class="text-subtitle-2 me-4"
:class="getTextClass()"
v-if="props.media?.season"
>{{ (props.media?.total_episode || 0) - (props.media?.lack_episode || 0) }} /
{{ props.media?.total_episode }}</span
>
>{{ (props.media?.total_episode || 0) - (props.media?.lack_episode || 0) }} /
{{ props.media?.total_episode }}</span>
<IconBtn
v-if="props.media?.username"
icon="mdi-account"
:color="getTextColor()"
class="me-1"
v-if="props.media?.username"
/>
<span
v-if="props.media?.username"
class="text-subtitle-2 me-4"
:class="getTextClass()"
v-if="props.media?.username"
>
{{ props.media?.username }}
</span>
</div>
</VCardText>
<VCardText
class="absolute right-0 bottom-0 d-flex align-center p-2 text-gray-300"
v-if="lastUpdateText"
class="absolute right-0 bottom-0 d-flex align-center p-2 text-gray-300"
>
<VIcon icon="mdi-download" class="me-1" /> {{ lastUpdateText }}
<VIcon
icon="mdi-download"
class="me-1"
/> {{ lastUpdateText }}
</VCardText>
<VProgressLinear
v-if="getPercentage() > 0"
@@ -301,23 +339,42 @@ const subscribeForm = reactive({
/>
</VCard>
<!-- 订阅编辑弹窗 -->
<VDialog v-model="subscribeInfoDialog" max-width="1000" persistent scrollable>
<VDialog
v-model="subscribeInfoDialog"
max-width="1000"
persistent
scrollable
>
<!-- Dialog Content -->
<VCard :title="`编辑订阅 - ${props.media?.name}`">
<VCardText>
<VForm @submit.prevent="() => {}">
<VRow>
<VCol cols="12" md="6">
<VTextField v-model="subscribeForm.keyword" label="搜索关键词" />
<VCol
cols="12"
md="6"
>
<VTextField
v-model="subscribeForm.keyword"
label="搜索关键词"
/>
</VCol>
<VCol cols="12" md="3" v-if="props.media?.type == '电视剧'">
<VCol
v-if="props.media?.type === '电视剧'"
cols="12"
md="3"
>
<VTextField
v-model="subscribeForm.total_episode"
label="总集数"
:rules="[numberValidator]"
/>
</VCol>
<VCol cols="12" md="3" v-if="props.media?.type == '电视剧'">
<VCol
v-if="props.media?.type === '电视剧'"
cols="12"
md="3"
>
<VTextField
v-model="subscribeForm.start_episode"
label="开始集数"
@@ -326,13 +383,19 @@ const subscribeForm = reactive({
</VCol>
</VRow>
<VRow>
<VCol cols="12" md="6">
<VCol
cols="12"
md="6"
>
<VTextField
v-model="subscribeForm.include"
label="包含(关键字、正则式)"
/>
</VCol>
<VCol cols="12" md="6">
<VCol
cols="12"
md="6"
>
<VTextField
v-model="subscribeForm.exclude"
label="排除(关键字、正则式)"
@@ -347,16 +410,20 @@ const subscribeForm = reactive({
chips
label="订阅站点"
multiple
></VSelect>
/>
</VCol>
</VRow>
</VForm>
</VCardText>
<VCardActions>
<VBtn @click="subscribeInfoDialog = false"> 取消 </VBtn>
<VBtn @click="subscribeInfoDialog = false">
取消
</VBtn>
<VSpacer />
<VBtn @click="updateSubscribeInfo"> 确定 </VBtn>
<VBtn @click="updateSubscribeInfo">
确定
</VBtn>
</VCardActions>
</VCard>
</VDialog>

View File

@@ -1,10 +1,11 @@
<script lang="ts" setup>
import { formatFileSize } from "@/@core/utils/formatters";
import api from "@/api";
import { doneNProgress, startNProgress } from "@/api/nprogress";
import { Context } from "@/api/types";
import { useToast } from "vue-toast-notification";
import { useConfirm } from "vuetify-use-dialog";
import type { PropType } from 'vue'
import { useToast } from 'vue-toast-notification'
import { useConfirm } from 'vuetify-use-dialog'
import { formatFileSize } from '@/@core/utils/formatters'
import api from '@/api'
import { doneNProgress, startNProgress } from '@/api/nprogress'
import type { Context } from '@/api/types'
// 输入参数
const props = defineProps({
@@ -12,135 +13,152 @@ const props = defineProps({
more: Array as PropType<Context[]>,
width: String,
height: String,
});
})
// 提示框
const $toast = useToast();
const $toast = useToast()
// 确认框
const createConfirm = useConfirm();
const createConfirm = useConfirm()
// 更多来源界面
const showMoreTorrents = ref(false);
const showMoreTorrents = ref(false)
// 种子信息
const torrent = ref(props.torrent?.torrent_info);
const torrent = ref(props.torrent?.torrent_info)
// 媒体信息
const media = ref(props.torrent?.media_info);
const media = ref(props.torrent?.media_info)
// 识别元数据
const meta = ref(props.torrent?.meta_info);
const meta = ref(props.torrent?.meta_info)
// 站点图标
const siteIcon = ref("");
const siteIcon = ref('')
// 查询站点图标
const getSiteIcon = async () => {
async function getSiteIcon() {
try {
siteIcon.value = (await api.get("site/icon/" + torrent?.value?.site)).data.icon;
} catch (error) {
console.error(error);
siteIcon.value = (await api.get(`site/icon/${torrent?.value?.site}`)).data.icon
}
};
catch (error) {
console.error(error)
}
}
// 询问并添加下载
const handleAddDownload = async (
_site: any = undefined,
async function handleAddDownload(_site: any = undefined,
_media: any = undefined,
_torrent: any = undefined
) => {
_torrent: any = undefined) {
if (!_media || !_torrent || !_site) {
_site = torrent.value?.site_name;
_media = media.value;
_torrent = torrent.value;
_site = torrent.value?.site_name
_media = media.value
_torrent = torrent.value
}
const isConfirmed = await createConfirm({
title: "确认",
title: '确认',
content: `是否确认下载【${_site}${_torrent?.title} ?`,
confirmationText: "确认",
cancellationText: "取消",
confirmationText: '确认',
cancellationText: '取消',
dialogProps: {
maxWidth: 600,
},
});
})
if (!isConfirmed) return;
if (!isConfirmed)
return
addDownload(_media, _torrent);
};
addDownload(_media, _torrent)
}
// 添加下载
const addDownload = async (_media: any, _torrent: any) => {
startNProgress();
async function addDownload(_media: any, _torrent: any) {
startNProgress()
try {
const result: { [key: string]: any } = await api.post("download", {
const result: { [key: string]: any } = await api.post('download', {
media_in: _media,
torrent_in: _torrent,
});
})
if (result.success) {
// 添加下载成功
$toast.success(`${_torrent?.site_name} ${_torrent?.title} 添加下载成功!`);
} else {
// 添加下载失败
$toast.error(`${_torrent?.site_name} ${_torrent?.title} 添加下载失败!`);
$toast.success(`${_torrent?.site_name} ${_torrent?.title} 添加下载成功!`)
}
else {
// 添加下载失败
$toast.error(`${_torrent?.site_name} ${_torrent?.title} 添加下载失败!`)
}
} catch (error) {
console.error(error);
}
doneNProgress();
};
catch (error) {
console.error(error)
}
doneNProgress()
}
// 打开种子详情页面
const openTorrentDetail = () => {
window.open(torrent.value?.page_url, "_blank");
};
function openTorrentDetail() {
window.open(torrent.value?.page_url, '_blank')
}
// 下载种子文件
const downloadTorrentFile = async () => {
window.open(torrent.value?.enclosure, "_blank");
};
async function downloadTorrentFile() {
window.open(torrent.value?.enclosure, '_blank')
}
// 促销Chip类
const getVolumeFactorClass = (downloadVolume: number, uploadVolume: number) => {
if (downloadVolume === 0) {
return "text-white bg-lime-500";
} else if (downloadVolume < 1) {
return "text-white bg-green-500";
} else if (uploadVolume != 1) {
return "text-white bg-sky-500";
} else {
return "text-white bg-gray-500";
}
};
function getVolumeFactorClass(downloadVolume: number, uploadVolume: number) {
if (downloadVolume === 0)
return 'text-white bg-lime-500'
else if (downloadVolume < 1)
return 'text-white bg-green-500'
else if (uploadVolume !== 1)
return 'text-white bg-sky-500'
else
return 'text-white bg-gray-500'
}
// 装载时查询站点图标
onMounted(() => {
getSiteIcon();
});
getSiteIcon()
})
// 弹出菜单
const dropdownItems = ref([
{
title: "查看详情",
title: '查看详情',
value: 1,
props: {
prependIcon: "mdi-information",
prependIcon: 'mdi-information',
click: openTorrentDetail,
},
},
{
title: "下载种子",
title: '下载种子',
value: 2,
props: {
prependIcon: "mdi-download",
prependIcon: 'mdi-download',
click: downloadTorrentFile,
},
},
]);
])
</script>
<template>
<VCard :width="props.width" :height="props.height" @click="handleAddDownload">
<template #image v-if="!showMoreTorrents">
<VAvatar class="absolute right-2 bottom-2 rounded" variant="flat" rounded="0">
<VCard
:width="props.width"
:height="props.height"
@click="handleAddDownload"
>
<template
v-if="!showMoreTorrents"
#image
>
<VAvatar
class="absolute right-2 bottom-2 rounded"
variant="flat"
rounded="0"
>
<VImg :src="siteIcon" />
</VAvatar>
</template>
@@ -153,19 +171,25 @@ const dropdownItems = ref([
<template #append>
<div class="me-n3">
<IconBtn>
<VIcon icon="mdi-dots-vertical" color="white" />
<VMenu activator="parent" close-on-content-click>
<VIcon
icon="mdi-dots-vertical"
color="white"
/>
<VMenu
activator="parent"
close-on-content-click
>
<VList>
<VListItem
v-for="(item, i) in dropdownItems"
variant="plain"
:key="i"
variant="plain"
@click="item.props.click"
>
<template #prepend>
<VIcon :icon="item.props.prependIcon"></VIcon>
<VIcon :icon="item.props.prependIcon" />
</template>
<VListItemTitle v-text="item.title"></VListItemTitle>
<VListItemTitle v-text="item.title" />
</VListItem>
</VList>
</VMenu>
@@ -177,23 +201,28 @@ const dropdownItems = ref([
{{ torrent?.title }}
</VCardText>
<VCardText>{{ torrent?.description }}</VCardText>
<VCardItem class="pb-3 pt-0 pe-12" v-if="torrent?.labels">
<VCardItem
v-if="torrent?.labels"
class="pb-3 pt-0 pe-12"
>
<VChip
v-for="(label, index) in torrent?.labels"
:key="index"
variant="elevated"
size="small"
v-for="label in torrent?.labels"
color="primary"
class="me-1 mb-1"
>{{ label }}</VChip
>
{{ label }}
</VChip>
<VChip
v-if="meta?.edition"
variant="elevated"
size="small"
class="me-1 mb-1 text-white bg-red-500"
>
{{ meta?.edition }}</VChip
>
{{ meta?.edition }}
</VChip>
<VChip
v-if="meta?.resource_pix"
variant="elevated"
@@ -240,26 +269,27 @@ const dropdownItems = ref([
</VCardItem>
<VCardActions>
<VBtn
@click.stop="showMoreTorrents = !showMoreTorrents"
v-if="props.more && props.more.length > 0"
@click.stop="showMoreTorrents = !showMoreTorrents"
>
<template #append>
<VIcon :icon="showMoreTorrents ? 'mdi-chevron-up' : 'mdi-chevron-down'"></VIcon>
<VIcon :icon="showMoreTorrents ? 'mdi-chevron-up' : 'mdi-chevron-down'" />
</template>
更多来源
</VBtn>
</VCardActions>
<VExpandTransition>
<div v-show="showMoreTorrents">
<VDivider></VDivider>
<VDivider />
<VChipGroup class="p-3">
<VChip
v-for="item in props.more"
v-for="(item, index) in props.more"
:key="index"
@click.stop="
handleAddDownload(
item.torrent_info?.site_name,
item.media_info,
item.torrent_info
item.torrent_info,
)
"
>
@@ -269,17 +299,16 @@ const dropdownItems = ref([
:content="`↑${item.torrent_info?.seeders}`"
inline
size="small"
></VBadge>
/>
<VBadge
v-if="
item.torrent_info?.downloadvolumefactor !== 1
|| item.torrent_info?.uploadvolumefactor !== 1
"
:content="item.torrent_info?.volume_factor"
inline
size="small"
v-if="
item.torrent_info?.downloadvolumefactor !== 1 ||
item.torrent_info?.uploadvolumefactor !== 1
"
>
</VBadge>
/>
</template>
{{ item.torrent_info.site_name }}
</VChip>

View File

@@ -1,14 +1,14 @@
<script lang="ts" setup>
import VerticalNavSectionTitle from "@/@layouts/components/VerticalNavSectionTitle.vue";
import VerticalNavLayout from "@layouts/components/VerticalNavLayout.vue";
import VerticalNavLink from "@layouts/components/VerticalNavLink.vue";
import VerticalNavSectionTitle from '@/@layouts/components/VerticalNavSectionTitle.vue'
import VerticalNavLayout from '@layouts/components/VerticalNavLayout.vue'
import VerticalNavLink from '@layouts/components/VerticalNavLink.vue'
// Components
import Footer from "@/layouts/components/Footer.vue";
import NavbarThemeSwitcher from "@/layouts/components/NavbarThemeSwitcher.vue";
import SearchBar from "@/layouts/components/SearchBar.vue";
import ShortcutBar from "@/layouts/components/ShortcutBar.vue";
import UserProfile from "@/layouts/components/UserProfile.vue";
import Footer from '@/layouts/components/Footer.vue'
import NavbarThemeSwitcher from '@/layouts/components/NavbarThemeSwitcher.vue'
import SearchBar from '@/layouts/components/SearchBar.vue'
import ShortcutBar from '@/layouts/components/ShortcutBar.vue'
import UserProfile from '@/layouts/components/UserProfile.vue'
</script>
<template>
@@ -17,7 +17,10 @@ import UserProfile from "@/layouts/components/UserProfile.vue";
<template #navbar="{ toggleVerticalOverlayNavActive }">
<div class="d-flex h-100 align-center mx-1">
<!-- 👉 Vertical Nav Toggle -->
<IconBtn class="ms-n2 d-lg-none" @click="toggleVerticalOverlayNavActive(true)">
<IconBtn
class="ms-n2 d-lg-none"
@click="toggleVerticalOverlayNavActive(true)"
>
<VIcon icon="mdi-menu" />
</IconBtn>

View File

@@ -1,35 +1,45 @@
<script lang="ts" setup>
// 路由
const router = useRouter();
const router = useRouter()
// 搜索词
const searchWord = ref<string>("");
const searchWord = ref<string>('')
// 搜索弹窗
const searchDialog = ref(false);
const searchDialog = ref(false)
// Search
const search = () => {
if (!searchWord.value) {
return;
}
searchDialog.value = false;
function search() {
if (!searchWord.value)
return
searchDialog.value = false
router.push({
path: "/browse/media/search",
path: '/browse/media/search',
query: {
title: searchWord.value,
},
});
};
})
}
</script>
<template>
<!-- 👉 Search Button -->
<div class="d-flex align-center cursor-pointer" style="user-select: none">
<VDialog v-model="searchDialog" max-width="600" transition="dialog-top-transition">
<div
class="d-flex align-center cursor-pointer"
style="user-select: none"
>
<VDialog
v-model="searchDialog"
max-width="600"
transition="dialog-top-transition"
>
<!-- Dialog Activator -->
<template #activator="{ props }">
<IconBtn class="d-lg-none" v-bind="props">
<IconBtn
class="d-lg-none"
v-bind="props"
>
<VIcon icon="mdi-magnify" />
</IconBtn>
</template>
@@ -38,14 +48,22 @@ const search = () => {
<VCardText>
<VRow>
<VCol cols="12">
<VTextField v-model="searchWord" label="电影、电视剧名称" />
<VTextField
v-model="searchWord"
label="电影、电视剧名称"
/>
</VCol>
</VRow>
</VCardText>
<VCardActions>
<VSpacer />
<VBtn @click="search" @keydown.enter="search"> 搜索 </VBtn>
<VBtn
@click="search"
@keydown.enter="search"
>
搜索
</VBtn>
</VCardActions>
</VCard>
</VDialog>
@@ -63,10 +81,10 @@ const search = () => {
append-inner-icon="mdi-magnify"
single-line
hide-details
@click:append-inner="search"
@keydown.enter="search"
flat
rounded
@click:append-inner="search"
@keydown.enter="search"
/>
</span>
</template>

View File

@@ -1,15 +1,15 @@
<script lang="ts" setup>
import NameTestView from "@/views/system/NameTestView.vue";
import NetTestView from "@/views/system/NetTestView.vue";
import NameTestView from '@/views/system/NameTestView.vue'
import NetTestView from '@/views/system/NetTestView.vue'
// App捷径
const appsMenu = ref(false);
const appsMenu = ref(false)
// 名称测试弹窗
const nameTestDialog = ref(false);
const nameTestDialog = ref(false)
// 网络测试弹窗
const netTestDialog = ref(false);
const netTestDialog = ref(false)
</script>
<template>
@@ -25,7 +25,10 @@ const netTestDialog = ref(false);
>
<!-- Menu Activator -->
<template #activator="{ props }">
<IconBtn class="me-2" v-bind="props">
<IconBtn
class="me-2"
v-bind="props"
>
<VIcon icon="mdi-checkbox-multiple-blank-outline" />
</IconBtn>
</template>
@@ -41,25 +44,44 @@ const netTestDialog = ref(false);
</VCardItem>
<div class="ps ps--active-y">
<VRow class="ma-0 mt-n1">
<VCol cols="6" class="text-center cursor-pointer pa-0 shortcut-icon border-e">
<VListItem @click="nameTestDialog = true" class="pa-4">
<VAvatar size="48" variant="tonal">
<VCol
cols="6"
class="text-center cursor-pointer pa-0 shortcut-icon border-e"
>
<VListItem
class="pa-4"
@click="nameTestDialog = true"
>
<VAvatar
size="48"
variant="tonal"
>
<VIcon icon="mdi-text-recognition" />
</VAvatar>
<h6 class="text-base font-weight-medium mt-2 mb-0">识别</h6>
<h6 class="text-base font-weight-medium mt-2 mb-0">
识别
</h6>
<span class="text-sm">名称识别测试</span>
</VListItem>
</VCol>
<VCol
@click="() => {}"
cols="6"
class="text-center cursor-pointer pa-0 shortcut-icon"
@click="() => {}"
>
<VListItem @click="netTestDialog = true" class="pa-4">
<VAvatar size="48" variant="tonal">
<VListItem
class="pa-4"
@click="netTestDialog = true"
>
<VAvatar
size="48"
variant="tonal"
>
<VIcon icon="mdi-network-outline" />
</VAvatar>
<h6 class="text-base font-weight-medium mt-2 mb-0">网络</h6>
<h6 class="text-base font-weight-medium mt-2 mb-0">
网络
</h6>
<span class="text-sm">测试网速连通性</span>
</VListItem>
</VCol>
@@ -68,7 +90,10 @@ const netTestDialog = ref(false);
</VCard>
</VMenu>
<!-- 名称测试弹窗 -->
<VDialog v-model="nameTestDialog" max-width="800">
<VDialog
v-model="nameTestDialog"
max-width="800"
>
<VCard title="名称识别测试">
<DialogCloseBtn @click="nameTestDialog = false" />
<VCardItem>
@@ -77,7 +102,10 @@ const netTestDialog = ref(false);
</VCard>
</VDialog>
<!-- 网络测试弹窗 -->
<VDialog v-model="netTestDialog" max-width="600">
<VDialog
v-model="netTestDialog"
max-width="600"
>
<VCard title="网络测试">
<DialogCloseBtn @click="netTestDialog = false" />
<VCardItem>

View File

@@ -1,30 +1,46 @@
<script setup lang="ts">
import router from "@/router";
import { useStore } from "vuex";
import { useStore } from 'vuex'
import router from '@/router'
// Vuex Store
const store = useStore();
const store = useStore()
// 执行注销操作
const logout = () => {
function logout() {
// 清除登录状态信息
store.dispatch("auth/clearToken");
store.dispatch('auth/clearToken')
// 重定向到登录页面或其他适当的页面
router.push("/login");
};
router.push('/login')
}
// 获取当前用户信息
const accountInfo: any = inject("accountInfo");
const accountInfo: any = inject('accountInfo')
</script>
<template>
<VBadge dot location="bottom right" offset-x="3" offset-y="3" color="success" bordered>
<VAvatar class="cursor-pointer" color="primary" variant="tonal">
<VBadge
dot
location="bottom right"
offset-x="3"
offset-y="3"
color="success"
bordered
>
<VAvatar
class="cursor-pointer"
color="primary"
variant="tonal"
>
<VImg :src="accountInfo.avatar" />
<!-- SECTION Menu -->
<VMenu activator="parent" width="230" location="bottom end" offset="14px">
<VMenu
activator="parent"
width="230"
location="bottom end"
offset="14px"
>
<VList>
<!-- 👉 User Avatar & Name -->
<VListItem>
@@ -37,7 +53,10 @@ const accountInfo: any = inject("accountInfo");
offset-y="3"
color="success"
>
<VAvatar color="primary" variant="tonal">
<VAvatar
color="primary"
variant="tonal"
>
<VImg :src="accountInfo.avatar" />
</VAvatar>
</VBadge>
@@ -52,9 +71,16 @@ const accountInfo: any = inject("accountInfo");
<VDivider class="my-2" />
<!-- 👉 Profile -->
<VListItem link to="account-setting">
<VListItem
link
to="account-setting"
>
<template #prepend>
<VIcon class="me-2" icon="mdi-account-outline" size="22" />
<VIcon
class="me-2"
icon="mdi-account-outline"
size="22"
/>
</template>
<VListItemTitle>设定</VListItemTitle>
@@ -66,7 +92,11 @@ const accountInfo: any = inject("accountInfo");
target="_blank"
>
<template #prepend>
<VIcon class="me-2" icon="mdi-help-circle-outline" size="22" />
<VIcon
class="me-2"
icon="mdi-help-circle-outline"
size="22"
/>
</template>
<VListItemTitle>帮助</VListItemTitle>
@@ -78,7 +108,11 @@ const accountInfo: any = inject("accountInfo");
<!-- 👉 Logout -->
<VListItem @click="logout">
<template #prepend>
<VIcon class="me-2" icon="mdi-logout" size="22" />
<VIcon
class="me-2"
icon="mdi-logout"
size="22"
/>
</template>
<VListItemTitle>注销</VListItemTitle>

View File

@@ -1,7 +1,9 @@
/* eslint-disable import/order */
import { createApp } from 'vue'
import '@/@iconify/icons-bundle'
import App from '@/App.vue'
import ToastPlugin from 'vue-toast-notification'
import VuetifyUseDialog from 'vuetify-use-dialog'
import { configureNProgress, doneNProgress, startNProgress } from '@/api/nprogress'
import App from '@/App.vue'
import vuetify from '@/plugins/vuetify'
import { loadFonts } from '@/plugins/webfontloader'
import router from '@/router'
@@ -9,10 +11,8 @@ import store from '@/store'
import '@core/scss/template/index.scss'
import '@layouts/styles/index.scss'
import '@styles/styles.scss'
import { createApp } from 'vue'
import ToastPlugin from 'vue-toast-notification'
import 'vue-toast-notification/dist/theme-default.css'
import VuetifyUseDialog from 'vuetify-use-dialog'
loadFonts()
// Nprogress
@@ -22,14 +22,7 @@ configureNProgress()
const app = createApp(App)
// Use plugins Mount vue app
app
.use(vuetify)
.use(router)
.use(store)
.use(ToastPlugin)
.use(VuetifyUseDialog)
.mount('#app')
app.use(vuetify).use(router).use(store).use(ToastPlugin).use(VuetifyUseDialog).mount('#app')
// 导航守卫
router.beforeEach((to, from, next) => {

View File

@@ -1,12 +1,12 @@
<script setup lang="ts">
import NoDataFound from '@/components/NoDataFound.vue';
import NoDataFound from '@/components/NoDataFound.vue'
</script>
<template>
<NoDataFound
errorCode="404"
errorTitle="页面不存在 ⚠️"
errorDescription="您想要访问的页面不存在请检查地址是否正确"
error-code="404"
error-title="页面不存在 "
error-description="您想要访问的页面不存在请检查地址是否正确"
>
<template #button>
<VBtn

View File

@@ -1,36 +1,50 @@
<script lang="ts" setup>
import AccountSettingAccount from "@/views/account-setting/AccountSettingAccount.vue";
import AccountSettingNotification from "@/views/account-setting/AccountSettingNotification.vue";
import AccountSettingRule from "@/views/account-setting/AccountSettingRule.vue";
import AccountSettingSite from "@/views/account-setting/AccountSettingSite.vue";
import AccountSettingWords from "@/views/account-setting/AccountSettingWords.vue";
import { useRoute } from "vue-router";
import { useRoute } from 'vue-router'
import AccountSettingAccount from '@/views/account-setting/AccountSettingAccount.vue'
import AccountSettingNotification from '@/views/account-setting/AccountSettingNotification.vue'
import AccountSettingRule from '@/views/account-setting/AccountSettingRule.vue'
import AccountSettingSite from '@/views/account-setting/AccountSettingSite.vue'
import AccountSettingWords from '@/views/account-setting/AccountSettingWords.vue'
const route = useRoute();
const route = useRoute()
const activeTab = ref(route.params.tab);
const activeTab = ref(route.params.tab)
// tabs
const tabs = [
{ title: "用户", icon: "mdi-account", tab: "account" },
{ title: "站点", icon: "mdi-web", tab: "site" },
{ title: "规则", icon: "mdi-filter-cog", tab: "filter" },
{ title: "通知", icon: "mdi-bell", tab: "notification" },
{ title: "自定义词表", icon: "mdi-file-word-box", tab: "words" },
];
{ title: '用户', icon: 'mdi-account', tab: 'account' },
{ title: '站点', icon: 'mdi-web', tab: 'site' },
{ title: '规则', icon: 'mdi-filter-cog', tab: 'filter' },
{ title: '通知', icon: 'mdi-bell', tab: 'notification' },
{ title: '自定义词表', icon: 'mdi-file-word-box', tab: 'words' },
]
</script>
<template>
<div>
<VTabs v-model="activeTab" show-arrows>
<VTab v-for="item in tabs" :key="item.icon" :value="item.tab">
<VIcon size="20" start :icon="item.icon" />
<VTabs
v-model="activeTab"
show-arrows
>
<VTab
v-for="item in tabs"
:key="item.icon"
:value="item.tab"
>
<VIcon
size="20"
start
:icon="item.icon"
/>
{{ item.title }}
</VTab>
</VTabs>
<VDivider />
<VWindow v-model="activeTab" class="mt-5 disable-tab-transition">
<VWindow
v-model="activeTab"
class="mt-5 disable-tab-transition"
>
<!-- Account -->
<VWindowItem value="account">
<AccountSettingAccount />

View File

@@ -1,58 +1,61 @@
<script setup lang="ts">
import MediaCardListView from "@/views/discover/MediaCardListView.vue";
import MediaCardListView from '@/views/discover/MediaCardListView.vue'
// 输入参数
const props = defineProps({
type: Array as PropType<string[]> | PropType<string>,
});
})
// 路由参数
const route = useRoute();
const route = useRoute()
// 面包屑标题定义
const titles: { [key: string]: any } = {
tmdb: {
trending: "流行趋势",
movies: "热门电影",
tvs: "热门电视剧",
trending: '流行趋势',
movies: '热门电影',
tvs: '热门电视剧',
},
douban: {
movies: "最新电影",
tvs: "最新电视剧",
tv_weekly_chinese: "华语剧集榜",
tv_weekly_global: "全球剧集榜",
movie_top250: "电影TOP250",
movies: '最新电影',
tvs: '最新电视剧',
tv_weekly_chinese: '华语剧集榜',
tv_weekly_global: '全球剧集榜',
movie_top250: '电影TOP250',
},
media: {
search: "搜索",
search: '搜索',
},
};
}
// 计算API路径
const getApiPath = (types: string[] | string) => {
if (Array.isArray(types)) {
return types.join("/");
} else {
return types;
}
};
function getApiPath(types: string[] | string) {
if (Array.isArray(types))
return types.join('/')
else
return types
}
// 面包屑标题
const getTitle = (types: string[] | string, title: any = "") => {
function getTitle(types: string[] | string, title: any = '') {
if (Array.isArray(types)) {
if (title) {
return [titles[types[0]][types[1]], title];
}
return ["推荐", titles[types[0]][types[1]]];
} else {
return ["发现"];
if (title)
return [titles[types[0]][types[1]], title]
return ['推荐', titles[types[0]][types[1]]]
}
};
else {
return ['发现']
}
}
</script>
<template>
<div>
<VBreadcrumbs :items="getTitle(props.type || '', route.query?.title)"></VBreadcrumbs>
<MediaCardListView :apipath="getApiPath(props.type || '')" :params="route.query" />
<VBreadcrumbs :items="getTitle(props.type || '', route.query?.title)" />
<MediaCardListView
:apipath="getApiPath(props.type || '')"
:params="route.query"
/>
</div>
</template>

View File

@@ -1,10 +1,9 @@
<script setup lang="ts">
import FullCalendarView from '@/views/subscribe/FullCalendarView.vue';
import FullCalendarView from '@/views/subscribe/FullCalendarView.vue'
</script>
<template>
<div>
<FullCalendarView/>
<FullCalendarView />
</div>
</template>

View File

@@ -1,31 +1,46 @@
<script setup lang="ts">
import AnalyticsMediaStatistic from "@/views/dashboard/AnalyticsMediaStatistic.vue";
import AnalyticsProcesses from "@/views/dashboard/AnalyticsProcesses.vue";
import AnalyticsScheduler from "@/views/dashboard/AnalyticsScheduler.vue";
import AnalyticsSpeed from "@/views/dashboard/AnalyticsSpeed.vue";
import AnalyticsStorage from "@/views/dashboard/AnalyticsStorage.vue";
import AnalyticsWeeklyOverview from "@/views/dashboard/AnalyticsWeeklyOverview.vue";
import AnalyticsMediaStatistic from '@/views/dashboard/AnalyticsMediaStatistic.vue'
import AnalyticsProcesses from '@/views/dashboard/AnalyticsProcesses.vue'
import AnalyticsScheduler from '@/views/dashboard/AnalyticsScheduler.vue'
import AnalyticsSpeed from '@/views/dashboard/AnalyticsSpeed.vue'
import AnalyticsStorage from '@/views/dashboard/AnalyticsStorage.vue'
import AnalyticsWeeklyOverview from '@/views/dashboard/AnalyticsWeeklyOverview.vue'
</script>
<template>
<VRow class="match-height">
<VCol cols="12" md="4">
<VCol
cols="12"
md="4"
>
<AnalyticsStorage />
</VCol>
<VCol cols="12" md="8">
<VCol
cols="12"
md="8"
>
<AnalyticsMediaStatistic />
</VCol>
<VCol cols="12" md="4">
<VCol
cols="12"
md="4"
>
<AnalyticsWeeklyOverview />
</VCol>
<VCol cols="12" md="4">
<VCol
cols="12"
md="4"
>
<AnalyticsSpeed />
</VCol>
<VCol cols="12" md="4">
<VCol
cols="12"
md="4"
>
<AnalyticsScheduler />
</VCol>

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import DownloadingListView from "@/views/reorganize/DownloadingListView.vue";
import DownloadingListView from '@/views/reorganize/DownloadingListView.vue'
</script>
<template>

View File

@@ -1,6 +1,5 @@
<script setup lang="ts">
import TransferHistoryView from '@/views/reorganize/TransferHistoryView.vue';
import TransferHistoryView from '@/views/reorganize/TransferHistoryView.vue'
</script>
<template>

View File

@@ -1,105 +1,109 @@
<script setup lang="ts">
import { requiredValidator } from "@/@validators";
import api from "@/api";
import router from "@/router";
import logo from "@images/logo.svg?raw";
import type { VForm } from "vuetify/components/VForm";
import { useStore } from "vuex";
import { VForm } from 'vuetify/components/VForm'
import { useStore } from 'vuex'
import { requiredValidator } from '@/@validators'
import api from '@/api'
import router from '@/router'
import logo from '@images/logo.svg?raw'
// Vuex Store
const store = useStore();
const store = useStore()
// 表单
const form = ref({
username: "",
password: "",
username: '',
password: '',
remember: true,
});
const refForm = ref<InstanceType<typeof VForm> | null>(null);
})
const refForm = ref<InstanceType<typeof VForm> | null>(null)
// 密码输入
const isPasswordVisible = ref(false);
const isPasswordVisible = ref(false)
// 错误信息
const errorMessage = ref("");
const errorMessage = ref('')
// 背景图片
const backgroundImageUrl = ref("");
const backgroundImageUrl = ref('')
// 背景图片加载状态
const isImageLoaded = ref(false);
const isImageLoaded = ref(false)
// 获取背景图片
const fetchBackgroundImage = async () => {
async function fetchBackgroundImage() {
api
.get("/login/tmdb")
.get('/login/tmdb')
.then((response: any) => {
backgroundImageUrl.value = response.message;
backgroundImageUrl.value = response.message
})
.catch((error: any) => {
console.log(error);
});
};
console.log(error)
})
}
// 登录获取token事件
const login = () => {
errorMessage.value = "";
function login() {
errorMessage.value = ''
// 进行表单校验
if (!form.value.username || !form.value.password) {
return;
}
if (!form.value.username || !form.value.password)
return
// 用户名密码
const formData = new FormData();
const formData = new FormData()
formData.append("username", form.value.username);
formData.append("password", form.value.password);
formData.append('username', form.value.username)
formData.append('password', form.value.password)
// 请求token
api
.post("/login/access-token", formData, {
.post('/login/access-token', formData, {
headers: {
Accept: "application/json", // 设置 Accept 类型
Accept: 'application/json', // 设置 Accept 类型
},
})
.then((response: any) => {
// 获取token
const token = response.access_token;
const token = response.access_token
// 更新token和remember状态到Vuex Store
store.dispatch("auth/updateToken", token);
store.dispatch("auth/updateRemember", form.value.remember);
store.dispatch('auth/updateToken', token)
store.dispatch('auth/updateRemember', form.value.remember)
// 跳转到首页
router.push("/");
router.push('/')
})
.catch((error: any) => {
// 登录失败,显示错误提示
if (!error.response) errorMessage.value = "登录失败,请检查网络连接";
if (!error.response)
errorMessage.value = '登录失败,请检查网络连接'
else if (error.response.status === 401)
errorMessage.value = "登录失败,请检查用户名和密码是否正确";
errorMessage.value = '登录失败,请检查用户名和密码是否正确'
else if (error.response.status === 403)
errorMessage.value = "登录失败,您没有权限访问";
else if (error.response.status === 500) errorMessage.value = "登录失败,服务器错误";
errorMessage.value = '登录失败,您没有权限访问'
else if (error.response.status === 500)
errorMessage.value = '登录失败,服务器错误'
else
errorMessage.value = `登录失败 ${error.response.status},请检查用户名和密码是否正确`;
});
};
errorMessage.value = `登录失败 ${error.response.status},请检查用户名和密码是否正确`
})
}
// 自动登录
onMounted(() => {
// 从Vuex Store中获取token和remember状态
const token = store.state.auth.token;
const remember = store.state.auth.remember;
const token = store.state.auth.token
const remember = store.state.auth.remember
// 如果token存在且保持登录状态为true则跳转到首页
if (token && remember) {
router.push("/");
} else {
// 获取背景图片
fetchBackgroundImage();
router.push('/')
}
});
else {
// 获取背景图片
fetchBackgroundImage()
}
})
</script>
<template>
@@ -107,8 +111,8 @@ onMounted(() => {
aspect-ratio="4/3"
:src="backgroundImageUrl"
class="w-full h-full overflow-hidden"
@load="isImageLoaded = true"
cover
@load="isImageLoaded = true"
>
<div class="auth-wrapper d-flex align-center justify-center pa-4">
<VCard
@@ -130,7 +134,10 @@ onMounted(() => {
</VCardItem>
<VCardText>
<VForm @submit.prevent="() => {}" ref="refForm">
<VForm
ref="refForm"
@submit.prevent="() => {}"
>
<VRow>
<!-- username -->
<VCol cols="12">
@@ -155,19 +162,30 @@ onMounted(() => {
@click:append-inner="isPasswordVisible = !isPasswordVisible"
/>
<div v-if="errorMessage" class="text-error mt-1">
<div
v-if="errorMessage"
class="text-error mt-1"
>
{{ errorMessage }}
</div>
<!-- remember me checkbox -->
<div
class="d-flex align-center justify-space-between flex-wrap mt-1 mb-4"
>
<VCheckbox v-model="form.remember" label="保持登录" required />
<div class="d-flex align-center justify-space-between flex-wrap mt-1 mb-4">
<VCheckbox
v-model="form.remember"
label="保持登录"
required
/>
</div>
<!-- login button -->
<VBtn block type="submit" @click="login"> 登录 </VBtn>
<VBtn
block
type="submit"
@click="login"
>
登录
</VBtn>
</VCol>
</VRow>
</VForm>

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import PluginCardListView from "@/views/setting/PluginCardListView.vue";
import PluginCardListView from '@/views/setting/PluginCardListView.vue'
</script>
<template>

View File

@@ -1,15 +1,24 @@
<script setup lang="ts">
import MediaCardSlideView from '@/views/discover/MediaCardSlideView.vue';
import MediaCardSlideView from '@/views/discover/MediaCardSlideView.vue'
</script>
<template>
<div>
<MediaCardSlideView apipath="tmdb/trending">
<template #title="{ loaded }">
<div class="slider-header ms-1" v-if="loaded">
<RouterLink to="/browse/tmdb/trending" class="slider-title">
<div
v-if="loaded"
class="slider-header ms-1"
>
<RouterLink
to="/browse/tmdb/trending"
class="slider-title"
>
<span>流行趋势</span>
<VIcon icon="mdi-arrow-right-circle-outline" class="ms-1" />
<VIcon
icon="mdi-arrow-right-circle-outline"
class="ms-1"
/>
</RouterLink>
</div>
</template>
@@ -17,10 +26,19 @@ import MediaCardSlideView from '@/views/discover/MediaCardSlideView.vue';
<MediaCardSlideView apipath="tmdb/movies">
<template #title="{ loaded }">
<div class="slider-header mt-3 ms-1" v-if="loaded">
<RouterLink to="/browse/tmdb/movies" class="slider-title">
<div
v-if="loaded"
class="slider-header mt-3 ms-1"
>
<RouterLink
to="/browse/tmdb/movies"
class="slider-title"
>
<span>热门电影</span>
<VIcon icon="mdi-arrow-right-circle-outline" class="ms-1" />
<VIcon
icon="mdi-arrow-right-circle-outline"
class="ms-1"
/>
</RouterLink>
</div>
</template>
@@ -28,10 +46,19 @@ import MediaCardSlideView from '@/views/discover/MediaCardSlideView.vue';
<MediaCardSlideView apipath="tmdb/tvs">
<template #title="{ loaded }">
<div class="slider-header mt-3 ms-1" v-if="loaded">
<RouterLink to="/browse/tmdb/tvs" class="slider-title">
<div
v-if="loaded"
class="slider-header mt-3 ms-1"
>
<RouterLink
to="/browse/tmdb/tvs"
class="slider-title"
>
<span>热门电视剧</span>
<VIcon icon="mdi-arrow-right-circle-outline" class="ms-1" />
<VIcon
icon="mdi-arrow-right-circle-outline"
class="ms-1"
/>
</RouterLink>
</div>
</template>
@@ -39,10 +66,19 @@ import MediaCardSlideView from '@/views/discover/MediaCardSlideView.vue';
<MediaCardSlideView apipath="douban/movies">
<template #title="{ loaded }">
<div class="slider-header mt-3 ms-1" v-if="loaded">
<RouterLink to="/browse/douban/movies" class="slider-title">
<div
v-if="loaded"
class="slider-header mt-3 ms-1"
>
<RouterLink
to="/browse/douban/movies"
class="slider-title"
>
<span>最新电影</span>
<VIcon icon="mdi-arrow-right-circle-outline" class="ms-1" />
<VIcon
icon="mdi-arrow-right-circle-outline"
class="ms-1"
/>
</RouterLink>
</div>
</template>
@@ -50,10 +86,19 @@ import MediaCardSlideView from '@/views/discover/MediaCardSlideView.vue';
<MediaCardSlideView apipath="douban/tvs">
<template #title="{ loaded }">
<div class="slider-header mt-3 ms-1" v-if="loaded">
<RouterLink to="/browse/douban/tvs" class="slider-title">
<div
v-if="loaded"
class="slider-header mt-3 ms-1"
>
<RouterLink
to="/browse/douban/tvs"
class="slider-title"
>
<span>最新电视剧</span>
<VIcon icon="mdi-arrow-right-circle-outline" class="ms-1" />
<VIcon
icon="mdi-arrow-right-circle-outline"
class="ms-1"
/>
</RouterLink>
</div>
</template>
@@ -61,10 +106,19 @@ import MediaCardSlideView from '@/views/discover/MediaCardSlideView.vue';
<MediaCardSlideView apipath="douban/movie_top250">
<template #title="{ loaded }">
<div class="slider-header mt-3 ms-1" v-if="loaded">
<RouterLink to="/browse/douban/movie_top250" class="slider-title">
<div
v-if="loaded"
class="slider-header mt-3 ms-1"
>
<RouterLink
to="/browse/douban/movie_top250"
class="slider-title"
>
<span>电影TOP250</span>
<VIcon icon="mdi-arrow-right-circle-outline" class="ms-1" />
<VIcon
icon="mdi-arrow-right-circle-outline"
class="ms-1"
/>
</RouterLink>
</div>
</template>
@@ -72,10 +126,19 @@ import MediaCardSlideView from '@/views/discover/MediaCardSlideView.vue';
<MediaCardSlideView apipath="douban/tv_weekly_chinese">
<template #title="{ loaded }">
<div class="slider-header mt-3 ms-1" v-if="loaded">
<RouterLink to="/browse/douban/tv_weekly_chinese" class="slider-title">
<div
v-if="loaded"
class="slider-header mt-3 ms-1"
>
<RouterLink
to="/browse/douban/tv_weekly_chinese"
class="slider-title"
>
<span>国产剧集榜</span>
<VIcon icon="mdi-arrow-right-circle-outline" class="ms-1" />
<VIcon
icon="mdi-arrow-right-circle-outline"
class="ms-1"
/>
</RouterLink>
</div>
</template>
@@ -83,10 +146,19 @@ import MediaCardSlideView from '@/views/discover/MediaCardSlideView.vue';
<MediaCardSlideView apipath="douban/tv_weekly_global">
<template #title="{ loaded }">
<div class="slider-header mt-3 ms-1" v-if="loaded">
<RouterLink to="/browse/douban/tv_weekly_global" class="slider-title">
<div
v-if="loaded"
class="slider-header mt-3 ms-1"
>
<RouterLink
to="/browse/douban/tv_weekly_global"
class="slider-title"
>
<span>全球剧集榜</span>
<VIcon icon="mdi-arrow-right-circle-outline" class="ms-1" />
<VIcon
icon="mdi-arrow-right-circle-outline"
class="ms-1"
/>
</RouterLink>
</div>
</template>

View File

@@ -1,17 +1,21 @@
<script setup lang="ts">
import TorrentCardListView from "@/views/discover/TorrentCardListView.vue";
import TorrentCardListView from '@/views/discover/TorrentCardListView.vue'
// 路由参数
const route = useRoute();
const route = useRoute()
// 查询TMDBID或标题
const keyword = route.query?.keyword?.toString() ?? "";
const keyword = route.query?.keyword?.toString() ?? ''
// 查询类型
const type = route.query?.type?.toString() ?? "";
const type = route.query?.type?.toString() ?? ''
</script>
<template>
<div>
<TorrentCardListView :keyword="keyword" :type="type" />
<TorrentCardListView
:keyword="keyword"
:type="type"
/>
</div>
</template>

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import SiteListView from "@/views/site/SiteCardListView.vue";
import SiteListView from '@/views/site/SiteCardListView.vue'
</script>
<template>

View File

@@ -1,10 +1,9 @@
<script setup lang="ts">
import SubscribeListView from '@/views/subscribe/SubscribeListView.vue';
import SubscribeListView from '@/views/subscribe/SubscribeListView.vue'
</script>
<template>
<div>
<SubscribeListView type="电影"/>
<SubscribeListView type="电影" />
</div>
</template>

View File

@@ -1,10 +1,9 @@
<script setup lang="ts">
import SubscribeListView from '@/views/subscribe/SubscribeListView.vue';
import SubscribeListView from '@/views/subscribe/SubscribeListView.vue'
</script>
<template>
<div>
<SubscribeListView type="电视剧"/>
<SubscribeListView type="电视剧" />
</div>
</template>

View File

@@ -11,7 +11,7 @@ const alertTypeIcon = {
const modifiedAliases = Object.assign(aliases, alertTypeIcon)
export const iconify = {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
component: (props: any) => h(Icon, props),
}

View File

@@ -5,6 +5,7 @@ import * as labsComponents from 'vuetify/labs/components'
import defaults from './defaults'
import { icons } from './icons'
import theme from './theme'
// Styles
import '@core/scss/template/libs/vuetify/index.scss'
import 'vuetify/styles'

View File

@@ -1,8 +1,8 @@
import { createRouter, createWebHistory } from 'vue-router';
import { createRouter, createWebHistory } from 'vue-router'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
scrollBehavior(to, from, savedPosition) {
scrollBehavior() {
// 始终滚动到顶部
return { top: 0 }
},

View File

@@ -1,17 +1,16 @@
import { Module } from 'vuex';
import type { Module } from 'vuex'
// 定义状态类型
interface AuthState {
token: string | null;
remember: boolean;
token: string | null
remember: boolean
}
// 定义根状态类型
interface RootState {
auth: AuthState;
auth: AuthState
}
// 导出模块
const authModule: Module<AuthState, RootState> = {
namespaced: true,
@@ -21,30 +20,30 @@ const authModule: Module<AuthState, RootState> = {
},
mutations: {
setToken(state, token: string) {
state.token = token;
state.token = token
},
clearToken(state) {
state.token = null;
state.token = null
},
setRemember(state, remember: boolean) {
state.remember = remember;
state.remember = remember
},
},
actions: {
updateToken({ commit }, token: string) {
commit('setToken', token);
commit('setToken', token)
},
clearToken({ commit },) {
commit('clearToken');
clearToken({ commit }) {
commit('clearToken')
},
updateRemember({ commit }, remember: boolean) {
commit('setRemember', remember);
commit('setRemember', remember)
},
},
getters: {
getToken: state => state.token,
getRemember: state => state.remember,
}
};
},
}
export default authModule;
export default authModule

View File

@@ -1,6 +1,6 @@
import { createStore } from 'vuex';
import createPersistedState from 'vuex-persistedstate';
import authModule from './auth';
import { createStore } from 'vuex'
import createPersistedState from 'vuex-persistedstate'
import authModule from './auth'
const store = createStore({
modules: {
@@ -14,6 +14,6 @@ const store = createStore({
key: 'moviepilot', // 存储的键名
}),
],
});
})
export default store;
export default store

View File

@@ -1,159 +1,173 @@
<script lang="ts" setup>
import { requiredValidator } from "@/@validators";
import api from "@/api";
import { User } from "@/api/types";
import avatar1 from "@images/avatars/avatar-1.png";
import { useToast } from "vue-toast-notification";
const isNewPasswordVisible = ref(false);
const isConfirmPasswordVisible = ref(false);
const isPasswordVisible = ref(false);
const newPassword = ref("");
const confirmPassword = ref("");
import { useToast } from 'vue-toast-notification'
import { requiredValidator } from '@/@validators'
import api from '@/api'
import type { User } from '@/api/types'
import avatar1 from '@images/avatars/avatar-1.png'
const isNewPasswordVisible = ref(false)
const isConfirmPasswordVisible = ref(false)
const isPasswordVisible = ref(false)
const newPassword = ref('')
const confirmPassword = ref('')
// 提示框
const $toast = useToast();
const $toast = useToast()
const refInputEl = ref<HTMLElement>();
const refInputEl = ref<HTMLElement>()
// 新增用户窗口
const addUserDialog = ref(false);
const addUserDialog = ref(false)
// 新增用户表单
const userForm = reactive({
name: "",
password: "",
email: "",
});
name: '',
password: '',
email: '',
})
// 当前用户信息
const accountInfo = ref<User>({
id: 0,
name: "",
password: "",
email: "",
name: '',
password: '',
email: '',
is_active: false,
is_superuser: false,
avatar: "",
});
avatar: '',
})
// 所有用户信息
const allUsers = ref<User[]>([]);
const allUsers = ref<User[]>([])
// changeAvatar function
const changeAvatar = (file: Event) => {
const fileReader = new FileReader();
const { files } = file.target as HTMLInputElement;
function changeAvatar(file: Event) {
const fileReader = new FileReader()
const { files } = file.target as HTMLInputElement
if (files && files.length > 0) {
fileReader.readAsDataURL(files[0]);
fileReader.readAsDataURL(files[0])
fileReader.onload = () => {
if (typeof fileReader.result === "string") {
accountInfo.value.avatar = fileReader.result;
saveAccountInfo();
if (typeof fileReader.result === 'string') {
accountInfo.value.avatar = fileReader.result
saveAccountInfo()
}
};
}
}
};
}
// reset avatar image
const resetAvatar = () => {
accountInfo.value.avatar = avatar1;
};
function resetAvatar() {
accountInfo.value.avatar = avatar1
}
// 调用API加载当前用户数据
const loadAccountInfo = async () => {
async function loadAccountInfo() {
try {
const user: User = await api.get(`user/current`);
accountInfo.value = user;
if (!accountInfo.value.avatar) accountInfo.value.avatar = avatar1;
} catch (error) {
console.log(error);
const user: User = await api.get('user/current')
accountInfo.value = user
if (!accountInfo.value.avatar)
accountInfo.value.avatar = avatar1
}
};
catch (error) {
console.log(error)
}
}
// 保存用户信息
const saveAccountInfo = async () => {
async function saveAccountInfo() {
if (newPassword.value || confirmPassword.value) {
if (newPassword.value !== confirmPassword.value) {
$toast.error("两次输入的密码不一致");
return;
$toast.error('两次输入的密码不一致')
return
}
accountInfo.value.password = newPassword.value;
accountInfo.value.password = newPassword.value
}
try {
const result: { [key: string]: any } = await api.put(`user`, accountInfo.value);
if (result.success) {
$toast.success("用户信息保存成功!");
} else {
$toast.error(`用户信息保存失败:${result.message}`);
}
} catch (error) {
console.log(error);
const result: { [key: string]: any } = await api.put('user', accountInfo.value)
if (result.success)
$toast.success('用户信息保存成功!')
else
$toast.error(`用户信息保存失败:${result.message}`)
}
};
catch (error) {
console.log(error)
}
}
// 调用API查询所有用户
const loadAllUsers = async () => {
async function loadAllUsers() {
try {
const result: User[] = await api.get(`/user`);
allUsers.value = result;
} catch (error) {
console.log(error);
const result: User[] = await api.get('/user')
allUsers.value = result
}
};
catch (error) {
console.log(error)
}
}
// 删除用户
const deleteUser = async (user: User) => {
async function deleteUser(user: User) {
try {
const result: { [key: string]: any } = await api.delete(`user/${user.name}`);
const result: { [key: string]: any } = await api.delete(`user/${user.name}`)
if (result.success) {
$toast.success("用户删除成功!");
loadAllUsers();
} else {
$toast.error(`用户删除失败:${result.message}`);
$toast.success('用户删除成功!')
loadAllUsers()
}
else {
$toast.error(`用户删除失败:${result.message}`)
}
} catch (error) {
console.log(error);
}
};
catch (error) {
console.log(error)
}
}
// 冻结用户
const deactivateUser = async (user: User) => {
async function deactivateUser(user: User) {
try {
user.is_active = !user.is_active;
const result: { [key: string]: any } = await api.put(`user`, user);
user.is_active = !user.is_active
const result: { [key: string]: any } = await api.put('user', user)
if (result.success) {
$toast.success("用户冻结成功!");
loadAllUsers();
} else {
$toast.error(`用户冻结失败:${result.message}`);
$toast.success('用户冻结成功!')
loadAllUsers()
}
else {
$toast.error(`用户冻结失败:${result.message}`)
}
} catch (error) {
console.log(error);
}
};
catch (error) {
console.log(error)
}
}
// 新增用户
const addUser = async () => {
async function addUser() {
try {
const result: { [key: string]: any } = await api.post(`user`, userForm);
const result: { [key: string]: any } = await api.post('user', userForm)
if (result.success) {
$toast.success("用户新增成功!");
loadAllUsers();
addUserDialog.value = false;
} else {
$toast.error(`用户新增失败:${result.message}`);
$toast.success('用户新增成功!')
loadAllUsers()
addUserDialog.value = false
}
else {
$toast.error(`用户新增失败:${result.message}`)
}
} catch (error) {
console.log(error);
}
};
catch (error) {
console.log(error)
}
}
// 加载当前用户数据
onMounted(() => {
loadAccountInfo();
loadAllUsers();
});
loadAccountInfo()
loadAllUsers()
})
</script>
<template>
@@ -162,13 +176,24 @@ onMounted(() => {
<VCard title="个人信息">
<VCardText class="d-flex">
<!-- 👉 Avatar -->
<VAvatar rounded="lg" size="100" class="me-6" :image="accountInfo.avatar" />
<VAvatar
rounded="lg"
size="100"
class="me-6"
:image="accountInfo.avatar"
/>
<!-- 👉 Upload Photo -->
<form class="d-flex flex-column justify-center gap-5">
<div class="d-flex flex-wrap gap-2">
<VBtn color="primary" @click="refInputEl?.click()">
<VIcon icon="mdi-cloud-upload-outline" class="d-sm-none" />
<VBtn
color="primary"
@click="refInputEl?.click()"
>
<VIcon
icon="mdi-cloud-upload-outline"
class="d-sm-none"
/>
<span class="d-none d-sm-block">上传头像</span>
</VBtn>
@@ -179,15 +204,25 @@ onMounted(() => {
accept=".jpeg,.png,.jpg,GIF"
hidden
@input="changeAvatar"
/>
>
<VBtn type="reset" color="error" variant="tonal" @click="resetAvatar">
<VBtn
type="reset"
color="error"
variant="tonal"
@click="resetAvatar"
>
<span class="d-none d-sm-block">重置</span>
<VIcon icon="mdi-refresh" class="d-sm-none" />
<VIcon
icon="mdi-refresh"
class="d-sm-none"
/>
</VBtn>
</div>
<p class="text-body-1 mb-0">允许 JPGGIF PNG 格式 最大尽寸 800K</p>
<p class="text-body-1 mb-0">
允许 JPGGIF PNG 格式 最大尽寸 800K
</p>
</form>
</VCardText>
@@ -198,16 +233,33 @@ onMounted(() => {
<VForm class="mt-6">
<VRow>
<!-- 👉 Name -->
<VCol md="6" cols="12">
<VTextField readonly v-model="accountInfo.name" label="用户名" />
<VCol
md="6"
cols="12"
>
<VTextField
v-model="accountInfo.name"
readonly
label="用户名"
/>
</VCol>
<!-- 👉 Email -->
<VCol cols="12" md="6">
<VTextField v-model="accountInfo.email" label="邮箱" type="email" />
<VCol
cols="12"
md="6"
>
<VTextField
v-model="accountInfo.email"
label="邮箱"
type="email"
/>
</VCol>
<VCol cols="12" md="6">
<VCol
cols="12"
md="6"
>
<!-- 👉 new password -->
<VTextField
v-model="newPassword"
@@ -216,12 +268,15 @@ onMounted(() => {
isNewPasswordVisible ? 'mdi-eye-off-outline' : 'mdi-eye-outline'
"
label="新密码"
@click:append-inner="isNewPasswordVisible = !isNewPasswordVisible"
autocomplete="new-password"
@click:append-inner="isNewPasswordVisible = !isNewPasswordVisible"
/>
</VCol>
<VCol cols="12" md="6">
<VCol
cols="12"
md="6"
>
<!-- 👉 confirm password -->
<VTextField
v-model="confirmPassword"
@@ -237,8 +292,13 @@ onMounted(() => {
</VCol>
<!-- 👉 Form Actions -->
<VCol cols="12" class="d-flex flex-wrap gap-4">
<VBtn @click="saveAccountInfo">保存</VBtn>
<VCol
cols="12"
class="d-flex flex-wrap gap-4"
>
<VBtn @click="saveAccountInfo">
保存
</VBtn>
</VCol>
</VRow>
</VForm>
@@ -246,7 +306,10 @@ onMounted(() => {
</VCard>
</VCol>
<VCol cols="12" v-if="accountInfo.is_superuser">
<VCol
v-if="accountInfo.is_superuser"
cols="12"
>
<!-- 👉 Accounts -->
<VCard title="所有用户">
<template #append>
@@ -257,38 +320,70 @@ onMounted(() => {
<VTable class="text-no-wrap">
<thead>
<tr>
<th scope="col">用户名</th>
<th scope="col">邮箱</th>
<th scope="col">状态</th>
<th scope="col">管理员</th>
<th scope="col" class="w-5"></th>
<th scope="col">
用户名
</th>
<th scope="col">
邮箱
</th>
<th scope="col">
状态
</th>
<th scope="col">
管理员
</th>
<th
scope="col"
class="w-5"
/>
</tr>
</thead>
<tbody>
<tr v-for="user in allUsers" :key="user.name">
<tr
v-for="user in allUsers"
:key="user.name"
>
<td>
{{ user.name }}
</td>
<td>{{ user.email }}</td>
<td>
<VChip color="success" text-color="white" v-if="user.is_active"
>激活</VChip
<VChip
v-if="user.is_active"
color="success"
text-color="white"
>
<VChip color="error" text-color="white" v-else>冻结</VChip>
激活
</VChip>
<VChip
v-else
color="error"
text-color="white"
>
冻结
</VChip>
</td>
<td>{{ user.is_superuser ? "是" : "否" }}</td>
<td>
<IconBtn v-show="!user.is_superuser">
<VIcon icon="mdi-dots-vertical" />
<VMenu activator="parent" close-on-content-click>
<VMenu
activator="parent"
close-on-content-click
>
<VList>
<VListItem variant="plain" @click="deactivateUser(user)">
<VListItem
variant="plain"
@click="deactivateUser(user)"
>
<template #prepend>
<VIcon icon="mdi-lock"></VIcon>
<VIcon icon="mdi-lock" />
</template>
<VListItemTitle>{{
user.is_active ? "冻结" : "解冻"
}}</VListItemTitle>
<VListItemTitle>
{{
user.is_active ? "冻结" : "解冻"
}}
</VListItemTitle>
</VListItem>
<VListItem
variant="plain"
@@ -296,7 +391,7 @@ onMounted(() => {
@click="deleteUser(user)"
>
<template #prepend>
<VIcon icon="mdi-delete"></VIcon>
<VIcon icon="mdi-delete" />
</template>
<VListItemTitle>删除</VListItemTitle>
</VListItem>
@@ -311,20 +406,30 @@ onMounted(() => {
</VCol>
</VRow>
<!-- 站点编辑弹窗 -->
<VDialog v-model="addUserDialog" max-width="800" persistent>
<VDialog
v-model="addUserDialog"
max-width="800"
persistent
>
<!-- Dialog Content -->
<VCard title="新增用户">
<VCardText>
<VForm @submit.prevent="() => {}">
<VRow>
<VCol cols="12" md="6">
<VCol
cols="12"
md="6"
>
<VTextField
v-model="userForm.name"
label="用户名"
:rules="[requiredValidator]"
/>
</VCol>
<VCol cols="12" md="6">
<VCol
cols="12"
md="6"
>
<VTextField
v-model="userForm.password"
label="密码"
@@ -336,16 +441,26 @@ onMounted(() => {
@click:append-inner="isPasswordVisible = !isPasswordVisible"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField v-model="userForm.email" label="邮箱" />
<VCol
cols="12"
md="6"
>
<VTextField
v-model="userForm.email"
label="邮箱"
/>
</VCol>
</VRow>
</VForm>
</VCardText>
<VCardActions>
<VBtn @click="addUserDialog = false"> 取消 </VBtn>
<VBtn @click="addUserDialog = false">
取消
</VBtn>
<VSpacer />
<VBtn @click="addUser"> 确定 </VBtn>
<VBtn @click="addUser">
确定
</VBtn>
</VCardActions>
</VCard>
</VDialog>

View File

@@ -1,44 +1,48 @@
<script lang="ts" setup>
import api from "@/api";
import { NotificationSwitch } from "@/api/types";
import { useToast } from "vue-toast-notification";
import { useToast } from 'vue-toast-notification'
import api from '@/api'
import type { NotificationSwitch } from '@/api/types'
const messagemTypes = ref<NotificationSwitch[]>([]);
const messagemTypes = ref<NotificationSwitch[]>([])
// 提示框
const $toast = useToast();
const $toast = useToast()
// 调用API查询消息开关
const loadNotificationSwitchs = async () => {
async function loadNotificationSwitchs() {
try {
const result: NotificationSwitch[] = await api.get("message/switchs");
messagemTypes.value = result;
} catch (error) {
console.log(error);
const result: NotificationSwitch[] = await api.get('message/switchs')
messagemTypes.value = result
}
};
catch (error) {
console.log(error)
}
}
// 调用API保存消息开关
const saveNotificationSwitchs = async () => {
async function saveNotificationSwitchs() {
try {
const result: { [key: string]: any } = await api.post(
"message/switchs",
messagemTypes.value
);
if (result.success) {
$toast.success("保存通知消息设置成功");
} else {
$toast.error("保存通知消息设置失败!");
}
messagemTypes.value = messagemTypes.value;
} catch (error) {
console.log(error);
'message/switchs',
messagemTypes.value,
)
if (result.success)
$toast.success('保存通知消息设置成功')
else
$toast.error('保存通知消息设置失败!')
// messagemTypes.value = messagemTypes.value
}
};
catch (error) {
console.log(error)
}
}
onMounted(() => {
loadNotificationSwitchs();
});
loadNotificationSwitchs()
})
</script>
<template>
@@ -48,14 +52,25 @@ onMounted(() => {
<VTable class="text-no-wrap">
<thead>
<tr>
<th scope="col">消息类型</th>
<th scope="col">微信</th>
<th scope="col">Telegram</th>
<th scope="col">Slack</th>
<th scope="col">
消息类型
</th>
<th scope="col">
微信
</th>
<th scope="col">
Telegram
</th>
<th scope="col">
Slack
</th>
</tr>
</thead>
<tbody>
<tr v-for="message in messagemTypes" :key="message.mtype">
<tr
v-for="message in messagemTypes"
:key="message.mtype"
>
<td>
{{ message.mtype }}
</td>
@@ -70,7 +85,12 @@ onMounted(() => {
</td>
</tr>
<tr v-if="messagemTypes.length === 0">
<td colspan="4" class="text-center">没有设置任何通知渠道</td>
<td
colspan="4"
class="text-center"
>
没有设置任何通知渠道
</td>
</tr>
</tbody>
</VTable>
@@ -79,7 +99,12 @@ onMounted(() => {
<VCardText>
<VForm @submit.prevent="() => {}">
<div class="d-flex flex-wrap gap-4 mt-4">
<VBtn mtype="submit" @click="saveNotificationSwitchs"> 保存 </VBtn>
<VBtn
mtype="submit"
@click="saveNotificationSwitchs"
>
保存
</VBtn>
</div>
</VForm>
</VCardText>

View File

@@ -1,152 +1,164 @@
<script lang="ts" setup>
import api from "@/api";
import FilterRuleCard from "@/components/cards/FilterRuleCard.vue";
import { useToast } from "vue-toast-notification";
import { useToast } from 'vue-toast-notification'
import api from '@/api'
import FilterRuleCard from '@/components/cards/FilterRuleCard.vue'
// 规则卡片类型
interface FilterCard {
// 优先级
pri: string;
pri: string
// 已选规则
rules: string[];
rules: string[]
// 是否可见
visible: boolean;
visible: boolean
}
// 提示框
const $toast = useToast();
const $toast = useToast()
// 种子优先规则
const selectedTorrentPriority = ref<string>("seeder");
const selectedTorrentPriority = ref<string>('seeder')
// 种子优先规则下拉框
const TorrentPriorityItems = [
{ title: "站点优先", value: "site" },
{ title: "做种数优先", value: "seeder" },
];
{ title: '站点优先', value: 'site' },
{ title: '做种数优先', value: 'seeder' },
]
// 规则卡片列表
const filterCards = ref<FilterCard[]>([])
// 查询已设置过滤规则
const queryCustomFilters = async () => {
async function queryCustomFilters() {
try {
const result: { [key: string]: any } = await api.get("system/setting/FilterRules");
const result: { [key: string]: any } = await api.get('system/setting/FilterRules')
if (result.success) {
// 保存的是个字符串,需要分割成数组
const groups = result.data?.value.split(">");
const groups = result.data?.value.split('>')
// 生成规则卡片
filterCards.value = groups?.map((group: string, index: number) => {
return {
pri: (index + 1).toString(),
rules: group.split("&"),
rules: group.split('&'),
visible: true,
};
});
}
})
}
} catch (error) {
console.log(error);
}
};
catch (error) {
console.log(error)
}
}
// 查询种子优先规则
const queryTorrentPriority = async () => {
async function queryTorrentPriority() {
try {
const result: { [key: string]: any } = await api.get(
"system/setting/TorrentsPriority"
);
selectedTorrentPriority.value = result.data?.value;
} catch (error) {
console.log(error);
'system/setting/TorrentsPriority',
)
selectedTorrentPriority.value = result.data?.value
}
};
catch (error) {
console.log(error)
}
}
// 保存用户设置的识别词
const saveCustomFilters = async () => {
async function saveCustomFilters() {
try {
// 有值才处理
if (filterCards.value.length === 0) {
return;
}
if (filterCards.value.length === 0)
return
// 将卡片规则接装为字符串
const value = filterCards.value
.filter((card) => card.rules.length > 0)
.map((card) => card.rules.join("&"))
.join(">");
.filter(card => card.rules.length > 0)
.map(card => card.rules.join('&'))
.join('>')
// 保存
const result: { [key: string]: any } = await api.post(
"system/setting/FilterRules",
value
);
if (result.success) {
$toast.success("过滤规则保存成功");
} else {
$toast.error("过滤规则保存失败!");
}
} catch (error) {
console.log(error);
'system/setting/FilterRules',
value,
)
if (result.success)
$toast.success('过滤规则保存成功')
else
$toast.error('过滤规则保存失败!')
}
};
catch (error) {
console.log(error)
}
}
// 保存种子优先规则
const saveTorrentPriority = async () => {
async function saveTorrentPriority() {
try {
// 用户名密码
const result: { [key: string]: any } = await api.post(
"system/setting/TorrentsPriority",
selectedTorrentPriority.value
);
if (result.success) {
$toast.success("优先规则保存成功");
} else {
$toast.error("优先规则保存失败!");
}
} catch (error) {
console.log(error);
}
};
'system/setting/TorrentsPriority',
selectedTorrentPriority.value,
)
// 规则卡片列表
const filterCards = ref<FilterCard[]>([]);
if (result.success)
$toast.success('优先规则保存成功')
else
$toast.error('优先规则保存失败!')
}
catch (error) {
console.log(error)
}
}
// 更新规则卡片的值
const updateFilterCardValue = (pri: string, rules: string[]) => {
const card = filterCards.value.find((card) => card.pri === pri);
if (card) {
card.rules = rules;
}
};
function updateFilterCardValue(pri: string, rules: string[]) {
const card = filterCards.value.find(card => card.pri === pri)
if (card)
card.rules = rules
}
// 移除卡片
const filterCardClose = (pri: string) => {
function filterCardClose(pri: string) {
// 将卡片从列表中删除,并更新剩余卡片的序号
const index = filterCards.value.findIndex((card) => card.pri === pri);
const index = filterCards.value.findIndex(card => card.pri === pri)
if (index !== -1) {
// 创建新的数组,然后使用 splice 方法来删除元素
const updatedCards = [...filterCards.value];
updatedCards.splice(index, 1);
const updatedCards = [...filterCards.value]
updatedCards.splice(index, 1)
// 更新剩余卡片的序号
updatedCards.forEach((card, i) => {
card.pri = (i + 1).toString();
});
card.pri = (i + 1).toString()
})
// 更新 filterCards.value
filterCards.value = updatedCards;
filterCards.value = updatedCards
}
};
}
// 增加卡片
const addFilterCard = () => {
function addFilterCard() {
// 优先级
const pri = (filterCards.value.length + 1).toString();
const pri = (filterCards.value.length + 1).toString()
// 新卡片
const newCard: FilterCard = { pri, rules: [], visible: true };
const newCard: FilterCard = { pri, rules: [], visible: true }
// 添加到列表
filterCards.value.push(newCard);
};
filterCards.value.push(newCard)
}
onMounted(() => {
queryTorrentPriority();
queryCustomFilters();
});
queryTorrentPriority()
queryCustomFilters()
})
</script>
<template>
@@ -168,8 +180,18 @@ onMounted(() => {
</div>
</VCardItem>
<VCardItem>
<VBtn type="submit" @click="saveCustomFilters" class="me-2"> 保存 </VBtn>
<VBtn @click="addFilterCard" color="success" variant="tonal">
<VBtn
type="submit"
class="me-2"
@click="saveCustomFilters"
>
保存
</VBtn>
<VBtn
color="success"
variant="tonal"
@click="addFilterCard"
>
<VIcon icon="mdi-plus" />
<span class="d-none d-sm-block">增加规则</span>
</VBtn>
@@ -184,16 +206,22 @@ onMounted(() => {
:items="TorrentPriorityItems"
label="优先规则"
outlined
></VSelect>
/>
</VCardText>
<VCardItem>
<VBtn type="submit" @click="saveTorrentPriority"> 保存 </VBtn>
<VBtn
type="submit"
@click="saveTorrentPriority"
>
保存
</VBtn>
</VCardItem>
</VCard>
</VCol>
</VRow>
</template>
<style type="scss">
<style lang="scss">
.grid-filterrule-card {
grid-template-columns: repeat(auto-fill, minmax(20rem, 1fr));
padding-block-end: 1rem;

View File

@@ -1,88 +1,96 @@
<script lang="ts" setup>
import api from "@/api";
import { Site } from "@/api/types";
import { useToast } from "vue-toast-notification";
import { useToast } from 'vue-toast-notification'
import api from '@/api'
import type { Site } from '@/api/types'
// 提示框
const $toast = useToast();
const $toast = useToast()
// 选中站点
const selectedSites = ref<number[]>([]);
const selectedSites = ref<number[]>([])
// 所有站点
const allSites = ref<Site[]>([]);
const allSites = ref<Site[]>([])
// 站点重置
const isConfirmResetSites = ref(false);
const isConfirmResetSites = ref(false)
// 站点重置按钮文本
const resetSitesText = ref("重置站点数据");
const resetSitesText = ref('重置站点数据')
// 站点重置按钮可用状态
const resetSitesDisabled = ref(false);
const resetSitesDisabled = ref(false)
// 查询所有站点
const querySites = async () => {
async function querySites() {
try {
const data: Site[] = await api.get("site");
const data: Site[] = await api.get('site')
// 过滤站点,只有启用的站点才显示
allSites.value = data.filter((item) => item.is_active);
querySelectedSites();
} catch (error) {
console.log(error);
allSites.value = data.filter(item => item.is_active)
querySelectedSites()
}
};
catch (error) {
console.log(error)
}
}
// 查询用户选中的站点
const querySelectedSites = async () => {
async function querySelectedSites() {
try {
const result: { [key: string]: any } = await api.get("system/setting/IndexerSites");
selectedSites.value = result.data?.value;
} catch (error) {
console.log(error);
const result: { [key: string]: any } = await api.get('system/setting/IndexerSites')
selectedSites.value = result.data?.value
}
};
catch (error) {
console.log(error)
}
}
// 保存用户选中的站点
const saveSelectedSites = async () => {
async function saveSelectedSites() {
try {
// 用户名密码
const result: { [key: string]: any } = await api.post(
"system/setting/IndexerSites",
selectedSites.value
);
if (result.success) {
$toast.success("索引站点保存成功");
} else {
$toast.error("索引站点保存失败!");
}
} catch (error) {
console.log(error);
'system/setting/IndexerSites',
selectedSites.value,
)
if (result.success)
$toast.success('索引站点保存成功')
else
$toast.error('索引站点保存失败!')
}
};
catch (error) {
console.log(error)
}
}
// 重置站点
const resetSites = async () => {
async function resetSites() {
try {
resetSitesDisabled.value = true;
resetSitesText.value = "正在重置...";
const result: { [key: string]: any } = await api.get("site/reset");
resetSitesDisabled.value = true
resetSitesText.value = '正在重置...'
const result: { [key: string]: any } = await api.get('site/reset')
if (result.success) {
$toast.success("站点重置成功请等待CookieCloud同步完成");
querySites();
} else {
$toast.error("站点重置失败!");
$toast.success('站点重置成功请等待CookieCloud同步完成')
querySites()
}
resetSitesDisabled.value = false;
resetSitesText.value = "重置站点数据";
} catch (error) {
console.log(error);
else {
$toast.error('站点重置失败!')
}
resetSitesDisabled.value = false
resetSitesText.value = '重置站点数据'
}
};
catch (error) {
console.log(error)
}
}
onMounted(() => {
querySites();
});
querySites()
})
</script>
<template>
@@ -92,12 +100,16 @@ onMounted(() => {
<VCardSubtitle> 只有选中的站点才会在搜索和订阅中使用 </VCardSubtitle>
<VCardItem>
<VChipGroup v-model="selectedSites" column multiple>
<VChipGroup
v-model="selectedSites"
column
multiple
>
<VChip
filter
variant="outlined"
v-for="site in allSites"
:key="site.id"
filter
variant="outlined"
:value="site.id"
>
{{ site.name }}
@@ -106,7 +118,12 @@ onMounted(() => {
</VCardItem>
<VCardItem>
<VBtn type="submit" @click="saveSelectedSites"> 保存 </VBtn>
<VBtn
type="submit"
@click="saveSelectedSites"
>
保存
</VBtn>
</VCardItem>
</VCard>
</VCol>

View File

@@ -1,80 +1,86 @@
<script lang="ts" setup>
import api from "@/api";
import { useToast } from "vue-toast-notification";
import { useToast } from 'vue-toast-notification'
import api from '@/api'
// 提示框
const $toast = useToast();
const $toast = useToast()
// 自定义识别词
const customIdentifiers = ref("");
const customIdentifiers = ref('')
// 自定义制作组
const customReleaseGroups = ref("");
const customReleaseGroups = ref('')
// 查询已设置的识别词
const queryCustomIdentifiers = async () => {
async function queryCustomIdentifiers() {
try {
const result: { [key: string]: any } = await api.get(
"system/setting/CustomIdentifiers"
);
customIdentifiers.value = result.data?.value.join("\n");
} catch (error) {
console.log(error);
'system/setting/CustomIdentifiers',
)
customIdentifiers.value = result.data?.value.join('\n')
}
};
catch (error) {
console.log(error)
}
}
// 查询已设置的制作组
const queryCustomReleaseGroups = async () => {
async function queryCustomReleaseGroups() {
try {
const result: { [key: string]: any } = await api.get(
"system/setting/CustomReleaseGroups"
);
customReleaseGroups.value = result.data?.value.join("\n");
} catch (error) {
console.log(error);
'system/setting/CustomReleaseGroups',
)
customReleaseGroups.value = result.data?.value.join('\n')
}
};
catch (error) {
console.log(error)
}
}
// 保存用户设置的识别词
const saveCustomIdentifiers = async () => {
async function saveCustomIdentifiers() {
try {
// 用户名密码
const result: { [key: string]: any } = await api.post(
"system/setting/CustomIdentifiers",
customIdentifiers.value.split("\n")
);
if (result.success) {
$toast.success("自定义识别词保存成功");
} else {
$toast.error("自定义识别词保存失败!");
}
} catch (error) {
console.log(error);
'system/setting/CustomIdentifiers',
customIdentifiers.value.split('\n'),
)
if (result.success)
$toast.success('自定义识别词保存成功')
else
$toast.error('自定义识别词保存失败!')
}
};
catch (error) {
console.log(error)
}
}
// 保存自定义制作组
const saveCustomReleaseGroups = async () => {
async function saveCustomReleaseGroups() {
try {
// 用户名密码
const result: { [key: string]: any } = await api.post(
"system/setting/CustomReleaseGroups",
customReleaseGroups.value.split("\n")
);
if (result.success) {
$toast.success("自定义制作组/字幕组保存成功");
} else {
$toast.error("自定义制作组/字幕组保存失败!");
}
} catch (error) {
console.log(error);
'system/setting/CustomReleaseGroups',
customReleaseGroups.value.split('\n'),
)
if (result.success)
$toast.success('自定义制作组/字幕组保存成功')
else
$toast.error('自定义制作组/字幕组保存失败!')
}
};
catch (error) {
console.log(error)
}
}
onMounted(() => {
queryCustomIdentifiers();
queryCustomReleaseGroups();
});
queryCustomIdentifiers()
queryCustomReleaseGroups()
})
</script>
<template>
@@ -90,11 +96,15 @@ onMounted(() => {
屏蔽词
被替换词 => 替换词
前定位词 <> 后定位词 >> 偏移量EP"
>
</VTextarea>
/>
</VCardItem>
<VCardItem>
<VBtn type="submit" @click="saveCustomIdentifiers"> 保存 </VBtn>
<VBtn
type="submit"
@click="saveCustomIdentifiers"
>
保存
</VBtn>
</VCardItem>
</VCard>
</VCol>
@@ -106,11 +116,15 @@ onMounted(() => {
v-model="customReleaseGroups"
auto-grow
placeholder="支持正则表达式,特殊字符需要\转义,一行代表一个制作组/字幕组"
>
</VTextarea>
/>
</VCardItem>
<VCardItem>
<VBtn type="submit" @click="saveCustomReleaseGroups"> 保存 </VBtn>
<VBtn
type="submit"
@click="saveCustomReleaseGroups"
>
保存
</VBtn>
</VCardItem>
</VCard>
</VCol>

View File

@@ -1,54 +1,56 @@
<script setup lang="ts">
import api from "@/api";
import { MediaStatistic } from "@/api/types";
import api from '@/api'
import type { MediaStatistic } from '@/api/types'
const statistics = ref([
{
title: "",
stats: "",
icon: "",
color: "",
title: '',
stats: '',
icon: '',
color: '',
},
]);
])
// 调用API加载媒体统计数据
const loadMediaStatistic = async () => {
async function loadMediaStatistic() {
try {
const res: MediaStatistic = await api.get("dashboard/statistic");
const res: MediaStatistic = await api.get('dashboard/statistic')
statistics.value = [
{
title: "电影",
title: '电影',
stats: res.movie_count.toLocaleString(),
icon: "mdi-movie-roll",
color: "primary",
icon: 'mdi-movie-roll',
color: 'primary',
},
{
title: "电视剧",
title: '电视剧',
stats: res.tv_count.toLocaleString(),
icon: "mdi-television-box",
color: "success",
icon: 'mdi-television-box',
color: 'success',
},
{
title: "剧集",
title: '剧集',
stats: res.episode_count.toLocaleString(),
icon: "mdi-television-classic",
color: "warning",
icon: 'mdi-television-classic',
color: 'warning',
},
{
title: "用户",
title: '用户',
stats: res.user_count.toLocaleString(),
icon: "mdi-account",
color: "info",
icon: 'mdi-account',
color: 'info',
},
];
} catch (e) {
console.log(e);
]
}
};
catch (e) {
console.log(e)
}
}
onMounted(() => {
loadMediaStatistic();
});
loadMediaStatistic()
})
</script>
<template>
@@ -59,11 +61,24 @@ onMounted(() => {
<VCardText>
<VRow>
<VCol v-for="item in statistics" :key="item.title" cols="6" sm="3">
<VCol
v-for="item in statistics"
:key="item.title"
cols="6"
sm="3"
>
<div class="d-flex align-center">
<div class="me-3">
<VAvatar :color="item.color" rounded size="42" class="elevation-1">
<VIcon size="24" :icon="item.icon" />
<VAvatar
:color="item.color"
rounded
size="42"
class="elevation-1"
>
<VIcon
size="24"
:icon="item.icon"
/>
</VAvatar>
</div>

View File

@@ -1,42 +1,45 @@
<script lang="ts" setup>
import { formatSeconds } from "@/@core/utils/formatters";
import api from "@/api";
import { Process } from "@/api/types";
import { formatSeconds } from '@/@core/utils/formatters'
import api from '@/api'
import type { Process } from '@/api/types'
// 表头
const headers = ["进程ID", "进程名称", "运行时间", "内存占用"];
const headers = ['进程ID', '进程名称', '运行时间', '内存占用']
// 数据列表
const processList = ref<Process[]>([]);
const processList = ref<Process[]>([])
// 定时器
let refreshTimer: NodeJS.Timer | null = null;
let refreshTimer: NodeJS.Timer | null = null
// 调用API加载数据
const loadProcessList = async () => {
async function loadProcessList() {
try {
const res: Process[] = await api.get("dashboard/processes");
processList.value = res;
} catch (e) {
console.log(e);
const res: Process[] = await api.get('dashboard/processes')
processList.value = res
}
};
catch (e) {
console.log(e)
}
}
onMounted(() => {
loadProcessList();
loadProcessList()
// 启动定时器
refreshTimer = setInterval(() => {
loadProcessList();
}, 5000);
});
loadProcessList()
}, 5000)
})
// 组件卸载时停止定时器
onUnmounted(() => {
if (refreshTimer) {
clearInterval(refreshTimer);
refreshTimer = null;
clearInterval(refreshTimer)
refreshTimer = null
}
});
})
</script>
<template>
@@ -51,20 +54,38 @@ onUnmounted(() => {
>
<thead>
<tr>
<th v-for="header in headers" :key="header" :id="header">
<th
v-for="header in headers"
:id="header"
:key="header"
>
{{ header }}
</th>
</tr>
</thead>
<tbody>
<tr v-for="row in processList" :key="row.pid">
<td class="text-sm" v-text="row.pid" />
<tr
v-for="row in processList"
:key="row.pid"
>
<td
class="text-sm"
v-text="row.pid"
/>
<!-- name -->
<td>
<h6 class="text-sm font-weight-medium">{{ row.name }}</h6>
<h6 class="text-sm font-weight-medium">
{{ row.name }}
</h6>
</td>
<td class="text-sm" v-text="formatSeconds(row.run_time)" />
<td class="text-sm" v-text="`${row.memory} MB`" />
<td
class="text-sm"
v-text="formatSeconds(row.run_time)"
/>
<td
class="text-sm"
v-text="`${row.memory} MB`"
/>
</tr>
</tbody>
</VTable>

View File

@@ -1,38 +1,41 @@
<script setup lang="ts">
import api from "@/api";
import { ScheduleInfo } from "@/api/types";
import api from '@/api'
import type { ScheduleInfo } from '@/api/types'
// 定时服务列表
const schedulerList = ref<ScheduleInfo[]>([]);
const schedulerList = ref<ScheduleInfo[]>([])
// 定时器
let refreshTimer: NodeJS.Timer | null = null;
let refreshTimer: NodeJS.Timer | null = null
// 调用API加载定时服务列表
const loadSchedulerList = async () => {
async function loadSchedulerList() {
try {
const res: ScheduleInfo[] = await api.get("dashboard/schedule");
schedulerList.value = res;
} catch (e) {
console.log(e);
const res: ScheduleInfo[] = await api.get('dashboard/schedule')
schedulerList.value = res
}
};
catch (e) {
console.log(e)
}
}
onMounted(() => {
loadSchedulerList();
loadSchedulerList()
// 启动定时器
refreshTimer = setInterval(() => {
loadSchedulerList();
}, 60000);
});
loadSchedulerList()
}, 60000)
})
// 组件卸载时停止定时器
onUnmounted(() => {
if (refreshTimer) {
clearInterval(refreshTimer);
refreshTimer = null;
clearInterval(refreshTimer)
refreshTimer = null
}
});
})
</script>
<template>
@@ -42,10 +45,21 @@ onUnmounted(() => {
</VCardItem>
<VCardText>
<VList class="card-list" height="250">
<VListItem v-for="item in schedulerList" :key="item.id">
<VList
class="card-list"
height="250"
>
<VListItem
v-for="item in schedulerList"
:key="item.id"
>
<template #prepend>
<VAvatar size="40" variant="tonal" color="" class="me-3">
<VAvatar
size="40"
variant="tonal"
color=""
class="me-3"
>
{{ item.name[0] }}
</VAvatar>
</template>
@@ -54,7 +68,9 @@ onUnmounted(() => {
<span class="text-sm font-weight-medium">{{ item.name }}</span>
</VListItemTitle>
<VListItemSubtitle class="text-xs"> {{ item.next_run }}</VListItemSubtitle>
<VListItemSubtitle class="text-xs">
{{ item.next_run }}
</VListItemSubtitle>
<template #append>
<div>
@@ -65,7 +81,9 @@ onUnmounted(() => {
</template>
</VListItem>
<VListItem v-if="schedulerList.length === 0">
<VListItemTitle class="text-center">没有后台服务</VListItemTitle>
<VListItemTitle class="text-center">
没有后台服务
</VListItemTitle>
</VListItem>
</VList>
</VCardText>

View File

@@ -1,76 +1,83 @@
<script setup lang="ts">
import { formatFileSize } from "@/@core/utils/formatters";
import api from "@/api";
import { DownloaderInfo } from "@/api/types";
import { formatFileSize } from '@/@core/utils/formatters'
import api from '@/api'
import type { DownloaderInfo } from '@/api/types'
// 定时器
let refreshTimer: NodeJS.Timer | null = null;
let refreshTimer: NodeJS.Timer | null = null
// 下载器信息
const downloadInfo = ref<DownloaderInfo>({
// 下载速度
download_speed: 0,
// 上传速度
upload_speed: 0,
// 下载量
download_size: 0,
// 上传量
upload_size: 0,
// 剩余空间
free_space: 0,
});
})
// 显示项
const infoItems = ref([
{
avatar: "",
title: "",
amount: "",
avatar: '',
title: '',
amount: '',
},
]);
])
// 调用API查询下载器数据
const loadDownloaderInfo = async () => {
async function loadDownloaderInfo() {
try {
const res: DownloaderInfo = await api.get("dashboard/downloader");
downloadInfo.value = res;
const res: DownloaderInfo = await api.get('dashboard/downloader')
downloadInfo.value = res
infoItems.value = [
{
avatar: "mdi-cloud-upload",
title: "总上传量",
avatar: 'mdi-cloud-upload',
title: '总上传量',
amount: formatFileSize(res.upload_size),
},
{
avatar: "mdi-download-box",
title: "总下载量",
avatar: 'mdi-download-box',
title: '总下载量',
amount: formatFileSize(res.download_size),
},
{
avatar: "mdi-content-save",
title: "磁盘剩余空间",
avatar: 'mdi-content-save',
title: '磁盘剩余空间',
amount: formatFileSize(res.free_space),
},
];
} catch (e) {
console.log(e);
]
}
};
catch (e) {
console.log(e)
}
}
onMounted(() => {
loadDownloaderInfo();
loadDownloaderInfo()
// 启动定时器
refreshTimer = setInterval(() => {
loadDownloaderInfo();
}, 3000);
});
loadDownloaderInfo()
}, 3000)
})
// 组件卸载时停止定时器
onUnmounted(() => {
if (refreshTimer) {
clearInterval(refreshTimer);
refreshTimer = null;
clearInterval(refreshTimer)
refreshTimer = null
}
});
})
</script>
<template>
@@ -81,13 +88,23 @@ onUnmounted(() => {
<VCardText class="pt-4">
<div>
<p class="text-h5 me-2">{{ formatFileSize(downloadInfo.upload_speed) }}/s</p>
<p class="text-h4 me-2">{{ formatFileSize(downloadInfo.download_speed) }}/s</p>
<p class="text-h5 me-2">
{{ formatFileSize(downloadInfo.upload_speed) }}/s
</p>
<p class="text-h4 me-2">
{{ formatFileSize(downloadInfo.download_speed) }}/s
</p>
</div>
<VList class="card-list mt-9">
<VListItem v-for="item in infoItems" :key="item.title">
<VListItem
v-for="item in infoItems"
:key="item.title"
>
<template #prepend>
<VIcon rounded :icon="item.avatar" />
<VIcon
rounded
:icon="item.avatar"
/>
</template>
<VListItemTitle class="text-sm font-weight-medium mb-1">

View File

@@ -1,57 +1,78 @@
<script setup lang="ts">
import { formatFileSize } from "@/@core/utils/formatters";
import api from "@/api";
import trophy from "@images/misc/storage.png";
import triangleDark from "@images/misc/triangle-dark.png";
import triangleLight from "@images/misc/triangle-light.png";
import { useTheme } from "vuetify";
import { useTheme } from 'vuetify'
import { formatFileSize } from '@/@core/utils/formatters'
import api from '@/api'
import trophy from '@images/misc/storage.png'
import triangleDark from '@images/misc/triangle-dark.png'
import triangleLight from '@images/misc/triangle-light.png'
const { global } = useTheme()
const { global } = useTheme();
const triangleBg = computed(() =>
global.name.value === "light" ? triangleLight : triangleDark
);
global.name.value === 'light' ? triangleLight : triangleDark,
)
// 总存储空间
const storage = ref(0);
const storage = ref(0)
// 已使用存储空间
const used = ref(0);
const used = ref(0)
// 计算已使用存储空间百分比精确到小数点后1位
const usedPercent = computed(() => {
return Math.round((used.value / storage.value) * 1000) / 10;
});
return Math.round((used.value / storage.value) * 1000) / 10
})
// 调用API查询存储空间
const getStorage = async () => {
async function getStorage() {
try {
const res: Storage = await api.get("dashboard/storage");
storage.value = res.total_storage;
used.value = res.used_storage;
} catch (e) {
console.log(e);
const res: Storage = await api.get('dashboard/storage')
storage.value = res.total_storage
used.value = res.used_storage
}
};
catch (e) {
console.log(e)
}
}
onMounted(() => {
getStorage();
});
getStorage()
})
</script>
<template>
<VCard title="存储空间" subtitle="" class="position-relative">
<VCard
title="存储空间"
subtitle=""
class="position-relative"
>
<VCardText>
<h5 class="text-2xl font-weight-medium text-primary">
{{ formatFileSize(storage) }}
</h5>
<p class="mt-2">已使用 {{ usedPercent }}% 🚀</p>
<p class="mt-1"><VProgressLinear :model-value="usedPercent" color="primary" /></p>
<p class="mt-2">
已使用 {{ usedPercent }}% 🚀
</p>
<p class="mt-1">
<VProgressLinear
:model-value="usedPercent"
color="primary"
/>
</p>
</VCardText>
<!-- Triangle Background -->
<VImg :src="triangleBg" class="triangle-bg flip-in-rtl" />
<VImg
:src="triangleBg"
class="triangle-bg flip-in-rtl"
/>
<!-- Trophy -->
<VImg :src="trophy" class="trophy" />
<VImg
:src="trophy"
class="trophy"
/>
</VCard>
</template>

View File

@@ -1,23 +1,24 @@
<script setup lang="ts">
import api from "@/api";
import { hexToRgb } from "@layouts/utils";
import VueApexCharts from "vue3-apexcharts";
import { useTheme } from "vuetify";
import VueApexCharts from 'vue3-apexcharts'
import { useTheme } from 'vuetify'
import api from '@/api'
import { hexToRgb } from '@layouts/utils'
const vuetifyTheme = useTheme();
const vuetifyTheme = useTheme()
const options = controlledComputed(
() => vuetifyTheme.name.value,
() => {
const currentTheme = ref(vuetifyTheme.current.value.colors);
const variableTheme = ref(vuetifyTheme.current.value.variables);
const currentTheme = ref(vuetifyTheme.current.value.colors)
const variableTheme = ref(vuetifyTheme.current.value.variables)
const disabledColor = `rgba(${hexToRgb(currentTheme.value["on-surface"])},${
variableTheme.value["disabled-opacity"]
})`;
const borderColor = `rgba(${hexToRgb(String(variableTheme.value["border-color"]))},${
variableTheme.value["border-opacity"]
})`;
const disabledColor = `rgba(${hexToRgb(currentTheme.value['on-surface'])},${
variableTheme.value['disabled-opacity']
})`
const borderColor = `rgba(${hexToRgb(String(variableTheme.value['border-color']))},${
variableTheme.value['border-opacity']
})`
return {
chart: {
@@ -28,9 +29,9 @@ const options = controlledComputed(
bar: {
borderRadius: 9,
distributed: true,
columnWidth: "40%",
endingShape: "rounded",
startingShape: "rounded",
columnWidth: '40%',
endingShape: 'rounded',
startingShape: 'rounded',
},
},
stroke: {
@@ -54,12 +55,12 @@ const options = controlledComputed(
dataLabels: { enabled: false },
colors: [currentTheme.value.primary],
states: {
hover: { filter: { type: "none" } },
active: { filter: { type: "none" } },
hover: { filter: { type: 'none' } },
active: { filter: { type: 'none' } },
},
xaxis: {
categories: ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"],
tickPlacement: "on",
categories: ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'],
tickPlacement: 'on',
labels: { show: false },
crosshairs: { opacity: 0 },
axisTicks: { show: false },
@@ -72,36 +73,38 @@ const options = controlledComputed(
offsetX: -17,
style: {
colors: disabledColor,
fontSize: "12px",
fontSize: '12px',
},
formatter: (value: number) =>
`${value > 999 ? `${(value / 1000).toFixed(0)}` : value}`,
value > 999 ? (value / 1000).toFixed(0) : value,
},
},
};
}
);
}
},
)
// 图表数据
const series = ref([{ data: [0, 0, 0, 0, 0, 0, 0] }]);
const series = ref([{ data: [0, 0, 0, 0, 0, 0, 0] }])
// 总数
const totalCount = computed(() => series.value[0].data.reduce((a, b) => a + b, 0));
const totalCount = computed(() => series.value[0].data.reduce((a, b) => a + b, 0))
// 调用API接口获取数据近7天数据
const getWeeklyData = async () => {
async function getWeeklyData() {
try {
const res: number[] = await api.get("dashboard/transfer");
series.value = [{ data: res }];
} catch (e) {
console.log(e);
const res: number[] = await api.get('dashboard/transfer')
series.value = [{ data: res }]
}
};
catch (e) {
console.log(e)
}
}
onMounted(() => {
getWeeklyData();
});
getWeeklyData()
})
</script>
<template>
@@ -111,14 +114,26 @@ onMounted(() => {
</VCardItem>
<VCardText>
<VueApexCharts type="bar" :options="options" :series="series" :height="160" />
<VueApexCharts
type="bar"
:options="options"
:series="series"
:height="160"
/>
<div class="d-flex align-center mb-3">
<h5 class="text-h5 me-4">{{ totalCount }}</h5>
<h5 class="text-h5 me-4">
{{ totalCount }}
</h5>
<p>最近一周入库了 {{ totalCount }} 部影片 😎</p>
</div>
<VBtn block to="/history"> 查看详情 </VBtn>
<VBtn
block
to="/history"
>
查看详情
</VBtn>
</VCardText>
</VCard>
</template>

View File

@@ -1,59 +1,64 @@
<script lang="ts" setup>
import api from "@/api";
import { MediaInfo } from "@/api/types";
import MediaCard from "@/components/cards/MediaCard.vue";
import NoDataFound from "@/components/NoDataFound.vue";
import api from '@/api'
import type { MediaInfo } from '@/api/types'
import MediaCard from '@/components/cards/MediaCard.vue'
import NoDataFound from '@/components/NoDataFound.vue'
// 输入参数
const props = defineProps({
apipath: String,
params: Object as PropType<{ [key: string]: any }>,
});
})
// 判断是否有滚动条
const hasScroll = () => {
function hasScroll() {
return (
document.body.scrollHeight -
(window.innerHeight || document.documentElement.clientHeight) >
2
);
};
document.body.scrollHeight
- (window.innerHeight || document.documentElement.clientHeight)
> 2
)
}
// 当前页码
const page = ref(1);
const page = ref(1)
// 是否加载中
const loading = ref(false);
const loading = ref(false)
// 是否加载完成
const isRefreshed = ref(false);
const isRefreshed = ref(false)
// 数据列表
const dataList = ref<MediaInfo[]>([]);
const currData = ref<MediaInfo[]>([]);
const dataList = ref<MediaInfo[]>([])
const currData = ref<MediaInfo[]>([])
// 拼装参数
const getParams = () => {
function getParams() {
let params = {
page: page.value,
};
if (props.params) {
params = { ...params, ...props.params };
}
return params;
};
if (props.params)
params = { ...params, ...props.params }
return params
}
// 获取订阅列表数据
const fetchData = async ({ done }: { done: any }) => {
async function fetchData({ done }: { done: any }) {
try {
if (!props.apipath) {
return;
}
if (!props.apipath)
return
// 如果正在加载中,直接返回
if (loading.value) {
done("ok");
return;
done('ok')
return
}
// 设置加载中
loading.value = true;
loading.value = true
// 加载到满屏或者加载出错
if (!hasScroll()) {
// 加载多次
@@ -61,83 +66,97 @@ const fetchData = async ({ done }: { done: any }) => {
// 请求API
currData.value = await api.get(props.apipath, {
params: getParams(),
});
})
// 标计为已请求完成
isRefreshed.value = true;
isRefreshed.value = true
if (currData.value.length === 0) {
// 如果没有数据,跳出
done("ok");
return;
done('ok')
return
}
// 合并数据
dataList.value = [...dataList.value, ...currData.value];
dataList.value = [...dataList.value, ...currData.value]
// 页码+1
page.value++;
page.value++
}
} else {
}
else {
// 加载一次
// 请求API
currData.value = await api.get(props.apipath, {
params: getParams(),
});
})
// 标计为已请求完成
isRefreshed.value = true;
isRefreshed.value = true
if (currData.value.length === 0) {
// 如果没有数据,跳出
done("ok");
return;
done('ok')
return
}
// 合并数据
dataList.value = [...dataList.value, ...currData.value];
dataList.value = [...dataList.value, ...currData.value]
// 页码+1
page.value++;
page.value++
}
// 取消加载中
loading.value = false;
loading.value = false
// 返回加载成功
done("ok");
} catch (error) {
console.error(error);
// 返回加载失败
done("error");
done('ok')
}
};
catch (error) {
console.error(error)
// 返回加载失败
done('error')
}
}
</script>
<template>
<VProgressCircular
class="centered"
v-if="!isRefreshed"
class="centered"
indeterminate
color="primary"
></VProgressCircular>
/>
<VInfiniteScroll
mode="intersect"
side="end"
:onLoad="fetchData"
:on-load="fetchData"
class="overflow-hidden"
>
<template #loading />
<div class="grid gap-4 grid-media-card mx-3" v-if="dataList.length > 0" tabindex="0">
<div
v-if="dataList.length > 0"
class="grid gap-4 grid-media-card mx-3"
tabindex="0"
>
<MediaCard
v-for="data in dataList"
:key="data.tmdb_id || data.douban_id"
:media="data"
>
</MediaCard>
/>
</div>
<NoDataFound
v-if="dataList.length === 0 && isRefreshed"
error-code="500"
error-title="出错啦"
error-description="无法获取到媒体信息请检查网络连接"
>
</NoDataFound>
/>
</VInfiniteScroll>
</template>
<style type="scss">
<style lang="scss">
.grid-media-card {
grid-template-columns: repeat(auto-fill, minmax(9.375rem, 1fr));
}

View File

@@ -1,57 +1,72 @@
<script lang="ts" setup>
import api from "@/api";
import { MediaInfo } from "@/api/types";
import MediaCard from "@/components/cards/MediaCard.vue";
import api from '@/api'
import type { MediaInfo } from '@/api/types'
import MediaCard from '@/components/cards/MediaCard.vue'
// 输入参数
const props = defineProps({
apipath: String,
});
})
// 组件加载完成
const componentLoaded = ref(false);
const componentLoaded = ref(false)
// 数据列表
const dataList = ref<MediaInfo[]>([]);
const dataList = ref<MediaInfo[]>([])
// 获取订阅列表数据
const fetchData = async () => {
async function fetchData() {
try {
if (!props.apipath) {
return;
}
dataList.value = await api.get(props.apipath);
componentLoaded.value = true;
} catch (error) {
console.error(error);
if (!props.apipath)
return
dataList.value = await api.get(props.apipath)
componentLoaded.value = true
}
};
catch (error) {
console.error(error)
}
}
// 加载时获取数据
onMounted(fetchData);
onMounted(fetchData)
</script>
<template>
<slot name="title" :loaded="componentLoaded"></slot>
<slot
name="title"
:loaded="componentLoaded"
/>
<VSlideGroup show-arrows="false">
<template #prev>
<VBtn class="rounded-circle shadow-none" icon="mdi-chevron-left" color="grey" />
<VBtn
class="rounded-circle shadow-none"
icon="mdi-chevron-left"
color="grey"
/>
</template>
<VSlideGroupItem v-for="data in dataList" :key="data.tmdb_id">
<VSlideGroupItem
v-for="data in dataList"
:key="data.tmdb_id"
>
<MediaCard
:key="data.tmdb_id || data.douban_id"
:media="data"
height="15rem"
width="10rem"
:key="data.tmdb_id || data.douban_id"
/>
</VSlideGroupItem>
<template #next>
<VBtn class="rounded-circle shadow-none" icon="mdi-chevron-right" color="grey" />
<VBtn
class="rounded-circle shadow-none"
icon="mdi-chevron-right"
color="grey"
/>
</template>
</VSlideGroup>
</template>
<style type="scss">
<style lang="scss">
.v-slide-group .v-card {
@apply m-2;
}

View File

@@ -1,279 +1,295 @@
<script lang="ts" setup>
import { isIntersected } from "@/@core/utils";
import api from "@/api";
import { Context } from "@/api/types";
import TorrentCard from "@/components/cards/TorrentCard.vue";
import NoDataFound from "@/components/NoDataFound.vue";
import store from "@/store";
import { isIntersected } from '@/@core/utils'
import api from '@/api'
import type { Context } from '@/api/types'
import TorrentCard from '@/components/cards/TorrentCard.vue'
import NoDataFound from '@/components/NoDataFound.vue'
import store from '@/store'
// 定义输入参数
const props = defineProps({
// 关键字或TMDBID
keyword: String,
// 类型
type: String,
});
})
// 数据列表
const dataList = ref<Context[]>([]);
const dataList = ref<Context[]>([])
// 分组后的数据列表
const groupedDataList = computed(() => {
return groupByTitleAndSize(dataList.value);
});
return groupByTitleAndSize(dataList.value)
})
// 是否刷新过
const isRefreshed = ref(false);
const isRefreshed = ref(false)
// 加载进度文本
const progressText = ref("");
const progressText = ref('')
// 加载进度
const progressValue = ref(0);
const progressValue = ref(0)
// 加载进度SSE
const progressEventSource = ref<EventSource>();
const progressEventSource = ref<EventSource>()
// 过滤表单
const filterForm = reactive({
// 站点
site: [] as string[],
// 季
season: [] as string[],
// 制作组
releaseGroup: [] as string[],
// 视频编码
videoCode: [] as string[],
// 促销状态
freeState: [] as string[],
// 质量
edition: [] as string[],
});
})
// 获取站点过滤选项
const getSiteFilterOptions = computed(() => {
const options: string[] = [];
const options: string[] = []
dataList.value.forEach((data) => {
if (data.torrent_info?.site_name && !options.includes(data.torrent_info?.site_name)) {
options.push(data.torrent_info?.site_name);
}
});
return options;
});
if (data.torrent_info?.site_name && !options.includes(data.torrent_info?.site_name))
options.push(data.torrent_info?.site_name)
})
return options
})
// 获取季过滤选项
const getSeasonFilterOptions = computed(() => {
const options: string[] = [];
const options: string[] = []
dataList.value.forEach((data) => {
if (
data.meta_info.season_episode &&
!options.includes(data.meta_info.season_episode)
) {
options.push(data.meta_info.season_episode);
}
});
return options;
});
data.meta_info.season_episode
&& !options.includes(data.meta_info.season_episode)
)
options.push(data.meta_info.season_episode)
})
return options
})
// 获取制作组过滤选项
const getReleaseGroupFilterOptions = computed(() => {
const options: string[] = [];
const options: string[] = []
dataList.value.forEach((data) => {
if (data.meta_info.resource_team && !options.includes(data.meta_info.resource_team)) {
options.push(data.meta_info.resource_team);
}
});
return options;
});
if (data.meta_info.resource_team && !options.includes(data.meta_info.resource_team))
options.push(data.meta_info.resource_team)
})
return options
})
// 获取视频编码过滤选项
const getVideoCodeFilterOptions = computed(() => {
const options: string[] = [];
const options: string[] = []
dataList.value.forEach((data) => {
if (data.meta_info.video_encode && !options.includes(data.meta_info.video_encode)) {
options.push(data.meta_info.video_encode);
}
});
return options;
});
if (data.meta_info.video_encode && !options.includes(data.meta_info.video_encode))
options.push(data.meta_info.video_encode)
})
return options
})
// 获取促销状态过滤选项
const getFreeStateFilterOptions = computed(() => {
const options: string[] = [];
const options: string[] = []
dataList.value.forEach((data) => {
if (
data.torrent_info.volume_factor &&
!options.includes(data.torrent_info.volume_factor)
) {
options.push(data.torrent_info.volume_factor);
}
});
return options;
});
data.torrent_info.volume_factor
&& !options.includes(data.torrent_info.volume_factor)
)
options.push(data.torrent_info.volume_factor)
})
return options
})
// 获取质量过滤选项
const getEditionFilterOptions = computed(() => {
const options: string[] = [];
const options: string[] = []
dataList.value.forEach((data) => {
if (data.meta_info.edition && !options.includes(data.meta_info.edition)) {
options.push(data.meta_info.edition);
}
});
return options;
});
if (data.meta_info.edition && !options.includes(data.meta_info.edition))
options.push(data.meta_info.edition)
})
return options
})
// 按过滤项过滤卡片
const filterTorrentsCard = (data: Context) => {
function filterTorrentsCard(data: Context) {
// 当前分组的所有数据
const items: Context[] =
groupedDataList.value.get(`${data.torrent_info.title}_${data.torrent_info.size}`) ??
[];
const items: Context[]
= groupedDataList.value.get(`${data.torrent_info.title}_${data.torrent_info.size}`)
?? []
// 站点名称、促销状态
let site_names = [];
let volume_factors = [];
const site_names = []
const volume_factors = []
for (const { torrent_info } of items) {
site_names.push(torrent_info.site_name);
volume_factors.push(torrent_info.volume_factor);
site_names.push(torrent_info.site_name)
volume_factors.push(torrent_info.volume_factor)
}
const { meta_info } = data;
const { meta_info } = data
// 季、制作组、视频编码
const { season_episode, resource_team, video_encode } = meta_info;
const { season_episode, resource_team, video_encode } = meta_info
// 站点过滤
if (filterForm.site.length > 0 && !isIntersected(filterForm.site, site_names)) {
return false;
}
if (filterForm.site.length > 0 && !isIntersected(filterForm.site, site_names))
return false
// 促销状态过滤
if (
filterForm.freeState.length > 0 &&
!isIntersected(filterForm.freeState, volume_factors)
) {
return false;
}
filterForm.freeState.length > 0
&& !isIntersected(filterForm.freeState, volume_factors)
)
return false
// 季过滤
if (filterForm.season.length > 0 && !filterForm.season.includes(season_episode)) {
return false;
}
if (filterForm.season.length > 0 && !filterForm.season.includes(season_episode))
return false
// 制作组过滤
if (
filterForm.releaseGroup.length > 0 &&
!filterForm.releaseGroup.includes(resource_team || "")
) {
return false;
}
filterForm.releaseGroup.length > 0
&& !filterForm.releaseGroup.includes(resource_team || '')
)
return false
// 视频编码过滤
if (
filterForm.videoCode.length > 0 &&
!filterForm.videoCode.includes(video_encode || "")
) {
return false;
}
filterForm.videoCode.length > 0
&& !filterForm.videoCode.includes(video_encode || '')
)
return false
// 质量过滤
if (filterForm.edition.length > 0 && !filterForm.edition.includes(meta_info.edition)) {
return false;
}
return true;
};
return !(filterForm.edition.length > 0 && !filterForm.edition.includes(meta_info.edition))
}
// 获取订阅列表数据
const fetchData = async () => {
async function fetchData() {
try {
let keyword = props.keyword ?? "";
let mtype = props.type ?? "";
const keyword = props.keyword ?? ''
const mtype = props.type ?? ''
if (!keyword) {
// 查询上次搜索结果
dataList.value = await api.get("search/last");
} else {
startLoadingProgress();
dataList.value = await api.get('search/last')
}
else {
startLoadingProgress()
// 优先按TMDBID精确查询
if (props.keyword?.startsWith("tmdb:") || props.keyword?.startsWith("douban:")) {
if (props.keyword?.startsWith('tmdb:') || props.keyword?.startsWith('douban:')) {
dataList.value = await api.get(`search/media/${props.keyword}`, {
params: {
mtype,
},
});
} else {
// 按标题模糊查询
dataList.value = await api.get(`search/title/${props.keyword}`);
})
}
stopLoadingProgress();
else {
// 按标题模糊查询
dataList.value = await api.get(`search/title/${props.keyword}`)
}
stopLoadingProgress()
}
isRefreshed.value = true;
} catch (error) {
console.error(error);
isRefreshed.value = true
}
};
catch (error) {
console.error(error)
}
}
// 按标题和大小分组
const groupByTitleAndSize = (contextArray: Context[]): Map<string, Context[]> => {
const groupMap = new Map<string, Context[]>();
function groupByTitleAndSize(contextArray: Context[]): Map<string, Context[]> {
const groupMap = new Map<string, Context[]>()
for (const context of contextArray) {
const { torrent_info } = context;
const key = `${torrent_info.title}_${torrent_info.size}`;
const { torrent_info } = context
const key = `${torrent_info.title}_${torrent_info.size}`
if (groupMap.has(key)) {
// 已存在相同标题和大小的分组,将当前上下文信息添加到分组中
const group = groupMap.get(key);
group?.push(context);
} else {
const group = groupMap.get(key)
group?.push(context)
}
else {
// 创建新的分组,并将当前上下文信息添加到分组中
groupMap.set(key, [context]);
groupMap.set(key, [context])
}
}
return groupMap;
};
return groupMap
}
// 获取每个分组的第一个数据
const getFirstContexts = computed(() => {
const firstContexts: Context[] = [];
const firstContexts: Context[] = []
groupedDataList.value.forEach((group) => {
if (group.length > 0) {
firstContexts.push(group[0]);
}
});
if (group.length > 0)
firstContexts.push(group[0])
})
return firstContexts;
});
return firstContexts
})
// 使用SSE监听加载进度
const startLoadingProgress = () => {
progressText.value = "正在搜索,请稍候...";
const token = store.state.auth.token;
function startLoadingProgress() {
progressText.value = '正在搜索,请稍候...'
const token = store.state.auth.token
progressEventSource.value = new EventSource(
`${import.meta.env.VITE_API_BASE_URL}system/progress/search?token=${token}`
);
`${import.meta.env.VITE_API_BASE_URL}system/progress/search?token=${token}`,
)
progressEventSource.value.onmessage = (event) => {
const progress = JSON.parse(event.data);
const progress = JSON.parse(event.data)
if (progress) {
progressText.value = progress.text;
progressValue.value = progress.value;
progressText.value = progress.text
progressValue.value = progress.value
}
};
};
}
}
// 停止监听加载进度
const stopLoadingProgress = () => {
progressEventSource.value?.close();
};
function stopLoadingProgress() {
progressEventSource.value?.close()
}
// 加载时获取数据
onBeforeMount(fetchData);
onBeforeMount(fetchData)
</script>
<template>
<VCard class="bg-transparent mb-3 pt-2 shadow-none">
<VRow>
<VCol v-if="getSiteFilterOptions.length > 0" cols="6" md="">
<VCol
v-if="getSiteFilterOptions.length > 0"
cols="6"
md=""
>
<VSelect
v-model="filterForm.site"
:items="getSiteFilterOptions"
@@ -284,7 +300,11 @@ onBeforeMount(fetchData);
multiple
/>
</VCol>
<VCol v-if="getSeasonFilterOptions.length > 0" cols="6" md="">
<VCol
v-if="getSeasonFilterOptions.length > 0"
cols="6"
md=""
>
<VSelect
v-model="filterForm.season"
:items="getSeasonFilterOptions"
@@ -295,7 +315,11 @@ onBeforeMount(fetchData);
multiple
/>
</VCol>
<VCol v-if="getReleaseGroupFilterOptions.length > 0" cols="6" md="">
<VCol
v-if="getReleaseGroupFilterOptions.length > 0"
cols="6"
md=""
>
<VSelect
v-model="filterForm.releaseGroup"
:items="getReleaseGroupFilterOptions"
@@ -306,7 +330,11 @@ onBeforeMount(fetchData);
multiple
/>
</VCol>
<VCol v-if="getEditionFilterOptions.length > 0" cols="6" md="">
<VCol
v-if="getEditionFilterOptions.length > 0"
cols="6"
md=""
>
<VSelect
v-model="filterForm.edition"
:items="getEditionFilterOptions"
@@ -317,7 +345,11 @@ onBeforeMount(fetchData);
multiple
/>
</VCol>
<VCol v-if="getVideoCodeFilterOptions.length > 0" cols="6" md="">
<VCol
v-if="getVideoCodeFilterOptions.length > 0"
cols="6"
md=""
>
<VSelect
v-model="filterForm.videoCode"
:items="getVideoCodeFilterOptions"
@@ -328,7 +360,11 @@ onBeforeMount(fetchData);
multiple
/>
</VCol>
<VCol v-if="getFreeStateFilterOptions.length > 0" cols="6" md="">
<VCol
v-if="getFreeStateFilterOptions.length > 0"
cols="6"
md=""
>
<VSelect
v-model="filterForm.freeState"
:items="getFreeStateFilterOptions"
@@ -342,14 +378,14 @@ onBeforeMount(fetchData);
</VRow>
</VCard>
<VProgressCircular
class="centered"
v-if="!isRefreshed && !props.keyword"
class="centered"
indeterminate
color="primary"
></VProgressCircular>
/>
<div
class="top-centered mt-12 w-full text-center text-gray-500 text-sm flex flex-col items-center"
v-if="!isRefreshed && props.keyword"
class="top-centered mt-12 w-full text-center text-gray-500 text-sm flex flex-col items-center"
>
<VProgressCircular
class="mb-3"
@@ -357,10 +393,13 @@ onBeforeMount(fetchData);
:model-value="progressValue"
size="64"
width="7"
></VProgressCircular>
/>
<span>{{ progressText }}</span>
</div>
<div class="grid gap-3 grid-torrent-card items-start" v-if="dataList.length > 0">
<div
v-if="dataList.length > 0"
class="grid gap-3 grid-torrent-card items-start"
>
<TorrentCard
v-for="data in getFirstContexts"
v-show="filterTorrentsCard(data)"
@@ -378,11 +417,10 @@ onBeforeMount(fetchData);
error-code="404"
error-title="没有资源"
error-description="没有搜索到符合条件的资源"
>
</NoDataFound>
/>
</template>
<style type="scss">
<style lang="scss">
.grid-torrent-card {
grid-template-columns: repeat(auto-fill, minmax(20rem, 1fr));
padding-block-end: 1rem;

View File

@@ -1,82 +1,92 @@
<script lang="ts" setup>
import api from "@/api";
import type { DownloadingInfo } from "@/api/types";
import NoDataFound from "@/components/NoDataFound.vue";
import DownloadingCard from "@/components/cards/DownloadingCard.vue";
import PullRefresh from "pull-refresh-vue3";
import PullRefresh from 'pull-refresh-vue3'
import api from '@/api'
import type { DownloadingInfo } from '@/api/types'
import NoDataFound from '@/components/NoDataFound.vue'
import DownloadingCard from '@/components/cards/DownloadingCard.vue'
// 定时器
let refreshTimer: NodeJS.Timer | null = null;
let refreshTimer: NodeJS.Timer | null = null
// 数据列表
const dataList = ref<DownloadingInfo[]>([]);
// 获取订阅列表数据
const fetchData = async () => {
try {
dataList.value = await api.get("download");
isRefreshed.value = true;
} catch (error) {
console.error(error);
}
};
// 刷新状态
const loading = ref(false);
const dataList = ref<DownloadingInfo[]>([])
// 是否刷新过
const isRefreshed = ref(false);
const isRefreshed = ref(false)
// 获取订阅列表数据
async function fetchData() {
try {
dataList.value = await api.get('download')
isRefreshed.value = true
}
catch (error) {
console.error(error)
}
}
// 刷新状态
const loading = ref(false)
// 下拉刷新
const onRefresh = () => {
loading.value = true;
fetchData();
loading.value = false;
};
function onRefresh() {
loading.value = true
fetchData()
loading.value = false
}
// 加载时获取数据
onBeforeMount(() => {
fetchData();
fetchData()
// 启动定时器
refreshTimer = setInterval(() => {
fetchData();
}, 3000);
});
fetchData()
}, 3000)
})
// 组件卸载时停止定时器
onUnmounted(() => {
if (refreshTimer) {
clearInterval(refreshTimer);
refreshTimer = null;
clearInterval(refreshTimer)
refreshTimer = null
}
});
})
</script>
<template>
<VProgressCircular
class="centered"
v-if="!isRefreshed"
class="centered"
indeterminate
color="primary"
></VProgressCircular>
<PullRefresh v-model="loading" @refresh="onRefresh">
<div class="grid gap-3 grid-downloading-card" v-if="dataList.length > 0">
<DownloadingCard v-for="data in dataList" :key="data.hash" :info="data" />
/>
<PullRefresh
v-model="loading"
@refresh="onRefresh"
>
<div
v-if="dataList.length > 0"
class="grid gap-3 grid-downloading-card"
>
<DownloadingCard
v-for="data in dataList"
:key="data.hash"
:info="data"
/>
</div>
<NoDataFound
v-if="dataList.length === 0 && isRefreshed"
error-code="404"
error-title="没有任务"
error-description="正在下载的任务将会显示在这里"
>
</NoDataFound>
/>
</PullRefresh>
</template>
<style type="scss">
<style lang="scss">
.grid-downloading-card {
grid-template-columns: repeat(auto-fill, minmax(20rem, 1fr));
padding-block-end: 1rem;
}
</style>

View File

@@ -1,198 +1,209 @@
<script setup lang="ts">
import { numberValidator, requiredValidator } from "@/@validators";
import api from "@/api";
import type { TransferHistory } from "@/api/types";
import { ref } from "vue";
import { useToast } from "vue-toast-notification";
import { useConfirm } from "vuetify-use-dialog";
import { ref } from 'vue'
import { useToast } from 'vue-toast-notification'
import { useConfirm } from 'vuetify-use-dialog'
import { numberValidator, requiredValidator } from '@/@validators'
import api from '@/api'
import type { TransferHistory } from '@/api/types'
// 确认框
const createConfirm = useConfirm();
const createConfirm = useConfirm()
// 提示框
const $toast = useToast();
const $toast = useToast()
// 重新整理对话框
const redoDialog = ref(false);
const redoDialog = ref(false)
// TMDB编号
const redoTmdbId = ref("");
const redoTmdbId = ref('')
// 当前操作记录
const currentHistory = ref<TransferHistory>();
const currentHistory = ref<TransferHistory>()
// 表头
const headers = [
{ title: "标题", key: "title", sortable: false },
{ title: "目录", key: "src", sortable: false },
{ title: "转移方式", key: "mode", sortable: false },
{ title: "时间", key: "date", sortable: false },
{ title: "状态", key: "status", sortable: false },
{ title: "失败原因", key: "errmsg", sortable: false },
{ title: "", key: "actions", sortable: false },
];
{ title: '标题', key: 'title', sortable: false },
{ title: '目录', key: 'src', sortable: false },
{ title: '转移方式', key: 'mode', sortable: false },
{ title: '时间', key: 'date', sortable: false },
{ title: '状态', key: 'status', sortable: false },
{ title: '失败原因', key: 'errmsg', sortable: false },
{ title: '', key: 'actions', sortable: false },
]
// 数据列表
const dataList = ref<TransferHistory[]>([]);
const dataList = ref<TransferHistory[]>([])
// 搜索
const search = ref("");
const search = ref('')
// 加载状态
const loading = ref(false);
const loading = ref(false)
// 总条数
const totalItems = ref(0);
const totalItems = ref(0)
// 每页条数
const itemsPerPage = ref(25);
const itemsPerPage = ref(25)
// 当前页码
const currentPage = ref(1);
const currentPage = ref(1)
// 获取订阅列表数据
const fetchData = async ({
async function fetchData({
page,
itemsPerPage,
}: {
page: number;
itemsPerPage: number;
}) => {
loading.value = true;
page: number
itemsPerPage: number
}) {
loading.value = true
try {
currentPage.value = page;
const result: { [key: string]: any } = await api.get("history/transfer", {
currentPage.value = page
const result: { [key: string]: any } = await api.get('history/transfer', {
params: {
page,
count: itemsPerPage,
title: search.value,
},
});
dataList.value = result.data.list;
totalItems.value = result.data.total;
} catch (error) {
console.error(error);
})
dataList.value = result.data.list
totalItems.value = result.data.total
}
loading.value = false;
};
catch (error) {
console.error(error)
}
loading.value = false
}
// 根据 type 返回不同的图标
const getIcon = (type: string) => {
if (type === "电影") {
return "mdi-movie";
} else if (type === "电视剧") {
return "mdi-television-classic";
} else {
return "mdi-help-circle";
}
};
function getIcon(type: string) {
if (type === '电影')
return 'mdi-movie'
else if (type === '电视剧')
return 'mdi-television-classic'
else
return 'mdi-help-circle'
}
// 计算颜色
const getStatusColor = (status: boolean) => {
return status ? "success" : "error";
};
function getStatusColor(status: boolean) {
return status ? 'success' : 'error'
}
// 转移方式字典
const TransferDict: { [key: string]: string } = {
copy: "复制",
move: "移动",
link: "硬链接",
softlink: "软链接",
};
copy: '复制',
move: '移动',
link: '硬链接',
softlink: '软链接',
}
// 删除历史记录
const removeHistory = async (item: TransferHistory) => {
async function removeHistory(item: TransferHistory) {
try {
const isConfirmed = await createConfirm({
title: "确认",
title: '确认',
content: `同步删除 ${item.title} 对应的媒体库文件 ?`,
confirmationText: "同步删除文件",
cancellationText: "仅删除历史记录",
confirmationText: '同步删除文件',
cancellationText: '仅删除历史记录',
dialogProps: {
maxWidth: 600,
},
});
let deleteFile = false;
if (isConfirmed) {
deleteFile = true;
}
})
let deleteFile = false
if (isConfirmed)
deleteFile = true
// 调用删除API
const result: { [key: string]: any } = await api.delete("history/transfer", {
const result: { [key: string]: any } = await api.delete('history/transfer', {
data: {
...item,
delete_file: deleteFile,
},
});
})
if (result.success) {
fetchData({
page: currentPage.value,
itemsPerPage: itemsPerPage.value,
});
} else {
$toast.error(`删除失败: ${result.msg}`);
})
}
else {
$toast.error(`删除失败: ${result.msg}`)
}
} catch (error) {
console.error(error);
}
};
catch (error) {
console.error(error)
}
}
// 重新整理
const rehandleHistory = async () => {
async function rehandleHistory() {
try {
if (!redoTmdbId.value) {
return;
}
redoDialog.value = false;
$toast.info(`正在重新整理 ${currentHistory.value?.title} ...`);
if (!redoTmdbId.value)
return
redoDialog.value = false
$toast.info(`正在重新整理 ${currentHistory.value?.title} ...`)
// 调用API接口重新转移
const requestData = {
...currentHistory.value,
};
}
const result: { [key: string]: any } = await api.post(
"history/transfer",
'history/transfer',
requestData,
{
params: {
new_tmdbid: parseInt(redoTmdbId.value),
},
}
);
},
)
if (result.success) {
fetchData({
page: currentPage.value,
itemsPerPage: itemsPerPage.value,
});
} else {
$toast.error(`重新整理失败: ${result.message}`);
})
}
else {
$toast.error(`重新整理失败: ${result.message}`)
}
} catch (e) {
console.log(e);
}
};
catch (e) {
console.log(e)
}
}
// 弹出菜单
const dropdownItems = ref([
{
title: "重新整理",
title: '重新整理',
value: 1,
props: {
prependIcon: "mdi-redo-variant",
prependIcon: 'mdi-redo-variant',
click: (item: TransferHistory) => {
redoDialog.value = true;
currentHistory.value = item;
redoDialog.value = true
currentHistory.value = item
},
},
},
{
title: "删除",
title: '删除',
value: 2,
props: {
prependIcon: "mdi-trash-can-outline",
color: "error",
prependIcon: 'mdi-trash-can-outline',
color: 'error',
click: removeHistory,
},
},
]);
])
</script>
<template>
@@ -226,17 +237,17 @@ const dropdownItems = ref([
:items-length="totalItems"
:search="search"
:loading="loading"
@update:options="fetchData"
density="compact"
item-value="id"
return-object
fixed-header
items-per-page-text="每页条数"
page-text="{0}-{1} {2} "
@update:options="fetchData"
>
<template #item.title="{ item }">
<div class="d-flex">
<VAvatar><VIcon :icon="getIcon(item.raw.type || '')"></VIcon></VAvatar>
<VAvatar><VIcon :icon="getIcon(item.raw.type || '')" /></VAvatar>
<div class="d-flex flex-column ms-1">
<span class="d-block whitespace-nowrap text-high-emphasis">
{{ item.raw.title }} {{ item.raw.seasons }}{{ item.raw.episodes }}
@@ -246,15 +257,24 @@ const dropdownItems = ref([
</div>
</template>
<template #item.src="{ item }">
<small>{{ item.raw.src }} <br />=> {{ item.raw.dest }}</small>
<small>{{ item.raw.src }} <br>=> {{ item.raw.dest }}</small>
</template>
<template #item.mode="{ item }">
<VChip variant="outlined" color="primary" size="small">{{
TransferDict[item.raw.mode]
}}</VChip>
<VChip
variant="outlined"
color="primary"
size="small"
>
{{
TransferDict[item.raw.mode]
}}
</VChip>
</template>
<template #item.status="{ item }">
<VChip :color="getStatusColor(item.raw.status)" size="small">
<VChip
:color="getStatusColor(item.raw.status)"
size="small"
>
{{ item.raw.status ? "成功" : "失败" }}
</VChip>
</template>
@@ -267,28 +287,36 @@ const dropdownItems = ref([
<template #item.actions="{ item }">
<IconBtn>
<VIcon icon="mdi-dots-vertical" />
<VMenu activator="parent" close-on-content-click>
<VMenu
activator="parent"
close-on-content-click
>
<VList>
<VListItem
v-for="(menu, i) in dropdownItems"
:key="i"
variant="plain"
:base-color="menu.props.color"
:key="i"
@click="menu.props.click(item.raw)"
>
<template #prepend>
<VIcon :icon="menu.props.prependIcon"></VIcon>
<VIcon :icon="menu.props.prependIcon" />
</template>
<VListItemTitle v-text="menu.title"></VListItemTitle>
<VListItemTitle v-text="menu.title" />
</VListItem>
</VList>
</VMenu>
</IconBtn>
</template>
<template #no-data> 没有数据 </template>
<template #no-data>
没有数据
</template>
</VDataTableServer>
</VCard>
<VDialog v-model="redoDialog" max-width="600">
<VDialog
v-model="redoDialog"
max-width="600"
>
<!-- Dialog Content -->
<VCard title="重新整理">
<VCardText>
@@ -305,13 +333,18 @@ const dropdownItems = ref([
<VCardActions>
<VSpacer />
<VBtn @click="rehandleHistory" @keydown.enter="rehandleHistory"> 确定 </VBtn>
<VBtn
@click="rehandleHistory"
@keydown.enter="rehandleHistory"
>
确定
</VBtn>
</VCardActions>
</VCard>
</VDialog>
</template>
<style type="scss">
<style lang="scss">
.v-table th {
white-space: nowrap;
}

View File

@@ -1,62 +1,66 @@
<script lang="ts" setup>
import api from "@/api";
import { Plugin } from "@/api/types";
import NoDataFound from "@/components/NoDataFound.vue";
import PluginAppCard from "@/components/cards/PluginAppCard.vue";
import PluginCard from "@/components/cards/PluginCard.vue";
import api from '@/api'
import type { Plugin } from '@/api/types'
import NoDataFound from '@/components/NoDataFound.vue'
import PluginAppCard from '@/components/cards/PluginAppCard.vue'
import PluginCard from '@/components/cards/PluginCard.vue'
// 数据列表
const dataList = ref<Plugin[]>([]);
const dataList = ref<Plugin[]>([])
// 是否刷新过
const isRefreshed = ref(false);
const isRefreshed = ref(false)
// APP市场窗口
const PluginAppDialog = ref(false);
const PluginAppDialog = ref(false)
// 获取已安装的插件列表
const getInstalledPluginList = computed(() => {
return dataList.value.filter((item) => item.installed);
});
return dataList.value.filter(item => item.installed)
})
// 获取未安装的插件列表
const getUninstalledPluginList = computed(() => {
return dataList.value.filter((item) => !item.installed);
});
return dataList.value.filter(item => !item.installed)
})
// 关闭插件市场窗口
const pluginDialogClose = () => {
PluginAppDialog.value = false;
};
function pluginDialogClose() {
PluginAppDialog.value = false
}
// 新安装了插件
const pluginInstalled = () => {
fetchData();
pluginDialogClose();
};
function pluginInstalled() {
fetchData()
pluginDialogClose()
}
// 获取插件列表数据
const fetchData = async () => {
async function fetchData() {
try {
dataList.value = await api.get("plugin");
isRefreshed.value = true;
} catch (error) {
console.error(error);
dataList.value = await api.get('plugin')
isRefreshed.value = true
}
};
catch (error) {
console.error(error)
}
}
// 加载时获取数据
onBeforeMount(fetchData);
onBeforeMount(fetchData)
</script>
<template>
<VProgressCircular
class="centered"
v-if="!isRefreshed"
class="centered"
indeterminate
color="primary"
></VProgressCircular>
<div class="grid gap-3 grid-plugin-card" v-if="dataList.length > 0">
/>
<div
v-if="dataList.length > 0"
class="grid gap-3 grid-plugin-card"
>
<PluginCard
v-for="data in getInstalledPluginList"
:key="data.id"
@@ -69,8 +73,7 @@ onBeforeMount(fetchData);
error-code="404"
error-title="没有安装插件"
error-description="点击右下角按钮前往插件市场安装插件"
>
</NoDataFound>
/>
<!-- App市场 -->
<VDialog
v-model="PluginAppDialog"
@@ -80,8 +83,12 @@ onBeforeMount(fetchData);
>
<!-- Dialog Activator -->
<template #activator="{ props }">
<VBtn icon="mdi-plus" v-bind="props" size="x-large" class="fixed right-5 bottom-5">
</VBtn>
<VBtn
icon="mdi-plus"
v-bind="props"
size="x-large"
class="fixed right-5 bottom-5"
/>
</template>
<!-- Dialog Content -->
@@ -94,8 +101,14 @@ onBeforeMount(fetchData);
<VSpacer />
<VToolbarItems>
<VBtn size="x-large" @click="pluginDialogClose">
<VIcon color="white" icon="mdi-close" />
<VBtn
size="x-large"
@click="pluginDialogClose"
>
<VIcon
color="white"
icon="mdi-close"
/>
</VBtn>
</VToolbarItems>
</VToolbar>
@@ -114,14 +127,13 @@ onBeforeMount(fetchData);
error-code="404"
error-title="没有未安装插件"
error-description="所有可用插件均已安装"
>
</NoDataFound>
/>
</div>
</VCard>
</VDialog>
</template>
<style type="scss">
<style lang="scss">
.grid-plugin-card {
grid-template-columns: repeat(auto-fill, minmax(18rem, 1fr));
padding-block-end: 1rem;

View File

@@ -1,8 +1,8 @@
<script lang="ts" setup>
import api from '@/api';
import { Site } from '@/api/types';
import SiteCard from '@/components/cards/SiteCard.vue';
import NoDataFound from "@/components/NoDataFound.vue";
import api from '@/api'
import type { Site } from '@/api/types'
import SiteCard from '@/components/cards/SiteCard.vue'
import NoDataFound from '@/components/NoDataFound.vue'
// 数据列表
const dataList = ref<Site[]>([])
@@ -11,11 +11,12 @@ const dataList = ref<Site[]>([])
const isRefreshed = ref(false)
// 获取站点列表数据
const fetchData = async () => {
async function fetchData() {
try {
dataList.value = await api.get('site')
isRefreshed.value = true
} catch (error) {
}
catch (error) {
console.error(error)
}
}
@@ -26,14 +27,14 @@ onBeforeMount(fetchData)
<template>
<VProgressCircular
class="centered"
v-if="!isRefreshed"
class="centered"
indeterminate
color="primary"
></VProgressCircular>
/>
<div
class="grid gap-3 grid-site-card"
v-if="dataList.length > 0"
class="grid gap-3 grid-site-card"
>
<SiteCard
v-for="data in dataList"
@@ -46,11 +47,10 @@ onBeforeMount(fetchData)
error-code="404"
error-title="没有站点"
error-description="已添加并支持的站点将会在这里显示"
>
</NoDataFound>
/>
</template>
<style type="scss">
<style lang="scss">
.grid-site-card {
grid-template-columns: repeat(auto-fill, minmax(20rem, 1fr));
padding-block-end: 1rem;

View File

@@ -1,102 +1,99 @@
<script lang="ts" setup>
import { parseDate } from "@/@core/utils/formatters";
import api from "@/api";
import type { MediaInfo, Subscribe, TmdbEpisode } from "@/api/types";
import dayGridPlugin from "@fullcalendar/daygrid";
import interactionPlugin from "@fullcalendar/interaction";
import timeGridPlugin from "@fullcalendar/timegrid";
import FullCalendar from "@fullcalendar/vue3";
import type { CalendarOptions } from '@fullcalendar/core'
import dayGridPlugin from '@fullcalendar/daygrid'
import interactionPlugin from '@fullcalendar/interaction'
import timeGridPlugin from '@fullcalendar/timegrid'
import FullCalendar from '@fullcalendar/vue3'
import type { Ref } from 'vue'
import type { MediaInfo, Subscribe, TmdbEpisode } from '@/api/types'
import api from '@/api'
import { parseDate } from '@/@core/utils/formatters'
// 日历属性
const calendarOptions = ref({
height: "auto",
locale: "zh-cn",
themeSystem: "standard",
const calendarOptions: Ref<CalendarOptions> = ref({
height: 'auto',
locale: 'zh-cn',
themeSystem: 'standard',
buttonText: {
today: "今天",
month: "月",
week: "周",
day: "日",
list: "列表",
today: '今天',
month: '月',
week: '周',
day: '日',
list: '列表',
},
plugins: [
dayGridPlugin,
timeGridPlugin,
interactionPlugin, // needed for dateClick
],
initialView: "dayGridMonth",
initialView: 'dayGridMonth',
weekends: false,
headerToolbar: {
left: "prev",
center: "title",
right: "next",
left: 'prev',
center: 'title',
right: 'next',
},
views: {
week: {
titleFormat: { day: "numeric" },
titleFormat: { day: 'numeric' },
},
},
events: [],
});
})
// 调用API查询所有订阅
const getSubscribes = async () => {
async function getSubscribes() {
try {
const subscribes: Subscribe[] = await api.get("subscribe");
const subscribes: Subscribe[] = await api.get('subscribe')
const events = await Promise.all(
subscribes.map(async (subscribe) => {
// 如果是电影直接返回
if (subscribe.type === "电影") {
if (subscribe.type === '电影') {
// 调用API查询TMDB详情
const movie: MediaInfo = await api.get(`tmdb/${subscribe.tmdbid}`, {
params: { tmdbid: subscribe.tmdbid, type_name: subscribe.type },
});
})
return {
title: subscribe.name,
start: parseDate(movie.release_date || ""),
start: parseDate(movie.release_date || ''),
allDay: false,
posterPath: subscribe.poster,
mediaType: subscribe.type,
};
} else {
}
}
else {
// 调用API查询集信息
const episodes: TmdbEpisode[] = await api.get(
`tmdb/${subscribe.tmdbid}/${subscribe.season}`
);
`tmdb/${subscribe.tmdbid}/${subscribe.season}`,
)
return episodes.map((episode) => {
return {
title: subscribe.name,
subtitle: `${episode.episode_number}`,
start: parseDate(episode.air_date || ""),
start: parseDate(episode.air_date || ''),
allDay: false,
posterPath: subscribe.poster,
mediaType: subscribe.type,
};
});
}
})
}
})
);
calendarOptions.value.events = events.flat();
} catch (error) {
console.error(error);
}
};
}),
)
// 根据 type 返回不同的图标
const getIcon = (type: string) => {
if (type === "电影") {
return "mdi-movie-roll";
} else if (type === "电视剧") {
return "mdi-television-box";
} else {
return "mdi-help-circle";
calendarOptions.value.events = events.flat()
}
};
catch (error) {
console.error(error)
}
}
// 页面加载时调用API查询所有订阅
onMounted(() => {
getSubscribes();
});
getSubscribes()
})
</script>
<template>
@@ -117,7 +114,9 @@ onMounted(() => {
</div>
<VDivider :vertical="$vuetify.display.mdAndUp" />
<div>
<VCardSubtitle class="pa-2 font-bold">{{ arg.event.title }}</VCardSubtitle>
<VCardSubtitle class="pa-2 font-bold">
{{ arg.event.title }}
</VCardSubtitle>
<VCardText class="pa-0 px-2">
{{ arg.event.extendedProps.subtitle }}
</VCardText>

View File

@@ -1,73 +1,82 @@
<script lang="ts" setup>
import api from "@/api";
import type { Subscribe } from "@/api/types";
import NoDataFound from "@/components/NoDataFound.vue";
import SubscribeCard from "@/components/cards/SubscribeCard.vue";
import PullRefresh from 'pull-refresh-vue3';
import PullRefresh from 'pull-refresh-vue3'
import api from '@/api'
import type { Subscribe } from '@/api/types'
import NoDataFound from '@/components/NoDataFound.vue'
import SubscribeCard from '@/components/cards/SubscribeCard.vue'
// 输入参数
const props = defineProps({
type: String,
});
})
// 是否刷新过
const isRefreshed = ref(false)
// 数据列表
const dataList = ref<Subscribe[]>([]);
const dataList = ref<Subscribe[]>([])
// 获取订阅列表数据
const fetchData = async () => {
async function fetchData() {
try {
dataList.value = await api.get("subscribe");
dataList.value = await api.get('subscribe')
isRefreshed.value = true
} catch (error) {
console.error(error);
}
};
catch (error) {
console.error(error)
}
}
// 加载时获取数据
onBeforeMount(fetchData);
onBeforeMount(fetchData)
// 刷新状态
const loading = ref(false);
const loading = ref(false)
// 下拉刷新
const onRefresh = () => {
loading.value = true;
fetchData();
loading.value = false;
};
function onRefresh() {
loading.value = true
fetchData()
loading.value = false
}
// 过滤数据
const filteredDataList = computed(() => {
return dataList.value.filter((data) => data.type === props.type);
});
return dataList.value.filter(data => data.type === props.type)
})
</script>
<template>
<VProgressCircular
class="centered"
v-if="!isRefreshed"
class="centered"
indeterminate
color="primary"
></VProgressCircular>
<PullRefresh v-model="loading" @refresh="onRefresh">
<div class="grid gap-3 grid-subscribe-card"
v-if="filteredDataList.length > 0">
<SubscribeCard v-for="data in filteredDataList" :key="data.id" :media="data" />
/>
<PullRefresh
v-model="loading"
@refresh="onRefresh"
>
<div
v-if="filteredDataList.length > 0"
class="grid gap-3 grid-subscribe-card"
>
<SubscribeCard
v-for="data in filteredDataList"
:key="data.id"
:media="data"
/>
</div>
<NoDataFound
v-if="filteredDataList.length === 0 && isRefreshed"
error-code="404"
error-title="没有订阅"
error-description="请通过搜索添加电影电视剧订阅"
>
</NoDataFound>
/>
</PullRefresh>
</template>
<style type="scss">
<style lang="scss">
.grid-subscribe-card {
grid-template-columns: repeat(auto-fill, minmax(20rem, 1fr));
padding-block-end: 1rem;

View File

@@ -1,48 +1,50 @@
<script setup lang="ts">
import { requiredValidator } from "@/@validators";
import api from "@/api";
import { Context } from "@/api/types";
import { reactive, ref } from "vue";
import { reactive, ref } from 'vue'
import { requiredValidator } from '@/@validators'
import api from '@/api'
import type { Context } from '@/api/types'
// 识别结果
const nameTestResult = ref<Context>();
const nameTestResult = ref<Context>()
// 名称识别表单
const nameTestForm = reactive({
title: "",
subtitle: "",
});
title: '',
subtitle: '',
})
// 识别按钮状态
const nameTestLoading = ref(false);
const nameTestLoading = ref(false)
// 识别按钮文本
const nameTestText = ref("识别");
const nameTestText = ref('识别')
// 是否显示结果
const showResult = ref(false);
const showResult = ref(false)
// 调用API识别
const nameTest = async () => {
if (!nameTestForm.title) {
return;
}
async function nameTest() {
if (!nameTestForm.title)
return
try {
nameTestLoading.value = true;
nameTestText.value = "识别中...";
showResult.value = false;
nameTestResult.value = await api.get("media/recognize", {
nameTestLoading.value = true
nameTestText.value = '识别中...'
showResult.value = false
nameTestResult.value = await api.get('media/recognize', {
params: {
title: nameTestForm.title,
subtitle: nameTestForm.subtitle,
},
});
nameTestLoading.value = false;
nameTestText.value = "重新识别";
showResult.value = true;
} catch (error) {
console.error(error);
})
nameTestLoading.value = false
nameTestText.value = '重新识别'
showResult.value = true
}
};
catch (error) {
console.error(error)
}
}
</script>
<template>
@@ -56,14 +58,25 @@ const nameTest = async () => {
/>
</VCol>
<VCol cols="12">
<VTextarea v-model="nameTestForm.subtitle" label="副标题" rows="2" auto-grow />
<VTextarea
v-model="nameTestForm.subtitle"
label="副标题"
rows="2"
auto-grow
/>
</VCol>
</VRow>
<VRow>
<VCol cols="12" class="text-center">
<VBtn @click="nameTest" :disabled="nameTestLoading">
<VCol
cols="12"
class="text-center"
>
<VBtn
:disabled="nameTestLoading"
@click="nameTest"
>
<template #prepend>
<VIcon icon="mdi-text-recognition"></VIcon>
<VIcon icon="mdi-text-recognition" />
</template>
{{ nameTestText }}
</VBtn>
@@ -74,10 +87,13 @@ const nameTest = async () => {
<div v-show="showResult">
<VCol>
<div
class="d-flex justify-space-between flex-wrap flex-md-nowrap flex-column flex-md-row"
v-if="nameTestResult?.meta_info?.name"
class="d-flex justify-space-between flex-wrap flex-md-nowrap flex-column flex-md-row"
>
<div class="ma-auto" v-if="nameTestResult?.media_info?.poster_path">
<div
v-if="nameTestResult?.media_info?.poster_path"
class="ma-auto"
>
<VImg
width="10rem"
aspect-ratio="2/3"
@@ -108,28 +124,28 @@ const nameTest = async () => {
<VCardItem>
<!-- 类型 -->
<VChip
v-if="nameTestResult?.media_info?.type || nameTestResult?.meta_info?.type"
variant="elevated"
class="me-1 mb-1 text-white bg-blue-500"
v-if="nameTestResult?.media_info?.type || nameTestResult?.meta_info?.type"
>
{{
nameTestResult?.media_info?.type || nameTestResult?.meta_info?.type
}}</VChip
>
}}
</VChip>
<!-- 二级分类 -->
<VChip
v-if="nameTestResult?.media_info?.category"
variant="elevated"
class="me-1 mb-1 text-white bg-blue-500"
v-if="nameTestResult?.media_info?.category"
>
{{ nameTestResult?.media_info?.category }}
</VChip>
<!-- TMDBID -->
<VChip
v-if="nameTestResult?.media_info?.tmdb_id"
variant="elevated"
color="success"
class="me-1 mb-1"
v-if="nameTestResult?.media_info?.tmdb_id"
>
{{ nameTestResult?.media_info?.tmdb_id }}
</VChip>
@@ -139,8 +155,8 @@ const nameTest = async () => {
variant="elevated"
class="me-1 mb-1 text-white bg-red-500"
>
{{ nameTestResult?.meta_info?.edition }}</VChip
>
{{ nameTestResult?.meta_info?.edition }}
</VChip>
<VChip
v-if="nameTestResult?.meta_info?.resource_pix"
variant="elevated"
@@ -172,7 +188,10 @@ const nameTest = async () => {
</VCardItem>
</div>
</div>
<VAlert icon="mdi-alert-circle-outline" v-if="!nameTestResult?.meta_info?.name">
<VAlert
v-if="!nameTestResult?.meta_info?.name"
icon="mdi-alert-circle-outline"
>
识别失败无法识别到有效信息
</VAlert>
</VCol>

View File

@@ -1,158 +1,170 @@
<script setup lang="ts">
import api from "@/api";
import douban from "@images/logos/douban.png";
import github from "@images/logos/github.png";
import slack from "@images/logos/slack.png";
import telegram from "@images/logos/telegram.webp";
import tmdb from "@images/logos/tmdb.png";
import wechat from "@images/logos/wechat.png";
import api from '@/api'
import douban from '@images/logos/douban.png'
import github from '@images/logos/github.png'
import slack from '@images/logos/slack.png'
import telegram from '@images/logos/telegram.webp'
import tmdb from '@images/logos/tmdb.png'
import wechat from '@images/logos/wechat.png'
interface Status {
OK: string;
Fail: string;
Normal: string;
Doing?: string;
OK: string
Fail: string
Normal: string
Doing?: string
}
interface Address {
image: string;
name: string;
url: string;
proxy: boolean;
status: keyof Status;
time: string;
message: string;
btndisable: boolean;
image: string
name: string
url: string
proxy: boolean
status: keyof Status
time: string
message: string
btndisable: boolean
}
// 测试集
const targets = ref<Address[]>([
{
image: tmdb,
name: "api.themoviedb.org",
url: "https://api.themoviedb.org/3/movie/550?api_key={TMDBAPIKEY}",
name: 'api.themoviedb.org',
url: 'https://api.themoviedb.org/3/movie/550?api_key={TMDBAPIKEY}',
proxy: true,
status: "Normal",
time: "",
message: "",
status: 'Normal',
time: '',
message: '',
btndisable: false,
},
{
image: tmdb,
name: "api.tmdb.org",
url: "https://api.tmdb.org",
name: 'api.tmdb.org',
url: 'https://api.tmdb.org',
proxy: true,
status: "Normal",
time: "",
message: "未测试",
status: 'Normal',
time: '',
message: '未测试',
btndisable: false,
},
{
image: tmdb,
name: "www.themoviedb.org",
url: "https://www.themoviedb.org",
name: 'www.themoviedb.org',
url: 'https://www.themoviedb.org',
proxy: true,
status: "Normal",
time: "",
message: "未测试",
status: 'Normal',
time: '',
message: '未测试',
btndisable: false,
},
{
image: telegram,
name: "api.telegram.org",
url: "https://api.telegram.org",
name: 'api.telegram.org',
url: 'https://api.telegram.org',
proxy: true,
status: "Normal",
time: "",
message: "未测试",
status: 'Normal',
time: '',
message: '未测试',
btndisable: false,
},
{
image: wechat,
name: "qyapi.weixin.qq.com",
url: "https://qyapi.weixin.qq.com/cgi-bin/gettoken",
name: 'qyapi.weixin.qq.com',
url: 'https://qyapi.weixin.qq.com/cgi-bin/gettoken',
proxy: false,
status: "Normal",
time: "",
message: "未测试",
status: 'Normal',
time: '',
message: '未测试',
btndisable: false,
},
{
image: douban,
name: "frodo.douban.com",
url: "https://frodo.douban.com",
name: 'frodo.douban.com',
url: 'https://frodo.douban.com',
proxy: false,
status: "Normal",
time: "",
message: "未测试",
status: 'Normal',
time: '',
message: '未测试',
btndisable: false,
},
{
image: slack,
name: "slack.com",
url: "https://slack.com",
name: 'slack.com',
url: 'https://slack.com',
proxy: false,
status: "Normal",
time: "",
message: "未测试",
status: 'Normal',
time: '',
message: '未测试',
btndisable: false,
},
{
image: github,
name: "github.com",
url: "https://github.com",
name: 'github.com',
url: 'https://github.com',
proxy: true,
status: "Normal",
time: "",
message: "未测试",
status: 'Normal',
time: '',
message: '未测试',
btndisable: false,
},
]);
])
const resolveStatusColor: Status = {
OK: "success",
Fail: "error",
Normal: "",
Doing: "warning",
};
OK: 'success',
Fail: 'error',
Normal: '',
Doing: 'warning',
}
// 调用API测试网络连接
const netTest = async (index: number) => {
async function netTest(index: number) {
try {
const target = targets.value[index];
target.btndisable = true;
target.status = "Doing";
target.message = "测试中...";
const result: { [key: string]: any } = await api.get("system/nettest", {
const target = targets.value[index]
target.btndisable = true
target.status = 'Doing'
target.message = '测试中...'
const result: { [key: string]: any } = await api.get('system/nettest', {
params: {
url: target.url,
proxy: target.proxy,
},
});
})
if (result.success) {
target.status = "OK";
target.message = "正常";
} else {
target.status = "Fail";
target.message = result.message;
target.status = 'OK'
target.message = '正常'
}
target.time = result.data?.time;
target.btndisable = false;
} catch (error) {
console.error(error);
else {
target.status = 'Fail'
target.message = result.message
}
target.time = result.data?.time
target.btndisable = false
}
};
catch (error) {
console.error(error)
}
}
// 加载时测试所有连接
onMounted(async () => {
for (let i = 0; i < targets.value.length; i++) {
await netTest(i);
}
});
for (let i = 0; i < targets.value.length; i++)
await netTest(i)
})
</script>
<template>
<VList lines="two" border rounded>
<template v-for="(target, index) of targets" :key="target.name">
<VList
lines="two"
border
rounded
>
<template
v-for="(target, index) of targets"
:key="target.name"
>
<VListItem>
<template #prepend>
<VAvatar :image="target.image" />
@@ -171,7 +183,10 @@ onMounted(async () => {
<span class="ms-4">{{ target.message }}</span>
</VBadge>
<span class="text-xs text-wrap text-disabled" v-if="target.time">
<span
v-if="target.time"
class="text-xs text-wrap text-disabled"
>
{{ target.time }} ms
</span>
</VListItemSubtitle>
@@ -179,10 +194,9 @@ onMounted(async () => {
<VBtn
size="small"
icon="mdi-connection"
@click="netTest(index)"
:disabled="target.btndisable"
>
</VBtn>
@click="netTest(index)"
/>
</template>
</VListItem>
<VDivider v-if="index !== targets.length - 1" />

View File

@@ -9,7 +9,7 @@ module.exports = {
},
plugins: [
require('@tailwindcss/aspect-ratio'),
// ...
],
}

View File

@@ -1,6 +1,6 @@
import { fileURLToPath } from 'node:url'
import vue from '@vitejs/plugin-vue'
import vueJsx from '@vitejs/plugin-vue-jsx'
import { fileURLToPath } from 'node:url'
import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
import { defineConfig } from 'vite'
@@ -27,7 +27,7 @@ export default defineConfig({
imports: ['vue', 'vue-router', '@vueuse/core', '@vueuse/math', 'vuex'],
vueTemplate: true,
}),
VitePWA({ registerType: 'autoUpdate', injectRegister: 'script', manifest: false}),
VitePWA({ registerType: 'autoUpdate', injectRegister: 'script', manifest: false }),
],
define: { 'process.env': {} },
resolve: {
@@ -38,7 +38,6 @@ export default defineConfig({
'@images': fileURLToPath(new URL('./src/assets/images/', import.meta.url)),
'@styles': fileURLToPath(new URL('./src/styles/', import.meta.url)),
'@configured-variables': fileURLToPath(new URL('./src/styles/variables/_template.scss', import.meta.url)),
'@axios': fileURLToPath(new URL('./src/plugins/axios', import.meta.url)),
'apexcharts': fileURLToPath(new URL('node_modules/apexcharts-clevision', import.meta.url)),
},
},