项目详细/添加/编辑三合一

This commit is contained in:
WindowBird 2025-11-24 15:44:02 +08:00
parent 1803c997e4
commit 1b651d43ef
5 changed files with 233 additions and 27 deletions

View File

@ -1,6 +1,6 @@
<template>
<view class="attachment-block">
<view class="form-item clickable-item" @click="handleChooseFiles">
<view class="form-item clickable-item" :class="{ disabled: isReadonly }" @click="handleChooseFiles">
<view class="form-icon">{{ icon }}</view>
<text class="form-label">{{ title }}</text>
<text class="arrow"></text>
@ -18,7 +18,7 @@
class="preview-image"
@click="previewImage(index)"
/>
<view class="remove-btn" @click.stop="removeFile(file)"></view>
<view class="remove-btn" v-if="!isReadonly" @click.stop="removeFile(file)"></view>
</view>
</view>
@ -37,7 +37,7 @@
<text class="file-size" v-if="file.size > 0">{{ formatFileSize(file.size) }}</text>
</view>
<view class="file-icon">{{ getFileIcon(file.name) }}</view>
<view class="remove-btn" @click.stop="removeFile(file)"></view>
<view class="remove-btn" v-if="!isReadonly" @click.stop="removeFile(file)"></view>
</view>
</view>
</view>
@ -67,6 +67,10 @@ const props = defineProps({
extensions: {
type: Array,
default: () => ['.pdf', '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx', '.txt', '.zip', '.rar', '.jpg', '.png']
},
readonly: {
type: Boolean,
default: false
}
});
@ -90,8 +94,12 @@ const isImageFile = (file) => {
const imageFiles = computed(() => files.value.filter((file) => isImageFile(file)));
const otherFiles = computed(() => files.value.filter((file) => !isImageFile(file)));
const isReadonly = computed(() => props.readonly);
const handleChooseFiles = async () => {
if (isReadonly.value) {
return;
}
const remainingCount = props.maxCount - files.value.length;
if (remainingCount <= 0) {
uni.showToast({
@ -283,6 +291,7 @@ const uploadFiles = async (fileList) => {
};
const removeFile = (file) => {
if (isReadonly.value) return;
const next = files.value.filter((item) => item !== file);
files.value = next;
};
@ -439,6 +448,14 @@ const previewFile = (file) => {
&:active {
background-color: #f5f5f5;
}
&.disabled {
cursor: default;
opacity: 0.6;
&:active {
background-color: inherit;
}
}
}
.form-icon {

View File

@ -159,7 +159,7 @@
{
"path": "pages/project/form/index",
"style": {
"navigationBarTitleText": "项目编辑"
"navigationBarTitleText": "项目操作"
}
},

View File

@ -19,6 +19,27 @@
</view>
<view v-else>
<view class="detail-header" v-if="isViewMode">
<view class="detail-title">{{ detailName }}</view>
<view class="detail-meta">
<text class="meta-label">状态</text>
<text class="meta-value">{{ detailStatusText }}</text>
<text class="meta-label">客户</text>
<text class="meta-value">{{ detailCustomerText }}</text>
</view>
<view class="detail-meta">
<text class="meta-label">金额</text>
<text class="meta-value">{{ detailAmountText }}</text>
<text class="meta-label">负责人</text>
<text class="meta-value">{{ detailOwnerName }}</text>
</view>
<view class="detail-meta">
<text class="meta-label">截止</text>
<text class="meta-value">{{ detailDeadlineText }}</text>
<text class="meta-label">创建</text>
<text class="meta-value">{{ detailCreatorText }}</text>
</view>
</view>
<view class="form-container">
<view class="form-section">
<view class="section-title">基本信息</view>
@ -28,6 +49,7 @@
<input
class="form-input"
v-model.trim="formData.name"
:disabled="isViewMode"
placeholder="请输入项目名称"
placeholder-style="color:#999;"
/>
@ -38,6 +60,7 @@
<input
class="form-input"
v-model.trim="formData.no"
:disabled="isViewMode"
placeholder="可填写项目编号"
placeholder-style="color:#999;"
/>
@ -50,13 +73,14 @@
class="form-input"
v-model="formData.amount"
type="digit"
:disabled="isViewMode"
placeholder="请输入金额"
placeholder-style="color:#999;"
/>
</view>
<view class="form-item half">
<text class="form-label">到账时间</text>
<view class="picker-input" @click="openDatePicker('expireTime')">
<view class="picker-input" :class="{ disabled: isViewMode }" @click="openDatePicker('expireTime')">
<text>{{ formData.expireTime || '请选择日期' }}</text>
<text class="picker-arrow"></text>
</view>
@ -65,7 +89,7 @@
<view class="form-item">
<text class="form-label ">客户</text>
<view class="picker-input" @click="openCustomerPicker">
<view class="picker-input" :class="{ disabled: isViewMode }" @click="openCustomerPicker">
<text>{{ formData.customerName || '请选择客户' }}</text>
<text class="picker-arrow"></text>
</view>
@ -76,6 +100,7 @@
<input
class="form-input"
v-model.trim="formData.projectTags"
:disabled="isViewMode"
placeholder="请输入标签,多个以逗号分隔"
placeholder-style="color:#999;"
/>
@ -86,6 +111,7 @@
<textarea
class="form-textarea"
v-model="formData.remark"
:disabled="isViewMode"
placeholder="请输入备注"
placeholder-style="color:#999;"
/>
@ -108,6 +134,7 @@
class="form-input"
type="digit"
v-model="formData[item.amountKey]"
:disabled="isViewMode"
placeholder="请输入金额"
placeholder-style="color:#999;"
/>
@ -116,6 +143,7 @@
<text class="cost-label">收款时间</text>
<view
class="picker-input"
:class="{ disabled: isViewMode }"
@click="openDatePicker(item.dateKey)"
>
<text>{{ formData[item.dateKey] || '请选择日期' }}</text>
@ -132,6 +160,7 @@
<AttachmentFileUploader
v-model="formData.attaches"
:max-count="10"
:readonly="isViewMode"
title="上传附件"
/>
<text class="section-tip">
@ -142,7 +171,12 @@
<view class="form-section">
<view class="section-title member-header">
<text>项目成员</text>
<uv-button size="mini" type="primary" @click="openMemberModal">
<uv-button
v-if="!isViewMode"
size="mini"
type="primary"
@click="openMemberModal"
>
选择成员
</uv-button>
</view>
@ -151,7 +185,7 @@
<text class="col index">序号</text>
<text class="col name">成员</text>
<text class="col role">角色</text>
<text class="col action">操作</text>
<text class="col action" v-if="!isViewMode">操作</text>
</view>
<scroll-view class="member-scroll" scroll-y>
<view
@ -166,7 +200,7 @@
<view class="col role role-options">
<label
class="role-option"
:class="{ active: member.role === 'OWNER' }"
:class="{ active: member.role === 'OWNER', disabled: isViewMode }"
@click="updateMemberRole(member.userId, 'OWNER')"
>
<text class="radio"></text>
@ -174,7 +208,7 @@
</label>
<label
class="role-option"
:class="{ active: member.role === 'FOLLOWER' }"
:class="{ active: member.role === 'FOLLOWER', disabled: isViewMode }"
@click="updateMemberRole(member.userId, 'FOLLOWER')"
>
<text class="radio"></text>
@ -182,14 +216,18 @@
</label>
<label
class="role-option"
:class="{ active: member.role === 'NORMAL' }"
:class="{ active: member.role === 'NORMAL', disabled: isViewMode }"
@click="updateMemberRole(member.userId, 'NORMAL')"
>
<text class="radio"></text>
<text>普通成员</text>
</label>
</view>
<text class="col action action-btn" @click="removeMember(member.userId)">
<text
v-if="!isViewMode"
class="col action action-btn"
@click="removeMember(member.userId)"
>
删除
</text>
</view>
@ -202,7 +240,7 @@
</view>
</view>
<view class="bottom-bar">
<view class="bottom-bar" v-if="!isViewMode">
<uv-button
type="primary"
:disabled="!canSubmit || submitting"
@ -227,7 +265,7 @@
@confirm="onDateConfirm"
></uv-datetime-picker>
<view class="member-modal" v-if="showMemberModal">
<view class="member-modal" v-if="showMemberModal && !isViewMode">
<view class="modal-mask" @click="closeMemberModal"></view>
<view class="modal-panel">
<view class="modal-header">
@ -272,7 +310,7 @@
</template>
<script setup>
import { ref, computed } from 'vue';
import { ref, computed, watch } from 'vue';
import { onLoad } from '@dcloudio/uni-app';
import AttachmentFileUploader from '@/components/task/AttachmentFileUploader.vue';
import {
@ -290,12 +328,35 @@ const costFields = [
];
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 detailInfo = ref(null);
const isViewMode = computed(() => mode.value === 'view');
const pageTitle = computed(() => {
if (isViewMode.value) {
return '项目详情';
}
return mode.value === 'edit' ? '编辑项目' : '新建项目';
});
const syncNavigationTitle = () => {
uni.setNavigationBarTitle({
title: pageTitle.value
});
};
watch(pageTitle, syncNavigationTitle, { immediate: true });
const detailName = computed(() => detailInfo.value?.name || detailInfo.value?.projectName || formData.value.name || '项目详情');
const detailStatusText = computed(() => getStatusText(detailInfo.value?.status || formData.value.status));
const detailCustomerText = computed(() => detailInfo.value?.customerName || formData.value.customerName || '—');
const detailCreatorText = computed(() => detailInfo.value?.createName || '—');
const detailDeadlineText = computed(() => formData.value.expireTime || detailInfo.value?.expireTime || '—');
const detailAmountText = computed(() => formatAmountDisplay(detailInfo.value?.amount ?? formData.value.amount));
const detailOwnerName = computed(() => {
const owner = formData.value.memberList.find((member) => member.role === 'OWNER');
return owner?.userName || detailInfo.value?.ownerName || '—';
});
const customerOptions = ref([]);
const customerPickerRef = ref(null);
@ -334,16 +395,24 @@ const filteredMemberOptions = computed(() => {
onLoad(async (options) => {
if (options?.mode) {
mode.value = options.mode === 'edit' ? 'edit' : 'add';
if (options.mode === 'edit') {
mode.value = 'edit';
} else if (options.mode === 'view') {
mode.value = 'view';
} else {
mode.value = 'add';
}
}
if (options?.id) {
projectId.value = options.id;
mode.value = 'edit';
if (mode.value !== 'view') {
mode.value = 'edit';
}
}
try {
await Promise.all([loadCustomerOptions(), loadMemberOptions()]);
if (mode.value === 'edit' && projectId.value) {
if ((mode.value === 'edit' || mode.value === 'view') && projectId.value) {
await loadProjectDetail(projectId.value);
}
} finally {
@ -356,6 +425,9 @@ const handleBack = () => {
};
const openCustomerPicker = async () => {
if (isViewMode.value) {
return;
}
if (!customerOptions.value.length) {
await loadCustomerOptions();
}
@ -380,6 +452,9 @@ const handleCustomerConfirm = ({ value }) => {
};
const openDatePicker = (field) => {
if (isViewMode.value) {
return;
}
pendingDateField.value = field;
datePickerMode.value = 'date';
datePickerValue.value = formData.value[field]
@ -396,6 +471,9 @@ const onDateConfirm = (event) => {
};
const openMemberModal = () => {
if (isViewMode.value) {
return;
}
selectedMemberIds.value = formData.value.memberList
.map((member) => member.userId)
.filter((id) => id);
@ -408,6 +486,9 @@ const closeMemberModal = () => {
};
const toggleMember = (userId) => {
if (isViewMode.value) {
return;
}
const id = String(userId);
const index = selectedMemberIds.value.indexOf(id);
if (index >= 0) {
@ -418,6 +499,9 @@ const toggleMember = (userId) => {
};
const confirmMemberSelection = () => {
if (isViewMode.value) {
return;
}
const existingMap = new Map(
formData.value.memberList.map((member) => [String(member.userId), member])
);
@ -437,12 +521,18 @@ const confirmMemberSelection = () => {
};
const removeMember = (userId) => {
if (isViewMode.value) {
return;
}
formData.value.memberList = formData.value.memberList.filter(
(member) => member.userId !== userId
);
};
const updateMemberRole = (userId, role) => {
if (isViewMode.value) {
return;
}
formData.value.memberList = formData.value.memberList.map((member) => {
if (member.userId === userId) {
return { ...member, role };
@ -452,6 +542,9 @@ const updateMemberRole = (userId, role) => {
};
const handleSubmit = async () => {
if (isViewMode.value) {
return;
}
if (!canSubmit.value || submitting.value) {
if (!canSubmit.value) {
uni.showToast({
@ -467,8 +560,8 @@ const handleSubmit = async () => {
title: mode.value === 'edit' ? '保存中...' : '创建中...'
});
const payload = buildPayload();
try {
const payload = buildPayload();
if (mode.value === 'edit') {
payload.id = projectId.value || payload.id;
await updateProject(payload);
@ -485,6 +578,10 @@ const handleSubmit = async () => {
}, 600);
} catch (error) {
console.error('保存项目失败:', error);
uni.showToast({
title: error?.msg || error?.message || '保存失败,请稍后重试',
icon: 'none'
});
} finally {
submitting.value = false;
uni.hideLoading();
@ -534,6 +631,10 @@ const loadProjectDetail = async (id) => {
...createDefaultForm(),
...detail
};
detailInfo.value = {
...detail,
memberList: normalizeMembers(detail.memberList)
};
formData.value = {
...formData.value,
id: merged.id || id,
@ -545,6 +646,7 @@ const loadProjectDetail = async (id) => {
expireTime: merged.expireTime,
projectTags: merged.projectTags || '',
remark: merged.remark || '',
status: merged.status || '',
developmentCost: safeNumberToString(merged.developmentCost),
middleCost: safeNumberToString(merged.middleCost),
afterCost: safeNumberToString(merged.afterCost),
@ -574,6 +676,7 @@ function createDefaultForm() {
expireTime: '',
projectTags: '',
remark: '',
status: '',
developmentCost: '',
middleCost: '',
afterCost: '',
@ -682,6 +785,37 @@ const normalizeMembers = (value) => {
.filter((member) => member.userId);
};
const statusTextMap = {
WAIT_START: '待开始',
IN_PROGRESS: '开发中',
COMPLETED: '开发完成',
ACCEPTED: '已验收',
MAINTENANCE: '维护中',
MAINTENANCE_OVERDUE: '维护到期',
DEVELOPMENT_COST: '前期费用',
MIDDLE_COST: '中期费用',
AFTER_COST: '后期费用',
DEVELOPMENT_OVERDUE: '开发超期'
};
function getStatusText(status) {
if (!status && status !== 0) {
return '—';
}
return statusTextMap[status] || status;
}
function formatAmountDisplay(value) {
if (value === null || value === undefined || value === '') {
return '—';
}
const num = Number(value);
if (Number.isNaN(num)) {
return '—';
}
return num.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
const getFileNameFromUrl = (url = '') => {
if (!url) return '附件';
const parts = url.split('/');
@ -732,6 +866,16 @@ const formatDate = (timestamp) => {
&.disabled {
color: #ccc;
}
&.disabled {
color: #bbb;
pointer-events: none;
.radio {
border-color: #ddd;
background: #f5f5f5;
}
}
}
.nav-title {
@ -762,6 +906,41 @@ const formatDate = (timestamp) => {
gap: 16px;
}
.detail-header {
background: #fff;
margin: 16px;
margin-bottom: 0;
border-radius: 12px;
padding: 16px;
display: flex;
flex-direction: column;
gap: 10px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.03);
}
.detail-title {
font-size: 18px;
font-weight: 600;
color: #111;
}
.detail-meta {
display: flex;
flex-wrap: wrap;
gap: 12px;
font-size: 13px;
color: #666;
}
.meta-label {
color: #999;
}
.meta-value {
color: #333;
margin-right: 12px;
}
.form-section {
background: #fff;
border-radius: 12px;
@ -833,6 +1012,11 @@ const formatDate = (timestamp) => {
.picker-input {
justify-content: space-between;
&.disabled {
color: #999;
background: #f0f0f0;
}
}
.picker-arrow {

View File

@ -823,11 +823,16 @@ onPullDownRefresh(async () => {
//
const goToProjectDetail = (project) => {
// TODO:
// uni.showToast({
// title: '',
// icon: 'none'
// });
if (!project?.id) {
uni.showToast({
title: '缺少项目ID',
icon: 'none'
});
return;
}
uni.navigateTo({
url: `/pages/project/form/index?mode=view&id=${project.id}`
});
};
//

View File

@ -11,7 +11,7 @@ export const Request = () => {
uni.$uv.http.setConfig((config) => {
/* config 为默认全局配置*/
config.baseURL = 'http://192.168.1.2:4001'; /* 根域名 */
config.baseURL = 'https://pm.ccttiot.com/prod-api'; /* 根域名 */
// config.baseURL = 'https://pm.ccttiot.com/prod-api'; /* 根域名 */
return config
})