瀏覽代碼

开发报告功能

1. 支持企业名称的新增,查询与选择
2. 报告统一使用编辑view
Kevin Jiang 2 年之前
父節點
當前提交
db3aa35677

+ 18 - 0
src/client/company.client.ts

@@ -0,0 +1,18 @@
+import httpClient from "@/services/httpClient";
+import type { Company } from "@/types/company.types";
+import type { Response } from "@/types/response.types";
+
+function _url(path: string) {
+  return `/gw/user${path}`;
+}
+
+export async function create(name: string): Promise<Company> {
+  const resp = await httpClient.put<Response<Company>>(_url("/company"), { name });
+  return resp.data.data;
+}
+
+export async function query(name: string, size: number = 10): Promise<Company[]> {
+  const params = { name, size };
+  const resp = await httpClient.get<Response<Company[]>>(_url("/company"), { params });
+  return resp.data.data;
+}

+ 25 - 0
src/client/report.client.ts

@@ -0,0 +1,25 @@
+import httpClient from "@/services/httpClient";
+import type { ReportSaveRequest } from "@/types/report.types";
+import type { Response } from "@/types/response.types";
+
+function _url(path: string) {
+  return `/gw/user${path}`;
+}
+
+/**
+ * 通过报告ID获取报告数据
+ * @param id 报告ID
+ * @returns Promise
+ */
+export async function get(id: number): Promise<ReportSaveRequest> {
+  const resp = await httpClient.get<Response<ReportSaveRequest>>(_url(`/report/${id}`))
+  return resp.data.data;
+}
+
+/**
+ * 请求后端服务创建一个新报告
+ */
+export async function create(): Promise<number> {
+  const resp = await httpClient.put<Response<number>>(_url("/report"));
+  return resp.data.data;
+}

+ 15 - 3
src/components/NavMenu.vue

@@ -11,7 +11,8 @@ import { onMounted, ref, watch } from 'vue';
 import { useRoute, RouterLink } from "vue-router";
 import _ from "lodash";
 import { useAuthStore } from '@/stores/auth.store';
-import { routeToLogin } from "@/router";
+import { routeToLogin, routeToReportEditor } from "@/router";
+import * as reportService from "@/services/report.service";
 
 const selectedKeys = ref<string[]>(['/']);
 const openKeys = ref<string[]>(['/']);
@@ -54,6 +55,16 @@ function logout(e: MouseEvent) {
   routeToLogin();
   e.stopPropagation();
 }
+
+function onCreateReport() {
+  if (authStore.currentUser) {
+    reportService.create(authStore.currentUser.id).then((report) => {
+      routeToReportEditor(report.id);
+    }).catch((err) => console.log(err));
+  } else {
+    routeToLogin();
+  }
+}
 </script>
 
 <template>
@@ -73,8 +84,9 @@ function logout(e: MouseEvent) {
       <a-menu-item key="/report/template">
         <RouterLink to="/report/template">模板管理</RouterLink>
       </a-menu-item>
-      <a-menu-item key="/report/create">
-        <RouterLink to="/report/create">创建报告</RouterLink>
+      <a-menu-item key="/report/create" @click="onCreateReport">
+        <!-- <RouterLink to="/report/create">创建报告</RouterLink> -->
+        创建报告
       </a-menu-item>
     </a-sub-menu>
     <a-sub-menu key="/search">

+ 15 - 0
src/helpers/log.helper.ts

@@ -0,0 +1,15 @@
+import { getCurrentInstance } from "vue";
+
+/**
+ * 输出错误信息,带上组件的名称
+ * @param args 输出数据
+ */
+// export function logError(...args: any) {
+//   console.error(getCurrentInstance()?.type.__name, ...args);
+// }
+
+export class CompLog {
+  public static logError(...args: any) {
+    console.error(getCurrentInstance()?.type.__name, ...args);
+  }
+}

+ 9 - 0
src/router/index.ts

@@ -32,6 +32,11 @@ export const router = createRouter({
           name: "ReportCreate",
           component: () => import("../views/report/ReportCreateView.vue"),
         },
+        {
+          path: "edit/:id",
+          name: "ReportEditor",
+          component: () => import("../views/report/ReportEditorView.vue"),
+        },
       ]
     },
     {
@@ -136,6 +141,10 @@ export function routeToLogin() {
   router.push({ name: "Login" });
 }
 
+export function routeToReportEditor(id: number) {
+  router.push({ name: "ReportEditor", params: { id }});
+}
+
 /**
  * 路由到搜索结果页面,把查询表达式传入到搜索结果页面
  * @param param queryString参数

+ 9 - 0
src/services/company.service.ts

@@ -0,0 +1,9 @@
+import * as companyClient from "@/client/company.client";
+
+export async function create(name: string) {
+  return companyClient.create(name);
+}
+
+export async function query(name: string, size: number = 0) {
+  return companyClient.query(name, size);
+}

+ 22 - 0
src/services/report.service.ts

@@ -0,0 +1,22 @@
+import * as reportClient from "@/client/report.client";
+import type { ReportSaveRequest } from "@/types/report.types";
+
+export async function get(id: number): Promise<ReportSaveRequest> {
+  return await reportClient.get(id);
+}
+
+export async function create(userId: number): Promise<ReportSaveRequest> {
+  const id = await reportClient.create();
+
+  const report: ReportSaveRequest = {
+    id,
+    companyId: 0,
+    name: '',
+    logoPath: '',
+    metadata: {},
+    chapters: [],
+    userId,
+  };
+
+  return report;
+}

+ 3 - 3
src/services/user.service.ts

@@ -1,4 +1,4 @@
-import type { SearchHistoryRecord, UserLoginRequest, UserLoginResponse } from "@/types/user.types";
+import type { SearchHistoryRecord, UserLoginRequest, CurrentUser } from "@/types/user.types";
 import type { Page } from "@/types/pagination.types"
 import type { Response } from "@/types/response.types";
 import type { AxiosResponse } from "axios";
@@ -10,8 +10,8 @@ function _url(path: string): string {
   return `${urlPrefix}${path}`;
 }
 
-function login(param: UserLoginRequest): Promise<AxiosResponse<Response<UserLoginResponse>>> {
-  return httpClient.post(_url("/login"), param);
+function login(param: UserLoginRequest): Promise<AxiosResponse<Response<CurrentUser>>> {
+  return httpClient.post<Response<CurrentUser>>(_url("/login"), param);
 }
 
 async function checkLogin(): Promise<boolean> {

+ 3 - 2
src/stores/auth.store.ts

@@ -1,19 +1,20 @@
 import { ref, type Ref } from "vue";
 import { defineStore } from "pinia";
+import type { CurrentUser } from "@/types/user.types";
 import userService from "@/services/user.service";
 
 const tokenKey = "token";
 
 export const useAuthStore = defineStore('auth', () => {
   const token = ref(localStorage.getItem(tokenKey));
-  const currentUser: Ref<Object | null> = ref(null);
+  const currentUser: Ref<CurrentUser | null> = ref(null);
 
   function setToken(_token: string) {
     token.value = _token;
     localStorage.setItem(tokenKey, _token);
   }
 
-  function setCurrentUser(user: Object) {
+  function setCurrentUser(user: CurrentUser) {
     currentUser.value = user;
   }
 

+ 5 - 0
src/types/company.types.ts

@@ -0,0 +1,5 @@
+export interface Company {
+  id: number;
+  name: string;
+  date: string;
+}

+ 14 - 0
src/types/report.types.ts

@@ -43,3 +43,17 @@ export interface Chapter {
   fullTitle?: string;
   content: string;
 }
+
+export interface ReportChapterRequest extends Chapter {
+  id: number;
+}
+
+export interface ReportSaveRequest {
+    id: number;
+    companyId: number;
+    name: string;
+    logoPath: string;
+    metadata: {[index: string]: string};
+    chapters: ReportChapterRequest[];
+    userId: number;
+}

+ 5 - 1
src/types/user.types.ts

@@ -8,8 +8,12 @@ export interface UserLoginRequest {
   type: number;
 }
 
-export interface UserLoginResponse {
+export interface CurrentUser {
+  id: number;
   token: string;
+  expirationTime: string;
+  truename: string;
+  [index: string]: any;
 }
 
 export interface SearchHistoryRecord {

+ 7 - 2
src/views/LoginView.vue

@@ -1,5 +1,5 @@
 <script setup lang="ts">
-import { reactive } from 'vue';
+import { reactive, ref } from 'vue';
 import user from "@/services/user.service";
 import { useAuthStore } from '@/stores/auth.store';
 import { routeToHome } from '@/router';
@@ -19,7 +19,10 @@ const formState = reactive<FormState>({
   remember: true,
 });
 
+const loading = ref(false);
+
 const onFinish = (values: any) => {
+  loading.value = true;
   let param = {
     mobilePhone: values.username,
     password: values.password,
@@ -29,7 +32,9 @@ const onFinish = (values: any) => {
     authStore.setCurrentUser(resp.data.data);
     authStore.setToken(resp.data.data.token);
     routeToHome();
+    loading.value = false;
   }).catch((err) => {
+    loading.value = false;
     console.log(err);
   })
 };
@@ -71,7 +76,7 @@ const onFinishFailed = (errorInfo: any) => {
             </a-form-item>
 
             <a-form-item :wrapper-col="{ sm: { span: 20, offset: 4 }, xs: { span: 24, offset: 0 } }">
-              <a-button type="primary" html-type="submit" block>登&nbsp;&nbsp;&nbsp;&nbsp;录</a-button>
+              <a-button type="primary" html-type="submit" block :loading="loading">登&nbsp;&nbsp;&nbsp;&nbsp;录</a-button>
             </a-form-item>
           </a-form>
           <p class="forgot-password-wrap">

+ 1 - 1
src/views/report/ReportCreateView.vue

@@ -1,5 +1,5 @@
 <script setup lang="ts">
-import { ref, onMounted, type Ref } from "vue";
+import { ref, type Ref } from "vue";
 import { PlusOutlined } from "@ant-design/icons-vue";
 import PageHeader from '@/components/PageHeader.vue';
 import RelationCompanyInput from './components/report-create/RelationCompanyInput.vue';

+ 105 - 0
src/views/report/ReportEditorView.vue

@@ -0,0 +1,105 @@
+<script setup lang="ts">
+import { onMounted, ref, type Ref } from "vue";
+import { useRoute } from "vue-router";
+import { PlusOutlined } from "@ant-design/icons-vue";
+import PageHeader from '@/components/PageHeader.vue';
+import RelationCompanyInput from './components/report-create/RelationCompanyInput.vue';
+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, ReportSaveRequest } from "@/types/report.types";
+import { ChapterManager } from "@/models/report.model";
+import ChapterEditor from "./components/ChapterEditor.vue";
+import * as reportService from "@/services/report.service";
+
+// 报告元数据
+const metadata: Ref<any[]> = ref([]);
+// 章节类型选择器可见状态
+const chapterTypeSelectVisible = ref(false);
+// 章节管理对象
+const chapterManager = ref(new ChapterManager());
+
+function onMetaChange(data: any[]) {
+  metadata.value = data;
+}
+
+function onChapterTypeSelected(type: ChapterType) {
+  chapterManager.value.addChapter(type);
+}
+
+function onChapterChange(index: number, data: Chapter) {
+  chapterManager.value.setChapter(index, data);
+}
+
+const route = useRoute();
+
+const report: Ref<ReportSaveRequest | null> = ref(null);
+
+onMounted(() => {
+  const id = parseInt(route.params.id as string);
+  if (id && id > 0) {
+    reportService.get(id).then((data) => {
+      report.value = data;
+    }).catch((err) => {
+      console.log(err);
+    });
+  }
+});
+</script>
+
+<template>
+  <PageHeader title="报告撰写" />
+  <a-row :gutter="24">
+    <a-col :span="12">
+      <div class="metadata-wrap">
+        <RelationCompanyInput />
+        <ReportCategory />
+        <DynamicMeta @change="onMetaChange" />
+      </div>
+    </a-col>
+    <a-col :span="12">
+      <LogoComponent />
+    </a-col>
+  </a-row>
+  <a-divider />
+  <div class="chapter-wrap">
+    <ChapterEditor
+      v-for="(item, index) in chapterManager.getChapters()"
+      :key="index"
+      :data="item"
+      @change="onChapterChange(index, $event)"
+    />
+  </div>
+  <div class="add-chapter-wrap">
+    <a-button @click="() => chapterTypeSelectVisible = true">
+      <plus-outlined />
+      添加章节
+    </a-button>
+  </div>
+  <div class="bottom-button-group">
+    <a-space>
+      <a-button type="primary">保存</a-button>
+      <a-button>预览</a-button>
+      <a-button>导出</a-button>
+      <a-button>保存模板</a-button>
+    </a-space>
+  </div>
+  <ChapterTypeSelect @ok="onChapterTypeSelected" v-model:visible="chapterTypeSelectVisible" />
+</template>
+
+<style scoped lang="scss">
+.metadata-wrap {
+  > * {
+    margin-top: 0.5em;
+  }
+}
+
+.add-chapter-wrap {
+  margin-top: 3em;
+}
+
+.bottom-button-group {
+  margin-top: 5em;
+}
+</style>

+ 51 - 41
src/views/report/components/report-create/RelationCompanyInput.vue

@@ -1,39 +1,42 @@
 <script setup lang="ts">
-import { ref, type Ref, watch } from "vue";
+import { ref, type Ref } from "vue";
 import { debounce } from "lodash";
+import { PlusOutlined } from '@ant-design/icons-vue';
+import * as companyService from "@/services/company.service";
+import type { SelectProps } from "ant-design-vue";
+import { CompLog } from "@/helpers/log.helper";
 
-let lastFetchId = 0;
-
-const data: Ref<string[]> = ref([]);
-const value: Ref<string[]> = ref([]);
-const fetching = ref(false);
-
-const fetchUser = debounce(value => {
-  console.log('fetching user', value);
-  lastFetchId += 1;
-  const fetchId = lastFetchId;
-  data.value = [];
-  fetching.value = true;
-  fetch('https://randomuser.me/api/?results=5')
-    .then(response => response.json())
-    .then(body => {
-      if (fetchId !== lastFetchId) {
-        // for fetch callback order
-        return;
-      }
-      const data = body.results.map((user: any) => ({
-        label: `${user.name.first} ${user.name.last}`,
-        value: user.login.username,
-      }));
-      data.value = data;
-      fetching.value = false;
-    });
+const name: Ref<string> = ref('');
+const companyId: Ref<number | null> = ref(null);
+
+const options = ref<SelectProps['options']>([]);
+
+const newName = ref('');
+
+const visible = ref(false);
+
+const fetchCompany = debounce((val) => {
+  companyService.query(val, 10).then((companies) => {
+    const data = companies.map((company) => ({ value: company.id, label: company.name }));
+    options.value = data;
+  }).catch(CompLog.logError);
 }, 300);
 
-watch(value.value, () => {
-  data.value = [];
-  fetching.value = false;
-});
+function onFocus() {
+  fetchCompany(name.value);
+}
+
+function onChange(val: number) {
+  companyId.value = val;
+}
+
+function onModalOk() {
+  visible.value = false;
+  companyService.create(newName.value).then((company) => {
+    name.value = company.name;
+    companyId.value = company.id;
+  }).catch(CompLog.logError);
+}
 </script>
 
 <template>
@@ -43,20 +46,27 @@ watch(value.value, () => {
     </a-col>
     <a-col flex="1">
       <a-select
-        v-model:value="value"
-        mode="multiple"
-        label-in-value
-        placeholder="请输入企业名称"
+        v-model:value="name"
+        show-search
+        mode="combobox"
+        placeholder="input search text"
         style="width: 100%"
+        :default-active-first-option="false"
+        :show-arrow="true"
         :filter-option="false"
-        :not-found-content="fetching ? undefined : null"
-        :options="data"
-        @search="fetchUser"
+        :not-found-content="null"
+        :options="options"
+        @focus="onFocus"
+        @search="fetchCompany"
+        @change="onChange"
       >
-        <template v-if="fetching" #notFoundContent>
-          <a-spin size="small" />
-        </template>
       </a-select>
     </a-col>
+    <a-col>
+      <a-button @click="visible = true"><plus-outlined />新增企业</a-button>
+    </a-col>
   </a-row>
+  <a-modal v-model:visible="visible" title="新增企业名称" @ok="onModalOk">
+    <p><a-input v-model:value="newName" placeholder="请输入新企业名称" /></p>
+  </a-modal>
 </template>