浏览代码

开发报告模板管理功能

Kevin Jiang 2 年之前
父节点
当前提交
2459c82008

+ 6 - 3
src/client/report.client.ts

@@ -1,10 +1,13 @@
 import httpClient from "@/services/httpClient";
 import type { ReportSaveRequest } from "@/types/report.types";
 import type { Response } from "@/types/response.types";
+import * as urlHelper from "@/helpers/url.helper"
 
-function _url(path: string) {
-  return `/gw/user${path}`;
-}
+// function _url(path: string) {
+//   return `/gw/user${path}`;
+// }
+
+const _url = urlHelper.withPrefix('/gw/user');
 
 /**
  * 通过报告ID获取报告数据

+ 26 - 0
src/client/reportTemplate.client.ts

@@ -0,0 +1,26 @@
+import httpClient from "@/services/httpClient";
+import type { ReportTemplateSave, ReportTemplatesResponse } from "@/types/report.types";
+import type { Response } from "@/types/response.types";
+import * as urlHelper from "@/helpers/url.helper"
+
+const _url = urlHelper.withPrefix('/gw/user/reportTemplate')
+
+export async function save(reportTemplateSave: ReportTemplateSave): Promise<ReportTemplateSave> {
+  const resp = await httpClient.put<Response<ReportTemplateSave>>(_url(""), reportTemplateSave);
+  return resp.data.data
+}
+
+export async function tags(): Promise<string[]> {
+  const resp = await httpClient.get<Response<string[]>>(_url("/tags"))
+  return resp.data.data
+}
+
+export async function list(): Promise<ReportTemplatesResponse> {
+  const resp = await httpClient.get<Response<ReportTemplatesResponse>>(_url());
+  return resp.data.data
+}
+
+export async function remove(id: number): Promise<boolean> {
+  const resp = await httpClient.delete<Response<boolean>>(_url(id))
+  return resp.data.data
+}

+ 1 - 12
src/components/NavMenu.vue

@@ -11,8 +11,7 @@ import { onMounted, ref, watch } from 'vue';
 import { useRoute, RouterLink } from "vue-router";
 import _ from "lodash";
 import { useAuthStore } from '@/stores/auth.store';
-import { routeToLogin, routeToReportEditor } from "@/router";
-import * as reportService from "@/services/report.service";
+import { routeToLogin } from "@/router";
 
 const selectedKeys = ref<string[]>(['/']);
 const openKeys = ref<string[]>(['/']);
@@ -55,16 +54,6 @@ function logout(e: MouseEvent) {
   routeToLogin();
   e.stopPropagation();
 }
-
-// function onCreateReport() {
-//   if (authStore.currentUser) {
-//     reportService.create().then((id) => {
-//       routeToReportEditor(id);
-//     }).catch((err) => console.log(err));
-//   } else {
-//     routeToLogin();
-//   }
-// }
 </script>
 
 <template>

+ 26 - 0
src/components/SpinComponent.vue

@@ -0,0 +1,26 @@
+<script setup lang="ts">
+import { LoadingOutlined } from '@ant-design/icons-vue';
+
+defineProps({
+  spinning: {
+    type: Boolean,
+    default: false,
+  }
+});
+</script>
+
+<template>
+  <a-spin :spinning="spinning" class="spinning" size="large">
+    <template #indicator>
+      <loading-outlined :spin="true" />
+    </template>
+    <slot></slot>
+  </a-spin>
+</template>
+
+<style scoped lang="scss">
+.spinning {
+  font-size: 24px;
+  width: 100%;
+}
+</style>

+ 5 - 2
src/helpers/url.helper.ts

@@ -1,7 +1,10 @@
 import _ from "lodash";
 
 export function withPrefix(prefix: string) {
-  return (path: string) => {
-    return _.trimEnd(prefix, "/") + "/" + _.trimStart(path, "/")
+  return (path?: string | number) => {
+    if (path) {
+      return _.trimEnd(prefix, "/") + "/" + _.trimStart(`${path}`, "/")
+    }
+    return prefix;
   }
 }

+ 1 - 1
src/router/index.ts

@@ -24,7 +24,7 @@ export const router = createRouter({
         {
           path: "template",
           name: "ReportTemplate",
-          component: () => import("../views/report/ReportTemplateView.vue"),
+          component: () => import("../views/reportTemplate/ReportTemplateListView.vue"),
         },
         // {
         //   path: "create",

+ 18 - 0
src/services/reportTemplate.service.ts

@@ -0,0 +1,18 @@
+import * as reportTemplateClient from "@/client/reportTemplate.client"
+import type { ReportTemplateSave } from "@/types/report.types";
+
+export async function save(reportTemplateSave: ReportTemplateSave) {
+  return reportTemplateClient.save(reportTemplateSave);
+}
+
+export async function tags() {
+  return reportTemplateClient.tags()
+}
+
+export async function list() {
+  return reportTemplateClient.list();
+}
+
+export async function remove(id: number) {
+  return reportTemplateClient.remove(id)
+}

+ 16 - 6
src/types/report.types.ts

@@ -67,16 +67,26 @@ export interface ReportBasic {
 }
 
 export interface ReportSaveRequest extends ReportBasic {
-    // id: number;
-    // companyId: number;
-    // name: string;
-    // logoPath: string;
-    // metadata: ReportMetadataItem[];
     chapters: ReportChapterRequest[];
-    // userId: number;
 }
 
 export interface ReportsResponse {
   total: number;
   reports: ReportBasic[];
 }
+
+export interface ReportTemplateSave {
+  id?: number;
+  name: string;
+  logoPath: string;
+  metadata: ReportMetadataItem[];
+  chapters: Chapter[];
+  tags?: string[];
+  createAt?: string;
+  updateAt?: string;
+}
+
+export interface ReportTemplatesResponse {
+  total: number;
+  items: ReportTemplateSave[];
+}

+ 34 - 3
src/views/report/ReportEditorView.vue

@@ -8,12 +8,15 @@ import ReportCategory from './components/report-create/ReportCategory.vue';
 import DynamicMeta from './components/dynamic-meta/DynamicMeta.vue';
 import ChapterTypeSelect from "./components/ChapterTypeSelect.vue";
 import LogoComponent from "./components/LogoComponent.vue";
-import type { Chapter, ChapterType, ReportMetadataItem, ReportSaveRequest } from "@/types/report.types";
+import type { Chapter, ChapterType, ReportMetadataItem, ReportSaveRequest, ReportTemplateSave } from "@/types/report.types";
 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 { CompLog } from "@/helpers/log.helper";
 import { message } from "ant-design-vue";
+import TemplateSaveModal from "./components/TemplateSaveModal.vue";
+import type { ReportTemplateModal } from "./reportComponent.types";
 
 const route = useRoute();
 
@@ -55,7 +58,6 @@ function onCompanyChange(companyId: number) {
 const saveLoading = ref(false);
 
 function onSave() {
-  console.log('report', report.value);
   if (report.value) {
     const reportName = (report.value.name || '').trim();
     if (reportName == '') {
@@ -74,6 +76,34 @@ function onSave() {
   }
 }
 
+const templateSaveModal = ref<InstanceType<typeof TemplateSaveModal> | null>(null);
+
+function showTemplateSaveModal() {
+  templateSaveModal.value?.show();
+}
+
+function onSaveTemplate(modalData: ReportTemplateModal) {
+  let chapters = report.value?.chapters.map((chapter) => {
+    return {
+      title: chapter.title
+    } as Chapter
+  })
+  const reportTemplateSave: ReportTemplateSave = {
+    name: modalData.templateName,
+    logoPath: report.value?.logoPath || '',
+    metadata: report.value?.metadata || [],
+    chapters: chapters || [],
+    tags: modalData.tags,
+  }
+
+  reportTemplateService.save(reportTemplateSave).then(() => {
+    templateSaveModal.value?.complete()
+  }).catch((err) => {
+    CompLog.logErr(err)
+    templateSaveModal.value?.complete()
+  })
+}
+
 onMounted(() => {
   const id = parseInt(route.params.id as string);
   if (id && id > 0) {
@@ -120,10 +150,11 @@ onMounted(() => {
       <a-button type="primary" @click="onSave" :loading="saveLoading">保存</a-button>
       <a-button>预览</a-button>
       <a-button>导出</a-button>
-      <a-button>保存为模板</a-button>
+      <a-button @click="showTemplateSaveModal">保存为模板</a-button>
     </a-space>
   </div>
   <ChapterTypeSelect @ok="onChapterTypeSelected" v-model:visible="chapterTypeSelectVisible" />
+  <TemplateSaveModal ref="templateSaveModal" @ok="onSaveTemplate" />
 </template>
 
 <style scoped lang="scss">

+ 5 - 12
src/views/report/ReportListView.vue

@@ -1,6 +1,6 @@
 <script setup lang="ts">
-import { onMounted, h, ref } from "vue";
-import { LoadingOutlined, PlusOutlined } from '@ant-design/icons-vue';
+import { onMounted, ref } from "vue";
+import { PlusOutlined } from '@ant-design/icons-vue';
 import PageHeader from '@/components/PageHeader.vue';
 import QueryBox from './components/report-list/QueryBox.vue';
 import CompanySelect from "./components/report-list/CompanySelect.vue";
@@ -10,6 +10,7 @@ import * as reportsService from "@/services/reports.service"
 import { CompLog } from "@/helpers/log.helper";
 import type { ReportBasic } from "@/types/report.types"
 import { routeToReportEditor } from "@/router";
+import SpinComponent from "@/components/SpinComponent.vue";
 
 const currentPage = ref(1);
 
@@ -99,13 +100,9 @@ onMounted(() => {
         <CompanySelect :data="companies" @change="onCompanySelectChange" />
       </a-col>
       <a-col :xxl="19" :xl="18" :lg="17" :span="16">
-        <a-spin
-          :spinning="spinning"
-          :indicator="h(LoadingOutlined, { style: { fontSize: '24px', }, spin: true, })"
-          class="spinning"
-        >
+        <SpinComponent :spinning="spinning">
           <ReportItem v-for="(report, index) in reports" :data="report" :key="index" class="report-item" />
-        </a-spin>
+        </SpinComponent>
       </a-col>
     </a-row>
     <div class="pagination-wrap">
@@ -138,10 +135,6 @@ onMounted(() => {
         margin-top: 0;
       }
     }
-
-    .spinning {
-      width: 100%;
-    }
   }
 
   .pagination-wrap {

+ 88 - 0
src/views/report/components/TemplateSaveModal.vue

@@ -0,0 +1,88 @@
+<script setup lang="ts">
+import { onMounted, reactive, ref } from "vue"
+import type { ReportTemplateModal } from "../reportComponent.types";
+import * as reportTemplateService from "@/services/reportTemplate.service"
+import { CompLog } from "@/helpers/log.helper";
+import type { SelectProps } from "ant-design-vue";
+
+const visible = ref(false)
+
+const saving = ref(false);
+
+const tagsOptions = ref<SelectProps['options']>([]);
+
+const emits = defineEmits<{
+  (e: "ok", data: ReportTemplateModal): void
+}>();
+
+const formState = reactive<ReportTemplateModal>({
+  templateName: '',
+  tags: [],
+});
+
+function onTagsChange() {
+  formState.tags = formState.tags.map((item) => item.trim())
+}
+
+function onOk() {
+  saving.value = true;
+  emits("ok", formState);
+}
+
+onMounted(() => {
+  reportTemplateService.tags().then((tags) => {
+    tagsOptions.value = tags.map((tag) => ({ value: tag }))
+  }).catch(CompLog.logErr)
+});
+
+function hide() {
+  visible.value = false
+}
+
+function complete() {
+  visible.value = false
+  saving.value = false
+}
+
+defineExpose({
+  show: () => visible.value = true,
+  hide,
+  complete,
+})
+</script>
+
+<template>
+  <a-modal v-model:visible="visible" title="另存为模板" @ok="onOk">
+    <a-form
+      :model="formState"
+      name="templateSaveModal"
+      :label-col="{ span: 5 }"
+      :wrapper-col="{ span: 19 }"
+      autocomplete="off"
+      :ok-button-props="{ loading: saving }"
+    >
+      <a-form-item
+        label="模板名称"
+        name="templateName"
+        :rules="[{ required: true, message: '请输入模板名称!' }]"
+      >
+        <a-input v-model:value="formState.templateName" />
+      </a-form-item>
+
+      <a-form-item
+        label="标签"
+        name="tags"
+        :rules="[{ required: false, message: '请填写模板标签', type: 'array' }]"
+      >
+        <a-select
+          v-model:value="formState.tags"
+          mode="tags"
+          :token-separators="[',', ',']"
+          @change="onTagsChange"
+          :options="tagsOptions"
+          placeholder="模板标签,输入逗号或者回车自动分割"
+        ></a-select>
+      </a-form-item>
+    </a-form>
+  </a-modal>
+</template>

+ 1 - 1
src/views/report/components/report-list/QueryBox.vue

@@ -37,7 +37,7 @@ function onSearch() {
     </a-col>
     <a-col flex="1">
       <a-input-search
-        :value="keyword"
+        v-model:value="keyword"
         placeholder="请输入关键词进行查询"
         enter-button="查&nbsp;&nbsp;询"
         size="large"

+ 4 - 0
src/views/report/reportComponent.types.ts

@@ -0,0 +1,4 @@
+export interface ReportTemplateModal {
+  templateName: string;
+  tags: string[];
+}

+ 90 - 0
src/views/reportTemplate/ReportTemplateListView.vue

@@ -0,0 +1,90 @@
+<script setup lang="ts">
+import { onMounted, ref } from "vue";
+import PageHeader from '@/components/PageHeader.vue';
+import type { ReportTemplateSave } from '@/types/report.types';
+import ReportTemplateQueryBox from "./components/ReportTemplateQueryBox.vue"
+import * as reportTemplateService from "@/services/reportTemplate.service"
+import { CompLog } from "@/helpers/log.helper";
+import SpinComponent from "@/components/SpinComponent.vue";
+import { message } from "ant-design-vue";
+
+const total = ref(0);
+const templates = ref<ReportTemplateSave[]>([])
+const loading = ref(false)
+
+function onQuery(queryData: { field: string, keyword: string }) {
+  console.log(queryData);
+}
+
+function onDelete(index: number) {
+  const template = templates.value[index];
+  console.log('delete template', template);
+  if (template.id) {
+    reportTemplateService.remove(template.id)
+  } else {
+    message.warning("模板ID为空,无法为您删除模板 " + template.name)
+  }
+}
+
+onMounted(() => {
+  loading.value = true
+  reportTemplateService.list().then((resp) => {
+    total.value = resp.total;
+    templates.value = resp.items;
+    loading.value = false
+  }).catch((err) => {
+    loading.value = false
+    CompLog.logErr(err)
+  })
+});
+</script>
+
+<template>
+  <PageHeader title="报告模板管理" />
+  <div class="query-box-wrap">
+    <ReportTemplateQueryBox @search="onQuery" />
+  </div>
+  <div class="templates-wrap">
+    <SpinComponent :spinning="loading">
+      <a-card v-for="(item, index) in templates" :key="index" :title="item.name" class="template-item">
+        <template #extra>
+          <a-button type="link">使用模板撰写报告</a-button>
+          <a-popconfirm
+            :title="'确定删模板 ' + item.name"
+            ok-text="确定"
+            cancel-text="取消"
+            @confirm="onDelete(index)"
+            placement="topRight"
+          >
+            <a-button type="link">删除</a-button>
+          </a-popconfirm>
+        </template>
+        <p v-for="(metadata, j) in item.metadata" :key="j">
+          {{ metadata.name }}: {{ metadata.value }}
+        </p>
+        <p>标签:<a-tag v-for="(tag, k) in item.tags" :key="k">{{ tag }}</a-tag></p>
+        <p>创建时间:{{ item.createAt }}</p>
+        <p>更新时间:{{ item.updateAt }}</p>
+      </a-card>
+    </SpinComponent>
+  </div>
+</template>
+
+<style scoped lang="scss">
+.query-box-wrap {
+  width: 80%;
+  margin: 0 auto;
+}
+
+.templates-wrap {
+  margin-top: 2em;
+
+  .template-item {
+    margin-top: 2em;
+
+    &:first-child {
+      margin-top: 0;
+    }
+  }
+}
+</style>

+ 43 - 0
src/views/reportTemplate/components/ReportTemplateQueryBox.vue

@@ -0,0 +1,43 @@
+<script setup lang="ts">
+import type { SelectProps } from "ant-design-vue";
+import { ref } from "vue";
+
+const field = ref('title');
+const keyword = ref('');
+
+const fieldOptions = ref<SelectProps['options']>([
+  { value: 'title', label: '按模板名称' },
+  { value: 'tags', label: '按标签' },
+]);
+
+const emits = defineEmits<{
+  (e: "search", data: { field: string, keyword: string }): void;
+}>();
+
+function onSearch() {
+  emits("search", { field: field.value, keyword: keyword.value})
+}
+</script>
+
+<template>
+  <a-row type="flex">
+    <a-col>
+      <a-select
+        ref="select"
+        :value="field"
+        style="width: 140px"
+        :options="fieldOptions"
+        size="large"
+      ></a-select>
+    </a-col>
+    <a-col flex="1">
+      <a-input-search
+        v-model:value="keyword"
+        placeholder="请输入关键词进行查询"
+        enter-button="查&nbsp;&nbsp;询"
+        size="large"
+        @search="onSearch"
+      />
+    </a-col>
+  </a-row>
+</template>