Преглед на файлове

feature 1001535 & 1001593

1. 报告撰写前端界面企业logo上传开发
2. 报告撰写定时保存实现
Kevin Jiang преди 3 години
родител
ревизия
4f5dd1a5a1

+ 20 - 0
src/client/logo.client.ts

@@ -0,0 +1,20 @@
+import httpClient from "@/services/httpClient"
+import type { Response } from "@/types/response.types"
+import * as urlHelper from "@/libs/url.lib"
+
+const _prefix = "/gw/user/logo"
+const _url = urlHelper.withPrefix(_prefix);
+
+export async function upload(formData: FormData): Promise<string> {
+  const resp = await httpClient.post<Response<string>>(_url(), formData)
+  return resp.data.data;
+}
+
+export async function fetch(reportId: number, token: string): Promise<Blob> {
+  const resp = await httpClient.get<File>(_url(`${reportId}?token=${token}`));
+  return resp.data;
+}
+
+export function link(reportId: number, token: string): string {
+  return `${_prefix}/${reportId}?token=${token}&t=${Date.now()}`
+}

+ 12 - 0
src/router/index.ts

@@ -150,6 +150,10 @@ export function routeToLogin() {
   router.push({ name: "Login" });
 }
 
+/**
+ * 路由到报告编辑界面
+ * @param id 报告ID
+ */
 export function routeToReportEditor(id: number) {
   router.push({ name: "ReportEditor", params: { id }});
 }
@@ -163,3 +167,11 @@ export function routeToSearch(param: SearchRouteParam, extras: {[index: string]:
 
   router.push({ name: 'SearchResult', query: queryStrParam });
 }
+
+/**
+ * 路由到报告预览界面
+ * @param id 报告ID
+ */
+export function routeToReportPreview(id: number) {
+  router.push({ name: 'ReportPreview', params: { id } })
+}

+ 13 - 0
src/services/logo.service.ts

@@ -0,0 +1,13 @@
+import * as logoClient from "@/client/logo.client"
+
+export async function upload(formData: FormData): Promise<string> {
+  return logoClient.upload(formData)
+}
+
+export async function fetch(reportId: number, token: string): Promise<Blob> {
+  return logoClient.fetch(reportId, token)
+}
+
+export function link(reportId: number, token: string): string {
+  return logoClient.link(reportId, token)
+}

+ 65 - 5
src/views/report/ReportEditorView.vue

@@ -1,5 +1,5 @@
 <script setup lang="ts">
-import { onMounted, ref, type Ref } from "vue";
+import { onMounted, onUnmounted, ref, type Ref } from "vue";
 import { useRoute } from "vue-router";
 import { PlusOutlined } from "@ant-design/icons-vue";
 import PageHeader from '@/components/PageHeader.vue';
@@ -13,11 +13,14 @@ import { ChapterManager } from "@/models/report.model";
 import ChapterEditor from "./components/ChapterEditor.vue";
 import * as reportService from "@/services/report.service";
 import * as reportTemplateService from "@/services/reportTemplate.service"
+import * as logoService from "@/services/logo.service"
 import { CompLog } from "@/libs/log.lib";
 import { message } from "ant-design-vue";
 import TemplateSaveModal from "./components/TemplateSaveModal.vue";
 import type { ReportTemplateModal } from "./reportComponent.types";
 import { useReportExport } from "@/libs/reportExport.lib";
+import { routeToReportPreview } from "@/router";
+import { useAuthStore } from "@/stores/auth.store";
 
 const _componentName = 'ReportEditorView'
 const logError = CompLog.logErr(_componentName)
@@ -26,6 +29,8 @@ const logError = CompLog.logErr(_componentName)
 const route = useRoute();
 // 报告下载函数库
 const reportExport = useReportExport()
+// 权限store
+const authStore = useAuthStore()
 
 const report: Ref<ReportSaveRequest | null> = ref(null);
 const reportCategoryEl = ref<HTMLInputElement | null>(null);
@@ -36,6 +41,12 @@ const metadata: Ref<ReportMetadataItem[]> = ref([]);
 const chapterTypeSelectVisible = ref(false);
 // 章节管理对象
 const chapterManager = new ChapterManager();
+// logo url
+const logoUrl = ref<string | undefined>(undefined)
+// 报告导出按钮是否在加载的状态
+const exporting = ref(false)
+// 定时保存handler
+const timeIntervalHandler = ref<number | null>(null)
 
 function onMetaChange(data: ReportMetadataItem[]) {
   metadata.value = data;
@@ -44,6 +55,29 @@ function onMetaChange(data: ReportMetadataItem[]) {
   }
 }
 
+/**
+ * Logo组件上传处理函数
+ * @param file 图片文件
+ */
+function onLogoChange(file: File) {
+  if (!report.value) {
+    return;
+  }
+  const formData = new FormData();
+  formData.append("file", file)
+  formData.append("reportId", report.value.id.toString())
+  console.log('formData', formData)
+
+  logoService.upload(formData).then((resp) => {
+    if (resp && report.value && authStore.token) {
+      report.value.logoPath = resp;
+      logoUrl.value = logoService.link(report.value.id, authStore.token)
+      onSave()
+    }
+  }).catch(logError)
+
+}
+
 function onChapterTypeSelected(type: ChapterType) {
   chapterManager.addChapter(type);
 }
@@ -91,14 +125,28 @@ function onSave() {
   }
 }
 
+/**
+ * 导出报告
+ */
 function onExport() {
+  exporting.value = true
   if (report.value) {
     reportExport.download(report.value.name, report.value.id)
+    setTimeout(() => exporting.value = false, 5000)
   } else {
     message.warning("暂无数据可以导出")
   }
 }
 
+/**
+ * 报告预览
+ */
+function onPreview() {
+  if (report.value) {
+    routeToReportPreview(report.value.id)
+  }
+}
+
 const templateSaveModal = ref<InstanceType<typeof TemplateSaveModal> | null>(null);
 
 function showTemplateSaveModal() {
@@ -134,9 +182,22 @@ onMounted(() => {
       report.value = data;
       const chapters = report.value.chapters;
       chapterManager.setChapters(chapters);
+      if (report.value.logoPath && authStore.token) {
+        logoUrl.value = logoService.link(report.value.id, authStore.token)
+      }
+      // 数据加载成功后,设置自动保存
+      timeIntervalHandler.value = window.setInterval(() => {
+        onSave();
+      }, 60000);
     }).catch(logError);
   }
 });
+
+onUnmounted(() => {
+  if (timeIntervalHandler.value) {
+    window.clearInterval(timeIntervalHandler.value)
+  }
+})
 </script>
 
 <template>
@@ -150,7 +211,7 @@ onMounted(() => {
       </div>
     </a-col>
     <a-col :span="12">
-      <LogoComponent />
+      <LogoComponent @change="onLogoChange" :url="logoUrl" />
     </a-col>
   </a-row>
   <a-divider />
@@ -174,9 +235,8 @@ onMounted(() => {
   <div class="bottom-button-group">
     <a-space>
       <a-button type="primary" @click="onSave" :loading="saveLoading">保存</a-button>
-      <a-button>预览</a-button>
-      <a-button @click="onExport">导出</a-button>
-      <!-- <a-button v-if="report" type="link" :href="reportExport.link(report.id, authStore.token)" target="_blank">导出</a-button> -->
+      <a-button @click="onPreview">预览</a-button>
+      <a-button @click="onExport" :loading="exporting">导出</a-button>
       <a-button @click="showTemplateSaveModal">另保存为模板</a-button>
     </a-space>
   </div>

+ 24 - 7
src/views/report/components/LogoComponent.vue

@@ -1,9 +1,20 @@
 <script setup lang="ts">
+import { ref } from 'vue';
 import { PlusOutlined, LoadingOutlined } from '@ant-design/icons-vue';
 import { message } from 'ant-design-vue';
-import { ref } from 'vue';
 import type { UploadChangeParam } from 'ant-design-vue';
 
+defineProps({
+  url: {
+    type: String,
+    required: false,
+  }
+})
+
+const emits = defineEmits<{
+  (e: "change", file: File): void;
+}>();
+
 function getBase64(img: Blob, callback: (base64Url: string) => void) {
   const reader = new FileReader();
   reader.addEventListener('load', () => callback(reader.result as string));
@@ -33,16 +44,22 @@ const handleChange = (info: UploadChangeParam) => {
 };
 
 const beforeUpload = (file: File) => {
-  console.log('file', file);
   const isJpgOrPng = file.type === 'image/jpeg' || file.type === 'image/png';
   if (!isJpgOrPng) {
-    message.error('You can only upload JPG file!');
+    message.error('仅支持JPG和PNG格式的图片');
   }
+
   const isLt2M = file.size / 1024 / 1024 < 2;
   if (!isLt2M) {
-    message.error('Image must smaller than 2MB!');
+    message.error('仅支持小于2MB的图片');
+  }
+  if (!isJpgOrPng || !isLt2M) {
+    return false;
   }
-  return isJpgOrPng && isLt2M;
+
+  emits("change", file);
+
+  return false;
 };
 </script>
 
@@ -55,11 +72,11 @@ const beforeUpload = (file: File) => {
         list-type="picture-card"
         class="logo-uploader"
         :show-upload-list="false"
-        action="https://www.mocky.io/v2/5cc8019d300000980a055e76"
+        action="/"
         :before-upload="beforeUpload"
         @change="handleChange"
       >
-        <img width="104" v-if="imageUrl" :src="imageUrl" alt="企业Logo" />
+        <img width="104" v-if="url" :src="url" alt="企业Logo" />
         <div v-else>
           <loading-outlined v-if="loading"></loading-outlined>
           <plus-outlined v-else></plus-outlined>

+ 102 - 20
src/views/reportPreview/ReportPreviewView.vue

@@ -1,16 +1,39 @@
 <!--报告预览视图-->
 <script setup lang="ts">
+import dayjs from 'dayjs';
 import { onMounted, ref } from 'vue';
 import { useRoute } from "vue-router";
 import * as reportService from "@/services/report.service"
 import type { ReportSaveRequest } from '@/types/report.types';
 import { CompLog } from '@/libs/log.lib';
+import { useReportExport } from '@/libs/reportExport.lib';
+import { routeToReportEditor } from '@/router';
 
 const logError = CompLog.logErr("ReportPreviewView")
-
+// 当前路由对象
 const route = useRoute()
-
+// 报告数据
 const data = ref<ReportSaveRequest | null>(null)
+// 报告导出函数库
+const reportExport = useReportExport()
+// 下载状态
+const downloading = ref(false)
+
+function onExport() {
+  if (data.value) {
+    reportExport.download(data.value.name, data.value.id)
+    downloading.value = true
+    setTimeout(() => {
+      downloading.value = false
+    }, 5000)
+  }
+}
+
+function onBackToEditor() {
+  if (data.value) {
+    routeToReportEditor(data.value.id)
+  }
+}
 
 onMounted(() => {
   const id = parseInt(route.params.id as string);
@@ -23,28 +46,87 @@ onMounted(() => {
 </script>
 
 <template>
-  <div v-if="data">
-    {{ data.id }}
-    <h1>{{ data.name }}</h1>
-    <a-row v-for="(item, index) in data.metadata" :key="index" class="metadata-wrap">
-      <a-col class="metadata-name">{{ item.name }}:</a-col>
-      <a-col class="metadata-value">{{ item.value }}</a-col>
-    </a-row>
-    <h2>{{ data.companyName }}</h2>
-    <h3>{{ (new Date()) }}</h3>
-    <a-divider />
-    <p v-for="(chapter, index) in data.chapters" :key="index">
-      {{ chapter }}
-    </p>
+  <div class="main" v-if="data">
+    <div class="report-preview-wrap">
+      <div class="cover">
+        <h1>{{ data.name }}</h1>
+        <div class="metadata-wrap">
+          <a-row v-for="(item, index) in data.metadata" :key="index" class="metadata-item" type="flex">
+            <a-col class="metadata-name">{{ item.name }}: </a-col>
+            <a-col class="metadata-value" flex="1">{{ item.value }}</a-col>
+          </a-row>
+        </div>
+        <h3>{{ data.companyName }}</h3>
+        <h3>{{ dayjs().format("YYYY-MM-DD") }}</h3>
+      </div>
+      <a-divider />
+      <div v-for="(chapter, index) in data.chapters" :key="index" class="chapter-wrap">
+        <h2>{{ chapter.fullTitle }}</h2>
+        <p v-html="chapter.content"></p>
+      </div>
+    </div>
+  </div>
+  <div class="btn-group">
+    <a-space>
+      <a-button @click="onBackToEditor">返回编辑</a-button>
+      <a-button type="primary" @click="onExport" :loading="downloading">导出报告</a-button>
+    </a-space>
   </div>
 </template>
 
 <style scoped lang="scss">
-.metadata-wrap {
-  .metadata-value {
-    margin: 10px 10px;
-    padding: 20px 10px;
-    border-bottom: 1px soild #000;
+.main {
+  background: #F0F2F5;
+  padding: 10px 0;
+
+  .report-preview-wrap {
+    width: 800px;
+    padding: 40px 80px;
+    margin: 0 auto;
+    background-color: #fff;
+
+    .cover {
+      h1, h3 {
+        text-align: center;
+      }
+      h1 {
+        font-size: 2em;
+      }
+
+      .metadata-wrap {
+        padding: 40px 0;
+        min-height: 400px;
+        display: flex;
+        align-items: center;
+
+        .metadata-item {
+          width: 100%;
+
+          .metadata-name, .metadata-value {
+            margin: 10px 10px;
+            padding: 20px 10px;
+          }
+          .metadata-name {
+            width: 120px;
+            text-align: right;
+            margin-right: 10px;
+          }
+          .metadata-value {
+            text-align: center;
+            border-bottom: solid 1px #000;
+          }
+        }
+      }
+    }
+
+    .chapter-wrap {
+      margin-top: 3em;
+    }
   }
 }
+
+.btn-group {
+  margin-top: 2em;
+  text-align: center;
+}
 </style>