OfficeSystem/pages/project/list/index.vue
2025-11-18 16:02:59 +08:00

994 lines
24 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<view class="project-list-page">
<!-- 筛选区域 -->
<view class="filter-section">
<view class="filter-row">
<view class="filter-item">
<text class="filter-label">客户</text>
<input
class="filter-input"
v-model="filterParams.customerName"
placeholder="请输入客户名称"
@confirm="handleSearch"
/>
</view>
<view class="filter-item">
<text class="filter-label">项目编号</text>
<input
class="filter-input"
v-model="filterParams.projectId"
placeholder="请输入项目编号"
@confirm="handleSearch"
/>
</view>
</view>
<view class="filter-row">
<view class="filter-item">
<text class="filter-label">项目名称</text>
<input
class="filter-input"
v-model="filterParams.projectName"
placeholder="请输入项目名称"
@confirm="handleSearch"
/>
</view>
<view class="filter-item">
<text class="filter-label">成员</text>
<picker
mode="selector"
:range="memberOptions"
range-key="nickName"
@change="handleMemberChange"
>
<view class="filter-picker">
<text class="picker-text">{{ selectedMemberName || '请选择用户' }}</text>
<text class="picker-arrow">▼</text>
</view>
</picker>
</view>
</view>
<!-- 开发超期筛选 -->
<view class="filter-row">
<text class="filter-label">开发超期</text>
<view class="radio-group">
<view
class="radio-item"
:class="{ active: filterParams.overdue === '' }"
@click="filterParams.overdue = ''; handleSearch()"
>
<text>全部</text>
</view>
<view
class="radio-item"
:class="{ active: filterParams.overdue === true }"
@click="filterParams.overdue = true; handleSearch()"
>
<text>是</text>
</view>
<view
class="radio-item"
:class="{ active: filterParams.overdue === false }"
@click="filterParams.overdue = false; handleSearch()"
>
<text>否</text>
</view>
</view>
</view>
<!-- 状态标签 -->
<view class="status-tabs">
<view
class="status-tab"
v-for="tab in statusTabs"
:key="tab.value"
:class="{ active: activeStatusTab === tab.value }"
@click="handleStatusTabClick(tab.value)"
>
<text>{{ tab.label }}</text>
</view>
</view>
<!-- 操作按钮 -->
<view class="action-buttons">
<uv-button type="primary" size="small" @click="handleSearch">
<text class="btn-icon">🔍</text>
<text>搜索</text>
</uv-button>
<uv-button size="small" @click="handleReset">
<text>重置</text>
</uv-button>
<uv-button type="success" size="small" @click="goToCreateProject">
<text>新增项目</text>
</uv-button>
</view>
</view>
<!-- 项目列表 -->
<view class="project-scroll">
<view class="project-container">
<view
class="project-card"
v-for="project in projects"
:key="project.id"
@click="goToProjectDetail(project)"
>
<!-- 卡片头部:状态标签、发布日期、操作菜单 -->
<view class="card-header">
<view class="header-left">
<view class="status-tag">{{ getStatusText(project.status) }}</view>
<view class="urgent-tag" v-if="getUrgentStatus(project)">
{{ getUrgentStatus(project) }}
</view>
</view>
<view class="header-right">
<text class="release-date">发布: {{ formatDate(project.createTime || project.releaseTime) }}</text>
<view class="action-menu" @click.stop="handleCardMenu(project)">
<text class="menu-icon">⋯</text>
</view>
</view>
</view>
<!-- 项目标题 -->
<view class="card-title">{{ project.name }}</view>
<!-- 项目标签和描述 -->
<!-- <view class="card-tags-row">-->
<!-- <view class="tag-circle" :style="{ backgroundColor: getTagColor(project) }">-->
<!-- <text class="tag-text">{{ getTagText(project) }}</text>-->
<!-- </view>-->
<!-- <text class="member-text">{{ project.createName }}</text>-->
<!-- </view>-->
<view class="card-tags-row" v-if="project.remark">
<text class="card-description" v-if="project.remark">{{ project.remark }}</text>
</view>
<!-- 操作标签 -->
<view class="action-tags" v-if="project.tags && project.tags.length > 0">
<view
class="action-tag"
v-for="(tag, index) in project.tags"
:key="index"
>
{{ tag }}
</view>
</view>
<!-- 日期和剩余时间 -->
<view class="card-footer">
<view class="date-info">
<text class="date-text">{{ formatDate(project.expireTime) }}</text>
<text class="time-icon">🕐</text>
<text class="time-text" :class="{ overdue: isOverdue(project) }">
{{ getTimeStatus(project) }}
</text>
</view>
<!-- 团队成员 -->
<view class="member-info" v-if="project.memberList && project.memberList.length > 0">
<view class="member-avatars">
<view
class="member-avatar"
v-for="(member, index) in getDisplayMembers(project.memberList)"
:key="index"
:style="{ backgroundColor: getAvatarColor(member, index) }"
>
<text class="avatar-text">{{ getAvatarText(member) }}</text>
</view>
</view>
<text class="member-text">{{ formatMemberNames(project.memberList) }}</text>
</view>
</view>
</view>
<!-- 加载状态 -->
<view class="empty-state" v-if="loading" style="grid-column: 1 / -1;">
<text class="empty-text">加载中...</text>
</view>
<!-- 空状态 -->
<view class="empty-state" v-else-if="isEmpty" style="grid-column: 1 / -1;">
<text class="empty-text">暂无项目数据</text>
</view>
<!-- 加载更多提示 -->
<view class="load-more-tip" v-if="!isEmpty && !loading && !noMore" style="grid-column: 1 / -1;">
<text class="load-more-text">上拉加载更多</text>
</view>
<view class="load-more-tip" v-if="!isEmpty && noMore" style="grid-column: 1 / -1;">
<text class="load-more-text">没有更多数据了</text>
</view>
</view>
</view>
</view>
</template>
<script setup>
import { ref, computed, watch, onMounted, nextTick, onUnmounted } from 'vue';
import { onLoad, onReachBottom, onPullDownRefresh } from '@dcloudio/uni-app';
import { getProjectList, getUserList } from '@/api';
import { usePagination } from '@/composables';
import { useDictStore } from '@/store/dict';
import { useUserStore } from '@/store/user';
import { getDictLabel } from '@/utils/dict';
const dictStore = useDictStore();
const userStore = useUserStore();
// 筛选参数
const filterParams = ref({
customerName: '',
projectId: '',
projectName: '',
joinUserId: '',
overdue: '', // ''表示全部, true表示是, false表示否
});
// 状态标签(使用字典键值)
const statusTabs = ref([
{ label: '待开始', value: 'WAIT_START' },
{ label: '开发中', value: 'IN_PROGRESS' },
{ label: '开发完成', value: 'COMPLETED' },
{ label: '已验收', value: 'ACCEPTED' },
{ label: '维护中', value: 'MAINTENANCE' },
{ label: '维护到期', value: 'MAINTENANCE_OVERDUE' },
{ label: '开发超期', value: 'DEVELOPMENT_OVERDUE' }
]);
const activeStatusTab = ref('IN_PROGRESS'); // 默认选中"开发中"
// 成员选项
const memberOptions = ref([]);
const selectedMemberName = ref('');
// 使用分页组合式函数
const {
list,
noMore,
isEmpty,
loading,
getList,
loadMore,
updateParams,
refresh,
queryParams,
reset
} = usePagination({
fetchData: async (params) => {
// 构建请求参数
const requestParams = {
...params,
orderByColumn: 'expireTime',
isAsc: 'ascending'
};
// 添加状态筛选
if (activeStatusTab.value) {
// 根据选中的状态标签设置statusList使用字典键值
// if (activeStatusTab.value === 'DEVELOPMENT_OVERDUE') {
// // 开发超期需要特殊处理状态是IN_PROGRESS但需要overdue=true
// requestParams.statusList = ['IN_PROGRESS'];
// requestParams.overdue = true;
// } else
{
// 其他状态直接使用字典键值
requestParams.statusList = [activeStatusTab.value];
}
}
// 添加其他筛选条件
if (filterParams.value.customerName) {
requestParams.customerName = filterParams.value.customerName;
}
if (filterParams.value.projectId) {
requestParams.projectId = filterParams.value.projectId;
}
if (filterParams.value.projectName) {
requestParams.projectName = filterParams.value.projectName;
}
if (filterParams.value.joinUserId) {
requestParams.joinUserId = filterParams.value.joinUserId;
}
if (filterParams.value.overdue !== '' && activeStatusTab.value !== 'DEVELOPMENT_OVERDUE') {
// 如果不是"开发超期"标签才使用overdue筛选
requestParams.overdue = filterParams.value.overdue;
}
// 添加创建人和负责人筛选(根据用户私有视角)
const userId = userStore.getUserInfo?.user?.userId || userStore.getUserInfo?.userId;
const privateView = userStore.privateView;
if (userId && privateView) {
requestParams.ownerId = userId;
requestParams.createId = userId;
}
const res = await getProjectList(requestParams);
return res;
},
mode: 'loadMore',
pageSize: 20,
defaultParams: {}
});
// 项目列表
const projects = computed(() => list.value);
// 获取状态文本
const getStatusText = (status) => {
if (!status && status !== 0) return '未知';
// 优先从字典获取字典键值作为dictValue
const dictLabel = getDictLabel('project_status', status);
if (dictLabel && dictLabel !== String(status)) {
return dictLabel;
}
// 默认映射(兼容字典键值)
const statusMap = {
'WAIT_START': '待开始',
'IN_PROGRESS': '开发中',
'COMPLETED': '开发完成',
'ACCEPTED': '已验收',
'MAINTENANCE': '维护中',
'MAINTENANCE_OVERDUE': '维护到期',
'DEVELOPMENT_OVERDUE': '开发超期',
// 兼容旧数据(数字状态值)
'1': '待开始',
'2': '开发中',
'4': '开发完成',
'5': '已验收',
'6': '维护中',
'7': '维护到期',
'8': '开发超期'
};
return statusMap[String(status)] || `状态${status}`;
};
// 获取状态类型(用于标签颜色)
const getStatusType = (status) => {
if (!status && status !== 0) return 'primary';
const typeMap = {
// 字典键值映射
'WAIT_START': 'primary', // 待开始
'IN_PROGRESS': 'warning', // 开发中
'COMPLETED': 'success', // 开发完成
'ACCEPTED': 'info', // 已验收
'MAINTENANCE': 'primary', // 维护中
'MAINTENANCE_OVERDUE': 'warning', // 维护到期
'DEVELOPMENT_OVERDUE': 'error', // 开发超期
// 兼容旧数据(数字状态值)
'1': 'primary', // 待开始
'2': 'warning', // 开发中
'4': 'success', // 开发完成
'5': 'info', // 已验收
'6': 'primary', // 维护中
'7': 'warning', // 维护到期
'8': 'error' // 开发超期
};
return typeMap[String(status)] || 'primary';
};
// 格式化日期
const formatDate = (dateStr) => {
if (!dateStr) return '未知';
return dateStr.split(' ')[0];
};
// 获取负责人名称
const getOwnerNames = (memberList) => {
if (!Array.isArray(memberList) || memberList.length === 0) return '未分配';
return memberList.map(member => member.userName || member.name || '').filter(name => name).join('、');
};
// 获取紧急状态标签
const getUrgentStatus = (project) => {
if (!project.expireTime) return '';
const expireDate = new Date(project.expireTime);
const now = new Date();
const diffTime = expireDate - now;
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
if (diffDays < 0) {
// 已逾期
return '';
} else if (diffDays <= 3) {
// 即将到期3天内
return '即将到期';
}
return '';
};
// 判断是否逾期
const isOverdue = (project) => {
if (!project.expireTime) return false;
const expireDate = new Date(project.expireTime);
const now = new Date();
return expireDate < now;
};
// 获取时间状态文本(剩余天数或逾期天数)
const getTimeStatus = (project) => {
if (!project.expireTime) return '';
const expireDate = new Date(project.expireTime);
const now = new Date();
const diffTime = expireDate - now;
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
if (diffDays < 0) {
return `逾期${Math.abs(diffDays)}`;
} else if (diffDays === 0) {
return '今天到期';
} else {
return `剩余${diffDays}`;
}
};
// 获取标签颜色(根据项目创建人或负责人)
const getTagColor = (project) => {
const colors = ['#87CEEB', '#FFB6C1', '#FFA500', '#98D8C8', '#DDA0DD'];
const name = project.createName || project.ownerName || '';
if (!name) return colors[0];
const index = name.charCodeAt(0) % colors.length;
return colors[index];
};
// 获取标签文本(取创建人名字的第一个字)
const getTagText = (project) => {
const name = project.createName || project.ownerName || '';
if (!name) return '项';
return name.charAt(0);
};
// 获取显示成员最多3个
const getDisplayMembers = (memberList) => {
if (!Array.isArray(memberList)) return [];
return memberList.slice(0, 3);
};
// 格式化成员名称
const formatMemberNames = (memberList) => {
if (!Array.isArray(memberList) || memberList.length === 0) return '';
const names = memberList.map(member => member.userName || member.name || '').filter(name => name);
if (names.length <= 3) {
return names.join('');
} else {
const firstThree = names.slice(0, 3).join('');
return `${firstThree}${names.length}`;
}
};
// 获取头像颜色
const getAvatarColor = (member, index) => {
const colors = ['#FFB6C1', '#87CEEB', '#DDA0DD', '#98D8C8', '#FFA500'];
return colors[index % colors.length];
};
// 获取头像文本(取名字的第一个字)
const getAvatarText = (member) => {
const name = member.userName || member.name || '';
if (!name) return '?';
return name.charAt(0);
};
// 处理卡片菜单
const handleCardMenu = (project) => {
uni.showActionSheet({
itemList: ['修改', '删除', '+ 新增任务', '√ 开始开发'],
success: (res) => {
if (res.tapIndex === 0) {
goToEditProject(project);
} else if (res.tapIndex === 1) {
// 删除
uni.showToast({ title: '删除功能开发中', icon: 'none' });
} else if (res.tapIndex === 2) {
// 新增任务
uni.showToast({ title: '新增任务功能开发中', icon: 'none' });
} else if (res.tapIndex === 3) {
// 开始开发
uni.showToast({ title: '开始开发功能开发中', icon: 'none' });
}
}
});
};
const goToCreateProject = () => {
uni.navigateTo({
url: '/pages/project/form/index?mode=add'
});
};
const goToEditProject = (project) => {
if (!project?.id) {
uni.showToast({
title: '缺少项目ID',
icon: 'none'
});
return;
}
uni.navigateTo({
url: `/pages/project/form/index?mode=edit&id=${project.id}`
});
};
// 处理状态标签点击
const handleStatusTabClick = (value) => {
activeStatusTab.value = value;
handleSearch();
};
// 处理成员选择
const handleMemberChange = (e) => {
const index = e.detail.value;
const member = memberOptions.value[index];
if (member) {
filterParams.value.joinUserId = member.userId;
selectedMemberName.value = member.nickName;
} else {
filterParams.value.joinUserId = '';
selectedMemberName.value = '';
}
};
// 搜索
const handleSearch = () => {
// 使用 updateParams 更新查询参数,会自动重置页码并重新加载数据
updateParams({});
};
// 重置
const handleReset = () => {
filterParams.value = {
customerName: '',
projectId: '',
projectName: '',
joinUserId: '',
overdue: ''
};
selectedMemberName.value = '';
activeStatusTab.value = 'IN_PROGRESS';
// 使用 updateParams 更新查询参数,会自动重置页码并重新加载数据
updateParams({});
};
// 上拉加载更多 - 使用 uniapp 的 onReachBottom
onReachBottom(() => {
if (!noMore.value && !loading.value) {
loadMore();
}
});
// 下拉刷新 - 使用 uniapp 的 onPullDownRefresh
onPullDownRefresh(async () => {
try {
// 重置并刷新数据
await refresh();
} finally {
// 停止下拉刷新动画
uni.stopPullDownRefresh();
}
});
// 跳转到项目详情
const goToProjectDetail = (project) => {
// TODO: 跳转到项目详情页面
uni.showToast({
title: '项目详情功能开发中',
icon: 'none'
});
};
// 加载成员列表
const loadMemberList = async () => {
try {
const res = await getUserList({ pageSize: 200 });
if (res && res.rows) {
memberOptions.value = res.rows || [];
}
} catch (err) {
console.error('加载成员列表失败:', err);
}
};
// 页面初始化标志
const isInitialized = ref(false);
// 监听用户私有视角变化
watch(() => userStore.privateView, () => {
if (isInitialized.value) {
handleSearch();
}
});
onMounted(() => {
if (!dictStore.isLoaded) {
dictStore.loadDictData();
}
loadMemberList();
uni.$on('projectListRefresh', handleSearch);
});
onUnmounted(() => {
uni.$off('projectListRefresh', handleSearch);
});
onLoad(() => {
nextTick(() => {
isInitialized.value = true;
// 初始化时直接调用 getList使用 reset=true 重置列表
getList(true);
});
});
</script>
<style lang="scss" scoped>
.project-list-page {
width: 100%;
min-height: 100vh;
background: #f5f5f5;
display: flex;
flex-direction: column;
}
.filter-section {
background: #fff;
padding: 16px;
margin-bottom: 8px;
}
.filter-row {
display: flex;
gap: 12px;
margin-bottom: 12px;
&:last-child {
margin-bottom: 0;
}
}
.filter-item {
flex: 1;
display: flex;
flex-direction: column;
gap: 6px;
}
.filter-label {
font-size: 12px;
color: #666;
}
.filter-input {
height: 36px;
padding: 0 12px;
background: #f5f6f7;
border-radius: 6px;
font-size: 14px;
}
.filter-picker {
height: 36px;
padding: 0 12px;
background: #f5f6f7;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: space-between;
}
.picker-text {
font-size: 14px;
color: #333;
}
.picker-arrow {
font-size: 12px;
color: #999;
}
.radio-group {
display: flex;
gap: 12px;
margin-top: 6px;
}
.radio-item {
padding: 6px 16px;
background: #f5f6f7;
border-radius: 16px;
font-size: 14px;
color: #666;
&.active {
background: #2885ff;
color: #fff;
}
}
.status-tabs {
display: flex;
gap: 8px;
margin: 12px 0;
flex-wrap: wrap;
}
.status-tab {
padding: 6px 16px;
background: #f5f6f7;
border-radius: 16px;
font-size: 14px;
color: #666;
&.active {
background: #2885ff;
color: #fff;
}
}
.action-buttons {
display: flex;
gap: 12px;
margin-top: 12px;
}
.btn-icon {
margin-right: 4px;
}
.project-scroll {
flex: 1;
width: 100%;
}
.project-container {
padding: 16px;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 16px;
}
.project-card {
background: #fff;
border-radius: 8px;
padding: 16px;
display: flex;
flex-direction: column;
gap: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
position: relative;
transition: all 0.3s ease;
cursor: pointer;
}
.project-card:active {
transform: scale(0.98);
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1);
}
/* 卡片头部 */
.card-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 4px;
}
.header-left {
display: flex;
flex-direction: column;
gap: 6px;
flex: 1;
}
.status-tag {
display: inline-block;
padding: 4px 10px;
background: #E5E5E5;
border-radius: 4px;
font-size: 12px;
color: #333;
width: fit-content;
}
.urgent-tag {
display: inline-block;
padding: 4px 10px;
background: #FF4444;
border-radius: 4px;
font-size: 12px;
color: #fff;
width: fit-content;
}
.header-right {
display: flex;
align-items: center;
gap: 8px;
}
.release-date {
font-size: 12px;
color: #666;
white-space: nowrap;
}
.action-menu {
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
padding: 4px;
border-radius: 4px;
transition: background-color 0.2s;
}
.action-menu:active {
background-color: #f0f0f0;
}
.menu-icon {
font-size: 18px;
color: #666;
line-height: 1;
transform: rotate(90deg);
}
/* 项目标题 */
.card-title {
font-size: 16px;
font-weight: 600;
color: #333;
margin-top: 4px;
}
/* 标签和描述行 */
.card-tags-row {
display: flex;
align-items: center;
gap: 8px;
margin-top: 4px;
}
.tag-circle {
width: 32px;
height: 32px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.tag-text {
font-size: 14px;
color: #fff;
font-weight: 500;
}
.card-description {
font-size: 14px;
color: #666;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* 操作标签 */
.action-tags {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin-top: 4px;
}
.action-tag {
padding: 4px 10px;
background: #E3F2FD;
border-radius: 4px;
font-size: 12px;
color: #1976D2;
}
/* 卡片底部 */
.card-footer {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 8px;
}
.date-info {
display: flex;
align-items: center;
gap: 6px;
}
.date-text {
font-size: 14px;
color: #333;
}
.time-icon {
font-size: 14px;
}
.time-text {
font-size: 14px;
color: #666;
&.overdue {
color: #FF4444;
}
}
.member-info {
display: flex;
align-items: center;
gap: 8px;
}
.member-avatars {
display: flex;
align-items: center;
}
.member-avatar {
width: 24px;
height: 24px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
border: 2px solid #fff;
margin-left: -8px;
&:first-child {
margin-left: 0;
}
}
.avatar-text {
font-size: 10px;
color: #fff;
font-weight: 500;
}
.member-text {
font-size: 12px;
color: #666;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.empty-state {
padding: 40px 0;
text-align: center;
}
.empty-text {
font-size: 14px;
color: #999;
}
.load-more-tip {
padding: 20px 0;
text-align: center;
}
.load-more-text {
font-size: 12px;
color: #999;
}
</style>