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

39
.gitignore vendored Normal file
View File

@@ -0,0 +1,39 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
.DS_Store
dist
dist-ssr
coverage
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
*.tsbuildinfo
.eslintcache
# Cypress
/cypress/videos/
/cypress/screenshots/
# Vitest
__screenshots__/
# Vite
*.timestamp-*-*.mjs

6
.prettierrc.json Normal file
View File

@@ -0,0 +1,6 @@
{
"$schema": "https://json.schemastore.org/prettierrc",
"semi": false,
"singleQuote": true,
"printWidth": 100
}

6
.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,6 @@
{
"recommendations": [
"Vue.volar",
"esbenp.prettier-vscode"
]
}

42
README.md Normal file
View File

@@ -0,0 +1,42 @@
# superData
This template should help get you started developing with Vue 3 in Vite.
## Recommended IDE Setup
[VS Code](https://code.visualstudio.com/) + [Vue (Official)](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur).
## Recommended Browser Setup
- Chromium-based browsers (Chrome, Edge, Brave, etc.):
- [Vue.js devtools](https://chromewebstore.google.com/detail/vuejs-devtools/nhdogjmejiglipccpnnnanhbledajbpd)
- [Turn on Custom Object Formatter in Chrome DevTools](http://bit.ly/object-formatters)
- Firefox:
- [Vue.js devtools](https://addons.mozilla.org/en-US/firefox/addon/vue-js-devtools/)
- [Turn on Custom Object Formatter in Firefox DevTools](https://fxdx.dev/firefox-devtools-custom-object-formatters/)
## Type Support for `.vue` Imports in TS
TypeScript cannot handle type information for `.vue` imports by default, so we replace the `tsc` CLI with `vue-tsc` for type checking. In editors, we need [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) to make the TypeScript language service aware of `.vue` types.
## Customize configuration
See [Vite Configuration Reference](https://vite.dev/config/).
## Project Setup
```sh
npm install
```
### Compile and Hot-Reload for Development
```sh
npm run dev
```
### Type-Check, Compile and Minify for Production
```sh
npm run build
```

1
env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

16
index.html Normal file
View File

@@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Vite App</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

39
package.json Normal file
View File

@@ -0,0 +1,39 @@
{
"name": "superdata",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "run-p type-check \"build-only {@}\" --",
"preview": "vite preview",
"build-only": "vite build",
"type-check": "vue-tsc --build",
"format": "prettier --write --experimental-cli src/"
},
"dependencies": {
"@vicons/ionicons5": "^0.13.0",
"axios": "^1.13.6",
"dev": "^0.1.3",
"naive-ui": "^2.44.1",
"pinia": "^3.0.4",
"vicons": "^0.0.1",
"vue": "^3.5.29",
"vue-router": "^5.0.3"
},
"devDependencies": {
"@tsconfig/node24": "^24.0.4",
"@types/node": "^24.11.0",
"@vitejs/plugin-vue": "^6.0.4",
"@vue/tsconfig": "^0.8.1",
"npm-run-all2": "^8.0.4",
"prettier": "3.8.1",
"typescript": "~5.9.3",
"vite": "^7.3.1",
"vite-plugin-vue-devtools": "^8.0.6",
"vue-tsc": "^3.2.5"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
}

2558
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

23
src/App.vue Normal file
View File

@@ -0,0 +1,23 @@
<template>
<!-- 全局配置中文语言包全局消息提示 -->
<n-config-provider :locale="zhCN" :date-locale="dateZhCN">
<n-message-provider>
<n-dialog-provider>
<router-view />
</n-dialog-provider>
</n-message-provider>
<n-global-style />
</n-config-provider>
</template>
<script setup>
import { NConfigProvider, NMessageProvider, NDialogProvider, NGlobalStyle, zhCN, dateZhCN } from 'naive-ui'
</script>
<style>
body {
margin: 0;
background-color: #f5f7fa;
font-family: v-sans, system-ui, -apple-system;
}
</style>

0
src/api/user.js Normal file
View File

7
src/main.js Normal file
View File

@@ -0,0 +1,7 @@
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
const app = createApp(App)
app.use(router)
app.mount('#app')

25
src/router/index.js Normal file
View File

@@ -0,0 +1,25 @@
import { createRouter, createWebHistory } from 'vue-router'
const routes = [
{ path: '/', redirect: '/login' },
{ path: '/login', component: () => import('../views/Login.vue') },
{ path: '/user', component: () => import('../views/UserHome.vue') },
{ path: '/admin', component: () => import('../views/AdminIndex.vue') },
]
const router = createRouter({
history: createWebHistory(),
routes,
})
// 路由守卫:校验权限
router.beforeEach((to, from, next) => {
const role = localStorage.getItem('userRole')
if (to.path !== '/login' && !role) {
next('/login')
} else {
next()
}
})
export default router

12
src/stores/counter.ts Normal file
View File

@@ -0,0 +1,12 @@
import { ref, computed } from 'vue'
import { defineStore } from 'pinia'
export const useCounterStore = defineStore('counter', () => {
const count = ref(0)
const doubleCount = computed(() => count.value * 2)
function increment() {
count.value++
}
return { count, doubleCount, increment }
})

129
src/utils/request.js Normal file
View File

@@ -0,0 +1,129 @@
import axios from 'axios'
// 1. 创建 axios 实例
const service = axios.create({
// 根据不同的环境使用不同的 baseURL (假设使用了 Vite 或 Webpack)
baseURL: 'http://192.168.15.115:5636/api' || '',
timeout: 10000, // 请求超时时间10秒
headers: {
'Content-Type': 'application/json;charset=utf-8',
},
})
// 2. 请求拦截器
service.interceptors.request.use(
(config) => {
// 从 localStorage 或状态管理器中获取 Token
const token = localStorage.getItem('token')
// 如果有 token将其添加到请求头中
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
// 你可以在这里添加全局的 Loading 效果开启逻辑
return config
},
(error) => {
// 处理请求错误
console.error('Request Error:', error)
return Promise.reject(error)
},
)
// 3. 响应拦截器
service.interceptors.response.use(
(response) => {
// 获取后端返回的数据
const res = response.data
// 处理二进制数据 (比如下载文件)
if (response.config.responseType === 'blob' || response.config.responseType === 'arraybuffer') {
return res
}
// 与后端约定的业务成功状态码,这里假设是 200
if (res.code !== 200) {
console.error(res.message || '系统异常')
// 比如 401: Token 过期,需要重新登录
if (res.code === 401) {
localStorage.removeItem('token')
window.location.href = '/login' // 跳回登录页
}
// 返回一个被拒绝的 Promise中断调用链
return Promise.reject(new Error(res.message || 'Error'))
}
// 业务正常,直接剥离最外层,返回 data 里面的数据
return res.data
},
(error) => {
// HTTP 网络错误统一处理
const status = error.response?.status
let errorMessage = '网络请求异常'
switch (status) {
case 400:
errorMessage = '请求参数错误'
break
case 401:
errorMessage = '未授权,请重新登录'
break
case 403:
errorMessage = '拒绝访问'
break
case 404:
errorMessage = '请求的资源不存在'
break
case 408:
errorMessage = '请求超时'
break
case 500:
errorMessage = '服务器内部错误'
break
case 502:
errorMessage = '网关错误'
break
case 503:
errorMessage = '服务不可用'
break
case 504:
errorMessage = '网关超时'
break
default:
errorMessage = `连接错误 (${status})`
}
console.error(errorMessage)
return Promise.reject(error)
},
)
// 4. 导出常用请求方法封装
const http = {
get(url, params, config) {
return service.get(url, { params, ...config })
},
post(url, data, config) {
return service.post(url, data, config)
},
put(url, data, config) {
return service.put(url, data, config)
},
delete(url, params, config) {
return service.delete(url, { params, ...config })
},
download(url, params) {
return service.get(url, { params, responseType: 'blob' })
},
}
export default http

484
src/views/AdminIndex.vue Normal file
View File

@@ -0,0 +1,484 @@
<template>
<n-config-provider :locale="zhCN" :date-locale="dateZhCN">
<div class="admin-layout">
<!-- 顶部装饰 -->
<div class="admin-header-bg"></div>
<div class="admin-main-content">
<!-- 头部标题区 -->
<div class="page-header">
<div class="title-group">
<div class="icon-box">
<!-- 修复点 TableOutlined 换成了 ReaderOutline -->
<n-icon size="24" color="#fff">
<ReaderOutline />
</n-icon>
</div>
<div>
<h1 class="main-title">提报信息汇总中心</h1>
<p class="sub-title">管理并分配所有分析师提交的客户资料与成交记录</p>
</div>
</div>
<div class="header-actions">
<n-button secondary type="error" round @click="logout">
<template #icon><n-icon>
<LogOutOutline />
</n-icon></template>
退出系统
</n-button>
</div>
</div>
<!-- 搜索筛选卡片 -->
<n-card class="search-card" :bordered="false">
<n-form inline :model="searchParams" label-placement="left" label-width="auto" size="medium">
<n-grid :x-gap="20" :y-gap="16" cols="1 s:2 m:3 l:4 xl:5" responsive="screen">
<n-grid-item>
<n-form-item label="分析师">
<n-input v-model:value="searchParams.analystName" placeholder="搜索分析师姓名" clearable />
</n-form-item>
</n-grid-item>
<n-grid-item>
<n-form-item label="家长信息">
<n-input v-model:value="searchParams.parentInfo" placeholder="姓名 / 电话" clearable />
</n-form-item>
</n-grid-item>
<n-grid-item span="m:1 l:1 xl:1">
<n-form-item label="分配状态">
<n-select v-model:value="searchParams.status" :options="statusOptions"
placeholder="全部状态" clearable />
</n-form-item>
</n-grid-item>
<n-grid-item span="m:2 l:1 xl:1">
<n-form-item label="成交日期">
<n-date-picker v-model:value="searchParams.dateRange" type="daterange" clearable />
</n-form-item>
</n-grid-item>
<n-grid-item class="search-btns">
<n-space>
<n-button type="primary" @click="handleSearch">
<template #icon><n-icon>
<SearchOutline />
</n-icon></template>
查询
</n-button>
<n-button secondary @click="resetSearch">重置</n-button>
</n-space>
</n-grid-item>
</n-grid>
</n-form>
</n-card>
<!-- 数据表格卡片 -->
<n-card class="table-card" :bordered="false">
<template #header>
<n-space align="center">
<span class="table-title">数据列表</span>
<n-text depth="3" style="font-size: 12px; font-weight: normal;">共找到 {{ displayData.length }}
条记录</n-text>
</n-space>
</template>
<template #header-extra>
<n-button size="small" strong secondary type="primary">
<template #icon><n-icon>
<DownloadOutline />
</n-icon></template>
导出报表
</n-button>
</template>
<n-data-table :columns="columns" :data="displayData" :scroll-x="1600" :bordered="false"
:pagination="pagination" :loading="loading" :row-class-name="() => 'table-row'" />
</n-card>
</div>
<!-- 右侧详情抽屉 -->
<n-drawer v-model:show="showDrawer" :width="600" placement="right">
<n-drawer-content title="原始提报资料详情" closable>
<div class="drawer-container">
<n-space vertical :size="24">
<div class="info-group">
<div class="group-title"><n-icon>
<PersonOutline />
</n-icon> 客户及成交信息</div>
<n-descriptions label-placement="left" :column="2" bordered size="small"
class="custom-desc">
<n-descriptions-item label="分析师主管">{{ editingRow.analystSupervisor
}}</n-descriptions-item>
<n-descriptions-item label="分析师部门">{{ editingRow.analystDepartment
}}</n-descriptions-item>
<n-descriptions-item label="分析师姓名">{{ editingRow.analystName
}}</n-descriptions-item>
<n-descriptions-item label="家长姓名">{{ editingRow.parentName }}</n-descriptions-item>
<n-descriptions-item label="家长电话">{{ editingRow.parentPhone }}</n-descriptions-item>
<n-descriptions-item label="身份证号">{{ editingRow.parentIdCard
}}</n-descriptions-item>
<n-descriptions-item label="成交日期">{{ editingRow.transactionDate
}}</n-descriptions-item>
<n-descriptions-item label="成交金额"><span class="price-text">¥{{
editingRow.transactionAmount
}}</span></n-descriptions-item>
<n-descriptions-item label="指导周期" :span="2">{{ editingRow.guidancePeriod
}}</n-descriptions-item>
<n-descriptions-item label="分析师备注" :span="2">
<n-input type="textarea" v-model:value="editingRow.analystNotes"
placeholder="无额外备注" :autosize="{ minRows: 2 }" />
</n-descriptions-item>
</n-descriptions>
</div>
<div class="info-group">
<div class="group-title"><n-icon>
<FileTrayOutline />
</n-icon> 附件文档</div>
<div class="file-box" v-if="editingRow.files?.length">
<n-button block secondary type="primary" @click="downloadFile">下载附件压缩包</n-button>
</div>
<n-empty v-else description="未上传附件" size="small" />
</div>
<div class="info-group">
<div class="group-title"><n-icon>
<ImagesOutline />
</n-icon> 付款截图</div>
<n-image-group>
<n-space>
<n-image v-for="(img, i) in editingRow.paymentImages" :key="i" width="120"
height="120" fit="cover" class="preview-img" :src="img" />
</n-space>
</n-image-group>
</div>
<div class="info-group">
<div class="group-title"><n-icon>
<BrushOutline />
</n-icon> 电子签名</div>
<div class="signature-wrapper">
<n-image v-if="editingRow.signature" width="240" :src="editingRow.signature" />
<n-text v-else depth="3">暂无签名</n-text>
</div>
</div>
</n-space>
</div>
<template #footer>
<n-space justify="end">
<n-button @click="showDrawer = false">关闭</n-button>
<n-button type="primary" @click="saveDetailEdit">保存修改内容</n-button>
</n-space>
</template>
</n-drawer-content>
</n-drawer>
</div>
</n-config-provider>
</template>
<script setup>
import { ref, h, reactive } from 'vue'
import { useRouter } from 'vue-router'
import {
useMessage, zhCN, dateZhCN, NConfigProvider, NCard, NDataTable, NButton, NIcon, NInput,
NDrawer, NDrawerContent, NSpace, NDescriptions, NDescriptionsItem, NImage, NImageGroup,
NText, NForm, NFormItem, NGrid, NGridItem, NDatePicker, NSelect, NTag, NEmpty
} from 'naive-ui'
// 修复导入项:去掉了不存在的 TableOutlined换成了 ReaderOutline
import {
SearchOutline, LogOutOutline, DownloadOutline, PersonOutline, FileTrayOutline,
ImagesOutline, BrushOutline, ReaderOutline, EyeOutline
} from '@vicons/ionicons5'
const router = useRouter()
const message = useMessage()
const loading = ref(false)
const searchParams = reactive({
analystName: '',
parentInfo: '',
dateRange: null,
status: null
})
const statusOptions = [
{ label: '已分配指导师', value: 'assigned' },
{ label: '待处理', value: 'pending' }
]
const allTableData = ref([
{
id: 1,
analystSupervisor: '王主管',
analystDepartment: '市场二部',
analystName: '张三',
parentName: '李四家长',
parentPhone: '13800000000',
parentIdCard: '110105199001010011',
transactionDate: '2023-10-24',
transactionAmount: 9800,
guidancePeriod: '一学期',
analystNotes: '需要周六上课',
guideName: '王老师',
guidePhone: '13900001111',
paymentImages: ['https://07akioni.oss-cn-beijing.aliyuncs.com/07akioni.jpeg'],
signature: 'https://gw.alipayobjects.com/zos/rmsportal/KDpgvguMpGfqaHPjicRK.svg'
},
{
id: 2,
analystSupervisor: '赵主管',
analystDepartment: '市场一部',
analystName: '李小龙',
parentName: '陈家长',
parentPhone: '15900002222',
transactionDate: '2023-11-05',
transactionAmount: 5000,
guideName: '',
guidePhone: '',
paymentImages: [],
signature: ''
}
])
const displayData = ref([...allTableData.value])
const handleSearch = () => {
loading.value = true
setTimeout(() => {
displayData.value = allTableData.value.filter(item => {
const matchAnalyst = item.analystName.includes(searchParams.analystName)
const matchParent = item.parentName.includes(searchParams.parentInfo) || item.parentPhone.includes(searchParams.parentInfo)
let matchStatus = true
if (searchParams.status === 'assigned') matchStatus = !!item.guideName
if (searchParams.status === 'pending') matchStatus = !item.guideName
return matchAnalyst && matchParent && matchStatus
})
loading.value = false
}, 400)
}
const resetSearch = () => {
Object.assign(searchParams, { analystName: '', parentInfo: '', dateRange: null, status: null })
displayData.value = [...allTableData.value]
}
const columns = [
{ title: '分析师姓名', key: 'analystName', width: 120, fixed: 'left' },
{ title: '家长姓名', key: 'parentName', width: 120 },
{ title: '家长电话', key: 'parentPhone', width: 140 },
{
title: '状态',
key: 'status',
width: 120,
render: (row) => {
const isAssigned = !!row.guideName
return h(NTag, { type: isAssigned ? 'success' : 'warning', round: true, size: 'small' }, {
default: () => isAssigned ? '已分配' : '待处理'
})
}
},
{ title: '成交日期', key: 'transactionDate', width: 130, sorter: (a, b) => new Date(a.transactionDate) - new Date(b.transactionDate) },
{
title: '成交金额',
key: 'transactionAmount',
width: 120,
render: (row) => h('span', { style: 'font-weight: bold; color: #d03050' }, `¥${row.transactionAmount.toLocaleString()}`)
},
{
title: '分配指导师',
key: 'guideName',
width: 180,
render: (row) => h(NInput, {
value: row.guideName,
placeholder: '输入师名...',
size: 'small',
onUpdateValue: (v) => { row.guideName = v }
})
},
{
title: '指导师电话',
key: 'guidePhone',
width: 180,
render: (row) => h(NInput, {
value: row.guidePhone,
placeholder: '输入电话...',
size: 'small',
onUpdateValue: (v) => { row.guidePhone = v }
})
},
{
title: '操作',
key: 'op',
width: 100,
fixed: 'right',
align: 'center',
render: (row) => h(NButton, {
type: 'primary',
quaternary: true,
size: 'small',
onClick: () => openDetails(row)
}, {
default: () => '详情',
icon: () => h(NIcon, null, { default: () => h(EyeOutline) })
})
}
]
const showDrawer = ref(false)
const editingRow = ref({})
const openDetails = (row) => {
editingRow.value = JSON.parse(JSON.stringify(row))
showDrawer.value = true
}
const saveDetailEdit = () => {
const idx = allTableData.value.findIndex(item => item.id === editingRow.value.id)
if (idx !== -1) {
allTableData.value[idx].analystNotes = editingRow.value.analystNotes
message.success('保存成功')
showDrawer.value = false
}
}
const pagination = reactive({ pageSize: 10 })
const logout = () => { localStorage.clear(); router.push('/login') }
const downloadFile = () => { message.loading('正在打包...') }
</script>
<style scoped>
.admin-layout {
min-height: 100vh;
background-color: #f6f8fa;
position: relative;
}
.admin-header-bg {
position: absolute;
top: 0;
left: 0;
right: 0;
height: 180px;
background: linear-gradient(135deg, #18a058 0%, #20804e 100%);
z-index: 0;
}
.admin-main-content {
position: relative;
z-index: 1;
max-width: 1600px;
margin: 0 auto;
padding: 30px 24px;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: flex-end;
margin-bottom: 24px;
color: #fff;
}
.title-group {
display: flex;
align-items: center;
gap: 16px;
}
.icon-box {
width: 54px;
height: 54px;
background: rgba(255, 255, 255, 0.2);
backdrop-filter: blur(10px);
border-radius: 14px;
display: flex;
align-items: center;
justify-content: center;
}
.main-title {
margin: 0;
font-size: 24px;
font-weight: 600;
}
.sub-title {
margin: 4px 0 0 0;
opacity: 0.8;
font-size: 14px;
}
.search-card {
border-radius: 12px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.05);
margin-bottom: 20px;
}
.search-btns {
display: flex;
justify-content: flex-end;
align-items: center;
}
.table-card {
border-radius: 12px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.05);
}
.table-title {
font-size: 16px;
font-weight: 600;
}
.drawer-container {
padding: 10px 4px;
}
.info-group {
background: #fcfcfd;
border: 1px solid #f0f0f5;
border-radius: 10px;
padding: 16px;
}
.group-title {
display: flex;
align-items: center;
gap: 8px;
font-size: 15px;
font-weight: 600;
color: #18a058;
margin-bottom: 16px;
}
.custom-desc :deep(.n-descriptions-table-header) {
background-color: #f9fafb;
font-weight: 500;
width: 100px;
}
.price-text {
color: #d03050;
font-weight: bold;
font-size: 15px;
}
.preview-img {
border-radius: 8px;
cursor: zoom-in;
transition: transform 0.2s;
}
.preview-img:hover {
transform: scale(1.05);
}
.signature-wrapper {
background: #fff;
border: 1px dashed #e0e0e0;
border-radius: 8px;
padding: 20px;
display: flex;
justify-content: center;
}
:deep(.n-data-table .n-data-table-td) {
vertical-align: middle;
}
</style>

64
src/views/Login.vue Normal file
View File

@@ -0,0 +1,64 @@
<template>
<div class="login-container">
<div class="login-card">
<div class="login-left">
<h1>提报管理系统</h1>
<p>高效 · 安全 · 数字化</p>
</div>
<div class="login-right">
<h2>欢迎登录</h2>
<n-space vertical size="large">
<n-button type="primary" size="large" block @click="login('user')">提报端 (分析师入口)</n-button>
<n-button secondary type="primary" size="large" block @click="login('admin')">管理端 (管理员入口)</n-button>
</n-space>
</div>
</div>
</div>
</template>
<script setup>
import { useRouter } from 'vue-router'
const router = useRouter()
const login = (role) => {
localStorage.setItem('userRole', role)
router.push(role === 'admin' ? '/admin' : '/user')
}
</script>
<style scoped>
.login-container {
height: 100vh;
display: flex;
justify-content: center;
align-items: center;
background: #eef2f5;
}
.login-card {
display: flex;
width: 700px;
height: 400px;
background: #fff;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1);
}
.login-left {
flex: 1;
background: #36ad6a;
color: #fff;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
.login-right {
flex: 1;
padding: 50px;
display: flex;
flex-direction: column;
justify-content: center;
}
</style>

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>

18
tsconfig.app.json Normal file
View File

@@ -0,0 +1,18 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
"exclude": ["src/**/__tests__/*"],
"compilerOptions": {
// Extra safety for array and object lookups, but may have false positives.
"noUncheckedIndexedAccess": true,
// Path mapping for cleaner imports.
"paths": {
"@/*": ["./src/*"]
},
// `vue-tsc --build` produces a .tsbuildinfo file for incremental type-checking.
// Specified here to keep it out of the root directory.
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo"
}
}

11
tsconfig.json Normal file
View File

@@ -0,0 +1,11 @@
{
"files": [],
"references": [
{
"path": "./tsconfig.node.json"
},
{
"path": "./tsconfig.app.json"
}
]
}

28
tsconfig.node.json Normal file
View File

@@ -0,0 +1,28 @@
// TSConfig for modules that run in Node.js environment via either transpilation or type-stripping.
{
"extends": "@tsconfig/node24/tsconfig.json",
"include": [
"vite.config.*",
"vitest.config.*",
"cypress.config.*",
"nightwatch.conf.*",
"playwright.config.*",
"eslint.config.*"
],
"compilerOptions": {
// Most tools use transpilation instead of Node.js's native type-stripping.
// Bundler mode provides a smoother developer experience.
"module": "preserve",
"moduleResolution": "bundler",
// Include Node.js types and avoid accidentally including other `@types/*` packages.
"types": ["node"],
// Disable emitting output during `vue-tsc --build`, which is used for type-checking only.
"noEmit": true,
// `vue-tsc --build` produces a .tsbuildinfo file for incremental type-checking.
// Specified here to keep it out of the root directory.
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo"
}
}

18
vite.config.ts Normal file
View File

@@ -0,0 +1,18 @@
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import vueDevTools from 'vite-plugin-vue-devtools'
// https://vite.dev/config/
export default defineConfig({
plugins: [
vue(),
vueDevTools(),
],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
},
},
})