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:
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user