ydmyzx 5 月之前
父节点
当前提交
dc06295e0e

+ 261 - 0
src/components/Cropper/src/CopperModal.vue

@@ -0,0 +1,261 @@
+<template>
+  <div>
+    <Dialog
+      v-model="dialogVisible"
+      :canFullscreen="false"
+      :title="t('cropper.modalTitle')"
+      maxHeight="380px"
+      width="800px"
+    >
+      <div :class="prefixCls">
+        <div :class="`${prefixCls}-left`">
+          <div :class="`${prefixCls}-cropper`">
+            <CropperImage
+              v-if="src"
+              :circled="circled"
+              :src="src"
+              height="300px"
+              @cropend="handleCropend"
+              @ready="handleReady"
+            />
+          </div>
+
+          <div :class="`${prefixCls}-toolbar`">
+            <el-upload :beforeUpload="handleBeforeUpload" :fileList="[]" accept="image/*">
+              <el-tooltip :content="t('cropper.selectImage')" placement="bottom">
+                <XButton preIcon="ant-design:upload-outlined" type="primary" />
+              </el-tooltip>
+            </el-upload>
+            <el-space>
+              <el-tooltip :content="t('cropper.btn_reset')" placement="bottom">
+                <XButton
+                  :disabled="!src"
+                  preIcon="ant-design:reload-outlined"
+                  size="small"
+                  type="primary"
+                  @click="handlerToolbar('reset')"
+                />
+              </el-tooltip>
+              <el-tooltip :content="t('cropper.btn_rotate_left')" placement="bottom">
+                <XButton
+                  :disabled="!src"
+                  preIcon="ant-design:rotate-left-outlined"
+                  size="small"
+                  type="primary"
+                  @click="handlerToolbar('rotate', -45)"
+                />
+              </el-tooltip>
+              <el-tooltip :content="t('cropper.btn_rotate_right')" placement="bottom">
+                <XButton
+                  :disabled="!src"
+                  preIcon="ant-design:rotate-right-outlined"
+                  size="small"
+                  type="primary"
+                  @click="handlerToolbar('rotate', 45)"
+                />
+              </el-tooltip>
+              <el-tooltip :content="t('cropper.btn_scale_x')" placement="bottom">
+                <XButton
+                  :disabled="!src"
+                  preIcon="vaadin:arrows-long-h"
+                  size="small"
+                  type="primary"
+                  @click="handlerToolbar('scaleX')"
+                />
+              </el-tooltip>
+              <el-tooltip :content="t('cropper.btn_scale_y')" placement="bottom">
+                <XButton
+                  :disabled="!src"
+                  preIcon="vaadin:arrows-long-v"
+                  size="small"
+                  type="primary"
+                  @click="handlerToolbar('scaleY')"
+                />
+              </el-tooltip>
+              <el-tooltip :content="t('cropper.btn_zoom_in')" placement="bottom">
+                <XButton
+                  :disabled="!src"
+                  preIcon="ant-design:zoom-in-outlined"
+                  size="small"
+                  type="primary"
+                  @click="handlerToolbar('zoom', 0.1)"
+                />
+              </el-tooltip>
+              <el-tooltip :content="t('cropper.btn_zoom_out')" placement="bottom">
+                <XButton
+                  :disabled="!src"
+                  preIcon="ant-design:zoom-out-outlined"
+                  size="small"
+                  type="primary"
+                  @click="handlerToolbar('zoom', -0.1)"
+                />
+              </el-tooltip>
+            </el-space>
+          </div>
+        </div>
+        <div :class="`${prefixCls}-right`">
+          <div :class="`${prefixCls}-preview`">
+            <img v-if="previewSource" :alt="t('cropper.preview')" :src="previewSource" />
+          </div>
+          <template v-if="previewSource">
+            <div :class="`${prefixCls}-group`">
+              <el-avatar :src="previewSource" size="large" />
+              <el-avatar :size="48" :src="previewSource" />
+              <el-avatar :size="64" :src="previewSource" />
+              <el-avatar :size="80" :src="previewSource" />
+            </div>
+          </template>
+        </div>
+      </div>
+      <template #footer>
+        <el-button type="primary" @click="handleOk">{{ t('cropper.okText') }}</el-button>
+      </template>
+    </Dialog>
+  </div>
+</template>
+<script lang="ts" setup>
+import { useDesign } from '@/hooks/web/useDesign'
+import { dataURLtoBlob } from '@/utils/filt'
+import { useI18n } from 'vue-i18n'
+import type { CropendResult, Cropper } from './types'
+import { propTypes } from '@/utils/propTypes'
+import { CropperImage } from '@/components/Cropper'
+
+defineOptions({ name: 'CopperModal' })
+
+const props = defineProps({
+  srcValue: propTypes.string.def(''),
+  circled: propTypes.bool.def(true)
+})
+const emit = defineEmits(['uploadSuccess'])
+const { t } = useI18n()
+const { getPrefixCls } = useDesign()
+const prefixCls = getPrefixCls('cropper-am')
+
+const src = ref(props.srcValue)
+const previewSource = ref('')
+const cropper = ref<Cropper>()
+const dialogVisible = ref(false)
+let filename = ''
+let scaleX = 1
+let scaleY = 1
+
+// Block upload
+function handleBeforeUpload(file: File) {
+  const reader = new FileReader()
+  reader.readAsDataURL(file)
+  src.value = ''
+  previewSource.value = ''
+  reader.onload = function (e) {
+    src.value = (e.target?.result as string) ?? ''
+    filename = file.name
+  }
+  return false
+}
+
+function handleCropend({ imgBase64 }: CropendResult) {
+  previewSource.value = imgBase64
+}
+
+function handleReady(cropperInstance: Cropper) {
+  cropper.value = cropperInstance
+}
+
+function handlerToolbar(event: string, arg?: number) {
+  if (event === 'scaleX') {
+    scaleX = arg = scaleX === -1 ? 1 : -1
+  }
+  if (event === 'scaleY') {
+    scaleY = arg = scaleY === -1 ? 1 : -1
+  }
+  cropper?.value?.[event]?.(arg)
+}
+
+async function handleOk() {
+  const blob = dataURLtoBlob(previewSource.value)
+  emit('uploadSuccess', { source: previewSource.value, data: blob, filename: filename })
+}
+
+function openModal() {
+  dialogVisible.value = true
+}
+
+function closeModal() {
+  dialogVisible.value = false
+}
+
+defineExpose({ openModal, closeModal })
+</script>
+<style lang="scss">
+$prefix-cls: #{$namespace}-cropper-am;
+
+.#{$prefix-cls} {
+  display: flex;
+
+  &-left,
+  &-right {
+    height: 340px;
+  }
+
+  &-left {
+    width: 55%;
+  }
+
+  &-right {
+    width: 45%;
+  }
+
+  &-cropper {
+    height: 300px;
+    background: #eee;
+    background-image: linear-gradient(
+        45deg,
+        rgb(0 0 0 / 25%) 25%,
+        transparent 0,
+        transparent 75%,
+        rgb(0 0 0 / 25%) 0
+      ),
+      linear-gradient(
+        45deg,
+        rgb(0 0 0 / 25%) 25%,
+        transparent 0,
+        transparent 75%,
+        rgb(0 0 0 / 25%) 0
+      );
+    background-position:
+      0 0,
+      12px 12px;
+    background-size: 24px 24px;
+  }
+
+  &-toolbar {
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+    margin-top: 10px;
+  }
+
+  &-preview {
+    width: 220px;
+    height: 220px;
+    margin: 0 auto;
+    overflow: hidden;
+    border: 1px solid;
+    border-radius: 50%;
+
+    img {
+      width: 100%;
+      height: 100%;
+    }
+  }
+
+  &-group {
+    display: flex;
+    padding-top: 8px;
+    margin-top: 8px;
+    border-top: 1px solid;
+    justify-content: space-around;
+    align-items: center;
+  }
+}
+</style>

+ 143 - 0
src/components/DiyEditor/components/mobile/HotZone/components/HotZoneEditDialog/controller.ts

@@ -0,0 +1,143 @@
+import { HotZoneItemProperty } from '@/components/DiyEditor/components/mobile/HotZone/config'
+import { StyleValue } from 'vue'
+
+// 热区的最小宽高
+export const HOT_ZONE_MIN_SIZE = 100
+
+// 控制的类型
+export enum CONTROL_TYPE_ENUM {
+  LEFT,
+  TOP,
+  WIDTH,
+  HEIGHT
+}
+
+// 定义热区的控制点
+export interface ControlDot {
+  position: string
+  types: CONTROL_TYPE_ENUM[]
+  style: StyleValue
+}
+
+// 热区的8个控制点
+export const CONTROL_DOT_LIST = [
+  {
+    position: '左上角',
+    types: [
+      CONTROL_TYPE_ENUM.LEFT,
+      CONTROL_TYPE_ENUM.TOP,
+      CONTROL_TYPE_ENUM.WIDTH,
+      CONTROL_TYPE_ENUM.HEIGHT
+    ],
+    style: { left: '-5px', top: '-5px', cursor: 'nwse-resize' }
+  },
+  {
+    position: '上方中间',
+    types: [CONTROL_TYPE_ENUM.TOP, CONTROL_TYPE_ENUM.HEIGHT],
+    style: { left: '50%', top: '-5px', cursor: 'n-resize', transform: 'translateX(-50%)' }
+  },
+  {
+    position: '右上角',
+    types: [CONTROL_TYPE_ENUM.TOP, CONTROL_TYPE_ENUM.WIDTH, CONTROL_TYPE_ENUM.HEIGHT],
+    style: { right: '-5px', top: '-5px', cursor: 'nesw-resize' }
+  },
+  {
+    position: '右侧中间',
+    types: [CONTROL_TYPE_ENUM.WIDTH],
+    style: { right: '-5px', top: '50%', cursor: 'e-resize', transform: 'translateX(-50%)' }
+  },
+  {
+    position: '右下角',
+    types: [CONTROL_TYPE_ENUM.WIDTH, CONTROL_TYPE_ENUM.HEIGHT],
+    style: { right: '-5px', bottom: '-5px', cursor: 'nwse-resize' }
+  },
+  {
+    position: '下方中间',
+    types: [CONTROL_TYPE_ENUM.HEIGHT],
+    style: { left: '50%', bottom: '-5px', cursor: 's-resize', transform: 'translateX(-50%)' }
+  },
+  {
+    position: '左下角',
+    types: [CONTROL_TYPE_ENUM.LEFT, CONTROL_TYPE_ENUM.WIDTH, CONTROL_TYPE_ENUM.HEIGHT],
+    style: { left: '-5px', bottom: '-5px', cursor: 'nesw-resize' }
+  },
+  {
+    position: '左侧中间',
+    types: [CONTROL_TYPE_ENUM.LEFT, CONTROL_TYPE_ENUM.WIDTH],
+    style: { left: '-5px', top: '50%', cursor: 'w-resize', transform: 'translateX(-50%)' }
+  }
+] as ControlDot[]
+
+//region 热区的缩放
+// 热区的缩放比例
+export const HOT_ZONE_SCALE_RATE = 2
+// 缩小:缩回适合手机屏幕的大小
+export const zoomOut = (list?: HotZoneItemProperty[]) => {
+  return (
+    list?.map((hotZone) => ({
+      ...hotZone,
+      left: (hotZone.left /= HOT_ZONE_SCALE_RATE),
+      top: (hotZone.top /= HOT_ZONE_SCALE_RATE),
+      width: (hotZone.width /= HOT_ZONE_SCALE_RATE),
+      height: (hotZone.height /= HOT_ZONE_SCALE_RATE)
+    })) || []
+  )
+}
+// 放大:作用是为了方便在电脑屏幕上编辑
+export const zoomIn = (list?: HotZoneItemProperty[]) => {
+  return (
+    list?.map((hotZone) => ({
+      ...hotZone,
+      left: (hotZone.left *= HOT_ZONE_SCALE_RATE),
+      top: (hotZone.top *= HOT_ZONE_SCALE_RATE),
+      width: (hotZone.width *= HOT_ZONE_SCALE_RATE),
+      height: (hotZone.height *= HOT_ZONE_SCALE_RATE)
+    })) || []
+  )
+}
+//endregion
+
+/**
+ * 封装热区拖拽
+ *
+ * 注:为什么不使用vueuse的useDraggable。在本场景下,其使用方式比较复杂
+ * @param hotZone 热区
+ * @param downEvent 鼠标按下事件
+ * @param callback 回调函数
+ */
+export const useDraggable = (
+  hotZone: HotZoneItemProperty,
+  downEvent: MouseEvent,
+  callback: (
+    left: number,
+    top: number,
+    width: number,
+    height: number,
+    moveWidth: number,
+    moveHeight: number
+  ) => void
+) => {
+  // 阻止事件冒泡
+  downEvent.stopPropagation()
+
+  // 移动前的鼠标坐标
+  const { clientX: startX, clientY: startY } = downEvent
+  // 移动前的热区坐标、大小
+  const { left, top, width, height } = hotZone
+
+  // 监听鼠标移动
+  document.onmousemove = (e) => {
+    // 移动宽度
+    const moveWidth = e.clientX - startX
+    // 移动高度
+    const moveHeight = e.clientY - startY
+    // 移动回调
+    callback(left, top, width, height, moveWidth, moveHeight)
+  }
+
+  // 松开鼠标后,结束拖拽
+  document.onmouseup = () => {
+    document.onmousemove = null
+    document.onmouseup = null
+  }
+}

+ 76 - 0
src/layout/components/ContextMenu/src/ContextMenu.vue

@@ -0,0 +1,76 @@
+<script lang="ts" setup>
+import { PropType } from 'vue'
+
+import { useDesign } from '@/hooks/web/useDesign'
+import type { RouteLocationNormalizedLoaded } from 'vue-router'
+import { contextMenuSchema } from '@/types/contextMenu'
+import type { ElDropdown } from 'element-plus'
+
+defineOptions({ name: 'ContextMenu' })
+
+const { getPrefixCls } = useDesign()
+
+const prefixCls = getPrefixCls('context-menu')
+
+const { t } = useI18n()
+
+const emit = defineEmits(['visibleChange'])
+
+const props = defineProps({
+  schema: {
+    type: Array as PropType<contextMenuSchema[]>,
+    default: () => []
+  },
+  trigger: {
+    type: String as PropType<'click' | 'hover' | 'focus' | 'contextmenu'>,
+    default: 'contextmenu'
+  },
+  tagItem: {
+    type: Object as PropType<RouteLocationNormalizedLoaded>,
+    default: () => ({})
+  }
+})
+
+const command = (item: contextMenuSchema) => {
+  item.command && item.command(item)
+}
+
+const visibleChange = (visible: boolean) => {
+  emit('visibleChange', visible, props.tagItem)
+}
+
+const elDropdownMenuRef = ref<ComponentRef<typeof ElDropdown>>()
+
+defineExpose({
+  elDropdownMenuRef,
+  tagItem: props.tagItem
+})
+</script>
+
+<template>
+  <ElDropdown
+    ref="elDropdownMenuRef"
+    :class="prefixCls"
+    :trigger="trigger"
+    placement="bottom-start"
+    popper-class="v-context-menu-popper"
+    @command="command"
+    @visible-change="visibleChange"
+  >
+    <slot></slot>
+    <template #dropdown>
+      <ElDropdownMenu>
+        <ElDropdownItem
+          v-for="(item, index) in schema"
+          :key="`dropdown${index}`"
+          :command="item"
+          :disabled="item.disabled"
+          :divided="item.divided"
+        >
+          <Icon :icon="item.icon" />
+          {{ t(item.label) }}
+        </ElDropdownItem>
+      </ElDropdownMenu>
+    </template>
+  </ElDropdown>
+</template>

+ 472 - 0
src/views/ai/chat/index/components/conversation/ConversationList.vue

@@ -0,0 +1,472 @@
+<!--  AI 对话  -->
+<template>
+  <el-aside width="260px" class="conversation-container h-100%">
+    <!-- 左顶部:对话 -->
+    <div class="h-100%">
+      <el-button class="w-1/1 btn-new-conversation" type="primary" @click="createConversation">
+        <Icon icon="ep:plus" class="mr-5px" />
+        新建对话
+      </el-button>
+
+      <!-- 左顶部:搜索对话 -->
+      <el-input
+        v-model="searchName"
+        size="large"
+        class="mt-10px search-input"
+        placeholder="搜索历史记录"
+        @keyup="searchConversation"
+      >
+        <template #prefix>
+          <Icon icon="ep:search" />
+        </template>
+      </el-input>
+
+      <!-- 左中间:对话列表 -->
+      <div class="conversation-list">
+        <!-- 情况一:加载中 -->
+        <el-empty v-if="loading" description="." :v-loading="loading" />
+        <!-- 情况二:按照 group 分组,展示聊天会话 list 列表 -->
+        <div v-for="conversationKey in Object.keys(conversationMap)" :key="conversationKey">
+          <div
+            class="conversation-item classify-title"
+            v-if="conversationMap[conversationKey].length"
+          >
+            <el-text class="mx-1" size="small" tag="b">{{ conversationKey }}</el-text>
+          </div>
+          <div
+            class="conversation-item"
+            v-for="conversation in conversationMap[conversationKey]"
+            :key="conversation.id"
+            @click="handleConversationClick(conversation.id)"
+            @mouseover="hoverConversationId = conversation.id"
+            @mouseout="hoverConversationId = ''"
+          >
+            <div
+              :class="
+                conversation.id === activeConversationId ? 'conversation active' : 'conversation'
+              "
+            >
+              <div class="title-wrapper">
+                <img class="avatar" :src="conversation.roleAvatar || roleAvatarDefaultImg" />
+                <span class="title">{{ conversation.title }}</span>
+              </div>
+              <div class="button-wrapper" v-show="hoverConversationId === conversation.id">
+                <el-button class="btn" link @click.stop="handleTop(conversation)">
+                  <el-icon title="置顶" v-if="!conversation.pinned"><Top /></el-icon>
+                  <el-icon title="置顶" v-if="conversation.pinned"><Bottom /></el-icon>
+                </el-button>
+                <el-button class="btn" link @click.stop="updateConversationTitle(conversation)">
+                  <el-icon title="编辑">
+                    <Icon icon="ep:edit" />
+                  </el-icon>
+                </el-button>
+                <el-button class="btn" link @click.stop="deleteChatConversation(conversation)">
+                  <el-icon title="删除对话">
+                    <Icon icon="ep:delete" />
+                  </el-icon>
+                </el-button>
+              </div>
+            </div>
+          </div>
+        </div>
+        <!-- 底部占位  -->
+        <div class="h-160px w-100%"></div>
+      </div>
+    </div>
+
+    <!-- 左底部:工具栏 -->
+    <div class="tool-box">
+      <div @click="handleRoleRepository">
+        <Icon icon="ep:user" />
+        <el-text size="small">角色仓库</el-text>
+      </div>
+      <div @click="handleClearConversation">
+        <Icon icon="ep:delete" />
+        <el-text size="small">清空未置顶对话</el-text>
+      </div>
+    </div>
+
+    <!-- 角色仓库抽屉 -->
+    <el-drawer v-model="roleRepositoryOpen" title="角色仓库" size="754px">
+      <RoleRepository />
+    </el-drawer>
+  </el-aside>
+</template>
+
+<script setup lang="ts">
+import { ChatConversationApi, ChatConversationVO } from '@/api/ai/chat/conversation'
+import RoleRepository from '../role/RoleRepository.vue'
+import { Bottom, Top } from '@element-plus/icons-vue'
+import roleAvatarDefaultImg from '@/assets/ai/gpt.svg'
+
+const message = useMessage() // 消息弹窗
+
+// 定义属性
+const searchName = ref<string>('') // 对话搜索
+const activeConversationId = ref<number | null>(null) // 选中的对话,默认为 null
+const hoverConversationId = ref<number | null>(null) // 悬浮上去的对话
+const conversationList = ref([] as ChatConversationVO[]) // 对话列表
+const conversationMap = ref<any>({}) // 对话分组 (置顶、今天、三天前、一星期前、一个月前)
+const loading = ref<boolean>(false) // 加载中
+const loadingTime = ref<any>() // 加载中定时器
+
+// 定义组件 props
+const props = defineProps({
+  activeId: {
+    type: String || null,
+    required: true
+  }
+})
+
+// 定义钩子
+const emits = defineEmits([
+  'onConversationCreate',
+  'onConversationClick',
+  'onConversationClear',
+  'onConversationDelete'
+])
+
+/** 搜索对话 */
+const searchConversation = async (e) => {
+  // 恢复数据
+  if (!searchName.value.trim().length) {
+    conversationMap.value = await getConversationGroupByCreateTime(conversationList.value)
+  } else {
+    // 过滤
+    const filterValues = conversationList.value.filter((item) => {
+      return item.title.includes(searchName.value.trim())
+    })
+    conversationMap.value = await getConversationGroupByCreateTime(filterValues)
+  }
+}
+
+/** 点击对话 */
+const handleConversationClick = async (id: number) => {
+  // 过滤出选中的对话
+  const filterConversation = conversationList.value.filter((item) => {
+    return item.id === id
+  })
+  // 回调 onConversationClick
+  // noinspection JSVoidFunctionReturnValueUsed
+  const success = emits('onConversationClick', filterConversation[0])
+  // 切换对话
+  if (success) {
+    activeConversationId.value = id
+  }
+}
+
+/** 获取对话列表 */
+const getChatConversationList = async () => {
+  try {
+    // 加载中
+    loadingTime.value = setTimeout(() => {
+      loading.value = true
+    }, 50)
+
+    // 1.1 获取 对话数据
+    conversationList.value = await ChatConversationApi.getChatConversationMyList()
+    // 1.2 排序
+    conversationList.value.sort((a, b) => {
+      return b.createTime - a.createTime
+    })
+    // 1.3 没有任何对话情况
+    if (conversationList.value.length === 0) {
+      activeConversationId.value = null
+      conversationMap.value = {}
+      return
+    }
+
+    // 2. 对话根据时间分组(置顶、今天、一天前、三天前、七天前、30 天前)
+    conversationMap.value = await getConversationGroupByCreateTime(conversationList.value)
+  } finally {
+    // 清理定时器
+    if (loadingTime.value) {
+      clearTimeout(loadingTime.value)
+    }
+    // 加载完成
+    loading.value = false
+  }
+}
+
+/** 按照 creteTime 创建时间,进行分组 */
+const getConversationGroupByCreateTime = async (list: ChatConversationVO[]) => {
+  // 排序、指定、时间分组(今天、一天前、三天前、七天前、30天前)
+  // noinspection NonAsciiCharacters
+  const groupMap = {
+    置顶: [],
+    今天: [],
+    一天前: [],
+    三天前: [],
+    七天前: [],
+    三十天前: []
+  }
+  // 当前时间的时间戳
+  const now = Date.now()
+  // 定义时间间隔常量(单位:毫秒)
+  const oneDay = 24 * 60 * 60 * 1000
+  const threeDays = 3 * oneDay
+  const sevenDays = 7 * oneDay
+  const thirtyDays = 30 * oneDay
+  for (const conversation of list) {
+    // 置顶
+    if (conversation.pinned) {
+      groupMap['置顶'].push(conversation)
+      continue
+    }
+    // 计算时间差(单位:毫秒)
+    const diff = now - conversation.createTime
+    // 根据时间间隔判断
+    if (diff < oneDay) {
+      groupMap['今天'].push(conversation)
+    } else if (diff < threeDays) {
+      groupMap['一天前'].push(conversation)
+    } else if (diff < sevenDays) {
+      groupMap['三天前'].push(conversation)
+    } else if (diff < thirtyDays) {
+      groupMap['七天前'].push(conversation)
+    } else {
+      groupMap['三十天前'].push(conversation)
+    }
+  }
+  return groupMap
+}
+
+/** 新建对话 */
+const createConversation = async () => {
+  // 1. 新建对话
+  const conversationId = await ChatConversationApi.createChatConversationMy(
+    {} as unknown as ChatConversationVO
+  )
+  // 2. 获取对话内容
+  await getChatConversationList()
+  // 3. 选中对话
+  await handleConversationClick(conversationId)
+  // 4. 回调
+  emits('onConversationCreate')
+}
+
+/** 修改对话的标题 */
+const updateConversationTitle = async (conversation: ChatConversationVO) => {
+  // 1. 二次确认
+  const { value } = await ElMessageBox.prompt('修改标题', {
+    inputPattern: /^[\s\S]*.*\S[\s\S]*$/, // 判断非空,且非空格
+    inputErrorMessage: '标题不能为空',
+    inputValue: conversation.title
+  })
+  // 2. 发起修改
+  await ChatConversationApi.updateChatConversationMy({
+    id: conversation.id,
+    title: value
+  } as ChatConversationVO)
+  message.success('重命名成功')
+  // 3. 刷新列表
+  await getChatConversationList()
+  // 4. 过滤当前切换的
+  const filterConversationList = conversationList.value.filter((item) => {
+    return item.id === conversation.id
+  })
+  if (filterConversationList.length > 0) {
+    // tip:避免切换对话
+    if (activeConversationId.value === filterConversationList[0].id) {
+      emits('onConversationClick', filterConversationList[0])
+    }
+  }
+}
+
+/** 删除聊天对话 */
+const deleteChatConversation = async (conversation: ChatConversationVO) => {
+  try {
+    // 删除的二次确认
+    await message.delConfirm(`是否确认删除对话 - ${conversation.title}?`)
+    // 发起删除
+    await ChatConversationApi.deleteChatConversationMy(conversation.id)
+    message.success('对话已删除')
+    // 刷新列表
+    await getChatConversationList()
+    // 回调
+    emits('onConversationDelete', conversation)
+  } catch {}
+}
+
+/** 清空对话 */
+const handleClearConversation = async () => {
+  try {
+    await message.confirm('确认后对话会全部清空,置顶的对话除外。')
+    await ChatConversationApi.deleteChatConversationMyByUnpinned()
+    ElMessage({
+      message: '操作成功!',
+      type: 'success'
+    })
+    // 清空 对话 和 对话内容
+    activeConversationId.value = null
+    // 获取 对话列表
+    await getChatConversationList()
+    // 回调 方法
+    emits('onConversationClear')
+  } catch {}
+}
+
+/** 对话置顶 */
+const handleTop = async (conversation: ChatConversationVO) => {
+  // 更新对话置顶
+  conversation.pinned = !conversation.pinned
+  await ChatConversationApi.updateChatConversationMy(conversation)
+  // 刷新对话
+  await getChatConversationList()
+}
+
+// ============ 角色仓库 ============
+
+/** 角色仓库抽屉 */
+const roleRepositoryOpen = ref<boolean>(false) // 角色仓库是否打开
+const handleRoleRepository = async () => {
+  roleRepositoryOpen.value = !roleRepositoryOpen.value
+}
+
+/** 监听选中的对话 */
+const { activeId } = toRefs(props)
+watch(activeId, async (newValue, oldValue) => {
+  activeConversationId.value = newValue as string
+})
+
+// 定义 public 方法
+defineExpose({ createConversation })
+
+/** 初始化 */
+onMounted(async () => {
+  // 获取 对话列表
+  await getChatConversationList()
+  // 默认选中
+  if (props.activeId) {
+    activeConversationId.value = props.activeId
+  } else {
+    // 首次默认选中第一个
+    if (conversationList.value.length) {
+      activeConversationId.value = conversationList.value[0].id
+      // 回调 onConversationClick
+      await emits('onConversationClick', conversationList.value[0])
+    }
+  }
+})
+</script>
+
+<style scoped lang="scss">
+.conversation-container {
+  position: relative;
+  display: flex;
+  flex-direction: column;
+  justify-content: space-between;
+  padding: 10px 10px 0;
+  overflow: hidden;
+
+  .btn-new-conversation {
+    padding: 18px 0;
+  }
+
+  .search-input {
+    margin-top: 20px;
+  }
+
+  .conversation-list {
+    overflow: auto;
+    height: 100%;
+
+    .classify-title {
+      padding-top: 10px;
+    }
+
+    .conversation-item {
+      margin-top: 5px;
+    }
+
+    .conversation {
+      display: flex;
+      flex-direction: row;
+      justify-content: space-between;
+      flex: 1;
+      padding: 0 5px;
+      cursor: pointer;
+      border-radius: 5px;
+      align-items: center;
+      line-height: 30px;
+
+      &.active {
+        background-color: #e6e6e6;
+
+        .button {
+          display: inline-block;
+        }
+      }
+
+      .title-wrapper {
+        display: flex;
+        flex-direction: row;
+        align-items: center;
+      }
+
+      .title {
+        padding: 2px 10px;
+        max-width: 220px;
+        font-size: 14px;
+        font-weight: 400;
+        color: rgba(0, 0, 0, 0.77);
+        overflow: hidden;
+        white-space: nowrap;
+        text-overflow: ellipsis;
+      }
+
+      .avatar {
+        width: 25px;
+        height: 25px;
+        border-radius: 5px;
+        display: flex;
+        flex-direction: row;
+        justify-items: center;
+      }
+
+      // 对话编辑、删除
+      .button-wrapper {
+        right: 2px;
+        display: flex;
+        flex-direction: row;
+        justify-items: center;
+        color: #606266;
+
+        .btn {
+          margin: 0;
+        }
+      }
+    }
+  }
+
+  // 角色仓库、清空未设置对话
+  .tool-box {
+    position: absolute;
+    bottom: 0;
+    left: 0;
+    right: 0;
+    //width: 100%;
+    padding: 0 20px;
+    background-color: #f4f4f4;
+    box-shadow: 0 0 1px 1px rgba(228, 228, 228, 0.8);
+    line-height: 35px;
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+    color: var(--el-text-color);
+
+    > div {
+      display: flex;
+      align-items: center;
+      color: #606266;
+      padding: 0;
+      margin: 0;
+      cursor: pointer;
+
+      > span {
+        margin-left: 5px;
+      }
+    }
+  }
+}
+</style>

+ 145 - 0
src/views/ai/chat/index/components/conversation/ConversationUpdateForm.vue

@@ -0,0 +1,145 @@
+<template>
+  <Dialog title="设定" v-model="dialogVisible">
+    <el-form
+      ref="formRef"
+      :model="formData"
+      :rules="formRules"
+      label-width="130px"
+      v-loading="formLoading"
+    >
+      <el-form-item label="角色设定" prop="systemMessage">
+        <el-input
+          type="textarea"
+          v-model="formData.systemMessage"
+          rows="4"
+          placeholder="请输入角色设定"
+        />
+      </el-form-item>
+      <el-form-item label="模型" prop="modelId">
+        <el-select v-model="formData.modelId" placeholder="请选择模型">
+          <el-option
+            v-for="chatModel in chatModelList"
+            :key="chatModel.id"
+            :label="chatModel.name"
+            :value="chatModel.id"
+          />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="温度参数" prop="temperature">
+        <el-input-number
+          v-model="formData.temperature"
+          placeholder="请输入温度参数"
+          :min="0"
+          :max="2"
+          :precision="2"
+        />
+      </el-form-item>
+      <el-form-item label="回复数 Token 数" prop="maxTokens">
+        <el-input-number
+          v-model="formData.maxTokens"
+          placeholder="请输入回复数 Token 数"
+          :min="0"
+          :max="4096"
+        />
+      </el-form-item>
+      <el-form-item label="上下文数量" prop="maxContexts">
+        <el-input-number
+          v-model="formData.maxContexts"
+          placeholder="请输入上下文数量"
+          :min="0"
+          :max="20"
+        />
+      </el-form-item>
+    </el-form>
+    <template #footer>
+      <el-button @click="submitForm" type="primary" :disabled="formLoading">确 定</el-button>
+      <el-button @click="dialogVisible = false">取 消</el-button>
+    </template>
+  </Dialog>
+</template>
+<script setup lang="ts">
+import { CommonStatusEnum } from '@/utils/constants'
+import { ChatModelApi, ChatModelVO } from '@/api/ai/model/chatModel'
+import { ChatConversationApi, ChatConversationVO } from '@/api/ai/chat/conversation'
+
+/** AI 聊天对话的更新表单 */
+defineOptions({ name: 'ChatConversationUpdateForm' })
+
+const message = useMessage() // 消息弹窗
+
+const dialogVisible = ref(false) // 弹窗的是否展示
+const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用
+const formData = ref({
+  id: undefined,
+  systemMessage: undefined,
+  modelId: undefined,
+  temperature: undefined,
+  maxTokens: undefined,
+  maxContexts: undefined
+})
+const formRules = reactive({
+  modelId: [{ required: true, message: '模型不能为空', trigger: 'blur' }],
+  status: [{ required: true, message: '状态不能为空', trigger: 'blur' }],
+  temperature: [{ required: true, message: '温度参数不能为空', trigger: 'blur' }],
+  maxTokens: [{ required: true, message: '回复数 Token 数不能为空', trigger: 'blur' }],
+  maxContexts: [{ required: true, message: '上下文数量不能为空', trigger: 'blur' }]
+})
+const formRef = ref() // 表单 Ref
+const chatModelList = ref([] as ChatModelVO[]) // 聊天模型列表
+
+/** 打开弹窗 */
+const open = async (id: number) => {
+  dialogVisible.value = true
+  resetForm()
+  // 修改时,设置数据
+  if (id) {
+    formLoading.value = true
+    try {
+      const data = await ChatConversationApi.getChatConversationMy(id)
+      formData.value = Object.keys(formData.value).reduce((obj, key) => {
+        if (data.hasOwnProperty(key)) {
+          obj[key] = data[key]
+        }
+        return obj
+      }, {})
+    } finally {
+      formLoading.value = false
+    }
+  }
+  // 获得下拉数据
+  chatModelList.value = await ChatModelApi.getChatModelSimpleList(CommonStatusEnum.ENABLE)
+}
+defineExpose({ open }) // 提供 open 方法,用于打开弹窗
+
+/** 提交表单 */
+const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调
+const submitForm = async () => {
+  // 校验表单
+  await formRef.value.validate()
+  // 提交请求
+  formLoading.value = true
+  try {
+    const data = formData.value as unknown as ChatConversationVO
+    await ChatConversationApi.updateChatConversationMy(data)
+    message.success('对话配置已更新')
+    dialogVisible.value = false
+    // 发送操作成功的事件
+    emit('success')
+  } finally {
+    formLoading.value = false
+  }
+}
+
+/** 重置表单 */
+const resetForm = () => {
+  formData.value = {
+    id: undefined,
+    systemMessage: undefined,
+    modelId: undefined,
+    temperature: undefined,
+    maxTokens: undefined,
+    maxContexts: undefined
+  }
+  formRef.value?.resetFields()
+}
+</script>

+ 247 - 0
src/views/crm/backlog/components/ContractAuditList.vue

@@ -0,0 +1,247 @@
+<!-- 待审核合同 -->
+<template>
+  <ContentWrap>
+    <div class="pb-5 text-xl">待审核合同</div>
+    <!-- 搜索工作栏 -->
+    <el-form
+      ref="queryFormRef"
+      :inline="true"
+      :model="queryParams"
+      class="-mb-15px"
+      label-width="68px"
+    >
+      <el-form-item label="合同状态" prop="auditStatus">
+        <el-select
+          v-model="queryParams.auditStatus"
+          class="!w-240px"
+          placeholder="状态"
+          @change="handleQuery"
+        >
+          <el-option
+            v-for="(option, index) in AUDIT_STATUS"
+            :label="option.label"
+            :value="option.value"
+            :key="index"
+          />
+        </el-select>
+      </el-form-item>
+    </el-form>
+  </ContentWrap>
+
+  <ContentWrap>
+    <el-table v-loading="loading" :data="list" :show-overflow-tooltip="true" :stripe="true">
+      <el-table-column align="center" fixed="left" label="合同编号" prop="no" width="180" />
+      <el-table-column align="center" fixed="left" label="合同名称" prop="name" width="160">
+        <template #default="scope">
+          <el-link :underline="false" type="primary" @click="openDetail(scope.row.id)">
+            {{ scope.row.name }}
+          </el-link>
+        </template>
+      </el-table-column>
+      <el-table-column align="center" label="客户名称" prop="customerName" width="120">
+        <template #default="scope">
+          <el-link
+            :underline="false"
+            type="primary"
+            @click="openCustomerDetail(scope.row.customerId)"
+          >
+            {{ scope.row.customerName }}
+          </el-link>
+        </template>
+      </el-table-column>
+      <el-table-column align="center" label="商机名称" prop="businessName" width="130">
+        <template #default="scope">
+          <el-link
+            :underline="false"
+            type="primary"
+            @click="openBusinessDetail(scope.row.businessId)"
+          >
+            {{ scope.row.businessName }}
+          </el-link>
+        </template>
+      </el-table-column>
+      <el-table-column
+        align="center"
+        label="合同金额(元)"
+        prop="totalPrice"
+        width="140"
+        :formatter="erpPriceTableColumnFormatter"
+      />
+      <el-table-column
+        align="center"
+        label="下单时间"
+        prop="orderDate"
+        width="120"
+        :formatter="dateFormatter2"
+      />
+      <el-table-column
+        align="center"
+        label="合同开始时间"
+        prop="startTime"
+        width="120"
+        :formatter="dateFormatter2"
+      />
+      <el-table-column
+        align="center"
+        label="合同结束时间"
+        prop="endTime"
+        width="120"
+        :formatter="dateFormatter2"
+      />
+      <el-table-column align="center" label="客户签约人" prop="contactName" width="130">
+        <template #default="scope">
+          <el-link
+            :underline="false"
+            type="primary"
+            @click="openContactDetail(scope.row.signContactId)"
+          >
+            {{ scope.row.signContactName }}
+          </el-link>
+        </template>
+      </el-table-column>
+      <el-table-column align="center" label="公司签约人" prop="signUserName" width="130" />
+      <el-table-column align="center" label="备注" prop="remark" width="200" />
+      <el-table-column
+        align="center"
+        label="已回款金额(元)"
+        prop="totalReceivablePrice"
+        width="140"
+        :formatter="erpPriceTableColumnFormatter"
+      />
+      <el-table-column
+        align="center"
+        label="未回款金额(元)"
+        prop="totalReceivablePrice"
+        width="140"
+        :formatter="erpPriceTableColumnFormatter"
+      >
+        <template #default="scope">
+          {{ erpPriceInputFormatter(scope.row.totalPrice - scope.row.totalReceivablePrice) }}
+        </template>
+      </el-table-column>
+      <el-table-column
+        :formatter="dateFormatter"
+        align="center"
+        label="最后跟进时间"
+        prop="contactLastTime"
+        width="180px"
+      />
+      <el-table-column align="center" label="负责人" prop="ownerUserName" width="120" />
+      <el-table-column align="center" label="所属部门" prop="ownerUserDeptName" width="100px" />
+      <el-table-column
+        :formatter="dateFormatter"
+        align="center"
+        label="更新时间"
+        prop="updateTime"
+        width="180px"
+      />
+      <el-table-column
+        :formatter="dateFormatter"
+        align="center"
+        label="创建时间"
+        prop="createTime"
+        width="180px"
+      />
+      <el-table-column align="center" label="创建人" prop="creatorName" width="120" />
+      <el-table-column align="center" fixed="right" label="合同状态" prop="auditStatus" width="120">
+        <template #default="scope">
+          <dict-tag :type="DICT_TYPE.CRM_AUDIT_STATUS" :value="scope.row.auditStatus" />
+        </template>
+      </el-table-column>
+      <el-table-column fixed="right" label="操作" width="90">
+        <template #default="scope">
+          <el-button
+            link
+            v-hasPermi="['crm:contract:update']"
+            type="primary"
+            @click="handleProcessDetail(scope.row)"
+          >
+            查看审批
+          </el-button>
+        </template>
+      </el-table-column>
+    </el-table>
+    <!-- 分页 -->
+    <Pagination
+      v-model:limit="queryParams.pageSize"
+      v-model:page="queryParams.pageNo"
+      :total="total"
+      @pagination="getList"
+    />
+  </ContentWrap>
+</template>
+
+<script setup lang="ts" name="CheckContract">
+import { dateFormatter, dateFormatter2 } from '@/utils/formatTime'
+import * as ContractApi from '@/api/crm/contract'
+import { DICT_TYPE } from '@/utils/dict'
+import { AUDIT_STATUS } from './common'
+import { erpPriceInputFormatter, erpPriceTableColumnFormatter } from '@/utils'
+
+const loading = ref(true) // 列表的加载中
+const total = ref(0) // 列表的总页数
+const list = ref([]) // 列表的数据
+const queryParams = reactive({
+  pageNo: 1,
+  pageSize: 10,
+  sceneType: 1, // 我负责的
+  auditStatus: 10
+})
+const queryFormRef = ref() // 搜索的表单
+
+/** 查询列表 */
+const getList = async () => {
+  loading.value = true
+  try {
+    const data = await ContractApi.getContractPage(queryParams)
+    list.value = data.list
+    total.value = data.total
+  } finally {
+    loading.value = false
+  }
+}
+
+/** 搜索按钮操作 */
+const handleQuery = () => {
+  queryParams.pageNo = 1
+  getList()
+}
+
+/** 查看审批 */
+const handleProcessDetail = (row: ContractApi.ContractVO) => {
+  push({ name: 'BpmProcessInstanceDetail', query: { id: row.processInstanceId } })
+}
+
+/** 打开合同详情 */
+const { push } = useRouter()
+const openDetail = (id: number) => {
+  push({ name: 'CrmContractDetail', params: { id } })
+}
+
+/** 打开客户详情 */
+const openCustomerDetail = (id: number) => {
+  push({ name: 'CrmCustomerDetail', params: { id } })
+}
+
+/** 打开联系人详情 */
+const openContactDetail = (id: number) => {
+  push({ name: 'CrmContactDetail', params: { id } })
+}
+
+/** 打开商机详情 */
+const openBusinessDetail = (id: number) => {
+  push({ name: 'CrmBusinessDetail', params: { id } })
+}
+
+/** 激活时 */
+onActivated(async () => {
+  await getList()
+})
+
+/** 初始化 **/
+onMounted(() => {
+  getList()
+})
+</script>
+
+<style scoped></style>

+ 246 - 0
src/views/crm/backlog/components/ContractRemindList.vue

@@ -0,0 +1,246 @@
+<!-- 即将到期的合同 -->
+<template>
+  <ContentWrap>
+    <div class="pb-5 text-xl"> 即将到期的合同 </div>
+    <!-- 搜索工作栏 -->
+    <el-form
+      class="-mb-15px"
+      :model="queryParams"
+      ref="queryFormRef"
+      :inline="true"
+      label-width="68px"
+    >
+      <el-form-item label="到期状态" prop="expiryType">
+        <el-select
+          v-model="queryParams.expiryType"
+          class="!w-240px"
+          placeholder="状态"
+          @change="handleQuery"
+        >
+          <el-option
+            v-for="(option, index) in CONTRACT_EXPIRY_TYPE"
+            :label="option.label"
+            :value="option.value"
+            :key="index"
+          />
+        </el-select>
+      </el-form-item>
+    </el-form>
+  </ContentWrap>
+
+  <ContentWrap>
+    <el-table v-loading="loading" :data="list" :show-overflow-tooltip="true" :stripe="true">
+      <el-table-column align="center" fixed="left" label="合同编号" prop="no" width="180" />
+      <el-table-column align="center" fixed="left" label="合同名称" prop="name" width="160">
+        <template #default="scope">
+          <el-link :underline="false" type="primary" @click="openDetail(scope.row.id)">
+            {{ scope.row.name }}
+          </el-link>
+        </template>
+      </el-table-column>
+      <el-table-column align="center" label="客户名称" prop="customerName" width="120">
+        <template #default="scope">
+          <el-link
+            :underline="false"
+            type="primary"
+            @click="openCustomerDetail(scope.row.customerId)"
+          >
+            {{ scope.row.customerName }}
+          </el-link>
+        </template>
+      </el-table-column>
+      <el-table-column align="center" label="商机名称" prop="businessName" width="130">
+        <template #default="scope">
+          <el-link
+            :underline="false"
+            type="primary"
+            @click="openBusinessDetail(scope.row.businessId)"
+          >
+            {{ scope.row.businessName }}
+          </el-link>
+        </template>
+      </el-table-column>
+      <el-table-column
+        align="center"
+        label="合同金额(元)"
+        prop="totalPrice"
+        width="140"
+        :formatter="erpPriceTableColumnFormatter"
+      />
+      <el-table-column
+        align="center"
+        label="下单时间"
+        prop="orderDate"
+        width="120"
+        :formatter="dateFormatter2"
+      />
+      <el-table-column
+        align="center"
+        label="合同开始时间"
+        prop="startTime"
+        width="120"
+        :formatter="dateFormatter2"
+      />
+      <el-table-column
+        align="center"
+        label="合同结束时间"
+        prop="endTime"
+        width="120"
+        :formatter="dateFormatter2"
+      />
+      <el-table-column align="center" label="客户签约人" prop="contactName" width="130">
+        <template #default="scope">
+          <el-link
+            :underline="false"
+            type="primary"
+            @click="openContactDetail(scope.row.signContactId)"
+          >
+            {{ scope.row.signContactName }}
+          </el-link>
+        </template>
+      </el-table-column>
+      <el-table-column align="center" label="公司签约人" prop="signUserName" width="130" />
+      <el-table-column align="center" label="备注" prop="remark" width="200" />
+      <el-table-column
+        align="center"
+        label="已回款金额(元)"
+        prop="totalReceivablePrice"
+        width="140"
+        :formatter="erpPriceTableColumnFormatter"
+      />
+      <el-table-column
+        align="center"
+        label="未回款金额(元)"
+        prop="totalReceivablePrice"
+        width="140"
+        :formatter="erpPriceTableColumnFormatter"
+      >
+        <template #default="scope">
+          {{ erpPriceInputFormatter(scope.row.totalPrice - scope.row.totalReceivablePrice) }}
+        </template>
+      </el-table-column>
+      <el-table-column
+        :formatter="dateFormatter"
+        align="center"
+        label="最后跟进时间"
+        prop="contactLastTime"
+        width="180px"
+      />
+      <el-table-column align="center" label="负责人" prop="ownerUserName" width="120" />
+      <el-table-column align="center" label="所属部门" prop="ownerUserDeptName" width="100px" />
+      <el-table-column
+        :formatter="dateFormatter"
+        align="center"
+        label="更新时间"
+        prop="updateTime"
+        width="180px"
+      />
+      <el-table-column
+        :formatter="dateFormatter"
+        align="center"
+        label="创建时间"
+        prop="createTime"
+        width="180px"
+      />
+      <el-table-column align="center" label="创建人" prop="creatorName" width="120" />
+      <el-table-column align="center" fixed="right" label="合同状态" prop="auditStatus" width="120">
+        <template #default="scope">
+          <dict-tag :type="DICT_TYPE.CRM_AUDIT_STATUS" :value="scope.row.auditStatus" />
+        </template>
+      </el-table-column>
+      <el-table-column fixed="right" label="操作" width="90">
+        <template #default="scope">
+          <el-button
+            link
+            v-hasPermi="['crm:contract:update']"
+            type="primary"
+            @click="handleProcessDetail(scope.row)"
+          >
+            查看审批
+          </el-button>
+        </template>
+      </el-table-column>
+    </el-table>
+    <!-- 分页 -->
+    <Pagination
+      v-model:limit="queryParams.pageSize"
+      v-model:page="queryParams.pageNo"
+      :total="total"
+      @pagination="getList"
+    />
+  </ContentWrap>
+</template>
+
+<script setup lang="ts" name="EndContract">
+import { dateFormatter, dateFormatter2 } from '@/utils/formatTime'
+import * as ContractApi from '@/api/crm/contract'
+import { fenToYuanFormat } from '@/utils/formatter'
+import { DICT_TYPE } from '@/utils/dict'
+import { CONTRACT_EXPIRY_TYPE } from './common'
+import { erpPriceInputFormatter, erpPriceTableColumnFormatter } from '@/utils'
+
+const loading = ref(true) // 列表的加载中
+const total = ref(0) // 列表的总页数
+const list = ref([]) // 列表的数据
+const queryParams = reactive({
+  pageNo: 1,
+  pageSize: 10,
+  sceneType: '1', // 自己负责的
+  expiryType: 1
+})
+const queryFormRef = ref() // 搜索的表单
+
+/** 查询列表 */
+const getList = async () => {
+  loading.value = true
+  try {
+    const data = await ContractApi.getContractPage(queryParams)
+    list.value = data.list
+    total.value = data.total
+  } finally {
+    loading.value = false
+  }
+}
+
+/** 搜索按钮操作 */
+const handleQuery = () => {
+  queryParams.pageNo = 1
+  getList()
+}
+
+/** 查看审批 */
+const handleProcessDetail = (row: ContractApi.ContractVO) => {
+  push({ name: 'BpmProcessInstanceDetail', query: { id: row.processInstanceId } })
+}
+
+/** 打开合同详情 */
+const { push } = useRouter()
+const openDetail = (id: number) => {
+  push({ name: 'CrmContractDetail', params: { id } })
+}
+
+/** 打开客户详情 */
+const openCustomerDetail = (id: number) => {
+  push({ name: 'CrmCustomerDetail', params: { id } })
+}
+
+/** 打开联系人详情 */
+const openContactDetail = (id: number) => {
+  push({ name: 'CrmContactDetail', params: { id } })
+}
+
+/** 打开商机详情 */
+const openBusinessDetail = (id: number) => {
+  push({ name: 'CrmBusinessDetail', params: { id } })
+}
+
+/** 激活时 */
+onActivated(async () => {
+  await getList()
+})
+
+/** 初始化 **/
+onMounted(() => {
+  getList()
+})
+</script>

+ 369 - 0
src/views/crm/contract/ContractForm.vue

@@ -0,0 +1,369 @@
+<template>
+  <Dialog v-model="dialogVisible" :title="dialogTitle" width="1280">
+    <el-form
+      ref="formRef"
+      v-loading="formLoading"
+      :model="formData"
+      :rules="formRules"
+      label-width="120px"
+    >
+      <el-row>
+        <el-col :span="8">
+          <el-form-item label="合同编号" prop="no">
+            <el-input disabled v-model="formData.no" placeholder="保存时自动生成" />
+          </el-form-item>
+        </el-col>
+        <el-col :span="8">
+          <el-form-item label="合同名称" prop="name">
+            <el-input v-model="formData.name" placeholder="请输入合同名称" />
+          </el-form-item>
+        </el-col>
+        <el-col :span="8">
+          <el-form-item label="负责人" prop="ownerUserId">
+            <el-select
+              v-model="formData.ownerUserId"
+              :disabled="formType !== 'create'"
+              class="w-1/1"
+            >
+              <el-option
+                v-for="item in userOptions"
+                :key="item.id"
+                :label="item.nickname"
+                :value="item.id"
+              />
+            </el-select>
+          </el-form-item>
+        </el-col>
+      </el-row>
+      <el-row>
+        <el-col :span="8">
+          <el-form-item label="客户名称" prop="customerId">
+            <el-select
+              v-model="formData.customerId"
+              placeholder="请选择客户"
+              class="w-1/1"
+              @change="handleCustomerChange"
+            >
+              <el-option
+                v-for="item in customerList"
+                :key="item.id"
+                :label="item.name"
+                :value="item.id"
+              />
+            </el-select>
+          </el-form-item>
+        </el-col>
+        <el-col :span="8">
+          <el-form-item label="商机名称" prop="businessId">
+            <el-select
+              @change="handleBusinessChange"
+              :disabled="!formData.customerId"
+              v-model="formData.businessId"
+              class="w-1/1"
+            >
+              <el-option
+                v-for="item in getBusinessOptions"
+                :key="item.id"
+                :label="item.name"
+                :value="item.id!"
+              />
+            </el-select>
+          </el-form-item>
+        </el-col>
+      </el-row>
+      <el-row>
+        <el-col :span="8">
+          <el-form-item label="下单日期" prop="orderDate">
+            <el-date-picker
+              v-model="formData.orderDate"
+              placeholder="选择下单日期"
+              type="date"
+              value-format="x"
+              class="!w-1/1"
+            />
+          </el-form-item>
+        </el-col>
+        <el-col :span="8">
+          <el-form-item label="开始时间" prop="startTime">
+            <el-date-picker
+              v-model="formData.startTime"
+              placeholder="选择开始时间"
+              type="date"
+              value-format="x"
+              class="!w-1/1"
+            />
+          </el-form-item>
+        </el-col>
+        <el-col :span="8">
+          <el-form-item label="结束时间" prop="endTime">
+            <el-date-picker
+              v-model="formData.endTime"
+              placeholder="选择结束时间"
+              type="date"
+              value-format="x"
+              class="!w-1/1"
+            />
+          </el-form-item>
+        </el-col>
+      </el-row>
+      <el-row>
+        <el-col :span="8">
+          <el-form-item label="公司签约人" prop="signUserId">
+            <el-select v-model="formData.signUserId" class="w-1/1">
+              <el-option
+                v-for="item in userOptions"
+                :key="item.id"
+                :label="item.nickname"
+                :value="item.id!"
+              />
+            </el-select>
+          </el-form-item>
+        </el-col>
+        <el-col :span="8">
+          <el-form-item label="客户签约人" prop="signContactId">
+            <el-select
+              v-model="formData.signContactId"
+              :disabled="!formData.customerId"
+              class="w-1/1"
+            >
+              <el-option
+                v-for="item in getContactOptions"
+                :key="item.id"
+                :label="item.name"
+                :value="item.id"
+              />
+            </el-select>
+          </el-form-item>
+        </el-col>
+        <el-col :span="8">
+          <el-form-item label="备注" prop="remark">
+            <el-input v-model="formData.remark" placeholder="请输入备注" type="textarea" />
+          </el-form-item>
+        </el-col>
+      </el-row>
+      <!-- 子表的表单 -->
+      <ContentWrap>
+        <el-tabs v-model="subTabsName" class="-mt-15px -mb-10px">
+          <el-tab-pane label="产品清单" name="product">
+            <ContractProductForm
+              ref="productFormRef"
+              :products="formData.products"
+              :disabled="disabled"
+            />
+          </el-tab-pane>
+        </el-tabs>
+      </ContentWrap>
+      <el-row>
+        <el-col :span="8">
+          <el-form-item label="产品总金额" prop="totalProductPrice">
+            <el-input
+              disabled
+              v-model="formData.totalProductPrice"
+              :formatter="erpPriceTableColumnFormatter"
+            />
+          </el-form-item>
+        </el-col>
+        <el-col :span="8">
+          <el-form-item label="整单折扣(%)" prop="discountPercent">
+            <el-input-number
+              v-model="formData.discountPercent"
+              placeholder="请输入整单折扣"
+              controls-position="right"
+              :min="0"
+              :precision="2"
+              class="!w-1/1"
+            />
+          </el-form-item>
+        </el-col>
+        <el-col :span="8">
+          <el-form-item label="折扣后金额" prop="totalPrice">
+            <el-input
+              disabled
+              v-model="formData.totalPrice"
+              placeholder="请输入商机金额"
+              :formatter="erpPriceTableColumnFormattere"
+            />
+          </el-form-item>
+        </el-col>
+      </el-row>
+    </el-form>
+    <template #footer>
+      <el-button :disabled="formLoading" type="primary" @click="submitForm">保存</el-button>
+      <el-button @click="dialogVisible = false">取 消</el-button>
+    </template>
+  </Dialog>
+</template>
+<script lang="ts" setup>
+import * as CustomerApi from '@/api/crm/customer'
+import * as ContractApi from '@/api/crm/contract'
+import * as UserApi from '@/api/system/user'
+import * as ContactApi from '@/api/crm/contact'
+import * as BusinessApi from '@/api/crm/business'
+import { erpPriceMultiply, erpPriceTableColumnFormatter } from '@/utils'
+import { useUserStore } from '@/store/modules/user'
+import ContractProductForm from '@/views/crm/contract/components/ContractProductForm.vue'
+
+const { t } = useI18n() // 国际化
+const message = useMessage() // 消息弹窗
+
+const dialogVisible = ref(false) // 弹窗的是否展示
+const dialogTitle = ref('') // 弹窗的标题
+const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用
+const formType = ref('') // 表单的类型:create - 新增;update - 修改
+const formData = ref({
+  id: undefined,
+  no: undefined,
+  name: undefined,
+  customerId: undefined,
+  businessId: undefined,
+  orderDate: undefined,
+  startTime: undefined,
+  endTime: undefined,
+  signUserId: undefined,
+  signContactId: undefined,
+  ownerUserId: undefined,
+  discountPercent: 0,
+  totalProductPrice: undefined,
+  remark: undefined,
+  products: []
+})
+const formRules = reactive({
+  name: [{ required: true, message: '合同名称不能为空', trigger: 'blur' }],
+  customerId: [{ required: true, message: '客户不能为空', trigger: 'blur' }],
+  orderDate: [{ required: true, message: '下单日期不能为空', trigger: 'blur' }],
+  ownerUserId: [{ required: true, message: '负责人不能为空', trigger: 'blur' }]
+})
+const formRef = ref() // 表单 Ref
+const userOptions = ref<UserApi.UserVO[]>([]) // 用户列表
+const customerList = ref([]) // 客户列表的数据
+const businessList = ref<BusinessApi.BusinessVO[]>([])
+const contactList = ref<ContactApi.ContactVO[]>([])
+
+/** 子表的表单 */
+const subTabsName = ref('product')
+const productFormRef = ref()
+
+/** 计算 discountPrice、totalPrice 价格 */
+watch(
+  () => formData.value,
+  (val) => {
+    if (!val) {
+      return
+    }
+    const totalProductPrice = val.products.reduce((prev, curr) => prev + curr.totalPrice, 0)
+    const discountPrice =
+      val.discountPercent != null
+        ? erpPriceMultiply(totalProductPrice, val.discountPercent / 100.0)
+        : 0
+    const totalPrice = totalProductPrice - discountPrice
+    // 赋值
+    formData.value.totalProductPrice = totalProductPrice
+    formData.value.totalPrice = totalPrice
+  },
+  { deep: true }
+)
+
+/** 打开弹窗 */
+const open = async (type: string, id?: number) => {
+  dialogVisible.value = true
+  dialogTitle.value = t('action.' + type)
+  formType.value = type
+  resetForm()
+  // 修改时,设置数据
+  if (id) {
+    formLoading.value = true
+    try {
+      formData.value = await ContractApi.getContract(id)
+    } finally {
+      formLoading.value = false
+    }
+  }
+  // 获得客户列表
+  customerList.value = await CustomerApi.getCustomerSimpleList()
+  // 获得用户列表
+  userOptions.value = await UserApi.getSimpleUserList()
+  // 默认新建时选中自己
+  if (formType.value === 'create') {
+    formData.value.ownerUserId = useUserStore().getUser.id
+  }
+  // 获取联系人
+  contactList.value = await ContactApi.getSimpleContactList()
+  // 获得商机列表
+  businessList.value = await BusinessApi.getSimpleBusinessList()
+}
+defineExpose({ open }) // 提供 open 方法,用于打开弹窗
+
+/** 提交表单 */
+const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调
+const submitForm = async () => {
+  // 校验表单
+  if (!formRef) return
+  const valid = await formRef.value.validate()
+  if (!valid) return
+  // 提交请求
+  formLoading.value = true
+  productFormRef.value.validate()
+  try {
+    const data = unref(formData.value) as unknown as ContractApi.ContractVO
+    if (formType.value === 'create') {
+      await ContractApi.createContract(data)
+      message.success(t('common.createSuccess'))
+    } else {
+      await ContractApi.updateContract(data)
+      message.success(t('common.updateSuccess'))
+    }
+    dialogVisible.value = false
+    // 发送操作成功的事件
+    emit('success')
+  } finally {
+    formLoading.value = false
+  }
+}
+
+/** 重置表单 */
+const resetForm = () => {
+  formData.value = {
+    id: undefined,
+    no: undefined,
+    name: undefined,
+    customerId: undefined,
+    businessId: undefined,
+    orderDate: undefined,
+    startTime: undefined,
+    endTime: undefined,
+    signUserId: undefined,
+    signContactId: undefined,
+    ownerUserId: undefined,
+    discountPercent: 0,
+    totalProductPrice: undefined,
+    remark: undefined,
+    products: []
+  }
+  formRef.value?.resetFields()
+}
+
+/** 处理切换客户 */
+const handleCustomerChange = () => {
+  formData.value.businessId = undefined
+  formData.value.signContactId = undefined
+  formData.value.products = []
+}
+
+/** 处理商机变化 */
+const handleBusinessChange = async (businessId: number) => {
+  const business = await BusinessApi.getBusiness(businessId)
+  business.products.forEach((item) => {
+    item.contractPrice = item.businessPrice
+  })
+  formData.value.products = business.products
+}
+
+/** 动态获取客户联系人 */
+const getContactOptions = computed(() =>
+  contactList.value.filter((item) => item.customerId == formData.value.customerId)
+)
+/** 动态获取商机 */
+const getBusinessOptions = computed(() =>
+  businessList.value.filter((item) => item.customerId == formData.value.customerId)
+)
+</script>

+ 136 - 0
src/views/crm/contract/components/ContractList.vue

@@ -0,0 +1,136 @@
+<template>
+  <!-- 操作栏 -->
+  <el-row justify="end">
+    <el-button @click="openForm">
+      <Icon class="mr-5px" icon="clarity:contract-line" />
+      创建合同
+    </el-button>
+  </el-row>
+
+  <!-- 列表 -->
+  <ContentWrap class="mt-10px">
+    <el-table v-loading="loading" :data="list" :stripe="true" :show-overflow-tooltip="true">
+      <el-table-column label="合同名称" fixed="left" align="center" prop="name">
+        <template #default="scope">
+          <el-link type="primary" :underline="false" @click="openDetail(scope.row.id)">
+            {{ scope.row.name }}
+          </el-link>
+        </template>
+      </el-table-column>
+      <el-table-column label="合同编号" align="center" prop="no" />
+      <el-table-column label="客户名称" align="center" prop="customerName" />
+      <el-table-column
+        label="合同金额(元)"
+        align="center"
+        prop="totalPrice"
+        :formatter="erpPriceTableColumnFormatter"
+      />
+      <el-table-column
+        label="开始时间"
+        align="center"
+        prop="startTime"
+        :formatter="dateFormatter"
+        width="180px"
+      />
+      <el-table-column
+        label="结束时间"
+        align="center"
+        prop="endTime"
+        :formatter="dateFormatter"
+        width="180px"
+      />
+      <el-table-column align="center" label="状态" prop="auditStatus">
+        <template #default="scope">
+          <dict-tag :type="DICT_TYPE.CRM_AUDIT_STATUS" :value="scope.row.auditStatus" />
+        </template>
+      </el-table-column>
+    </el-table>
+    <!-- 分页 -->
+    <Pagination
+      :total="total"
+      v-model:page="queryParams.pageNo"
+      v-model:limit="queryParams.pageSize"
+      @pagination="getList"
+    />
+  </ContentWrap>
+
+  <!-- 表单弹窗:添加 -->
+  <ContractForm ref="formRef" @success="getList" />
+</template>
+<script setup lang="ts">
+import * as ContractApi from '@/api/crm/contract'
+import ContractForm from './../ContractForm.vue'
+import { BizTypeEnum } from '@/api/crm/permission'
+import { dateFormatter } from '@/utils/formatTime'
+import { DICT_TYPE } from '@/utils/dict'
+import { erpPriceTableColumnFormatter } from '@/utils'
+
+defineOptions({ name: 'CrmContractList' })
+const props = defineProps<{
+  bizType: number // 业务类型
+  bizId: number // 业务编号
+}>()
+
+const loading = ref(true) // 列表的加载中
+const total = ref(0) // 列表的总页数
+const list = ref([]) // 列表的数据
+const queryParams = reactive({
+  pageNo: 1,
+  pageSize: 10,
+  customerId: undefined as unknown // 允许 undefined + number
+})
+
+/** 查询列表 */
+const getList = async () => {
+  loading.value = true
+  try {
+    // 置空参数
+    queryParams.customerId = undefined
+    // 执行查询
+    let data = { list: [], total: 0 }
+    switch (props.bizType) {
+      case BizTypeEnum.CRM_CUSTOMER:
+        queryParams.customerId = props.bizId
+        data = await ContractApi.getContractPageByCustomer(queryParams)
+        break
+      case BizTypeEnum.CRM_BUSINESS:
+        queryParams.businessId = props.bizId
+        data = await ContractApi.getContractPageByBusiness(queryParams)
+        break
+      default:
+        return
+    }
+    list.value = data.list
+    total.value = data.total
+  } finally {
+    loading.value = false
+  }
+}
+
+/** 搜索按钮操作 */
+const handleQuery = () => {
+  queryParams.pageNo = 1
+  getList()
+}
+
+/** 添加 */
+const formRef = ref()
+const openForm = () => {
+  formRef.value.open('create')
+}
+
+/** 打开合同详情 */
+const { push } = useRouter()
+const openDetail = (id: number) => {
+  push({ name: 'CrmContractDetail', params: { id } })
+}
+
+/** 监听打开的 bizId + bizType,从而加载最新的列表 */
+watch(
+  () => [props.bizId, props.bizType],
+  () => {
+    handleQuery()
+  },
+  { immediate: true, deep: true }
+)
+</script>

+ 183 - 0
src/views/crm/contract/components/ContractProductForm.vue

@@ -0,0 +1,183 @@
+<template>
+  <el-form
+    ref="formRef"
+    :model="formData"
+    :rules="formRules"
+    v-loading="formLoading"
+    label-width="0px"
+    :inline-message="true"
+    :disabled="disabled"
+  >
+    <el-table :data="formData" class="-mt-10px">
+      <el-table-column label="序号" type="index" align="center" width="60" />
+      <el-table-column label="产品名称" min-width="180">
+        <template #default="{ row, $index }">
+          <el-form-item :prop="`${$index}.productId`" :rules="formRules.productId" class="mb-0px!">
+            <el-select
+              v-model="row.productId"
+              clearable
+              filterable
+              @change="onChangeProduct($event, row)"
+              placeholder="请选择产品"
+            >
+              <el-option
+                v-for="item in productList"
+                :key="item.id"
+                :label="item.name"
+                :value="item.id"
+              />
+            </el-select>
+          </el-form-item>
+        </template>
+      </el-table-column>
+      <el-table-column label="条码" min-width="150">
+        <template #default="{ row }">
+          <el-form-item class="mb-0px!">
+            <el-input disabled v-model="row.productNo" />
+          </el-form-item>
+        </template>
+      </el-table-column>
+      <el-table-column label="单位" min-width="80">
+        <template #default="{ row }">
+          <dict-tag :type="DICT_TYPE.CRM_PRODUCT_UNIT" :value="row.productUnit" />
+        </template>
+      </el-table-column>
+      <el-table-column label="价格(元)" min-width="120">
+        <template #default="{ row }">
+          <el-form-item class="mb-0px!">
+            <el-input disabled v-model="row.productPrice" :formatter="erpPriceInputFormatter" />
+          </el-form-item>
+        </template>
+      </el-table-column>
+      <el-table-column label="售价(元)" fixed="right" min-width="140">
+        <template #default="{ row, $index }">
+          <el-form-item :prop="`${$index}.contractPrice`" class="mb-0px!">
+            <el-input-number
+              v-model="row.contractPrice"
+              controls-position="right"
+              :min="0.001"
+              :precision="2"
+              class="!w-100%"
+            />
+          </el-form-item>
+        </template>
+      </el-table-column>
+      <el-table-column label="数量" prop="count" fixed="right" min-width="120">
+        <template #default="{ row, $index }">
+          <el-form-item :prop="`${$index}.count`" :rules="formRules.count" class="mb-0px!">
+            <el-input-number
+              v-model="row.count"
+              controls-position="right"
+              :min="0.001"
+              :precision="3"
+              class="!w-100%"
+            />
+          </el-form-item>
+        </template>
+      </el-table-column>
+      <el-table-column label="合计" prop="totalPrice" fixed="right" min-width="140">
+        <template #default="{ row, $index }">
+          <el-form-item :prop="`${$index}.totalPrice`" class="mb-0px!">
+            <el-input disabled v-model="row.totalPrice" :formatter="erpPriceInputFormatter" />
+          </el-form-item>
+        </template>
+      </el-table-column>
+      <el-table-column align="center" fixed="right" label="操作" width="60">
+        <template #default="{ $index }">
+          <el-button @click="handleDelete($index)" link>—</el-button>
+        </template>
+      </el-table-column>
+    </el-table>
+  </el-form>
+  <el-row justify="center" class="mt-3" v-if="!disabled">
+    <el-button @click="handleAdd" round>+ 添加产品</el-button>
+  </el-row>
+</template>
+<script setup lang="ts">
+import * as ProductApi from '@/api/crm/product'
+import { erpPriceInputFormatter, erpPriceMultiply } from '@/utils'
+import { DICT_TYPE } from '@/utils/dict'
+
+const props = defineProps<{
+  products: undefined
+  disabled: false
+}>()
+const formLoading = ref(false) // 表单的加载中
+const formData = ref([])
+const formRules = reactive({
+  productId: [{ required: true, message: '产品不能为空', trigger: 'blur' }],
+  contractPrice: [{ required: true, message: '合同价格不能为空', trigger: 'blur' }],
+  count: [{ required: true, message: '产品数量不能为空', trigger: 'blur' }]
+})
+const formRef = ref([]) // 表单 Ref
+const productList = ref<ProductApi.ProductVO[]>([]) // 产品列表
+
+/** 初始化设置产品项 */
+watch(
+  () => props.products,
+  async (val) => {
+    formData.value = val
+  },
+  { immediate: true }
+)
+
+/** 监听合同产品变化,计算合同产品总价 */
+watch(
+  () => formData.value,
+  (val) => {
+    if (!val || val.length === 0) {
+      return
+    }
+    // 循环处理
+    val.forEach((item) => {
+      if (item.contractPrice != null && item.count != null) {
+        item.totalPrice = erpPriceMultiply(item.contractPrice, item.count)
+      } else {
+        item.totalPrice = undefined
+      }
+    })
+  },
+  { deep: true }
+)
+
+/** 新增按钮操作 */
+const handleAdd = () => {
+  const row = {
+    id: undefined,
+    productId: undefined,
+    productUnit: undefined, // 产品单位
+    productNo: undefined, // 产品条码
+    productPrice: undefined, // 产品价格
+    contractPrice: undefined,
+    count: 1
+  }
+  formData.value.push(row)
+}
+
+/** 删除按钮操作 */
+const handleDelete = (index: number) => {
+  formData.value.splice(index, 1)
+}
+
+/** 处理产品变更 */
+const onChangeProduct = (productId, row) => {
+  const product = productList.value.find((item) => item.id === productId)
+  if (product) {
+    row.productUnit = product.unit
+    row.productNo = product.no
+    row.productPrice = product.price
+    row.contractPrice = product.price
+  }
+}
+
+/** 表单校验 */
+const validate = () => {
+  return formRef.value.validate()
+}
+defineExpose({ validate })
+
+/** 初始化 */
+onMounted(async () => {
+  productList.value = await ProductApi.getProductSimpleList()
+})
+</script>

+ 45 - 0
src/views/crm/contract/detail/ContractDetailsHeader.vue

@@ -0,0 +1,45 @@
+<!-- 合同详情头部组件-->
+<template>
+  <div>
+    <div class="flex items-start justify-between">
+      <div>
+        <el-col>
+          <el-row>
+            <span class="text-xl font-bold">{{ contract.name }}</span>
+          </el-row>
+        </el-col>
+      </div>
+      <div>
+        <!-- 右上:按钮 -->
+        <slot></slot>
+      </div>
+    </div>
+  </div>
+  <ContentWrap class="mt-10px">
+    <el-descriptions :column="5" direction="vertical">
+      <el-descriptions-item label="客户名称">
+        {{ contract.customerName }}
+      </el-descriptions-item>
+      <el-descriptions-item label="合同金额(元)">
+        {{ erpPriceInputFormatter(contract.totalPrice) }}
+      </el-descriptions-item>
+      <el-descriptions-item label="下单时间">
+        {{ formatDate(contract.orderDate) }}
+      </el-descriptions-item>
+      <el-descriptions-item label="回款金额(元)">
+        {{ erpPriceInputFormatter(contract.totalReceivablePrice) }}
+      </el-descriptions-item>
+      <el-descriptions-item label="负责人">
+        {{ contract.ownerUserName }}
+      </el-descriptions-item>
+    </el-descriptions>
+  </ContentWrap>
+</template>
+<script lang="ts" setup>
+import * as ContractApi from '@/api/crm/contract'
+import { formatDate } from '@/utils/formatTime'
+import { erpPriceInputFormatter } from '@/utils'
+
+defineOptions({ name: 'ContractDetailsHeader' })
+defineProps<{ contract: ContractApi.ContractVO }>()
+</script>

+ 76 - 0
src/views/crm/contract/detail/ContractDetailsInfo.vue

@@ -0,0 +1,76 @@
+<!-- 合同详情组件 -->
+<template>
+  <ContentWrap>
+    <el-collapse v-model="activeNames">
+      <el-collapse-item name="contractInfo">
+        <template #title>
+          <span class="text-base font-bold">基本信息</span>
+        </template>
+        <el-descriptions :column="4">
+          <el-descriptions-item label="合同编号">{{ contract.no }}</el-descriptions-item>
+          <el-descriptions-item label="合同名称">{{ contract.name }}</el-descriptions-item>
+          <el-descriptions-item label="客户名称">{{ contract.customerName }}</el-descriptions-item>
+          <el-descriptions-item label="商机名称">{{ contract.businessName }}</el-descriptions-item>
+          <el-descriptions-item label="合同金额(元)">
+            {{ erpPriceInputFormatter(contract.totalPrice) }}
+          </el-descriptions-item>
+          <el-descriptions-item label="下单时间">
+            {{ formatDate(contract.orderDate) }}
+          </el-descriptions-item>
+          <el-descriptions-item label="合同开始时间">
+            {{ formatDate(contract.startTime) }}
+          </el-descriptions-item>
+          <el-descriptions-item label="合同结束时间">
+            {{ formatDate(contract.endTime) }}
+          </el-descriptions-item>
+          <el-descriptions-item label="客户签约人">
+            {{ contract.signContactName }}
+          </el-descriptions-item>
+          <el-descriptions-item label="公司签约人">
+            {{ contract.signUserName }}
+          </el-descriptions-item>
+          <el-descriptions-item label="备注">
+            {{ contract.remark }}
+          </el-descriptions-item>
+          <el-descriptions-item label="合同状态">
+            <dict-tag :type="DICT_TYPE.CRM_AUDIT_STATUS" :value="contract.auditStatus" />
+          </el-descriptions-item>
+        </el-descriptions>
+      </el-collapse-item>
+      <el-collapse-item name="systemInfo">
+        <template #title>
+          <span class="text-base font-bold">系统信息</span>
+        </template>
+        <el-descriptions :column="4">
+          <el-descriptions-item label="负责人">{{ contract.ownerUserName }}</el-descriptions-item>
+          <el-descriptions-item label="最后跟进时间">
+            {{ formatDate(contract.contactLastTime) }}
+          </el-descriptions-item>
+          <el-descriptions-item label="">&nbsp;</el-descriptions-item>
+          <el-descriptions-item label="">&nbsp;</el-descriptions-item>
+          <el-descriptions-item label="创建人">{{ contract.creatorName }}</el-descriptions-item>
+          <el-descriptions-item label="创建时间">
+            {{ formatDate(contract.createTime) }}
+          </el-descriptions-item>
+          <el-descriptions-item label="更新时间">
+            {{ formatDate(contract.updateTime) }}
+          </el-descriptions-item>
+        </el-descriptions>
+      </el-collapse-item>
+    </el-collapse>
+  </ContentWrap>
+</template>
+<script lang="ts" setup>
+import * as ContractApi from '@/api/crm/contract'
+import { formatDate } from '@/utils/formatTime'
+import { DICT_TYPE } from '@/utils/dict'
+import { erpPriceInputFormatter } from '@/utils'
+
+defineOptions({ name: 'ContractDetailsInfo' })
+defineProps<{
+  contract: ContractApi.ContractVO
+}>()
+
+// 展示的折叠面板
+const activeNames = ref(['contractInfo', 'systemInfo'])
+</script>

+ 66 - 0
src/views/crm/contract/detail/ContractProductList.vue

@@ -0,0 +1,66 @@
+<template>
+  <ContentWrap>
+    <el-table :data="contract.products" :stripe="true" :show-overflow-tooltip="true">
+      <el-table-column
+        align="center"
+        label="产品名称"
+        fixed="left"
+        prop="productName"
+        min-width="160"
+      >
+        <template #default="scope">
+          {{ scope.row.productName }}
+        </template>
+      </el-table-column>
+      <el-table-column label="产品条码" align="center" prop="productNo" min-width="120" />
+      <el-table-column align="center" label="产品单位" prop="productUnit" min-width="160">
+        <template #default="{ row }">
+          <dict-tag :type="DICT_TYPE.CRM_PRODUCT_UNIT" :value="row.productUnit" />
+        </template>
+      </el-table-column>
+      <el-table-column
+        label="产品价格(元)"
+        align="center"
+        prop="productPrice"
+        min-width="140"
+        :formatter="erpPriceTableColumnFormatter"
+      />
+      <el-table-column
+        label="合同价格(元)"
+        align="center"
+        prop="contractPrice"
+        min-width="140"
+        :formatter="erpPriceTableColumnFormatter"
+      />
+      <el-table-column
+        align="center"
+        label="数量"
+        prop="count"
+        min-width="100px"
+        :formatter="erpPriceTableColumnFormatter"
+      />
+      <el-table-column
+        label="合计金额(元)"
+        align="center"
+        prop="totalPrice"
+        min-width="140"
+        :formatter="erpPriceTableColumnFormatter"
+      />
+    </el-table>
+    <el-row class="mt-10px" justify="end">
+      <el-col :span="3"> 整单折扣:{{ erpPriceInputFormatter(contract.discountPercent) }}% </el-col>
+      <el-col :span="4">
+        产品总金额:{{ erpPriceInputFormatter(contract.totalProductPrice) }} 元
+      </el-col>
+    </el-row>
+  </ContentWrap>
+</template>
+<script setup lang="ts">
+import * as ContractApi from '@/api/crm/contract'
+import { erpPriceInputFormatter, erpPriceTableColumnFormatter } from '@/utils'
+import { DICT_TYPE } from '@/utils/dict'
+
+const { contract } = defineProps<{
+  contract: ContractApi.ContractVO
+}>()
+</script>

+ 236 - 0
src/views/crm/statistics/performance/components/ContractCountPerformance.vue

@@ -0,0 +1,236 @@
+<!-- 员工业绩统计 -->
+<template>
+  <!-- Echarts图 -->
+  <el-card shadow="never">
+    <el-skeleton :loading="loading" animated>
+      <Echart :height="500" :options="echartsOption" />
+    </el-skeleton>
+  </el-card>
+
+  <!-- 统计列表 -->
+  <el-card shadow="never" class="mt-16px">
+    <el-table v-loading="loading" :data="tableData">
+      <el-table-column
+        v-for="item in columnsData"
+        :key="item.prop"
+        :label="item.label"
+        :prop="item.prop"
+        align="center"
+      >
+        <template #default="scope">
+          {{ scope.row[item.prop] }}
+        </template>
+      </el-table-column>
+    </el-table>
+  </el-card>
+</template>
+<script setup lang="ts">
+import { EChartsOption } from 'echarts'
+import {
+  StatisticsPerformanceApi,
+  StatisticsPerformanceRespVO
+} from '@/api/crm/statistics/performance'
+
+defineOptions({ name: 'ContractCountPerformance' })
+const props = defineProps<{ queryParams: any }>() // 搜索参数
+
+const loading = ref(false) // 加载中
+const list = ref<StatisticsPerformanceRespVO[]>([]) // 列表的数据
+
+/** 柱状图配置:纵向 */
+const echartsOption = reactive<EChartsOption>({
+  grid: {
+    left: 20,
+    right: 20,
+    bottom: 20,
+    containLabel: true
+  },
+  legend: {},
+  series: [
+    {
+      name: '当月合同数量(个)',
+      type: 'line',
+      data: []
+    },
+    {
+      name: '上月合同数量(个)',
+      type: 'line',
+      data: []
+    },
+    {
+      name: '去年同月合同数量(个)',
+      type: 'line',
+      data: []
+    },
+    {
+      name: '环比增长率(%)',
+      type: 'line',
+      yAxisIndex: 1,
+      data: []
+    },
+    {
+      name: '同比增长率(%)',
+      type: 'line',
+      yAxisIndex: 1,
+      data: []
+    }
+  ],
+  toolbox: {
+    feature: {
+      dataZoom: {
+        xAxisIndex: false // 数据区域缩放:Y 轴不缩放
+      },
+      brush: {
+        type: ['lineX', 'clear'] // 区域缩放按钮、还原按钮
+      },
+      saveAsImage: { show: true, name: '客户总量分析' } // 保存为图片
+    }
+  },
+  tooltip: {
+    trigger: 'axis',
+    axisPointer: {
+      type: 'shadow'
+    }
+  },
+  yAxis: [
+    {
+      type: 'value',
+      name: '数量(个)',
+      axisTick: {
+        show: false
+      },
+      axisLabel: {
+        color: '#BDBDBD',
+        formatter: '{value}'
+      },
+      /** 坐标轴轴线相关设置 */
+      axisLine: {
+        lineStyle: {
+          color: '#BDBDBD'
+        }
+      },
+      splitLine: {
+        show: true,
+        lineStyle: {
+          color: '#e6e6e6'
+        }
+      }
+    },
+    {
+      type: 'value',
+      name: '',
+      axisTick: {
+        alignWithLabel: true,
+        lineStyle: {
+          width: 0
+        }
+      },
+      axisLabel: {
+        color: '#BDBDBD',
+        formatter: '{value}%'
+      },
+      /** 坐标轴轴线相关设置 */
+      axisLine: {
+        lineStyle: {
+          color: '#BDBDBD'
+        }
+      },
+      splitLine: {
+        show: true,
+        lineStyle: {
+          color: '#e6e6e6'
+        }
+      }
+    }
+  ],
+  xAxis: {
+    type: 'category',
+    name: '日期',
+    data: []
+  }
+}) as EChartsOption
+
+/** 获取统计数据 */
+const loadData = async () => {
+  // 1. 加载统计数据
+  loading.value = true
+  const performanceList = await StatisticsPerformanceApi.getContractCountPerformance(
+    props.queryParams
+  )
+
+  // 2.1 更新 Echarts 数据
+  if (echartsOption.xAxis && echartsOption.xAxis['data']) {
+    echartsOption.xAxis['data'] = performanceList.map((s: StatisticsPerformanceRespVO) => s.time)
+  }
+  if (echartsOption.series && echartsOption.series[0] && echartsOption.series[0]['data']) {
+    echartsOption.series[0]['data'] = performanceList.map(
+      (s: StatisticsPerformanceRespVO) => s.currentMonthCount
+    )
+  }
+  if (echartsOption.series && echartsOption.series[1] && echartsOption.series[1]['data']) {
+    echartsOption.series[1]['data'] = performanceList.map(
+      (s: StatisticsPerformanceRespVO) => s.lastMonthCount
+    )
+    echartsOption.series[3]['data'] = performanceList.map((s: StatisticsPerformanceRespVO) =>
+      s.lastMonthCount !== 0
+        ? (((s.currentMonthCount - s.lastMonthCount) / s.lastMonthCount) * 100).toFixed(2)
+        : 'NULL'
+    )
+  }
+  if (echartsOption.series && echartsOption.series[2] && echartsOption.series[2]['data']) {
+    echartsOption.series[2]['data'] = performanceList.map(
+      (s: StatisticsPerformanceRespVO) => s.lastYearCount
+    )
+    echartsOption.series[4]['data'] = performanceList.map((s: StatisticsPerformanceRespVO) =>
+      s.lastYearCount !== 0
+        ? (((s.currentMonthCount - s.lastYearCount) / s.lastYearCount) * 100).toFixed(2)
+        : 'NULL'
+    )
+  }
+
+  // 2.2 更新列表数据
+  list.value = performanceList
+  convertListData()
+  loading.value = false
+}
+
+// 初始化数据
+const columnsData = reactive([])
+const tableData = reactive([
+  { title: '当月合同数量统计(个)' },
+  { title: '上月合同数量统计(个)' },
+  { title: '去年当月合同数量统计(个)' },
+  { title: '环比增长率(%)' },
+  { title: '同比增长率(%)' }
+])
+
+// 定义 convertListData 方法,数据行列转置,展示每月数据
+const convertListData = () => {
+  const columnObj = { label: '日期', prop: 'title' }
+  columnsData.splice(0, columnsData.length) //清空数组
+  columnsData.push(columnObj)
+
+  list.value.forEach((item, index) => {
+    const columnObj = { label: item.time, prop: 'prop' + index }
+    columnsData.push(columnObj)
+    tableData[0]['prop' + index] = item.currentMonthCount
+    tableData[1]['prop' + index] = item.lastMonthCount
+    tableData[2]['prop' + index] = item.lastYearCount
+    tableData[3]['prop' + index] =
+      item.lastMonthCount !== 0
+        ? (((item.currentMonthCount - item.lastMonthCount) / item.lastMonthCount) * 100).toFixed(2)
+        : 'NULL'
+    tableData[4]['prop' + index] =
+      item.lastYearCount !== 0
+        ? (((item.currentMonthCount - item.lastYearCount) / item.lastYearCount) * 100).toFixed(2)
+        : 'NULL'
+  })
+}
+
+defineExpose({ loadData })
+
+/** 初始化 */
+onMounted(async () => {
+  await loadData()
+})
+</script>

+ 236 - 0
src/views/crm/statistics/performance/components/ContractPricePerformance.vue

@@ -0,0 +1,236 @@
+<!-- 员工业绩统计 -->
+<template>
+  <!-- Echarts图 -->
+  <el-card shadow="never">
+    <el-skeleton :loading="loading" animated>
+      <Echart :height="500" :options="echartsOption" />
+    </el-skeleton>
+  </el-card>
+
+  <!-- 统计列表 -->
+  <el-card shadow="never" class="mt-16px">
+    <el-table v-loading="loading" :data="tableData">
+      <el-table-column
+        v-for="item in columnsData"
+        :key="item.prop"
+        :label="item.label"
+        :prop="item.prop"
+        align="center"
+      >
+        <template #default="scope">
+          {{ scope.row[item.prop] }}
+        </template>
+      </el-table-column>
+    </el-table>
+  </el-card>
+</template>
+<script setup lang="ts">
+import { EChartsOption } from 'echarts'
+import {
+  StatisticsPerformanceApi,
+  StatisticsPerformanceRespVO
+} from '@/api/crm/statistics/performance'
+
+defineOptions({ name: 'ContractPricePerformance' })
+const props = defineProps<{ queryParams: any }>() // 搜索参数
+
+const loading = ref(false) // 加载中
+const list = ref<StatisticsPerformanceRespVO[]>([]) // 列表的数据
+
+/** 柱状图配置:纵向 */
+const echartsOption = reactive<EChartsOption>({
+  grid: {
+    left: 20,
+    right: 20,
+    bottom: 20,
+    containLabel: true
+  },
+  legend: {},
+  series: [
+    {
+      name: '当月合同金额(元)',
+      type: 'line',
+      data: []
+    },
+    {
+      name: '上月合同金额(元)',
+      type: 'line',
+      data: []
+    },
+    {
+      name: '去年同月合同金额(元)',
+      type: 'line',
+      data: []
+    },
+    {
+      name: '环比增长率(%)',
+      type: 'line',
+      yAxisIndex: 1,
+      data: []
+    },
+    {
+      name: '同比增长率(%)',
+      type: 'line',
+      yAxisIndex: 1,
+      data: []
+    }
+  ],
+  toolbox: {
+    feature: {
+      dataZoom: {
+        xAxisIndex: false // 数据区域缩放:Y 轴不缩放
+      },
+      brush: {
+        type: ['lineX', 'clear'] // 区域缩放按钮、还原按钮
+      },
+      saveAsImage: { show: true, name: '客户总量分析' } // 保存为图片
+    }
+  },
+  tooltip: {
+    trigger: 'axis',
+    axisPointer: {
+      type: 'shadow'
+    }
+  },
+  yAxis: [
+    {
+      type: 'value',
+      name: '金额(元)',
+      axisTick: {
+        show: false
+      },
+      axisLabel: {
+        color: '#BDBDBD',
+        formatter: '{value}'
+      },
+      /** 坐标轴轴线相关设置 */
+      axisLine: {
+        lineStyle: {
+          color: '#BDBDBD'
+        }
+      },
+      splitLine: {
+        show: true,
+        lineStyle: {
+          color: '#e6e6e6'
+        }
+      }
+    },
+    {
+      type: 'value',
+      name: '',
+      axisTick: {
+        alignWithLabel: true,
+        lineStyle: {
+          width: 0
+        }
+      },
+      axisLabel: {
+        color: '#BDBDBD',
+        formatter: '{value}%'
+      },
+      /** 坐标轴轴线相关设置 */
+      axisLine: {
+        lineStyle: {
+          color: '#BDBDBD'
+        }
+      },
+      splitLine: {
+        show: true,
+        lineStyle: {
+          color: '#e6e6e6'
+        }
+      }
+    }
+  ],
+  xAxis: {
+    type: 'category',
+    name: '日期',
+    data: []
+  }
+}) as EChartsOption
+
+/** 获取统计数据 */
+const loadData = async () => {
+  // 1. 加载统计数据
+  loading.value = true
+  const performanceList = await StatisticsPerformanceApi.getContractPricePerformance(
+    props.queryParams
+  )
+
+  // 2.1 更新 Echarts 数据
+  if (echartsOption.xAxis && echartsOption.xAxis['data']) {
+    echartsOption.xAxis['data'] = performanceList.map((s: StatisticsPerformanceRespVO) => s.time)
+  }
+  if (echartsOption.series && echartsOption.series[0] && echartsOption.series[0]['data']) {
+    echartsOption.series[0]['data'] = performanceList.map(
+      (s: StatisticsPerformanceRespVO) => s.currentMonthCount
+    )
+  }
+  if (echartsOption.series && echartsOption.series[1] && echartsOption.series[1]['data']) {
+    echartsOption.series[1]['data'] = performanceList.map(
+      (s: StatisticsPerformanceRespVO) => s.lastMonthCount
+    )
+    echartsOption.series[3]['data'] = performanceList.map((s: StatisticsPerformanceRespVO) =>
+      s.lastMonthCount !== 0
+        ? (((s.currentMonthCount - s.lastMonthCount) / s.lastMonthCount) * 100).toFixed(2)
+        : 'NULL'
+    )
+  }
+  if (echartsOption.series && echartsOption.series[2] && echartsOption.series[2]['data']) {
+    echartsOption.series[2]['data'] = performanceList.map(
+      (s: StatisticsPerformanceRespVO) => s.lastYearCount
+    )
+    echartsOption.series[4]['data'] = performanceList.map((s: StatisticsPerformanceRespVO) =>
+      s.lastYearCount !== 0
+        ? (((s.currentMonthCount - s.lastYearCount) / s.lastYearCount) * 100).toFixed(2)
+        : 'NULL'
+    )
+  }
+
+  // 2.2 更新列表数据
+  list.value = performanceList
+  convertListData()
+  loading.value = false
+}
+
+// 初始化数据
+const columnsData = reactive([])
+const tableData = reactive([
+  { title: '当月合同金额统计(元)' },
+  { title: '上月合同金额统计(元)' },
+  { title: '去年当月合同金额统计(元)' },
+  { title: '环比增长率(%)' },
+  { title: '同比增长率(%)' }
+])
+
+// 定义 init 方法
+const convertListData = () => {
+  const columnObj = { label: '日期', prop: 'title' }
+  columnsData.splice(0, columnsData.length) //清空数组
+  columnsData.push(columnObj)
+
+  list.value.forEach((item, index) => {
+    const columnObj = { label: item.time, prop: 'prop' + index }
+    columnsData.push(columnObj)
+    tableData[0]['prop' + index] = item.currentMonthCount
+    tableData[1]['prop' + index] = item.lastMonthCount
+    tableData[2]['prop' + index] = item.lastYearCount
+    tableData[3]['prop' + index] =
+      item.lastMonthCount !== 0
+        ? (((item.currentMonthCount - item.lastMonthCount) / item.lastMonthCount) * 100).toFixed(2)
+        : 'NULL'
+    tableData[4]['prop' + index] =
+      item.lastYearCount !== 0
+        ? (((item.currentMonthCount - item.lastYearCount) / item.lastYearCount) * 100).toFixed(2)
+        : 'NULL'
+  })
+}
+
+defineExpose({ loadData })
+
+/** 初始化 */
+onMounted(async () => {
+  await loadData()
+})
+</script>

+ 98 - 0
src/views/crm/statistics/rank/components/ContractCountRank.vue

@@ -0,0 +1,98 @@
+<!-- 签约合同排行 -->
+<template>
+  <!-- 柱状图 -->
+  <el-card shadow="never">
+    <el-skeleton :loading="loading" animated>
+      <Echart :height="500" :options="echartsOption" />
+    </el-skeleton>
+  </el-card>
+
+  <!-- 排行列表 -->
+  <el-card shadow="never" class="mt-16px">
+    <el-table v-loading="loading" :data="list">
+      <el-table-column label="公司排名" align="center" type="index" width="80" />
+      <el-table-column label="签订人" align="center" prop="nickname" min-width="200" />
+      <el-table-column label="部门" align="center" prop="deptName" min-width="200" />
+      <el-table-column label="签约合同数(个)" align="center" prop="count" min-width="200" />
+    </el-table>
+  </el-card>
+</template>
+<script setup lang="ts">
+import { StatisticsRankApi, StatisticsRankRespVO } from '@/api/crm/statistics/rank'
+import { EChartsOption } from 'echarts'
+import { clone } from 'lodash-es'
+
+defineOptions({ name: 'ContractCountRank' })
+const props = defineProps<{ queryParams: any }>() // 搜索参数
+
+const loading = ref(false) // 加载中
+const list = ref<StatisticsRankRespVO[]>([]) // 列表的数据
+
+/** 柱状图配置:横向 */
+const echartsOption = reactive<EChartsOption>({
+  dataset: {
+    dimensions: ['nickname', 'count'],
+    source: []
+  },
+  grid: {
+    left: 20,
+    right: 20,
+    bottom: 20,
+    containLabel: true
+  },
+  legend: {
+    top: 50
+  },
+  series: [
+    {
+      name: '签约合同排行',
+      type: 'bar'
+    }
+  ],
+  toolbox: {
+    feature: {
+      dataZoom: {
+        yAxisIndex: false // 数据区域缩放:Y 轴不缩放
+      },
+      brush: {
+        type: ['lineX', 'clear'] // 区域缩放按钮、还原按钮
+      },
+      saveAsImage: { show: true, name: '签约合同排行' } // 保存为图片
+    }
+  },
+  tooltip: {
+    trigger: 'axis',
+    axisPointer: {
+      type: 'shadow'
+    }
+  },
+  xAxis: {
+    type: 'value',
+    name: '签约合同数(个)'
+  },
+  yAxis: {
+    type: 'category',
+    name: '签订人'
+  }
+}) as EChartsOption
+
+/** 获取签约合同排行 */
+const loadData = async () => {
+  // 1. 加载排行数据
+  loading.value = true
+  const rankingList = await StatisticsRankApi.getContractCountRank(props.queryParams)
+  // 2.1 更新 Echarts 数据
+  if (echartsOption.dataset && echartsOption.dataset['source']) {
+    echartsOption.dataset['source'] = clone(rankingList).reverse()
+  }
+  // 2.2 更新列表数据
+  list.value = rankingList
+  loading.value = false
+}
+defineExpose({ loadData })
+
+/** 初始化 */
+onMounted(() => {
+  loadData()
+})
+</script>

+ 105 - 0
src/views/crm/statistics/rank/components/ContractPriceRank.vue

@@ -0,0 +1,105 @@
+<!-- 合同金额排行 -->
+<template>
+  <!-- 柱状图 -->
+  <el-card shadow="never">
+    <el-skeleton :loading="loading" animated>
+      <Echart :height="500" :options="echartsOption" />
+    </el-skeleton>
+  </el-card>
+
+  <!-- 排行列表 -->
+  <el-card shadow="never" class="mt-16px">
+    <el-table v-loading="loading" :data="list">
+      <el-table-column label="公司排名" align="center" type="index" width="80" />
+      <el-table-column label="签订人" align="center" prop="nickname" min-width="200" />
+      <el-table-column label="部门" align="center" prop="deptName" min-width="200" />
+      <el-table-column
+        label="合同金额(元)"
+        align="center"
+        prop="count"
+        min-width="200"
+        :formatter="erpPriceTableColumnFormatter"
+      />
+    </el-table>
+  </el-card>
+</template>
+<script setup lang="ts">
+import { StatisticsRankApi, StatisticsRankRespVO } from '@/api/crm/statistics/rank'
+import { EChartsOption } from 'echarts'
+import { clone } from 'lodash-es'
+import { erpPriceTableColumnFormatter } from '@/utils'
+
+defineOptions({ name: 'ContractPriceRank' })
+const props = defineProps<{ queryParams: any }>() // 搜索参数
+
+const loading = ref(false) // 加载中
+const list = ref<StatisticsRankRespVO[]>([]) // 列表的数据
+
+/** 柱状图配置:横向 */
+const echartsOption = reactive<EChartsOption>({
+  dataset: {
+    dimensions: ['nickname', 'count'],
+    source: []
+  },
+  grid: {
+    left: 20,
+    right: 20,
+    bottom: 20,
+    containLabel: true
+  },
+  legend: {
+    top: 50
+  },
+  series: [
+    {
+      name: '合同金额排行',
+      type: 'bar'
+    }
+  ],
+  toolbox: {
+    feature: {
+      dataZoom: {
+        yAxisIndex: false // 数据区域缩放:Y 轴不缩放
+      },
+      brush: {
+        type: ['lineX', 'clear'] // 区域缩放按钮、还原按钮
+      },
+      saveAsImage: { show: true, name: '合同金额排行' } // 保存为图片
+    }
+  },
+  tooltip: {
+    trigger: 'axis',
+    axisPointer: {
+      type: 'shadow'
+    }
+  },
+  xAxis: {
+    type: 'value',
+    name: '合同金额(元)'
+  },
+  yAxis: {
+    type: 'category',
+    name: '签订人'
+  }
+}) as EChartsOption
+
+/** 获取合同金额排行 */
+const loadData = async () => {
+  // 1. 加载排行数据
+  loading.value = true
+  const rankingList = await StatisticsRankApi.getContractPriceRank(props.queryParams)
+  // 2.1 更新 Echarts 数据
+  if (echartsOption.dataset && echartsOption.dataset['source']) {
+    echartsOption.dataset['source'] = clone(rankingList).reverse()
+  }
+  // 2.2 更新列表数据
+  list.value = rankingList
+  loading.value = false
+}
+defineExpose({ loadData })
+
+/** 初始化 */
+onMounted(() => {
+  loadData()
+})
+</script>