v0.1.0: CRM/ERP 系统内测版本 - 安全加固完成

- Docker bridge 网络隔离(8000 端口封死)
- Gunicorn 4 Worker 多进程
- Alembic 数据库迁移基线
- 日志轮转 20m×3
- JWT 密钥 + DB 密码 + CORS 收紧
- 3-2-1 备份链路(NAS + R740-B 冷备)
- 连接池 pool_pre_ping + pool_recycle=3600
This commit is contained in:
hankin
2026-03-16 07:31:37 +00:00
commit 423baff73b
2578 changed files with 824643 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
node_modules/
.git/
.venv/
venv/
__pycache__/
*.pyc
+3
View File
@@ -0,0 +1,3 @@
# 开发环境 API 基础路径
# Vite 开发服务器通过 proxy 转发 /api → http://127.0.0.1:8000
VITE_API_BASE_URL=
+3
View File
@@ -0,0 +1,3 @@
# 生产环境 API 基础路径
# Nginx 统一代理,前端和后端同域,无需跨域
VITE_API_BASE_URL=
+13
View File
@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="天津硕博霖客户信息管理系统" />
<title>SHBL-CRM</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+2042
View File
File diff suppressed because it is too large Load Diff
+27
View File
@@ -0,0 +1,27 @@
{
"name": "shbl-crm-frontend",
"version": "2.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview",
"lint": "eslint . --ext .vue,.js,.jsx,.ts,.tsx"
},
"dependencies": {
"vue": "^3.5.0",
"vue-router": "^4.4.0",
"pinia": "^2.2.0",
"axios": "^1.7.0",
"element-plus": "^2.9.0",
"@element-plus/icons-vue": "^2.3.0",
"pinia-plugin-persistedstate": "^3.2.1"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.2.0",
"typescript": "~5.7.0",
"vite": "^6.0.0",
"vue-tsc": "^2.2.0"
}
}
+25
View File
@@ -0,0 +1,25 @@
<script setup lang="ts">
/**
* 根组件
* 只负责渲染 <router-view>,具体页面由路由决定。
*/
</script>
<template>
<router-view />
</template>
<style>
/* 全局基础样式重置 */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Microsoft YaHei', 'PingFang SC', -apple-system, sans-serif;
background-color: #f5f7fa;
color: #303133;
}
</style>
+23
View File
@@ -0,0 +1,23 @@
import request from './request'
// 对话历史接口
export interface ChatMessage {
id: string
role: 'user' | 'assistant'
type?: 'text' | 'action_card'
content?: string
action?: string
card?: Record<string, any> // action_card 结构化数据
created_at?: string
metadata?: Record<string, any>
}
// 获取对话历史
export const getChatHistory = (limit: number = 50) => {
return request.get<{ data: ChatMessage[] }>('/chat/history', { params: { limit } })
}
// 获取动作卡片回调
export const sendActionCardCallback = (data: { card_type: string; action_key: string; params: Record<string, any> }) => {
return request.post('/chat/action-card/callback', data)
}
+29
View File
@@ -0,0 +1,29 @@
/**
* CRM 业务相关的 API 封装
*/
import request from './request'
// ==== 类型声明 ====
export interface LogSubmitData {
customer_id: string
content: string
}
// ==== 接口定义 ====
/**
* 提交客户沟通日志
* @param data 客户 ID 和沟通内容
*/
export const submitCustomerLog = (data: LogSubmitData): Promise<{ message: string; id: string }> => {
return request.post('/sales-logs', data)
}
/**
* 模糊搜索客户(用于远程选择器)
*/
export const searchCustomers = (q: string): Promise<any[]> => {
return request.get('/customers/search', { params: { q } })
}
+101
View File
@@ -0,0 +1,101 @@
/**
* Axios 请求封装
* - 请求拦截器:自动携带 JWT Token
* - 响应拦截器:
* 1. 剥离后端 { code, data, message } 外壳,直接返回 data
* 2. 统一捕获业务异常并 ElMessage 弹出
* 3. 401 自动清 Token 跳登录页
*/
import axios, { type AxiosResponse, type InternalAxiosRequestConfig } from 'axios'
import { ElMessage } from 'element-plus'
import { useUserStore } from '@/store/user'
import router from '@/router'
// 后端统一响应结构
interface ApiResponse<T = any> {
code: number
data: T
message: string
}
const request = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL as string,
timeout: 15000,
headers: {
'Content-Type': 'application/json',
},
})
// ---- 请求拦截器:自动注入 Authorization 头 ----
request.interceptors.request.use(
(config: InternalAxiosRequestConfig) => {
const userStore = useUserStore()
if (userStore.token) {
config.headers.Authorization = `Bearer ${userStore.token}`
}
return config
},
(error) => {
return Promise.reject(error)
},
)
// ---- 响应拦截器:统一剥离外壳 + 错误处理 ----
request.interceptors.response.use(
(response: AxiosResponse<ApiResponse>) => {
const res = response.data
// 后端返回 code !== 200 视为业务异常
if (res.code && res.code !== 200) {
ElMessage.error(res.message || '请求失败')
// 401 → 清 Token 跳登录
if (res.code === 401) {
const userStore = useUserStore()
userStore.logout()
router.push('/login')
}
return Promise.reject(new Error(res.message || '请求失败'))
}
// 成功:直接返回 data(剥离外壳)
return res.data
},
(error) => {
const status = error.response?.status
const res = error.response?.data as ApiResponse | undefined
const message = res?.message || error.response?.data?.detail || '服务器内部错误'
switch (status) {
case 401:
ElMessage.error('登录已过期,请重新登录')
{
const userStore = useUserStore()
userStore.logout()
router.push('/login')
}
break
case 403:
ElMessage.warning(message || '权限不足,无法执行此操作')
break
case 422:
ElMessage.warning(`参数错误:${message}`)
break
case 500:
ElMessage.error(`服务器错误:${message}`)
break
default:
ElMessage.error(message || '网络连接异常')
}
return Promise.reject(error)
},
)
export default request
+522
View File
@@ -0,0 +1,522 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted, nextTick } from 'vue'
import { ChatDotRound, Select, Close } from '@element-plus/icons-vue'
import { useUserStore } from '@/store/user'
import { useChatStore } from '@/store/chat'
import { ElMessage } from 'element-plus'
const userStore = useUserStore()
const chatStore = useChatStore()
// 悬浮球状态
const isChatOpen = ref(false)
// 拖拽相关逻辑
const floatingBall = ref<HTMLElement | null>(null)
const position = ref({ x: document.documentElement.clientWidth - 80, y: document.documentElement.clientHeight - 80 })
let isDragging = false
let startX = 0
let startY = 0
let initialX = 0
let initialY = 0
const onMouseDown = (e: MouseEvent) => {
if (e.button !== 0) return // Only left click
isDragging = false // Reset drag state initially
startX = e.clientX
startY = e.clientY
initialX = position.value.x
initialY = position.value.y
document.addEventListener('mousemove', onMouseMove)
document.addEventListener('mouseup', onMouseUp)
}
const onMouseMove = (e: MouseEvent) => {
const dx = e.clientX - startX
const dy = e.clientY - startY
// A small threshold to distinguish between click and drag
if (Math.abs(dx) > 3 || Math.abs(dy) > 3) {
isDragging = true
}
if (isDragging && floatingBall.value) {
let newX = initialX + dx
let newY = initialY + dy
// Constraints to keep it within the viewport
const ballWidth = 60
const ballHeight = 60
const maxX = document.documentElement.clientWidth - ballWidth
const maxY = document.documentElement.clientHeight - ballHeight
position.value.x = Math.max(0, Math.min(newX, maxX))
position.value.y = Math.max(0, Math.min(newY, maxY))
}
}
const onMouseUp = () => {
document.removeEventListener('mousemove', onMouseMove)
document.removeEventListener('mouseup', onMouseUp)
}
const toggleChat = (e: Event) => {
if (!isDragging) {
isChatOpen.value = !isChatOpen.value
}
}
// ----------------
const inputMessage = ref('')
const isLoading = ref(false)
const chatBodyRef = ref<HTMLElement | null>(null)
const scrollToBottom = async () => {
await nextTick()
if (chatBodyRef.value) {
chatBodyRef.value.scrollTop = chatBodyRef.value.scrollHeight
}
}
const sendMessage = async () => {
if (!inputMessage.value.trim() || isLoading.value) return
const userMsg = inputMessage.value.trim()
inputMessage.value = ''
chatStore.appendMessage({
id: Date.now().toString(),
role: 'user',
content: userMsg,
type: 'text'
})
scrollToBottom()
isLoading.value = true
// Create an initial empty assistant message to append to
const assistantMsgId = (Date.now() + 1).toString()
chatStore.appendMessage({
id: assistantMsgId,
role: 'assistant',
content: '',
type: 'text' // Will be updated if an action_card arrives, or we handle it by appending new card objects
})
try {
const token = userStore.token
if (!token) {
ElMessage.error('未登录或 Token 失效')
isLoading.value = false
return
}
const response = await fetch('/api/chat/stream', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({ message: userMsg, conversation_id: chatStore.conversationId })
})
if (!response.ok) {
if(response.status === 401) {
ElMessage.error('登录状态过期,请重新登录')
userStore.logout()
// Optional: redirect to login
} else {
ElMessage.error(`请求失败: ${response.status}`)
}
isLoading.value = false
return
}
const reader = response.body?.getReader()
const decoder = new TextDecoder('utf-8')
if (!reader) {
throw new Error("No reader available")
}
let buf = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
buf += decoder.decode(value, { stream: true })
// Process complete SSE messages separated by \n\n
let idx: number
while ((idx = buf.indexOf('\n\n')) >= 0) {
const chunk = buf.slice(0, idx)
buf = buf.slice(idx + 2)
if (chunk.startsWith('data: ')) {
const jsonStr = chunk.slice(6).trim()
if (!jsonStr) continue
try {
const data = JSON.parse(jsonStr)
// Find the current assistant message
const currentMsgIndex = chatStore.messages.findIndex(m => m.id === assistantMsgId)
if (currentMsgIndex !== -1) {
if (data.type === 'text') {
// It's a text chunk, append it
chatStore.appendTextToMessage(assistantMsgId, data.content)
} else if (data.type === 'conversation_id') {
// Dify 返回的会话 ID,存起来用于下一轮
chatStore.setConversationId(data.conversation_id)
} else if (data.type === 'action_card') {
// 工具返回的确认卡片
chatStore.appendMessage({
id: Date.now().toString() + '_card',
role: 'assistant',
type: 'action_card',
content: data.content || '请确认操作',
card: data.card // 包含 card_type, fields, actions, params
})
}
scrollToBottom()
}
} catch (e) {
console.error('JSON parse error from SSE:', e, jsonStr)
}
}
}
}
} catch (err) {
console.error('Chat error:', err)
chatStore.appendMessage({
id: Date.now().toString() + '_err',
role: 'assistant',
content: '请求发生异常:' + String(err),
type: 'text'
})
scrollToBottom()
} finally {
isLoading.value = false
}
}
// Window resize handler to ensure ball stays on screen
const onResize = () => {
const ballWidth = 60
const ballHeight = 60
if (position.value.x > document.documentElement.clientWidth - ballWidth) {
position.value.x = Math.max(0, document.documentElement.clientWidth - ballWidth)
}
if (position.value.y > document.documentElement.clientHeight - ballHeight) {
position.value.y = Math.max(0, document.documentElement.clientHeight - ballHeight)
}
}
onMounted(async () => {
window.addEventListener('resize', onResize)
if (!chatStore.isHistoryLoaded && userStore.isLoggedIn) {
await chatStore.loadHistory()
scrollToBottom()
}
})
onUnmounted(() => {
window.removeEventListener('resize', onResize)
})
const handleCardAction = async (msg: any, actionKey: string) => {
if (!msg.card) return
const token = userStore.token
if (!token) { ElMessage.error('未登录'); return }
try {
const resp = await fetch('/api/chat/action-card/callback', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` },
body: JSON.stringify({
card_type: msg.card.card_type,
action_key: actionKey,
params: msg.card.params || {},
}),
})
const result = await resp.json()
if (actionKey === 'cancel') {
ElMessage.info('操作已取消')
} else {
ElMessage.success(result.message || '操作成功')
}
// 替换卡片为结果文本
msg.type = 'text'
msg.content = actionKey === 'cancel' ? '✖️ 操作已取消' : `${result.message || '操作已确认执行'}`
} catch (e) {
ElMessage.error('回调异常: ' + String(e))
}
}
</script>
<template>
<div v-if="userStore.isLoggedIn" class="floating-chat-container">
<!-- 悬浮球 -->
<div
ref="floatingBall"
class="floating-ball"
:style="{ left: position.x + 'px', top: position.y + 'px' }"
@mousedown.prevent="onMouseDown"
@click="toggleChat"
>
<el-icon size="28" color="#fff"><ChatDotRound /></el-icon>
</div>
<!-- 侧边栏/抽屉 聊天窗口 -->
<el-drawer
v-model="isChatOpen"
title="智能助手"
size="400px"
direction="rtl"
:with-header="true"
custom-class="chat-drawer"
>
<div class="chat-layout">
<div class="chat-body" ref="chatBodyRef">
<div v-if="chatStore.messages.length === 0" class="empty-tip">
您好我是您的智能业务助手有什么我可以帮您的
</div>
<div v-for="msg in chatStore.messages" :key="msg.id" :class="['chat-bubble-wrapper', msg.role]">
<div class="avatar" v-if="msg.role === 'assistant'">🤖</div>
<div class="bubble-content">
<!-- 文本渲染 -->
<div v-if="msg.type === 'text' || !msg.type" class="text-message">
<!-- 可以按需引入 marked 渲染 Markdown这里简单用 pre-wrap 保证换行 -->
<span style="white-space: pre-wrap;">{{ msg.content }}</span>
<!-- 可以在这里加入一个打字光标的样式如果正处于加载状态且是最后一条消息 -->
<span v-if="isLoading && msg.role === 'assistant' && msg === chatStore.messages[chatStore.messages.length - 1]" class="typing-cursor"></span>
</div>
<!-- Action Card 渲染 -->
<div v-else-if="msg.type === 'action_card' && msg.card" class="action-card">
<div class="card-header">
<el-icon><Select /></el-icon>
<span>{{ msg.card.title || '操作确认' }}</span>
</div>
<div class="card-body">
<div v-for="field in (msg.card.fields || [])" :key="field.label" class="card-field">
<span class="field-label">{{ field.label }}</span>
<span class="field-value">{{ field.value }}</span>
</div>
</div>
<div class="card-footer">
<el-button v-for="action in (msg.card.actions || [])" :key="action.key"
size="small"
:type="action.style === 'primary' ? 'primary' : action.key === 'cancel' ? 'default' : 'primary'"
:plain="action.key === 'cancel'"
@click="handleCardAction(msg, action.key)"
>{{ action.label }}</el-button>
</div>
</div>
</div>
<div class="avatar" v-if="msg.role === 'user'">👤</div>
</div>
</div>
<div class="chat-footer">
<el-input
v-model="inputMessage"
placeholder="输入消息,开启智能分析..."
@keyup.enter="sendMessage"
:disabled="isLoading"
>
<template #append>
<el-button type="primary" @click="sendMessage" :disabled="!inputMessage.trim() || isLoading">
发送
</el-button>
</template>
</el-input>
</div>
</div>
</el-drawer>
</div>
</template>
<style scoped>
.floating-chat-container {
z-index: 9999;
}
.floating-ball {
position: fixed;
width: 60px;
height: 60px;
background: linear-gradient(135deg, #1890ff 0%, #0050b3 100%);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4px 12px rgba(24, 144, 255, 0.4);
cursor: pointer;
z-index: 9999;
user-select: none;
transition: box-shadow 0.3s ease, transform 0.2s;
}
.floating-ball:hover {
box-shadow: 0 6px 16px rgba(24, 144, 255, 0.6);
transform: scale(1.05);
}
.chat-drawer {
display: flex;
flex-direction: column;
}
.chat-layout {
display: flex;
flex-direction: column;
height: 100%;
/* 配合 element-plus 的 drawer_body 可以适当配置高度以占满整个抽屉 */
}
/* 如果要适配 v-model / :with-header 这些产生的内层 padding,需要使用 :deep() */
:deep(.el-drawer__body) {
padding: 0;
display: flex;
flex-direction: column;
}
.chat-body {
flex: 1;
overflow-y: auto;
padding: 20px;
background-color: #f7f9fc;
}
.chat-footer {
padding: 15px 20px;
background-color: #fff;
border-top: 1px solid #eba4a4; /* fallback */
border-top-color: var(--el-border-color-light);
}
.empty-tip {
text-align: center;
color: #909399;
font-size: 13px;
margin-top: 50px;
}
.chat-bubble-wrapper {
display: flex;
margin-bottom: 20px;
align-items: flex-start;
}
.chat-bubble-wrapper.user {
justify-content: flex-end;
}
.avatar {
font-size: 24px;
margin: 0 10px;
}
.bubble-content {
max-width: 80%;
font-size: 14px;
line-height: 1.5;
}
/* user texts */
.user .text-message {
background-color: var(--el-color-primary);
color: #fff;
padding: 10px 14px;
border-radius: 12px 0 12px 12px;
word-break: break-all;
}
/* assistant texts */
.assistant .text-message {
background-color: #fff;
color: var(--el-text-color-primary);
padding: 10px 14px;
border-radius: 0 12px 12px 12px;
word-break: break-all;
box-shadow: 0 1px 4px rgba(0,0,0,0.05);
}
/* action card (mock) */
.action-card {
background-color: #fff;
border: 1px solid var(--el-border-color-lighter);
border-radius: 8px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0,0,0,0.05);
width: 260px; /* 略微定宽 */
}
.card-header {
background-color: #f0f9eb;
color: #67c23a;
padding: 10px;
font-size: 13px;
font-weight: bold;
display: flex;
align-items: center;
gap: 6px;
border-bottom: 1px solid var(--el-border-color-lighter);
}
.card-body {
padding: 12px;
font-size: 13px;
color: var(--el-text-color-regular);
}
.card-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 10px;
border-top: 1px solid var(--el-border-color-lighter);
background-color: #fafafa;
}
.card-field {
display: flex;
padding: 4px 0;
font-size: 13px;
line-height: 1.6;
}
.field-label {
color: #909399;
min-width: 70px;
flex-shrink: 0;
}
.field-value {
color: #303133;
font-weight: 500;
}
.typing-cursor {
display: inline-block;
width: 6px;
height: 14px;
background-color: #333;
margin-left: 2px;
animation: blink 1s step-end infinite;
}
@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
</style>
+17
View File
@@ -0,0 +1,17 @@
/// <reference types="vite/client" />
// 声明 .vue 模块类型,使 TypeScript 能正确导入 Vue 单文件组件
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<object, object, unknown>
export default component
}
// 声明 Vite 环境变量类型
interface ImportMetaEnv {
readonly VITE_API_BASE_URL: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}
+363
View File
@@ -0,0 +1,363 @@
<script setup lang="ts">
import { ref, reactive, nextTick } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { ElMessage } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import { useUserStore } from '@/store/user'
import request from '@/api/request'
import {
House,
Briefcase,
User,
Tickets,
Van,
Box,
Goods,
Money,
Wallet,
Connection,
Document,
DataAnalysis,
Setting,
Fold,
Expand,
ArrowDown
} from '@element-plus/icons-vue'
import FloatingChat from '@/components/FloatingChat.vue'
const isCollapse = ref(false)
const route = useRoute()
const router = useRouter()
const userStore = useUserStore()
const toggleSidebar = () => {
isCollapse.value = !isCollapse.value
}
// ---- 修改密码 ----
const pwdDialogVisible = ref(false)
const pwdSubmitting = ref(false)
const pwdFormRef = ref<FormInstance>()
const pwdForm = reactive({ oldPassword: '', newPassword: '', confirmPassword: '' })
const pwdRules = reactive<FormRules>({
oldPassword: [{ required: true, message: '请输入旧密码', trigger: 'blur' }],
newPassword: [
{ required: true, message: '请输入新密码', trigger: 'blur' },
{ min: 6, message: '密码至少6位', trigger: 'blur' },
],
confirmPassword: [
{ required: true, message: '请确认新密码', trigger: 'blur' },
{
validator: (_rule: any, value: string, callback: any) => {
if (value !== pwdForm.newPassword) callback(new Error('两次密码不一致'))
else callback()
},
trigger: 'blur',
},
],
})
const openPwdDialog = () => {
pwdDialogVisible.value = true
nextTick(() => pwdFormRef.value?.resetFields())
}
const submitChangePassword = async () => {
const valid = await pwdFormRef.value?.validate().catch(() => false)
if (!valid) return
pwdSubmitting.value = true
try {
await request.put('/api/auth/password', {
old_password: pwdForm.oldPassword,
new_password: pwdForm.newPassword,
})
ElMessage.success('密码修改成功,请重新登录')
pwdDialogVisible.value = false
// 强制重新登录
setTimeout(() => {
userStore.logout()
router.push('/login')
}, 800)
} catch {
// 已统一拦截
} finally {
pwdSubmitting.value = false
}
}
const handleCommand = (command: string) => {
if (command === 'logout') {
userStore.logout()
ElMessage.success('已安全退出')
router.push('/login')
} else if (command === 'changePassword') {
openPwdDialog()
}
}
</script>
<template>
<el-container class="layout-container">
<!-- 左侧菜单 -->
<el-aside :width="isCollapse ? '64px' : '240px'" class="aside">
<div class="logo">
<span v-show="!isCollapse" class="logo-text"><strong>SHBL-ERP</strong></span>
<span v-show="isCollapse" class="logo-icon">S</span>
</div>
<el-menu
:default-active="route.path"
class="el-menu-vertical"
:collapse="isCollapse"
router
unique-opened
background-color="#001529"
text-color="#a6adb4"
active-text-color="#fff"
>
<el-menu-item index="/">
<el-icon><House /></el-icon>
<template #title>工作台</template>
</el-menu-item>
<el-sub-menu index="sales">
<template #title>
<el-icon><Briefcase /></el-icon>
<span>业务线</span>
</template>
<el-menu-item index="/customers">
<el-icon><User /></el-icon>
<template #title>客户管理</template>
</el-menu-item>
<el-menu-item index="/orders">
<el-icon><Tickets /></el-icon>
<template #title>订单管理</template>
</el-menu-item>
<el-menu-item index="/shipping">
<el-icon><Van /></el-icon>
<template #title>发货记录</template>
</el-menu-item>
</el-sub-menu>
<el-sub-menu index="supply-chain">
<template #title>
<el-icon><Box /></el-icon>
<span>供应链</span>
</template>
<el-menu-item index="/products">
<el-icon><Goods /></el-icon>
<template #title>产品与库存</template>
</el-menu-item>
</el-sub-menu>
<el-sub-menu index="finance-group">
<template #title>
<el-icon><Money /></el-icon>
<span>财务管理</span>
</template>
<el-menu-item index="/finance/sales-invoices">销项发票</el-menu-item>
<el-menu-item index="/finance">报销管理</el-menu-item>
</el-sub-menu>
<el-sub-menu index="oa">
<template #title>
<el-icon><Connection /></el-icon>
<span>协同办公</span>
</template>
<el-menu-item index="/logs">
<el-icon><Document /></el-icon>
<template #title>销售日志</template>
</el-menu-item>
<el-menu-item index="/reports">
<el-icon><DataAnalysis /></el-icon>
<template #title>AI 智能复盘</template>
</el-menu-item>
</el-sub-menu>
<el-sub-menu index="sys-settings">
<template #title>
<el-icon><Setting /></el-icon>
<span>系统设置</span>
</template>
<el-menu-item index="/settings">
<el-icon><User /></el-icon>
<template #title>权限与员工管理</template>
</el-menu-item>
</el-sub-menu>
</el-menu>
</el-aside>
<!-- 右侧容器 -->
<el-container>
<!-- 顶栏 -->
<el-header class="header">
<div class="header-left">
<el-icon class="toggle-icon" @click="toggleSidebar">
<component :is="isCollapse ? Expand : Fold" />
</el-icon>
<!-- 面包屑 -->
<el-breadcrumb separator="/">
<el-breadcrumb-item :to="{ path: '/' }">首页</el-breadcrumb-item>
<el-breadcrumb-item>{{ (route.meta?.title as string) || route.name }}</el-breadcrumb-item>
</el-breadcrumb>
</div>
<div class="header-right">
<el-dropdown @command="handleCommand">
<span class="user-dropdown">
<el-avatar :size="30" src="https://cube.elemecdn.com/3/7c/3ea6beec64369c2642b92c6726f1epng.png" />
<span class="username">{{ userStore.realName || userStore.username || 'Admin' }}</span>
<el-icon class="el-icon--right"><arrow-down /></el-icon>
</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item :command="'changePassword'">修改密码</el-dropdown-item>
<el-dropdown-item divided :command="'logout'">退出登录</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</el-header>
<!-- 主体内容 -->
<el-main class="main">
<router-view v-slot="{ Component }">
<transition name="fade-transform" mode="out-in">
<component :is="Component" :key="$route.fullPath" />
</transition>
</router-view>
</el-main>
</el-container>
<!-- 修改密码弹窗 -->
<el-dialog v-model="pwdDialogVisible" title="修改密码" width="420px" destroy-on-close>
<el-form ref="pwdFormRef" :model="pwdForm" :rules="pwdRules" label-width="90px">
<el-form-item label="旧密码" prop="oldPassword">
<el-input v-model="pwdForm.oldPassword" type="password" show-password placeholder="请输入当前密码" />
</el-form-item>
<el-form-item label="新密码" prop="newPassword">
<el-input v-model="pwdForm.newPassword" type="password" show-password placeholder="至少6位" />
</el-form-item>
<el-form-item label="确认密码" prop="confirmPassword">
<el-input v-model="pwdForm.confirmPassword" type="password" show-password placeholder="再次输入新密码" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="pwdDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="pwdSubmitting" @click="submitChangePassword">确认修改</el-button>
</template>
</el-dialog>
<!-- 全局 AI 悬浮球 -->
<FloatingChat />
</el-container>
</template>
<style scoped>
.layout-container {
height: 100vh;
width: 100vw;
}
.aside {
background-color: #001529;
transition: width 0.3s;
display: flex;
flex-direction: column;
}
.logo {
height: 60px;
line-height: 60px;
text-align: center;
color: #fff;
background-color: #002140;
overflow: hidden;
white-space: nowrap;
}
.logo-text {
font-size: 18px;
letter-spacing: 1px;
}
.logo-icon {
font-size: 20px;
font-weight: bold;
}
.el-menu-vertical {
flex: 1;
border-right: none;
}
/* Ensure sub-menu titles have a nice color change on hover */
:deep(.el-sub-menu__title:hover) {
background-color: rgba(255, 255, 255, 0.05) !important;
}
.el-menu-item:hover {
background-color: rgba(255, 255, 255, 0.05) !important;
}
.el-menu-item.is-active {
background-color: var(--el-color-primary) !important;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid #dcdfe6;
background-color: #fff;
height: 60px;
padding: 0 20px;
}
.header-left {
display: flex;
align-items: center;
}
.toggle-icon {
font-size: 20px;
cursor: pointer;
margin-right: 20px;
}
.header-right {
display: flex;
align-items: center;
}
.user-dropdown {
display: flex;
align-items: center;
cursor: pointer;
}
.username {
margin-left: 8px;
font-size: 14px;
}
.main {
background-color: #f0f2f5;
padding: 20px;
overflow-y: auto;
}
/* 页面过渡动画 */
.fade-transform-leave-active,
.fade-transform-enter-active {
transition: all 0.3s;
}
.fade-transform-enter-from {
opacity: 0;
transform: translateX(-30px);
}
.fade-transform-leave-to {
opacity: 0;
transform: translateX(30px);
}
</style>
+29
View File
@@ -0,0 +1,29 @@
/**
* Vue 应用入口
* 初始化 Vue3 + Pinia + Router + Element Plus
*/
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import zhCn from 'element-plus/es/locale/lang/zh-cn'
import App from './App.vue'
import router from './router'
const app = createApp(App)
// 挂载 Pinia 状态管理
const pinia = createPinia()
pinia.use(piniaPluginPersistedstate)
app.use(pinia)
// 挂载路由
app.use(router)
// 挂载 Element Plus (中文语言包)
app.use(ElementPlus, { locale: zhCn })
app.mount('#app')
+145
View File
@@ -0,0 +1,145 @@
/**
* Vue Router 配置
* 路由守卫:
* 1. 未登录 → 跳 /login
* 2. 有 Token 无用户信息 → 调 GET /api/auth/me 拉取
* 3. 已登录访问 /login → 跳首页
*/
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
import { useUserStore } from '@/store/user'
import Layout from '@/layout/index.vue'
const routes: RouteRecordRaw[] = [
{
path: '/login',
name: 'Login',
component: () => import('@/views/Login.vue'),
meta: { requiresAuth: false },
},
{
path: '/',
component: Layout,
meta: { requiresAuth: true },
children: [
{
path: '',
name: 'Dashboard',
component: () => import('@/views/dashboard/index.vue'),
meta: { title: '工作台' },
},
{
path: 'customers',
name: 'Customers',
component: () => import('@/views/customers/index.vue'),
meta: { title: '客户管理' },
},
{
path: 'customers/detail',
name: 'CustomerDetail',
component: () => import('@/views/customers/CustomerDetail.vue'),
meta: { title: '客户档案' },
},
{
path: 'orders',
name: 'Orders',
component: () => import('@/views/orders/index.vue'),
meta: { title: '订单管理' },
},
{
path: 'shipping',
name: 'Shipping',
component: () => import('@/views/shipping/index.vue'),
meta: { title: '发货记录' },
},
{
path: 'products',
name: 'Products',
component: () => import('@/views/products/index.vue'),
meta: { title: '产品与库存' },
},
{
path: 'finance',
name: 'Finance',
component: () => import('@/views/finance/index.vue'),
meta: { title: '报销大盘' },
},
{
path: 'settings',
name: 'Settings',
component: () => import('@/views/settings/index.vue'),
meta: { title: '权限与员工管理' },
},
{
path: 'logs',
name: 'SalesLogs',
component: () => import('@/views/logs/index.vue'),
meta: { title: '销售日志' },
},
{
path: 'reports',
name: 'MonthlyReport',
component: () => import('@/views/dashboard/MonthlyReport.vue'),
meta: { title: 'AI 智能复盘' },
},
{
path: 'finance/sales-invoices',
name: 'SalesInvoices',
component: () => import('@/views/finance/SalesInvoice.vue'),
meta: { title: '销项发票' },
},
],
},
]
const router = createRouter({
history: createWebHistory(),
routes,
})
// 白名单(免鉴权路由)
const WHITE_LIST = ['Login']
// ---- 全局前置守卫 ----
router.beforeEach(async (to: any, _from: any, next: any) => {
const userStore = useUserStore()
// 已登录
if (userStore.isLoggedIn) {
if (to.name === 'Login') {
// 已登录还访问登录页 → 跳首页
next({ name: 'Dashboard' })
return
}
// 有 Token 但还没拉取用户信息 → 调 /auth/me
if (!userStore.userInfo) {
try {
await userStore.fetchUserInfo()
console.log('[Auth Guard] 用户信息已获取:', {
username: userStore.username,
dataScope: userStore.dataScope,
menuKeys: userStore.menuKeys,
})
next({ ...to, replace: true })
} catch {
// Token 过期或无效 → 清除并跳登录
userStore.logout()
next({ name: 'Login', query: { redirect: to.fullPath } })
}
return
}
next()
return
}
// 未登录
if (WHITE_LIST.includes(to.name as string)) {
next()
} else {
next({ name: 'Login', query: { redirect: to.fullPath } })
}
})
export default router
+79
View File
@@ -0,0 +1,79 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import type { ChatMessage } from '@/api/chat'
import { getChatHistory } from '@/api/chat'
import { useUserStore } from '@/store/user'
import { ElMessage } from 'element-plus'
export const useChatStore = defineStore(
'chat',
() => {
const messages = ref<ChatMessage[]>([])
const isHistoryLoaded = ref(false)
const conversationId = ref('') // Dify 会话 ID,用于上下文记忆
// 清除消息
const clearMessages = () => {
messages.value = []
isHistoryLoaded.value = false
conversationId.value = ''
}
const setConversationId = (id: string) => {
conversationId.value = id
}
// 重置会话(开始新对话)
const resetConversation = () => {
messages.value = []
conversationId.value = ''
}
// 从接口拉取最新历史(合并到本地)
const loadHistory = async () => {
const userStore = useUserStore()
if (!userStore.isLoggedIn) return
try {
const res: any = await getChatHistory(50)
// request 拦截器已剥离 {code, data, message} 外壳,res 直接是 data
const items = Array.isArray(res) ? res : (res?.data || [])
messages.value = items
isHistoryLoaded.value = true
} catch (e) {
console.error('Failed to load chat history:', e)
}
}
// 追加单条消息
const appendMessage = (msg: ChatMessage) => {
messages.value.push(msg)
}
// 追加文本到已有消息
const appendTextToMessage = (id: string, text: string) => {
const msg = messages.value.find(m => m.id === id)
if (msg && msg.type === 'text') {
msg.content = (msg.content || '') + text
}
}
return {
messages,
isHistoryLoaded,
conversationId,
clearMessages,
loadHistory,
appendMessage,
appendTextToMessage,
setConversationId,
resetConversation,
}
},
{
persist: {
key: 'crm-chat-history',
storage: localStorage,
}
}
)
+73
View File
@@ -0,0 +1,73 @@
/**
* 用户状态管理 (Pinia)
* Token 持久化 + /auth/me 获取完整用户上下文(含 data_scope, menu_keys)
*/
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import request from '@/api/request'
// 用户信息类型(对应后端 CurrentUserPayload
interface UserInfo {
user_id: string
username: string
real_name: string | null
dept_id: string | null
dept_name: string | null
role_id: string | null
role_name: string | null
data_scope: string
menu_keys: string[]
}
export const useUserStore = defineStore('user', () => {
// ---- State ----
const token = ref<string>(localStorage.getItem('crm_token') || '')
const userInfo = ref<UserInfo | null>(null)
// ---- Getters ----
const isLoggedIn = computed(() => !!token.value)
const username = computed(() => userInfo.value?.username || '')
const realName = computed(() => userInfo.value?.real_name || userInfo.value?.username || '')
const dataScope = computed(() => userInfo.value?.data_scope || 'self')
const menuKeys = computed(() => userInfo.value?.menu_keys || [])
// ---- Actions ----
/** 登录:POST /api/auth/login → 拿 Token */
async function login(loginUsername: string, password: string) {
const data = await request.post('/api/auth/login', {
username: loginUsername,
password,
}) as any
token.value = data.access_token
localStorage.setItem('crm_token', data.access_token)
}
/** 获取用户信息:GET /api/auth/me → 完整 Payload */
async function fetchUserInfo() {
const data = await request.get('/api/auth/me') as unknown as UserInfo
userInfo.value = data
}
/** 登出 */
function logout() {
token.value = ''
userInfo.value = null
localStorage.removeItem('crm_token')
}
return {
token,
userInfo,
isLoggedIn,
username,
realName,
dataScope,
menuKeys,
login,
fetchUserInfo,
logout,
}
})
+33
View File
@@ -0,0 +1,33 @@
<script setup lang="ts">
/**
* Dashboard 占位页
* 后续替换为经营看板的图表和数据。
*/
import { useUserStore } from '@/store/user'
import { useRouter } from 'vue-router'
const userStore = useUserStore()
const router = useRouter()
function handleLogout() {
userStore.logout()
router.push('/login')
}
</script>
<template>
<el-container style="min-height: 100vh">
<el-header style="display: flex; align-items: center; justify-content: space-between; background: #fff; box-shadow: 0 1px 4px rgba(0,0,0,0.08)">
<h3>SHBL-CRM 控制台</h3>
<div>
<span style="margin-right: 16px; color: #606266">
{{ userStore.realName }} ({{ userStore.userInfo?.role_name || '未分配角色' }})
</span>
<el-button size="small" @click="handleLogout">退出登录</el-button>
</div>
</el-header>
<el-main>
<el-result icon="success" title="登录成功" sub-title="系统骨架已就绪后续业务模块开发中" />
</el-main>
</el-container>
</template>
+123
View File
@@ -0,0 +1,123 @@
<script setup lang="ts">
/**
* 登录页
* POST /api/auth/login → Token → fetchUserInfo → 跳转
*/
import { ref, reactive } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
import { useUserStore } from '@/store/user'
const router = useRouter()
const route = useRoute()
const userStore = useUserStore()
const loading = ref(false)
const formRef = ref<FormInstance>()
const form = reactive({
username: '',
password: '',
})
const rules = reactive<FormRules>({
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
password: [
{ required: true, message: '请输入密码', trigger: 'blur' },
{ min: 6, message: '密码至少 6 位', trigger: 'blur' },
],
})
async function handleLogin() {
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) return
loading.value = true
try {
// 1. 登录拿 Token
await userStore.login(form.username, form.password)
// 2. 拉取用户信息(data_scope, menu_keys 等)
await userStore.fetchUserInfo()
ElMessage.success(`欢迎回来,${userStore.realName}`)
const redirect = (route.query.redirect as string) || '/'
router.push(redirect)
} catch {
// request.ts 拦截器已统一弹错误
} finally {
loading.value = false
}
}
</script>
<template>
<div class="login-container">
<el-card class="login-card" shadow="hover">
<template #header>
<h2 class="login-title">天津硕博霖 CRM 系统</h2>
<p class="login-subtitle">客户信息管理系统 v2.0</p>
</template>
<el-form
ref="formRef"
:model="form"
:rules="rules"
label-width="0"
size="large"
@keyup.enter="handleLogin"
>
<el-form-item prop="username">
<el-input v-model="form.username" placeholder="用户名" prefix-icon="User" />
</el-form-item>
<el-form-item prop="password">
<el-input
v-model="form.password"
type="password"
placeholder="密码"
prefix-icon="Lock"
show-password
/>
</el-form-item>
<el-form-item>
<el-button
type="primary"
:loading="loading"
style="width: 100%"
@click="handleLogin"
>
</el-button>
</el-form-item>
</el-form>
</el-card>
</div>
</template>
<style scoped>
.login-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.login-card {
width: 420px;
border-radius: 12px;
}
.login-title {
text-align: center;
font-size: 22px;
color: #303133;
margin-bottom: 4px;
}
.login-subtitle {
text-align: center;
font-size: 13px;
color: #909399;
}
</style>
+383
View File
@@ -0,0 +1,383 @@
<script setup lang="ts">
/**
* 销售日志列表页 (V5.0 升级)
* 支持查看所有日志,可以通过传入 customer_id 过滤特定客户的日志
* 客户关联使用异步远程搜索选择器
* V5.0: 增加联系人级联多选 + contact_ids 传参
*/
import { ref, reactive, onMounted, watch } from 'vue'
import { useRoute } from 'vue-router'
import { ElMessage, type FormInstance } from 'element-plus'
import { Plus, Search } from '@element-plus/icons-vue'
import request from '@/api/request'
import { useUserStore } from '@/store/user'
const route = useRoute()
const userStore = useUserStore()
// --- 客户搜索选择器 ---
interface CustomerOption {
id: string
name: string
level: string
contact: string | null
phone: string | null
}
const customerOptions = ref<CustomerOption[]>([])
const customerSearchLoading = ref(false)
const handleCustomerSearch = async (query: string) => {
if (!query || query.length < 1) {
customerOptions.value = []
return
}
customerSearchLoading.value = true
try {
const res: any = await request.get('/api/customers/search', { params: { q: query } })
customerOptions.value = res || []
} catch {
customerOptions.value = []
} finally {
customerSearchLoading.value = false
}
}
// --- 关联联系人 ---
const contactOptions = ref<any[]>([])
const loadingContacts = ref(false)
const loadContacts = async (customerId: string) => {
if (!customerId) {
contactOptions.value = []
return
}
loadingContacts.value = true
try {
const res: any = await request.get(`/api/customers/${customerId}/contacts`)
contactOptions.value = res || []
} catch {
} finally {
loadingContacts.value = false
}
}
// --- 列表状态 ---
const loading = ref(false)
const tableData = ref<any[]>([])
const currentPage = ref(1)
const pageSize = ref(10)
const total = ref(0)
const customerIdQuery = ref<string>((route.query.customer_id as string) || '')
const customerNameQuery = ref<string>((route.query.customer_name as string) || '')
// 客户名称映射缓存(用于列表显示)
const customerNameMap = ref<Record<string, string>>({})
const fetchLogs = async () => {
loading.value = true
try {
const params: Record<string, any> = {
page: currentPage.value,
size: pageSize.value,
start_date: dateRange.value?.[0] || '',
end_date: dateRange.value?.[1] || ''
}
if (customerIdQuery.value) {
params.customer_id = customerIdQuery.value
}
const res: any = await request.get('/api/sales-logs', { params })
tableData.value = res.items || []
total.value = res.total || 0
// 获取日志中涉及的客户名称
const unknownIds = (res.items || [])
.map((item: any) => item.customer_id)
.filter((id: string | null) => id && !customerNameMap.value[id])
if (unknownIds.length > 0) {
// 批量查名称:逐个 search(数量通常很少)
for (const id of [...new Set(unknownIds)] as string[]) {
try {
const detail: any = await request.get(`/api/customers/${id}`)
if (detail?.name) {
customerNameMap.value[id] = detail.name
}
} catch {
// 客户可能被删除
customerNameMap.value[id] = '(已删除)'
}
}
}
} catch {
// 已统一处理报错
} finally {
loading.value = false
}
}
const getCustomerName = (customerId: string | null) => {
if (!customerId) return '--'
return customerNameMap.value[customerId] || customerId.slice(0, 8) + '...'
}
// 筛选时间范围
const dateRange = ref<[string, string] | null>(null)
const handleSearch = () => {
currentPage.value = 1
fetchLogs()
}
const handleReset = () => {
dateRange.value = null
customerIdQuery.value = ''
customerNameQuery.value = ''
handleSearch()
}
// --- 新建日志弹窗 ---
const dialogVisible = ref(false)
const dialogSubmitting = ref(false)
const formRef = ref<FormInstance>()
const form = reactive({
customer_id: customerIdQuery.value || '',
contact_ids: [] as string[],
content: ''
})
const formRules = {
content: [{ required: true, message: '请输入跟进日志内容', trigger: 'blur' }]
}
// watch 必须放在 form 声明之后(避免 TDZ)
watch(() => form.customer_id, (newVal) => {
if (newVal) {
loadContacts(newVal)
} else {
contactOptions.value = []
}
form.contact_ids = []
})
const openAddDialog = () => {
form.customer_id = customerIdQuery.value || ''
form.contact_ids = []
form.content = ''
// 如果有预置的客户,加载到选择器选项中
if (customerIdQuery.value && customerNameQuery.value) {
customerOptions.value = [{
id: customerIdQuery.value,
name: customerNameQuery.value,
level: '',
contact: null,
phone: null
}]
// 主动加载该客户的联系人
loadContacts(customerIdQuery.value)
}
dialogVisible.value = true
}
const submitForm = async () => {
if (!formRef.value) return
const valid = await formRef.value.validate().catch(() => false)
if (!valid) return
dialogSubmitting.value = true
try {
await request.post('/api/sales-logs', {
content: form.content,
customer_id: form.customer_id || undefined,
contact_ids: form.contact_ids.length > 0 ? form.contact_ids : undefined,
log_date: new Date().toISOString().split('T')[0]
})
ElMessage.success('写跟进成功!AI 将在后台提炼客户特征。')
dialogVisible.value = false
// 缓存已选客户名称
if (form.customer_id) {
const selected = customerOptions.value.find(c => c.id === form.customer_id)
if (selected) {
customerNameMap.value[form.customer_id] = selected.name
}
}
handleSearch()
} catch {
// ...
} finally {
dialogSubmitting.value = false
}
}
onMounted(() => {
fetchLogs()
})
</script>
<template>
<div class="sales-logs-container">
<!-- 筛选栏 -->
<el-card shadow="never" class="filter-card">
<div class="filter-bar">
<el-form :inline="true" @submit.prevent>
<el-form-item label="跟进日期">
<el-date-picker
v-model="dateRange"
type="daterange"
range-separator=""
start-placeholder="开始日期"
end-placeholder="结束日期"
value-format="YYYY-MM-DD"
style="width: 250px"
/>
</el-form-item>
<el-form-item label="指定客户" v-if="customerNameQuery">
<el-tag closable @close="handleReset" type="primary">{{ customerNameQuery }}</el-tag>
</el-form-item>
<el-form-item>
<el-button type="primary" :icon="Search" @click="handleSearch">搜索</el-button>
<el-button @click="handleReset" v-if="dateRange || customerIdQuery">重置</el-button>
</el-form-item>
</el-form>
<div class="actions">
<el-button type="success" :icon="Plus" @click="openAddDialog">写跟进</el-button>
</div>
</div>
</el-card>
<!-- 表格部分 -->
<el-card shadow="never" class="table-card">
<el-table :data="tableData" border stripe style="width: 100%" v-loading="loading">
<el-table-column prop="log_date" label="跟进日期" width="120" align="center" />
<el-table-column label="关联客户" width="200">
<template #default="scope">
<span>{{ getCustomerName(scope.row.customer_id) }}</span>
</template>
</el-table-column>
<el-table-column label="跟进内容" min-width="400">
<template #default="scope">
<div class="log-content-cell">{{ scope.row.content }}</div>
</template>
</el-table-column>
<el-table-column label="AI 画像状态" width="160" align="center">
<template #default="scope">
<el-tag v-if="scope.row.ai_processed" type="success" effect="light">已提取画像</el-tag>
<el-tag v-else type="info" effect="light">--</el-tag>
</template>
</el-table-column>
</el-table>
<div class="pagination-wrapper">
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="pageSize"
:total="total"
layout="total, prev, pager, next"
@current-change="fetchLogs"
/>
</div>
</el-card>
<!-- 弹窗写跟进 -->
<el-dialog v-model="dialogVisible" title="填写销售跟进记录" width="600px" destroy-on-close>
<el-form ref="formRef" :model="form" :rules="formRules" label-width="100px" label-position="top">
<el-alert
title="智能化录入"
type="info"
description="请直接记录沟通细节,后台 AI 将自动抽取出【核心痛点】、【偏好】及【购买意向】更新至客户档案。"
show-icon
style="margin-bottom: 20px"
:closable="false"
/>
<el-form-item label="关联客户">
<el-select
v-model="form.customer_id"
filterable
remote
clearable
reserve-keyword
placeholder="输入客户名称搜索..."
:remote-method="handleCustomerSearch"
:loading="customerSearchLoading"
style="width: 100%"
>
<el-option
v-for="item in customerOptions"
:key="item.id"
:label="item.name"
:value="item.id"
>
<div style="display: flex; justify-content: space-between; align-items: center">
<span>{{ item.name }}</span>
<span style="color: #999; font-size: 12px; margin-left: 12px">
{{ item.level }} {{ item.contact || '' }}
</span>
</div>
</el-option>
</el-select>
</el-form-item>
<el-form-item label="关联联系人" v-if="form.customer_id">
<el-select
v-model="form.contact_ids"
multiple
placeholder="(选填)选择拜访的联系人"
style="width: 100%"
:loading="loadingContacts"
>
<el-option
v-for="item in contactOptions"
:key="item.id"
:label="item.name + (item.title ? ` - ${item.title}` : '')"
:value="item.id"
/>
</el-select>
</el-form-item>
<el-form-item label="跟进纪要" prop="content">
<el-input
v-model="form.content"
type="textarea"
:rows="6"
placeholder="例如:今天下午拜访了张总,客户表示目前对价格比较敏感,需要我们下周出具一套具备更高性价比的报价方案。重点决策人是王工。"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="dialogSubmitting" @click="submitForm">提交并触发 AI 提取</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.sales-logs-container {
display: flex;
flex-direction: column;
gap: 16px;
}
.filter-card {
border: none;
border-radius: 8px;
}
.filter-bar {
display: flex;
justify-content: space-between;
align-items: flex-start;
}
.table-card {
border: none;
border-radius: 8px;
}
.log-content-cell {
white-space: pre-wrap;
word-break: break-all;
line-height: 1.5;
color: #303133;
}
.pagination-wrapper {
margin-top: 16px;
display: flex;
justify-content: flex-end;
}
</style>
@@ -0,0 +1,429 @@
<script setup lang="ts">
/**
* 客户详情页 —— V5.0 升级
* routes: /customers/detail?id=xxx
* GET /api/customers/{id} → 渲染档案卡 + AI 画像 + 联系人
*/
import { ref, reactive, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import {
Document,
ArrowLeft,
CirclePlus,
Delete
} from '@element-plus/icons-vue'
import { ElMessage, ElMessageBox, type FormInstance } from 'element-plus'
import request from '@/api/request'
const route = useRoute()
const router = useRouter()
const loading = ref(true)
// --- 销售跟进记录 ---
const followUpLogs = ref<any[]>([])
const loadingLogs = ref(false)
// --- 关联产品库 ---
const relatedProducts = ref<any[]>([])
const loadingProducts = ref(false)
// --- 客户档案(真实数据) ---
const customer = reactive({
id: '',
name: '',
level: '',
industry: '',
contact: '',
phone: '',
email: '',
address: '',
ai_score: 0,
owner_name: '',
status: 1,
created_at: '',
updated_at: '',
ai_persona: null as any,
})
const fetchCustomer = async () => {
const id = route.query.id as string
if (!id) {
ElMessage.warning('缺少客户 ID 参数')
router.push('/customers')
return
}
loading.value = true
try {
const data: any = await request.get(`/api/customers/${id}`)
Object.assign(customer, data)
fetchLogs(id)
fetchContacts(id)
fetchProducts(id)
} catch {
ElMessage.error('获取客户详情失败')
router.push('/customers')
} finally {
loading.value = false
}
}
// --- 联系人管理 ---
const contacts = ref<any[]>([])
const loadingContacts = ref(false)
const contactDialogVisible = ref(false)
const contactSubmitting = ref(false)
const contactFormRef = ref<FormInstance>()
const contactForm = reactive({ name: '', phone: '', title: '' })
const contactRules = {
name: [{ required: true, message: '请输入联系人姓名', trigger: 'blur' }]
}
const fetchContacts = async (customerId: string) => {
loadingContacts.value = true
try {
const res: any = await request.get(`/api/customers/${customerId}/contacts`)
contacts.value = res || []
} catch {
} finally {
loadingContacts.value = false
}
}
const openContactDialog = () => {
contactForm.name = ''
contactForm.phone = ''
contactForm.title = ''
contactDialogVisible.value = true
}
const submitContact = async () => {
if (!contactFormRef.value) return
const valid = await contactFormRef.value.validate().catch(() => false)
if (!valid) return
contactSubmitting.value = true
try {
await request.post(`/api/customers/${customer.id}/contacts`, contactForm)
ElMessage.success('添加联系人成功')
contactDialogVisible.value = false
fetchContacts(customer.id)
} catch {
} finally {
contactSubmitting.value = false
}
}
const deleteContact = (id: string, name: string) => {
ElMessageBox.confirm(`确定要删除联系人「${name}」吗?`, '警告', { type: 'warning' })
.then(async () => {
try {
await request.delete(`/api/contacts/${id}`)
ElMessage.success('已删除联系人')
fetchContacts(customer.id)
} catch {}
}).catch(() => {})
}
const fetchLogs = async (customerId: string) => {
loadingLogs.value = true
try {
const res: any = await request.get('/api/sales-logs', { params: { customer_id: customerId, size: 50 } })
followUpLogs.value = res.items || []
} catch {
// 错误已统一处理
} finally {
loadingLogs.value = false
}
}
const fetchProducts = async (customerId: string) => {
loadingProducts.value = true
try {
const res: any = await request.get(`/api/customers/${customerId}/products`)
relatedProducts.value = res || []
} catch {
} finally {
loadingProducts.value = false
}
}
// --- 级别显示映射 ---
const levelMap: Record<string, { text: string; type: string }> = {
A: { text: 'A级 · 核心客户', type: 'danger' },
B: { text: 'B级 · 重要客户', type: 'warning' },
C: { text: 'C级 · 普通客户', type: 'info' },
}
const getLevelInfo = (level: string) => levelMap[level] || { text: level, type: 'info' }
// --- 归档 ---
const archiveCustomer = () => {
ElMessageBox.confirm(`确定要归档客户「${customer.name}」吗?`, '警告', { type: 'warning' })
.then(async () => {
try {
await request.delete(`/api/customers/${customer.id}`)
ElMessage.success('客户已归档')
router.push('/customers')
} catch { /* 已统一 */ }
})
.catch(() => {})
}
// --- 写新日志 ---
const handleAddLog = () => {
router.push({ path: '/logs', query: { customer_id: customer.id, customer_name: customer.name } })
}
onMounted(fetchCustomer)
</script>
<template>
<div class="customer-detail" v-loading="loading">
<!-- 返回按钮 -->
<el-button :icon="ArrowLeft" link @click="router.push('/customers')" style="margin-bottom:10px; font-size:14px">返回客户列表</el-button>
<!-- 1. 顶部客户档案卡片 -->
<el-card shadow="never" class="profile-header" v-if="customer.id">
<div class="header-content">
<div class="info-section">
<div class="title-row">
<h2 class="customer-name">{{ customer.name }}</h2>
<el-tag :type="(getLevelInfo(customer.level).type as any)" effect="dark" class="level-tag">{{ getLevelInfo(customer.level).text }}</el-tag>
<el-tag v-if="customer.status === 0" type="info" effect="plain">已停用</el-tag>
</div>
<el-descriptions :column="2" class="desc-box">
<el-descriptions-item label="所属行业">{{ customer.industry || '-' }}</el-descriptions-item>
<el-descriptions-item label="归属人">{{ customer.owner_name || '-' }}</el-descriptions-item>
<el-descriptions-item label="联系人">{{ customer.contact || '-' }}</el-descriptions-item>
<el-descriptions-item label="联系电话">{{ customer.phone || '-' }}</el-descriptions-item>
<el-descriptions-item label="电子邮箱">{{ customer.email || '-' }}</el-descriptions-item>
<el-descriptions-item label="详细地址">{{ customer.address || '-' }}</el-descriptions-item>
<el-descriptions-item label="创建时间">{{ customer.created_at }}</el-descriptions-item>
<el-descriptions-item label="更新时间">{{ customer.updated_at }}</el-descriptions-item>
</el-descriptions>
</div>
<div class="ai-assist-section">
<div class="ai-score-container">
<el-progress
type="dashboard"
:percentage="Math.round(customer.ai_score)"
:color="[ { color: '#f56c6c', percentage: 20 }, { color: '#e6a23c', percentage: 60 }, { color: '#5cb87a', percentage: 100 } ]"
:width="100"
>
<template #default="{ percentage }">
<div class="score-value">{{ percentage }}</div>
<div class="score-label">AI 客情健康</div>
</template>
</el-progress>
</div>
<div class="action-buttons">
<el-button type="primary" :icon="Document">🤖 生成拜访简报</el-button>
<el-button type="danger" plain @click="archiveCustomer">归档客户</el-button>
</div>
</div>
</div>
</el-card>
<!-- 2. Tabs 面板 -->
<el-card shadow="never" class="tabs-card" v-if="customer.id">
<el-tabs>
<el-tab-pane label="AI 企业画像" name="persona">
<div v-if="customer.ai_persona && Object.keys(customer.ai_persona).length > 0" class="persona-container">
<!-- 兼容新老 Schema -->
<el-row :gutter="20">
<el-col :span="12">
<el-card shadow="hover" class="persona-card h-full">
<template #header><div class="card-header">🏭 企业属性 (Firmographics)</div></template>
<div v-if="customer.ai_persona.firmographics" class="desc-box">
<el-descriptions :column="1" size="small">
<el-descriptions-item label="行业">{{ customer.ai_persona.firmographics.industry || '-' }}</el-descriptions-item>
<el-descriptions-item label="规模">{{ customer.ai_persona.firmographics.scale || '-' }}</el-descriptions-item>
<el-descriptions-item label="商业模式">{{ customer.ai_persona.firmographics.business_model || '-' }}</el-descriptions-item>
</el-descriptions>
</div>
<div v-else class="empty-text">无数据</div>
</el-card>
</el-col>
<el-col :span="12">
<el-card shadow="hover" class="persona-card h-full">
<template #header><div class="card-header">🔥 核心痛点与意向 (Dynamic Status)</div></template>
<div v-if="customer.ai_persona.dynamic_status">
<div style="margin-bottom:10px;">
<strong>痛点: </strong>
<el-tag v-for="(item, idx) in customer.ai_persona.dynamic_status.pain_points" :key="idx" type="danger" effect="light" class="p-tag" style="margin-right:5px;margin-bottom:5px;">{{ item }}</el-tag>
</div>
<div>
<strong>意向: </strong>
<span class="summary-text">{{ customer.ai_persona.dynamic_status.purchase_intent || '未知' }}</span>
</div>
<div v-if="customer.ai_persona.dynamic_status.recent_events?.length" style="margin-top:10px;">
<strong>近期事件: </strong>
<ul><li v-for="(ev, idx) in customer.ai_persona.dynamic_status.recent_events" :key="idx" class="summary-text" style="font-size:13px">{{ev}}</li></ul>
</div>
</div>
<!-- 兼容旧版痛点 -->
<div v-else-if="customer.ai_persona.pain_points">
<el-tag v-for="(item, idx) in customer.ai_persona.pain_points" :key="idx" type="danger" effect="light" class="p-tag" style="margin-right:5px;">{{ item }}</el-tag>
</div>
<div v-else class="empty-text">无数据</div>
</el-card>
</el-col>
</el-row>
<el-row :gutter="20" class="mt-20" v-if="customer.ai_persona.summary">
<el-col :span="24">
<el-card shadow="hover" class="persona-card">
<template #header><div class="card-header">📝 AI 总结 (Summary)</div></template>
<div class="summary-text">{{ customer.ai_persona.summary }}</div>
</el-card>
</el-col>
</el-row>
</div>
<div v-else class="timeline-container">
<el-empty description="暂无 AI 画像数据。销售人员提交跟进日志后,AI 将自动分析提取。" />
</div>
</el-tab-pane>
<el-tab-pane label="联系人管理" name="contacts">
<div class="timeline-container" v-loading="loadingContacts">
<div style="margin-bottom: 15px; text-align: right;">
<el-button type="primary" :icon="CirclePlus" @click="openContactDialog">新增联系人</el-button>
</div>
<el-table :data="contacts" border stripe style="width: 100%">
<el-table-column prop="name" label="姓名" width="120" />
<el-table-column prop="title" label="职位" width="150" />
<el-table-column prop="phone" label="电话" width="150" />
<el-table-column label="AI 画像 (Buyer Persona)" min-width="250">
<template #default="{ row }">
<div v-if="row.ai_buyer_persona" class="summary-text" style="font-size: 13px;">
<template v-if="row.ai_buyer_persona.role">
<el-tag size="small" effect="plain">{{ row.ai_buyer_persona.role.decision_role }}</el-tag>
<span style="margin-left: 8px;">权限: {{ row.ai_buyer_persona.role.authority_level }}</span><br/>
</template>
<span v-if="row.ai_buyer_persona.preference?.comm_style">👩💻 沟通: {{ row.ai_buyer_persona.preference.comm_style }}<br/></span>
<span v-if="row.ai_buyer_persona.kpi?.core_goals?.length">🎯 目标: {{ row.ai_buyer_persona.kpi.core_goals.join('') }}</span>
</div>
<span v-else class="empty-text">无画像档案</span>
</template>
</el-table-column>
<el-table-column label="操作" width="100" align="center" fixed="right">
<template #default="{ row }">
<el-button type="danger" link :icon="Delete" @click="deleteContact(row.id, row.name)">删除</el-button>
</template>
</el-table-column>
</el-table>
</div>
</el-tab-pane>
<el-tab-pane label="全景时间轴" name="timeline">
<div class="timeline-container" v-loading="loadingLogs">
<el-timeline v-if="followUpLogs.length > 0">
<el-timeline-item
v-for="(log, idx) in followUpLogs"
:key="log.id"
:timestamp="log.log_date"
placement="top"
:type="idx === 0 ? 'primary' : 'info'"
>
<el-card shadow="hover" class="timeline-card">
<div class="log-content-pre">{{ log.content }}</div>
<div class="log-meta">
<span class="meta-item">创建时间{{ log.created_at || '-' }}</span>
<el-tag v-if="log.ai_processed" type="success" size="small" effect="plain" class="ai-tag"> AI 已提取画像</el-tag>
<el-tag v-else type="info" size="small" effect="plain" class="ai-tag"> 等待 AI 提取</el-tag>
</div>
</el-card>
</el-timeline-item>
</el-timeline>
<el-empty v-else description="暂无跟进记录" />
</div>
</el-tab-pane>
<el-tab-pane label="关联产品库" name="products">
<div class="timeline-container" v-loading="loadingProducts">
<el-table v-if="relatedProducts.length > 0" :data="relatedProducts" border stripe style="width: 100%">
<el-table-column prop="sku_code" label="SKU 编码" width="140" />
<el-table-column prop="sku_name" label="产品名称" min-width="180" show-overflow-tooltip />
<el-table-column prop="spec" label="规格" min-width="150" show-overflow-tooltip>
<template #default="{ row }">{{ row.spec || '-' }}</template>
</el-table-column>
<el-table-column label="累计购买量" width="120" align="right">
<template #default="{ row }"><b>{{ row.total_qty }}</b></template>
</el-table-column>
<el-table-column prop="order_count" label="关联订单数" width="110" align="center" />
<el-table-column prop="last_order_date" label="最近购买日期" width="130" align="center">
<template #default="{ row }">{{ row.last_order_date || '-' }}</template>
</el-table-column>
</el-table>
<el-empty v-else description="暂无关联产品,该客户尚未产生订单记录" />
</div>
</el-tab-pane>
</el-tabs>
</el-card>
<!-- 联系人表单弹窗 -->
<el-dialog v-model="contactDialogVisible" title="新增联系人" width="400px" destroy-on-close>
<el-form ref="contactFormRef" :model="contactForm" :rules="contactRules" label-width="80px">
<el-form-item label="姓名" prop="name">
<el-input v-model="contactForm.name" placeholder="联系人姓名" />
</el-form-item>
<el-form-item label="职位" prop="title">
<el-input v-model="contactForm.title" placeholder="如:采购总监" />
</el-form-item>
<el-form-item label="电话" prop="phone">
<el-input v-model="contactForm.phone" placeholder="联系电话" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="contactDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="contactSubmitting" @click="submitContact">保存</el-button>
</template>
</el-dialog>
<!-- 悬浮按钮 - 写新日志 -->
<el-tooltip content="写新跟进记录" placement="left">
<el-button v-if="customer.id" type="primary" circle size="large" class="floating-btn" @click="handleAddLog" :icon="CirclePlus" />
</el-tooltip>
</div>
</template>
<style scoped>
.customer-detail { display: flex; flex-direction: column; gap: 20px; position: relative; }
.profile-header { border-radius: 8px; border: none; box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08); }
.header-content { display: flex; justify-content: space-between; align-items: flex-start; gap: 30px; }
.info-section { flex: 1; }
.title-row { display: flex; align-items: center; gap: 15px; margin-bottom: 20px; }
.customer-name { margin: 0; font-size: 24px; color: #303133; }
.level-tag { font-weight: bold; }
.desc-box :deep(.el-descriptions__label) { width: 100px; color: #909399; }
.desc-box :deep(.el-descriptions__content) { color: #606266; font-weight: 500; }
.ai-assist-section { display: flex; flex-direction: column; align-items: center; gap: 20px; min-width: 200px; padding-left: 30px; border-left: 1px dashed #dcdfe6; }
.ai-score-container { display: flex; justify-content: center; align-items: center; }
.score-value { font-size: 24px; font-weight: bold; color: #303133; }
.score-label { font-size: 12px; color: #909399; margin-top: 4px; }
.action-buttons { display: flex; flex-direction: column; gap: 10px; width: 100%; }
.action-buttons .el-button { margin: 0; }
.tabs-card { border-radius: 8px; border: none; box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08); min-height: 400px; }
.timeline-container { padding: 10px 20px; }
.floating-btn { position: fixed; right: 40px; bottom: 40px; width: 48px; height: 48px; font-size: 20px; box-shadow: 0 4px 12px rgba(64, 158, 255, 0.4); z-index: 100; }
.drawer-content { display: flex; flex-direction: column; height: 100%; }
.drawer-footer { display: flex; justify-content: flex-end; }
/* AI Persona Styles */
.persona-container { padding: 10px; }
.mt-20 { margin-top: 20px; }
.persona-card { border-radius: 8px; border: 1px solid #ebeef5; }
.h-full { height: 100%; }
.card-header { font-weight: bold; color: #303133; font-size: 14px; }
.tags-wrapper { display: flex; flex-wrap: wrap; gap: 8px; min-height: 24px; }
.p-tag { font-size: 13px; }
.empty-text { color: #909399; font-size: 13px; font-style: italic; }
.intent-wrapper { display: flex; align-items: center; justify-content: center; height: 100%; min-height: 40px; }
.summary-text { color: #606266; font-size: 14px; line-height: 1.6; }
/* Timeline Styles */
.timeline-card { margin-bottom: 10px; border-radius: 6px; }
.log-content-pre { font-size: 14px; color: #303133; white-space: pre-wrap; line-height: 1.6; margin-bottom: 12px; }
.log-meta { display: flex; align-items: center; gap: 12px; font-size: 12px; color: #909399; }
.ai-tag { margin-left: auto; }
</style>
+484
View File
@@ -0,0 +1,484 @@
<script setup lang="ts">
/**
* CRM 客户管理 —— 真实 API 驱动
* GET /api/customers (分页 + keyword 搜索)
* POST /api/customers (新增开户)
* PUT /api/customers/{id} (编辑)
* DELETE /api/customers/{id} (软删除/归档)
*/
import { ref, reactive, onMounted, nextTick } from 'vue'
import { useRouter } from 'vue-router'
import { Search, Plus, View, Edit, Box, Upload, Download } from '@element-plus/icons-vue'
import { ElMessageBox, ElMessage, type FormInstance, type FormRules } from 'element-plus'
import request from '@/api/request'
import { useUserStore } from '@/store/user'
import { computed } from 'vue'
const userStore = useUserStore()
const isAdmin = computed(() => userStore.userInfo?.data_scope === 'all' || (userStore.userInfo?.role_name || '').toLowerCase() === 'admin')
const router = useRouter()
const loading = ref(false)
// --- 搜索表单 ---
const searchForm = reactive({
keyword: '',
level: '',
industry: '',
showArchived: false,
})
// --- 数据 ---
const customerData = ref<any[]>([])
const currentPage = ref(1)
const pageSize = ref(10)
const total = ref(0)
// --- 拉取客户列表 ---
const fetchCustomers = async () => {
loading.value = true
try {
const params: Record<string, any> = {
page: currentPage.value,
size: pageSize.value,
}
if (searchForm.keyword) params.keyword = searchForm.keyword
if (searchForm.level) params.level = searchForm.level
if (searchForm.showArchived) params.include_archived = true
const data: any = await request.get('/api/customers', { params })
customerData.value = data.items || []
total.value = data.total || 0
} catch {
// 已统一处理
} finally {
loading.value = false
}
}
const handleSearch = () => {
currentPage.value = 1
fetchCustomers()
}
const handlePageChange = () => {
fetchCustomers()
}
// --- 新增客户弹窗 ---
const addDialogVisible = ref(false)
const addFormRef = ref<FormInstance>()
const addSubmitting = ref(false)
const addForm = reactive({
name: '',
level: 'B',
industry: '',
contact: '',
phone: '',
address: '',
remark: '',
})
const addFormRules = reactive<FormRules>({
name: [{ required: true, message: '请输入客户名称', trigger: 'blur' }],
})
const handleAddCustomer = () => {
Object.assign(addForm, { name: '', level: 'B', industry: '', contact: '', phone: '', address: '', remark: '' })
addDialogVisible.value = true
nextTick(() => addFormRef.value?.clearValidate())
}
const submitAddCustomer = async () => {
const valid = await addFormRef.value?.validate().catch(() => false)
if (!valid) return
addSubmitting.value = true
try {
await request.post('/api/customers', addForm)
ElMessage.success('客户开户成功')
addDialogVisible.value = false
fetchCustomers()
} catch {
// 已统一处理
} finally {
addSubmitting.value = false
}
}
// --- 编辑客户弹窗 ---
const editDialogVisible = ref(false)
const editFormRef = ref<FormInstance>()
const editSubmitting = ref(false)
const editForm = reactive({
id: '',
name: '',
level: '',
industry: '',
contact: '',
phone: '',
address: '',
remark: '',
})
const editFormRules = reactive<FormRules>({
name: [{ required: true, message: '请输入客户名称', trigger: 'blur' }],
})
const editCustomer = (row: any) => {
Object.assign(editForm, {
id: row.id,
name: row.name || '',
level: row.level || '',
industry: row.industry || '',
contact: row.contact || '',
phone: row.phone || '',
address: row.address || '',
remark: row.remark || '',
})
editDialogVisible.value = true
nextTick(() => editFormRef.value?.clearValidate())
}
const submitEditCustomer = async () => {
const valid = await editFormRef.value?.validate().catch(() => false)
if (!valid) return
editSubmitting.value = true
try {
const { id, ...payload } = editForm
await request.put(`/api/customers/${id}`, payload)
ElMessage.success('客户信息已更新')
editDialogVisible.value = false
fetchCustomers()
} catch {
// 已统一处理
} finally {
editSubmitting.value = false
}
}
// --- 查看档案 ---
const viewDetails = (row: any) => {
router.push({
path: '/customers/detail',
query: { id: row.id }
})
}
// --- 归档 (软删除) ---
const archiveCustomer = (row: any) => {
ElMessageBox.confirm(
`确定要将客户 "${row.name}" 设为归档状态吗?该操作不会彻底删除数据。`,
'归档确认',
{ confirmButtonText: '确定归档', cancelButtonText: '取消', type: 'warning' }
).then(async () => {
try {
await request.delete(`/api/customers/${row.id}`)
ElMessage.success('归档成功!')
fetchCustomers()
} catch {
// 已统一处理
}
}).catch(() => {})
}
const restoreCustomer = (row: any) => {
ElMessageBox.confirm(
`确定要恢复客户 "${row.name}" 吗?`,
'恢复确认',
{ confirmButtonText: '确定恢复', cancelButtonText: '取消', type: 'info' }
).then(async () => {
try {
await request.put(`/api/customers/${row.id}/restore`)
ElMessage.success('客户已恢复!')
fetchCustomers()
} catch {
// 已统一处理
}
}).catch(() => {})
}
// --- 辅助函数 ---
const getLevelType = (level: string) => {
if (!level) return ''
if (level.includes('A')) return 'danger'
if (level.includes('B')) return 'warning'
if (level.includes('C')) return 'info'
return ''
}
const getLevelLabel = (level: string) => {
if (level === 'A') return 'A级重点'
if (level === 'B') return 'B级普通'
if (level === 'C') return 'C级长尾'
return level || '-'
}
// --- 导入 / 导出 ---
const importDialogVisible = ref(false)
const getUploadHeaders = () => {
return { Authorization: `Bearer ${localStorage.getItem('crm_token') || ''}` }
}
const handleImportSuccess = (response: any) => {
if (response.code === 200) {
ElMessage.success(response.message || '导入成功')
importDialogVisible.value = false
fetchCustomers()
} else {
ElMessage.error(response.message || '导入失败')
}
}
const handleImportError = () => { ElMessage.error('上传失败,请检查网络或后端服务') }
const handleExport = async () => {
try {
const res = await request.get('/api/crm/export', {
responseType: 'blob'
})
const blob = new Blob([res as any], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' })
const url = window.URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `customers_export_${new Date().toISOString().split('T')[0]}.xlsx`
a.click()
window.URL.revokeObjectURL(url)
ElMessage.success('导出成功')
} catch {
ElMessage.error('导出失败或无权限')
}
}
// --- 初始化 ---
onMounted(() => {
fetchCustomers()
})
</script>
<template>
<div class="customer-list-container">
<!-- 1. 顶部高级检索区 -->
<el-card shadow="never" class="filter-section">
<div class="filter-wrapper">
<el-form :inline="true" :model="searchForm" class="filter-form">
<el-form-item label="客户名称">
<el-input v-model="searchForm.keyword" placeholder="请输入名称/拼音" clearable @keyup.enter="handleSearch" />
</el-form-item>
<el-form-item label="客户级别">
<el-select v-model="searchForm.level" placeholder="全部分级" clearable style="width: 150px">
<el-option label="A级重点" value="A" />
<el-option label="B级普通" value="B" />
<el-option label="C级长尾" value="C" />
</el-select>
</el-form-item>
<el-form-item>
<el-switch v-model="searchForm.showArchived" active-text="显示已归档" @change="handleSearch" style="margin-right: 10px" />
</el-form-item>
</el-form>
<div class="action-buttons">
<el-button type="primary" :icon="Search" @click="handleSearch">搜索</el-button>
<el-button type="warning" :icon="Upload" @click="importDialogVisible = true">导入客户</el-button>
<el-button v-if="isAdmin" type="info" :icon="Download" @click="handleExport">导出</el-button>
<el-button type="success" :icon="Plus" @click="handleAddCustomer">新增客户</el-button>
</div>
</div>
</el-card>
<!-- 2. 核心数据表格 -->
<el-card shadow="never" class="table-section">
<el-table :data="customerData" stripe border style="width: 100%" v-loading="loading">
<el-table-column prop="name" label="客户名称" min-width="220" show-overflow-tooltip>
<template #default="scope">
<span class="customer-name-bold">{{ scope.row.name }}</span>
</template>
</el-table-column>
<el-table-column label="客户级别" width="120" align="center">
<template #default="scope">
<el-tag :type="getLevelType(scope.row.level)" effect="light">
{{ getLevelLabel(scope.row.level) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="industry" label="所属行业" min-width="160" show-overflow-tooltip />
<el-table-column prop="contact" label="联系人" width="120" />
<el-table-column prop="phone" label="联系电话" width="140" />
<el-table-column prop="address" label="地址" min-width="180" show-overflow-tooltip />
<el-table-column prop="created_at" label="创建时间" width="170" />
<el-table-column label="操作" width="220" fixed="right">
<template #default="scope">
<el-button type="primary" link :icon="View" @click="viewDetails(scope.row)">查看档案</el-button>
<template v-if="!scope.row.is_deleted">
<el-button type="primary" link :icon="Edit" @click="editCustomer(scope.row)">编辑</el-button>
<el-button type="danger" link :icon="Box" @click="archiveCustomer(scope.row)">归档</el-button>
</template>
<el-button v-else type="success" link @click="restoreCustomer(scope.row)">恢复</el-button>
</template>
</el-table-column>
</el-table>
<!-- 3. 分页 -->
<div class="pagination-section">
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="pageSize"
:page-sizes="[10, 20, 50]"
background
layout="total, prev, pager, next, jumper"
:total="total"
@current-change="handlePageChange"
@size-change="handlePageChange"
/>
</div>
</el-card>
<!-- 4. 新增客户弹窗 -->
<el-dialog v-model="addDialogVisible" title="新增客户开户" width="560px" destroy-on-close>
<el-form ref="addFormRef" :model="addForm" :rules="addFormRules" label-width="80px">
<el-form-item label="客户名称" prop="name">
<el-input v-model="addForm.name" placeholder="请输入客户公司名称" />
</el-form-item>
<el-form-item label="客户级别">
<el-select v-model="addForm.level" style="width: 100%">
<el-option label="A级重点" value="A" />
<el-option label="B级普通" value="B" />
<el-option label="C级长尾" value="C" />
</el-select>
</el-form-item>
<el-form-item label="行业">
<el-input v-model="addForm.industry" placeholder="所属行业" />
</el-form-item>
<el-form-item label="联系人">
<el-input v-model="addForm.contact" placeholder="联系人姓名" />
</el-form-item>
<el-form-item label="手机号">
<el-input v-model="addForm.phone" placeholder="联系电话" />
</el-form-item>
<el-form-item label="地址">
<el-input v-model="addForm.address" placeholder="地址" />
</el-form-item>
<el-form-item label="备注">
<el-input v-model="addForm.remark" type="textarea" :rows="2" placeholder="备注" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="addDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="addSubmitting" @click="submitAddCustomer">确定提交</el-button>
</template>
</el-dialog>
<!-- 5. 编辑客户弹窗 -->
<el-dialog v-model="editDialogVisible" title="编辑客户信息" width="560px" destroy-on-close>
<el-form ref="editFormRef" :model="editForm" :rules="editFormRules" label-width="80px">
<el-form-item label="客户名称" prop="name">
<el-input v-model="editForm.name" />
</el-form-item>
<el-form-item label="客户级别">
<el-select v-model="editForm.level" style="width: 100%">
<el-option label="A级重点" value="A" />
<el-option label="B级普通" value="B" />
<el-option label="C级长尾" value="C" />
</el-select>
</el-form-item>
<el-form-item label="行业">
<el-input v-model="editForm.industry" />
</el-form-item>
<el-form-item label="联系人">
<el-input v-model="editForm.contact" />
</el-form-item>
<el-form-item label="手机号">
<el-input v-model="editForm.phone" />
</el-form-item>
<el-form-item label="地址">
<el-input v-model="editForm.address" />
</el-form-item>
<el-form-item label="备注">
<el-input v-model="editForm.remark" type="textarea" :rows="2" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="editDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="editSubmitting" @click="submitEditCustomer">保存修改</el-button>
</template>
</el-dialog>
<!-- 6. 批量导入弹窗 -->
<el-dialog v-model="importDialogVisible" title="批量导入客户" width="400px" destroy-on-close>
<div style="margin-bottom: 20px; line-height: 1.6;">
<p>1. 请先下载标准模板按要求填写数据</p>
<p>2. 仅支持 .xlsx 格式文件</p>
<p style="margin-top: 10px;">
<a href="/api/templates/customer_import_template.xlsx" target="_blank" style="color: #409eff; text-decoration: none;">
点击下载客户导入模板.xlsx
</a>
</p>
</div>
<el-upload
drag
action="/api/crm/import"
:headers="getUploadHeaders()"
accept=".xlsx,.xls"
:show-file-list="false"
:on-success="handleImportSuccess"
:on-error="handleImportError"
>
<el-icon class="el-icon--upload"><Upload /></el-icon>
<div class="el-upload__text">将文件拖到此处 <em>点击上传</em></div>
</el-upload>
</el-dialog>
</div>
</template>
<style scoped>
.customer-list-container {
display: flex;
flex-direction: column;
gap: 20px;
}
/* 顶部检索区 */
.filter-section {
border-radius: 8px;
border: none;
box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08);
}
.filter-wrapper {
display: flex;
justify-content: space-between;
flex-wrap: wrap;
align-items: flex-start;
}
.filter-form .el-form-item {
margin-bottom: 0;
margin-right: 20px;
}
.action-buttons {
display: flex;
gap: 10px;
}
/* 数据表格区 */
.table-section {
border-radius: 8px;
border: none;
box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08);
}
.customer-name-bold {
font-weight: bold;
color: #303133;
}
/* 分页 */
.pagination-section {
margin-top: 20px;
display: flex;
justify-content: flex-end;
}
</style>
@@ -0,0 +1,316 @@
<script setup lang="ts">
/**
* AI 复盘报告页面
* - SSE 流式生成复盘报告
* - 自动存 draft 防丢失
* - 历史报告查看/编辑/存档/删除
*/
import { ref, nextTick, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Delete } from '@element-plus/icons-vue'
import request from '@/api/request'
import { useUserStore } from '@/store/user'
const userStore = useUserStore()
// 时间范围:默认本月
const now = new Date()
const firstDay = new Date(now.getFullYear(), now.getMonth(), 1)
const lastDay = new Date(now.getFullYear(), now.getMonth() + 1, 0)
const fmt = (d: Date) => d.toISOString().split('T')[0]
const dateRange = ref<[string, string]>([fmt(firstDay), fmt(lastDay)])
const loading = ref(false)
const reportContent = ref('')
const isFinished = ref(false)
const confirmLoading = ref(false)
const isConfirmed = ref(false)
const isEditing = ref(false)
const reportContainerRef = ref<HTMLDivElement>()
const currentReportId = ref('')
// --- 历史报告 ---
const historyReports = ref<any[]>([])
const loadingHistory = ref(false)
const fetchHistory = async () => {
loadingHistory.value = true
try {
const res: any = await request.get('/api/reports/history', { params: { size: 50 } })
historyReports.value = res?.items || []
} catch { }
finally { loadingHistory.value = false }
}
onMounted(() => { fetchHistory() })
// 加载历史报告到显示区
const loadHistoryReport = (report: any) => {
reportContent.value = report.content_md || ''
currentReportId.value = report.id
isFinished.value = true
isConfirmed.value = report.status === 'confirmed'
isEditing.value = false
dateRange.value = [report.period_start, report.period_end]
}
// 进入编辑模式
const enterEdit = () => {
isEditing.value = true
isConfirmed.value = false
}
const deleteHistoryReport = (report: any) => {
ElMessageBox.confirm('确定要删除这份复盘报告吗?', '删除确认', {
confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning'
}).then(async () => {
await request.delete(`/api/reports/${report.id}`)
ElMessage.success('已删除')
if (currentReportId.value === report.id) {
reportContent.value = ''
currentReportId.value = ''
isFinished.value = false
}
fetchHistory()
}).catch(() => {})
}
// 生成报告
const handleGenerate = async () => {
if (!dateRange.value?.[0] || !dateRange.value?.[1]) {
ElMessage.warning('请选择时间范围')
return
}
loading.value = true
reportContent.value = ''
isFinished.value = false
isConfirmed.value = false
isEditing.value = false
currentReportId.value = ''
try {
const token = userStore.token || localStorage.getItem('crm_token') || ''
const res = await fetch('/api/reports/generate', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify({
start_date: dateRange.value[0],
end_date: dateRange.value[1],
}),
})
if (!res.ok) {
ElMessage.error(`请求失败: ${res.status}`)
loading.value = false
return
}
const reader = res.body?.getReader()
if (!reader) {
ElMessage.error('无法读取 SSE 流')
loading.value = false
return
}
const decoder = new TextDecoder()
let buffer = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
while (buffer.includes('\n\n')) {
const [eventBlock, rest] = buffer.split('\n\n', 2)
buffer = rest || ''
const dataLine = eventBlock.split('\n').find(l => l.startsWith('data: '))
if (!dataLine) continue
try {
const event = JSON.parse(dataLine.slice(6))
if (event.type === 'text') {
reportContent.value += event.content
await nextTick()
if (reportContainerRef.value) {
reportContainerRef.value.scrollTop = reportContainerRef.value.scrollHeight
}
} else if (event.type === 'done') {
isFinished.value = true
}
} catch { }
}
}
isFinished.value = true
// 自动存为 draft
if (reportContent.value.trim()) {
try {
const draftRes: any = await request.post('/api/reports/confirm', {
start_date: dateRange.value[0],
end_date: dateRange.value[1],
content_md: reportContent.value,
report_type: 'monthly',
})
if (draftRes?.id) currentReportId.value = draftRes.id
fetchHistory()
} catch { }
}
} catch (err: any) {
ElMessage.error(`生成失败: ${err.message || err}`)
} finally {
loading.value = false
}
}
// 确认存档 / 重新存档
const handleConfirm = async () => {
if (!reportContent.value.trim()) {
ElMessage.warning('无报告内容可存档')
return
}
confirmLoading.value = true
try {
if (currentReportId.value) {
await request.put(`/api/reports/${currentReportId.value}`, {
content_md: reportContent.value,
status: 'confirmed',
})
} else {
const res: any = await request.post('/api/reports/confirm', {
start_date: dateRange.value[0],
end_date: dateRange.value[1],
content_md: reportContent.value,
report_type: 'monthly',
})
if (res?.id) currentReportId.value = res.id
}
ElMessage.success('复盘报告已确认存档')
isConfirmed.value = true
isEditing.value = false
fetchHistory()
} catch { }
finally { confirmLoading.value = false }
}
</script>
<template>
<div class="report-page">
<!-- 顶部操作栏 -->
<el-card shadow="never" class="action-card">
<div class="action-bar">
<el-date-picker
v-model="dateRange"
type="daterange"
range-separator=""
start-placeholder="开始日期"
end-placeholder="结束日期"
value-format="YYYY-MM-DD"
style="width: 280px"
/>
<el-button type="primary" size="large" @click="handleGenerate" :loading="loading">
{{ loading ? 'AI 正在生成...' : '生成复盘报告' }}
</el-button>
</div>
</el-card>
<!-- 报告内容区域 -->
<el-card shadow="never" class="report-card" v-if="reportContent">
<template #header>
<div class="card-header">
<span>AI 智能复盘报告</span>
<div class="header-actions" v-if="isFinished">
<template v-if="isEditing">
<el-button type="success" @click="handleConfirm" :loading="confirmLoading">保存并存档</el-button>
<el-button @click="isEditing = false">取消编辑</el-button>
</template>
<template v-else>
<el-button v-if="!isConfirmed" type="success" @click="handleConfirm" :loading="confirmLoading">确认存档</el-button>
<el-button type="warning" plain @click="enterEdit">编辑</el-button>
<el-tag v-if="isConfirmed" type="success" effect="dark" style="margin-left: 8px">已存档</el-tag>
</template>
</div>
</div>
</template>
<!-- 编辑模式textarea -->
<el-input
v-if="isEditing"
v-model="reportContent"
type="textarea"
:autosize="{ minRows: 10, maxRows: 30 }"
placeholder="编辑复盘报告内容..."
class="report-editor"
/>
<!-- 查看模式格式化显示 -->
<div v-else ref="reportContainerRef" class="report-body">
<template v-for="(paragraph, index) in reportContent.split('\n')" :key="index">
<p v-if="paragraph.trim()" class="report-paragraph">{{ paragraph }}</p>
<br v-else-if="index > 0" />
</template>
<span v-if="loading" class="typing-cursor"></span>
</div>
</el-card>
<!-- 空状态 -->
<el-empty
v-if="!reportContent && !loading"
description="选择时间范围,点击按钮生成 AI 复盘报告"
class="empty-state"
/>
<!-- 历史报告列表 -->
<el-card shadow="never" class="history-card" v-if="historyReports.length > 0 || loadingHistory">
<template #header>
<div class="card-header">
<span>📋 历史复盘报告</span>
<el-tag type="info" size="small"> {{ historyReports.length }} </el-tag>
</div>
</template>
<div v-loading="loadingHistory">
<div v-for="report in historyReports" :key="report.id" class="history-item"
:class="{ active: currentReportId === report.id }">
<div class="history-info" @click="loadHistoryReport(report)">
<span class="history-period">{{ report.period_start }} ~ {{ report.period_end }}</span>
<el-tag :type="report.status === 'confirmed' ? 'success' : 'warning'" size="small" effect="plain">
{{ report.status === 'confirmed' ? '已确认' : '草稿' }}
</el-tag>
<span class="history-time">{{ report.created_at?.slice(0, 16)?.replace('T', ' ') }}</span>
</div>
<el-button :icon="Delete" type="danger" link size="small" @click.stop="deleteHistoryReport(report)" />
</div>
</div>
</el-card>
</div>
</template>
<style scoped>
.report-page { display: flex; flex-direction: column; gap: 20px; }
.action-card { border: none; border-radius: 8px; }
.action-bar { display: flex; align-items: center; gap: 16px; justify-content: flex-end; }
.report-card { border: none; border-radius: 8px; }
.card-header { display: flex; justify-content: space-between; align-items: center; font-weight: bold; font-size: 16px; }
.header-actions { display: flex; align-items: center; gap: 8px; }
.report-body { line-height: 1.8; color: #333; font-size: 15px; max-height: calc(100vh - 350px); overflow-y: auto; padding-right: 8px; }
.report-paragraph { margin-bottom: 8px; }
.report-editor :deep(.el-textarea__inner) { font-size: 15px; line-height: 1.8; font-family: inherit; }
.typing-cursor { animation: blink 1s step-end infinite; color: #409eff; }
@keyframes blink { 50% { opacity: 0; } }
.empty-state { margin-top: 40px; background: white; padding: 40px; border-radius: 8px; }
.history-card { border: none; border-radius: 8px; }
.history-item { display: flex; justify-content: space-between; align-items: center; padding: 10px 12px; border-bottom: 1px solid #f0f0f0; cursor: pointer; transition: background 0.2s; border-radius: 4px; }
.history-item:hover { background: #f5f7fa; }
.history-item.active { background: #ecf5ff; border-left: 3px solid #409eff; }
.history-item:last-child { border-bottom: none; }
.history-info { display: flex; align-items: center; gap: 12px; flex: 1; }
.history-period { font-weight: 500; color: #303133; font-size: 14px; }
.history-time { color: #909399; font-size: 12px; }
</style>
+138
View File
@@ -0,0 +1,138 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { Plus, Van, Box, EditPen } from '@element-plus/icons-vue'
import { useRouter } from 'vue-router'
import request from '@/api/request'
const router = useRouter()
const loading = ref(true)
const stats = ref({
orders_count: 0,
pending_shipping: 0,
warning_skus: 0,
monthly_revenue: 0,
})
const fetchStats = async () => {
loading.value = true
try {
const data: any = await request.get('/api/dashboard/stats')
if (data) Object.assign(stats.value, data)
} catch {}
finally { loading.value = false }
}
const formatMoney = (v: number) => v >= 10000 ? `${(v / 10000).toFixed(1)}` : `¥${v.toLocaleString()}`
onMounted(fetchStats)
</script>
<template>
<div class="dashboard-container">
<!-- 顶部快捷操作 -->
<div class="quick-actions">
<el-button type="primary" :icon="Plus" @click="router.push('/orders')">新建订单</el-button>
<el-button type="success" :icon="Van" @click="router.push('/shipping')">安排发货</el-button>
<el-button type="warning" :icon="Box" @click="router.push('/products')">库存入库</el-button>
<el-button type="info" :icon="EditPen" @click="router.push('/logs')">写销售日志</el-button>
</div>
<!-- 中部核心数据 KPI -->
<el-row :gutter="20" class="kpi-cards">
<el-col :span="6">
<el-card shadow="hover" class="kpi-card" v-loading="loading">
<div class="kpi-title">本月新增订单</div>
<div class="kpi-value">{{ stats.orders_count }} <span class="kpi-unit"></span></div>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="hover" class="kpi-card" v-loading="loading">
<div class="kpi-title">待出库发货</div>
<div class="kpi-value" style="color: #e6a23c;">{{ stats.pending_shipping }} <span class="kpi-unit"></span></div>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="hover" class="kpi-card" v-loading="loading">
<div class="kpi-title">库存预警 SKU</div>
<div class="kpi-value" style="color: #f56c6c;">{{ stats.warning_skus }} <span class="kpi-unit"></span></div>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="hover" class="kpi-card" v-loading="loading">
<div class="kpi-title">本月预计营收</div>
<div class="kpi-value" style="color: #67c23a;">{{ formatMoney(stats.monthly_revenue) }}</div>
</el-card>
</el-col>
</el-row>
<!-- 底部最新动态 -->
<el-card shadow="never" class="recent-activities">
<template #header>
<div class="card-header">
<span>近期业务动态</span>
</div>
</template>
<el-empty description="暂无业务动态数据,请先录入订单和日志" />
</el-card>
</div>
</template>
<style scoped>
.dashboard-container {
display: flex;
flex-direction: column;
gap: 20px;
}
.quick-actions {
display: flex;
gap: 12px;
background-color: #fff;
padding: 20px;
border-radius: 4px;
box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08);
}
.kpi-cards .el-col {
margin-bottom: 20px;
}
.kpi-card {
height: 120px;
display: flex;
flex-direction: column;
justify-content: center;
border-radius: 8px;
border: none;
}
.kpi-title {
font-size: 14px;
color: #909399;
margin-bottom: 12px;
}
.kpi-value {
font-size: 28px;
font-weight: bold;
color: #303133;
}
.kpi-unit {
font-size: 14px;
font-weight: normal;
color: #606266;
}
.recent-activities {
border-radius: 8px;
border: none;
box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08);
}
.card-header {
font-weight: bold;
font-size: 16px;
}
</style>
+561
View File
@@ -0,0 +1,561 @@
<script setup lang="ts">
/**
* 销项发票管理页 (AR)
* 多条件查询 + 回款标记 + 新增 + 导出
*/
import { ref, reactive, onMounted, computed } from 'vue'
import { ElMessage, ElMessageBox, type FormInstance } from 'element-plus'
import { Plus, Search, Download } from '@element-plus/icons-vue'
import request from '@/api/request'
import { useUserStore } from '@/store/user'
import { UploadFilled } from '@element-plus/icons-vue'
import type { UploadFile } from 'element-plus'
const userStore = useUserStore()
const isAdmin = computed(() => userStore.userInfo?.data_scope === 'all' || (userStore.userInfo?.role_name || '').toLowerCase() === 'admin')
// --- 客户搜索 ---
interface CustomerOption { id: string; name: string; level: string; contact: string | null }
const customerOptions = ref<CustomerOption[]>([])
const customerSearchLoading = ref(false)
const handleCustomerSearch = async (query: string) => {
if (!query) { customerOptions.value = []; return }
customerSearchLoading.value = true
try {
const res: any = await request.get('/api/customers/search', { params: { q: query } })
customerOptions.value = res || []
} catch { customerOptions.value = [] }
finally { customerSearchLoading.value = false }
}
// --- 列表 ---
const loading = ref(false)
const tableData = ref<any[]>([])
const currentPage = ref(1)
const pageSize = ref(20)
const total = ref(0)
const filters = reactive({
customer_name: '',
invoice_number: '',
payment_status: '',
dateRange: null as [string, string] | null,
})
const fetchList = async () => {
loading.value = true
try {
const params: Record<string, any> = {
page: currentPage.value,
size: pageSize.value,
}
if (filters.customer_name) params.customer_name = filters.customer_name
if (filters.invoice_number) params.invoice_number = filters.invoice_number
if (filters.payment_status) params.payment_status = filters.payment_status
if (filters.dateRange?.[0]) params.start_date = filters.dateRange[0]
if (filters.dateRange?.[1]) params.end_date = filters.dateRange[1]
const res: any = await request.get('/api/finance/sales-invoices', { params })
tableData.value = res.items || []
total.value = res.total || 0
} catch {
//
} finally {
loading.value = false
}
}
const handleSearch = () => { currentPage.value = 1; fetchList() }
const handleReset = () => {
filters.customer_name = ''
filters.invoice_number = ''
filters.payment_status = ''
filters.dateRange = null
handleSearch()
}
// --- 新增 ---
const addDialogVisible = ref(false)
const addSubmitting = ref(false)
const addFormRef = ref<FormInstance>()
const addForm = reactive({
issuer: '',
receiver_customer_id: '',
invoice_number: '',
amount: 0,
billing_date: '',
remark: '',
})
const addFormRules = {
issuer: [{ required: true, message: '请输入开票方', trigger: 'blur' }],
receiver_customer_id: [{ required: true, message: '请选择受票客户', trigger: 'change' }],
invoice_number: [{ required: true, message: '请输入发票号', trigger: 'blur' }],
amount: [{ required: true, message: '请输入票面金额', trigger: 'blur' }],
billing_date: [{ required: true, message: '请选择开票日期', trigger: 'change' }],
}
const openAddDialog = () => {
addForm.issuer = ''
addForm.receiver_customer_id = ''
addForm.invoice_number = ''
addForm.amount = 0
addForm.billing_date = ''
addForm.remark = ''
customerOptions.value = []
addDialogVisible.value = true
}
const submitAdd = async () => {
if (!addFormRef.value) return
const valid = await addFormRef.value.validate().catch(() => false)
if (!valid) return
addSubmitting.value = true
try {
await request.post('/api/finance/sales-invoices', addForm)
ElMessage.success('销项发票创建成功')
addDialogVisible.value = false
handleSearch()
} catch {
//
} finally {
addSubmitting.value = false
}
}
// --- OCR 解析上传(支持批量) ---
const uploadProcessing = ref(false)
const aiParsing = ref(false)
let uploadAbortController: AbortController | null = null
interface OcrQueueItem {
name: string
status: 'waiting' | 'processing' | 'success' | 'error'
message: string
}
const ocrQueue = ref<OcrQueueItem[]>([])
const handleOCRUpload = async (_file: UploadFile, fileList: any[]) => {
const rawFiles = fileList.filter((f: any) => f.raw).map((f: any) => f.raw as File)
if (!rawFiles.length) return
// 单文件:填入表单(旧逻辑)
if (rawFiles.length === 1) {
const file = rawFiles[0]
uploadProcessing.value = true
aiParsing.value = true
uploadAbortController = new AbortController()
try {
const formData = new FormData()
formData.append('file', file)
formData.append('scene', 'invoice')
const token = userStore.token || localStorage.getItem('crm_token') || ''
const response = await fetch('/api/finance/ocr', {
method: 'POST',
headers: { 'Authorization': `Bearer ${token}` },
body: formData,
signal: uploadAbortController.signal,
})
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`)
const result = await response.json()
if (result.code !== 200) throw new Error(result.message || 'OCR 识别失败')
const aiData = result.data?.ocr_data || {}
ElMessage.success('🤖 AI 识别成功,数据已尝试填入表单')
addForm.issuer = aiData.seller_name || aiData.merchant || aiData.merchant_name || addForm.issuer
addForm.invoice_number = aiData.invoice_num || aiData.invoice_number || addForm.invoice_number
addForm.amount = parseFloat(aiData.amount) || addForm.amount
addForm.billing_date = aiData.date || addForm.billing_date
const buyerName = (aiData.buyer_name || aiData.customer_name || '').replace(/[\r\n\t]/g, '').trim()
if (buyerName) {
await handleCustomerSearch(buyerName)
if (customerOptions.value.length > 0) {
addForm.receiver_customer_id = customerOptions.value[0].id
ElMessage.success(`已自动匹配并选中受票客户: ${customerOptions.value[0].name}`)
} else {
ElMessage.warning(`AI提取受票方为「${buyerName}」,但系统中未找到该客户。`)
}
}
} catch (e: any) {
if (e.name === 'AbortError') return
ElMessage.warning('AI 解析失败或服务不可用,请手动填写。')
} finally {
uploadProcessing.value = false
aiParsing.value = false
uploadAbortController = null
}
return
}
// 多文件:批量处理,逐个 OCR + 自动创建
ocrQueue.value = rawFiles.map(f => ({ name: f.name, status: 'waiting' as const, message: '' }))
uploadProcessing.value = true
uploadAbortController = new AbortController()
const signal = uploadAbortController.signal
for (let i = 0; i < rawFiles.length; i++) {
if (signal.aborted) break
const file = rawFiles[i]
ocrQueue.value[i].status = 'processing'
ocrQueue.value[i].message = '🤖 解析中...'
try {
const formData = new FormData()
formData.append('file', file)
formData.append('scene', 'invoice')
const token = userStore.token || localStorage.getItem('crm_token') || ''
const response = await fetch('/api/finance/ocr', {
method: 'POST',
headers: { 'Authorization': `Bearer ${token}` },
body: formData,
signal,
})
if (!response.ok) throw new Error(`HTTP ${response.status}`)
const result = await response.json()
if (result.code !== 200) throw new Error(result.message || 'OCR 失败')
const aiData = result.data?.ocr_data || {}
const issuer = aiData.seller_name || aiData.merchant || aiData.merchant_name || ''
const invoiceNumber = aiData.invoice_num || aiData.invoice_number || ''
const amount = parseFloat(aiData.amount) || 0
const billingDate = aiData.date || new Date().toISOString().split('T')[0]
const buyerName = (aiData.buyer_name || aiData.customer_name || '').replace(/[\r\n\t]/g, '').trim()
// 自动查找客户
let customerId = ''
if (buyerName) {
await handleCustomerSearch(buyerName)
if (customerOptions.value.length > 0) customerId = customerOptions.value[0].id
}
if (issuer && invoiceNumber && amount > 0 && customerId) {
await request.post('/api/finance/sales-invoices', {
issuer, receiver_customer_id: customerId, invoice_number: invoiceNumber,
amount, billing_date: billingDate, remark: 'AI批量导入',
})
ocrQueue.value[i].status = 'success'
ocrQueue.value[i].message = `${invoiceNumber}`
} else {
ocrQueue.value[i].status = 'error'
ocrQueue.value[i].message = '⚠️ 数据不完整,请手动创建'
}
} catch (e: any) {
if (e.name === 'AbortError') break
ocrQueue.value[i].status = 'error'
ocrQueue.value[i].message = `${e.message || '失败'}`
}
}
uploadProcessing.value = false
uploadAbortController = null
if (!signal.aborted) {
const ok = ocrQueue.value.filter(q => q.status === 'success').length
ElMessage.success(`批量处理完成:${ok}/${rawFiles.length} 自动创建成功`)
handleSearch()
}
setTimeout(() => { ocrQueue.value = [] }, 5000)
}
// --- Dialog 关闭保护 ---
const handleDialogClose = (done: () => void) => {
if (uploadProcessing.value) {
ElMessageBox.confirm(
'OCR 批量处理正在进行中,关闭将中断所有未完成的任务。确定关闭?',
'提示',
{ confirmButtonText: '确认关闭', cancelButtonText: '继续等待', type: 'warning' }
).then(() => {
if (uploadAbortController) { uploadAbortController.abort(); uploadAbortController = null }
uploadProcessing.value = false
ocrQueue.value = []
done()
}).catch(() => {})
} else {
done()
}
}
// --- 回款标记 ---
const paymentDialogVisible = ref(false)
const paymentSubmitting = ref(false)
const paymentFormRef = ref<FormInstance>()
const paymentForm = reactive({
id: '',
payment_status: '已结清' as string,
payment_amount: 0,
payment_date: '',
})
const openPaymentDialog = (row: any) => {
paymentForm.id = row.id
paymentForm.payment_status = row.payment_status === '已结清' ? '已结清' : '已结清'
paymentForm.payment_amount = row.amount
paymentForm.payment_date = new Date().toISOString().split('T')[0]
paymentDialogVisible.value = true
}
const submitPayment = async () => {
paymentSubmitting.value = true
try {
await request.put(`/api/finance/sales-invoices/${paymentForm.id}`, {
payment_status: paymentForm.payment_status,
payment_amount: paymentForm.payment_amount,
payment_date: paymentForm.payment_date,
})
ElMessage.success('回款状态更新成功')
paymentDialogVisible.value = false
fetchList()
} catch {
//
} finally {
paymentSubmitting.value = false
}
}
// --- 导出 ---
const handleExport = async () => {
try {
const params: any = {}
if (filters.customer_name) params.customer_name = filters.customer_name
if (filters.invoice_number) params.invoice_number = filters.invoice_number
if (filters.payment_status) params.payment_status = filters.payment_status
if (filters.dateRange?.[0]) params.start_date = filters.dateRange[0]
if (filters.dateRange?.[1]) params.end_date = filters.dateRange[1]
const res = await request.get('/api/finance/sales-invoices/export', {
params,
responseType: 'blob'
})
const blob = new Blob([res as any], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' })
const url = window.URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `sales_invoices_${new Date().toISOString().split('T')[0]}.xlsx`
a.click()
window.URL.revokeObjectURL(url)
ElMessage.success('导出成功')
} catch {
ElMessage.error('导出失败')
}
}
// --- 状态标签颜色 ---
const statusTag = (status: string) => {
if (status === '已结清') return 'success'
if (status === '部分回款') return 'warning'
return 'danger'
}
onMounted(fetchList)
</script>
<template>
<div class="sales-invoice-container">
<!-- 筛选栏 -->
<el-card shadow="never" class="filter-card">
<div class="filter-bar">
<el-form :inline="true" @submit.prevent>
<el-form-item label="客户名称">
<el-input v-model="filters.customer_name" placeholder="客户名称" clearable style="width: 160px" />
</el-form-item>
<el-form-item label="发票号">
<el-input v-model="filters.invoice_number" placeholder="发票号" clearable style="width: 160px" />
</el-form-item>
<el-form-item label="回款状态">
<el-select v-model="filters.payment_status" placeholder="全部" clearable style="width: 120px">
<el-option label="未回款" value="未回款" />
<el-option label="部分回款" value="部分回款" />
<el-option label="已结清" value="已结清" />
</el-select>
</el-form-item>
<el-form-item label="开票日期">
<el-date-picker
v-model="filters.dateRange"
type="daterange"
range-separator=""
start-placeholder="开始"
end-placeholder="结束"
value-format="YYYY-MM-DD"
style="width: 240px"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" :icon="Search" @click="handleSearch">搜索</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
<div class="actions">
<el-button type="success" :icon="Plus" @click="openAddDialog">新增发票</el-button>
<el-button v-if="isAdmin" type="warning" :icon="Download" @click="handleExport">导出</el-button>
</div>
</div>
</el-card>
<!-- 表格 -->
<el-card shadow="never" class="table-card">
<el-table :data="tableData" border stripe style="width: 100%" v-loading="loading">
<el-table-column prop="invoice_number" label="发票号" width="180" show-overflow-tooltip />
<el-table-column prop="issuer" label="开票方" width="180" show-overflow-tooltip />
<el-table-column prop="customer_name" label="受票客户" width="180" show-overflow-tooltip />
<el-table-column prop="amount" label="票面金额" width="120" align="right">
<template #default="{ row }">¥ {{ Number(row.amount).toLocaleString() }}</template>
</el-table-column>
<el-table-column prop="billing_date" label="开票日期" width="120" align="center" />
<el-table-column prop="payment_status" label="回款状态" width="110" align="center">
<template #default="{ row }">
<el-tag :type="statusTag(row.payment_status)" effect="light">{{ row.payment_status }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="payment_amount" label="已回款" width="120" align="right">
<template #default="{ row }">¥ {{ Number(row.payment_amount || 0).toLocaleString() }}</template>
</el-table-column>
<el-table-column prop="payment_date" label="回款日期" width="120" align="center" />
<el-table-column label="操作" width="120" align="center" fixed="right">
<template #default="{ row }">
<el-button
v-if="row.payment_status !== '已结清'"
type="primary"
link
size="small"
@click="openPaymentDialog(row)"
>回款标记</el-button>
<span v-else style="color: #67c23a; font-size: 12px">已结清</span>
</template>
</el-table-column>
</el-table>
<div class="pagination-wrapper">
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="pageSize"
:total="total"
layout="total, sizes, prev, pager, next"
:page-sizes="[10, 20, 50]"
@current-change="fetchList"
@size-change="handleSearch"
/>
</div>
</el-card>
<!-- 新增发票弹窗 -->
<el-dialog v-model="addDialogVisible" title="发票智能录入" width="600px" destroy-on-close :before-close="handleDialogClose">
<div style="margin-bottom: 20px" v-loading="uploadProcessing || aiParsing" :element-loading-text="uploadProcessing ? '文件上传中...' : '🤖 AI 正在识别发票表单字段...'">
<el-upload
drag
:auto-upload="false"
:show-file-list="false"
accept=".pdf,.jpg,.jpeg,.png"
multiple
@change="handleOCRUpload"
>
<el-icon class="el-icon--upload"><upload-filled /></el-icon>
<div class="el-upload__text">
将发票原件(PDF/图片)拖到此处 <em>点击上传进行AI填单</em>
</div>
<div class="el-upload__tip" style="font-size:12px;color:#909399">支持 PDF/图片/MD 文件单文件自动填入表单多文件自动批量创建发票</div>
</el-upload>
<!-- 批量处理队列 -->
<div v-if="ocrQueue.length" style="margin-top: 10px">
<div v-for="(item, idx) in ocrQueue" :key="idx" style="display:flex; justify-content:space-between; align-items:center; padding: 4px 8px; border-bottom: 1px solid #f0f0f0; font-size: 13px">
<span style="color:#606266; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; max-width:280px">{{ item.name }}</span>
<el-tag v-if="item.status === 'waiting'" size="small" type="info" effect="plain">等待中</el-tag>
<el-tag v-else-if="item.status === 'processing'" size="small" type="warning" effect="dark">处理中</el-tag>
<el-tag v-else-if="item.status === 'success'" size="small" type="success" effect="dark">{{ item.message }}</el-tag>
<el-tag v-else size="small" type="danger" effect="dark">{{ item.message }}</el-tag>
</div>
</div>
</div>
<el-form ref="addFormRef" :model="addForm" :rules="addFormRules" label-width="100px">
<el-form-item label="开票方" prop="issuer">
<el-input v-model="addForm.issuer" placeholder="我方公司名称" />
</el-form-item>
<el-form-item label="受票客户" prop="receiver_customer_id">
<el-select
v-model="addForm.receiver_customer_id"
filterable remote clearable reserve-keyword
placeholder="输入客户名称搜索..."
:remote-method="handleCustomerSearch"
:loading="customerSearchLoading"
style="width: 100%"
>
<el-option
v-for="item in customerOptions"
:key="item.id"
:label="item.name"
:value="item.id"
>
<span>{{ item.name }}</span>
<span style="color: #999; font-size: 12px; margin-left: 8px">{{ item.level }}</span>
</el-option>
</el-select>
</el-form-item>
<el-form-item label="发票号" prop="invoice_number">
<el-input v-model="addForm.invoice_number" placeholder="INV-20260312-001" />
</el-form-item>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="票面金额" prop="amount">
<el-input-number v-model="addForm.amount" :min="0.01" :precision="2" style="width: 100%" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="开票日期" prop="billing_date">
<el-date-picker v-model="addForm.billing_date" type="date" value-format="YYYY-MM-DD" style="width: 100%" />
</el-form-item>
</el-col>
</el-row>
<el-form-item label="备注">
<el-input v-model="addForm.remark" type="textarea" :rows="2" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="addDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="addSubmitting" @click="submitAdd">确认创建</el-button>
</template>
</el-dialog>
<!-- 回款标记弹窗 -->
<el-dialog v-model="paymentDialogVisible" title="回款标记" width="450px" destroy-on-close>
<el-form ref="paymentFormRef" :model="paymentForm" label-width="100px">
<el-form-item label="回款状态">
<el-select v-model="paymentForm.payment_status" style="width: 100%">
<el-option label="部分回款" value="部分回款" />
<el-option label="已结清" value="已结清" />
</el-select>
</el-form-item>
<el-form-item label="回款金额">
<el-input-number v-model="paymentForm.payment_amount" :min="0" :precision="2" style="width: 100%" />
</el-form-item>
<el-form-item label="回款日期">
<el-date-picker v-model="paymentForm.payment_date" type="date" value-format="YYYY-MM-DD" style="width: 100%" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="paymentDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="paymentSubmitting" @click="submitPayment">确认</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.sales-invoice-container {
display: flex;
flex-direction: column;
gap: 16px;
}
.filter-card { border: none; border-radius: 8px; }
.filter-bar {
display: flex;
justify-content: space-between;
align-items: flex-start;
flex-wrap: wrap;
gap: 12px;
}
.actions { display: flex; gap: 8px; }
.table-card { border: none; border-radius: 8px; }
.pagination-wrapper {
margin-top: 16px;
display: flex;
justify-content: flex-end;
}
</style>
+767
View File
@@ -0,0 +1,767 @@
<script setup lang="ts">
/**
* 财务票据中心 — 灵魂级 UX 重构
* Tab 1: 统一票据池(报销/客户分流 + 拖拽上传 + AI 解析)
* Tab 2: 购物车式新建报销(AI 一键生成草稿)
* Tab 3: 报销大盘与审批(A4 打印 + Excel 导出 + 审批态只读)
*/
import { ref, reactive, computed, onMounted, onBeforeUnmount, nextTick, watch } from 'vue'
import { Search, Plus, Delete, View, Check, Close, RefreshLeft, Download, Printer, Link } from '@element-plus/icons-vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import type { FormInstance, FormRules, UploadFile } from 'element-plus'
import request from '@/api/request'
import { useUserStore } from '@/store/user'
import { useRoute } from 'vue-router'
const userStore = useUserStore()
const route = useRoute()
// 支持 /finance?tab=dashboard 直接跳到报销大盘
const activeTab = ref((route.query.tab as string) || 'pool')
// ════════════════════════════════════════════════════════
// Tab 1: 统一票据池(报销/客户 两 Tab 分流)
// ════════════════════════════════════════════════════════
const invCategory = ref('expense')
const invLoading = ref(false)
const invList = ref<any[]>([])
const invTotal = ref(0)
const invPage = ref(1)
const invSize = ref(20)
const invUsedFilter = ref('' as '' | 'true' | 'false')
const fetchInvoices = async () => {
invLoading.value = true
try {
const params: Record<string, any> = { page: invPage.value, size: invSize.value, category: invCategory.value }
if (invUsedFilter.value !== '') params.is_used = invUsedFilter.value
const data: any = await request.get('/api/finance/invoices', { params })
invList.value = data?.items || []
invTotal.value = data?.total || 0
} catch {}
finally { invLoading.value = false }
}
watch(invCategory, () => { invPage.value = 1; fetchInvoices() })
// ── 拖拽上传 + AI 解析链路(批量队列) ──
const uploadProcessing = ref(false)
const aiParsing = ref(false)
interface QueueItem {
name: string
status: 'waiting' | 'processing' | 'success' | 'error'
message: string
}
const uploadQueue = ref<QueueItem[]>([])
let uploadAbortController: AbortController | null = null
const handleUploadChange = async (_file: UploadFile, fileList: any[]) => {
// 收集所有新增文件的 raw
const rawFiles = fileList.filter((f: any) => f.raw).map((f: any) => f.raw as File)
if (!rawFiles.length) return
// 初始化队列
uploadQueue.value = rawFiles.map(f => ({ name: f.name, status: 'waiting' as const, message: '' }))
uploadProcessing.value = true
uploadAbortController = new AbortController()
const signal = uploadAbortController.signal
for (let i = 0; i < rawFiles.length; i++) {
if (signal.aborted) break
const file = rawFiles[i]
uploadQueue.value[i].status = 'processing'
uploadQueue.value[i].message = '🤖 AI 正在解析...'
try {
const formData = new FormData()
formData.append('file', file)
formData.append('scene', 'invoice')
const response = await fetch('/api/finance/ocr', {
method: 'POST',
headers: { 'Authorization': `Bearer ${userStore.token}` },
body: formData,
signal,
})
if (!response.ok) throw new Error(`HTTP ${response.status}`)
const result = await response.json()
if (result.code !== 200) throw new Error(result.message || 'OCR 识别失败')
const ocrResult = result.data || {}
const aiData = ocrResult.ocr_data || {}
const fileUrl = ocrResult.file_url || `/uploads/${file.name}`
const ocrSuccess = !!(aiData.merchant || aiData.merchant_name || aiData.amount)
const merchant = aiData.merchant || aiData.merchant_name || 'AI 未识别)'
const amount = parseFloat(aiData.amount) || 0
const invoiceDate = aiData.date || new Date().toISOString().slice(0, 10)
await request.post('/api/finance/invoices', {
merchant_name: merchant, amount, invoice_date: invoiceDate,
type: invCategory.value, file_url: fileUrl, ai_extracted_data: aiData,
})
uploadQueue.value[i].status = 'success'
uploadQueue.value[i].message = ocrSuccess
? `${merchant}${amount}`
: '⚠️ 已上传,AI未能识别'
} catch (e: any) {
if (e.name === 'AbortError') break
uploadQueue.value[i].status = 'error'
uploadQueue.value[i].message = `${e.message || '处理失败'}`
}
}
uploadProcessing.value = false
uploadAbortController = null
if (!signal.aborted) {
const successCount = uploadQueue.value.filter(q => q.status === 'success').length
ElMessage.success(`批量处理完成:${successCount}/${rawFiles.length} 成功`)
fetchInvoices()
}
// 3秒后清空队列
setTimeout(() => { uploadQueue.value = [] }, 3000)
}
// 手动录入弹窗
const invDialogVisible = ref(false)
const invSubmitting = ref(false)
const invFormRef = ref<FormInstance>()
const invForm = reactive({ merchant_name: '', amount: 0, invoice_date: '', ai_json: '' })
const invRules = reactive<FormRules>({
merchant_name: [{ required: true, message: '请输入开票方', trigger: 'blur' }],
amount: [{ required: true, message: '请输入金额', trigger: 'blur' }],
})
const openAddInvoice = () => {
Object.assign(invForm, { merchant_name: '', amount: 0, invoice_date: '', ai_json: '' })
invDialogVisible.value = true
nextTick(() => invFormRef.value?.clearValidate())
}
const submitInvoice = async () => {
const valid = await invFormRef.value?.validate().catch(() => false)
if (!valid) return
invSubmitting.value = true
try {
let aiData = {}
if (invForm.ai_json.trim()) {
try { aiData = JSON.parse(invForm.ai_json) } catch { ElMessage.error('JSON 格式错误'); invSubmitting.value = false; return }
}
await request.post('/api/finance/invoices', {
merchant_name: invForm.merchant_name, amount: invForm.amount,
invoice_date: invForm.invoice_date || null, type: invCategory.value,
ai_extracted_data: aiData,
})
ElMessage.success('发票录入成功'); invDialogVisible.value = false; fetchInvoices()
} catch {}
finally { invSubmitting.value = false }
}
const voidInvoice = (row: any) => {
ElMessageBox.confirm(`确定作废「${row.merchant_name}」(¥${row.amount})`, '作废确认', { type: 'warning' })
.then(async () => { try { await request.delete(`/api/finance/invoices/${row.id}`); ElMessage.success('已作废'); fetchInvoices() } catch {} }).catch(() => {})
}
const linkOrder = () => { ElMessage.info('🔗 关联订单功能为远期规划,敬请期待') }
// ════════════════════════════════════════════════════════
// Tab 2: 购物车式新建报销(AI 一键生成草稿)
// ════════════════════════════════════════════════════════
const availableInvLoading = ref(false)
const availableInvList = ref<any[]>([])
const leftSelection = ref<any[]>([])
const fetchAvailableInvoices = async () => {
availableInvLoading.value = true
try {
const data: any = await request.get('/api/finance/invoices', { params: { page: 1, size: 100, is_used: false, category: 'expense' } })
availableInvList.value = data?.items || []
} catch {}
finally { availableInvLoading.value = false }
}
interface CartItem {
invoice_id: string; merchant_name: string; invoice_amount: number
expense_desc: string; expense_date: string; original_type: string; offset_type: string; amount: number
}
const cart = ref<CartItem[]>([])
const expRemark = ref('')
const expSubmitting = ref(false)
const cartTotal = computed(() => cart.value.reduce((s, r) => s + r.amount, 0))
const originalTypes = [
{ label: '办公费', value: '办公费' }, { label: '招待费', value: '招待费' },
{ label: '差旅费', value: '差旅费' }, { label: '交通费', value: '交通费' },
{ label: '物流费', value: '物流费' }, { label: '油费', value: '油费' },
{ label: '其他', value: '其他' },
]
const offsetTypes = [
{ label: '客户费用', value: '客户费用' }, { label: '税务费用', value: '税务费用' },
{ label: '工资提成', value: '工资提成' }, { label: '办公费', value: '办公费' },
{ label: '招待费', value: '招待费' }, { label: '差旅费', value: '差旅费' },
{ label: '交通费', value: '交通费' }, { label: '物流费', value: '物流费' },
{ label: '油费', value: '油费' }, { label: '福利费', value: '福利费' },
{ label: '其他', value: '其他' },
]
// AI 一键生成报销草稿
const aiGenerating = ref(false)
const aiGenerateDraft = async () => {
if (!leftSelection.value.length) { ElMessage.warning('请先勾选需要报销的发票'); return }
aiGenerating.value = true
await new Promise(r => setTimeout(r, 800))
for (const inv of leftSelection.value) {
if (cart.value.find(c => c.invoice_id === inv.id)) continue
const ai = inv.ai_extracted_data || {}
cart.value.push({
invoice_id: inv.id,
merchant_name: ai.merchant || inv.merchant_name || '-',
invoice_amount: ai.amount || inv.amount,
expense_desc: '',
expense_date: ai.date || inv.invoice_date || new Date().toISOString().slice(0, 10),
original_type: '办公费',
offset_type: '客户费用',
amount: ai.amount || inv.amount,
})
}
aiGenerating.value = false
ElMessage.success(`🤖 已智能生成 ${leftSelection.value.length} 条报销草稿`)
}
const removeCartItem = (idx: number) => { cart.value.splice(idx, 1) }
const submitExpense = async () => {
if (!cart.value.length) { ElMessage.warning('请先勾选发票生成草稿'); return }
if (cart.value.some(r => r.amount <= 0)) { ElMessage.warning('报销金额必须大于 0'); return }
try { await ElMessageBox.confirm(`确认提交?总金额 ¥${cartTotal.value.toFixed(2)}`, '提交确认', { type: 'info' }) } catch { return }
expSubmitting.value = true
try {
await request.post('/api/finance/expenses', {
total_amount: cartTotal.value, remark: expRemark.value || null,
items: cart.value.map(r => ({
invoice_id: r.invoice_id, expense_desc: r.expense_desc || null,
expense_date: r.expense_date || null,
original_type: r.original_type, offset_type: r.offset_type, amount: r.amount,
})),
})
ElMessage.success('报销单提交成功!'); cart.value = []; expRemark.value = ''
fetchAvailableInvoices(); activeTab.value = 'expenses'; fetchExpenses()
} catch {}
finally { expSubmitting.value = false }
}
// ════════════════════════════════════════════════════════
// Tab 3: 报销大盘(审批 + 打印 + 导出)
// ════════════════════════════════════════════════════════
const expLoading = ref(false)
const expList = ref<any[]>([])
const expTotal = ref(0)
const expPage = ref(1)
const expSize = ref(20)
const expStatusFilter = ref('')
const fetchExpenses = async () => {
expLoading.value = true
try {
const params: Record<string, any> = { page: expPage.value, size: expSize.value }
if (expStatusFilter.value) params.status = expStatusFilter.value
const data: any = await request.get('/api/finance/expenses', { params })
expList.value = data?.items || []
expTotal.value = data?.total || 0
} catch {}
finally { expLoading.value = false }
}
const expDetailVisible = ref(false)
const expDetailLoading = ref(false)
const currentExp = ref<any>({})
const openExpDetail = async (row: any) => {
expDetailVisible.value = true; expDetailLoading.value = true
try { const data: any = await request.get(`/api/finance/expenses/${row.id}`); currentExp.value = data } catch {}
finally { expDetailLoading.value = false }
}
const statusLabel = (s: string) => ({ submitted: '待审批', approved: '已通过', rejected: '已驳回', voided: '已撤回' }[s] || s)
const statusTagType = (s: string) => ({ submitted: 'warning', approved: 'success', rejected: 'danger', voided: 'info' }[s] || 'info')
const canWithdraw = (row: any) => row.status === 'submitted' && row.applicant_id === userStore.userInfo?.user_id
const canApprove = (row: any) => row.status === 'submitted' && (userStore.dataScope === 'all' || userStore.dataScope === 'dept_and_sub')
const isApprover = computed(() => userStore.dataScope === 'all' || userStore.dataScope === 'dept_and_sub')
const doAction = async (expenseId: string, action: string, label: string) => {
let reason: string | null = null
try {
if (action === 'reject') {
const res: any = await ElMessageBox.prompt('请输入驳回原因', '驳回', { confirmButtonText: '确认驳回', cancelButtonText: '取消', type: 'warning' })
reason = res.value
} else {
await ElMessageBox.confirm(`确定${label}`, '操作确认', { type: action === 'approve' ? 'success' : 'warning' })
}
} catch { return }
try {
await request.put(`/api/finance/expenses/${expenseId}/status`, { action, reason })
ElMessage.success(`${label}成功`); fetchExpenses()
if (expDetailVisible.value && currentExp.value.id === expenseId) openExpDetail({ id: expenseId })
} catch {}
}
// ── A4 打印(独立 Iframe 无损排版) ──
const printExpense = async (row?: any) => {
let expData = currentExp.value
if (row && row.id && row.id !== currentExp.value.id) {
try {
const data: any = await request.get(`/api/finance/expenses/${row.id}`)
expData = data
} catch { return }
} else if (!expData.id && row && row.id) {
// maybe list row is passed directly but detail isn't open
try {
const data: any = await request.get(`/api/finance/expenses/${row.id}`)
expData = data
} catch { return }
}
if (!expData || !expData.id) {
ElMessage.warning('未能获取打印数据')
return
}
const fCurrency = (v: number) => `${v?.toLocaleString('zh-CN', { minimumFractionDigits: 2 })}`;
const sLabel = (s: string) => ({ submitted: '待审批', approved: '已通过', rejected: '已驳回', voided: '已撤回' }[s] || s);
const rowsHtml = (expData.details || []).map((d: any, i: number) => `
<tr>
<td>${i + 1}</td>
<td style="text-align:left">${d.invoice_merchant || ''}</td>
<td>${fCurrency(d.invoice_amount || 0)}</td>
<td style="text-align:left">${d.expense_desc || '-'}</td>
<td>${d.expense_date || '-'}</td>
<td>${d.original_type || '-'}</td>
<td>${d.offset_type || '-'}</td>
<td>${fCurrency(d.amount)}</td>
</tr>
`).join('');
const html = `<!DOCTYPE html>
<html>
<head>
<title>打印报销单</title>
<style>
@page { size: A4; margin: 15mm; }
body { font-family: 'SimSun', 'Songti SC', serif; color: #000; margin: 0; padding: 0; }
.print-header { text-align: center; margin-bottom: 20px; }
.print-header h1 { font-size: 24px; margin: 0 0 8px; letter-spacing: 6px; }
.print-meta { text-align: center; font-size: 13px; color: #333; margin-bottom: 20px; }
.print-meta span { margin: 0 16px; }
table { width: 100%; border-collapse: collapse; margin-bottom: 16px; font-size: 13px; }
th, td { border: 1px solid #000; padding: 6px 10px; text-align: center; }
.info-table td:nth-child(odd) { background: #f0f0f0; font-weight: bold; width: 90px; }
th { background: #e0e0e0; }
tfoot td { border-top: 2px solid #000; }
.signatures { display: flex; justify-content: space-between; margin-top: 50px; font-weight: bold; font-size: 14px; }
</style>
</head>
<body>
<div class="print-header">
<h1>天津硕博霖报销单</h1>
<div class="print-meta">
<span>单号:${expData.system_no}</span>
<span>日期:${expData.created_at?.slice(0, 10)}</span>
</div>
</div>
<table class="info-table">
<tr>
<td>申请人</td><td>${expData.applicant_name}</td>
<td>报销总额</td><td>${fCurrency(expData.total_amount)}</td>
</tr>
<tr>
<td>审批状态</td><td>${sLabel(expData.status)}</td>
<td>备注</td><td>${expData.remark || '-'}</td>
</tr>
</table>
<table>
<thead>
<tr><th>#</th><th>开票方</th><th>发票金额</th><th>费用描述</th><th>发生时间</th><th>原始种类</th><th>冲顶类型</th><th>报销金额</th></tr>
</thead>
<tbody>
${rowsHtml}
</tbody>
<tfoot>
<tr>
<td colspan="7" style="text-align:right; font-weight:bold">合计</td>
<td style="font-weight:bold">${fCurrency(expData.total_amount)}</td>
</tr>
</tfoot>
</table>
<div class="signatures">
<span>本人签字:___________</span>
<span>部门领导签字:___________</span>
<span>财务签字:___________</span>
<span>总经理签字:___________</span>
</div>
</body>
</html>`;
const iframe = document.createElement('iframe');
iframe.style.display = 'none';
document.body.appendChild(iframe);
const iframeDoc = iframe.contentWindow?.document;
if (iframeDoc) {
iframeDoc.open();
iframeDoc.write(html);
iframeDoc.close();
setTimeout(() => {
iframe.contentWindow?.focus();
iframe.contentWindow?.print();
setTimeout(() => document.body.removeChild(iframe), 1000);
}, 200);
}
}
const exportExcel = async () => {
if (!expList.value.length) { ElMessage.warning('暂无数据可导出'); return }
ElMessage.info('正在拉取明细数据,请稍候...')
const BOM = '\uFEFF'
const header = '报销单号,申请人,报销总金额,状态,提交时间,备注,明细-开票方,明细-发票金额,明细-用途,明细-发生时间,明细-原始种类,明细-冲顶类型,明细-报销额\n'
let rows = ''
for (const exp of expList.value) {
try {
const detailRes: any = await request.get(`/api/finance/expenses/${exp.id}`)
const baseInfo = `${exp.system_no},${exp.applicant_name},${exp.total_amount},${statusLabel(exp.status)},${exp.created_at},${detailRes.remark || ''}`
if (detailRes.details && detailRes.details.length > 0) {
for (const d of detailRes.details) {
rows += `${baseInfo},${d.invoice_merchant || ''},${d.invoice_amount || 0},${d.expense_desc || ''},${d.expense_date || ''},${d.original_type || ''},${d.offset_type || ''},${d.amount}\n`
}
} else {
rows += `${baseInfo},,,,,,,\n`
}
} catch {
rows += `${exp.system_no},${exp.applicant_name},${exp.total_amount},${statusLabel(exp.status)},${exp.created_at},获取明细失败,,,,,,,\n`
}
}
const blob = new Blob([BOM + header + rows], { type: 'text/csv;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url; a.download = `报销大盘_${new Date().toISOString().slice(0, 10)}.csv`; a.click()
URL.revokeObjectURL(url)
ElMessage.success('导出成功')
}
const formatCurrency = (v: number) => `${v?.toLocaleString('zh-CN', { minimumFractionDigits: 2 })}`
onMounted(() => { fetchInvoices() })
onBeforeUnmount(() => {
if (uploadAbortController) { uploadAbortController.abort(); uploadAbortController = null }
})
watch(activeTab, (tab) => {
if (tab === 'create') fetchAvailableInvoices()
if (tab === 'expenses') fetchExpenses()
})
</script>
<template>
<div class="finance-wrapper">
<el-card shadow="never" class="main-card hide-on-print">
<el-tabs v-model="activeTab" class="finance-tabs">
<!-- Tab 1: 统一票据池 -->
<el-tab-pane label="🎫 统一票据池" name="pool">
<!-- 报销票据池 -->
<div class="inv-sub-header" style="margin-bottom: 8px; font-size: 14px; color: #606266; font-weight: 500;">📄 报销发票池</div>
<!-- 拖拽上传区支持多文件 -->
<div class="upload-zone">
<el-upload drag :auto-upload="false" :show-file-list="false" accept=".pdf,.jpg,.jpeg,.png"
multiple @change="handleUploadChange">
<div class="upload-inner">
<el-icon style="font-size:40px; color:#409eff"><Plus /></el-icon>
<div class="upload-text">拖拽发票文件到此处 <em>点击上传</em></div>
<div class="upload-hint">支持 PDF / JPG / PNG / MD可一次选择多个文件批量上传AI 自动排队解析</div>
</div>
</el-upload>
<!-- 批量上传队列状态 -->
<div v-if="uploadQueue.length" class="upload-queue">
<div v-for="(item, idx) in uploadQueue" :key="idx" class="queue-item">
<span class="queue-name">{{ item.name }}</span>
<el-tag v-if="item.status === 'waiting'" size="small" type="info" effect="plain">等待中</el-tag>
<el-tag v-else-if="item.status === 'processing'" size="small" type="warning" effect="dark">处理中...</el-tag>
<el-tag v-else-if="item.status === 'success'" size="small" type="success" effect="dark">{{ item.message }}</el-tag>
<el-tag v-else size="small" type="danger" effect="dark">{{ item.message }}</el-tag>
</div>
</div>
<el-button v-if="!uploadQueue.length" type="primary" plain size="small" style="margin-top:8px" @click="openAddInvoice">
手动录入
</el-button>
</div>
<!-- 筛选 + 列表 -->
<div class="tab-toolbar">
<el-form :inline="true" class="filter-form">
<el-form-item label="使用状态">
<el-select v-model="invUsedFilter" clearable placeholder="全部" style="width:120px" @change="fetchInvoices">
<el-option label="未使用" value="false" /><el-option label="已报销" value="true" />
</el-select>
</el-form-item>
<el-form-item><el-button type="primary" :icon="Search" @click="fetchInvoices">查询</el-button></el-form-item>
</el-form>
</div>
<el-table :data="invList" v-loading="invLoading" stripe border style="width:100%" height="calc(100vh - 480px)">
<el-table-column prop="merchant_name" label="开票方" min-width="180" show-overflow-tooltip />
<el-table-column label="票面金额" width="130" align="right">
<template #default="{ row }"><b style="color:#e6a23c">{{ formatCurrency(row.amount) }}</b></template>
</el-table-column>
<el-table-column prop="invoice_date" label="开票日期" width="110" align="center" />
<el-table-column label="使用状态" width="90" align="center">
<template #default="{ row }">
<el-tag size="small" :type="row.is_used ? 'danger' : 'success'" effect="dark">{{ row.is_used ? '已报销' : '未使用' }}</el-tag>
</template>
</el-table-column>
<el-table-column label="AI 提取" width="180" show-overflow-tooltip>
<template #default="{ row }">
<el-popover v-if="row.ai_extracted_data && Object.keys(row.ai_extracted_data).length" trigger="hover" width="320" placement="left">
<template #reference>
<el-tag size="small" type="success" effect="plain" style="cursor:pointer">🤖 查看 AI 数据</el-tag>
</template>
<pre style="font-size:12px; white-space:pre-wrap; max-height:300px; overflow:auto">{{ JSON.stringify(row.ai_extracted_data, null, 2) }}</pre>
</el-popover>
<span v-else style="color:#c0c4cc">-</span>
</template>
</el-table-column>
<el-table-column prop="uploader_name" label="上传人" width="90" align="center" />
<el-table-column label="操作" width="140" fixed="right">
<template #default="{ row }">
<el-button v-if="!row.is_used" type="danger" link :icon="Delete" @click="voidInvoice(row)">作废</el-button>
<span v-if="row.is_used" style="color:#c0c4cc; font-size:12px">-</span>
</template>
</el-table-column>
</el-table>
<el-pagination style="margin-top:12px; justify-content:flex-end" background
layout="total, sizes, prev, pager, next" :total="invTotal" :page-size="invSize" :current-page="invPage"
:page-sizes="[10, 20, 50]" @current-change="(p: number) => { invPage = p; fetchInvoices() }"
@size-change="(s: number) => { invSize = s; invPage = 1; fetchInvoices() }" />
</el-tab-pane>
<!-- ═══════ Tab 2: 新建报销(AI 一键生成草稿) ═══════ -->
<el-tab-pane label="📝 新建报销" name="create">
<el-row :gutter="20">
<el-col :span="11">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:12px">
<h4>📋 可用发票(未使用)</h4>
<el-button type="success" :loading="aiGenerating" @click="aiGenerateDraft">
🤖 AI 一键生成报销草稿
</el-button>
</div>
<el-table :data="availableInvList" v-loading="availableInvLoading" stripe border height="450px"
style="width:100%" @selection-change="(val: any[]) => { leftSelection = val }">
<el-table-column type="selection" width="42" />
<el-table-column prop="merchant_name" label="开票方" min-width="140" show-overflow-tooltip />
<el-table-column label="金额" width="110" align="right">
<template #default="{ row }"><b>{{ formatCurrency(row.amount) }}</b></template>
</el-table-column>
<el-table-column prop="invoice_date" label="日期" width="100" align="center" />
<el-table-column label="AI" width="60" align="center">
<template #default="{ row }">
<el-tag v-if="row.ai_extracted_data && Object.keys(row.ai_extracted_data).length" size="small" type="success" effect="plain">✓</el-tag>
<span v-else style="color:#c0c4cc">-</span>
</template>
</el-table-column>
</el-table>
</el-col>
<el-col :span="13">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:12px">
<h4>🛒 报销草稿</h4>
<el-statistic title="总金额" :value="cartTotal" :precision="2" prefix="" style="text-align:right" />
</div>
<el-table :data="cart" border height="340px" style="width:100%">
<el-table-column type="index" label="#" width="40" />
<el-table-column prop="merchant_name" label="开票方" min-width="110" show-overflow-tooltip />
<el-table-column label="票面" width="90" align="right">
<template #default="{ row }">{{ formatCurrency(row.invoice_amount) }}</template>
</el-table-column>
<el-table-column label="费用描述" min-width="110">
<template #default="{ row }">
<el-input v-model="row.expense_desc" size="small" placeholder="用途说明" />
</template>
</el-table-column>
<el-table-column label="发生时间" width="130">
<template #default="{ row }">
<el-date-picker v-model="row.expense_date" type="date" value-format="YYYY-MM-DD" placeholder="日期" size="small" style="width:100%" />
</template>
</el-table-column>
<el-table-column label="原始种类" width="110">
<template #default="{ row }">
<el-select v-model="row.original_type" size="small" style="width:100%">
<el-option v-for="t in originalTypes" :key="t.value" :label="t.label" :value="t.value" />
</el-select>
</template>
</el-table-column>
<el-table-column label="冲顶类型" width="110">
<template #default="{ row }">
<el-select v-model="row.offset_type" size="small" style="width:100%">
<el-option v-for="t in offsetTypes" :key="t.value" :label="t.label" :value="t.value" />
</el-select>
</template>
</el-table-column>
<el-table-column label="报销额" width="100">
<template #default="{ row }">
<el-input-number v-model="row.amount" :min="0.01" :max="row.invoice_amount" :precision="2" size="small" style="width:85px" />
</template>
</el-table-column>
<el-table-column label="" width="45">
<template #default="{ $index }">
<el-button type="danger" link :icon="Delete" size="small" @click="removeCartItem($index)" />
</template>
</el-table-column>
</el-table>
<el-input v-model="expRemark" type="textarea" :rows="2" placeholder="报销备注非必填" style="margin-top:10px" />
<el-button type="primary" size="large" :loading="expSubmitting" :disabled="!cart.length"
style="width:100%; margin-top:10px" @click="submitExpense">
✅ 提交报销({{ formatCurrency(cartTotal) }}
</el-button>
</el-col>
</el-row>
</el-tab-pane>
<!-- ═══════ Tab 3: 报销大盘与审批 ═══════ -->
<el-tab-pane label="📊 报销大盘" name="expenses">
<div class="tab-toolbar">
<el-form :inline="true" class="filter-form">
<el-form-item label="审批状态">
<el-select v-model="expStatusFilter" clearable placeholder="全部" style="width:130px"
@change="() => { expPage = 1; fetchExpenses() }">
<el-option label="待审批" value="submitted" /><el-option label="已通过" value="approved" />
<el-option label="已驳回" value="rejected" /><el-option label="已撤回" value="voided" />
</el-select>
</el-form-item>
<el-form-item><el-button type="primary" :icon="Search" @click="fetchExpenses">查询</el-button></el-form-item>
</el-form>
<el-button type="success" plain :icon="Download" @click="exportExcel">导出 Excel</el-button>
</div>
<el-table :data="expList" v-loading="expLoading" stripe border style="width:100%" height="calc(100vh - 350px)">
<el-table-column prop="system_no" label="报销单号" width="200" fixed="left">
<template #default="{ row }"><span class="exp-id-bold">{{ row.system_no }}</span></template>
</el-table-column>
<el-table-column prop="applicant_name" label="申请人" width="110" align="center" />
<el-table-column label="报销金额" width="130" align="right">
<template #default="{ row }"><b style="color:#e6a23c">{{ formatCurrency(row.total_amount) }}</b></template>
</el-table-column>
<el-table-column label="状态" width="100" align="center">
<template #default="{ row }">
<el-tag :type="(statusTagType(row.status) as any)" effect="dark" size="small">{{ statusLabel(row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="created_at" label="提交时间" min-width="160" show-overflow-tooltip />
<el-table-column label="操作" width="280" fixed="right">
<template #default="{ row }">
<el-button type="primary" link :icon="View" @click="openExpDetail(row)">详情</el-button>
<el-button type="primary" link :icon="Printer" @click="printExpense(row)">打印</el-button>
<el-button v-if="canWithdraw(row)" type="info" link :icon="RefreshLeft" @click="doAction(row.id, 'withdraw', '撤回')">撤回</el-button>
<el-button v-if="canApprove(row)" type="success" link :icon="Check" @click="doAction(row.id, 'approve', '审批通过')">通过</el-button>
<el-button v-if="canApprove(row)" type="danger" link :icon="Close" @click="doAction(row.id, 'reject', '驳回')">驳回</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination style="margin-top:12px; justify-content:flex-end" background
layout="total, sizes, prev, pager, next" :total="expTotal" :page-size="expSize" :current-page="expPage"
:page-sizes="[10, 20, 50]" @current-change="(p: number) => { expPage = p; fetchExpenses() }"
@size-change="(s: number) => { expSize = s; expPage = 1; fetchExpenses() }" />
</el-tab-pane>
</el-tabs>
</el-card>
<!-- ═══════ 手动录入发票弹窗 ═══════ -->
<el-dialog v-model="invDialogVisible" title="手动录入发票" width="480px" destroy-on-close class="hide-on-print">
<el-form ref="invFormRef" :model="invForm" :rules="invRules" label-width="80px">
<el-form-item label="开票方" prop="merchant_name"><el-input v-model="invForm.merchant_name" /></el-form-item>
<el-form-item label="票面金额" prop="amount"><el-input-number v-model="invForm.amount" :min="0" :precision="2" style="width:100%" /></el-form-item>
<el-form-item label="开票日期"><el-date-picker v-model="invForm.invoice_date" type="date" value-format="YYYY-MM-DD" style="width:100%" /></el-form-item>
<el-form-item label="AI 数据"><el-input v-model="invForm.ai_json" type="textarea" :rows="2" placeholder='JSON(可选)' /></el-form-item>
</el-form>
<template #footer>
<el-button @click="invDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="invSubmitting" @click="submitInvoice">录入</el-button>
</template>
</el-dialog>
<!-- ═══════ 报销单详情 Drawer ═══════ -->
<el-drawer v-model="expDetailVisible" title="报销单详情" size="700px">
<div v-loading="expDetailLoading">
<template v-if="currentExp.id">
<el-descriptions :column="2" border>
<el-descriptions-item label="报销单号"><span class="exp-id-bold">{{ currentExp.system_no }}</span></el-descriptions-item>
<el-descriptions-item label="申请人">{{ currentExp.applicant_name }}</el-descriptions-item>
<el-descriptions-item label="报销金额"><b style="color:#e6a23c">{{ formatCurrency(currentExp.total_amount) }}</b></el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag :type="(statusTagType(currentExp.status) as any)" effect="dark" size="small">{{ statusLabel(currentExp.status) }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="审批人">{{ currentExp.approver_name || '-' }}</el-descriptions-item>
<el-descriptions-item label="审批时间">{{ currentExp.approved_at || '-' }}</el-descriptions-item>
<el-descriptions-item label="备注" :span="2">{{ currentExp.remark || '-' }}</el-descriptions-item>
</el-descriptions>
<el-divider content-position="left">📋 报销明细</el-divider>
<el-table :data="currentExp.details || []" border stripe style="width:100%">
<el-table-column prop="invoice_merchant" label="开票方" min-width="130" show-overflow-tooltip />
<el-table-column label="发票金额" width="100" align="right">
<template #default="{ row }">{{ formatCurrency(row.invoice_amount || 0) }}</template>
</el-table-column>
<el-table-column prop="original_type" label="原始种类" width="100" align="center" />
<el-table-column prop="offset_type" label="冲顶类型" width="110" align="center">
<template #default="{ row }">
<el-tag v-if="row.offset_type && row.offset_type !== '常规报销'" type="warning" size="small" effect="dark">{{ row.offset_type }}</el-tag>
<span v-else>{{ row.offset_type || '-' }}</span>
</template>
</el-table-column>
<el-table-column prop="expense_desc" label="说明" min-width="100" show-overflow-tooltip>
<template #default="{ row }">{{ row.expense_desc || '-' }}</template>
</el-table-column>
<el-table-column prop="expense_date" label="发生时间" width="110" align="center">
<template #default="{ row }">{{ row.expense_date || '-' }}</template>
</el-table-column>
<el-table-column label="报销金额" width="100" align="right">
<template #default="{ row }"><b style="color:#e6a23c">{{ formatCurrency(row.amount) }}</b></template>
</el-table-column>
</el-table>
<div style="margin-top:16px; display:flex; justify-content:space-between">
<el-button :icon="Printer" @click="printExpense">🖨️ 打印报销汇总单</el-button>
<div>
<el-button v-if="canWithdraw(currentExp)" :icon="RefreshLeft" @click="doAction(currentExp.id, 'withdraw', '撤回')">撤回</el-button>
<el-button v-if="canApprove(currentExp)" type="success" :icon="Check" @click="doAction(currentExp.id, 'approve', '审批通过')">通过</el-button>
<el-button v-if="canApprove(currentExp)" type="danger" :icon="Close" @click="doAction(currentExp.id, 'reject', '驳回')">驳回</el-button>
</div>
</div>
</template>
</div>
</el-drawer>
</div>
</template>
<style scoped>
.finance-wrapper { height: 100%; }
.main-card { height: calc(100vh - 100px); border-radius: 8px; border: none; box-shadow: 0 1px 4px rgba(0,21,41,0.08); }
.finance-tabs { height: 100%; }
.inv-sub-tabs { margin-bottom: 8px; }
.upload-zone { background: #f5f7fa; border-radius: 8px; padding: 16px; margin-bottom: 12px; text-align: center; }
.upload-inner { padding: 12px 0; }
.upload-text { margin-top: 8px; font-size: 14px; color: #606266; }
.upload-text em { color: #409eff; font-style: normal; cursor: pointer; }
.upload-hint { font-size: 12px; color: #909399; margin-top: 4px; }
.tab-toolbar { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
.filter-form .el-form-item { margin-bottom: 0; }
.exp-id-bold { font-family: Consolas, 'Courier New', monospace; font-weight: bold; color: #409eff; }
.upload-queue { margin-top: 10px; text-align: left; }
.queue-item { display: flex; justify-content: space-between; align-items: center; padding: 6px 12px; border-bottom: 1px solid #f0f0f0; }
.queue-item:last-child { border-bottom: none; }
.queue-name { font-size: 13px; color: #606266; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 300px; }
</style>
+124
View File
@@ -0,0 +1,124 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { Search, Document, Plus, Refresh } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import request from '@/api/request'
import { useRouter } from 'vue-router'
const router = useRouter()
const loading = ref(false)
const logs = ref<any[]>([])
const total = ref(0)
const page = ref(1)
const size = ref(20)
const keyword = ref('')
const dateRange = ref<string[]>([])
const fetchLogs = async () => {
loading.value = true
try {
const params: Record<string, any> = { page: page.value, size: size.value }
if (keyword.value) params.keyword = keyword.value
if (dateRange.value?.length === 2) {
params.start_date = dateRange.value[0]
params.end_date = dateRange.value[1]
}
const data: any = await request.get('/api/sales-logs', { params })
logs.value = data?.items || []
total.value = data?.total || 0
} catch {
ElMessage.error('销售日志加载失败')
} finally {
loading.value = false
}
}
const handleSearch = () => { page.value = 1; fetchLogs() }
const handlePageChange = (p: number) => { page.value = p; fetchLogs() }
onMounted(fetchLogs)
</script>
<template>
<div class="sales-logs-container">
<!-- 搜索栏 -->
<el-card shadow="never" class="filter-card">
<el-row :gutter="12" align="middle">
<el-col :span="8">
<el-input
v-model="keyword"
placeholder="搜索日志内容..."
:prefix-icon="Search"
clearable
@keyup.enter="handleSearch"
@clear="handleSearch"
/>
</el-col>
<el-col :span="8">
<el-date-picker
v-model="dateRange"
type="daterange"
range-separator=""
start-placeholder="开始日期"
end-placeholder="结束日期"
value-format="YYYY-MM-DD"
@change="handleSearch"
style="width: 100%"
/>
</el-col>
<el-col :span="8">
<el-button type="primary" :icon="Search" @click="handleSearch">搜索</el-button>
<el-button :icon="Refresh" @click="keyword = ''; dateRange = []; handleSearch()">重置</el-button>
<el-button type="success" :icon="Plus" @click="router.push('/logs?action=new')">写日志</el-button>
</el-col>
</el-row>
</el-card>
<!-- 日志列表 -->
<el-card shadow="never" class="list-card">
<el-table :data="logs" v-loading="loading" stripe>
<el-table-column prop="log_date" label="日期" width="120" />
<el-table-column prop="customer_name" label="关联客户" width="180">
<template #default="{ row }">
{{ row.customer_name || '未关联' }}
</template>
</el-table-column>
<el-table-column prop="content" label="日志内容" show-overflow-tooltip />
<el-table-column prop="author_name" label="记录人" width="100" />
<el-table-column prop="created_at" label="创建时间" width="160">
<template #default="{ row }">
{{ row.created_at?.slice(0, 16)?.replace('T', ' ') }}
</template>
</el-table-column>
</el-table>
<div class="pagination-wrap" v-if="total > size">
<el-pagination
layout="total, prev, pager, next"
:total="total"
:page-size="size"
:current-page="page"
@current-change="handlePageChange"
/>
</div>
</el-card>
</div>
</template>
<style scoped>
.sales-logs-container {
display: flex;
flex-direction: column;
gap: 16px;
}
.filter-card, .list-card {
border-radius: 8px;
border: none;
}
.pagination-wrap {
display: flex;
justify-content: flex-end;
margin-top: 16px;
}
</style>
+541
View File
@@ -0,0 +1,541 @@
<script setup lang="ts">
/**
* 订单管理 —— 全真实 API 驱动
* 列表 + 开单 + 详情(含发货记录 timeline)+ 安排发货弹窗(防超发)
*/
import { ref, reactive, computed, onMounted, nextTick, watch } from 'vue'
import { Search, Plus, View, Delete, Van } from '@element-plus/icons-vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import request from '@/api/request'
// ════════════════════════ 订单列表 ════════════════════════
const loading = ref(false)
const orderList = ref<any[]>([])
const total = ref(0)
const page = ref(1)
const pageSize = ref(20)
const searchForm = reactive({ keyword: '', shipping_state: '', payment_state: '' })
const fetchOrders = async () => {
loading.value = true
try {
const params: Record<string, any> = { page: page.value, size: pageSize.value }
if (searchForm.keyword) params.keyword = searchForm.keyword
if (searchForm.shipping_state) params.shipping_state = searchForm.shipping_state
if (searchForm.payment_state) params.payment_state = searchForm.payment_state
const data: any = await request.get('/api/orders', { params })
orderList.value = data?.items || []
total.value = data?.total || 0
} catch {}
finally { loading.value = false }
}
const handleSearch = () => { page.value = 1; fetchOrders() }
const handlePageChange = (p: number) => { page.value = p; fetchOrders() }
const handleSizeChange = (s: number) => { pageSize.value = s; page.value = 1; fetchOrders() }
const shippingTagType = (s: string) => ({ pending: 'info', partial: 'warning', shipped: 'success' }[s] || 'info')
const shippingLabel = (s: string) => ({ pending: '待发货', partial: '部分发货', shipped: '已发货' }[s] || s)
const paymentTagType = (s: string) => ({ unpaid: 'danger', partial: 'warning', cleared: 'success' }[s] || 'info')
const paymentLabel = (s: string) => ({ unpaid: '未收款', partial: '部分收款', cleared: '已结清' }[s] || s)
const formatCurrency = (v: number) => `${v?.toLocaleString('zh-CN', { minimumFractionDigits: 2 })}`
// ════════════════════════ 订单详情 ════════════════════════
const detailVisible = ref(false)
const detailLoading = ref(false)
const currentOrder = ref<any>({})
const activeDetailTab = ref('items')
const shippingHistory = ref<any[]>([])
const shippingHistoryLoading = ref(false)
const openDetail = async (row: any) => {
activeDetailTab.value = 'items'
detailVisible.value = true
detailLoading.value = true
shippingHistory.value = []
try {
const data: any = await request.get(`/api/orders/${row.id}`)
currentOrder.value = data
// 同时加载发货轨迹
fetchShippingHistory(row.id)
} catch {}
finally { detailLoading.value = false }
}
const fetchShippingHistory = async (orderId: string) => {
shippingHistoryLoading.value = true
try {
const data: any = await request.get(`/api/shipping/order/${orderId}`)
shippingHistory.value = data?.shipments || []
} catch {}
finally { shippingHistoryLoading.value = false }
}
// ════════════════════════ 沉浸式开单 ════════════════════════
const createVisible = ref(false)
const createSubmitting = ref(false)
const createFormRef = ref<FormInstance>()
const customerOptions = ref<any[]>([])
const customerLoading = ref(false)
const fetchCustomers = async (q?: string) => {
customerLoading.value = true
try {
const params: Record<string, any> = { page: 1, size: 50 }
if (q) params.keyword = q
const data: any = await request.get('/api/customers', { params })
customerOptions.value = data?.items || []
} catch {}
finally { customerLoading.value = false }
}
const skuOptions = ref<any[]>([])
const fetchSkus = async () => {
try {
const data: any = await request.get('/api/products/skus', { params: { page: 1, size: 100 } })
skuOptions.value = data?.items || []
} catch {}
}
interface OrderRow {
sku_id: string; sku_name: string; spec: string; unit_price: number
qty: number; subtotal: number; price_source: string; price_hint: string
}
const orderForm = reactive({ customer_id: '', remark: '', items: [] as OrderRow[] })
const createRules = reactive<FormRules>({ customer_id: [{ required: true, message: '请选择客户', trigger: 'change' }] })
const totalAmount = computed(() => orderForm.items.reduce((s, r) => s + r.subtotal, 0))
const openCreateOrder = async () => {
orderForm.customer_id = ''; orderForm.remark = ''; orderForm.items = []
addRow()
createVisible.value = true
await fetchCustomers(); await fetchSkus()
nextTick(() => createFormRef.value?.clearValidate())
}
const addRow = () => {
orderForm.items.push({ sku_id: '', sku_name: '', spec: '', unit_price: 0, qty: 1, subtotal: 0, price_source: '', price_hint: '' })
}
const removeRow = (idx: number) => {
if (orderForm.items.length <= 1) { ElMessage.warning('至少保留一个明细行'); return }
orderForm.items.splice(idx, 1)
}
const handleSkuChange = async (skuId: string, idx: number) => {
const row = orderForm.items[idx]
const sku = skuOptions.value.find((s: any) => s.id === skuId)
if (sku) { row.sku_name = sku.name; row.spec = sku.spec || '' }
if (!orderForm.customer_id || !skuId) {
row.unit_price = sku?.standard_price || 0; row.price_source = 'standard'; row.price_hint = '指导价(未选客户)'
updateSubtotal(idx); return
}
try {
const data: any = await request.get('/api/orders/price/calculate', { params: { customer_id: orderForm.customer_id, sku_id: skuId } })
row.unit_price = data.unit_price; row.price_source = data.price_source
row.price_hint = data.price_source === 'history' ? `历史专享价(来自 ${data.last_order_no}` : '标准指导价'
} catch { row.unit_price = sku?.standard_price || 0; row.price_source = 'standard'; row.price_hint = '指导价(定价服务异常)' }
updateSubtotal(idx)
}
const handleCustomerChange = () => { orderForm.items.forEach((r, i) => { if (r.sku_id) handleSkuChange(r.sku_id, i) }) }
const updateSubtotal = (idx: number) => { const r = orderForm.items[idx]; r.subtotal = Math.round(r.qty * r.unit_price * 100) / 100 }
const submitOrder = async () => {
const valid = await createFormRef.value?.validate().catch(() => false)
if (!valid) return
if (!orderForm.items.some(r => r.sku_id)) { ElMessage.warning('请至少添加一个产品明细行'); return }
if (orderForm.items.some(r => !r.sku_id)) { ElMessage.warning('有未选择产品的明细行'); return }
createSubmitting.value = true
try {
await request.post('/api/orders', {
customer_id: orderForm.customer_id, remark: orderForm.remark || null,
items: orderForm.items.map(r => ({ sku_id: r.sku_id, qty: r.qty, unit_price: r.unit_price })),
})
ElMessage.success('订单创建成功!'); createVisible.value = false; fetchOrders()
} catch {}
finally { createSubmitting.value = false }
}
// ════════════════════════ 安排发货 ════════════════════════
const shipDialogVisible = ref(false)
const shipSubmitting = ref(false)
const shipFormRef = ref<FormInstance>()
const shipOrder = ref<any>({})
interface ShipRow {
order_item_id: string; sku_id: string; sku_code: string; sku_name: string
qty: number; shipped_qty: number; remaining: number; ship_qty: number
overLimit: boolean
}
const shipForm = reactive({
carrier: '',
tracking_no: '',
remark: '',
items: [] as ShipRow[],
})
const canSubmitShip = computed(() => {
return shipForm.items.some(r => r.ship_qty > 0) && !shipForm.items.some(r => r.overLimit)
})
const openShipDialog = async (row: any) => {
// 加载订单详情获取 items
try {
const data: any = await request.get(`/api/orders/${row.id}`)
shipOrder.value = data
shipForm.carrier = ''
shipForm.tracking_no = ''
shipForm.remark = ''
shipForm.items = (data.items || []).map((item: any) => {
const remaining = item.qty - (item.shipped_qty || 0)
return {
order_item_id: item.id,
sku_id: item.sku_id,
sku_code: item.sku_code,
sku_name: item.sku_name,
qty: item.qty,
shipped_qty: item.shipped_qty || 0,
remaining,
ship_qty: remaining > 0 ? remaining : 0,
overLimit: false,
}
})
shipDialogVisible.value = true
} catch {}
}
const validateShipQty = (idx: number) => {
const row = shipForm.items[idx]
row.overLimit = row.ship_qty > row.remaining || row.ship_qty < 0
}
const submitShipping = async () => {
const activeItems = shipForm.items.filter(r => r.ship_qty > 0)
if (!activeItems.length) { ElMessage.warning('请至少填写一行发货数量'); return }
if (shipForm.items.some(r => r.overLimit)) { ElMessage.error('存在超发行,请检查'); return }
try {
await ElMessageBox.confirm(
`确认对订单「${shipOrder.value.order_no}」执行发货?(${activeItems.length} 个明细行)`,
'发货确认', { type: 'warning' }
)
} catch { return }
shipSubmitting.value = true
try {
await request.post('/api/shipping', {
order_id: shipOrder.value.id,
carrier: shipForm.carrier || null,
tracking_no: shipForm.tracking_no || null,
remark: shipForm.remark || null,
items: activeItems.map(r => ({
order_item_id: r.order_item_id,
sku_id: r.sku_id,
shipped_qty: r.ship_qty,
})),
})
ElMessage.success('发货成功,库存已同步扣减')
shipDialogVisible.value = false
fetchOrders()
// 如果详情也开着,刷新它
if (detailVisible.value && currentOrder.value.id === shipOrder.value.id) {
openDetail(shipOrder.value)
}
} catch {}
finally { shipSubmitting.value = false }
}
// ════════════════════════ Init ════════════════════════
onMounted(fetchOrders)
</script>
<template>
<div class="order-management-wrapper">
<!-- 1. 顶部筛选 -->
<el-card shadow="never" class="filter-card">
<div class="filter-wrapper">
<el-form :inline="true" :model="searchForm" class="filter-form">
<el-form-item label="订单号">
<el-input v-model="searchForm.keyword" placeholder="模糊搜索" clearable style="width:180px" @keyup.enter="handleSearch" />
</el-form-item>
<el-form-item label="发货状态">
<el-select v-model="searchForm.shipping_state" clearable placeholder="全部" style="width:120px">
<el-option label="待发货" value="pending" /><el-option label="部分发货" value="partial" /><el-option label="已发货" value="shipped" />
</el-select>
</el-form-item>
<el-form-item label="收款状态">
<el-select v-model="searchForm.payment_state" clearable placeholder="全部" style="width:120px">
<el-option label="未收款" value="unpaid" /><el-option label="部分收款" value="partial" /><el-option label="已结清" value="cleared" />
</el-select>
</el-form-item>
<el-form-item><el-button type="primary" :icon="Search" @click="handleSearch">检索</el-button></el-form-item>
</el-form>
<el-button type="primary" :icon="Plus" size="large" @click="openCreateOrder">新建订单</el-button>
</div>
</el-card>
<!-- 2. 订单列表 -->
<el-card shadow="never" class="table-card">
<el-table :data="orderList" v-loading="loading" stripe border style="width:100%" height="calc(100vh - 290px)">
<el-table-column prop="order_no" label="订单编号" width="200" fixed="left">
<template #default="{ row }"><span class="order-id-bold">{{ row.order_no }}</span></template>
</el-table-column>
<el-table-column prop="customer_name" label="客户名称" min-width="200" show-overflow-tooltip />
<el-table-column label="订单金额" width="140" align="right">
<template #default="{ row }"><b style="color:#e6a23c">{{ formatCurrency(row.total_amount) }}</b></template>
</el-table-column>
<el-table-column label="发货状态" width="120" align="center">
<template #default="{ row }">
<el-tag :type="(shippingTagType(row.shipping_state) as any)" effect="dark" size="small">{{ shippingLabel(row.shipping_state) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="收款状态" width="120" align="center">
<template #default="{ row }">
<el-tag :type="(paymentTagType(row.payment_state) as any)" size="small">{{ paymentLabel(row.payment_state) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="order_date" label="下单日期" width="120" align="center" />
<el-table-column prop="salesperson_name" label="业务员" width="100" align="center" />
<el-table-column label="操作" width="180" fixed="right">
<template #default="{ row }">
<el-button type="primary" link :icon="View" @click="openDetail(row)">详情</el-button>
<el-button type="success" link :icon="Van" @click="openShipDialog(row)" :disabled="row.shipping_state === 'shipped'">发货</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination style="margin-top:16px; justify-content:flex-end" background layout="total, sizes, prev, pager, next"
:total="total" :page-size="pageSize" :current-page="page" :page-sizes="[10, 20, 50]"
@current-change="handlePageChange" @size-change="handleSizeChange" />
</el-card>
<!-- 3. 沉浸式开单抽屉 -->
<el-drawer v-model="createVisible" title="✨ 新建订单" size="85%" destroy-on-close>
<el-form ref="createFormRef" :model="orderForm" :rules="createRules" label-width="80px">
<el-row :gutter="20">
<el-col :span="10">
<el-form-item label="选择客户" prop="customer_id">
<el-select v-model="orderForm.customer_id" filterable remote reserve-keyword
:remote-method="fetchCustomers" :loading="customerLoading" placeholder="搜索客户名称"
style="width:100%" @change="handleCustomerChange">
<el-option v-for="c in customerOptions" :key="c.id" :label="c.name" :value="c.id">
<span>{{ c.name }}</span><span style="float:right; color:#909399; font-size:12px">{{ c.level }}</span>
</el-option>
</el-select>
</el-form-item>
</el-col>
<el-col :span="10">
<el-form-item label="备注"><el-input v-model="orderForm.remark" placeholder="订单备注(非必填)" /></el-form-item>
</el-col>
<el-col :span="4" style="text-align:right; padding-top:2px">
<el-statistic title="订单总额" :value="totalAmount" :precision="2" prefix="¥" />
</el-col>
</el-row>
<el-divider content-position="left">📦 订单明细</el-divider>
<el-table :data="orderForm.items" border style="width:100%">
<el-table-column type="index" label="#" width="50" />
<el-table-column label="选择产品" min-width="260">
<template #default="{ row, $index }">
<el-select v-model="row.sku_id" filterable placeholder="搜索 SKU / 产品名" style="width:100%"
@change="(val: string) => handleSkuChange(val, $index)">
<el-option v-for="s in skuOptions" :key="s.id" :label="`${s.sku_code} — ${s.name}`" :value="s.id">
<span>{{ s.sku_code }}</span><span style="margin-left:8px; color:#606266">{{ s.name }}</span>
<span style="float:right; color:#909399; font-size:12px">库存:{{ s.stock_qty }}</span>
</el-option>
</el-select>
</template>
</el-table-column>
<el-table-column label="规格" width="100"><template #default="{ row }">{{ row.spec || '-' }}</template></el-table-column>
<el-table-column label="单价" width="160">
<template #default="{ row, $index }">
<el-input-number v-model="row.unit_price" :min="0" :precision="2" size="small" style="width:120px" @change="updateSubtotal($index)" />
<el-tooltip v-if="row.price_hint" :content="row.price_hint" placement="top">
<el-tag :type="row.price_source === 'history' ? 'success' : 'info'" size="small" effect="plain" style="margin-left:4px; cursor:help">
{{ row.price_source === 'history' ? '专享' : '指导' }}
</el-tag>
</el-tooltip>
</template>
</el-table-column>
<el-table-column label="数量" width="120">
<template #default="{ row, $index }">
<el-input-number v-model="row.qty" :min="0.01" :precision="2" size="small" style="width:100px" @change="updateSubtotal($index)" />
</template>
</el-table-column>
<el-table-column label="小计" width="120" align="right">
<template #default="{ row }"><b style="color:#e6a23c">{{ formatCurrency(row.subtotal) }}</b></template>
</el-table-column>
<el-table-column label="" width="60">
<template #default="{ $index }"><el-button type="danger" link :icon="Delete" @click="removeRow($index)" /></template>
</el-table-column>
</el-table>
<el-button type="primary" plain :icon="Plus" style="width:100%; margin-top:12px" @click="addRow">添加产品行</el-button>
</el-form>
<template #footer>
<el-button @click="createVisible = false">取消</el-button>
<el-button type="primary" size="large" :loading="createSubmitting" @click="submitOrder">
确认开单总额 {{ formatCurrency(totalAmount) }}
</el-button>
</template>
</el-drawer>
<!-- 4. 订单详情抽屉含发货轨迹 tab -->
<el-drawer v-model="detailVisible" title="订单全景详情" size="750px">
<div v-loading="detailLoading">
<template v-if="currentOrder.id">
<el-descriptions :column="2" border>
<el-descriptions-item label="订单编号"><span class="order-id-bold">{{ currentOrder.order_no }}</span></el-descriptions-item>
<el-descriptions-item label="客户">{{ currentOrder.customer_name }}</el-descriptions-item>
<el-descriptions-item label="业务员">{{ currentOrder.salesperson_name || '-' }}</el-descriptions-item>
<el-descriptions-item label="下单日期">{{ currentOrder.order_date }}</el-descriptions-item>
<el-descriptions-item label="订单金额"><b style="color:#e6a23c">{{ formatCurrency(currentOrder.total_amount) }}</b></el-descriptions-item>
<el-descriptions-item label="已收款">{{ formatCurrency(currentOrder.paid_amount || 0) }}</el-descriptions-item>
<el-descriptions-item label="发货状态">
<el-tag :type="(shippingTagType(currentOrder.shipping_state) as any)" effect="dark" size="small">{{ shippingLabel(currentOrder.shipping_state) }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="收款状态">
<el-tag :type="(paymentTagType(currentOrder.payment_state) as any)" size="small">{{ paymentLabel(currentOrder.payment_state) }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="备注" :span="2">{{ currentOrder.remark || '-' }}</el-descriptions-item>
</el-descriptions>
<el-tabs v-model="activeDetailTab" style="margin-top:16px">
<!-- Tab 1: 商品明细 -->
<el-tab-pane label="📦 商品明细" name="items">
<el-table :data="currentOrder.items || []" border stripe style="width:100%">
<el-table-column prop="sku_code" label="SKU" width="180"><template #default="{ row }"><span class="sku-bold">{{ row.sku_code }}</span></template></el-table-column>
<el-table-column prop="sku_name" label="产品名称" min-width="180" show-overflow-tooltip />
<el-table-column prop="spec" label="规格" width="100" />
<el-table-column label="单价" width="110" align="right"><template #default="{ row }">{{ formatCurrency(row.unit_price) }}</template></el-table-column>
<el-table-column prop="qty" label="订购" width="70" align="center" />
<el-table-column label="已发" width="70" align="center">
<template #default="{ row }">
<span :style="{ color: row.shipped_qty >= row.qty ? '#67c23a' : '#e6a23c', fontWeight: 'bold' }">{{ row.shipped_qty }}</span>
</template>
</el-table-column>
<el-table-column label="小计" width="110" align="right"><template #default="{ row }"><b>{{ formatCurrency(row.sub_total) }}</b></template></el-table-column>
</el-table>
</el-tab-pane>
<!-- Tab 2: 发货记录 -->
<el-tab-pane label="🚛 发货记录" name="shipping">
<div v-loading="shippingHistoryLoading">
<el-empty v-if="!shippingHistory.length" description="暂无发货记录" />
<el-timeline v-else>
<el-timeline-item
v-for="ship in shippingHistory" :key="ship.id"
:timestamp="ship.ship_date" placement="top"
:color="ship.status === 'delivered' ? '#67c23a' : '#409eff'"
>
<el-card shadow="hover" class="timeline-card">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:8px">
<span class="order-id-bold">{{ ship.shipping_no }}</span>
<el-tag size="small" :type="ship.status === 'delivered' ? 'success' : 'primary'" effect="dark">
{{ ship.status === 'delivered' ? '已签收' : '运输中' }}
</el-tag>
</div>
<p style="margin:4px 0; font-size:13px; color:#606266">物流{{ ship.carrier || '-' }} | 单号{{ ship.tracking_no || '-' }}</p>
<p style="margin:4px 0; font-size:13px; color:#606266">操作人{{ ship.operator_name || '-' }}</p>
<el-table :data="ship.items" border size="small" style="margin-top:8px">
<el-table-column prop="sku_code" label="SKU" width="140" />
<el-table-column prop="sku_name" label="产品" min-width="130" />
<el-table-column prop="spec" label="规格" width="100">
<template #default="{ row }">{{ row.spec || '-' }}</template>
</el-table-column>
<el-table-column label="发货数量" width="100" align="center">
<template #default="{ row }">{{ row.shipped_qty }} {{ row.unit || '' }}</template>
</el-table-column>
</el-table>
</el-card>
</el-timeline-item>
</el-timeline>
</div>
</el-tab-pane>
</el-tabs>
<!-- 详情页快速发货按钮 -->
<div v-if="currentOrder.shipping_state !== 'shipped'" style="margin-top:16px; text-align:right">
<el-button type="success" :icon="Van" @click="openShipDialog(currentOrder)">安排发货</el-button>
</div>
</template>
</div>
</el-drawer>
<!-- 5. 安排发货弹窗防超发 -->
<el-dialog v-model="shipDialogVisible" title="🚛 安排发货" width="750px" destroy-on-close>
<el-alert
:title="`订单 ${shipOrder.order_no} — ${shipOrder.customer_name}`"
type="info" show-icon :closable="false" style="margin-bottom:16px"
/>
<el-form label-width="80px">
<el-row :gutter="20">
<el-col :span="8">
<el-form-item label="物流公司"><el-input v-model="shipForm.carrier" placeholder="如 德邦物流" /></el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="物流单号"><el-input v-model="shipForm.tracking_no" placeholder="快递/物流单号" /></el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="备注"><el-input v-model="shipForm.remark" placeholder="非必填" /></el-form-item>
</el-col>
</el-row>
</el-form>
<el-divider content-position="left">📦 发货明细防超发校验</el-divider>
<el-table :data="shipForm.items" border style="width:100%">
<el-table-column prop="sku_code" label="SKU" width="160">
<template #default="{ row }"><span class="sku-bold">{{ row.sku_code }}</span></template>
</el-table-column>
<el-table-column prop="sku_name" label="产品" min-width="160" show-overflow-tooltip />
<el-table-column label="购买量" width="80" align="center"><template #default="{ row }">{{ row.qty }}</template></el-table-column>
<el-table-column label="已发" width="70" align="center">
<template #default="{ row }"><span style="color:#67c23a; font-weight:bold">{{ row.shipped_qty }}</span></template>
</el-table-column>
<el-table-column label="可发余量" width="80" align="center">
<template #default="{ row }"><span style="color:#e6a23c; font-weight:bold">{{ row.remaining }}</span></template>
</el-table-column>
<el-table-column label="本次发货" width="130">
<template #default="{ row, $index }">
<el-input-number
v-model="row.ship_qty"
:min="0" :max="row.remaining" :precision="2"
size="small" style="width:110px"
:class="{ 'over-limit': row.overLimit }"
@change="validateShipQty($index)"
/>
</template>
</el-table-column>
<el-table-column label="状态" width="80" align="center">
<template #default="{ row }">
<el-tag v-if="row.remaining <= 0" type="success" size="small">已发完</el-tag>
<el-tag v-else-if="row.overLimit" type="danger" size="small">超发!</el-tag>
<el-tag v-else-if="row.ship_qty > 0" type="primary" size="small" effect="plain">待发</el-tag>
<el-tag v-else type="info" size="small" effect="plain">跳过</el-tag>
</template>
</el-table-column>
</el-table>
<template #footer>
<el-button @click="shipDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="shipSubmitting" :disabled="!canSubmitShip" @click="submitShipping">
确认发货
</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.order-management-wrapper { display: flex; flex-direction: column; gap: 16px; height: 100%; }
.filter-card { border-radius: 8px; border: none; box-shadow: 0 1px 4px rgba(0,21,41,0.08); }
.filter-wrapper { display: flex; justify-content: space-between; align-items: center; }
.filter-form .el-form-item { margin-bottom: 0; }
.table-card { flex: 1; border-radius: 8px; border: none; box-shadow: 0 1px 4px rgba(0,21,41,0.08); }
.order-id-bold { font-family: Consolas, 'Courier New', monospace; font-weight: bold; color: #409eff; }
.sku-bold { font-family: Consolas, 'Courier New', monospace; font-weight: bold; color: #409eff; }
.timeline-card { border-radius: 6px; }
.timeline-card p { line-height: 1.4; }
.over-limit :deep(.el-input__inner) { color: #f56c6c !important; border-color: #f56c6c !important; }
</style>
+594
View File
@@ -0,0 +1,594 @@
<script setup lang="ts">
/**
* 产品与库存管理 —— 全真实 API 驱动
* 左树右表 + 分类 CRUD + 新增/编辑 SKU + 库存变更原子事务
*/
import { ref, reactive, onMounted, nextTick } from 'vue'
import { Search, Plus, View, Box, Edit, Delete, CirclePlus, Upload } from '@element-plus/icons-vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import request from '@/api/request'
// ════════════════════════ 分类树 ════════════════════════
const treeData = ref<any[]>([])
const currentCategoryId = ref<string | null>(null)
const treeProps = { children: 'children', label: 'name' }
const fetchCategoryTree = async () => {
try {
const data: any = await request.get('/api/products/categories/tree')
treeData.value = Array.isArray(data) ? data : []
} catch { /* 已统一 */ }
}
const handleNodeClick = (data: any) => {
currentCategoryId.value = data.id
fetchSkus()
}
const clearCategoryFilter = () => {
currentCategoryId.value = null
fetchSkus()
}
// ── 分类 CRUD 弹窗 ──
const catDialogVisible = ref(false)
const catDialogTitle = ref('新建分类')
const catSubmitting = ref(false)
const catFormRef = ref<FormInstance>()
const isCatEdit = ref(false)
const editCatId = ref('')
const catForm = reactive({ name: '', parent_id: null as string | null })
const catRules = reactive<FormRules>({
name: [{ required: true, message: '请输入分类名称', trigger: 'blur' }],
})
const openAddTopCategory = () => {
isCatEdit.value = false
catDialogTitle.value = '新建顶级分类'
editCatId.value = ''
catForm.name = ''
catForm.parent_id = null
catDialogVisible.value = true
nextTick(() => catFormRef.value?.clearValidate())
}
const openAddChildCategory = (parentNode: any) => {
isCatEdit.value = false
catDialogTitle.value = `新建子分类(上级:${parentNode.name}`
editCatId.value = ''
catForm.name = ''
catForm.parent_id = parentNode.id
catDialogVisible.value = true
nextTick(() => catFormRef.value?.clearValidate())
}
const openEditCategory = (node: any) => {
isCatEdit.value = true
catDialogTitle.value = '编辑分类'
editCatId.value = node.id
catForm.name = node.name
catForm.parent_id = node.parent_id || null
catDialogVisible.value = true
nextTick(() => catFormRef.value?.clearValidate())
}
const submitCategory = async () => {
const valid = await catFormRef.value?.validate().catch(() => false)
if (!valid) return
catSubmitting.value = true
try {
if (isCatEdit.value) {
await request.put(`/api/products/categories/${editCatId.value}`, { name: catForm.name })
ElMessage.success('分类已更新')
} else {
const payload: any = { name: catForm.name }
if (catForm.parent_id) payload.parent_id = catForm.parent_id
await request.post('/api/products/categories', payload)
ElMessage.success('分类创建成功')
}
catDialogVisible.value = false
await fetchCategoryTree()
} catch { /* 已统一 */ }
finally { catSubmitting.value = false }
}
const deleteCategory = (node: any) => {
ElMessageBox.confirm(`确定删除分类「${node.name}」吗?`, '删除确认', { type: 'warning' })
.then(async () => {
try {
await request.delete(`/api/products/categories/${node.id}`)
ElMessage.success('分类已删除')
if (currentCategoryId.value === node.id) {
currentCategoryId.value = null
fetchSkus()
}
await fetchCategoryTree()
} catch { /* 已统一 */ }
})
.catch(() => {})
}
// ════════════════════════ SKU 列表 ════════════════════════
const loading = ref(false)
const skuList = ref<any[]>([])
const total = ref(0)
const page = ref(1)
const pageSize = ref(20)
const keyword = ref('')
const fetchSkus = async () => {
loading.value = true
try {
const params: Record<string, any> = { page: page.value, size: pageSize.value }
if (currentCategoryId.value) params.category_id = currentCategoryId.value
if (keyword.value.trim()) params.keyword = keyword.value.trim()
const data: any = await request.get('/api/products/skus', { params })
skuList.value = data?.items || []
total.value = data?.total || 0
} catch { /* 已统一 */ }
finally { loading.value = false }
}
const handleSearch = () => { page.value = 1; fetchSkus() }
const handlePageChange = (p: number) => { page.value = p; fetchSkus() }
const handleSizeChange = (s: number) => { pageSize.value = s; page.value = 1; fetchSkus() }
// ════════════════════════ 新增/编辑 SKU ════════════════════════
const skuDialogVisible = ref(false)
const skuDialogTitle = ref('新增产品')
const skuSubmitting = ref(false)
const skuFormRef = ref<FormInstance>()
const isEditMode = ref(false)
const editSkuId = ref('')
const skuForm = reactive({
sku_code: '',
name: '',
category_id: '' as string | null,
spec: '',
standard_price: 0,
stock_qty: 0,
warning_threshold: 0,
unit: '桶',
status: 1,
})
const skuRules = reactive<FormRules>({
sku_code: [{ required: true, message: '请输入 SKU 编码', trigger: 'blur' }],
name: [{ required: true, message: '请输入产品名称', trigger: 'blur' }],
})
const openAddSku = () => {
isEditMode.value = false
skuDialogTitle.value = '新增产品'
editSkuId.value = ''
Object.assign(skuForm, {
sku_code: '', name: '', category_id: currentCategoryId.value || null,
spec: '', standard_price: 0, stock_qty: 0, warning_threshold: 0, unit: '桶', status: 1
})
skuDialogVisible.value = true
nextTick(() => skuFormRef.value?.clearValidate())
}
const openEditSku = (row: any) => {
isEditMode.value = true
skuDialogTitle.value = '编辑产品'
editSkuId.value = row.id
Object.assign(skuForm, {
sku_code: row.sku_code, name: row.name, category_id: row.category_id || null,
spec: row.spec || '', standard_price: row.standard_price, stock_qty: row.stock_qty,
warning_threshold: row.warning_threshold, unit: row.unit || '桶', status: row.status
})
skuDialogVisible.value = true
nextTick(() => skuFormRef.value?.clearValidate())
}
const submitSku = async () => {
const valid = await skuFormRef.value?.validate().catch(() => false)
if (!valid) return
skuSubmitting.value = true
try {
if (isEditMode.value) {
// ⚠️ 编辑时不传 stock_qty 和 sku_code
const { stock_qty, sku_code, ...editData } = skuForm
await request.put(`/api/products/skus/${editSkuId.value}`, editData)
ElMessage.success('产品信息已更新')
} else {
await request.post('/api/products/skus', skuForm)
ElMessage.success('产品创建成功')
}
skuDialogVisible.value = false
fetchSkus()
} catch { /* 已统一 */ }
finally { skuSubmitting.value = false }
}
// ════════════════════════ 详情抽屉 ════════════════════════
const detailVisible = ref(false)
const currentDetail = ref<any>({})
const openDetail = (row: any) => { currentDetail.value = row; detailVisible.value = true }
// ════════════════════════ 导入 SKU ════════════════════════
const importDialogVisible = ref(false)
const getUploadHeaders = () => {
return { Authorization: `Bearer ${localStorage.getItem('crm_token') || ''}` }
}
const handleImportSuccess = (response: any) => {
if (response.code === 200) {
ElMessage.success(response.message || '导入成功')
importDialogVisible.value = false
fetchSkus()
} else {
ElMessage.error(response.message || '导入失败')
}
}
const handleImportError = () => { ElMessage.error('上传失败,请检查网络或后端服务') }
// ════════════════════════ 库存变更 ════════════════════════
const invDialogVisible = ref(false)
const invSubmitting = ref(false)
const invFormRef = ref<FormInstance>()
const currentInvSku = ref<any>({})
const invForm = reactive({
direction: 'in' as 'in' | 'out',
qty: 1,
reason: 'purchase',
remark: '',
})
const invRules = reactive<FormRules>({
qty: [{ required: true, message: '请输入变更数量', trigger: 'blur' }],
reason: [{ required: true, message: '请选择变动原因', trigger: 'change' }],
})
const inReasons = [
{ label: '厂家进货', value: 'purchase' },
{ label: '盘点盘盈', value: 'adjust' },
{ label: '客户退货', value: 'return' },
]
const outReasons = [
{ label: '发货出库', value: 'shipment' },
{ label: '送样损耗', value: 'loss' },
{ label: '盘点盘亏', value: 'adjust' },
]
const openInventory = (row: any) => {
currentInvSku.value = row
Object.assign(invForm, { direction: 'in', qty: 1, reason: 'purchase', remark: '' })
invDialogVisible.value = true
nextTick(() => invFormRef.value?.clearValidate())
}
const submitInventory = async () => {
const valid = await invFormRef.value?.validate().catch(() => false)
if (!valid) return
const changeQty = invForm.direction === 'in' ? invForm.qty : -invForm.qty
try {
await ElMessageBox.confirm(
`确认对「${currentInvSku.value.name}${invForm.direction === 'in' ? '入库' : '出库'} ${invForm.qty} ${currentInvSku.value.unit || '件'}`,
'库存变更确认', { type: 'warning' }
)
} catch { return }
invSubmitting.value = true
try {
await request.post('/api/products/inventory/flow', {
sku_id: currentInvSku.value.id,
change_qty: changeQty,
reason: invForm.reason,
remark: invForm.remark || null,
})
ElMessage.success('库存变更成功')
invDialogVisible.value = false
fetchSkus()
} catch { /* 已统一 */ }
finally { invSubmitting.value = false }
}
// ════════════════════════ Init ════════════════════════
onMounted(async () => {
await fetchCategoryTree()
await fetchSkus()
})
</script>
<template>
<div class="product-management-wrapper">
<el-row :gutter="20" class="page-row">
<!-- 1. 左侧分类树 -->
<el-col :span="5" class="left-col">
<el-card shadow="never" class="tree-card">
<template #header>
<div class="card-header">
<span class="tree-title">产品目录</span>
<el-button type="primary" :icon="Plus" link @click="openAddTopCategory">新建</el-button>
</div>
</template>
<el-button v-if="currentCategoryId" link type="primary" size="small" style="margin-bottom:8px" @click="clearCategoryFilter">
清除分类筛选
</el-button>
<el-tree
:data="treeData"
:props="treeProps"
node-key="id"
@node-click="handleNodeClick"
default-expand-all
highlight-current
class="filter-tree"
>
<template #default="{ data }">
<span class="tree-node">
<span class="tree-node-label">{{ data.name }}</span>
<span class="tree-node-actions">
<el-icon class="tree-action-icon" @click.stop="openAddChildCategory(data)"><CirclePlus /></el-icon>
<el-icon class="tree-action-icon" @click.stop="openEditCategory(data)"><Edit /></el-icon>
<el-icon class="tree-action-icon danger" @click.stop="deleteCategory(data)"><Delete /></el-icon>
</span>
</span>
</template>
</el-tree>
<el-empty v-if="!treeData.length" description="暂无分类,请先新建" :image-size="60" />
</el-card>
</el-col>
<!-- 2 & 3. 右侧主体区 -->
<el-col :span="19" class="right-col">
<!-- 检索区 -->
<el-card shadow="never" class="filter-card">
<div class="filter-wrapper">
<el-form :inline="true" class="filter-form">
<el-form-item label="模糊搜索">
<el-input v-model="keyword" placeholder="SKU编码 / 产品名称" clearable style="width:280px" @keyup.enter="handleSearch" />
</el-form-item>
</el-form>
<div class="action-buttons">
<el-button type="primary" :icon="Search" @click="handleSearch">搜索</el-button>
<el-button type="warning" :icon="Upload" @click="importDialogVisible = true">导入SKU</el-button>
<el-button type="success" :icon="Plus" @click="openAddSku">新增产品</el-button>
</div>
</div>
</el-card>
<!-- 表格区 -->
<el-card shadow="never" class="table-card">
<el-table :data="skuList" v-loading="loading" stripe border height="calc(100vh - 320px)" style="width:100%">
<el-table-column prop="sku_code" label="产品编码 (SKU)" width="180" fixed="left">
<template #default="{ row }">
<span class="sku-bold">{{ row.sku_code }}</span>
</template>
</el-table-column>
<el-table-column prop="name" label="中文名称" min-width="200" show-overflow-tooltip />
<el-table-column prop="category_name" label="分类" width="130" />
<el-table-column prop="spec" label="规格" width="120" align="center" />
<el-table-column label="指导价" width="120" align="right">
<template #default="{ row }">
{{ row.standard_price?.toFixed(2) }}
</template>
</el-table-column>
<el-table-column label="当前库存" width="110" align="center">
<template #default="{ row }">
<el-tag v-if="row.stock_qty <= row.warning_threshold && row.warning_threshold > 0" type="danger" effect="dark" size="small">
<b>{{ row.stock_qty }}</b>
</el-tag>
<span v-else class="normal-stock">{{ row.stock_qty }}</span>
</template>
</el-table-column>
<el-table-column prop="unit" label="单位" width="70" align="center" />
<el-table-column label="操作" width="260" fixed="right">
<template #default="{ row }">
<el-button type="primary" link :icon="View" @click="openDetail(row)">详情</el-button>
<el-button type="warning" link :icon="Box" @click="openInventory(row)">库存变更</el-button>
<el-button type="primary" link :icon="Edit" @click="openEditSku(row)">编辑</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
style="margin-top:16px; justify-content:flex-end"
background layout="total, sizes, prev, pager, next"
:total="total" :page-size="pageSize" :current-page="page" :page-sizes="[10, 20, 50]"
@current-change="handlePageChange" @size-change="handleSizeChange"
/>
</el-card>
</el-col>
</el-row>
<!-- 分类新增/编辑弹窗 -->
<el-dialog v-model="catDialogVisible" :title="catDialogTitle" width="420px" destroy-on-close>
<el-form ref="catFormRef" :model="catForm" :rules="catRules" label-width="80px">
<el-form-item label="分类名称" prop="name">
<el-input v-model="catForm.name" placeholder="如 工业润滑油" />
</el-form-item>
<el-form-item label="上级分类">
<el-tree-select
v-model="catForm.parent_id"
:data="treeData"
:props="{ children: 'children', label: 'name', value: 'id' }"
node-key="id"
check-strictly
clearable
placeholder="不选则为顶级分类"
style="width:100%"
:disabled="!isCatEdit"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="catDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="catSubmitting" @click="submitCategory">
{{ isCatEdit ? '保存' : '新建' }}
</el-button>
</template>
</el-dialog>
<!-- 新增/编辑 SKU 弹窗 -->
<el-dialog v-model="skuDialogVisible" :title="skuDialogTitle" width="560px" destroy-on-close>
<el-form ref="skuFormRef" :model="skuForm" :rules="skuRules" label-width="100px">
<el-form-item label="SKU 编码" prop="sku_code">
<el-input v-model="skuForm.sku_code" :disabled="isEditMode" placeholder="如 LUB-HYD-46-200L" />
</el-form-item>
<el-form-item label="产品名称" prop="name">
<el-input v-model="skuForm.name" placeholder="如 长城卓力 AE 46 号抗磨液压油" />
</el-form-item>
<el-form-item label="所属分类">
<el-tree-select
v-model="skuForm.category_id"
:data="treeData"
:props="{ children: 'children', label: 'name', value: 'id' }"
node-key="id"
check-strictly
clearable
placeholder="请选择分类"
style="width:100%"
/>
</el-form-item>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="包装规格">
<el-input v-model="skuForm.spec" placeholder="如 200L/桶" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="计量单位">
<el-input v-model="skuForm.unit" placeholder="桶 / 件 / KG" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="指导价">
<el-input-number v-model="skuForm.standard_price" :min="0" :precision="2" style="width:100%" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="预警阈值">
<el-input-number v-model="skuForm.warning_threshold" :min="0" style="width:100%" />
</el-form-item>
</el-col>
</el-row>
<el-form-item v-if="!isEditMode" label="初始库存">
<el-input-number v-model="skuForm.stock_qty" :min="0" style="width:100%" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="skuDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="skuSubmitting" @click="submitSku">
{{ isEditMode ? '保存修改' : '确认新增' }}
</el-button>
</template>
</el-dialog>
<!-- 详情抽屉 -->
<el-drawer v-model="detailVisible" title="产品详细档案" size="420px">
<el-descriptions :column="1" border direction="vertical">
<el-descriptions-item label="产品编码 (SKU)">
<span class="sku-bold">{{ currentDetail.sku_code }}</span>
</el-descriptions-item>
<el-descriptions-item label="中文名称">{{ currentDetail.name }}</el-descriptions-item>
<el-descriptions-item label="所属分类">{{ currentDetail.category_name || '-' }}</el-descriptions-item>
<el-descriptions-item label="包装规格">{{ currentDetail.spec || '-' }}</el-descriptions-item>
<el-descriptions-item label="标准指导价">
<b>{{ currentDetail.standard_price?.toFixed(2) }}</b>
</el-descriptions-item>
<el-descriptions-item label="当前库存">
{{ currentDetail.stock_qty }} {{ currentDetail.unit }}
</el-descriptions-item>
<el-descriptions-item label="预警阈值">{{ currentDetail.warning_threshold }} {{ currentDetail.unit }}</el-descriptions-item>
<el-descriptions-item label="创建时间">{{ currentDetail.created_at }}</el-descriptions-item>
<el-descriptions-item label="更新时间">{{ currentDetail.updated_at }}</el-descriptions-item>
</el-descriptions>
</el-drawer>
<!-- 库存变更弹窗 -->
<el-dialog v-model="invDialogVisible" title="库存变更" width="500px" destroy-on-close>
<el-alert
:title="`当前操作:${currentInvSku.name}${currentInvSku.sku_code})— 现有库存 ${currentInvSku.stock_qty} ${currentInvSku.unit || '件'}`"
type="info" show-icon :closable="false" style="margin-bottom:20px"
/>
<el-form ref="invFormRef" :model="invForm" :rules="invRules" label-width="90px">
<el-form-item label="变更方向">
<el-radio-group v-model="invForm.direction" @change="invForm.reason = invForm.direction === 'in' ? 'purchase' : 'shipment'">
<el-radio-button value="in">📦 入库</el-radio-button>
<el-radio-button value="out">📤 出库</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item label="变动原因" prop="reason">
<el-select v-model="invForm.reason" style="width:100%">
<template v-if="invForm.direction === 'in'">
<el-option v-for="r in inReasons" :key="r.value" :label="r.label" :value="r.value" />
</template>
<template v-else>
<el-option v-for="r in outReasons" :key="r.value" :label="r.label" :value="r.value" />
</template>
</el-select>
</el-form-item>
<el-form-item label="变更数量" prop="qty">
<el-input-number v-model="invForm.qty" :min="1" :max="99999" controls-position="right" style="width:100%" />
</el-form-item>
<el-form-item label="操作备注">
<el-input v-model="invForm.remark" type="textarea" :rows="2" placeholder="非必填,记录特殊单号或说明" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="invDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="invSubmitting" @click="submitInventory">确认变更</el-button>
</template>
</el-dialog>
<!-- 批量导入弹窗 -->
<el-dialog v-model="importDialogVisible" title="批量导入产品 SKU" width="400px" destroy-on-close>
<div style="margin-bottom: 20px; line-height: 1.6;">
<p>1. 请先下载标准模板按要求填写数据</p>
<p>2. 仅支持 .xlsx 格式文件</p>
<p>3. 系统将根据 SKU 编码自动防重</p>
<p style="margin-top: 10px;">
<a href="/api/templates/product_import_template.xlsx" target="_blank" style="color: #409eff; text-decoration: none;">
点击下载产品导入模板.xlsx
</a>
</p>
</div>
<el-upload
drag
action="/api/products/import"
:headers="getUploadHeaders()"
accept=".xlsx,.xls"
:show-file-list="false"
:on-success="handleImportSuccess"
:on-error="handleImportError"
>
<el-icon class="el-icon--upload"><Upload /></el-icon>
<div class="el-upload__text">将文件拖到此处 <em>点击上传</em></div>
</el-upload>
</el-dialog>
</div>
</template>
<style scoped>
.product-management-wrapper { height: 100%; }
.page-row { height: 100%; }
.tree-card { height: calc(100vh - 100px); border-radius: 8px; border: none; box-shadow: 0 1px 4px rgba(0,21,41,0.08); overflow-y: auto; }
.card-header { display: flex; justify-content: space-between; align-items: center; }
.card-header .tree-title { font-weight: bold; font-size: 15px; color: #303133; }
.filter-tree { margin-top: -10px; }
/* 树节点 — 悬浮显示操作按钮 */
.tree-node { display: flex; align-items: center; justify-content: space-between; width: 100%; padding-right: 4px; }
.tree-node-label { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.tree-node-actions { display: none; flex-shrink: 0; margin-left: 6px; }
.tree-node:hover .tree-node-actions { display: inline-flex; gap: 4px; }
.tree-action-icon { font-size: 14px; cursor: pointer; color: #909399; transition: color 0.2s; }
.tree-action-icon:hover { color: #409eff; }
.tree-action-icon.danger:hover { color: #f56c6c; }
.right-col { display: flex; flex-direction: column; gap: 15px; }
.filter-card { border-radius: 8px; border: none; box-shadow: 0 1px 4px rgba(0,21,41,0.08); }
.filter-wrapper { display: flex; justify-content: space-between; align-items: center; }
.filter-form .el-form-item { margin-bottom: 0; }
.table-card { flex: 1; border-radius: 8px; border: none; box-shadow: 0 1px 4px rgba(0,21,41,0.08); }
.sku-bold { font-family: Consolas, 'Courier New', monospace; font-weight: bold; color: #409eff; }
.normal-stock { font-weight: bold; color: #606266; }
</style>
+517
View File
@@ -0,0 +1,517 @@
<script setup lang="ts">
/**
* 系统设置页 —— 完整 CRUD 交互
* Tab1: 部门树 + 员工管理(新增/编辑弹窗、重置密码、启停)
* Tab2: 角色管理(新增/编辑弹窗 + 权限分配面板 + JSONB menu_keys
*/
import { ref, reactive, onMounted, nextTick } from 'vue'
import { Plus, Edit, Key, Lock, Setting, User, Operation, Check } from '@element-plus/icons-vue'
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
import request from '@/api/request'
const activeTab = ref('users')
const loading = ref(false)
// ==========================================
// Tab 1: 部门与员工管理
// ==========================================
// --- 左侧:部门树 ---
const orgTreeData = ref<any[]>([])
const defaultProps = { children: 'children', label: 'name' }
const currentDeptId = ref<string | null>(null)
const currentDeptName = ref('全部部门')
const fetchDeptTree = async () => {
try {
const data = await request.get('/api/settings/departments/tree')
orgTreeData.value = Array.isArray(data) ? data : []
} catch (e) { console.warn('[Settings] fetchDeptTree error:', e) }
}
const handleNodeClick = (data: any) => {
currentDeptId.value = data.id
currentDeptName.value = data.name
fetchUsers()
}
// --- 扁平化部门列表(供 el-select 使用)---
const flatDepts = ref<any[]>([])
const flattenDepts = (nodes: any[], result: any[] = [], depth = 0) => {
for (const n of nodes) {
result.push({ id: n.id, name: ' '.repeat(depth) + n.name })
if (n.children?.length) flattenDepts(n.children, result, depth + 1)
}
return result
}
// --- 右侧:员工大表 ---
const userSearchInput = ref('')
const userList = ref<any[]>([])
const userTotal = ref(0)
const userPage = ref(1)
const userPageSize = ref(20)
const fetchUsers = async () => {
loading.value = true
try {
const params: Record<string, any> = { page: userPage.value, size: userPageSize.value }
if (currentDeptId.value) params.dept_id = currentDeptId.value
if (userSearchInput.value) params.keyword = userSearchInput.value
const data: any = await request.get('/api/settings/users', { params })
userList.value = (data?.items || []).map((u: any) => ({ ...u, status: u.status === 1 }))
userTotal.value = data?.total || 0
} catch (e) { console.warn('[Settings] fetchUsers error:', e) }
finally { loading.value = false }
}
const handleUserSearch = () => { userPage.value = 1; fetchUsers() }
const toggleUserStatus = async (row: any) => {
try {
const newStatus = row.status ? 1 : 0
await request.put(`/api/settings/users/${row.id}`, { status: newStatus })
ElMessage.success(`账号 [${row.username}] 已${row.status ? '启用' : '停用'}`)
} catch { row.status = !row.status }
}
const resetPassword = (row: any) => {
ElMessageBox.confirm(
`确定要将员工 [${row.real_name || row.username}] 的密码重置为默认密码 (123456) 吗?`, '警告', { type: 'warning' }
).then(async () => {
try {
await request.put(`/api/settings/users/${row.id}/reset-password`, { new_password: '123456' })
ElMessage.success(`员工 [${row.real_name || row.username}] 密码已重置!`)
} catch { /* 已统一 */ }
}).catch(() => {})
}
// --- 员工新增/编辑弹窗 ---
const userDialogVisible = ref(false)
const userDialogTitle = ref('新增员工')
const userFormRef = ref<FormInstance>()
const userSubmitting = ref(false)
const userForm = reactive({
id: '' as string,
username: '',
password: '',
real_name: '',
phone: '',
email: '',
dept_id: null as string | null,
role_id: null as string | null,
})
const isEditUser = ref(false)
const userFormRules = reactive<FormRules>({
username: [{ required: true, message: '请输入登录账号', trigger: 'blur' }],
password: [{ required: true, message: '请输入初始密码', trigger: 'blur' }, { min: 6, message: '密码至少6位', trigger: 'blur' }],
real_name: [{ required: true, message: '请输入员工姓名', trigger: 'blur' }],
})
const openAddUser = () => {
isEditUser.value = false
userDialogTitle.value = '新增员工'
Object.assign(userForm, { id: '', username: '', password: '', real_name: '', phone: '', email: '', dept_id: null, role_id: null })
userDialogVisible.value = true
nextTick(() => userFormRef.value?.clearValidate())
}
const openEditUser = (row: any) => {
isEditUser.value = true
userDialogTitle.value = '编辑员工'
Object.assign(userForm, {
id: row.id,
username: row.username,
password: '',
real_name: row.real_name || '',
phone: row.phone || '',
email: row.email || '',
dept_id: row.dept_id || null,
role_id: row.role_id || null,
})
userDialogVisible.value = true
nextTick(() => userFormRef.value?.clearValidate())
}
const submitUser = async () => {
const valid = await userFormRef.value?.validate().catch(() => false)
if (!valid) return
userSubmitting.value = true
try {
if (isEditUser.value) {
const { id, password, username, ...payload } = userForm
await request.put(`/api/settings/users/${id}`, payload)
ElMessage.success('员工信息已更新')
} else {
await request.post('/api/settings/users', userForm)
ElMessage.success('员工账号创建成功')
}
userDialogVisible.value = false
fetchUsers()
} catch { /* 已统一 */ }
finally { userSubmitting.value = false }
}
// ==========================================
// Tab 2: 角色与权限控制
// ==========================================
const rolesList = ref<any[]>([])
const currentRole = ref<any>({})
const fetchRoles = async () => {
try {
const data = await request.get('/api/settings/roles')
const arr = Array.isArray(data) ? data : []
rolesList.value = arr.map((r: any) => ({
id: r.id, name: r.role_name, desc: r.description || '',
data_scope: r.data_scope, menu_keys: r.menu_keys || [], status: r.status,
}))
if (rolesList.value.length > 0 && !currentRole.value?.id) {
selectRole(rolesList.value[0])
}
} catch (e) { console.warn('[Settings] fetchRoles error:', e) }
}
const selectRole = (role: any) => {
currentRole.value = role
dataScope.value = role.data_scope || 'self'
// 延迟设置 menu_keys 勾选(组件可能还未渲染,需安全访问)
nextTick(() => {
try { menuTreeRef.value?.setCheckedKeys(role.menu_keys || [], false) } catch { /* tree not ready */ }
})
}
// --- 权限面板 ---
const dataScopeOptions = [
{ label: '全部数据 (最高权限)', value: 'all' },
{ label: '本部门及下属部门数据', value: 'dept_and_sub' },
{ label: '仅本人数据', value: 'self' }
]
const dataScope = ref('all')
const menuPermissionsData = [
{ id: 'dashboard', label: '🏠 工作台 (Dashboard)' },
{
id: 'sales', label: '💼 业务线 (Sales)',
children: [
{ id: 'CustomerList', label: '👥 客户管理' },
{ id: 'OrderList', label: '📄 订单管理' },
{ id: 'ShippingList', label: '🚚 发货记录' },
]
},
{ id: 'supply', label: '📦 供应链 (Supply)', children: [{ id: 'ProductList', label: '📦 产品与库存' }] },
{ id: 'finance', label: '💰 财务管理 (Finance)', children: [{ id: 'FinanceList', label: '🎫 发票与报销' }] },
{ id: 'settings', label: '⚙️ 系统设置 (Settings)' },
]
const menuTreeRef = ref<any>(null)
const savePermissions = async () => {
if (!currentRole.value?.id) return
try {
const checkedKeys = menuTreeRef.value?.getCheckedKeys(false) || []
await request.put(`/api/settings/roles/${currentRole.value.id}`, {
data_scope: dataScope.value,
menu_keys: checkedKeys,
})
currentRole.value.data_scope = dataScope.value
currentRole.value.menu_keys = checkedKeys
ElMessage.success(`角色 [${currentRole.value.name}] 的权限配置保存成功!`)
} catch { /* 已统一 */ }
}
// --- 角色新增/编辑弹窗 ---
const roleDialogVisible = ref(false)
const roleDialogTitle = ref('新增角色')
const roleFormRef = ref<FormInstance>()
const roleSubmitting = ref(false)
const roleForm = reactive({
id: '' as string,
role_name: '',
data_scope: 'self',
description: '',
})
const isEditRole = ref(false)
const roleFormRules = reactive<FormRules>({
role_name: [{ required: true, message: '请输入角色名称', trigger: 'blur' }],
})
const openAddRole = () => {
isEditRole.value = false
roleDialogTitle.value = '新增角色'
Object.assign(roleForm, { id: '', role_name: '', data_scope: 'self', description: '' })
roleDialogVisible.value = true
nextTick(() => roleFormRef.value?.clearValidate())
}
const openEditRole = () => {
if (!currentRole.value?.id) return
isEditRole.value = true
roleDialogTitle.value = '编辑角色'
Object.assign(roleForm, {
id: currentRole.value.id,
role_name: currentRole.value.name,
data_scope: currentRole.value.data_scope || 'self',
description: currentRole.value.desc || '',
})
roleDialogVisible.value = true
nextTick(() => roleFormRef.value?.clearValidate())
}
const submitRole = async () => {
const valid = await roleFormRef.value?.validate().catch(() => false)
if (!valid) return
roleSubmitting.value = true
try {
if (isEditRole.value) {
const { id, ...payload } = roleForm
await request.put(`/api/settings/roles/${id}`, payload)
ElMessage.success('角色信息已更新')
} else {
const { id, ...payload } = roleForm
await request.post('/api/settings/roles', payload)
ElMessage.success('角色创建成功')
}
roleDialogVisible.value = false
await fetchRoles()
} catch { /* 已统一 */ }
finally { roleSubmitting.value = false }
}
// ==========================================
// 初始化
// ==========================================
onMounted(async () => {
// 独立 await 避免一个接口失败导致全部阻塞
await fetchDeptTree()
flatDepts.value = flattenDepts(orgTreeData.value)
await fetchUsers()
await fetchRoles()
})
</script>
<template>
<div class="settings-wrapper">
<el-card shadow="never" class="main-card">
<el-tabs v-model="activeTab" class="settings-tabs">
<!-- ============================================== -->
<!-- Tab 1: 部门与员工管理 -->
<!-- ============================================== -->
<el-tab-pane name="users">
<template #label>
<span class="custom-tabs-label"><el-icon><User /></el-icon><span>部门与员工管理</span></span>
</template>
<el-row :gutter="20">
<!-- 左侧部门树 -->
<el-col :span="5">
<el-card shadow="never" class="tree-card">
<template #header><div class="card-header-bold">组织架构</div></template>
<el-tree :data="orgTreeData" :props="defaultProps" default-expand-all highlight-current node-key="id" @node-click="handleNodeClick" />
</el-card>
</el-col>
<!-- 右侧员工表 -->
<el-col :span="19">
<el-card shadow="never" class="user-table-card">
<div class="table-toolbar">
<div class="toolbar-left">
<span class="dept-title">{{ currentDeptName }} 员工名单</span>
</div>
<div class="toolbar-right">
<el-input v-model="userSearchInput" placeholder="姓名 / 手机号" prefix-icon="Search" style="width: 200px; margin-right: 15px;" clearable @keyup.enter="handleUserSearch" @clear="handleUserSearch" />
<el-button type="primary" :icon="Plus" @click="openAddUser">新增员工</el-button>
</div>
</div>
<el-table :data="userList" stripe border style="width: 100%" v-loading="loading">
<el-table-column prop="real_name" label="员工姓名" width="120">
<template #default="scope"><b>{{ scope.row.real_name || '-' }}</b></template>
</el-table-column>
<el-table-column prop="username" label="登录账号" width="140" />
<el-table-column prop="dept_name" label="所属部门" width="150" />
<el-table-column label="分配角色" min-width="160">
<template #default="scope">
<el-tag :type="scope.row.role_name?.includes('管理') ? 'danger' : 'primary'" effect="plain" v-if="scope.row.role_name">{{ scope.row.role_name }}</el-tag>
<span v-else style="color: #909399;">未分配</span>
</template>
</el-table-column>
<el-table-column label="数据权限" width="130">
<template #default="scope">
<el-tag :type="scope.row.data_scope === 'all' ? 'danger' : scope.row.data_scope === 'dept_and_sub' ? 'warning' : 'info'" size="small" v-if="scope.row.data_scope">
{{ scope.row.data_scope === 'all' ? '全部' : scope.row.data_scope === 'dept_and_sub' ? '本部门' : '仅本人' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="登录状态" width="100" align="center">
<template #default="scope">
<el-switch v-model="scope.row.status" style="--el-switch-on-color: #13ce66; --el-switch-off-color: #ff4949" @change="toggleUserStatus(scope.row)" />
</template>
</el-table-column>
<el-table-column label="安全与操作" width="220" align="center" fixed="right">
<template #default="scope">
<el-button type="primary" link :icon="Edit" @click="openEditUser(scope.row)">编辑</el-button>
<el-button type="danger" link :icon="Key" @click="resetPassword(scope.row)">重置密码</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination v-if="userTotal > userPageSize" style="margin-top: 16px; justify-content: flex-end;" background layout="total, prev, pager, next" :total="userTotal" :page-size="userPageSize" v-model:current-page="userPage" @current-change="fetchUsers" />
</el-card>
</el-col>
</el-row>
</el-tab-pane>
<!-- ============================================== -->
<!-- Tab 2: 角色与权限控制 -->
<!-- ============================================== -->
<el-tab-pane name="roles">
<template #label>
<span class="custom-tabs-label"><el-icon><Lock /></el-icon><span>角色与权限控制 (RBAC)</span></span>
</template>
<el-row :gutter="20">
<!-- 左侧角色列表 -->
<el-col :span="6">
<el-card shadow="never" class="roles-card">
<template #header>
<div style="display:flex; justify-content:space-between; align-items:center;">
<span class="card-header-bold">平台运营角色</span>
<el-button type="primary" link :icon="Plus" @click="openAddRole">新增角色</el-button>
</div>
</template>
<div class="role-list">
<div v-for="role in rolesList" :key="role.id" class="role-item" :class="{ 'is-active': currentRole.id === role.id }" @click="selectRole(role)">
<div class="role-name">{{ role.name }}</div>
<div class="role-desc">{{ role.desc }}</div>
</div>
<el-empty v-if="rolesList.length === 0" description="暂无角色" :image-size="60" />
</div>
</el-card>
</el-col>
<!-- 右侧权限分配面板 -->
<el-col :span="18">
<el-card shadow="never" class="perm-card">
<template #header>
<div class="perm-header">
<div class="card-header-bold">
角色配置<span style="color:#409EFF">{{ currentRole.name || '请选择角色' }}</span>
<el-button v-if="currentRole.id" type="primary" link :icon="Edit" style="margin-left: 10px;" @click="openEditRole">编辑基础信息</el-button>
</div>
<el-button type="success" :icon="Check" @click="savePermissions" :disabled="!currentRole.id">保存当前权限设置</el-button>
</div>
</template>
<div class="perm-section">
<div class="section-title"><el-icon><Operation /></el-icon> 数据权限穿透范围 (Data Scope)</div>
<el-form label-position="top">
<el-form-item>
<el-select v-model="dataScope" style="width: 400px">
<el-option v-for="item in dataScopeOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
<div class="tip-text">控制该角色在查看客户订单发票等数据时的横向与纵向范围限制</div>
</el-form-item>
</el-form>
</div>
<el-divider />
<div class="perm-section">
<div class="section-title"><el-icon><Setting /></el-icon> 侧边栏模块与按钮级访问权限 (Menu Permissions)</div>
<el-tree ref="menuTreeRef" :data="menuPermissionsData" show-checkbox default-expand-all node-key="id" :default-checked-keys="currentRole.menu_keys || []" class="perm-tree" />
</div>
</el-card>
</el-col>
</el-row>
</el-tab-pane>
</el-tabs>
</el-card>
<!-- ============================================== -->
<!-- 弹窗新增/编辑员工 -->
<!-- ============================================== -->
<el-dialog v-model="userDialogVisible" :title="userDialogTitle" width="520px" destroy-on-close>
<el-form ref="userFormRef" :model="userForm" :rules="userFormRules" label-width="80px">
<el-form-item label="登录账号" prop="username">
<el-input v-model="userForm.username" placeholder="用于登录系统" :disabled="isEditUser" />
</el-form-item>
<el-form-item label="初始密码" prop="password" v-if="!isEditUser">
<el-input v-model="userForm.password" type="password" show-password placeholder="至少6位" />
</el-form-item>
<el-form-item label="员工姓名" prop="real_name">
<el-input v-model="userForm.real_name" placeholder="真实姓名" />
</el-form-item>
<el-form-item label="手机号">
<el-input v-model="userForm.phone" placeholder="手机号码" />
</el-form-item>
<el-form-item label="邮箱">
<el-input v-model="userForm.email" placeholder="邮箱地址" />
</el-form-item>
<el-form-item label="所属部门">
<el-select v-model="userForm.dept_id" placeholder="选择部门" clearable style="width: 100%">
<el-option v-for="d in flatDepts" :key="d.id" :label="d.name" :value="d.id" />
</el-select>
</el-form-item>
<el-form-item label="分配角色">
<el-select v-model="userForm.role_id" placeholder="选择角色" clearable style="width: 100%">
<el-option v-for="r in rolesList" :key="r.id" :label="r.name" :value="r.id" />
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="userDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="userSubmitting" @click="submitUser">{{ isEditUser ? '保存修改' : '确定创建' }}</el-button>
</template>
</el-dialog>
<!-- ============================================== -->
<!-- 弹窗新增/编辑角色 -->
<!-- ============================================== -->
<el-dialog v-model="roleDialogVisible" :title="roleDialogTitle" width="480px" destroy-on-close>
<el-form ref="roleFormRef" :model="roleForm" :rules="roleFormRules" label-width="80px">
<el-form-item label="角色名称" prop="role_name">
<el-input v-model="roleForm.role_name" placeholder="如:工业油销售总监" />
</el-form-item>
<el-form-item label="数据权限">
<el-select v-model="roleForm.data_scope" style="width: 100%">
<el-option v-for="item in dataScopeOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
<el-form-item label="角色描述">
<el-input v-model="roleForm.description" type="textarea" :rows="2" placeholder="可选描述" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="roleDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="roleSubmitting" @click="submitRole">{{ isEditRole ? '保存修改' : '确定创建' }}</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.settings-wrapper { height: 100%; }
.main-card { border-radius: 8px; border: none; box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08); min-height: calc(100vh - 100px); }
.settings-tabs :deep(.el-tabs__item) { font-size: 15px; height: 50px; line-height: 50px; }
.custom-tabs-label { display: flex; align-items: center; gap: 6px; font-weight: bold; }
.card-header-bold { font-weight: bold; font-size: 15px; color: #303133; }
.tree-card, .user-table-card, .roles-card, .perm-card { border-radius: 6px; min-height: 600px; }
.table-toolbar { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }
.dept-title { font-size: 16px; font-weight: bold; color: #409EFF; border-left: 4px solid #409EFF; padding-left: 10px; }
.toolbar-right { display: flex; align-items: center; }
.role-list { display: flex; flex-direction: column; gap: 10px; }
.role-item { padding: 15px; border: 1px solid #EBEEF5; border-radius: 4px; cursor: pointer; transition: all 0.3s; }
.role-item:hover { box-shadow: 0 2px 12px 0 rgba(0,0,0,0.1); }
.role-item.is-active { border-color: #409EFF; background-color: #ecf5ff; }
.role-name { font-weight: bold; font-size: 14px; margin-bottom: 6px; color: #303133; }
.role-desc { font-size: 12px; color: #909399; }
.perm-header { display: flex; justify-content: space-between; align-items: center; }
.section-title { font-size: 15px; font-weight: bold; margin-bottom: 15px; display: flex; align-items: center; gap: 8px; color: #303133; }
.tip-text { font-size: 12px; color: #909399; margin-top: 8px; }
.perm-tree { margin-top: 10px; background: #f8f9fa; padding: 15px; border-radius: 4px; border: 1px solid #ebeef5; }
</style>
+164
View File
@@ -0,0 +1,164 @@
<script setup lang="ts">
/**
* 发货大盘 —— 全真实 API 驱动
* GET /api/shipping 列表 + 详情查看
*/
import { ref, reactive, onMounted } from 'vue'
import { Search, View } from '@element-plus/icons-vue'
import request from '@/api/request'
// ════════════════════════ 列表 ════════════════════════
const loading = ref(false)
const shippingList = ref<any[]>([])
const total = ref(0)
const page = ref(1)
const pageSize = ref(20)
const searchForm = reactive({ order_no: '', tracking_no: '' })
const fetchList = async () => {
loading.value = true
try {
const params: Record<string, any> = { page: page.value, size: pageSize.value }
if (searchForm.order_no) params.order_no = searchForm.order_no
if (searchForm.tracking_no) params.tracking_no = searchForm.tracking_no
const data: any = await request.get('/api/shipping', { params })
shippingList.value = data?.items || []
total.value = data?.total || 0
} catch {}
finally { loading.value = false }
}
const handleSearch = () => { page.value = 1; fetchList() }
const handlePageChange = (p: number) => { page.value = p; fetchList() }
const handleSizeChange = (s: number) => { pageSize.value = s; page.value = 1; fetchList() }
const statusTagType = (s: string) => ({ transit: 'primary', delivered: 'success', pending: 'info' }[s] || 'info')
const statusLabel = (s: string) => ({ transit: '运输中', delivered: '已签收', pending: '待揽收' }[s] || s)
// ════════════════════════ 详情 ════════════════════════
const detailVisible = ref(false)
const detailLoading = ref(false)
const currentShip = ref<any>({})
const openDetail = async (row: any) => {
detailVisible.value = true
detailLoading.value = true
try {
// 通过订单发货轨迹接口获取含明细的完整数据
const data: any = await request.get(`/api/shipping/order/${row.order_id}`)
const matched = (data?.shipments || []).find((s: any) => s.id === row.id)
currentShip.value = matched || row
} catch {}
finally { detailLoading.value = false }
}
// ════════════════════════ Init ════════════════════════
onMounted(fetchList)
</script>
<template>
<div class="shipping-wrapper">
<!-- 1. 筛选 -->
<el-card shadow="never" class="filter-card">
<div class="filter-wrapper">
<el-form :inline="true" :model="searchForm" class="filter-form">
<el-form-item label="订单号">
<el-input v-model="searchForm.order_no" placeholder="模糊搜索" clearable style="width:180px" @keyup.enter="handleSearch" />
</el-form-item>
<el-form-item label="物流单号">
<el-input v-model="searchForm.tracking_no" placeholder="搜索快递单号" clearable style="width:180px" @keyup.enter="handleSearch" />
</el-form-item>
<el-form-item>
<el-button type="primary" :icon="Search" @click="handleSearch">检索</el-button>
</el-form-item>
</el-form>
</div>
</el-card>
<!-- 2. 发货单列表 -->
<el-card shadow="never" class="table-card">
<el-table :data="shippingList" v-loading="loading" stripe border style="width:100%" height="calc(100vh - 250px)">
<el-table-column prop="shipping_no" label="发货单号" width="200" fixed="left">
<template #default="{ row }"><span class="shipping-id-bold">{{ row.shipping_no }}</span></template>
</el-table-column>
<el-table-column prop="order_no" label="关联订单" width="200">
<template #default="{ row }"><span class="order-id">{{ row.order_no }}</span></template>
</el-table-column>
<el-table-column prop="customer_name" label="客户名称" min-width="160" show-overflow-tooltip />
<el-table-column prop="carrier" label="物流公司" width="140">
<template #default="{ row }">{{ row.carrier || '-' }}</template>
</el-table-column>
<el-table-column prop="tracking_no" label="物流单号" width="180">
<template #default="{ row }">{{ row.tracking_no || '-' }}</template>
</el-table-column>
<el-table-column label="状态" width="100" align="center">
<template #default="{ row }">
<el-tag :type="(statusTagType(row.status) as any)" effect="dark" size="small">{{ statusLabel(row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="ship_date" label="发货日期" width="120" align="center" />
<el-table-column prop="operator_name" label="操作人" width="100" align="center" />
<el-table-column prop="created_at" label="创建时间" min-width="160" show-overflow-tooltip />
<el-table-column label="操作" width="100" fixed="right">
<template #default="{ row }">
<el-button type="primary" link :icon="View" @click="openDetail(row)">详情</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
style="margin-top:16px; justify-content:flex-end" background
layout="total, sizes, prev, pager, next"
:total="total" :page-size="pageSize" :current-page="page" :page-sizes="[10, 20, 50]"
@current-change="handlePageChange" @size-change="handleSizeChange"
/>
</el-card>
<!-- 3. 发货详情 Drawer -->
<el-drawer v-model="detailVisible" title="发货单详情" size="550px">
<div v-loading="detailLoading">
<template v-if="currentShip.id">
<el-descriptions :column="2" border>
<el-descriptions-item label="发货单号"><span class="shipping-id-bold">{{ currentShip.shipping_no }}</span></el-descriptions-item>
<el-descriptions-item label="关联订单">{{ currentShip.order_no || '-' }}</el-descriptions-item>
<el-descriptions-item label="客户名称">{{ currentShip.customer_name || '-' }}</el-descriptions-item>
<el-descriptions-item label="物流公司">{{ currentShip.carrier || '-' }}</el-descriptions-item>
<el-descriptions-item label="物流单号">{{ currentShip.tracking_no || '-' }}</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag :type="(statusTagType(currentShip.status) as any)" effect="dark" size="small">{{ statusLabel(currentShip.status) }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="发货日期">{{ currentShip.ship_date }}</el-descriptions-item>
<el-descriptions-item label="操作人">{{ currentShip.operator_name || '-' }}</el-descriptions-item>
<el-descriptions-item label="备注">{{ currentShip.remark || '-' }}</el-descriptions-item>
</el-descriptions>
<el-divider content-position="left">📦 发货明细</el-divider>
<el-table :data="currentShip.items || []" border stripe style="width:100%">
<el-table-column prop="sku_code" label="SKU" width="180">
<template #default="{ row }"><span class="sku-bold">{{ row.sku_code }}</span></template>
</el-table-column>
<el-table-column prop="sku_name" label="产品名称" min-width="160" show-overflow-tooltip />
<el-table-column prop="spec" label="包装规格" width="120">
<template #default="{ row }">{{ row.spec || '-' }}</template>
</el-table-column>
<el-table-column label="发货数量" width="110" align="center">
<template #default="{ row }">{{ row.shipped_qty }} {{ row.unit || '' }}</template>
</el-table-column>
</el-table>
</template>
</div>
</el-drawer>
</div>
</template>
<style scoped>
.shipping-wrapper { display: flex; flex-direction: column; gap: 16px; height: 100%; }
.filter-card { border-radius: 8px; border: none; box-shadow: 0 1px 4px rgba(0,21,41,0.08); }
.filter-wrapper { display: flex; justify-content: space-between; align-items: center; }
.filter-form .el-form-item { margin-bottom: 0; }
.table-card { flex: 1; border-radius: 8px; border: none; box-shadow: 0 1px 4px rgba(0,21,41,0.08); }
.shipping-id-bold { font-family: Consolas, 'Courier New', monospace; font-weight: bold; color: #67c23a; }
.order-id { font-family: Consolas, 'Courier New', monospace; color: #409eff; }
.sku-bold { font-family: Consolas, 'Courier New', monospace; font-weight: bold; color: #409eff; }
</style>
+35
View File
@@ -0,0 +1,35 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"lib": [
"ES2020",
"DOM",
"DOM.Iterable"
],
"moduleResolution": "bundler",
"strict": true,
"jsx": "preserve",
"resolveJsonModule": true,
"isolatedModules": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"baseUrl": ".",
"paths": {
"@/*": [
"src/*"
]
}
},
"include": [
"src/**/*.ts",
"src/**/*.d.ts",
"src/**/*.vue"
],
"references": [
{
"path": "./tsconfig.node.json"
}
]
}
+16
View File
@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "bundler",
"verbatimModuleSyntax": true,
"composite": true,
"declaration": true,
"declarationMap": true,
"strict": true,
"skipLibCheck": true
},
"include": [
"vite.config.ts"
]
}
+23
View File
@@ -0,0 +1,23 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'
// https://vite.dev/config/
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': resolve(__dirname, 'src'), // @ 指向 src 目录
},
},
server: {
port: 5173,
// 开发环境代理:将 /api 请求转发到后端,避免 CORS 问题
proxy: {
'/api': {
target: 'http://127.0.0.1:8000',
changeOrigin: true,
},
},
},
})