2026-07-09 16:18:13 +08:00

977 lines
30 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.

<template>
<div class="page-section active" id="page-consultation-form">
<div class="page-padding">
<div class="title-wrap">
<h1 class="page-title">咨询表单</h1>
</div>
<div class="stats-grid">
<div class="stats-card">
<p class="stats-label">总咨询量</p>
<p class="stats-value stats-value-dark">{{ formatCount(totalCount) }}</p>
</div>
<div class="stats-card">
<p class="stats-label">微信公众号渠道</p>
<p class="stats-value stats-value-green">{{ formatCount(wechatCount) }}</p>
</div>
<div class="stats-card">
<p class="stats-label">官网渠道</p>
<p class="stats-value stats-value-purple">{{ formatCount(webCount) }}</p>
</div>
<div class="stats-card">
<p class="stats-label">待回复</p>
<p class="stats-value stats-value-orange">{{ formatCount(pendingCount) }}</p>
</div>
</div>
<div class="filter-card">
<el-form :model="queryForm" inline class="filter-form">
<el-form-item label="姓名">
<el-input v-model.trim="queryForm.name" size="small" clearable placeholder="请输入姓名" @keyup.enter.native="handleSearch" />
</el-form-item>
<el-form-item label="手机号">
<el-input v-model.trim="queryForm.phone" size="small" clearable placeholder="请输入手机号" @keyup.enter.native="handleSearch" />
</el-form-item>
<el-form-item label="邮箱">
<el-input v-model.trim="queryForm.email" size="small" clearable placeholder="请输入邮箱" @keyup.enter.native="handleSearch" />
</el-form-item>
<el-form-item label="渠道来源">
<el-select v-model="queryForm.source" size="small" clearable placeholder="全部渠道">
<el-option label="全部渠道" value="" />
<el-option v-for="item in sourceList" :key="item" :label="item" :value="item" />
</el-select>
</el-form-item>
<el-form-item label="回复状态">
<el-select v-model="queryForm.feedback" size="small" clearable placeholder="全部状态">
<el-option label="全部状态" value="" />
<el-option v-for="item in feedbackOptions" :key="item.id" :label="item.name" :value="String(item.id)" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" size="small" @click="handleSearch">查询</el-button>
<el-button size="small" @click="resetSearch">重置</el-button>
</el-form-item>
</el-form>
</div>
<div class="action-bar">
<div class="batch-actions">
<el-button
type="success"
icon="el-icon-download"
size="small"
:loading="exportLoading"
@click="getTableData('1')"
>
导出Excel
</el-button>
<el-button type="primary" size="small" :loading="actionLoading" :disabled="!selectedRows.length" @click="batchUpdateFeedback('1')">
批量标记沟通中
</el-button>
<el-button type="warning" size="small" :loading="actionLoading" :disabled="!selectedRows.length" @click="batchUpdateFeedback('0')">
批量标记待回复
</el-button>
<el-button type="danger" size="small" :loading="actionLoading" :disabled="!selectedRows.length" @click="batchDelete">
批量删除
</el-button>
</div>
<div class="record-count"> {{ totalCount }} 条记录</div>
</div>
<div class="table-card">
<el-table
v-loading="tableLoading"
:data="tableData"
class="consulting-table"
empty-text="暂无咨询数据"
style="width: 100%"
@selection-change="handleSelectionChange"
>
<el-table-column type="selection" width="46" align="center" />
<el-table-column label="ID" width="80" align="center">
<template slot-scope="scope">
{{ getRowNo(scope.$index) }}
</template>
</el-table-column>
<el-table-column prop="name" label="姓名" min-width="90" />
<el-table-column prop="phone" label="手机号" min-width="120" />
<el-table-column prop="email" label="邮箱" min-width="180" show-overflow-tooltip />
<el-table-column label="公司名称/企业规模" min-width="180" show-overflow-tooltip>
<template slot-scope="scope">
<div class="company-cell">
<span class="company-name">{{ scope.row.company || '-' }}</span>
<span class="company-scale">企业规模{{ scope.row.enterprise_type_name || '-' }}</span>
</div>
</template>
</el-table-column>
<el-table-column label="咨询渠道" min-width="120" align="center">
<template slot-scope="scope">
<span :class="['source-tag', getSourceClass(scope.row.source)]">
{{ scope.row.source || '-' }}
</span>
</template>
</el-table-column>
<el-table-column prop="consult_direction_name" label="咨询方向" min-width="220" show-overflow-tooltip />
<el-table-column prop="content" label="需求描述" min-width="220" show-overflow-tooltip />
<el-table-column prop="update_time" label="提交时间" min-width="160" />
<el-table-column label="回复状态" min-width="110" align="center">
<template slot-scope="scope">
<el-tag
size="mini"
effect="plain"
class="feedback-tag"
:type="feedbackTagType(scope.row.feedback)"
@click="toggleFeedback(scope.row)"
>
{{ feedbackText(scope.row) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="110" align="center" fixed="right">
<template slot-scope="scope">
<el-button type="text" size="small" @click="viewUser(scope.row)">详情</el-button>
<el-button type="text" size="small" text="warning" @click="openRemark(scope.row)">备注</el-button>
<!-- <el-button type="text" size="small" class="danger-text" @click="deleteRows([scope.row])">删除</el-button> -->
</template>
</el-table-column>
</el-table>
</div>
<div class="pager-wrap">
<div class="pager-info">显示 {{ pageStart }}-{{ pageEnd }} {{ totalCount }} </div>
<el-pagination
background
layout="prev, pager, next"
:total="totalCount"
:page-size="pageSize"
:current-page.sync="page"
@current-change="handleCurrentChange"
/>
</div>
</div>
<el-dialog
title="咨询详情"
:visible.sync="detailVisible"
width="600px"
append-to-body
custom-class="detail-dialog"
top="8vh"
>
<div v-if="currentRow" class="detail-panel">
<div class="detail-grid">
<div class="detail-item">
<span class="detail-label">姓名</span>
<strong>{{ currentRow.name || '-' }}</strong>
</div>
<div class="detail-item">
<span class="detail-label">联系电话</span>
<strong>{{ currentRow.phone || '-' }}</strong>
</div>
<div class="detail-item">
<span class="detail-label">邮箱</span>
<strong>{{ currentRow.email || '-' }}</strong>
</div>
<div class="detail-item">
<span class="detail-label">公司名称</span>
<strong>{{ currentRow.company || '-' }}</strong>
</div>
<div class="detail-item">
<span class="detail-label">企业类型</span>
<strong>{{ currentRow.enterprise_type_name || currentRow.custom_type_name || '-' }}</strong>
</div>
<div class="detail-item">
<span class="detail-label">咨询渠道</span>
<strong>{{ currentRow.source || '-' }}</strong>
</div>
<div class="detail-item">
<span class="detail-label">提交时间</span>
<strong>{{ currentRow.update_time || currentRow.create_at || '-' }}</strong>
</div>
</div>
<div class="detail-section">
<span class="detail-label">咨询方向</span>
<strong>{{ currentRow.consult_direction_name || '-' }}</strong>
</div>
<div class="detail-section">
<span class="detail-label">需求描述/留言</span>
<div class="detail-content-box">{{ currentRow.content || '-' }}</div>
</div>
<div class="detail-divider"></div>
<div class="detail-section">
<span class="detail-label">回复状态</span>
<el-tag size="small" effect="plain" :type="feedbackTagType(currentRow.feedback)">
{{ feedbackText(currentRow) }}
</el-tag>
</div>
<div class="detail-section">
<span class="detail-label">备注信息</span>
<div class="detail-content-box detail-remark-text">{{ currentRow.remark || '暂无备注' }}</div>
<div class="detail-remark-row">
<el-input
v-model.trim="detailRemarkText"
maxlength="300"
placeholder="添加备注..."
@keyup.enter.native="saveDetailRemark"
/>
<el-button type="primary" class="detail-save-btn" :loading="actionLoading" @click="saveDetailRemark">
保存
</el-button>
</div>
</div>
</div>
</el-dialog>
<el-dialog
title="添加备注"
:visible.sync="remarkVisible"
width="520px"
append-to-body
custom-class="remark-dialog"
:close-on-click-modal="true"
@close="saveRemarkOnClose"
>
<div class="remark-content">
<label class="remark-label">备注内容</label>
<el-input
v-model.trim="remarkText"
type="textarea"
:rows="6"
maxlength="300"
show-word-limit
placeholder="请输入备注信息..."
/>
<p class="remark-tip">点击弹窗外任意位置或关闭弹窗将自动保存备注</p>
</div>
</el-dialog>
</div>
</template>
<script>
import { reqSearchUserInquiry, reqUserInquiryDelete, reqUserInquiryStatusSwitch } from '@/api/ncmatch'
import XLSX from 'xlsx'
export default {
name: 'ConsultingMangement',
data() {
return {
tableData: [],
tableLoading: false,
exportLoading: false,
actionLoading: false,
page: 1,
pageSize: 10,
totalCount: 0,
selectedRows: [],
detailVisible: false,
remarkVisible: false,
remarkText: '',
detailRemarkText: '',
originalRemarkText: '',
currentRow: null,
sourceList: [],
queryForm: {
name: '',
phone: '',
email: '',
source: '',
feedback: ''
},
feedbackOptions: [
{ id: 0, name: '待回复' },
{ id: 1, name: '沟通中' },
{ id: 2, name: '已关闭' },
{ id: 3, name: '号码错误' }
]
}
},
computed: {
pageStart() {
if (!this.tableData.length) return 0
return (this.page - 1) * this.pageSize + 1
},
pageEnd() {
if (!this.tableData.length) return 0
return (this.page - 1) * this.pageSize + this.tableData.length
},
wechatCount() {
return this.tableData.filter(item => /微信|云宝/.test(String(item.source || ''))).length
},
webCount() {
return this.tableData.filter(item => !/微信|云宝/.test(String(item.source || ''))).length
},
pendingCount() {
return this.tableData.filter(item => String(item.feedback) === '0').length
}
},
created() {
this.getTableData('0')
},
methods: {
formatCount(value) {
return Number(value || 0).toLocaleString('en-US')
},
feedbackText(row) {
if (row.feedback_name) return row.feedback_name
const found = this.feedbackOptions.find(item => String(item.id) === String(row.feedback))
return found ? found.name : '待回复'
},
feedbackTagType(feedback) {
const value = String(feedback)
if (value === '2') return 'success'
if (value === '1') return ''
if (value === '3') return 'info'
return 'warning'
},
getSourceClass(source) {
const value = String(source || '')
if (value.includes('公众号')) return 'source-tag-green'
if (value.includes('官网')) return 'source-tag-blue'
return 'source-tag-gray'
},
getRowId(row) {
return row && (row.id || row.inquiry_id || row.user_inquiry_id)
},
buildIds(rows) {
return JSON.stringify(rows.map(row => this.getRowId(row)).filter(Boolean))
},
getRowNo(index) {
return (this.page - 1) * this.pageSize + index + 1
},
buildSearchParams(to_excel) {
return {
url_link: window.location.href.split('#')[0] || window.location.href,
to_excel: String(to_excel),
page: String(this.page),
page_size: String(this.pageSize),
name: (this.queryForm.name || '').trim(),
phone: (this.queryForm.phone || '').trim(),
email: (this.queryForm.email || '').trim(),
source: this.queryForm.source || '',
feedback: this.queryForm.feedback === null || this.queryForm.feedback === undefined
? ''
: String(this.queryForm.feedback)
}
},
extractRows(res) {
const data = (res && res.data) || {}
if (Array.isArray(res && res.data)) return res.data
if (Array.isArray(data.list)) return data.list
if (Array.isArray(data.data)) return data.data
if (Array.isArray(data.rows)) return data.rows
if (Array.isArray(data.records)) return data.records
return []
},
extractTotal(res, listLength) {
const data = (res && res.data) || {}
const total = Number(
data.total_count ||
data.total ||
data.count ||
data.totalCount ||
res.total_count ||
res.total ||
res.count
)
return Number.isFinite(total) && total >= 0 ? total : listLength
},
extractSourceList(res, rows = []) {
const data = (res && res.data) || {}
const fromApi = data.source_list || data.sourceList || res.source_list || res.sourceList
const apiList = Array.isArray(fromApi) ? fromApi : []
const rowList = rows.map(item => item.source)
return [...new Set(
[...this.sourceList, ...apiList, ...rowList]
.map(item => String(item || '').trim())
.filter(Boolean)
)]
},
getTableData(to_excel) {
const params = this.buildSearchParams(to_excel)
if (to_excel === '0') this.tableLoading = true
if (to_excel === '1') this.exportLoading = true
reqSearchUserInquiry(params).then(res => {
if (!res || !res.status) {
this.$message.warning((res && res.msg) || '暂无咨询数据')
return
}
const rows = this.extractRows(res)
if (to_excel === '0') {
this.tableData = rows
this.totalCount = this.extractTotal(res, rows.length)
this.sourceList = this.extractSourceList(res, rows)
this.selectedRows = []
return
}
if (!rows.length) {
this.$message.warning('暂无可导出的数据')
return
}
const wb = XLSX.utils.book_new()
const ws1 = XLSX.utils.json_to_sheet(rows)
XLSX.utils.book_append_sheet(wb, ws1, 'sheet1')
XLSX.writeFile(wb, '咨询列表.xlsx')
this.$message.success('导出成功')
}).catch(() => {
this.$message.error('咨询列表加载失败,请稍后重试')
}).finally(() => {
this.tableLoading = false
this.exportLoading = false
})
},
handleSearch() {
this.page = 1
this.getTableData('0')
},
resetSearch() {
this.queryForm = {
name: '',
phone: '',
email: '',
source: '',
feedback: ''
}
this.page = 1
this.getTableData('0')
},
handleCurrentChange(page) {
this.page = page
this.getTableData('0')
},
handleSelectionChange(rows) {
this.selectedRows = rows
},
async updateInquiryStatus(rows, feedback, remark) {
const ids = this.buildIds(rows)
if (!ids || ids === '[]') {
this.$message.warning('请选择有效数据')
return false
}
const params = {
ids,
feedback: String(feedback)
}
if (remark !== undefined) params.remark = remark
this.actionLoading = true
try {
const res = await reqUserInquiryStatusSwitch(params)
if (res && res.status) {
this.$message.success('操作成功')
this.getTableData('0')
return true
}
this.$message.error((res && res.msg) || '操作失败')
return false
} catch (error) {
this.$message.error('操作失败,请稍后重试')
return false
} finally {
this.actionLoading = false
}
},
toggleFeedback(row) {
const nextFeedback = String(row.feedback) === '0' ? '1' : '0'
this.updateInquiryStatus([row], nextFeedback)
},
batchUpdateFeedback(feedback) {
this.updateInquiryStatus(this.selectedRows, feedback)
},
async deleteRows(rows) {
const ids = this.buildIds(rows)
if (!ids || ids === '[]') {
this.$message.warning('请选择有效数据')
return
}
try {
await this.$confirm('确认删除选中的咨询记录吗?', '提示', {
confirmButtonText: '确认',
cancelButtonText: '取消',
type: 'warning'
})
} catch (error) {
return
}
this.actionLoading = true
try {
const res = await reqUserInquiryDelete({ ids })
if (res && res.status) {
this.$message.success('删除成功')
this.getTableData('0')
return
}
this.$message.error((res && res.msg) || '删除失败')
} catch (error) {
this.$message.error('删除失败,请稍后重试')
} finally {
this.actionLoading = false
}
},
batchDelete() {
this.deleteRows(this.selectedRows)
},
viewUser(row) {
this.currentRow = row
this.detailRemarkText = row.remark || ''
this.detailVisible = true
},
openRemark(row) {
this.currentRow = row
this.remarkText = row.remark || ''
this.originalRemarkText = this.remarkText
this.remarkVisible = true
},
saveRemarkOnClose() {
if (!this.currentRow) return
if (this.remarkText === this.originalRemarkText) return
this.updateInquiryStatus(
[this.currentRow],
this.currentRow.feedback === undefined || this.currentRow.feedback === null ? '0' : this.currentRow.feedback,
this.remarkText
)
},
async saveDetailRemark() {
if (!this.currentRow) return
const success = await this.updateInquiryStatus(
[this.currentRow],
this.currentRow.feedback === undefined || this.currentRow.feedback === null ? '0' : this.currentRow.feedback,
this.detailRemarkText
)
if (success) {
this.currentRow.remark = this.detailRemarkText
}
}
}
}
</script>
<style scoped>
.page-section {
min-height: calc(100vh - 84px);
background: #eef3f9;
}
.page-padding {
padding: 24px;
}
.title-wrap {
margin-bottom: 24px;
}
.page-title {
margin: 0;
color: #1f2937;
font-size: 30px;
font-weight: 700;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 16px;
margin-bottom: 24px;
}
.stats-card {
background: #fff;
border: 1px solid #edf0f5;
border-radius: 14px;
padding: 16px;
}
.stats-label {
margin: 0;
color: #6b7280;
font-size: 13px;
}
.stats-value {
margin: 6px 0 0;
font-size: 34px;
line-height: 1;
font-weight: 700;
}
.stats-value-dark {
color: #1f2937;
}
.stats-value-green {
color: #16a34a;
}
.stats-value-purple {
color: #9333ea;
}
.stats-value-orange {
color: #ea580c;
}
.filter-card {
background: #fff;
border: 1px solid #edf0f5;
border-radius: 14px;
padding: 16px;
margin-bottom: 24px;
}
.filter-form {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
}
.filter-form ::v-deep .el-form-item {
margin-right: 14px;
margin-bottom: 0;
}
.filter-form ::v-deep .el-form-item__label {
color: #4b5563;
font-size: 13px;
}
.filter-form ::v-deep .el-input,
.filter-form ::v-deep .el-select {
width: 180px;
}
.filter-form ::v-deep .el-input__inner {
border-radius: 10px;
}
.filter-form ::v-deep .el-button {
border-radius: 10px;
}
.action-bar {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.batch-actions {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
}
.batch-actions ::v-deep .el-button + .el-button {
margin-left: 0;
}
.record-count {
color: #6b7280;
font-size: 13px;
}
.table-card {
background: #fff;
border: 1px solid #edf0f5;
border-radius: 14px;
overflow: hidden;
}
.consulting-table ::v-deep .el-table__header th {
background: #f9fafb;
color: #4b5563;
font-size: 13px;
font-weight: 600;
}
.consulting-table ::v-deep .el-table__body td {
color: #374151;
font-size: 13px;
}
.consulting-table ::v-deep .el-table__body tr:hover > td {
background: #f9fafb;
}
.company-cell {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.company-name {
color: #1f2937;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.company-scale {
color: #8a94a6;
font-size: 12px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.source-tag {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 52px;
height: 24px;
padding: 0 10px;
border-radius: 999px;
font-size: 12px;
font-weight: 600;
line-height: 24px;
}
.source-tag-green {
color: #15803d;
background: #dcfce7;
}
.source-tag-blue {
color: #1d4ed8;
background: #dbeafe;
}
.source-tag-gray {
color: #64748b;
background: #f1f5f9;
}
.feedback-tag {
cursor: pointer;
user-select: none;
}
.danger-text {
color: #f56c6c;
}
::v-deep .detail-dialog {
border-radius: 24px;
overflow: hidden;
box-shadow: 0 24px 70px rgba(15, 23, 42, 0.22);
}
::v-deep .detail-dialog .el-dialog__header {
padding: 28px 34px 22px;
border-bottom: 1px solid #eef2f7;
}
::v-deep .detail-dialog .el-dialog__title {
color: #111827;
font-size: 24px;
font-weight: 700;
}
::v-deep .detail-dialog .el-dialog__headerbtn {
top: 30px;
right: 34px;
font-size: 22px;
}
::v-deep .detail-dialog .el-dialog__body {
padding: 28px 34px 30px;
}
.detail-panel {
color: #111827;
}
.detail-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 28px 72px;
}
.detail-item {
display: flex;
flex-direction: column;
gap: 6px;
word-break: break-word;
}
.detail-label {
display: block;
margin-bottom: 8px;
color: #8a94a6;
font-size: 14px;
line-height: 1.4;
}
.detail-item strong,
.detail-section strong {
color: #111827;
font-size: 16px;
line-height: 1.5;
font-weight: 700;
}
.detail-section {
margin-top: 28px;
}
.detail-content-box {
min-height: 42px;
padding: 14px 16px;
border-radius: 8px;
background: #f8fafc;
color: #1f2937;
font-size: 15px;
line-height: 1.7;
word-break: break-word;
}
.detail-remark-text {
color: #d97706;
}
.detail-divider {
height: 1px;
margin: 24px 0;
background: #edf1f7;
}
.detail-remark-row {
margin-top: 14px;
display: flex;
align-items: center;
gap: 12px;
}
.detail-remark-row ::v-deep .el-input__inner {
height: 42px;
border-radius: 8px;
}
.detail-save-btn {
width: 72px;
height: 42px;
border: 0;
border-radius: 8px;
background: #9333ea;
font-weight: 600;
}
::v-deep .remark-dialog {
border-radius: 24px;
overflow: hidden;
box-shadow: 0 24px 70px rgba(15, 23, 42, 0.22);
}
::v-deep .remark-dialog .el-dialog__header {
padding: 28px 34px 22px;
border-bottom: 1px solid #eef2f7;
}
::v-deep .remark-dialog .el-dialog__title {
color: #111827;
font-size: 24px;
font-weight: 700;
}
::v-deep .remark-dialog .el-dialog__headerbtn {
top: 30px;
right: 34px;
font-size: 22px;
}
::v-deep .remark-dialog .el-dialog__body {
padding: 28px 34px 30px;
}
.remark-content {
display: flex;
flex-direction: column;
}
.remark-label {
margin-bottom: 12px;
color: #1f2937;
font-size: 16px;
font-weight: 600;
}
.remark-content ::v-deep .el-textarea__inner {
min-height: 150px !important;
padding: 16px 18px;
border: 1px solid #dfe6f0;
border-radius: 12px;
color: #111827;
font-size: 15px;
line-height: 1.7;
resize: vertical;
}
.remark-content ::v-deep .el-textarea__inner::placeholder {
color: #a0aec0;
}
.remark-content ::v-deep .el-input__count {
right: 14px;
bottom: 10px;
color: #9ca3af;
background: transparent;
}
.remark-tip {
margin: 12px 0 0;
color: #8a94a6;
font-size: 13px;
line-height: 1.6;
}
.pager-wrap {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 16px;
}
.pager-info {
color: #6b7280;
font-size: 13px;
}
@media (max-width: 1200px) {
.stats-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 768px) {
.page-padding {
padding: 12px;
}
.stats-grid {
grid-template-columns: 1fr;
}
.pager-wrap {
flex-direction: column;
gap: 8px;
align-items: flex-start;
}
}
</style>