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