815cbf9d8c
- 更新 .gitignore:全面覆盖环境变量、数据库、日志、缓存、上传文件 - 移除误跟踪的 server/venv/、crm_data.db、.env 文件 - 新增 server/.env.example 模板 - 新增合同管理、利润核算、AI教练等功能模块 - 新增 Playwright e2e 测试套件 - 前后端多项功能升级和 bug 修复
280 lines
12 KiB
Vue
280 lines
12 KiB
Vue
<script setup lang="ts">
|
||
/**
|
||
* 合同管理列表页
|
||
* GET /api/contracts (分页 + 搜索 + 状态筛选)
|
||
* POST /api/contracts (新增)
|
||
* DELETE /api/contracts/{id} (软删除)
|
||
*/
|
||
import { ref, reactive, onMounted, nextTick, computed } from 'vue'
|
||
import { useRouter } from 'vue-router'
|
||
import { Search, Plus, View, Delete, Document } 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'
|
||
|
||
const router = useRouter()
|
||
const userStore = useUserStore()
|
||
const loading = ref(false)
|
||
|
||
// --- 数据 ---
|
||
const contractData = ref<any[]>([])
|
||
const currentPage = ref(1)
|
||
const pageSize = ref(20)
|
||
const total = ref(0)
|
||
const searchForm = reactive({ keyword: '', status: '' })
|
||
|
||
// --- 搜索/分页 ---
|
||
const fetchContracts = 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.status) params.status = searchForm.status
|
||
const data: any = await request.get('/api/contracts', { params })
|
||
contractData.value = data.items || []
|
||
total.value = data.total || 0
|
||
} catch { /* 统一处理 */ } finally { loading.value = false }
|
||
}
|
||
const handleSearch = () => { currentPage.value = 1; fetchContracts() }
|
||
const handlePageChange = () => fetchContracts()
|
||
|
||
// --- 新增合同弹窗 ---
|
||
const addDialogVisible = ref(false)
|
||
const addFormRef = ref<FormInstance>()
|
||
const addSubmitting = ref(false)
|
||
const skuOptions = ref<any[]>([])
|
||
const customerOptions = ref<any[]>([])
|
||
|
||
const addForm = reactive({
|
||
buyer_customer_id: '',
|
||
payment_terms: '货到付全款',
|
||
shipping_terms: '买方自提',
|
||
delivery_terms: '',
|
||
remark: '',
|
||
items: [{ sku_id: '', qty: 1, unit_price: 0, sub_total: 0 }] as any[],
|
||
})
|
||
|
||
const addFormRules = reactive<FormRules>({
|
||
buyer_customer_id: [{ required: true, message: '请选择买方客户', trigger: 'change' }],
|
||
})
|
||
|
||
const paymentTermsOptions = [
|
||
'预付全款订货', '预付30%订货,到货前付清', '预付50%订货,到货前付清',
|
||
'货到付全款', '开具发票后30天内付款', '开具发票45天付款',
|
||
'开具发票60天付款', '开具发票90天付款',
|
||
]
|
||
const shippingTermsOptions = [
|
||
'买方自提', '卖方免费送达天津指定地点', '卖方免费送达指定地点', '物流发货,运费买方承担',
|
||
]
|
||
|
||
const addContractItem = () => {
|
||
addForm.items.push({ sku_id: '', qty: 1, unit_price: 0, sub_total: 0 })
|
||
}
|
||
const removeContractItem = (index: number) => {
|
||
if (addForm.items.length > 1) addForm.items.splice(index, 1)
|
||
}
|
||
const calcSubTotal = (item: any) => {
|
||
item.sub_total = +(item.qty * item.unit_price).toFixed(2)
|
||
}
|
||
const contractTotal = computed(() => addForm.items.reduce((s: number, i: any) => s + (i.sub_total || 0), 0))
|
||
|
||
const handleSkuChange = (item: any) => {
|
||
const sku = skuOptions.value.find((s: any) => s.id === item.sku_id)
|
||
if (sku) {
|
||
item.unit_price = sku.standard_price || 0
|
||
calcSubTotal(item)
|
||
}
|
||
}
|
||
|
||
const handleAddContract = async () => {
|
||
addForm.buyer_customer_id = ''
|
||
addForm.payment_terms = '货到付全款'
|
||
addForm.shipping_terms = '买方自提'
|
||
addForm.delivery_terms = ''
|
||
addForm.remark = ''
|
||
addForm.items = [{ sku_id: '', qty: 1, unit_price: 0, sub_total: 0 }]
|
||
addDialogVisible.value = true
|
||
// 拉取客户、SKU 和卖方公司信息
|
||
try {
|
||
const [custRes, skuRes, companyRes] = await Promise.all([
|
||
request.get('/api/customers/search', { params: { q: '%' } }),
|
||
request.get('/api/products/skus', { params: { page: 1, size: 100 } }),
|
||
request.get('/api/companies/current'),
|
||
])
|
||
customerOptions.value = (custRes as any) || []
|
||
skuOptions.value = ((skuRes as any)?.items || skuRes) || []
|
||
sellerCompanyName.value = (companyRes as any)?.full_info?.company_name || (companyRes as any)?.name || '当前公司'
|
||
} catch { /* */ }
|
||
}
|
||
|
||
const sellerCompanyName = ref('当前公司')
|
||
|
||
const submitAddContract = async () => {
|
||
addSubmitting.value = true
|
||
try {
|
||
await request.post('/api/contracts', {
|
||
buyer_customer_id: addForm.buyer_customer_id,
|
||
payment_terms: addForm.payment_terms,
|
||
shipping_terms: addForm.shipping_terms,
|
||
delivery_terms: addForm.delivery_terms || null,
|
||
remark: addForm.remark,
|
||
items: addForm.items.filter((i: any) => i.sku_id),
|
||
})
|
||
ElMessage.success('合同创建成功')
|
||
addDialogVisible.value = false
|
||
fetchContracts()
|
||
} catch { /* */ } finally { addSubmitting.value = false }
|
||
}
|
||
|
||
// --- 查看详情 ---
|
||
const viewDetail = (row: any) => {
|
||
router.push({ path: '/contracts/detail', query: { id: row.id } })
|
||
}
|
||
|
||
// --- 删除 ---
|
||
const deleteContract = (row: any) => {
|
||
ElMessageBox.confirm(`确定要删除合同 "${row.contract_no}" 吗?`, '确认删除', {
|
||
confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning',
|
||
}).then(async () => {
|
||
await request.delete(`/api/contracts/${row.id}`)
|
||
ElMessage.success('合同已删除')
|
||
fetchContracts()
|
||
}).catch(() => {})
|
||
}
|
||
|
||
// --- 辅助 ---
|
||
const getStatusType = (s: string) => {
|
||
const map: Record<string, string> = { draft: 'info', active: '', completed: 'success', cancelled: 'danger' }
|
||
return map[s] || 'info'
|
||
}
|
||
const getStatusLabel = (s: string) => {
|
||
const map: Record<string, string> = { draft: '草稿', active: '执行中', completed: '已完成', cancelled: '已取消' }
|
||
return map[s] || s
|
||
}
|
||
|
||
onMounted(() => fetchContracts())
|
||
</script>
|
||
|
||
<template>
|
||
<div class="contract-list-container">
|
||
<!-- 搜索区 -->
|
||
<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.status" placeholder="全部" clearable style="width: 140px">
|
||
<el-option label="草稿" value="draft" />
|
||
<el-option label="执行中" value="active" />
|
||
<el-option label="已完成" value="completed" />
|
||
<el-option label="已取消" value="cancelled" />
|
||
</el-select>
|
||
</el-form-item>
|
||
</el-form>
|
||
<div class="action-buttons">
|
||
<el-button type="primary" :icon="Search" @click="handleSearch">搜索</el-button>
|
||
<el-button type="success" :icon="Plus" @click="handleAddContract">新增合同</el-button>
|
||
</div>
|
||
</div>
|
||
</el-card>
|
||
|
||
<!-- 数据表格 -->
|
||
<el-card shadow="never" class="table-section">
|
||
<el-table :data="contractData" stripe border v-loading="loading">
|
||
<el-table-column prop="contract_no" label="合同编号" width="200" />
|
||
<el-table-column prop="buyer_customer_name" label="买方客户" min-width="200" show-overflow-tooltip />
|
||
<el-table-column prop="seller_company_name" label="卖方公司" min-width="160" show-overflow-tooltip />
|
||
<el-table-column label="合同金额" width="140" align="right">
|
||
<template #default="scope">
|
||
¥{{ (scope.row.total_amount_incl_tax || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2 }) }}
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column prop="payment_terms" label="付款条件" width="200" show-overflow-tooltip />
|
||
<el-table-column label="状态" width="100" align="center">
|
||
<template #default="scope">
|
||
<el-tag :type="getStatusType(scope.row.status)" effect="light">{{ getStatusLabel(scope.row.status) }}</el-tag>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="双签" width="80" align="center">
|
||
<template #default="scope">
|
||
<el-tag v-if="scope.row.is_signed" type="success" effect="plain">已签</el-tag>
|
||
<el-tag v-else type="info" effect="plain">未签</el-tag>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column prop="created_at" label="创建时间" width="170" />
|
||
<el-table-column label="操作" width="180" fixed="right">
|
||
<template #default="scope">
|
||
<el-button type="primary" link :icon="View" @click="viewDetail(scope.row)">详情</el-button>
|
||
<el-button type="danger" link :icon="Delete" @click="deleteContract(scope.row)">删除</el-button>
|
||
</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
<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>
|
||
|
||
<!-- 新增合同弹窗 -->
|
||
<el-dialog v-model="addDialogVisible" title="新增合同" width="780px" destroy-on-close>
|
||
<el-form ref="addFormRef" :model="addForm" :rules="addFormRules" label-width="100px">
|
||
<el-form-item label="买方客户" prop="buyer_customer_id">
|
||
<el-select v-model="addForm.buyer_customer_id" placeholder="请选择客户" filterable style="width: 100%">
|
||
<el-option v-for="c in customerOptions" :key="c.id" :label="c.name" :value="c.id" />
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item label="卖方公司">
|
||
<el-input :model-value="sellerCompanyName" disabled />
|
||
<div style="font-size: 12px; color: #909399; margin-top: 4px;">卖方信息由系统设置中的企业账号配置自动填充</div>
|
||
</el-form-item>
|
||
<el-form-item label="付款条件">
|
||
<el-select v-model="addForm.payment_terms" style="width: 100%">
|
||
<el-option v-for="t in paymentTermsOptions" :key="t" :label="t" :value="t" />
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item label="运费条款">
|
||
<el-select v-model="addForm.shipping_terms" style="width: 100%">
|
||
<el-option v-for="t in shippingTermsOptions" :key="t" :label="t" :value="t" />
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item label="货期">
|
||
<el-input v-model="addForm.delivery_terms" placeholder="如:下单后15个工作日发货" />
|
||
</el-form-item>
|
||
<el-form-item label="备注">
|
||
<el-input v-model="addForm.remark" type="textarea" :rows="2" />
|
||
</el-form-item>
|
||
|
||
<el-divider content-position="left">合同明细</el-divider>
|
||
<div v-for="(item, idx) in addForm.items" :key="idx" style="display: flex; gap: 8px; margin-bottom: 12px; align-items: center;">
|
||
<el-select v-model="item.sku_id" placeholder="选择产品" filterable style="flex: 2" @change="handleSkuChange(item)">
|
||
<el-option v-for="s in skuOptions" :key="s.id" :label="`${s.name} (${s.sku_code})`" :value="s.id" />
|
||
</el-select>
|
||
<el-input-number v-model="item.qty" :min="0.01" :precision="2" style="flex: 1" @change="calcSubTotal(item)" />
|
||
<el-input-number v-model="item.unit_price" :min="0" :precision="2" style="flex: 1" @change="calcSubTotal(item)" />
|
||
<span style="min-width: 80px; text-align: right;">¥{{ item.sub_total.toFixed(2) }}</span>
|
||
<el-button type="danger" link @click="removeContractItem(idx)" :disabled="addForm.items.length <= 1">删除</el-button>
|
||
</div>
|
||
<el-button type="primary" link @click="addContractItem">+ 添加明细行</el-button>
|
||
<div style="margin-top: 12px; text-align: right; font-weight: bold;">合计:¥{{ contractTotal.toFixed(2) }}</div>
|
||
</el-form>
|
||
<template #footer>
|
||
<el-button @click="addDialogVisible = false">取消</el-button>
|
||
<el-button type="primary" :loading="addSubmitting" @click="submitAddContract">确定提交</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.contract-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); }
|
||
.pagination-section { margin-top: 20px; display: flex; justify-content: flex-end; }
|
||
</style>
|