v0.2.0: CRM/ERP 系统升级 - 清理 .gitignore 并移除误提交的 venv/env/db 文件
- 更新 .gitignore:全面覆盖环境变量、数据库、日志、缓存、上传文件 - 移除误跟踪的 server/venv/、crm_data.db、.env 文件 - 新增 server/.env.example 模板 - 新增合同管理、利润核算、AI教练等功能模块 - 新增 Playwright e2e 测试套件 - 前后端多项功能升级和 bug 修复
This commit is contained in:
@@ -0,0 +1,279 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user