|
|
@@ -1,258 +1,299 @@
|
|
|
<template>
|
|
|
- <view class="app-container detail-page pb-24">
|
|
|
+ <view class="app-container detail-page">
|
|
|
<TopBar :title="mode === 'edit' ? '编辑项目' : '新增项目'" show-back />
|
|
|
|
|
|
<view v-if="loadingDetail" class="py-20 text-center">
|
|
|
<text class="text-gray-400">加载中...</text>
|
|
|
</view>
|
|
|
|
|
|
- <view v-else class="px-4 py-4 gap-y-4">
|
|
|
+ <view v-else class="form-body">
|
|
|
<!-- 关联合同 -->
|
|
|
- <view class="card p-4">
|
|
|
- <view class="detail-section-title mb-4">
|
|
|
- <view class="w-8 h-8 rounded-lg bg-green-100 flex items-center justify-center mr-3">
|
|
|
- <text class="uni-icons uniui-wallet-filled text-green-500"></text>
|
|
|
+ <view class="card">
|
|
|
+ <view class="section-title">
|
|
|
+ <view class="section-icon section-icon--green">
|
|
|
+ <text class="uni-icons uniui-wallet-filled"></text>
|
|
|
</view>
|
|
|
- <text>关联合同</text>
|
|
|
+ <text class="section-text">关联合同</text>
|
|
|
</view>
|
|
|
|
|
|
- <view class="gap-y-4">
|
|
|
- <view>
|
|
|
- <text class="form-label">
|
|
|
- 选择合同
|
|
|
- <text class="text-red-500">*</text>
|
|
|
- </text>
|
|
|
- <picker mode="selector" :range="contractLabels" :value="selectedContractIndex" @change="onContractChange">
|
|
|
- <view class="form-select flex items-center justify-between" :class="{ 'text-gray-400': selectedContractIndex < 0 }">
|
|
|
- <text class="truncate flex-1">{{ selectedContractIndex >= 0 ? contractLabels[selectedContractIndex] : '请选择合同' }}</text>
|
|
|
- <text class="uni-icons uniui-arrowdown text-gray-400 ml-2"></text>
|
|
|
- </view>
|
|
|
- </picker>
|
|
|
- <text v-if="errors.contractId" class="text-red-500 text-xs mt-1">{{ errors.contractId }}</text>
|
|
|
- </view>
|
|
|
-
|
|
|
- <view v-if="selectedContract" class="bg-gray-50 rounded-xl p-3 gap-y-2">
|
|
|
- <view class="flex justify-between text-sm">
|
|
|
- <text class="text-gray-500">客户</text>
|
|
|
- <text class="text-gray-800">{{ form.customerName || '-' }}</text>
|
|
|
- </view>
|
|
|
- <view class="flex justify-between text-sm">
|
|
|
- <text class="text-gray-500">主联系人</text>
|
|
|
- <text class="text-gray-800">{{ form.primaryContactName || '-' }} {{ form.primaryContactPhone || '' }}</text>
|
|
|
- </view>
|
|
|
- <view class="flex justify-between text-sm">
|
|
|
- <text class="text-gray-500">合同金额</text>
|
|
|
- <text class="text-gray-800">{{ formatMoney(form.contractAmount) }}</text>
|
|
|
- </view>
|
|
|
- <view class="flex justify-between text-sm">
|
|
|
- <text class="text-gray-500">付款方式</text>
|
|
|
- <text class="text-gray-800">{{ getPaymentMethodText(form.paymentMethod || '') }}</text>
|
|
|
- </view>
|
|
|
- <view class="flex justify-between text-sm">
|
|
|
- <text class="text-gray-500">项目地址</text>
|
|
|
- <text class="text-gray-800 flex-1 text-right ml-3">{{ derivedAddress || '-' }}</text>
|
|
|
+ <view class="form-group">
|
|
|
+ <text class="form-label">选择合同</text>
|
|
|
+ <picker mode="selector" :range="contractLabels" :value="selectedContractIndex" @change="onContractChange">
|
|
|
+ <view class="form-select" :class="{ 'form-select--empty': selectedContractIndex < 0 }">
|
|
|
+ <text class="truncate flex-1">{{ selectedContractIndex >= 0 ? contractLabels[selectedContractIndex] : '请选择合同(选填)' }}</text>
|
|
|
+ <text class="uni-icons uniui-arrowdown text-gray-400 ml-2"></text>
|
|
|
</view>
|
|
|
+ </picker>
|
|
|
+ </view>
|
|
|
+
|
|
|
+ <view v-if="selectedContract" class="info-panel">
|
|
|
+ <view class="info-row">
|
|
|
+ <text class="info-label">客户</text>
|
|
|
+ <text class="info-value">{{ form.customerName || '-' }}</text>
|
|
|
+ </view>
|
|
|
+ <view class="info-row">
|
|
|
+ <text class="info-label">主联系人</text>
|
|
|
+ <text class="info-value">{{ form.primaryContactName || '-' }} {{ form.primaryContactPhone || '' }}</text>
|
|
|
</view>
|
|
|
</view>
|
|
|
</view>
|
|
|
|
|
|
<!-- 基本信息 -->
|
|
|
- <view class="card p-4">
|
|
|
- <view class="detail-section-title mb-4">
|
|
|
- <view class="w-8 h-8 rounded-lg bg-blue-100 flex items-center justify-center mr-3">
|
|
|
- <text class="uni-icons uniui-folder-add-filled text-primary"></text>
|
|
|
+ <view class="card">
|
|
|
+ <view class="section-title">
|
|
|
+ <view class="section-icon section-icon--blue">
|
|
|
+ <text class="uni-icons uniui-folder-add-filled"></text>
|
|
|
</view>
|
|
|
- <text>基本信息</text>
|
|
|
+ <text class="section-text">基本信息</text>
|
|
|
</view>
|
|
|
|
|
|
- <view class="gap-y-4">
|
|
|
- <view>
|
|
|
- <text class="form-label">
|
|
|
- 项目名称
|
|
|
- <text class="text-red-500">*</text>
|
|
|
- </text>
|
|
|
- <uni-easyinput class="form-input" placeholder="请输入项目名称" v-model="form.projectName" @blur="validateField('projectName')" />
|
|
|
- <text v-if="errors.projectName" class="text-red-500 text-xs mt-1">{{ errors.projectName }}</text>
|
|
|
- </view>
|
|
|
+ <view class="form-group">
|
|
|
+ <text class="form-label">
|
|
|
+ 项目名称
|
|
|
+ <text class="required">*</text>
|
|
|
+ </text>
|
|
|
+ <uni-easyinput class="form-input" placeholder="请输入项目名称" v-model="form.projectName" @blur="validateField('projectName')" />
|
|
|
+ <text v-if="errors.projectName" class="error-text">{{ errors.projectName }}</text>
|
|
|
+ </view>
|
|
|
|
|
|
- <view>
|
|
|
- <text class="form-label">
|
|
|
- 合作类型
|
|
|
- <text class="text-red-500">*</text>
|
|
|
- </text>
|
|
|
- <picker mode="selector" :range="cooperationLabels" :value="cooperationIndex" @change="onCooperationChange">
|
|
|
- <view class="form-select flex items-center" :class="{ 'text-gray-400': !form.cooperationType }">
|
|
|
- <text class="uni-icons uniui-arrowdown text-gray-400 mr-2"></text>
|
|
|
- {{ form.cooperationType ? getCooperationTypeText(form.cooperationType) : '请选择合作类型' }}
|
|
|
- </view>
|
|
|
- </picker>
|
|
|
- <text v-if="errors.cooperationType" class="text-red-500 text-xs mt-1">{{ errors.cooperationType }}</text>
|
|
|
- </view>
|
|
|
+ <view class="form-group">
|
|
|
+ <text class="form-label">
|
|
|
+ 合作类型
|
|
|
+ <text class="required">*</text>
|
|
|
+ </text>
|
|
|
+ <picker mode="selector" :range="cooperationLabels" :value="cooperationIndex" @change="onCooperationChange">
|
|
|
+ <view class="form-select" :class="{ 'form-select--empty': !form.cooperationType }">
|
|
|
+ <text class="uni-icons uniui-arrowdown text-gray-400 mr-2"></text>
|
|
|
+ <text class="flex-1">{{ form.cooperationType ? getCooperationTypeText(form.cooperationType) : '请选择合作类型' }}</text>
|
|
|
+ </view>
|
|
|
+ </picker>
|
|
|
+ <text v-if="errors.cooperationType" class="error-text">{{ errors.cooperationType }}</text>
|
|
|
+ </view>
|
|
|
|
|
|
- <view>
|
|
|
- <text class="form-label">
|
|
|
- 所属行业
|
|
|
- <text class="text-red-500">*</text>
|
|
|
- </text>
|
|
|
- <picker mode="selector" :range="industryLabels" :value="industryIndex" @change="onIndustryChange">
|
|
|
- <view class="form-select flex items-center" :class="{ 'text-gray-400': !form.industry }">
|
|
|
- <text class="uni-icons uniui-arrowdown text-gray-400 mr-2"></text>
|
|
|
- {{ form.industry ? getIndustryText(form.industry) : '请选择所属行业' }}
|
|
|
- </view>
|
|
|
- </picker>
|
|
|
- <text v-if="errors.industry" class="text-red-500 text-xs mt-1">{{ errors.industry }}</text>
|
|
|
+ <view class="form-group">
|
|
|
+ <text class="form-label">
|
|
|
+ 设施类型
|
|
|
+ <text class="required">*</text>
|
|
|
+ <text class="form-hint">可多选</text>
|
|
|
+ </text>
|
|
|
+ <view class="chip-group">
|
|
|
+ <view
|
|
|
+ v-for="ft in facilityTypeOptions"
|
|
|
+ :key="ft.value"
|
|
|
+ class="chip"
|
|
|
+ :class="form.facilityTypes.includes(ft.value) ? 'chip--active' : 'chip--default'"
|
|
|
+ @click="toggleFacility(ft.value)"
|
|
|
+ >
|
|
|
+ {{ ft.label }}
|
|
|
+ </view>
|
|
|
</view>
|
|
|
+ <text v-if="errors.facilityTypes" class="error-text">{{ errors.facilityTypes }}</text>
|
|
|
+ </view>
|
|
|
|
|
|
- <view>
|
|
|
- <text class="form-label">
|
|
|
- 设施类型
|
|
|
- <text class="text-red-500">*</text>
|
|
|
- <text class="text-gray-400 text-xs ml-2">可多选</text>
|
|
|
- </text>
|
|
|
- <view class="flex flex-wrap gap-2 mt-1">
|
|
|
- <view
|
|
|
- v-for="ft in facilityTypes"
|
|
|
- :key="ft.value"
|
|
|
- class="px-3 py-2 rounded-xl text-sm border"
|
|
|
- :class="form.facilityTypes.includes(ft.value) ? 'bg-primary text-white border-primary' : 'bg-gray-50 text-gray-600 border-gray-200'"
|
|
|
- @click="toggleFacility(ft.value)"
|
|
|
- >
|
|
|
- {{ ft.label }}
|
|
|
- </view>
|
|
|
+ <view class="form-group">
|
|
|
+ <text class="form-label">
|
|
|
+ 项目地址
|
|
|
+ <text class="required">*</text>
|
|
|
+ </text>
|
|
|
+ <view class="flex items-center gap-2">
|
|
|
+ <uni-easyinput
|
|
|
+ class="form-input flex-1"
|
|
|
+ placeholder="请输入项目地址或点击地图选点"
|
|
|
+ v-model="form.address"
|
|
|
+ @blur="validateField('address')"
|
|
|
+ />
|
|
|
+ <view class="map-btn" @click="chooseLocation">
|
|
|
+ <text class="uni-icons uniui-location-filled map-btn-icon"></text>
|
|
|
+ <text class="map-btn-text">地图</text>
|
|
|
</view>
|
|
|
- <text v-if="errors.facilityTypes" class="text-red-500 text-xs mt-1">{{ errors.facilityTypes }}</text>
|
|
|
</view>
|
|
|
+ <text v-if="errors.address" class="error-text">{{ errors.address }}</text>
|
|
|
+ <text v-if="form.latitude && form.longitude" class="geo-text">
|
|
|
+ <text class="uni-icons uniui-location-filled mr-1"></text>
|
|
|
+ {{ form.latitude }}, {{ form.longitude }}
|
|
|
+ </text>
|
|
|
+ </view>
|
|
|
|
|
|
- <view class="grid grid-cols-2 gap-3">
|
|
|
- <view>
|
|
|
- <text class="form-label">
|
|
|
- 入住率(%)
|
|
|
- <text class="text-red-500">*</text>
|
|
|
- </text>
|
|
|
- <uni-easyinput class="form-input" placeholder="如 90" v-model="form.occupancyRate" type="digit" @blur="validateField('occupancyRate')" />
|
|
|
- <text v-if="errors.occupancyRate" class="text-red-500 text-xs mt-1">{{ errors.occupancyRate }}</text>
|
|
|
- </view>
|
|
|
- <view>
|
|
|
- <text class="form-label">
|
|
|
- 建筑面积(㎡)
|
|
|
- <text class="text-red-500">*</text>
|
|
|
- </text>
|
|
|
- <uni-easyinput class="form-input" placeholder="如 12000" v-model="form.buildingArea" type="digit" @blur="validateField('buildingArea')" />
|
|
|
- <text v-if="errors.buildingArea" class="text-red-500 text-xs mt-1">{{ errors.buildingArea }}</text>
|
|
|
+ <view class="form-group">
|
|
|
+ <text class="form-label">
|
|
|
+ 位置联系人
|
|
|
+ <text class="required">*</text>
|
|
|
+ </text>
|
|
|
+ <view v-if="locationContacts.length > 0" class="contact-list">
|
|
|
+ <view
|
|
|
+ v-for="(contact, index) in locationContacts"
|
|
|
+ :key="index"
|
|
|
+ class="contact-card"
|
|
|
+ >
|
|
|
+ <view class="contact-header">
|
|
|
+ <text class="contact-index">联系人 {{ index + 1 }}</text>
|
|
|
+ <view class="contact-delete" @click="removeLocationContact(index)">
|
|
|
+ <text class="uni-icons uniui-trash text-sm mr-1"></text>
|
|
|
+ <text>删除</text>
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+ <view class="contact-fields">
|
|
|
+ <view class="contact-field">
|
|
|
+ <text class="contact-field-label">姓名</text>
|
|
|
+ <input v-model="contact.name" class="contact-field-input" placeholder="请输入姓名" @blur="validateField('locationContacts')" />
|
|
|
+ </view>
|
|
|
+ <view class="contact-field">
|
|
|
+ <text class="contact-field-label">电话</text>
|
|
|
+ <input v-model="contact.phone" class="contact-field-input" placeholder="请输入手机号" maxlength="11" @blur="validateField('locationContacts')" />
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
</view>
|
|
|
</view>
|
|
|
-
|
|
|
- <view>
|
|
|
- <text class="form-label">项目介绍</text>
|
|
|
- <textarea
|
|
|
- v-model="form.projectDescription"
|
|
|
- class="w-full p-3 bg-surface rounded-xl text-sm text-gray-700"
|
|
|
- placeholder="请输入项目介绍..."
|
|
|
- :maxlength="500"
|
|
|
- />
|
|
|
+ <view class="add-btn" @click="addLocationContact">
|
|
|
+ <text class="uni-icons uniui-plus text-primary mr-1"></text>
|
|
|
+ <text class="text-primary text-sm">添加联系人</text>
|
|
|
</view>
|
|
|
+ <text v-if="errors.locationContacts" class="error-text">{{ errors.locationContacts }}</text>
|
|
|
+ </view>
|
|
|
+
|
|
|
+ <view class="form-group mb-0">
|
|
|
+ <text class="form-label">项目介绍</text>
|
|
|
+ <textarea
|
|
|
+ v-model="form.projectDescription"
|
|
|
+ class="form-textarea"
|
|
|
+ placeholder="请输入项目介绍..."
|
|
|
+ :maxlength="500"
|
|
|
+ />
|
|
|
</view>
|
|
|
</view>
|
|
|
|
|
|
- <!-- 合同日期 -->
|
|
|
- <view class="card p-4">
|
|
|
- <view class="detail-section-title mb-4">
|
|
|
- <view class="w-8 h-8 rounded-lg bg-purple-100 flex items-center justify-center mr-3">
|
|
|
- <text class="uni-icons uniui-calendar text-purple-500"></text>
|
|
|
+ <!-- 服务有效期 -->
|
|
|
+ <view class="card">
|
|
|
+ <view class="section-title">
|
|
|
+ <view class="section-icon section-icon--purple">
|
|
|
+ <text class="uni-icons uniui-calendar"></text>
|
|
|
</view>
|
|
|
- <text>合同日期</text>
|
|
|
+ <text class="section-text">服务有效期</text>
|
|
|
</view>
|
|
|
- <view class="grid grid-cols-2 gap-3">
|
|
|
- <view>
|
|
|
- <text class="form-label">合同开始</text>
|
|
|
- <picker mode="date" :value="form.contractStartDate" @change="onStartDateChange">
|
|
|
- <view class="form-select flex items-center text-gray-700">
|
|
|
+ <view class="date-range">
|
|
|
+ <view class="date-field">
|
|
|
+ <text class="form-label">开始日期</text>
|
|
|
+ <picker mode="date" :value="form.serviceStartDate" @change="onServiceStartDateChange">
|
|
|
+ <view class="form-select" :class="{ 'form-select--empty': !form.serviceStartDate }">
|
|
|
<text class="uni-icons uniui-calendar text-gray-400 mr-2"></text>
|
|
|
- {{ form.contractStartDate || '选择日期' }}
|
|
|
+ <text>{{ form.serviceStartDate || '选择日期' }}</text>
|
|
|
</view>
|
|
|
</picker>
|
|
|
</view>
|
|
|
- <view>
|
|
|
- <text class="form-label">合同结束</text>
|
|
|
- <picker mode="date" :value="form.contractEndDate" @change="onEndDateChange">
|
|
|
- <view class="form-select flex items-center text-gray-700">
|
|
|
+ <view class="date-divider">至</view>
|
|
|
+ <view class="date-field">
|
|
|
+ <text class="form-label">结束日期</text>
|
|
|
+ <picker mode="date" :value="form.serviceEndDate" @change="onServiceEndDateChange">
|
|
|
+ <view class="form-select" :class="{ 'form-select--empty': !form.serviceEndDate }">
|
|
|
<text class="uni-icons uniui-calendar text-gray-400 mr-2"></text>
|
|
|
- {{ form.contractEndDate || '选择日期' }}
|
|
|
+ <text>{{ form.serviceEndDate || '选择日期' }}</text>
|
|
|
</view>
|
|
|
</picker>
|
|
|
</view>
|
|
|
</view>
|
|
|
</view>
|
|
|
|
|
|
+ <!-- 费用信息 -->
|
|
|
+ <view class="card">
|
|
|
+ <view class="section-title">
|
|
|
+ <view class="section-icon section-icon--yellow">
|
|
|
+ <text class="uni-icons uniui-wallet-filled"></text>
|
|
|
+ </view>
|
|
|
+ <text class="section-text">费用信息</text>
|
|
|
+ </view>
|
|
|
+
|
|
|
+ <view class="form-group mb-0">
|
|
|
+ <text class="form-label">合同总金额(元)</text>
|
|
|
+ <uni-easyinput
|
|
|
+ class="form-input"
|
|
|
+ type="digit"
|
|
|
+ placeholder="请输入合同总金额"
|
|
|
+ v-model="form.contractAmount"
|
|
|
+ />
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+
|
|
|
<!-- 服务点信息 -->
|
|
|
- <view class="card p-4">
|
|
|
- <view class="detail-section-title mb-4">
|
|
|
- <view class="w-8 h-8 rounded-lg bg-cyan-100 flex items-center justify-center mr-3">
|
|
|
- <text class="uni-icons uniui-location-filled text-cyan-500"></text>
|
|
|
+ <view class="card">
|
|
|
+ <view class="section-title">
|
|
|
+ <view class="section-icon section-icon--cyan">
|
|
|
+ <text class="uni-icons uniui-location-filled"></text>
|
|
|
</view>
|
|
|
- <text>服务点与服务项</text>
|
|
|
+ <text class="section-text">服务点与服务项</text>
|
|
|
</view>
|
|
|
|
|
|
- <view class="gap-y-4">
|
|
|
+ <view v-if="form.servicePoints.length > 0" class="service-point-list">
|
|
|
<view
|
|
|
v-for="(point, index) in form.servicePoints"
|
|
|
:key="index"
|
|
|
- class="border border-gray-200 rounded-xl p-4"
|
|
|
+ class="service-point-card"
|
|
|
>
|
|
|
- <view class="flex justify-between items-center mb-3">
|
|
|
- <text class="text-sm font-semibold text-gray-700">服务点 {{ index + 1 }}</text>
|
|
|
- <text class="text-red-500 text-sm flex items-center" @click="removeServicePoint(index)">
|
|
|
+ <view class="service-point-header">
|
|
|
+ <view class="flex items-center">
|
|
|
+ <view class="service-point-number">{{ index + 1 }}</view>
|
|
|
+ <text class="service-point-title">{{ point.name || `服务点 ${index + 1}` }}</text>
|
|
|
+ </view>
|
|
|
+ <view class="service-point-delete" @click="removeServicePoint(index)">
|
|
|
<text class="uni-icons uniui-trash text-sm mr-1"></text>
|
|
|
- 删除
|
|
|
- </text>
|
|
|
+ <text>删除</text>
|
|
|
+ </view>
|
|
|
</view>
|
|
|
- <view class="gap-y-3">
|
|
|
- <uni-easyinput class="form-input mb-2" placeholder="服务点名称" v-model="point.name" />
|
|
|
- <uni-easyinput class="form-input mb-2" placeholder="服务点地址" v-model="point.address" />
|
|
|
- <uni-easyinput class="form-input mb-2" placeholder="服务类型" v-model="point.serviceType" />
|
|
|
- <uni-easyinput class="form-input mb-2" placeholder="联系人" v-model="point.contact" />
|
|
|
- <uni-easyinput class="form-input" placeholder="联系电话" v-model="point.phone" type="number" maxlength="11" />
|
|
|
+ <view class="service-point-body">
|
|
|
+ <view class="form-row">
|
|
|
+ <view class="form-col">
|
|
|
+ <text class="form-label">名称</text>
|
|
|
+ <uni-easyinput class="form-input" placeholder="服务点名称" v-model="point.name" />
|
|
|
+ </view>
|
|
|
+ <view class="form-col">
|
|
|
+ <text class="form-label">对应服务项</text>
|
|
|
+ <uni-easyinput class="form-input" placeholder="服务项" v-model="point.serviceItem" />
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+ <view class="form-group mb-0">
|
|
|
+ <text class="form-label">地址</text>
|
|
|
+ <uni-easyinput class="form-input" placeholder="服务点地址" v-model="point.address" />
|
|
|
+ </view>
|
|
|
</view>
|
|
|
</view>
|
|
|
+ </view>
|
|
|
|
|
|
- <button
|
|
|
- class="add-point-btn w-full py-3 rounded-xl text-sm flex items-center justify-center"
|
|
|
- @click="addServicePoint"
|
|
|
- >
|
|
|
- <text class="uni-icons uniui-plusempty mr-1"></text>
|
|
|
- 添加服务点
|
|
|
- </button>
|
|
|
+ <view class="add-btn" @click="addServicePoint">
|
|
|
+ <text class="uni-icons uniui-plusempty text-primary mr-1"></text>
|
|
|
+ <text class="text-primary text-sm">添加服务点</text>
|
|
|
</view>
|
|
|
</view>
|
|
|
|
|
|
<!-- 备注 -->
|
|
|
- <view class="card p-4">
|
|
|
- <view class="detail-section-title mb-4">
|
|
|
- <view class="w-8 h-8 rounded-lg bg-gray-100 flex items-center justify-center mr-3">
|
|
|
- <text class="uni-icons uniui-compose text-gray-500"></text>
|
|
|
+ <view class="card">
|
|
|
+ <view class="section-title">
|
|
|
+ <view class="section-icon section-icon--gray">
|
|
|
+ <text class="uni-icons uniui-compose"></text>
|
|
|
</view>
|
|
|
- <text>备注</text>
|
|
|
+ <text class="section-text">备注</text>
|
|
|
</view>
|
|
|
<textarea
|
|
|
v-model="form.remark"
|
|
|
- class="w-full p-3 bg-surface rounded-xl text-sm text-gray-700"
|
|
|
+ class="form-textarea"
|
|
|
placeholder="请输入项目备注..."
|
|
|
:maxlength="200"
|
|
|
/>
|
|
|
</view>
|
|
|
|
|
|
- <!-- 提交按钮 -->
|
|
|
- <view class="px-4 pt-2 pb-6">
|
|
|
- <button
|
|
|
- class="w-full py-4 btn-primary rounded-2xl text-base font-semibold flex items-center justify-center"
|
|
|
- @click="submitForm"
|
|
|
- :disabled="submitting"
|
|
|
- >
|
|
|
- <text v-if="submitting" class="uni-icons uniui-spinner-cycle mr-2 animate-spin"></text>
|
|
|
- <text class="uni-icons uniui-checkmarkempty mr-2" v-else></text>
|
|
|
- {{ submitting ? '提交中...' : (mode === 'edit' ? '保存修改' : '提交项目') }}
|
|
|
- </button>
|
|
|
- </view>
|
|
|
+ <!-- 底部占位 -->
|
|
|
+ <view class="h-20"></view>
|
|
|
+ </view>
|
|
|
+
|
|
|
+ <!-- 提交按钮 -->
|
|
|
+ <view class="submit-bar">
|
|
|
+ <button
|
|
|
+ class="submit-btn"
|
|
|
+ @click="submitForm"
|
|
|
+ :disabled="submitting"
|
|
|
+ >
|
|
|
+ <text v-if="submitting" class="uni-icons uniui-spinner-cycle mr-2 animate-spin"></text>
|
|
|
+ <text class="uni-icons uniui-checkmarkempty mr-2" v-else></text>
|
|
|
+ {{ submitting ? '提交中...' : (mode === 'edit' ? '保存修改' : '提交项目') }}
|
|
|
+ </button>
|
|
|
</view>
|
|
|
</view>
|
|
|
</template>
|
|
|
@@ -264,18 +305,18 @@ import TopBar from '../../components/common/TopBar.vue'
|
|
|
import { createProject, updateProject, getProjectDetail } from '../../api/project'
|
|
|
import { getContractList } from '../../api/contract'
|
|
|
import { getCustomerDetail } from '../../api/customer'
|
|
|
+import { getDictTree, buildDictOptions } from '../../api/dict'
|
|
|
import {
|
|
|
getCooperationTypeText,
|
|
|
- getIndustryText,
|
|
|
- getPaymentMethodText,
|
|
|
formatMoney,
|
|
|
- parseJsonArray
|
|
|
+ parseJsonArray,
|
|
|
+ parseLocationContacts,
|
|
|
+ formatLocationContacts
|
|
|
} from '../../utils'
|
|
|
import type {
|
|
|
Contract,
|
|
|
CooperationType,
|
|
|
FacilityType,
|
|
|
- Industry,
|
|
|
PaymentMethod,
|
|
|
ProjectStatus,
|
|
|
ServicePoint
|
|
|
@@ -286,31 +327,43 @@ const editId = ref<number | string>('')
|
|
|
const loadingDetail = ref(false)
|
|
|
const submitting = ref(false)
|
|
|
|
|
|
-const cooperationTypes: { value: CooperationType; label: string }[] = [
|
|
|
+// 字典选项
|
|
|
+const cooperationTypeOptions = ref<{ value: CooperationType; label: string }[]>([
|
|
|
{ value: 'annual', label: '包年' },
|
|
|
{ value: 'single', label: '单次' },
|
|
|
{ value: 'quarter', label: '季度' },
|
|
|
{ value: 'monthly', label: '月付' }
|
|
|
-]
|
|
|
-const cooperationLabels = cooperationTypes.map(t => t.label)
|
|
|
-
|
|
|
-const industries: { value: Industry; label: string }[] = [
|
|
|
- { value: 'residential', label: '住宅' },
|
|
|
- { value: 'commercial', label: '商业' },
|
|
|
- { value: 'industrial', label: '工业' },
|
|
|
- { value: 'municipal', label: '市政' },
|
|
|
- { value: 'medical', label: '医疗' },
|
|
|
- { value: 'education', label: '教育' },
|
|
|
- { value: 'catering', label: '餐饮' }
|
|
|
-]
|
|
|
-const industryLabels = industries.map(t => t.label)
|
|
|
-
|
|
|
-const facilityTypes: { value: FacilityType; label: string }[] = [
|
|
|
+])
|
|
|
+const facilityTypeOptions = ref<{ value: FacilityType; label: string }[]>([
|
|
|
{ value: 'septic_tank', label: '化粪池' },
|
|
|
{ value: 'direct_pump', label: '直抽直排' },
|
|
|
{ value: 'grease_pipe', label: '油污管+化粪池' },
|
|
|
{ value: 'pvc_pipe', label: 'PVC+化粪池' }
|
|
|
-]
|
|
|
+])
|
|
|
+
|
|
|
+async function loadDictOptions() {
|
|
|
+ try {
|
|
|
+ const coopData = await getDictTree('cooperation_type')
|
|
|
+ const coopOpts = buildDictOptions(coopData)
|
|
|
+ if (coopOpts.options.length > 0) {
|
|
|
+ cooperationTypeOptions.value = coopOpts.options as { value: CooperationType; label: string }[]
|
|
|
+ }
|
|
|
+ } catch {
|
|
|
+ // 失败时使用硬编码兜底
|
|
|
+ }
|
|
|
+
|
|
|
+ try {
|
|
|
+ const facilityData = await getDictTree('facility_type')
|
|
|
+ const facilityOpts = buildDictOptions(facilityData)
|
|
|
+ if (facilityOpts.options.length > 0) {
|
|
|
+ facilityTypeOptions.value = facilityOpts.options as { value: FacilityType; label: string }[]
|
|
|
+ }
|
|
|
+ } catch {
|
|
|
+ // 失败时使用硬编码兜底
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+const cooperationLabels = computed(() => cooperationTypeOptions.value.map(t => t.label))
|
|
|
|
|
|
const contracts = ref<Contract[]>([])
|
|
|
const contractLabels = computed(() =>
|
|
|
@@ -328,12 +381,7 @@ interface ProjectForm {
|
|
|
projectName: string
|
|
|
contractId: number | string
|
|
|
cooperationType: CooperationType | ''
|
|
|
- industry: Industry | ''
|
|
|
facilityTypes: FacilityType[]
|
|
|
- occupancyRate: string
|
|
|
- buildingArea: string
|
|
|
- contractStartDate: string
|
|
|
- contractEndDate: string
|
|
|
projectDescription: string
|
|
|
servicePoints: ServicePoint[]
|
|
|
remark: string
|
|
|
@@ -344,17 +392,12 @@ interface ProjectForm {
|
|
|
contractAmount?: number
|
|
|
paymentMethod: PaymentMethod | ''
|
|
|
address: string
|
|
|
- province: string
|
|
|
- city: string
|
|
|
- district: string
|
|
|
- street: string
|
|
|
- detailAddress: string
|
|
|
latitude: string
|
|
|
longitude: string
|
|
|
primaryContactName: string
|
|
|
primaryContactPhone: string
|
|
|
- backupContactName: string
|
|
|
- backupContactPhone: string
|
|
|
+ serviceStartDate: string
|
|
|
+ serviceEndDate: string
|
|
|
status: ProjectStatus
|
|
|
}
|
|
|
|
|
|
@@ -362,12 +405,7 @@ const form = reactive<ProjectForm>({
|
|
|
projectName: '',
|
|
|
contractId: '',
|
|
|
cooperationType: '',
|
|
|
- industry: '',
|
|
|
facilityTypes: [],
|
|
|
- occupancyRate: '',
|
|
|
- buildingArea: '',
|
|
|
- contractStartDate: '',
|
|
|
- contractEndDate: '',
|
|
|
projectDescription: '',
|
|
|
servicePoints: [],
|
|
|
remark: '',
|
|
|
@@ -378,56 +416,37 @@ const form = reactive<ProjectForm>({
|
|
|
contractAmount: undefined,
|
|
|
paymentMethod: '',
|
|
|
address: '',
|
|
|
- province: '',
|
|
|
- city: '',
|
|
|
- district: '',
|
|
|
- street: '',
|
|
|
- detailAddress: '',
|
|
|
latitude: '',
|
|
|
longitude: '',
|
|
|
primaryContactName: '',
|
|
|
primaryContactPhone: '',
|
|
|
- backupContactName: '',
|
|
|
- backupContactPhone: '',
|
|
|
+ serviceStartDate: '',
|
|
|
+ serviceEndDate: '',
|
|
|
status: 'pending'
|
|
|
})
|
|
|
|
|
|
+const locationContacts = ref<{ name: string; phone: string }[]>([])
|
|
|
+
|
|
|
const errors = reactive<Record<string, string>>({
|
|
|
contractId: '',
|
|
|
projectName: '',
|
|
|
cooperationType: '',
|
|
|
- industry: '',
|
|
|
facilityTypes: '',
|
|
|
- occupancyRate: '',
|
|
|
- buildingArea: ''
|
|
|
+ address: '',
|
|
|
+ locationContacts: ''
|
|
|
})
|
|
|
|
|
|
const cooperationIndex = computed(() =>
|
|
|
- cooperationTypes.findIndex(t => t.value === form.cooperationType)
|
|
|
-)
|
|
|
-const industryIndex = computed(() =>
|
|
|
- industries.findIndex(t => t.value === form.industry)
|
|
|
+ cooperationTypeOptions.value.findIndex(t => t.value === form.cooperationType)
|
|
|
)
|
|
|
|
|
|
-const derivedAddress = computed(() => {
|
|
|
- if (form.address) return form.address
|
|
|
- return [form.province, form.city, form.district, form.street, form.detailAddress].filter(Boolean).join('')
|
|
|
-})
|
|
|
-
|
|
|
-function joinAddress() {
|
|
|
- return form.address || [form.province, form.city, form.district, form.street, form.detailAddress].filter(Boolean).join('')
|
|
|
-}
|
|
|
-
|
|
|
-function splitServicePeriod(period?: string) {
|
|
|
- if (!period) return { start: '', end: '' }
|
|
|
- const parts = period.split(/\s*至\s*/)
|
|
|
- return { start: parts[0] || '', end: parts[1] || '' }
|
|
|
-}
|
|
|
-
|
|
|
-async function onContractChange(e: any) {
|
|
|
+function onContractChange(e: any) {
|
|
|
const idx = Number(e.detail.value)
|
|
|
const contract = contracts.value[idx]
|
|
|
- if (!contract) return
|
|
|
+ if (!contract) {
|
|
|
+ form.contractId = ''
|
|
|
+ return
|
|
|
+ }
|
|
|
|
|
|
form.contractId = contract.id
|
|
|
form.customerId = contract.customerId
|
|
|
@@ -436,31 +455,29 @@ async function onContractChange(e: any) {
|
|
|
form.contractAmount = contract.contractAmount
|
|
|
form.paymentMethod = contract.paymentMethod
|
|
|
|
|
|
+ // 若未手动填写服务有效期,尝试用合同服务期回填
|
|
|
const period = splitServicePeriod(contract.servicePeriod)
|
|
|
- if (!form.contractStartDate) form.contractStartDate = period.start
|
|
|
- if (!form.contractEndDate) form.contractEndDate = period.end
|
|
|
+ if (!form.serviceStartDate) form.serviceStartDate = period.start
|
|
|
+ if (!form.serviceEndDate) form.serviceEndDate = period.end
|
|
|
|
|
|
- errors.contractId = ''
|
|
|
-
|
|
|
- try {
|
|
|
- const customer = await getCustomerDetail(contract.customerId)
|
|
|
- if (customer) {
|
|
|
- form.customerName = customer.customerName || form.customerName
|
|
|
- form.customerPhone = customer.phone || ''
|
|
|
- form.primaryContactName = customer.contactName || ''
|
|
|
- form.primaryContactPhone = customer.phone || ''
|
|
|
- form.province = customer.province || ''
|
|
|
- form.city = customer.city || ''
|
|
|
- form.district = customer.district || ''
|
|
|
- form.street = customer.street || ''
|
|
|
- form.detailAddress = customer.detailAddress || ''
|
|
|
- form.address = customer.address || joinAddress()
|
|
|
- form.latitude = customer.latitude || ''
|
|
|
- form.longitude = customer.longitude || ''
|
|
|
- }
|
|
|
- } catch (e) {
|
|
|
- // 客户详情获取失败不阻断,仅使用合同自带信息
|
|
|
- }
|
|
|
+ // 尝试获取客户详情以回填联系人/地址
|
|
|
+ getCustomerDetail(contract.customerId)
|
|
|
+ .then((customer) => {
|
|
|
+ if (customer) {
|
|
|
+ form.customerName = customer.customerName || form.customerName
|
|
|
+ form.customerPhone = customer.phone || ''
|
|
|
+ form.primaryContactName = customer.contactName || ''
|
|
|
+ form.primaryContactPhone = customer.phone || ''
|
|
|
+ if (!form.address) {
|
|
|
+ form.address = customer.address || ''
|
|
|
+ }
|
|
|
+ if (!form.latitude) form.latitude = customer.latitude || ''
|
|
|
+ if (!form.longitude) form.longitude = customer.longitude || ''
|
|
|
+ }
|
|
|
+ })
|
|
|
+ .catch(() => {
|
|
|
+ // 客户详情获取失败不阻断
|
|
|
+ })
|
|
|
|
|
|
if (!form.projectName && form.customerName) {
|
|
|
form.projectName = `${form.customerName}项目`
|
|
|
@@ -468,15 +485,10 @@ async function onContractChange(e: any) {
|
|
|
}
|
|
|
|
|
|
function onCooperationChange(e: any) {
|
|
|
- form.cooperationType = cooperationTypes[Number(e.detail.value)].value
|
|
|
+ form.cooperationType = cooperationTypeOptions.value[Number(e.detail.value)].value
|
|
|
errors.cooperationType = ''
|
|
|
}
|
|
|
|
|
|
-function onIndustryChange(e: any) {
|
|
|
- form.industry = industries[Number(e.detail.value)].value
|
|
|
- errors.industry = ''
|
|
|
-}
|
|
|
-
|
|
|
function toggleFacility(value: FacilityType) {
|
|
|
const idx = form.facilityTypes.indexOf(value)
|
|
|
if (idx >= 0) form.facilityTypes.splice(idx, 1)
|
|
|
@@ -484,33 +496,85 @@ function toggleFacility(value: FacilityType) {
|
|
|
errors.facilityTypes = ''
|
|
|
}
|
|
|
|
|
|
-function onStartDateChange(e: any) {
|
|
|
- form.contractStartDate = e.detail.value
|
|
|
+function onServiceStartDateChange(e: any) {
|
|
|
+ form.serviceStartDate = e.detail.value
|
|
|
+}
|
|
|
+
|
|
|
+function onServiceEndDateChange(e: any) {
|
|
|
+ form.serviceEndDate = e.detail.value
|
|
|
}
|
|
|
|
|
|
-function onEndDateChange(e: any) {
|
|
|
- form.contractEndDate = e.detail.value
|
|
|
+function addLocationContact() {
|
|
|
+ locationContacts.value.push({ name: '', phone: '' })
|
|
|
+}
|
|
|
+
|
|
|
+function removeLocationContact(index: number) {
|
|
|
+ locationContacts.value.splice(index, 1)
|
|
|
+}
|
|
|
+
|
|
|
+function chooseLocation() {
|
|
|
+ uni.authorize({
|
|
|
+ scope: 'scope.userLocation',
|
|
|
+ success: () => {
|
|
|
+ uni.chooseLocation({
|
|
|
+ success: (res) => {
|
|
|
+ form.address = res.address || res.name || form.address
|
|
|
+ form.latitude = String(res.latitude ?? '')
|
|
|
+ form.longitude = String(res.longitude ?? '')
|
|
|
+ errors.address = ''
|
|
|
+ },
|
|
|
+ fail: (err) => {
|
|
|
+ console.error('chooseLocation fail', err)
|
|
|
+ uni.showToast({ title: '选择位置失败', icon: 'none' })
|
|
|
+ }
|
|
|
+ })
|
|
|
+ },
|
|
|
+ fail: () => {
|
|
|
+ uni.showModal({
|
|
|
+ title: '需要位置权限',
|
|
|
+ content: '请在设置中允许使用位置信息',
|
|
|
+ success: (res) => {
|
|
|
+ if (res.confirm) {
|
|
|
+ uni.openSetting()
|
|
|
+ }
|
|
|
+ }
|
|
|
+ })
|
|
|
+ }
|
|
|
+ })
|
|
|
}
|
|
|
|
|
|
function addServicePoint() {
|
|
|
- form.servicePoints.push({ name: '', address: '', serviceType: '', contact: '', phone: '' })
|
|
|
+ form.servicePoints.push({ name: '', address: '', serviceItem: '' })
|
|
|
}
|
|
|
|
|
|
function removeServicePoint(index: number) {
|
|
|
form.servicePoints.splice(index, 1)
|
|
|
}
|
|
|
|
|
|
+function splitServicePeriod(period?: string) {
|
|
|
+ if (!period) return { start: '', end: '' }
|
|
|
+ const parts = period.split(/\s*至\s*/)
|
|
|
+ return { start: parts[0] || '', end: parts[1] || '' }
|
|
|
+}
|
|
|
+
|
|
|
function validateField(field: string) {
|
|
|
errors[field] = ''
|
|
|
+ const phonePattern = /^1[3-9]\d{9}$/
|
|
|
switch (field) {
|
|
|
case 'projectName':
|
|
|
if (!form.projectName.trim()) errors.projectName = '请输入项目名称'
|
|
|
break
|
|
|
- case 'occupancyRate':
|
|
|
- if (form.occupancyRate === '' || isNaN(Number(form.occupancyRate))) errors.occupancyRate = '请输入入住率'
|
|
|
+ case 'address':
|
|
|
+ if (!form.address.trim()) errors.address = '请输入项目地址'
|
|
|
break
|
|
|
- case 'buildingArea':
|
|
|
- if (form.buildingArea === '' || isNaN(Number(form.buildingArea))) errors.buildingArea = '请输入建筑面积'
|
|
|
+ case 'locationContacts':
|
|
|
+ if (locationContacts.value.length === 0) {
|
|
|
+ errors.locationContacts = '请至少添加一个位置联系人'
|
|
|
+ } else if (locationContacts.value.some(c => !c.name.trim() || !c.phone.trim())) {
|
|
|
+ errors.locationContacts = '请填写完整的联系人姓名和电话'
|
|
|
+ } else if (locationContacts.value.some(c => !phonePattern.test(c.phone.trim()))) {
|
|
|
+ errors.locationContacts = '请填写正确的手机号'
|
|
|
+ }
|
|
|
break
|
|
|
}
|
|
|
return !errors[field]
|
|
|
@@ -518,56 +582,63 @@ function validateField(field: string) {
|
|
|
|
|
|
function validateForm() {
|
|
|
let valid = true
|
|
|
- if (!form.contractId) { errors.contractId = '请选择关联合同'; valid = false } else errors.contractId = ''
|
|
|
+ const phonePattern = /^1[3-9]\d{9}$/
|
|
|
if (!form.projectName.trim()) { errors.projectName = '请输入项目名称'; valid = false } else errors.projectName = ''
|
|
|
if (!form.cooperationType) { errors.cooperationType = '请选择合作类型'; valid = false } else errors.cooperationType = ''
|
|
|
- if (!form.industry) { errors.industry = '请选择所属行业'; valid = false } else errors.industry = ''
|
|
|
if (form.facilityTypes.length === 0) { errors.facilityTypes = '请选择设施类型'; valid = false } else errors.facilityTypes = ''
|
|
|
- if (form.occupancyRate === '' || isNaN(Number(form.occupancyRate))) { errors.occupancyRate = '请输入入住率'; valid = false } else errors.occupancyRate = ''
|
|
|
- if (form.buildingArea === '' || isNaN(Number(form.buildingArea))) { errors.buildingArea = '请输入建筑面积'; valid = false } else errors.buildingArea = ''
|
|
|
+ if (!form.address.trim()) { errors.address = '请输入项目地址'; valid = false } else errors.address = ''
|
|
|
+ if (locationContacts.value.length === 0) {
|
|
|
+ errors.locationContacts = '请至少添加一个位置联系人'
|
|
|
+ valid = false
|
|
|
+ } else if (locationContacts.value.some(c => !c.name.trim() || !c.phone.trim())) {
|
|
|
+ errors.locationContacts = '请填写完整的联系人姓名和电话'
|
|
|
+ valid = false
|
|
|
+ } else if (locationContacts.value.some(c => !phonePattern.test(c.phone.trim()))) {
|
|
|
+ errors.locationContacts = '请填写正确的手机号'
|
|
|
+ valid = false
|
|
|
+ } else {
|
|
|
+ errors.locationContacts = ''
|
|
|
+ }
|
|
|
return valid
|
|
|
}
|
|
|
|
|
|
function buildPayload() {
|
|
|
const points = form.servicePoints
|
|
|
- .filter(p => p.name || p.address || p.contact || p.phone)
|
|
|
+ .filter(p => p.name || p.address)
|
|
|
.map(p => ({
|
|
|
name: p.name || '',
|
|
|
address: p.address,
|
|
|
- contact: p.contact,
|
|
|
- phone: p.phone,
|
|
|
- serviceType: p.serviceType
|
|
|
+ serviceItem: p.serviceItem
|
|
|
}))
|
|
|
|
|
|
+ // 主联系人从第一个位置联系人推导,回退客户联系人
|
|
|
+ const firstContact = locationContacts.value[0]
|
|
|
+ const primaryContactName = firstContact?.name?.trim() || form.primaryContactName || undefined
|
|
|
+ const primaryContactPhone = firstContact?.phone?.trim() || form.primaryContactPhone || undefined
|
|
|
+
|
|
|
return {
|
|
|
projectName: form.projectName.trim(),
|
|
|
- contractId: form.contractId,
|
|
|
+ contractId: form.contractId || undefined,
|
|
|
customerId: form.customerId || undefined,
|
|
|
- customerName: form.customerName || undefined,
|
|
|
+ customerName: form.customerName || form.projectName.trim() || undefined,
|
|
|
customerPhone: form.customerPhone || undefined,
|
|
|
contractNo: form.contractNo || undefined,
|
|
|
cooperationType: form.cooperationType as CooperationType,
|
|
|
- industry: form.industry as Industry,
|
|
|
+ industry: 'commercial',
|
|
|
facilityTypes: JSON.stringify(form.facilityTypes),
|
|
|
- occupancyRate: Number(form.occupancyRate),
|
|
|
- buildingArea: Number(form.buildingArea),
|
|
|
- contractStartDate: form.contractStartDate || undefined,
|
|
|
- contractEndDate: form.contractEndDate || undefined,
|
|
|
+ contractAmount: form.contractAmount,
|
|
|
+ serviceStartDate: form.serviceStartDate || undefined,
|
|
|
+ serviceEndDate: form.serviceEndDate || undefined,
|
|
|
projectDescription: form.projectDescription || undefined,
|
|
|
servicePoints: JSON.stringify(points),
|
|
|
- address: joinAddress() || undefined,
|
|
|
- province: form.province || undefined,
|
|
|
- city: form.city || undefined,
|
|
|
- district: form.district || undefined,
|
|
|
- street: form.street || undefined,
|
|
|
- detailAddress: form.detailAddress || undefined,
|
|
|
+ locationContacts: formatLocationContacts(locationContacts.value),
|
|
|
+ address: form.address.trim(),
|
|
|
latitude: form.latitude || undefined,
|
|
|
longitude: form.longitude || undefined,
|
|
|
- primaryContactName: form.primaryContactName || undefined,
|
|
|
- primaryContactPhone: form.primaryContactPhone || undefined,
|
|
|
- backupContactName: form.backupContactName || undefined,
|
|
|
- backupContactPhone: form.backupContactPhone || undefined,
|
|
|
+ primaryContactName,
|
|
|
+ primaryContactPhone,
|
|
|
paymentMethod: (form.paymentMethod || undefined) as PaymentMethod | undefined,
|
|
|
+ contractStatus: form.contractId ? 'active' : undefined,
|
|
|
status: form.status,
|
|
|
remark: form.remark || undefined
|
|
|
}
|
|
|
@@ -611,19 +682,12 @@ async function loadDetail(id: number | string) {
|
|
|
form.projectName = p.projectName || ''
|
|
|
form.contractId = p.contractId || ''
|
|
|
form.cooperationType = (p.cooperationType || '') as CooperationType | ''
|
|
|
- form.industry = (p.industry || '') as Industry | ''
|
|
|
form.facilityTypes = parseJsonArray<FacilityType>(p.facilityTypes)
|
|
|
- form.occupancyRate = p.occupancyRate !== undefined && p.occupancyRate !== null ? String(p.occupancyRate) : ''
|
|
|
- form.buildingArea = p.buildingArea !== undefined && p.buildingArea !== null ? String(p.buildingArea) : ''
|
|
|
- form.contractStartDate = p.contractStartDate || ''
|
|
|
- form.contractEndDate = p.contractEndDate || ''
|
|
|
form.projectDescription = p.projectDescription || ''
|
|
|
form.servicePoints = parseJsonArray<ServicePoint>(p.servicePoints).map(sp => ({
|
|
|
name: sp.name || '',
|
|
|
address: sp.address,
|
|
|
- contact: sp.contact,
|
|
|
- phone: sp.phone,
|
|
|
- serviceType: sp.serviceType
|
|
|
+ serviceItem: sp.serviceItem || (sp as any).serviceType
|
|
|
}))
|
|
|
form.remark = p.remark || ''
|
|
|
form.customerId = p.customerId || ''
|
|
|
@@ -633,18 +697,22 @@ async function loadDetail(id: number | string) {
|
|
|
form.contractAmount = p.contractAmount
|
|
|
form.paymentMethod = (p.paymentMethod || '') as PaymentMethod | ''
|
|
|
form.address = p.address || ''
|
|
|
- form.province = p.province || ''
|
|
|
- form.city = p.city || ''
|
|
|
- form.district = p.district || ''
|
|
|
- form.street = p.street || ''
|
|
|
- form.detailAddress = p.detailAddress || ''
|
|
|
form.latitude = p.latitude || ''
|
|
|
form.longitude = p.longitude || ''
|
|
|
form.primaryContactName = p.primaryContactName || ''
|
|
|
form.primaryContactPhone = p.primaryContactPhone || ''
|
|
|
- form.backupContactName = p.backupContactName || ''
|
|
|
- form.backupContactPhone = p.backupContactPhone || ''
|
|
|
+ form.serviceStartDate = p.serviceStartDate || ''
|
|
|
+ form.serviceEndDate = p.serviceEndDate || ''
|
|
|
form.status = p.status || 'pending'
|
|
|
+
|
|
|
+ locationContacts.value = parseLocationContacts(p.locationContacts)
|
|
|
+ // 若后台未存储 locationContacts 但存储了主联系人,则至少回填一个
|
|
|
+ if (locationContacts.value.length === 0 && (form.primaryContactName || form.primaryContactPhone)) {
|
|
|
+ locationContacts.value.push({
|
|
|
+ name: form.primaryContactName || '',
|
|
|
+ phone: form.primaryContactPhone || ''
|
|
|
+ })
|
|
|
+ }
|
|
|
} catch (e) {
|
|
|
uni.showToast({ title: '加载项目失败', icon: 'none' })
|
|
|
} finally {
|
|
|
@@ -653,6 +721,7 @@ async function loadDetail(id: number | string) {
|
|
|
}
|
|
|
|
|
|
onLoad(async (options) => {
|
|
|
+ await loadDictOptions()
|
|
|
await loadContracts()
|
|
|
if (options?.mode === 'edit' && options?.id) {
|
|
|
mode.value = 'edit'
|
|
|
@@ -663,57 +732,447 @@ onLoad(async (options) => {
|
|
|
</script>
|
|
|
|
|
|
<style scoped>
|
|
|
+/* 页面基调 */
|
|
|
.detail-page {
|
|
|
background-color: #f6fbf9;
|
|
|
+ min-height: 100vh;
|
|
|
+}
|
|
|
+
|
|
|
+.form-body {
|
|
|
+ padding: 12px 16px 80px;
|
|
|
}
|
|
|
|
|
|
+/* 卡片 */
|
|
|
.card {
|
|
|
+ background: #ffffff;
|
|
|
border-radius: 20px;
|
|
|
+ padding: 18px 16px;
|
|
|
+ margin-bottom: 12px;
|
|
|
+ box-shadow: 0 4px 20px -4px rgba(54, 143, 111, 0.12);
|
|
|
+ border: 1px solid rgba(164, 216, 152, 0.25);
|
|
|
+}
|
|
|
+
|
|
|
+/* 区块标题 */
|
|
|
+.section-title {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ margin-bottom: 18px;
|
|
|
+}
|
|
|
+.section-icon {
|
|
|
+ width: 34px;
|
|
|
+ height: 34px;
|
|
|
+ border-radius: 10px;
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ margin-right: 10px;
|
|
|
+ font-size: 16px;
|
|
|
+}
|
|
|
+.section-icon--green {
|
|
|
+ background: linear-gradient(135deg, #e6f7ef 0%, #d1f2e3 100%);
|
|
|
+ color: #368f6f;
|
|
|
+}
|
|
|
+.section-icon--blue {
|
|
|
+ background: linear-gradient(135deg, #e6f4fb 0%, #d1ecf7 100%);
|
|
|
+ color: #3a8fb0;
|
|
|
+}
|
|
|
+.section-icon--purple {
|
|
|
+ background: linear-gradient(135deg, #f3e8ff 0%, #ede0fc 100%);
|
|
|
+ color: #8b5cf6;
|
|
|
+}
|
|
|
+.section-icon--yellow {
|
|
|
+ background: linear-gradient(135deg, #fff7ed 0%, #ffedd5 100%);
|
|
|
+ color: #f59e0b;
|
|
|
+}
|
|
|
+.section-icon--cyan {
|
|
|
+ background: linear-gradient(135deg, #e6fffa 0%, #d0f7f0 100%);
|
|
|
+ color: #0d9488;
|
|
|
+}
|
|
|
+.section-icon--gray {
|
|
|
+ background: linear-gradient(135deg, #f3f4f6 0%, #e5e7eb 100%);
|
|
|
+ color: #6b7280;
|
|
|
+}
|
|
|
+.section-text {
|
|
|
+ font-size: 16px;
|
|
|
+ font-weight: 600;
|
|
|
+ color: #1f2937;
|
|
|
+}
|
|
|
+
|
|
|
+/* 表单组 */
|
|
|
+.form-group {
|
|
|
+ margin-bottom: 18px;
|
|
|
+}
|
|
|
+.form-group:last-child,
|
|
|
+.form-group.mb-0 {
|
|
|
margin-bottom: 0;
|
|
|
- border: 1px solid rgba(164, 216, 152, 0.28);
|
|
|
- box-shadow: 0 14px 34px -18px rgba(54, 143, 111, 0.35);
|
|
|
}
|
|
|
|
|
|
-.detail-section-title {
|
|
|
- padding: 0;
|
|
|
- border-bottom: none;
|
|
|
+.form-label {
|
|
|
+ display: block;
|
|
|
+ font-size: 13px;
|
|
|
+ font-weight: 600;
|
|
|
+ color: #4b5563;
|
|
|
+ margin-bottom: 8px;
|
|
|
}
|
|
|
-.detail-section-title .w-8 {
|
|
|
- background-color: rgba(164, 216, 152, 0.2) !important;
|
|
|
+.required {
|
|
|
+ color: #ef4444;
|
|
|
+ margin-left: 2px;
|
|
|
}
|
|
|
-.detail-section-title .uni-icons {
|
|
|
- color: #368f6f !important;
|
|
|
+.form-hint {
|
|
|
+ font-weight: 400;
|
|
|
+ color: #9ca3af;
|
|
|
+ margin-left: 6px;
|
|
|
}
|
|
|
|
|
|
-/* 设施类型多选选中态 */
|
|
|
-.bg-primary {
|
|
|
- background: linear-gradient(135deg, #368f6f 0%, #5ab8d0 100%);
|
|
|
+/* 输入框 */
|
|
|
+.form-input :deep(.uni-easyinput__content) {
|
|
|
+ min-height: 44px;
|
|
|
+ border-radius: 12px;
|
|
|
+ background-color: #f9fafb;
|
|
|
+ border: 1px solid #e5e7eb;
|
|
|
+ transition: all 0.2s ease;
|
|
|
}
|
|
|
-.border-primary {
|
|
|
+.form-input :deep(.uni-easyinput__content.focus) {
|
|
|
+ background-color: #ffffff;
|
|
|
border-color: #368f6f;
|
|
|
+ box-shadow: 0 0 0 3px rgba(54, 143, 111, 0.1);
|
|
|
+}
|
|
|
+.form-input :deep(.uni-easyinput__placeholder-class) {
|
|
|
+ color: #9ca3af;
|
|
|
+ font-size: 14px;
|
|
|
}
|
|
|
|
|
|
-.btn-primary {
|
|
|
+/* 选择器 */
|
|
|
+.form-select {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ min-height: 44px;
|
|
|
+ padding: 0 12px;
|
|
|
+ border-radius: 12px;
|
|
|
+ background-color: #f9fafb;
|
|
|
+ border: 1px solid #e5e7eb;
|
|
|
+ font-size: 14px;
|
|
|
+ color: #1f2937;
|
|
|
+ transition: all 0.2s ease;
|
|
|
+}
|
|
|
+.form-select--empty {
|
|
|
+ color: #9ca3af;
|
|
|
+}
|
|
|
+
|
|
|
+/* 标签组 */
|
|
|
+.chip-group {
|
|
|
+ display: flex;
|
|
|
+ flex-wrap: wrap;
|
|
|
+ gap: 10px;
|
|
|
+}
|
|
|
+.chip {
|
|
|
+ padding: 8px 16px;
|
|
|
+ border-radius: 999px;
|
|
|
+ font-size: 13px;
|
|
|
+ font-weight: 500;
|
|
|
+ transition: all 0.15s ease;
|
|
|
+}
|
|
|
+.chip--default {
|
|
|
+ background-color: #f3f4f6;
|
|
|
+ color: #4b5563;
|
|
|
+ border: 1px solid #e5e7eb;
|
|
|
+}
|
|
|
+.chip--active {
|
|
|
background: linear-gradient(135deg, #368f6f 0%, #5ab8d0 100%);
|
|
|
- box-shadow: 0 10px 22px -10px rgba(54, 143, 111, 0.55);
|
|
|
+ color: #ffffff;
|
|
|
+ border: 1px solid transparent;
|
|
|
+ box-shadow: 0 4px 12px -4px rgba(54, 143, 111, 0.4);
|
|
|
}
|
|
|
|
|
|
-.form-input :deep(.uni-easyinput__content.focus) {
|
|
|
- border-color: #368f6f !important;
|
|
|
+/* 错误提示 */
|
|
|
+.error-text {
|
|
|
+ display: block;
|
|
|
+ font-size: 12px;
|
|
|
+ color: #ef4444;
|
|
|
+ margin-top: 6px;
|
|
|
}
|
|
|
|
|
|
-.bg-surface {
|
|
|
- background-color: #ffffff;
|
|
|
+/* 地图按钮 */
|
|
|
+.map-btn {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ width: 52px;
|
|
|
+ height: 44px;
|
|
|
+ border-radius: 12px;
|
|
|
+ background: linear-gradient(135deg, #368f6f 0%, #5ab8d0 100%);
|
|
|
+ color: #ffffff;
|
|
|
+ flex-shrink: 0;
|
|
|
+ box-shadow: 0 4px 12px -4px rgba(54, 143, 111, 0.4);
|
|
|
+}
|
|
|
+.map-btn-icon {
|
|
|
+ font-size: 16px;
|
|
|
+ margin-bottom: 1px;
|
|
|
+}
|
|
|
+.map-btn-text {
|
|
|
+ font-size: 10px;
|
|
|
+ line-height: 1;
|
|
|
+}
|
|
|
+
|
|
|
+/* 经纬度提示 */
|
|
|
+.geo-text {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ font-size: 12px;
|
|
|
+ color: #368f6f;
|
|
|
+ margin-top: 6px;
|
|
|
+}
|
|
|
+
|
|
|
+/* 信息面板 */
|
|
|
+.info-panel {
|
|
|
+ background: #f9fafb;
|
|
|
+ border-radius: 14px;
|
|
|
+ padding: 14px;
|
|
|
+ margin-top: 8px;
|
|
|
+}
|
|
|
+.info-row {
|
|
|
+ display: flex;
|
|
|
+ justify-content: space-between;
|
|
|
+ align-items: center;
|
|
|
+ font-size: 13px;
|
|
|
+ padding: 6px 0;
|
|
|
+}
|
|
|
+.info-row:first-child {
|
|
|
+ padding-top: 0;
|
|
|
+}
|
|
|
+.info-row:last-child {
|
|
|
+ padding-bottom: 0;
|
|
|
+}
|
|
|
+.info-label {
|
|
|
+ color: #6b7280;
|
|
|
+}
|
|
|
+.info-value {
|
|
|
+ color: #1f2937;
|
|
|
+ font-weight: 500;
|
|
|
+}
|
|
|
+
|
|
|
+/* 联系人卡片 */
|
|
|
+.contact-list {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ gap: 12px;
|
|
|
+ margin-bottom: 12px;
|
|
|
+}
|
|
|
+.contact-card {
|
|
|
+ background: #f9fafb;
|
|
|
+ border-radius: 14px;
|
|
|
+ border: 1px solid #e5e7eb;
|
|
|
+ overflow: hidden;
|
|
|
+}
|
|
|
+.contact-header {
|
|
|
+ display: flex;
|
|
|
+ justify-content: space-between;
|
|
|
+ align-items: center;
|
|
|
+ padding: 10px 12px;
|
|
|
+ background: #f3f4f6;
|
|
|
+ border-bottom: 1px solid #e5e7eb;
|
|
|
+}
|
|
|
+.contact-index {
|
|
|
+ font-size: 13px;
|
|
|
+ font-weight: 600;
|
|
|
+ color: #4b5563;
|
|
|
+}
|
|
|
+.contact-delete {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ font-size: 12px;
|
|
|
+ color: #ef4444;
|
|
|
+ padding: 2px 6px;
|
|
|
+ border-radius: 6px;
|
|
|
+}
|
|
|
+.contact-delete:active {
|
|
|
+ background: rgba(239, 68, 68, 0.1);
|
|
|
+}
|
|
|
+.contact-fields {
|
|
|
+ display: flex;
|
|
|
+ gap: 10px;
|
|
|
+ padding: 12px;
|
|
|
+}
|
|
|
+.contact-field {
|
|
|
+ flex: 1;
|
|
|
+}
|
|
|
+.contact-field-label {
|
|
|
+ display: block;
|
|
|
+ font-size: 12px;
|
|
|
+ color: #6b7280;
|
|
|
+ margin-bottom: 6px;
|
|
|
+}
|
|
|
+.contact-field-input {
|
|
|
+ width: 100%;
|
|
|
+ height: 40px;
|
|
|
+ padding: 0 10px;
|
|
|
+ border-radius: 10px;
|
|
|
border: 1px solid #e5e7eb;
|
|
|
+ background-color: #ffffff;
|
|
|
+ font-size: 14px;
|
|
|
+ color: #1f2937;
|
|
|
+}
|
|
|
+.contact-field-input:focus {
|
|
|
+ border-color: #368f6f;
|
|
|
}
|
|
|
|
|
|
-/* 添加服务点 */
|
|
|
-.add-point-btn {
|
|
|
- background-color: rgba(164, 216, 152, 0.10);
|
|
|
- border: 1px dashed rgba(54, 143, 111, 0.5);
|
|
|
+/* 添加按钮 */
|
|
|
+.add-btn {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ padding: 12px 0;
|
|
|
+ border-radius: 12px;
|
|
|
+ border: 1px dashed #a7f3d0;
|
|
|
+ background-color: rgba(164, 216, 152, 0.08);
|
|
|
+ transition: all 0.15s ease;
|
|
|
+}
|
|
|
+.add-btn:active {
|
|
|
+ background-color: rgba(164, 216, 152, 0.18);
|
|
|
+}
|
|
|
+.text-primary {
|
|
|
color: #368f6f;
|
|
|
}
|
|
|
-.add-point-btn:active {
|
|
|
- background-color: rgba(164, 216, 152, 0.22);
|
|
|
+
|
|
|
+/* 日期范围 */
|
|
|
+.date-range {
|
|
|
+ display: flex;
|
|
|
+ align-items: flex-end;
|
|
|
+ gap: 10px;
|
|
|
+}
|
|
|
+.date-field {
|
|
|
+ flex: 1;
|
|
|
+}
|
|
|
+.date-divider {
|
|
|
+ padding-bottom: 12px;
|
|
|
+ font-size: 13px;
|
|
|
+ color: #9ca3af;
|
|
|
+ font-weight: 500;
|
|
|
+}
|
|
|
+
|
|
|
+/* 文本域 */
|
|
|
+.form-textarea {
|
|
|
+ width: 100%;
|
|
|
+ min-height: 90px;
|
|
|
+ padding: 12px;
|
|
|
+ border-radius: 12px;
|
|
|
+ border: 1px solid #e5e7eb;
|
|
|
+ background-color: #f9fafb;
|
|
|
+ font-size: 14px;
|
|
|
+ color: #1f2937;
|
|
|
+ line-height: 1.5;
|
|
|
+}
|
|
|
+.form-textarea:focus {
|
|
|
+ background-color: #ffffff;
|
|
|
+ border-color: #368f6f;
|
|
|
+ box-shadow: 0 0 0 3px rgba(54, 143, 111, 0.1);
|
|
|
+}
|
|
|
+
|
|
|
+/* 服务点卡片 */
|
|
|
+.service-point-list {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ gap: 14px;
|
|
|
+ margin-bottom: 14px;
|
|
|
+}
|
|
|
+.service-point-card {
|
|
|
+ background: #f9fafb;
|
|
|
+ border-radius: 16px;
|
|
|
+ border: 1px solid #e5e7eb;
|
|
|
+ overflow: hidden;
|
|
|
+}
|
|
|
+.service-point-header {
|
|
|
+ display: flex;
|
|
|
+ justify-content: space-between;
|
|
|
+ align-items: center;
|
|
|
+ padding: 12px 14px;
|
|
|
+ background: linear-gradient(135deg, #e6fffa 0%, #d0f7f0 100%);
|
|
|
+ border-bottom: 1px solid #e5e7eb;
|
|
|
+}
|
|
|
+.service-point-number {
|
|
|
+ width: 22px;
|
|
|
+ height: 22px;
|
|
|
+ border-radius: 50%;
|
|
|
+ background: #0d9488;
|
|
|
+ color: #ffffff;
|
|
|
+ font-size: 12px;
|
|
|
+ font-weight: 600;
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ margin-right: 8px;
|
|
|
+}
|
|
|
+.service-point-title {
|
|
|
+ font-size: 14px;
|
|
|
+ font-weight: 600;
|
|
|
+ color: #0f766e;
|
|
|
+}
|
|
|
+.service-point-delete {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ font-size: 12px;
|
|
|
+ color: #ef4444;
|
|
|
+ padding: 2px 6px;
|
|
|
+ border-radius: 6px;
|
|
|
+}
|
|
|
+.service-point-delete:active {
|
|
|
+ background: rgba(239, 68, 68, 0.1);
|
|
|
+}
|
|
|
+.service-point-body {
|
|
|
+ padding: 14px;
|
|
|
+}
|
|
|
+.form-row {
|
|
|
+ display: flex;
|
|
|
+ gap: 10px;
|
|
|
+ margin-bottom: 14px;
|
|
|
+}
|
|
|
+.form-row:last-child {
|
|
|
+ margin-bottom: 0;
|
|
|
+}
|
|
|
+.form-col {
|
|
|
+ flex: 1;
|
|
|
+}
|
|
|
+
|
|
|
+/* 提交栏 */
|
|
|
+.submit-bar {
|
|
|
+ position: fixed;
|
|
|
+ left: 0;
|
|
|
+ right: 0;
|
|
|
+ bottom: 0;
|
|
|
+ padding: 12px 16px calc(12px + env(safe-area-inset-bottom));
|
|
|
+ background: #ffffff;
|
|
|
+ border-top: 1px solid rgba(164, 216, 152, 0.3);
|
|
|
+ box-shadow: 0 -4px 20px -4px rgba(0, 0, 0, 0.06);
|
|
|
+ z-index: 50;
|
|
|
+}
|
|
|
+.submit-btn {
|
|
|
+ width: 100%;
|
|
|
+ height: 48px;
|
|
|
+ border-radius: 14px;
|
|
|
+ font-size: 16px;
|
|
|
+ font-weight: 600;
|
|
|
+ color: #ffffff;
|
|
|
+ background: linear-gradient(135deg, #368f6f 0%, #5ab8d0 100%);
|
|
|
+ box-shadow: 0 8px 20px -6px rgba(54, 143, 111, 0.55);
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ border: none;
|
|
|
+}
|
|
|
+.submit-btn[disabled] {
|
|
|
+ opacity: 0.7;
|
|
|
+}
|
|
|
+.submit-btn::after {
|
|
|
+ border: none;
|
|
|
+}
|
|
|
+
|
|
|
+/* 动画 */
|
|
|
+.animate-spin {
|
|
|
+ animation: spin 1s linear infinite;
|
|
|
+}
|
|
|
+@keyframes spin {
|
|
|
+ from { transform: rotate(0deg); }
|
|
|
+ to { transform: rotate(360deg); }
|
|
|
}
|
|
|
</style>
|