Browse Source

feat(frontend): 添加下载队列功能

Signed-off-by: Myon <[email protected]>
Myon 3 years ago
parent
commit
ae3166ebd0

+ 8 - 0
frontend/src/api/JobApi.js

@@ -6,5 +6,13 @@ class JobApi extends BaseApi {
   start = (data) => this.http('/v1/daemon/start', data, 'POST');
 
   stop = (data) => this.http('/v1/daemon/stop', data, 'POST');
+
+  getList = () => this.http('/v1/jobs/list');
+
+  update = (id, data) => this.http(`/v1/jobs/change-job-status`, { id, ...data }, 'POST');
+
+  delete = (id) => this.http(`/v1/jobs/delete-job`, { id }, 'POST');
+
+  getLog = (id) => this.http(`/v1/jobs/log`, { id }, 'POST');
 }
 export default new JobApi();

+ 56 - 0
frontend/src/components/LogViewerRaw.vue

@@ -0,0 +1,56 @@
+<template>
+  <div class="log-viewer col bg-grey-2 overflow-auto relative-position">
+    <q-virtual-scroll ref="logArea" class="full-height q-pa-sm" :items="logLines">
+      <template v-slot="{ item, index }">
+        <div :key="index" style="white-space: nowrap; line-height: 2">
+          {{ item }}
+        </div>
+      </template>
+    </q-virtual-scroll>
+    <copy-to-clipboard-btn
+      v-if="logLines.length"
+      class="copy-btn hidden absolute-top-right q-ma-md"
+      :get-text="getTextLogs"
+    />
+  </div>
+</template>
+
+<script setup>
+// 自动滚动到底部
+import { nextTick, watch } from 'vue';
+import { templateRef } from '@vueuse/core';
+import CopyToClipboardBtn from 'components/CopyToClipboardBtn';
+
+const props = defineProps({
+  logLines: {
+    type: Array,
+    default: () => [],
+  },
+});
+
+const logArea = templateRef('logArea');
+
+const getTextLogs = () => props.logLines.join('\n');
+
+watch(
+  () => props.logLines.length,
+  () => {
+    const element = logArea.value.$el;
+    // console.log(element.scrollTop, element.clientHeight, element.scrollHeight);
+    // 如果当前正处于底部,则自动滚动
+    if (element.scrollTop + element.clientHeight >= element.scrollHeight - 10) {
+      nextTick(() => {
+        logArea.value.scrollTo(props.logLines.length - 1);
+      });
+    }
+  }
+);
+</script>
+
+<style lang="scss" scoped>
+.log-viewer:hover {
+  .copy-btn {
+    display: block !important;
+  }
+}
+</style>

+ 23 - 0
frontend/src/constants/JobConstants.js

@@ -0,0 +1,23 @@
+export const JOB_STATUS_PENDING = 0;
+export const JOB_STATUS_IN_PROGRESS = 1;
+export const JOB_STATUS_COMPLETED = 2;
+export const JOB_STATUS_FAILED = 3;
+
+export const JOB_STATUS_MAP = {
+  [JOB_STATUS_PENDING]: '等待运行',
+  [JOB_STATUS_IN_PROGRESS]: '处理中',
+  [JOB_STATUS_COMPLETED]: '已完成',
+  [JOB_STATUS_FAILED]: '失败',
+};
+
+export const JOB_STATUS_COLOR_MAP = {
+  [JOB_STATUS_PENDING]: '#bbb',
+  [JOB_STATUS_IN_PROGRESS]: '#3874CB',
+  [JOB_STATUS_COMPLETED]: '#59B755',
+  [JOB_STATUS_FAILED]: '#EB5451',
+};
+
+export const JOB_STATUS_OPTIONS = Object.keys(JOB_STATUS_MAP).map((k) => ({
+  label: JOB_STATUS_MAP[k],
+  value: parseInt(k, 10),
+}));

+ 8 - 0
frontend/src/constants/SettingConstants.js

@@ -46,3 +46,11 @@ export const PROXY_TYPE_NAME_MAP = {
   [PROXY_TYPE_HTTP]: 'HTTP',
   [PROXY_TYPE_SOCKS5]: 'SOCKS5',
 };
+
+export const VIDEO_TYPE_MOVIE = 0;
+export const VIDEO_TYPE_TV = 1;
+
+export const VIDEO_TYPE_NAME_MAP = {
+  [VIDEO_TYPE_MOVIE]: '电影',
+  [VIDEO_TYPE_TV]: '电视剧',
+};

+ 114 - 0
frontend/src/pages/jobs/JobDetailBtnDialog.vue

@@ -0,0 +1,114 @@
+<template>
+  <q-btn label="详情" flat dense @click="show = true" color="primary" />
+
+  <q-dialog v-model="show">
+    <q-card style="width: 900px; max-width: 900px">
+      <q-card-section>
+        <div class="text-body1">任务详情</div>
+      </q-card-section>
+
+      <q-separator />
+
+      <q-card-section>
+        <table class="job-detail-table">
+          <tbody>
+            <tr>
+              <td>ID</td>
+              <td>{{ data.id }}</td>
+            </tr>
+            <tr>
+              <td>状态</td>
+              <td>{{ JOB_STATUS_MAP[data.job_status] }}</td>
+            </tr>
+            <tr>
+              <td>类型</td>
+              <td>{{ VIDEO_TYPE_NAME_MAP[data.video_type] }}</td>
+            </tr>
+            <tr>
+              <td>路径</td>
+              <td>{{ data.video_f_path }}</td>
+            </tr>
+            <tr>
+              <td>名称</td>
+              <td>{{ data.video_name }}</td>
+            </tr>
+            <tr>
+              <td>特征码</td>
+              <td>{{ data.feature }}</td>
+            </tr>
+            <template v-if="data.video_type === VIDEO_TYPE_TV">
+              <tr>
+                <td>连续剧目录</td>
+                <td>{{ data.series_root_dir_path }}</td>
+              </tr>
+              <tr>
+                <td>季</td>
+                <td>{{ data.season }}</td>
+              </tr>
+              <tr>
+                <td>集</td>
+                <td>{{ data.episode }}</td>
+              </tr>
+            </template>
+            <tr>
+              <td>媒体服务器ID</td>
+              <td>{{ data.media_server_inside_video_id }}</td>
+            </tr>
+            <tr>
+              <td>优先级</td>
+              <td>{{ data.task_priority }}</td>
+            </tr>
+            <tr>
+              <td>视频创建时间</td>
+              <td>{{ data.created_time }}</td>
+            </tr>
+            <tr>
+              <td>任务创建时间</td>
+              <td>{{ data.added_time }}</td>
+            </tr>
+            <tr>
+              <td>更新时间</td>
+              <td>{{ data.update_time }}</td>
+            </tr>
+            <tr>
+              <td>错误信息</td>
+              <td>{{ data.error_info }}</td>
+            </tr>
+            <tr>
+              <td>下载次数</td>
+              <td>{{ data.download_times }}</td>
+            </tr>
+            <tr>
+              <td>重试次数</td>
+              <td>{{ data.retry_times }}</td>
+            </tr>
+          </tbody>
+        </table>
+      </q-card-section>
+    </q-card>
+  </q-dialog>
+</template>
+
+<script setup>
+import { ref } from 'vue';
+import { JOB_STATUS_MAP } from 'src/constants/JobConstants';
+import { VIDEO_TYPE_NAME_MAP, VIDEO_TYPE_TV } from 'src/constants/SettingConstants';
+
+defineProps({
+  data: {
+    type: Object,
+  },
+});
+
+const show = ref(false);
+</script>
+
+<style lang="scss" scoped>
+.job-detail-table {
+  tr td:first-child {
+    padding-right: 20px;
+    text-align: right;
+    color: $grey;
+  }
+}
+</style>

+ 46 - 0
frontend/src/pages/jobs/JobLogBtnDialog.vue

@@ -0,0 +1,46 @@
+<template>
+  <q-btn label="任务日志" flat dense @click="show = true" color="primary" />
+
+  <q-dialog v-model="show" @before-show="handleBeforeShow">
+    <q-card style="width: 900px; max-width: 900px">
+      <q-card-section>
+        <div class="text-body1">任务日志</div>
+      </q-card-section>
+
+      <q-separator />
+
+      <q-card-section>
+        <log-viewer-raw :log-lines="logLines" style="height: 500px" />
+      </q-card-section>
+    </q-card>
+  </q-dialog>
+</template>
+
+<script setup>
+import JobApi from 'src/api/JobApi';
+import { ref } from 'vue';
+import { SystemMessage } from 'src/utils/Message';
+import LogViewerRaw from 'components/LogViewerRaw';
+
+const props = defineProps({
+  data: {
+    type: Object,
+  },
+});
+
+const show = ref(false);
+const logLines = ref([]);
+
+const getJobLog = async () => {
+  const [res, err] = await JobApi.getLog(props.data.id);
+  if (err != null) {
+    SystemMessage.error(err.message);
+  } else {
+    logLines.value = res.data?.one_line;
+  }
+};
+
+const handleBeforeShow = () => {
+  getJobLog();
+};
+</script>

+ 212 - 62
frontend/src/pages/jobs/index.vue

@@ -1,96 +1,246 @@
 <template>
   <q-page class="q-pa-md">
-    <q-card v-if="systemState.jobStatus" flat>
-      <header class="row items-center q-gutter-lg">
-        <div>
-          <div>
-            守护进程:
-            <q-badge v-if="isJobRunning" color="positive">运行中</q-badge>
-            <q-badge v-else color="grey">未运行</q-badge>
-          </div>
-          <div class="text-grey">用于处理定时任务、启动扫描程序</div>
-        </div>
-        <div>
-          <q-btn v-if="isJobRunning" label="强制停止" color="negative" @click="stopJobs" :loading="submitting"></q-btn>
-          <q-btn v-else label="立即运行" color="primary" @click="startJobs" :loading="submitting"></q-btn>
-        </div>
-      </header>
-    </q-card>
+    <q-table
+      :columns="columns"
+      :rows="filteredData"
+      flat
+      bordered
+      selection="multiple"
+      v-model:selected="selected"
+      :pagination="{ rowsPerPage: 20 }"
+    >
+      <template v-slot:top>
+        <div class="col">
+          <div class="row">
+            <div class="col-2 q-table__title">下载队列</div>
 
-    <template v-if="subJobsDetail && isJobRunning">
-      <q-separator class="q-my-md" />
+            <q-space />
 
-      <job-detail-panel />
-
-      <q-separator class="q-my-md" />
+            <div class="q-gutter-sm row">
+              <q-select
+                label="状态"
+                v-model.number="form.status"
+                :options="statusOptions"
+                outlined
+                dense
+                map-options
+                emit-value
+                style="width: 120px"
+              ></q-select>
+              <q-select
+                v-model.number="form.videoType"
+                :options="videoTypeOptions"
+                label="类型"
+                emit-value
+                map-options
+                outlined
+                dense
+                style="width: 100px"
+              ></q-select>
+              <q-select
+                v-model="form.priority"
+                :options="priorityOptions"
+                label="优先级"
+                outlined
+                dense
+                map-options
+                emit-value
+                style="width: 130px"
+              ></q-select>
+              <q-input v-model="form.search" outlined label="输入关键字搜索" dense></q-input>
+            </div>
+          </div>
+          <div class="q-mt-sm q-gutter-xs">
+            <q-btn
+              :disable="selected.length === 0"
+              size="sm"
+              icon="expand_less"
+              label="提升优先级"
+              color="primary"
+              @click="batchUpdatePriority('high')"
+            />
+            <q-btn
+              :disable="selected.length === 0"
+              size="sm"
+              icon="expand_more"
+              label="降低优先级"
+              color="primary"
+              @click="batchUpdatePriority('low')"
+            />
+            <q-btn :disable="selected.length === 0" size="sm" label="删除" color="negative" @click="batchDeleteJobs" />
+          </div>
+        </div>
+      </template>
 
-      <job-r-t-log-panel />
-    </template>
+      <template v-slot:body-cell-jobStatus="{ row }">
+        <q-td>
+          <span
+            :style="{
+              background: JOB_STATUS_COLOR_MAP[row.job_status],
+              color: 'white',
+              borderRadius: '3px',
+              padding: '1px 3px',
+              fontSize: '12px',
+            }"
+            >{{ JOB_STATUS_MAP[row.job_status] }}</span
+          >
+        </q-td>
+      </template>
 
-    <div v-else-if="isJobRunning" class="q-mt-lg row items-center">
-      <q-spinner-facebook color="primary" size="2em" />
-      <div class="text-primary q-ml-sm">正在获取任务执行情况...</div>
-    </div>
+      <template v-slot:body-cell-actions="{ row }">
+        <q-td>
+          <job-detail-btn-dialog :data="row" />
+          <job-log-btn-dialog :data="row" />
+        </q-td>
+      </template>
+    </q-table>
   </q-page>
 </template>
 
 <script setup>
-import { getJobsStatus, isJobRunning, systemState } from 'src/store/systemState';
-import { useQuasar } from 'quasar';
-import { onBeforeUnmount, onMounted, ref } from 'vue';
+import { computed, onMounted, reactive, ref } from 'vue';
 import JobApi from 'src/api/JobApi';
 import { SystemMessage } from 'src/utils/Message';
-import JobRTLogPanel from 'pages/jobs/JobRTLogPanel';
-import { subJobsDetail, useJob } from 'pages/jobs/useJob';
-import JobDetailPanel from 'pages/jobs/JobDetailPanel';
-import { wsManager } from 'src/composables/useWebSocketApi';
+import { VIDEO_TYPE_NAME_MAP } from 'src/constants/SettingConstants';
+import { JOB_STATUS_COLOR_MAP, JOB_STATUS_MAP, JOB_STATUS_OPTIONS } from 'src/constants/JobConstants';
+import { useQuasar } from 'quasar';
+import JobLogBtnDialog from 'pages/jobs/JobLogBtnDialog';
+import JobDetailBtnDialog from 'pages/jobs/JobDetailBtnDialog';
 
 const $q = useQuasar();
 
-const submitting = ref(false);
+const columns = [
+  // { label: 'ID', field: 'id' },
+  { label: '状态', field: 'job_status', name: 'jobStatus' },
+  { label: '类型', field: 'video_type', format: (val) => VIDEO_TYPE_NAME_MAP[val] },
+  // { label: '路径', field: 'video_f_path' },
+  { label: '名称', field: 'video_name', width: '100px' },
+  // { label: '特征码', field: 'feature' },
+  // { label: '连续剧目录', field: 'series_root_dir_path' },
+  // { label: '季', field: 'season' },
+  // { label: '集', field: 'episode' },
+  { label: '优先级', field: 'task_priority' },
+  // { label: '视频创建时间', field: 'created_time' },
+  { label: '创建时间', field: 'added_time' },
+  { label: '更新时间', field: 'update_time' },
+  // { label: '媒体服务器ID', field: 'media_server_inside_video_id' },
+  { label: '错误信息', field: 'error_info' },
+  { label: '下载次数', field: 'download_times' },
+  { label: '重试次数', field: 'retry_times' },
+  { label: '操作', name: 'actions' },
+];
+
+const data = ref([]);
+const selected = ref([]);
+const form = reactive({
+  search: '',
+  status: null,
+  videoType: null,
+  priority: null,
+});
+
+const filteredData = computed(() => {
+  const { search, status, videoType, priority } = form;
+  return data.value.filter((item) => {
+    if (search !== '') {
+      if (
+        !(
+          item.video_name.includes(search) ||
+          item.video_f_path.includes(search) ||
+          item.series_root_dir_path.includes(search) ||
+          String(item.media_server_inside_video_id) === search
+        )
+      ) {
+        return false;
+      }
+    }
+    if (status !== null && item.job_status !== status) {
+      return false;
+    }
+    if (videoType !== null && item.video_type !== videoType) {
+      return false;
+    }
+    if (priority !== null && item.task_priority !== priority) {
+      if (priority === 'high' && item.task_priority > 3) {
+        return false;
+      }
+      if (priority === 'low' && item.task_priority < 7) {
+        return false;
+      }
+      if (priority === 'middle' && (item.task_priority >= 7 || item.task_priority <= 3)) {
+        return false;
+      }
+    }
+    return true;
+  });
+});
 
-useJob();
+const statusOptions = [{ label: '全部', value: null }, ...JOB_STATUS_OPTIONS];
+const videoTypeOptions = [
+  { label: '全部', value: null },
+  ...Object.keys(VIDEO_TYPE_NAME_MAP).map((key) => ({ label: VIDEO_TYPE_NAME_MAP[key], value: parseInt(key, 10) })),
+];
+const priorityOptions = [
+  { label: '全部', value: null },
+  { label: '低(7-10)', value: 'low' },
+  { label: '中(4-6)', value: 'middle' },
+  { label: '高(1-3)', value: 'high' },
+];
 
-const startJobs = () => {
+const batchUpdatePriority = async (priority) => {
   $q.dialog({
-    title: '是否立即运行?',
+    title: '操作确认',
+    message: `确认修改优先级?`,
     cancel: true,
+    persistent: true,
+    focus: 'none',
   }).onOk(async () => {
-    submitting.value = true;
-    const [, err] = await JobApi.start();
-    submitting.value = false;
-    if (err !== null) {
-      SystemMessage.error(err.message);
-      return;
+    const selectedIds = selected.value.map((item) => item.id);
+    const results = await Promise.allSettled(
+      selectedIds.map((id) =>
+        JobApi.update(id, {
+          task_priority: priority,
+        })
+      )
+    );
+    const errorCount = results.filter(({ value: [, err] }) => err !== null).length;
+    if (errorCount > 0) {
+      SystemMessage.error(`${errorCount}个任务修改优先级失败!`);
+    } else {
+      SystemMessage.success('成功修改优先级');
     }
-    getJobsStatus();
-    SystemMessage.success('启动成功');
   });
 };
 
-const stopJobs = () => {
+const batchDeleteJobs = async () => {
   $q.dialog({
-    title: '是否强制停止?',
+    title: '操作确认',
+    message: `确认删除选中任务?`,
     cancel: true,
+    persistent: true,
+    focus: 'none',
   }).onOk(async () => {
-    submitting.value = true;
-    const [, err] = await JobApi.stop();
-    submitting.value = false;
-    if (err !== null) {
-      SystemMessage.error(err.message);
-      return;
+    const selectedIds = selected.value.map((item) => item.id);
+    const results = await Promise.allSettled(selectedIds.map((id) => JobApi.delete(id)));
+    const errorCount = results.filter(({ value: [, err] }) => err !== null).length;
+    if (errorCount > 0) {
+      SystemMessage.error(`${errorCount}个任务删除失败!`);
+    } else {
+      SystemMessage.success('删除成功');
     }
-    getJobsStatus();
-    SystemMessage.success('停止成功');
   });
 };
 
-onMounted(() => {
-  getJobsStatus();
-});
+const getData = async () => {
+  const [res, err] = await JobApi.getList();
+  if (err !== null) {
+    SystemMessage.error(err.message);
+  } else {
+    data.value = res.all_jobs;
+  }
+};
 
-onBeforeUnmount(() => {
-  wsManager.close();
-  wsManager.ws = null;
+onMounted(() => {
+  getData();
 });
 </script>

+ 2 - 2
frontend/src/pages/jobs/JobDetailPanel.vue → frontend/src/pages/overview/JobDetailPanel.vue

@@ -46,10 +46,10 @@
 
 <script setup>
 import { computed } from 'vue';
-import JobProgressCard from 'pages/jobs/JobProgressCard';
+import JobProgressCard from 'pages/overview/JobProgressCard';
 import dayjs from 'dayjs';
 import TimeCounter from 'components/TimeCounter';
-import { subJobsDetail, isWaiting, isPreparing, isScanMovie, isScanSeries } from 'pages/jobs/useJob';
+import { subJobsDetail, isWaiting, isPreparing, isScanMovie, isScanSeries } from 'pages/overview/useJob';
 
 const startTimestamp = computed(() => dayjs(subJobsDetail.value.started_time).unix());
 

+ 1 - 1
frontend/src/pages/jobs/JobProgressCard.vue → frontend/src/pages/overview/JobProgressCard.vue

@@ -28,7 +28,7 @@
 
 <script setup>
 import { computed } from 'vue';
-import { isScanMovie, isScanSeries } from 'pages/jobs/useJob';
+import { isScanMovie, isScanSeries } from 'pages/overview/useJob';
 
 const props = defineProps({
   title: String,

+ 0 - 0
frontend/src/pages/jobs/JobRTLogPanel.vue → frontend/src/pages/overview/JobRTLogPanel.vue


+ 96 - 0
frontend/src/pages/overview/index.vue

@@ -0,0 +1,96 @@
+<template>
+  <q-page class="q-pa-md">
+    <q-card v-if="systemState.jobStatus" flat>
+      <header class="row items-center q-gutter-lg">
+        <div>
+          <div>
+            守护进程:
+            <q-badge v-if="isJobRunning" color="positive">运行中</q-badge>
+            <q-badge v-else color="grey">未运行</q-badge>
+          </div>
+          <div class="text-grey">用于处理定时任务、启动扫描程序</div>
+        </div>
+        <div>
+          <q-btn v-if="isJobRunning" label="强制停止" color="negative" @click="stopJobs" :loading="submitting"></q-btn>
+          <q-btn v-else label="立即运行" color="primary" @click="startJobs" :loading="submitting"></q-btn>
+        </div>
+      </header>
+    </q-card>
+
+    <template v-if="subJobsDetail && isJobRunning">
+      <q-separator class="q-my-md" />
+
+      <job-detail-panel />
+
+      <q-separator class="q-my-md" />
+
+      <job-r-t-log-panel />
+    </template>
+
+    <div v-else-if="isJobRunning" class="q-mt-lg row items-center">
+      <q-spinner-facebook color="primary" size="2em" />
+      <div class="text-primary q-ml-sm">正在获取任务执行情况...</div>
+    </div>
+  </q-page>
+</template>
+
+<script setup>
+import { getJobsStatus, isJobRunning, systemState } from 'src/store/systemState';
+import { useQuasar } from 'quasar';
+import { onBeforeUnmount, onMounted, ref } from 'vue';
+import JobApi from 'src/api/JobApi';
+import { SystemMessage } from 'src/utils/Message';
+import JobRTLogPanel from 'pages/overview/JobRTLogPanel';
+import { subJobsDetail, useJob } from 'pages/overview/useJob';
+import JobDetailPanel from 'pages/overview/JobDetailPanel';
+import { wsManager } from 'src/composables/useWebSocketApi';
+
+const $q = useQuasar();
+
+const submitting = ref(false);
+
+useJob();
+
+const startJobs = () => {
+  $q.dialog({
+    title: '是否立即运行?',
+    cancel: true,
+  }).onOk(async () => {
+    submitting.value = true;
+    const [, err] = await JobApi.start();
+    submitting.value = false;
+    if (err !== null) {
+      SystemMessage.error(err.message);
+      return;
+    }
+    getJobsStatus();
+    SystemMessage.success('启动成功');
+  });
+};
+
+const stopJobs = () => {
+  $q.dialog({
+    title: '是否强制停止?',
+    cancel: true,
+  }).onOk(async () => {
+    submitting.value = true;
+    const [, err] = await JobApi.stop();
+    submitting.value = false;
+    if (err !== null) {
+      SystemMessage.error(err.message);
+      return;
+    }
+    getJobsStatus();
+    SystemMessage.success('停止成功');
+  });
+};
+
+onMounted(() => {
+  getJobsStatus();
+});
+
+onBeforeUnmount(() => {
+  wsManager.close();
+  wsManager.ws = null;
+});
+</script>

+ 0 - 0
frontend/src/pages/jobs/useJob.js → frontend/src/pages/overview/useJob.js


+ 8 - 8
frontend/src/router/routes.js

@@ -4,25 +4,25 @@ const routes = [
   {
     path: '/',
     component: () => import('layouts/MainLayout.vue'),
-    redirect: { name: 'jobs' },
+    redirect: { name: 'overview' },
     children: [
-      // {
-      //   name: 'home',
-      //   path: '/home',
-      //   component: () => import('pages/index.vue'),
-      //   meta: { title: '首页', icon: 'home' },
-      // },
       // {
       //   name: 'library',
       //   path: 'library',
       //   component: () => import('pages/library/index.vue'),
       //   meta: { title: '库', icon: 'video_library' },
       // },
+      {
+        name: 'overview',
+        path: 'overview',
+        component: () => import('pages/overview/index.vue'),
+        meta: { title: '总览', icon: 'home' },
+      },
       {
         name: 'jobs',
         path: 'jobs',
         component: () => import('pages/jobs/index.vue'),
-        meta: { title: '任务', icon: 'cloud_queue' },
+        meta: { title: '下载队列', icon: 'assignment' },
       },
       {
         name: 'logs',