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:
hankin
2026-05-11 07:24:19 +00:00
parent 0f4c6b7924
commit 815cbf9d8c
2526 changed files with 11875 additions and 804148 deletions
+279
View File
@@ -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>