OfficeSystem/pages/project/form/index.vue
2025-11-18 16:12:32 +08:00

1105 lines
26 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-form-page">
<view class="custom-navbar">
<view class="navbar-content">
<text class="nav-btn" @click="handleBack">取消</text>
<text class="nav-title">{{ pageTitle }}</text>
<text
class="nav-btn"
:class="{ disabled: !canSubmit || submitting }"
@click="handleSubmit"
>
{{ mode === 'edit' ? '保存' : '创建' }}
</text>
</view>
</view>
<view class="loading-box" v-if="pageLoading">
<text class="loading-text">加载中...</text>
</view>
<view v-else>
<view class="form-container">
<view class="form-section">
<view class="section-title">基本信息</view>
<view class="form-item">
<text class="form-label required">项目名称</text>
<input
class="form-input"
v-model.trim="formData.name"
placeholder="请输入项目名称"
placeholder-style="color:#999;"
/>
</view>
<view class="form-item">
<text class="form-label">项目编号</text>
<input
class="form-input"
v-model.trim="formData.no"
placeholder="可填写项目编号"
placeholder-style="color:#999;"
/>
</view>
<view class="form-row">
<view class="form-item half">
<text class="form-label ">项目金额</text>
<input
class="form-input"
v-model="formData.amount"
type="digit"
placeholder="请输入金额"
placeholder-style="color:#999;"
/>
</view>
<view class="form-item half">
<text class="form-label">到账时间</text>
<view class="picker-input" @click="openDatePicker('expireTime')">
<text>{{ formData.expireTime || '请选择日期' }}</text>
<text class="picker-arrow"></text>
</view>
</view>
</view>
<view class="form-item">
<text class="form-label ">客户</text>
<view class="picker-input" @click="openCustomerPicker">
<text>{{ formData.customerName || '请选择客户' }}</text>
<text class="picker-arrow"></text>
</view>
</view>
<view class="form-item">
<text class="form-label">标签</text>
<input
class="form-input"
v-model.trim="formData.projectTags"
placeholder="请输入标签,多个以逗号分隔"
placeholder-style="color:#999;"
/>
</view>
<view class="form-item">
<text class="form-label">备注</text>
<textarea
class="form-textarea"
v-model="formData.remark"
placeholder="请输入备注"
placeholder-style="color:#999;"
/>
</view>
</view>
<view class="form-section">
<view class="section-title">费用规划</view>
<view
class="cost-block"
v-for="item in costFields"
:key="item.amountKey"
>
<view class="cost-row">
<text class="cost-title">{{ item.label }}</text>
<view class="cost-inputs">
<view class="cost-item">
<text class="cost-label">金额(元)</text>
<input
class="form-input"
type="digit"
v-model="formData[item.amountKey]"
placeholder="请输入金额"
placeholder-style="color:#999;"
/>
</view>
<view class="cost-item">
<text class="cost-label">收款时间</text>
<view
class="picker-input"
@click="openDatePicker(item.dateKey)"
>
<text>{{ formData[item.dateKey] || '请选择日期' }}</text>
<text class="picker-arrow"></text>
</view>
</view>
</view>
</view>
</view>
</view>
<view class="form-section">
<view class="section-title">附件</view>
<AttachmentFileUploader
v-model="formData.attaches"
:max-count="10"
title="上传附件"
/>
<text class="section-tip">
支持 png/jpg/jpeg/doc/docx/xls/xlsx/ppt/pdf/zip/rar 等格式,单个不超过 200MB
</text>
</view>
<view class="form-section">
<view class="section-title member-header">
<text>项目成员</text>
<uv-button size="mini" type="primary" @click="openMemberModal">
选择成员
</uv-button>
</view>
<view class="member-table">
<view class="member-row header">
<text class="col index">序号</text>
<text class="col name">成员</text>
<text class="col role">角色</text>
<text class="col action">操作</text>
</view>
<scroll-view class="member-scroll" scroll-y>
<view
class="member-row"
v-for="(member, index) in formData.memberList"
:key="member.userId || index"
>
<text class="col index">{{ index + 1 }}</text>
<view class="col name member-name">
<text>{{ member.userName || '未命名' }}</text>
</view>
<view class="col role role-options">
<label
class="role-option"
:class="{ active: member.role === 'OWNER' }"
@click="updateMemberRole(member.userId, 'OWNER')"
>
<text class="radio"></text>
<text>负责人</text>
</label>
<label
class="role-option"
:class="{ active: member.role === 'FOLLOWER' }"
@click="updateMemberRole(member.userId, 'FOLLOWER')"
>
<text class="radio"></text>
<text>跟进人</text>
</label>
<label
class="role-option"
:class="{ active: member.role === 'NORMAL' }"
@click="updateMemberRole(member.userId, 'NORMAL')"
>
<text class="radio"></text>
<text>普通成员</text>
</label>
</view>
<text class="col action action-btn" @click="removeMember(member.userId)">
删除
</text>
</view>
<view class="empty-tip" v-if="!formData.memberList.length">
请点击「选择成员」添加项目成员
</view>
</scroll-view>
</view>
</view>
</view>
</view>
<view class="bottom-bar">
<uv-button
type="primary"
:disabled="!canSubmit || submitting"
:loading="submitting"
@click="handleSubmit"
>
{{ mode === 'edit' ? '保存修改' : '创建项目' }}
</uv-button>
</view>
<uv-picker
ref="customerPickerRef"
:columns="customerColumns"
keyName="label"
@confirm="handleCustomerConfirm"
></uv-picker>
<uv-datetime-picker
ref="datePickerRef"
v-model="datePickerValue"
:mode="datePickerMode"
@confirm="onDateConfirm"
></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.trim="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-text">{{ 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 AttachmentFileUploader from '@/components/task/AttachmentFileUploader.vue';
import {
getCustomerList,
getUserList,
getProjectDetail,
createProject,
updateProject
} from '@/api';
const costFields = [
{ label: '前期费用', amountKey: 'developmentCost', dateKey: 'developmentTime' },
{ label: '中期费用', amountKey: 'middleCost', dateKey: 'middleTime' },
{ label: '后期费用', amountKey: 'afterCost', dateKey: 'afterTime' }
];
const mode = ref('add');
const pageTitle = computed(() => (mode.value === 'edit' ? '编辑项目' : '新建项目'));
const pageLoading = ref(true);
const submitting = ref(false);
const projectId = ref('');
const formData = ref(createDefaultForm());
const customerOptions = ref([]);
const customerPickerRef = ref(null);
const customerColumns = computed(() => [customerOptions.value]);
const memberOptions = ref([]);
const memberKeyword = ref('');
const showMemberModal = ref(false);
const selectedMemberIds = ref([]);
const datePickerRef = ref(null);
const datePickerValue = ref(Date.now());
const datePickerMode = ref('date');
const pendingDateField = ref('');
const canSubmit = computed(() => {
return (
!!formData.value.name
// &&
// !!formData.value.customerId &&
// !!formData.value.amount &&
// formData.value.memberList.length > 0 &&
// !submitting.value
);
});
const filteredMemberOptions = computed(() => {
if (!memberKeyword.value) return memberOptions.value;
const keyword = memberKeyword.value.toLowerCase();
return memberOptions.value.filter((item) => {
const name = (item.userName || '').toLowerCase();
const dept = (item.deptName || '').toLowerCase();
return name.includes(keyword) || dept.includes(keyword);
});
});
onLoad(async (options) => {
if (options?.mode) {
mode.value = options.mode === 'edit' ? 'edit' : 'add';
}
if (options?.id) {
projectId.value = options.id;
mode.value = 'edit';
}
try {
await Promise.all([loadCustomerOptions(), loadMemberOptions()]);
if (mode.value === 'edit' && projectId.value) {
await loadProjectDetail(projectId.value);
}
} finally {
pageLoading.value = false;
}
});
const handleBack = () => {
uni.navigateBack();
};
const openCustomerPicker = async () => {
if (!customerOptions.value.length) {
await loadCustomerOptions();
}
if (!customerOptions.value.length) {
uni.showToast({
title: '暂无可选客户',
icon: 'none'
});
return;
}
if (customerPickerRef.value?.open) {
customerPickerRef.value.open();
}
};
const handleCustomerConfirm = ({ value }) => {
const selected = value?.[0];
if (selected) {
formData.value.customerId = String(selected.value);
formData.value.customerName = selected.label;
}
};
const openDatePicker = (field) => {
pendingDateField.value = field;
datePickerMode.value = 'date';
datePickerValue.value = formData.value[field]
? new Date(formData.value[field].replace(/-/g, '/')).getTime()
: Date.now();
if (datePickerRef.value?.open) {
datePickerRef.value.open();
}
};
const onDateConfirm = (event) => {
if (!pendingDateField.value || !event?.value) return;
formData.value[pendingDateField.value] = formatDate(event.value);
};
const openMemberModal = () => {
selectedMemberIds.value = formData.value.memberList
.map((member) => member.userId)
.filter((id) => id);
memberKeyword.value = '';
showMemberModal.value = true;
};
const closeMemberModal = () => {
showMemberModal.value = false;
};
const toggleMember = (userId) => {
const id = String(userId);
const index = selectedMemberIds.value.indexOf(id);
if (index >= 0) {
selectedMemberIds.value.splice(index, 1);
} else {
selectedMemberIds.value.push(id);
}
};
const confirmMemberSelection = () => {
const existingMap = new Map(
formData.value.memberList.map((member) => [String(member.userId), member])
);
const selected = memberOptions.value.filter((user) =>
selectedMemberIds.value.includes(user.userId)
);
formData.value.memberList = selected.map((user) => {
const persisted = existingMap.get(user.userId);
return {
userId: user.userId,
userName: user.userName,
userAvatar: user.avatar || user.userAvatar || '',
role: persisted?.role || 'NORMAL'
};
});
showMemberModal.value = false;
};
const removeMember = (userId) => {
formData.value.memberList = formData.value.memberList.filter(
(member) => member.userId !== userId
);
};
const updateMemberRole = (userId, role) => {
formData.value.memberList = formData.value.memberList.map((member) => {
if (member.userId === userId) {
return { ...member, role };
}
return member;
});
};
const handleSubmit = async () => {
if (!canSubmit.value || submitting.value) {
if (!canSubmit.value) {
uni.showToast({
title: '请完善必填信息',
icon: 'none'
});
}
return;
}
submitting.value = true;
uni.showLoading({
title: mode.value === 'edit' ? '保存中...' : '创建中...'
});
const payload = buildPayload();
try {
if (mode.value === 'edit') {
payload.id = projectId.value || payload.id;
await updateProject(payload);
} else {
await createProject(payload);
}
uni.showToast({
title: mode.value === 'edit' ? '保存成功' : '创建成功',
icon: 'success'
});
uni.$emit('projectListRefresh');
setTimeout(() => {
uni.navigateBack();
}, 600);
} catch (error) {
console.error('保存项目失败:', error);
} finally {
submitting.value = false;
uni.hideLoading();
}
};
const loadCustomerOptions = async () => {
try {
const res = await getCustomerList({ pageNum: 1, pageSize: 200 });
customerOptions.value = (res.rows || []).map((item) => ({
label: item.name,
value: String(item.id),
raw: item
}));
} catch (error) {
console.error('加载客户列表失败:', error);
uni.showToast({
title: '加载客户失败',
icon: 'none'
});
}
};
const loadMemberOptions = async () => {
try {
const res = await getUserList({ pageSize: 200 });
memberOptions.value = (res.rows || res.data || res.list || []).map((item) => ({
userId: String(item.userId || item.id),
userName: item.nickName || '',
deptName: item.deptName || item.dept?.deptName || '',
avatar: item.avatar || item.userAvatar || ''
}));
} catch (error) {
console.error('加载成员列表失败:', error);
uni.showToast({
title: '加载成员失败',
icon: 'none'
});
}
};
const loadProjectDetail = async (id) => {
try {
const detail = await getProjectDetail(id);
if (!detail) return;
const merged = {
...createDefaultForm(),
...detail
};
formData.value = {
...formData.value,
id: merged.id || id,
no: merged.no || '',
name: merged.name || merged.projectName || '',
amount: merged.amount != null ? String(merged.amount) : '',
customerId: merged.customerId ? String(merged.customerId) : merged.customerId,
customerName: merged.customerName || '',
expireTime: merged.expireTime,
projectTags: merged.projectTags || '',
remark: merged.remark || '',
developmentCost: safeNumberToString(merged.developmentCost),
middleCost: safeNumberToString(merged.middleCost),
afterCost: safeNumberToString(merged.afterCost),
developmentTime: normalizeDateString(merged.developmentTime),
middleTime: normalizeDateString(merged.middleTime),
afterTime: normalizeDateString(merged.afterTime),
attaches: normalizeAttaches(merged.attaches),
memberList: normalizeMembers(merged.memberList)
};
} catch (error) {
console.error('获取项目详情失败:', error);
uni.showToast({
title: '加载项目失败',
icon: 'none'
});
}
};
function createDefaultForm() {
return {
id: null,
no: '',
name: '',
amount: '',
customerId: '',
customerName: '',
expireTime: '',
projectTags: '',
remark: '',
developmentCost: '',
middleCost: '',
afterCost: '',
developmentTime: '',
middleTime: '',
afterTime: '',
attaches: [],
memberList: []
};
}
const buildPayload = () => {
const payload = {
id: formData.value.id,
no: formData.value.no || null,
name: formData.value.name,
amount: parseNumber(formData.value.amount),
customerId: formData.value.customerId,
customerName: formData.value.customerName,
expireTime: formData.value.expireTime,
projectTags: formData.value.projectTags || '',
remark: formData.value.remark || '',
developmentCost: parseNumber(formData.value.developmentCost),
developmentTime: formatDateTimeForPayload(formData.value.developmentTime),
middleCost: parseNumber(formData.value.middleCost),
middleTime: formatDateTimeForPayload(formData.value.middleTime),
afterCost: parseNumber(formData.value.afterCost),
afterTime: formatDateTimeForPayload(formData.value.afterTime),
attaches: formData.value.attaches.map((file, index) => ({
name: file.name || getFileNameFromUrl(file.path),
url: file.path,
uid: file.uid || `${Date.now()}_${index}`,
status: 'success'
})),
memberList: formData.value.memberList.map((member) => ({
userId: member.userId,
userName: member.userName,
userAvatar: member.userAvatar || '',
role: member.role || 'NORMAL'
}))
};
return payload;
};
const parseNumber = (value) => {
if (value === null || value === undefined || value === '') return null;
const num = Number(value);
return Number.isNaN(num) ? null : num;
};
const safeNumberToString = (value) => {
if (value === null || value === undefined || value === '') return '';
return String(value);
};
const normalizeDateString = (value) => {
if (!value) return '';
return value.replace('T', ' ').split(' ')[0];
};
const formatDateTimeForPayload = (value) => {
if (!value) return null;
const cleaned = value.replace('T', ' ').trim();
if (cleaned.includes(':')) {
return cleaned;
}
return `${cleaned} 00:00:00`;
};
const normalizeAttaches = (value) => {
if (!value) return [];
if (Array.isArray(value)) {
return value
.map((item, index) => ({
name: item.name || getFileNameFromUrl(item.url || item.path),
path: item.url || item.path,
size: item.size || 0,
uid: item.uid || `${Date.now()}_${index}`
}))
.filter((item) => item.path);
}
if (typeof value === 'string') {
return value
.split(',')
.map((url, index) => url.trim())
.filter((url) => url)
.map((url, index) => ({
name: getFileNameFromUrl(url),
path: url,
size: 0,
uid: `${Date.now()}_${index}`
}));
}
return [];
};
const normalizeMembers = (value) => {
if (!Array.isArray(value)) return [];
return value
.map((member) => ({
userId: String(member.userId || member.id || ''),
userName: member.userName || member.name || '',
userAvatar: member.userAvatar || member.avatar || '',
role: member.role || 'NORMAL'
}))
.filter((member) => member.userId);
};
const getFileNameFromUrl = (url = '') => {
if (!url) return '附件';
const parts = url.split('/');
return parts[parts.length - 1] || '附件';
};
const formatDate = (timestamp) => {
const date = new Date(timestamp);
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, '0');
const d = String(date.getDate()).padStart(2, '0');
return `${y}-${m}-${d}`;
};
</script>
<style scoped lang="scss">
.project-form-page {
min-height: 100vh;
background: #f5f5f5;
display: flex;
flex-direction: column;
}
.custom-navbar {
padding: 20px 20px;
display: flex;
align-items: flex-end;
background: #fff;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
}
.navbar-content {
margin-top: 15px;
width: 100%;
display: flex;
justify-content: space-between;
align-items: center;
}
.nav-btn {
font-size: 15px;
color: #1677ff;
min-width: 48px;
text-align: center;
&.disabled {
color: #ccc;
}
}
.nav-title {
font-size: 16px;
font-weight: 600;
color: #111;
}
.loading-box {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
}
.loading-text {
color: #666;
}
.form-scroll {
flex: 1;
}
.form-container {
padding: 16px;
display: flex;
flex-direction: column;
gap: 16px;
}
.form-section {
background: #fff;
border-radius: 12px;
padding: 16px;
}
.section-title {
font-size: 15px;
font-weight: 600;
margin-bottom: 12px;
color: #111;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.form-item {
display: flex;
flex-direction: column;
margin-bottom: 12px;
gap: 8px;
&:last-child {
margin-bottom: 0;
}
}
.form-row {
display: flex;
gap: 12px;
}
.form-item.half {
flex: 1;
}
.form-label {
font-size: 13px;
color: #666;
}
.form-label.required::after {
content: '*';
color: #ff4d4f;
margin-left: 4px;
}
.form-input,
.form-textarea,
.picker-input {
width: 100%;
background: #f7f8fa;
border-radius: 8px;
padding: 0 12px;
min-height: 40px;
display: flex;
align-items: center;
font-size: 14px;
color: #111;
box-sizing: border-box;
}
.form-textarea {
min-height: 90px;
padding-top: 10px;
padding-bottom: 10px;
}
.picker-input {
justify-content: space-between;
}
.picker-arrow {
color: #999;
font-size: 18px;
}
.cost-block {
border-radius: 12px;
background: #f9fafc;
padding: 12px;
margin-bottom: 12px;
&:last-child {
margin-bottom: 0;
}
}
.cost-row {
display: flex;
flex-direction: column;
gap: 12px;
}
.cost-title {
font-size: 14px;
font-weight: 600;
color: #333;
}
.cost-inputs {
display: flex;
gap: 12px;
}
.cost-item {
flex: 1;
display: flex;
flex-direction: column;
gap: 8px;
}
.cost-label {
font-size: 12px;
color: #666;
}
.section-tip {
display: block;
margin-top: 8px;
font-size: 12px;
color: #999;
}
.member-table {
border-radius: 12px;
border: 1px solid #f0f0f0;
overflow: hidden;
display: flex;
flex-direction: column;
}
.member-scroll {
max-height: 400rpx;
min-height: 80rpx;
overflow-y: auto;
}
.member-row {
display: flex;
align-items: center;
padding: 12px;
background: #fff;
&.header {
background: #f5f6f7;
font-weight: 600;
color: #555;
}
&:not(.header) + .member-row {
border-top: 1px solid #f0f0f0;
}
}
.member-name {
font-weight: 500;
color: #333;
}
.col {
font-size: 13px;
color: #333;
}
.col.index {
width: 50px;
}
.col.name {
flex: 1;
}
.col.role {
width: 140px;
}
.col.action {
width: 60px;
text-align: right;
}
.action-btn {
color: #ff4d4f;
}
.empty-tip {
padding: 16px;
text-align: center;
color: #999;
font-size: 13px;
}
.role-options {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
.role-option {
display: flex;
align-items: center;
gap: 6px;
color: #666;
font-size: 12px;
.radio {
width: 14px;
height: 14px;
border-radius: 50%;
border: 1px solid #bbb;
}
&.active {
color: #1677ff;
.radio {
border-color: #1677ff;
background: #1677ff;
}
}
}
.bottom-bar {
padding: 12px 16px;
background: #fff;
box-shadow: 0 -4px 12px rgba(0, 0, 0, 0.03);
}
.member-modal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 999;
}
.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;
padding: 16px;
display: flex;
flex-direction: column;
max-height: 90%;
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.modal-title {
font-size: 16px;
font-weight: 600;
}
.modal-close {
font-size: 20px;
color: #999;
}
.search-box {
margin-bottom: 12px;
}
.search-input {
width: 100%;
background: #f5f6f7;
border-radius: 10px;
padding: 10px 14px;
font-size: 14px;
}
.member-list {
height: 600px;
margin: 12px 0;
}
.member-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 0;
border-bottom: 1px solid #f0f0f0;
}
.member-info {
display: flex;
flex-direction: column;
gap: 4px;
}
.member-name-text {
font-size: 14px;
color: #333;
}
.member-dept {
font-size: 12px;
color: #999;
}
.select-indicator {
width: 18px;
height: 18px;
border-radius: 50%;
border: 1px solid #ccc;
&.active {
border-color: #1677ff;
background: #1677ff;
}
}
.modal-actions {
display: flex;
justify-content: space-between;
gap: 12px;
margin-top: 12px;
}
</style>