feat: 项目列表 card

This commit is contained in:
JiangSheng
2026-02-02 14:34:58 +08:00
parent 19a66f3c59
commit 34b5c52afb
4 changed files with 412 additions and 401 deletions

View File

@@ -0,0 +1,206 @@
<template>
<div class="lyric-project-card" @click="cardClickFun">
<img
class="gl-pointer-class cover-img"
:src="coverImgMap[project.detailImgUrl ? 'custom' : index % 2 !== 0 ? 'blue' : 'green']"
alt=""
/>
<div class="overlay-text">
<div class="project-code" :title="project.nodeCode">{{ project.nodeCode }}</div>
<div class="project-name" :title="project.nodeName">{{ project.nodeName }}</div>
</div>
<div class="bottom-box">
<span class="gl-text-ellipsis gl-pointer-class" :title="project.currentPhase">
{{ project.currentPhase }}
</span>
<span :class="statusClassMap[project.exeStatus] || 'status car-info-status'">
{{ PROJECT_EXE_STATUS.O[project.exeStatus] || '未开始' }}
</span>
</div>
<el-dropdown class="options-dropdown" :teleported="false">
<div class="options-btn" @click.stop>
<el-icon :size="18"><MoreFilled /></el-icon>
</div>
<template #dropdown>
<el-dropdown-menu @click.stop>
<el-dropdown-item v-for="(action, aIndex) in actionList" :key="aIndex">
<el-link :type="action.type" @click.stop="actionClickFun(action)">
{{ action.title }}
</el-link>
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { MoreFilled } from '@element-plus/icons-vue';
import { useDict } from '@/utils/useDict';
import { PROJECT_EXE_STATUS_CODE } from '@/utils/enum/project';
import projectBlue from '@/assets/imgs/projectList/project-blue.png';
import projectGreen from '@/assets/imgs/projectList/project-green.png';
interface Props {
project: any;
index: number;
actionList: any[];
}
const props = withDefaults(defineProps<Props>(), {
project: () => ({}),
index: 0,
actionList: () => [],
});
const emit = defineEmits(['cardClick', 'actionClick']);
const { PROJECT_EXE_STATUS } = useDict('PROJECT_EXE_STATUS');
const env = import.meta.env;
const coverImgMap = computed(() => ({
custom: `${env.VITE_API_IMAGE_PREVIEW_URL}/data/previewImage?fileId=${props.project.detailImgUrl}`,
blue: projectBlue,
green: projectGreen,
}));
const statusClassMap: Record<string, string> = {
[PROJECT_EXE_STATUS_CODE.COMPLETED]: 'status car-completed',
[PROJECT_EXE_STATUS_CODE.IN_PROGRESS]: 'status car-ing',
[PROJECT_EXE_STATUS_CODE.PAUSED]: 'status car-paused',
[PROJECT_EXE_STATUS_CODE.POSTPONED]: 'status car-postponed',
};
const cardClickFun = () => {
emit('cardClick', props.project);
};
const actionClickFun = (action: any) => {
emit('actionClick', props.project, action);
};
</script>
<style lang="scss" scoped>
@import '@/views/task/projectList/components/projectCard.scss';
.lyric-project-card {
@extend .project-card-base;
border-radius: 12px;
border: 1px solid #f0f0f0;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
&:hover {
transform: translateY(-5px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
.cover-img {
transform: scale(1.1);
}
}
.cover-img {
transition: transform 0.6s cubic-bezier(0.165, 0.84, 0.44, 1);
}
.overlay-text {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: calc(100% - 46px);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
z-index: 5;
// background: linear-gradient(180deg, rgba(0, 0, 0, 0.05) 0%, rgba(0, 0, 0, 0.4) 100%);
pointer-events: none;
.project-code {
font-size: 22px;
font-weight: 700;
color: #ffffff;
text-shadow: 0 2px 10px rgba(0, 0, 0, 0.5);
margin-bottom: 6px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 90%;
letter-spacing: 0.5px;
pointer-events: auto;
}
.project-name {
font-size: 13px;
color: rgba(255, 255, 255, 0.95);
text-shadow: 0 1px 6px rgba(0, 0, 0, 0.5);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 90%;
font-weight: 500;
pointer-events: auto;
}
}
.bottom-box {
height: 46px;
padding: 0 16px;
background-color: #ffffff;
border-top: 1px solid #f5f5f5;
.gl-text-ellipsis {
font-size: 14px;
color: #333;
font-weight: 500;
transition: color 0.3s;
&:hover {
color: var(--el-color-primary);
}
}
.status {
border: none;
font-weight: 600;
border-radius: 6px;
width: auto;
min-width: 64px;
padding: 0 8px;
height: 24px;
font-size: 12px;
display: inline-flex;
align-items: center;
justify-content: center;
&.car-ing {
background-color: #e6f7ff;
color: #1890ff;
}
&.car-completed {
background-color: #f6ffed;
color: #52c41a;
}
&.car-paused {
background-color: #fffbe6;
color: #faad14;
}
&.car-postponed {
background-color: #fff1f0;
color: #f5222d;
}
&.car-info-status {
background-color: #f5f5f5;
color: #8c8c8c;
}
}
}
}
</style>

View File

@@ -0,0 +1,95 @@
.project-card-base {
height: 160px;
background: white;
border-radius: 8px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.05);
transition: all 0.3s ease;
border: 1px solid var(--el-border-color);
overflow: hidden;
position: relative;
cursor: pointer;
&:hover {
transform: translateY(-3px);
box-shadow: 0 6px 12px rgba(0, 0, 0, 0.1);
.options-dropdown {
opacity: 1;
}
}
.cover-img {
width: 100%;
height: 100%;
object-fit: cover;
}
.options-dropdown {
opacity: 0;
position: absolute;
top: var(--margin-tiny);
right: var(--margin-tiny);
transition: all 0.3s ease-in-out;
z-index: 10;
.options-btn {
width: 32px;
height: 32px;
background-color: rgba(255, 255, 255, 0.9);
font-weight: bold;
font-size: 18px;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--border-radius-normal);
cursor: pointer;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
}
.bottom-box {
position: absolute;
left: 0;
bottom: 0;
height: 40px;
width: 100%;
background-color: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(5px);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 20px;
z-index: 5;
.status {
display: flex;
align-items: center;
justify-content: center;
border: 1px solid var(--el-border-color);
font-size: 12px;
border-radius: 4px;
width: 75px;
height: 25px;
}
}
}
.car-ing {
background-color: #e7f1ff;
color: #2580fe;
}
.car-paused {
background-color: #f7ffc8;
color: #b7c03e;
}
.car-completed {
background-color: #e0f6eb;
color: #1eaa39;
}
.car-postponed {
background-color: #fae4e5;
color: #d8232a;
}

View File

@@ -0,0 +1,84 @@
<template>
<div class="project-card" @click="cardClickFun">
<el-dropdown class="options-dropdown" :teleported="false">
<div class="options-btn" @click.stop>
<el-icon :size="18"><MoreFilled /></el-icon>
</div>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item v-for="(action, aIndex) in actionList" :key="aIndex">
<el-link :type="action.type" @click.stop="actionClickFun(action)">
{{ action.title }}
</el-link>
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<img class="gl-pointer-class cover-img" :src="coverImgMap[project.detailImgUrl ? 'custom' : (index % 2 !== 0 ? 'blue' : 'green')]" alt="" />
<div class="bottom-box">
<span class="gl-text-ellipsis gl-pointer-class" :title="project.nodeName">
{{ project.nodeName }}
</span>
<span :class="statusClassMap[project.exeStatus] || 'status car-info-status'">
{{ PROJECT_EXE_STATUS.O[project.exeStatus] || '未开始' }}
</span>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { MoreFilled } from '@element-plus/icons-vue';
import { useDict } from '@/utils/useDict';
import { PROJECT_EXE_STATUS_CODE } from '@/utils/enum/project';
import projectBlue from '@/assets/imgs/projectList/project-blue.png';
import projectGreen from '@/assets/imgs/projectList/project-green.png';
interface Props {
project: any;
index: number;
actionList: any[];
}
const props = withDefaults(defineProps<Props>(), {
project: () => ({}),
index: 0,
actionList: () => [],
});
const emit = defineEmits(['cardClick', 'actionClick']);
const { PROJECT_EXE_STATUS } = useDict('PROJECT_EXE_STATUS');
const env = import.meta.env;
const coverImgMap = computed(() => ({
custom: `${env.VITE_API_IMAGE_PREVIEW_URL}/data/previewImage?fileId=${props.project.detailImgUrl}`,
blue: projectBlue,
green: projectGreen,
}));
const statusClassMap: Record<string, string> = {
[PROJECT_EXE_STATUS_CODE.COMPLETED]: 'status car-completed',
[PROJECT_EXE_STATUS_CODE.IN_PROGRESS]: 'status car-ing',
[PROJECT_EXE_STATUS_CODE.PAUSED]: 'status car-paused',
[PROJECT_EXE_STATUS_CODE.POSTPONED]: 'status car-postponed',
};
const cardClickFun = () => {
emit('cardClick', props.project);
};
const actionClickFun = (action: any) => {
emit('actionClick', props.project, action);
};
</script>
<style lang="scss" scoped>
@import './projectCard.scss';
.project-card {
@extend .project-card-base;
}
</style>

View File

@@ -18,76 +18,28 @@
<template #cardTemplate="{ tableData }"> <template #cardTemplate="{ tableData }">
<div class="project-card-box"> <div class="project-card-box">
<div class="projects-grid"> <div class="projects-grid">
<div v-for="(project, index) in tableData" :key="project.id" class="project-card"> <template v-if="enableConfigByTenant([TENANT_ENUM.LYRIC])">
<el-dropdown class="options-dropdown" :teleported="false"> <LyricProjectCard
<div class="options-btn"> v-for="(project, index) in tableData"
<el-icon :size="18"><MoreFilled /></el-icon> :key="project.id"
</div> :project="project"
<template #dropdown> :index="Number(index)"
<el-dropdown-menu> :actionList="cardActionList"
<el-dropdown-item v-for="(action, aIndex) in cardActionList" :key="aIndex"> @cardClick="cardClickFun"
<el-link :type="action.type" @click="actionClickFun(project, action)"> @actionClick="actionClickFun"
{{ action.title }}
</el-link>
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<img
class="gl-pointer-class cover-img"
v-if="project.detailImgUrl"
@click="goProjectDetailFun(project.uuid, project.nodeName)"
:src="getImgPathFun(project.detailImgUrl)"
alt=""
/> />
<img </template>
class="gl-pointer-class" <template v-else>
v-else-if="(index as number) % 2 !== 0" <DefaultProjectCard
@click="goProjectDetailFun(project.uuid, project.nodeName)" v-for="(project, index) in tableData"
src="@/assets/imgs/projectList/project-blue.png" :key="project.id"
alt="" :project="project"
:index="Number(index)"
:actionList="cardActionList"
@cardClick="cardClickFun"
@actionClick="actionClickFun"
/> />
<img </template>
class="gl-pointer-class"
v-else
@click="goProjectDetailFun(project.uuid, project.nodeName)"
src="@/assets/imgs/projectList/project-green.png"
alt=""
/>
<div class="bottom-box">
<span
:title="project.nodeName"
@click="goProjectDetailFun(project.uuid, project.nodeName)"
class="gl-text-ellipsis gl-pointer-class"
>{{
enableConfigByTenant([TENANT_ENUM.LYRIC])
? project.nodeCode
: project.nodeName
}}</span
>
<!-- <OverflowTooltip
class="gl-pointer-class"
@click="goProjectDetailFun(project.uuid, project.nodeName)"
:content="project.nodeName"
/> -->
<span
:class="
project.exeStatus === PROJECT_EXE_STATUS_CODE.COMPLETED
? 'status car-completed'
: project.exeStatus === PROJECT_EXE_STATUS_CODE.IN_PROGRESS
? 'status car-ing'
: project.exeStatus === PROJECT_EXE_STATUS_CODE.PAUSED
? 'status car-paused'
: project.exeStatus === PROJECT_EXE_STATUS_CODE.POSTPONED
? 'status car-postponed'
: 'status car-info-status'
"
>{{ PROJECT_EXE_STATUS.O[project.exeStatus] || '未开始' }}</span
>
</div>
</div>
</div> </div>
</div> </div>
</template> </template>
@@ -168,29 +120,21 @@
import { computed, reactive, ref } from 'vue'; import { computed, reactive, ref } from 'vue';
import BaseTable from '@/components/common/table/baseTable.vue'; import BaseTable from '@/components/common/table/baseTable.vue';
import projectInfoDialog from '@/components/project/projectInfoDialog.vue'; import projectInfoDialog from '@/components/project/projectInfoDialog.vue';
// import { useRouter } from 'vue-router'; import { deleteNodeApi, exportProjectApi, queryNodeListApi } from '@/api/project/node';
import {
deleteNodeApi,
exportProjectApi,
queryNodeListApi,
// syncProjectListApi,
} from '@/api/project/node';
import { ElMessage } from 'element-plus'; import { ElMessage } from 'element-plus';
import { NODE_TYPE } from '@/utils/enum/node'; import { NODE_TYPE } from '@/utils/enum/node';
import { disposeMemberList } from '../projectDetail/components/project'; import { disposeMemberList } from '../projectDetail/components/project';
import { useDict } from '@/utils/useDict'; import { useDict } from '@/utils/useDict';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import ProjectDetail from '../projectDetail/index.vue'; import ProjectDetail from '../projectDetail/index.vue';
// import OverflowTooltip from '@/components/common/text/overflowTooltip.vue';
import StatusDot from '@/components/common/statusDot/index.vue'; import StatusDot from '@/components/common/statusDot/index.vue';
import { projectStatus } from '@/components/common/statusDot/statusMap'; import { projectStatus } from '@/components/common/statusDot/statusMap';
import { MoreFilled } from '@element-plus/icons-vue';
import { ElMessageBox } from 'element-plus'; import { ElMessageBox } from 'element-plus';
import { PROJECT_EXE_STATUS_CODE } from '@/utils/enum/project';
import { enableConfigByTenant, TENANT_ENUM } from '@/tenants/tenant'; import { enableConfigByTenant, TENANT_ENUM } from '@/tenants/tenant';
// import { getUserId, getUserTenantId } from '@/utils/user';
import projectOverview from '@/views/task/projectOverview/index.vue'; import projectOverview from '@/views/task/projectOverview/index.vue';
import { hasPermission } from '@/utils/permission'; import { hasPermission } from '@/utils/permission';
import DefaultProjectCard from './components/projectCard.vue';
import LyricProjectCard from '@/tenants/lyric/views/project/projectCard.vue';
export interface IUserInfo { export interface IUserInfo {
id: number; id: number;
@@ -222,7 +166,6 @@ interface IProjectInfo {
actualEndTime: string; actualEndTime: string;
description: string; description: string;
} }
const env = import.meta.env;
const actionList = ref<any>([ const actionList = ref<any>([
{ {
@@ -418,6 +361,10 @@ const actionClickFun = (row: any, action: any) => {
} }
} }
}; };
const cardClickFun = (project: any) => {
goProjectDetailFun(project.uuid, project.nodeName);
};
// const syncProjectList = async () => { // const syncProjectList = async () => {
// const res: any = await syncProjectListApi({ // const res: any = await syncProjectListApi({
// jobnumber: getUserId(), // jobnumber: getUserId(),
@@ -431,14 +378,6 @@ const actionClickFun = (row: any, action: any) => {
// } // }
// }; // };
const getImgPathFun = (fileId: any) => {
let url: any = '';
if (fileId) {
url = `${env.VITE_API_IMAGE_PREVIEW_URL}/data/previewImage?fileId=${fileId}`;
}
return url;
};
const projectUuids = ref<any>([]); const projectUuids = ref<any>([]);
const projectsDetailWindow = ref(false); const projectsDetailWindow = ref(false);
const shwoProjectsFun = () => { const shwoProjectsFun = () => {
@@ -460,69 +399,6 @@ const shwoProjectsFun = () => {
.project-table-list { .project-table-list {
height: 100%; height: 100%;
} }
.project-car-list {
width: 100%;
// // overflow-y: auto;
// // height: calc(100% - 16px);
// // margin-top: 16px;
// display: flex;
// flex-wrap: wrap;
// gap: @DEFAULT_PADDING;
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
// padding-right: 5px;
.filter-btn {
text-align: right;
margin: 10px 0;
}
.project {
// width: calc(25% - 9px);
height: 156px;
display: flex;
justify-content: space-between;
padding: 12px;
border-radius: 6px;
position: relative;
overflow: hidden;
border: 1px solid red;
.progress {
width: 40px;
display: flex;
flex-direction: column;
align-items: center;
}
}
.activeProject {
// background-color: @DEFAULT_THEME_BACKGROUND_COLOR;
border: 2px solid red;
// color: #fff;
box-shadow: 0 0 5px 2px #d3d0f0;
.projectCenter {
.projectName {
font-size: 14px;
// color: #fff;
}
}
.name {
font-weight: 700;
}
}
.circle {
display: inline-block;
width: 10px;
height: 10px;
position: absolute;
top: 6px;
right: 6px;
margin-right: 0;
}
}
} }
.projects-grid { .projects-grid {
@@ -530,254 +406,4 @@ const shwoProjectsFun = () => {
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 20px; gap: 20px;
} }
.project-card {
height: 160px;
background: white;
border-radius: 8px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.05);
// padding: 18px;
transition: all 0.3s ease;
border: 1px solid var(--el-border-color);
overflow: hidden;
position: relative;
&:hover {
.options-dropdown {
opacity: 1;
}
}
.cover-img {
width: 100%;
height: 100%;
object-fit: cover;
}
.options-dropdown {
opacity: 0;
position: absolute;
top: var(--margin-tiny);
right: var(--margin-tiny);
transition: all 0.3s ease-in-out;
.options-btn {
position: absolute;
top: var(--margin-tiny);
right: var(--margin-tiny);
width: 32px;
height: 32px;
background-color: var(--el-bg-color);
font-weight: bold;
font-size: 18px;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--border-radius-normal);
cursor: pointer;
}
}
.img-box {
width: 100%;
height: 100%;
position: relative;
img {
width: 100%;
height: 100%;
}
.pure-bg {
width: 100%;
height: 100%;
// background: rgba(216, 210, 210, 0.2);
display: flex;
align-items: center;
justify-content: center;
}
.node-code {
position: absolute;
top: 40%;
left: 50%;
transform: translate(-50%, -50%);
color: var(--el-color-primary);
font-size: 20px;
cursor: pointer;
width: 100%;
text-align: center;
padding: 0 15px;
}
}
.bottom-box {
position: absolute;
left: 0;
bottom: 0;
height: 40px;
width: 100%;
border-radius: 8px;
background-color: var(--el-bg-color);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 20px;
.status {
display: flex;
align-items: center;
justify-content: center;
// background-color: var(--el-bg-color-page);
border: 1px solid var(--el-border-color);
font-size: 12px;
border-radius: 4px;
width: 75px;
height: 25px;
}
}
}
.project-card:hover {
transform: translateY(-3px);
box-shadow: 0 6px 12px rgba(0, 0, 0, 0.1);
border-color: var(--el-border-color);
}
.card-header {
display: flex;
align-items: center;
margin-bottom: 16px;
}
.project-icon {
width: 36px;
height: 36px;
background-color: #ef4444;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 14px;
font-weight: bold;
margin-right: 12px;
flex-shrink: 0;
}
.title-section {
flex-grow: 1;
}
.project-title {
font-size: 16px;
font-weight: 600;
color: #1e293b;
margin-bottom: 2px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.project-manager {
font-size: 12px;
color: #64748b;
}
.project-info {
margin-bottom: 16px;
}
.info-row {
display: flex;
justify-content: space-between;
margin-bottom: 10px;
font-size: 13px;
}
.info-label {
color: #64748b;
font-weight: 500;
}
.info-value {
color: #1e293b;
font-weight: 500;
}
.status-badge {
padding: 4px 10px;
border-radius: 12px;
font-size: 12px;
font-weight: 500;
display: inline-block;
}
.status-planning {
background-color: #eff6ff;
color: #3b82f6;
}
.status-progress {
background-color: #ecfdf5;
color: #10b981;
}
.status-delayed {
background-color: #fef2f2;
color: #ef4444;
}
.status-no-start {
background-color: #f8fafc;
color: #64748b;
}
.progress-section {
margin-top: 18px;
}
.progress-header {
display: flex;
justify-content: space-between;
margin-bottom: 6px;
font-size: 13px;
}
.progress-label {
color: #64748b;
}
.progress-value {
color: #3b82f6;
font-weight: 600;
}
.progress-bar {
height: 6px;
background-color: #e2e8f0;
border-radius: 3px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, #3b82f6, #60a5fa);
border-radius: 3px;
transition: width 0.5s ease;
}
.date-row {
display: flex;
justify-content: space-between;
margin-top: 4px;
font-size: 12px;
color: #64748b;
}
.car-ing {
background-color: #e7f1ff;
color: #2580fe;
}
.car-paused {
background-color: #f7ffc8;
color: #b7c03e;
}
.car-completed {
background-color: #e0f6eb;
color: #1eaa39;
}
.car-postponed {
background-color: #fae4e5;
color: #d8232a;
}
</style> </style>