feat: 初始化提报管理系统前端项目

- 添加基础项目结构及核心功能模块
- 实现用户登录界面及权限控制
- 完成分析师提报表单和管理员数据表格功能
- 配置Vue3 + Vite + Naive UI技术栈
- 集成Pinia状态管理和路由系统
- 添加axios请求封装及全局拦截器
This commit is contained in:
2026-03-17 19:09:51 +08:00
commit a22526c820
22 changed files with 4191 additions and 0 deletions

665
src/views/UserHome.vue Normal file
View File

@@ -0,0 +1,665 @@
<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: 附件文档 -->
<div class="section">
<div class="section-header">
<n-icon class="section-icon" size="20">
<DocumentAttachOutline />
</n-icon>
<span>2. 附件文档</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-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">
<!-- 移除 :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">
<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: 电子签名 -->
<div class="section">
<div class="section-header">
<n-icon class="section-icon" size="20">
<BrushOutline />
</n-icon>
<span>4. 电子签名</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">
<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 } from 'vue'
import { 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 router = useRouter()
const message = useMessage()
const formRef = ref(null)
// 表单数据结构
const formData = reactive({
analystSupervisor: '',
analystDepartment: '',
analystName: '',
parentName: '',
parentPhone: '',
parentIdCard: '',
transactionDate: null,
transactionAmount: null,
guidancePeriod: '',
analystNotes: '',
documentFileList: [],
paymentFileList: [],
signatureFileList: []
})
// 表单校验规则
const rules = {
analystName: [{ required: true, message: '请输入分析师姓名', trigger: 'blur' }],
parentName: [{ required: true, message: '请输入家长姓名', trigger: 'blur' }],
parentPhone: [{ required: true, message: '请输入联系电话', trigger: 'blur' }],
transactionAmount: [{ required: true, type: 'number', message: '请输入成交金额', trigger: 'blur' }],
}
// 预览功能
const handlePreview = (file) => {
// 优先预览从服务端获取到的 url
if (file.url) {
window.open(file.url)
} else if (file.file) {
const url = URL.createObjectURL(file.file)
window.open(url)
}
}
// 退出登录
const logout = () => {
localStorage.clear()
message.info('已退出系统')
// router.push('/login')
}
// 取消
const handleCancel = () => {
message.info('操作已取消')
}
// 👉 自定义文件上传逻辑
const customUpload = async ({ file, data, headers, onFinish, onError, onProgress }) => {
// 1. 构造 FormData 对象
const uploadData = new FormData()
// naive-ui 的 file 是一个包装对象,原生的 File 对象在 file.file 属性中
uploadData.append('file', file.file)
// 如果组件传入了额外数据,也追加进去
if (data) {
Object.keys(data).forEach((key) => {
uploadData.append(key, data[key])
})
}
try {
// 2. 调用封装好的上传接口 (注意:这里直接写 /v1/material/upload前提是 utils/request.js 中配置好了 /api 等前缀)
const res = await http.post('/v1/material/upload', uploadData, {
headers: {
'Content-Type': 'multipart/form-data',
...headers
},
// 监听上传进度,并在页面上显示绿色进度条
onUploadProgress: (progressEvent) => {
const percentCompleted = Math.round((progressEvent.loaded * 100) / progressEvent.total)
onProgress({ percent: percentCompleted })
}
})
// 3. 上传成功后处理数据
// 根据你 utils/request.js 的响应拦截器逻辑,`res` 已经是接口的 `data` 部分了
// 这里把后台返回的 url 赋值给当前文件对象,便于后续图片预览及提交时抽取数据
file.url = res.url
file.object_name = res.object_name // 保留一下 object_name以防后台提交需要用到
// 4. 通知组件上传成功
onFinish()
} catch (error) {
console.error('上传失败:', error)
// 5. 通知组件上传失败,变红
onError()
message.error(`${file.name} 上传失败,请重试`)
}
}
// 👉 提交表单数据
// 👉 修改后的提交方法
const handleSubmit = () => {
formRef.value?.validate(async (errors) => {
if (!errors) {
// 1. 检查文件上传状态
const allFiles = [
...formData.documentFileList,
...formData.paymentFileList,
...formData.signatureFileList
]
const isUploading = allFiles.some(file => file.status === 'uploading')
const hasError = allFiles.some(file => file.status === 'error')
if (isUploading) return message.warning('文件正在上传中,请稍候')
if (hasError) return message.error('部分文件上传失败,请处理后再提交')
// 2. 构造符合后端接口要求的 JSON 对象
// 注意接口字段为下划线命名且文件URL要求为字符串
const submitPayload = {
// 从缓存获取 wecom_id (如果没有则传空字符串或从路由获取)
wecom_id: localStorage.getItem('wecom_id') || 'default_user',
// 文件 URL 处理:取第一个,或者用逗号拼接(根据你接口单数命名的理解,通常传第一个或拼接)
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,
// 指导周期
guidance_period: formData.guidancePeriod,
// 特殊处理:金额转为字符串
transaction_amount: String(formData.transactionAmount || '0'),
// 特殊处理:日期转为 YYYY-MM-DD 字符串
transaction_date: formData.transactionDate
? new Date(formData.transactionDate).toISOString().split('T')[0]
: ''
}
const d = message.loading('正在提报材料...', { duration: 0 })
try {
// 3. 调用提交接口
// 你的 request.js baseURL 已经包含 /api所以这里写相对路径
const res = await http.post('/v1/material/submit', submitPayload)
d.destroy()
message.success('提报成功!')
console.log('提交结果:', res)
// 4. 提交成功后的操作:比如跳转或重置表单
// router.push('/success')
} catch (error) {
d.destroy()
// 错误提示已在 request.js 拦截器处理,这里可以做特定逻辑
console.error('提报失败', error)
}
} else {
message.error('请完善表单必填项')
}
})
}
</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>