OfficeSystem/pages/project/list/index.vue

1430 lines
34 KiB
Vue
Raw Normal View History

2025-11-17 11:58:49 +08:00
<template>
<view class="project-list-page">
2025-11-22 11:57:31 +08:00
<!-- 顶部标题栏 -->
<view class="header">
<view @click="goToProjectSearch">
<view style="height: 5px;"></view>
<img src="https://api.ccttiot.com/image-1763782244238.png" alt="" style="width: 20px !important; height: 20px !important;">
2025-11-17 11:58:49 +08:00
</view>
2025-11-22 11:57:31 +08:00
<view class="header-tabs">
<view
class="tab-item"
v-for="tab in mainStatusTabs"
:key="tab.value"
:class="{ 'active': activeStatusTab === tab.value }"
@click="handleStatusTabClick(tab.value)"
>
{{ tab.label }}
2025-11-17 11:58:49 +08:00
</view>
</view>
2025-11-22 11:57:31 +08:00
<view class="filter-btn" @click="showFilter = !showFilter">
<text class="filter-text">筛选</text>
</view>
</view>
<!-- 筛选面板 -->
<view class="filter-panel" v-if="showFilter">
<view class="filter-item">
2025-11-22 13:32:55 +08:00
<text class="filter-label">状态</text>
<view class="filter-options">
<text
class="filter-option"
v-for="tab in filterStatusTabs"
:key="tab.value"
:class="{ 'active': filterParams.status === tab.value }"
2025-11-22 13:42:54 +08:00
@click="handleFilterStatusChange(tab.value)"
2025-11-22 13:32:55 +08:00
>{{ tab.label }}</text>
</view>
2025-11-22 11:57:31 +08:00
</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>
2025-11-17 11:58:49 +08:00
</view>
2025-11-22 11:57:31 +08:00
</picker>
</view>
<view class="filter-item">
<text class="filter-label">开发超期</text>
<view class="filter-options">
<text
class="filter-option"
:class="{ 'active': filterParams.overdue === '' }"
2025-11-22 13:32:55 +08:00
@click="filterParams.overdue = ''"
2025-11-22 11:57:31 +08:00
>全部</text>
<text
class="filter-option"
:class="{ 'active': filterParams.overdue === true }"
2025-11-22 13:32:55 +08:00
@click="filterParams.overdue = true"
2025-11-22 11:57:31 +08:00
></text>
<text
class="filter-option"
:class="{ 'active': filterParams.overdue === false }"
2025-11-22 13:32:55 +08:00
@click="filterParams.overdue = false"
2025-11-22 11:57:31 +08:00
></text>
2025-11-17 11:58:49 +08:00
</view>
</view>
2025-11-22 11:57:31 +08:00
<view class="filter-actions">
<uv-button size="small" @click="handleReset">重置</uv-button>
<uv-button type="primary" size="small" @click="handleSearch">确定</uv-button>
2025-11-17 11:58:49 +08:00
</view>
</view>
<!-- 项目列表 -->
2025-11-22 11:57:31 +08:00
<view
class="project-list"
:class="{ 'with-filter': showFilter }"
>
2025-11-17 11:58:49 +08:00
<view class="project-container">
<view
class="project-card"
v-for="project in projects"
:key="project.id"
@click="goToProjectDetail(project)"
>
2025-11-17 13:58:47 +08:00
<!-- 卡片头部状态标签发布日期操作菜单 -->
<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>
2025-11-17 11:58:49 +08:00
</view>
2025-11-17 13:58:47 +08:00
<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>
2025-11-17 11:58:49 +08:00
</view>
</view>
2025-11-17 13:58:47 +08:00
<!-- 项目标题 -->
2025-11-17 14:51:22 +08:00
<view class="card-title">{{ project.name }}</view>
2025-11-17 13:58:47 +08:00
<!-- 项目标签和描述 -->
2025-11-18 16:02:59 +08:00
<!-- <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">
2025-11-17 14:51:22 +08:00
<text class="card-description" v-if="project.remark">{{ project.remark }}</text>
2025-11-17 13:58:47 +08:00
</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>
2025-11-17 11:58:49 +08:00
</view>
2025-11-17 13:58:47 +08:00
<!-- 团队成员 -->
<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>
2025-11-17 11:58:49 +08:00
</view>
</view>
</view>
<!-- 加载状态 -->
2025-11-17 13:58:47 +08:00
<view class="empty-state" v-if="loading" style="grid-column: 1 / -1;">
2025-11-17 11:58:49 +08:00
<text class="empty-text">加载中...</text>
</view>
<!-- 空状态 -->
2025-11-17 13:58:47 +08:00
<view class="empty-state" v-else-if="isEmpty" style="grid-column: 1 / -1;">
2025-11-17 11:58:49 +08:00
<text class="empty-text">暂无项目数据</text>
</view>
<!-- 加载更多提示 -->
2025-11-17 13:58:47 +08:00
<view class="load-more-tip" v-if="!isEmpty && !loading && !noMore" style="grid-column: 1 / -1;">
2025-11-17 11:58:49 +08:00
<text class="load-more-text">上拉加载更多</text>
</view>
2025-11-17 13:58:47 +08:00
<view class="load-more-tip" v-if="!isEmpty && noMore" style="grid-column: 1 / -1;">
2025-11-17 11:58:49 +08:00
<text class="load-more-text">没有更多数据了</text>
</view>
</view>
2025-11-17 14:06:09 +08:00
</view>
2025-11-18 17:10:08 +08:00
<!-- 开始开发弹窗 -->
<view v-if="showStartModal" class="start-modal">
<view class="modal-mask" @click="closeStartModal"></view>
<view class="modal-panel">
<view class="modal-header">
<text class="modal-title">开始开发</text>
<text class="modal-close" @click="closeStartModal"></text>
</view>
<view class="modal-body">
<text class="modal-subtitle" v-if="currentStartProjectName">项目{{ currentStartProjectName }}</text>
<view class="field">
<text class="field-label">预计完成日期</text>
<view class="date-input" @click="openStartDatePicker">
<text class="placeholder" v-if="!startProjectForm.expectedCompleteDate">请选择预计完成日期</text>
<text class="value" v-else>{{ startProjectForm.expectedCompleteDate }}</text>
<text class="date-icon">📅</text>
</view>
</view>
</view>
<view class="modal-actions">
<uv-button size="small" @click="closeStartModal" :disabled="startingProject">取消</uv-button>
<uv-button
type="primary"
size="small"
@click="submitStartDevelopment"
:loading="startingProject"
:disabled="startingProject"
>
确定
</uv-button>
</view>
</view>
</view>
<uv-datetime-picker
ref="startDatePickerRef"
v-model="startDatePickerValue"
mode="date"
@confirm="onStartDateConfirm"
></uv-datetime-picker>
2025-11-22 11:57:31 +08:00
<!-- 悬浮添加按钮 -->
<FabPlus @click="goToCreateProject" />
2025-11-17 11:58:49 +08:00
</view>
</template>
<script setup>
2025-11-18 17:10:08 +08:00
import { ref, reactive, computed, watch, onMounted, nextTick, onUnmounted } from 'vue';
2025-11-17 14:06:09 +08:00
import { onLoad, onReachBottom, onPullDownRefresh } from '@dcloudio/uni-app';
2025-11-18 17:10:08 +08:00
import { getProjectList, getUserList, deleteProject, startProjectDevelopment } from '@/api';
2025-11-17 11:58:49 +08:00
import { usePagination } from '@/composables';
import { useDictStore } from '@/store/dict';
import { useUserStore } from '@/store/user';
import { getDictLabel } from '@/utils/dict';
2025-11-22 11:57:31 +08:00
import FabPlus from '@/components/FabPlus.vue';
2025-11-17 11:58:49 +08:00
const dictStore = useDictStore();
const userStore = useUserStore();
// 筛选参数
const filterParams = ref({
2025-11-17 15:44:45 +08:00
joinUserId: '',
2025-11-17 11:58:49 +08:00
overdue: '', // ''表示全部, true表示是, false表示否
2025-11-22 13:32:55 +08:00
status: '', // 筛选面板中的状态筛选
2025-11-17 11:58:49 +08:00
});
// 默认状态字典(用于字典数据尚未加载时的兜底)
const fallbackProjectStatusDict = [
{ dictLabel: '前期费用', dictValue: 'DEVELOPMENT_COST', listClass: 'primary', dictSort: 0 },
{ dictLabel: '中期费用', dictValue: 'MIDDLE_COST', listClass: 'success', dictSort: 0 },
{ dictLabel: '后期费用', dictValue: 'AFTER_COST', listClass: 'warning', dictSort: 0 },
{ dictLabel: '待开始', dictValue: 'WAIT_START', listClass: 'info', dictSort: 1 },
{ dictLabel: '开发中', dictValue: 'IN_PROGRESS', listClass: 'warning', dictSort: 100 },
{ dictLabel: '开发完成', dictValue: 'COMPLETED', listClass: 'primary', dictSort: 200 },
{ dictLabel: '已验收', dictValue: 'ACCEPTED', listClass: 'success', dictSort: 300 },
{ dictLabel: '维护中', dictValue: 'MAINTENANCE', listClass: 'warning', dictSort: 400 },
{ dictLabel: '维护到期', dictValue: 'MAINTENANCE_OVERDUE', listClass: 'warning', dictSort: 500 },
{ dictLabel: '开发超期', dictValue: 'DEVELOPMENT_OVERDUE', listClass: 'danger', dictSort: 600 }
];
2025-11-17 13:54:18 +08:00
// 状态标签(使用字典键值)
const statusTabs = computed(() => {
const dictItems = typeof dictStore.getDictByType === 'function'
? dictStore.getDictByType('project_status')
: [];
const source = Array.isArray(dictItems) && dictItems.length > 0
? dictItems
: fallbackProjectStatusDict;
return source
.slice()
.sort((a, b) => {
const sortA = parseInt(a.dictSort) || 0;
const sortB = parseInt(b.dictSort) || 0;
return sortA - sortB;
})
.map(item => ({
label: item.dictLabel || item.label,
value: item.dictValue || item.value,
listClass: item.listClass || item.type || 'primary'
}));
});
2025-11-17 11:58:49 +08:00
2025-11-17 13:54:18 +08:00
const activeStatusTab = ref('IN_PROGRESS'); // 默认选中"开发中"
2025-11-22 11:57:31 +08:00
const showFilter = ref(false);
// 主要状态标签(开发中、维护中、全部)
const mainStatusTabs = computed(() => {
const allTabs = statusTabs.value;
const mainTabs = [
allTabs.find(tab => tab.value === 'IN_PROGRESS'),
allTabs.find(tab => tab.value === 'MAINTENANCE'),
{ label: '全部', value: 'ALL' }
].filter(Boolean);
return mainTabs;
});
2025-11-17 11:58:49 +08:00
2025-11-22 13:32:55 +08:00
// 筛选面板中的其他状态(除了开发中、维护中之外的所有状态)
const filterStatusTabs = computed(() => {
const allTabs = statusTabs.value;
return allTabs.filter(tab =>
tab.value !== 'IN_PROGRESS' &&
tab.value !== 'MAINTENANCE'
);
});
// 当字典加载完成后确保当前选中状态仍然存在
watch(statusTabs, (tabs) => {
if (!Array.isArray(tabs) || tabs.length === 0) {
return;
}
const exists = tabs.some(tab => tab.value === activeStatusTab.value);
if (!exists) {
activeStatusTab.value = tabs[0].value;
}
}, { immediate: true });
2025-11-17 11:58:49 +08:00
// 成员选项
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'
};
// 添加状态筛选
2025-11-22 13:42:54 +08:00
// 导航栏的三个状态(开发中、维护中、全部)和筛选面板中的状态是独立的
2025-11-22 11:57:31 +08:00
if (activeStatusTab.value && activeStatusTab.value !== 'ALL') {
2025-11-22 13:42:54 +08:00
// 如果导航栏选择的是"开发中"或"维护中",只使用导航栏的状态,忽略筛选面板中的状态
2025-11-22 11:57:31 +08:00
requestParams.statusList = [activeStatusTab.value];
2025-11-22 13:42:54 +08:00
} else if (activeStatusTab.value === 'ALL') {
// 如果导航栏选择的是"全部",则使用筛选面板中的状态(如果有选择的话)
if (filterParams.value.status) {
requestParams.statusList = [filterParams.value.status];
}
// 如果筛选面板中也没有选择状态则不设置statusList显示所有状态
2025-11-17 11:58:49 +08:00
}
// 添加其他筛选条件
2025-11-17 15:44:45 +08:00
if (filterParams.value.joinUserId) {
requestParams.joinUserId = filterParams.value.joinUserId;
2025-11-17 11:58:49 +08:00
}
2025-11-22 14:14:59 +08:00
// 如果筛选面板中选择了开发超期(是或否),则传递参数
if (filterParams.value.overdue !== '') {
2025-11-17 11:58:49 +08:00
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;
}
2025-11-22 14:14:59 +08:00
console.log('查询参数',requestParams);
2025-11-17 11:58:49 +08:00
const res = await getProjectList(requestParams);
2025-11-22 14:14:59 +08:00
2025-11-17 11:58:49 +08:00
return res;
},
mode: 'loadMore',
pageSize: 20,
defaultParams: {}
});
// 项目列表
const projects = computed(() => list.value);
// 获取状态文本
const getStatusText = (status) => {
if (!status && status !== 0) return '未知';
2025-11-17 13:54:18 +08:00
// 优先从字典获取字典键值作为dictValue
2025-11-17 11:58:49 +08:00
const dictLabel = getDictLabel('project_status', status);
if (dictLabel && dictLabel !== String(status)) {
return dictLabel;
}
2025-11-17 13:54:18 +08:00
// 默认映射(兼容字典键值)
2025-11-17 11:58:49 +08:00
const statusMap = {
'DEVELOPMENT_COST': '前期费用',
'MIDDLE_COST': '中期费用',
'AFTER_COST': '后期费用',
2025-11-17 13:54:18 +08:00
'WAIT_START': '待开始',
'IN_PROGRESS': '开发中',
'COMPLETED': '开发完成',
'ACCEPTED': '已验收',
'MAINTENANCE': '维护中',
'MAINTENANCE_OVERDUE': '维护到期',
'DEVELOPMENT_OVERDUE': '开发超期',
// 兼容旧数据(数字状态值)
2025-11-17 11:58:49 +08:00
'1': '待开始',
'2': '开发中',
'4': '开发完成',
'5': '已验收',
'6': '维护中',
2025-11-17 13:54:18 +08:00
'7': '维护到期',
'8': '开发超期'
2025-11-17 11:58:49 +08:00
};
return statusMap[String(status)] || `状态${status}`;
};
// 获取状态类型(用于标签颜色)
const getStatusType = (status) => {
if (!status && status !== 0) return 'primary';
const typeMap = {
2025-11-17 13:54:18 +08:00
// 字典键值映射
'DEVELOPMENT_COST': 'primary', // 前期费用
'MIDDLE_COST': 'success', // 中期费用
'AFTER_COST': 'warning', // 后期费用
2025-11-17 13:54:18 +08:00
'WAIT_START': 'primary', // 待开始
'IN_PROGRESS': 'warning', // 开发中
'COMPLETED': 'success', // 开发完成
'ACCEPTED': 'info', // 已验收
'MAINTENANCE': 'primary', // 维护中
'MAINTENANCE_OVERDUE': 'warning', // 维护到期
'DEVELOPMENT_OVERDUE': 'error', // 开发超期
// 兼容旧数据(数字状态值)
2025-11-17 11:58:49 +08:00
'1': 'primary', // 待开始
'2': 'warning', // 开发中
'4': 'success', // 开发完成
'5': 'info', // 已验收
'6': 'primary', // 维护中
2025-11-17 13:54:18 +08:00
'7': 'warning', // 维护到期
'8': 'error' // 开发超期
2025-11-17 11:58:49 +08:00
};
return typeMap[String(status)] || 'primary';
};
// 格式化日期
const formatDate = (dateStr) => {
if (!dateStr) return '未知';
2025-11-18 17:10:08 +08:00
if (typeof dateStr === 'number') {
return formatDateValue(dateStr);
}
2025-11-17 11:58:49 +08:00
return dateStr.split(' ')[0];
};
2025-11-18 17:10:08 +08:00
const formatDateValue = (value) => {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return '';
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
};
const extractDateOnly = (value) => {
if (!value) return '';
if (typeof value === 'string') {
const trimmed = value.trim();
if (/^\d{4}-\d{2}-\d{2}$/.test(trimmed)) {
return trimmed;
}
if (trimmed.includes(' ')) {
return trimmed.split(' ')[0];
}
}
return formatDateValue(value);
};
2025-11-17 11:58:49 +08:00
// 获取负责人名称
const getOwnerNames = (memberList) => {
if (!Array.isArray(memberList) || memberList.length === 0) return '未分配';
return memberList.map(member => member.userName || member.name || '').filter(name => name).join('、');
};
2025-11-17 13:58:47 +08:00
// 获取紧急状态标签
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);
};
2025-11-18 17:10:08 +08:00
const showStartModal = ref(false);
const startingProject = ref(false);
const startProjectForm = reactive({
id: '',
expectedCompleteDate: ''
});
const currentStartProjectName = ref('');
const startDatePickerRef = ref(null);
const startDatePickerValue = ref(Date.now());
2025-11-18 16:58:22 +08:00
2025-11-17 13:58:47 +08:00
// 处理卡片菜单
const handleCardMenu = (project) => {
uni.showActionSheet({
2025-11-22 14:24:17 +08:00
itemList: ['修改', '删除', '新增任务', '开始开发'],
2025-11-17 13:58:47 +08:00
success: (res) => {
if (res.tapIndex === 0) {
2025-11-17 16:58:11 +08:00
goToEditProject(project);
2025-11-17 13:58:47 +08:00
} else if (res.tapIndex === 1) {
2025-11-18 16:58:22 +08:00
handleDeleteProject(project);
2025-11-17 13:58:47 +08:00
} else if (res.tapIndex === 2) {
2025-11-18 17:30:28 +08:00
goToAddTask(project);
2025-11-17 13:58:47 +08:00
} else if (res.tapIndex === 3) {
2025-11-18 17:10:08 +08:00
handleStartDevelopment(project);
2025-11-17 13:58:47 +08:00
}
}
});
};
2025-11-18 17:10:08 +08:00
const handleStartDevelopment = (project) => {
if (!project?.id) {
uni.showToast({
title: '缺少项目ID',
icon: 'none'
});
return;
}
startProjectForm.id = project.id;
startProjectForm.expectedCompleteDate = extractDateOnly(project.expectedCompleteDate || project.expireTime || '');
currentStartProjectName.value = project.name || project.projectName || '';
showStartModal.value = true;
};
const closeStartModal = () => {
resetStartForm();
showStartModal.value = false;
};
const resetStartForm = () => {
startProjectForm.id = '';
startProjectForm.expectedCompleteDate = '';
currentStartProjectName.value = '';
};
const submitStartDevelopment = async () => {
if (!startProjectForm.id) {
uni.showToast({
title: '缺少项目ID',
icon: 'none'
});
return;
}
if (!startProjectForm.expectedCompleteDate) {
uni.showToast({
title: '请选择预计完成日期',
icon: 'none'
});
return;
}
try {
startingProject.value = true;
await startProjectDevelopment({
id: startProjectForm.id,
expectedCompleteDate: startProjectForm.expectedCompleteDate
});
uni.showToast({ title: '已开始开发', icon: 'success' });
closeStartModal();
await refresh();
} catch (err) {
console.error('开始开发失败:', err);
uni.showToast({
title: err?.msg || '操作失败,请稍后重试',
2025-11-18 17:10:08 +08:00
icon: 'none'
});
} finally {
startingProject.value = false;
}
};
const openStartDatePicker = () => {
startDatePickerValue.value = startProjectForm.expectedCompleteDate
? new Date(startProjectForm.expectedCompleteDate.replace(/-/g, '/')).getTime()
: Date.now();
if (startDatePickerRef.value?.open) {
startDatePickerRef.value.open();
}
};
const onStartDateConfirm = (event) => {
if (!event?.value) return;
startProjectForm.expectedCompleteDate = formatDateValue(event.value);
};
2025-11-18 16:58:22 +08:00
const handleDeleteProject = (project) => {
if (!project?.id) {
uni.showToast({
title: '缺少项目ID',
icon: 'none'
});
return;
}
const projectName = project.name || project.projectName || '';
uni.showModal({
title: '删除项目',
content: projectName ? `确认删除项目「${projectName}」?` : '确认删除该项目?',
confirmColor: '#f56c6c',
success: async ({ confirm }) => {
if (!confirm) {
return;
}
try {
uni.showLoading({ title: '删除中...', mask: true });
await deleteProject(project.id);
uni.showToast({ title: '删除成功', icon: 'success' });
await refresh();
} catch (err) {
console.error('删除项目失败:', err);
uni.showToast({
2025-11-24 16:12:22 +08:00
title: err?.msg || '删除失败,请稍后重试',
2025-11-18 16:58:22 +08:00
icon: 'none'
});
} finally {
uni.hideLoading();
}
}
});
};
2025-11-17 16:58:11 +08:00
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}`
});
};
2025-11-18 17:30:28 +08:00
const goToAddTask = (project) => {
if (!project?.id) {
uni.showToast({
title: '缺少项目ID',
icon: 'none'
});
return;
}
const projectName = project.name || project.projectName || '';
const encodedName = encodeURIComponent(projectName);
uni.navigateTo({
url: `/pages/task/add/index?projectId=${project.id}&projectName=${encodedName}`
});
};
2025-11-17 11:58:49 +08:00
// 处理状态标签点击
const handleStatusTabClick = (value) => {
activeStatusTab.value = value;
2025-11-22 13:53:43 +08:00
// 点击导航栏按钮时,清空筛选面板中的状态选择,确保导航栏状态和筛选面板状态独立
filterParams.value.status = '';
2025-11-17 11:58:49 +08:00
handleSearch();
};
2025-11-22 11:57:31 +08:00
// 跳转到项目搜索页面
const goToProjectSearch = () => {
uni.navigateTo({
url: '/pages/project/search/index'
});
};
2025-11-17 11:58:49 +08:00
// 处理成员选择
const handleMemberChange = (e) => {
const index = e.detail.value;
const member = memberOptions.value[index];
if (member) {
2025-11-17 15:44:45 +08:00
filterParams.value.joinUserId = member.userId;
selectedMemberName.value = member.nickName;
2025-11-17 11:58:49 +08:00
} else {
2025-11-17 15:44:45 +08:00
filterParams.value.joinUserId = '';
2025-11-17 11:58:49 +08:00
selectedMemberName.value = '';
}
};
2025-11-22 13:42:54 +08:00
// 处理筛选面板中的状态选择
const handleFilterStatusChange = (status) => {
filterParams.value.status = status;
// 当在筛选面板中选择具体状态(非"全部")时,自动将导航栏切换到"全部",使筛选面板中的状态选择生效
// 这样导航栏的状态和筛选面板中的状态是独立的
if (status) {
activeStatusTab.value = 'ALL';
}
};
2025-11-17 11:58:49 +08:00
// 搜索
const handleSearch = () => {
2025-11-17 14:06:09 +08:00
// 使用 updateParams 更新查询参数,会自动重置页码并重新加载数据
updateParams({});
2025-11-17 11:58:49 +08:00
};
// 重置
const handleReset = () => {
filterParams.value = {
2025-11-17 15:44:45 +08:00
joinUserId: '',
2025-11-22 13:32:55 +08:00
overdue: '',
status: ''
2025-11-17 11:58:49 +08:00
};
selectedMemberName.value = '';
2025-11-22 11:57:31 +08:00
handleSearch();
2025-11-17 11:58:49 +08:00
};
2025-11-17 14:06:09 +08:00
// 上拉加载更多 - 使用 uniapp 的 onReachBottom
onReachBottom(() => {
2025-11-17 11:58:49 +08:00
if (!noMore.value && !loading.value) {
loadMore();
}
2025-11-17 14:06:09 +08:00
});
// 下拉刷新 - 使用 uniapp 的 onPullDownRefresh
onPullDownRefresh(async () => {
try {
// 重置并刷新数据
await refresh();
} finally {
// 停止下拉刷新动画
uni.stopPullDownRefresh();
}
});
2025-11-17 11:58:49 +08:00
// 跳转到项目详情
const goToProjectDetail = (project) => {
2025-11-24 15:44:02 +08:00
if (!project?.id) {
uni.showToast({
title: '缺少项目ID',
icon: 'none'
});
return;
}
uni.navigateTo({
url: `/pages/project/form/index?mode=view&id=${project.id}`
});
2025-11-17 11:58:49 +08:00
};
// 加载成员列表
const loadMemberList = async () => {
try {
const res = await getUserList({ pageSize: 200 });
if (res && res.rows) {
2025-11-17 15:44:45 +08:00
2025-11-17 11:58:49 +08:00
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();
2025-11-17 16:58:11 +08:00
uni.$on('projectListRefresh', handleSearch);
});
onUnmounted(() => {
uni.$off('projectListRefresh', handleSearch);
2025-11-17 11:58:49 +08:00
});
onLoad(() => {
nextTick(() => {
isInitialized.value = true;
2025-11-17 14:06:09 +08:00
// 初始化时直接调用 getList使用 reset=true 重置列表
getList(true);
2025-11-17 11:58:49 +08:00
});
});
</script>
<style lang="scss" scoped>
.project-list-page {
2025-11-22 11:57:31 +08:00
margin-top: var(--status-bar-height, 0);
2025-11-17 11:58:49 +08:00
display: flex;
flex-direction: column;
2025-11-22 11:57:31 +08:00
height: 100%;
width: 100%;
background-color: #f5f7fa;
position: relative;
2025-11-17 11:58:49 +08:00
}
2025-11-22 11:57:31 +08:00
/* 顶部标题栏 */
.header {
display: flex;
align-items: center;
gap: 16px;
padding: 5px 24px;
background-color: #fff;
border-bottom: 1px solid #e4e7ed;
position: fixed;
2025-11-24 16:12:22 +08:00
// TODO: 调整top
top: 0;
2025-11-22 11:57:31 +08:00
right: 0;
left: 0;
z-index: 100;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
2025-11-17 11:58:49 +08:00
}
2025-11-22 11:57:31 +08:00
.header-tabs {
2025-11-17 11:58:49 +08:00
display: flex;
2025-11-22 11:57:31 +08:00
align-items: center;
justify-content: center;
gap: 24px;
flex: 1;
}
.tab-item {
font-size: 14px;
color: #909399;
padding: 8px 0;
cursor: pointer;
position: relative;
transition: all 0.3s ease;
2025-11-17 11:58:49 +08:00
2025-11-22 11:57:31 +08:00
&.active {
color: #303133;
font-weight: 500;
&::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 2px;
background-color: #409eff;
}
}
&:active {
opacity: 0.7;
}
}
.filter-btn {
display: flex;
align-items: center;
padding: 6px 0;
cursor: pointer;
flex-shrink: 0;
&:active {
opacity: 0.7;
}
}
.filter-text {
font-size: 14px;
color: #303133;
font-weight: 500;
}
/* 筛选面板 */
.filter-panel {
background-color: #fff;
padding: 16px;
border-bottom: 1px solid #e4e7ed;
position: fixed;
top: 41px;
right: 0;
left: 0;
z-index: 99;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
animation: slideDown 0.3s ease;
}
@keyframes slideDown {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
2025-11-17 11:58:49 +08:00
}
}
.filter-item {
display: flex;
2025-11-22 11:57:31 +08:00
align-items: center;
gap: 16px;
margin-bottom: 12px;
&:last-child {
margin-bottom: 0;
}
2025-11-17 11:58:49 +08:00
}
.filter-label {
2025-11-22 11:57:31 +08:00
font-size: 14px;
color: #606266;
flex-shrink: 0;
font-weight: 500;
min-width: 80px;
2025-11-17 11:58:49 +08:00
}
.filter-input {
2025-11-22 11:57:31 +08:00
flex: 1;
2025-11-17 11:58:49 +08:00
height: 36px;
padding: 0 12px;
background: #f5f6f7;
border-radius: 6px;
font-size: 14px;
}
.filter-picker {
2025-11-22 11:57:31 +08:00
flex: 1;
2025-11-17 11:58:49 +08:00
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;
}
2025-11-22 11:57:31 +08:00
.filter-options {
2025-11-17 11:58:49 +08:00
display: flex;
2025-11-22 11:57:31 +08:00
gap: 10px;
flex-wrap: wrap;
flex: 1;
2025-11-17 11:58:49 +08:00
}
2025-11-22 11:57:31 +08:00
.filter-option {
2025-11-17 11:58:49 +08:00
padding: 6px 16px;
font-size: 14px;
2025-11-22 11:57:31 +08:00
color: #606266;
background-color: #f5f7fa;
border-radius: 20px;
transition: all 0.3s ease;
cursor: pointer;
border: 1px solid transparent;
2025-11-17 11:58:49 +08:00
2025-11-22 11:57:31 +08:00
&:active {
transform: scale(0.95);
2025-11-17 11:58:49 +08:00
}
&.active {
2025-11-22 11:57:31 +08:00
color: #2885ff;
background-color: #e6f2ff;
border-color: #2885ff;
font-weight: 500;
2025-11-17 11:58:49 +08:00
}
}
2025-11-22 11:57:31 +08:00
.filter-actions {
2025-11-17 11:58:49 +08:00
display: flex;
2025-11-22 11:57:31 +08:00
justify-content: flex-end;
2025-11-17 11:58:49 +08:00
gap: 12px;
2025-11-22 11:57:31 +08:00
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid #e4e7ed;
2025-11-17 11:58:49 +08:00
}
2025-11-22 11:57:31 +08:00
.project-list {
2025-11-17 11:58:49 +08:00
flex: 1;
2025-11-22 14:24:17 +08:00
padding-top: 6px;
2025-11-22 11:57:31 +08:00
padding-bottom: 100px;
background-color: #f5f7fa;
transition: padding-top 0.3s ease;
&.with-filter {
padding-top: 320px; /* header(41px) + filter panel(约280px) */
}
2025-11-17 11:58:49 +08:00
}
.project-container {
padding: 16px;
2025-11-17 13:58:47 +08:00
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 16px;
}
2025-11-17 11:58:49 +08:00
.project-card {
background: #fff;
2025-11-17 13:58:47 +08:00
border-radius: 8px;
2025-11-17 11:58:49 +08:00
padding: 16px;
display: flex;
flex-direction: column;
gap: 12px;
2025-11-17 13:58:47 +08:00
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
position: relative;
transition: all 0.3s ease;
cursor: pointer;
2025-11-17 11:58:49 +08:00
}
2025-11-17 13:58:47 +08:00
.project-card:active {
transform: scale(0.98);
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1);
2025-11-17 11:58:49 +08:00
}
2025-11-17 13:58:47 +08:00
/* 卡片头部 */
.card-header {
2025-11-17 11:58:49 +08:00
display: flex;
2025-11-17 13:58:47 +08:00
align-items: flex-start;
justify-content: space-between;
margin-bottom: 4px;
2025-11-17 11:58:49 +08:00
}
2025-11-17 13:58:47 +08:00
.header-left {
2025-11-17 11:58:49 +08:00
display: flex;
2025-11-17 13:58:47 +08:00
flex-direction: column;
2025-11-17 11:58:49 +08:00
gap: 6px;
2025-11-17 13:58:47 +08:00
flex: 1;
2025-11-17 11:58:49 +08:00
}
2025-11-17 13:58:47 +08:00
.status-tag {
display: inline-block;
padding: 4px 10px;
background: #E5E5E5;
border-radius: 4px;
2025-11-17 11:58:49 +08:00
font-size: 12px;
2025-11-17 13:58:47 +08:00
color: #333;
width: fit-content;
2025-11-17 11:58:49 +08:00
}
2025-11-17 13:58:47 +08:00
.urgent-tag {
display: inline-block;
padding: 4px 10px;
background: #FF4444;
border-radius: 4px;
font-size: 12px;
color: #fff;
width: fit-content;
2025-11-17 11:58:49 +08:00
}
2025-11-17 13:58:47 +08:00
.header-right {
2025-11-17 11:58:49 +08:00
display: flex;
2025-11-17 13:58:47 +08:00
align-items: center;
2025-11-17 11:58:49 +08:00
gap: 8px;
}
2025-11-17 13:58:47 +08:00
.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 {
2025-11-17 11:58:49 +08:00
font-size: 16px;
font-weight: 600;
color: #333;
2025-11-17 13:58:47 +08:00
margin-top: 4px;
}
/* 标签和描述行 */
.card-tags-row {
display: flex;
align-items: center;
gap: 8px;
margin-top: 4px;
2025-11-17 11:58:49 +08:00
}
2025-11-17 13:58:47 +08:00
.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 {
2025-11-17 11:58:49 +08:00
font-size: 14px;
color: #666;
2025-11-17 13:58:47 +08:00
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
2025-11-17 11:58:49 +08:00
}
2025-11-17 13:58:47 +08:00
/* 操作标签 */
.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 {
2025-11-17 11:58:49 +08:00
display: flex;
flex-direction: column;
2025-11-17 13:58:47 +08:00
gap: 8px;
2025-11-17 11:58:49 +08:00
margin-top: 8px;
}
2025-11-17 13:58:47 +08:00
.date-info {
display: flex;
align-items: center;
gap: 6px;
}
.date-text {
font-size: 14px;
color: #333;
2025-11-17 11:58:49 +08:00
}
2025-11-17 13:58:47 +08:00
.time-icon {
font-size: 14px;
}
.time-text {
font-size: 14px;
color: #666;
&.overdue {
color: #FF4444;
}
}
.member-info {
2025-11-17 11:58:49 +08:00
display: flex;
2025-11-17 13:58:47 +08:00
align-items: center;
gap: 8px;
2025-11-17 11:58:49 +08:00
}
2025-11-17 13:58:47 +08:00
.member-avatars {
display: flex;
align-items: center;
2025-11-17 11:58:49 +08:00
}
2025-11-17 13:58:47 +08:00
.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 {
2025-11-17 11:58:49 +08:00
font-size: 12px;
2025-11-17 13:58:47 +08:00
color: #666;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
2025-11-17 11:58:49 +08:00
}
.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;
}
2025-11-18 17:10:08 +08:00
.start-modal {
position: fixed;
inset: 0;
z-index: 999;
}
.modal-mask {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.4);
}
.modal-panel {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
width: 86%;
max-width: 360px;
background: #fff;
border-radius: 12px;
padding: 16px;
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.12);
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.modal-title {
font-size: 16px;
font-weight: 600;
color: #333;
}
.modal-close {
font-size: 18px;
color: #999;
padding: 4px;
}
.modal-body {
display: flex;
flex-direction: column;
gap: 12px;
margin-bottom: 16px;
}
.modal-subtitle {
font-size: 14px;
color: #666;
}
.field {
display: flex;
flex-direction: column;
gap: 8px;
}
.field-label {
font-size: 14px;
color: #333;
}
.date-input {
height: 40px;
border-radius: 8px;
background: #f5f6f7;
padding: 0 12px;
display: flex;
align-items: center;
justify-content: space-between;
font-size: 14px;
color: #333;
}
.date-input .placeholder {
color: #999;
}
.date-icon {
font-size: 16px;
margin-left: 8px;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 12px;
}
2025-11-17 11:58:49 +08:00
</style>