OfficeSystem/pages/task/add/index.vue
2025-11-18 17:56:30 +08:00

993 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="task-add-page">
<view class="content-scroll" >
<view class="form-card">
<view class="section-title">任务信息</view>
<view class="form-item" @click="openProjectPicker">
<text class="form-label required">项目</text>
<view class="form-value">
<text v-if="formData.projectName" class="value-text">{{ formData.projectName }}</text>
<text v-else class="placeholder">请选择项目</text>
</view>
<text class="arrow"></text>
</view>
<view class="form-item">
<text class="form-label required">任务类型</text>
<view class="pill-group">
<view
class="pill-item"
v-for="type in typeOptions"
:key="type.value"
:class="{ active: formData.type === type.value }"
@click="selectType(type.value)"
>
{{ type.label }}
</view>
<text v-if="!typeOptions.length" class="placeholder">暂无可用任务类型</text>
</view>
</view>
<view class="form-item">
<text class="form-label required">优先级</text>
<view class="pill-group">
<view
class="pill-item priority"
v-for="level in levelOptions"
:key="level.value"
:class="{ active: formData.level === level.value }"
@click="selectLevel(level.value)"
>
{{ level.label }}
</view>
<text v-if="!levelOptions.length" class="placeholder">暂无可用优先级</text>
</view>
</view>
<view class="form-item">
<text class="form-label required">截止时间</text>
<view class="form-value" @click="openExpireTimePicker">
<text v-if="formData.expireTime" class="value-text">{{ formData.expireTime }}</text>
<text v-else class="placeholder">请选择截止时间</text>
</view>
<text class="arrow"></text>
</view>
<view class="form-item align-start">
<text class="form-label required">任务内容</text>
<textarea
v-model="formData.description"
class="textarea-input"
placeholder="请填写任务内容、输出要求或其他说明最多500字"
placeholder-style="color:#999;"
:maxlength="500"
/>
</view>
</view>
<view class="form-card">
<view class="section-title">附件</view>
<view class="attachment-tip">请上传不超过200MB的文件支持常见图片、Office、PDF、压缩包等格式。</view>
<AttachmentImageUploader v-model="formData.images" />
<AttachmentFileUploader v-model="formData.files" />
</view>
<view class="form-card">
<view class="section-title">负责人</view>
<view class="form-item">
<text class="form-label required">负责人</text>
<view class="responsible-content">
<view class="selected-members" v-if="formData.members.length">
<view
class="member-chip"
v-for="member in formData.members"
:key="member.userId"
>
<text>{{ member.userName }}</text>
<text class="chip-remove" @click.stop="removeMember(member.userId)">✕</text>
</view>
</view>
<view class="placeholder" v-else>请选择负责人,可多选</view>
</view>
<view class="picker-trigger" @click="openMemberModal">
<text>{{ formData.members.length ? '调整' : '选择' }}</text>
<text class="arrow"></text>
</view>
</view>
</view>
</view>
<view class="submit-bar">
<uv-button
type="primary"
:disabled="!canSubmit || submitting"
:loading="submitting"
loadingText="提交中..."
@click="handleSubmit"
>
创建任务
</uv-button>
</view>
<uv-picker
ref="projectPicker"
:columns="projectColumns"
keyName="label"
@confirm="handleProjectConfirm"
></uv-picker>
<uv-datetime-picker
ref="expirePickerRef"
v-model="expirePickerValue"
mode="datetime"
@confirm="onExpireTimeConfirm"
></uv-datetime-picker>
<view class="member-modal" v-if="showMemberModal">
<view class="modal-mask" @click="closeMemberModal"></view>
<view class="modal-panel">
<view class="modal-header">
<text class="modal-title">选择负责人</text>
<text class="modal-close" @click="closeMemberModal">✕</text>
</view>
<view class="search-box">
<input
v-model="memberKeyword"
class="search-input"
placeholder="搜索姓名或部门"
placeholder-style="color:#999;"
/>
</view>
<scroll-view class="member-list" scroll-y>
<view
class="member-item"
v-for="user in filteredMemberOptions"
:key="user.userId"
@click="toggleMember(user.userId)"
>
<view class="member-info">
<text class="member-name">{{ user.userName }}</text>
<text class="member-dept" v-if="user.deptName">{{ user.deptName }}</text>
</view>
<view class="select-indicator" :class="{ active: selectedMemberIds.includes(user.userId) }"></view>
</view>
<view class="empty-tip" v-if="!filteredMemberOptions.length">
<text>未找到匹配的人员</text>
</view>
</scroll-view>
<view class="modal-actions">
<uv-button @click="closeMemberModal">取消</uv-button>
<uv-button type="primary" @click="confirmMemberSelection">确定</uv-button>
</view>
</view>
</view>
</view>
</template>
<script setup>
import { ref, computed } from 'vue';
import { onLoad } from '@dcloudio/uni-app';
import { createTask, getProjectListAll, getUserList } from '@/api';
import { useDictStore } from '@/store/dict';
import AttachmentImageUploader from '@/components/task/AttachmentImageUploader.vue';
import AttachmentFileUploader from '@/components/task/AttachmentFileUploader.vue';
const dictStore = useDictStore();
const formData = ref({
projectId: '',
projectName: '',
type: '',
level: '',
description: '',
expireTime: '',
attachments: [],
members: [],
images: [],
files: []
});
const presetProjectId = ref('');
const submitting = ref(false);
const loading = ref(false);
const projectColumns = ref([[]]);
const projectOptions = ref([]);
const showMemberModal = ref(false);
const memberOptions = ref([]);
const memberKeyword = ref('');
const selectedMemberIds = ref([]);
const projectPicker=ref(null);
const expirePickerRef = ref(null);
const expirePickerValue = ref(Date.now());
const typeOptions = computed(() => {
return dictStore.getDictByType('task_type').map(item => ({
label: item.dictLabel,
value: item.dictValue
}));
});
const levelOptions = computed(() => {
return dictStore.getDictByType('task_level').map(item => ({
label: item.dictLabel,
value: item.dictValue
}));
});
const filteredMemberOptions = computed(() => {
if (!memberKeyword.value.trim()) {
return memberOptions.value;
}
const keyword = memberKeyword.value.trim().toLowerCase();
return memberOptions.value.filter(user => {
const name = (user.userName || '').toLowerCase();
const dept = (user.deptName || '').toLowerCase();
return name.includes(keyword) || dept.includes(keyword);
});
});
const canSubmit = computed(() => {
return Boolean(
formData.value.projectId &&
formData.value.type &&
formData.value.level &&
formData.value.expireTime &&
formData.value.description.trim() &&
formData.value.members.length
);
});
const formatDateTime = (value) => {
if (!value && value !== 0) return '';
const date = typeof value === 'number' ? new Date(value) : 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');
const hour = String(date.getHours()).padStart(2, '0');
const minute = String(date.getMinutes()).padStart(2, '0');
const second = String(date.getSeconds()).padStart(2, '0');
return `${year}-${month}-${day} ${hour}:${minute}:${second}`;
};
const loadProjects = async () => {
try {
const res = await getProjectListAll();
console.log('xiangmu',res);
const list = res;
projectOptions.value = list.map(item => ({
id: item.id,
name: item.name
}));
projectColumns.value = [projectOptions.value.map(item => ({
label: item.name,
value: item.id
}))];
if (presetProjectId.value) {
const matched = projectOptions.value.find(item => String(item.id) === String(presetProjectId.value));
if (matched) {
formData.value.projectId = String(matched.id);
formData.value.projectName = matched.name;
}
}
} catch (error) {
console.error('加载项目列表失败:', error);
uni.showToast({
title: '加载项目失败',
icon: 'none'
});
}
};
const loadMembers = async (projectId) => {
if (!projectId) {
memberOptions.value = [];
return;
}
try {
const res = await getUserList({
pageNum: 1,
pageSize: 200,
status: 0,
delFlag: 0,
projectId
});
const rows = Array.isArray(res?.rows) ? res.rows : Array.isArray(res?.data) ? res.data : [];
memberOptions.value = rows.map(item => ({
userId: String(item.userId || item.id),
userName: item.nickName || item.userName || '',
deptName: item.dept?.deptName || item.deptName || ''
}));
} catch (error) {
console.error('加载用户列表失败:', error);
uni.showToast({
title: '加载负责人失败',
icon: 'none'
});
}
};
const refreshMembersForProject = async () => {
formData.value.members = [];
selectedMemberIds.value = [];
await loadMembers(formData.value.projectId);
};
const openProjectPicker = () => {
console.log('openProjectPicker');
if (!projectOptions.value.length) {
uni.showToast({
title: '暂无项目可选',
icon: 'none'
});
return;
}
if (projectPicker.value?.open) {
projectPicker.value.open();
}
};
const handleProjectConfirm = async ({ value }) => {
if (value && value.length) {
const selected = value[0];
formData.value.projectId = String(selected.value);
formData.value.projectName = selected.label;
await refreshMembersForProject();
}
};
const selectType = (typeValue) => {
formData.value.type = typeValue;
};
const selectLevel = (levelValue) => {
formData.value.level = levelValue;
};
const openExpireTimePicker = () => {
if (formData.value.expireTime) {
expirePickerValue.value = new Date(formData.value.expireTime.replace(/-/g, '/')).getTime();
} else {
expirePickerValue.value = Date.now();
}
if (expirePickerRef.value?.open) {
expirePickerRef.value.open();
}
};
const onExpireTimeConfirm = (event) => {
if (event?.value) {
formData.value.expireTime = formatDateTime(event.value);
}
};
const openMemberModal = () => {
if (!formData.value.projectId) {
uni.showToast({
title: '请先选择项目',
icon: 'none'
});
return;
}
selectedMemberIds.value = formData.value.members.map(member => member.userId);
memberKeyword.value = '';
showMemberModal.value = true;
};
const closeMemberModal = () => {
showMemberModal.value = false;
};
const toggleMember = (userId) => {
const index = selectedMemberIds.value.indexOf(userId);
if (index >= 0) {
selectedMemberIds.value.splice(index, 1);
} else {
selectedMemberIds.value.push(userId);
}
};
const confirmMemberSelection = () => {
const selected = memberOptions.value.filter(user => selectedMemberIds.value.includes(user.userId));
formData.value.members = selected.map(user => ({
userId: user.userId,
userName: user.userName
}));
showMemberModal.value = false;
};
const removeMember = (userId) => {
formData.value.members = formData.value.members.filter(member => member.userId !== userId);
};
const handleSubmit = async () => {
if (submitting.value) {
return;
}
if (!canSubmit.value) {
uni.showToast({
title: '请完善必填信息',
icon: 'none'
});
return;
}
// 合并所有附件URL图片和文件
// 图片和文件都已经上传到七牛云直接使用URL
const allAttaches = [
...formData.value.images, // 图片已经是七牛云URL
...formData.value.files.map(file => file.path) // 文件已经是七牛云URL
].filter(url => url && url.trim() !== ''); // 过滤空值
// 将附件数组转换为逗号分隔的字符串
const submitAttaches = allAttaches.join(',');
const payload = {
id: null,
projectId: formData.value.projectId,
type: formData.value.type,
level: formData.value.level,
picture: submitAttaches,
description: formData.value.description.trim(),
expireTime: formData.value.expireTime,
memberList: formData.value.members.map(member => ({
userId: member.userId,
userName: member.userName
}))
};
submitting.value = true;
uni.showLoading({
title: '提交中...'
});
try {
const res = await createTask(payload);
console.log('@@@@@@@@@@',res);
uni.hideLoading();
uni.showToast({
title: '创建成功',
icon: 'success'
});
uni.$emit('taskListRefresh');
setTimeout(() => {
uni.navigateBack();
}, 1200);
} catch (error) {
uni.hideLoading();
console.error('创建任务失败:', error);
// 错误提示已在响应拦截器中统一处理,这里不需要重复显示
} finally {
submitting.value = false;
console.log('submitting', submitting.value);
}
};
onLoad(async (options) => {
if (options?.projectId) {
const projectId = String(options.projectId);
presetProjectId.value = projectId;
formData.value.projectId = projectId;
if (options?.projectName) {
try {
formData.value.projectName = decodeURIComponent(options.projectName);
} catch (err) {
formData.value.projectName = options.projectName;
}
}
}
loading.value = true;
try {
await dictStore.loadDictData();
await loadProjects();
if (formData.value.projectId) {
await loadMembers(formData.value.projectId);
} else {
memberOptions.value = [];
}
} finally {
loading.value = false;
}
});
</script>
<style scoped lang="scss">
.task-add-page {
background: #f5f5f5;
min-height: 100vh;
display: flex;
flex-direction: column;
padding-bottom: 120rpx;
}
.content-scroll {
flex: 1;
}
.form-card {
margin: 16px;
padding: 16px;
background: #fff;
border-radius: 12px;
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.03);
}
.section-title {
font-size: 16px;
font-weight: 600;
margin-bottom: 12px;
color: #333;
}
.form-item {
display: flex;
align-items: center;
padding: 12px 0;
border-bottom: 1px solid #f2f2f2;
gap: 12px;
}
.form-item:last-child {
border-bottom: none;
}
.align-start {
align-items: flex-start;
}
.form-label {
width: 80px;
font-size: 14px;
color: #333;
}
.form-label.required::before {
content: '*';
color: #f56c6c;
margin-right: 4px;
}
.form-value {
flex: 1;
display: flex;
align-items: center;
min-height: 24px;
}
.value-text {
color: #333;
font-size: 14px;
}
.placeholder {
color: #999;
font-size: 14px;
}
.arrow {
font-size: 18px;
color: #999;
}
.textarea-input {
flex: 1;
min-height: 120px;
padding: 12px;
border-radius: 8px;
background: #f8f9fb;
font-size: 14px;
color: #333;
line-height: 1.6;
}
.pill-group {
flex: 1;
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.pill-item {
padding: 6px 14px;
border-radius: 20px;
background: #f3f5f9;
color: #666;
font-size: 13px;
}
.pill-item.active {
background: #2885ff;
color: #fff;
}
.attachment-tip {
font-size: 12px;
color: #999;
margin-bottom: 12px;
}
.attachment-grid {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.attachment-item {
width: calc(33.33% - 8px);
min-width: 100px;
height: 110px;
background: #f6f7fb;
border-radius: 12px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 12px;
gap: 6px;
position: relative;
}
.attachment-item.add-item {
border: 1px dashed #d1d8e6;
background: #fff;
color: #2885ff;
}
.add-icon {
font-size: 28px;
}
.add-text {
font-size: 13px;
}
.file-icon {
font-size: 26px;
}
.file-name {
font-size: 12px;
color: #333;
text-align: center;
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.remove-btn {
position: absolute;
top: 6px;
right: 6px;
width: 20px;
height: 20px;
border-radius: 50%;
background: rgba(0, 0, 0, 0.4);
color: #fff;
font-size: 12px;
display: flex;
align-items: center;
justify-content: center;
}
.responsible-content {
flex: 1;
}
.selected-members {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.member-chip {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
background: #eef4ff;
border-radius: 16px;
font-size: 12px;
color: #2885ff;
}
.chip-remove {
font-size: 14px;
}
.picker-trigger {
display: flex;
align-items: center;
gap: 4px;
color: #2885ff;
font-size: 14px;
}
.submit-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
padding: 12px 16px;
background: #fff;
border-top: 1px solid #eee;
}
.member-modal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 99;
}
.modal-mask {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.4);
}
.modal-panel {
position: absolute;
left: 0;
right: 0;
bottom: 0;
background: #fff;
border-top-left-radius: 16px;
border-top-right-radius: 16px;
display: flex;
flex-direction: column;
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px;
border-bottom: 1px solid #f2f2f2;
}
.modal-title {
font-size: 16px;
font-weight: 600;
}
.modal-close {
font-size: 20px;
color: #999;
}
.search-box {
padding: 12px 16px;
border-bottom: 1px solid #f2f2f2;
}
.search-input {
background: #f5f6f8;
border-radius: 20px;
padding: 8px 14px;
font-size: 14px;
}
.member-list {
height: 600px;
}
.member-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 16px;
border-bottom: 1px solid #f5f5f5;
}
.member-info {
display: flex;
flex-direction: column;
gap: 4px;
}
.member-name {
font-size: 14px;
color: #333;
}
.member-dept {
font-size: 12px;
color: #999;
}
.select-indicator {
width: 20px;
height: 20px;
border-radius: 4px;
border: 1px solid #dcdfe6;
}
.select-indicator.active {
background: #2885ff;
border-color: #2885ff;
}
.modal-actions {
display: flex;
gap: 12px;
padding: 12px 16px 24px;
}
.empty-tip {
padding: 40px 0;
text-align: center;
color: #999;
font-size: 14px;
}
/* 表单项 */
.form-item {
display: flex;
align-items: center;
padding: 16px;
background-color: #fff;
border-radius: 8px;
margin-bottom: 12px;
gap: 12px;
}
.clickable-item {
cursor: pointer;
&:active {
background-color: #f5f5f5;
}
}
.form-icon {
font-size: 20px;
flex-shrink: 0;
}
.form-content {
flex: 1;
display: flex;
flex-direction: column;
}
.form-label {
font-size: 15px;
color: #333;
}
.form-value {
font-size: 15px;
color: #333;
font-weight: 500;
}
.form-placeholder {
font-size: 15px;
color: #999;
}
.arrow {
font-size: 20px;
color: #999;
flex-shrink: 0;
}
.description-input {
flex: 1;
min-height: 80px;
font-size: 15px;
color: #333;
line-height: 1.6;
}
/* 图片预览 */
.images-preview {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 12px;
padding: 0 16px;
}
.image-item {
position: relative;
width: 100px;
height: 100px;
border-radius: 8px;
overflow: hidden;
}
.preview-image {
width: 100%;
height: 100%;
}
.remove-btn {
position: relative;
width: 24px;
height: 24px;
background-color: rgba(0, 0, 0, 0.6);
color: #fff;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
cursor: pointer;
}
/* 文件列表 */
.files-list {
padding: 0 16px;
margin-bottom: 12px;
}
.file-item {
display: flex;
align-items: center;
padding: 12px;
background-color: #fff;
border-radius: 8px;
margin-bottom: 8px;
gap: 12px;
cursor: pointer;
&:active {
background-color: #f5f5f5;
}
}
.file-icon {
font-size: 24px;
flex-shrink: 0;
}
.file-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.file-name {
text-align: left;
font-size: 14px;
color: #333;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 500;
}
.file-size {
font-size: 12px;
color: #999;
}
</style>