You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

422 lines
10 KiB

<script lang="ts" setup>
import { Card, Dropdown, Menu, Popconfirm } from 'ant-design-vue'
import type { PropType } from 'vue'
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
import type { ModelItem } from './data'
import Icon from '@/components/Icon/index'
import { modelCardListApi, modelDeleteApi } from '@/api/alert/model/models'
import type { ModelCardQueryParams } from '@/api/alert/model/model/models'
import { useGo } from '@/hooks/web/usePage'
import { useMessage } from '@/hooks/web/useMessage'
const props = defineProps({
loading: {
type: Boolean,
},
selectData: {
type: Object as PropType<Record<string, any> | null | undefined>,
},
systemId: {
type: Number,
},
unitId: {
type: Number,
},
})
const { createMessage } = useMessage()
// 点击模型卡片跳转训练页面
const go = useGo()
function changeModel(id, version?) {
const versionParam = version ? `?version=${encodeURIComponent(version)}` : '?version=v-test'
go(`/model/train/${id}${versionParam}`)
}
const modelCardList = ref<Array<ModelItem>>([])
const rootRef = ref<HTMLElement | null>(null)
const loadMoreTriggerRef = ref<HTMLElement | null>(null)
const lastQuery = ref<ModelCardQueryParams | null>(null)
const pageNo = ref(1)
const pageSize = 25
const listLoading = ref(false)
const noMore = ref(false)
let loadMoreObserver: IntersectionObserver | null = null
const colors = ['#8dc63f', '#dbb09e']
const statusStr = ['未下装', '已下装']
const statusIcons = ['ant-design:unlock-outlined', 'ant-design:lock-outlined']
function buildQuery(value: any): ModelCardQueryParams {
return {
unitId: value?.unit,
typeId: value?.type,
systemId: value?.system,
name: value?.name,
}
}
function normalizeCards(modelList: any[] = []): ModelItem[] {
const cardList: ModelItem[] = []
for (const modelCard of modelList) {
const statusIndex = Number(modelCard.status) || 0
const card: ModelItem = {
id: modelCard.id,
title: modelCard.name,
version: modelCard.version,
icon: statusIcons[statusIndex],
value: 1,
total: 1,
color: colors[statusIndex],
status: statusStr[statusIndex],
creator: modelCard.creator,
createTime: modelCard.createTime,
description: modelCard.name,
headStyle: {},
bodyStyle: {},
statusColor: colors[statusIndex],
algorithm: modelCard.algorithm,
}
cardList.push(card)
}
return cardList
}
async function fetchModelList(queryParams: ModelCardQueryParams, append: boolean) {
if (listLoading.value)
return
listLoading.value = true
try {
const pageResult = await modelCardListApi({
...queryParams,
pageNo: pageNo.value,
pageSize,
})
if (!pageResult) {
modelCardList.value = []
noMore.value = true
return
}
const records = pageResult.records || []
const cardList = normalizeCards(records)
modelCardList.value = append ? modelCardList.value.concat(cardList) : cardList
noMore.value = pageResult.pages ? pageResult.current >= pageResult.pages : records.length < pageSize
}
finally {
listLoading.value = false
}
}
async function loadModelList(value: any) {
pageNo.value = 1
noMore.value = false
const queryParams = buildQuery(value)
lastQuery.value = queryParams
await fetchModelList(queryParams, false)
}
watch(
() => props.selectData,
async (value) => {
await loadModelList(value)
},
{ immediate: true },
)
async function confirmDelete(id: number | string) {
await modelDeleteApi(id)
createMessage.success('删除成功')
await loadModelList(props.selectData)
}
function goCreateModel() {
const query: string[] = []
if (props.systemId !== undefined && props.systemId !== null)
query.push(`systemId=${encodeURIComponent(String(props.systemId))}`)
if (props.unitId !== undefined && props.unitId !== null)
query.push(`unitId=${encodeURIComponent(String(props.unitId))}`)
go(`/model/create${query.length ? `?${query.join('&')}` : ''}`)
}
async function loadMore() {
if (listLoading.value || noMore.value || !lastQuery.value)
return
pageNo.value += 1
await fetchModelList(lastQuery.value, true)
}
function getScrollParent(el: HTMLElement | null) {
let current = el?.parentElement || null
while (current) {
const style = window.getComputedStyle(current)
if (/(auto|scroll)/.test(style.overflowY))
return current
current = current.parentElement
}
return null
}
function initLoadMoreObserver() {
if (loadMoreObserver) {
loadMoreObserver.disconnect()
loadMoreObserver = null
}
const target = loadMoreTriggerRef.value
if (!target)
return
const root = getScrollParent(rootRef.value)
loadMoreObserver = new IntersectionObserver(
(entries) => {
if (entries.some(entry => entry.isIntersecting))
loadMore()
},
{ root, rootMargin: '160px 0px' },
)
loadMoreObserver.observe(target)
}
onMounted(() => {
initLoadMoreObserver()
})
onBeforeUnmount(() => {
if (loadMoreObserver) {
loadMoreObserver.disconnect()
loadMoreObserver = null
}
})
</script>
<template>
<div ref="rootRef" class="enter-y">
<div class="card-grid">
<template v-for="item in modelCardList" :key="item.title">
<Dropdown :trigger="['contextmenu']">
<Card
size="small"
:loading="loading || (listLoading && !modelCardList.length)"
:hoverable="true"
class="model-card"
:style="{ borderLeft: `6px solid ${item.statusColor}` }"
@click="changeModel(item.id, item.version)"
>
<div class="card-top">
<div class="card-title" :title="item.title">
{{ item.title }}
</div>
<div class="card-tags">
<span class="status-icon" :style="{ color: item.statusColor }">
<Icon :icon="item.icon" :size="20" />
</span>
</div>
</div>
<div class="card-meta">
<span class="meta-item">
<Icon icon="ic:baseline-person" :size="16" class="meta-icon" />
{{ item.creator || '未知' }}
</span>
<span class="meta-item">
<Icon icon="ant-design:calendar-outlined" :size="16" class="meta-icon" />
{{ item.createTime || '--' }}
</span>
</div>
<div class="card-divider" />
<div class="card-footer">
<span class="algo-pill">
{{ item.algorithm || '未知' }}
</span>
<span class="tag version" :style="{ backgroundColor: item.statusColor, color: '#fff' }">
{{ item.version || 'v-test' }}
</span>
</div>
</Card>
<template #overlay>
<Menu>
<Menu.Item key="delete">
<Popconfirm
title="确认删除该模型?"
ok-text="删除"
ok-type="danger"
@confirm="() => confirmDelete(item.id)"
>
<span>删除</span>
</Popconfirm>
</Menu.Item>
</Menu>
</template>
</Dropdown>
</template>
<Card v-show="systemId != null" size="small" class="icon-card" :hoverable="true" @click="goCreateModel">
<Icon icon="ic:sharp-add" :size="80" color="#a7a9a7" />
</Card>
</div>
<div ref="loadMoreTriggerRef" class="load-more-trigger" />
<div v-if="modelCardList.length" class="load-status">
<span v-if="listLoading">加载中...</span>
<span v-else-if="noMore">没有更多了</span>
</div>
<div v-else-if="!listLoading" class="load-status empty">
暂无数据
</div>
</div>
</template>
<style scoped>
.card-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 20px;
padding: 8px 0;
}
.model-card {
padding: 14px 14px 12px;
background: linear-gradient(180deg, #fdfdfd 0%, #f7f8fa 100%);
border: 1px solid #d0d7de;
border-radius: 8px;
box-shadow: 0 6px 16px rgb(15 23 42 / 8%);
transition: transform 0.18s ease, box-shadow 0.18s ease;
}
.model-card:hover {
box-shadow: 0 12px 32px rgb(15 23 42 / 12%);
transform: translateY(-2px);
}
.card-top {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.card-title {
padding-right: 8px;
overflow: hidden;
font-size: 18px;
font-weight: 600;
line-height: 1.4;
color: #1f2937;
text-overflow: ellipsis;
word-break: keep-all;
white-space: nowrap;
}
.card-tags {
display: inline-flex;
gap: 8px;
align-items: center;
}
.tag.version {
display: inline-flex;
align-items: center;
padding: 4px 10px;
font-size: 12px;
font-weight: 600;
line-height: 1;
text-transform: uppercase;
letter-spacing: 0.3px;
border-radius: 999px;
}
.status-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
background-color: rgb(0 0 0 / 6%);
border: 1px solid rgb(0 0 0 / 8%);
border-radius: 8px;
}
.card-meta {
display: flex;
gap: 8px;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
font-size: 12px;
color: #6b7280;
}
.meta-item {
display: inline-flex;
gap: 6px;
align-items: center;
min-width: 0;
}
.meta-icon {
color: #9ca3af;
}
.card-footer {
display: flex;
gap: 12px;
align-items: center;
justify-content: space-between;
padding-top: 6px;
font-size: 13px;
color: #4b5563;
}
.card-divider {
height: 1px;
margin: 4px 0 8px;
border-top: 1px dashed #e5e7eb;
}
.footer-text.strong {
font-weight: 600;
}
.footer-divider {
flex: 1;
height: 1px;
border-top: 1px dashed #e5e7eb;
}
.algo-pill {
display: inline-flex;
align-items: center;
padding: 4px 10px;
font-weight: 700;
color: #1d4ed8;
letter-spacing: 0.3px;
background: #e0e7ff;
border: 1px solid #c7d2fe;
border-radius: 10px;
}
.icon-card {
display: flex;
align-items: center;
justify-content: center;
min-height: 180px;
background-color: #f3f4f6;
border: 1px dashed #d1d5db;
border-radius: 12px;
transition: border-color 0.2s ease, background 0.2s ease;
}
.icon-card:hover {
background-color: #eef2ff;
border-color: #4c7af0;
}
.load-status {
padding: 12px 0 4px;
font-size: 12px;
color: #9ca3af;
text-align: center;
}
.load-status.empty {
padding-top: 24px;
}
.load-more-trigger {
height: 1px;
}
</style>