feat: 初始化提报管理系统前端项目
- 添加基础项目结构及核心功能模块 - 实现用户登录界面及权限控制 - 完成分析师提报表单和管理员数据表格功能 - 配置Vue3 + Vite + Naive UI技术栈 - 集成Pinia状态管理和路由系统 - 添加axios请求封装及全局拦截器
This commit is contained in:
39
.gitignore
vendored
Normal file
39
.gitignore
vendored
Normal 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
6
.prettierrc.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/prettierrc",
|
||||
"semi": false,
|
||||
"singleQuote": true,
|
||||
"printWidth": 100
|
||||
}
|
||||
6
.vscode/extensions.json
vendored
Normal file
6
.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"Vue.volar",
|
||||
"esbenp.prettier-vscode"
|
||||
]
|
||||
}
|
||||
42
README.md
Normal file
42
README.md
Normal 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
|
||||
```
|
||||
16
index.html
Normal file
16
index.html
Normal 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
39
package.json
Normal 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
2558
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
BIN
public/favicon.ico
Normal file
BIN
public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
23
src/App.vue
Normal file
23
src/App.vue
Normal 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
0
src/api/user.js
Normal file
7
src/main.js
Normal file
7
src/main.js
Normal 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
25
src/router/index.js
Normal 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
12
src/stores/counter.ts
Normal 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
129
src/utils/request.js
Normal 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
484
src/views/AdminIndex.vue
Normal 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
64
src/views/Login.vue
Normal 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
665
src/views/UserHome.vue
Normal 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
18
tsconfig.app.json
Normal 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
11
tsconfig.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.node.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.app.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
28
tsconfig.node.json
Normal file
28
tsconfig.node.json
Normal 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
18
vite.config.ts
Normal 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))
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user