bill 8 달 전
부모
커밋
8abafbc133

+ 4 - 0
src/api/constant.ts

@@ -96,3 +96,7 @@ export const FLODER_LIST = `/fusion/caseFiles/allList`
 
 // 文件上传
 export const UPLOAD_FILE = `/fusion/upload/file`
+
+// 素材库分页
+export const MATERIAL_PAG = `/fusion/material/allList`
+export const MATERIAL_GROUP_LIST = `/fusion/material/allList`

+ 73 - 0
src/api/material.ts

@@ -0,0 +1,73 @@
+import { asyncTimeout } from "@/utils";
+import { PagingRequest, PagingResult } from ".";
+import { MATERIAL_GROUP_LIST, MATERIAL_PAG } from "./constant";
+import axios from "./instance";
+
+export type MaterialGroup = {
+  id: number;
+  name: string;
+};
+
+export type Material = {
+  id: number;
+  name: string;
+  format: string;
+  url: string;
+  size: number;
+  groupId: number;
+  group: string;
+};
+
+export type MaterialPageProps = PagingRequest<Partial<Material> & {groupIds: number[]}>;
+export const fetchMaterialPage = async (params: MaterialPageProps) => {
+  await asyncTimeout(160)
+  let materials: PagingResult<Material[]> = {
+    ...params,
+    total: 100,
+    list: [
+      {
+        id: params.pageNum,
+        name: "test",
+        format: params.format || "png",
+        url: "icon/h_default_64.png",
+        groupId: 1,
+        group: "分组1",
+        size: 1024,
+      },
+      {
+        id: params.pageNum + 100,
+        name: "test",
+        format: params.format || "mp3",
+        url: "icon/h_default_64.png",
+        groupId: 1,
+        group: "分组1",
+        size: 1024,
+      },
+      {
+        id: params.pageNum + 200,
+        name: "test",
+        format: params.format || "mp4",
+        url: "icon/h_default_64.png",
+        groupId: 1,
+        group: "分组1",
+        size: 1024,
+      },
+    ],
+  };
+  if (params.groupIds) {
+    materials.list =  materials.list.filter(i => params.groupIds.includes(i.groupId))
+  }
+  return materials
+  // const material = await axios.get<PagingResult<Material[]>>(MATERIAL_PAG, { params })
+  // return material
+};
+
+export const fetchMaterialGroups = async () => {
+  await asyncTimeout(160)
+  const groups: MaterialGroup[] = [
+    {id: 1, name: '分组1'},
+    {id: 2, name: '分组2'},
+  ]
+  return groups
+  // return axios.get<MaterialGroup[]>(MATERIAL_GROUP_LIST);
+};

+ 4 - 0
src/api/tagging.ts

@@ -24,6 +24,7 @@ interface ServerTagging {
   "tagTitle": string,
 
   show3dTitle: boolean
+  audio: string
 
 }
 
@@ -37,6 +38,7 @@ export interface Tagging {
   method: string
   principal: string
   images: string[],
+  audio: string
 }
 
 export type Taggings = Tagging[]
@@ -51,6 +53,7 @@ const serviceToLocal = (serviceTagging: ServerTagging): Tagging => ({
   show3dTitle: serviceTagging.show3dTitle,
   method: serviceTagging.getMethod,
   principal: serviceTagging.getUser,
+  audio: serviceTagging.audio,
   images: JSON.parse(serviceTagging.tagImgUrl),
 })
 
@@ -66,6 +69,7 @@ const localToService = (tagging: Tagging, update = false): PartialProps<ServerTa
   "leaveBehind": tagging.part,
   "tagDescribe": tagging.desc,
   "tagTitle": tagging.title,
+  audio: tagging.audio
 })
 
 

+ 9 - 13
src/app.vue

@@ -1,5 +1,5 @@
 <template>
-  <ConfigProvider :theme="token">
+  <ConfigProvider v-bind="config">
     <ui-editor-layout
       @click.stop
       id="layout-app"
@@ -17,6 +17,12 @@
     </ui-editor-layout>
 
     <PwdModel v-if="inputPwd" @close="inputPwd = false" />
+
+    <template v-for="needMount in needMounts">
+      <Teleport :to="needMount[0]">
+        <component :is="needMount[1]" v-bind="needMount[2]" />
+      </Teleport>
+    </template>
   </ConfigProvider>
 </template>
 
@@ -25,20 +31,10 @@ import { custom, params } from "@/env";
 import { computed, ref, watch, watchEffect, nextTick } from "vue";
 import { isEdit, prefix, appEl, initialSetting, caseProject, refreshCase } from "@/store";
 import router, { currentLayout, RoutesName } from "./router";
-import { loadPack } from "@/utils";
+import { loadPack, needMounts } from "@/utils";
 import { ConfigProvider } from "ant-design-vue";
 import PwdModel from "@/layout/pwd.vue";
-import { theme } from "ant-design-vue";
-
-const token = {
-  algorithm: theme.darkAlgorithm,
-  token: {
-    colorPrimary: "#00c8af",
-    fontSize: 14,
-    wireframe: true,
-    colorInfo: "#00c8af",
-  },
-};
+import { config } from "./config";
 
 const loaded = ref(false);
 const inputPwd = ref(false);

+ 192 - 164
src/components/bill-ui/components/input/file.vue

@@ -1,194 +1,222 @@
 <template>
-    <div class="input file" :class="{ suffix: $slots.icon, disabled: disabled, valuable }">
-        <template v-if="valuable">
-            <slot name="valuable" :key="modelValue" />
-        </template>
-        <input class="ui-text" type="file" ref="inputRef" :accept="accept" :multiple="multiple" @change="selectFileHandler" v-if="!maxLen || maxLen > modelValue.length" />
-        <template v-if="!$slots.replace">
-            <span class="replace">
-                <div class="placeholder" v-if="!valuable">
-                    <p><ui-icon type="add" /></p>
-                    <p>{{ placeholder }}</p>
-                    <p class="bottom">
-                        <template v-if="!othPlaceholder">
-                            <template v-if="accept">支持 {{ accept }} 等格式,</template>
-                            <template v-if="normalizeScale">宽*高比例 {{ scale }},</template>
-                            <template v-if="maxSize">大小不超过 {{ sizeStr }}{{ maxLen ? ',' : '' }}</template>
-                            <template v-if="maxLen">个数不超过 {{ maxLen }}个</template>
-                        </template>
-                        <template v-else>
-                            {{ othPlaceholder }}
-                        </template>
-                    </p>
-                </div>
-
-                <span v-else v-if="!maxLen || maxLen > modelValue.length">
-                    {{ multiple ? '继续添加' : '替换' }}
-                </span>
-                <span class="tj" v-if="maxLen && modelValue.length">
-                    <span>{{ modelValue.length || 0 }}</span> / {{ maxLen }}
-                </span>
-            </span>
-        </template>
-        <div class="use-replace" v-else>
-            <slot name="replace" />
+  <div
+    class="input file"
+    :class="{ suffix: $slots.icon, disabled: disabled, valuable: valuable }"
+  >
+    <template v-if="valuable">
+      <slot name="valuable" :key="modelValue" />
+    </template>
+    <input
+      class="ui-text"
+      type="file"
+      ref="inputRef"
+      :accept="accept"
+      :multiple="multiple"
+      @change="selectFileHandler"
+      v-if="!maxLen || maxLen > modelValue.length"
+    />
+    <template v-if="!$slots.replace">
+      <span class="replace">
+        <div class="placeholder" v-if="!valuable">
+          <template v-if="!disablePlaceholder">
+            <p><ui-icon type="add" /></p>
+            <p>{{ placeholder }}</p>
+          </template>
+          <p class="bottom">
+            <template v-if="!othPlaceholder">
+              <template v-if="accept">支持 {{ accept }} 等格式,</template>
+              <template v-if="normalizeScale">宽*高比例 {{ scale }},</template>
+              <template v-if="maxSize"
+                >大小不超过 {{ sizeStr }}{{ maxLen ? "," : "" }}</template
+              >
+              <template v-if="maxLen">个数不超过 {{ maxLen }}个</template>
+            </template>
+            <template v-else>
+              {{ othPlaceholder }}
+            </template>
+          </p>
         </div>
+
+        <span v-else v-if="!maxLen || maxLen > modelValue.length">
+          {{ multiple ? "继续添加" : "替换" }}
+        </span>
+        <span class="tj" v-if="maxLen && modelValue.length">
+          <span>{{ modelValue.length || 0 }}</span> / {{ maxLen }}
+        </span>
+      </span>
+    </template>
+    <div class="use-replace" v-else>
+      <slot name="replace" />
     </div>
+  </div>
 </template>
 
 <script setup>
-import { filePropsDesc } from './state'
-import { toRawType } from '../../utils'
-import Message from '../message'
-import { ref, computed } from 'vue'
+import { filePropsDesc } from "./state";
+import { toRawType } from "../../utils";
+import Message from "../message";
+import { ref, computed } from "vue";
 
 const props = defineProps({
-    ...filePropsDesc,
-})
-const emit = defineEmits(['update:modelValue'])
-const inputRef = ref(null)
+  ...filePropsDesc,
+});
+const emit = defineEmits(["update:modelValue"]);
+const inputRef = ref(null);
 const normalizeScale = computed(() => {
-    if (props.scale) {
-        const [w, h] = props.scale.split(':')
-        if (Number(w) && Number(h)) {
-            return [Number(w), Number(h)]
-        }
+  if (props.scale) {
+    const [w, h] = props.scale.split(":");
+    if (Number(w) && Number(h)) {
+      return [Number(w), Number(h)];
     }
-})
+  }
+});
 
-const valuable = computed(() => (Array.isArray(props.modelValue) ? props.modelValue.length : !!props.modelValue))
+const valuable = computed(() =>
+  Array.isArray(props.modelValue) ? props.modelValue.length : !!props.modelValue
+);
 const sizeStr = computed(() => {
-    if (props.maxSize) {
-        const mb = props.maxSize / 1024 / 1024
-        if (mb > 1024) {
-            return mb / 1024 + 'GB'
-        } else {
-            return mb + 'MB'
-        }
+  if (props.maxSize) {
+    const mb = props.maxSize / 1024 / 1024;
+    if (mb > 1024) {
+      return mb / 1024 + "GB";
+    } else {
+      return mb + "MB";
     }
-})
+  }
+});
 
 const supports = {
-    image: {
-        types: ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp'],
-        preview(file, url) {
-            return new Promise((resolve, reject) => {
-                const img = new Image()
-                img.onload = () => resolve([img.width, img.height, file])
-                img.onerror = reject
-                img.src = url
-            })
-        },
+  image: {
+    types: ["image/jpeg", "image/jpg", "image/png", "image/gif", "image/webp"],
+    preview(file, url) {
+      return new Promise((resolve, reject) => {
+        const img = new Image();
+        img.onload = () => resolve([img.width, img.height, file]);
+        img.onerror = reject;
+        img.src = url;
+      });
     },
-    video: {
-        types: ['video/mp4'],
-        preview(file, url) {
-            return new Promise((resolve, reject) => {
-                const video = document.createElement('video')
-                video.preload = 'metadata'
-                video.onloadedmetadata = () => resolve([video.videoWidth, video.videoHeight, file])
-                video.onerror = reject
-                video.src = url
-            })
-        },
+  },
+  video: {
+    types: ["video/mp4"],
+    preview(file, url) {
+      return new Promise((resolve, reject) => {
+        const video = document.createElement("video");
+        video.preload = "metadata";
+        video.onloadedmetadata = () =>
+          resolve([video.videoWidth, video.videoHeight, file]);
+        video.onerror = reject;
+        video.src = url;
+      });
     },
-}
-
-const producePreviews = files =>
-    Promise.all(
-        files.map(
-            file =>
-                new Promise((resolve, reject) => {
-                    const fr = new FileReader()
-                    fr.onloadend = e => resolve(e.target.result)
-                    fr.onerror = e => loaderror(file, reject(e))
-                    fr.readAsDataURL(file)
-                })
-        )
+  },
+};
+
+const producePreviews = (files) =>
+  Promise.all(
+    files.map(
+      (file) =>
+        new Promise((resolve, reject) => {
+          const fr = new FileReader();
+          fr.onloadend = (e) => resolve(e.target.result);
+          fr.onerror = (e) => loaderror(file, reject(e));
+          fr.readAsDataURL(file);
+        })
     )
-
-const calcScale = (w, h) => parseInt((w / h) * 1000)
-
-const selectFileHandler = async ev => {
-    const fileEl = ev.target
-    const files = Array.from(fileEl.files)
-    const previewError = (e, msg = `预览加载失败!`) => {
-        console.error(e)
-        Message.error(msg)
-        fileEl.value = ''
+  );
+
+const calcScale = (w, h) => parseInt((w / h) * 1000);
+
+const selectFileHandler = async (ev) => {
+  const fileEl = ev.target;
+  const files = Array.from(fileEl.files);
+  const previewError = (e, msg = `预览加载失败!`) => {
+    console.error(e);
+    Message.error(msg);
+    fileEl.value = "";
+  };
+
+  if (props.accept) {
+    for (const file of files) {
+      const accepts = props.accept.split(",").map((atom) => atom.trim().toUpperCase());
+      const hname = file.name.substr(file.name.lastIndexOf(".")).toUpperCase();
+      if (!accepts.includes(hname)) {
+        return previewError("格式错误", `仅支持${props.accept}格式文件`);
+      }
     }
-
-    if (props.accept) {
-        for (const file of files) {
-            const accepts = props.accept.split(',').map(atom => atom.trim().toUpperCase())
-            const hname = file.name.substr(file.name.lastIndexOf('.')).toUpperCase()
-            if (!accepts.includes(hname)) {
-                return previewError('格式错误', `仅支持${props.accept}格式文件`)
-            }
-        }
+  }
+
+  let previews;
+  if (props.preview || normalizeScale.value) {
+    try {
+      previews = await producePreviews(files);
+    } catch (e) {
+      return previewError(e);
     }
-
-    let previews
-    if (props.preview || normalizeScale.value) {
-        try {
-            previews = await producePreviews(files)
-        } catch (e) {
-            return previewError(e)
-        }
+  }
+
+  if (normalizeScale.value) {
+    const sizesConfirm = [];
+    for (let i = 0; i < files.length; i++) {
+      const support = Object.values(supports).find((support) =>
+        support.types.includes(files[i].type)
+      );
+      if (support) {
+        sizesConfirm.push(support.preview(files[i], previews[i]));
+      }
     }
 
-    if (normalizeScale.value) {
-        const sizesConfirm = []
-        for (let i = 0; i < files.length; i++) {
-            const support = Object.values(supports).find(support => support.types.includes(files[i].type))
-            if (support) {
-                sizesConfirm.push(support.preview(files[i], previews[i]))
-            }
-        }
-
-        let sizes
-        try {
-            sizes = await Promise.all(sizesConfirm)
-        } catch (e) {
-            return previewError(e)
-        }
-
-        for (const [w, h, file] of sizes) {
-            const scaleDiff = calcScale(...normalizeScale.value) - calcScale(w, h)
-
-            if (Math.abs(scaleDiff) > 300) {
-                return previewError('error scale', `${file.name}的比例部位不为${props.scale}`)
-            }
-        }
+    let sizes;
+    try {
+      sizes = await Promise.all(sizesConfirm);
+    } catch (e) {
+      return previewError(e);
     }
 
-    if (props.maxSize) {
-        for (const file of files) {
-            if (file.size > props.maxSize) {
-                return previewError('error size', `${file.name}的大小超过${sizeStr.value}`)
-            }
-        }
-    }
-
-    const value = props.modelValue ? (props.multiple ? (toRawType(props.modelValue) === 'Array' ? props.modelValue : [props.modelValue]) : null) : props.multiple ? [] : null
+    for (const [w, h, file] of sizes) {
+      const scaleDiff = calcScale(...normalizeScale.value) - calcScale(w, h);
 
-    const emitData = props.multiple
-        ? props.preview
-            ? [...value, ...files.map((file, i) => ({ file, preview: previews[i] }))]
-            : [...value, files]
-        : props.preview
-        ? { file: files[0], preview: previews[0] }
-        : files[0]
-
-    if (Array.isArray(emitData) && props.maxLen && emitData.length > props.maxLen) {
-        return previewError('err len', `最多仅支持${props.maxLen}个文件!`)
+      if (Math.abs(scaleDiff) > 300) {
+        return previewError("error scale", `${file.name}的比例部位不为${props.scale}`);
+      }
     }
+  }
 
-    emit('update:modelValue', emitData)
-    fileEl.value = ''
-}
+  if (props.maxSize) {
+    for (const file of files) {
+      if (file.size > props.maxSize) {
+        return previewError("error size", `${file.name}的大小超过${sizeStr.value}`);
+      }
+    }
+  }
+
+  const value = props.modelValue
+    ? props.multiple
+      ? toRawType(props.modelValue) === "Array"
+        ? props.modelValue
+        : [props.modelValue]
+      : null
+    : props.multiple
+    ? []
+    : null;
+
+  const emitData = props.multiple
+    ? props.preview
+      ? [...value, ...files.map((file, i) => ({ file, preview: previews[i] }))]
+      : [...value, files]
+    : props.preview
+    ? { file: files[0], preview: previews[0] }
+    : files[0];
+
+  if (Array.isArray(emitData) && props.maxLen && emitData.length > props.maxLen) {
+    return previewError("err len", `最多仅支持${props.maxLen}个文件!`);
+  }
+
+  emit("update:modelValue", emitData);
+  fileEl.value = "";
+};
 
 defineExpose({
-    input: inputRef,
-})
+  input: inputRef,
+});
 </script>

+ 4 - 0
src/components/bill-ui/components/input/state.js

@@ -29,6 +29,10 @@ export const colorPropsDesc = {
 
 export const filePropsDesc = {
     ...instalcePublic,
+    disablePlaceholder: {
+        require: false,
+        default: false,
+    },
     placeholder: {
         require: false,
         default: '请选择',

+ 190 - 2
src/components/materials/index.vue

@@ -1,3 +1,191 @@
 <template>
-  
-</template>
+  <Modal
+    width="800px"
+    title="媒体库"
+    :open="visible"
+    @ok="okHandler"
+    :afterClose="afterClose"
+    @cancel="emit('update:visible', false)"
+    okText="确定"
+    cancelText="取消"
+    class="model-table"
+  >
+    <div>
+      <div className="model-header">
+        <p class="header-desc">
+          已选择数据<span>( {{ rowSelection.selectedRowKeys.length }} )</span>
+        </p>
+        <Search
+          v-if="Object.keys(allData).length"
+          className="content-header-search"
+          placeholder="输入名称搜索"
+          v-model:value="params.name"
+          allow-clear
+          style="width: 244px"
+        />
+      </div>
+
+      <div class="table-layout">
+        <Table
+          v-if="Object.keys(allData).length"
+          :row-key="(record: Material) => record.id"
+          :columns="cloumns"
+          :rowSelection="rowSelection"
+          :data-source="origin.list"
+          :pagination="{ ...origin, current: origin.pageNum }"
+          @change="handleTableChange"
+        />
+        <div style="padding: 1px" v-else>
+          <Empty
+            description="暂无结果"
+            :image="Empty.PRESENTED_IMAGE_SIMPLE"
+            className="ant-empty ant-empty-normal"
+          />
+        </div>
+      </div>
+    </div>
+  </Modal>
+</template>
+
+<script lang="ts" setup>
+import { Modal, Input, Table, Empty, TableProps } from "ant-design-vue";
+import { computed, reactive, ref, watch } from "vue";
+import { createLoadPack, debounce, debounceStack } from "@/utils";
+import type { PagingResult, Scene } from "@/api";
+import {
+  fetchMaterialGroups,
+  fetchMaterialPage,
+  Material,
+  MaterialGroup,
+  MaterialPageProps,
+} from "@/api/material";
+import Message from "bill/components/message/message.vue";
+
+const props = defineProps<{
+  format?: string[];
+  maxSize?: number;
+  visible: boolean;
+  count?: number;
+  afterClose?: () => void;
+}>();
+
+const emit = defineEmits<{
+  (e: "update:visible", v: boolean): void;
+  (e: "selectMaterials", v: Material[]): void;
+}>();
+
+const Search = Input.Search;
+const params = reactive({ pageNum: 1, pageSize: 12 }) as MaterialPageProps;
+const origin = ref<PagingResult<Material[]>>({
+  list: [],
+  pageNum: 1,
+  pageSize: 12,
+  total: 0,
+});
+const groups = ref<MaterialGroup[]>([]);
+const selectKeys = ref<Material["id"][]>([]);
+const allData: Record<Material["id"], Material> = reactive({});
+
+const rowSelection: any = ref({
+  selectedRowKeys: selectKeys,
+  onChange: (ids: number[]) => {
+    const otherPageKeys = selectKeys.value.filter(
+      (key) => !origin.value.list.some((item) => key === item.id)
+    );
+    const newKeys = Array.from(new Set([...otherPageKeys, ...ids]));
+    if (!props.count || props.count >= newKeys.length) {
+      selectKeys.value = newKeys;
+    } else {
+      Message.error(`最多选择${props.count}项`);
+    }
+  },
+  getCheckboxProps: (record: Material) => {
+    return {
+      disabled:
+        props.format &&
+        !props.format.includes(record.format) &&
+        props.maxSize &&
+        record.size > props.maxSize,
+    };
+  },
+});
+const cloumns = computed(() => [
+  {
+    width: "400px",
+    title: "图片名称",
+    dataIndex: "name",
+    key: "name",
+  },
+  {
+    title: "格式",
+    dataIndex: "format",
+    key: "format",
+  },
+  {
+    title: "大小",
+    dataIndex: "number",
+    key: "number",
+  },
+  {
+    title: "分组",
+    dataIndex: "group",
+    key: "group",
+    filters: groups.value.map((g) => ({
+      text: g.name,
+      value: g.id,
+    })),
+  },
+]);
+
+const fetchData = createLoadPack(() =>
+  Promise.all([fetchMaterialGroups(), fetchMaterialPage(params)])
+);
+watch(
+  params,
+  debounceStack(
+    fetchData,
+    ([group, pag]) => {
+      groups.value = group;
+      origin.value = pag;
+      pag.list.forEach((item) => (allData[item.id] = item));
+    },
+    160
+  ),
+  { immediate: true, deep: true }
+);
+
+const okHandler = () => {
+  emit(
+    "selectMaterials",
+    selectKeys.value.map((id) => allData[id])
+  );
+};
+const handleTableChange: TableProps["onChange"] = (pag, filters) => {
+  params.pageSize = pag.pageSize!;
+  params.pageNum = pag.current!;
+  params.groupIds = filters.group as number[];
+};
+</script>
+
+<style lang="less" scoped>
+.model-header {
+  display: flex;
+  justify-content: space-between;
+  padding-bottom: 24px;
+  align-items: center;
+}
+
+.table-layout {
+  max-height: 500px;
+  overflow-y: auto;
+}
+</style>
+
+<style lang="less">
+.model-header .header-desc {
+  margin-bottom: 0;
+}
+.ant-modal-root .ant-table-tbody > tr > td {
+  word-break: break-all;
+}
+</style>

+ 29 - 0
src/components/materials/quisk.ts

@@ -0,0 +1,29 @@
+import { mount } from "@/utils";
+import Materials from "./index.vue";
+import { Material } from "@/api/material";
+import { reactive } from "vue";
+
+export const selectMaterials = async (props: {
+  format?: string[];
+  maxSize?: number;
+  count?: number
+} = {}) => {
+  return new Promise<Material[] | null>((resolve) => {
+    const mprops = reactive({
+      ...props,
+      visible: true,
+      onSelectMaterials: (val: Material[]) => {
+        resolve(val);
+        mprops.visible = false
+      },
+      "onUpdate:visible": () => {
+        mprops.visible = false
+        resolve(null);
+      },
+      afterClose() {
+        umMount();
+      }
+    })
+    const umMount = mount(document.querySelector("#app")!, Materials, mprops);
+  });
+};

+ 27 - 21
src/components/static-preview/index.vue

@@ -4,7 +4,13 @@
       <ui-icon type="close" ctrl />
     </span>
     <div class="pull-preview pc">
-      <ui-slide v-if="items.length > 1" :currentIndex="current" showCtrl :items="items" showInfos>
+      <ui-slide
+        v-if="items.length > 1"
+        :currentIndex="current"
+        showCtrl
+        :items="items"
+        showInfos
+      >
         <template v-slot="{ raw }">
           <Sign :media="raw" />
         </template>
@@ -15,42 +21,42 @@
 </template>
 
 <script lang="ts">
-import { defineComponent, PropType, ref } from 'vue'
-import Sign from './sign.vue'
+import { defineComponent, PropType, ref } from "vue";
+import Sign from "./sign.vue";
 
 export enum MediaType {
-  video,
-  img,
-  web
+  video = "video",
+  img = "img",
+  web = "web",
+  audio = "audio",
 }
 
 export type MediaItem = {
-  url: Blob | string,
-  type: MediaType
-}
+  url: Blob | string;
+  type?: MediaType;
+};
 
-export const Preview =  defineComponent({
-  name: 'static-preview',
+export const Preview = defineComponent({
+  name: "static-preview",
   props: {
     items: {
       type: Array as PropType<MediaItem[]>,
-      required: true
+      required: true,
     },
     current: {
       type: Number,
-      default: 1
-    }
+      default: 1,
+    },
   },
   emits: {
-    close: () => true
+    close: () => true,
   },
   components: {
-    Sign
-  }
-})
-
+    Sign,
+  },
+});
 
-export default Preview
+export default Preview;
 </script>
 
-<style scoped lang="scss" src="./style.scss"></style>
+<style scoped lang="scss" src="./style.scss"></style>

+ 69 - 0
src/components/static-preview/resource.vue

@@ -0,0 +1,69 @@
+<template>
+  <video v-if="type === MediaType.video" controls autoplay playsinline webkit-playsinline>
+    <source :src="url" />
+  </video>
+  <iframe v-else-if="type === MediaType.web" :src="url"></iframe>
+  <img :src="url" v-if="type === MediaType.img" />
+  <audio
+    :src="url"
+    v-if="type === MediaType.audio"
+    controls
+    autoplay
+    playsinline
+    webkit-playsinline
+  />
+</template>
+
+<script lang="ts" setup>
+import { getResource } from "@/env";
+import { MediaType } from "./index.vue";
+import { computed } from "vue";
+import { getUrlType, MetaType } from "@/utils/meta";
+
+const props = defineProps<{ data: string | Blob | File; type?: MediaType }>();
+
+const url = computed(() =>
+  typeof props.data === "string"
+    ? getResource(props.data)
+    : URL.createObjectURL(props.data)
+);
+console.log(url.value);
+const type = computed(() => {
+  if (props.type) {
+    return props.type;
+  } else if (props.data instanceof File || typeof props.data === "string") {
+    const d = props.data instanceof File ? props.data.name : props.data;
+    const otype = getUrlType(d);
+    const map = {
+      [MetaType.other]: MediaType.web,
+      [MetaType.audio]: MediaType.audio,
+      [MetaType.image]: MediaType.img,
+      [MetaType.video]: MediaType.video,
+    };
+    return map[otype];
+  } else {
+    return MediaType.web;
+  }
+});
+</script>
+
+<style scoped>
+audio,
+iframe {
+  width: 100%;
+  height: 100%;
+  display: block;
+}
+
+video,
+img {
+  max-width: 100%;
+  max-height: 100%;
+  display: block;
+  object-fit: contain;
+}
+
+iframe {
+  border: none;
+}
+</style>

+ 25 - 31
src/components/static-preview/sign.vue

@@ -1,54 +1,48 @@
 <template>
   <div class="preview-layer">
     <div class="pull-meta">
-      <video v-if="media.type === MediaType.video" controls autoplay playsinline webkit-playsinline>
-        <source :src="staticURL" />
-      </video>
-      <iframe v-else-if="media.type === MediaType.web" :src="staticURL"></iframe>
-      <img :src="staticURL" v-if="media.type === MediaType.img"/>
+      <ResourceView :data="media.url" :type="media.type" />
     </div>
   </div>
 </template>
 
 <script lang="ts">
-import { ref, watchEffect, defineComponent, PropType } from 'vue'
-import { getResource } from '@/env'
-import { MediaType } from './index.vue'
-import type { MediaItem } from './index.vue'
+import { ref, watchEffect, defineComponent, PropType } from "vue";
+import { getResource } from "@/env";
+import { MediaType } from "./index.vue";
+import ResourceView from "./resource.vue";
+import type { MediaItem } from "./index.vue";
 
-
-export const Preview =  defineComponent({
-  name: 'static-preview',
+export const Preview = defineComponent({
+  name: "static-preview",
   props: {
     media: {
       type: Object as PropType<MediaItem>,
-        required: true
-    }
+      required: true,
+    },
   },
   emits: {
-    close: () => true
+    close: () => true,
   },
   setup(props) {
-    const staticURL = ref('')
+    const staticURL = ref("");
     watchEffect(() => {
-      const data = props.media.url
-      const url = typeof data === 'string'
-        ? getResource(data)
-        : URL.createObjectURL(data)
+      const data = props.media.url;
+      const url =
+        typeof data === "string" ? getResource(data) : URL.createObjectURL(data);
+
+      staticURL.value = url;
+      return () => URL.revokeObjectURL(url);
+    });
 
-      staticURL.value = url
-      return () => URL.revokeObjectURL(url)
-    })
-    
     return {
       staticURL,
-      MediaType
-    }
-  }
-})
-
+      MediaType,
+    };
+  },
+});
 
-export default Preview
+export default Preview;
 </script>
 
-<style scoped lang="scss" src="./style.scss"></style>
+<style scoped lang="scss" src="./style.scss"></style>

+ 5 - 5
src/components/tagging/sign-new.vue

@@ -53,7 +53,7 @@ import { sdk } from "@/sdk";
 import { custom, getResource } from "@/env";
 
 import type { Tagging, TaggingPosition } from "@/store";
-import { usePixel } from "@/hook/use-pixel";
+import { useCameraChange, usePixel } from "@/hook/use-pixel";
 
 export type SignProps = { tagging: Tagging; scenePos: TaggingPosition; show?: boolean };
 
@@ -74,7 +74,7 @@ const [posStyle, pos] = usePixel(() => ({
 
 const queryItems = computed(() =>
   props.tagging.images.map((image) => ({
-    type: MediaType.img,
+    // type: MediaType.img,
     url: getResource(getFileUrl(image)),
   }))
 );
@@ -133,9 +133,9 @@ watch(
   { deep: true }
 );
 
-const toCameraDistance = ref(tag.toCameraDistance);
-tag.bus.on("toCameraDistanceChange", (v) => (toCameraDistance.value = v));
-
+const toCameraDistance = useCameraChange(
+  () => tag.getCameraDisSquared && tag.getCameraDisSquared()
+);
 const show = computed(
   () =>
     props.scenePos.globalVisibility ||

+ 17 - 0
src/config.ts

@@ -0,0 +1,17 @@
+
+import zhCN from "ant-design-vue/es/locale/zh_CN";
+import { theme } from "ant-design-vue";
+
+export const config = {
+  locale: zhCN,
+  theme: {
+    algorithm: theme.darkAlgorithm,
+    token: {
+      colorPrimary: "#00c8af",
+      fontSize: 14,
+      wireframe: true,
+      colorInfo: "#00c8af",
+    },
+  }
+};
+

+ 7 - 3
src/env/index.ts

@@ -81,10 +81,14 @@ export type Params = {
   token?: string
 }
 
-export const baseURL = params.baseURL ? params.baseURL : '/'
-
+export const baseURL = params.baseURL ? params.baseURL : ''
 
 export const getResource = (uri: string) => {
   if (~uri.indexOf('base64') || ~uri.indexOf('bolb') || ~uri.indexOf('//')) return uri
-  return `${baseURL}/${uri}`
+
+  if (uri[0] === '/') {
+    return `${baseURL}${uri}`
+  } else {
+    return `${baseURL}/${uri}`
+  }
 }

+ 20 - 8
src/hook/use-pixel.ts

@@ -1,12 +1,25 @@
 import { sdk } from "@/sdk";
-import { ref, Ref, watch, watchEffect } from "vue";
+import { ref, Ref, UnwrapRef, watch, watchEffect } from "vue";
 import { useViewStack } from "./viewStack";
 
+export const useCameraChange = <T>(change: () => T): Ref<UnwrapRef<T>> => {
+  const data = ref(change())
+  useViewStack(() => {
+    const update = () => {
+      data.value = change() as UnwrapRef<T>
+    }
+    sdk.sceneBus.on("cameraChange", update);
+    return () => {
+      sdk.sceneBus.off("cameraChange", update);
+    };
+  });
+  return data
+}
+
 export const usePixel = (
   getter: () => ({ localPos: SceneLocalPos; modelId?: string } | undefined)
 ) => {
-  const pos = ref(getter())
-  watch(getter, val => pos.value = val)
+  const pos = useCameraChange(getter)
   const pixel = ref<{ left: string; top: string }>();
   const updatePosStyle = () => {
     if (!pos.value) {
@@ -28,13 +41,12 @@ export const usePixel = (
       top: screenPos.pos.y + "px",
     };
   };
+
+
   useViewStack(() => {
+    watch(getter, val => pos.value = val)
     const stop = watch(pos, updatePosStyle, {deep: true})
-    sdk.sceneBus.on("cameraChange", updatePosStyle);
-    return () => {
-      stop()
-      sdk.sceneBus.off("cameraChange", updatePosStyle);
-    };
+    return stop
   });
 
   return [pixel, pos] as const;

+ 1 - 3
src/sdk/sdk.ts

@@ -275,8 +275,6 @@ export type Tagging3D = {
     leave: void;
     // 位置变更
     changePosition: {pos: SceneLocalPos, modelId: string, normal: SceneLocalPos}
-    // 距离相机位置变更
-    toCameraDistanceChange: number
   }>;
   // 设置标题
   changeTitle: (title: string) => void
@@ -297,7 +295,7 @@ export type Tagging3D = {
   // 更改热点类型
   changeType: (val: TaggingPositionType) => void
   // 距离相机位置
-  toCameraDistance: number
+  getCameraDisSquared: () => number
   // 标注销毁
   destory: () => void
 }

+ 8 - 3
src/store/tagging.ts

@@ -38,7 +38,7 @@ import {
 import type { Tagging as STagging } from '@/api'
 import type { TaggingStyle } from './tagging-style'
 
-export type Tagging = LocalMode<STagging, 'images'>
+export type Tagging = LocalMode<LocalMode<STagging, 'images'>, 'audio'>
 export type Taggings = Tagging[]
 
 export const taggings = ref<Taggings>([])
@@ -57,6 +57,7 @@ export const createTagging = (tagging: Partial<Tagging> = {}): Tagging => {
     method: '',
     show3dTitle: false,
     principal: '',
+    audio: '',
     images: [],
     ...tagging
   }
@@ -78,8 +79,12 @@ export const transformTagging = async (tagging: Tagging): Promise<STagging> => {
     uploadFile(file).then(url => images[index] = url)
   )
 
-  await Promise.all(uploadImages)
-  return { ...tagging, images }
+  let audio: string
+  const uploadAudio = tagging.audio && uploadFile(tagging.audio).then((url) => {
+    audio = url
+  })
+  await Promise.all([...uploadImages, uploadAudio])
+  return { ...tagging, images, audio: audio! }
 }
 
 export const recoverTaggings = recoverStoreItems(taggings, () => bcTaggings)

+ 39 - 0
src/utils/index.ts

@@ -1,3 +1,5 @@
+import { getUrlType } from './meta';
+
 // 加载第三方库
 export const loadLib = (() => {
   const cache: Record<string, Promise<void>> = {};
@@ -41,7 +43,12 @@ export const getFileUrl = (file: LocalFile | string) =>
   typeof file === 'string'
     ? file
     : file.url
+export const getFileName = (file: any): string => {
 
+  return typeof file === 'string'
+    ? file.substring(file.indexOf('/') + 1)
+    : file.blob instanceof File ? file.blob.name : getUrlType(file.url)
+}
 
 export const asyncTimeout = (mis: number = 0) => new Promise(resolve => setTimeout(resolve, mis))
 
@@ -61,6 +68,38 @@ export const jsonToForm = (data: { [key in string]: any }) => {
 }
 
 
+// 防抖
+export const debounce = <T extends (...args: any) => any>(
+	fn: T,
+	delay: number = 160
+) => {
+	let timeout: any;
+	return function (...args: Parameters<T>) {
+		clearTimeout(timeout);
+		timeout = setTimeout(() => {
+			fn.apply(null, args);
+		}, delay);
+	};
+};
+
+// 防抖
+export const debounceStack = <T extends (...args: any) => any>(
+	fn: T,
+  success: (data: ReturnType<T> extends Promise<infer P> ? P : ReturnType<T>) => void,
+	delay: number = 160
+) => {
+  let dsToken = 0
+  return debounce(async (...args: Parameters<T>) => {
+    ++dsToken
+    const currentToken = dsToken
+    const result = await fn.apply(null, args);
+    if (currentToken === dsToken) {    
+      success(result)
+    }
+  }, delay)
+};
+
+
 // 四舍五入保留指定位数
 export const round = (num: number, index: number = 2) => {
   const s = Math.pow(10, index)

+ 15 - 0
src/utils/meta.ts

@@ -1,11 +1,13 @@
 export enum MetaType {
   image = 'image',
   video = 'video',
+  audio = 'audio',
   other = 'other'
 }
 
 export const metaTypeExtnames = {
   [MetaType.image]: ['bmp', 'jpg', 'png', 'tif', 'gif', 'pcx', 'tga', 'exif', 'fpx', 'svg', 'psd', 'cdr', 'pcd', 'dxf', 'ufo', 'eps', 'ai', 'raw', 'WMF', 'webp', 'avif', 'apng'],
+  [MetaType.audio]: ['mp3'],
   [MetaType.video]: ['wmv', 'asf', 'asx', 'rm', 'rmvb', 'mp4', '3gp', 'mov', 'm4v', 'avi', 'dat', 'mkv', 'flv', 'vob']
 }
 
@@ -19,6 +21,19 @@ export const getExtname = (url: string) => {
 }
 
 export const getUrlType = (url: string) => {
+  if (url.includes(';base64')) {
+    const type = url.substring(url.indexOf(':') + 1, url.indexOf('/'))
+    if (type === 'image') {
+      return MetaType.image
+    } else if (type === 'video') {
+      return MetaType.video
+    } else if (type === 'audio') {
+      return MetaType.audio
+    } else {
+      return MetaType.other
+    }
+  }
+
   const extname = getExtname(url)?.toLowerCase()
   if (extname) {
     for (const [type, extnames] of Object.entries(metaTypeExtnames)) {

+ 17 - 12
src/utils/mount.ts

@@ -1,16 +1,21 @@
-import { createVNode, render, Teleport, createBlock, openBlock } from 'vue'
-import app from '@/main'
+import { reactive, markRaw } from "vue";
 
+import type { Component } from "vue";
 
-import type { Component } from 'vue'
+export type MInfo = [HTMLDivElement, Component, Record<string, any> | undefined]
+export const needMounts: MInfo[] = reactive([])
 
-export const mount = (to: HTMLDivElement, Component: Component, props?: Record<string, any>) => {
-  const appEl = document.createElement('div')
-  const vnode = createVNode(Component, props)
-  vnode.appContext = app._context
-  openBlock()
-  const portBlock =createBlock(Teleport as any, { to }, [ vnode ])
-  render(portBlock, appEl)
+export const mount = (
+  to: HTMLDivElement,
+  Component: Component,
+  props?: Record<string, any>
+) => {
+  const info = [to, Component, props] as MInfo
+  markRaw(Component)
+  needMounts.push(info)
 
-  return () => render(null, appEl)
-}
+  return () => {
+    const ndx = needMounts.indexOf(info)
+    needMounts.splice(ndx, 1)
+  }
+};

+ 121 - 39
src/views/tagging/edit.vue

@@ -75,7 +75,7 @@
       <div class="input">
         <div class="mat-select">
           <span>音乐</span>
-          <span class="select">+ 从媒体库上传</span>
+          <span class="select" @click="musicSelect">+从媒体库上传</span>
         </div>
 
         <ui-input
@@ -84,15 +84,47 @@
           width="100%"
           height="40px"
           preview
-          placeholder="上传图片"
           othPlaceholder="支持 mp3/wav 格式,≤30MB"
-          accept=".mp3, .wav"
+          :accept="audioFormat.map((u) => `.${u}`).join(',')"
           :disable="true"
           :multiple="false"
-          :maxSize="30 * 1024 * 1024"
+          :maxSize="audioSize"
+          :modelValue="tagging.audio"
+          @update:modelValue="audioChange"
+        >
+          <template v-slot:replace>
+            <p class="audio-tip" v-if="!tagging.audio">
+              <ui-icon type="add" /> 支持 mp3/wav 格式,≤30MB
+            </p>
+            <p v-else class="rep-val">
+              {{ getFileName(tagging.audio) }}
+              <ui-icon class="icon" @click.stop="tagging.audio = ''" type="del" ctrl />
+            </p>
+          </template>
+        </ui-input>
+      </div>
+
+      <div class="input">
+        <div class="mat-select">
+          <span>图片/视频</span>
+          <span class="select" @click="imageSelect">+从媒体库上传</span>
+        </div>
+        <ui-input
+          type="file"
+          width="100%"
+          height="225px"
+          preview
+          placeholder="上传图片/视频"
+          othPlaceholder="支持JPG、PNG、MP4等格式,单张不超过5MB,最多支持上传9张。"
+          :accept="imageFormat.map((u) => `.${u}`).join(',')"
+          :disable="true"
+          :multiple="true"
+          :maxSize="imageSize"
+          :maxLen="imageCount"
+          :modelValue="tagging.images"
           @update:modelValue="fileChange"
         >
-          <!-- <template v-slot:valuable>
+          <template v-slot:valuable>
             <Images :tagging="tagging" :hideInfo="true">
               <template v-slot:icons="{ active }">
                 <span @click="delImageHandler(active)" class="del-file">
@@ -100,41 +132,14 @@
                 </span>
               </template>
             </Images>
-          </template> -->
+          </template>
         </ui-input>
-      </div>
-
-      <ui-input
-        class="input"
-        type="file"
-        width="100%"
-        height="225px"
-        preview
-        placeholder="上传图片"
-        othPlaceholder="支持JPG、PNG图片格式,单张不超过5MB,最多支持上传9张。"
-        accept=".jpg, .png"
-        :disable="true"
-        :multiple="true"
-        :maxSize="5 * 1024 * 1024"
-        :maxLen="9"
-        :modelValue="tagging.images"
-        @update:modelValue="fileChange"
-      >
-        <template v-slot:valuable>
-          <Images :tagging="tagging" :hideInfo="true">
-            <template v-slot:icons="{ active }">
-              <span @click="delImageHandler(active)" class="del-file">
-                <ui-icon type="del" ctrl />
-              </span>
-            </template>
-          </Images>
-        </template>
-      </ui-input>
-      <div class="edit-hot">
-        <a @click="submitHandler">
-          <ui-icon type="nav-edit" />
-          确定
-        </a>
+        <div class="edit-hot">
+          <a @click="submitHandler">
+            <ui-icon type="nav-edit" />
+            确定
+          </a>
+        </div>
       </div>
     </div>
   </div>
@@ -156,11 +161,20 @@ import {
   defaultStyle,
 } from "@/store";
 import { styleTypes } from "@/api";
+import { selectMaterials } from "@/components/materials/quisk";
+import { getFileName } from "@/utils";
 
 export type EditProps = {
   data: Tagging;
 };
 
+const imageSize = 50 * 1024 * 1024;
+const imageCount = 9;
+const imageFormat = ["jpg", "png", "mp4"];
+const audioSize = 30 * 1024 * 1024;
+const audioCount = 1;
+const audioFormat = ["mp3", "wav"];
+
 const props = defineProps<EditProps>();
 const emit = defineEmits<{ (e: "quit"): void; (e: "save", data: Tagging): void }>();
 const tagging = ref<Tagging>({ ...props.data, images: [...props.data.images] });
@@ -229,6 +243,17 @@ const uploadStyle = (style: TaggingStyle) => {
   tagging.value.styleId = style.id;
 };
 
+const audioChange = (file: LocalImageFile) => {
+  const data =
+    typeof file === "string" || "blob" in file
+      ? file
+      : {
+          blob: file.file,
+          url: file.preview,
+        };
+  tagging.value.audio = data;
+};
+
 type LocalImageFile = { file: File; preview: string } | Tagging["images"][number];
 const fileChange = (file: LocalImageFile | LocalImageFile[]) => {
   const files = Array.isArray(file) ? file : [file];
@@ -251,9 +276,42 @@ const delImageHandler = async (file: Tagging["images"][number]) => {
     tagging.value.images.splice(index, 1);
   }
 };
+
+const musicSelect = async () => {
+  const list = await selectMaterials({
+    format: audioFormat,
+    count: audioCount,
+    maxSize: audioCount,
+  });
+  if (list?.length) {
+    audioChange(list[0].url);
+  }
+};
+const imageSelect = async () => {
+  const list = await selectMaterials({
+    format: imageFormat,
+    count: imageCount - tagging.value.images.length,
+    maxSize: imageCount,
+  });
+  if (list?.length) {
+    fileChange([...tagging.value.images, ...list.map((item) => item.url)]);
+  }
+};
 </script>
 
 <style lang="scss" scoped>
+.rep-val {
+  width: 100%;
+  position: relative;
+  text-align: center;
+  .icon {
+    position: absolute;
+    right: 10px;
+    top: 50%;
+    transform: translateY(-50%);
+    pointer-events: all;
+  }
+}
 .edit-hot-layer {
   position: fixed;
   inset: 0;
@@ -290,6 +348,17 @@ const delImageHandler = async (file: Tagging["images"][number]) => {
   justify-content: space-between;
 }
 
+.mat-select {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  margin-bottom: 8px;
+
+  .select {
+    color: var(--colors-primary-base);
+    cursor: pointer;
+  }
+}
 .edit-title {
   padding-bottom: 18px;
   margin-bottom: 18px;
@@ -316,6 +385,19 @@ const delImageHandler = async (file: Tagging["images"][number]) => {
     cursor: pointer;
   }
 }
+
+.audio-tip {
+  font-size: 12px;
+  color: rgba(255, 255, 255, 0.3);
+  background: var(--colors-normal-back);
+  height: 100%;
+  padding: 8px 10px;
+  border-radius: 4px;
+  border: 1px solid var(--base-border-color);
+  transition: border 0.3s ease;
+  width: 100%;
+  text-align: center;
+}
 </style>
 <style>
 .edit-hot-item .preplace input {

+ 29 - 29
src/views/tagging/images.vue

@@ -1,20 +1,21 @@
 <template>
   <div class="mates">
-    <ui-slide 
-      v-if="tagging" 
-      :items="tagging.images" 
-      :showCtrl="tagging.images.length > 1" 
+    <ui-slide
+      v-if="tagging"
+      :items="tagging.images"
+      :showCtrl="tagging.images.length > 1"
       :currentIndex="index"
-      @change="(i: number) => index = i" 
+      @change="(i: number) => index = i"
       :showInfos="tagging.images.length > 1 && !hideInfo"
     >
       <template v-slot="{ raw, index }">
-        <div 
-          class="meta-item" 
-          :class="{ full: inFull }" 
+        <div
+          class="meta-item"
+          :class="{ full: inFull }"
           @click="inFull && $emit('pull', index)"
         >
-          <img :src="getResource(getFileUrl(raw))" />
+          <ResourceView :data="getFileUrl(raw)" />
+          <!-- <img :src="getResource(getFileUrl(raw))" /> -->
         </div>
       </template>
       <template v-slot:attach="{ active }">
@@ -27,27 +28,27 @@
 </template>
 
 <script lang="ts" setup>
-import { ref } from 'vue'
-import { getFileUrl } from '@/utils'
-import { Tagging } from '@/store'
-import { getResource } from '@/env'
+import { ref } from "vue";
+import { getFileUrl } from "@/utils";
+import { Tagging } from "@/store";
+import { getResource } from "@/env";
+import ResourceView from "@/components/static-preview/resource.vue";
 
 export type ImagesProps = {
-  tagging: Tagging
-  inFull?: boolean
-  hideInfo?: boolean
-}
+  tagging: Tagging;
+  inFull?: boolean;
+  hideInfo?: boolean;
+};
 
-defineProps<ImagesProps>()
+defineProps<ImagesProps>();
 defineEmits<{
-    (e: 'pull', index: number): void
-    (e: 'change', i: number): void
-}>()
-const index = ref(0)
+  (e: "pull", index: number): void;
+  (e: "change", i: number): void;
+}>();
+const index = ref(0);
 </script>
 
 <style lang="scss" scoped>
-
 .mates {
   width: 100%;
   height: 100%;
@@ -69,7 +70,7 @@ const index = ref(0)
     position: relative;
 
     &::after {
-      content: '';
+      content: "";
       position: absolute;
       bottom: 0;
       top: 0;
@@ -90,7 +91,7 @@ const index = ref(0)
   img {
     object-fit: contain;
   }
-  iframe{
+  iframe {
     border: none;
   }
 
@@ -102,9 +103,9 @@ const index = ref(0)
       display: block;
       width: 24px;
       height: 24px;
-      background-color: rgba(0,0,0,0.3);
+      background-color: rgba(0, 0, 0, 0.3);
       font-size: 14px;
-      color: rgba(255,255,255,0.6);
+      color: rgba(255, 255, 255, 0.6);
       border-radius: 50%;
       display: flex;
       align-items: center;
@@ -117,5 +118,4 @@ const index = ref(0)
     }
   }
 }
-
-</style>
+</style>