Ver código fonte

feat: 构建智慧工地后台管理系统

bill 2 anos atrás
pai
commit
c302d19ee2
100 arquivos alterados com 6568 adições e 0 exclusões
  1. 1 0
      .env
  2. 1 0
      .env.pord
  3. 61 0
      .eslintrc.json
  4. 24 0
      .gitignore
  5. 6 0
      .husky/commit-msg
  6. 4 0
      .husky/pre-commit
  7. 9 0
      .prettierrc
  8. 3 0
      .vscode/extensions.json
  9. 13 0
      index.html
  10. 54 0
      package.json
  11. 1 0
      public/vite.svg
  12. 24 0
      scripts/verifyCommit.js
  13. 16 0
      src/App.vue
  14. 49 0
      src/api/constant.ts
  15. 21 0
      src/api/index.ts
  16. 72 0
      src/api/instance.ts
  17. 37 0
      src/api/member.ts
  18. 148 0
      src/api/project.ts
  19. 17 0
      src/api/record.ts
  20. 49 0
      src/api/scene.ts
  21. 274 0
      src/api/setup.ts
  22. 70 0
      src/api/user.ts
  23. 539 0
      src/assets/iconfont/demo.css
  24. 925 0
      src/assets/iconfont/demo_index.html
  25. 144 0
      src/assets/iconfont/iconfont.css
  26. 1 0
      src/assets/iconfont/iconfont.js
  27. 233 0
      src/assets/iconfont/iconfont.json
  28. 83 0
      src/assets/iconfont/iconfont.svg
  29. BIN
      src/assets/iconfont/iconfont.ttf
  30. BIN
      src/assets/iconfont/iconfont.woff
  31. BIN
      src/assets/iconfont/iconfont.woff2
  32. BIN
      src/assets/images/bg.png
  33. BIN
      src/assets/images/logo.png
  34. BIN
      src/assets/images/un-data.png
  35. BIN
      src/assets/images/un-scene.png
  36. 60 0
      src/components.d.ts
  37. 45 0
      src/components/data-list/index.vue
  38. 49 0
      src/components/loading/index.ts
  39. 85 0
      src/components/loading/index.vue
  40. 18 0
      src/components/loading/props.ts
  41. 51 0
      src/components/simples/index.vue
  42. 102 0
      src/components/upload/index.vue
  43. 19 0
      src/env/index.ts
  44. 29 0
      src/helper/index.ts
  45. 37 0
      src/helper/mount.ts
  46. 4 0
      src/hook/index.ts
  47. 22 0
      src/hook/useActive.ts
  48. 39 0
      src/hook/usePaging.ts
  49. 20 0
      src/hook/useProject.ts
  50. 27 0
      src/hook/useRealtime.ts
  51. 40 0
      src/hook/useVisible.ts
  52. 127 0
      src/layout/header.vue
  53. 49 0
      src/layout/main.vue
  54. 51 0
      src/layout/panl/index.ts
  55. 29 0
      src/layout/panl/style.module.scss
  56. 93 0
      src/layout/sider.vue
  57. 58 0
      src/layout/system.vue
  58. 12 0
      src/main.ts
  59. 56 0
      src/router/config.ts
  60. 65 0
      src/router/constant.ts
  61. 38 0
      src/router/index.ts
  62. 36 0
      src/shared/copy.ts
  63. 24 0
      src/shared/diff.ts
  64. 15 0
      src/shared/el.ts
  65. 70 0
      src/shared/index.ts
  66. 8 0
      src/shared/meta.ts
  67. 37 0
      src/shared/mount.ts
  68. 17 0
      src/shared/params.ts
  69. 103 0
      src/shared/test.ts
  70. 1 0
      src/store/constant.ts
  71. 9 0
      src/store/index.ts
  72. 155 0
      src/store/project.ts
  73. 21 0
      src/store/scene.ts
  74. 55 0
      src/store/user.ts
  75. 43 0
      src/style.scss
  76. 30 0
      src/views/material/columns.ts
  77. 93 0
      src/views/material/edit.vue
  78. 93 0
      src/views/material/list.vue
  79. 30 0
      src/views/member/columns.ts
  80. 93 0
      src/views/member/edit.vue
  81. 99 0
      src/views/member/list.vue
  82. 88 0
      src/views/personal/index.vue
  83. 89 0
      src/views/project/columns.ts
  84. 162 0
      src/views/project/detailed.vue
  85. 138 0
      src/views/project/edit.vue
  86. 147 0
      src/views/project/list.vue
  87. 35 0
      src/views/record/columns.ts
  88. 32 0
      src/views/record/list.vue
  89. 83 0
      src/views/scene/actions.vue
  90. 85 0
      src/views/scene/columns.ts
  91. 135 0
      src/views/scene/insert.vue
  92. 60 0
      src/views/scene/list.vue
  93. 62 0
      src/views/scene/select-scenes.vue
  94. 74 0
      src/views/scene/update.vue
  95. 35 0
      src/views/scene/upload-bim.vue
  96. 67 0
      src/views/system/login.vue
  97. 30 0
      src/views/system/shared/cache.ts
  98. 107 0
      src/views/system/shared/encode.ts
  99. 3 0
      src/views/system/shared/index.ts
  100. 0 0
      src/views/system/shared/rules.ts

+ 1 - 0
.env

@@ -0,0 +1 @@
+VITE_SERVER=https://test.4dkankan.com

+ 1 - 0
.env.pord

@@ -0,0 +1 @@
+VITE_SERVER=https://www.4dkankan.com

+ 61 - 0
.eslintrc.json

@@ -0,0 +1,61 @@
+{
+  "root": true,
+  "rules": {
+    //强制使用单引号
+    "quotes": ["error", "single"],
+    //强制不使用分号结尾
+    "semi": ["error", "never"],
+    // 允许使用{}
+    "@typescript-eslint/ban-types": [
+      "error",
+      {
+        "extendDefaults": true,
+        "types": {
+          "{}": false
+        }
+      }
+    ],
+    // 允许any
+    "@typescript-eslint/no-explicit-any": "off"
+  },
+  "parser": "@typescript-eslint/parser",
+  "parserOptions": {
+    "ecmaVersion": "latest",
+    "sourceType": "module"
+  },
+  "extends": [
+    "plugin:@typescript-eslint/eslint-recommended",
+    "plugin:@typescript-eslint/recommended",
+    "plugin:prettier/recommended"
+  ],
+  "overrides": [
+    {
+      "files": ["src/*.ts"],
+      "env": { "browser": true }
+    },
+    {
+      "files": ["./vite.config.ts"],
+      "env": { "node": true }
+    },
+    {
+      "files": ["scripts/*.js"],
+      "env": { "node": true },
+      "parser": "espree"
+    },
+    {
+      "files": ["src/**/*.vue"],
+      "parser": "vue-eslint-parser",
+      "globals": {
+        "defineOptions": "writable"
+      },
+      "extends": ["plugin:vue/vue3-recommended", "plugin:prettier/recommended"],
+      "env": { "browser": true },
+      "parserOptions": {
+        "parser": "@typescript-eslint/parser"
+      },
+      "rules": {
+        "vue/multi-word-component-names": 0
+      }
+    }
+  ]
+}

+ 24 - 0
.gitignore

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

+ 6 - 0
.husky/commit-msg

@@ -0,0 +1,6 @@
+#!/usr/bin/env sh
+. "$(dirname -- "$0")/_/husky.sh"
+
+commit_msg=`cat $1`
+
+node ./scripts/verifyCommit.js --msg "$commit_msg"

+ 4 - 0
.husky/pre-commit

@@ -0,0 +1,4 @@
+#!/usr/bin/env sh
+. "$(dirname -- "$0")/_/husky.sh"
+
+yarn lint-staged

+ 9 - 0
.prettierrc

@@ -0,0 +1,9 @@
+{
+  "singleQuote": true,
+  "semi": false,
+  "tabWidth": 2,
+  "printWidth": 80,
+  "trailingComma": "none",
+  "arrowParens": "avoid",
+  "eslintIntegration": true
+}

+ 3 - 0
.vscode/extensions.json

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

+ 13 - 0
index.html

@@ -0,0 +1,13 @@
+<!DOCTYPE html>
+<html lang="en">
+  <head>
+    <meta charset="UTF-8" />
+    <link rel="icon" type="image/svg+xml" href="//4dkk.4dage.com/FDKKIMG/icon/kankan_icon.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>

+ 54 - 0
package.json

@@ -0,0 +1,54 @@
+{
+  "name": "back",
+  "private": true,
+  "version": "0.0.0",
+  "type": "module",
+  "scripts": {
+    "dev": "vite",
+    "build-dev": "vue-tsc --noEmit && vite build",
+    "build": "vue-tsc --noEmit && vite build --mode pord",
+    "preview": "vite preview",
+    "format": "prettier --write ./**/*.{vue,ts,tsx,js,jsx,css,less,scss,json,md}",
+    "lint": "eslint src scripts/**/*.js vite.config.ts src/**/*.ts vite.config.ts src/**/*.vue"
+  },
+  "lint-staged": {
+    "*.(js,ts,vue)": [
+      "eslint",
+      "prettier --write"
+    ]
+  },
+  "dependencies": {
+    "@ant-design/icons-vue": "^6.1.0",
+    "@types/node": "^18.7.18",
+    "ant-design-vue": "3.3.0-beta.3",
+    "axios": "^0.27.2",
+    "dayjs": "^1.11.5",
+    "js-base64": "^3.7.2",
+    "less": "^4.1.3",
+    "pinia": "^2.0.22",
+    "sass": "^1.54.9",
+    "vue": "^3.2.37",
+    "vue-router": "4"
+  },
+  "devDependencies": {
+    "@typescript-eslint/eslint-plugin": "^5.36.2",
+    "@typescript-eslint/parser": "^5.36.2",
+    "@vitejs/plugin-vue": "^3.1.0",
+    "chalk": "^5.0.1",
+    "eslint": "^8.23.1",
+    "eslint-config-prettier": "^8.5.0",
+    "eslint-plugin-prettier": "^4.2.1",
+    "eslint-plugin-vue": "^9.5.1",
+    "husky": "^8.0.1",
+    "lint-staged": "^13.0.3",
+    "minimist": "^1.2.6",
+    "prettier": "^2.7.1",
+    "typescript": "^4.6.4",
+    "unplugin-auto-import": "^0.11.2",
+    "unplugin-vue-components": "^0.22.7",
+    "unplugin-vue-define-options": "^0.11.2",
+    "vite": "^3.1.0",
+    "vue-eslint-parser": "^9.1.0",
+    "vue-tsc": "^0.40.4"
+  }
+}

Diferenças do arquivo suprimidas por serem muito extensas
+ 1 - 0
public/vite.svg


+ 24 - 0
scripts/verifyCommit.js

@@ -0,0 +1,24 @@
+// Invoked on the commit-msg git hook by yorkie.
+import chalk from 'chalk'
+import minimist from 'minimist'
+
+const msg = minimist(process.argv.slice(2)).msg
+const commitRE =
+  /^(revert: )?(feat|fix|docs|dx|style|refactor|perf|test|workflow|build|ci|chore|types|wip|release): .{1,50}/
+
+if (!commitRE.test(msg)) {
+  console.error(
+    `  ${chalk.bgRed.white(' ERROR ')} ${chalk.red(
+      'invalid commit message format.'
+    )}\n\n` +
+      chalk.red(
+        '  Proper commit message format is required for automated changelog generation. Examples:\n\n'
+      ) +
+      `    ${chalk.green('feat(compiler): add "comments" option')}\n` +
+      `    ${chalk.green(
+        'fix(v-model): handle events on blur (close #28)'
+      )}\n\n` +
+      chalk.red('  See .github/commit-convention.md for more details.\n')
+  )
+  process.exit(1)
+}

+ 16 - 0
src/App.vue

@@ -0,0 +1,16 @@
+<template>
+  <a-config-provider :locale="zhCN">
+    <RouterView v-slot="{ Component }">
+      <KeepAlive>
+        <component :is="Component" />
+      </KeepAlive>
+    </RouterView>
+  </a-config-provider>
+</template>
+
+<script setup lang="ts">
+import zhCN from 'ant-design-vue/es/locale/zh_CN'
+import dayjs from 'dayjs'
+import 'dayjs/locale/zh-cn'
+dayjs.locale('zh-cn')
+</script>

+ 49 - 0
src/api/constant.ts

@@ -0,0 +1,49 @@
+export enum ResCode {
+  TOKEN_INVALID = 4008,
+  SUCCESS = 0
+}
+
+export const all = '__all'
+export type All = typeof all
+
+export const ResCodeDesc: { [key in ResCode]: string } = {
+  [ResCode.TOKEN_INVALID]: 'token已失效',
+  [ResCode.SUCCESS]: '请求成功'
+}
+
+// 上传文件
+export const UPLOAD_FILE = '/smart-site/upload/file'
+
+// 用户
+export const POST_LOGIN = '/smart-site/fdLogin'
+export const POST_LOGOUT = '/smart-site/fdLogout'
+export const GET_USER = '/smart-site/getUserInfo'
+export const GET_USER_META = '/smart-site/project/userData'
+
+// 项目
+export const GET_PROJECT_LIST = '/smart-site/project/list'
+export const GET_PROJECT = '/smart-site/project/info'
+export const ADD_PROJECT = '/smart-site/project/add'
+export const UPDATE_PROJECT = '/smart-site/project/updateName'
+export const FINE_PROJECT = '/smart-site/project/over'
+export const DEL_PROJECT = '/smart-site/project/del'
+
+// 操作记录
+export const GET_RECORD_LIST = '/smart-site/projectLog/list'
+
+// 场景
+export const GET_SCENE_LIST = '/smart-site/scene/list'
+export const REPLACE_SCENES = '/smart-site/project/addScene'
+export const DEL_SCENE = '/smart-site/project/delScene'
+
+// bim
+export const UPLOAD_BIM = '/smart-site/upload/bim'
+export const DEL_BIM = '/smart-site/project/deleteBim'
+export const UPDATE_BIM_NAME = '/smart-site/project/updateBimName'
+
+// 成员
+export const GET_MEMBER_LIST = '/smart-site/projectTeam/list'
+export const CHECK_MEMBER_PHONE = '/smart-site/projectTeam/checkUserName'
+export const ADD_MEMBER = '/smart-site/projectTeam/addUser'
+export const DEL_MEMBER = '/smart-site/projectTeam/deleteUser'
+export const UPDATE_MEBER = '/smart-site/projectTeam/updateRemark'

+ 21 - 0
src/api/index.ts

@@ -0,0 +1,21 @@
+export * from './user'
+export * from './instance'
+export * from './scene'
+export * from './record'
+export * from './project'
+export * from './member'
+
+export type PageResult<T = any> = {
+  pageNum: number
+  pageSize: number
+  total: number
+  list: T[]
+}
+
+export type PageRequest<T = {}> = {
+  pageNum: number
+  pageSize: number
+} & T
+
+export { all } from './constant'
+export type { All } from './constant'

+ 72 - 0
src/api/instance.ts

@@ -0,0 +1,72 @@
+import { axiosFactory } from './setup'
+import { message } from 'ant-design-vue'
+import { showLoading, hideLoading } from '@/components/loading'
+import { ResCode, ResCodeDesc, POST_LOGIN } from './constant'
+import { baseURL } from '@/env'
+import { router, RoutesName } from '@/router'
+
+const instance = axiosFactory()
+
+export const {
+  axios,
+  addUnsetTokenURLS,
+  delUnsetTokenURLS,
+  addReqErrorHandler,
+  addResErrorHandler,
+  delReqErrorHandler,
+  delResErrorHandler,
+  getToken,
+  setToken,
+  delToken,
+  setDefaultURI,
+  addHook,
+  delHook,
+  setHook
+} = instance
+
+export const gotoLogin = () => {
+  router.push({ name: RoutesName.login })
+}
+
+const tokenInvalid = () => {
+  message.error(ResCodeDesc[ResCode.TOKEN_INVALID])
+  gotoLogin()
+}
+
+addReqErrorHandler(err => {
+  showLoading()
+  tokenInvalid()
+})
+
+addResErrorHandler((response, data) => {
+  console.log(response)
+  if (!response || !response.data || response.status !== 200) {
+    message.error('服务错误,请稍后再试')
+  } else if (data) {
+    const msg =
+      data.code && ResCodeDesc[data.code]
+        ? ResCodeDesc[data.code]
+        : data?.message || data?.msg
+    if (data.code === ResCode.TOKEN_INVALID) {
+      tokenInvalid()
+    } else {
+      message.error(msg)
+    }
+  }
+})
+
+addHook({
+  before: () => showLoading(),
+  after: config => config && hideLoading()
+})
+
+setDefaultURI(baseURL)
+addUnsetTokenURLS(POST_LOGIN)
+
+// if (!token) {
+//   tokenInvalid()
+// } else {
+//   setToken(token)
+// }
+
+export default axios

+ 37 - 0
src/api/member.ts

@@ -0,0 +1,37 @@
+import axios from './instance'
+import {
+  GET_MEMBER_LIST,
+  ADD_MEMBER,
+  DEL_MEMBER,
+  UPDATE_MEBER,
+  CHECK_MEMBER_PHONE
+} from './constant'
+import type { PageResult, PageRequest, Project } from './'
+
+export interface Member {
+  teamId: number
+  userName: string
+  remark: string
+}
+
+export type Members = Member[]
+
+export const fetchMembers = (
+  params: PageRequest<
+    Pick<Member, 'userName'> & { projectId: Project['projectId'] }
+  >
+) => axios.post<PageResult<Member>>(GET_MEMBER_LIST, params)
+
+export const deleteMember = (teamId: Member['teamId']) =>
+  axios.post(DEL_MEMBER, { teamId })
+
+export const addMember = (
+  projectId: Project['projectId'],
+  member: Omit<Member, 'teamId'>
+) => axios.post(ADD_MEMBER, { projectId, ...member })
+
+export const updateMember = (member: PartialPart<Member, 'userName'>) =>
+  axios.post(UPDATE_MEBER, member)
+
+export const checkMemberUserName = (userName: Member['userName']) =>
+  axios.get(CHECK_MEMBER_PHONE, { params: { userName } })

+ 148 - 0
src/api/project.ts

@@ -0,0 +1,148 @@
+import axios from './instance'
+import { jsonToForm } from '@/shared'
+import { SceneType } from './scene'
+import {
+  GET_PROJECT_LIST,
+  GET_PROJECT,
+  UPLOAD_BIM,
+  ADD_PROJECT,
+  REPLACE_SCENES,
+  FINE_PROJECT,
+  DEL_PROJECT,
+  UPDATE_PROJECT,
+  DEL_BIM,
+  DEL_SCENE,
+  UPDATE_BIM_NAME
+} from './constant'
+
+import type { PageResult, PageRequest, Scenes, Scene } from './'
+
+export enum ProjectStatus {
+  undone,
+  done
+}
+
+export const ProjectStatusDesc = {
+  [ProjectStatus.done]: '完成',
+  [ProjectStatus.undone]: '未完成'
+}
+
+export interface SimpleProject {
+  projectId: number
+  projectName: string
+  projectMsg: string
+  projectImg: string
+  projectCreater: string
+  bimId: string
+  panos: string
+  createTime: string
+  updateTime: string
+  projectStatus: ProjectStatus
+}
+
+export enum BimStatus {
+  error = 'ERROR',
+  done = 'DONE',
+  upload = 'UPLOAD',
+  transfrom = 'TRANSLATE',
+  offline = 'OFFLINE'
+}
+
+export const BimStatusDesc = {
+  [BimStatus.error]: '错误',
+  [BimStatus.done]: '完成',
+  [BimStatus.upload]: '上传',
+  [BimStatus.transfrom]: '转换',
+  [BimStatus.offline]: '离线包生成'
+}
+
+export type Bim = {
+  bimId: number
+  bimLocalFilePath: string
+  bimName: string
+  bimOssFilePath: string
+  bimServiceId: null
+  bimStatus: BimStatus
+  createTime: string
+}
+
+export type Project = SimpleProject & {
+  sceneList: Scenes
+  bimData?: Bim
+}
+
+export type SimpleProjects = SimpleProject[]
+
+export const fetchProjects = async (filter: PageRequest) => {
+  return axios.post<PageResult<SimpleProject>>(GET_PROJECT_LIST, filter)
+}
+
+export const fetchProject = async (id: Project['projectId']) => {
+  return axios.get<Project>(GET_PROJECT, {
+    params: { projectId: id }
+  })
+}
+
+export type InsertProjectData = PartialPart<
+  Pick<Project, 'projectImg' | 'projectName' | 'projectMsg'>,
+  'projectImg'
+>
+export const insertProject = (data: InsertProjectData) =>
+  axios.post<Project>(ADD_PROJECT, data)
+
+export type UpdateProjectData = InsertProjectData & Pick<Project, 'projectId'>
+
+export const updateProject = (data: UpdateProjectData) =>
+  axios.post<Project>(UPDATE_PROJECT, data)
+
+export const finishProject = (id: Project['projectId']) =>
+  axios.post<Project>(FINE_PROJECT, { projectId: id })
+
+export const deleteProject = (id: Project['projectId']) =>
+  axios.post<Project>(DEL_PROJECT, { projectId: id })
+
+export type BimUploadData = {
+  name: string
+  file: File
+}
+export const uploadProjectBimScene = async (
+  id: Project['projectId'],
+  data: BimUploadData
+) => {
+  await axios({
+    url: UPLOAD_BIM,
+    method: 'POST',
+    data: jsonToForm({
+      projectId: id,
+      file: data.file,
+      projectName: data.name
+    }),
+    headers: {
+      'Content-Type': 'multipart/form-data'
+    }
+  })
+}
+
+export const deleteProjectBimScene = async (bimId: Bim['bimId']) =>
+  axios.post(DEL_BIM, { bimId })
+
+export const updateProjectBimSceneName = async (
+  data: Pick<Bim, 'bimId' | 'bimName'>
+) => axios.post(UPDATE_BIM_NAME, data)
+
+export const deleteProjectScene = async (data: {
+  projectId: Project['projectId']
+  num: Scene['num']
+}) => axios.post(DEL_SCENE, data)
+
+export const relaceProjectScenes = async (data: {
+  id: Project['projectId']
+  sceneNumParam: {
+    type: SceneType
+    numList: Scene['num'][]
+  }[]
+}) =>
+  axios.post(REPLACE_SCENES, {
+    projectId: data.id,
+    sceneNumParam: data.sceneNumParam
+  })

+ 17 - 0
src/api/record.ts

@@ -0,0 +1,17 @@
+import { GET_RECORD_LIST } from './constant'
+import axios from './instance'
+
+import type { PageResult, PageRequest } from './'
+
+export interface Record {
+  createTime: string
+  logId: number
+  logMsg: string
+  projectName: string
+  projectId: number
+  updateTime: string
+  userName: string
+}
+
+export const fetchRecords = (paging: PageRequest) =>
+  axios.post<PageResult>(GET_RECORD_LIST, paging)

+ 49 - 0
src/api/scene.ts

@@ -0,0 +1,49 @@
+import axios from './instance'
+import { GET_SCENE_LIST } from './constant'
+import type { PageResult, PageRequest, SimpleProject } from './'
+
+export enum SceneStatus {
+  DEL = -1,
+  RUN = 0,
+  ERR = 1,
+  SUCCESS = 2,
+  ARCHIVE = 3,
+  RERUN = 4
+}
+export const sceneStatusDesc = {
+  [SceneStatus.DEL]: '场景被删',
+  [SceneStatus.RUN]: '计算中',
+  [SceneStatus.ERR]: '计算失败',
+  [SceneStatus.SUCCESS]: '计算成功',
+  [SceneStatus.ARCHIVE]: '封存',
+  [SceneStatus.RERUN]: '重新计算中'
+}
+
+export enum SceneType {
+  SWKK,
+  SWKJ,
+  SWSS
+}
+export const SceneTypeDesc = {
+  [SceneType.SWKK]: '四维看看',
+  [SceneType.SWKJ]: '四维看见',
+  [SceneType.SWSS]: '四维深时'
+}
+
+export interface Scene {
+  id: number
+  name: string
+  num: string
+  title: string
+  sceneName: string
+  thumb: string
+  createTime: string
+  type: SceneType
+  status: SceneStatus
+  phone: string
+}
+
+export type Scenes = Scene[]
+
+export const fetchScenes = async (paging: PageRequest<{ type: SceneType }>) =>
+  axios.post<PageResult<Scene>>(GET_SCENE_LIST, paging)

+ 274 - 0
src/api/setup.ts

@@ -0,0 +1,274 @@
+import Axios from 'axios'
+import { ResCode } from './constant'
+
+import type { AxiosResponse, AxiosRequestConfig } from 'axios'
+
+export type ResErrorHandler = <D, T extends ResData<D>>(
+  response: AxiosResponse<T>,
+  data?: T
+) => void
+export type ReqErrorHandler = <T>(
+  err: Error,
+  response: AxiosRequestConfig<T>
+) => void
+export type ResData<T> = {
+  code: ResCode
+  message: string
+  data: T
+  msg: string
+}
+export type Hook = {
+  before?: (config: AxiosRequestConfig) => void
+  after?: (config: AxiosRequestConfig) => void
+}
+
+export const axiosFactory = () => {
+  const axiosRaw = Axios.create()
+  const axiosConfig = {
+    token: localStorage.getItem('token'),
+    unTokenSet: [] as string[],
+    unReqErrorSet: [] as string[],
+    unResErrorSet: [] as string[],
+    resErrorHandler: [] as ResErrorHandler[],
+    reqErrorHandler: [] as ReqErrorHandler[],
+    unLoadingSet: [] as string[],
+    hook: [] as Hook[]
+  }
+
+  type AxiosConfig = typeof axiosConfig
+  type ExponseApi<K extends keyof AxiosConfig> = {
+    set: (val: AxiosConfig[K]) => void
+  } & (AxiosConfig[K] extends Array<any>
+    ? {
+        add: (...val: AxiosConfig[K]) => void
+        del: (...val: AxiosConfig[K]) => void
+      }
+    : { del: () => void })
+
+  const getExponseApi = <K extends keyof AxiosConfig>(
+    key: K
+  ): ExponseApi<K> => {
+    let axiosObj = axiosConfig[key] as any[]
+    const apis: any = {
+      set(val: AxiosConfig[K]) {
+        axiosObj = axiosConfig[key] = val as any
+      }
+    }
+
+    if (Array.isArray(axiosObj)) {
+      apis.add = (...val: any[]) => {
+        axiosObj.push(...val)
+      }
+      apis.del = (...val: any[]) => {
+        if (val) {
+          apis.set(axiosObj.filter((item: any) => !val?.includes(item)) as any)
+        } else {
+          axiosObj.length = 0
+        }
+      }
+    } else {
+      apis.del = () => {
+        axiosConfig[key] = undefined as any
+      }
+    }
+    return apis
+  }
+
+  const getToken = () => axiosConfig.token
+  const setToken = (token: string) => {
+    localStorage.setItem('token', token)
+    axiosConfig.token = token
+  }
+  const delToken = () => {
+    localStorage.removeItem('token')
+    axiosConfig.token = null
+  }
+
+  const {
+    set: setUnsetTokenURLS,
+    add: addUnsetTokenURLS,
+    del: delUnsetTokenURLS
+  } = getExponseApi('unTokenSet')
+
+  const {
+    set: setResErrorHandler,
+    add: addResErrorHandler,
+    del: delResErrorHandler
+  } = getExponseApi('resErrorHandler')
+
+  const {
+    set: setUnsetReqErrorURLS,
+    add: addUnsetReqErrorURLS,
+    del: delUnsetReqErrorURLS
+  } = getExponseApi('unReqErrorSet')
+
+  const {
+    set: setReqErrorHandler,
+    add: addReqErrorHandler,
+    del: delReqErrorHandler
+  } = getExponseApi('reqErrorHandler')
+
+  const {
+    set: setUnsetResErrorURLS,
+    add: addUnsetResErrorURLS,
+    del: delUnsetResErrorURLS
+  } = getExponseApi('unResErrorSet')
+
+  const { set: setHook, add: addHook, del: delHook } = getExponseApi('hook')
+
+  const setDefaultURI = (url: string) => {
+    axiosRaw.defaults.baseURL = url
+  }
+
+  const matchURL = (urls: string[], config: AxiosRequestConfig<any>) =>
+    config && config.url && urls.includes(config.url)
+
+  const callErrorHandler = (key: 'req' | 'res', ...args: any[]) => {
+    Promise.resolve().then(() => {
+      const api = `${key}ErrorHandler`
+      ;(axiosConfig as any)[api].forEach((handler: any) => handler(...args))
+    })
+  }
+
+  axiosRaw.interceptors.request.use(config => {
+    for (const hook of axiosConfig.hook) {
+      hook.before && hook.before(config)
+    }
+
+    if (!matchURL(axiosConfig.unTokenSet, config)) {
+      if (!axiosConfig.token) {
+        if (!matchURL(axiosConfig.unReqErrorSet, config)) {
+          const error = new Error('缺少token')
+          callErrorHandler('req', error, config)
+          throw error
+        }
+      } else {
+        config.headers = {
+          ...config.headers,
+          token: axiosConfig.token
+        }
+      }
+    }
+    return config
+  })
+
+  axiosRaw.interceptors.response.use(
+    (response: AxiosResponse<ResData<any>>) => {
+      for (const hook of axiosConfig.hook) {
+        hook.after && hook.after(response.config)
+      }
+
+      if (matchURL(axiosConfig.unResErrorSet, response.config)) {
+        return response
+      }
+
+      if (response.status !== 200) {
+        callErrorHandler('res', response)
+        throw new Error(response.statusText)
+      } else if (response.data.code !== ResCode.SUCCESS) {
+        callErrorHandler('res', response, response.data)
+        if (response.data.code === ResCode.TOKEN_INVALID) {
+          delToken()
+        }
+        throw new Error(response?.data?.message)
+      } else {
+        return response.data.data
+      }
+    },
+    err => {
+      for (const hook of axiosConfig.hook) {
+        hook.after && hook.after(err.config)
+      }
+      if (!matchURL(axiosConfig.unResErrorSet, err.config)) {
+        callErrorHandler('res', err.response)
+      }
+      throw new Error(err.response ? err.response.statusText : err)
+    }
+  )
+
+  type AxiosProcess = {
+    getUri(config?: AxiosRequestConfig): string
+    request<R = any, D = any>(config: AxiosRequestConfig<D>): Promise<R>
+    get<R = any, D = any>(
+      url: string,
+      config?: AxiosRequestConfig<D>
+    ): Promise<R>
+    delete<R = any, D = any>(
+      url: string,
+      config?: AxiosRequestConfig<D>
+    ): Promise<R>
+    head<R = any, D = any>(
+      url: string,
+      config?: AxiosRequestConfig<D>
+    ): Promise<R>
+    options<R = any, D = any>(
+      url: string,
+      config?: AxiosRequestConfig<D>
+    ): Promise<R>
+    post<R = any, D = any>(
+      url: string,
+      data?: D,
+      config?: AxiosRequestConfig<D>
+    ): Promise<R>
+    put<R = any, D = any>(
+      url: string,
+      data?: D,
+      config?: AxiosRequestConfig<D>
+    ): Promise<R>
+    patch<R = any, D = any>(
+      url: string,
+      data?: D,
+      config?: AxiosRequestConfig<D>
+    ): Promise<R>
+    postForm<R = any, D = any>(
+      url: string,
+      data?: D,
+      config?: AxiosRequestConfig<D>
+    ): Promise<R>
+    putForm<R = any, D = unknown>(
+      url: string,
+      data?: D,
+      config?: AxiosRequestConfig<D>
+    ): Promise<R>
+    patchForm<R = any, D = any>(
+      url: string,
+      data?: D,
+      config?: AxiosRequestConfig<D>
+    ): Promise<R>
+  }
+
+  interface AxiosInstanceProcess extends AxiosProcess {
+    <R = any>(config: AxiosRequestConfig): Promise<R>
+    <R = any>(url: string, config?: AxiosRequestConfig): Promise<R>
+  }
+
+  const axios: AxiosInstanceProcess = axiosRaw as any
+
+  return {
+    axios,
+    getToken,
+    setToken,
+    delToken,
+    setUnsetTokenURLS,
+    addUnsetTokenURLS,
+    delUnsetTokenURLS,
+    setResErrorHandler,
+    addResErrorHandler,
+    delResErrorHandler,
+    setUnsetReqErrorURLS,
+    addUnsetReqErrorURLS,
+    delUnsetReqErrorURLS,
+    setReqErrorHandler,
+    addReqErrorHandler,
+    delReqErrorHandler,
+    setUnsetResErrorURLS,
+    addUnsetResErrorURLS,
+    delUnsetResErrorURLS,
+    setDefaultURI,
+    setHook,
+    addHook,
+    delHook
+  }
+}
+
+export default axiosFactory

+ 70 - 0
src/api/user.ts

@@ -0,0 +1,70 @@
+import axios from './instance'
+import {
+  GET_USER,
+  POST_LOGIN,
+  GET_USER_META,
+  POST_LOGOUT,
+  UPLOAD_FILE
+} from './constant'
+import { jsonToForm } from '@/shared'
+
+export interface User {
+  nickname: string
+  phone: string
+  avatar: string
+  email: string
+}
+
+type SUser = {
+  head: string
+  nickName: string
+  userName: string
+  email: string
+}
+
+const toLocal = (data: SUser): User => ({
+  nickname: data.nickName,
+  avatar: data.head,
+  phone: data.userName,
+  email: data.email
+})
+
+export const fetchUser = async (): Promise<User> => {
+  const data = await axios.post<SUser>(GET_USER)
+  return toLocal(data)
+}
+
+export type LoginState = {
+  phone: string
+  password: string
+}
+export const postLogin = async (state: LoginState) => {
+  const data = await axios.post<{ token: string; user: SUser }>(POST_LOGIN, {
+    phoneNum: state.phone,
+    password: state.password
+  })
+  return {
+    token: data.token,
+    user: toLocal(data.user)
+  }
+}
+
+export const postLogout = async () => axios.post(POST_LOGOUT)
+
+export type UserMeta = {
+  projectCount: number
+  projectFileCount: number
+  projectSceneCount: number
+  projectOverCount: number
+}
+export const fetchUserMeta = () => axios.get<UserMeta>(GET_USER_META)
+
+export const uploadFile = async (file: File) =>
+  await axios<string>({
+    url: UPLOAD_FILE,
+    method: 'POST',
+    data: jsonToForm({ file }),
+    headers: {
+      'Content-Type': 'multipart/form-data'
+    }
+  })

+ 539 - 0
src/assets/iconfont/demo.css

@@ -0,0 +1,539 @@
+/* Logo 字体 */
+@font-face {
+  font-family: "iconfont logo";
+  src: url('https://at.alicdn.com/t/font_985780_km7mi63cihi.eot?t=1545807318834');
+  src: url('https://at.alicdn.com/t/font_985780_km7mi63cihi.eot?t=1545807318834#iefix') format('embedded-opentype'),
+    url('https://at.alicdn.com/t/font_985780_km7mi63cihi.woff?t=1545807318834') format('woff'),
+    url('https://at.alicdn.com/t/font_985780_km7mi63cihi.ttf?t=1545807318834') format('truetype'),
+    url('https://at.alicdn.com/t/font_985780_km7mi63cihi.svg?t=1545807318834#iconfont') format('svg');
+}
+
+.logo {
+  font-family: "iconfont logo";
+  font-size: 160px;
+  font-style: normal;
+  -webkit-font-smoothing: antialiased;
+  -moz-osx-font-smoothing: grayscale;
+}
+
+/* tabs */
+.nav-tabs {
+  position: relative;
+}
+
+.nav-tabs .nav-more {
+  position: absolute;
+  right: 0;
+  bottom: 0;
+  height: 42px;
+  line-height: 42px;
+  color: #666;
+}
+
+#tabs {
+  border-bottom: 1px solid #eee;
+}
+
+#tabs li {
+  cursor: pointer;
+  width: 100px;
+  height: 40px;
+  line-height: 40px;
+  text-align: center;
+  font-size: 16px;
+  border-bottom: 2px solid transparent;
+  position: relative;
+  z-index: 1;
+  margin-bottom: -1px;
+  color: #666;
+}
+
+
+#tabs .active {
+  border-bottom-color: #f00;
+  color: #222;
+}
+
+.tab-container .content {
+  display: none;
+}
+
+/* 页面布局 */
+.main {
+  padding: 30px 100px;
+  width: 960px;
+  margin: 0 auto;
+}
+
+.main .logo {
+  color: #333;
+  text-align: left;
+  margin-bottom: 30px;
+  line-height: 1;
+  height: 110px;
+  margin-top: -50px;
+  overflow: hidden;
+  *zoom: 1;
+}
+
+.main .logo a {
+  font-size: 160px;
+  color: #333;
+}
+
+.helps {
+  margin-top: 40px;
+}
+
+.helps pre {
+  padding: 20px;
+  margin: 10px 0;
+  border: solid 1px #e7e1cd;
+  background-color: #fffdef;
+  overflow: auto;
+}
+
+.icon_lists {
+  width: 100% !important;
+  overflow: hidden;
+  *zoom: 1;
+}
+
+.icon_lists li {
+  width: 100px;
+  margin-bottom: 10px;
+  margin-right: 20px;
+  text-align: center;
+  list-style: none !important;
+  cursor: default;
+}
+
+.icon_lists li .code-name {
+  line-height: 1.2;
+}
+
+.icon_lists .icon {
+  display: block;
+  height: 100px;
+  line-height: 100px;
+  font-size: 42px;
+  margin: 10px auto;
+  color: #333;
+  -webkit-transition: font-size 0.25s linear, width 0.25s linear;
+  -moz-transition: font-size 0.25s linear, width 0.25s linear;
+  transition: font-size 0.25s linear, width 0.25s linear;
+}
+
+.icon_lists .icon:hover {
+  font-size: 100px;
+}
+
+.icon_lists .svg-icon {
+  /* 通过设置 font-size 来改变图标大小 */
+  width: 1em;
+  /* 图标和文字相邻时,垂直对齐 */
+  vertical-align: -0.15em;
+  /* 通过设置 color 来改变 SVG 的颜色/fill */
+  fill: currentColor;
+  /* path 和 stroke 溢出 viewBox 部分在 IE 下会显示
+      normalize.css 中也包含这行 */
+  overflow: hidden;
+}
+
+.icon_lists li .name,
+.icon_lists li .code-name {
+  color: #666;
+}
+
+/* markdown 样式 */
+.markdown {
+  color: #666;
+  font-size: 14px;
+  line-height: 1.8;
+}
+
+.highlight {
+  line-height: 1.5;
+}
+
+.markdown img {
+  vertical-align: middle;
+  max-width: 100%;
+}
+
+.markdown h1 {
+  color: #404040;
+  font-weight: 500;
+  line-height: 40px;
+  margin-bottom: 24px;
+}
+
+.markdown h2,
+.markdown h3,
+.markdown h4,
+.markdown h5,
+.markdown h6 {
+  color: #404040;
+  margin: 1.6em 0 0.6em 0;
+  font-weight: 500;
+  clear: both;
+}
+
+.markdown h1 {
+  font-size: 28px;
+}
+
+.markdown h2 {
+  font-size: 22px;
+}
+
+.markdown h3 {
+  font-size: 16px;
+}
+
+.markdown h4 {
+  font-size: 14px;
+}
+
+.markdown h5 {
+  font-size: 12px;
+}
+
+.markdown h6 {
+  font-size: 12px;
+}
+
+.markdown hr {
+  height: 1px;
+  border: 0;
+  background: #e9e9e9;
+  margin: 16px 0;
+  clear: both;
+}
+
+.markdown p {
+  margin: 1em 0;
+}
+
+.markdown>p,
+.markdown>blockquote,
+.markdown>.highlight,
+.markdown>ol,
+.markdown>ul {
+  width: 80%;
+}
+
+.markdown ul>li {
+  list-style: circle;
+}
+
+.markdown>ul li,
+.markdown blockquote ul>li {
+  margin-left: 20px;
+  padding-left: 4px;
+}
+
+.markdown>ul li p,
+.markdown>ol li p {
+  margin: 0.6em 0;
+}
+
+.markdown ol>li {
+  list-style: decimal;
+}
+
+.markdown>ol li,
+.markdown blockquote ol>li {
+  margin-left: 20px;
+  padding-left: 4px;
+}
+
+.markdown code {
+  margin: 0 3px;
+  padding: 0 5px;
+  background: #eee;
+  border-radius: 3px;
+}
+
+.markdown strong,
+.markdown b {
+  font-weight: 600;
+}
+
+.markdown>table {
+  border-collapse: collapse;
+  border-spacing: 0px;
+  empty-cells: show;
+  border: 1px solid #e9e9e9;
+  width: 95%;
+  margin-bottom: 24px;
+}
+
+.markdown>table th {
+  white-space: nowrap;
+  color: #333;
+  font-weight: 600;
+}
+
+.markdown>table th,
+.markdown>table td {
+  border: 1px solid #e9e9e9;
+  padding: 8px 16px;
+  text-align: left;
+}
+
+.markdown>table th {
+  background: #F7F7F7;
+}
+
+.markdown blockquote {
+  font-size: 90%;
+  color: #999;
+  border-left: 4px solid #e9e9e9;
+  padding-left: 0.8em;
+  margin: 1em 0;
+}
+
+.markdown blockquote p {
+  margin: 0;
+}
+
+.markdown .anchor {
+  opacity: 0;
+  transition: opacity 0.3s ease;
+  margin-left: 8px;
+}
+
+.markdown .waiting {
+  color: #ccc;
+}
+
+.markdown h1:hover .anchor,
+.markdown h2:hover .anchor,
+.markdown h3:hover .anchor,
+.markdown h4:hover .anchor,
+.markdown h5:hover .anchor,
+.markdown h6:hover .anchor {
+  opacity: 1;
+  display: inline-block;
+}
+
+.markdown>br,
+.markdown>p>br {
+  clear: both;
+}
+
+
+.hljs {
+  display: block;
+  background: white;
+  padding: 0.5em;
+  color: #333333;
+  overflow-x: auto;
+}
+
+.hljs-comment,
+.hljs-meta {
+  color: #969896;
+}
+
+.hljs-string,
+.hljs-variable,
+.hljs-template-variable,
+.hljs-strong,
+.hljs-emphasis,
+.hljs-quote {
+  color: #df5000;
+}
+
+.hljs-keyword,
+.hljs-selector-tag,
+.hljs-type {
+  color: #a71d5d;
+}
+
+.hljs-literal,
+.hljs-symbol,
+.hljs-bullet,
+.hljs-attribute {
+  color: #0086b3;
+}
+
+.hljs-section,
+.hljs-name {
+  color: #63a35c;
+}
+
+.hljs-tag {
+  color: #333333;
+}
+
+.hljs-title,
+.hljs-attr,
+.hljs-selector-id,
+.hljs-selector-class,
+.hljs-selector-attr,
+.hljs-selector-pseudo {
+  color: #795da3;
+}
+
+.hljs-addition {
+  color: #55a532;
+  background-color: #eaffea;
+}
+
+.hljs-deletion {
+  color: #bd2c00;
+  background-color: #ffecec;
+}
+
+.hljs-link {
+  text-decoration: underline;
+}
+
+/* 代码高亮 */
+/* PrismJS 1.15.0
+https://prismjs.com/download.html#themes=prism&languages=markup+css+clike+javascript */
+/**
+ * prism.js default theme for JavaScript, CSS and HTML
+ * Based on dabblet (http://dabblet.com)
+ * @author Lea Verou
+ */
+code[class*="language-"],
+pre[class*="language-"] {
+  color: black;
+  background: none;
+  text-shadow: 0 1px white;
+  font-family: Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono', monospace;
+  text-align: left;
+  white-space: pre;
+  word-spacing: normal;
+  word-break: normal;
+  word-wrap: normal;
+  line-height: 1.5;
+
+  -moz-tab-size: 4;
+  -o-tab-size: 4;
+  tab-size: 4;
+
+  -webkit-hyphens: none;
+  -moz-hyphens: none;
+  -ms-hyphens: none;
+  hyphens: none;
+}
+
+pre[class*="language-"]::-moz-selection,
+pre[class*="language-"] ::-moz-selection,
+code[class*="language-"]::-moz-selection,
+code[class*="language-"] ::-moz-selection {
+  text-shadow: none;
+  background: #b3d4fc;
+}
+
+pre[class*="language-"]::selection,
+pre[class*="language-"] ::selection,
+code[class*="language-"]::selection,
+code[class*="language-"] ::selection {
+  text-shadow: none;
+  background: #b3d4fc;
+}
+
+@media print {
+
+  code[class*="language-"],
+  pre[class*="language-"] {
+    text-shadow: none;
+  }
+}
+
+/* Code blocks */
+pre[class*="language-"] {
+  padding: 1em;
+  margin: .5em 0;
+  overflow: auto;
+}
+
+:not(pre)>code[class*="language-"],
+pre[class*="language-"] {
+  background: #f5f2f0;
+}
+
+/* Inline code */
+:not(pre)>code[class*="language-"] {
+  padding: .1em;
+  border-radius: .3em;
+  white-space: normal;
+}
+
+.token.comment,
+.token.prolog,
+.token.doctype,
+.token.cdata {
+  color: slategray;
+}
+
+.token.punctuation {
+  color: #999;
+}
+
+.namespace {
+  opacity: .7;
+}
+
+.token.property,
+.token.tag,
+.token.boolean,
+.token.number,
+.token.constant,
+.token.symbol,
+.token.deleted {
+  color: #905;
+}
+
+.token.selector,
+.token.attr-name,
+.token.string,
+.token.char,
+.token.builtin,
+.token.inserted {
+  color: #690;
+}
+
+.token.operator,
+.token.entity,
+.token.url,
+.language-css .token.string,
+.style .token.string {
+  color: #9a6e3a;
+  background: hsla(0, 0%, 100%, .5);
+}
+
+.token.atrule,
+.token.attr-value,
+.token.keyword {
+  color: #07a;
+}
+
+.token.function,
+.token.class-name {
+  color: #DD4A68;
+}
+
+.token.regex,
+.token.important,
+.token.variable {
+  color: #e90;
+}
+
+.token.important,
+.token.bold {
+  font-weight: bold;
+}
+
+.token.italic {
+  font-style: italic;
+}
+
+.token.entity {
+  cursor: help;
+}

+ 925 - 0
src/assets/iconfont/demo_index.html

@@ -0,0 +1,925 @@
+<!DOCTYPE html>
+<html>
+<head>
+  <meta charset="utf-8"/>
+  <title>iconfont Demo</title>
+  <link rel="shortcut icon" href="//img.alicdn.com/imgextra/i4/O1CN01Z5paLz1O0zuCC7osS_!!6000000001644-55-tps-83-82.svg" type="image/x-icon"/>
+  <link rel="icon" type="image/svg+xml" href="//img.alicdn.com/imgextra/i4/O1CN01Z5paLz1O0zuCC7osS_!!6000000001644-55-tps-83-82.svg"/>
+  <link rel="stylesheet" href="https://g.alicdn.com/thx/cube/1.3.2/cube.min.css">
+  <link rel="stylesheet" href="demo.css">
+  <link rel="stylesheet" href="iconfont.css">
+  <script src="iconfont.js"></script>
+  <!-- jQuery -->
+  <script src="https://a1.alicdn.com/oss/uploads/2018/12/26/7bfddb60-08e8-11e9-9b04-53e73bb6408b.js"></script>
+  <!-- 代码高亮 -->
+  <script src="https://a1.alicdn.com/oss/uploads/2018/12/26/a3f714d0-08e6-11e9-8a15-ebf944d7534c.js"></script>
+  <style>
+    .main .logo {
+      margin-top: 0;
+      height: auto;
+    }
+
+    .main .logo a {
+      display: flex;
+      align-items: center;
+    }
+
+    .main .logo .sub-title {
+      margin-left: 0.5em;
+      font-size: 22px;
+      color: #fff;
+      background: linear-gradient(-45deg, #3967FF, #B500FE);
+      -webkit-background-clip: text;
+      -webkit-text-fill-color: transparent;
+    }
+  </style>
+</head>
+<body>
+  <div class="main">
+    <h1 class="logo"><a href="https://www.iconfont.cn/" title="iconfont 首页" target="_blank">
+      <img width="200" src="https://img.alicdn.com/imgextra/i3/O1CN01Mn65HV1FfSEzR6DKv_!!6000000000514-55-tps-228-59.svg">
+      
+    </a></h1>
+    <div class="nav-tabs">
+      <ul id="tabs" class="dib-box">
+        <li class="dib active"><span>Unicode</span></li>
+        <li class="dib"><span>Font class</span></li>
+        <li class="dib"><span>Symbol</span></li>
+      </ul>
+      
+      <a href="https://www.iconfont.cn/manage/index?manage_type=myprojects&projectId=3676870" target="_blank" class="nav-more">查看项目</a>
+      
+    </div>
+    <div class="tab-container">
+      <div class="content unicode" style="display: block;">
+          <ul class="icon_lists dib-box">
+          
+            <li class="dib">
+              <span class="icon iconfont">&#xe704;</span>
+                <div class="name">brushes</div>
+                <div class="code-name">&amp;#xe704;</div>
+              </li>
+          
+            <li class="dib">
+              <span class="icon iconfont">&#xe710;</span>
+                <div class="name">pop-up_screen_off</div>
+                <div class="code-name">&amp;#xe710;</div>
+              </li>
+          
+            <li class="dib">
+              <span class="icon iconfont">&#xe712;</span>
+                <div class="name">pop-up_screen_on</div>
+                <div class="code-name">&amp;#xe712;</div>
+              </li>
+          
+            <li class="dib">
+              <span class="icon iconfont">&#xe703;</span>
+                <div class="name">arrow</div>
+                <div class="code-name">&amp;#xe703;</div>
+              </li>
+          
+            <li class="dib">
+              <span class="icon iconfont">&#xe705;</span>
+                <div class="name">arrows</div>
+                <div class="code-name">&amp;#xe705;</div>
+              </li>
+          
+            <li class="dib">
+              <span class="icon iconfont">&#xe706;</span>
+                <div class="name">chat_on</div>
+                <div class="code-name">&amp;#xe706;</div>
+              </li>
+          
+            <li class="dib">
+              <span class="icon iconfont">&#xe707;</span>
+                <div class="name">exit</div>
+                <div class="code-name">&amp;#xe707;</div>
+              </li>
+          
+            <li class="dib">
+              <span class="icon iconfont">&#xe708;</span>
+                <div class="name">cross</div>
+                <div class="code-name">&amp;#xe708;</div>
+              </li>
+          
+            <li class="dib">
+              <span class="icon iconfont">&#xe709;</span>
+                <div class="name">chat_off</div>
+                <div class="code-name">&amp;#xe709;</div>
+              </li>
+          
+            <li class="dib">
+              <span class="icon iconfont">&#xe70a;</span>
+                <div class="name">guided</div>
+                <div class="code-name">&amp;#xe70a;</div>
+              </li>
+          
+            <li class="dib">
+              <span class="icon iconfont">&#xe70b;</span>
+                <div class="name">members</div>
+                <div class="code-name">&amp;#xe70b;</div>
+              </li>
+          
+            <li class="dib">
+              <span class="icon iconfont">&#xe70c;</span>
+                <div class="name">list</div>
+                <div class="code-name">&amp;#xe70c;</div>
+              </li>
+          
+            <li class="dib">
+              <span class="icon iconfont">&#xe70d;</span>
+                <div class="name">mic_off</div>
+                <div class="code-name">&amp;#xe70d;</div>
+              </li>
+          
+            <li class="dib">
+              <span class="icon iconfont">&#xe70e;</span>
+                <div class="name">mic_on</div>
+                <div class="code-name">&amp;#xe70e;</div>
+              </li>
+          
+            <li class="dib">
+              <span class="icon iconfont">&#xe70f;</span>
+                <div class="name">invitation</div>
+                <div class="code-name">&amp;#xe70f;</div>
+              </li>
+          
+            <li class="dib">
+              <span class="icon iconfont">&#xe711;</span>
+                <div class="name">revocation</div>
+                <div class="code-name">&amp;#xe711;</div>
+              </li>
+          
+            <li class="dib">
+              <span class="icon iconfont">&#xe713;</span>
+                <div class="name">show</div>
+                <div class="code-name">&amp;#xe713;</div>
+              </li>
+          
+            <li class="dib">
+              <span class="icon iconfont">&#xe702;</span>
+                <div class="name">Checkbox-off</div>
+                <div class="code-name">&amp;#xe702;</div>
+              </li>
+          
+            <li class="dib">
+              <span class="icon iconfont">&#xe6fd;</span>
+                <div class="name">works_delete</div>
+                <div class="code-name">&amp;#xe6fd;</div>
+              </li>
+          
+            <li class="dib">
+              <span class="icon iconfont">&#xe6fc;</span>
+                <div class="name">Checkbox-on</div>
+                <div class="code-name">&amp;#xe6fc;</div>
+              </li>
+          
+            <li class="dib">
+              <span class="icon iconfont">&#xe6fe;</span>
+                <div class="name">works_cancel</div>
+                <div class="code-name">&amp;#xe6fe;</div>
+              </li>
+          
+            <li class="dib">
+              <span class="icon iconfont">&#xe6ff;</span>
+                <div class="name">toast_green</div>
+                <div class="code-name">&amp;#xe6ff;</div>
+              </li>
+          
+            <li class="dib">
+              <span class="icon iconfont">&#xe700;</span>
+                <div class="name">toast_red</div>
+                <div class="code-name">&amp;#xe700;</div>
+              </li>
+          
+            <li class="dib">
+              <span class="icon iconfont">&#xe701;</span>
+                <div class="name">toast_yellow</div>
+                <div class="code-name">&amp;#xe701;</div>
+              </li>
+          
+            <li class="dib">
+              <span class="icon iconfont">&#xe6f4;</span>
+                <div class="name">material_preview_upload_collect</div>
+                <div class="code-name">&amp;#xe6f4;</div>
+              </li>
+          
+            <li class="dib">
+              <span class="icon iconfont">&#xe6f5;</span>
+                <div class="name">pop-ups_shut-down</div>
+                <div class="code-name">&amp;#xe6f5;</div>
+              </li>
+          
+            <li class="dib">
+              <span class="icon iconfont">&#xe6f6;</span>
+                <div class="name">works_add</div>
+                <div class="code-name">&amp;#xe6f6;</div>
+              </li>
+          
+            <li class="dib">
+              <span class="icon iconfont">&#xe6f7;</span>
+                <div class="name">works_search</div>
+                <div class="code-name">&amp;#xe6f7;</div>
+              </li>
+          
+            <li class="dib">
+              <span class="icon iconfont">&#xe6f8;</span>
+                <div class="name">top</div>
+                <div class="code-name">&amp;#xe6f8;</div>
+              </li>
+          
+            <li class="dib">
+              <span class="icon iconfont">&#xe6f9;</span>
+                <div class="name">works_share</div>
+                <div class="code-name">&amp;#xe6f9;</div>
+              </li>
+          
+            <li class="dib">
+              <span class="icon iconfont">&#xe6fa;</span>
+                <div class="name">works_look</div>
+                <div class="code-name">&amp;#xe6fa;</div>
+              </li>
+          
+            <li class="dib">
+              <span class="icon iconfont">&#xe6fb;</span>
+                <div class="name">works_editor</div>
+                <div class="code-name">&amp;#xe6fb;</div>
+              </li>
+          
+          </ul>
+          <div class="article markdown">
+          <h2 id="unicode-">Unicode 引用</h2>
+          <hr>
+
+          <p>Unicode 是字体在网页端最原始的应用方式,特点是:</p>
+          <ul>
+            <li>支持按字体的方式去动态调整图标大小,颜色等等。</li>
+            <li>默认情况下不支持多色,直接添加多色图标会自动去色。</li>
+          </ul>
+          <blockquote>
+            <p>注意:新版 iconfont 支持两种方式引用多色图标:SVG symbol 引用方式和彩色字体图标模式。(使用彩色字体图标需要在「编辑项目」中开启「彩色」选项后并重新生成。)</p>
+          </blockquote>
+          <p>Unicode 使用步骤如下:</p>
+          <h3 id="-font-face">第一步:拷贝项目下面生成的 <code>@font-face</code></h3>
+<pre><code class="language-css"
+>@font-face {
+  font-family: 'iconfont';
+  src: url('iconfont.woff2?t=1664433387288') format('woff2'),
+       url('iconfont.woff?t=1664433387288') format('woff'),
+       url('iconfont.ttf?t=1664433387288') format('truetype'),
+       url('iconfont.svg?t=1664433387288#iconfont') format('svg');
+}
+</code></pre>
+          <h3 id="-iconfont-">第二步:定义使用 iconfont 的样式</h3>
+<pre><code class="language-css"
+>.iconfont {
+  font-family: "iconfont" !important;
+  font-size: 16px;
+  font-style: normal;
+  -webkit-font-smoothing: antialiased;
+  -moz-osx-font-smoothing: grayscale;
+}
+</code></pre>
+          <h3 id="-">第三步:挑选相应图标并获取字体编码,应用于页面</h3>
+<pre>
+<code class="language-html"
+>&lt;span class="iconfont"&gt;&amp;#x33;&lt;/span&gt;
+</code></pre>
+          <blockquote>
+            <p>"iconfont" 是你项目下的 font-family。可以通过编辑项目查看,默认是 "iconfont"。</p>
+          </blockquote>
+          </div>
+      </div>
+      <div class="content font-class">
+        <ul class="icon_lists dib-box">
+          
+          <li class="dib">
+            <span class="icon iconfont icon-brushes"></span>
+            <div class="name">
+              brushes
+            </div>
+            <div class="code-name">.icon-brushes
+            </div>
+          </li>
+          
+          <li class="dib">
+            <span class="icon iconfont icon-pop-up_screen_off"></span>
+            <div class="name">
+              pop-up_screen_off
+            </div>
+            <div class="code-name">.icon-pop-up_screen_off
+            </div>
+          </li>
+          
+          <li class="dib">
+            <span class="icon iconfont icon-pop-up_screen_on"></span>
+            <div class="name">
+              pop-up_screen_on
+            </div>
+            <div class="code-name">.icon-pop-up_screen_on
+            </div>
+          </li>
+          
+          <li class="dib">
+            <span class="icon iconfont icon-arrow"></span>
+            <div class="name">
+              arrow
+            </div>
+            <div class="code-name">.icon-arrow
+            </div>
+          </li>
+          
+          <li class="dib">
+            <span class="icon iconfont icon-arrows"></span>
+            <div class="name">
+              arrows
+            </div>
+            <div class="code-name">.icon-arrows
+            </div>
+          </li>
+          
+          <li class="dib">
+            <span class="icon iconfont icon-chat_on"></span>
+            <div class="name">
+              chat_on
+            </div>
+            <div class="code-name">.icon-chat_on
+            </div>
+          </li>
+          
+          <li class="dib">
+            <span class="icon iconfont icon-exit"></span>
+            <div class="name">
+              exit
+            </div>
+            <div class="code-name">.icon-exit
+            </div>
+          </li>
+          
+          <li class="dib">
+            <span class="icon iconfont icon-cross"></span>
+            <div class="name">
+              cross
+            </div>
+            <div class="code-name">.icon-cross
+            </div>
+          </li>
+          
+          <li class="dib">
+            <span class="icon iconfont icon-chat_off"></span>
+            <div class="name">
+              chat_off
+            </div>
+            <div class="code-name">.icon-chat_off
+            </div>
+          </li>
+          
+          <li class="dib">
+            <span class="icon iconfont icon-guided"></span>
+            <div class="name">
+              guided
+            </div>
+            <div class="code-name">.icon-guided
+            </div>
+          </li>
+          
+          <li class="dib">
+            <span class="icon iconfont icon-members"></span>
+            <div class="name">
+              members
+            </div>
+            <div class="code-name">.icon-members
+            </div>
+          </li>
+          
+          <li class="dib">
+            <span class="icon iconfont icon-list"></span>
+            <div class="name">
+              list
+            </div>
+            <div class="code-name">.icon-list
+            </div>
+          </li>
+          
+          <li class="dib">
+            <span class="icon iconfont icon-mic_off"></span>
+            <div class="name">
+              mic_off
+            </div>
+            <div class="code-name">.icon-mic_off
+            </div>
+          </li>
+          
+          <li class="dib">
+            <span class="icon iconfont icon-mic_on"></span>
+            <div class="name">
+              mic_on
+            </div>
+            <div class="code-name">.icon-mic_on
+            </div>
+          </li>
+          
+          <li class="dib">
+            <span class="icon iconfont icon-invitation"></span>
+            <div class="name">
+              invitation
+            </div>
+            <div class="code-name">.icon-invitation
+            </div>
+          </li>
+          
+          <li class="dib">
+            <span class="icon iconfont icon-revocation"></span>
+            <div class="name">
+              revocation
+            </div>
+            <div class="code-name">.icon-revocation
+            </div>
+          </li>
+          
+          <li class="dib">
+            <span class="icon iconfont icon-show"></span>
+            <div class="name">
+              show
+            </div>
+            <div class="code-name">.icon-show
+            </div>
+          </li>
+          
+          <li class="dib">
+            <span class="icon iconfont icon-Checkbox-off"></span>
+            <div class="name">
+              Checkbox-off
+            </div>
+            <div class="code-name">.icon-Checkbox-off
+            </div>
+          </li>
+          
+          <li class="dib">
+            <span class="icon iconfont icon-works_delete"></span>
+            <div class="name">
+              works_delete
+            </div>
+            <div class="code-name">.icon-works_delete
+            </div>
+          </li>
+          
+          <li class="dib">
+            <span class="icon iconfont icon-Checkbox-on"></span>
+            <div class="name">
+              Checkbox-on
+            </div>
+            <div class="code-name">.icon-Checkbox-on
+            </div>
+          </li>
+          
+          <li class="dib">
+            <span class="icon iconfont icon-works_cancel"></span>
+            <div class="name">
+              works_cancel
+            </div>
+            <div class="code-name">.icon-works_cancel
+            </div>
+          </li>
+          
+          <li class="dib">
+            <span class="icon iconfont icon-toast_green"></span>
+            <div class="name">
+              toast_green
+            </div>
+            <div class="code-name">.icon-toast_green
+            </div>
+          </li>
+          
+          <li class="dib">
+            <span class="icon iconfont icon-toast_red"></span>
+            <div class="name">
+              toast_red
+            </div>
+            <div class="code-name">.icon-toast_red
+            </div>
+          </li>
+          
+          <li class="dib">
+            <span class="icon iconfont icon-toast_yellow"></span>
+            <div class="name">
+              toast_yellow
+            </div>
+            <div class="code-name">.icon-toast_yellow
+            </div>
+          </li>
+          
+          <li class="dib">
+            <span class="icon iconfont icon-material_preview_upload_collect"></span>
+            <div class="name">
+              material_preview_upload_collect
+            </div>
+            <div class="code-name">.icon-material_preview_upload_collect
+            </div>
+          </li>
+          
+          <li class="dib">
+            <span class="icon iconfont icon-pop-ups_shut-down"></span>
+            <div class="name">
+              pop-ups_shut-down
+            </div>
+            <div class="code-name">.icon-pop-ups_shut-down
+            </div>
+          </li>
+          
+          <li class="dib">
+            <span class="icon iconfont icon-works_add"></span>
+            <div class="name">
+              works_add
+            </div>
+            <div class="code-name">.icon-works_add
+            </div>
+          </li>
+          
+          <li class="dib">
+            <span class="icon iconfont icon-works_search"></span>
+            <div class="name">
+              works_search
+            </div>
+            <div class="code-name">.icon-works_search
+            </div>
+          </li>
+          
+          <li class="dib">
+            <span class="icon iconfont icon-top"></span>
+            <div class="name">
+              top
+            </div>
+            <div class="code-name">.icon-top
+            </div>
+          </li>
+          
+          <li class="dib">
+            <span class="icon iconfont icon-works_share"></span>
+            <div class="name">
+              works_share
+            </div>
+            <div class="code-name">.icon-works_share
+            </div>
+          </li>
+          
+          <li class="dib">
+            <span class="icon iconfont icon-works_look"></span>
+            <div class="name">
+              works_look
+            </div>
+            <div class="code-name">.icon-works_look
+            </div>
+          </li>
+          
+          <li class="dib">
+            <span class="icon iconfont icon-works_editor"></span>
+            <div class="name">
+              works_editor
+            </div>
+            <div class="code-name">.icon-works_editor
+            </div>
+          </li>
+          
+        </ul>
+        <div class="article markdown">
+        <h2 id="font-class-">font-class 引用</h2>
+        <hr>
+
+        <p>font-class 是 Unicode 使用方式的一种变种,主要是解决 Unicode 书写不直观,语意不明确的问题。</p>
+        <p>与 Unicode 使用方式相比,具有如下特点:</p>
+        <ul>
+          <li>相比于 Unicode 语意明确,书写更直观。可以很容易分辨这个 icon 是什么。</li>
+          <li>因为使用 class 来定义图标,所以当要替换图标时,只需要修改 class 里面的 Unicode 引用。</li>
+        </ul>
+        <p>使用步骤如下:</p>
+        <h3 id="-fontclass-">第一步:引入项目下面生成的 fontclass 代码:</h3>
+<pre><code class="language-html">&lt;link rel="stylesheet" href="./iconfont.css"&gt;
+</code></pre>
+        <h3 id="-">第二步:挑选相应图标并获取类名,应用于页面:</h3>
+<pre><code class="language-html">&lt;span class="iconfont icon-xxx"&gt;&lt;/span&gt;
+</code></pre>
+        <blockquote>
+          <p>"
+            iconfont" 是你项目下的 font-family。可以通过编辑项目查看,默认是 "iconfont"。</p>
+        </blockquote>
+      </div>
+      </div>
+      <div class="content symbol">
+          <ul class="icon_lists dib-box">
+          
+            <li class="dib">
+                <svg class="icon svg-icon" aria-hidden="true">
+                  <use xlink:href="#icon-brushes"></use>
+                </svg>
+                <div class="name">brushes</div>
+                <div class="code-name">#icon-brushes</div>
+            </li>
+          
+            <li class="dib">
+                <svg class="icon svg-icon" aria-hidden="true">
+                  <use xlink:href="#icon-pop-up_screen_off"></use>
+                </svg>
+                <div class="name">pop-up_screen_off</div>
+                <div class="code-name">#icon-pop-up_screen_off</div>
+            </li>
+          
+            <li class="dib">
+                <svg class="icon svg-icon" aria-hidden="true">
+                  <use xlink:href="#icon-pop-up_screen_on"></use>
+                </svg>
+                <div class="name">pop-up_screen_on</div>
+                <div class="code-name">#icon-pop-up_screen_on</div>
+            </li>
+          
+            <li class="dib">
+                <svg class="icon svg-icon" aria-hidden="true">
+                  <use xlink:href="#icon-arrow"></use>
+                </svg>
+                <div class="name">arrow</div>
+                <div class="code-name">#icon-arrow</div>
+            </li>
+          
+            <li class="dib">
+                <svg class="icon svg-icon" aria-hidden="true">
+                  <use xlink:href="#icon-arrows"></use>
+                </svg>
+                <div class="name">arrows</div>
+                <div class="code-name">#icon-arrows</div>
+            </li>
+          
+            <li class="dib">
+                <svg class="icon svg-icon" aria-hidden="true">
+                  <use xlink:href="#icon-chat_on"></use>
+                </svg>
+                <div class="name">chat_on</div>
+                <div class="code-name">#icon-chat_on</div>
+            </li>
+          
+            <li class="dib">
+                <svg class="icon svg-icon" aria-hidden="true">
+                  <use xlink:href="#icon-exit"></use>
+                </svg>
+                <div class="name">exit</div>
+                <div class="code-name">#icon-exit</div>
+            </li>
+          
+            <li class="dib">
+                <svg class="icon svg-icon" aria-hidden="true">
+                  <use xlink:href="#icon-cross"></use>
+                </svg>
+                <div class="name">cross</div>
+                <div class="code-name">#icon-cross</div>
+            </li>
+          
+            <li class="dib">
+                <svg class="icon svg-icon" aria-hidden="true">
+                  <use xlink:href="#icon-chat_off"></use>
+                </svg>
+                <div class="name">chat_off</div>
+                <div class="code-name">#icon-chat_off</div>
+            </li>
+          
+            <li class="dib">
+                <svg class="icon svg-icon" aria-hidden="true">
+                  <use xlink:href="#icon-guided"></use>
+                </svg>
+                <div class="name">guided</div>
+                <div class="code-name">#icon-guided</div>
+            </li>
+          
+            <li class="dib">
+                <svg class="icon svg-icon" aria-hidden="true">
+                  <use xlink:href="#icon-members"></use>
+                </svg>
+                <div class="name">members</div>
+                <div class="code-name">#icon-members</div>
+            </li>
+          
+            <li class="dib">
+                <svg class="icon svg-icon" aria-hidden="true">
+                  <use xlink:href="#icon-list"></use>
+                </svg>
+                <div class="name">list</div>
+                <div class="code-name">#icon-list</div>
+            </li>
+          
+            <li class="dib">
+                <svg class="icon svg-icon" aria-hidden="true">
+                  <use xlink:href="#icon-mic_off"></use>
+                </svg>
+                <div class="name">mic_off</div>
+                <div class="code-name">#icon-mic_off</div>
+            </li>
+          
+            <li class="dib">
+                <svg class="icon svg-icon" aria-hidden="true">
+                  <use xlink:href="#icon-mic_on"></use>
+                </svg>
+                <div class="name">mic_on</div>
+                <div class="code-name">#icon-mic_on</div>
+            </li>
+          
+            <li class="dib">
+                <svg class="icon svg-icon" aria-hidden="true">
+                  <use xlink:href="#icon-invitation"></use>
+                </svg>
+                <div class="name">invitation</div>
+                <div class="code-name">#icon-invitation</div>
+            </li>
+          
+            <li class="dib">
+                <svg class="icon svg-icon" aria-hidden="true">
+                  <use xlink:href="#icon-revocation"></use>
+                </svg>
+                <div class="name">revocation</div>
+                <div class="code-name">#icon-revocation</div>
+            </li>
+          
+            <li class="dib">
+                <svg class="icon svg-icon" aria-hidden="true">
+                  <use xlink:href="#icon-show"></use>
+                </svg>
+                <div class="name">show</div>
+                <div class="code-name">#icon-show</div>
+            </li>
+          
+            <li class="dib">
+                <svg class="icon svg-icon" aria-hidden="true">
+                  <use xlink:href="#icon-Checkbox-off"></use>
+                </svg>
+                <div class="name">Checkbox-off</div>
+                <div class="code-name">#icon-Checkbox-off</div>
+            </li>
+          
+            <li class="dib">
+                <svg class="icon svg-icon" aria-hidden="true">
+                  <use xlink:href="#icon-works_delete"></use>
+                </svg>
+                <div class="name">works_delete</div>
+                <div class="code-name">#icon-works_delete</div>
+            </li>
+          
+            <li class="dib">
+                <svg class="icon svg-icon" aria-hidden="true">
+                  <use xlink:href="#icon-Checkbox-on"></use>
+                </svg>
+                <div class="name">Checkbox-on</div>
+                <div class="code-name">#icon-Checkbox-on</div>
+            </li>
+          
+            <li class="dib">
+                <svg class="icon svg-icon" aria-hidden="true">
+                  <use xlink:href="#icon-works_cancel"></use>
+                </svg>
+                <div class="name">works_cancel</div>
+                <div class="code-name">#icon-works_cancel</div>
+            </li>
+          
+            <li class="dib">
+                <svg class="icon svg-icon" aria-hidden="true">
+                  <use xlink:href="#icon-toast_green"></use>
+                </svg>
+                <div class="name">toast_green</div>
+                <div class="code-name">#icon-toast_green</div>
+            </li>
+          
+            <li class="dib">
+                <svg class="icon svg-icon" aria-hidden="true">
+                  <use xlink:href="#icon-toast_red"></use>
+                </svg>
+                <div class="name">toast_red</div>
+                <div class="code-name">#icon-toast_red</div>
+            </li>
+          
+            <li class="dib">
+                <svg class="icon svg-icon" aria-hidden="true">
+                  <use xlink:href="#icon-toast_yellow"></use>
+                </svg>
+                <div class="name">toast_yellow</div>
+                <div class="code-name">#icon-toast_yellow</div>
+            </li>
+          
+            <li class="dib">
+                <svg class="icon svg-icon" aria-hidden="true">
+                  <use xlink:href="#icon-material_preview_upload_collect"></use>
+                </svg>
+                <div class="name">material_preview_upload_collect</div>
+                <div class="code-name">#icon-material_preview_upload_collect</div>
+            </li>
+          
+            <li class="dib">
+                <svg class="icon svg-icon" aria-hidden="true">
+                  <use xlink:href="#icon-pop-ups_shut-down"></use>
+                </svg>
+                <div class="name">pop-ups_shut-down</div>
+                <div class="code-name">#icon-pop-ups_shut-down</div>
+            </li>
+          
+            <li class="dib">
+                <svg class="icon svg-icon" aria-hidden="true">
+                  <use xlink:href="#icon-works_add"></use>
+                </svg>
+                <div class="name">works_add</div>
+                <div class="code-name">#icon-works_add</div>
+            </li>
+          
+            <li class="dib">
+                <svg class="icon svg-icon" aria-hidden="true">
+                  <use xlink:href="#icon-works_search"></use>
+                </svg>
+                <div class="name">works_search</div>
+                <div class="code-name">#icon-works_search</div>
+            </li>
+          
+            <li class="dib">
+                <svg class="icon svg-icon" aria-hidden="true">
+                  <use xlink:href="#icon-top"></use>
+                </svg>
+                <div class="name">top</div>
+                <div class="code-name">#icon-top</div>
+            </li>
+          
+            <li class="dib">
+                <svg class="icon svg-icon" aria-hidden="true">
+                  <use xlink:href="#icon-works_share"></use>
+                </svg>
+                <div class="name">works_share</div>
+                <div class="code-name">#icon-works_share</div>
+            </li>
+          
+            <li class="dib">
+                <svg class="icon svg-icon" aria-hidden="true">
+                  <use xlink:href="#icon-works_look"></use>
+                </svg>
+                <div class="name">works_look</div>
+                <div class="code-name">#icon-works_look</div>
+            </li>
+          
+            <li class="dib">
+                <svg class="icon svg-icon" aria-hidden="true">
+                  <use xlink:href="#icon-works_editor"></use>
+                </svg>
+                <div class="name">works_editor</div>
+                <div class="code-name">#icon-works_editor</div>
+            </li>
+          
+          </ul>
+          <div class="article markdown">
+          <h2 id="symbol-">Symbol 引用</h2>
+          <hr>
+
+          <p>这是一种全新的使用方式,应该说这才是未来的主流,也是平台目前推荐的用法。相关介绍可以参考这篇<a href="">文章</a>
+            这种用法其实是做了一个 SVG 的集合,与另外两种相比具有如下特点:</p>
+          <ul>
+            <li>支持多色图标了,不再受单色限制。</li>
+            <li>通过一些技巧,支持像字体那样,通过 <code>font-size</code>, <code>color</code> 来调整样式。</li>
+            <li>兼容性较差,支持 IE9+,及现代浏览器。</li>
+            <li>浏览器渲染 SVG 的性能一般,还不如 png。</li>
+          </ul>
+          <p>使用步骤如下:</p>
+          <h3 id="-symbol-">第一步:引入项目下面生成的 symbol 代码:</h3>
+<pre><code class="language-html">&lt;script src="./iconfont.js"&gt;&lt;/script&gt;
+</code></pre>
+          <h3 id="-css-">第二步:加入通用 CSS 代码(引入一次就行):</h3>
+<pre><code class="language-html">&lt;style&gt;
+.icon {
+  width: 1em;
+  height: 1em;
+  vertical-align: -0.15em;
+  fill: currentColor;
+  overflow: hidden;
+}
+&lt;/style&gt;
+</code></pre>
+          <h3 id="-">第三步:挑选相应图标并获取类名,应用于页面:</h3>
+<pre><code class="language-html">&lt;svg class="icon" aria-hidden="true"&gt;
+  &lt;use xlink:href="#icon-xxx"&gt;&lt;/use&gt;
+&lt;/svg&gt;
+</code></pre>
+          </div>
+      </div>
+
+    </div>
+  </div>
+  <script>
+  $(document).ready(function () {
+      $('.tab-container .content:first').show()
+
+      $('#tabs li').click(function (e) {
+        var tabContent = $('.tab-container .content')
+        var index = $(this).index()
+
+        if ($(this).hasClass('active')) {
+          return
+        } else {
+          $('#tabs li').removeClass('active')
+          $(this).addClass('active')
+
+          tabContent.hide().eq(index).fadeIn()
+        }
+      })
+    })
+  </script>
+</body>
+</html>

+ 144 - 0
src/assets/iconfont/iconfont.css

@@ -0,0 +1,144 @@
+@font-face {
+  font-family: "iconfont"; /* Project id 3676870 */
+  src: url('iconfont.woff2?t=1664433387288') format('woff2'),
+       url('iconfont.woff?t=1664433387288') format('woff'),
+       url('iconfont.ttf?t=1664433387288') format('truetype'),
+       url('iconfont.svg?t=1664433387288#iconfont') format('svg');
+}
+
+.iconfont {
+  font-family: "iconfont" !important;
+  font-size: 16px;
+  font-style: normal;
+  -webkit-font-smoothing: antialiased;
+  -moz-osx-font-smoothing: grayscale;
+}
+
+.icon-brushes:before {
+  content: "\e704";
+}
+
+.icon-pop-up_screen_off:before {
+  content: "\e710";
+}
+
+.icon-pop-up_screen_on:before {
+  content: "\e712";
+}
+
+.icon-arrow:before {
+  content: "\e703";
+}
+
+.icon-arrows:before {
+  content: "\e705";
+}
+
+.icon-chat_on:before {
+  content: "\e706";
+}
+
+.icon-exit:before {
+  content: "\e707";
+}
+
+.icon-cross:before {
+  content: "\e708";
+}
+
+.icon-chat_off:before {
+  content: "\e709";
+}
+
+.icon-guided:before {
+  content: "\e70a";
+}
+
+.icon-members:before {
+  content: "\e70b";
+}
+
+.icon-list:before {
+  content: "\e70c";
+}
+
+.icon-mic_off:before {
+  content: "\e70d";
+}
+
+.icon-mic_on:before {
+  content: "\e70e";
+}
+
+.icon-invitation:before {
+  content: "\e70f";
+}
+
+.icon-revocation:before {
+  content: "\e711";
+}
+
+.icon-show:before {
+  content: "\e713";
+}
+
+.icon-Checkbox-off:before {
+  content: "\e702";
+}
+
+.icon-works_delete:before {
+  content: "\e6fd";
+}
+
+.icon-Checkbox-on:before {
+  content: "\e6fc";
+}
+
+.icon-works_cancel:before {
+  content: "\e6fe";
+}
+
+.icon-toast_green:before {
+  content: "\e6ff";
+}
+
+.icon-toast_red:before {
+  content: "\e700";
+}
+
+.icon-toast_yellow:before {
+  content: "\e701";
+}
+
+.icon-material_preview_upload_collect:before {
+  content: "\e6f4";
+}
+
+.icon-pop-ups_shut-down:before {
+  content: "\e6f5";
+}
+
+.icon-works_add:before {
+  content: "\e6f6";
+}
+
+.icon-works_search:before {
+  content: "\e6f7";
+}
+
+.icon-top:before {
+  content: "\e6f8";
+}
+
+.icon-works_share:before {
+  content: "\e6f9";
+}
+
+.icon-works_look:before {
+  content: "\e6fa";
+}
+
+.icon-works_editor:before {
+  content: "\e6fb";
+}
+

Diferenças do arquivo suprimidas por serem muito extensas
+ 1 - 0
src/assets/iconfont/iconfont.js


+ 233 - 0
src/assets/iconfont/iconfont.json

@@ -0,0 +1,233 @@
+{
+  "id": "3676870",
+  "name": "四维带看2.0",
+  "font_family": "iconfont",
+  "css_prefix_text": "icon-",
+  "description": "",
+  "glyphs": [
+    {
+      "icon_id": "32082999",
+      "name": "brushes",
+      "font_class": "brushes",
+      "unicode": "e704",
+      "unicode_decimal": 59140
+    },
+    {
+      "icon_id": "32083001",
+      "name": "pop-up_screen_off",
+      "font_class": "pop-up_screen_off",
+      "unicode": "e710",
+      "unicode_decimal": 59152
+    },
+    {
+      "icon_id": "32083004",
+      "name": "pop-up_screen_on",
+      "font_class": "pop-up_screen_on",
+      "unicode": "e712",
+      "unicode_decimal": 59154
+    },
+    {
+      "icon_id": "32082923",
+      "name": "arrow",
+      "font_class": "arrow",
+      "unicode": "e703",
+      "unicode_decimal": 59139
+    },
+    {
+      "icon_id": "32082925",
+      "name": "arrows",
+      "font_class": "arrows",
+      "unicode": "e705",
+      "unicode_decimal": 59141
+    },
+    {
+      "icon_id": "32082926",
+      "name": "chat_on",
+      "font_class": "chat_on",
+      "unicode": "e706",
+      "unicode_decimal": 59142
+    },
+    {
+      "icon_id": "32082927",
+      "name": "exit",
+      "font_class": "exit",
+      "unicode": "e707",
+      "unicode_decimal": 59143
+    },
+    {
+      "icon_id": "32082928",
+      "name": "cross",
+      "font_class": "cross",
+      "unicode": "e708",
+      "unicode_decimal": 59144
+    },
+    {
+      "icon_id": "32082929",
+      "name": "chat_off",
+      "font_class": "chat_off",
+      "unicode": "e709",
+      "unicode_decimal": 59145
+    },
+    {
+      "icon_id": "32082930",
+      "name": "guided",
+      "font_class": "guided",
+      "unicode": "e70a",
+      "unicode_decimal": 59146
+    },
+    {
+      "icon_id": "32082931",
+      "name": "members",
+      "font_class": "members",
+      "unicode": "e70b",
+      "unicode_decimal": 59147
+    },
+    {
+      "icon_id": "32082932",
+      "name": "list",
+      "font_class": "list",
+      "unicode": "e70c",
+      "unicode_decimal": 59148
+    },
+    {
+      "icon_id": "32082933",
+      "name": "mic_off",
+      "font_class": "mic_off",
+      "unicode": "e70d",
+      "unicode_decimal": 59149
+    },
+    {
+      "icon_id": "32082934",
+      "name": "mic_on",
+      "font_class": "mic_on",
+      "unicode": "e70e",
+      "unicode_decimal": 59150
+    },
+    {
+      "icon_id": "32082935",
+      "name": "invitation",
+      "font_class": "invitation",
+      "unicode": "e70f",
+      "unicode_decimal": 59151
+    },
+    {
+      "icon_id": "32082937",
+      "name": "revocation",
+      "font_class": "revocation",
+      "unicode": "e711",
+      "unicode_decimal": 59153
+    },
+    {
+      "icon_id": "32082939",
+      "name": "show",
+      "font_class": "show",
+      "unicode": "e713",
+      "unicode_decimal": 59155
+    },
+    {
+      "icon_id": "32081381",
+      "name": "Checkbox-off",
+      "font_class": "Checkbox-off",
+      "unicode": "e702",
+      "unicode_decimal": 59138
+    },
+    {
+      "icon_id": "32081238",
+      "name": "works_delete",
+      "font_class": "works_delete",
+      "unicode": "e6fd",
+      "unicode_decimal": 59133
+    },
+    {
+      "icon_id": "32079562",
+      "name": "Checkbox-on",
+      "font_class": "Checkbox-on",
+      "unicode": "e6fc",
+      "unicode_decimal": 59132
+    },
+    {
+      "icon_id": "32079564",
+      "name": "works_cancel",
+      "font_class": "works_cancel",
+      "unicode": "e6fe",
+      "unicode_decimal": 59134
+    },
+    {
+      "icon_id": "32079565",
+      "name": "toast_green",
+      "font_class": "toast_green",
+      "unicode": "e6ff",
+      "unicode_decimal": 59135
+    },
+    {
+      "icon_id": "32079566",
+      "name": "toast_red",
+      "font_class": "toast_red",
+      "unicode": "e700",
+      "unicode_decimal": 59136
+    },
+    {
+      "icon_id": "32079567",
+      "name": "toast_yellow",
+      "font_class": "toast_yellow",
+      "unicode": "e701",
+      "unicode_decimal": 59137
+    },
+    {
+      "icon_id": "32078822",
+      "name": "material_preview_upload_collect",
+      "font_class": "material_preview_upload_collect",
+      "unicode": "e6f4",
+      "unicode_decimal": 59124
+    },
+    {
+      "icon_id": "32078823",
+      "name": "pop-ups_shut-down",
+      "font_class": "pop-ups_shut-down",
+      "unicode": "e6f5",
+      "unicode_decimal": 59125
+    },
+    {
+      "icon_id": "32078825",
+      "name": "works_add",
+      "font_class": "works_add",
+      "unicode": "e6f6",
+      "unicode_decimal": 59126
+    },
+    {
+      "icon_id": "32078826",
+      "name": "works_search",
+      "font_class": "works_search",
+      "unicode": "e6f7",
+      "unicode_decimal": 59127
+    },
+    {
+      "icon_id": "32078827",
+      "name": "top",
+      "font_class": "top",
+      "unicode": "e6f8",
+      "unicode_decimal": 59128
+    },
+    {
+      "icon_id": "32078828",
+      "name": "works_share",
+      "font_class": "works_share",
+      "unicode": "e6f9",
+      "unicode_decimal": 59129
+    },
+    {
+      "icon_id": "32078829",
+      "name": "works_look",
+      "font_class": "works_look",
+      "unicode": "e6fa",
+      "unicode_decimal": 59130
+    },
+    {
+      "icon_id": "32078830",
+      "name": "works_editor",
+      "font_class": "works_editor",
+      "unicode": "e6fb",
+      "unicode_decimal": 59131
+    }
+  ]
+}

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


BIN
src/assets/iconfont/iconfont.ttf


BIN
src/assets/iconfont/iconfont.woff


BIN
src/assets/iconfont/iconfont.woff2


BIN
src/assets/images/bg.png


BIN
src/assets/images/logo.png


BIN
src/assets/images/un-data.png


BIN
src/assets/images/un-scene.png


+ 60 - 0
src/components.d.ts

@@ -0,0 +1,60 @@
+// generated by unplugin-vue-components
+// We suggest you to commit this file into source control
+// Read more: https://github.com/vuejs/core/pull/3399
+import '@vue/runtime-core'
+
+export {}
+
+declare module '@vue/runtime-core' {
+  export interface GlobalComponents {
+    AAvatar: typeof import('ant-design-vue/es')['Avatar']
+    AButton: typeof import('ant-design-vue/es')['Button']
+    ACard: typeof import('ant-design-vue/es')['Card']
+    ACheckbox: typeof import('ant-design-vue/es')['Checkbox']
+    AConfigProvider: typeof import('ant-design-vue/es')['ConfigProvider']
+    ADatePicker: typeof import('ant-design-vue/es')['DatePicker']
+    ADropdown: typeof import('ant-design-vue/es')['Dropdown']
+    AForm: typeof import('ant-design-vue/es')['Form']
+    AFormItem: typeof import('ant-design-vue/es')['FormItem']
+    AInput: typeof import('ant-design-vue/es')['Input']
+    AInputPassword: typeof import('ant-design-vue/es')['InputPassword']
+    AInputSearch: typeof import('ant-design-vue/es')['InputSearch']
+    ALayout: typeof import('ant-design-vue/es')['Layout']
+    ALayoutContent: typeof import('ant-design-vue/es')['LayoutContent']
+    ALayoutHeader: typeof import('ant-design-vue/es')['LayoutHeader']
+    ALayoutSider: typeof import('ant-design-vue/es')['LayoutSider']
+    AList: typeof import('ant-design-vue/es')['List']
+    AListItem: typeof import('ant-design-vue/es')['ListItem']
+    AMenu: typeof import('ant-design-vue/es')['Menu']
+    AMenuItem: typeof import('ant-design-vue/es')['MenuItem']
+    AModal: typeof import('ant-design-vue/es')['Modal']
+    APopover: typeof import('ant-design-vue/es')['Popover']
+    ARadio: typeof import('ant-design-vue/es')['Radio']
+    ARadioGroup: typeof import('ant-design-vue/es')['RadioGroup']
+    ASelect: typeof import('ant-design-vue/es')['Select']
+    ASelectOption: typeof import('ant-design-vue/es')['SelectOption']
+    ATable: typeof import('ant-design-vue/es')['Table']
+    ATabPane: typeof import('ant-design-vue/es')['TabPane']
+    ATabs: typeof import('ant-design-vue/es')['Tabs']
+    ATextarea: typeof import('ant-design-vue/es')['Textarea']
+    AUpload: typeof import('ant-design-vue/es')['Upload']
+    CloseOutlined: typeof import('@ant-design/icons-vue')['CloseOutlined']
+    DataList: typeof import('./components/data-list/index.vue')['default']
+    DeleteOutlined: typeof import('@ant-design/icons-vue')['DeleteOutlined']
+    DownOutlined: typeof import('@ant-design/icons-vue')['DownOutlined']
+    EyeOutlined: typeof import('@ant-design/icons-vue')['EyeOutlined']
+    Loading: typeof import('./components/loading/index.vue')['default']
+    LockOutlined: typeof import('@ant-design/icons-vue')['LockOutlined']
+    PieChartOutlined: typeof import('@ant-design/icons-vue')['PieChartOutlined']
+    PlusOutlined: typeof import('@ant-design/icons-vue')['PlusOutlined']
+    RouterLink: typeof import('vue-router')['RouterLink']
+    RouterView: typeof import('vue-router')['RouterView']
+    SearchOutlined: typeof import('@ant-design/icons-vue')['SearchOutlined']
+    Simples: typeof import('./components/simples/index.vue')['default']
+    SmileOutlined: typeof import('@ant-design/icons-vue')['SmileOutlined']
+    Upload: typeof import('./components/upload/index.vue')['default']
+    UploadOutlined: typeof import('@ant-design/icons-vue')['UploadOutlined']
+    UserOutlined: typeof import('@ant-design/icons-vue')['UserOutlined']
+    VerticalAlignTopOutlined: typeof import('@ant-design/icons-vue')['VerticalAlignTopOutlined']
+  }
+}

+ 45 - 0
src/components/data-list/index.vue

@@ -0,0 +1,45 @@
+<template>
+  <slot v-if="dataSource.length"></slot>
+  <div v-else class="un-data">
+    <img :src="unDataPng" class="icon" />
+    <p v-if="keyword">未搜索到结果,</p>
+    <p>您还没有{{ name || '数据' }},请先创建{{ name || '数据' }}~</p>
+    <div class="undata">
+      <slot name="undata"></slot>
+    </div>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import unDataPng from '@/assets/images/un-data.png'
+
+defineOptions({ name: 'DataList' })
+defineProps<{
+  dataSource: Array<any>
+  name?: string
+  keyword: string
+}>()
+</script>
+
+<style scoped lang="scss">
+.un-data {
+  margin: 120px;
+  text-align: center;
+
+  .icon {
+    margin-bottom: 20px;
+    width: 80px;
+  }
+
+  p {
+    font-size: 14px;
+    font-weight: 400;
+    color: #646566;
+    margin-bottom: 5px;
+  }
+
+  .undata {
+    margin-top: 22px;
+  }
+}
+</style>

+ 49 - 0
src/components/loading/index.ts

@@ -0,0 +1,49 @@
+import Loading from './index.vue'
+import { mount } from '@/helper/mount'
+import type { App } from 'vue'
+import type { LoadingProps } from './props'
+
+export type LoginMark = string | symbol | number
+
+type Stack = { key?: LoginMark; close?: () => void }[]
+const closeStack: Stack = []
+
+export const showLoading = function (
+  props?: LoadingProps,
+  app?: App,
+  key?: LoginMark
+) {
+  if (closeStack.length) {
+    closeStack.push({ key })
+  } else {
+    const timeout = setTimeout(() => {
+      const { destroy } = mount(Loading, { app, props })
+      item.close = destroy
+    }, 300)
+    const item: Stack[number] = {
+      key,
+      close: () => clearTimeout(timeout)
+    }
+    closeStack.push(item)
+  }
+}
+
+export const hideLoading = function (hkey?: LoginMark) {
+  if (closeStack.length) {
+    const { key } = closeStack[closeStack.length - 1]
+    if (key === hkey) {
+      const stack = closeStack.pop()
+      stack?.close && stack?.close()
+    }
+  }
+}
+
+export const hideLoadingAll = function () {
+  for (const { close } of closeStack) {
+    close && close()
+  }
+  closeStack.length = 0
+}
+
+export { Loading }
+export default Loading

+ 85 - 0
src/components/loading/index.vue

@@ -0,0 +1,85 @@
+<template>
+  <teleport :to="el">
+    <div
+      class="ui-loading"
+      :style="{ zIndex: 1000, ['--width']: size + 'px', ['--color']: color }"
+    >
+      <div class="ui-loading__box">
+        <div class="default">
+          <div></div>
+          <div></div>
+          <div></div>
+        </div>
+      </div>
+    </div>
+  </teleport>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue'
+import { props } from './props'
+
+export default defineComponent({
+  name: 'Loading',
+  props: props
+})
+</script>
+
+<style lang="scss" scoped>
+.ui-loading {
+  position: absolute;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  left: 0;
+  top: 0;
+  width: 100%;
+  height: 100%;
+  overflow: hidden;
+  background-color: rgba($color: #000000, $alpha: 0.3);
+  --width: 15px;
+  --color: #fff;
+}
+.ui-loading__box {
+  position: relative;
+  width: 100px;
+  height: 100px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  .default {
+    div {
+      width: var(--width);
+      height: var(--width);
+      background: var(--color);
+      border-radius: 50%;
+      display: inline-block;
+      margin-left: calc(var(--width) * 0.6);
+    }
+    div:nth-child(1) {
+      animation: ui-loading-default 1s -0.5s linear infinite;
+    }
+    div:nth-child(2) {
+      animation: ui-loading-default 1s -0.25s linear infinite;
+    }
+    div:nth-child(3) {
+      animation: ui-loading-default 1s 0s linear infinite;
+    }
+  }
+}
+
+@keyframes ui-loading-default {
+  0% {
+    transform: scale(1);
+    opacity: 1;
+  }
+  50% {
+    transform: scale(0.5);
+    opacity: 0.5;
+  }
+  100% {
+    transform: scale(1);
+    opacity: 0.8;
+  }
+}
+</style>

+ 18 - 0
src/components/loading/props.ts

@@ -0,0 +1,18 @@
+import type { ExtractPropTypes } from 'vue'
+
+export const props = {
+  el: {
+    type: String,
+    default: 'body'
+  },
+  size: {
+    type: Number,
+    default: 15
+  },
+  color: {
+    type: String,
+    default: '#fff'
+  }
+}
+
+export type LoadingProps = Partial<ExtractPropTypes<typeof props>>

+ 51 - 0
src/components/simples/index.vue

@@ -0,0 +1,51 @@
+<template>
+  <div class="simples">
+    <div v-for="simple in data" :key="simple.label">
+      <span class="label">{{ simple.label }}</span>
+      <span class="value">{{ simple.value }}</span>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+export type Simple = { label: string; value: string | number }
+
+defineProps<{ data: Simple[] }>()
+defineOptions<{ name: 'Simples' }>()
+</script>
+
+<style scoped lang="scss">
+.simples {
+  display: flex;
+
+  div {
+    width: 140px;
+    text-align: center;
+    position: relative;
+    display: flex;
+    flex-direction: column;
+
+    .label {
+      color: #8c8c8c;
+      font-size: 14px;
+      margin-bottom: 6px;
+    }
+
+    .value {
+      color: #323233;
+      font-size: 24px;
+    }
+
+    &:not(:last-child)::after {
+      content: '';
+      position: absolute;
+      top: 50%;
+      right: 0;
+      transform: translateY(-50%);
+      width: 1px;
+      height: 20px;
+      background: #e9e9e9;
+    }
+  }
+}
+</style>

+ 102 - 0
src/components/upload/index.vue

@@ -0,0 +1,102 @@
+<template>
+  <a-upload :file-list="[]" :multiple="false" :before-upload="onBeforeUpload">
+    <a-button type="primary" :disabled="disabled || !!file">
+      <upload-outlined></upload-outlined>
+      上传
+    </a-button>
+  </a-upload>
+  <ol class="desc">
+    <li>支持{{ extxTip }}文件格式;</li>
+    <li>最大支持上传{{ maxSizeTip }};</li>
+    <template v-if="tips">
+      <li v-for="tip in tips" :key="tip">{{ tip }}</li>
+    </template>
+  </ol>
+  <div v-if="file" class="action">
+    <p>{{ typeof file === 'string' ? file : file.name }}</p>
+    <delete-outlined
+      v-if="!disabled"
+      class="icon"
+      @click="emit('update:file', undefined)"
+    />
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { message } from 'ant-design-vue'
+import { getExtname } from '@/shared'
+import { computed } from 'vue'
+
+import type { UploadProps } from 'ant-design-vue'
+
+export type BUploadProps = {
+  disabled?: boolean
+  file?: File | string
+  maxSize: number
+  extnames: string | string[]
+  tips?: string[]
+}
+
+const props = defineProps<BUploadProps>()
+
+const emit = defineEmits<{
+  (e: 'update:file', v: BUploadProps['file']): void
+}>()
+
+const maxMB = computed(() => props.maxSize * 1024 * 1024)
+const maxSizeTip = computed(() =>
+  props.maxSize > 1024 ? props.maxSize / 1024 + 'GB' : props.maxSize + 'MB'
+)
+const extnames = computed(() =>
+  (Array.isArray(props.extnames) ? props.extnames : [props.extnames]).map(ext =>
+    ext.toLowerCase()
+  )
+)
+const extxTip = computed(
+  () =>
+    extnames.value.map(ext => `.${ext}`).join('、') +
+    (extnames.value.length > 1 ? '等' : '')
+)
+
+const onBeforeUpload: UploadProps['beforeUpload'] = file => {
+  const ext = getExtname(file.name)?.toLocaleLowerCase()
+
+  if (file.size > maxMB.value) {
+    message.error(`最大支持上传${maxSizeTip.value}`)
+  } else if (!ext || !extnames.value.includes(ext)) {
+    message.error(`仅支持${extxTip.value}文件格式`)
+  } else {
+    emit('update:file', file)
+  }
+  return false
+}
+</script>
+
+<style lang="scss" scoped>
+.desc {
+  margin-top: 16px;
+  color: #999999;
+  padding-left: 20px;
+}
+
+.action {
+  font-size: 14px;
+  color: #323233;
+  display: flex;
+  align-items: center;
+  p {
+    margin: 0;
+    word-break: break-all;
+  }
+  .icon {
+    margin-left: 10px;
+    color: #c8c8c8;
+    cursor: pointer;
+    transition: color 0.3s ease;
+
+    &:hover {
+      color: inherit;
+    }
+  }
+}
+</style>

+ 19 - 0
src/env/index.ts

@@ -0,0 +1,19 @@
+import { strToParams } from '@/shared'
+
+export type Params = {
+  token?: string
+}
+
+export const params = strToParams(location.search) as Params
+export const baseURL = import.meta.env.DEV
+  ? '/api'
+  : 'https://v4-test.4dkankan.com'
+
+export const registerLink =
+  import.meta.env.VITE_SERVER + '/#/login/register?from=%2F'
+export const forgetLink =
+  import.meta.env.VITE_SERVER + '/#/login/forget?from=%2F'
+export const userInfoLink = import.meta.env.VITE_SERVER + '/#/information'
+
+export const projectManage =
+  import.meta.env.VITE_SERVER + '/smarts/smart-viewer.html'

+ 29 - 0
src/helper/index.ts

@@ -0,0 +1,29 @@
+import { app } from '@/main'
+import { genMount } from './mount'
+
+export const mount = genMount(app)
+
+export const renderCompoent = <P>(comp: ComponentConstructor<P>, props: P) => {
+  const element = document.createElement('div')
+  const { destroy } = mount(comp, { props, element })
+  return () => {
+    destroy()
+    if (document.body.contains(element)) {
+      document.body.removeChild(element)
+    }
+  }
+}
+
+export const renderModal = <P>(
+  comp: ComponentConstructor<P>,
+  propsRaw: Omit<P, 'onCancel'>
+) => {
+  const props = {
+    ...propsRaw,
+    onCancel: () => {
+      destroy && destroy()
+    }
+  } as P
+
+  const destroy = renderCompoent(comp, props)
+}

+ 37 - 0
src/helper/mount.ts

@@ -0,0 +1,37 @@
+import { createVNode, render } from 'vue'
+import type { App, VNode } from 'vue'
+
+export type MountContext<P = any> = {
+  props?: P
+  children?: unknown
+  element?: HTMLElement
+  app?: App
+}
+export function mount<P>(
+  component: ComponentConstructor<P>,
+  { props, children, element, app }: MountContext<P> = {}
+) {
+  let el = element
+  let vNode: VNode | undefined = createVNode(component, props as any, children)
+
+  if (app && app._context) vNode.appContext = app._context
+  if (el) {
+    render(vNode, el)
+    console.log('render', el)
+  } else if (typeof document !== 'undefined') {
+    render(vNode, (el = document.createElement('div')))
+  }
+
+  const destroy = () => {
+    if (el) render(null, el)
+    el = undefined
+    vNode = undefined
+  }
+
+  return { vNode, destroy, el }
+}
+
+export const genMount =
+  (app: App) =>
+  <P>(component: ComponentConstructor<P>, context: MountContext<P>) =>
+    mount(component, { ...context, app })

+ 4 - 0
src/hook/index.ts

@@ -0,0 +1,4 @@
+export * from './useVisible'
+export * from './useProject'
+export * from './useRealtime'
+export * from './usePaging'

+ 22 - 0
src/hook/useActive.ts

@@ -0,0 +1,22 @@
+import {
+  onActivated,
+  onMounted,
+  onDeactivated,
+  onBeforeUnmount,
+  ref
+} from 'vue'
+import { onBeforeRouteLeave } from 'vue-router'
+
+export const useActive = () => {
+  const active = ref(true)
+
+  onMounted(() => (active.value = true))
+  onActivated(() => (active.value = true))
+  onBeforeRouteLeave(() => {
+    active.value = false
+  })
+  onDeactivated(() => (active.value = false))
+  onBeforeUnmount(() => (active.value = false))
+
+  return active
+}

+ 39 - 0
src/hook/usePaging.ts

@@ -0,0 +1,39 @@
+import { useRealtime } from './useRealtime'
+import { reactive, watch, isProxy } from 'vue'
+import { useActive } from './useActive'
+
+import type { PageRequest, PageResult } from '@/api'
+
+export function usePaging<P extends {}, A>(
+  fetch: (p: PageRequest<P>) => Promise<PageResult<A>>,
+  initial: P,
+  setting?: { pageSize?: number; auto?: boolean }
+) {
+  const pagination = reactive({
+    current: 1,
+    total: 0,
+    showSizeChanger: false,
+    pageSize: setting?.pageSize || 12,
+    onChange: (current: number) => {
+      pagination.current = current
+      updateList()
+    }
+  })
+
+  const [list, updateList] = useRealtime(async () => {
+    const result = await fetch({
+      ...initial,
+      pageNum: pagination.current,
+      pageSize: pagination.pageSize
+    })
+    pagination.total = result.total
+    return result.list
+  }, [])
+
+  if (!setting || !('auto' in setting) || setting?.auto) {
+    const active = useActive()
+    isProxy(initial) && watch(initial, () => active.value && updateList())
+  }
+
+  return { list, pagination, updateList }
+}

+ 20 - 0
src/hook/useProject.ts

@@ -0,0 +1,20 @@
+import { fetchProject } from '@/api'
+import { router } from '@/router'
+import { useRealtime } from './useRealtime'
+
+import type { Project } from '@/api'
+
+export const useProject = (
+  id: Project['projectId'],
+  onErr?: (err: any) => void
+) => useRealtime(() => fetchProject(id).catch(onErr))[0]
+
+export const useCurrentProject = () => {
+  const id = Number(router.currentRoute.value.params.id)
+  const back = () => router.back()
+  if (!id || id < 0) {
+    back()
+    throw '错误页面'
+  }
+  return useProject(id, back)
+}

+ 27 - 0
src/hook/useRealtime.ts

@@ -0,0 +1,27 @@
+import { ref, watch } from 'vue'
+import { useActive } from './useActive'
+
+import type { Ref } from 'vue'
+
+type Result<R> = [state: Ref<R>, update: () => void]
+export function useRealtime<R>(fun: () => Promise<R>): Result<R | void>
+export function useRealtime<R>(fun: () => Promise<R>, initial: R): Result<R>
+export function useRealtime<R>(fun: () => R): Result<R | void>
+export function useRealtime<R>(fun: () => R, initial: R): Result<R>
+export function useRealtime<R>(fun: () => Promise<R> | R, initial?: R) {
+  const state = ref<R>()
+  state.value = initial
+
+  const update = () => {
+    const result = fun()
+    if (result instanceof Promise) {
+      result.then(newState => (state.value = newState))
+    } else {
+      state.value = result
+    }
+  }
+
+  const active = useActive()
+  watch(active, () => active.value && update(), { immediate: true })
+  return [state, update]
+}

+ 40 - 0
src/hook/useVisible.ts

@@ -0,0 +1,40 @@
+import { computed, isRef, watchEffect, ref } from 'vue'
+import { throttle, isVisible } from '@/shared'
+import type { Ref } from 'vue'
+
+export type VisibleEl = Ref<HTMLElement | undefined | null> | HTMLElement
+
+const getEl = (target: VisibleEl) => (isRef(target) ? target.value : target)
+
+export type UseVisibleProps = {
+  target: VisibleEl
+  parent?: VisibleEl
+  bound?: number
+}
+export const useVisible = ({ target, parent, bound = 0 }: UseVisibleProps) => {
+  const visible = ref(true)
+  const targetComputed = computed(() => getEl(target))
+  const parentComputed = computed(() =>
+    parent ? getEl(parent) : targetComputed.value?.parentElement
+  )
+
+  watchEffect(onCleanup => {
+    if (!targetComputed.value || !parentComputed.value) {
+      return
+    }
+    const parent = parentComputed.value
+    const target = targetComputed.value
+    const scrollHandler = throttle(() => {
+      visible.value = isVisible({ parent, target, bound })
+    }, 500)
+
+    scrollHandler()
+    parent.addEventListener('scroll', scrollHandler)
+
+    onCleanup(() => {
+      console.log('清除清除')
+      parent.removeEventListener('scroll', scrollHandler)
+    })
+  })
+  return visible
+}

+ 127 - 0
src/layout/header.vue

@@ -0,0 +1,127 @@
+<template>
+  <a-layout-header class="header-layout">
+    <div class="route-paths">
+      <span
+        v-for="meta in metas"
+        :key="meta.name"
+        :class="{ active: router.currentRoute.value.name === meta.name }"
+        @click="routeChange(meta.name)"
+      >
+        {{ meta.title }}
+      </span>
+    </div>
+    <a-dropdown placement="bottomRight">
+      <template #overlay>
+        <a-menu style="width: 100px" @click="handlerMenuClick">
+          <a-menu-item v-for="menu in menus" :key="menu.key">
+            {{ menu.label }}
+          </a-menu-item>
+        </a-menu>
+      </template>
+      <div class="avatar">
+        <a-avatar :size="32">
+          <template #icon>
+            <img :src="userStore.current.avatar" />
+          </template>
+        </a-avatar>
+        <span>
+          {{ userStore.current.phone }}
+          <DownOutlined />
+        </span>
+      </div>
+    </a-dropdown>
+  </a-layout-header>
+</template>
+
+<script lang="ts" setup>
+import { MenuProps } from 'ant-design-vue'
+import { useUserStore } from '@/store'
+import { gotoLogin } from '@/api'
+import { router, getRouteTreePaths, routesMetas, RoutesName } from '@/router'
+import { computed } from 'vue'
+
+defineOptions({ name: 'LayoutHeader' })
+
+const userStore = useUserStore()
+const metas = computed(() => {
+  const ignores = [RoutesName.project]
+  const paths = getRouteTreePaths()
+  return paths
+    .filter(name => !ignores.includes(name))
+    .map(name => ({ name, title: routesMetas[name].title }))
+})
+
+const routeChange = (routeName: RoutesName) => {
+  router.push({
+    name: routeName
+    // params: router.currentRoute.value.params
+  })
+}
+
+const menus = [{ label: '退出', key: 'logout' }]
+const handlerMenuClick: MenuProps['onClick'] = async e => {
+  if (e.key === 'logout') {
+    await userStore.logout()
+    gotoLogin()
+  }
+}
+
+userStore.fetch()
+</script>
+
+<style lang="scss" scoped>
+.header-layout {
+  background-color: #fff;
+  display: flex;
+  padding: 21px 24px;
+  justify-content: space-between;
+  align-items: center;
+  border-bottom: 1px solid rgba(0, 0, 0, 0.1);
+}
+
+.avatar {
+  display: flex;
+  align-items: center;
+  cursor: pointer;
+  > span {
+    margin-left: 8px;
+  }
+}
+
+.route-paths {
+  margin-right: 20px;
+
+  span {
+    color: #8c8c8c;
+    font-size: 14px;
+    padding: 0 11px;
+    position: relative;
+    display: inline-block;
+    transition: color 0.3s ease;
+
+    &:hover,
+    &.active {
+      color: #646566;
+    }
+
+    &:not(.active) {
+      cursor: pointer;
+    }
+
+    &:not(:last-child)::after {
+      content: '/';
+      position: absolute;
+      right: 0;
+      top: 0;
+    }
+
+    &:first-child {
+      padding-left: 0;
+    }
+
+    &:last-child {
+      padding-right: 0;
+    }
+  }
+}
+</style>

+ 49 - 0
src/layout/main.vue

@@ -0,0 +1,49 @@
+<template>
+  <a-layout class="layout">
+    <LayoutSider />
+    <a-layout>
+      <LayoutHeader />
+      <a-layout-content>
+        <div ref="contentRef" class="content">
+          <RouterView v-slot="{ Component }">
+            <KeepAlive>
+              <component :is="Component" />
+            </KeepAlive>
+          </RouterView>
+        </div>
+      </a-layout-content>
+    </a-layout>
+  </a-layout>
+</template>
+
+<script lang="ts">
+import { defineComponent, ref } from 'vue'
+import LayoutHeader from '@/layout/header.vue'
+import LayoutSider from '@/layout/sider.vue'
+
+export const contentRef = ref<HTMLDivElement>()
+
+export default defineComponent({
+  name: 'App',
+  components: {
+    LayoutHeader,
+    LayoutSider
+  },
+  setup() {
+    return { contentRef }
+  }
+})
+</script>
+
+<style lang="scss" scoped>
+.content,
+.layout {
+  height: 100%;
+}
+
+.content {
+  --padding: 24px;
+  display: flex;
+  flex-direction: column;
+}
+</style>

+ 51 - 0
src/layout/panl/index.ts

@@ -0,0 +1,51 @@
+import { Fragment, h } from 'vue'
+import style from './style.module.scss'
+
+import type { FunctionalComponent, VNode } from 'vue'
+
+export const HeadPanl: FunctionalComponent = (_, ctx) =>
+  h(
+    'div',
+    { class: style['header-panl'] },
+    ctx.slots.default && ctx.slots.default()
+  )
+
+export const BodyPanlHeader: FunctionalComponent = (_, ctx) =>
+  h(
+    'div',
+    { class: style['content-header'] },
+    ctx.slots.default && ctx.slots.default()
+  )
+export const BodyPanlBody: FunctionalComponent = (_, ctx) =>
+  h(
+    'div',
+    { class: style['content-body'] },
+    ctx.slots.default && ctx.slots.default()
+  )
+
+export const BodyPanl: FunctionalComponent<{ title?: string }> = (
+  props,
+  ctx
+) => {
+  let header: VNode[] | VNode | undefined
+  if (props.title || ctx.slots.header) {
+    header = props.title
+      ? h(BodyPanlHeader, null, () =>
+          h(Fragment, null, [
+            h('h3', null, props.title),
+            ctx.slots.action && ctx.slots.action()
+          ])
+        )
+      : ctx.slots.header && ctx.slots.header()
+  }
+  return h(
+    'div',
+    { class: style['content-panl'] },
+    h(Fragment, null, [
+      header,
+      header
+        ? h(BodyPanlBody, null, ctx.slots.default)
+        : ctx.slots.default && ctx.slots.default()
+    ])
+  )
+}

+ 29 - 0
src/layout/panl/style.module.scss

@@ -0,0 +1,29 @@
+.header-panl {
+  flex: none;
+  padding: var(--padding);
+  background-color: #fff;
+}
+
+.content-panl {
+  margin: var(--padding);
+  flex: 1;
+  overflow-y: auto;
+  background-color: #fff;
+}
+
+.content-header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 21px var(--padding);
+  border-bottom: 1px solid #e9e9e9;
+  h3 {
+    font-size: 16px;
+    color: #323233;
+    margin: 0;
+  }
+}
+
+.content-body {
+  padding: var(--padding);
+}

+ 93 - 0
src/layout/sider.vue

@@ -0,0 +1,93 @@
+<template>
+  <a-layout-sider class="sider">
+    <h2>项目管理系统</h2>
+
+    <a-menu
+      mode="inline"
+      theme="dark"
+      :selected-keys="selectedNames"
+      class="sider-menu"
+      @select="selectMenu"
+    >
+      <a-menu-item v-for="menu in menus" :key="menu" class="sider-menu-item">
+        <template #icon>
+          <i class="iconfont" :class="routesMetas[menu].icon" />
+        </template>
+        <span>{{ routesMetas[menu].title }}</span>
+      </a-menu-item>
+    </a-menu>
+  </a-layout-sider>
+</template>
+
+<script setup lang="ts">
+import { RoutesName, routesMetas } from '@/router'
+import { computed } from 'vue'
+import { router, getRouteTreePaths } from '@/router'
+
+import type { MenuProps } from 'ant-design-vue/es/menu'
+
+defineOptions<{ name: 'layout-sider' }>()
+
+type MenuName = typeof menus[number]
+
+const menus = [RoutesName.personal, RoutesName.projects] as const
+const selectMenu: MenuProps['onSelect'] = ({ key }) => {
+  router.push({ name: key as RoutesName })
+}
+
+const selectedNames = computed(() => {
+  const routeName = router.currentRoute.value.name as MenuName
+  return getRouteTreePaths(routeName) || []
+})
+</script>
+
+<style scoped lang="scss">
+.sider {
+  background-color: #001529;
+}
+h2 {
+  background: #002140;
+  margin: 0;
+  padding: 17px 0;
+  font-size: 22px;
+  font-weight: bold;
+  color: #fff;
+  text-align: center;
+}
+</style>
+
+<style lang="scss">
+.sider {
+  background-color: #001529;
+
+  .sider-menu {
+    .sider-menu-item {
+      margin-top: 0;
+      margin-bottom: 0;
+      height: 60px;
+      line-height: 60px;
+      position: relative;
+      transition: all 0.3s ease;
+
+      &::after {
+        content: '';
+        position: absolute;
+        width: 0px;
+        top: 0;
+        bottom: 0;
+        right: 0;
+        background-color: #00b3ec;
+        transition: width 0.3s ease;
+      }
+
+      &.ant-menu-item-selected {
+        background-color: rgba(24, 144, 255, 0.05);
+
+        &::after {
+          width: 4px;
+        }
+      }
+    }
+  }
+}
+</style>

+ 58 - 0
src/layout/system.vue

@@ -0,0 +1,58 @@
+<template>
+  <a-layout class="layout">
+    <div class="content">
+      <div class="sys">
+        <h1>四维项目管理系统</h1>
+        <p>Project Management Systems</p>
+      </div>
+      <div class="action">
+        <RouterView v-slot="{ Component }">
+          <KeepAlive>
+            <component :is="Component" />
+          </KeepAlive>
+        </RouterView>
+      </div>
+    </div>
+  </a-layout>
+</template>
+
+<style scoped lang="scss">
+.layout {
+  width: 100%;
+  height: 100%;
+  background: #275ec4 url(@/assets/images/bg.png) no-repeat center center;
+  background-size: cover;
+  display: flex;
+  align-items: center;
+  flex-direction: row;
+  justify-content: center;
+}
+
+.content {
+  padding: 0 18.75%;
+  flex: 1;
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+}
+
+.sys h1 {
+  font-size: 48px;
+  color: #fff;
+  margin-bottom: 10px;
+}
+
+.sys p {
+  font-weight: 400;
+  font-size: 26px;
+  color: #ffffff;
+}
+
+.action {
+  flex: none;
+  padding: 40px 40px 33px;
+  background-color: #fff;
+  box-shadow: 0px 2px 20px 0px rgba(5, 38, 38, 0.15);
+  border-radius: 10px;
+}
+</style>

+ 12 - 0
src/main.ts

@@ -0,0 +1,12 @@
+import { createApp } from 'vue'
+import '@/assets/iconfont/iconfont.css'
+import 'ant-design-vue/lib/message/style/index.less'
+import './style.scss'
+import App from './App.vue'
+import router from './router'
+import store from './store'
+
+export const app = createApp(App)
+app.use(router)
+app.use(store)
+app.mount('#app')

+ 56 - 0
src/router/config.ts

@@ -0,0 +1,56 @@
+import { RoutesName, routesPaths } from './constant'
+
+export type RouteRaw = typeof routes[number]
+export const routes = [
+  {
+    path: '/',
+    name: 'main-layout',
+    component: () => import('@/layout/main.vue'),
+    children: [
+      {
+        path: routesPaths[RoutesName.personal],
+        name: RoutesName.personal,
+        component: () => import('@/views/personal/index.vue')
+      },
+      {
+        path: routesPaths[RoutesName.projects],
+        name: RoutesName.projects,
+        component: () => import('@/views/project/list.vue')
+      },
+      {
+        path: routesPaths[RoutesName.project],
+        name: RoutesName.project,
+        component: () => import('@/views/project/detailed.vue'),
+        children: [
+          {
+            path: routesPaths[RoutesName.projectMaterial],
+            name: RoutesName.projectMaterial,
+            component: () => import('@/views/material/list.vue')
+          },
+          {
+            path: routesPaths[RoutesName.projectMembers],
+            name: RoutesName.projectMembers,
+            component: () => import('@/views/member/list.vue')
+          },
+          {
+            path: routesPaths[RoutesName.projectScenes],
+            name: RoutesName.projectScenes,
+            component: () => import('@/views/scene/list.vue')
+          }
+        ]
+      }
+    ]
+  },
+  {
+    path: '/',
+    name: 'system-layout',
+    component: () => import('@/layout/system.vue'),
+    children: [
+      {
+        path: routesPaths[RoutesName.login],
+        name: RoutesName.login,
+        component: () => import('@/views/system/login.vue')
+      }
+    ]
+  }
+]

+ 65 - 0
src/router/constant.ts

@@ -0,0 +1,65 @@
+export enum RoutesName {
+  login = 'login',
+  personal = 'personal',
+  projects = 'projects',
+  project = 'project',
+  projectScenes = 'projectScenes',
+  projectMembers = 'projectMembers',
+  projectMaterial = 'projectInfos'
+}
+
+export const routesPaths = {
+  [RoutesName.login]: 'login',
+  [RoutesName.personal]: 'personal',
+  [RoutesName.projects]: 'projects',
+
+  [RoutesName.project]: 'project/:id',
+  [RoutesName.projectScenes]: 'scenes',
+  [RoutesName.projectMembers]: 'members',
+  [RoutesName.projectMaterial]: 'infos'
+}
+
+export const routesMetas = {
+  [RoutesName.login]: {
+    title: '登录'
+  },
+  [RoutesName.personal]: {
+    title: '个人信息',
+    icon: ''
+  },
+  [RoutesName.projects]: {
+    title: '项目管理',
+    icon: ''
+  },
+  [RoutesName.projectScenes]: {
+    title: '场景管理'
+  },
+  [RoutesName.projectMaterial]: {
+    title: '项目资料'
+  },
+  [RoutesName.projectMembers]: {
+    title: '成员管理'
+  },
+  [RoutesName.project]: {
+    title: '项目'
+  }
+}
+
+export type RouteTree = { name: RoutesName; children?: RouteTree[] }
+
+export const routeTrees: RouteTree[] = [
+  { name: RoutesName.personal },
+  {
+    name: RoutesName.projects,
+    children: [
+      {
+        name: RoutesName.project,
+        children: [
+          { name: RoutesName.projectMaterial },
+          { name: RoutesName.projectMembers },
+          { name: RoutesName.projectScenes }
+        ]
+      }
+    ]
+  }
+]

+ 38 - 0
src/router/index.ts

@@ -0,0 +1,38 @@
+import { createRouter, createWebHashHistory } from 'vue-router'
+import { routes } from './config'
+import { computed } from 'vue'
+import { routesMetas, routeTrees } from './constant'
+
+import type { RoutesName } from './constant'
+
+export const history = createWebHashHistory()
+export const router = createRouter({ history, routes })
+
+export const getRouteTreePaths = (
+  name: RoutesName = router.currentRoute.value.name as RoutesName,
+  trees = routeTrees
+): RoutesName[] => {
+  for (const tree of trees) {
+    if (tree.name === name) {
+      return [tree.name]
+    } else if (tree.children) {
+      const childTreePaths = getRouteTreePaths(name, tree.children)
+      if (childTreePaths.length) {
+        return [tree.name, ...childTreePaths]
+      }
+    }
+  }
+  return []
+}
+
+export const currentMeta = computed(() => {
+  const currentName = router.currentRoute.value.name as RoutesName
+  if (currentName && currentName in routesMetas) {
+    return routesMetas[currentName]
+  }
+})
+
+export * from './config'
+export * from './constant'
+
+export default router

+ 36 - 0
src/shared/copy.ts

@@ -0,0 +1,36 @@
+export const copyText = async (
+  text: string,
+  fallback?: boolean
+): Promise<void> => {
+  if (navigator.clipboard && !fallback) {
+    const permiss = await navigator.permissions.query({ name: 'geolocation' })
+    permiss.state === 'denied'
+
+    if (permiss && permiss.state === 'denied') {
+      console.error(permiss)
+      throw new Error('请授予写入粘贴板权限!')
+    } else {
+      try {
+        await navigator.clipboard.writeText(text)
+      } catch (e) {
+        console.error('不支持navigator.clipboard.writeText 开启回退')
+        return await copyText(text, true)
+      }
+    }
+  } else {
+    const textarea = document.createElement('textarea')
+    document.body.appendChild(textarea)
+    // 隐藏此输入框
+    textarea.style.position = 'fixed'
+    textarea.style.clip = 'rect(0 0 0 0)'
+    textarea.style.top = '10px'
+    // 赋值
+    textarea.value = text
+    // 选中
+    textarea.select()
+    // 复制
+    document.execCommand('copy', true)
+    // 移除输入框
+    document.body.removeChild(textarea)
+  }
+}

+ 24 - 0
src/shared/diff.ts

@@ -0,0 +1,24 @@
+export const diffArrayChange = <T extends Array<any>>(
+  newItems: T,
+  oldItems: T
+) => {
+  const addedItems = [] as unknown as T
+  const deletedItems = [] as unknown as T
+
+  for (const item of newItems) {
+    if (!oldItems.includes(item)) {
+      addedItems.push(item)
+    }
+  }
+
+  for (const item of oldItems) {
+    if (!newItems.includes(item)) {
+      deletedItems.push(item)
+    }
+  }
+
+  return {
+    added: addedItems,
+    deleted: deletedItems
+  }
+}

+ 15 - 0
src/shared/el.ts

@@ -0,0 +1,15 @@
+export type IsVisibleProps = {
+  target: HTMLElement
+  parent?: HTMLElement
+  bound?: number
+}
+export const isVisible = ({
+  target,
+  parent = document.body,
+  bound = 0
+}: IsVisibleProps) => {
+  const parentRect = parent.getBoundingClientRect()
+  const targetRect = target.getBoundingClientRect()
+  const targetTop = targetRect.top - parentRect.top
+  return targetTop + targetRect.height > bound
+}

+ 70 - 0
src/shared/index.ts

@@ -0,0 +1,70 @@
+export * from './params'
+export * from './copy'
+export * from './diff'
+export * from './el'
+// export * from './test'
+export * from './meta'
+
+export const throttle = <Args extends any[]>(
+  fn: (...args: Args) => void,
+  deley: number
+) => {
+  let valib = false
+  let lastCtx: { self: any; args: Args } | null = null
+
+  return function (this: any, ...args: Args) {
+    lastCtx = {
+      args,
+      self: this
+    }
+    if (valib) {
+      return
+    }
+    const currentCtx = lastCtx
+    valib = true
+
+    setTimeout(() => {
+      fn.apply(currentCtx.self, currentCtx.args)
+      currentCtx !== lastCtx && fn.apply(lastCtx!.self, lastCtx!.args)
+      valib = false
+    }, deley)
+  }
+}
+
+type BatchAsyncFn<Args extends any[] = any[], Result = any> = (
+  ...args: Args
+) => Promise<Result>
+
+export const batchFetchPick = (max: number) => {
+  const tasks: (Promise<any> | null)[] = []
+  let workTaskCount = 0
+
+  return <Args extends any[], Result>(
+    fetchFunction: BatchAsyncFn<Args, Result>
+  ) => {
+    return function fetch(this: any, ...args: Args): Promise<Result> {
+      if (workTaskCount >= max) {
+        const getResult = () => fetch.apply(this, args)
+        return Promise.race(tasks).then(getResult).catch(getResult)
+      } else {
+        workTaskCount = tasks.length + 1
+        const result = fetchFunction.apply(this, args)
+        const pickResult = result.finally(() => {
+          const currentIndex = tasks.indexOf(pickResult)
+          ~currentIndex && tasks.splice(currentIndex, 1)
+          workTaskCount = tasks.length
+        })
+        tasks.push(pickResult)
+        return result
+      }
+    }
+  }
+}
+
+export const jsonToForm = (data: { [key in string]: any }) => {
+  const formData = new FormData()
+  for (const [key, val] of Object.entries(data)) {
+    formData.append(key, val)
+  }
+  return formData
+}

+ 8 - 0
src/shared/meta.ts

@@ -0,0 +1,8 @@
+export const getExtname = (url: string) => {
+  const index = url.lastIndexOf('.')
+  if (~index) {
+    return url.substring(index + 1)
+  } else {
+    return null
+  }
+}

+ 37 - 0
src/shared/mount.ts

@@ -0,0 +1,37 @@
+import { createVNode, render } from 'vue'
+import type { App, VNode } from 'vue'
+
+export type MountContext<P = any> = {
+  props?: P
+  children?: unknown
+  element?: HTMLElement
+  app?: App
+}
+export function mount<P>(
+  component: ComponentConstructor<P>,
+  { props, children, element, app }: MountContext<P> = {}
+) {
+  let el = element
+  let vNode: VNode | undefined = createVNode(component, props as any, children)
+
+  if (app && app._context) vNode.appContext = app._context
+  if (el) {
+    render(vNode, el)
+    console.log('render', el)
+  } else if (typeof document !== 'undefined') {
+    render(vNode, (el = document.createElement('div')))
+  }
+
+  const destroy = () => {
+    if (el) render(null, el)
+    el = undefined
+    vNode = undefined
+  }
+
+  return { vNode, destroy, el }
+}
+
+export const genMount =
+  (app: App) =>
+  <P>(component: ComponentConstructor<P>, context: MountContext<P>) =>
+    mount(component, { ...context, app })

+ 17 - 0
src/shared/params.ts

@@ -0,0 +1,17 @@
+// 字符串转params对象
+export const strToParams = (str: string) => {
+  if (str[0] === '?') {
+    str = str.substr(1)
+  }
+
+  const result: { [key: string]: string } = {}
+  const splitRG = /([^=&]+)(?:=([^&]*))?&?/
+
+  let rgRet
+  while ((rgRet = str.match(splitRG))) {
+    result[rgRet[1]] = rgRet[2] === undefined ? '' : rgRet[2]
+    str = str.substr(rgRet[0].length)
+  }
+
+  return result
+}

+ 103 - 0
src/shared/test.ts

@@ -0,0 +1,103 @@
+
+export const arr = [
+  {
+    id: 2,
+    name: '部门B',
+    parentId: 0
+  },
+  {
+    id: 3,
+    name: '部门C',
+    parentId: 1
+  },
+  {
+    id: 1,
+    name: '部门A',
+    parentId: 2
+  },
+  {
+    id: 4,
+    name: '部门D',
+    parentId: 1
+  },
+  {
+    id: 5,
+    name: '部门E',
+    parentId: 2
+  },
+  {
+    id: 6,
+    name: '部门F',
+    parentId: 3
+  },
+  {
+    id: 7,
+    name: '部门G',
+    parentId: 2
+  },
+  {
+    id: 8,
+    name: '部门H',
+    parentId: 4
+  }
+]
+
+type Item = typeof arr[number]
+type ItemTree = Item & { children: ItemTree[] }
+
+const normalItem = (item: Item | ItemTree): ItemTree => {
+  if ('children' in item && Array.isArray(item.children)) {
+    return item
+  } else {
+    return { ...item, children: [] }
+  }
+}
+
+const toTrees = (items: Item[]) => {
+  const map = new Map<number, ItemTree>()
+  for (const item of items) {
+    map.set(item.id, normalItem(item))
+  }
+
+  const roots: ItemTree[] = []
+  const rootTreeAddNode = (itemTree: ItemTree) => {
+    const parent = map.get(itemTree.parentId)
+    if (parent) {
+      parent.children.push(itemTree)
+    } else {
+      roots.push(itemTree)
+    }
+    return !parent
+  }
+  for (const itemTree of map.values()) {
+    rootTreeAddNode(itemTree)
+  }
+
+
+  return {
+    roots,
+    addItem(item: Item) {
+      if (map.has(item.id)) {
+        return;
+      }
+      const itemTree = normalItem(item)
+      map.set(item.id, itemTree)
+      if (rootTreeAddNode(itemTree)) {
+        const oldRoots = roots.slice()
+        roots.length = 0;
+        oldRoots.forEach(rootTreeAddNode)
+      }
+    },
+    delItem(item: Item) {
+      const itemTree = map.get(item.id)
+      if (!itemTree) {
+        return;
+      }
+      const sameTrees = map.get(itemTree.parentId)?.children || roots
+      const index = sameTrees.indexOf(itemTree)
+      if (index !== -1) {
+        sameTrees.splice(index, 1)
+      }
+    }
+  }
+}

+ 1 - 0
src/store/constant.ts

@@ -0,0 +1 @@
+export const TemplateId = -1

+ 9 - 0
src/store/index.ts

@@ -0,0 +1,9 @@
+import { createPinia } from 'pinia'
+
+export * from './user'
+export * from './scene'
+export * from './project'
+export * from './constant'
+
+export const pinia = createPinia()
+export default pinia

+ 155 - 0
src/store/project.ts

@@ -0,0 +1,155 @@
+import { defineStore } from 'pinia'
+import {
+  fetchProject,
+  uploadProjectBimScene,
+  relaceProjectScenes,
+  SceneType,
+  deleteProject,
+  finishProject,
+  updateProject,
+  deleteProjectBimScene,
+  BimStatus,
+  deleteProjectScene,
+  updateProjectBimSceneName
+} from '@/api'
+
+import type {
+  Project,
+  Scene,
+  BimUploadData,
+  UpdateProjectData,
+  Bim
+} from '@/api'
+
+export const BinType = Symbol('bin')
+export const binTypeDesc = 'bin'
+export type { Project, BimUploadData, Bim }
+
+export type ProjectScene = Omit<Scene, 'type' | 'num' | 'status'> &
+  (
+    | {
+        type: Scene['type']
+        status: Scene['status']
+        num: Scene['num']
+      }
+    | {
+        type: typeof BinType
+        status: BimStatus
+      }
+  )
+export type SelectTypeScenes = {
+  [SceneType.SWKJ]: Scene['num'][]
+  [SceneType.SWKK]: Scene['num'][]
+  [SceneType.SWSS]: Scene['num'][]
+}
+
+type CurrentState = null | Project
+export const useProject = defineStore('project', {
+  state: () => ({
+    current: null as CurrentState
+  }),
+  getters: {
+    scenes: state => {
+      const scenes: ProjectScene[] = state.current?.sceneList || []
+      const bimData = state.current?.bimData
+      if (bimData) {
+        return [
+          {
+            id: bimData.bimId,
+            name: bimData.bimName,
+            title: bimData.bimName,
+            sceneName: bimData.bimName,
+            thumb: bimData.bimOssFilePath,
+            createTime: bimData.createTime,
+            type: BinType,
+            status: bimData.bimStatus,
+            phone: ''
+          },
+          ...scenes
+        ]
+      } else {
+        return scenes
+      }
+    },
+    isSelf: state => (id?: Project['projectId']) =>
+      state.current?.projectId === id
+  },
+  actions: {
+    async setCurrent(id: Project['projectId']) {
+      this.current = await fetchProject(id)
+    },
+    async updateCurrent(id?: Project['projectId']) {
+      if ((id = id || this.current?.projectId)) {
+        this.isSelf(id) && (await this.setCurrent(id))
+      }
+    },
+    async delete(id?: Project['projectId']) {
+      if ((id = id || this.current?.projectId)) {
+        await deleteProject(id)
+        await this.updateCurrent(id)
+      }
+    },
+    async update(data: PartialPart<UpdateProjectData, 'projectId'>) {
+      const id = data.projectId || this.current?.projectId
+      if (id) {
+        await updateProject({
+          ...data,
+          projectId: id
+        })
+        await this.updateCurrent(id)
+      }
+    },
+    async finish(id?: Project['projectId']) {
+      if ((id = id || this.current?.projectId)) {
+        await finishProject(id)
+        await this.updateCurrent(id)
+      }
+    },
+    async addBim(data: BimUploadData, id?: Project['projectId']) {
+      if ((id = id || this.current?.projectId)) {
+        await uploadProjectBimScene(id, data)
+        await this.updateCurrent(id)
+      }
+    },
+    async updateBimName(bimName: Bim['bimName'], bimId?: Bim['bimId']) {
+      if ((bimId = bimId || this.current?.bimData?.bimId)) {
+        await updateProjectBimSceneName({ bimId, bimName })
+        this.current?.bimData?.bimId === bimId && (await this.updateCurrent())
+      }
+    },
+    async deleteBim(bimId?: Bim['bimId']) {
+      if ((bimId = bimId || this.current?.bimData?.bimId)) {
+        await deleteProjectBimScene(bimId)
+        this.current?.bimData?.bimId === bimId && (await this.updateCurrent())
+      }
+    },
+    async replaceScenes(
+      typeScenes: SelectTypeScenes,
+      id?: Project['projectId']
+    ) {
+      if ((id = id || this.current?.projectId)) {
+        const sceneNumParam = Object.entries(typeScenes).map(
+          ([type, nums]) => ({
+            type: Number(type) as SceneType,
+            numList: nums
+          })
+        )
+        await relaceProjectScenes({ id, sceneNumParam })
+        await this.updateCurrent(id)
+      }
+    },
+    async deleteScene(num: Scene['num'], id?: Project['projectId']) {
+      if ((id = id || this.current?.projectId)) {
+        await deleteProjectScene({ projectId: id, num })
+        await this.updateCurrent(id)
+      }
+    }
+  }
+})
+
+export {
+  ProjectStatus,
+  ProjectStatusDesc,
+  BimStatus,
+  BimStatusDesc
+} from '@/api'

+ 21 - 0
src/store/scene.ts

@@ -0,0 +1,21 @@
+import { defineStore } from 'pinia'
+import { fetchScenes } from '@/api'
+import type { Scenes } from '@/api'
+
+export type { Scenes, Scene } from '@/api'
+export { SceneStatus, SceneType, SceneTypeDesc, sceneStatusDesc } from '@/api'
+
+export const useSceneStore = defineStore('scene', {
+  state: () => ({
+    list: [] as Scenes
+  }),
+  getters: {
+    getScenesByNums: state => (nums: string[]) =>
+      state.list.filter(scene => nums.includes(scene.num))
+  },
+  actions: {
+    async fetch() {
+      this.list = await fetchScenes()
+    }
+  }
+})

+ 55 - 0
src/store/user.ts

@@ -0,0 +1,55 @@
+import {
+  fetchUser,
+  fetchUserMeta,
+  postLogin,
+  postLogout,
+  setToken,
+  delToken
+} from '@/api'
+import { defineStore } from 'pinia'
+
+import type { User, UserMeta, LoginState } from '@/api'
+
+const defState = {
+  current: {
+    nickname: '游客',
+    phone: '',
+    email: '',
+    avatar: 'https://4dkk.4dage.com/head/18819272208/head_1662022947583.png'
+  } as User,
+  meta: {
+    projectCount: 0,
+    projectFileCount: 0,
+    projectSceneCount: 0,
+    projectOverCount: 0
+  } as UserMeta
+}
+
+export const useUserStore = defineStore('user', {
+  state: () => defState,
+  actions: {
+    async fetchUser() {
+      this.current = await fetchUser()
+    },
+    async fetchMeta() {
+      this.meta = await fetchUserMeta()
+      console.log(this.$state)
+    },
+    fetch() {
+      return Promise.all([this.fetchUser(), this.fetchMeta()])
+    },
+    async login(state: LoginState) {
+      const { token, user } = await postLogin({
+        phone: state.phone,
+        password: state.password
+      })
+      this.current = user
+      setToken(token)
+    },
+    async logout() {
+      await postLogout()
+      this.$state = defState
+      delToken()
+    }
+  }
+})

+ 43 - 0
src/style.scss

@@ -0,0 +1,43 @@
+#app {
+  height: 100%;
+}
+
+.content-layout { 
+  width: 100%;
+  max-width: 1460px;
+  margin: 0 auto;
+  padding: 0 90px;
+  position: relative;
+}
+
+body .ant-modal-confirm .ant-modal-body {
+  padding: 26px;
+}
+
+
+body .ant-dropdown {
+  min-width: auto !important;
+}
+
+.filter-form.ant-form-inline {
+  .ant-form-item {
+    flex: 1;
+    margin-right: 24px;
+  }
+  .ant-form-item.actions {
+    text-align: right;
+    margin-right: 0;
+  }
+  .ant-form-item-label > label {
+    color: #646566;
+  }
+}
+
+
+.table-actions a:not(:last-child) {
+  margin-right: 16px;
+}
+
+.warn {
+  color: #FA5555 !important;
+}

+ 30 - 0
src/views/material/columns.ts

@@ -0,0 +1,30 @@
+import type { Member } from '@/api'
+import type { ColumnsType } from 'ant-design-vue/es/table'
+
+export const materialColumns: ColumnsType<Member> = [
+  {
+    title: '序列',
+    dataIndex: 'teamId',
+    key: 'teamId'
+  },
+  {
+    title: '成员名称',
+    dataIndex: 'userName',
+    key: 'userName'
+  },
+  {
+    title: '账号',
+    dataIndex: 'userName',
+    key: 'userName'
+  },
+  {
+    title: '备注',
+    key: 'remark',
+    dataIndex: 'remark'
+  },
+  {
+    title: '添加时间',
+    dataIndex: 'createTime',
+    key: 'createTime'
+  }
+]

+ 93 - 0
src/views/material/edit.vue

@@ -0,0 +1,93 @@
+<template>
+  <a-modal
+    v-model:visible="visible"
+    :title="`${editMember.teamId ? '修改' : '新增'}项目成员`"
+    width="480px"
+    :after-close="onCancel"
+    @ok="saveHandler"
+  >
+    <template #footer>
+      <a-button class="action-bottom" size="middle" @click="visible = false">
+        取消
+      </a-button>
+      <a-button
+        class="action-bottom"
+        type="primary"
+        size="middle"
+        @click="saveHandler"
+      >
+        保存
+      </a-button>
+    </template>
+
+    <a-form
+      ref="fromRef"
+      :model="editMember"
+      class="form"
+      label-align="right"
+      :label-col="{ span: 5 }"
+    >
+      <a-form-item
+        name="userName"
+        label="账号"
+        :rules="[{ required: true, message: '请输入用户账号' }]"
+      >
+        <a-input
+          v-model:value="editMember.userName"
+          :disabled="!!editMember.teamId"
+          placeholder="请输入用户账号"
+        />
+      </a-form-item>
+      <a-form-item
+        name="remark"
+        label="备注"
+        :rules="[{ required: true, message: '请输入备注' }]"
+      >
+        <a-input v-model:value="editMember.remark" placeholder="请输入备注" />
+      </a-form-item>
+    </a-form>
+  </a-modal>
+</template>
+
+<script lang="ts" setup>
+import { ref, defineProps, toRaw } from 'vue'
+
+import type { Member } from '@/api'
+import type { FormInstance } from 'ant-design-vue'
+
+export type EditMember = PartialPart<Member, 'teamId'>
+
+defineOptions<{ name: 'edit-member' }>()
+
+const props = defineProps<{
+  member?: EditMember
+  onSave: (data: EditMember) => void
+  onCancel: () => void
+}>()
+
+const editMember = ref<EditMember>(
+  props.member ? { ...props.member } : { userName: '', remark: '' }
+)
+const fromRef = ref<FormInstance>()
+const visible = ref(true)
+
+const saveHandler = async () => {
+  await fromRef.value?.validate()
+  await props.onSave(toRaw(editMember.value))
+  visible.value = false
+}
+</script>
+
+<style lang="scss" scoped>
+.footer {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+
+  p {
+    margin-bottom: 0;
+    color: #646566;
+    font-size: 14px;
+  }
+}
+</style>

+ 93 - 0
src/views/material/list.vue

@@ -0,0 +1,93 @@
+<template>
+  <BodyPanlHeader>
+    <a-button type="primary" @click="addMaterial()">新建备注</a-button>
+    <div>
+      <a-input-search
+        v-model:value="filterName"
+        style="width: 280px"
+        placeholder="请输入笔记名称"
+        @search="updateMembers"
+      />
+    </div>
+  </BodyPanlHeader>
+
+  <BodyPanlBody>
+    <a-table
+      :data-source="materials"
+      :columns="materialColumns"
+      :pagination="pagination"
+    >
+      <template #bodyCell="{ column }">
+        <template v-if="column.key === 'action'">
+          <div class="table-actions">
+            <a>查看</a>
+          </div>
+        </template>
+      </template>
+    </a-table>
+  </BodyPanlBody>
+</template>
+
+<script lang="ts" setup>
+import EditMember from './edit.vue'
+import { ref, reactive, computed } from 'vue'
+import { BodyPanlHeader, BodyPanlBody } from '@/layout/panl'
+import { useRealtime } from '@/hook'
+import { router } from '@/router'
+import { renderModal } from '@/helper'
+import { materialColumns as baseColumns } from './columns'
+import {
+  fetchMembers,
+  checkMemberUserName,
+  addMember,
+  updateMember
+} from '@/api'
+
+import type { Member } from '@/api'
+
+const materialColumns = [
+  ...baseColumns,
+  {
+    title: '操作',
+    dataIndex: 'action',
+    key: 'action'
+  }
+]
+
+const filterName = ref('')
+const projectId = computed(() => Number(router.currentRoute.value.params.id))
+const pagination = reactive({
+  current: 1,
+  total: 0,
+  pageSize: 12,
+  onChange: (current: number) => {
+    pagination.current = current
+    updateMaterials()
+  }
+})
+
+const [materials, updateMaterials] = useRealtime(async () => {
+  const result = await fetchMembers({
+    userName: filterName.value,
+    projectId: projectId.value,
+    pageNum: pagination.current,
+    pageSize: pagination.pageSize
+  })
+  pagination.total = result.total
+  return result.list
+}, [])
+
+const addMaterial = () => {
+  renderModal(EditMember, {
+    async onSave(member) {
+      if (member.teamId) {
+        await updateMember(member as Member)
+      } else {
+        await checkMemberUserName(member.userName)
+        await addMember(projectId.value, member)
+      }
+      await updateMembers()
+    }
+  })
+}
+</script>

+ 30 - 0
src/views/member/columns.ts

@@ -0,0 +1,30 @@
+import type { Member } from '@/api'
+import type { ColumnsType } from 'ant-design-vue/es/table'
+
+export const memberColumns: ColumnsType<Member> = [
+  {
+    title: '序列',
+    dataIndex: 'teamId',
+    key: 'teamId'
+  },
+  {
+    title: '成员名称',
+    dataIndex: 'userName',
+    key: 'userName'
+  },
+  {
+    title: '账号',
+    dataIndex: 'userName',
+    key: 'userName'
+  },
+  {
+    title: '备注',
+    key: 'remark',
+    dataIndex: 'remark'
+  },
+  {
+    title: '添加时间',
+    dataIndex: 'createTime',
+    key: 'createTime'
+  }
+]

+ 93 - 0
src/views/member/edit.vue

@@ -0,0 +1,93 @@
+<template>
+  <a-modal
+    v-model:visible="visible"
+    :title="`${editMember.teamId ? '修改' : '新增'}项目成员`"
+    width="480px"
+    :after-close="onCancel"
+    @ok="saveHandler"
+  >
+    <template #footer>
+      <a-button class="action-bottom" size="middle" @click="visible = false">
+        取消
+      </a-button>
+      <a-button
+        class="action-bottom"
+        type="primary"
+        size="middle"
+        @click="saveHandler"
+      >
+        保存
+      </a-button>
+    </template>
+
+    <a-form
+      ref="fromRef"
+      :model="editMember"
+      class="form"
+      label-align="right"
+      :label-col="{ span: 5 }"
+    >
+      <a-form-item
+        name="userName"
+        label="账号"
+        :rules="[{ required: true, message: '请输入用户账号' }]"
+      >
+        <a-input
+          v-model:value="editMember.userName"
+          :disabled="!!editMember.teamId"
+          placeholder="请输入用户账号"
+        />
+      </a-form-item>
+      <a-form-item
+        name="remark"
+        label="备注"
+        :rules="[{ required: true, message: '请输入备注' }]"
+      >
+        <a-input v-model:value="editMember.remark" placeholder="请输入备注" />
+      </a-form-item>
+    </a-form>
+  </a-modal>
+</template>
+
+<script lang="ts" setup>
+import { ref, defineProps, toRaw } from 'vue'
+
+import type { Member } from '@/api'
+import type { FormInstance } from 'ant-design-vue'
+
+export type EditMember = PartialPart<Member, 'teamId'>
+
+defineOptions<{ name: 'edit-member' }>()
+
+const props = defineProps<{
+  member?: EditMember
+  onSave: (data: EditMember) => void
+  onCancel: () => void
+}>()
+
+const editMember = ref<EditMember>(
+  props.member ? { ...props.member } : { userName: '', remark: '' }
+)
+const fromRef = ref<FormInstance>()
+const visible = ref(true)
+
+const saveHandler = async () => {
+  await fromRef.value?.validate()
+  await props.onSave(toRaw(editMember.value))
+  visible.value = false
+}
+</script>
+
+<style lang="scss" scoped>
+.footer {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+
+  p {
+    margin-bottom: 0;
+    color: #646566;
+    font-size: 14px;
+  }
+}
+</style>

+ 99 - 0
src/views/member/list.vue

@@ -0,0 +1,99 @@
+<template>
+  <BodyPanlHeader>
+    <a-button type="primary" @click="setMember()">新增成员</a-button>
+    <div>
+      <a-input-search
+        v-model:value="params.userName"
+        style="width: 280px"
+        placeholder="请选择项目创建时间"
+        allow-clear
+        @search="updateList"
+      />
+    </div>
+  </BodyPanlHeader>
+
+  <BodyPanlBody>
+    <a-table
+      :data-source="list"
+      :columns="memberColumns"
+      :pagination="pagination"
+    >
+      <template #bodyCell="{ column, record }">
+        <template v-if="column.key === 'action'">
+          <div class="table-actions">
+            <a @click="setMember(record)">修改</a>
+            <!-- <a>查看</a> -->
+            <a class="warn" @click="delMemberHandler(record)">删除</a>
+          </div>
+        </template>
+      </template>
+    </a-table>
+  </BodyPanlBody>
+</template>
+
+<script lang="ts" setup>
+import EditMember from './edit.vue'
+import { computed, reactive, watchEffect } from 'vue'
+import { BodyPanlHeader, BodyPanlBody } from '@/layout/panl'
+import { usePaging } from '@/hook'
+import { router } from '@/router'
+import { renderModal } from '@/helper'
+import { memberColumns as baseColumns } from './columns'
+import { Modal } from 'ant-design-vue'
+import {
+  fetchMembers,
+  checkMemberUserName,
+  addMember,
+  updateMember,
+  deleteMember
+} from '@/api'
+
+import type { Member } from '@/api'
+
+const memberColumns = [
+  ...baseColumns,
+  {
+    title: '操作',
+    dataIndex: 'action',
+    key: 'action'
+  }
+]
+
+const params = reactive({
+  userName: '',
+  projectId: computed(() => Number(router.currentRoute.value.params.id))
+})
+
+const { list, pagination, updateList } = usePaging(fetchMembers, params, {
+  pageSize: 7
+})
+
+const setMember = (member?: Member) => {
+  renderModal(EditMember, {
+    member,
+    async onSave(member) {
+      if (member.teamId) {
+        await updateMember(member as Member)
+      } else {
+        await checkMemberUserName(member.userName)
+        await addMember(params.projectId, member)
+      }
+      await updateList()
+    }
+  })
+}
+const delMemberHandler = (member: Member) => {
+  Modal.confirm({
+    content: '确定要删除此用户?',
+    title: '系统提示',
+    width: '400px',
+    okText: '删除',
+    icon: null,
+    cancelText: '取消',
+    onOk: async () => {
+      await deleteMember(member.teamId)
+      await updateList()
+    }
+  })
+}
+</script>

+ 88 - 0
src/views/personal/index.vue

@@ -0,0 +1,88 @@
+<template>
+  <HeadPanl class="personal-head">
+    <div class="user">
+      <img :src="user.avatar" />
+      <div class="info">
+        <h4>早上好,{{ user.nickname }}</h4>
+        <div class="account">
+          <span>{{ user.phone }}</span>
+          <span>{{ user.email }}</span>
+        </div>
+        <div class="account-manage">
+          <a :href="userInfoLink">修改信息 ></a>
+          <a :href="userInfoLink">修改密码 ></a>
+        </div>
+      </div>
+    </div>
+    <Simples :data="simples" />
+  </HeadPanl>
+
+  <BodyPanl title="操作记录">
+    <RecordList />
+  </BodyPanl>
+</template>
+
+<script lang="ts" setup>
+import RecordList from '@/views/record/list.vue'
+import Simples from '@/components/simples/index.vue'
+import { HeadPanl, BodyPanl } from '@/layout/panl'
+import { toRefs } from 'vue'
+import { computed } from 'vue'
+import { useUserStore } from '@/store'
+import { userInfoLink } from '@/env'
+
+const { current: user, meta } = toRefs(useUserStore().$state)
+const simples = computed(() => [
+  { label: '项目数', value: meta.value.projectCount },
+  { label: '项目文件数', value: meta.value.projectFileCount },
+  { label: '项目场景数', value: meta.value.projectSceneCount },
+  { label: '已完成项目数', value: meta.value.projectOverCount }
+])
+</script>
+
+<style lang="scss" scoped>
+.personal-head {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+}
+
+.user {
+  display: flex;
+  img {
+    width: 104px;
+    height: 104px;
+    border: 1px solid rgba(0, 0, 0, 0.05);
+    flex: none;
+    margin-right: var(--padding);
+    display: block;
+    border-radius: 50%;
+  }
+}
+
+.info {
+  flex: 1;
+
+  h3 {
+    font-size: 20px;
+    margin-bottom: 10px;
+    color: #323233;
+  }
+}
+
+.account {
+  margin-bottom: 20px;
+  color: #666666;
+  font-size: 14px;
+  span {
+    margin-right: 14px;
+  }
+}
+.account-manage {
+  a {
+    font-size: 14px;
+    color: #0076f6;
+    margin-right: 30px;
+  }
+}
+</style>

+ 89 - 0
src/views/project/columns.ts

@@ -0,0 +1,89 @@
+import { h } from 'vue'
+import { ProjectStatusDesc, ProjectStatus } from '@/api'
+import { router, RoutesName } from '@/router'
+import { projectManage } from '@/env'
+
+import type { SimpleProject } from '@/api'
+import type { ColumnsType } from 'ant-design-vue/es/table'
+
+export const ProjectStatusComp = (props: SimpleProject) => {
+  const desc = ProjectStatusDesc[props.projectStatus]
+  const style = {
+    color: props.projectStatus === ProjectStatus.done ? '#04CE75' : '#323233',
+    fontSize: '14px'
+  }
+  return h('span', { style }, desc)
+}
+
+export const projectColumns: ColumnsType<SimpleProject> = [
+  {
+    title: '序列',
+    dataIndex: 'projectId',
+    key: 'projectId'
+  },
+  {
+    title: '封面',
+    dataIndex: 'projectImg',
+    key: 'projectImg',
+    customRender({ record }) {
+      const style = {
+        width: '60px',
+        height: '60px'
+      }
+      return h('img', { src: record.projectImg, style })
+    }
+  },
+  {
+    title: '项目名称',
+    dataIndex: 'projectName',
+    key: 'projectName'
+  },
+  {
+    title: '创建人',
+    dataIndex: 'projectCreater',
+    key: 'projectCreater'
+  },
+  {
+    title: '创建时间',
+    dataIndex: 'createTime',
+    key: 'createTime'
+  },
+  {
+    title: '更新时间',
+    dataIndex: 'updateTime',
+    key: 'updateTime'
+  },
+  {
+    title: '项目状态',
+    dataIndex: 'projectStatus',
+    key: 'projectStatus',
+    customRender: ({ record }) => ProjectStatusComp(record)
+  },
+  {
+    title: '操作',
+    dataIndex: 'projectId',
+    key: 'projectId',
+    customRender({ record }) {
+      const onManage = () => {
+        router.push({
+          name: RoutesName.projectScenes,
+          params: { id: record.projectId }
+        })
+      }
+      const onQuery = () => {
+        window.open(`${projectManage}?projectId=${record.projectId}`)
+      }
+
+      const renderQuery = h('a', { onClick: onQuery }, '查看')
+      const renderManage = h('a', { onClick: onManage }, '管理')
+
+      return h(
+        'div',
+        {
+          class: 'table-actions'
+        },
+        [renderManage, renderQuery]
+      )
+    }
+  }
+]

+ 162 - 0
src/views/project/detailed.vue

@@ -0,0 +1,162 @@
+<template>
+  <HeadPanl class="project-detail-header">
+    <div class="meta">
+      <div class="header">
+        <h3>{{ project?.projectName }}</h3>
+        <div class="actions">
+          <a-button @click="updateProject">修改项目</a-button>
+          <a-button @click="deleteProject">删除项目</a-button>
+          <a-button type="primary" @click="finishProject"> 完成项目 </a-button>
+        </div>
+      </div>
+      <div class="body">
+        <p class="desc">{{ project?.projectMsg }}</p>
+        <!-- <Simples :data="simples" /> -->
+      </div>
+
+      <a-tabs :active-key="activeTabName" @change="changeTab">
+        <a-tab-pane
+          v-for="option in tabOptions"
+          :key="option.key"
+          :tab="option.label"
+        />
+      </a-tabs>
+    </div>
+  </HeadPanl>
+
+  <BodyPanl>
+    <RouterView v-slot="{ Component }">
+      <KeepAlive>
+        <component :is="Component" />
+      </KeepAlive>
+    </RouterView>
+  </BodyPanl>
+</template>
+
+<script setup lang="ts">
+import Simples from '@/components/simples/index.vue'
+import EditProject from './edit.vue'
+import { Modal } from 'ant-design-vue'
+import { HeadPanl, BodyPanl } from '@/layout/panl'
+import { router, RoutesName, routesMetas } from '@/router'
+import { computed, toRef } from 'vue'
+import { useProject } from '@/store'
+import { useRealtime } from '@/hook'
+import { renderModal } from '@/helper'
+import { uploadFile } from '@/api'
+
+const simples = computed(() => [
+  { label: '信息', value: 2 },
+  { label: '待处理', value: 2 },
+  { label: '已解决', value: 2 },
+  { label: '未解决', value: 2 },
+  { label: '进行中', value: 2 }
+])
+
+const tabOptions = [
+  // RoutesName.projectMaterial,
+  RoutesName.projectScenes,
+  RoutesName.projectMembers
+].map(name => ({
+  key: name,
+  label: routesMetas[name].title
+}))
+const activeTabName = computed(
+  () => router.currentRoute.value.name as RoutesName
+)
+const changeTab = (name: any) => {
+  router.replace({ name, params: router.currentRoute.value.params })
+}
+
+const projectStore = useProject()
+const project = toRef(projectStore.$state, 'current')
+
+useRealtime(() => {
+  const id = Number(router.currentRoute.value.params.id)
+  const back = () => router.back()
+  if (!id || id < 0) {
+    back()
+    throw '错误页面'
+  }
+  return projectStore.setCurrent(id).catch(back)
+})
+const deleteProject = () => {
+  Modal.confirm({
+    content: '删除后无法恢复,是否确认?',
+    title: '删除项目',
+    width: '400px',
+    okText: '删除',
+    icon: null,
+    cancelText: '取消',
+    onOk: async () => {
+      await projectStore.delete()
+      router.replace({ name: RoutesName.projects })
+    }
+  })
+}
+const finishProject = async () => {
+  await projectStore.finish()
+  router.replace({ name: RoutesName.projects })
+}
+const updateProject = async () => {
+  renderModal(EditProject, {
+    project: projectStore.current!,
+    async onSave({ projectImg, bimFile, ...data }) {
+      const img =
+        typeof projectImg !== 'string'
+          ? await uploadFile(projectImg as File)
+          : projectImg
+
+      await projectStore.update({ ...data, projectImg: img })
+    }
+  })
+}
+</script>
+
+<style lang="scss" scoped>
+.project-detail-header {
+  padding-bottom: 0;
+}
+
+.header {
+  display: flex;
+  justify-content: space-between;
+  margin-bottom: 24px;
+  align-items: center;
+
+  h3 {
+    font-size: 20px;
+    color: #323233;
+    margin: 0;
+  }
+
+  .actions button {
+    margin-left: 16px;
+  }
+}
+
+.body {
+  display: flex;
+  justify-content: space-between;
+
+  .desc {
+    flex: 1;
+    max-width: 800px;
+    padding: 14px 10px;
+    background: #fafafa;
+    color: #646566;
+    line-height: 20px;
+    font-size: 14px;
+  }
+
+  .tabs {
+    margin-top: 16px;
+  }
+}
+</style>
+
+<style lang="scss">
+.project-detail-header .ant-tabs-top > .ant-tabs-nav {
+  margin: 0;
+}
+</style>

+ 138 - 0
src/views/project/edit.vue

@@ -0,0 +1,138 @@
+<template>
+  <a-modal
+    v-model:visible="visible"
+    :title="`${project.projectId ? '修改' : '新建'}项目`"
+    width="660px"
+    :after-close="onCancel"
+    @ok="saveHandler"
+  >
+    <template #footer>
+      <a-button class="action-bottom" size="middle" @click="visible = false">
+        取消
+      </a-button>
+      <a-button
+        class="action-bottom"
+        type="primary"
+        size="middle"
+        @click="saveHandler"
+      >
+        {{ project.projectId ? '修改' : '添加' }}
+      </a-button>
+    </template>
+
+    <a-form
+      ref="fromRef"
+      :model="project"
+      class="form"
+      label-align="right"
+      :label-col="{ span: 4 }"
+    >
+      <a-form-item
+        name="projectName"
+        label="项目名称"
+        :rules="[rules.projectName]"
+      >
+        <a-input
+          v-model:value="project.projectName"
+          :placeholder="rules.projectName.message"
+        />
+      </a-form-item>
+      <a-form-item
+        name="projectMsg"
+        label="项目描述"
+        :rules="[rules.projectMsg]"
+      >
+        <a-textarea
+          v-model:value="project.projectMsg"
+          :resize="false"
+          style="height: 104px; resize: none"
+          :placeholder="rules.projectMsg.message"
+        />
+      </a-form-item>
+      <a-form-item name="projectImg" label="项目封面">
+        <Upload
+          v-model:file="project.projectImg"
+          :max-size="10"
+          :extnames="['png', 'jpg', 'gif']"
+          :tips="['推荐大小:500 * 500 像素']"
+        />
+      </a-form-item>
+      <a-form-item v-if="!project.projectId" name="projectImg" label="BIM文件">
+        <Upload
+          v-model:file="project.bimFile"
+          :max-size="3 * 1024"
+          :extnames="['dwg', 'dxf', 'dwf', 'ifc']"
+        />
+      </a-form-item>
+    </a-form>
+  </a-modal>
+</template>
+
+<script lang="ts" setup>
+import { ref, defineProps, toRaw } from 'vue'
+import Upload from '@/components/upload/index.vue'
+
+import type { InsertProjectData, Project } from '@/api'
+import type { FormInstance } from 'ant-design-vue'
+
+export type IProject = Omit<InsertProjectData, 'projectImg'> & {
+  projectId?: Project['projectId']
+  bimFile?: File
+  projectImg?: string | File
+}
+
+defineOptions<{ name: 'insert-project' }>()
+
+const props = defineProps<{
+  project?: IProject
+  onSave: (data: IProject) => void
+  onCancel: () => void
+}>()
+
+const project = ref<IProject>(
+  props.project
+    ? { ...props.project }
+    : {
+        projectName: '',
+        projectMsg: '',
+        bimFile: undefined,
+        projectImg: undefined
+      }
+)
+const rules = {
+  projectName: {
+    required: true,
+    max: 40,
+    min: 1,
+    message: '请输入名称最多40字'
+  },
+  projectMsg: {
+    required: true,
+    max: 40,
+    min: 1,
+    message: '请输入项目描述最多200字'
+  }
+}
+const fromRef = ref<FormInstance>()
+const visible = ref(true)
+
+const saveHandler = async () => {
+  await fromRef.value?.validate()
+  await props.onSave(toRaw(project.value))
+  visible.value = false
+}
+</script>
+
+<style lang="scss" scoped>
+.footer {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+
+  p {
+    margin-bottom: 0;
+    color: #646566;
+    font-size: 14px;
+  }
+}
+</style>

+ 147 - 0
src/views/project/list.vue

@@ -0,0 +1,147 @@
+<template>
+  <HeadPanl>
+    <a-form
+      ref="filterFromRef"
+      class="filter-form"
+      :model="filterState"
+      name="horizontal_login"
+      layout="inline"
+      autocomplete="off"
+      @finish="updateList"
+    >
+      <a-form-item label="项目名称" name="title">
+        <a-input
+          v-model:value="filterState.title"
+          placeholder="请输入项目名称"
+          allow-clear
+        />
+      </a-form-item>
+      <a-form-item label="创建人" name="author">
+        <a-input
+          v-model:value="filterState.author"
+          placeholder="请输入项目创建人"
+          allow-clear
+        />
+      </a-form-item>
+      <a-form-item label="选择日期" name="time">
+        <a-date-picker
+          v-model:value="filterState.time"
+          style="width: 100%"
+          placeholder="选择项目开始日期"
+          allow-clear
+        />
+      </a-form-item>
+      <a-form-item label="项目状态" name="state">
+        <a-select
+          ref="select"
+          :value="filterState.state"
+          allow-clear
+          @update:value="(val: any) => (filterState.state = val === undefined ? all : val)"
+        >
+          <a-select-option
+            v-for="option in statusOptions"
+            :key="option.value"
+            :value="option.value"
+          >
+            {{ option.label }}</a-select-option
+          >
+        </a-select>
+      </a-form-item>
+
+      <a-form-item class="actions">
+        <a-button type="primary" html-type="submit">搜索</a-button>
+        <a-button style="margin-left: 10px" @click="resetFilter">
+          重置
+        </a-button>
+      </a-form-item>
+    </a-form>
+  </HeadPanl>
+  <BodyPanl title="管理列表">
+    <template #action>
+      <a-button type="primary" @click="createProject">新建项目</a-button>
+    </template>
+    <a-table
+      :data-source="list"
+      :columns="projectColumns"
+      :pagination="pagination"
+    />
+  </BodyPanl>
+</template>
+
+<script setup lang="ts">
+import InsertProject from './edit.vue'
+import { HeadPanl, BodyPanl } from '@/layout/panl'
+import { projectColumns } from './columns'
+import { reactive, ref } from 'vue'
+import { usePaging } from '@/hook'
+import { renderModal } from '@/helper'
+import { useProject } from '@/store'
+import {
+  ProjectStatus,
+  ProjectStatusDesc,
+  all,
+  fetchProjects,
+  uploadFile,
+  insertProject
+} from '@/api'
+
+import type { All, InsertProjectData } from '@/api'
+import type { FormInstance } from 'ant-design-vue'
+
+const statusOptions = [
+  { label: '全部', value: all },
+  { label: ProjectStatusDesc[ProjectStatus.done], value: ProjectStatus.done },
+  {
+    label: ProjectStatusDesc[ProjectStatus.undone],
+    value: ProjectStatus.undone
+  }
+]
+
+type FilterState = {
+  title: string
+  author: string
+  time: string
+  state: ProjectStatus | All
+}
+
+const filterState: FilterState = reactive({
+  title: '',
+  author: '',
+  time: '',
+  state: all
+})
+
+const { list, pagination, updateList } = usePaging(fetchProjects, filterState, {
+  auto: false
+})
+
+const filterFromRef = ref<FormInstance>()
+const resetFilter = () => {
+  filterFromRef.value!.resetFields()
+  updateList()
+}
+
+const projectStore = useProject()
+const createProject = () => {
+  renderModal(InsertProject, {
+    async onSave({ projectImg, bimFile, ...data }) {
+      const projectData: InsertProjectData = data
+      if (projectImg) {
+        projectData.projectImg = await uploadFile(projectImg as File)
+      }
+
+      const project = await insertProject(projectData)
+      if (bimFile) {
+        await projectStore.addBim(
+          {
+            name: bimFile.name,
+            file: bimFile
+          },
+          project.projectId
+        )
+      }
+      await updateList()
+    }
+  })
+}
+</script>

+ 35 - 0
src/views/record/columns.ts

@@ -0,0 +1,35 @@
+import type { Record } from '@/api'
+import type { ColumnsType } from 'ant-design-vue/es/table'
+
+export const recordColumns: ColumnsType<Record> = [
+  {
+    title: '序列',
+    dataIndex: 'logId',
+    key: 'logId'
+  },
+  {
+    title: '项目名称',
+    dataIndex: 'projectName',
+    key: 'projectName'
+  },
+  {
+    title: '备注名称',
+    dataIndex: 'logMsg',
+    key: 'logMsg'
+  },
+  {
+    title: '创建人',
+    dataIndex: 'userName',
+    key: 'userName'
+  },
+  {
+    title: '更新时间',
+    dataIndex: 'createTime',
+    key: 'createTime'
+  },
+  {
+    title: '操作',
+    dataIndex: 'action',
+    key: 'action'
+  }
+]

+ 32 - 0
src/views/record/list.vue

@@ -0,0 +1,32 @@
+<template>
+  <a-table
+    :data-source="list"
+    :columns="recordColumns"
+    :pagination="pagination"
+  >
+    <template #bodyCell="{ column, record }">
+      <template v-if="column.key === 'action'">
+        <div class="table-actions">
+          <a @click="gotoProject(record)">查看</a>
+        </div>
+      </template>
+    </template>
+  </a-table>
+</template>
+
+<script lang="ts" setup>
+import { fetchRecords } from '@/api'
+import { recordColumns } from './columns'
+import { usePaging } from '@/hook'
+import { router, RoutesName } from '@/router'
+
+import type { Record } from '@/api'
+
+const { list, pagination } = usePaging(fetchRecords, {})
+const gotoProject = (record: Record) => {
+  router.push({
+    name: RoutesName.projectScenes,
+    params: { id: record.projectId }
+  })
+}
+</script>

+ 83 - 0
src/views/scene/actions.vue

@@ -0,0 +1,83 @@
+<template>
+  <div class="table-actions">
+    <template v-if="bimDone">
+      <a @click="updateBimName">修改</a>
+      <a v-if="!store.current!.panos" @click="syncScene">同步</a>
+    </template>
+    <template v-if="done">
+      <a @click="queryScene">查看</a>
+      <!-- <a>分享</a> -->
+    </template>
+    <a v-if="lastStep" class="warn" @click="deleteScene"> 删除 </a>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { useProject, BinType, BimStatus, SceneStatus } from '@/store'
+import { Modal } from 'ant-design-vue'
+import { computed } from 'vue'
+import { renderModal } from '@/helper'
+import { projectManage } from '@/env'
+import UploadBim from './update.vue'
+import type { ProjectScene } from '@/store'
+
+const props = defineProps<{ scene: ProjectScene }>()
+const store = useProject()
+const bimDone = computed(
+  () => props.scene.type === BinType && props.scene.status === BimStatus.done
+)
+const sceneDone = computed(
+  () =>
+    props.scene.type !== BinType && props.scene.status === SceneStatus.SUCCESS
+)
+const done = computed(() => sceneDone.value || bimDone.value)
+const lastStep = computed(
+  () =>
+    props.scene.type !== BinType ||
+    [BimStatus.done, BimStatus.error].includes(props.scene.status)
+)
+
+const deleteScene = () => {
+  Modal.confirm({
+    content: '确定要删除此场景?',
+    title: '系统提示',
+    width: '400px',
+    okText: '删除',
+    icon: null,
+    cancelText: '取消',
+    onOk: async () => {
+      if (props.scene.type === BinType) {
+        await store.deleteBim()
+      } else {
+        await store.deleteScene(props.scene.num)
+      }
+    }
+  })
+}
+
+const queryScene = () => {
+  const base = `${projectManage}?projectId=${store.current!.projectId}`
+  if ('num' in props.scene) {
+    window.open(`${base}&m=${props.scene.num}`)
+  } else {
+    window.open(`${base}&bim`)
+  }
+}
+
+const syncScene = () => {
+  window.open(`${projectManage}?projectId=${store.current!.projectId}&adjust`)
+}
+
+const updateBimName = () => {
+  const bim = store.current!.bimData!
+  renderModal(UploadBim, {
+    bim: {
+      bimName: bim.bimName,
+      bimPath: bim.bimOssFilePath || bim.bimLocalFilePath
+    },
+    async onSave(data) {
+      await store.updateBimName(data.bimName)
+    }
+  })
+}
+</script>

+ 85 - 0
src/views/scene/columns.ts

@@ -0,0 +1,85 @@
+import { h } from 'vue'
+import SceneActions from './actions.vue'
+import {
+  SceneTypeDesc,
+  BinType,
+  binTypeDesc,
+  BimStatusDesc,
+  BimStatus,
+  sceneStatusDesc,
+  SceneStatus
+} from '@/store'
+
+import type { ProjectScene } from '@/store'
+import type { ColumnsType } from 'ant-design-vue/es/table'
+
+export const sceneColumns: ColumnsType<ProjectScene> = [
+  {
+    title: '序列',
+    dataIndex: 'id',
+    key: 'id'
+  },
+  {
+    title: '名称',
+    dataIndex: 'name',
+    key: 'name'
+  },
+  {
+    title: '创建人',
+    dataIndex: 'phone',
+    key: 'phone'
+  },
+  {
+    key: 'num',
+    dataIndex: 'num',
+    title: '场景码'
+  },
+  {
+    title: '类型',
+    dataIndex: 'type',
+    key: 'type',
+    customRender: ({ record }) =>
+      record.type === BinType ? binTypeDesc : SceneTypeDesc[record.type]
+  },
+  {
+    title: '状态',
+    dataIndex: 'status',
+    key: 'status',
+    customRender: ({ record }) => {
+      const props =
+        record.type === BinType
+          ? {
+              desc: BimStatusDesc[record.status],
+              done: record.status === BimStatus.done
+            }
+          : {
+              desc: sceneStatusDesc[record.status],
+              done: record.status === SceneStatus.SUCCESS
+            }
+
+      const style = {
+        color: props.done ? '#04CE75' : '#323233',
+        fontSize: '14px'
+      }
+      return h('span', { style }, props.desc)
+    }
+  },
+  {
+    title: '拍摄时间',
+    dataIndex: 'createTime',
+    key: 'createTime'
+  },
+  {
+    title: '操作',
+    dataIndex: 'action',
+    key: 'action',
+    customRender: ({ record }) => h(SceneActions, { scene: record })
+  }
+]
+
+export const projectSceneColumns = sceneColumns.filter(
+  ({ key }) => !['num'].includes(key as string)
+)
+export const selectSceneColumns = sceneColumns.filter(column =>
+  ['name', 'num', 'createTime'].includes(column.key as string)
+)

+ 135 - 0
src/views/scene/insert.vue

@@ -0,0 +1,135 @@
+<template>
+  <a-modal
+    v-model:visible="visible"
+    :title="`${currentType === Type.bim ? '创建' : '选择'}场景`"
+    :width="`${currentType === Type.bim ? 480 : 660}px`"
+    :after-close="onCancel"
+    @ok="saveHandler"
+  >
+    <template #footer>
+      <div class="footer">
+        <p>
+          <template v-if="currentType === Type.scene">
+            已选择
+            {{ Object.values(typeNums).reduce((t, c) => t + c.length, 0) }}
+            个场景
+          </template>
+        </p>
+        <div>
+          <a-button
+            class="action-bottom"
+            size="middle"
+            @click="visible = false"
+          >
+            取消
+          </a-button>
+          <a-button
+            class="action-bottom"
+            type="primary"
+            size="middle"
+            @click="saveHandler"
+          >
+            保存
+          </a-button>
+        </div>
+      </div>
+    </template>
+
+    <a-form name="basic" label-align="left" autocomplete="off">
+      <a-form-item label="场景类型" required>
+        <a-radio-group v-model:value="currentType" name="radioGroup">
+          <a-radio :value="Type.bim">{{ Type.bim }}</a-radio>
+          <a-radio :value="Type.scene">{{ Type.scene }}</a-radio>
+        </a-radio-group>
+      </a-form-item>
+      <UploadBim v-if="currentType === Type.bim" v-model:data="bim" />
+    </a-form>
+    <SelectScenes
+      v-if="currentType === Type.scene"
+      v-model:type-nums="typeNums"
+    />
+  </a-modal>
+</template>
+
+<script lang="ts" setup>
+import { ref, defineProps, toRaw } from 'vue'
+import { message } from 'ant-design-vue'
+import { SceneType } from '@/store'
+import SelectScenes from './select-scenes.vue'
+import UploadBim from './upload-bim.vue'
+
+import type { UploadData } from './upload-bim.vue'
+import type { Project, BimUploadData, SelectTypeScenes } from '@/store'
+
+export type SaveData =
+  | {
+      type: 'bim'
+      payload: BimUploadData
+    }
+  | {
+      type: 'scene'
+      payload: SelectTypeScenes
+    }
+
+defineOptions<{ name: 'insert-project-scene' }>()
+
+const props = defineProps<{
+  project: Project
+  onSave: (data: SaveData) => void
+  onCancel: () => void
+}>()
+
+enum Type {
+  scene = '场景',
+  bim = 'bim'
+}
+const currentType = ref(Type.bim)
+
+const bim = ref<UploadData>({ name: '', file: undefined })
+const typeNums = ref(
+  props.project.sceneList.reduce(
+    (t, scene) => {
+      t[scene.type].push(scene.num)
+      return t
+    },
+    {
+      [SceneType.SWKJ]: [],
+      [SceneType.SWKK]: [],
+      [SceneType.SWSS]: []
+    } as SelectTypeScenes
+  )
+)
+
+const visible = ref(true)
+
+const saveHandler = async () => {
+  let data: SaveData
+  if (currentType.value === Type.bim) {
+    if (!bim.value.name) {
+      return message.error('请输入场景名称')
+    } else if (!bim.value.file) {
+      return message.error('请上传BIM文件')
+    }
+    data = { payload: toRaw(bim.value) as BimUploadData, type: 'bim' }
+  } else {
+    data = { payload: toRaw(typeNums.value), type: 'scene' }
+  }
+
+  await props.onSave(data)
+  visible.value = false
+}
+</script>
+
+<style lang="scss" scoped>
+.footer {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+
+  p {
+    margin-bottom: 0;
+    color: #646566;
+    font-size: 14px;
+  }
+}
+</style>

+ 60 - 0
src/views/scene/list.vue

@@ -0,0 +1,60 @@
+<template>
+  <BodyPanlHeader>
+    <a-button type="primary" @click="insertScene">创建场景</a-button>
+    <div>
+      <a-input-search
+        v-model:value="filterName"
+        style="width: 280px"
+        placeholder="请输入场景名称"
+        allow-clear
+      />
+    </div>
+  </BodyPanlHeader>
+
+  <BodyPanlBody>
+    <a-table
+      :data-source="filterScene"
+      :columns="sceneColumns"
+      :pagination="pagination"
+    />
+  </BodyPanlBody>
+</template>
+
+<script lang="ts" setup>
+import InsertScene from './insert.vue'
+import { computed, reactive, ref } from 'vue'
+import { BodyPanlHeader, BodyPanlBody } from '@/layout/panl'
+import { useProject } from '@/store'
+import { sceneColumns } from './columns'
+import { renderModal } from '@/helper'
+
+const filterName = ref('')
+const projectStore = useProject()
+const filterScene = computed(() =>
+  projectStore.scenes.filter(
+    scene => !scene.name || scene.name?.includes(filterName.value)
+  )
+)
+
+const pagination = reactive({
+  current: 1,
+  total: filterScene.value.length,
+  pageSize: 7,
+  onChange(num: number) {
+    pagination.current = num
+  }
+})
+
+const insertScene = () => {
+  renderModal(InsertScene, {
+    project: projectStore.current!,
+    async onSave({ type, payload }) {
+      if (type === 'bim') {
+        await projectStore.addBim(payload)
+      } else if (type === 'scene') {
+        await projectStore.replaceScenes(payload)
+      }
+    }
+  })
+}
+</script>

+ 62 - 0
src/views/scene/select-scenes.vue

@@ -0,0 +1,62 @@
+<template>
+  <!-- <a-tabs :active-key="params.type" @update:active-key="updateType">
+    <a-tab-pane
+      v-for="option in tabOptions"
+      :key="option.key"
+      :tab="option.label"
+    />
+  </a-tabs> -->
+  <a-table
+    row-key="num"
+    :data-source="list"
+    :columns="sceneColumns"
+    :pagination="pagination"
+    :row-selection="{
+      selectedRowKeys: typeNums[params.type],
+      onChange: changeSelectNums
+    }"
+  >
+  </a-table>
+</template>
+
+<script lang="ts" setup>
+import { selectSceneColumns as sceneColumns } from './columns'
+import { reactive } from 'vue'
+import { fetchScenes, SceneType, SceneTypeDesc } from '@/api'
+import { usePaging } from '@/hook'
+
+import type { SelectTypeScenes } from '@/store'
+
+defineOptions<{ name: 'select-scene-list' }>()
+
+const props = defineProps<{ typeNums: SelectTypeScenes }>()
+const emit = defineEmits<{
+  (e: 'update:typeNums', value: SelectTypeScenes): void
+}>()
+
+const updateType = (type: any) => {
+  pagination.current = 1
+  params.type = type
+}
+
+const tabOptions = [SceneType.SWKJ, SceneType.SWKK, SceneType.SWSS].map(
+  type => ({
+    key: type,
+    label: SceneTypeDesc[type]
+  })
+)
+
+const params = reactive({ type: SceneType.SWSS })
+const { list, pagination } = usePaging(fetchScenes, params)
+
+const changeSelectNums = (nums: any[]) => {
+  const currentNums = props.typeNums[params.type]
+  const reserve = currentNums.filter(num =>
+    list.value.every(scene => scene.num !== num)
+  )
+  emit('update:typeNums', {
+    ...props.typeNums,
+    [params.type]: [...reserve, ...nums]
+  })
+}
+</script>

+ 74 - 0
src/views/scene/update.vue

@@ -0,0 +1,74 @@
+<template>
+  <a-modal
+    v-model:visible="visible"
+    title="修改bim"
+    width="480px"
+    :after-close="onCancel"
+    @ok="saveHandler"
+  >
+    <template #footer>
+      <a-button class="action-bottom" size="middle" @click="visible = false">
+        取消
+      </a-button>
+      <a-button
+        class="action-bottom"
+        type="primary"
+        size="middle"
+        @click="saveHandler"
+      >
+        保存
+      </a-button>
+    </template>
+
+    <a-form name="basic" label-align="left" autocomplete="off">
+      <UploadBim v-model:data="bim" :update="true" />
+    </a-form>
+  </a-modal>
+</template>
+
+<script lang="ts" setup>
+import { ref, defineProps } from 'vue'
+import { message } from 'ant-design-vue'
+import UploadBim from './upload-bim.vue'
+
+import type { Bim } from '@/store'
+
+export type SaveData = Pick<Bim, 'bimName'> & { bimPath: string }
+
+defineOptions<{ name: 'update-bim-scene' }>()
+
+const props = defineProps<{
+  bim: SaveData
+  onSave: (data: SaveData) => void
+  onCancel: () => void
+}>()
+
+const bim = ref({ name: props.bim.bimName, file: props.bim.bimPath })
+const visible = ref(true)
+
+const saveHandler = async () => {
+  if (!bim.value.name) {
+    return message.error('请输入场景名称')
+  } else {
+    await props.onSave({
+      bimName: bim.value.name,
+      bimPath: props.bim.bimPath
+    })
+  }
+  visible.value = false
+}
+</script>
+
+<style lang="scss" scoped>
+.footer {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+
+  p {
+    margin-bottom: 0;
+    color: #646566;
+    font-size: 14px;
+  }
+}
+</style>

+ 35 - 0
src/views/scene/upload-bim.vue

@@ -0,0 +1,35 @@
+<template>
+  <a-form-item label="场景名称" required>
+    <a-input
+      :value="data.name"
+      name="radioGroup"
+      placeholder="请输入名称最多40字"
+      :maxlength="40"
+      @update:value="name => $emit('update:data', { ...data, name })"
+    />
+  </a-form-item>
+  <a-form-item label="BIM文件" required>
+    <Upload
+      :disabled="update"
+      :file="data.file"
+      :max-size="5 * 1024"
+      :extnames="['ifc']"
+      @update:file="file => $emit('update:data', { ...data, file })"
+    />
+  </a-form-item>
+</template>
+
+<script setup lang="ts">
+import Upload from '@/components/upload/index.vue'
+import { BimUploadData } from '@/store'
+
+import type { BUploadProps } from '@/components/upload/index.vue'
+
+export type UploadData = Pick<BUploadProps, 'file'> &
+  Omit<BimUploadData, 'file'>
+
+defineOptions<{ name: 'project-bim-upload' }>()
+
+defineProps<{ data: UploadData; update?: boolean }>()
+defineEmits<{ (e: 'update:data', value: UploadData): void }>()
+</script>

+ 67 - 0
src/views/system/login.vue

@@ -0,0 +1,67 @@
+<template>
+  <div class="header">
+    <h2>欢迎登录</h2>
+    <p>账号登录</p>
+  </div>
+
+  <a-form :model="loginState" class="form" @finish="login">
+    <a-form-item name="phone" :rules="[phoneRule]">
+      <a-input
+        v-model:value="loginState.phone"
+        style="height: 50px"
+        placeholder="请输入手机号"
+      />
+    </a-form-item>
+    <a-form-item name="password" :rules="[passwordRule]">
+      <a-input-password
+        v-model:value="loginState.password"
+        placeholder="请输入密码"
+        style="height: 50px"
+      />
+    </a-form-item>
+    <a-form-item>
+      <a-form-item name="remember" no-style>
+        <a-checkbox v-model:checked="loginState.remember">记住密码</a-checkbox>
+      </a-form-item>
+    </a-form-item>
+    <a-form-item>
+      <a-button
+        type="primary"
+        html-type="submit"
+        block
+        class="login-form-button"
+        style="height: 50px"
+      >
+        登录
+      </a-button>
+    </a-form-item>
+  </a-form>
+  <div class="footer">
+    <a :href="forgetLink">忘记密码</a>
+    <a :href="registerLink">账号注册</a>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { useUserStore } from '@/store'
+import { router, RoutesName } from '@/router'
+import { phoneRule, passwordRule, encodePwd, useCacheState } from './shared'
+import { registerLink, forgetLink } from '@/env'
+
+defineOptions<{ name: 'login' }>()
+
+const [loginState, saveLoginState] = useCacheState()
+const userStore = useUserStore()
+const login = async () => {
+  saveLoginState()
+  await userStore.login({
+    phone: loginState.value.phone,
+    password: encodePwd(loginState.value.password)
+  })
+  router.push({ name: RoutesName.personal })
+}
+</script>
+
+<style scoped lang="scss">
+@import './shared/style.scss';
+</style>

+ 30 - 0
src/views/system/shared/cache.ts

@@ -0,0 +1,30 @@
+import { onActivated, ref } from 'vue'
+import type { LoginState } from '@/api'
+
+const cacheKey = '__loginState__'
+
+export const useCacheState = () => {
+  const initial = { phone: '', password: '', remember: false }
+  const state = ref<LoginState & { remember: boolean }>(initial)
+  const updateCacheState = () => {
+    try {
+      const stateStr = localStorage.getItem(cacheKey)
+      if (stateStr) {
+        state.value = JSON.parse(stateStr)
+      }
+    } catch {}
+  }
+  updateCacheState()
+  onActivated(updateCacheState)
+
+  return [
+    state,
+    () => {
+      if (state.value.remember) {
+        localStorage.setItem(cacheKey, JSON.stringify(state.value))
+      } else {
+        localStorage.removeItem(cacheKey)
+      }
+    }
+  ] as const
+}

+ 107 - 0
src/views/system/shared/encode.ts

@@ -0,0 +1,107 @@
+import { Base64 } from 'js-base64'
+
+function randomWord(randomFlag: boolean, min: number, max?: number) {
+  let str = ''
+  let range = min
+  const arr = [
+    '0',
+    '1',
+    '2',
+    '3',
+    '4',
+    '5',
+    '6',
+    '7',
+    '8',
+    '9',
+    'a',
+    'b',
+    'c',
+    'd',
+    'e',
+    'f',
+    'g',
+    'h',
+    'i',
+    'j',
+    'k',
+    'l',
+    'm',
+    'n',
+    'o',
+    'p',
+    'q',
+    'r',
+    's',
+    't',
+    'u',
+    'v',
+    'w',
+    'x',
+    'y',
+    'z',
+    'A',
+    'B',
+    'C',
+    'D',
+    'E',
+    'F',
+    'G',
+    'H',
+    'I',
+    'J',
+    'K',
+    'L',
+    'M',
+    'N',
+    'O',
+    'P',
+    'Q',
+    'R',
+    'S',
+    'T',
+    'U',
+    'V',
+    'W',
+    'X',
+    'Y',
+    'Z'
+  ]
+  // 随机产生
+  if (randomFlag && max) {
+    range = Math.round(Math.random() * (max - min)) + min
+  }
+  for (let i = 0; i < range; i++) {
+    const pos = Math.round(Math.random() * (arr.length - 1))
+    str += arr[pos]
+  }
+  return str
+}
+
+/**
+ * 密码加密
+ * @param {String} pwd
+ */
+export function encodePwd(str: string, strv: string): string[]
+export function encodePwd(str: string): string
+export function encodePwd(str: string, strv = '') {
+  str = Base64.encode(str)
+  const NUM = 2
+  const front = randomWord(false, 8)
+  const middle = randomWord(false, 8)
+  const end = randomWord(false, 8)
+
+  const str1 = str.substring(0, NUM)
+  const str2 = str.substring(NUM)
+
+  if (strv) {
+    const strv1 = strv.substring(0, NUM)
+    const strv2 = strv.substring(NUM)
+    return [
+      front + str2 + middle + str1 + end,
+      front + strv2 + middle + strv1 + end
+    ]
+  }
+
+  return front + str2 + middle + str1 + end
+}

+ 3 - 0
src/views/system/shared/index.ts

@@ -0,0 +1,3 @@
+export * from './cache'
+export * from './rules'
+export * from './encode'

+ 0 - 0
src/views/system/shared/rules.ts


Alguns arquivos não foram mostrados porque muitos arquivos mudaram nesse diff