Files
crm_project/frontend/src/views/contracts/index.vue
T
hankin 815cbf9d8c v0.2.0: CRM/ERP 系统升级 - 清理 .gitignore 并移除误提交的 venv/env/db 文件
- 更新 .gitignore:全面覆盖环境变量、数据库、日志、缓存、上传文件
- 移除误跟踪的 server/venv/、crm_data.db、.env 文件
- 新增 server/.env.example 模板
- 新增合同管理、利润核算、AI教练等功能模块
- 新增 Playwright e2e 测试套件
- 前后端多项功能升级和 bug 修复
2026-05-11 07:24:19 +00:00

280 lines
12 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>