OfficeSystem/pages/project/search/index.vue
2025-11-24 16:45:52 +08:00

977 lines
21 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-search-page">
<view class="search-panel">
<view class="form-row">
<text class="form-label">客户名称</text>
<uv-input
v-model="form.customerName"
placeholder="请输入客户名称"
clearable
/>
</view>
<view class="form-row">
<text class="form-label">项目编号</text>
<uv-input
v-model="form.projectId"
placeholder="请输入项目编号"
clearable
/>
</view>
<view class="form-row">
<text class="form-label">项目名称</text>
<uv-input
v-model="form.projectName"
placeholder="请输入项目名称"
clearable
/>
</view>
<text class="form-tip">客户名称 / 项目编号 / 项目名称至少填写一项</text>
<view class="form-actions">
<uv-button size="small" plain @click="handleReset">重置</uv-button>
<uv-button
size="small"
type="primary"
:disabled="!canSearch"
@click="handleSearch"
>
搜索
</uv-button>
</view>
</view>
<view class="result-section">
<view v-if="!hasSearched" class="result-placeholder">
输入项目信息后点击搜索查看结果
</view>
<template v-else>
<view
class="project-card"
v-for="project in list"
:key="project.id"
@click="handleProjectClick(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" 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="isEmpty && !loading">
<text class="empty-text">暂无匹配项目</text>
</view>
<view class="loading-state" v-if="loading && list.length === 0">
<text class="loading-text">加载中...</text>
</view>
<view class="load-more" v-if="hasSearched && list.length > 0">
<text class="load-more-text">
{{ loading ? '加载中...' : (noMore ? '没有更多数据了' : '上拉加载更多') }}
</text>
</view>
</template>
</view>
<!-- 开始开发弹窗 -->
<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>
</view>
</template>
<script setup>
import { reactive, ref, computed, onMounted, onUnmounted } from 'vue';
import { onPullDownRefresh, onReachBottom } from '@dcloudio/uni-app';
import { getProjectList, deleteProject, startProjectDevelopment } from '@/api';
import { usePagination } from '@/composables';
import { getDictLabel } from '@/utils/dict';
const PAGE_SIZE = 20;
const BASE_QUERY = {
orderByColumn: 'createTime',
isAsc: 'descending',
};
const form = reactive({
customerName: '',
projectId: '',
projectName: ''
});
const hasSearched = ref(false);
const {
list,
loading,
noMore,
isEmpty,
getList,
loadMore,
updateParams,
reset,
queryParams
} = usePagination({
fetchData: getProjectList,
pageSize: PAGE_SIZE,
defaultParams: {
...BASE_QUERY
}
});
const toast = (message) => {
if (uni?.$uv?.toast) {
uni.$uv.toast(message);
} else {
uni.showToast({
title: message,
icon: 'none'
});
}
};
const sanitize = (value) => {
if (!value) return undefined;
const trimmed = value.trim();
return trimmed.length ? trimmed : undefined;
};
const canSearch = computed(() =>
Boolean(sanitize(form.customerName) || sanitize(form.projectId) || sanitize(form.projectName))
);
const handleSearch = () => {
if (!canSearch.value) {
toast('请输入客户名称/项目编号/项目名称任意一项');
return;
}
hasSearched.value = true;
updateParams({
...BASE_QUERY,
customerName: sanitize(form.customerName),
projectId: sanitize(form.projectId),
projectName: sanitize(form.projectName)
});
};
const handleReset = () => {
form.customerName = '';
form.projectId = '';
form.projectName = '';
hasSearched.value = false;
reset();
queryParams.value = {
pageNum: 1,
pageSize: PAGE_SIZE,
...BASE_QUERY
};
};
// 格式化日期
const formatDate = (dateStr) => {
if (!dateStr) return '未知';
if (typeof dateStr === 'number') {
return formatDateValue(dateStr);
}
return dateStr.split(' ')[0];
};
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);
};
// 获取状态文本
const getStatusText = (status) => {
if (!status && status !== 0) return '未知';
const dictLabel = getDictLabel('project_status', status);
if (dictLabel && dictLabel !== String(status)) {
return dictLabel;
}
const statusMap = {
'DEVELOPMENT_COST': '前期费用',
'MIDDLE_COST': '中期费用',
'AFTER_COST': '后期费用',
'WAIT_START': '待开始',
'IN_PROGRESS': '开发中',
'COMPLETED': '开发完成',
'ACCEPTED': '已验收',
'MAINTENANCE': '维护中',
'MAINTENANCE_OVERDUE': '维护到期',
'DEVELOPMENT_OVERDUE': '开发超期',
'1': '待开始',
'2': '开发中',
'4': '开发完成',
'5': '已验收',
'6': '维护中',
'7': '维护到期',
'8': '开发超期'
};
return statusMap[String(status)] || `状态${status}`;
};
// 获取紧急状态标签
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) {
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}`;
}
};
// 获取显示成员最多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 showStartModal = ref(false);
const startingProject = ref(false);
const startProjectForm = reactive({
id: '',
expectedCompleteDate: ''
});
const currentStartProjectName = ref('');
const startDatePickerRef = ref(null);
const startDatePickerValue = ref(Date.now());
// 处理卡片菜单
const handleCardMenu = (project) => {
uni.showActionSheet({
itemList: ['修改', '删除', '+ 新增任务', '√ 开始开发'],
success: (res) => {
if (res.tapIndex === 0) {
goToEditProject(project);
} else if (res.tapIndex === 1) {
handleDeleteProject(project);
} else if (res.tapIndex === 2) {
goToAddTask(project);
} else if (res.tapIndex === 3) {
handleStartDevelopment(project);
}
}
});
};
const handleStartDevelopment = (project) => {
if (!project?.id) {
toast('缺少项目ID');
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) {
toast('缺少项目ID');
return;
}
if (!startProjectForm.expectedCompleteDate) {
toast('请选择预计完成日期');
return;
}
try {
startingProject.value = true;
await startProjectDevelopment({
id: startProjectForm.id,
expectedCompleteDate: startProjectForm.expectedCompleteDate
});
toast('已开始开发');
closeStartModal();
await getList(true);
} catch (err) {
console.error('开始开发失败:', err);
toast(err?.message || '操作失败,请稍后重试');
} 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);
};
const handleDeleteProject = (project) => {
if (!project?.id) {
toast('缺少项目ID');
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);
toast('删除成功');
await getList(true);
} catch (err) {
console.error('删除项目失败:', err);
toast(err?.message || '删除失败,请稍后重试');
} finally {
uni.hideLoading();
}
}
});
};
const goToEditProject = (project) => {
if (!project?.id) {
toast('缺少项目ID');
return;
}
uni.navigateTo({
url: `/pages/project/form/index?mode=edit&id=${project.id}`
});
};
const goToAddTask = (project) => {
if (!project?.id) {
toast('缺少项目ID');
return;
}
const projectName = project.name || project.projectName || '';
const encodedName = encodeURIComponent(projectName);
uni.navigateTo({
url: `/pages/task/add/index?projectId=${project.id}&projectName=${encodedName}`
});
};
const goToProjectDetail = (project) => {
if (!project?.id) {
toast('缺少项目ID');
return;
}
uni.navigateTo({
url: `/pages/project/form/index?mode=view&id=${project.id}`
});
};
const handleProjectClick = (project) => {
goToProjectDetail(project);
};
onPullDownRefresh(async () => {
if (!hasSearched.value) {
uni.stopPullDownRefresh();
return;
}
try {
await getList(true);
} finally {
uni.stopPullDownRefresh();
}
});
onReachBottom(() => {
if (hasSearched.value && !loading.value && !noMore.value) {
loadMore();
}
});
const handleProjectListRefresh = () => {
if (hasSearched.value) {
getList(true);
}
};
onMounted(() => {
uni.$on('projectListRefresh', handleProjectListRefresh);
});
onUnmounted(() => {
uni.$off('projectListRefresh', handleProjectListRefresh);
});
</script>
<style lang="scss" scoped>
.project-search-page {
display: flex;
flex-direction: column;
min-height: 100vh;
background-color: #f5f7fa;
padding-bottom: 40px;
}
.search-panel {
background-color: #fff;
padding: 16px;
margin: 12px;
border-radius: 12px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
}
.form-row {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
}
.form-label {
width: 80px;
font-size: 14px;
color: #606266;
}
.form-tip {
font-size: 12px;
color: #909399;
margin-bottom: 12px;
}
.form-actions {
display: flex;
justify-content: flex-end;
gap: 12px;
}
.result-section {
flex: 1;
}
.result-placeholder {
margin: 80px auto;
text-align: center;
color: #909399;
font-size: 14px;
}
.project-card {
background: #fff;
border-radius: 8px;
padding: 16px;
margin: 0 12px 12px 12px;
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;
}
.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 {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
padding: 80px 20px;
}
.empty-text {
font-size: 14px;
color: #909399;
margin-top: 12px;
}
.loading-state {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
padding: 60px 20px;
}
.loading-text {
font-size: 14px;
color: #909399;
}
.load-more {
display: flex;
justify-content: center;
align-items: center;
padding: 20px;
margin-bottom: 20px;
}
.load-more-text {
font-size: 13px;
color: #909399;
}
.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;
}
</style>