bill пре 2 година
родитељ
комит
a201a8cb83
51 измењених фајлова са 4590 додато и 2743 уклоњено
  1. 35 28
      .eslintrc.json
  2. 1 1
      .husky/pre-commit
  3. 8 1
      package.json
  4. 0 2554
      pnpm-lock.yaml
  5. 1 1
      scripts/verifyCommit.js
  6. 22 32
      src/App.vue
  7. 18 0
      src/api/constant.ts
  8. 4 0
      src/api/index.ts
  9. 72 0
      src/api/instance.ts
  10. 92 0
      src/api/room.ts
  11. 46 0
      src/api/scene.ts
  12. 274 0
      src/api/setup.ts
  13. 16 0
      src/api/user.ts
  14. 0 1
      src/assets/vue.svg
  15. 48 0
      src/components.d.ts
  16. 0 39
      src/components/HelloWorld.vue
  17. 43 0
      src/components/data-list/index.vue
  18. 42 0
      src/components/loading/index.ts
  19. 26 0
      src/components/loading/index.vue
  20. 18 0
      src/components/loading/props.ts
  21. 9 0
      src/env/index.ts
  22. 29 0
      src/helper/index.ts
  23. 72 0
      src/layout/header.vue
  24. 8 1
      src/main.ts
  25. 10 0
      src/router/config.ts
  26. 23 0
      src/router/constant.ts
  27. 21 0
      src/router/index.ts
  28. 36 0
      src/shared/code.ts
  29. 24 0
      src/shared/diff.ts
  30. 4 0
      src/shared/index.ts
  31. 37 0
      src/shared/mount.ts
  32. 17 0
      src/shared/params.ts
  33. 1 0
      src/store/constant.ts
  34. 8 0
      src/store/index.ts
  35. 61 0
      src/store/room.ts
  36. 20 0
      src/store/scene.ts
  37. 19 0
      src/store/user.ts
  38. 12 72
      src/style.css
  39. 6 0
      src/views/room/edit-room/index.ts
  40. 213 0
      src/views/room/edit-room/index.vue
  41. 17 0
      src/views/room/edit-room/props.ts
  42. 158 0
      src/views/room/edit-room/scene-list.vue
  43. 175 0
      src/views/room/list.vue
  44. 32 0
      src/views/room/modal/mini-sync.vue
  45. 31 0
      src/views/room/modal/share.vue
  46. 183 0
      src/views/room/sign.vue
  47. 124 0
      src/views/scene/list.vue
  48. 7 5
      src/vite-env.d.ts
  49. 6 1
      tsconfig.json
  50. 45 7
      vite.config.ts
  51. 2416 0
      yarn.lock

+ 35 - 28
.eslintrc.json

@@ -4,51 +4,58 @@
     //强制使用单引号
     "quotes": ["error", "single"],
     //强制不使用分号结尾
-    "semi": ["error", "never"]
+    "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 },
-      "parser": "@typescript-eslint/parser",
-      "plugins": ["@typescript-eslint"],
-      "extends": [
-        "eslint:recommended",
-        "plugin:@typescript-eslint/eslint-recommended",
-        "plugin:@typescript-eslint/recommended",
-        "plugin:prettier/recommended"
-      ]
+      "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
       }
-    },
-    {
-      "files": ["./vite.config.ts"],
-      "env": { "node": true },
-      "parser": "@typescript-eslint/parser",
-      "plugins": ["@typescript-eslint"],
-      "extends": [
-        "eslint:recommended",
-        "plugin:@typescript-eslint/eslint-recommended",
-        "plugin:@typescript-eslint/recommended",
-        "plugin:prettier/recommended"
-      ]
-    },
-    {
-      "files": ["scripts/*.js"],
-      "env": { "node": true },
-      "parser": "espree",
-      "extends": ["eslint:recommended", "plugin:prettier/recommended"]
     }
   ]
 }

+ 1 - 1
.husky/pre-commit

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

+ 8 - 1
package.json

@@ -17,9 +17,13 @@
     ]
   },
   "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",
+    "less": "^4.1.3",
     "pinia": "^2.0.22",
-    "vite-plugin-importer": "^0.2.5",
+    "sass": "^1.54.9",
     "vue": "^3.2.37",
     "vue-router": "4"
   },
@@ -37,6 +41,9 @@
     "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"

Разлика између датотеке није приказан због своје велике величине
+ 0 - 2554
pnpm-lock.yaml


+ 1 - 1
scripts/verifyCommit.js

@@ -14,7 +14,7 @@ if (!commitRE.test(msg)) {
       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('feat(compiler): add "comments" option')}\n` +
       `    ${chalk.green(
         'fix(v-model): handle events on blur (close #28)'
       )}\n\n` +

+ 22 - 32
src/App.vue

@@ -1,37 +1,27 @@
-<script setup lang="ts">
-// This starter template is using Vue 3 <script setup> SFCs
-// Check out https://vuejs.org/api/sfc-script-setup.html#script-setup
-import HelloWorld from './components/HelloWorld.vue'
-
-const a =
-  '12啊啊啊啊啊啊啊啊啊啊啊啊啊啊312啊啊啊啊啊啊啊啊啊啊啊啊啊啊312啊啊啊啊啊啊' +
-  '啊啊啊啊啊啊啊啊312啊啊啊啊啊啊啊啊啊啊啊啊啊啊3'
-</script>
-
 <template>
-  <div>
-    <a href="https://vitejs.dev" target="_blank">
-      <img src="/vite.svg" class="logo" alt="Vite logo" />
-    </a>
-    <a href="https://vuejs.org/" target="_blank">
-      <img src="./assets/vue.svg" class="logo vue" alt="Vue logo" />
-    </a>
-  </div>
-  <HelloWorld msg="Vite + Vue" />
+  <a-layout class="layout">
+    <LayoutHeader />
+    <a-layout-content>
+      <div class="content content-layout">
+        <RouterView v-slot="{ Component }">
+          <KeepAlive>
+            <component :is="Component" />
+          </KeepAlive>
+        </RouterView>
+      </div>
+    </a-layout-content>
+  </a-layout>
 </template>
 
-<style scoped>
-.logo {
-  height: 6em;
-  padding: 1.5em;
-  will-change: filter;
-}
-.logo:hover {
-  filter: drop-shadow(0 0 2em #646cffaa);
-}
-.logo.vue:hover {
-  filter: drop-shadow(0 0 2em #42b883aa);
-  -webkit-filter: blur(4px);
-  background-color: #ffffff;
+<script lang="ts" setup>
+import LayoutHeader from '@/layout/header.vue'
+
+defineOptions({ name: 'App' })
+</script>
+
+<style lang="scss" scoped>
+.content,
+.layout {
+  height: 100%;
 }
 </style>

+ 18 - 0
src/api/constant.ts

@@ -0,0 +1,18 @@
+export enum ResCode {
+  TOKEN_INVALID = 4008,
+  SUCCESS = 0
+}
+
+export const ResCodeDesc: { [key in ResCode]: string } = {
+  [ResCode.TOKEN_INVALID]: 'token已失效',
+  [ResCode.SUCCESS]: '请求成功'
+}
+
+export const GET_ROOM_LIST = '/takelook/roomList'
+export const GET_ROOM = '/takelook/roomInfo'
+export const SET_ROOM = '/takelook/roomAddOrUpdate'
+export const DEL_ROOM = '/takelook/roomDelete'
+export const GET_ROOM_MINI_CODE = '/takelook/roomGetShareCode'
+export const GET_SECELE_LIST = '/takelook/sceneList'
+
+export const GET_SCENE_LIST = '/takelook/sceneList'

+ 4 - 0
src/api/index.ts

@@ -0,0 +1,4 @@
+export * from './room'
+export * from './user'
+export * from './instance'
+export * from './scene'

+ 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 } from './constant'
+import { baseURL, token } from '@/env'
+
+const instance = axiosFactory()
+
+export const {
+  axios,
+  addUnsetTokenURLS,
+  delUnsetTokenURLS,
+  addReqErrorHandler,
+  addResErrorHandler,
+  delReqErrorHandler,
+  delResErrorHandler,
+  getToken,
+  setToken,
+  delToken,
+  setDefaultURI,
+  addHook,
+  delHook,
+  setHook
+} = instance
+
+const gotoLogin = () => {
+  const loginHref = import.meta.env.DEV
+    ? 'https://test.4dkankan.com'
+    : 'https://www.4dkankan.com'
+
+  if (import.meta.env.PROD) {
+    location.href = loginHref + '?redirect=' + escape(location.href)
+  } else {
+    message.error('token已过期')
+  }
+}
+
+addReqErrorHandler(err => {
+  message.error(err.message)
+  showLoading()
+  gotoLogin()
+})
+
+addResErrorHandler((response, data) => {
+  if (response && response.status !== 200) {
+  } else if (data) {
+    const msg =
+      data.code && ResCodeDesc[data.code]
+        ? ResCodeDesc[data.code]
+        : data?.message || data?.msg
+    if (data.code === ResCode.TOKEN_INVALID) {
+      gotoLogin()
+    } else {
+      message.error(msg)
+    }
+  }
+})
+
+addHook({
+  before: () => showLoading(),
+  after: config => config && hideLoading()
+})
+
+setDefaultURI(baseURL)
+
+if (!token) {
+  gotoLogin()
+} else {
+  setToken(token)
+}
+
+export default axios

+ 92 - 0
src/api/room.ts

@@ -0,0 +1,92 @@
+import { GET_ROOM_LIST, GET_ROOM_MINI_CODE } from './constant'
+import axios from './instance'
+
+export interface RoomScene {
+  num: string
+  title: string
+  cover: string
+}
+
+export interface Room {
+  id: number
+  title: string
+  desc: string
+  time: string
+  viewCount: number
+  cover: string
+  leaderName: string
+  userName: string
+  shareUrl: string
+  scenes: RoomScene[]
+}
+
+export type Rooms = Room[]
+
+export const fetchRomms = async () => {
+  const test: Rooms = [
+    {
+      id: 1,
+      title: '1212121212121212121212121212121212121212121212121212121212121212',
+      desc: '1231',
+      cover: 'https://4dkk.4dage.com/head/18819272208/head_1662022947583.png',
+      leaderName: 'asdasd',
+      userName: 'aaa',
+      shareUrl: 'http://www.4dkankan.com',
+      time: '2020-03-02',
+      viewCount: 1002,
+      scenes: [
+        {
+          num: 't-cc',
+          cover:
+            'https://4dkk.4dage.com/head/18819272208/head_1662022947583.png',
+          title: '田心村'
+        }
+      ]
+    },
+    {
+      id: 2,
+      title: '12',
+      desc: '1231',
+      cover: 'https://4dkk.4dage.com/head/18819272208/head_1662022947583.png',
+      leaderName: 'asdasd',
+      time: '2020-03-02',
+      userName: 'aaa',
+      shareUrl: 'http://www.4dkankan.com',
+      viewCount: 1002,
+      scenes: [
+        {
+          num: 't-cc',
+          cover:
+            'https://4dkk.4dage.com/head/18819272208/head_1662022947583.png',
+          title: '田心村'
+        }
+      ]
+    },
+    {
+      id: 3,
+      title: '12',
+      time: '2020-03-02',
+      viewCount: 1002,
+      desc: '1231',
+      userName: 'aaa',
+      shareUrl: 'http://www.4dkankan.com',
+      cover: 'https://4dkk.4dage.com/head/18819272208/head_1662022947583.png',
+      leaderName: 'asdasd',
+      scenes: [
+        {
+          num: 't-cc',
+          cover:
+            'https://4dkk.4dage.com/head/18819272208/head_1662022947583.png',
+          title: '田心村'
+        }
+      ]
+    }
+  ]
+  // const res = await axios.get<Room[]>(GET_ROOM_LIST)
+  return test
+}
+
+export const fetchRoomMiniCode = (room: Room) => {
+  return 'https://4dkk.4dage.com/head/18819272208/head_1662022947583.png'
+  // return axios.get<string>(GET_ROOM_MINI_CODE, { params: { roomId: room.id } })
+}

+ 46 - 0
src/api/scene.ts

@@ -0,0 +1,46 @@
+import axios from './instance'
+import { GET_SCENE_LIST } from './constant'
+
+export interface Scene {
+  id: string
+  num: string
+  title: string
+  cover: string
+  time: string
+}
+
+export type Scenes = Scene[]
+
+export const fetchScenes = async () => {
+  return [
+    {
+      id: '1',
+      num: 't-cc',
+      time: '2020-03-02',
+      cover: 'https://4dkk.4dage.com/head/18819272208/head_1662022947583.png',
+      title: '田心村'
+    },
+    {
+      id: '2',
+      num: 't-cc2',
+      time: '2020-03-02',
+      cover: 'https://4dkk.4dage.com/head/18819272208/head_1662022947583.png',
+      title: '田心村'
+    },
+    {
+      id: '3',
+      num: 't-cc3',
+      time: '2020-03-02',
+      cover: 'https://4dkk.4dage.com/head/18819272208/head_1662022947583.png',
+      title: '田心村'
+    },
+    {
+      id: '4',
+      num: 't-cc4',
+      time: '2020-03-02',
+      cover: 'https://4dkk.4dage.com/head/18819272208/head_1662022947583.png',
+      title: '田心村'
+    }
+  ]
+  // return axios.get<Scenes>(GET_SCENE_LIST)
+}

+ 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

+ 16 - 0
src/api/user.ts

@@ -0,0 +1,16 @@
+import axios from 'axios'
+
+export interface User {
+  nickname: string
+  phone: string
+  avatar: string
+}
+
+export const fetchUser = async () => {
+  // const res = axios.get<User>('')
+  return {
+    nickname: 'aaa',
+    phone: '15919209354',
+    avatar: 'https://4dkk.4dage.com/head/18819272208/head_1662022947583.png'
+  }
+}

+ 0 - 1
src/assets/vue.svg

@@ -1 +0,0 @@
-<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>

+ 48 - 0
src/components.d.ts

@@ -0,0 +1,48 @@
+// 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']
+    ACardMeta: typeof import('ant-design-vue/es')['CardMeta']
+    ADropdown: typeof import('ant-design-vue/es')['Dropdown']
+    AEmpty: typeof import('ant-design-vue/es')['Empty']
+    AForm: typeof import('ant-design-vue/es')['Form']
+    AFormItem: typeof import('ant-design-vue/es')['FormItem']
+    AInput: typeof import('ant-design-vue/es')['Input']
+    ALayout: typeof import('ant-design-vue/es')['Layout']
+    ALayoutContent: typeof import('ant-design-vue/es')['LayoutContent']
+    ALayoutFooter: typeof import('ant-design-vue/es')['LayoutFooter']
+    ALayoutHeader: typeof import('ant-design-vue/es')['LayoutHeader']
+    AList: typeof import('ant-design-vue/es')['List']
+    AListItem: typeof import('ant-design-vue/es')['ListItem']
+    AliyunOutlined: typeof import('@ant-design/icons-vue')['AliyunOutlined']
+    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']
+    ATable: typeof import('ant-design-vue/es')['Table']
+    ATextarea: typeof import('ant-design-vue/es')['Textarea']
+    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']
+    DownloadOutlined: typeof import('@ant-design/icons-vue')['DownloadOutlined']
+    DownOutlined: typeof import('@ant-design/icons-vue')['DownOutlined']
+    EditOutlined: typeof import('@ant-design/icons-vue')['EditOutlined']
+    EyeOutlined: typeof import('@ant-design/icons-vue')['EyeOutlined']
+    List: typeof import('./components/list/index.vue')['default']
+    Loading: typeof import('./components/loading/index.vue')['default']
+    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']
+    ShareAltOutlined: typeof import('@ant-design/icons-vue')['ShareAltOutlined']
+    UserOutlined: typeof import('@ant-design/icons-vue')['UserOutlined']
+  }
+}

+ 0 - 39
src/components/HelloWorld.vue

@@ -1,39 +0,0 @@
-<script setup lang="ts">
-import { ref } from 'vue'
-
-defineProps<{ msg: string }>()
-
-const count = ref(0)
-</script>
-
-<template>
-  <h1>{{ msg }}</h1>
-
-  <div class="card">
-    <button type="button" @click="count++">count is {{ count }}</button>
-    <p>
-      Edit
-      <code>components/HelloWorld.vue</code> to test HMR
-    </p>
-  </div>
-
-  <p>
-    Check out
-    <a href="https://vuejs.org/guide/quick-start.html#local" target="_blank">
-      create-vue
-    </a>
-    , the official Vue + Vite starter
-  </p>
-  <p>
-    Install
-    <a href="https://github.com/johnsoncodehk/volar" target="_blank">Volar</a>
-    in your IDE for a better DX
-  </p>
-  <p class="read-the-docs">Click on the Vite and Vue logos to learn more</p>
-</template>
-
-<style scoped>
-.read-the-docs {
-  color: #888;
-}
-</style>

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

@@ -0,0 +1,43 @@
+<template>
+  <slot v-if="dataSource.length"></slot>
+  <div v-else class="un-data">
+    <aliyun-outlined 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>
+defineOptions({ name: 'DataList' })
+defineProps<{
+  dataSource: Array<any>
+  name?: string
+  keyword: string
+}>()
+</script>
+
+<style scoped lang="scss">
+.un-data {
+  margin: 120px;
+  text-align: center;
+
+  .icon {
+    font-size: 80px;
+    margin-bottom: 20px;
+  }
+
+  p {
+    font-size: 14px;
+    font-weight: 400;
+    color: #646566;
+    margin-bottom: 5px;
+  }
+
+  .undata {
+    margin-top: 22px;
+  }
+}
+</style>

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

@@ -0,0 +1,42 @@
+import Loading from './index.vue'
+import { mount } from '@/shared'
+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 { destroy } = mount(Loading, { app, props })
+    closeStack.push({ key, close: destroy })
+  }
+}
+
+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

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

@@ -0,0 +1,26 @@
+<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>

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

+ 9 - 0
src/env/index.ts

@@ -0,0 +1,9 @@
+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' : '/'
+export const token = params.token

+ 29 - 0
src/helper/index.ts

@@ -0,0 +1,29 @@
+import { app } from '@/main'
+import { genMount } from '@/shared'
+
+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)
+}

+ 72 - 0
src/layout/header.vue

@@ -0,0 +1,72 @@
+<template>
+  <a-layout-header class="header-layout">
+    <div class="content-layout">
+      <h2>四维看看</h2>
+
+      <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>
+    </div>
+  </a-layout-header>
+</template>
+
+<script lang="ts" setup>
+import { MenuProps } from 'ant-design-vue'
+import { useUserStore } from '@/store'
+
+defineOptions({ name: 'LayoutHeader' })
+
+const userStore = useUserStore()
+userStore.fetch()
+
+const menus = [{ label: '退出', key: 'logout' }]
+const handlerMenuClick: MenuProps['onClick'] = e => {
+  if (e.key === 'logout') {
+    console.log('退出')
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.header-layout {
+  background-color: #fff;
+  display: flex;
+  padding: 0;
+
+  h2 {
+    margin: 0;
+  }
+}
+
+.content-layout {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+}
+
+.avatar {
+  display: flex;
+  align-items: center;
+  cursor: pointer;
+  > span {
+    margin-left: 8px;
+  }
+}
+</style>

+ 8 - 1
src/main.ts

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

+ 10 - 0
src/router/config.ts

@@ -0,0 +1,10 @@
+import { RoutesName, routesPaths } from './constant'
+
+export type RouteRaw = typeof routes[number]
+export const routes = [
+  {
+    path: routesPaths[RoutesName.rooms],
+    name: RoutesName.rooms,
+    component: () => import('@/views/room/list.vue')
+  }
+]

+ 23 - 0
src/router/constant.ts

@@ -0,0 +1,23 @@
+export enum RoutesName {
+  rooms = 'rooms',
+  createRoom = 'createRoom',
+  updateRoom = 'updateRoom'
+}
+
+export const routesPaths = {
+  [RoutesName.rooms]: '',
+  [RoutesName.createRoom]: 'add',
+  [RoutesName.updateRoom]: 'update/:id'
+}
+
+export const routesMetas = {
+  [RoutesName.rooms]: {
+    title: '房间列表'
+  },
+  [RoutesName.createRoom]: {
+    title: '创建房间'
+  },
+  [RoutesName.updateRoom]: {
+    title: '修改房间'
+  }
+}

+ 21 - 0
src/router/index.ts

@@ -0,0 +1,21 @@
+import { createRouter, createWebHashHistory } from 'vue-router'
+import { routes } from './config'
+import { computed } from 'vue'
+import { routesMetas } from './constant'
+
+import type { RoutesName } from './constant'
+
+export const history = createWebHashHistory()
+export const router = createRouter({ history, routes })
+
+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/code.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
+  }
+}

+ 4 - 0
src/shared/index.ts

@@ -0,0 +1,4 @@
+export * from './mount'
+export * from './params'
+export * from './code'
+export * from './diff'

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

+ 1 - 0
src/store/constant.ts

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

+ 8 - 0
src/store/index.ts

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

+ 61 - 0
src/store/room.ts

@@ -0,0 +1,61 @@
+import { fetchRomms, fetchRoomMiniCode } from '@/api'
+import { defineStore } from 'pinia'
+import { TemplateId } from './constant'
+import { useUserStore } from './user'
+
+import { Room as SRoom } from '@/api'
+
+export type { RoomScene } from '@/api'
+export type Rooms = Room[]
+export type Room = SRoom & { miniCode?: string }
+
+export const createRoom = (room: Partial<Room>): Room => {
+  const user = useUserStore().current
+  return {
+    id: TemplateId,
+    userName: user.nickname,
+    leaderName: user.nickname,
+    title: '',
+    time: new Date().toDateString(),
+    viewCount: 0,
+    desc: '',
+    shareUrl: '',
+    cover: '',
+    scenes: [],
+    ...room
+  }
+}
+
+export const useRoomStore = defineStore('room', {
+  state: () => ({
+    list: [] as Rooms
+  }),
+  getters: {
+    filter: state => (keyowrd: string) =>
+      state.list.filter(room => room.title.includes(keyowrd))
+  },
+  actions: {
+    async fetch() {
+      this.list = await fetchRomms()
+    },
+    async delete(room: Room) {
+      const index = this.list.indexOf(room)
+      if (~index) {
+        this.list.splice(index, 1)
+      }
+    },
+    async update(room: Room) {
+      const storeRoom = this.list.find(({ id }) => id === room.id)
+      if (storeRoom) {
+        Object.assign(storeRoom, room)
+      }
+    },
+    async insert(room: Omit<Room, 'id'>) {
+      this.list.push({ ...room, id: -1 })
+    },
+    async setMiniCode(room: Room) {
+      const code = room.miniCode || (await fetchRoomMiniCode(room))
+      room.miniCode = code
+    }
+  }
+})

+ 20 - 0
src/store/scene.ts

@@ -0,0 +1,20 @@
+import { defineStore } from 'pinia'
+import { fetchScenes } from '@/api'
+import type { Scenes } from '@/api'
+
+export type { Scenes, Scene } 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()
+    }
+  }
+})

+ 19 - 0
src/store/user.ts

@@ -0,0 +1,19 @@
+import { fetchUser } from '@/api'
+import { defineStore } from 'pinia'
+
+import type { User } from '@/api'
+
+const defUser: User = {
+  nickname: '游客',
+  phone: '',
+  avatar: 'https://4dkk.4dage.com/head/18819272208/head_1662022947583.png'
+}
+
+export const useUserStore = defineStore('user', {
+  state: () => ({ current: defUser }),
+  actions: {
+    async fetch() {
+      this.current = await fetchUser()
+    }
+  }
+})

+ 12 - 72
src/style.css

@@ -1,79 +1,19 @@
-:root {
-  font-family: Inter, Avenir, Helvetica, Arial, sans-serif;
-  font-size: 16px;
-  line-height: 24px;
-  font-weight: 400;
-  padding-top: 10px;
-  font-weight: normal;
-  padding-bottom: 20px;
-}
-
-a {
-  font-weight: 500;
-  color: #646cff;
-  text-decoration: inherit;
-}
-
-a:hover {
-  color: #535bf2;
-}
-
-body {
-  margin: 0;
-  display: flex;
-  place-items: center;
-  min-width: 320px;
-  min-height: 100vh;
-}
-
-h1 {
-  font-size: 3.2em;
-  line-height: 1.1;
-}
-
-button {
-  border-radius: 8px;
-  border: 1px solid transparent;
-  padding: 0.6em 1.2em;
-  font-size: 1em;
-  font-weight: 500;
-  font-family: inherit;
-  background-color: #1a1a1a;
-  cursor: pointer;
-  transition: border-color 0.25s;
-}
-
-button:hover {
-  border-color: #646cff;
-}
-
-button:focus,
-button:focus-visible {
-  outline: 4px auto -webkit-focus-ring-color;
-}
-
-.card {
-  padding: 2em;
+#app {
+  height: 100%;
 }
 
-#app {
-  max-width: 1280px;
+.content-layout { 
+  width: 100%;
+  max-width: 1340px;
   margin: 0 auto;
-  padding: 2rem;
-  text-align: center;
+  padding: 0 30px;
 }
 
-@media (prefers-color-scheme: light) {
-  :root {
-    color: #213547;
-    background-color: #fff;
-  }
+body .ant-modal-confirm .ant-modal-body {
+  padding: 26px;
+}
 
-  a:hover {
-    color: #747bff;
-  }
 
-  button {
-    background-color: #f9f9f9;
-  }
-}
+body .ant-dropdown {
+  min-width: auto !important;
+}

+ 6 - 0
src/views/room/edit-room/index.ts

@@ -0,0 +1,6 @@
+import EditRoom from './index.vue'
+import { EditRoomProps } from './props'
+
+export { EditRoom }
+export type { EditRoomProps }
+export default EditRoom

+ 213 - 0
src/views/room/edit-room/index.vue

@@ -0,0 +1,213 @@
+<template>
+  <a-modal
+    :visible="visible"
+    title="创建房间"
+    :after-close="onCancel"
+    width="912px"
+    @cancel="visible = false"
+  >
+    <template #footer>
+      <a-button class="action-bottom" size="middle" @click="visible = false">
+        取消
+      </a-button>
+      <a-button
+        class="action-bottom"
+        type="primary"
+        size="middle"
+        @click="saveRoom"
+      >
+        保存
+      </a-button>
+      <a-button
+        class="action-bottom"
+        type="primary"
+        size="middle"
+        @click="onSave && onSave(current)"
+      >
+        开始带看
+      </a-button>
+    </template>
+    <div class="edit-room-layout">
+      <div class="scene">
+        <template v-if="!current.scenes.length">
+          {{ current }}
+        </template>
+        <iframe
+          v-else
+          :src="`https://test.4dkankan.com/spg.html?m=${current.scenes[0].num}`"
+          frameborder="0"
+        />
+      </div>
+      <a-form
+        ref="formRef"
+        class="info"
+        :label-col="{ span: 24 }"
+        :wrapper-col="{ span: 24 }"
+        :model="current"
+      >
+        <h4>房间信息</h4>
+        <a-form-item
+          label="标题"
+          name="title"
+          :rules="[{ required: true, message: '标题为必填字段' }]"
+        >
+          <a-input v-model:value="current.title" :maxlength="15" show-count />
+        </a-form-item>
+        <a-form-item label="简介" name="desc">
+          <a-textarea
+            v-model:value="current.desc"
+            no
+            :maxlength="200"
+            show-count
+          />
+        </a-form-item>
+        <h4>主持人信息</h4>
+        <a-form-item
+          label="昵称"
+          name="leaderName"
+          :rules="[{ required: true, message: '请输入昵称' }]"
+        >
+          <a-input
+            v-model:value="current.leaderName"
+            :maxlength="15"
+            show-count
+          />
+        </a-form-item>
+        <h4>选择场景</h4>
+        <EditScenes
+          :scenes="current.scenes"
+          @delete="deleteScene"
+          @insert="scene => current.scenes.push(scene)"
+        />
+      </a-form>
+    </div>
+  </a-modal>
+</template>
+
+<script lang="ts">
+import { ref, defineComponent, reactive } from 'vue'
+import { createRoom } from '@/store'
+import { props } from './props'
+import EditScenes from './scene-list.vue'
+
+import type { RoomScene } from '@/store'
+import { FormInstance, message } from 'ant-design-vue'
+
+export default defineComponent({
+  name: 'EditRoom',
+  components: { EditScenes },
+  props,
+  setup(props) {
+    const visible = ref(true)
+    const formRef = ref<FormInstance>()
+    const current = reactive(createRoom(props.room || {}))
+    const deleteScene = (scene: RoomScene) => {
+      const index = current.scenes.indexOf(scene)
+      if (~index) {
+        current.scenes.splice(index, 1)
+      }
+    }
+    const saveRoom = async () => {
+      try {
+        await formRef.value?.validate()
+        if (!current.scenes.length) {
+          return message.error('至少添加一个场景')
+        }
+
+        current.cover = current.scenes[0].cover
+        props.onSave && props.onSave(current)
+        visible.value = false
+      } catch (config: any) {
+        if (config && config.errorFields && config.errorFields.length) {
+          message.error(config.errorFields[0].errors.join(','))
+        }
+      }
+    }
+
+    return {
+      visible,
+      current,
+      formRef,
+      deleteScene,
+      saveRoom
+    }
+  }
+})
+</script>
+
+<style lang="scss" scoped>
+.edit-room-layout {
+  display: flex;
+}
+.scene {
+  flex: none;
+  width: 320px;
+  margin-right: 30px;
+  height: 692px;
+  background: #f7f8fa;
+
+  iframe {
+    width: 100%;
+    height: 100%;
+  }
+}
+
+.info {
+  flex: 1;
+
+  h4 {
+    font-size: 18px;
+    color: #333;
+    font-weight: 400;
+
+    &:not(:first-child) {
+      margin-top: 20px;
+    }
+  }
+}
+</style>
+
+<style lang="scss">
+.edit-room-layout {
+  .ant-form-item {
+    margin-bottom: 16px;
+  }
+
+  .ant-input-affix-wrapper,
+  .ant-input {
+    border-radius: 4px 4px 4px 4px;
+    opacity: 1;
+    border: 1px solid #ebedf0;
+    padding: 6px 11px;
+    // height: 36px;
+  }
+
+  textarea.ant-input {
+    resize: none;
+    height: 120px;
+  }
+
+  .ant-input-textarea {
+    position: relative;
+
+    &::after {
+      position: absolute;
+      bottom: 4px;
+      right: 8px;
+      margin: 0;
+    }
+  }
+
+  .ant-form-item-label {
+    padding-bottom: 0;
+    > label {
+      color: #646566;
+    }
+  }
+}
+
+.action-bottom {
+  border-radius: 4px;
+  min-width: 100px;
+}
+</style>

+ 17 - 0
src/views/room/edit-room/props.ts

@@ -0,0 +1,17 @@
+import type { Room } from '@/store'
+import type { PropType, ExtractPropTypes } from 'vue'
+
+export const props = {
+  room: {
+    type: Object as PropType<Room>,
+    required: true
+  },
+  onSave: {
+    type: Function as PropType<(room: Room) => void>
+  },
+  onCancel: {
+    type: Function as PropType<() => void>
+  }
+}
+
+export type EditRoomProps = ExtractPropTypes<typeof props>

+ 158 - 0
src/views/room/edit-room/scene-list.vue

@@ -0,0 +1,158 @@
+<template>
+  <a-list :grid="{ gutter: 20, column: 4 }" :data-source="current">
+    <template #renderItem="{ item }">
+      <a-list-item class="scene-item">
+        <div v-if="item === addMarked" class="add-scene" @click="selectScenes">
+          <a-button shape="circle" class="button" type="primary">
+            <plus-outlined class="add-room-icon" />
+          </a-button>
+        </div>
+
+        <div v-else class="scene-sign">
+          <img :src="item.cover" />
+          <span class="delete-scene" @click="deleteScene(item)">
+            <close-outlined class="delete-scene-icon" />
+          </span>
+          <p>{{ item.title }}</p>
+        </div>
+      </a-list-item>
+    </template>
+  </a-list>
+</template>
+
+<script lang="ts" setup>
+import { computed } from 'vue'
+import { Modal } from 'ant-design-vue'
+import { useSceneStore } from '@/store'
+import { diffArrayChange } from '@/shared'
+import SceneList from '@/views/scene/list.vue'
+
+import type { RoomScene } from '@/store'
+import { renderModal } from '@/helper'
+
+defineOptions<{ name: 'RoomSceneList' }>()
+const props = defineProps<{ scenes: RoomScene[] }>()
+const emit = defineEmits<{
+  (e: 'delete', scene: RoomScene): void
+  (e: 'insert', scene: RoomScene): void
+}>()
+
+const addMarked = Symbol('add-scene')
+const current = computed(() => [addMarked, ...props.scenes])
+const sceneStore = useSceneStore()
+
+const deleteScene = (scene: RoomScene) => {
+  Modal.confirm({
+    content: '删除后无法恢复,是否确认?',
+    title: '删除场景',
+    width: '400px',
+    okText: '删除',
+    icon: null,
+    cancelText: '取消',
+    onOk: () => emit('delete', scene)
+  })
+}
+
+const selectScenes = () => {
+  const existsNums = props.scenes.map(scene => scene.num)
+
+  renderModal(SceneList, {
+    selectNums: existsNums,
+    onSave(selectNums) {
+      const { added, deleted } = diffArrayChange(
+        sceneStore.getScenesByNums(selectNums),
+        sceneStore.getScenesByNums(existsNums)
+      )
+      for (const addScene of added) {
+        emit('insert', addScene)
+      }
+      for (const delScene of deleted) {
+        const parentDelScene = props.scenes.find(
+          scene => scene.num === delScene.num
+        )
+        emit('delete', parentDelScene || delScene)
+      }
+    }
+  })
+}
+</script>
+
+<style lang="scss" scoped>
+.add-scene {
+  border: 1px solid #ebedf0;
+  border-radius: 4px;
+  height: 100%;
+  cursor: pointer;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+
+  .button {
+    width: 40px;
+    height: 40px;
+    background: linear-gradient(144deg, #00aefb 0%, #0076f6 100%);
+  }
+
+  .add-room-icon {
+    font-size: 18px;
+  }
+}
+
+.scene-sign {
+  height: 100%;
+  border-radius: 4px;
+  overflow: hidden;
+  position: relative;
+  img {
+    width: 100%;
+    height: 100%;
+    display: block;
+    object-fit: cover;
+  }
+  p {
+    position: absolute;
+    bottom: 0;
+    left: 0;
+    right: 0;
+    height: 24px;
+    background: rgba(0, 0, 0, 0.5);
+    padding: 5px;
+    font-size: 12px;
+    color: #fff;
+    margin: 0;
+  }
+
+  .delete-scene {
+    position: absolute;
+    right: 0;
+    top: 0;
+    width: 52px;
+    height: 52px;
+    background-color: rgba(0, 0, 0, 0.5);
+    color: #fa5555;
+    font-size: 14px;
+    border-radius: 50%;
+    display: flex;
+    align-items: flex-end;
+    transform: translate(100%, -100%);
+    transition: all 0.3s ease;
+    opacity: 0;
+    cursor: pointer;
+
+    .delete-scene-icon {
+      padding: 10px;
+    }
+  }
+
+  &:hover .delete-scene {
+    transform: translate(50%, -50%);
+    opacity: 1;
+  }
+}
+</style>
+
+<style>
+.scene-item {
+  height: 120px;
+}
+</style>

+ 175 - 0
src/views/room/list.vue

@@ -0,0 +1,175 @@
+<template>
+  <div class="header">
+    <h3>我的房间({{ roomStore.list.length }})</h3>
+    <a-input
+      v-model:value="keyword"
+      placeholder="搜索房间"
+      class="room-search"
+      allow-clear
+    >
+      <template #prefix><search-outlined class="room-search-icon" /></template>
+    </a-input>
+  </div>
+
+  <DataList
+    :data-source="roomList.filter(room => room !== addMarked)"
+    :keyword="keyword"
+    name="作品"
+  >
+    <template #undata>
+      <a-button type="primary" shape="round" size="middle" @click="editRoom()">
+        创建作品
+      </a-button>
+    </template>
+    <a-list :grid="{ gutter: 20, column: 5 }" :data-source="roomList">
+      <template #renderItem="{ item }">
+        <a-list-item>
+          <RoomSign
+            v-if="item !== addMarked"
+            :room="item"
+            @web-sync="webSyncRoom(item)"
+            @delete="deleteRoom(item)"
+            @share="shareRoom(item)"
+            @mini-sync="miniSyncRoom(item)"
+            @edit="editRoom(item)"
+          />
+          <a-card v-else class="add-room" hoverable @click="editRoom()">
+            <a-button shape="circle" class="button" type="primary">
+              <plus-outlined class="add-room-icon" />
+            </a-button>
+            <p>创建作品</p>
+          </a-card>
+        </a-list-item>
+      </template>
+    </a-list>
+  </DataList>
+</template>
+
+<script setup lang="ts">
+import { useRoomStore } from '@/store'
+import { ref, computed, createVNode } from 'vue'
+import { message, Modal } from 'ant-design-vue'
+import { copyText } from '@/shared'
+import { renderModal } from '@/helper'
+import EditRoom from './edit-room'
+import RoomSign from './sign.vue'
+import Share from './modal/share.vue'
+import MiniSync from './modal/mini-sync.vue'
+import DataList from '@/components/data-list/index.vue'
+
+import type { Room } from '@/store'
+
+defineOptions({ name: 'RoomList' })
+
+const addMarked = Symbol('add-room')
+const roomStore = useRoomStore()
+roomStore.fetch()
+
+const keyword = ref('')
+const roomList = computed(() => [addMarked, ...roomStore.filter(keyword.value)])
+
+const deleteRoom = (room: Room) => {
+  Modal.confirm({
+    content: '删除后无法恢复,是否确认?',
+    title: '删除作品',
+    width: '400px',
+    okText: '删除',
+    icon: null,
+    cancelText: '取消',
+    onOk: () => roomStore.delete(room)
+  })
+}
+const shareRoom = async (room: Room) => {
+  await roomStore.setMiniCode(room)
+  Modal.confirm({
+    content: createVNode(Share, { room }),
+    title: '分享',
+    icon: null,
+    width: '500px',
+    okText: '复制链接',
+    cancelText: '取消',
+    onOk: async (room: Room) => {
+      await copyText(room.shareUrl)
+      message.success('链接复制成功')
+    }
+  })
+}
+const miniSyncRoom = async (room: Room) => {
+  await roomStore.setMiniCode(room)
+  Modal.info({
+    content: createVNode(MiniSync, { room }),
+    title: '小程序带看',
+    width: '500px',
+    icon: null,
+    okText: '确定',
+    cancelText: null
+  })
+}
+const webSyncRoom = (room: Room) => window.open(room.shareUrl)
+
+const editRoom = (room?: Room) => {
+  renderModal(EditRoom, {
+    room,
+    onSave(actionRoom) {
+      if (room) {
+        roomStore.update(actionRoom)
+      } else {
+        roomStore.insert(actionRoom)
+      }
+    }
+  })
+}
+</script>
+
+<style scoped lang="scss">
+.header {
+  padding: 0 30px;
+  height: 80px;
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  background-color: #fff;
+  margin: 30px 0;
+
+  .room-search {
+    width: 290px;
+    height: 40px;
+    border-radius: 20px;
+  }
+
+  .room-search-icon {
+    color: #cfd0d3;
+  }
+}
+
+.add-room {
+  height: 321px;
+  cursor: pointer;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+
+  .button {
+    width: 60px;
+    height: 60px;
+    background: linear-gradient(144deg, #00aefb 0%, #0076f6 100%);
+  }
+
+  .add-room-icon {
+    font-size: 24px;
+  }
+
+  p {
+    font-size: 14px;
+    color: #333;
+    margin-top: 10px;
+  }
+}
+</style>
+
+<style>
+.room-search,
+.room-search input {
+  background: #f7f8fa;
+}
+</style>

+ 32 - 0
src/views/room/modal/mini-sync.vue

@@ -0,0 +1,32 @@
+<template>
+  <div class="mini-code">
+    <img :src="room.miniCode" />
+    <p>打开微信“扫一扫”</p>
+    <p>进入微信小程序开始带看</p>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { Room } from '@/store'
+
+defineOptions<{ name: 'MiniSync' }>()
+defineProps<{ room: Room }>()
+</script>
+
+<style lang="scss" scoped>
+.mini-code {
+  text-align: center;
+  img {
+    width: 216px;
+    height: 216px;
+    margin: 16px 30px;
+  }
+
+  p {
+    font-size: 14px;
+    color: #666666;
+    line-height: 20px;
+    margin: 0;
+  }
+}
+</style>

+ 31 - 0
src/views/room/modal/share.vue

@@ -0,0 +1,31 @@
+<template>
+  <a-form
+    :label-col="{ span: 5 }"
+    :wrapper-col="{ span: 19 }"
+    class="share-form"
+  >
+    <a-form-item label="作品链接">
+      <a-input disabled :value="room.shareUrl" />
+    </a-form-item>
+    <a-form-item label="作品葵花码">
+      <img :src="room.miniCode" class="mini-code" />
+    </a-form-item>
+  </a-form>
+</template>
+
+<script lang="ts" setup>
+import type { Room } from '@/store'
+
+defineOptions({ name: 'RoomShare' })
+defineProps<{ room: Room }>()
+</script>
+
+<style lang="scss" scoped>
+.share-form {
+  margin: 20px 0;
+}
+.mini-code {
+  width: 120px;
+  height: 120px;
+}
+</style>

+ 183 - 0
src/views/room/sign.vue

@@ -0,0 +1,183 @@
+<template>
+  <a-card hoverable class="room-card">
+    <template #cover>
+      <div class="room-cover">
+        <img alt="example" :src="room.cover" />
+        <div class="action">
+          <a-button
+            class="botton"
+            shape="round"
+            type="ghost"
+            size="large"
+            @click="$emit('miniSync')"
+          >
+            小程序带看
+          </a-button>
+          <a-button
+            class="botton"
+            shape="round"
+            type="ghost"
+            size="large"
+            @click="$emit('webSync')"
+          >
+            网页带看
+          </a-button>
+          <div class="more">
+            <span style="--hover-color: #0076f6" @click="$emit('edit')">
+              <edit-outlined />编辑
+            </span>
+            <span
+              style="--hover-color: rgba(255, 255, 255, 0.5)"
+              @click="$emit('share')"
+            >
+              <share-alt-outlined />分享
+            </span>
+            <span style="--hover-color: #fa5555" @click="$emit('delete')">
+              <delete-outlined />删除
+            </span>
+          </div>
+        </div>
+      </div>
+    </template>
+    <div class="room-meta">
+      <a-popover v-if="room.title.length > 14" placement="bottom">
+        <template #content>
+          <div class="room-title">{{ room.title }}</div>
+        </template>
+        <h4>{{ room.title }}</h4>
+      </a-popover>
+      <h4 v-else>{{ room.title }}</h4>
+      <div class="desc">
+        <span>{{ room.time }}</span>
+        <span><eye-outlined />{{ room.viewCount }}</span>
+      </div>
+    </div>
+  </a-card>
+</template>
+
+<script lang="ts" setup>
+import type { Room } from '@/store'
+
+type RoomSignProps = { room: Room }
+type RoomSignEmit = {
+  (e: 'share'): void
+  (e: 'edit'): void
+  (e: 'delete'): void
+  (e: 'miniSync'): void
+  (e: 'webSync'): void
+}
+
+defineProps<RoomSignProps>()
+defineEmits<RoomSignEmit>()
+</script>
+
+<style scoped lang="scss">
+.room-cover {
+  height: 240px;
+  position: relative;
+  overflow: hidden;
+
+  img {
+    width: 100%;
+    height: 100%;
+    object-fit: cover;
+  }
+
+  .action {
+    transition: all 0.3s ease;
+    position: absolute;
+    top: 100%;
+    left: 0;
+    right: 0;
+    height: 100%;
+    background: rgba(0, 0, 0, 0.7);
+    opacity: 0;
+    z-index: 1;
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+    justify-content: center;
+
+    .botton {
+      width: 130px;
+      text-align: center;
+      color: #fff;
+      margin: 5px;
+
+      &:hover {
+        background-color: #0076f6;
+        border-color: #0076f6;
+      }
+    }
+
+    .more {
+      position: absolute;
+      bottom: 0;
+      left: 0;
+      right: 0;
+      display: flex;
+      justify-content: space-between;
+      padding: 10px;
+
+      > span {
+        font-size: 13px;
+        color: #fff;
+        transition: color 0.3s ease;
+
+        span {
+          margin-right: 4px;
+        }
+        &:hover {
+          color: var(--hover-color);
+        }
+      }
+    }
+  }
+}
+
+.room-meta {
+  h4 {
+    font-size: 16px;
+    color: #323233;
+    line-height: 1em;
+    margin-bottom: 10px;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+  }
+
+  .desc {
+    font-size: 14px;
+    color: #969799;
+    display: flex;
+    justify-content: space-between;
+  }
+}
+
+.room-title {
+  word-wrap: break-word;
+  max-width: 200px;
+}
+
+.room-card,
+.room-cover .action {
+  transition: all 0.3s ease;
+}
+
+.room-card.active,
+.room-card:hover {
+  transform: translateY(-10px);
+  .room-cover .action {
+    top: 0;
+    opacity: 1;
+  }
+}
+</style>
+
+<style lang="scss">
+.room-card {
+  .ant-card-body {
+    padding: 16px 14px;
+  }
+}
+</style>

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

@@ -0,0 +1,124 @@
+<template>
+  <a-modal
+    v-model:visible="visible"
+    title="选择场景"
+    width="600px"
+    ok-text="确定"
+    cancel-text="取消"
+    :after-close="onCancel"
+    @ok="saveHandler"
+  >
+    <div class="scene-modal">
+      <a-input v-model:value="keyword" placeholder="输入关键词" allow-clear>
+        <template #suffix>
+          <search-outlined class="search-icon" />
+        </template>
+      </a-input>
+      <DataList :data-source="filterScenes" :keyword="keyword" name="场景">
+        <div class="scene-list">
+          <a-table
+            row-key="num"
+            :columns="sceneColumns"
+            :data-source="filterScenes"
+            :pagination="false"
+            :row-selection="{
+              selectedRowKeys: selectedSceneKeys,
+              onChange: keys => (selectedSceneKeys = keys as SceneKey[])
+            }"
+          >
+            <template #bodyCell="{ column, record }">
+              <template v-if="column.key === 'cover'">
+                <img :src="record.cover" />
+              </template>
+            </template>
+          </a-table>
+        </div>
+      </DataList>
+    </div>
+  </a-modal>
+</template>
+
+<script lang="ts" setup>
+import { ref, computed } from 'vue'
+import { useSceneStore } from '@/store'
+import DataList from '@/components/data-list/index.vue'
+import type { Scene } from '@/store'
+
+defineOptions<{ name: 'SceneList' }>()
+
+type SceneKey = Scene['num']
+
+const props = defineProps<{
+  selectNums: SceneKey[]
+  onSave?: (selectIds: SceneKey[]) => void
+  onCancel: () => void
+}>()
+
+const keyword = ref('')
+const visible = ref(true)
+const sceneStore = useSceneStore()
+const filterScenes = computed(() =>
+  sceneStore.list.filter(scene => scene.title.includes(keyword.value))
+)
+
+sceneStore.fetch()
+
+const sceneColumns = [
+  {
+    title: '场景',
+    dataIndex: 'cover',
+    key: 'cover'
+  },
+  {
+    title: '名称',
+    dataIndex: 'title',
+    key: 'title'
+  },
+  {
+    title: '时间',
+    dataIndex: 'time',
+    key: 'time'
+  }
+]
+const selectedSceneKeys = ref<SceneKey[]>(props.selectNums)
+const saveHandler = () => {
+  props.onSave && props.onSave(selectedSceneKeys.value)
+  visible.value = false
+}
+</script>
+
+<style scoped lang="scss">
+.scene-list {
+  margin-top: 20px;
+  border: 1px solid #ebedf0;
+  border-bottom: none;
+}
+
+.search-icon {
+  color: #1890ff;
+  font-size: 18px;
+}
+</style>
+
+<style lang="scss">
+.scene-modal {
+  .ant-input-affix-wrapper {
+    border-radius: 4px 4px 4px 4px;
+    opacity: 1;
+    border: 1px solid #ebedf0;
+    height: 36px;
+  }
+
+  .ant-table-thead > tr > th {
+    height: 40px;
+    padding-top: 0;
+    padding-bottom: 0;
+    font-weight: 400;
+    color: #646566;
+
+    &::before {
+      display: none;
+    }
+  }
+}
+</style>

+ 7 - 5
src/vite-env.d.ts

@@ -2,10 +2,12 @@
 
 declare module '*.vue' {
   import type { DefineComponent } from 'vue'
-  const component: DefineComponent<
-    Record<string, unknown>,
-    Record<string, unknown>,
-    any
-  >
+  const component: DefineComponent<{}, {}, unknown>
   export default component
 }
+
+interface ComponentConstructor<P = any> {
+  new (...args: any[]): {
+    $props: P
+  }
+}

+ 6 - 1
tsconfig.json

@@ -11,7 +11,12 @@
     "isolatedModules": true,
     "esModuleInterop": true,
     "lib": ["ESNext", "DOM"],
-    "skipLibCheck": true
+    "skipLibCheck": true,
+    "types": ["unplugin-vue-define-options/macros-global"],
+    "baseUrl": "./",
+    "paths": {
+      "@/*": ["src/*"]
+    }
   },
   "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
   "references": [{ "path": "./tsconfig.node.json" }]

+ 45 - 7
vite.config.ts

@@ -1,17 +1,55 @@
 import { defineConfig } from 'vite'
 import vue from '@vitejs/plugin-vue'
-import usePluginImport from 'vite-plugin-importer'
+import ViteComponents from 'unplugin-vue-components/vite'
+import { AntDesignVueResolver } from 'unplugin-vue-components/resolvers'
+import DefineOptions from 'unplugin-vue-define-options/vite'
 
-window.queueMicrotask
+import { resolve } from 'path'
+
+const proxy = {
+  '/api': {
+    target: 'http://192.168.0.38:8818',
+    changeOrigin: true,
+    rewrite: path => path.replace(/^\/local/, '')
+  }
+}
 
 // https://vitejs.dev/config/
 export default defineConfig({
   plugins: [
     vue(),
-    usePluginImport({
-      libraryName: 'ant-design-vue',
-      libraryDirectory: 'es',
-      style: 'css'
+    DefineOptions(),
+    ViteComponents({
+      resolvers: [
+        AntDesignVueResolver({ resolveIcons: true, importStyle: 'less' })
+      ],
+      dts: 'src/components.d.ts'
     })
-  ]
+  ],
+  css: {
+    preprocessorOptions: {
+      less: {
+        javascriptEnabled: true,
+        modifyVars: {
+          'primary-color': '#0076F6',
+          'link-color': '#0076F6',
+          'border-radius-base': '2px'
+        }
+      }
+    }
+  },
+  resolve: {
+    alias: [
+      {
+        find: '@',
+        replacement: resolve(__dirname, './src')
+      }
+    ]
+  },
+  server: {
+    host: '0.0.0.0',
+    port: 5173,
+    open: true,
+    proxy: proxy
+  }
 })

Разлика између датотеке није приказан због своје велике величине
+ 2416 - 0
yarn.lock