Files
license-manager/frontend/src/views/EnterpriseLeads/index.vue
T
2026-02-02 18:47:27 +08:00

496 lines
12 KiB
Vue

<template>
<Layout :page-title="t('enterpriseLeads.title')">
<div class="enterprise-leads">
<div class="stats-grid">
<div v-for="stat in stats" :key="stat.key" class="stat-card">
<div class="stat-info">
<p class="stat-label">{{ t(`enterpriseLeads.stats.${stat.key}`) }}</p>
<p class="stat-value">{{ stat.value.toLocaleString() }}</p>
</div>
<div class="stat-icon" :class="stat.key">
<el-icon><component :is="stat.icon" /></el-icon>
</div>
</div>
</div>
<div class="filter-card">
<div class="filter-row">
<div class="search-group">
<el-input
v-model="filters.keyword"
:placeholder="t('enterpriseLeads.filter.searchPlaceholder')"
class="search-input"
clearable
@keyup.enter="handleFilter"
>
<template #append>
<el-button class="search-btn" @click="handleFilter">
<el-icon><Search /></el-icon>
</el-button>
</template>
</el-input>
</div>
<div class="status-group">
<el-select v-model="filters.status" class="status-select" :placeholder="t('enterpriseLeads.filter.statusPlaceholder')" clearable @change="handleFilter">
<el-option :label="t('enterpriseLeads.filter.allStatus')" value="" />
<el-option :label="t('enterpriseLeads.status.pending')" value="pending" />
<el-option :label="t('enterpriseLeads.status.contacting')" value="contacting" />
<el-option :label="t('enterpriseLeads.status.completed')" value="completed" />
<el-option :label="t('enterpriseLeads.status.rejected')" value="rejected" />
</el-select>
</div>
</div>
</div>
<div class="list-card" v-loading="loading">
<div class="list-header">
<div class="list-title">
<span class="title-bar"></span>
<span>{{ t('enterpriseLeads.table.title') }}</span>
</div>
<el-button class="refresh-btn" :icon="Refresh" text @click="handleRefresh">
{{ t('enterpriseLeads.actions.refresh') }}
</el-button>
</div>
<el-table
:data="tableData"
stripe
class="leads-table"
:header-cell-style="{
backgroundColor: '#E6F7F3',
color: '#4F4F4F',
fontWeight: '600'
}"
>
<el-table-column prop="id" :label="t('enterpriseLeads.table.id')" />
<el-table-column prop="company_name" :label="t('enterpriseLeads.table.company')" />
<el-table-column prop="contact_name" :label="t('enterpriseLeads.table.contact')" width="100" />
<el-table-column prop="contact_phone" :label="t('enterpriseLeads.table.phone')" />
<el-table-column prop="created_at" :label="t('enterpriseLeads.table.submittedAt')" />
<el-table-column :label="t('enterpriseLeads.table.status')" width="80">
<template #default="{ row }">
<span class="status-tag" :class="row.status">{{ t(`enterpriseLeads.status.${row.status}`) }}</span>
</template>
</el-table-column>
<el-table-column :label="t('enterpriseLeads.table.actions')" width="200" fixed="right">
<template #default="{ row }">
<div class="action-buttons">
<el-button size="small" class="btn-view" @click="handleView(row)">{{ t('enterpriseLeads.actions.view') }}</el-button>
<el-button size="small" class="btn-edit" @click="handleEdit(row)">{{ t('enterpriseLeads.actions.edit') }}</el-button>
<el-button size="small" class="btn-delete" @click="handleDelete(row)">{{ t('enterpriseLeads.actions.delete') }}</el-button>
</div>
</template>
</el-table-column>
</el-table>
<div class="pagination-container">
<el-pagination
v-model:current-page="page"
v-model:page-size="pageSize"
:page-sizes="[10, 20, 50, 100]"
layout="prev, pager, next, jumper, sizes, total"
:total="total"
background
/>
</div>
</div>
</div>
<LeadDetailDialog
v-model="detailVisible"
:id="selectedLead?.id"
/>
<LeadEditDialog
v-model="editVisible"
:id="selectedLead?.id"
@save="handleUpdate"
/>
</Layout>
</template>
<script setup lang="ts">
import { ref, onMounted, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import Layout from '@/components/common/layout/Layout.vue'
import { Phone, CircleCheck, Refresh, User, OfficeBuilding, Search } from '@element-plus/icons-vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import LeadDetailDialog from './components/LeadDetailDialog.vue'
import LeadEditDialog from './components/LeadEditDialog.vue'
import { getLeads, getLeadSummary, updateLead, deleteLead, type Lead } from '@/api/lead'
import { formatDateTime } from '@/utils/date'
const { t } = useI18n()
const stats = ref([
{ key: 'total', value: 0, icon: OfficeBuilding, field: 'total_count' },
{ key: 'pending', value: 0, icon: Phone, field: 'pending_count' },
{ key: 'contacting', value: 0, icon: CircleCheck, field: 'contacted_count' },
{ key: 'completed', value: 0, icon: User, field: 'converted_count' }
])
const filters = ref({
keyword: '',
status: ''
})
const tableData = ref<Lead[]>([])
const loading = ref(false)
const page = ref(1)
const pageSize = ref(10)
const total = ref(0)
const detailVisible = ref(false)
const editVisible = ref(false)
const selectedLead = ref<any>(null)
const fetchSummary = async () => {
try {
const res = await getLeadSummary()
if (res.code === '000000' && res.data) {
stats.value.forEach(item => {
if (res.data[item.field] !== undefined) {
item.value = res.data[item.field]
}
})
}
} catch (error) {
console.error('Fetch lead summary error:', error)
}
}
const fetchData = async () => {
loading.value = true
try {
const params = {
page: page.value,
page_size: pageSize.value,
search: filters.value.keyword,
status: filters.value.status
}
const res = await getLeads(params)
if (res.code === '000000' && res.data) {
tableData.value = res.data.leads.map((item: any) => ({
...item,
created_at: formatDateTime(item.created_at)
}))
total.value = res.data.total_count
}
} catch (error: any) {
console.error('Fetch leads error:', error)
ElMessage.error(error.backendMessage || t('enterpriseLeads.messages.fetchError'))
} finally {
loading.value = false
}
}
onMounted(() => {
fetchData()
fetchSummary()
})
watch([page, pageSize], () => {
fetchData()
})
const handleFilter = () => {
page.value = 1
fetchData()
}
const handleRefresh = () => {
fetchData()
fetchSummary()
}
const handleView = (row: any) => {
selectedLead.value = row
detailVisible.value = true
}
const handleEdit = (row: any) => {
selectedLead.value = row
editVisible.value = true
}
const handleDelete = (row: any) => {
ElMessageBox.confirm(
t('enterpriseLeads.messages.deleteConfirm'),
t('common.confirm'),
{
confirmButtonText: t('common.confirm'),
cancelButtonText: t('common.cancel'),
type: 'warning',
}
).then(async () => {
try {
const res = await deleteLead(row.id)
if (res.code === '000000') {
ElMessage.success(t('enterpriseLeads.messages.deleteSuccess'))
fetchData()
fetchSummary()
}
} catch (error: any) {
console.error('Delete lead error:', error)
ElMessage.error(error.backendMessage || t('enterpriseLeads.messages.deleteError'))
}
}).catch(() => {
// Cancelled
})
}
const handleUpdate = async (updatedData: any) => {
try {
const { id, ...data } = updatedData
// 格式化日期为 ISO 8601 格式 (2026-02-05T10:00:00Z),去除毫秒
if (data.follow_up_date) {
data.follow_up_date = new Date(data.follow_up_date).toISOString().replace(/\.\d{3}/, '')
}
const res = await updateLead(id, data)
if (res.code === '000000') {
ElMessage.success(t('enterpriseLeads.messages.updateSuccess'))
fetchData()
fetchSummary()
}
} catch (error: any) {
console.error('Update lead error:', error)
ElMessage.error(error.backendMessage || t('enterpriseLeads.messages.updateError'))
}
}
</script>
<style lang="scss" scoped>
.enterprise-leads {
padding: 24px;
background-color: #f0f2f5;
min-height: calc(100vh - 80px);
}
.stats-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 24px;
margin-bottom: 24px;
}
.stat-card {
background: #fff;
border-radius: 8px;
padding: 20px 24px;
display: flex;
justify-content: space-between;
align-items: center;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.05);
border: none;
height: 100px;
}
.stat-label {
font-size: 14px;
color: #999;
margin-bottom: 8px;
}
.stat-value {
font-size: 28px;
font-weight: bold;
color: #00a870;
line-height: 1.2;
}
.stat-icon {
width: 56px;
height: 56px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
font-size: 28px;
}
.stat-icon.total {
background: #f0f4ff;
color: #409eff;
}
.stat-icon.pending {
background: #fff8e6;
color: #e6a23c;
}
.stat-icon.contacting {
background: #e6f7f3;
color: #00a870;
}
.stat-icon.completed {
background: #f5f0ff;
color: #7c5cfc;
}
.filter-card {
background: #fff;
padding: 24px;
border-radius: 8px;
margin-bottom: 24px;
display: flex;
align-items: center;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.05);
border: none;
}
.filter-row {
width: 100%;
display: flex;
gap: 20px;
align-items: center;
}
.search-group {
width: 320px;
}
.search-input :deep(.el-input__wrapper) {
border-radius: 4px 0 0 4px;
box-shadow: 1px 0 0 0 var(--el-input-border-color) inset, 0 1px 0 0 var(--el-input-border-color) inset, 0 -1px 0 0 var(--el-input-border-color) inset !important;
}
.search-btn {
background: #00a870 !important;
border-color: #00a870 !important;
color: #fff !important;
border-radius: 0 4px 4px 0;
width: 44px;
height: 32px;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
}
.status-select {
width: 160px;
}
.status-select :deep(.el-input__wrapper) {
border-radius: 4px;
}
.list-card {
background: #fff;
border-radius: 8px;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.05);
padding: 24px;
border: none;
}
.list-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
}
.list-title {
display: flex;
align-items: center;
gap: 12px;
font-size: 18px;
font-weight: 600;
color: #333;
}
.title-bar {
width: 4px;
height: 18px;
background: #00a870;
border-radius: 2px;
}
.refresh-btn {
color: #666;
font-size: 14px;
}
.leads-table :deep(.el-table__header th) {
font-weight: 600;
height: 50px;
}
.status-tag {
font-size: 14px;
font-weight: 500;
}
.status-tag.contacting {
color: #409eff;
}
.status-tag.pending {
color: #e6a23c;
}
.status-tag.completed {
color: #333;
}
.status-tag.rejected {
color: #999;
}
.action-buttons {
display: flex;
gap: 8px;
}
.btn-view, .btn-edit, .btn-delete {
border: none;
padding: 4px 12px;
height: 28px;
font-size: 12px;
border-radius: 4px;
}
.btn-view, .btn-edit {
background: #e6f7f3 !important;
color: #00a870 !important;
}
.btn-delete {
background: #fff1f0 !important;
color: #f5222d !important;
}
.pagination-container {
display: flex;
justify-content: flex-end;
margin-top: 24px;
}
.pagination-container :deep(.el-pagination.is-background .el-pager li:not(.is-disabled).is-active) {
background-color: #00a870;
}
@media (max-width: 1400px) {
.stats-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 768px) {
.enterprise-leads {
padding: 16px;
}
.filter-row {
flex-direction: column;
align-items: stretch;
}
.status-select {
width: 100%;
}
}
</style>