feat(用户主页): 重构用户主页表单及上传逻辑

refactor(管理后台): 优化管理员列表页及分配功能

- 用户主页新增文件上传限制及数据回显逻辑
- 重构表单提交数据处理逻辑,确保数据纯净
- 管理后台新增分页查询及远程数据加载
- 优化分配操作流程,增加状态管理
This commit is contained in:
2026-03-18 17:52:54 +08:00
parent 2f2152b008
commit 8cca4a8f45
2 changed files with 357 additions and 297 deletions

View File

@@ -48,58 +48,49 @@
<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-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-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-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-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-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-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-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%">
<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-input v-model:value="formData.guidancePeriod" placeholder="例如: 3个月" clearable />
</n-form-item>
</n-grid-item>
</n-grid>
@@ -113,25 +104,24 @@
</div>
</div>
<!-- 模块 2: 附件文档 -->
<!-- 模块 2: 附件文档 (仅限1份) -->
<div class="section">
<div class="section-header">
<n-icon class="section-icon" size="20">
<DocumentAttachOutline />
</n-icon>
<span>2. 附件文档</span>
<span>2. 附件文档 (限1份)</span>
</div>
<div class="section-body">
<!-- 移除 :default-upload="false"增加 @custom-request="customUpload" -->
<n-upload multiple directory-dnd v-model:file-list="formData.documentFileList"
@preview="handlePreview" :custom-request="customUpload">
<n-upload :max="1" directory-dnd v-model:file-list="formData.documentFileList"
@preview="handlePreview" :custom-request="handleCustomUpload">
<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-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>
@@ -140,19 +130,18 @@
</div>
</div>
<!-- 模块 3: 付款截图 -->
<!-- 模块 3: 付款截图 (可上传多张) -->
<div class="section">
<div class="section-header">
<n-icon class="section-icon" size="20">
<ImagesOutline />
</n-icon>
<span>3. 付款截图</span>
<span>3. 付款截图 (可上传多张)</span>
</div>
<div class="section-body">
<!-- 移除 :default-upload="false"增加 @custom-request="customUpload" -->
<n-upload class="custom-multi-upload" accept="image/*" multiple list-type="image-card"
v-model:file-list="formData.paymentFileList" @preview="handlePreview"
:custom-request="customUpload">
:custom-request="handleCustomUpload">
<div class="upload-placeholder">
<div class="icon-bg">
<n-icon size="28" color="#666">
@@ -165,19 +154,18 @@
</div>
</div>
<!-- 模块 4: 电子签名 -->
<!-- 模块 4: 电子签名 (仅限1张) -->
<div class="section">
<div class="section-header">
<n-icon class="section-icon" size="20">
<BrushOutline />
</n-icon>
<span>4. 电子签名</span>
<span>4. 电子签名 (限1张)</span>
</div>
<div class="section-body">
<!-- 移除 :default-upload="false"增加 @custom-request="customUpload" -->
<n-upload class="signature-upload-container" accept="image/*" :max="1"
list-type="image-card" v-model:file-list="formData.signatureFileList"
@preview="handlePreview" :custom-request="customUpload">
@preview="handlePreview" :custom-request="handleCustomUpload">
<div class="upload-placeholder signature-placeholder">
<n-icon size="32" depth="3">
<CreateOutline />
@@ -215,8 +203,9 @@
</div>
</n-config-provider>
</template>
<script setup>
import { reactive, ref, onMounted } from 'vue' // 👉 增加 onMounted
import { reactive, ref, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import {
useMessage, zhCN, dateZhCN, NConfigProvider, NCard, NSpace, NGrid, NGridItem,
@@ -232,12 +221,12 @@ import {
import http from '@/utils/request'
const router = useRouter()
const route = useRoute() // 👉 用于获取 URL 中的参数
const message = useMessage()
const route = useRoute()
const router = useRouter()
const formRef = ref(null)
// 表单数据结构
// 响应式表单数据
const formData = reactive({
analystSupervisor: '',
analystDepartment: '',
@@ -249,127 +238,153 @@ const formData = reactive({
transactionAmount: null,
guidancePeriod: '',
analystNotes: '',
// 存储 UI 显示用的文件列表
documentFileList: [],
paymentFileList: [],
signatureFileList: []
})
// 👉 新增:初始化获取数据的方法
const fetchCustomerInfo = async () => {
// 优先从 URL 获取 wecom_id如果没有则使用你提供的测试 ID
const wecom_id = route.query.wecom_id || 'wmcr-ECwAAzKclEfIKNcVgOdxD-TcqLg'
// 定义校验规则
const rules = {
analystSupervisor: { required: true, message: '请输入主管姓名', trigger: 'blur' },
parentName: { required: true, message: '请输入家长姓名', trigger: 'blur' },
transactionDate: { required: true, type: 'number', message: '请选择日期', trigger: ['blur', 'change'] }
}
const d = message.loading('正在加载客户信息...')
/**
* 核心:通用文件上传逻辑
* 直接把后端返回的数据挂载到文件对象的 rawResponse 字段,完全隔离前端生成的 id/fullPath
*/
const handleCustomUpload = async ({ file, onFinish, onError, onProgress }) => {
try {
const res = await http.get('/v1/customer/get_customers_info', { wecom_id })
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) {
// 【重点】把请求回来的整个 data 存起来,不理会 UI 生成的字段
file.rawResponse = response
file.object_name = response.object_name
// 下面两行是给 UI 看的,保证能回显图片和显示“完成”状态
file.url = response.url
file.status = 'finished'
formData.documentFileList.push(file)
console.log('Updated documentFileList:', formData.documentFileList)
message.success('上传成功')
onFinish()
} else {
message.error(response.message || '上传失败')
onError()
}
} catch (error) {
message.error('网络错误,上传失败')
onError()
}
}
/**
* 核心:初始化数据回显
*/
const initData = async () => {
try {
const wecomId = route.query.wecom_id || 'wmcr-ECwAAzKclEfIKNcVgOdxD-TcqLg'
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.analystName = res.analystName
// 数组取第一个
formData.analystDepartment = res.analystDepartmentName?.[0] || ''
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()
// 金额转数字
formData.transactionAmount = res.dealAmount ? Number(res.dealAmount) : null
// 日期字符串转时间戳
formData.transactionDate = res.dealDate ? new Date(res.dealDate).getTime() : null
formData.analystNotes = res.notes || ''
// 👉 回显付款截图 (将 URL 数组转为 Upload 组件需要的对象格式)
if (res.proofOfPayment && Array.isArray(res.proofOfPayment)) {
formData.paymentFileList = res.proofOfPayment.map((url, index) => ({
id: 'init-' + index,
name: '已上传凭证-' + (index + 1),
// 2. 回显付款截图:将已有的 object_name 和 url 封装进 rawResponse
if (res.proofOfPayment && res.proofOfPayment_urls) {
formData.paymentFileList = res.proofOfPayment.map((objName, index) => ({
id: objName, // UI 需要一个 ID
name: `凭证-${index + 1}`,
status: 'finished',
url: url
url: res.proofOfPayment_urls[index],
// 【重点】手动构造一个 rawResponse确保和上传后的结构一致
rawResponse: {
object_name: objName,
url: res.proofOfPayment_urls[index],
success: true
}
}))
}
message.success('客户资料已自动填充')
message.success('数据加载成功')
} catch (error) {
console.error('获取初始信息失败:', error)
message.error('无法获取客户基础信息')
} finally {
d.destroy()
console.error('加载错误:', error)
message.error('客户信息加载失败')
}
}
// 👉 页面加载时执行
onMounted(() => {
fetchCustomerInfo()
initData()
})
// --- 以下是之前的逻辑,保持不变 ---
// 自定义上传逻辑
const customUpload = async ({ file, onFinish, onError, onProgress }) => {
const uploadData = new FormData()
uploadData.append('file', file.file)
try {
const res = await http.post('/v1/material/upload', uploadData, {
onUploadProgress: (p) => onProgress({ percent: Math.round((p.loaded * 100) / p.total) })
})
file.url = res.url
onFinish()
} catch (e) {
onError()
message.error('上传失败')
}
// 预览
const handlePreview = (file) => {
const url = file.url || file.thumbnailUrl
if (url) window.open(url)
}
// 最终提交逻辑
// 提交表单
const handleSubmit = () => {
formRef.value?.validate(async (errors) => {
if (!errors) {
// 【核心逻辑】只提取 rawResponse 里的数据,扔掉前端生成的 id, fullPath, type 等
const submitPayload = {
wecom_id: route.query.wecom_id || 'wmcr-ECwAAvBzsjWQ6RQZuXmxzXHMLiQ',
payment_image_url: formData.paymentFileList.map(f => f.url).filter(Boolean).join(','),
signature_image_url: formData.signatureFileList.map(f => f.url).filter(Boolean).join(','),
attachment_file_url: formData.documentFileList.map(f => f.url).filter(Boolean).join(','),
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: formData.transactionDate ? new Date(formData.transactionDate).toISOString().split('T')[0] : '',
transaction_amount: String(formData.transactionAmount || ''),
guidance_period: formData.guidancePeriod
// 1. 提取基础文本字段
analystSupervisor: formData.analystSupervisor,
analystDepartment: formData.analystDepartment,
analystName: formData.analystName,
parentName: formData.parentName,
parentPhone: formData.parentPhone,
parentIdCard: formData.parentIdCard,
transactionDate: formData.transactionDate,
transactionAmount: formData.transactionAmount,
guidancePeriod: formData.guidancePeriod,
analystNotes: formData.analystNotes,
// 2. 提取纯净的文件数据(仅包含请求回来的 data 对象)
// 单个文件取第 0 项的 rawResponse
document: formData.documentFileList[0]?.rawResponse || null,
// 多个文件用 map 提取数组
proofOfPayment: formData.paymentFileList.map(f => f.rawResponse).filter(Boolean),
// 签名文件
signature: formData.signatureFileList[0]?.rawResponse || null
}
const d = message.loading('提交中...')
console.log('--- 准备提交给后端的最终纯净数据 ---')
console.log(submitPayload)
try {
await http.post('/v1/material/submit', submitPayload)
message.success('提报成功!')
} catch (e) {
console.error(e)
} finally {
d.destroy()
// await http.post('/v1/material/submit_all', submitPayload)
message.success('材料已正式提交成功!')
} catch (err) {
message.error('提交失败,请联系管理员')
}
} else {
message.error('请完善必填信息')
}
})
}
const handlePreview = (file) => {
window.open(file.url)
}
const logout = () => { localStorage.clear(); message.info('已退出'); }
const handleCancel = () => { message.info('已取消'); }
const rules = {
analystName: [{ required: true, message: '必填', trigger: 'blur' }],
parentName: [{ required: true, message: '必填', trigger: 'blur' }],
parentPhone: [{ required: true, message: '必填', trigger: 'blur' }],
}
const logout = () => router.push('/login')
const handleCancel = () => router.back()
</script>
<style scoped>
/* 页面整体背景:浅色微渐变 */
.page-container {
min-height: 100vh;
background-color: #f0f2f5;
@@ -381,7 +396,6 @@ const rules = {
overflow: hidden;
}
/* 顶部装饰条,增加企业级感觉 */
.bg-decoration {
position: absolute;
top: 0;
@@ -393,7 +407,6 @@ const rules = {
z-index: 0;
}
/* 主卡片样式 */
.main-card {
max-width: 960px;
width: 100%;
@@ -418,7 +431,6 @@ const rules = {
border-top: 1px solid #f0f0f0;
}
/* 头部布局 */
.header-content {
display: flex;
justify-content: space-between;
@@ -455,14 +467,12 @@ const rules = {
color: #8c929b;
}
/* 模块整体 */
.section {
display: flex;
flex-direction: column;
gap: 16px;
}
/* 模块标题 */
.section-header {
display: flex;
align-items: center;
@@ -479,7 +489,6 @@ const rules = {
border-radius: 8px;
}
/* 模块内容区背景 */
.section-body {
background-color: #fcfcfd;
border: 1px solid #f0f0f5;
@@ -493,7 +502,6 @@ const rules = {
border-color: #e8e8ed;
}
/* 拖拽上传区域优化 */
.custom-dragger {
background-color: #fafafc;
border-radius: 8px;
@@ -515,7 +523,6 @@ const rules = {
color: #18a058;
}
/* 上传占位符全局样式 */
.upload-placeholder {
display: flex;
flex-direction: column;
@@ -531,7 +538,6 @@ const rules = {
background-color: #f3fcf6;
}
/* 付款截图 */
.custom-multi-upload :deep(.n-upload-trigger.n-upload-trigger--image-card) {
width: 120px;
height: 120px;
@@ -562,7 +568,6 @@ const rules = {
font-weight: 500;
}
/* 签名 */
.signature-upload-container :deep(.n-upload-trigger.n-upload-trigger--image-card) {
width: 300px;
height: 120px;
@@ -580,7 +585,6 @@ const rules = {
background-color: transparent;
}
/* 底部操作按钮 */
.footer-actions {
display: flex;
justify-content: flex-end;
@@ -597,7 +601,6 @@ const rules = {
box-shadow: 0 4px 12px rgba(24, 160, 88, 0.3);
}
/* 响应式调整 */
@media (max-width: 768px) {
.page-container {
padding: 20px 12px;