dev01 1 ano atrás
pai
commit
8a25348032
92 arquivos alterados com 15598 adições e 0 exclusões
  1. 20 0
      .eslintrc.cjs
  2. 28 0
      .gitignore
  3. 1 0
      .prettierrc.json
  4. 3 0
      .vscode/extensions.json
  5. 7 0
      Dockerfile
  6. 8 0
      cypress/e2e/example.cy.ts
  7. 10 0
      cypress/e2e/tsconfig.json
  8. 5 0
      cypress/fixtures/example.json
  9. 39 0
      cypress/support/commands.ts
  10. 20 0
      cypress/support/e2e.ts
  11. 1 0
      env.d.ts
  12. 13 0
      index.html
  13. 11 0
      nginx/conf.d/default.conf
  14. 31 0
      nginx/nginx.conf
  15. 5996 0
      package-lock.json
  16. 56 0
      package.json
  17. BIN
      public/favicon.ico
  18. 303 0
      public/mockServiceWorker.js
  19. 28 0
      src/App.vue
  20. 87 0
      src/assets/base.scss
  21. BIN
      src/assets/imgs/login-banner@1x.png
  22. BIN
      src/assets/imgs/login-banner@2x.png
  23. 7 0
      src/assets/logo-banner.svg
  24. 13 0
      src/assets/logo.svg
  25. 1 0
      src/assets/main.scss
  26. 2 0
      src/assets/variables.scss
  27. 11 0
      src/client/duplicateCheck.client.ts
  28. 39 0
      src/client/report.client.ts
  29. 35 0
      src/client/reports.client.ts
  30. 40 0
      src/components/HelloWorld.vue
  31. 88 0
      src/components/NavMenu.vue
  32. 30 0
      src/components/PageHeader.vue
  33. 26 0
      src/components/SpinComponent.vue
  34. 86 0
      src/components/TheWelcome.vue
  35. 86 0
      src/components/WelcomeItem.vue
  36. 11 0
      src/components/__tests__/HelloWorld.spec.ts
  37. 7 0
      src/components/icons/IconCommunity.vue
  38. 7 0
      src/components/icons/IconDocumentation.vue
  39. 7 0
      src/components/icons/IconEcosystem.vue
  40. 7 0
      src/components/icons/IconSupport.vue
  41. 19 0
      src/components/icons/IconTooling.vue
  42. 18 0
      src/libs/log.lib.ts
  43. 51 0
      src/libs/reportExport.lib.ts
  44. 10 0
      src/libs/url.lib.ts
  45. 18 0
      src/main.ts
  46. 64 0
      src/router/index.ts
  47. 57 0
      src/services/httpClient.ts
  48. 32 0
      src/services/report.service.ts
  49. 9 0
      src/services/reports.service.ts
  50. 75 0
      src/services/user.service.ts
  51. 42 0
      src/stores/auth.store.ts
  52. 12 0
      src/stores/counter.ts
  53. 15 0
      src/stores/side-bar.ts
  54. 11 0
      src/types/company.types.ts
  55. 6 0
      src/types/httpClients.types.ts
  56. 4 0
      src/types/pagination.types.ts
  57. 207 0
      src/types/report.types.ts
  58. 5 0
      src/types/response.types.ts
  59. 38 0
      src/types/user.types.ts
  60. 153 0
      src/views/LoginView.vue
  61. 67 0
      src/views/RouteView.vue
  62. 372 0
      src/views/report/ReportEditorView.vue
  63. 288 0
      src/views/report/ReportListView.vue
  64. 265 0
      src/views/report/components/ChapterEditor.vue
  65. 68 0
      src/views/report/components/ChapterTypeSelect.vue
  66. 129 0
      src/views/report/components/ContentEditor.vue
  67. 132 0
      src/views/report/components/LogoComponent.vue
  68. 105 0
      src/views/report/components/TemplateSaveModal.vue
  69. 54 0
      src/views/report/components/TemplateSelect.vue
  70. 145 0
      src/views/report/components/dynamic-meta/DynamicMeta.vue
  71. 67 0
      src/views/report/components/dynamic-meta/ReportMetaDateInput.vue
  72. 79 0
      src/views/report/components/dynamic-meta/ReportMetaDateRangeInput.vue
  73. 56 0
      src/views/report/components/dynamic-meta/ReportMetaTextInput.vue
  74. 97 0
      src/views/report/components/reference/AIContentDraw.vue
  75. 207 0
      src/views/report/components/reference/ReferenceDraw.vue
  76. 36 0
      src/views/report/components/reference/ReferenceSearchBox.vue
  77. 154 0
      src/views/report/components/reference/ReferenceSearchResultItem.vue
  78. 108 0
      src/views/report/components/report-create/RelationCompanyInput.vue
  79. 39 0
      src/views/report/components/report-create/ReportField.vue
  80. 41 0
      src/views/report/components/report-list/CompanySelect.vue
  81. 47 0
      src/views/report/components/report-list/CompanySelectItem.vue
  82. 68 0
      src/views/report/components/report-list/QueryBox.vue
  83. 93 0
      src/views/report/components/report-list/ReportItem.vue
  84. 4 0
      src/views/report/reportComponent.types.ts
  85. 94 0
      src/views/user/CreateUser.vue
  86. 198 0
      src/views/user/UserList.vue
  87. 12 0
      tsconfig.app.json
  88. 8 0
      tsconfig.config.json
  89. 14 0
      tsconfig.json
  90. 9 0
      tsconfig.vitest.json
  91. 41 0
      vite.config.ts
  92. 4485 0
      yarn.lock

+ 20 - 0
.eslintrc.cjs

@@ -0,0 +1,20 @@
+/* eslint-env node */
+require("@rushstack/eslint-patch/modern-module-resolution");
+
+module.exports = {
+  root: true,
+  extends: [
+    "plugin:vue/vue3-essential",
+    "eslint:recommended",
+    "@vue/eslint-config-typescript",
+  ],
+  overrides: [
+    {
+      files: ["cypress/e2e/**.{cy,spec}.{js,ts,jsx,tsx}"],
+      extends: ["plugin:cypress/recommended"],
+    },
+  ],
+  parserOptions: {
+    ecmaVersion: "latest",
+  },
+};

+ 28 - 0
.gitignore

@@ -0,0 +1,28 @@
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+lerna-debug.log*
+
+node_modules
+.DS_Store
+dist
+dist-ssr
+coverage
+*.local
+
+/cypress/videos/
+/cypress/screenshots/
+
+# Editor directories and files
+.vscode/*
+!.vscode/extensions.json
+.idea
+*.suo
+*.ntvs*
+*.njsproj
+*.sln
+*.sw?

+ 1 - 0
.prettierrc.json

@@ -0,0 +1 @@
+{}

+ 3 - 0
.vscode/extensions.json

@@ -0,0 +1,3 @@
+{
+  "recommendations": ["Vue.volar", "Vue.vscode-typescript-vue-plugin"]
+}

+ 7 - 0
Dockerfile

@@ -0,0 +1,7 @@
+FROM nginx:alpine
+WORKDIR /app
+COPY dist /app
+COPY nginx/nginx.conf /etc/nginx/
+COPY nginx/conf.d/default.conf /etc/nginx/conf.d
+
+EXPOSE 8080

+ 8 - 0
cypress/e2e/example.cy.ts

@@ -0,0 +1,8 @@
+// https://docs.cypress.io/api/introduction/api.html
+
+describe('My First Test', () => {
+  it('visits the app root url', () => {
+    cy.visit('/')
+    cy.contains('h1', 'You did it!')
+  })
+})

+ 10 - 0
cypress/e2e/tsconfig.json

@@ -0,0 +1,10 @@
+{
+  "extends": "@vue/tsconfig/tsconfig.web.json",
+  "include": ["./**/*", "../support/**/*"],
+  "compilerOptions": {
+    "isolatedModules": false,
+    "target": "es5",
+    "lib": ["es5", "dom"],
+    "types": ["cypress"]
+  }
+}

+ 5 - 0
cypress/fixtures/example.json

@@ -0,0 +1,5 @@
+{
+  "name": "Using fixtures to represent data",
+  "email": "hello@cypress.io",
+  "body": "Fixtures are a great way to mock data for responses to routes"
+}

+ 39 - 0
cypress/support/commands.ts

@@ -0,0 +1,39 @@
+/// <reference types="cypress" />
+// ***********************************************
+// This example commands.ts shows you how to
+// create various custom commands and overwrite
+// existing commands.
+//
+// For more comprehensive examples of custom
+// commands please read more here:
+// https://on.cypress.io/custom-commands
+// ***********************************************
+//
+//
+// -- This is a parent command --
+// Cypress.Commands.add('login', (email, password) => { ... })
+//
+//
+// -- This is a child command --
+// Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... })
+//
+//
+// -- This is a dual command --
+// Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... })
+//
+//
+// -- This will overwrite an existing command --
+// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... })
+//
+// declare global {
+//   namespace Cypress {
+//     interface Chainable {
+//       login(email: string, password: string): Chainable<void>
+//       drag(subject: string, options?: Partial<TypeOptions>): Chainable<Element>
+//       dismiss(subject: string, options?: Partial<TypeOptions>): Chainable<Element>
+//       visit(originalFn: CommandOriginalFn, url: string, options: Partial<VisitOptions>): Chainable<Element>
+//     }
+//   }
+// }
+
+export {}

+ 20 - 0
cypress/support/e2e.ts

@@ -0,0 +1,20 @@
+// ***********************************************************
+// This example support/index.js is processed and
+// loaded automatically before your test files.
+//
+// This is a great place to put global configuration and
+// behavior that modifies Cypress.
+//
+// You can change the location of this file or turn off
+// automatically serving support files with the
+// 'supportFile' configuration option.
+//
+// You can read more here:
+// https://on.cypress.io/configuration
+// ***********************************************************
+
+// Import commands.js using ES2015 syntax:
+import './commands'
+
+// Alternatively you can use CommonJS syntax:
+// require('./commands')

+ 1 - 0
env.d.ts

@@ -0,0 +1 @@
+/// <reference types="vite/client" />

+ 13 - 0
index.html

@@ -0,0 +1,13 @@
+<!DOCTYPE html>
+<html lang="en">
+  <head>
+    <meta charset="UTF-8">
+    <link rel="icon" href="/favicon.ico">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <title>后台管理 | 技淘平台智慧产权大脑</title>
+  </head>
+  <body>
+    <div id="app"></div>
+    <script type="module" src="/src/main.ts"></script>
+  </body>
+</html>

+ 11 - 0
nginx/conf.d/default.conf

@@ -0,0 +1,11 @@
+server {
+    listen       8080;
+    listen  [::]:8080;
+    server_name  _;
+
+    location / {
+        root   /app;
+        index  index.html index.htm;
+        try_files $uri $uri/ /index.html;
+    }
+}

+ 31 - 0
nginx/nginx.conf

@@ -0,0 +1,31 @@
+user  nginx;
+worker_processes  2;
+
+error_log  /var/log/nginx/error.log notice;
+pid        /var/run/nginx.pid;
+
+
+events {
+    worker_connections  1024;
+}
+
+
+http {
+    include       /etc/nginx/mime.types;
+    default_type  application/octet-stream;
+
+    log_format  main  '$remote_addr - $remote_user [$time_local] "$request" '
+                      '$status $body_bytes_sent "$http_referer" '
+                      '"$http_user_agent" "$http_x_forwarded_for"';
+
+    access_log  /var/log/nginx/access.log  main;
+
+    sendfile        on;
+    #tcp_nopush     on;
+
+    keepalive_timeout  65;
+
+    gzip  on;
+
+    include /etc/nginx/conf.d/*.conf;
+}

Diferenças do arquivo suprimidas por serem muito extensas
+ 5996 - 0
package-lock.json


+ 56 - 0
package.json

@@ -0,0 +1,56 @@
+{
+  "name": "admin-front",
+  "version": "0.0.0",
+  "private": true,
+  "scripts": {
+    "dev": "vite",
+    "build": "run-p type-check build-only",
+    "preview": "vite preview",
+    "test:unit": "vitest --environment jsdom --root src/",
+    "test:e2e:dev": "start-server-and-test 'vite dev --port 4173' :4173 'cypress open --e2e'",
+    "build-only": "vite build",
+    "type-check": "vue-tsc --noEmit -p tsconfig.vitest.json --composite false",
+    "lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore"
+  },
+  "dependencies": {
+    "-": "0.0.1",
+    "@antv/g6": "^4.7.16",
+    "@tinymce/tinymce-vue": "^5.0.0",
+    "@types/lodash": "^4.14.188",
+    "ant-design-vue": "^3.2.15",
+    "axios": "^1.1.3",
+    "less-loader": "^6.0.0",
+    "nzh": "^1.0.8",
+    "pinia": "^2.0.21",
+    "save": "^2.9.0",
+    "tinymce": "^6.3.1",
+    "vue": "^3.2.38",
+    "vue-router": "^4.1.5",
+    "vuedraggable": "^4.1.0"
+  },
+  "devDependencies": {
+    "@rushstack/eslint-patch": "^1.1.4",
+    "@types/jsdom": "^20.0.1",
+    "@types/node": "^18.11.12",
+    "@vitejs/plugin-vue": "^4.0.0",
+    "@vitejs/plugin-vue-jsx": "^3.0.0",
+    "@vue/eslint-config-prettier": "^7.0.0",
+    "@vue/eslint-config-typescript": "^11.0.0",
+    "@vue/test-utils": "^2.2.6",
+    "@vue/tsconfig": "^0.1.3",
+    "eslint": "^8.22.0",
+    "eslint-plugin-cypress": "^2.12.1",
+    "eslint-plugin-vue": "^9.3.0",
+    "jsdom": "^20.0.3",
+    "less": "^4.1.3",
+    "npm-run-all": "^4.1.5",
+    "prettier": "^2.7.1",
+    "sass": "^1.58.3",
+    "start-server-and-test": "^1.15.2",
+    "typescript": "~4.7.4",
+    "unplugin-vue-components": "^0.24.0",
+    "vite": "^4.0.0",
+    "vitest": "^0.25.6",
+    "vue-tsc": "^1.0.12"
+  }
+}

BIN
public/favicon.ico


+ 303 - 0
public/mockServiceWorker.js

@@ -0,0 +1,303 @@
+/* eslint-disable */
+/* tslint:disable */
+
+/**
+ * Mock Service Worker (0.48.1).
+ * @see https://github.com/mswjs/msw
+ * - Please do NOT modify this file.
+ * - Please do NOT serve this file on production.
+ */
+
+const INTEGRITY_CHECKSUM = '3d6b9f06410d179a7f7404d4bf4c3c70'
+const activeClientIds = new Set()
+
+self.addEventListener('install', function () {
+  self.skipWaiting()
+})
+
+self.addEventListener('activate', function (event) {
+  event.waitUntil(self.clients.claim())
+})
+
+self.addEventListener('message', async function (event) {
+  const clientId = event.source.id
+
+  if (!clientId || !self.clients) {
+    return
+  }
+
+  const client = await self.clients.get(clientId)
+
+  if (!client) {
+    return
+  }
+
+  const allClients = await self.clients.matchAll({
+    type: 'window',
+  })
+
+  switch (event.data) {
+    case 'KEEPALIVE_REQUEST': {
+      sendToClient(client, {
+        type: 'KEEPALIVE_RESPONSE',
+      })
+      break
+    }
+
+    case 'INTEGRITY_CHECK_REQUEST': {
+      sendToClient(client, {
+        type: 'INTEGRITY_CHECK_RESPONSE',
+        payload: INTEGRITY_CHECKSUM,
+      })
+      break
+    }
+
+    case 'MOCK_ACTIVATE': {
+      activeClientIds.add(clientId)
+
+      sendToClient(client, {
+        type: 'MOCKING_ENABLED',
+        payload: true,
+      })
+      break
+    }
+
+    case 'MOCK_DEACTIVATE': {
+      activeClientIds.delete(clientId)
+      break
+    }
+
+    case 'CLIENT_CLOSED': {
+      activeClientIds.delete(clientId)
+
+      const remainingClients = allClients.filter((client) => {
+        return client.id !== clientId
+      })
+
+      // Unregister itself when there are no more clients
+      if (remainingClients.length === 0) {
+        self.registration.unregister()
+      }
+
+      break
+    }
+  }
+})
+
+self.addEventListener('fetch', function (event) {
+  const { request } = event
+  const accept = request.headers.get('accept') || ''
+
+  // Bypass server-sent events.
+  if (accept.includes('text/event-stream')) {
+    return
+  }
+
+  // Bypass navigation requests.
+  if (request.mode === 'navigate') {
+    return
+  }
+
+  // Opening the DevTools triggers the "only-if-cached" request
+  // that cannot be handled by the worker. Bypass such requests.
+  if (request.cache === 'only-if-cached' && request.mode !== 'same-origin') {
+    return
+  }
+
+  // Bypass all requests when there are no active clients.
+  // Prevents the self-unregistered worked from handling requests
+  // after it's been deleted (still remains active until the next reload).
+  if (activeClientIds.size === 0) {
+    return
+  }
+
+  // Generate unique request ID.
+  const requestId = Math.random().toString(16).slice(2)
+
+  event.respondWith(
+    handleRequest(event, requestId).catch((error) => {
+      if (error.name === 'NetworkError') {
+        console.warn(
+          '[MSW] Successfully emulated a network error for the "%s %s" request.',
+          request.method,
+          request.url,
+        )
+        return
+      }
+
+      // At this point, any exception indicates an issue with the original request/response.
+      console.error(
+        `\
+[MSW] Caught an exception from the "%s %s" request (%s). This is probably not a problem with Mock Service Worker. There is likely an additional logging output above.`,
+        request.method,
+        request.url,
+        `${error.name}: ${error.message}`,
+      )
+    }),
+  )
+})
+
+async function handleRequest(event, requestId) {
+  const client = await resolveMainClient(event)
+  const response = await getResponse(event, client, requestId)
+
+  // Send back the response clone for the "response:*" life-cycle events.
+  // Ensure MSW is active and ready to handle the message, otherwise
+  // this message will pend indefinitely.
+  if (client && activeClientIds.has(client.id)) {
+    ;(async function () {
+      const clonedResponse = response.clone()
+      sendToClient(client, {
+        type: 'RESPONSE',
+        payload: {
+          requestId,
+          type: clonedResponse.type,
+          ok: clonedResponse.ok,
+          status: clonedResponse.status,
+          statusText: clonedResponse.statusText,
+          body:
+            clonedResponse.body === null ? null : await clonedResponse.text(),
+          headers: Object.fromEntries(clonedResponse.headers.entries()),
+          redirected: clonedResponse.redirected,
+        },
+      })
+    })()
+  }
+
+  return response
+}
+
+// Resolve the main client for the given event.
+// Client that issues a request doesn't necessarily equal the client
+// that registered the worker. It's with the latter the worker should
+// communicate with during the response resolving phase.
+async function resolveMainClient(event) {
+  const client = await self.clients.get(event.clientId)
+
+  if (client?.frameType === 'top-level') {
+    return client
+  }
+
+  const allClients = await self.clients.matchAll({
+    type: 'window',
+  })
+
+  return allClients
+    .filter((client) => {
+      // Get only those clients that are currently visible.
+      return client.visibilityState === 'visible'
+    })
+    .find((client) => {
+      // Find the client ID that's recorded in the
+      // set of clients that have registered the worker.
+      return activeClientIds.has(client.id)
+    })
+}
+
+async function getResponse(event, client, requestId) {
+  const { request } = event
+  const clonedRequest = request.clone()
+
+  function passthrough() {
+    // Clone the request because it might've been already used
+    // (i.e. its body has been read and sent to the client).
+    const headers = Object.fromEntries(clonedRequest.headers.entries())
+
+    // Remove MSW-specific request headers so the bypassed requests
+    // comply with the server's CORS preflight check.
+    // Operate with the headers as an object because request "Headers"
+    // are immutable.
+    delete headers['x-msw-bypass']
+
+    return fetch(clonedRequest, { headers })
+  }
+
+  // Bypass mocking when the client is not active.
+  if (!client) {
+    return passthrough()
+  }
+
+  // Bypass initial page load requests (i.e. static assets).
+  // The absence of the immediate/parent client in the map of the active clients
+  // means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet
+  // and is not ready to handle requests.
+  if (!activeClientIds.has(client.id)) {
+    return passthrough()
+  }
+
+  // Bypass requests with the explicit bypass header.
+  // Such requests can be issued by "ctx.fetch()".
+  if (request.headers.get('x-msw-bypass') === 'true') {
+    return passthrough()
+  }
+
+  // Notify the client that a request has been intercepted.
+  const clientMessage = await sendToClient(client, {
+    type: 'REQUEST',
+    payload: {
+      id: requestId,
+      url: request.url,
+      method: request.method,
+      headers: Object.fromEntries(request.headers.entries()),
+      cache: request.cache,
+      mode: request.mode,
+      credentials: request.credentials,
+      destination: request.destination,
+      integrity: request.integrity,
+      redirect: request.redirect,
+      referrer: request.referrer,
+      referrerPolicy: request.referrerPolicy,
+      body: await request.text(),
+      bodyUsed: request.bodyUsed,
+      keepalive: request.keepalive,
+    },
+  })
+
+  switch (clientMessage.type) {
+    case 'MOCK_RESPONSE': {
+      return respondWithMock(clientMessage.data)
+    }
+
+    case 'MOCK_NOT_FOUND': {
+      return passthrough()
+    }
+
+    case 'NETWORK_ERROR': {
+      const { name, message } = clientMessage.data
+      const networkError = new Error(message)
+      networkError.name = name
+
+      // Rejecting a "respondWith" promise emulates a network error.
+      throw networkError
+    }
+  }
+
+  return passthrough()
+}
+
+function sendToClient(client, message) {
+  return new Promise((resolve, reject) => {
+    const channel = new MessageChannel()
+
+    channel.port1.onmessage = (event) => {
+      if (event.data && event.data.error) {
+        return reject(event.data.error)
+      }
+
+      resolve(event.data)
+    }
+
+    client.postMessage(message, [channel.port2])
+  })
+}
+
+function sleep(timeMs) {
+  return new Promise((resolve) => {
+    setTimeout(resolve, timeMs)
+  })
+}
+
+async function respondWithMock(response) {
+  await sleep(response.delay)
+  return new Response(response.body, response)
+}

+ 28 - 0
src/App.vue

@@ -0,0 +1,28 @@
+<script setup lang="ts">
+import { RouterView } from "vue-router";
+import dayjs from "dayjs";
+import "dayjs/locale/zh-cn";
+import zhCN from "ant-design-vue/es/locale/zh_CN";
+import { onMounted, ref } from "vue";
+import { useAuthStore } from "./stores/auth.store";
+import { routeToLogin } from "./router";
+dayjs.locale("zh-cn");
+
+const locale = ref(zhCN);
+
+const authStore = useAuthStore();
+
+onMounted(() => {
+  authStore.checkLogin().then((isLogin) => {
+    if (!isLogin) {
+      routeToLogin();
+    }
+  });
+});
+</script>
+
+<template>
+  <a-config-provider :locale="locale">
+    <RouterView />
+  </a-config-provider>
+</template>

+ 87 - 0
src/assets/base.scss

@@ -0,0 +1,87 @@
+/* color palette from <https://github.com/vuejs/theme> */
+/* :root {
+  --vt-c-white: #ffffff;
+  --vt-c-white-soft: #f8f8f8;
+  --vt-c-white-mute: #f2f2f2;
+
+  --vt-c-black: #181818;
+  --vt-c-black-soft: #222222;
+  --vt-c-black-mute: #282828;
+
+  --vt-c-indigo: #2c3e50;
+
+  --vt-c-divider-light-1: rgba(60, 60, 60, 0.29);
+  --vt-c-divider-light-2: rgba(60, 60, 60, 0.12);
+  --vt-c-divider-dark-1: rgba(84, 84, 84, 0.65);
+  --vt-c-divider-dark-2: rgba(84, 84, 84, 0.48);
+
+  --vt-c-text-light-1: var(--vt-c-indigo);
+  --vt-c-text-light-2: rgba(60, 60, 60, 0.66);
+  --vt-c-text-dark-1: var(--vt-c-white);
+  --vt-c-text-dark-2: rgba(235, 235, 235, 0.64);
+} */
+
+/* semantic color variables for this project */
+/* :root {
+  --color-background: var(--vt-c-white);
+  --color-background-soft: var(--vt-c-white-soft);
+  --color-background-mute: var(--vt-c-white-mute);
+
+  --color-border: var(--vt-c-divider-light-2);
+  --color-border-hover: var(--vt-c-divider-light-1);
+
+  --color-heading: var(--vt-c-text-light-1);
+  --color-text: var(--vt-c-text-light-1);
+
+  --section-gap: 160px;
+}
+
+@media (prefers-color-scheme: dark) {
+  :root {
+    --color-background: var(--vt-c-black);
+    --color-background-soft: var(--vt-c-black-soft);
+    --color-background-mute: var(--vt-c-black-mute);
+
+    --color-border: var(--vt-c-divider-dark-2);
+    --color-border-hover: var(--vt-c-divider-dark-1);
+
+    --color-heading: var(--vt-c-text-dark-1);
+    --color-text: var(--vt-c-text-dark-2);
+  }
+}
+
+*,
+*::before,
+*::after {
+  box-sizing: border-box;
+  margin: 0;
+  position: relative;
+  font-weight: normal;
+}
+
+body {
+  min-height: 100vh;
+  color: var(--color-text);
+  background: var(--color-background);
+  transition: color 0.5s, background-color 0.5s;
+  line-height: 1.6;
+  font-family: Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu,
+    Cantarell, 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
+  font-size: 15px;
+  text-rendering: optimizeLegibility;
+  -webkit-font-smoothing: antialiased;
+  -moz-osx-font-smoothing: grayscale;
+} */
+
+$border-color-primary: #f0f0f0;
+$border-color-secondary: #e0e0e0;
+
+$desc-color: #4f4f4f;
+
+h1.title {
+  font-size: 1.75em;
+}
+
+.text-red {
+  color: red;
+}

BIN
src/assets/imgs/login-banner@1x.png


BIN
src/assets/imgs/login-banner@2x.png


Diferenças do arquivo suprimidas por serem muito extensas
+ 7 - 0
src/assets/logo-banner.svg


Diferenças do arquivo suprimidas por serem muito extensas
+ 13 - 0
src/assets/logo.svg


+ 1 - 0
src/assets/main.scss

@@ -0,0 +1 @@
+@import "./base.scss";

+ 2 - 0
src/assets/variables.scss

@@ -0,0 +1,2 @@
+
+$border-color: #d9d9d9;

+ 11 - 0
src/client/duplicateCheck.client.ts

@@ -0,0 +1,11 @@
+import httpClient from "@/services/httpClient";
+import type { DuplicateCheckResponse , DuplicateCheckRequest} from "@/types/report.types";
+import type { Response } from "@/types/response.types";
+import * as urlHelper from "@/libs/url.lib"
+
+const _url = urlHelper.withPrefix('/gw/user/duplicateCheck');
+
+export async function duplicateCheck(duplicateCheckRequest: DuplicateCheckRequest){
+  const resp = await httpClient.post<Response<DuplicateCheckResponse>>(_url(), duplicateCheckRequest);
+  return resp.data.data;
+}

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

@@ -0,0 +1,39 @@
+import httpClient from "@/services/httpClient";
+import type { ReportCreateRequest, ReportSaveRequest } from "@/types/report.types";
+import type { Response } from "@/types/response.types";
+import * as urlHelper from "@/libs/url.lib"
+
+const _url = urlHelper.withPrefix('/gw/user/report');
+
+/**
+ * 通过报告ID获取报告数据
+ * @param id 报告ID
+ * @returns Promise
+ */
+export async function get(id: number): Promise<ReportSaveRequest> {
+  const resp = await httpClient.get<Response<ReportSaveRequest>>(_url(`/${id}`))
+  return resp.data.data;
+}
+
+/**
+ * 请求后端服务创建一个新报告
+ */
+export async function create(request: ReportCreateRequest): Promise<number> {
+  const resp = await httpClient.put<Response<number>>(_url(), request);
+  return resp.data.data;
+}
+
+export async function save(reportSaveRequest: ReportSaveRequest) {
+  const resp = await httpClient.post<Response<ReportSaveRequest>>(_url(), reportSaveRequest);
+  return resp.data.data;
+}
+
+export async function updateStatus(id: number) {
+  const resp = await httpClient.put<Response<number>>(_url(`/updateStatus/${id}`));
+  return resp.data.data;
+}
+
+export async function remove(id: number) {
+  const resp = await httpClient.delete<Response<boolean>>(_url(`${id}`))
+  return resp.data.data;
+}

+ 35 - 0
src/client/reports.client.ts

@@ -0,0 +1,35 @@
+/// 报告列表请求客户端
+
+import { withPrefix } from "@/libs/url.lib";
+import httpClient from "@/services/httpClient";
+import type { CompanyAgg } from "@/types/company.types";
+import type { ReportsResponse } from "@/types/report.types";
+import type { Response } from "@/types/response.types";
+
+const _url = withPrefix("/api")
+
+export async function query(page: number, companyIds?: number[], field?: string, keyword?: string, status?: number): Promise<ReportsResponse> {
+  //const params: any = { page, companyIds, field, keyword }
+  // if (companyId) {
+  //   params['companyId'] = companyId;
+  // }
+
+  let param = ''
+  if (companyIds && companyIds.length > 0) {
+    param = companyIds.map((id: number)=>`companyIds=${id}`).join('&');
+  }
+  if (field && keyword && keyword.length > 0 && field.length > 0) {
+    param += `&field=${field}&keyword=${keyword}`
+  }
+  if (status || status == 0) {
+     param += `&status=${status}`
+  }
+
+  const resp = await httpClient.get<Response<ReportsResponse>>(_url(`/srUserReport/all/?page=${page}&${param}`))
+  return resp.data.data
+}
+
+export async function agg(): Promise<CompanyAgg[]> {
+  const resp = await httpClient.get<Response<CompanyAgg[]>>(_url("/company/agg"))
+  return resp.data.data;
+}

+ 40 - 0
src/components/HelloWorld.vue

@@ -0,0 +1,40 @@
+<script setup lang="ts">
+defineProps<{
+  msg: string
+}>()
+</script>
+
+<template>
+  <div class="greetings">
+    <h1 class="green">{{ msg }}</h1>
+    <h3>
+      You’ve successfully created a project with
+      <a href="https://vitejs.dev/" target="_blank" rel="noopener">Vite</a> +
+      <a href="https://vuejs.org/" target="_blank" rel="noopener">Vue 3</a>. What's next?
+    </h3>
+  </div>
+</template>
+
+<style scoped>
+h1 {
+  font-weight: 500;
+  font-size: 2.6rem;
+  top: -10px;
+}
+
+h3 {
+  font-size: 1.2rem;
+}
+
+.greetings h1,
+.greetings h3 {
+  text-align: center;
+}
+
+@media (min-width: 1024px) {
+  .greetings h1,
+  .greetings h3 {
+    text-align: left;
+  }
+}
+</style>

+ 88 - 0
src/components/NavMenu.vue

@@ -0,0 +1,88 @@
+<script setup lang="ts">
+import { FilePptOutlined, LogoutOutlined } from "@ant-design/icons-vue";
+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";
+
+const selectedKeys = ref<string[]>(["/"]);
+const openKeys = ref<string[]>(["/"]);
+// 当前路由
+const route = useRoute();
+// 授权store
+const authStore = useAuthStore();
+
+onMounted(() => {
+  openKeys.value = getOpenKeys(route.path);
+  selectedKeys.value = [route.path];
+
+  watch(
+    () => route.path,
+    (path: string) => {
+      const currentOpenkeys = getOpenKeys(path);
+      openKeys.value = _.uniq(_.concat(openKeys.value, currentOpenkeys));
+      selectedKeys.value = openKeys.value;
+    }
+  );
+});
+
+function getOpenKeys(path: string): string[] {
+  const parts = _.dropRight(
+    path
+      .split("/")
+      .filter((x) => x != "")
+      .map((x) => "/" + x),
+    1
+  );
+  if (parts.length == 0) {
+    return ["/"];
+  }
+  let openKeys = parts.reduce((acc, item) => {
+    if (acc.length == 0) {
+      return acc.concat([item]);
+    }
+    return acc.concat([acc[acc.length - 1] + item]);
+  }, [] as string[]);
+  openKeys.unshift("/");
+  return openKeys;
+}
+
+function logout(e: MouseEvent) {
+  authStore.logout();
+  routeToLogin();
+  e.stopPropagation();
+}
+</script>
+
+<template>
+  <a-menu v-model:openKeys="openKeys" v-model:selectedKeys="selectedKeys" mode="inline">
+    <a-sub-menu key="/user">
+      <template #icon>
+        <FilePptOutlined />
+      </template>
+      <template #title>用户管理</template>
+      <a-menu-item key="/user/list">
+        <RouterLink to="/user/list">用户管理</RouterLink>
+      </a-menu-item>
+      <a-menu-item key="/user/create">
+        <RouterLink to="/user/create">创建用户</RouterLink>
+      </a-menu-item>
+    </a-sub-menu>
+    <a-sub-menu key="/report">
+      <template #icon>
+        <FilePptOutlined />
+      </template>
+      <template #title>报告管理</template>
+      <a-menu-item key="/report/rlist">
+        <RouterLink to="/report/rlist">报告列表</RouterLink>
+      </a-menu-item>
+    </a-sub-menu>
+    <a-menu-item key="/logout" @click="logout">
+      <template #icon>
+        <logout-outlined />
+      </template>
+      退出系统
+    </a-menu-item>
+  </a-menu>
+</template>

+ 30 - 0
src/components/PageHeader.vue

@@ -0,0 +1,30 @@
+<script setup lang="ts">
+defineProps({
+  title: {
+    type: String,
+    required: true,
+  }
+});
+</script>
+
+<template>
+  <a-row type="flex" justify="space-between" :align="'middle'">
+    <a-col>
+      <h1 class="title">{{ title }}</h1>
+    </a-col>
+    <a-col>
+      <slot></slot>
+    </a-col>
+  </a-row>
+  <a-divider class="divider" />
+</template>
+
+<style scoped lang="scss">
+h1.title {
+  font-size: 1.75em;
+}
+
+.divider {
+  margin-top: 1em;
+}
+</style>

+ 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>

+ 86 - 0
src/components/TheWelcome.vue

@@ -0,0 +1,86 @@
+<script setup lang="ts">
+import WelcomeItem from './WelcomeItem.vue'
+import DocumentationIcon from './icons/IconDocumentation.vue'
+import ToolingIcon from './icons/IconTooling.vue'
+import EcosystemIcon from './icons/IconEcosystem.vue'
+import CommunityIcon from './icons/IconCommunity.vue'
+import SupportIcon from './icons/IconSupport.vue'
+</script>
+
+<template>
+  <WelcomeItem>
+    <template #icon>
+      <DocumentationIcon />
+    </template>
+    <template #heading>Documentation</template>
+
+    Vue’s
+    <a href="https://vuejs.org/" target="_blank" rel="noopener">official documentation</a>
+    provides you with all information you need to get started.
+  </WelcomeItem>
+
+  <WelcomeItem>
+    <template #icon>
+      <ToolingIcon />
+    </template>
+    <template #heading>Tooling</template>
+
+    This project is served and bundled with
+    <a href="https://vitejs.dev/guide/features.html" target="_blank" rel="noopener">Vite</a>. The
+    recommended IDE setup is
+    <a href="https://code.visualstudio.com/" target="_blank" rel="noopener">VSCode</a> +
+    <a href="https://github.com/johnsoncodehk/volar" target="_blank" rel="noopener">Volar</a>. If
+    you need to test your components and web pages, check out
+    <a href="https://www.cypress.io/" target="_blank" rel="noopener">Cypress</a> and
+    <a href="https://on.cypress.io/component" target="_blank">Cypress Component Testing</a>.
+
+    <br />
+
+    More instructions are available in <code>README.md</code>.
+  </WelcomeItem>
+
+  <WelcomeItem>
+    <template #icon>
+      <EcosystemIcon />
+    </template>
+    <template #heading>Ecosystem</template>
+
+    Get official tools and libraries for your project:
+    <a href="https://pinia.vuejs.org/" target="_blank" rel="noopener">Pinia</a>,
+    <a href="https://router.vuejs.org/" target="_blank" rel="noopener">Vue Router</a>,
+    <a href="https://test-utils.vuejs.org/" target="_blank" rel="noopener">Vue Test Utils</a>, and
+    <a href="https://github.com/vuejs/devtools" target="_blank" rel="noopener">Vue Dev Tools</a>. If
+    you need more resources, we suggest paying
+    <a href="https://github.com/vuejs/awesome-vue" target="_blank" rel="noopener">Awesome Vue</a>
+    a visit.
+  </WelcomeItem>
+
+  <WelcomeItem>
+    <template #icon>
+      <CommunityIcon />
+    </template>
+    <template #heading>Community</template>
+
+    Got stuck? Ask your question on
+    <a href="https://chat.vuejs.org" target="_blank" rel="noopener">Vue Land</a>, our official
+    Discord server, or
+    <a href="https://stackoverflow.com/questions/tagged/vue.js" target="_blank" rel="noopener"
+      >StackOverflow</a
+    >. You should also subscribe to
+    <a href="https://news.vuejs.org" target="_blank" rel="noopener">our mailing list</a> and follow
+    the official
+    <a href="https://twitter.com/vuejs" target="_blank" rel="noopener">@vuejs</a>
+    twitter account for latest news in the Vue world.
+  </WelcomeItem>
+
+  <WelcomeItem>
+    <template #icon>
+      <SupportIcon />
+    </template>
+    <template #heading>Support Vue</template>
+
+    As an independent project, Vue relies on community backing for its sustainability. You can help
+    us by
+    <a href="https://vuejs.org/sponsor/" target="_blank" rel="noopener">becoming a sponsor</a>.
+  </WelcomeItem>
+</template>

+ 86 - 0
src/components/WelcomeItem.vue

@@ -0,0 +1,86 @@
+<template>
+  <div class="item">
+    <i>
+      <slot name="icon"></slot>
+    </i>
+    <div class="details">
+      <h3>
+        <slot name="heading"></slot>
+      </h3>
+      <slot></slot>
+    </div>
+  </div>
+</template>
+
+<style scoped>
+.item {
+  margin-top: 2rem;
+  display: flex;
+}
+
+.details {
+  flex: 1;
+  margin-left: 1rem;
+}
+
+i {
+  display: flex;
+  place-items: center;
+  place-content: center;
+  width: 32px;
+  height: 32px;
+
+  color: var(--color-text);
+}
+
+h3 {
+  font-size: 1.2rem;
+  font-weight: 500;
+  margin-bottom: 0.4rem;
+  color: var(--color-heading);
+}
+
+@media (min-width: 1024px) {
+  .item {
+    margin-top: 0;
+    padding: 0.4rem 0 1rem calc(var(--section-gap) / 2);
+  }
+
+  i {
+    top: calc(50% - 25px);
+    left: -26px;
+    position: absolute;
+    border: 1px solid var(--color-border);
+    background: var(--color-background);
+    border-radius: 8px;
+    width: 50px;
+    height: 50px;
+  }
+
+  .item:before {
+    content: ' ';
+    border-left: 1px solid var(--color-border);
+    position: absolute;
+    left: 0;
+    bottom: calc(50% + 25px);
+    height: calc(50% - 25px);
+  }
+
+  .item:after {
+    content: ' ';
+    border-left: 1px solid var(--color-border);
+    position: absolute;
+    left: 0;
+    top: calc(50% + 25px);
+    height: calc(50% - 25px);
+  }
+
+  .item:first-of-type:before {
+    display: none;
+  }
+
+  .item:last-of-type:after {
+    display: none;
+  }
+}
+</style>

+ 11 - 0
src/components/__tests__/HelloWorld.spec.ts

@@ -0,0 +1,11 @@
+import { describe, it, expect } from 'vitest'
+
+import { mount } from '@vue/test-utils'
+import HelloWorld from '../HelloWorld.vue'
+
+describe('HelloWorld', () => {
+  it('renders properly', () => {
+    const wrapper = mount(HelloWorld, { props: { msg: 'Hello Vitest' } })
+    expect(wrapper.text()).toContain('Hello Vitest')
+  })
+})

Diferenças do arquivo suprimidas por serem muito extensas
+ 7 - 0
src/components/icons/IconCommunity.vue


Diferenças do arquivo suprimidas por serem muito extensas
+ 7 - 0
src/components/icons/IconDocumentation.vue


Diferenças do arquivo suprimidas por serem muito extensas
+ 7 - 0
src/components/icons/IconEcosystem.vue


+ 7 - 0
src/components/icons/IconSupport.vue

@@ -0,0 +1,7 @@
+<template>
+  <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor">
+    <path
+      d="M10 3.22l-.61-.6a5.5 5.5 0 0 0-7.666.105 5.5 5.5 0 0 0-.114 7.665L10 18.78l8.39-8.4a5.5 5.5 0 0 0-.114-7.665 5.5 5.5 0 0 0-7.666-.105l-.61.61z"
+    />
+  </svg>
+</template>

+ 19 - 0
src/components/icons/IconTooling.vue

@@ -0,0 +1,19 @@
+<!-- This icon is from <https://github.com/Templarian/MaterialDesign>, distributed under Apache 2.0 (https://www.apache.org/licenses/LICENSE-2.0) license-->
+<template>
+  <svg
+    xmlns="http://www.w3.org/2000/svg"
+    xmlns:xlink="http://www.w3.org/1999/xlink"
+    aria-hidden="true"
+    role="img"
+    class="iconify iconify--mdi"
+    width="24"
+    height="24"
+    preserveAspectRatio="xMidYMid meet"
+    viewBox="0 0 24 24"
+  >
+    <path
+      d="M20 18v-4h-3v1h-2v-1H9v1H7v-1H4v4h16M6.33 8l-1.74 4H7v-1h2v1h6v-1h2v1h2.41l-1.74-4H6.33M9 5v1h6V5H9m12.84 7.61c.1.22.16.48.16.8V18c0 .53-.21 1-.6 1.41c-.4.4-.85.59-1.4.59H4c-.55 0-1-.19-1.4-.59C2.21 19 2 18.53 2 18v-4.59c0-.32.06-.58.16-.8L4.5 7.22C4.84 6.41 5.45 6 6.33 6H7V5c0-.55.18-1 .57-1.41C7.96 3.2 8.44 3 9 3h6c.56 0 1.04.2 1.43.59c.39.41.57.86.57 1.41v1h.67c.88 0 1.49.41 1.83 1.22l2.34 5.39z"
+      fill="currentColor"
+    ></path>
+  </svg>
+</template>

+ 18 - 0
src/libs/log.lib.ts

@@ -0,0 +1,18 @@
+
+export class CompLog {
+  /**
+   * 输出错误信息,带上tag信息
+   * @param args 输出数据
+   */
+  public static logErr(tag: string) {
+    return (...args: any) => {
+      console.error(tag, ...args);
+    };
+  }
+
+  public static log(tag: string) {
+    return (...args: any) => {
+      console.log(tag, ...args);
+    };
+  }
+}

+ 51 - 0
src/libs/reportExport.lib.ts

@@ -0,0 +1,51 @@
+import { useAuthStore } from "@/stores/auth.store"
+
+export function useReportExport() {
+  const authStore = useAuthStore()
+
+  // async function download(name: string, id: number) {
+  //   return reportExportService.download(id).then((resp) => {
+  //     if (!resp) {
+  //       return
+  //     }
+  //     const url = window.URL.createObjectURL(new Blob([resp]))
+  //     const a = document.createElement('a')
+  //     a.style.display = 'none'
+  //     a.setAttribute('download', name + '.docx')
+  //     a.href = url
+  //     document.body.appendChild(a)
+  //     a.click()
+  //     window.URL.revokeObjectURL(a.href)
+  //     document.body.removeChild(a)
+  //   })
+  // }
+
+  function download(name: string, id: number) {
+    const href = link(id, authStore.token)
+    if (!href) {
+      return
+    }
+    const a = document.createElement("a")
+    a.setAttribute("download", name + '.docx')
+    a.setAttribute("href", href)
+    a.style.display = 'none'
+    document.body.appendChild(a)
+    a.click()
+    document.body.removeChild(a)
+  }
+
+  /**
+   * 生成报告下载链接
+   * @param id 报告ID
+   * @param token 用户token
+   * @returns 报告下载的链接字符串或者null
+   */
+  function link(id: number, token: string | null) {
+    if (!token || !id) {
+      return null
+    }
+    return `/gw/user/reportExport/${id}?token=${token}`
+  }
+
+  return { download, link }
+}

+ 10 - 0
src/libs/url.lib.ts

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

+ 18 - 0
src/main.ts

@@ -0,0 +1,18 @@
+import { createApp } from "vue";
+import { createPinia } from "pinia";
+import Antd from "ant-design-vue";
+
+import App from "./App.vue";
+import { router } from "./router";
+
+import "ant-design-vue/dist/antd.less";
+
+import "./assets/main.scss";
+
+const app = createApp(App);
+
+app.use(Antd);
+app.use(createPinia());
+app.use(router);
+
+app.mount("#app");

+ 64 - 0
src/router/index.ts

@@ -0,0 +1,64 @@
+import { useAuthStore } from "@/stores/auth.store";
+import { createRouter, createWebHistory } from "vue-router";
+import RouteView from "../views/RouteView.vue";
+
+export const router = createRouter({
+  history: createWebHistory(import.meta.env.BASE_URL),
+  routes: [
+    {
+      path: "/",
+      name: "Home",
+      redirect: "/user/list",
+    },
+    {
+      path: "/login",
+      name: "Login",
+      component: () => import("../views/LoginView.vue"),
+    },
+    {
+      path: "/user",
+      name: "User",
+      component: RouteView,
+      children: [
+        {
+          path: "create",
+          name: "Create",
+          component: () => import("../views/user/CreateUser.vue"),
+        },
+        {
+          path: "list",
+          name: "LIst",
+          component: () => import("../views/user/UserList.vue"),
+        },
+      ],
+    },
+    {
+      path: "/report",
+      name: "Report",
+      component: RouteView,
+      children: [
+        {
+          path: "rlist",
+          name: "RList",
+          component: () => import("../views/report/ReportListView.vue"),
+        },
+      ],
+    },
+  ],
+});
+
+router.beforeResolve(async (to) => {
+  const authStore = useAuthStore();
+
+  if (to.name != "Login" && !authStore.isLoginIn()) {
+    return { name: "Login" };
+  }
+});
+
+export function routeToHome() {
+  router.push({ name: "Home" });
+}
+
+export function routeToLogin() {
+  router.push({ name: "Login" });
+}

+ 57 - 0
src/services/httpClient.ts

@@ -0,0 +1,57 @@
+import { message } from "ant-design-vue";
+import axios, { AxiosError, type AxiosRequestConfig } from "axios";
+import { routeToLogin } from "@/router";
+import type { MessageType } from "ant-design-vue/lib/message";
+import type { ResponseData } from "@/types/httpClients.types";
+
+const urlPrefix = "/";
+
+const httpClient = axios.create({
+  baseURL: urlPrefix,
+  timeout: 60000,
+});
+
+// 添加请求拦截器
+httpClient.interceptors.request.use(
+  function (config: AxiosRequestConfig) {
+    // 在发送请求之前做些什么
+    const token = localStorage.getItem("token");
+    if (token && config.headers) {
+      config.headers.Authorization = token;
+    }
+    return config;
+  },
+  function (error) {
+    // 对请求错误做些什么
+    return Promise.reject(error);
+  }
+);
+
+let errorMsgHandler: MessageType | null = null;
+
+// 添加响应拦截器
+httpClient.interceptors.response.use(
+  function (response) {
+    // 对响应数据做点什么
+    return response;
+  },
+  function (error: AxiosError<ResponseData<string>>) {
+    if (error.response?.status == 404) {
+      message.error("请求参数错误!");
+    } else if (error.response?.status == 401) {
+      routeToLogin();
+    } else if (error.response?.status == 400) {
+      message.error(error.response.data.msg);
+    } else if (error) {
+      if (errorMsgHandler != null) {
+        errorMsgHandler();
+      }
+      errorMsgHandler = message.error(error.message);
+      // routeToLogin();
+    }
+
+    return Promise.reject(error);
+  }
+);
+
+export default httpClient;

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

@@ -0,0 +1,32 @@
+import * as reportClient from "@/client/report.client";
+import * as duplicateCheckClient from "@/client/duplicateCheck.client";
+import type { ReportCreateRequest, ReportSaveRequest, DuplicateCheckRequest } from "@/types/report.types";
+
+export async function get(id: number): Promise<ReportSaveRequest> {
+  const resp = await reportClient.get(id);
+  if (!resp.metadata) {
+    resp.metadata = [];
+  }
+  return resp;
+}
+
+export async function create(request: ReportCreateRequest): Promise<number> {
+  const id = await reportClient.create(request);
+  return id;
+}
+
+export async function save(reportSaveRequest: ReportSaveRequest) {
+  return reportClient.save(reportSaveRequest);
+}
+
+export async function updateStatus(id: number){
+  return reportClient.updateStatus(id);
+}
+
+export async function remove(id: number) {
+  return reportClient.remove(id);
+}
+
+export function duplicateCheck(duplicateCheckRequest: DuplicateCheckRequest){
+  return duplicateCheckClient.duplicateCheck(duplicateCheckRequest);
+}

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

@@ -0,0 +1,9 @@
+import * as reportsClient from "@/client/reports.client"
+
+export async function query(page?: number, companyIds?: number[], field?: string, keyword?: string, status?: number) {
+  return reportsClient.query(page || 1, companyIds, field, keyword, status);
+}
+
+export async function agg() {
+  return reportsClient.agg();
+}

+ 75 - 0
src/services/user.service.ts

@@ -0,0 +1,75 @@
+import type {
+  UserLoginRequest,
+  CurrentUser,
+  CreateUser,
+  Users,
+} from "@/types/user.types";
+import type { Page } from "@/types/pagination.types";
+import type { Response } from "@/types/response.types";
+import type { AxiosResponse } from "axios";
+import httpClient from "./httpClient";
+import _ from "lodash";
+
+export function withPrefix(prefix: string) {
+  return (path?: string | number) => {
+    if (path) {
+      return _.trimEnd(prefix, "/") + "/" + _.trimStart(`${path}`, "/");
+    }
+    return prefix;
+  };
+}
+
+const urlPrefix = "/api";
+
+const _url = withPrefix(urlPrefix);
+
+function login(
+  param: UserLoginRequest
+): Promise<AxiosResponse<Response<CurrentUser>>> {
+  return httpClient.post<Response<CurrentUser>>(_url("/login"), param);
+}
+
+async function checkLogin(): Promise<boolean> {
+  const resp = await httpClient.get(_url("/user/checkLogin"));
+  if (resp) {
+    return true;
+  }
+  return false;
+}
+
+function createUser(
+  param: CreateUser
+): Promise<AxiosResponse<Response<CreateUser>>> {
+  return httpClient.post<Response<CreateUser>>(_url("/user"), param);
+}
+
+function users(
+  page: number = 1
+): Promise<AxiosResponse<Response<Page<Users>>>> {
+  return httpClient.get<Response<Page<Users>>>(
+    _url(`/user/users?page=${page}`)
+  );
+}
+
+function updateIsDisable(id: number) {
+  return httpClient.put(_url(`/user/updateIsDisable/${id}`));
+}
+
+function updatePassword(data: any) {
+  return httpClient.put(_url("/user/updatePasswordByPhone"), data);
+}
+
+async function logout() {
+  const resp = await httpClient.get(_url("/user/logout"));
+  return resp;
+}
+
+export default {
+  login,
+  checkLogin,
+  logout,
+  createUser,
+  users,
+  updateIsDisable,
+  updatePassword,
+};

+ 42 - 0
src/stores/auth.store.ts

@@ -0,0 +1,42 @@
+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<CurrentUser | null> = ref(null);
+
+  function setToken(_token: string) {
+    token.value = _token;
+    localStorage.setItem(tokenKey, _token);
+  }
+
+  function setCurrentUser(user: CurrentUser) {
+    currentUser.value = user;
+  }
+
+  function isLoginIn() {
+    return token.value && token.value.trim() != "";
+  }
+
+  function checkLogin() {
+    return userService.checkLogin();
+  }
+
+  function logout() {
+    return userService.logout();
+  }
+
+  return {
+    token,
+    currentUser,
+    setToken,
+    setCurrentUser,
+    isLoginIn,
+    logout,
+    checkLogin,
+  };
+});

+ 12 - 0
src/stores/counter.ts

@@ -0,0 +1,12 @@
+import { ref, computed } from 'vue'
+import { defineStore } from 'pinia'
+
+export const useCounterStore = defineStore('counter', () => {
+  const count = ref(0)
+  const doubleCount = computed(() => count.value * 2)
+  function increment() {
+    count.value++
+  }
+
+  return { count, doubleCount, increment }
+})

+ 15 - 0
src/stores/side-bar.ts

@@ -0,0 +1,15 @@
+import { defineStore } from "pinia";
+import { ref } from "vue";
+
+export const useSideBarStore = defineStore("sideBar", () => {
+  const collapsed = ref(false);
+
+  function setCollapse(collapse: boolean) {
+    collapsed.value = collapse;
+  }
+
+  return {
+    collapsed,
+    setCollapse,
+  };
+});

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

@@ -0,0 +1,11 @@
+export interface Company {
+  id: number;
+  name: string;
+  date: string;
+}
+
+export interface CompanyAgg {
+  companyId: number;
+  name: string;
+  count: number;
+}

+ 6 - 0
src/types/httpClients.types.ts

@@ -0,0 +1,6 @@
+export interface ResponseData<T> {
+  data: T;
+  msg: string;
+  status: number;
+  timestamp: number;
+}

+ 4 - 0
src/types/pagination.types.ts

@@ -0,0 +1,4 @@
+export interface Page<T> {
+  records: T[];
+  total: number;
+}

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

@@ -0,0 +1,207 @@
+/**
+ * 章节类型
+ */
+export enum ChapterType {
+  BACKGROUND = 'BACKGROUND',
+  RESEARCH_STATUS = 'RESEARCH_STATUS',
+  RESEARCH = 'RESEARCH',
+  INNOVATION = 'INNOVATION',
+  SOLVE_PROBLEM = 'SOLVE_PROBLEM',
+  SOLUTION = 'SOLUTION',
+  USER_DEFINED = 'USER_DEFINED',
+  PROGRESS = 'PROGRESS',
+  BUDGET = 'BUDGET',
+  MARKET = 'MARKET',
+  BENEFIT_ANALYSIS = 'BENEFIT_ANALYSIS',
+  RISK = 'RISK',
+  UNKNOWN = 'UNKNOWN',
+}
+
+export const ChapterTypeLabels = [
+  {
+    key: ChapterType.BACKGROUND,
+    label: '立项背景与意义'
+  },
+  {
+    key: ChapterType.RESEARCH_STATUS,
+    label: '国内外研究现状与发展趋势'
+  },
+  {
+    key: ChapterType.RESEARCH,
+    label: '项目主要研究内容'
+  },
+  {
+    key: ChapterType.INNOVATION,
+    label: '关键技术与创新点'
+  },
+  {
+    key: ChapterType.SOLVE_PROBLEM,
+    label: '拟解决的关键问题'
+  },
+  {
+    key: ChapterType.SOLUTION,
+    label: '实施方案及进度安排'
+  },
+  // {
+  //   key: ChapterType.PROGRESS,
+  //   label: '进度安排、计划内容'
+  // },
+  {
+    key: ChapterType.BUDGET,
+    label: '经费预算'
+  },
+  // {
+  //   key: ChapterType.MARKET,
+  //   label: '应用或产业化前景与市场需求分析'
+  // },
+  {
+    key: ChapterType.BENEFIT_ANALYSIS,
+    label: '经济及社会效益分析'
+  },
+  {
+    key: ChapterType.RISK,
+    label: '项目风险分析和对策'
+  },
+  {
+    key: ChapterType.USER_DEFINED,
+    label: '自定义章节'
+  },
+]
+
+export const ChapterTypeKeywords = [
+  {
+    key: ChapterType.BACKGROUND,
+    keywords: ['必要性'],
+  },
+  {
+    key: ChapterType.RESEARCH_STATUS,
+    keywords: ['研究现状', '发展趋势'],
+  },
+  {
+    key: ChapterType.RESEARCH,
+    keywords: ['技术特点', '技术研究', '实际应用'],
+  },
+  {
+    key: ChapterType.INNOVATION,
+    keywords: ['关键技术', '创新点'],
+  },
+  {
+    key: ChapterType.SOLVE_PROBLEM,
+    keywords: ['解决的问题', '解决的关键问题'],
+  },
+  {
+    key: ChapterType.SOLUTION,
+    keywords: ['实施方案'],
+  },
+  // {
+  //   key: ChapterType.PROGRESS,
+  //   keywords: [],
+  // },
+  {
+    key: ChapterType.BUDGET,
+    keywords: [],
+  },
+  // {
+  //   key: ChapterType.MARKET,
+  //   keywords: [],
+  // },
+  {
+    key: ChapterType.BENEFIT_ANALYSIS,
+    keywords: ['经济效益', '社会效益', '经济效益分析', '社会效益分析'],
+  },
+  {
+    key: ChapterType.RISK,
+    keywords: ['风险分析', '风险对策'],
+  },
+  {
+    key: ChapterType.USER_DEFINED,
+    keywords: []
+  },
+]
+
+export interface SelectedChapterType {
+  type: ChapterType;
+  keywords: string[];
+}
+
+export interface ReportCreateRequest {
+  templateId: number | null;
+}
+
+export interface ReportMetadataItem {
+  name: string;
+  value: string;
+  type: 'text' | 'date' | 'dateRange';
+}
+
+export interface Chapter {
+  title: string;
+  fullTitle?: string;
+  content: string;
+  chapterType: ChapterType;
+  keywords: string[]
+}
+
+export interface ReportChapterRequest extends Chapter {
+  id: number;
+}
+
+export interface ReportBasic {
+  id: number;
+  companyId: number;
+  companyName?: string;
+  name: string;
+  logoPath: string;
+  keywords: string;
+  reportName: string;
+  supervisor: string;
+  metadata: ReportMetadataItem[];
+  userId: number;
+  createAt?: string;
+  updateAt?: string;
+  status:number
+}
+
+export interface ReportSaveRequest extends ReportBasic {
+    chapters: ReportChapterRequest[];
+}
+
+export interface ReportsResponse {
+  total: number;
+  reports: ReportBasic[];
+}
+
+export interface ReportTemplateSave {
+  id: number;
+  companyId: number;
+  name: string;
+  reportName: string;
+  logoPath: string;
+  metadata: ReportMetadataItem[];
+  chapters: Chapter[];
+  tags: string[];
+  createAt?: string;
+  updateAt?: string;
+}
+
+export interface ReportTemplatesResponse {
+  total: number;
+  items: ReportTemplateSave[];
+}
+
+export interface Logo {
+  name: string;
+  url: string;
+}
+
+export interface DuplicateCheckRequest{
+  from?: number;
+  size?: number;
+  reportId: string;
+  similarity?: string;
+  contents: string[];
+}
+
+export interface DuplicateCheckResponse{
+  repetitiveRate: number[];
+}

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

@@ -0,0 +1,5 @@
+export interface Response<T> {
+  status: number;
+  msg: string;
+  data: T;
+}

+ 38 - 0
src/types/user.types.ts

@@ -0,0 +1,38 @@
+/**
+ * 用户登录请求数据结构
+ */
+export interface UserLoginRequest {
+  mobilePhone: string;
+  password: string;
+  type: number;
+}
+
+export interface CurrentUser {
+  id: number;
+  token: string;
+  expirationTime: string;
+  truename: string;
+  [index: string]: any;
+}
+
+export interface CreateUser {
+  username?: string;
+  password: string;
+  truename?: string;
+  email: string;
+  sex: number;
+  userType: number;
+  mobile: string;
+  position: string;
+}
+export interface Users {
+  id: number;
+  username?: string;
+  password: string;
+  truename?: string;
+  email: string;
+  sex: number;
+  mobile: string;
+  position: string;
+  isDisable: number;
+}

+ 153 - 0
src/views/LoginView.vue

@@ -0,0 +1,153 @@
+<script setup lang="ts">
+import { reactive, ref } from "vue";
+import user from "@/services/user.service";
+import { useAuthStore } from "@/stores/auth.store";
+import { routeToHome } from "@/router";
+
+interface FormState {
+  username: string;
+  password: string;
+  remember: boolean;
+}
+
+// 授权store
+const authStore = useAuthStore();
+
+const formState = reactive<FormState>({
+  username: "",
+  password: "",
+  remember: true,
+});
+
+const loading = ref(false);
+
+const onFinish = (values: any) => {
+  loading.value = true;
+  let param = {
+    mobilePhone: values.username,
+    password: values.password,
+    type: 1,
+  };
+  user
+    .login(param)
+    .then((resp) => {
+      authStore.setCurrentUser(resp.data.data);
+      authStore.setToken(resp.data.data.token);
+      routeToHome();
+      loading.value = false;
+    })
+    .catch((err) => {
+      loading.value = false;
+      console.log(err);
+    });
+};
+
+const onFinishFailed = (errorInfo: any) => {
+  console.log("Failed:", errorInfo);
+};
+</script>
+
+<template>
+  <div class="login-main">
+    <a-row type="flex">
+      <a-col class="left" :flex="1">
+        <h1 class="login-title">技淘平台智慧产权大脑</h1>
+        <p class="login-banner">
+          <img src="@/assets/imgs/login-banner@2x.png" />
+        </p>
+      </a-col>
+      <a-col class="right" :flex="1">
+        <h1>后台登录</h1>
+        <a-divider></a-divider>
+        <div class="login-form-wrapper">
+          <a-form
+            :model="formState"
+            name="basic"
+            :label-col="{ span: 4 }"
+            :wrapper-col="{ span: 20 }"
+            autocomplete="off"
+            @finish="onFinish"
+            @finishFailed="onFinishFailed"
+          >
+            <a-form-item
+              label="用户名"
+              name="username"
+              :rules="[{ required: true, message: '请输入用户名' }]"
+            >
+              <a-input v-model:value="formState.username" />
+            </a-form-item>
+
+            <a-form-item
+              label="密码"
+              name="password"
+              :rules="[{ required: true, message: '请输入密码' }]"
+            >
+              <a-input-password v-model:value="formState.password" />
+            </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
+                :loading="loading"
+                >登&nbsp;&nbsp;&nbsp;&nbsp;录</a-button
+              >
+            </a-form-item>
+          </a-form>
+          <p class="forgot-password-wrap">
+            <a href="#">忘记密码?</a>
+          </p>
+        </div>
+      </a-col>
+    </a-row>
+  </div>
+</template>
+
+<style scoped lang="scss">
+.login-main {
+  height: 100vh;
+  background-color: #00a09a;
+
+  .left {
+    text-align: center;
+    padding-top: 100px;
+
+    .login-title {
+      color: #ffffff;
+    }
+
+    .login-banner {
+      margin: 0 auto;
+      width: 300px;
+
+      img {
+        margin-top: 50px;
+        width: 100%;
+      }
+    }
+  }
+
+  .right {
+    height: 100vh;
+    padding-top: 100px;
+    padding-left: 60px;
+    border-top-left-radius: 1em;
+    border-bottom-left-radius: 1em;
+    background-color: #ffffff;
+
+    .login-form-wrapper {
+      width: 400px;
+    }
+  }
+}
+
+.forgot-password-wrap {
+  text-align: right;
+}
+</style>

+ 67 - 0
src/views/RouteView.vue

@@ -0,0 +1,67 @@
+<script setup lang="ts">
+import { ref, watch, computed } from "vue";
+import { RouterView } from "vue-router";
+import NavMenu from "@/components/NavMenu.vue";
+import { useSideBarStore } from "@/stores/side-bar";
+
+const sideBarStore = useSideBarStore();
+
+const brand = ref("技淘平台智慧产权大脑");
+
+const collapsed = computed(() => sideBarStore.collapsed);
+
+watch(
+  () => sideBarStore.collapsed,
+  (value: boolean) => {
+    brand.value = value ? "大脑" : "技淘平台智慧产权大脑";
+  }
+);
+</script>
+
+<template>
+  <a-layout class="layout">
+    <a-layout-sider
+      class="sider-nav-bar-wrap"
+      width="260"
+      theme="light"
+      v-model:collapsed="sideBarStore.collapsed"
+      collapsible
+    >
+      <NavMenu class="sider-nav" />
+    </a-layout-sider>
+    <a-layout>
+      <a-layout-content class="main-content">
+        <RouterView />
+      </a-layout-content>
+    </a-layout>
+  </a-layout>
+</template>
+
+<style scoped lang="scss">
+.layout {
+  background-color: #fff;
+  .main-content {
+    background-color: #fff;
+    border-left: 1px solid #f3f3f3;
+  }
+
+  .sider-nav-bar-wrap {
+    height: 100vh;
+    .logo {
+      height: 32px;
+      margin: 16px;
+      text-align: center;
+    }
+    .sider-nav {
+      border-right: 0;
+      padding-bottom: 50px;
+    }
+  }
+
+  .main-content {
+    min-width: 800px;
+    padding: 20px 40px;
+    padding-bottom: 60px;
+  }
+}
+</style>

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

@@ -0,0 +1,372 @@
+<script setup lang="ts">
+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';
+import RelationCompanyInput from './components/report-create/RelationCompanyInput.vue';
+import ReportField from "./components/report-create/ReportField.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, ReportTemplateSave, DuplicateCheckRequest, ReportChapterRequest } 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 * 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)
+
+// 当前路由
+const route = useRoute();
+// 报告下载函数库
+const reportExport = useReportExport()
+// 权限store
+const authStore = useAuthStore()
+
+const chapterEditors = ref<any | null>([])
+
+const report: Ref<ReportSaveRequest | null> = ref(null);
+const relationCompanyEl = ref<HTMLInputElement | null>(null);
+const reportCategoryEl = ref<HTMLInputElement | null>(null);
+const reportNameEl = ref<HTMLInputElement | null>(null);
+const supervisorEl = ref<HTMLInputElement | null>(null);
+const keywordsEl = ref<HTMLInputElement | null>(null);
+const duplicateCheckRequest: Ref<DuplicateCheckRequest> = ref({
+  reportId: "",
+  contents: []
+});
+
+const placeholders = ref(["请输入报告类型","请输入项目名称","请输入项目负责人","请输入报告关键词"])
+
+// 报告元数据
+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)
+//报告状态
+const reportStatus = ref<number>(0)
+
+function onMetaChange(data: ReportMetadataItem[]) {
+  metadata.value = data;
+  if (report.value) {
+    report.value.metadata = metadata.value;
+  }
+}
+
+/**
+ * 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())
+
+  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 onLogoDelete() {
+  if (!report.value || !report.value.logoPath) {
+    return;
+  }
+  logoService.remove(report.value.id).then((resp) => {
+    if (resp) {
+      if (report.value) {
+        report.value.logoPath = ''
+      }
+      logoUrl.value = undefined
+    }
+  })
+}
+
+function onChapterTypeSelected(types: ChapterType[]) {
+  chapterManager.addChapters(types);
+}
+
+function onChapterChange(index: number, data: Chapter) {
+  chapterManager.setChapter(index, data);
+}
+
+function onChapterMoveUp(index: number) {
+  chapterManager.moveUp(index)
+}
+
+function onChapterMoveDown(index: number) {
+  chapterManager.moveDown(index)
+}
+
+function onChapterRemove(index: number) {
+  chapterManager.remove(index)
+}
+
+function onCompanyChange(companyId: number) {
+  if (report.value) {
+    report.value.companyId = companyId;
+  }
+}
+
+const saveLoading = ref(false);
+
+function validateString(text: string, msg: string, el: Ref<HTMLInputElement | null>) {
+  if ((text || '').trim() == '') {
+    message.warning(msg);
+    el.value?.focus()
+    return false;
+  }
+  return true;
+}
+
+function onSave() {
+  if (report.value) {
+    if (!report.value.companyId) {
+      message.warning("请填写关联企业!")
+      relationCompanyEl.value?.focus();
+      return;
+    }
+    const validtions: [string, string, Ref<HTMLInputElement | null>][] = [
+      [report.value.name, "请填写报告类型!", reportCategoryEl],
+      [report.value.reportName, "请填写项目名称!", reportNameEl],
+      [report.value.supervisor, '请填写负责人!', supervisorEl],
+      [report.value.keywords, "请填写关键词!", keywordsEl],
+    ];
+    for (const [text, msg, el] of validtions) {
+      if (!validateString(text, msg, el)) {
+        return;
+      }
+    }
+    if (report.value.metadata) {
+      for (const item of report.value.metadata) {
+        if (item.name == '' || item.value == '') {
+          message.warning("有动态字段的名称或值为空,请填写!")
+          return;
+        }
+      }
+    }
+    saveLoading.value = true;
+    reportService.save(report.value).then(() => {
+      saveLoading.value = false;
+      message.success("保存成功")
+    }).catch((err) => {
+      saveLoading.value = false;
+      logError(err);
+    });
+  }
+}
+
+/**
+ * 导出报告
+ */
+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() {
+  templateSaveModal.value?.show();
+}
+
+function onSaveTemplate(modalData: ReportTemplateModal) {
+  let chapters = report.value?.chapters.map((chapter) => {
+    return {
+      title: chapter.title,
+      keywords: chapter.keywords,
+    } as Chapter
+  })
+  const reportTemplateSave: ReportTemplateSave = {
+    id: 0,
+    companyId: report.value?.companyId || 0,
+    name: modalData.templateName,
+    reportName: report.value?.reportName || '',
+    logoPath: report.value?.logoPath || '',
+    metadata: report.value?.metadata || [],
+    chapters: chapters || [],
+    tags: modalData.tags,
+  }
+
+  reportTemplateService.save(reportTemplateSave).then(() => {
+    templateSaveModal.value?.complete()
+    message.success("模板保存成功")
+  }).catch((err) => {
+    logError(err)
+    templateSaveModal.value?.complete()
+  })
+}
+
+function updateStatus() {
+  if (report.value) {
+    reportService.updateStatus(report.value.id)
+    getStatus();
+  }
+}
+
+function getStatus(){
+  reportStatus.value = reportStatus.value == 0 ? 1 : 0;
+}
+
+function duplicateCheck() {
+  if (report.value) {
+    extract(report.value.chapters, report.value.id +"")
+  }
+ if (duplicateCheckRequest?.value) {
+  reportService.duplicateCheck(duplicateCheckRequest.value).then((data: any) => {
+    let index = 0;
+    for (let item of data.repetitiveRate) {
+      if (item && item > 0) {
+        chapterEditors.value[index]?.notifyRepetitiveWarning(true);
+      } else {
+        chapterEditors.value[index]?.notifyRepetitiveWarning(false);
+      }
+      index += 1;
+    }
+  })
+ }
+}
+
+function extract(chapters: ReportChapterRequest[], id: string) {
+  duplicateCheckRequest.value.contents = []
+  for (var i = 0; i < chapters.length; i ++) {
+    duplicateCheckRequest.value?.contents.push(chapters[i].content)
+  }
+  if (duplicateCheckRequest?.value) {
+      duplicateCheckRequest.value.reportId = id;
+  }
+}
+
+onMounted(() => {
+  const id = parseInt(route.params.id as string);
+  if (id && id > 0) {
+    reportService.get(id).then((data) => {
+      report.value = data;
+      reportStatus.value = data.status
+      const chapters = report.value.chapters;
+      chapterManager.setChapters(chapters);
+      // 设置Logo信息
+      if (report.value.id && 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>
+  <PageHeader title="报告撰写" />
+  <a-row :gutter="24" v-if="report">
+    <a-col :span="12">
+      <div class="metadata-wrap">
+        <RelationCompanyInput v-model:id="report.companyId" @change="onCompanyChange" ref="relationCompanyEl" />
+        <ReportField name="报告类型" v-model="report.name" :placeholder="placeholders[0]" ref="reportCategoryEl" />
+        <ReportField name="项目名称" v-model="report.reportName" :placeholder="placeholders[1]" ref="reportNameEl"/>
+        <ReportField name="项目负责人" v-model="report.supervisor" :placeholder="placeholders[2]" ref="supervisorEl" />
+        <ReportField name="关键词" v-model="report.keywords" :placeholder="placeholders[3]" ref="keywordsEl" />
+        <DynamicMeta v-model:data="report.metadata" @change="onMetaChange" />
+      </div>
+    </a-col>
+    <a-col :span="12">
+      <LogoComponent @change="onLogoChange" @delete="onLogoDelete" :url="logoUrl" />
+    </a-col>
+  </a-row>
+  <a-divider />
+  <div class="chapter-wrap" v-if="report">
+    <ChapterEditor
+      v-for="(item, index) in chapterManager.getChapters()"
+      :key="index"
+      :title="report.reportName"
+      :data="item"
+      ref="chapterEditors"
+      :reportKeywords="report.keywords"
+      @change="onChapterChange(index, $event)"
+      @move-up="onChapterMoveUp(index)"
+      @move-down="onChapterMoveDown(index)"
+      @remove="onChapterRemove(index)"
+    />
+  </div>
+  <div class="add-chapter-wrap">
+    <a-button @click="() => chapterTypeSelectVisible = true">
+      <plus-outlined />
+      添加章节
+    </a-button>
+  </div>
+  <div class="bottom-button-group">
+    <a-space v-if="report">
+      <a-checkbox @change="updateStatus()"  :checked="reportStatus == 1">
+        <span v-if="reportStatus == 0">未完成</span>
+        <span v-if="reportStatus == 1">已完成</span>
+      </a-checkbox>
+      <a-button type="primary" @click="onSave" :loading="saveLoading">保存</a-button>
+      <a-button @click="onPreview">预览</a-button>
+      <a-button @click="onExport" :loading="exporting">导出</a-button>
+      <a-button @click="showTemplateSaveModal">另保存为模板</a-button>
+      <a-button @click="duplicateCheck">查重</a-button>
+    </a-space>
+  </div>
+  <ChapterTypeSelect @ok="onChapterTypeSelected" v-model:visible="chapterTypeSelectVisible" />
+  <TemplateSaveModal ref="templateSaveModal" @ok="onSaveTemplate" />
+</template>
+
+<style scoped lang="scss">
+.metadata-wrap {
+  > * {
+    margin-top: 0.5em;
+  }
+}
+
+.add-chapter-wrap {
+  margin-top: 3em;
+}
+
+.bottom-button-group {
+  margin-top: 5em;
+}
+</style>

+ 288 - 0
src/views/report/ReportListView.vue

@@ -0,0 +1,288 @@
+<script setup lang="ts">
+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 type { FieldType } from "./components/report-list/QueryBox.vue";
+import CompanySelect from "./components/report-list/CompanySelect.vue";
+import ReportItem from "./components/report-list/ReportItem.vue";
+import * as reportService from "@/services/report.service";
+import * as reportsService from "@/services/reports.service";
+import { CompLog } from "@/libs/log.lib";
+import type { ReportBasic } from "@/types/report.types";
+import { routeToReportEditor } from "@/router";
+import SpinComponent from "@/components/SpinComponent.vue";
+import TemplateSelect from "./components/TemplateSelect.vue";
+import { message } from "ant-design-vue";
+
+const _componentName = "ReportListView";
+const logError = CompLog.logErr(_componentName);
+
+const currentPage = ref(1);
+
+const reportStatus = ref([
+  {
+    label: "全部",
+    value: undefined,
+  },
+  {
+    label: "编写中",
+    value: 0,
+  },
+  {
+    label: "已完成",
+    value: 1,
+  },
+]);
+
+const status = ref(undefined);
+
+interface CompanyAggProp {
+  id?: number;
+  label: string;
+  tail: number;
+  checked: boolean;
+}
+// 我服务的企业名录列表
+const companies = ref<CompanyAggProp[]>([]);
+// 报告总数
+const total = ref(0);
+// 当前面报告列表
+const reports = ref<ReportBasic[]>([]);
+// 加载状态
+const spinning = ref(true);
+// 查询字段
+const field = ref<FieldType>("title");
+// 查询关键词
+const keyword = ref("");
+// 过滤栏选择的企业id
+const companyIdSelected = ref<Array<number>>([]);
+// 模板选择器ref
+const templateSelect = ref<InstanceType<typeof TemplateSelect> | null>(null);
+
+function fetchReports(
+  companyIds?: Array<number>,
+  page?: number,
+  field?: string,
+  keyword?: string
+) {
+  spinning.value = true;
+  reportsService
+    .query(page, companyIds, field, keyword, status.value)
+    .then((resp) => {
+      total.value = resp.total;
+      reports.value = resp.reports;
+      spinning.value = false;
+    })
+    .catch((err) => {
+      // logError(err);
+      spinning.value = false;
+    });
+}
+
+// function onCompanySelectChange(event: {index: number, checked: boolean}) {
+//   console.log('company select change', event);
+//   const company = companies.value[event.index];
+//   company.checked = event.checked;
+
+//   if (company.id || company.id == 0) {
+//     if (!event.checked) {
+//       companyIdSelected.value.splice(companyIdSelected.value.indexOf(company.id), 1);
+//     } else {
+//       companyIdSelected.value.push(company.id);
+//     }
+//   }
+
+//   if (companyIdSelected.value) {
+//     if (companyIdSelected.value.length > 0) {
+//       fetchReports(companyIdSelected.value)
+//     } else {
+//       fetchReports()
+//     }
+//   }
+// }
+
+function onQuery(queryData: { field: string; keyword: string }) {
+  currentPage.value = 1;
+  fetchReports(
+    companyIdSelected.value,
+    currentPage.value,
+    queryData.field,
+    queryData.keyword
+  );
+}
+
+function onPaginationChange(page: number) {
+  fetchReports(companyIdSelected.value, page, field.value, keyword.value)
+}
+
+function onCreateReport() {
+  templateSelect.value?.show();
+}
+
+// function onTemplateSelected(templateId: number | null) {
+//   reportService.create({templateId}).then((id) => {
+//     // routeToReportEditor(id);
+//   }).catch(logError);
+// }
+
+function onFilterChange() {
+  currentPage.value = 1;
+  fetchReports(companyIdSelected.value, currentPage.value);
+}
+
+function onReportDelete(id: number) {
+  reportService
+    .remove(id)
+    .then((ok) => {
+      if (ok) {
+        message.success("删除报告成功");
+        currentPage.value =
+          reports.value.length === 1 ? currentPage.value - 1 : currentPage.value;
+        fetchReports(
+          companyIdSelected.value,
+          currentPage.value,
+          field.value,
+          keyword.value
+        );
+      } else {
+        message.success("删除报告失败");
+      }
+    })
+    .catch(() => {
+      message.success("删除报告失败");
+    });
+}
+
+function serialNumber(index: number) {
+  index += 1;
+  return (currentPage.value - 1) * 10 + index;
+}
+
+onMounted(() => {
+  fetchReports();
+  // 左侧企业名录统计;
+  // reportsService
+  //   .agg()
+  //   .then((aggs) => {
+  //     companies.value = aggs
+  //       .filter((agg) => agg.count > 0)
+  //       .map((agg) => {
+  //         return {
+  //           id: agg.companyId,
+  //           label: agg.name,
+  //           tail: agg.count,
+  //           checked: false,
+  //         };
+  //       });
+  //   })
+  //   .catch(logError);
+});
+</script>
+
+<template>
+  <div class="reports-wrap">
+    <PageHeader title="全部报告">
+      <a-button type="primary" @click="onCreateReport"
+        ><plus-outlined />新建报告</a-button
+      >
+    </PageHeader>
+
+    <div class="query-box-wrap">
+      <QueryBox v-model:field="field" v-model:keyword="keyword" @search="onQuery" />
+    </div>
+
+    <div class="filter-box-wrap">
+      <a-radio-group
+        :options="reportStatus"
+        v-model:value="status"
+        @change="onFilterChange()"
+      />
+    </div>
+
+    <a-row class="main-content" :gutter="24">
+      <!-- <a-col :xxl="5" :xl="6" :lg="7" :span="8" class="company-filter-wrap">
+        <h3>我所服务的企业  <span>【{{ companies.length }}】</span></h3>
+        <CompanySelect :data="companies" @change="onCompanySelectChange" />
+      </a-col> -->
+
+      <a-col :xxl="24" :xl="24" :lg="24" :span="24">
+        <SpinComponent :spinning="spinning">
+          <ReportItem
+            v-for="(report, index) in reports"
+            :data="report"
+            :key="index"
+            :serialNumber="serialNumber(index)"
+            class="report-item"
+            @delete="onReportDelete"
+          />
+        </SpinComponent>
+      </a-col>
+    </a-row>
+    <div class="pagination-wrap">
+      <a-pagination
+        v-model:current="currentPage"
+        :total="total"
+        @change="onPaginationChange"
+      />
+    </div>
+  </div>
+  <!-- <TemplateSelect ref="templateSelect" @ok="onTemplateSelected" /> -->
+</template>
+
+<style scoped lang="scss">
+@import "@/assets/base.scss";
+
+.reports-wrap {
+  .query-box-wrap {
+    width: 80%;
+    margin: 0 auto;
+  }
+
+  .filter-box-wrap {
+    display: flex;
+    margin: 1.5em 21.1%;
+  }
+  .main-content {
+    margin-top: 1.5em;
+
+    .company-filter-wrap {
+      border: 1px solid $border-color-primary;
+      padding: 20px 16px;
+
+      > h3 > span {
+        color: red;
+      }
+    }
+
+    .report-item {
+      margin-top: 16px;
+
+      &:first-child {
+        margin-top: 0;
+      }
+    }
+
+    :deep(.ant-card) {
+      background-color: #f5f5f5;
+
+      .ant-card-head-title {
+        font-size: 1.2em;
+      }
+
+      .ant-card-body p {
+        margin-bottom: 0.5em;
+      }
+
+      &.ant-card-bordered {
+        border: 1px solid #d0d0d0;
+      }
+    }
+  }
+
+  .pagination-wrap {
+    margin-top: 1em;
+    text-align: right;
+  }
+}
+</style>

+ 265 - 0
src/views/report/components/ChapterEditor.vue

@@ -0,0 +1,265 @@
+<!-- 章节编辑器 -->
+
+<script setup lang="ts">
+import { ref, watch, type PropType } from 'vue';
+import { EditOutlined, DownOutlined } from '@ant-design/icons-vue';
+import { marked } from "marked";
+import type { Chapter } from '@/types/report.types';
+import ContentEditor from "../components/ContentEditor.vue";
+import ReferenceDraw from "../components/reference/ReferenceDraw.vue";
+import AIContentDraw from "../components/reference/AIContentDraw.vue";
+import * as aiService from "@/services/ai.service"
+import { message } from 'ant-design-vue';
+
+const props = defineProps({
+  title: {
+    type: String,
+    required: true,
+  },
+  reportKeywords: {
+    type: String,
+    required: false,
+  },
+  data: {
+    type: Object as PropType<Chapter>,
+    required: true
+  },
+  contentEditorVisible: {
+    type: Boolean,
+    required: false,
+    default: true,
+  }
+});
+
+const titleEditor = ref<HTMLInputElement | null>(null);
+
+const titleEditing = ref(false);
+
+const keywordsInChapter = ref('')
+
+const keywordsEditor = ref<HTMLInputElement | null>(null);
+
+const keywordsEditing = ref(false);
+
+const repetitiveWarning = ref(false);
+const LowRepetitionRate = ref(false);
+
+const fullTitle = ref('');
+// 参考文献抽屉组件ref
+const referenceDraw = ref<InstanceType<typeof ReferenceDraw> | null>(null);
+// 内容编辑器组件ref
+const contentEditor = ref<InstanceType<typeof ContentEditor> | null>(null)
+// AI内容展示组件ref
+const aiContentDraw = ref<InstanceType<typeof ReferenceDraw> | null>(null);
+
+const emits = defineEmits<{
+  (e: "update:data", data: Chapter): void;
+  (e: "change", data: Chapter): void;
+  (e: "moveUp"): void;
+  (e: "moveDown"): void;
+  (e: "remove"): void;
+  (e: "sectionRecordUse", sectionId: number): void;
+}>();
+
+const visible = ref<boolean>(false);
+
+const handleOk = () => {
+  emits("remove");
+  visible.value = false;
+};
+
+watch(
+  () => titleEditor.value,
+  (el: HTMLInputElement | null) => {
+    el?.focus();
+  }
+);
+
+function onEditTitle() {
+  fullTitle.value = props.data.title;
+  titleEditing.value = true;
+}
+
+function onEditKeywords() {
+  keywordsInChapter.value = props.data.keywords.join(",");
+  keywordsEditing.value = true;
+}
+
+function onTitleChange() {
+  titleEditing.value = false;
+  emits("change", { ...props.data, title: fullTitle.value })
+}
+
+function onKeywordsChange() {
+  keywordsEditing.value = false;
+  emits("change", { ...props.data, keywords: keywordsInChapter.value.split(",") })
+}
+
+function onTitleChangeCancel() {
+  fullTitle.value = props.data.title;
+  titleEditing.value = false;
+}
+
+function onKeywordsChangeCancel() {
+  keywordsInChapter.value = props.data.keywords.join(",");
+  keywordsEditing.value = false;
+}
+
+function onContentChange(value: string) {
+  emits("change", { ...props.data, content: value });
+}
+
+function onMenuClick(e: { key: string }) {
+  switch (e.key) {
+    case "UP":
+      emits("moveUp");
+      break;
+    case "DOWN":
+      emits("moveDown");
+      break;
+    case "REMOVE":
+      visible.value = true;
+      break;
+  }
+}
+
+function searchReference() {
+  referenceDraw.value?.show();
+}
+
+function onInsert(text: string) {
+  contentEditor.value?.insert(text)
+}
+
+function onReferenceDrawClose() {
+  contentEditor.value?.focus()
+}
+
+function onSectionRecordUse(sectionId: number) {
+  emits("sectionRecordUse", sectionId)
+}
+
+function notifyRepetitiveWarning(data: boolean) {
+  repetitiveWarning.value = data;
+  LowRepetitionRate.value = false
+  if(!data&&props.data.content) LowRepetitionRate.value = !data;
+}
+
+function test1() {
+  const loadingHandler = message.loading({ content: 'AI正在撰写章节内容,请稍等……', duration: 0 })
+  aiService.generate(props.title, props.reportKeywords || '', props.data.title).then((resp) => {
+    onContentChange(marked.parse(resp))
+    if (loadingHandler) {
+      loadingHandler()
+    }
+  }).catch((err) => {
+    console.log('err', err)
+    if (loadingHandler) {
+      loadingHandler()
+    }
+  })
+}
+
+defineExpose({
+  notifyRepetitiveWarning
+})
+</script>
+
+<template>
+  <div class="chapter-item">
+    <a-row type="flex" justify="space-between">
+      <a-col>
+        <a-space>
+          <a-input v-if="titleEditing" v-model:value="fullTitle" @blur="onTitleChange" @pressEnter="onTitleChange"
+            @keyup.esc="onTitleChangeCancel" ref="titleEditor" class="title-editor" />
+          <h1 v-else>
+            <span>{{ data.fullTitle }}</span>
+          </h1>
+          <a-button type="text" @click="onEditTitle" class="edit-btn"><edit-outlined /></a-button>
+        </a-space>
+      </a-col>
+      <a-col flex="1">
+        <!-- 关键词编辑 -->
+        <a-space class="keywords-wrap">
+          <h1>
+            <span>章节关键词:</span><span v-if="!keywordsEditing">{{ data.keywords.join(', ') }}</span>
+          </h1>
+          <a-input v-if="keywordsEditing" v-model:value="keywordsInChapter" @blur="onKeywordsChange"
+            @pressEnter="onKeywordsChange" @keyup.esc="onKeywordsChangeCancel" ref="keywordsEditor"
+            class="keywords-editor" />
+          <a-button type="text" @click="onEditKeywords" class="edit-btn"><edit-outlined /></a-button>
+        </a-space>
+      </a-col>
+      <a-col>
+        <a-dropdown>
+          <template #overlay>
+            <a-menu @click="onMenuClick">
+              <a-menu-item key="UP">上移</a-menu-item>
+              <a-menu-item key="DOWN">下移</a-menu-item>
+              <a-menu-item key="REMOVE">删除</a-menu-item>
+            </a-menu>
+          </template>
+          <a-button type="link" size="small">
+            操作
+            <DownOutlined />
+          </a-button>
+        </a-dropdown>
+      </a-col>
+    </a-row>
+    <p v-if="contentEditorVisible" :class="`${repetitiveWarning?'repetitiveWarning':LowRepetitionRate?'LowRepetitionRate':''}`">
+      <ContentEditor :data="data.content || ''" @change="onContentChange" ref="contentEditor" />
+    </p>
+    <div class="chapter-button-group" v-if="contentEditorVisible">
+      <a-space>
+        <a-button @click="test1">AI生成</a-button>
+        <a-button @click="searchReference">匹配文献</a-button>
+      </a-space>
+    </div>
+  </div>
+  <ReferenceDraw :report-name="title" :reportKeywords="reportKeywords || ''" :chapter-keywords="data.keywords || []"
+    ref="referenceDraw" @insert="onInsert" @close="onReferenceDrawClose" @recordUse="onSectionRecordUse" />
+  <AIContentDraw content="''" ref="aiContentDraw" />
+  <!--删除章节 start -->
+  <a-modal v-model:visible="visible" title="删除章节" @ok="handleOk">
+    <p>确认删除章节</p>
+  </a-modal>
+  <!-- end -->
+</template>
+
+<style scoped lang="scss">
+.chapter-item {
+  margin-top: 2em;
+
+  .title-editor {
+    width: 400px;
+  }
+
+  .keywords-wrap {
+    font-size: 0.9em;
+    display: flex;
+    align-items: baseline;
+    margin-left: 2em;
+  }
+
+  .keywords-editor {
+    width: 400px;
+  }
+
+  .edit-btn {
+    margin-left: 0.5em;
+  }
+
+  .chapter-button-group {
+    text-align: right;
+  }
+
+  .repetitiveWarning {
+    border: 1px solid red;
+    border-radius: 8px;
+  }
+  .LowRepetitionRate {
+    border: 1px solid green;
+    border-radius: 8px;
+  }
+}
+</style>

+ 68 - 0
src/views/report/components/ChapterTypeSelect.vue

@@ -0,0 +1,68 @@
+<script setup lang="ts">
+import { ChapterType, ChapterTypeLabels } from "@/types/report.types";
+import { ref } from "vue";
+
+defineProps({
+  visible: {
+    type: Boolean,
+    required: true,
+    default: false,
+  }
+});
+
+const emits = defineEmits<{
+  (e: "update:visible", visible: boolean): void;
+  (e: "ok", vaule: ChapterType[]): void;
+}>();
+
+const categoryOptions = ChapterTypeLabels.map((item) => ({
+  label: item.label,
+  value: item.key
+}))
+
+const checkedCategories = ref<ChapterType[]>([]);
+
+function onOk() {
+  let values: ChapterType[] = [];
+  for (let item of categoryOptions) {
+    if (checkedCategories.value.find(x => x == item.value)) {
+      values.push(item.value)
+    }
+  }
+  emits("ok", values);
+  emits("update:visible", false);
+  reset();
+}
+
+function onCancel() {
+  emits("update:visible", false);
+  reset();
+}
+
+function reset() {
+  checkedCategories.value = [];
+}
+</script>
+
+<template>
+  <a-modal
+    :visible="visible"
+    title="选择章节类型"
+    :ok-button-props="{ disabled: checkedCategories.length == 0 }"
+    @ok="onOk"
+    @cancel="onCancel"
+  >
+    <a-checkbox-group v-model:value="checkedCategories" name="checkboxgroup">
+      <div v-for="(item, index) in categoryOptions" :key="index" class="category-item">
+        <a-checkbox :value="item.value">{{ item.label }}</a-checkbox>
+      </div>
+    </a-checkbox-group>
+  </a-modal>
+</template>
+
+<style scoped lang="scss">
+.category-item {
+  height: 2em;
+  line-height: 2em;
+}
+</style>

+ 129 - 0
src/views/report/components/ContentEditor.vue

@@ -0,0 +1,129 @@
+<script setup lang="ts">
+import { onMounted, ref, watch } from "vue";
+import Editor from "@tinymce/tinymce-vue";
+
+const props = defineProps({
+  data: {
+    type: String,
+    required: true,
+  }
+});
+
+const content = ref("");
+
+const toolbar = [
+  'undo redo',
+  'blocks',
+  'bold italic',
+  'alignleft aligncenter alignright alignjustify',
+  // 'indent outdent',
+  'table',
+  'image',
+  'fullscreen'
+];
+
+const editInit = {
+  language: 'zh_CN',
+  skin: 'small',
+  // icons: 'small',
+  plugins: 'lists link image table code help wordcount fullscreen',
+  contextmenu: false,
+  toolbar: toolbar.join(' | '),
+  menubar: false,
+  branding: false,
+  resize: true,
+  file_picker_types: 'image',
+  file_picker_callback: filePickerCallback,
+  height: 260,
+  init_instance_callback : function(editor:any){
+    tinymce.value = editor
+  }
+}
+
+/**
+ * TinyMCE选择文件回调处理函数
+ *
+ * 参考 https://www.tiny.cloud/docs/tinymce/6/file-image-upload/#file_picker_callback
+ * @param callback 回调函数
+ */
+function filePickerCallback(callback: any) {
+  const input = document.createElement('input');
+  input.setAttribute('type', 'file');
+  input.setAttribute('accept', 'image/*');
+
+  input.addEventListener('change', (e: any) => {
+    const file = e.target.files[0];
+
+    const reader = new FileReader();
+    reader.addEventListener('load', () => {
+      callback(reader.result, { title: file.name });
+    });
+    reader.readAsDataURL(file);
+  });
+
+  input.click();
+}
+
+const emits = defineEmits<{
+  (e: "update:data", content: string): void;
+  (e: "change", content: string): void;
+}>();
+
+function onChange() {
+  emits("update:data", content.value);
+  emits("change", content.value);
+}
+
+const editor = ref<any>(null)
+
+const tinymce = ref<any>(null)
+
+function onInit(event: any, editorIn: any) {
+  editor.value = editorIn
+}
+
+function focus() {
+  editor.value.focus()
+}
+
+function insert(text: string) {
+  editor.value.insertContent(text)
+}
+
+function createErrorNotification() {
+  if (tinymce.value) {
+    tinymce.value.notificationManager.open({
+      text: '该章节重复率过高',
+      type: 'warning'
+    });
+  }
+}
+
+onMounted(() => {
+  content.value = props.data;
+
+  watch(
+    () => props.data,
+    (newData: string) => {
+      content.value = newData
+    }
+  );
+});
+
+defineExpose({
+  focus,
+  insert,
+  createErrorNotification
+})
+</script>
+
+<template>
+  <Editor
+    api-key="ybn5lke39bgjhdehsamztepaxlch6nja25ekokjjav5nqdlq"
+    :init="editInit"
+    v-model="content"
+    output-format="html"
+    @change="onChange"
+    @init="onInit"
+  />
+</template>

+ 132 - 0
src/views/report/components/LogoComponent.vue

@@ -0,0 +1,132 @@
+<script setup lang="ts">
+import { ref } from 'vue';
+import { PlusOutlined, LoadingOutlined, DeleteOutlined } from '@ant-design/icons-vue';
+import { message } from 'ant-design-vue';
+import type { UploadChangeParam } from 'ant-design-vue';
+
+defineProps({
+  url: {
+    type: String,
+    required: false,
+  }
+})
+
+const emits = defineEmits<{
+  (e: "change", file: File): void;
+  (e: "delete"): void;
+}>();
+
+function getBase64(img: Blob, callback: (base64Url: string) => void) {
+  const reader = new FileReader();
+  reader.addEventListener('load', () => callback(reader.result as string));
+  reader.readAsDataURL(img);
+}
+
+const fileList = ref([]);
+const loading = ref<boolean>(false);
+const imageUrl = ref<string>('');
+
+const handleChange = (info: UploadChangeParam) => {
+  if (info.file.status === 'uploading') {
+    loading.value = true;
+    return;
+  }
+  if (info.file.status === 'done') {
+    // Get this url from response in real world.
+    getBase64(info.file.originFileObj as Blob, (base64Url: string) => {
+      imageUrl.value = base64Url;
+      loading.value = false;
+    });
+  }
+  if (info.file.status === 'error') {
+    loading.value = false;
+    message.error('upload error');
+  }
+};
+
+const beforeUpload = (file: File) => {
+  const isJpgOrPng = file.type === 'image/jpeg' || file.type === 'image/png';
+  if (!isJpgOrPng) {
+    message.error('仅支持JPG和PNG格式的图片');
+  }
+
+  const isLt2M = file.size / 1024 / 1024 < 2;
+  if (!isLt2M) {
+    message.error('仅支持小于2MB的图片');
+  }
+  if (!isJpgOrPng || !isLt2M) {
+    return false;
+  }
+
+  emits("change", file);
+
+  return false;
+};
+
+function onDelete() {
+  emits("delete")
+}
+</script>
+
+<template>
+  <a-row>
+    <a-col>
+      <a-upload
+        v-model:file-list="fileList"
+        name="avatar"
+        list-type="picture-card"
+        class="logo-uploader"
+        :show-upload-list="false"
+        action="/"
+        :before-upload="beforeUpload"
+        @change="handleChange"
+      >
+        <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>
+          <div class="ant-upload-text">上传</div>
+        </div>
+      </a-upload>
+    </a-col>
+    <a-col class="description-wrap">
+      <div class="description">
+        <div style="flex: 1;">
+          请按需上传企业logo<br />仅支持 jpg.  png.  格式图片
+        </div>
+        <div>
+          <a-popconfirm
+            title="确定删除此Logoo?"
+            ok-text="确定"
+            cancel-text="取消"
+            @confirm="onDelete"
+            placement="topRight"
+          >
+            <a-button :disabled="!url"><delete-outlined /></a-button>
+          </a-popconfirm>
+        </div>
+      </div>
+    </a-col>
+  </a-row>
+</template>
+
+<style scoped lang="scss">
+@import "@/assets/base.scss";
+
+.logo-uploader {
+  margin-left: 20px;
+}
+
+.description-wrap {
+  font-size: 0.85em;
+  color: $desc-color;
+  padding-bottom: 8px;
+
+  .description {
+    height: 100%;
+    display: flex;
+    flex-direction: column;
+    align-content: space-between;
+  }
+}
+</style>

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

@@ -0,0 +1,105 @@
+<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 "@/libs/log.lib";
+import { message, type FormInstance, type SelectProps } from "ant-design-vue";
+
+const logError = CompLog.logErr("TemplateSaveModal")
+
+// 弹窗组件显示状态
+const visible = ref(false)
+// 确定按钮加载状态
+const saving = ref(false);
+// 标签值列表
+const tagsOptions = ref<SelectProps['options']>([]);
+// 表单ref
+const templateSaveModalRef = ref<FormInstance>();
+// 模板名称输入框ref
+const templateNameElem = ref<HTMLInputElement | null>(null)
+
+const emits = defineEmits<{
+  (e: "ok", data: ReportTemplateModal): void
+}>();
+
+const formState = reactive<ReportTemplateModal>({
+  templateName: '',
+  tags: [],
+});
+
+function onTagsChange() {
+  formState.tags = formState.tags.map((item) => item.trim())
+}
+
+async function onOk() {
+  const templateName = (formState.templateName || '').trim()
+  formState.templateName = templateName
+  templateSaveModalRef.value?.validateFields()
+  if (templateName == '') {
+    templateNameElem.value?.focus()
+    message.warning("请填写模板名称!")
+    return
+  }
+  saving.value = true;
+  emits("ok", formState);
+}
+
+onMounted(() => {
+  reportTemplateService.tags().then((tags) => {
+    tagsOptions.value = tags.map((tag) => ({ value: tag }))
+  }).catch(logError)
+});
+
+function hide() {
+  visible.value = false
+  saving.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"
+      ref="templateSaveModalRef"
+      :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" ref="templateNameElem" />
+      </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>

+ 54 - 0
src/views/report/components/TemplateSelect.vue

@@ -0,0 +1,54 @@
+<!-- 报告模板选择  -->
+<script setup lang="ts">
+import { onMounted, ref } from "vue"
+import * as reportTemplateService from "@/services/reportTemplate.service"
+import type { ReportTemplatesResponse } from "@/types/report.types";
+
+const visible = ref(false)
+
+const selectedTemplate = ref<number | null>(null)
+
+const templates = ref<ReportTemplatesResponse>({total: 0, items: []})
+
+const emits = defineEmits<{
+  (e: "ok", id: number | null): void;
+}>();
+
+function fetchTemplates() {
+  reportTemplateService.list().then((resp) => {
+    templates.value = resp
+  })
+}
+
+function onOk() {
+  emits("ok", selectedTemplate.value)
+}
+
+onMounted(() => {
+  fetchTemplates()
+})
+
+defineExpose({
+  show: () => visible.value = true,
+  hide: () => visible.value = false,
+})
+</script>
+
+<template>
+  <a-modal v-model:visible="visible" title="报告模板选择" @ok="onOk">
+    <a-radio-group v-model:value="selectedTemplate">
+      <a-radio class="radio" :value="item.id" v-for="(item, index) in templates.items" :key="index">
+        {{ item.name }}
+      </a-radio>
+      <a-radio class="radio" :value="null">空白报告</a-radio>
+    </a-radio-group>
+  </a-modal>
+</template>
+
+<style scoped lang="scss">
+.radio {
+  display: flex;
+  height: 2em;
+  line-height: 2em;
+}
+</style>

+ 145 - 0
src/views/report/components/dynamic-meta/DynamicMeta.vue

@@ -0,0 +1,145 @@
+<!-- 报告元数据,封面上的动态字段 -->
+<script setup lang="ts">
+import { defineAsyncComponent, ref, markRaw, type Ref, type PropType, onMounted } from "vue";
+import { PlusOutlined, MinusCircleOutlined } from '@ant-design/icons-vue';
+import draggable from 'vuedraggable'
+
+import type ReportMetaTextInput from './ReportMetaTextInput.vue';
+import type { ReportMetadataItem } from "@/types/report.types";
+
+type ComponentType = typeof ReportMetaTextInput;
+
+const props = defineProps({
+  data: {
+    type: Array as PropType<ReportMetadataItem[]>,
+    required: true,
+  }
+});
+
+interface DynamicComponent {
+  id: number;
+  component: ComponentType;
+  prop: ReportMetadataItem;
+}
+
+const dynamicComponents: Ref<DynamicComponent[]> = ref([]);
+
+const emits = defineEmits<{
+  (e: "change", data: ReportMetadataItem[]): void;
+}>();
+
+function addTextComponent(index: number, prop: ReportMetadataItem = { name: '', value: '', type: 'text' }) {
+  dynamicComponents.value.push({
+    id: index,
+    component: markRaw(defineAsyncComponent(() => import('./ReportMetaTextInput.vue') )),
+    prop
+  });
+}
+
+function addDateComponent(index: number, prop: ReportMetadataItem = { name: '', value: '', type: 'date' }) {
+  dynamicComponents.value.push({
+    id: index,
+    component: markRaw(defineAsyncComponent(() => import('./ReportMetaDateInput.vue') )),
+    prop
+  });
+}
+
+function addDateRangeComponent(index: number, prop: ReportMetadataItem = { name: '', value: '', type: 'dateRange' }) {
+  dynamicComponents.value.push({
+    id: index,
+    component: markRaw(defineAsyncComponent(() => import('./ReportMetaDateRangeInput.vue') )),
+    prop
+  });
+}
+
+function handleMenuClick(e: {key: string}) {
+  const index = dynamicComponents.value.length+1;
+  if (e.key == 'text') {
+    addTextComponent(index);
+  } else if (e.key === 'date') {
+    addDateComponent(index);
+  } else if (e.key === 'dateRange') {
+    addDateRangeComponent(index,{ name: '起止时间', value: '', type: 'dateRange' });
+  }
+}
+
+function emitChange() {
+  const props = dynamicComponents.value.map((item) => item.prop);
+  emits("change", props);
+}
+
+function deleteComponent(index: number) {
+  dynamicComponents.value.splice(index, 1);
+  emitChange()
+}
+
+function onMetaCompChange() {
+  emitChange()
+}
+
+function onDragEnd() {
+  emitChange()
+}
+
+onMounted(() => {
+  if (props.data) {
+    props.data.forEach((metadataItem, index) => {
+      if (metadataItem.type == 'text') {
+        addTextComponent(index, metadataItem);
+      } else if (metadataItem.type == 'date') {
+        addDateComponent(index, metadataItem);
+      } else if (metadataItem.type == 'dateRange') {
+        addDateRangeComponent(index, metadataItem);
+      }
+    })
+  }
+});
+</script>
+
+<template>
+  <div class="dynamic-meta-wrap">
+    <draggable v-model="dynamicComponents" item-key="id" group="components" @end="onDragEnd">
+      <template #item="{element, index}">
+        <a-row type="flex" class="dynamic-meta-item-row">
+          <a-col flex="1">
+            <component
+              :is="element.component"
+              v-model:data="element.prop"
+              @update:data="onMetaCompChange"
+            />
+          </a-col>
+          <a-col>
+            <a-button type="text" @click="deleteComponent(index)">
+              <template #icon><minus-circle-outlined /></template>
+            </a-button>
+          </a-col>
+        </a-row>
+      </template>
+    </draggable>
+    <a-dropdown class="add-item-dropdown">
+      <template #overlay>
+        <a-menu @click="handleMenuClick">
+          <a-menu-item key="text">文本项</a-menu-item>
+          <a-menu-item key="date">日期项</a-menu-item>
+          <a-menu-item key="dateRange">起止时间</a-menu-item>
+        </a-menu>
+      </template>
+      <a-button>
+        <plus-outlined />
+        添加新项
+      </a-button>
+    </a-dropdown>
+  </div>
+</template>
+
+<style scoped lang="scss">
+.dynamic-meta-wrap {
+  .dynamic-meta-item-row {
+    margin-top: 0.5em;
+  }
+
+  .add-item-dropdown {
+    margin-top: 0.5em;
+  }
+}
+</style>

+ 67 - 0
src/views/report/components/dynamic-meta/ReportMetaDateInput.vue

@@ -0,0 +1,67 @@
+<!-- 报告元数据日期输入类型组件 -->
+
+<script setup lang="ts">
+import { onMounted, ref, type PropType } from "vue";
+import type { Dayjs } from 'dayjs';
+import dayjs from "dayjs";
+import type { ReportMetadataItem } from "@/types/report.types";
+
+const props = defineProps({
+  data: {
+    type: Object as PropType<ReportMetadataItem>,
+    required: true,
+  }
+});
+
+const value = ref<Dayjs>();
+
+onMounted(() => {
+  const date = dayjs(props.data.value);
+  if (date.isValid()) {
+    value.value = date;
+  }
+});
+
+const emits = defineEmits<{
+  (e: "update:data", data: ReportMetadataItem): void;
+}>();
+
+function onNameChange(e: InputEvent) {
+  change((e.target as HTMLInputElement).value, props.data.value);
+}
+
+function onValueChange(_: any, dateString: string) {
+  change(props.data.name, dateString);
+}
+
+function change(name: string, value: string) {
+  emits("update:data", { name, value, type: props.data.type });
+}
+</script>
+
+<template>
+  <a-row type="flex" style="align-items: center">
+    <a-col class="name-wrap">
+      <a-space>
+        <a-input :value="data.name" @change="onNameChange" placeholder="字段名称" class="name" />
+        <span>:</span>
+      </a-space>
+    </a-col>
+    <a-col flex="1">
+      <a-date-picker v-model:value="value" @change="onValueChange" class="value" />
+    </a-col>
+  </a-row>
+</template>
+
+<style scoped lang="scss">
+.name-wrap {
+  width: 120px;
+
+  .name {
+    width: 100%
+  }
+}
+.value {
+  width: 100%;
+}
+</style>

+ 79 - 0
src/views/report/components/dynamic-meta/ReportMetaDateRangeInput.vue

@@ -0,0 +1,79 @@
+<!-- 报告元数据日期输入类型组件 -->
+
+<script setup lang="ts">
+import { onMounted, ref, type PropType } from "vue";
+import type { Dayjs } from 'dayjs';
+import dayjs from "dayjs";
+import type { ReportMetadataItem } from "@/types/report.types";
+
+const props = defineProps({
+  data: {
+    type: Object as PropType<ReportMetadataItem>,
+    required: true,
+  }
+});
+
+type RangeValue = [Dayjs, Dayjs];
+
+const value = ref<RangeValue>();
+// 日期连接字符串
+const concatChar = ' 至 ';
+
+onMounted(() => {
+  const v = props.data.value;
+  let from = '', to = '';
+  if (v.includes(concatChar)) {
+    [from, to] = v.split(concatChar);
+  } else {
+    [from, to] = props.data.value.split(' ');
+  }
+  const dateFrom = dayjs(from);
+  const dateTo = dayjs(to);
+  if (dateFrom.isValid() && dateTo.isValid()) {
+    value.value = [dateFrom, dateTo]
+  }
+});
+
+const emits = defineEmits<{
+  (e: "update:data", data: ReportMetadataItem): void;
+}>();
+
+function onNameChange(e: InputEvent) {
+  change((e.target as HTMLInputElement).value, props.data.value);
+}
+
+function onValueChange(_: Dayjs | string, dateString: [string, string]) {
+  change(props.data.name, dateString.join(concatChar));
+}
+
+function change(name: string, value: string) {
+  emits("update:data", { name, value, type: props.data.type });
+}
+</script>
+
+<template>
+  <a-row type="flex" style="align-items: center">
+    <a-col class="name-wrap">
+      <a-space>
+        <a-input :value="data.name" @change="onNameChange" placeholder="字段名称" class="name" />
+        <span>:</span>
+      </a-space>
+    </a-col>
+    <a-col flex="1">
+      <a-range-picker v-model:value="value" @change="onValueChange" class="value" />
+    </a-col>
+  </a-row>
+</template>
+
+<style scoped lang="scss">
+.name-wrap {
+  width: 120px;
+
+  .name {
+    width: 100%
+  }
+}
+.value {
+  width: 100%;
+}
+</style>

+ 56 - 0
src/views/report/components/dynamic-meta/ReportMetaTextInput.vue

@@ -0,0 +1,56 @@
+<!-- 报告元数据文本输入类型组件 -->
+
+<script setup lang="ts">
+import type { ReportMetadataItem } from "@/types/report.types";
+import type { PropType } from "vue";
+
+const props = defineProps({
+  data: {
+    type: Object as PropType<ReportMetadataItem>,
+    required: true,
+  }
+});
+
+const emits = defineEmits<{
+  (e: "update:data", data: ReportMetadataItem): void;
+}>();
+
+function onNameChange(e: InputEvent) {
+  change((e.target as HTMLInputElement).value, props.data.value);
+}
+
+function onValueChange(e: InputEvent) {
+  change(props.data.name, (e.target as HTMLInputElement).value);
+}
+
+function change(name: string, value: string) {
+  emits("update:data", { name, value, type: props.data.type });
+}
+</script>
+
+<template>
+  <a-row type="flex" style="align-items: center">
+    <a-col class="name-wrap">
+      <a-space>
+        <a-input :value="data.name" @change="onNameChange" placeholder="字段名称" class="name" />
+        <span>:</span>
+      </a-space>
+    </a-col>
+    <a-col flex="1">
+      <a-input :value="data.value" @change="onValueChange" placeholder="请输入字段值" class="value" />
+    </a-col>
+  </a-row>
+</template>
+
+<style scoped lang="scss">
+.name-wrap {
+  width: 120px;
+
+  .name {
+    width: 100%
+  }
+}
+.value {
+  width: 100%;
+}
+</style>

+ 97 - 0
src/views/report/components/reference/AIContentDraw.vue

@@ -0,0 +1,97 @@
+<!-- AI生成内容展示的抽屉组件 -->
+
+<script setup lang="ts">
+import { message } from 'ant-design-vue';
+import { ref } from 'vue';
+import { useClipboard } from "@/libs/clipboard.lib";
+
+const props = defineProps({
+  content: {
+    type: String,
+    required: true
+  }
+})
+
+const clipboard = useClipboard()
+
+// 组件可见状态
+const visible = ref<boolean>(false);
+
+const emits = defineEmits<{
+  (e: "insert", text: string): void;
+  (e: "close"): void;
+}>();
+
+function getSelectedText() {
+  if (window.getSelection) {
+    const range = window.getSelection()
+    return range?.toString()
+  } else {
+    message.warning("暂不支持此版本浏览器选择复制")
+  }
+  return null
+}
+
+/**
+ * 清理文本当中的em标签
+ * @param {string} text 文本
+ * @returns {string} 清理后的文本
+ */
+function purnText(text: string): string {
+  if (!text) {
+    return text
+  }
+  const re = new RegExp("</?em>", "gi");
+  return text.replace(re, '')
+}
+
+function onCopy() {
+  const selectedText = getSelectedText()
+  const text = purnText(selectedText || '')
+  const msg = selectedText && "复制所选内容成功" || "复制内容成功"
+  clipboard.copy(text).then(() => {
+    message.success(msg)
+  })
+}
+
+function onInsertion() {
+  const text = purnText(getSelectedText() || props.content)
+  emits("insert", text)
+}
+
+const onClose = () => {
+  visible.value = false;
+  emits("close")
+};
+
+const show = () => {
+  visible.value = true;
+};
+
+defineExpose({
+  show
+});
+</script>
+
+<template>
+  <a-drawer
+    :width="'60%'"
+    title="检索参考文献"
+    placement="right"
+    :visible="visible"
+    @close="onClose"
+    class="reference-drawer"
+  >
+    <h1>AI生成的参考内容</h1>
+    <p>{{ content }}</p>
+    <div class="btn-group">
+      <a-radio-group>
+        <a-radio-button value="default" @mousedown="onCopy">复制</a-radio-button>
+        <a-radio-button value="small" @mousedown="onInsertion">插入</a-radio-button>
+      </a-radio-group>
+    </div>
+  </a-drawer>
+</template>
+
+<style scoped lang="scss">
+</style>

+ 207 - 0
src/views/report/components/reference/ReferenceDraw.vue

@@ -0,0 +1,207 @@
+<!-- 参考文献抽屉组件 -->
+
+<script setup lang="ts">
+import { ref, reactive, type Ref } from "vue";
+import ReferenceSearchBox from "./ReferenceSearchBox.vue";
+import ReferenceSearchResultItem from "./ReferenceSearchResultItem.vue";
+import type { ChapterSearchDocResponse, ChapterSearchRequest } from "@/types/search.types"
+import * as chapterSearchService from "@/services/chapterSearch.service"
+import * as paperSectionService from "@/services/paperSection.service"
+import { CompLog } from "@/libs/log.lib";
+import SpinComponent from "@/components/SpinComponent.vue";
+
+const props = defineProps({
+  reportName: {
+    type: String,
+    required: true,
+  },
+  reportKeywords: {
+    type: String,
+    required: true,
+  },
+  chapterKeywords: {
+    type: Array<String>,
+    required: true,
+  }
+});
+
+const emits = defineEmits<{
+  (e: "insert", text: string): void;
+  (e: "close"): void;
+  (e: "recordUse", sectionId: number): void;
+}>();
+
+// 组件可见状态
+const visible = ref<boolean>(false);
+// 章节搜索结果总数量
+const total = ref(0)
+// 章节搜索结果
+const referenceSearchResult: Ref<ChapterSearchDocResponse[]> = ref([]);
+// 文献章节段落内容是否使用ref
+const paperSectionUsage = ref<Record<number, boolean>>({})
+// 章节搜索状态
+const loading = ref(false)
+// 翻页
+const pagination = reactive({
+  page: 1,
+  size: 10,
+})
+// 重新检索的查询词
+const queryKeywords = ref<string[]>([])
+// 在结果中检索的查询
+const queryWithInKeywords = ref<string[]>([])
+
+function getFrom(page?: number) {
+  return (Math.max(1, page || 1) - 1) * pagination.size
+}
+
+function getDefaultKeywords(): string[] {
+  return props.chapterKeywords as string[]
+}
+
+function getSearchRequest(): ChapterSearchRequest {
+  const request: ChapterSearchRequest = {
+    reportName: props.reportName || '',
+    reportKeywords: props.reportKeywords || '',
+    chapterKeywords: getDefaultKeywords(),
+    queryKeywords: queryKeywords.value,
+    queryWithInKeywords: queryWithInKeywords.value,
+    from: getFrom(pagination.page)
+  }
+  return request
+}
+
+function querySectionUsage(sectionIds: number[]) {
+  paperSectionService.query(sectionIds).then((usages) => {
+    paperSectionUsage.value = usages
+  })
+}
+
+function search(request: ChapterSearchRequest) {
+  loading.value = true
+  referenceSearchResult.value = []
+  chapterSearchService.search(request).then((resp) => {
+    loading.value = false
+    total.value = resp.total
+    referenceSearchResult.value = resp.items
+
+    querySectionUsage(resp.items.map(item => item.id))
+  }).catch((err) => {
+    CompLog.logErr("ReferenceDraw")(err)
+    loading.value = false
+  })
+}
+
+function onPageChange(page: number) {
+  pagination.page = page
+  search(getSearchRequest())
+}
+
+function onReferenceSearchBoxSearch(queryKeywordsIn: string[]) {
+  pagination.page = 1
+  queryKeywords.value = queryKeywordsIn
+  queryWithInKeywords.value = []
+  search(getSearchRequest())
+}
+
+function onReferenceSearchWithInSearch(queryWithInKeywordsIn: string[]) {
+  pagination.page = 1
+  queryWithInKeywords.value = queryWithInKeywordsIn
+  search(getSearchRequest())
+}
+
+function onSectionRecordUse(sectionId: number) {
+  paperSectionService.record(sectionId).then(() => {
+    paperSectionUsage.value[`${sectionId}`] = true
+  })
+}
+
+function onSectionRecordCancel(sectionId: number) {
+  paperSectionService.cancel(sectionId).then(() => {
+    paperSectionUsage.value[`${sectionId}`] = false
+  })
+}
+
+function serialNumber(index: number) {
+  index += 1;
+  return (pagination.page - 1) * pagination.size + index;
+}
+
+const onClose = () => {
+  visible.value = false;
+  emits("close")
+};
+
+const show = () => {
+  visible.value = true;
+  const keywords = props.chapterKeywords
+  if (keywords && keywords.length > 0) {
+    search(getSearchRequest())
+  }
+};
+
+defineExpose({
+  show
+});
+</script>
+
+<template>
+  <a-drawer
+    :width="'60%'"
+    title="检索参考文献"
+    placement="right"
+    :visible="visible"
+    @close="onClose"
+    class="reference-drawer"
+  >
+    <template #extra>
+      <span>项目名称:{{ reportName }}</span>
+      <a-divider type="vertical" />
+      <span>关键词:{{ reportKeywords }}</span>
+      <a-divider type="vertical" />
+      <span>章节关键词:{{ chapterKeywords.join(", ") }}</span>
+    </template>
+    <ReferenceSearchBox @search="onReferenceSearchBoxSearch" @search-with-in="onReferenceSearchWithInSearch" />
+    <a-divider orientation="left">
+      <span class="total">章节数量:共 {{ total }} 条</span>
+    </a-divider>
+    <SpinComponent :spinning="loading">
+      <div v-for="(item, index) in referenceSearchResult" :key="index">
+        <ReferenceSearchResultItem
+          :data="item"
+          :used="paperSectionUsage[item.id] || false"
+          :serialNumber="serialNumber(index)"
+          @insert="emits('insert', $event)"
+          @record-use="onSectionRecordUse"
+          @record-cancel="onSectionRecordCancel"
+        />
+        <a-divider class="result-item-divider" v-if="index < referenceSearchResult.length - 1" />
+      </div>
+      <div class="pigination-wrap">
+        <a-pagination
+          :current="pagination.page"
+          :pageSize="pagination.size"
+          :total="total"
+          :show-size-changer="false"
+          size="small"
+          @change="onPageChange"
+        />
+      </div>
+    </SpinComponent>
+  </a-drawer>
+</template>
+
+<style scoped lang="scss">
+.result-item-divider {
+  margin: 1.2em 0;
+}
+
+.total {
+  font-size: 0.8em;
+}
+
+.pigination-wrap {
+  text-align: right;
+  margin-top: 1em;
+}
+</style>

+ 36 - 0
src/views/report/components/reference/ReferenceSearchBox.vue

@@ -0,0 +1,36 @@
+<script setup lang="ts">
+import { ref } from "vue";
+
+const keyword = ref('');
+
+const emits = defineEmits<{
+  (e: "search", keyword: string[]): void;
+  (e: "searchWithIn", keyword: string[]): void;
+}>();
+
+function getKeywords(keyword: string) {
+  return keyword.split(' ').filter((item) => item.trim() != "")
+}
+
+function onSearch() {
+  emits("search", getKeywords(keyword.value));
+}
+
+function onSearchWithIn() {
+  emits("searchWithIn", getKeywords(keyword.value));
+}
+</script>
+
+<template>
+  <a-row type="flex" :gutter="8">
+    <a-col flex="1">
+      <a-input v-model:value="keyword" placeholder="请输入查询词进行检索,使用空格区分多个查询词" />
+    </a-col>
+    <a-col>
+      <a-space>
+        <a-button type="primary" @click="onSearch">重新检索</a-button>
+        <a-button @click="onSearchWithIn">在结果中检索</a-button>
+      </a-space>
+    </a-col>
+  </a-row>
+</template>

+ 154 - 0
src/views/report/components/reference/ReferenceSearchResultItem.vue

@@ -0,0 +1,154 @@
+<script setup lang="ts">
+import { useClipboard } from "@/libs/clipboard.lib";
+import type { ChapterSearchDocResponse } from "@/types/search.types";
+import { message } from "ant-design-vue";
+import { ExclamationCircleFilled } from "@ant-design/icons-vue";
+import type { PropType } from "vue";
+
+const props = defineProps({
+  data: {
+    type: Object as PropType<ChapterSearchDocResponse>,
+    required: true
+  },
+  used: {
+    type: Boolean,
+    required: false,
+  },
+  serialNumber: {
+    type: Number,
+    required: true,
+  }
+});
+
+const emits = defineEmits<{
+  (e: "insert", text: string): void;
+  (e: "recordUse", sectionId: number): void;
+  (e: "recordCancel", sectionid: number): void;
+}>();
+
+const clipboard = useClipboard()
+
+function getSelectedText() {
+  if (window.getSelection) {
+    const range = window.getSelection()
+    return range?.toString()
+  } else {
+    message.warning("暂不支持此版本浏览器选择复制")
+  }
+  return null
+}
+
+/**
+ * 清理文本当中的em标签
+ * @param {string} text 文本
+ * @returns {string} 清理后的文本
+ */
+function purnText(text: string): string {
+  if (!text) {
+    return text
+  }
+  const re = new RegExp("</?em>", "gi");
+  return text.replace(re, '')
+}
+
+function onRecordUse() {
+  emits("recordUse", props.data.id)
+}
+
+function onRecordCancel() {
+  emits("recordCancel", props.data.id)
+}
+
+function onCopy() {
+  const selectedText = getSelectedText()
+  const text = purnText(selectedText || props.data.content)
+  const msg = selectedText && "复制所选内容成功" || "复制内容成功"
+  clipboard.copy(text).then(() => {
+    message.success(msg)
+  })
+}
+
+function onInsertion() {
+  const text = purnText(getSelectedText() || props.data.content)
+  emits("insert", text)
+}
+
+function section(content: string, number: number) {
+  let tag: string = "</span><br/>";
+  let section: string = "<span style='padding-left: 2em;'>";
+
+  let result: string = ""
+  let sentenceArr: string[] | null = content.match(/。/g);
+  if (sentenceArr && sentenceArr.length > number) {
+     let length = sentenceArr.length;
+     for (let i = 0; i < length; i++) {
+        if (i > 0 && i % number == 0) {
+          result += (section + tag);
+          section= "<span style='padding-left: 2em;'>";
+        }
+        let index = content.indexOf("。");
+        section += content.slice(0, index+1);
+        content = content.slice(index+1);
+     }   
+     return result;
+  }
+  return content;
+}
+</script>
+
+<template>
+  <h3>
+    <span>{{ serialNumber }}. </span>
+    <a v-if="data.docId" :href="'/detail/' + data.docId" target="_blank">{{ data.title }}</a>
+    <span v-else>{{ data.title }}</span>
+  </h3>
+  <div class="meta">
+    <a-space style="row-gap: 0px;column-gap: 8px;">
+      <span v-if="data.journalName">期刊名称:{{ data.journalName }}</span> <br />
+      <span v-if="data.year">时间:{{ data.year }}</span>
+      <span v-for="(item, index) in data.unitScholars" :key="index">
+        {{ item.scholarName }} {{ item.unitName }}
+      </span>
+    </a-space>
+  </div>
+  <h4 class="section-title" v-html="data.section"></h4>
+  <div class="content-wrap" :class="{'used': used}">
+    <exclamation-circle-filled v-if="used" />
+    <span class="content" v-html="section(data.content, 3)"></span>
+  </div>
+  <div class="btn-group">
+    <a-radio-group>
+      <a-radio-button value="large" @click="onRecordUse" v-if="!used">标记使用</a-radio-button>
+      <a-radio-button value="large" @click="onRecordCancel" v-if="used">取消标记</a-radio-button>
+      <a-radio-button value="default" @mousedown="onCopy">复制</a-radio-button>
+      <a-radio-button value="small" @mousedown="onInsertion">插入</a-radio-button>
+    </a-radio-group>
+  </div>
+</template>
+
+<style scoped lang="scss">
+.meta .ant-space {
+  flex-wrap: wrap;
+  font-size: 12px;
+}
+.section-title {
+  margin: 4px 0 4px 0;
+  :deep em {
+    color: red;
+    font-style: normal;
+  }
+}
+.content-wrap {
+  &.used {
+    color: #bebebe;
+  }
+
+  :deep .content em {
+    color: red;
+    font-style: normal;
+  }
+}
+.btn-group {
+  margin-top: 0.8em;
+}
+</style>

+ 108 - 0
src/views/report/components/report-create/RelationCompanyInput.vue

@@ -0,0 +1,108 @@
+<script setup lang="ts">
+import { onMounted, 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 "@/libs/log.lib";
+
+const logError = CompLog.logErr("RelationCompanyInput")
+
+const name: Ref<string> = ref('');
+const companyId: Ref<number | null> = ref(null);
+
+const props = defineProps({
+  id: {
+    type: Number,
+    required: true,
+  }
+});
+
+const options = ref<SelectProps['options']>([]);
+
+const selectEl = ref<HTMLInputElement | null>(null);
+
+const newName = ref('');
+
+const visible = ref(false);
+
+const emits = defineEmits<{
+  (e: "change", companyId: number): void;
+}>();
+
+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(logError);
+}, 300);
+
+function onFocus() {
+  fetchCompany(name.value);
+}
+
+function onChange(val: number) {
+  companyId.value = val;
+  emits("change", companyId.value);
+}
+
+function onModalOk() {
+  visible.value = false;
+  companyService.create(newName.value).then((company) => {
+    name.value = company.name;
+    companyId.value = company.id;
+    emits("change", companyId.value);
+  }).catch(logError);
+}
+
+function focus() {
+  selectEl.value?.focus();
+}
+
+onMounted(() => {
+  const id = props.id;
+  if (id > 0) {
+    companyService.get(id).then((company) => {
+      companyId.value = id;
+      name.value = company.name;
+    }).catch(logError);
+  }
+});
+
+defineExpose({
+  focus
+});
+</script>
+
+<template>
+  <a-row type="flex" style="align-items: center">
+    <a-col style="width: 120px; text-align: right;">
+      关联企业:
+    </a-col>
+    <a-col flex="1">
+      <a-select
+        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="null"
+        :options="options"
+        @focus="onFocus"
+        @search="fetchCompany"
+        @change="onChange"
+        ref="selectEl"
+      >
+      </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>

+ 39 - 0
src/views/report/components/report-create/ReportField.vue

@@ -0,0 +1,39 @@
+<script setup lang="ts">
+import { ref } from "vue";
+
+defineProps({
+  name: String,
+  modelValue: String,
+  placeholder: String,
+});
+
+const emits = defineEmits<{
+  (e: "update:modelValue", value: string): void;
+}>();
+
+const input = ref<HTMLInputElement | null>(null);
+
+function focus() {
+  input.value?.focus();
+}
+
+defineExpose({
+  focus
+});
+</script>
+
+<template>
+  <a-row type="flex" style="align-items: center">
+    <a-col style="width: 120px; text-align: right;">
+      {{ name }}:
+    </a-col>
+    <a-col flex="1">
+      <a-input
+        :value="modelValue"
+        @input="emits('update:modelValue', $event.target.value)"
+        :placeholder="placeholder"
+        ref="input"
+      />
+    </a-col>
+  </a-row>
+</template>

+ 41 - 0
src/views/report/components/report-list/CompanySelect.vue

@@ -0,0 +1,41 @@
+<script setup lang="ts">
+import type { PropType } from "vue";
+import CompanySelectItem from "./CompanySelectItem.vue";
+import type { Prop } from "./CompanySelectItem.vue";
+
+defineProps({
+  data: {
+    type: Array as PropType<Prop[]>,
+    required: true,
+  }
+});
+
+const emits = defineEmits<{
+  (e: "change", event: {index: number, checked: boolean}): void;
+}>();
+
+function onItemChange(index: number, checked: boolean) {
+  emits("change", {index, checked});
+}
+</script>
+
+<template>
+  <CompanySelectItem
+    v-for="(item, i) in data"
+    :key="i"
+    :data="item"
+    @change="onItemChange(i, $event)"
+    class="select-item"
+    :class="{'kede-checked': item.checked}"
+  />
+</template>
+
+<style scoped lang="scss">
+.select-item {
+  margin-top: 0.5em;
+}
+
+.kede-checked {
+  background-color: #E4F4FF;
+}
+</style>

+ 47 - 0
src/views/report/components/report-list/CompanySelectItem.vue

@@ -0,0 +1,47 @@
+<script setup lang="ts">
+import { ref, watch, type PropType, onMounted } from "vue";
+
+export interface Prop {
+  label: string;
+  tail: string | number;
+  checked?: boolean;
+}
+
+const props = defineProps({
+  data: {
+    type: Object as PropType<Prop>,
+    required: true,
+  }
+});
+
+const checked = ref(false);
+
+watch(
+  () => !!props.data.checked,
+  (value: boolean) => checked.value = value
+);
+
+onMounted(() => {
+  checked.value = !!props.data.checked;
+
+});
+
+const emits = defineEmits<{
+  (e: "change", checked: boolean): void;
+}>();
+
+function onChange() {
+  emits("change", checked.value);
+}
+</script>
+
+<template>
+  <a-row type="flex">
+    <a-col :span="22">
+      <a-checkbox v-model:checked="checked" @change="onChange">{{ data.label }}</a-checkbox>
+    </a-col>
+    <a-col :span="2">
+      {{ data.tail }}
+    </a-col>
+  </a-row>
+</template>

+ 68 - 0
src/views/report/components/report-list/QueryBox.vue

@@ -0,0 +1,68 @@
+<script setup lang="ts">
+import type { SelectProps } from "ant-design-vue";
+import { ref, type PropType } from "vue";
+
+export type FieldType = 'title' | 'companyName' | 'keyword' | 'reportName';
+
+const props = defineProps({
+  field: {
+    type: String as PropType<FieldType>,
+    required: true,
+  },
+  keyword: {
+    type: String,
+    required: true,
+  }
+});
+
+const fieldOptions = ref<SelectProps['options']>([
+  { value: 'title', label: '按报告类型' },
+  { value: 'companyName', label: '按企业名称' },
+  { value: 'reportName', label: '按项目名称' },
+  { value: 'keyword', label: '按关键词' },
+]);
+
+const emits = defineEmits<{
+  (e: "update:field", field: string): void;
+  (e: "update:keyword", keyword: string): void;
+  (e: "search", data: { field: string, keyword: string }): void;
+}>();
+
+function onFieldChange(value: string) {
+  emits("update:field", value);
+}
+
+function onKeywordChange(e: InputEvent) {
+  const value = (e.target as HTMLInputElement).value
+  emits("update:keyword", value);
+}
+
+function onSearch() {
+  emits("search", { field: props.field, keyword: props.keyword })
+}
+</script>
+
+<template>
+  <a-row type="flex">
+    <a-col>
+      <a-select
+        ref="select"
+        :value="field"
+        style="width: 140px"
+        :options="fieldOptions"
+        @change="onFieldChange"
+        size="large"
+      ></a-select>
+    </a-col>
+    <a-col flex="1">
+      <a-input-search
+        :value="keyword"
+        placeholder="请输入关键词进行查询"
+        enter-button
+        size="large"
+        @change="onKeywordChange"
+        @search="onSearch"
+      />
+    </a-col>
+  </a-row>
+</template>

+ 93 - 0
src/views/report/components/report-list/ReportItem.vue

@@ -0,0 +1,93 @@
+<!-- 报告列表的一项 -->
+<script setup lang="ts">
+import { useReportExport } from "@/libs/reportExport.lib";
+import { useAuthStore } from "@/stores/auth.store";
+import type { ReportBasic } from "@/types/report.types";
+import type { PropType } from "vue";
+
+// 用户权限store
+const authStore = useAuthStore();
+// 报告下载函数库
+const reportExport = useReportExport();
+
+defineProps({
+  data: {
+    type: Object as PropType<ReportBasic>,
+    required: true,
+  },
+  serialNumber: {
+    type: Number,
+    required: true,
+  },
+});
+
+const emits = defineEmits<{
+  (e: "delete", id: number): void;
+}>();
+
+function onDelete(id: number) {
+  emits("delete", id);
+}
+</script>
+
+<template>
+  <a-card hoverable size="small">
+    <div class="title-link">
+      <span class="text-black"> {{ serialNumber }}. </span>
+      <span v-if="data.companyName" class="text-black"> {{ data.companyName }}</span>
+      <span v-else class="text-red">无关联企业</span>
+      <span class="text-black"> - </span>
+      <span v-if="data.name" class="text-black">{{ data.name }}</span>
+      <span v-else class="text-red">无报告类型</span>
+      <span v-if="data.status == 1" class="text-black">&nbsp;&nbsp; 已完成</span>
+      <span v-else class="text-black">&nbsp;&nbsp; 编写中</span>
+    </div>
+
+    <!-- <template #extra>
+      <RouterLink :to="{ name: 'ReportEditor', params: { id: data.id } }">编辑</RouterLink>
+      <a-button type="link" :href="reportExport.link(data.id, authStore.token)" target="_blank">下载</a-button>
+      <a-popconfirm
+        title="确定删除此报告?"
+        ok-text="确定"
+        cancel-text="取消"
+        @confirm="onDelete(data.id)"
+        placement="topRight"
+      >
+        <a-button type="link">删除</a-button>
+      </a-popconfirm>
+    </template> -->
+
+    <div class="report-metadata-wrap">
+      <p>
+        <span>企业名称:</span><span>{{ data.companyName }}</span>
+      </p>
+      <p>
+        <span>项目名称:</span><span>{{ data.reportName }}</span>
+      </p>
+      <p>
+        <span>项目负责人:</span><span>{{ data.supervisor }}</span>
+      </p>
+      <p v-for="(item, index) in data.metadata" :key="index">
+        <span>{{ item.name }}:</span>
+        <span>{{ item.value }}</span>
+      </p>
+    </div>
+  </a-card>
+</template>
+
+<style scoped lang="scss">
+.title-link {
+  margin-bottom: 0.6em;
+  .text-black {
+    color: rgba(0, 0, 0, 0.85);
+  }
+}
+.report-metadata-wrap {
+  padding-top: 0.3em;
+  padding-bottom: 0.3em;
+  p {
+    margin-top: 0.3em;
+    margin-bottom: 0.3em;
+  }
+}
+</style>

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

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

+ 94 - 0
src/views/user/CreateUser.vue

@@ -0,0 +1,94 @@
+<script setup lang="ts">
+import { defineComponent, reactive, toRaw } from "vue";
+import PageHeader from "@/components/PageHeader.vue";
+import type { CreateUser } from "@/types/user.types";
+import user from "@/services/user.service";
+import { message } from "ant-design-vue";
+
+const formState = reactive<CreateUser>({
+  username: "",
+  password: "",
+  truename: "",
+  email: "",
+  sex: 1,
+  userType: 1,
+  mobile: "",
+  position: "",
+});
+
+function onSubmit() {
+  user.createUser(formState).then(() => {
+    message.success("添加成功");
+    initForm();
+  });
+}
+
+function initForm() {
+  formState.username = "";
+  formState.password = "";
+  formState.truename = "";
+  formState.email = "";
+  formState.sex = 1;
+  formState.userType = 1;
+  formState.mobile = "";
+  formState.position = "";
+}
+</script>
+
+<template>
+  <div class="create-user-wrap">
+    <PageHeader title="添加用户"> </PageHeader>
+    <a-form
+      class="create-user-form"
+      :model="formState"
+      :label-col="{ span: 2 }"
+      :wrapper-col="{ span: 22 }"
+    >
+      <a-form-item label="用户名">
+        <a-input v-model:value="formState.username" />
+      </a-form-item>
+      <a-form-item
+        label="密码"
+        :rules="[{ required: true, message: '请输入密码!' }]"
+      >
+        <a-input v-model:value="formState.password" />
+      </a-form-item>
+      <a-form-item
+        label="真实姓名"
+        :rules="[{ required: true, message: '请输入用户名!' }]"
+      >
+        <a-input v-model:value="formState.truename" />
+      </a-form-item>
+      <a-form-item
+        label="手机号"
+        :rules="[{ required: true, message: '请输入手机号!' }]"
+      >
+        <a-input v-model:value="formState.mobile" />
+      </a-form-item>
+      <a-form-item label="邮箱">
+        <a-input v-model:value="formState.email" />
+      </a-form-item>
+      <a-form-item label="职务">
+        <a-input v-model:value="formState.position" />
+      </a-form-item>
+      <a-form-item label="性别">
+        <a-radio-group v-model:value="formState.sex">
+          <a-radio value="1">男</a-radio>
+          <a-radio value="0">女</a-radio>
+        </a-radio-group>
+      </a-form-item>
+      <a-form-item :wrapper-col="{ span: 14, offset: 4 }">
+        <a-button type="primary" @click="onSubmit">添加</a-button>
+      </a-form-item>
+    </a-form>
+  </div>
+</template>
+<style scoped lang="scss">
+.create-user-wrap {
+  .create-user-form {
+    > input {
+      width: 80%;
+    }
+  }
+}
+</style>

+ 198 - 0
src/views/user/UserList.vue

@@ -0,0 +1,198 @@
+<script setup lang="ts">
+import { reactive, onMounted, ref, type Ref } from "vue";
+import PageHeader from "@/components/PageHeader.vue";
+import user from "@/services/user.service";
+import { message } from "ant-design-vue";
+
+interface DataItem {
+  id: number;
+  key: string;
+  username?: string;
+  truename?: string;
+  email: string;
+  sex: string;
+  mobile: string;
+  position: string;
+  isDisable: number;
+}
+
+const formState = reactive({
+  username: "",
+  truename: "",
+  email: "",
+  sex: 0,
+  mobile: "",
+  position: "",
+  isDisable: 1,
+});
+
+const dataSource: Ref<DataItem[]> = ref([]);
+
+const userPassword = reactive({
+  password: "",
+  password2: "",
+  mobilePhone: "",
+  truename: "",
+});
+
+const columns = [
+  {
+    title: "用户名",
+    dataIndex: "username",
+  },
+  {
+    title: "真实姓名",
+    dataIndex: "truename",
+  },
+  {
+    title: "邮箱",
+    dataIndex: "email",
+  },
+  {
+    title: "性别",
+    dataIndex: "sex",
+  },
+  {
+    title: "手机",
+    dataIndex: "mobile",
+  },
+  {
+    title: "职务",
+    dataIndex: "position",
+  },
+  {
+    title: "操作",
+    dataIndex: "cz",
+    slots: { customRender: "operation" },
+  },
+];
+
+const pagination = reactive({
+  pageIndex: 1,
+  pageSize: 10,
+  total: 0,
+  showSizeChanger: false,
+  showTotal: (total: number) => `共 ${total} 条数据`, // 展示总共有几条数据
+});
+
+const disables: Ref<number[]> = ref([]);
+
+const visible: Ref<boolean> = ref(false);
+
+function setDataSource(page: number) {
+  disables.value = [];
+  dataSource.value = [];
+  user.users(page).then((resp) => {
+    const data = resp.data.data;
+    pagination.total = data.total;
+    for (let i = 0; i < data.records.length; i++) {
+      let sex: string = data.records[i].sex == 0 ? "女" : "男";
+      let isDisable: number = data.records[i].isDisable;
+      let item: DataItem = {
+        id: data.records[i].id,
+        key: i + "",
+        username: data.records[i].username,
+        truename: data.records[i].truename,
+        email: data.records[i].email,
+        sex: sex,
+        mobile: data.records[i].mobile,
+        position: data.records[i].position,
+        isDisable: isDisable,
+      };
+      dataSource.value.push(item);
+      disables.value.push(isDisable);
+    }
+  });
+}
+
+function isDisable(key: number) {
+  if (disables.value[key] == 0) {
+    return true;
+  }
+  return false;
+}
+
+function disable(record: any) {
+  disables.value[record.key] = disables.value[record.key] == 0 ? 1 : 0;
+  user.updateIsDisable(record.id);
+}
+
+function paginationChange(page: any) {
+  setDataSource(page.current);
+  pagination.pageIndex = page.current;
+}
+
+function resetting(record: any) {
+  userPassword.mobilePhone = record.mobile;
+  userPassword.truename = record.truename;
+  userPassword.password = "";
+  userPassword.password2 = "";
+  visible.value = true;
+}
+
+function handleOk() {
+  if (
+    !(
+      userPassword.password &&
+      userPassword.password2 &&
+      userPassword.password == userPassword.password2
+    )
+  ) {
+    message.warn("俩次密码不一致");
+    return;
+  }
+  user.updatePassword(userPassword).then(() => {
+    message.success("重置密码成功");
+    visible.value = false;
+  });
+}
+
+onMounted(() => {
+  setDataSource(1);
+});
+</script>
+
+<template>
+  <div class="user-list-wrap">
+    <PageHeader title="用户管理"></PageHeader>
+    <div>
+      <a-table
+        bordered
+        :data-source="dataSource"
+        :columns="columns"
+        :pagination="pagination"
+        @change="paginationChange"
+      >
+        <template #operation="{ record }">
+          <a-button
+            type="primary"
+            ghost
+            @click="disable(record)"
+            v-if="isDisable(record.key)"
+          >
+            激活
+          </a-button>
+          <a-button type="primary" ghost @click="disable(record)" v-else>
+            禁用
+          </a-button>
+          <a-button type="primary" ghost @click="resetting(record)">
+            重置密码
+          </a-button>
+        </template>
+      </a-table>
+    </div>
+    <a-modal v-model:visible="visible" title="重置密码" @ok="handleOk">
+      <a-input v-model:value="userPassword.mobilePhone" :disabled="true" />
+      <a-input v-model:value="userPassword.truename" :disabled="true" />
+      <a-input-password
+        v-model:value="userPassword.password"
+        placeholder="新密码"
+      />
+      <a-input-password
+        v-model:value="userPassword.password2"
+        placeholder="请确认密码"
+      />
+    </a-modal>
+  </div>
+</template>
+<style scoped lang="scss"></style>

+ 12 - 0
tsconfig.app.json

@@ -0,0 +1,12 @@
+{
+  "extends": "@vue/tsconfig/tsconfig.web.json",
+  "include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
+  "exclude": ["src/**/__tests__/*"],
+  "compilerOptions": {
+    "composite": true,
+    "baseUrl": ".",
+    "paths": {
+      "@/*": ["./src/*"]
+    }
+  }
+}

+ 8 - 0
tsconfig.config.json

@@ -0,0 +1,8 @@
+{
+  "extends": "@vue/tsconfig/tsconfig.node.json",
+  "include": ["vite.config.*", "vitest.config.*", "cypress.config.*", "playwright.config.*"],
+  "compilerOptions": {
+    "composite": true,
+    "types": ["node"]
+  }
+}

+ 14 - 0
tsconfig.json

@@ -0,0 +1,14 @@
+{
+  "files": [],
+  "references": [
+    {
+      "path": "./tsconfig.config.json"
+    },
+    {
+      "path": "./tsconfig.app.json"
+    },
+    {
+      "path": "./tsconfig.vitest.json"
+    }
+  ]
+}

+ 9 - 0
tsconfig.vitest.json

@@ -0,0 +1,9 @@
+{
+  "extends": "./tsconfig.app.json",
+  "exclude": [],
+  "compilerOptions": {
+    "composite": true,
+    "lib": [],
+    "types": ["node", "jsdom"]
+  }
+}

+ 41 - 0
vite.config.ts

@@ -0,0 +1,41 @@
+import { fileURLToPath, URL } from "node:url";
+
+import { defineConfig } from "vite";
+import vue from "@vitejs/plugin-vue";
+import vueJsx from "@vitejs/plugin-vue-jsx";
+
+// https://vitejs.dev/config/
+export default defineConfig({
+  plugins: [vue(), vueJsx()],
+  resolve: {
+    alias: {
+      "@": fileURLToPath(new URL("./src", import.meta.url)),
+    },
+  },
+  server: {
+    proxy: {
+      "/api": {
+        // target: "http://localhost:8071",
+        target: "http://172.16.0.255:8071",
+        changeOrigin: true,
+        rewrite: (path) => path.replace(/^\/gw/, ""),
+      },
+      "/login": {
+        target: "http://localhost:8071",
+        changeOrigin: true,
+      },
+      "/user": {
+        target: "http://localhost:8071",
+        changeOrigin: true,
+      }
+    },
+    host: "0.0.0.0"
+  },
+  css: {
+    preprocessorOptions: {
+      less: {
+        javascriptEnabled: true,
+      },
+    },
+  },
+});

Diferenças do arquivo suprimidas por serem muito extensas
+ 4485 - 0
yarn.lock