Browse Source

feat(frontend): 对接后台接口

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

+ 11 - 0
frontend/package-lock.json

@@ -12,6 +12,7 @@
         "@vueuse/core": "^4.11.2",
         "axios": "^0.25.0",
         "core-js": "^3.6.5",
+        "dayjs": "^1.10.8",
         "events": "^3.3.0",
         "github-markdown-css": "^5.1.0",
         "markdown": "^0.5.0",
@@ -4860,6 +4861,11 @@
       "dev": true,
       "peer": true
     },
+    "node_modules/dayjs": {
+      "version": "1.10.8",
+      "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.10.8.tgz",
+      "integrity": "sha512-wbNwDfBHHur9UOzNUjeKUOJ0fCb0a52Wx0xInmQ7Y8FstyajiV1NmK1e00cxsr9YrE9r7yAChE0VvpuY5Rnlow=="
+    },
     "node_modules/debug": {
       "version": "4.3.3",
       "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.3.tgz",
@@ -17152,6 +17158,11 @@
       "dev": true,
       "peer": true
     },
+    "dayjs": {
+      "version": "1.10.8",
+      "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.10.8.tgz",
+      "integrity": "sha512-wbNwDfBHHur9UOzNUjeKUOJ0fCb0a52Wx0xInmQ7Y8FstyajiV1NmK1e00cxsr9YrE9r7yAChE0VvpuY5Rnlow=="
+    },
     "debug": {
       "version": "4.3.3",
       "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.3.tgz",

+ 1 - 0
frontend/package.json

@@ -23,6 +23,7 @@
     "@vueuse/core": "^4.11.2",
     "axios": "^0.25.0",
     "core-js": "^3.6.5",
+    "dayjs": "^1.10.8",
     "events": "^3.3.0",
     "github-markdown-css": "^5.1.0",
     "markdown": "^0.5.0",

+ 2 - 0
frontend/src/api/CommonApi.js

@@ -8,5 +8,7 @@ class CommonAPi extends BaseApi {
   checkProxy = (params) => this.http('/check-proxy', params, 'POST');
 
   checkPath = (params) => this.http('/check-path', params, 'POST');
+
+  checkPath = (data) => this.http('/check-emby-path', data, 'POST');
 }
 export default new CommonAPi();

+ 48 - 0
frontend/src/components/TimeCounter.vue

@@ -0,0 +1,48 @@
+<template>
+  <span>
+    <slot :days="days" :hours="hours" :minutes="minutes" :seconds="seconds"></slot>
+  </span>
+</template>
+
+<script setup>
+import {computed, onBeforeUnmount, onMounted, ref} from 'vue';
+import dayjs from 'dayjs';
+
+const props = defineProps({
+  // 单位秒
+  time: {
+    type: Number,
+    required: true,
+  },
+  interval: {
+    type: Number,
+    default: 1,
+  },
+});
+
+const nowTime = ref(0);
+
+const dur = computed(() => Math.abs(props.time - nowTime.value));
+
+const days = computed(() => Math.floor(dur.value / (24 * 60 * 60)));
+const hours = computed(() => Math.floor((dur.value % (24 * 60 * 60)) / (60 * 60)));
+const minutes = computed(() => Math.floor((dur.value % (60 * 60)) / 60));
+const seconds = computed(() => Math.floor(dur.value % 60));
+
+const updateNowTime = () => {
+  nowTime.value = dayjs().unix();
+};
+
+updateNowTime();
+
+const timer = ref(null);
+
+onMounted(() => {
+  timer.value = setInterval(updateNowTime, props.interval * 1000);
+})
+
+onBeforeUnmount(() => {
+  clearInterval(timer.value);
+})
+
+</script>

+ 7 - 0
frontend/src/components/VersionUpdateItem.vue

@@ -111,3 +111,10 @@ const navigateToReleasePage = () => {
 
 onMounted(getLatestVersion);
 </script>
+
+
+<style lang="scss" scoped>
+a {
+  color: $primary;
+}
+</style>

+ 19 - 4
frontend/src/composables/useWebSocketApi.js

@@ -8,7 +8,7 @@ class WSManager extends EventEmitter {
 
   url = null;
 
-  autoConnect = true;
+  autoRetry = true;
 
   connected = false;
 
@@ -18,6 +18,14 @@ class WSManager extends EventEmitter {
   }
 
   send(type, data) {
+    // console.log(
+    //   'send',
+    //   type,
+    //   JSON.stringify({
+    //     type,
+    //     data: JSON.stringify(data),
+    //   })
+    // );
     this.ws.send(
       JSON.stringify({
         type,
@@ -27,6 +35,7 @@ class WSManager extends EventEmitter {
   }
 
   connect() {
+    this.autoRetry = true;
     const ws = new WebSocket(this.url);
     ws.onopen = () => {
       // 连接成功后自动发送验证信息
@@ -37,6 +46,7 @@ class WSManager extends EventEmitter {
 
     ws.onmessage = (e) => {
       try {
+        // console.log('receive', e.data);
         const { type, data } = JSON.parse(e.data);
         // console.log(type, data);
         this.emit(type, JSON.parse(data));
@@ -48,7 +58,7 @@ class WSManager extends EventEmitter {
 
     ws.onclose = (e) => {
       this.connected = false;
-      if (this.autoConnect) {
+      if (this.autoRetry) {
         // eslint-disable-next-line no-console
         console.log('Socket is closed. Reconnect will be attempted in 2 second.', e.reason);
         setTimeout(() => {
@@ -70,15 +80,19 @@ class WSManager extends EventEmitter {
 
   // 强制关闭连接
   close() {
-    this.autoConnect = false;
+    this.autoRetry = false;
     this.ws?.close();
+    this.connected = false;
   }
 }
 
 // 根据BACKEND_URL配置计算ws地址
 export const getWsBaseUrl = () => {
   try {
-    return process.env.BACKEND_WS_URL;
+    const wsUrl = process.env.BACKEND_WS_URL;
+    if (wsUrl) {
+      return wsUrl;
+    }
   } catch (e) {
     // do nothing
   }
@@ -118,5 +132,6 @@ export const useWebSocketApi = (eventType, eventHandler) => {
 
   onBeforeUnmount(() => {
     wsManager.off(eventType, eventHandler);
+    wsManager.close();
   });
 };

+ 53 - 0
frontend/src/pages/jobs/JobDetailPanel.vue

@@ -0,0 +1,53 @@
+<template>
+  <div v-if="subJobsDetail">
+    <div class="q-my-xs">
+      任务状态:
+      <q-badge outline v-if="isPreparing" color="secondary">已启动</q-badge>
+      <q-badge outline v-if="isRunning" color="positive">运行中</q-badge>
+      <q-badge outline v-else-if="isWaiting" color="warning">等待下一次定时任务启动</q-badge>
+    </div>
+
+    <div class="text-grey row items-center">
+      <div>
+        <template v-if="isRunning">
+          已运行:<time-counter :time="startTimestamp" v-slot="{ days, hours, minutes, seconds }">
+            {{ days }} 天 {{ hours }} 小时 {{ minutes }} 分 {{ seconds }} 秒
+          </time-counter>
+        </template>
+        <template v-else-if="isWaiting">
+          <time-counter :time="startTimestamp" v-slot="{ days, hours, minutes, seconds }">
+            {{ days }} 天 {{ hours }} 小时 {{ minutes }} 分 {{ seconds }} 秒
+          </time-counter>
+          后开始
+        </template>
+      </div>
+    </div>
+
+    <section v-if="isRunning" class="row q-mt-md">
+      <job-progress-card
+        title="任务进度"
+        :current="subJobsDetail.working_unit_index"
+        :total="subJobsDetail.unit_count"
+        :current-name="subJobsDetail.working_unit_name"
+      />
+
+      <job-progress-card
+        v-if="subJobsDetail.video_count"
+        title="子任务(视频)进度"
+        :current="subJobsDetail.working_video_index"
+        :total="subJobsDetail.video_count"
+        :current-name="subJobsDetail.working_video_name"
+      />
+    </section>
+  </div>
+</template>
+
+<script setup>
+import { computed } from 'vue';
+import JobProgressCard from 'pages/jobs/JobProgressCard';
+import dayjs from 'dayjs';
+import TimeCounter from 'components/TimeCounter';
+import { subJobsDetail, isWaiting, isPreparing, isRunning } from 'pages/jobs/useJob';
+
+const startTimestamp = computed(() => dayjs(subJobsDetail.value.started_time).unix());
+</script>

+ 0 - 28
frontend/src/pages/jobs/JobListTable.vue

@@ -1,28 +0,0 @@
-<template>
-  <q-table title="任务列表" :columns="columns" :rows="[{}, {}, {}]">
-    <template v-slot:top-right>
-      <q-select
-        :options="[{ label: '成功', value: 'success' }, { label: '失败', value: 'failed' }]"
-        outlined
-        model-value=""
-        option-label="label"
-        option-value="value"
-        label="运行结果"
-        dense
-        style="width: 200px"
-      ></q-select>
-    </template>
-  </q-table>
-</template>
-
-<script setup>
-const columns = [
-  { label: 'ID', field: 'id' },
-  { label: '状态', field: 'status' },
-  { label: '处理总数', field: 'total' },
-  { label: '跳过数量', field: 'skipped' },
-  { label: '新增数量', field: 'added' },
-  { label: '失败数量', field: 'added' },
-  { label: '运行结果', field: 'added' },
-].map((e) => ({ align: 'left', ...e }));
-</script>

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

@@ -50,6 +50,6 @@ const progress = computed(() => {
 .title {
   font-size: 14px;
   font-weight: bold;
-  padding-left: 20px;
+  padding-left: 30px;
 }
 </style>

+ 0 - 43
frontend/src/pages/jobs/SubJobPabel.vue

@@ -1,43 +0,0 @@
-<template>
-  <div v-if="subJobsDetail">
-    <div class="text-grey row items-center">
-      <div>
-        开始于 {{ subJobsDetail.started_time }}
-      </div>
-
-      <div class="q-my-xs q-ml-md">
-        <q-badge outline v-if="subJobsDetail.status === 'running'" color="positive">运行中</q-badge>
-        <q-badge outline v-else-if="subJobsDetail.status === 'waiting'" color="warning">等待中</q-badge>
-      </div>
-    </div>
-
-    <section class="row q-mt-md">
-      <job-progress-card
-        title="子任务进度"
-        :current="subJobsDetail.working_unit_index"
-        :total="subJobsDetail.unit_count"
-        :current-name="subJobsDetail.working_unit_name"
-      />
-
-      <job-progress-card
-        title="视频进度"
-        :current="subJobsDetail.working_video_index"
-        :total="subJobsDetail.video_count"
-        :current-name="subJobsDetail.working_video_name"
-      />
-
-    </section>
-  </div>
-</template>
-
-<script setup>
-import { useWebSocketApi } from 'src/composables/useWebSocketApi';
-import { ref } from 'vue';
-import JobProgressCard from 'pages/jobs/JobProgressCard';
-
-const subJobsDetail = ref(null);
-
-useWebSocketApi('sub_download_jobs_status', (data) => {
-  subJobsDetail.value = data;
-});
-</script>

+ 30 - 20
frontend/src/pages/jobs/index.vue

@@ -1,49 +1,59 @@
 <template>
   <q-page class="q-pa-md">
     <q-card v-if="systemState.jobStatus" flat>
-      <header class="row items-center justify-between q-gutter-md">
+      <header class="row items-center q-gutter-lg">
         <div>
-          当前任务状态:
-          <q-badge v-if="isRunning" color="positive">运行中</q-badge>
-          <q-badge v-else color="grey">未运行</q-badge>
+          <div>
+            守护进程:
+            <q-badge v-if="isRunning" color="positive">运行中</q-badge>
+            <q-badge v-else color="grey">未运行</q-badge>
+          </div>
+          <div class="text-grey">用于处理定时任务、启动扫描程序</div>
         </div>
         <div>
-          <q-btn
-            v-if="isRunning"
-            label="强制停止"
-            color="negative"
-            @click="stopJobs"
-            :loading="submitting"
-          ></q-btn>
+          <q-btn v-if="isRunning" 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-separator class="q-my-md"/>
+    <template v-if="subJobsDetail">
+      <q-separator class="q-my-md" />
 
-    <sub-job-pabel/>
+      <job-detail-panel />
 
-    <q-separator class="q-my-md"/>
+      <q-separator class="q-my-md" />
 
-    <job-r-t-log-panel/>
+      <job-r-t-log-panel />
+    </template>
+
+    <div v-else-if="isRunning" 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, systemState} from 'src/store/systemState';
+import { getJobsStatus, systemState } from 'src/store/systemState';
 import { useQuasar } from 'quasar';
-import {computed, onMounted, ref} from 'vue';
+import { computed, onMounted, ref } from 'vue';
 import JobApi from 'src/api/JobApi';
 import { SystemMessage } from 'src/utils/Message';
-import SubJobPabel from 'pages/jobs/SubJobPabel';
 import JobRTLogPanel from 'pages/jobs/JobRTLogPanel';
+import {subJobsDetail, useJob} from 'pages/jobs/useJob';
+import JobDetailPanel from 'pages/jobs/JobDetailPanel';
 
 const $q = useQuasar();
 
 const submitting = ref(false);
 
-const isRunning = computed(() => systemState.jobStatus?.status === 'running')
+const isRunning = computed(() => systemState.jobStatus?.status === 'running');
+
+useJob();
 
 const startJobs = () => {
   $q.dialog({
@@ -80,5 +90,5 @@ const stopJobs = () => {
 
 onMounted(() => {
   getJobsStatus();
-})
+});
 </script>

+ 28 - 0
frontend/src/pages/jobs/useJob.js

@@ -0,0 +1,28 @@
+import { onMounted, ref, watch, computed } from 'vue';
+import { useWebSocketApi } from 'src/composables/useWebSocketApi';
+import { systemState } from 'src/store/systemState';
+
+export const subJobsDetail = ref(null);
+
+export const isRunning = computed(() => subJobsDetail.value.status === 'running');
+
+export const isPreparing = computed(() => subJobsDetail.value.status === 'preparing');
+
+export const isWaiting = computed(() => subJobsDetail.value.status === 'waiting');
+
+export const useJob = () => {
+  watch(
+    () => systemState.jobStatus?.status,
+    () => {
+      subJobsDetail.value = null;
+    }
+  );
+
+  useWebSocketApi('sub_download_jobs_status', (data) => {
+    subJobsDetail.value = data;
+  });
+
+  onMounted(() => {
+    subJobsDetail.value = null;
+  });
+};

+ 5 - 26
frontend/src/pages/logs/index.vue

@@ -3,28 +3,12 @@
     <q-card class="row col" flat bordered>
       <div class="full-height q-pa-md">
         <q-list style="width: 220px">
-          <q-item clickable @click="logType = 'rt'" :active="logType === 'rt'" active-class="text-primary text-bold">
-            <q-item-section>实时日志</q-item-section>
-
-            <q-item-section side>
-              <q-btn
-                flat
-                round
-                icon="file_download"
-                size="sm"
-                @click.stop="downloadLog(rtLogLines)"
-                title="下载日志"
-              ></q-btn>
-            </q-item-section>
-          </q-item>
-
-          <q-separator class="q-my-xs" />
-
           <q-item-label header>历史日志</q-item-label>
+          <q-separator />
           <q-item
             v-for="item in logList"
             :key="item.index"
-            :active="logType === 'history' && item.index === currentIndex"
+            :active="item.index === currentIndex"
             active-class="text-primary text-bold"
             @click="handleHistoryItemClick(item)"
             clickable
@@ -47,7 +31,7 @@
       <log-viewer
         class="full-height"
         :log-lines="currentLogLines"
-        :key="logType + currentItem?.log_lines[0]?.date_time"
+        :key="currentItem?.log_lines[0]?.date_time"
       />
     </q-card>
   </fix-height-q-page>
@@ -57,20 +41,15 @@
 import { useLogList } from 'pages/logs/useLogList';
 import FixHeightQPage from 'components/FixHeightQPage';
 import { saveText } from 'src/utils/FileDownload';
-import { computed, ref } from 'vue';
-import { useRealTimeLog } from 'src/composables/useRealTimeLog';
+import { computed } from 'vue';
 import LogViewer from 'components/LogViewer';
 import {getExportSettings, useSettings} from 'pages/settings/useSettings';
 
 const { logList, currentIndex, currentItem } = useLogList();
-const logType = ref('rt'); // rt or history
 
 useSettings();
-const { logLines: rtLogLines } = useRealTimeLog();
-
 const handleHistoryItemClick = (item) => {
   currentIndex.value = item.index;
-  logType.value = 'history';
 };
 
 // eslint-disable-next-line camelcase
@@ -81,7 +60,7 @@ const getTextLogLines = (logLines = []) => logLines.map(getTexLogLine);
 const getTextLogContent = (logLines = []) => getTextLogLines(logLines).join('\n');
 
 const currentLogLines = computed(() => {
-  const lines = logType.value === 'rt' ? rtLogLines.value : currentItem.value?.log_lines;
+  const lines = currentItem.value?.log_lines;
   return lines || [];
 });
 

+ 20 - 2
frontend/src/pages/settings/EmbySettings.vue

@@ -82,7 +82,15 @@
                         v-model="form.movie_paths_mapping[source]"
                         standout
                         dense
-                        :rules="[(val) => (form.enable && !!val) || '不能为空']"
+                        :rules="[
+                          (val) => (form.enable && !!val) || '不能为空',
+                          (val) => validateEmbyPath(val, {
+                                  address_url: form.address_url,
+                                  api_key: form.api_key,
+                                  path_type: 'movie', // movie / series
+                                  cfs_media_path: source,
+                          })
+                          ]"
                       />
                     </td>
                   </tr>
@@ -113,7 +121,15 @@
                         v-model="form.series_paths_mapping[source]"
                         standout
                         dense
-                        :rules="[(val) => (form.enable && !!val) || '不能为空']"
+                        :rules="[
+                          (val) => (form.enable && !!val) || '不能为空',
+                          (val) => validateEmbyPath(val, {
+                                  address_url: form.address_url,
+                                  api_key: form.api_key,
+                                  path_type: 'series', // movie / series
+                                  cfs_media_path: source,
+                          })
+                          ]"
                       />
                     </td>
                   </tr>
@@ -135,6 +151,8 @@
 import { formModel, submitAll } from 'pages/settings/useSettings';
 import { toRefs } from '@vueuse/core';
 import FormSubmitArea from 'pages/settings/FormSubmitArea';
+import {validateEmbyPath} from 'src/utils/QuasarValidators';
 
 const { emby_settings: form } = toRefs(formModel);
+
 </script>

+ 6 - 0
frontend/src/pages/settings/index.vue

@@ -5,6 +5,12 @@
         <q-icon name="warning" />
       </template>
       当前有任务正在运行中,不能更改配置
+      <template v-slot:action>
+        <q-btn color="white" label="去任务页面停止" flat @click="$router.push('/jobs')"/>
+      </template>
+      <span>
+      </span>
+
     </q-banner>
     <q-card v-if="isSettingsLoaded" flat>
       <q-tabs

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

@@ -4,7 +4,7 @@ const routes = [
   {
     path: '/',
     component: () => import('layouts/MainLayout.vue'),
-    redirect: { name: 'settings' },
+    redirect: { name: 'jobs' },
     children: [
       // {
       //   name: 'home',
@@ -24,18 +24,18 @@ const routes = [
         component: () => import('pages/jobs/index.vue'),
         meta: { title: '任务', icon: 'cloud_queue' },
       },
-      {
-        name: 'settings',
-        path: 'settings',
-        component: () => import('pages/settings/index.vue'),
-        meta: { title: '配置中心', icon: 'settings' },
-      },
       {
         name: 'logs',
         path: 'logs',
         component: () => import('pages/logs/index.vue'),
         meta: { title: '日志', icon: 'receipt_long' },
       },
+      {
+        name: 'settings',
+        path: 'settings',
+        component: () => import('pages/settings/index.vue'),
+        meta: { title: '配置中心', icon: 'settings' },
+      },
     ],
   },
 

+ 17 - 0
frontend/src/utils/QuasarValidators.js

@@ -11,4 +11,21 @@ export const validateRemotePath = (val) =>
     });
   });
 
+export const validateEmbyPath = (val, extendData) =>
+  new Promise((resolve) => {
+    CommonApi.checkPath({
+      address_url: extendData.address_url,
+      api_key: extendData.api_key,
+      path_type: extendData.path_type, // movie / series
+      cfs_media_path: extendData.cfs_media_path,
+      emby_media_path: val,
+    }).then(([res, err]) => {
+      if (!res?.media_list?.length) {
+        resolve(err?.message || '目录不可用,请输入正确的Emby目录');
+      } else {
+        resolve(true);
+      }
+    });
+  });
+
 export const validateCronDuration = (val) => /^(-?\d+(ns|us|µs|ms|s|m|h))+$/.test(val) || '格式不正确';