Files
super-data/src/views/UserHome.vue
lbw_9527443 f5498f017f feat(用户主页): 重构表单提交逻辑并添加日期格式化功能
- 从路由中提取 wecom_id 作为全局变量
- 新增 formatDate 工具函数用于日期格式化
- 重构 handleSubmit 方法以匹配新接口字段要求
- 优化文件上传逻辑,移除调试代码
2026-03-18 18:43:31 +08:00

676 lines
25 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>
<!-- 1. 配置中文语言包 -->
<n-config-provider :locale="zhCN" :date-locale="dateZhCN">
<div class="page-container">
<!-- 顶部装饰背景 -->
<div class="bg-decoration"></div>
<n-card class="main-card" :bordered="false">
<!-- 头部 -->
<template #header>
<div class="header-content">
<div class="header-title">
<div class="title-icon-wrapper">
<n-icon size="24" color="#fff">
<AnalyticsOutline />
</n-icon>
</div>
<div>
<h2>材料提报中心</h2>
<p class="sub-title">请如实填写客户成交信息并上传相关凭证</p>
</div>
</div>
<n-button class="logout-btn" secondary type="error" round @click="logout">
<template #icon>
<n-icon>
<LogOutOutline />
</n-icon>
</template>
退出系统
</n-button>
</div>
</template>
<n-form ref="formRef" :model="formData" :rules="rules" label-placement="top"
require-mark-placement="right-hanging">
<n-space vertical :size="32">
<!-- 模块 1: 客户及成交信息 -->
<div class="section">
<div class="section-header">
<n-icon class="section-icon" size="20">
<IdCardOutline />
</n-icon>
<span>1. 客户及成交信息录入</span>
</div>
<div class="section-body">
<n-grid :x-gap="24" :y-gap="8" cols="1 s:2 m:3" responsive="screen">
<n-grid-item>
<n-form-item label="分析师主管" path="analystSupervisor">
<n-input v-model:value="formData.analystSupervisor" placeholder="请输入主管姓名"
clearable />
</n-form-item>
</n-grid-item>
<n-grid-item>
<n-form-item label="分析师部门" path="analystDepartment">
<n-input v-model:value="formData.analystDepartment" placeholder="例如: 市场一部"
clearable />
</n-form-item>
</n-grid-item>
<n-grid-item>
<n-form-item label="分析师姓名" path="analystName">
<n-input v-model:value="formData.analystName" placeholder="请输入分析师姓名"
clearable />
</n-form-item>
</n-grid-item>
<n-grid-item>
<n-form-item label="家长姓名" path="parentName">
<n-input v-model:value="formData.parentName" placeholder="请输入家长姓名"
clearable />
</n-form-item>
</n-grid-item>
<n-grid-item>
<n-form-item label="家长电话" path="parentPhone">
<n-input v-model:value="formData.parentPhone" placeholder="请输入联系电话"
clearable />
</n-form-item>
</n-grid-item>
<n-grid-item>
<n-form-item label="家长身份证号码" path="parentIdCard">
<n-input v-model:value="formData.parentIdCard" placeholder="请输入18位身份证号"
clearable />
</n-form-item>
</n-grid-item>
<n-grid-item>
<n-form-item label="成交日期" path="transactionDate">
<n-date-picker v-model:value="formData.transactionDate" type="date"
clearable style="width: 100%" />
</n-form-item>
</n-grid-item>
<n-grid-item>
<n-form-item label="成交金额" path="transactionAmount">
<n-input-number v-model:value="formData.transactionAmount"
placeholder="0.00" clearable style="width: 100%">
<template #prefix>¥</template>
</n-input-number>
</n-form-item>
</n-grid-item>
<n-grid-item>
<n-form-item label="指导周期" path="guidancePeriod">
<n-input v-model:value="formData.guidancePeriod" placeholder="例如: 3个月"
clearable />
</n-form-item>
</n-grid-item>
</n-grid>
<div style="margin-top: 8px;">
<n-form-item label="分析师备注">
<n-input v-model:value="formData.analystNotes" type="textarea"
placeholder="请填写额外备注说明(选填)..." :autosize="{ minRows: 3, maxRows: 5 }" />
</n-form-item>
</div>
</div>
</div>
<!-- 模块 2: 附件文档 (仅限1份) -->
<div class="section">
<div class="section-header">
<n-icon class="section-icon" size="20">
<DocumentAttachOutline />
</n-icon>
<span>2. 附件文档 (限1份)</span>
</div>
<div class="section-body">
<n-upload :max="1" directory-dnd v-model:file-list="documentFileListLast"
@preview="handlePreview"
:custom-request="({ file, onFinish, onError, onProgress }) => handleCustomUpload({ file, onFinish, onError, onProgress }, 'documentFileList')">
<n-upload-dragger class="custom-dragger">
<div class="dragger-icon">
<n-icon size="48" :depth="3">
<CloudUploadOutline />
</n-icon>
</div>
<n-text style="font-size: 16px; font-weight: 500;">点击或者拖动单份文件到该区域来上传</n-text>
<n-p depth="3" style="margin: 8px 0 0 0; font-size: 13px;">
支持 PDF, DOCX, XLSX 等格式,单文件不超过 50MB
</n-p>
</n-upload-dragger>
</n-upload>
</div>
</div>
<!-- 模块 3: 付款截图 (可上传多张) -->
<div class="section">
<div class="section-header">
<n-icon class="section-icon" size="20">
<ImagesOutline />
</n-icon>
<span>3. 付款截图 (可上传多张)</span>
</div>
<div class="section-body">
<n-upload class="custom-multi-upload" accept="image/*" multiple list-type="image-card"
v-model:file-list="paymentFileListLast" @preview="handlePreview"
:custom-request="({ file, onFinish, onError, onProgress }) => handleCustomUpload({ file, onFinish, onError, onProgress }, 'paymentFileList')">
<div class="upload-placeholder">
<div class="icon-bg">
<n-icon size="28" color="#666">
<CameraOutline />
</n-icon>
</div>
<span class="placeholder-text">上传凭证</span>
</div>
</n-upload>
</div>
</div>
<!-- 模块 4: 电子签名 (仅限1张) -->
<div class="section">
<div class="section-header">
<n-icon class="section-icon" size="20">
<BrushOutline />
</n-icon>
<span>4. 电子签名 (限1张)</span>
</div>
<div class="section-body">
<n-upload class="signature-upload-container" accept="image/*" :max="1"
list-type="image-card" v-model:file-list="signatureFileListLast"
@preview="handlePreview"
:custom-request="({ file, onFinish, onError, onProgress }) => handleCustomUpload({ file, onFinish, onError, onProgress }, 'signatureFileList')">
<div class="upload-placeholder signature-placeholder">
<n-icon size="32" depth="3">
<CreateOutline />
</n-icon>
<n-text depth="3" style="font-size: 14px; margin-top: 8px;">点击上传手写签名</n-text>
</div>
</n-upload>
</div>
</div>
</n-space>
</n-form>
<!-- 底部操作 -->
<template #footer>
<div class="footer-actions">
<n-button size="large" class="action-btn" @click="handleCancel">
<template #icon>
<n-icon>
<CloseOutline />
</n-icon>
</template>
取消录入
</n-button>
<n-button size="large" type="primary" class="action-btn submit-btn" @click="handleSubmit">
<template #icon>
<n-icon>
<CheckmarkCircleOutline />
</n-icon>
</template>
提交资料
</n-button>
</div>
</template>
</n-card>
</div>
</n-config-provider>
</template>
<script setup>
import { reactive, ref, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import {
useMessage, zhCN, dateZhCN, NConfigProvider, NCard, NSpace, NGrid, NGridItem,
NForm, NFormItem, NInput, NInputNumber, NDatePicker, NUpload, NUploadDragger,
NButton, NIcon, NText, NP
} from 'naive-ui'
import {
AnalyticsOutline, LogOutOutline, IdCardOutline, DocumentAttachOutline,
ImagesOutline, BrushOutline, CloudUploadOutline, CameraOutline,
CreateOutline, CloseOutline, CheckmarkCircleOutline
} from '@vicons/ionicons5'
import http from '@/utils/request'
const message = useMessage()
const route = useRoute()
const router = useRouter()
const formRef = ref(null)
// 从路由中获取 wecom_id后续提交时需要
const wecomId = route.query.wecom_id || 'wmcr-ECwAAzKclEfIKNcVgOdxD-TcqLg'
// 响应式表单数据
const formData = reactive({
analystSupervisor: '',
analystDepartment: '',
analystName: '',
parentName: '',
parentPhone: '',
parentIdCard: '',
transactionDate: null,
transactionAmount: null,
guidancePeriod: '',
analystNotes: '',
// 存储 UI 显示用的文件列表
documentFileList: [],
paymentFileList: [],
signatureFileList: []
})
// 临时文件列表,用于上传前预览
const documentFileListLast = ref([])
const paymentFileListLast = ref([])
const signatureFileListLast = ref([])
// 定义校验规则
const rules = {
analystSupervisor: { required: true, message: '请输入主管姓名', trigger: 'blur' },
parentName: { required: true, message: '请输入家长姓名', trigger: 'blur' },
transactionDate: { required: true, type: 'number', message: '请选择日期', trigger: ['blur', 'change'] }
}
/**
* 格式化日期:将时间戳转换为 YYYY-MM-DD 格式
* @param {number} timestamp - The date timestamp.
* @returns {string|null} - Formatted date string or null.
*/
const formatDate = (timestamp) => {
if (!timestamp) return null;
const date = new Date(timestamp);
const year = date.getFullYear();
const month = (date.getMonth() + 1).toString().padStart(2, '0');
const day = date.getDate().toString().padStart(2, '0');
return `${year}-${month}-${day}`;
};
/**
* 核心:通用文件上传逻辑
* 直接把后端返回的数据挂载到文件对象的 rawResponse 字段,完全隔离前端生成的 id/fullPath
*/
const handleCustomUpload = async ({ file, onFinish, onError, onProgress }, type) => {
try {
const uploadData = new FormData()
uploadData.append('file', file.file)
// 注意:这里的上传接口地址可能需要根据您的实际情况修改
const response = await http.post('/v1/material/upload', uploadData, {
headers: { 'Content-Type': 'multipart/form-data' },
onUploadProgress: (p) => onProgress({ percent: Math.ceil((p.loaded / p.total) * 100) })
})
if (response.success && response) {
file.rawResponse = response
file.object_name = response.object_name
file.url = response.url
file.status = 'finished'
if (type === 'paymentFileList') {
formData.paymentFileList.push(file)
} else if (type === 'documentFileList') {
formData.documentFileList.push(file)
} else if (type === 'signatureFileList') {
formData.signatureFileList.push(file)
}
message.success('上传成功')
onFinish()
} else {
message.error(response.message || '上传失败')
onError()
}
} catch (error) {
message.error('网络错误,上传失败')
onError()
}
}
/**
* 核心:初始化数据回显
*/
const initData = async () => {
try {
// 注意:这里的获取信息接口地址可能需要根据您的实际情况修改
const res = await http.get('/v1/customer/get_customers_info', { wecom_id: wecomId })
// 1. 回显基础字段
formData.analystName = res.analystName
formData.parentName = res.customerName
formData.parentPhone = res.customerPhone
formData.analystNotes = res.notes
formData.analystSupervisor = res.analystDepartmentLeader?.[0] || ''
formData.analystDepartment = res.analystDepartmentName?.[0] || ''
if (res.dealAmount) formData.transactionAmount = Number(res.dealAmount)
if (res.dealDate) formData.transactionDate = new Date(res.dealDate).getTime()
// 2. 回显付款截图
if (res.proofOfPayment && res.proofOfPayment_urls) {
const paymentFiles = res.proofOfPayment.map((objName, index) => ({
id: objName,
name: `凭证-${index + 1}`,
status: 'finished',
url: res.proofOfPayment_urls[index],
rawResponse: {
object_name: objName,
url: res.proofOfPayment_urls[index],
success: true
}
}))
formData.paymentFileList = paymentFiles
paymentFileListLast.value = paymentFiles
}
message.success('数据加载成功')
} catch (error) {
console.error('加载错误:', error)
message.error('客户信息加载失败')
}
}
onMounted(() => {
initData()
})
// 预览
const handlePreview = (file) => {
const url = file.url || file.thumbnailUrl
if (url) window.open(url)
}
// ==============================================================================
// 核心改动: 更新提交逻辑以匹配新接口
// ==============================================================================
const handleSubmit = () => {
formRef.value?.validate(async (errors) => {
if (!errors) {
// 1. 构建完全符合后端接口要求的 payload
const submitPayload = {
wecom_id: wecomId,
analyst_supervisor: formData.analystSupervisor,
analyst_department: formData.analystDepartment,
analyst_name: formData.analystName,
parent_name: formData.parentName,
parent_phone: formData.parentPhone,
parent_id_card: formData.parentIdCard,
// 格式化日期和金额
transaction_date: formatDate(formData.transactionDate),
transaction_amount: String(formData.transactionAmount || ''),
guidance_period: formData.guidancePeriod,
// 提取文件 object_name
payment_object_names: formData.paymentFileList.map(f => f.object_name).filter(Boolean),
signature_object_name: formData.signatureFileList[0]?.object_name || null,
attachment_object_name: formData.documentFileList[0]?.object_name || null,
// analystNotes 似乎不在接口中,如果需要提交请后端添加字段
// assignee_name 和 assignee_phone 表单中未提供,暂不提交
};
console.log('--- 准备提交给后端的最终纯净数据 ---', submitPayload);
try {
// 2. 调用新的提交接口
// 注意:请确保 http util 配置了正确的 baseURL, 例如 http://192.168.15.115:5636/api
await http.post('/v1/material/submit', submitPayload);
message.success('材料已正式提交成功!');
// 可选:提交成功后跳转或重置表单
// router.push('/success-page');
} catch (err) {
console.error('提交失败:', err);
message.error('提交失败,请联系管理员');
}
} else {
message.error('请完善必填信息');
}
});
};
const logout = () => router.push('/login')
const handleCancel = () => router.back()
</script>
<style scoped>
.page-container {
min-height: 100vh;
background-color: #f0f2f5;
background-image: radial-gradient(circle at 50% 0%, #ffffff 0%, #f0f2f5 80%);
display: flex;
justify-content: center;
padding: 60px 20px;
position: relative;
overflow: hidden;
}
.bg-decoration {
position: absolute;
top: 0;
left: 0;
right: 0;
height: 240px;
background: linear-gradient(135deg, #18a058 0%, #36ad6a 100%);
clip-path: polygon(0 0, 100% 0, 100% 60%, 0% 100%);
z-index: 0;
}
.main-card {
max-width: 960px;
width: 100%;
border-radius: 16px;
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.08);
z-index: 1;
overflow: hidden;
}
.main-card :deep(.n-card-header) {
padding: 24px 32px;
border-bottom: 1px solid #f0f0f0;
}
.main-card :deep(.n-card__content) {
padding: 32px;
}
.main-card :deep(.n-card__footer) {
padding: 24px 32px;
background-color: #fafafc;
border-top: 1px solid #f0f0f0;
}
.header-content {
display: flex;
justify-content: space-between;
align-items: center;
}
.header-title {
display: flex;
align-items: center;
gap: 16px;
}
.title-icon-wrapper {
width: 48px;
height: 48px;
background: linear-gradient(135deg, #18a058 0%, #36ad6a 100%);
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4px 12px rgba(24, 160, 88, 0.3);
}
.header-title h2 {
margin: 0 0 4px 0;
font-size: 22px;
color: #1f2225;
font-weight: 600;
}
.sub-title {
margin: 0;
font-size: 13px;
color: #8c929b;
}
.section {
display: flex;
flex-direction: column;
gap: 16px;
}
.section-header {
display: flex;
align-items: center;
gap: 8px;
font-size: 17px;
font-weight: 600;
color: #1f2225;
}
.section-icon {
color: #18a058;
background-color: #e8f5ed;
padding: 6px;
border-radius: 8px;
}
.section-body {
background-color: #fcfcfd;
border: 1px solid #f0f0f5;
border-radius: 12px;
padding: 24px;
transition: all 0.3s ease;
}
.section-body:hover {
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.02);
border-color: #e8e8ed;
}
.custom-dragger {
background-color: #fafafc;
border-radius: 8px;
padding: 32px 0;
transition: all 0.3s ease;
}
.custom-dragger:hover {
background-color: #f3fcf6;
}
.dragger-icon {
margin-bottom: 16px;
transition: transform 0.3s ease;
}
.custom-dragger:hover .dragger-icon {
transform: translateY(-4px);
color: #18a058;
}
.upload-placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
background-color: #fafafc;
transition: all 0.3s ease;
}
.upload-placeholder:hover {
background-color: #f3fcf6;
}
.custom-multi-upload :deep(.n-upload-trigger.n-upload-trigger--image-card) {
width: 120px;
height: 120px;
border-radius: 8px;
}
.custom-multi-upload :deep(.n-upload-file.n-upload-file--image-card) {
width: 120px;
height: 120px;
border-radius: 8px;
}
.icon-bg {
width: 48px;
height: 48px;
background-color: #fff;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
margin-bottom: 8px;
}
.placeholder-text {
font-size: 13px;
color: #666;
font-weight: 500;
}
.signature-upload-container :deep(.n-upload-trigger.n-upload-trigger--image-card) {
width: 300px;
height: 120px;
border-radius: 8px;
border: 2px dashed #d9d9d9;
}
.signature-upload-container :deep(.n-upload-file.n-upload-file--image-card) {
width: 300px;
height: 120px;
border-radius: 8px;
}
.signature-placeholder {
background-color: transparent;
}
.footer-actions {
display: flex;
justify-content: flex-end;
gap: 16px;
}
.action-btn {
min-width: 120px;
border-radius: 8px;
font-weight: 500;
}
.submit-btn {
box-shadow: 0 4px 12px rgba(24, 160, 88, 0.3);
}
@media (max-width: 768px) {
.page-container {
padding: 20px 12px;
}
.header-content {
flex-direction: column;
align-items: flex-start;
gap: 16px;
}
.logout-btn {
align-self: flex-end;
}
.main-card :deep(.n-card__content) {
padding: 20px 16px;
}
.section-body {
padding: 16px;
}
.signature-upload-container :deep(.n-upload-trigger.n-upload-trigger--image-card),
.signature-upload-container :deep(.n-upload-file.n-upload-file--image-card) {
width: 100%;
height: 100px;
}
}
</style>