feat: 重构前端架构并实现企业微信登录集成
- 移除旧的登录页面和计数器store,新增用户store管理登录状态 - 集成企业微信OAuth2.0登录流程,实现自动授权和token管理 - 重构路由守卫逻辑,支持角色权限控制和异常处理 - 优化HTTP请求模块,修复响应数据处理问题并增加取消请求处理 - 重写App.vue作为全局授权中心,添加加载状态和错误兜底界面 - 重构用户主页UI,采用现代化设计并优化表单交互 - 更新文件上传逻辑,确保与后端API兼容
This commit is contained in:
204
src/App.vue
204
src/App.vue
@@ -1,9 +1,28 @@
|
||||
<template>
|
||||
<!-- 全局配置:中文语言包、全局消息提示 -->
|
||||
<n-config-provider :locale="zhCN" :date-locale="dateZhCN">
|
||||
<n-message-provider>
|
||||
<n-dialog-provider>
|
||||
<router-view />
|
||||
|
||||
<!-- 1. 全局鉴权加载状态 -->
|
||||
<div v-if="isLoading" class="global-auth-container">
|
||||
<div class="spinner"></div>
|
||||
<div class="loading-text">正在验证身份及获取权限...</div>
|
||||
</div>
|
||||
|
||||
<!-- 2. 鉴权失败 / 死循环阻断时的兜底状态 -->
|
||||
<div v-else-if="authFailed" class="global-auth-container">
|
||||
<div class="error-card">
|
||||
<h2>身份验证失败</h2>
|
||||
<p>未能自动完成企业微信授权,请尝试手动重新登录。</p>
|
||||
<n-button type="primary" size="large" @click="manualWechatLogin">
|
||||
重新尝试企微登录
|
||||
</n-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 3. 鉴权通过,正常渲染路由页面 -->
|
||||
<router-view v-else />
|
||||
|
||||
</n-dialog-provider>
|
||||
</n-message-provider>
|
||||
<n-global-style />
|
||||
@@ -11,13 +30,192 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { NConfigProvider, NMessageProvider, NDialogProvider, NGlobalStyle, zhCN, dateZhCN } from 'naive-ui'
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { NConfigProvider, NMessageProvider, NDialogProvider, NGlobalStyle, NButton, zhCN, dateZhCN } from 'naive-ui'
|
||||
import { useUserStore } from '@/stores/user' // 确保路径正确
|
||||
|
||||
const REDIRECT_URI = 'https://superdata.nycjy.cn'
|
||||
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
|
||||
// 全局状态
|
||||
const isLoading = ref(true) // 是否正在全屏加载验证
|
||||
const authFailed = ref(false) // 验证是否失败(用于展示兜底按钮)
|
||||
|
||||
onMounted(async () => {
|
||||
// 1. 检查本地是否已经登录
|
||||
if (userStore.isAuthenticated) {
|
||||
const role = userStore.getUserRole || localStorage.getItem('userRole')
|
||||
isLoading.value = false
|
||||
|
||||
// 如果用户是在根目录,则按角色重定向;否则留在当前页面(支持深度链接)
|
||||
const currentPath = window.location.pathname
|
||||
if (currentPath === '/' || currentPath === '/login') {
|
||||
await router.replace(role === 'admin' ? '/admin' : '/user')
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 2. 获取 URL 中的 Code 参数
|
||||
const urlParams = new URLSearchParams(window.location.search)
|
||||
const code = urlParams.get('code')
|
||||
|
||||
// 3. 逻辑分流:无 Code 则去授权,有 Code 则去服务端验证
|
||||
if (!code) {
|
||||
const redirectCount = parseInt(sessionStorage.getItem('wecom_redirect_count') || '0')
|
||||
|
||||
if (redirectCount >= 2) {
|
||||
console.warn('🚨 拦截到可能的重定向死循环,已阻断请求')
|
||||
sessionStorage.removeItem('wecom_redirect_count')
|
||||
isLoading.value = false
|
||||
authFailed.value = true // 超过阈值,展示兜底界面
|
||||
return
|
||||
}
|
||||
|
||||
sessionStorage.setItem('wecom_redirect_count', (redirectCount + 1).toString())
|
||||
await redirectToWechat()
|
||||
|
||||
} else {
|
||||
sessionStorage.removeItem('wecom_redirect_count')
|
||||
|
||||
try {
|
||||
const response = await fetch('https://superdata.nycjy.cn/api/v1/auth/wecom/login', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'accept': 'application/json',
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ code: code })
|
||||
})
|
||||
const resData = await response.json()
|
||||
|
||||
if (resData.data && resData.data.token) {
|
||||
const role = resData.data.user_info?.role || 'user'
|
||||
|
||||
userStore.setUser(resData.data.token, {
|
||||
name: resData.data.user_info?.name,
|
||||
role: role,
|
||||
userid: resData.data.user_info?.userid
|
||||
})
|
||||
localStorage.setItem('userRole', role)
|
||||
|
||||
// 彻底清除 URL 上的 code,防止刷新页面报错
|
||||
window.history.replaceState({}, document.title, window.location.pathname)
|
||||
|
||||
// 验证成功,解除全局拦截,开始渲染路由
|
||||
isLoading.value = false
|
||||
|
||||
// 根据角色进行路由跳转
|
||||
await router.replace(role === 'admin' ? '/admin' : '/user')
|
||||
|
||||
} else {
|
||||
throw new Error(resData.message || '授权换取失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('登录失败:', error)
|
||||
window.history.replaceState({}, document.title, window.location.pathname)
|
||||
userStore.logout()
|
||||
|
||||
isLoading.value = false
|
||||
authFailed.value = true // 验证异常,展示兜底页面
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// 获取企微授权链接并跳转
|
||||
const redirectToWechat = async () => {
|
||||
try {
|
||||
const res = await fetch('https://superdata.nycjy.cn/api/v1/auth/wecom/url?redirect_uri=' + encodeURIComponent(REDIRECT_URI), {
|
||||
method: 'GET',
|
||||
headers: { 'accept': 'application/json' }
|
||||
})
|
||||
const resData = await res.json()
|
||||
|
||||
if (resData && resData.login_url) {
|
||||
window.location.replace(resData.login_url)
|
||||
} else {
|
||||
throw new Error('未能获取企业微信授权链接')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取授权链接失败:', error)
|
||||
isLoading.value = false
|
||||
authFailed.value = true // 网络异常等,展示兜底UI
|
||||
}
|
||||
}
|
||||
|
||||
// 页面按钮手动触发企微登录
|
||||
const manualWechatLogin = () => {
|
||||
isLoading.value = true
|
||||
authFailed.value = false
|
||||
sessionStorage.removeItem('wecom_redirect_count')
|
||||
redirectToWechat()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* 全局基础样式 */
|
||||
body {
|
||||
margin: 0;
|
||||
background-color: #f5f7fa;
|
||||
font-family: v-sans, system-ui, -apple-system;
|
||||
}
|
||||
|
||||
/* 鉴权拦截状态容器 */
|
||||
.global-auth-container {
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background: #eef2f5;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
/* 错误兜底卡片 */
|
||||
.error-card {
|
||||
background: #fff;
|
||||
padding: 40px;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.05);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.error-card h2 {
|
||||
margin-top: 0;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.error-card p {
|
||||
color: #666;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
/* 加载动画 */
|
||||
.spinner {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border: 4px solid #d1d5db;
|
||||
border-top: 4px solid #36ad6a;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
color: #666;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
</style>
|
||||
10
src/main.js
10
src/main.js
@@ -1,7 +1,13 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia' // 1. 引入 createPinia
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import router from './router/index.js'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
// 2. 创建 pinia 实例
|
||||
const pinia = createPinia()
|
||||
|
||||
app.use(router)
|
||||
app.mount('#app')
|
||||
app.use(pinia) // 3. 现在这里就不会报错了
|
||||
app.mount('#app')
|
||||
@@ -1,10 +1,15 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
|
||||
const routes = [
|
||||
{ path: '/', redirect: '/login' },
|
||||
{ path: '/login', component: () => import('../views/Login.vue') },
|
||||
const routes =[
|
||||
// 根路径作为登录和企微回调的中转站。
|
||||
// 因为 App.vue 中没验证完时 <router-view> 不会渲染,所以这里给个空的 template 即可
|
||||
{ path: '/', name: 'Root', component: { template: '<div></div>' } },
|
||||
|
||||
{ path: '/user', component: () => import('../views/UserHome.vue') },
|
||||
{ path: '/admin', component: () => import('../views/AdminIndex.vue') },
|
||||
|
||||
// 捕获所有未匹配的路由,重定向到根路径
|
||||
{ path: '/:pathMatch(.*)*', redirect: '/' }
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
@@ -12,14 +17,22 @@ const router = createRouter({
|
||||
routes,
|
||||
})
|
||||
|
||||
// 路由守卫:校验权限
|
||||
// 路由守卫:校验权限与角色
|
||||
router.beforeEach((to, from, next) => {
|
||||
const role = localStorage.getItem('userRole')
|
||||
if (to.path !== '/login' && !role) {
|
||||
next('/login')
|
||||
} else {
|
||||
|
||||
// 1. 如果没有角色信息,且访问的不是根路径(企微回调路径),则拦截到根路径让 App.vue 处理授权
|
||||
if (!role && to.path !== '/') {
|
||||
next('/')
|
||||
}
|
||||
// 2. 权限越界拦截:普通用户(user)尝试访问管理端(/admin),强制拉回到用户端
|
||||
else if (role === 'user' && to.path.startsWith('/admin')) {
|
||||
next('/user')
|
||||
}
|
||||
// 3. 正常放行
|
||||
else {
|
||||
next()
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
export default router
|
||||
@@ -1,12 +0,0 @@
|
||||
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 }
|
||||
})
|
||||
39
src/stores/user.js
Normal file
39
src/stores/user.js
Normal file
@@ -0,0 +1,39 @@
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
export const useUserStore = defineStore('user', {
|
||||
state: () => ({
|
||||
token: '',
|
||||
userInfo: {
|
||||
name: '',
|
||||
role: '',
|
||||
userid: ''
|
||||
}
|
||||
}),
|
||||
getters: {
|
||||
isAuthenticated: (state) => !!state.token && state.token.length > 0,
|
||||
getUserName: (state) => state.userInfo.name,
|
||||
getUserRole: (state) => state.userInfo.role,
|
||||
getUserId: (state) => state.userInfo.userid
|
||||
},
|
||||
actions: {
|
||||
setToken(token) {
|
||||
this.token = token
|
||||
},
|
||||
setUserInfo(userInfo) {
|
||||
this.userInfo = {
|
||||
name: userInfo.name || '',
|
||||
role: userInfo.role || '',
|
||||
userid: userInfo.userid || ''
|
||||
}
|
||||
},
|
||||
setUser(token, userInfo) {
|
||||
this.setToken(token)
|
||||
this.setUserInfo(userInfo)
|
||||
},
|
||||
logout() {
|
||||
this.token = ''
|
||||
this.userInfo = { name: '', role: '', userid: '' }
|
||||
}
|
||||
},
|
||||
persist: true
|
||||
})
|
||||
106
src/utils/http.js
Normal file
106
src/utils/http.js
Normal file
@@ -0,0 +1,106 @@
|
||||
import axios from 'axios'
|
||||
|
||||
// 1. 创建 axios 实例
|
||||
const service = axios.create({
|
||||
baseURL: 'https://superdata.nycjy.cn/api' || '',
|
||||
timeout: 150000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json;charset=utf-8',
|
||||
},
|
||||
})
|
||||
|
||||
// 2. 请求拦截器
|
||||
service.interceptors.request.use(
|
||||
(config) => {
|
||||
// 【规范】:统一使用 'token' 作为本地存储的 Key
|
||||
const token = localStorage.getItem('token')
|
||||
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
return config
|
||||
},
|
||||
(error) => {
|
||||
console.error('Request Error:', error)
|
||||
return Promise.reject(error)
|
||||
},
|
||||
)
|
||||
|
||||
// 3. 响应拦截器
|
||||
service.interceptors.response.use(
|
||||
(response) => {
|
||||
// 获取后端返回的数据 (例如: { code: 200, success: true, data: [...], pagination: {...} })
|
||||
const res = response.data
|
||||
|
||||
if (response.config.responseType === 'blob' || response.config.responseType === 'arraybuffer') {
|
||||
return res
|
||||
}
|
||||
|
||||
// 处理后端的业务报错 (假设有 code 且不为 200 即为异常)
|
||||
if (res.code && res.code !== 200) {
|
||||
console.error(res.message || '系统异常')
|
||||
|
||||
if (res.code === 401) {
|
||||
localStorage.removeItem('token')
|
||||
window.location.href = '/login'
|
||||
}
|
||||
return Promise.reject(new Error(res.message || 'Error'))
|
||||
}
|
||||
|
||||
// ✅ 【关键修复 1】:直接返回完整的 res,不要只返回 res.data
|
||||
// 否则 AdminIndex.vue 里的 if (res.success) 会永远是 undefined 导致列表渲染失败
|
||||
return res
|
||||
},
|
||||
(error) => {
|
||||
// ✅ 【关键修复 2】:静默处理浏览器/Vue Router 中止引起的被取消的请求,防止弹窗报错
|
||||
if (axios.isCancel(error) || error.message === 'canceled') {
|
||||
console.log('请求被中止(canceled),已静默拦截:', error.message)
|
||||
return Promise.reject(error)
|
||||
}
|
||||
|
||||
// HTTP 网络错误统一处理
|
||||
const status = error?.response?.status // 使用可选链,防止空指针
|
||||
let errorMessage = '网络请求异常'
|
||||
|
||||
switch (status) {
|
||||
case 400: errorMessage = '请求参数错误'; break;
|
||||
case 401:
|
||||
errorMessage = '未授权,请重新登录';
|
||||
localStorage.removeItem('token');
|
||||
window.location.href = '/login';
|
||||
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
|
||||
@@ -1,129 +0,0 @@
|
||||
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
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,64 +0,0 @@
|
||||
<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>
|
||||
@@ -1,216 +1,179 @@
|
||||
<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>
|
||||
<!-- 1. 配置中文语言包及自定义主题圆角 -->
|
||||
<n-config-provider :locale="zhCN" :date-locale="dateZhCN" :theme-overrides="themeOverrides">
|
||||
<div class="page-wrapper">
|
||||
<!-- 顶部装饰背景(动态渐变) -->
|
||||
<div class="header-banner">
|
||||
<div class="banner-content">
|
||||
<div class="logo-area">
|
||||
<div class="icon-hexagon">
|
||||
<n-icon size="32" color="#fff">
|
||||
<AnalyticsOutline />
|
||||
</n-icon>
|
||||
</div>
|
||||
<div class="text-group">
|
||||
<h1>材料提报中心</h1>
|
||||
<p>Transaction Material Reporting Center</p>
|
||||
</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-button class="logout-btn" secondary type="error" round @click="logout">
|
||||
<template #icon>
|
||||
<n-icon><LogOutOutline /></n-icon>
|
||||
</template>
|
||||
退出系统
|
||||
</n-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="main-content">
|
||||
<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 }" />
|
||||
|
||||
<!-- 模块 1: 基础信息卡片 -->
|
||||
<div class="glass-card section-card">
|
||||
<div class="card-header">
|
||||
<div class="header-indicator"></div>
|
||||
<n-icon size="22"><IdCardOutline /></n-icon>
|
||||
<h3>1. 客户及成交信息</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<n-grid :x-gap="24" :y-gap="0" 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="11位手机号" 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>
|
||||
<n-form-item label="分析师备注" class="mt-8">
|
||||
<n-input v-model:value="formData.analystNotes" type="textarea" placeholder="填写备注信息(选填)" :autosize="{ minRows: 2, maxRows: 4 }" />
|
||||
</n-form-item>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 模块 2 & 4: 附件与签名并排 (响应式) -->
|
||||
<n-grid :x-gap="20" :y-gap="20" cols="1 m:2" responsive="screen" class="mt-20">
|
||||
<n-grid-item>
|
||||
<div class="glass-card section-card full-h">
|
||||
<div class="card-header">
|
||||
<div class="header-indicator"></div>
|
||||
<n-icon size="22"><DocumentAttachOutline /></n-icon>
|
||||
<h3>2. 附件文档 (限1份)</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<n-upload :max="1" directory-dnd v-model:file-list="documentFileListLast"
|
||||
@preview="handlePreview"
|
||||
:custom-request="({ file, onFinish, onError, onProgress }) => handleCustomUpload({ file, onFinish, onError, onProgress }, 'documentFileList')">
|
||||
<n-upload-dragger class="modern-dragger">
|
||||
<div class="dragger-icon">
|
||||
<n-icon size="44" depth="3"><CloudUploadOutline /></n-icon>
|
||||
</div>
|
||||
<n-text class="dragger-text">点击或拖拽上传</n-text>
|
||||
<n-p depth="3" class="dragger-subtext">支持 PDF, DOCX, XLSX (Max 50MB)</n-p>
|
||||
</n-upload-dragger>
|
||||
</n-upload>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 模块 2: 附件文档 (仅限1份) -->
|
||||
<div class="section">
|
||||
<div class="section-header">
|
||||
<n-icon class="section-icon" size="20">
|
||||
<DocumentAttachOutline />
|
||||
</n-icon>
|
||||
<span>2. 附件文档 (限1份)</span>
|
||||
</div>
|
||||
<div class="section-body">
|
||||
<n-upload :max="1" directory-dnd v-model:file-list="documentFileListLast"
|
||||
@preview="handlePreview"
|
||||
:custom-request="({ file, onFinish, onError, onProgress }) => handleCustomUpload({ file, onFinish, onError, onProgress }, 'documentFileList')">
|
||||
<n-upload-dragger class="custom-dragger">
|
||||
<div class="dragger-icon">
|
||||
<n-icon size="48" :depth="3">
|
||||
<CloudUploadOutline />
|
||||
</n-icon>
|
||||
</n-grid-item>
|
||||
<n-grid-item>
|
||||
<div class="glass-card section-card full-h">
|
||||
<div class="card-header">
|
||||
<div class="header-indicator"></div>
|
||||
<n-icon size="22"><BrushOutline /></n-icon>
|
||||
<h3>4. 电子签名 (限1张)</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<n-upload class="signature-modern-upload" accept="image/*" :max="1"
|
||||
list-type="image-card" v-model:file-list="signatureFileListLast"
|
||||
@preview="handlePreview"
|
||||
:custom-request="({ file, onFinish, onError, onProgress }) => handleCustomUpload({ file, onFinish, onError, onProgress }, 'signatureFileList')">
|
||||
<div class="signature-placeholder">
|
||||
<n-icon size="28" depth="3"><CreateOutline /></n-icon>
|
||||
<span>上传手写签名</span>
|
||||
</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>
|
||||
</n-upload>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</n-grid-item>
|
||||
</n-grid>
|
||||
|
||||
<!-- 模块 3: 付款截图 (可上传多张) -->
|
||||
<div class="section">
|
||||
<div class="section-header">
|
||||
<n-icon class="section-icon" size="20">
|
||||
<ImagesOutline />
|
||||
</n-icon>
|
||||
<span>3. 付款截图 (可上传多张)</span>
|
||||
</div>
|
||||
<div class="section-body">
|
||||
<n-upload class="custom-multi-upload" accept="image/*" multiple list-type="image-card"
|
||||
v-model:file-list="paymentFileListLast" @preview="handlePreview"
|
||||
:custom-request="({ file, onFinish, onError, onProgress }) => handleCustomUpload({ file, onFinish, onError, onProgress }, 'paymentFileList')">
|
||||
<div class="upload-placeholder">
|
||||
<div class="icon-bg">
|
||||
<n-icon size="28" color="#666">
|
||||
<CameraOutline />
|
||||
</n-icon>
|
||||
</div>
|
||||
<span class="placeholder-text">上传凭证</span>
|
||||
</div>
|
||||
</n-upload>
|
||||
</div>
|
||||
<!-- 模块 3: 付款截图 -->
|
||||
<div class="glass-card section-card mt-20">
|
||||
<div class="card-header">
|
||||
<div class="header-indicator"></div>
|
||||
<n-icon size="22"><ImagesOutline /></n-icon>
|
||||
<h3>3. 付款截图凭证 (可多张)</h3>
|
||||
</div>
|
||||
|
||||
<!-- 模块 4: 电子签名 (仅限1张) -->
|
||||
<div class="section">
|
||||
<div class="section-header">
|
||||
<n-icon class="section-icon" size="20">
|
||||
<BrushOutline />
|
||||
</n-icon>
|
||||
<span>4. 电子签名 (限1张)</span>
|
||||
</div>
|
||||
<div class="section-body">
|
||||
<n-upload class="signature-upload-container" accept="image/*" :max="1"
|
||||
list-type="image-card" v-model:file-list="signatureFileListLast"
|
||||
@preview="handlePreview"
|
||||
:custom-request="({ file, onFinish, onError, onProgress }) => handleCustomUpload({ file, onFinish, onError, onProgress }, 'signatureFileList')">
|
||||
<div class="upload-placeholder signature-placeholder">
|
||||
<n-icon size="32" depth="3">
|
||||
<CreateOutline />
|
||||
</n-icon>
|
||||
<n-text depth="3" style="font-size: 14px; margin-top: 8px;">点击上传手写签名</n-text>
|
||||
</div>
|
||||
</n-upload>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<n-upload class="payment-modern-upload" accept="image/*" multiple list-type="image-card"
|
||||
v-model:file-list="paymentFileListLast" @preview="handlePreview"
|
||||
:custom-request="({ file, onFinish, onError, onProgress }) => handleCustomUpload({ file, onFinish, onError, onProgress }, 'paymentFileList')">
|
||||
<div class="payment-placeholder">
|
||||
<n-icon size="32"><CameraOutline /></n-icon>
|
||||
<span>添加凭证</span>
|
||||
</div>
|
||||
</n-upload>
|
||||
</div>
|
||||
</n-space>
|
||||
</div>
|
||||
</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>
|
||||
<!-- 底部悬浮操作栏 -->
|
||||
<div class="footer-actions-wrapper">
|
||||
<div class="footer-blur-bg"></div>
|
||||
<div class="footer-content">
|
||||
<n-button size="large" round @click="handleCancel" class="btn-cancel">
|
||||
取消录入
|
||||
</n-button>
|
||||
<n-button size="large" type="primary" class="action-btn submit-btn" @click="handleSubmit">
|
||||
<n-button size="large" type="primary" round @click="handleSubmit" class="btn-submit">
|
||||
<template #icon>
|
||||
<n-icon>
|
||||
<CheckmarkCircleOutline />
|
||||
</n-icon>
|
||||
<n-icon><CheckmarkCircleOutline /></n-icon>
|
||||
</template>
|
||||
提交资料
|
||||
提交材料至中心
|
||||
</n-button>
|
||||
</div>
|
||||
</template>
|
||||
</n-card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</n-config-provider>
|
||||
</template>
|
||||
@@ -230,17 +193,29 @@ import {
|
||||
CreateOutline, CloseOutline, CheckmarkCircleOutline
|
||||
} from '@vicons/ionicons5'
|
||||
|
||||
import http from '@/utils/request'
|
||||
import http from '@/utils/http'
|
||||
|
||||
/**
|
||||
* 主题定制:优化圆角和品牌色
|
||||
*/
|
||||
const themeOverrides = {
|
||||
common: {
|
||||
primaryColor: '#18a058',
|
||||
primaryColorHover: '#36ad6a',
|
||||
primaryColorPressed: '#0c7a43',
|
||||
borderRadius: '10px'
|
||||
}
|
||||
}
|
||||
|
||||
const message = useMessage()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const formRef = ref(null)
|
||||
|
||||
// 从路由中获取 wecom_id,后续提交时需要
|
||||
// 保持原逻辑:获取 wecom_id
|
||||
const wecomId = route.query.wecom_id || 'wmcr-ECwAAzKclEfIKNcVgOdxD-TcqLg'
|
||||
|
||||
// 响应式表单数据
|
||||
// 保持原逻辑:响应式表单数据
|
||||
const formData = reactive({
|
||||
analystSupervisor: '',
|
||||
analystDepartment: '',
|
||||
@@ -252,18 +227,17 @@ const formData = reactive({
|
||||
transactionAmount: null,
|
||||
guidancePeriod: '',
|
||||
analystNotes: '',
|
||||
// 存储 UI 显示用的文件列表
|
||||
documentFileList: [],
|
||||
paymentFileList: [],
|
||||
signatureFileList: []
|
||||
})
|
||||
|
||||
// 临时文件列表,用于上传前预览
|
||||
// 保持原逻辑:文件预览列表
|
||||
const documentFileListLast = ref([])
|
||||
const paymentFileListLast = ref([])
|
||||
const signatureFileListLast = ref([])
|
||||
|
||||
// 定义校验规则
|
||||
// 保持原逻辑:校验规则
|
||||
const rules = {
|
||||
analystSupervisor: { required: true, message: '请输入主管姓名', trigger: 'blur' },
|
||||
parentName: { required: true, message: '请输入家长姓名', trigger: 'blur' },
|
||||
@@ -271,9 +245,7 @@ const rules = {
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化日期:将时间戳转换为 YYYY-MM-DD 格式
|
||||
* @param {number} timestamp - The date timestamp.
|
||||
* @returns {string|null} - Formatted date string or null.
|
||||
* 保持原逻辑:格式化日期
|
||||
*/
|
||||
const formatDate = (timestamp) => {
|
||||
if (!timestamp) return null;
|
||||
@@ -284,17 +256,14 @@ const formatDate = (timestamp) => {
|
||||
return `${year}-${month}-${day}`;
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* 核心:通用文件上传逻辑
|
||||
* 直接把后端返回的数据挂载到文件对象的 rawResponse 字段,完全隔离前端生成的 id/fullPath
|
||||
* 保持原逻辑:通用文件上传逻辑
|
||||
*/
|
||||
const handleCustomUpload = async ({ file, onFinish, onError, onProgress }, type) => {
|
||||
try {
|
||||
const uploadData = new FormData()
|
||||
uploadData.append('file', file.file)
|
||||
|
||||
// 注意:这里的上传接口地址可能需要根据您的实际情况修改
|
||||
const response = await http.post('/v1/material/upload', uploadData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
onUploadProgress: (p) => onProgress({ percent: Math.ceil((p.loaded / p.total) * 100) })
|
||||
@@ -325,14 +294,12 @@ const handleCustomUpload = async ({ file, onFinish, onError, onProgress }, type)
|
||||
}
|
||||
|
||||
/**
|
||||
* 核心:初始化数据回显
|
||||
* 保持原逻辑:初始化数据回显
|
||||
*/
|
||||
const initData = async () => {
|
||||
try {
|
||||
// 注意:这里的获取信息接口地址可能需要根据您的实际情况修改
|
||||
const res = await http.get('/v1/customer/get_customers_info', { wecom_id: wecomId })
|
||||
|
||||
// 1. 回显基础字段
|
||||
formData.analystName = res.analystName
|
||||
formData.parentName = res.customerName
|
||||
formData.parentPhone = res.customerPhone
|
||||
@@ -342,7 +309,6 @@ const initData = async () => {
|
||||
if (res.dealAmount) formData.transactionAmount = Number(res.dealAmount)
|
||||
if (res.dealDate) formData.transactionDate = new Date(res.dealDate).getTime()
|
||||
|
||||
// 2. 回显付款截图
|
||||
if (res.proofOfPayment && res.proofOfPayment_urls) {
|
||||
const paymentFiles = res.proofOfPayment.map((objName, index) => ({
|
||||
id: objName,
|
||||
@@ -350,8 +316,10 @@ const initData = async () => {
|
||||
status: 'finished',
|
||||
url: res.proofOfPayment_urls[index],
|
||||
rawResponse: {
|
||||
object_name: objName,
|
||||
url: res.proofOfPayment_urls[index],
|
||||
data: {
|
||||
object_name: objName,
|
||||
url: res.proofOfPayment_urls[index],
|
||||
},
|
||||
success: true
|
||||
}
|
||||
}))
|
||||
@@ -369,19 +337,17 @@ onMounted(() => {
|
||||
initData()
|
||||
})
|
||||
|
||||
// 预览
|
||||
const handlePreview = (file) => {
|
||||
const url = file.url || file.thumbnailUrl
|
||||
if (url) window.open(url)
|
||||
}
|
||||
|
||||
// ==============================================================================
|
||||
// 核心改动: 更新提交逻辑以匹配新接口
|
||||
// ==============================================================================
|
||||
/**
|
||||
* 保持原逻辑:更新提交逻辑
|
||||
*/
|
||||
const handleSubmit = () => {
|
||||
formRef.value?.validate(async (errors) => {
|
||||
if (!errors) {
|
||||
// 1. 构建完全符合后端接口要求的 payload
|
||||
const submitPayload = {
|
||||
wecom_id: wecomId,
|
||||
analyst_supervisor: formData.analystSupervisor,
|
||||
@@ -390,27 +356,16 @@ const handleSubmit = () => {
|
||||
parent_name: formData.parentName,
|
||||
parent_phone: formData.parentPhone,
|
||||
parent_id_card: formData.parentIdCard,
|
||||
// 格式化日期和金额
|
||||
transaction_date: formatDate(formData.transactionDate),
|
||||
transaction_amount: String(formData.transactionAmount || ''),
|
||||
guidance_period: formData.guidancePeriod,
|
||||
// 提取文件 object_name
|
||||
payment_object_names: formData.paymentFileList.map(f => f.object_name).filter(Boolean),
|
||||
signature_object_name: formData.signatureFileList[0]?.object_name || null,
|
||||
attachment_object_name: formData.documentFileList[0]?.object_name || null,
|
||||
// analystNotes 似乎不在接口中,如果需要提交请后端添加字段
|
||||
// assignee_name 和 assignee_phone 表单中未提供,暂不提交
|
||||
payment_object_names: formData.paymentFileList.map(f => f.rawResponse?.data?.object_name).filter(Boolean),
|
||||
signature_object_name: formData.signatureFileList[0]?.rawResponse?.data?.object_name || null,
|
||||
attachment_object_name: formData.documentFileList[0]?.rawResponse?.data?.object_name || null,
|
||||
};
|
||||
|
||||
console.log('--- 准备提交给后端的最终纯净数据 ---', submitPayload);
|
||||
|
||||
try {
|
||||
// 2. 调用新的提交接口
|
||||
// 注意:请确保 http util 配置了正确的 baseURL, 例如 http://192.168.15.115:5636/api
|
||||
await http.post('/v1/material/submit', submitPayload);
|
||||
message.success('材料已正式提交成功!');
|
||||
// 可选:提交成功后跳转或重置表单
|
||||
// router.push('/success-page');
|
||||
} catch (err) {
|
||||
console.error('提交失败:', err);
|
||||
message.error('提交失败,请联系管理员');
|
||||
@@ -421,256 +376,270 @@ const handleSubmit = () => {
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
const logout = () => router.push('/login')
|
||||
const handleCancel = () => router.back()
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-container {
|
||||
/* 核心容器 */
|
||||
.page-wrapper {
|
||||
min-height: 100vh;
|
||||
background-color: #f0f2f5;
|
||||
background-image: radial-gradient(circle at 50% 0%, #ffffff 0%, #f0f2f5 80%);
|
||||
background-color: #f5f7fa;
|
||||
padding-bottom: 120px; /* 为底部悬浮栏留出空间 */
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
}
|
||||
|
||||
/* 顶部 Banner 优化 */
|
||||
.header-banner {
|
||||
background: linear-gradient(135deg, #18a058 0%, #2d5a43 100%);
|
||||
height: 220px;
|
||||
color: white;
|
||||
padding: 0 5%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 60px 20px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
align-items: center;
|
||||
clip-path: polygon(0 0, 100% 0, 100% 85%, 0% 100%);
|
||||
}
|
||||
|
||||
.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;
|
||||
.banner-content {
|
||||
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 {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding-bottom: 30px;
|
||||
}
|
||||
|
||||
.header-title {
|
||||
.logo-area {
|
||||
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);
|
||||
.icon-hexagon {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
backdrop-filter: blur(10px);
|
||||
padding: 12px;
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.header-title h2 {
|
||||
margin: 0 0 4px 0;
|
||||
font-size: 22px;
|
||||
color: #1f2225;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.sub-title {
|
||||
.text-group h1 {
|
||||
margin: 0;
|
||||
font-size: 26px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.text-group p {
|
||||
margin: 4px 0 0 0;
|
||||
font-size: 13px;
|
||||
color: #8c929b;
|
||||
opacity: 0.8;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
/* 主内容区域 */
|
||||
.main-content {
|
||||
max-width: 1100px;
|
||||
margin: -60px auto 0;
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
/* 玻璃质感卡片 */
|
||||
.glass-card {
|
||||
background: rgba(255, 255, 255, 1);
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.8);
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.05);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.section-card {
|
||||
transition: transform 0.3s ease, box-shadow 0.3s ease;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.section-card:hover {
|
||||
box-shadow: 0 15px 40px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
padding: 20px 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
gap: 10px;
|
||||
border-bottom: 1px solid #f0f2f5;
|
||||
background: #fafbfc;
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.card-header h3 {
|
||||
margin: 0;
|
||||
font-size: 17px;
|
||||
color: #333;
|
||||
font-weight: 600;
|
||||
color: #1f2225;
|
||||
}
|
||||
|
||||
.section-icon {
|
||||
color: #18a058;
|
||||
background-color: #e8f5ed;
|
||||
padding: 6px;
|
||||
border-radius: 8px;
|
||||
.header-indicator {
|
||||
width: 4px;
|
||||
height: 18px;
|
||||
background: #18a058;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.section-body {
|
||||
background-color: #fcfcfd;
|
||||
border: 1px solid #f0f0f5;
|
||||
border-radius: 12px;
|
||||
.card-body {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
/* 布局辅助 */
|
||||
.mt-8 { margin-top: 8px; }
|
||||
.mt-20 { margin-top: 20px; }
|
||||
.full-h { height: 100%; display: flex; flex-direction: column; }
|
||||
|
||||
/* 上传组件现代风格 */
|
||||
.modern-dragger {
|
||||
border: 2px dashed #e0e0e0;
|
||||
background: #fcfdfe !important;
|
||||
border-radius: 12px;
|
||||
padding: 30px 0 !important;
|
||||
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;
|
||||
.modern-dragger:hover {
|
||||
border-color: #18a058;
|
||||
background: #f6fbf8 !important;
|
||||
}
|
||||
|
||||
.dragger-icon {
|
||||
margin-bottom: 16px;
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.custom-dragger:hover .dragger-icon {
|
||||
transform: translateY(-4px);
|
||||
margin-bottom: 12px;
|
||||
color: #18a058;
|
||||
}
|
||||
|
||||
.upload-placeholder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
.dragger-text {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.dragger-subtext {
|
||||
font-size: 12px;
|
||||
margin-top: 4px !important;
|
||||
}
|
||||
|
||||
/* 签名上传样式 */
|
||||
.signature-modern-upload :deep(.n-upload-trigger.n-upload-trigger--image-card) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: #fafafc;
|
||||
transition: all 0.3s ease;
|
||||
height: 140px;
|
||||
border-radius: 12px;
|
||||
background: #fcfdfe;
|
||||
border: 2px dashed #e0e0e0;
|
||||
}
|
||||
|
||||
.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-modern-upload :deep(.n-upload-file.n-upload-file--image-card) {
|
||||
width: 100%;
|
||||
height: 140px;
|
||||
}
|
||||
|
||||
.signature-placeholder {
|
||||
background-color: transparent;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: #8c929b;
|
||||
}
|
||||
|
||||
.footer-actions {
|
||||
/* 付款凭证样式 */
|
||||
.payment-modern-upload :deep(.n-upload-trigger.n-upload-trigger--image-card) {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
.payment-modern-upload :deep(.n-upload-file.n-upload-file--image-card) {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.payment-placeholder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: #18a058;
|
||||
font-weight: 500;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* 底部操作栏 */
|
||||
.footer-actions-wrapper {
|
||||
position: fixed;
|
||||
bottom: 24px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 90%;
|
||||
max-width: 1100px;
|
||||
height: 70px;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.footer-blur-bg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(255, 255, 255, 0.85);
|
||||
backdrop-filter: blur(20px);
|
||||
border-radius: 35px;
|
||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.15);
|
||||
border: 1px solid rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.footer-content {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
padding: 0 32px;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
min-width: 120px;
|
||||
border-radius: 8px;
|
||||
font-weight: 500;
|
||||
.btn-cancel {
|
||||
background: #fff;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.submit-btn {
|
||||
box-shadow: 0 4px 12px rgba(24, 160, 88, 0.3);
|
||||
.btn-submit {
|
||||
min-width: 180px;
|
||||
box-shadow: 0 4px 15px rgba(24, 160, 88, 0.3);
|
||||
}
|
||||
|
||||
/* 响应式调整 */
|
||||
@media (max-width: 768px) {
|
||||
.page-container {
|
||||
padding: 20px 12px;
|
||||
.header-banner {
|
||||
height: 180px;
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
.text-group h1 { font-size: 20px; }
|
||||
.text-group p { font-size: 11px; }
|
||||
.logout-btn { scale: 0.9; }
|
||||
|
||||
.main-content {
|
||||
margin-top: -40px;
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
}
|
||||
.card-body { padding: 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) {
|
||||
.footer-actions-wrapper {
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
height: 100px;
|
||||
border-radius: 0;
|
||||
height: 80px;
|
||||
}
|
||||
|
||||
.footer-blur-bg { border-radius: 20px 20px 0 0; }
|
||||
.footer-content { justify-content: space-around; padding: 0 16px; }
|
||||
.btn-cancel, .btn-submit { flex: 1; }
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user