<!--
|
项目新增 / 编辑
|
|
表单分区对齐管理端 formDia.vue:基础资料 / 产品信息 / 项目团队 / 收货地址 / 联系信息 / 附件。
|
管理端还有一个「项目阶段」子表,整块是注释掉的且不在 /save 的入参里,
|
所以这里也不做可编辑阶段表(做了会静默丢数据);阶段通过详情页查看、通过「进度汇报」写入。
|
|
基础资料里「预计工期」在管理端可填但不在提交结构里(后端按计划起止日期派生),
|
所以这里做成只读派生值,避免用户填了不生效。
|
-->
|
<template>
|
<view class="project-edit">
|
<PageHeader :title="pageTitle"
|
@back="goBack" />
|
|
<view class="form-container">
|
<up-form ref="formRef"
|
:model="form"
|
:rules="rules"
|
label-width="120"
|
input-align="right"
|
error-message-align="right">
|
<u-cell-group title="基础资料"
|
class="form-section">
|
<up-form-item v-if="projectId"
|
label="单据编号">
|
<up-input :model-value="form.billNo || '系统生成'"
|
disabled />
|
</up-form-item>
|
<up-form-item label="项目名称"
|
prop="projectName"
|
required>
|
<up-input v-model="form.projectName"
|
placeholder="请输入项目名称"
|
clearable />
|
</up-form-item>
|
<up-form-item label="客户名称"
|
prop="customerName">
|
<up-input v-model="form.customerName"
|
placeholder="请输入客户名称"
|
clearable />
|
</up-form-item>
|
<up-form-item label="立项日期"
|
prop="setupDate">
|
<FormPicker v-model="form.setupDate"
|
type="date"
|
placeholder="请选择立项日期" />
|
</up-form-item>
|
<up-form-item label="项目来源"
|
prop="projectSource">
|
<up-input v-model="form.projectSource"
|
placeholder="请输入项目来源"
|
clearable />
|
</up-form-item>
|
<up-form-item label="立项人"
|
prop="creatorName">
|
<up-input v-model="form.creatorName"
|
placeholder="请输入立项人"
|
clearable />
|
</up-form-item>
|
<up-form-item label="项目类型"
|
prop="projectManagementPlanId"
|
required>
|
<FormPicker v-model="form.projectManagementPlanId"
|
type="select"
|
title="选择项目类型"
|
:options="projectTypeOptions"
|
placeholder="请选择项目类型" />
|
</up-form-item>
|
<up-form-item label="计划开始日期"
|
prop="planStartDate">
|
<FormPicker v-model="form.planStartDate"
|
type="date"
|
placeholder="请选择计划开始日期" />
|
</up-form-item>
|
<up-form-item label="计划完成日期"
|
prop="planEndDate">
|
<FormPicker v-model="form.planEndDate"
|
type="date"
|
placeholder="请选择计划完成日期" />
|
</up-form-item>
|
<up-form-item label="预计工期(天)">
|
<up-input :model-value="estimatedDays === null ? '按计划日期自动计算' : String(estimatedDays)"
|
disabled />
|
</up-form-item>
|
<up-form-item label="项目金额"
|
prop="projectAmount">
|
<up-input v-model="form.projectAmount"
|
type="number"
|
placeholder="请输入项目金额"
|
clearable />
|
</up-form-item>
|
<up-form-item label="审核状态"
|
prop="auditStatus">
|
<FormPicker v-model="form.auditStatus"
|
type="select"
|
title="选择审核状态"
|
:options="auditOptions"
|
placeholder="请选择审核状态" />
|
</up-form-item>
|
<up-form-item label="备注"
|
prop="remark">
|
<up-textarea v-model="form.remark"
|
placeholder="请输入备注"
|
:maxlength="100"
|
count
|
auto-height />
|
</up-form-item>
|
</u-cell-group>
|
|
<u-cell-group title="产品信息"
|
class="form-section">
|
<view class="section-tools">
|
<up-button type="primary"
|
size="small"
|
text="添加产品"
|
@click="addProduct" />
|
</view>
|
<view v-if="productList.length === 0"
|
class="empty-text">
|
<text>请至少添加一条产品信息</text>
|
</view>
|
<view v-else
|
class="row-list">
|
<ProductRow v-for="(row, index) in productList"
|
:key="row.uid"
|
:row="row"
|
:index="index"
|
:tax-rate-options="taxRateOptions"
|
@remove="removeProduct(index)" />
|
</view>
|
</u-cell-group>
|
|
<u-cell-group title="项目团队"
|
class="form-section">
|
<view class="section-tools">
|
<up-button type="primary"
|
size="small"
|
text="添加成员"
|
@click="addTeamRow" />
|
</view>
|
<view v-if="teamList.length === 0"
|
class="empty-text">
|
<text>暂无项目成员</text>
|
</view>
|
<view v-else
|
class="row-list">
|
<TeamRow v-for="(row, index) in teamList"
|
:key="index"
|
:row="row"
|
:index="index"
|
:user-options="userOptions"
|
:role-options="roleOptions"
|
@remove="removeTeamRow(index)" />
|
</view>
|
</u-cell-group>
|
|
<u-cell-group title="收货地址"
|
class="form-section">
|
<view v-if="addressList.length === 0"
|
class="section-tools">
|
<up-button type="primary"
|
size="small"
|
text="添加地址"
|
@click="addAddressRow" />
|
</view>
|
<view v-else
|
class="row-list">
|
<AddressRow v-for="(row, index) in addressList"
|
:key="index"
|
:row="row" />
|
</view>
|
</u-cell-group>
|
|
<u-cell-group title="联系信息"
|
class="form-section">
|
<up-form-item label="联系人姓名"
|
prop="contactName">
|
<up-input v-model="form.contactName"
|
placeholder="请输入联系人姓名"
|
clearable />
|
</up-form-item>
|
<up-form-item label="性别"
|
prop="contactGender">
|
<FormPicker v-model="form.contactGender"
|
type="select"
|
title="选择性别"
|
:options="GENDER_OPTIONS"
|
placeholder="请选择性别" />
|
</up-form-item>
|
<up-form-item label="生日"
|
prop="contactBirthday">
|
<FormPicker v-model="form.contactBirthday"
|
type="date"
|
placeholder="请选择生日" />
|
</up-form-item>
|
<up-form-item label="部门"
|
prop="contactDept">
|
<up-input v-model="form.contactDept"
|
placeholder="请输入部门"
|
clearable />
|
</up-form-item>
|
<up-form-item label="职务"
|
prop="contactJob">
|
<up-input v-model="form.contactJob"
|
placeholder="请输入职务"
|
clearable />
|
</up-form-item>
|
<up-form-item label="手机号码"
|
prop="contactMobile">
|
<up-input v-model="form.contactMobile"
|
type="number"
|
placeholder="请输入手机号码"
|
clearable />
|
</up-form-item>
|
<up-form-item label="邮箱"
|
prop="contactEmail">
|
<up-input v-model="form.contactEmail"
|
placeholder="请输入邮箱"
|
clearable />
|
</up-form-item>
|
<up-form-item label="QQ"
|
prop="contactQq">
|
<up-input v-model="form.contactQq"
|
placeholder="请输入 QQ"
|
clearable />
|
</up-form-item>
|
<up-form-item label="微信号码"
|
prop="contactWechat">
|
<up-input v-model="form.contactWechat"
|
placeholder="请输入微信号码"
|
clearable />
|
</up-form-item>
|
<up-form-item label="企业微信"
|
prop="contactWorkWechat">
|
<up-input v-model="form.contactWorkWechat"
|
placeholder="请输入企业微信"
|
clearable />
|
</up-form-item>
|
<up-form-item label="地址"
|
prop="contactAddress">
|
<up-input v-model="form.contactAddress"
|
placeholder="请输入地址"
|
clearable />
|
</up-form-item>
|
<up-form-item label="联系信息备注"
|
prop="contactRemark">
|
<up-textarea v-model="form.contactRemark"
|
placeholder="请输入备注"
|
:maxlength="200"
|
count
|
auto-height />
|
</up-form-item>
|
</u-cell-group>
|
|
<u-cell-group title="附件"
|
class="form-section">
|
<view class="upload-block">
|
<CommonUpload v-model="attachmentFiles"
|
accept="all" />
|
</view>
|
</u-cell-group>
|
</up-form>
|
</view>
|
|
<FooterButtons :loading="loading"
|
confirmText="保存"
|
@cancel="goBack"
|
@confirm="handleSubmit" />
|
</view>
|
</template>
|
|
<script setup>
|
import { computed, ref } from "vue";
|
import { onLoad } from "@dcloudio/uni-app";
|
import PageHeader from "@/components/PageHeader.vue";
|
import FooterButtons from "@/components/FooterButtons.vue";
|
import FormPicker from "@/components/FormPicker.vue";
|
import CommonUpload from "@/components/CommonUpload.vue";
|
import ProductRow from "./_components/ProductRow.vue";
|
import TeamRow from "./_components/TeamRow.vue";
|
import AddressRow from "./_components/AddressRow.vue";
|
import { useDict } from "@/utils/dict";
|
import { parseDate } from "@/utils/warning";
|
import { unwrapList } from "@/utils/list";
|
import { userListNoPageByTenantId } from "@/api/system/user";
|
import { findRoleListPage } from "@/api/projectManagement/role";
|
import { listPlan } from "@/api/projectManagement/projectType";
|
import { getProject, saveProject } from "@/api/projectManagement/project";
|
import {
|
GENDER_OPTIONS,
|
createEmptyProduct,
|
toForm,
|
toPayload,
|
} from "./_utils/mapper";
|
import { toPickerOptions } from "./_utils/dict";
|
|
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
|
const { project_management, tax_rate } = useDict(
|
"project_management",
|
"tax_rate"
|
);
|
const auditOptions = computed(() => toPickerOptions(project_management.value));
|
const taxRateOptions = computed(() =>
|
(tax_rate.value || []).map(item => ({ label: item.label, value: item.value }))
|
);
|
|
const formRef = ref();
|
const loading = ref(false);
|
const projectId = ref("");
|
const projectTypeOptions = ref([]);
|
const userOptions = ref([]);
|
const roleOptions = ref([]);
|
|
const form = ref({
|
id: undefined,
|
billNo: "",
|
projectName: "",
|
clientId: "",
|
customerName: "",
|
parentProjectId: "",
|
parentProjectName: "",
|
projectManagementPlanId: "",
|
projectSource: "",
|
managerId: "",
|
creatorName: "",
|
salesmanId: "",
|
salesmanName: "",
|
departmentId: "",
|
departmentName: "",
|
setupDate: "",
|
planStartDate: "",
|
planEndDate: "",
|
actualStartDate: "",
|
actualEndDate: "",
|
orderDate: "",
|
projectAmount: "",
|
billStatus: "",
|
auditStatus: "",
|
projectStage: "",
|
remark: "",
|
contactName: "",
|
contactGender: "",
|
contactBirthday: "",
|
contactDept: "",
|
contactJob: "",
|
contactMobile: "",
|
contactEmail: "",
|
contactQq: "",
|
contactWechat: "",
|
contactWorkWechat: "",
|
contactAddress: "",
|
contactRemark: "",
|
});
|
|
const teamList = ref([]);
|
const addressList = ref([]);
|
const productList = ref([]);
|
const attachmentFiles = ref([]);
|
|
const rules = {
|
projectName: [{ required: true, message: "请输入项目名称", trigger: "blur" }],
|
projectManagementPlanId: [
|
{ required: true, message: "请选择项目类型", trigger: "change" },
|
],
|
};
|
|
const pageTitle = computed(() => (projectId.value ? "编辑项目" : "新增项目"));
|
|
// 后端按计划起止日期派生 estimatedDays,这里只做展示,不参与提交
|
const estimatedDays = computed(() => {
|
const start = parseDate(form.value.planStartDate);
|
const end = parseDate(form.value.planEndDate);
|
if (!start || !end || end < start) return null;
|
return Math.floor((end.getTime() - start.getTime()) / MS_PER_DAY) + 1;
|
});
|
|
const goBack = () => uni.navigateBack();
|
|
const addProduct = () => productList.value.push(createEmptyProduct());
|
|
const removeProduct = index => {
|
productList.value.splice(index, 1);
|
};
|
|
const addTeamRow = () =>
|
teamList.value.push({
|
memberId: "",
|
memberName: "",
|
roleId: "",
|
roleName: "",
|
enterDate: "",
|
leaveDate: "",
|
phone: "",
|
remark: "",
|
});
|
|
const removeTeamRow = index => {
|
teamList.value.splice(index, 1);
|
};
|
|
const addAddressRow = () =>
|
addressList.value.push({ receiver: "", phone: "", address: "" });
|
|
const loadProjectTypes = () => {
|
listPlan({ current: 1, size: 999 })
|
.then(res => {
|
projectTypeOptions.value = unwrapList(res).map(item => ({
|
name: item.name || item.planName || "",
|
value: item.id,
|
}));
|
})
|
.catch(() => {
|
projectTypeOptions.value = [];
|
});
|
};
|
|
const loadUsers = () => {
|
userListNoPageByTenantId()
|
.then(res => {
|
const rows = unwrapList(res);
|
userOptions.value = (Array.isArray(rows) ? rows : []).map(item => ({
|
name: item.nickName || item.userName || "",
|
value: item.userId ?? item.id,
|
}));
|
})
|
.catch(() => {
|
userOptions.value = [];
|
});
|
};
|
|
const loadRoles = () => {
|
findRoleListPage({ pageNum: 1, pageSize: 999 })
|
.then(res => {
|
roleOptions.value = unwrapList(res).map(item => ({
|
name: item.roleName || item.name || "",
|
value: item.id,
|
}));
|
})
|
.catch(() => {
|
roleOptions.value = [];
|
});
|
};
|
|
const loadDetail = async () => {
|
try {
|
const res = await getProject(projectId.value);
|
const detail = res?.data?.data ?? res?.data ?? res;
|
const mapped = toForm(detail || {});
|
form.value = { ...form.value, ...mapped.form };
|
teamList.value = mapped.teamList;
|
addressList.value = mapped.addressList;
|
productList.value = mapped.productList;
|
attachmentFiles.value = mapped.attachments;
|
} catch (error) {
|
uni.showToast({ title: "获取项目详情失败", icon: "none" });
|
}
|
};
|
|
const validateProducts = () => {
|
if (productList.value.length === 0) {
|
uni.showToast({ title: "请添加产品信息", icon: "none" });
|
return false;
|
}
|
const invalid = productList.value.some(row => !row.productCategoryId);
|
if (invalid) {
|
uni.showToast({ title: "请为每条产品选择产品大类", icon: "none" });
|
return false;
|
}
|
return true;
|
};
|
|
const handleSubmit = async () => {
|
const valid = await formRef.value.validate().catch(() => false);
|
if (!valid) return;
|
if (!validateProducts()) return;
|
|
// CommonUpload 存的是上传接口返回的对象,提交只要 id
|
const attachmentIds = attachmentFiles.value
|
.map(item => item?.id ?? item?.attachmentId)
|
.filter(Boolean);
|
|
loading.value = true;
|
saveProject(
|
toPayload({
|
form: form.value,
|
teamList: teamList.value,
|
addressList: addressList.value,
|
productList: productList.value,
|
attachmentIds,
|
})
|
)
|
.then(() => {
|
uni.showToast({ title: "保存成功", icon: "success" });
|
setTimeout(() => uni.navigateBack(), 300);
|
})
|
.catch(() => {
|
uni.showToast({ title: "保存失败", icon: "none" });
|
})
|
.finally(() => {
|
loading.value = false;
|
});
|
};
|
|
onLoad(async options => {
|
projectId.value = options?.id || "";
|
if (projectId.value) form.value.id = projectId.value;
|
|
loadProjectTypes();
|
loadUsers();
|
loadRoles();
|
|
if (projectId.value) {
|
await loadDetail();
|
} else {
|
// 新增时先铺一条空行,减少一次点击;产品必须至少一条,后端会校验
|
addProduct();
|
addAddressRow();
|
}
|
});
|
</script>
|
|
<style scoped lang="scss">
|
@import "@/static/scss/form-common.scss";
|
|
.project-edit {
|
min-height: 100vh;
|
background: #f8f9fa;
|
padding-bottom: 100px;
|
}
|
|
.form-container {
|
padding: 12px 12px 0;
|
}
|
|
.form-section {
|
margin-bottom: 12px;
|
border-radius: 12px;
|
overflow: hidden;
|
box-shadow: 0 2px 10px rgba(15, 35, 95, 0.05);
|
}
|
|
.section-tools {
|
display: flex;
|
justify-content: flex-end;
|
padding: 12px 12px 0;
|
}
|
|
.row-list {
|
padding: 12px;
|
display: flex;
|
flex-direction: column;
|
gap: 12px;
|
}
|
|
.empty-text {
|
padding: 16px 12px;
|
color: #999;
|
font-size: 14px;
|
}
|
|
.upload-block {
|
padding: 12px;
|
}
|
|
:deep(.u-cell-group__title) {
|
padding: 14px 18px 10px !important;
|
font-size: 15px !important;
|
font-weight: 600 !important;
|
color: #22324d !important;
|
background: #f8fbff !important;
|
}
|
</style>
|