chore: 保留后台当前版本(工单菜单、支付通道配置、聊天相关改动)

UFO 适配开工前的检查点,便于回滚。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
XingQue
2026-07-22 17:24:33 +08:00
parent f41145bc0d
commit 69fe2a51e0
7 changed files with 1497 additions and 142 deletions

View File

@@ -5,7 +5,7 @@
:closable="false"
show-icon
title="俱乐部主数据与小程序/支付/GoEasy 密钥"
description="仅超级管理员可编辑。修改后影响该俱乐部小程序登录、支付回调、IM 等,请谨慎操作。"
description="仅超级管理员可编辑。收款商户与提现商户可分开配置;证书支持直接上传,无需再手填服务器路径。"
class="tip"
/>
@@ -41,7 +41,7 @@
</template>
</el-dialog>
<el-form v-loading="loading" :model="form" label-width="140px" class="config-form">
<el-form v-loading="loading" :model="form" label-width="150px" class="config-form">
<el-divider content-position="left">小程序微信</el-divider>
<el-form-item label="wx_appid">
<el-input v-model="form.wx_appid" placeholder="小程序 AppID" />
@@ -50,31 +50,153 @@
<el-input v-model="form.wx_secret" type="password" show-password placeholder="小程序 Secret" />
</el-form-item>
<el-divider content-position="left">微信支付同一商户V2 收款 / V3 提现 密钥分开配</el-divider>
<el-form-item label="商户号 mch_id">
<el-input v-model="form.mch_id" />
<!-- ========== 收款商户点单/会员 V2 ========== -->
<el-divider content-position="left">收款商户点单 / 会员 / 押金 · V2</el-divider>
<el-alert
type="info"
:closable="false"
show-icon
class="section-tip"
title="专门标记为收款商户:下单走这套;退款应对齐这套商户与证书。"
/>
<el-form-item label="收款商户号 mch_id">
<el-input v-model="form.mch_id" placeholder="微信支付商户号(收款)" />
</el-form-item>
<el-form-item label="pay_app_id">
<el-input v-model="form.pay_app_id" placeholder="可与 wx_appid 相同,或留空" />
</el-form-item>
<el-form-item label="APIv2 密钥 mch_key">
<el-form-item label="收款 APIv2 密钥">
<el-input
v-model="form.mch_key"
type="password"
show-password
placeholder="小程序 JSAPI 收款(会员/点单/押金等)"
placeholder="商户平台 → API安全 → APIv2密钥"
/>
<div class="field-help">微信商户平台 API安全 设置APIv2密钥与下方 APIv3 不是同一个</div>
</el-form-item>
<el-form-item label="APIv3 密钥 api_v3_key">
<el-form-item label="收款私钥 key.pem">
<div class="upload-row">
<el-input v-model="form.pay_key_path" placeholder="上传后自动填入路径" readonly />
<el-upload
:show-file-list="false"
:http-request="(opt) => uploadCert('pay_key', opt)"
accept=".pem,.key"
>
<el-button :loading="uploadingKind === 'pay_key'">上传</el-button>
</el-upload>
</div>
</el-form-item>
<el-form-item label="收款证书 cert.pem">
<div class="upload-row">
<el-input v-model="form.pay_cert_path" placeholder="上传后自动填入路径" readonly />
<el-upload
:show-file-list="false"
:http-request="(opt) => uploadCert('pay_cert', opt)"
accept=".pem,.crt"
>
<el-button :loading="uploadingKind === 'pay_cert'">上传</el-button>
</el-upload>
</div>
</el-form-item>
<!-- ========== 提现商户V3 ========== -->
<el-divider content-position="left">提现商户(转账到零钱 · V3可与收款不同</el-divider>
<el-form-item label="提现商户号">
<el-input
v-model="form.withdraw_mch_id"
placeholder="留空则回落收款商户号 mch_id"
/>
<div class="field-help">与收款商户不同时必填;相同可留空。</div>
</el-form-item>
<el-form-item label="提现 APIv3 密钥">
<el-input
v-model="form.api_v3_key"
type="password"
show-password
placeholder="自动提现 / 转账到零钱 / 回调解密"
placeholder="商户平台 API安全 APIv3密钥"
/>
<div class="field-help">微信商户平台 API安全 设置APIv3密钥仅用于提现不用于小程序收款</div>
</el-form-item>
<el-form-item label="提现私钥 key.pem">
<div class="upload-row">
<el-input v-model="form.private_key_path" placeholder="上传后自动填入路径" readonly />
<el-upload
:show-file-list="false"
:http-request="(opt) => uploadCert('withdraw_key', opt)"
accept=".pem,.key"
>
<el-button :loading="uploadingKind === 'withdraw_key'">上传</el-button>
</el-upload>
</div>
</el-form-item>
<el-form-item label="提现证书 cert.pem">
<div class="upload-row">
<el-input :model-value="withdrawCertHint" placeholder="与私钥同目录上传后自动配对序列号" readonly />
<el-upload
:show-file-list="false"
:http-request="(opt) => uploadCert('withdraw_cert', opt)"
accept=".pem,.crt"
>
<el-button :loading="uploadingKind === 'withdraw_cert'">上传</el-button>
</el-upload>
</div>
</el-form-item>
<el-form-item label="平台证书/公钥">
<div class="upload-row">
<el-input v-model="form.platform_cert_dir" placeholder="上传后自动填入目录" readonly />
<el-upload
:show-file-list="false"
:http-request="(opt) => uploadCert('withdraw_platform', opt)"
accept=".pem"
>
<el-button :loading="uploadingKind === 'withdraw_platform'">上传</el-button>
</el-upload>
</div>
<div class="field-help">回调验签用;存为提现目录下的 pub_key.pem字段记录目录。</div>
</el-form-item>
<el-form-item label="证书序列号">
<el-input v-model="form.cert_serial_no" placeholder="一般可留空系统按私钥自动配对" />
</el-form-item>
<!-- ========== 小程序收款通道:微信直连 / 付呗 ========== -->
<el-divider content-position="left">小程序收款通道(微信直连 / 付呗)</el-divider>
<el-alert
type="info"
:closable="false"
show-icon
class="section-tip"
title="前端仍走微信支付此处选择实际下单走微信商户号还是付呗默认微信直连不改不影响现网"
/>
<el-form-item label="当前选用">
<el-radio-group v-model="form.mini_pay_channel">
<el-radio label="wechat">微信直连商户号</el-radio>
<el-radio label="fubei">付呗</el-radio>
</el-radio-group>
<div class="field-help">保存俱乐部配置时会一并写入;也可点下方「仅切换通道」立即生效。</div>
</el-form-item>
<el-form-item label="付呗开放平台ID">
<el-input v-model="fubeiForm.app_id" placeholder=" 202607111990887" />
</el-form-item>
<el-form-item label="付呗接口密钥">
<el-input
v-model="fubeiForm.api_key"
type="password"
show-password
:placeholder="fubeiForm.api_key_configured ? `已配置 ${fubeiForm.api_key_masked},留空则不修改` : '付呗 app_secret'"
/>
</el-form-item>
<el-form-item label="付呗门店ID">
<el-input v-model="fubeiForm.store_id" placeholder=" 2384931" />
</el-form-item>
<el-form-item label="付呗商户ID">
<el-input v-model="fubeiForm.merchant_id" placeholder=" 3245035选填" />
</el-form-item>
<el-form-item label="支付小程序AppID">
<el-input v-model="fubeiForm.mini_app_id" placeholder="可与 wx_appid 相同" />
</el-form-item>
<el-form-item>
<el-button type="primary" plain :loading="fubeiSaving" @click="saveFubeiOnly">保存付呗配置</el-button>
<el-button :loading="channelSwitching" @click="switchChannelOnly">仅切换通道</el-button>
</el-form-item>
<el-alert
v-if="payWarnings.length"
type="warning"
@@ -83,15 +205,6 @@
class="pay-warn"
:title="payWarnings.join('')"
/>
<el-form-item label="cert_serial_no">
<el-input v-model="form.cert_serial_no" placeholder="APIv3 商户证书序列号(提现签名用)" />
</el-form-item>
<el-form-item label="private_key_path">
<el-input v-model="form.private_key_path" placeholder="服务器上 apiclient_key.pem 路径(提现签名用)" />
</el-form-item>
<el-form-item label="platform_cert_dir">
<el-input v-model="form.platform_cert_dir" placeholder="微信平台证书目录(提现回调验签用)" />
</el-form-item>
<el-divider content-position="left">服务号</el-divider>
<el-form-item label="official_appid">
@@ -131,7 +244,7 @@
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { ref, reactive, computed, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { Refresh } from '@element-plus/icons-vue'
import request from '@/utils/request'
@@ -141,6 +254,7 @@ const loading = ref(false)
const saving = ref(false)
const creating = ref(false)
const createVisible = ref(false)
const uploadingKind = ref('')
const clubList = ref([])
const currentClubId = ref('xq')
const createForm = reactive({
@@ -150,6 +264,19 @@ const createForm = reactive({
from_club: 'xq',
})
const payWarnings = ref([])
const fubeiSaving = ref(false)
const channelSwitching = ref(false)
const fubeiForm = reactive({
id: null,
name: '主付呗',
app_id: '',
api_key: '',
api_key_masked: '',
api_key_configured: false,
store_id: '',
merchant_id: '',
mini_app_id: '',
})
const form = reactive({
name: '',
wx_appid: '',
@@ -157,6 +284,10 @@ const form = reactive({
mch_id: '',
pay_app_id: '',
mch_key: '',
pay_cert_path: '',
pay_key_path: '',
withdraw_mch_id: '',
mini_pay_channel: 'wechat',
api_v3_key: '',
cert_serial_no: '',
private_key_path: '',
@@ -177,6 +308,15 @@ const form = reactive({
const phone = localStorage.getItem('username')
const withdrawCertHint = computed(() => {
const key = form.private_key_path || ''
if (!key) return ''
if (key.includes('apiclient_key.pem')) {
return key.replace('apiclient_key.pem', 'apiclient_cert.pem')
}
return key
})
const loadClubList = async () => {
const res = await request.post(JITUAN_API.clubManage, { phone, action: 'list' })
if (res.code === 0) {
@@ -197,8 +337,15 @@ const loadClub = async () => {
club_id: currentClubId.value,
})
if (res.code === 0 && res.data) {
Object.assign(form, res.data)
Object.assign(form, {
pay_cert_path: '',
pay_key_path: '',
withdraw_mch_id: '',
mini_pay_channel: 'wechat',
...res.data,
})
payWarnings.value = (res.data.pay_config && res.data.pay_config.warnings) || []
await loadFubeiChannel()
} else {
ElMessage.error(res.msg || '加载失败')
}
@@ -209,6 +356,130 @@ const loadClub = async () => {
}
}
const loadFubeiChannel = async () => {
try {
const res = await request.post(JITUAN_API.paymentChannelManage, {
phone,
action: 'get',
club_id: currentClubId.value,
})
if (res.code !== 0) return
if (res.data?.mini_pay_channel) {
form.mini_pay_channel = res.data.mini_pay_channel
}
const list = res.data?.fubei_channels || []
const row = list[0]
if (row) {
Object.assign(fubeiForm, {
id: row.id,
name: row.name || '主付呗',
app_id: row.app_id || '',
api_key: '',
api_key_masked: row.api_key_masked || '',
api_key_configured: !!row.api_key_configured,
store_id: row.store_id != null ? String(row.store_id) : '',
merchant_id: row.merchant_id || '',
mini_app_id: row.mini_app_id || '',
})
} else {
Object.assign(fubeiForm, {
id: null,
name: '主付呗',
app_id: '',
api_key: '',
api_key_masked: '',
api_key_configured: false,
store_id: '',
merchant_id: '',
mini_app_id: form.wx_appid || '',
})
}
} catch {
/* 旧后端无此接口时忽略 */
}
}
const saveFubeiOnly = async () => {
fubeiSaving.value = true
try {
const payload = {
phone,
action: 'save_fubei',
club_id: currentClubId.value,
id: fubeiForm.id,
name: fubeiForm.name || '主付呗',
app_id: fubeiForm.app_id,
store_id: fubeiForm.store_id,
merchant_id: fubeiForm.merchant_id,
mini_app_id: fubeiForm.mini_app_id || form.wx_appid,
is_default: true,
activate: form.mini_pay_channel === 'fubei',
}
if (fubeiForm.api_key) payload.api_key = fubeiForm.api_key
const res = await request.post(JITUAN_API.paymentChannelManage, payload)
if (res.code === 0) {
ElMessage.success(res.msg || '付呗配置已保存')
await loadFubeiChannel()
} else {
ElMessage.error(res.msg || '保存失败')
}
} catch {
ElMessage.error('保存付呗配置失败')
} finally {
fubeiSaving.value = false
}
}
const switchChannelOnly = async () => {
channelSwitching.value = true
try {
const res = await request.post(JITUAN_API.paymentChannelManage, {
phone,
action: 'set_mini_pay_channel',
club_id: currentClubId.value,
mini_pay_channel: form.mini_pay_channel,
})
if (res.code === 0) {
ElMessage.success(res.msg || '通道已切换')
if (res.warning) ElMessage.warning('请先完善付呗配置')
} else {
ElMessage.error(res.msg || '切换失败')
}
} catch {
ElMessage.error('切换通道失败')
} finally {
channelSwitching.value = false
}
}
const uploadCert = async (kind, opt) => {
const file = opt?.file
if (!file || !currentClubId.value) return
uploadingKind.value = kind
try {
const fd = new FormData()
fd.append('phone', phone || '')
fd.append('club_id', currentClubId.value)
fd.append('kind', kind)
fd.append('file', file)
const res = await request.post(JITUAN_API.clubCertUpload, fd)
if (res.code === 0) {
ElMessage.success('上传成功')
const d = res.data || {}
if (d.pay_key_path !== undefined) form.pay_key_path = d.pay_key_path || ''
if (d.pay_cert_path !== undefined) form.pay_cert_path = d.pay_cert_path || ''
if (d.private_key_path !== undefined) form.private_key_path = d.private_key_path || ''
if (d.platform_cert_dir !== undefined) form.platform_cert_dir = d.platform_cert_dir || ''
} else {
ElMessage.error(res.msg || '上传失败')
}
} catch {
ElMessage.error('上传失败')
} finally {
uploadingKind.value = ''
}
}
const saveClub = async () => {
saving.value = true
try {
@@ -221,6 +492,7 @@ const saveClub = async () => {
if (res.code === 0) {
ElMessage.success('保存成功')
await loadClubList()
await loadClub()
} else {
ElMessage.error(res.msg || '保存失败')
}
@@ -264,7 +536,7 @@ const createClub = async () => {
ElMessage.error(res.msg || '创建失败')
}
} catch {
ElMessage.error('创建失败,请确认账号有 000001 权限且后端已部署')
ElMessage.error('创建失败,请确认账号有权限且后端已部署')
} finally {
creating.value = false
}
@@ -283,6 +555,9 @@ onMounted(async () => {
.tip {
margin-bottom: 16px;
}
.section-tip {
margin-bottom: 12px;
}
.toolbar {
display: flex;
align-items: center;
@@ -290,7 +565,7 @@ onMounted(async () => {
margin-bottom: 20px;
}
.config-form {
max-width: 720px;
max-width: 820px;
}
.field-help {
font-size: 12px;
@@ -299,6 +574,15 @@ onMounted(async () => {
margin-top: 4px;
}
.pay-warn {
margin-bottom: 16px;
margin: 12px 0 20px;
}
.upload-row {
display: flex;
gap: 8px;
width: 100%;
align-items: center;
}
.upload-row .el-input {
flex: 1;
}
</style>

View File

@@ -109,7 +109,9 @@
<el-button :disabled="!searchRole || !searchUid" @click="startNewChat">发起会话</el-button>
</template>
</el-input>
<div class="search-tip">示例选择打手输入123456将联系 Ds123456</div>
<div class="search-tip">
身份与小程序一致打手/管事/组长 Ds老板 Boss商家 Sj示例打手 + 123456 = Ds123456
</div>
</div>
</div>
@@ -140,17 +142,17 @@ import {
goOnlineManually,
goOfflineManually,
isGoeasyConnected,
checkCsOnlineStatus
checkCsOnlineStatus,
normalizeCustomerImId,
resolveConversationCustomerId
} from '@/utils/kefuChat'
const { proxy } = getCurrentInstance()
const availableRoles = [
{ prefix: 'Ds', name: '打手' },
{ prefix: 'Ds', name: '打手/管事/组长' },
{ prefix: 'Boss', name: '老板' },
{ prefix: 'Gs', name: '管事' },
{ prefix: 'Sj', name: '商家' },
{ prefix: 'Zz', name: '组长' }
{ prefix: 'Sj', name: '商家' }
]
const goeasyReady = ref(isGoeasyConnected())
@@ -190,7 +192,10 @@ function getCsteamLocal() {
function filterConversations(convs) {
if (activeRole.value === 'all') return convs
return convs.filter(c => {
const userId = c.userId || c.id || ''
const userId = resolveConversationCustomerId(c) || c.userId || c.id || ''
if (activeRole.value === 'Ds') {
return /^(Ds|Gs|Zz)/i.test(userId)
}
return userId.startsWith(activeRole.value)
})
}
@@ -311,8 +316,21 @@ const acceptAndOpenChat = async (conv, openDialog = true) => {
ElMessage.warning('请先上线后再接入会话')
return
}
const customerId = conv.id || conv.userId
const customerData = conv.data || { name: customerId, avatar: defaultAvatar }
const rawId = resolveConversationCustomerId(conv)
const normalized = normalizeCustomerImId(rawId)
if (!normalized.ok) {
ElMessage.error(normalized.reason || '客户ID无效无法接入')
return
}
if (normalized.changed) {
ElMessage.info(`已纠正客户ID${rawId}${normalized.id}`)
}
const customerId = normalized.id
const customerData = {
...(conv.data || {}),
name: conv.data?.name || customerId,
avatar: conv.data?.avatar || defaultAvatar
}
try {
await ensureCustomerAccepted(customerId, customerData)
ElMessage.success('已接入')
@@ -348,8 +366,21 @@ const endConversation = (conv) => {
}
const openChat = async (conv) => {
const customerId = conv.id || conv.userId
const customerData = conv.data || { name: customerId, avatar: defaultAvatar }
const rawId = resolveConversationCustomerId(conv)
const normalized = normalizeCustomerImId(rawId)
if (!normalized.ok) {
ElMessage.error(normalized.reason || '客户ID无效')
return
}
if (normalized.changed) {
ElMessage.info(`已纠正客户ID${rawId}${normalized.id}`)
}
const customerId = normalized.id
const customerData = {
...(conv.data || {}),
name: conv.data?.name || customerId,
avatar: conv.data?.avatar || defaultAvatar
}
if (!goeasyReady.value) {
ElMessage.warning('消息服务连接中,请稍候…')
return
@@ -385,14 +416,19 @@ const startNewChat = () => {
return
}
const fullId = searchRole.value + uid
const active = activeList.value.find(c => (c.id === fullId || c.userId === fullId))
const normalized = normalizeCustomerImId(searchRole.value + uid)
if (!normalized.ok) {
ElMessage.warning(normalized.reason || '客户ID无效')
return
}
const fullId = normalized.id
const active = activeList.value.find(c => resolveConversationCustomerId(c) === fullId)
if (active) {
openChat(active)
return
}
const pending = pendingList.value.find(c => c.id === fullId)
const pending = pendingList.value.find(c => resolveConversationCustomerId(c) === fullId)
if (pending) {
acceptAndOpenChat(pending, true)
return

729
src/views/gongdan/List.vue Normal file
View File

@@ -0,0 +1,729 @@
<template>
<div class="gongdan-page">
<div class="stat-row">
<div class="stat-card danger" @click="quickFilter(1)">
<div class="stat-label">待处理</div>
<div class="stat-num">{{ stats.pending }}<span class="unit"></span></div>
</div>
<div class="stat-card warn" @click="quickFilter(3)">
<div class="stat-label">不满意</div>
<div class="stat-num">{{ stats.dissatisfied }}<span class="unit"></span></div>
</div>
<div class="stat-card escalate" @click="filterEscalated">
<div class="stat-label">二次投诉待办</div>
<div class="stat-num">{{ stats.escalated }}<span class="unit"></span></div>
</div>
<div class="stat-card need" @click="filterNeedHandle">
<div class="stat-label">需处理合计</div>
<div class="stat-num">{{ stats.need_handle }}<span class="unit"></span></div>
</div>
<div class="stat-card ok" @click="quickFilter(2)">
<div class="stat-label">已处理</div>
<div class="stat-num">{{ stats.done }}<span class="unit"></span></div>
</div>
<div class="stat-card muted" @click="resetFilters">
<div class="stat-label">全部工单</div>
<div class="stat-num">{{ stats.total }}<span class="unit"></span></div>
</div>
</div>
<el-card shadow="never" class="filter-card">
<el-form :inline="true" @submit.prevent>
<el-form-item label="状态">
<el-select v-model="filters.zhuangtai" clearable placeholder="全部" style="width:140px">
<el-option label="处理中" :value="1" />
<el-option label="已处理" :value="2" />
<el-option label="不满意" :value="3" />
<el-option label="已关闭" :value="4" />
</el-select>
</el-form-item>
<el-form-item label="类型">
<el-select v-model="filters.leixing" clearable placeholder="全部" style="width:140px">
<el-option label="订单投诉" :value="1" />
<el-option label="充值投诉" :value="2" />
<el-option label="罚款投诉" :value="3" />
<el-option label="管事投诉" :value="4" />
<el-option label="被骗投诉" :value="5" />
</el-select>
</el-form-item>
<el-form-item label="关键词">
<el-input
v-model="filters.keyword"
clearable
placeholder="工单号/用户/订单ID/充值ID"
style="width:240px"
@keyup.enter="search"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="loading" @click="search">搜索</el-button>
<el-button :loading="loading" @click="refresh">刷新</el-button>
<el-button @click="resetFilters">重置</el-button>
</el-form-item>
</el-form>
</el-card>
<el-card shadow="never">
<el-table
:data="list"
v-loading="loading"
border
stripe
row-key="gongdan_id"
:row-class-name="rowClassName"
@row-click="openDetail"
>
<el-table-column label="工单号" min-width="200">
<template #default="{ row }">
<div class="id-cell">
<span class="mono">{{ row.gongdan_id }}</span>
<el-button type="primary" link size="small" @click.stop="copyText(row.gongdan_id)">复制</el-button>
</div>
<div class="badge-row">
<el-tag v-if="isPending(row)" type="danger" size="small" effect="dark">待处理</el-tag>
<el-tag v-if="row.zhuangtai === 3" type="warning" size="small" effect="dark">不满意</el-tag>
<el-tag v-if="(row.shengji_cishu || 0) > 0" type="danger" size="small" effect="plain">
{{ (row.shengji_cishu || 0) + 1 }}次投诉
</el-tag>
</div>
</template>
</el-table-column>
<el-table-column prop="leixing_label" label="类型" width="100" />
<el-table-column label="状态" width="110">
<template #default="{ row }">
<el-tag :type="statusTagType(row.zhuangtai)" size="small" effect="dark">
{{ row.zhuangtai_label || statusLabel(row.zhuangtai) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="投诉次数" width="100" align="center">
<template #default="{ row }">
<span :class="{ 'text-danger': (row.shengji_cishu || 0) > 0 }">
{{ (row.shengji_cishu || 0) + 1 }}
</span>
</template>
</el-table-column>
<el-table-column label="申请人" width="130">
<template #default="{ row }">
<div class="id-cell">
<span>{{ row.shenqingren_id || '-' }}</span>
<el-button
v-if="row.shenqingren_id"
type="primary"
link
size="small"
@click.stop="copyText(row.shenqingren_id)"
>复制</el-button>
</div>
</template>
</el-table-column>
<el-table-column label="关联单号" min-width="180">
<template #default="{ row }">
<div v-if="row.order_id" class="id-cell">
<span class="muted">订单</span>
<span class="mono">{{ row.order_id }}</span>
<el-button type="primary" link size="small" @click.stop="copyText(row.order_id)">复制</el-button>
</div>
<div v-if="row.chongzhi_id" class="id-cell">
<span class="muted">充值</span>
<span class="mono">{{ row.chongzhi_id }}</span>
<el-button type="primary" link size="small" @click.stop="copyText(row.chongzhi_id)">复制</el-button>
</div>
<div v-if="row.fadan_id" class="id-cell">
<span class="muted">罚单</span>
<span class="mono">{{ row.fadan_id }}</span>
<el-button type="primary" link size="small" @click.stop="copyText(row.fadan_id)">复制</el-button>
</div>
<div v-if="row.guanshi_id" class="id-cell">
<span class="muted">管事</span>
<span class="mono">{{ row.guanshi_id }}</span>
<el-button type="primary" link size="small" @click.stop="copyText(row.guanshi_id)">复制</el-button>
</div>
<span v-if="!row.order_id && !row.chongzhi_id && !row.fadan_id && !row.guanshi_id">-</span>
</template>
</el-table-column>
<el-table-column prop="shuoming" label="说明" min-width="160" show-overflow-tooltip />
<el-table-column label="处理人" width="120">
<template #default="{ row }">
<span>{{ row.chuliren_name || row.chuliren_id || '-' }}</span>
</template>
</el-table-column>
<el-table-column label="创建时间" width="170">
<template #default="{ row }">{{ formatTime(row.CreateTime) }}</template>
</el-table-column>
<el-table-column label="操作" width="100" fixed="right">
<template #default="{ row }">
<el-button type="primary" link @click.stop="openDetail(row)">
{{ isPending(row) || row.zhuangtai === 3 ? '去处理' : '详情' }}
</el-button>
</template>
</el-table-column>
</el-table>
<div class="pager">
<el-pagination
background
layout="total, prev, pager, next"
:total="total"
:page-size="pageSize"
:current-page="page"
@current-change="load"
/>
</div>
</el-card>
<el-drawer
v-model="drawer"
title="工单详情"
size="520px"
append-to-body
class="gongdan-drawer"
destroy-on-close
>
<template v-if="current">
<div class="detail-wrap">
<div class="detail-status-bar" :class="detailStatusClass(current)">
<el-tag :type="statusTagType(current.zhuangtai)" effect="dark" size="large">
{{ current.zhuangtai_label || statusLabel(current.zhuangtai) }}
</el-tag>
<el-tag v-if="(current.shengji_cishu || 0) > 0" type="danger" effect="plain" size="large">
已补充投诉 {{ current.shengji_cishu }} · {{ (current.shengji_cishu || 0) + 1 }}
</el-tag>
</div>
<div class="detail-block">
<div class="detail-row">
<span class="label">工单号</span>
<span class="value mono">{{ current.gongdan_id }}</span>
<el-button type="primary" link @click="copyText(current.gongdan_id)">复制</el-button>
</div>
<div class="detail-row">
<span class="label">类型</span>
<span class="value">{{ current.leixing_label }}</span>
</div>
<div class="detail-row">
<span class="label">申请人</span>
<span class="value">{{ current.shenqingren_id }}</span>
<el-button
v-if="current.shenqingren_id"
type="primary"
link
@click="copyText(current.shenqingren_id)"
>复制</el-button>
</div>
<div v-if="current.order_id" class="detail-row">
<span class="label">订单ID</span>
<span class="value mono">{{ current.order_id }}</span>
<el-button type="primary" link @click="copyText(current.order_id)">复制</el-button>
</div>
<div v-if="current.chongzhi_id" class="detail-row">
<span class="label">充值ID</span>
<span class="value mono">{{ current.chongzhi_id }}</span>
<el-button type="primary" link @click="copyText(current.chongzhi_id)">复制</el-button>
</div>
<div v-if="current.fadan_id" class="detail-row">
<span class="label">罚单ID</span>
<span class="value mono">{{ current.fadan_id }}</span>
<el-button type="primary" link @click="copyText(current.fadan_id)">复制</el-button>
</div>
<div v-if="current.guanshi_id" class="detail-row">
<span class="label">管事ID</span>
<span class="value mono">{{ current.guanshi_id }}</span>
<el-button type="primary" link @click="copyText(current.guanshi_id)">复制</el-button>
</div>
<div class="detail-row">
<span class="label">创建时间</span>
<span class="value">{{ formatTime(current.CreateTime) }}</span>
</div>
<div class="detail-row">
<span class="label">更新时间</span>
<span class="value">{{ formatTime(current.UpdateTime) }}</span>
</div>
</div>
<div class="detail-block">
<div class="block-title">投诉说明</div>
<div class="desc-box">{{ current.shuoming || '无' }}</div>
<div v-if="current.images?.length" class="imgs">
<el-image
v-for="(img, i) in current.images"
:key="i"
:src="img.url"
:preview-src-list="current.images.map(x => x.url)"
fit="cover"
preview-teleported
style="width:88px;height:88px;margin:4px;border-radius:6px"
/>
</div>
</div>
<div v-if="current.shengji_list?.length" class="detail-block escalate-block">
<div class="block-title">二次 / 补充投诉记录</div>
<div v-for="s in current.shengji_list" :key="s.cishu" class="shengji-item">
<div class="shengji-head">
<el-tag type="danger" size="small">第{{ s.cishu }}次补充</el-tag>
<span class="time">{{ formatTime(s.CreateTime) }}</span>
</div>
<div class="shengji-body">{{ s.shuoming || '无说明' }}</div>
</div>
</div>
<div class="detail-block">
<div class="block-title">处理信息</div>
<div class="detail-row">
<span class="label">处理人</span>
<span class="value">{{ current.chuliren_name || current.chuliren_id || '尚未处理' }}</span>
</div>
<div class="detail-row">
<span class="label">处理时间</span>
<span class="value">{{ formatTime(current.chuli_time) || '-' }}</span>
</div>
<div v-if="current.chuli_jieguo" class="desc-box prev-result">
<div class="muted-label">上次处理结果</div>
{{ current.chuli_jieguo }}
</div>
</div>
<el-divider />
<div class="block-title">填写处理结果</div>
<el-input v-model="jieguo" type="textarea" :rows="4" placeholder="填写处理结果标记已处理时必填" />
<div class="actions">
<el-button type="primary" :loading="saving" @click="handle('done')">标记已处理</el-button>
<el-button :loading="saving" @click="handle('close')">关闭工单</el-button>
</div>
</div>
</template>
</el-drawer>
</div>
</template>
<script setup>
import { reactive, ref, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import request from '@/utils/request'
const loading = ref(false)
const saving = ref(false)
const list = ref([])
const total = ref(0)
const page = ref(1)
const pageSize = 20
const filters = reactive({ zhuangtai: '', leixing: '', keyword: '' })
const onlyEscalated = ref(false)
const needHandle = ref(false)
const drawer = ref(false)
const current = ref(null)
const jieguo = ref('')
const stats = reactive({
total: 0,
pending: 0,
done: 0,
dissatisfied: 0,
closed: 0,
escalated: 0,
need_handle: 0,
})
function isPending(row) {
return Number(row?.zhuangtai) === 1
}
function statusLabel(z) {
return ({ 1: '处理中', 2: '已处理', 3: '不满意', 4: '已关闭' })[Number(z)] || String(z || '')
}
function statusTagType(z) {
const n = Number(z)
if (n === 1) return 'danger'
if (n === 2) return 'success'
if (n === 3) return 'warning'
if (n === 4) return 'info'
return ''
}
function rowClassName({ row }) {
if (Number(row.zhuangtai) === 1) return 'row-pending'
if (Number(row.zhuangtai) === 3) return 'row-dissatisfied'
if ((row.shengji_cishu || 0) > 0 && [1, 3].includes(Number(row.zhuangtai))) return 'row-escalate'
return ''
}
function detailStatusClass(row) {
const z = Number(row?.zhuangtai)
if (z === 1) return 'is-pending'
if (z === 3) return 'is-dissatisfied'
return ''
}
function formatTime(v) {
if (!v) return ''
return String(v).replace('T', ' ').slice(0, 19)
}
function copyText(text) {
const val = String(text || '').trim()
if (!val) {
ElMessage.warning('无可复制内容')
return
}
navigator.clipboard.writeText(val).then(() => {
ElMessage.success('已复制')
}).catch(() => {
ElMessage.error('复制失败')
})
}
function applyStats(data) {
const s = data?.stats
if (s && typeof s === 'object' && (s.total != null || s.pending != null || s.need_handle != null)) {
stats.total = Number(s.total) || 0
stats.pending = Number(s.pending) || 0
stats.done = Number(s.done) || 0
stats.dissatisfied = Number(s.dissatisfied) || 0
stats.closed = Number(s.closed) || 0
stats.escalated = Number(s.escalated) || 0
stats.need_handle = Number(s.need_handle) || (stats.pending + stats.dissatisfied)
return true
}
return false
}
/** 旧后端无 stats 字段时:按状态各查一次 total 兜底 */
async function loadStatsFallback() {
const fetchTotal = async (payload = {}) => {
try {
const res = await request.post('/gongdan/admin/list/', {
page: 1,
page_size: 1,
...payload,
})
if (res.code === 200 || res.code === 0) return Number(res.data?.total) || 0
} catch (e) {}
return 0
}
const [pending, done, dissatisfied, closed, total, escalated] = await Promise.all([
fetchTotal({ zhuangtai: 1 }),
fetchTotal({ zhuangtai: 2 }),
fetchTotal({ zhuangtai: 3 }),
fetchTotal({ zhuangtai: 4 }),
fetchTotal({}),
fetchTotal({ only_escalated: 1 }),
])
stats.pending = pending
stats.done = done
stats.dissatisfied = dissatisfied
stats.closed = closed
stats.total = total
stats.escalated = escalated
stats.need_handle = pending + dissatisfied
}
async function load(p = 1) {
page.value = p
loading.value = true
try {
const res = await request.post('/gongdan/admin/list/', {
page: page.value,
page_size: pageSize,
zhuangtai: needHandle.value || onlyEscalated.value ? '' : filters.zhuangtai,
leixing: filters.leixing,
keyword: filters.keyword,
only_escalated: onlyEscalated.value ? 1 : 0,
need_handle: needHandle.value ? 1 : 0,
})
if (res.code === 200 || res.code === 0) {
list.value = res.data?.list || []
total.value = res.data?.total || 0
const ok = applyStats(res.data)
if (!ok) await loadStatsFallback()
} else {
ElMessage.error(res.message || res.msg || '加载失败')
}
} catch (e) {
ElMessage.error('加载失败')
} finally {
loading.value = false
}
}
function refresh() {
load(page.value)
}
function search() {
onlyEscalated.value = false
needHandle.value = false
load(1)
}
function resetFilters() {
filters.zhuangtai = ''
filters.leixing = ''
filters.keyword = ''
onlyEscalated.value = false
needHandle.value = false
load(1)
}
function quickFilter(zhuangtai) {
onlyEscalated.value = false
needHandle.value = false
filters.zhuangtai = zhuangtai
load(1)
}
function filterNeedHandle() {
onlyEscalated.value = false
needHandle.value = true
filters.zhuangtai = ''
load(1)
}
function filterEscalated() {
onlyEscalated.value = true
needHandle.value = false
filters.zhuangtai = ''
load(1)
}
async function openDetail(row) {
drawer.value = true
jieguo.value = row.chuli_jieguo || ''
current.value = row
try {
const res = await request.post('/gongdan/detail/', { gongdan_id: row.gongdan_id })
if (res.code === 200 || res.code === 0) current.value = res.data
} catch (e) {
// 保留列表行数据
}
}
async function handle(action) {
if (!current.value) return
if (action === 'done' && !jieguo.value.trim()) {
ElMessage.warning('请填写处理结果')
return
}
saving.value = true
try {
const res = await request.post('/gongdan/admin/handle/', {
gongdan_id: current.value.gongdan_id,
action,
chuli_jieguo: jieguo.value,
})
if (res.code === 200 || res.code === 0) {
ElMessage.success('已保存')
drawer.value = false
load(page.value)
} else ElMessage.error(res.message || res.msg || '失败')
} catch (e) {
ElMessage.error('处理失败')
} finally {
saving.value = false
}
}
onMounted(() => load(1))
</script>
<style scoped>
.gongdan-page { padding: 12px; color: #1f2937; }
.stat-row {
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 12px;
margin-bottom: 16px;
}
.stat-card {
background: rgba(6, 12, 20, 0.75);
border: 1px solid rgba(0, 242, 255, 0.22);
border-radius: 12px;
padding: 16px 14px;
cursor: pointer;
transition: transform .15s ease, box-shadow .15s ease, border-color .15s ease;
}
.stat-card:hover {
transform: translateY(-1px);
box-shadow: 0 0 16px rgba(0, 242, 255, 0.18);
border-color: rgba(0, 242, 255, 0.45);
}
.stat-label {
margin-bottom: 8px;
font-size: 13px;
color: rgba(238, 242, 255, 0.72);
}
.stat-num {
font-size: 30px;
font-weight: 800;
line-height: 1.1;
color: #eef2ff;
}
.stat-num .unit {
margin-left: 4px;
font-size: 13px;
font-weight: 500;
color: rgba(238, 242, 255, 0.55);
}
.stat-card.danger {
border-color: rgba(248, 113, 113, 0.55);
background: linear-gradient(180deg, rgba(127, 29, 29, 0.45) 0%, rgba(6, 12, 20, 0.8) 100%);
}
.stat-card.danger .stat-num { color: #fca5a5; }
.stat-card.warn {
border-color: rgba(251, 191, 36, 0.5);
background: linear-gradient(180deg, rgba(120, 53, 15, 0.4) 0%, rgba(6, 12, 20, 0.8) 100%);
}
.stat-card.warn .stat-num { color: #fcd34d; }
.stat-card.escalate {
border-color: rgba(251, 113, 133, 0.55);
background: linear-gradient(180deg, rgba(136, 19, 55, 0.4) 0%, rgba(6, 12, 20, 0.8) 100%);
}
.stat-card.escalate .stat-num { color: #fda4af; }
.stat-card.need {
border-color: rgba(248, 113, 113, 0.65);
background: linear-gradient(180deg, rgba(153, 27, 27, 0.5) 0%, rgba(6, 12, 20, 0.8) 100%);
}
.stat-card.need .stat-num { color: #fecaca; }
.stat-card.ok .stat-num { color: #86efac; }
.stat-card.muted .stat-num { color: #93c5fd; }
.filter-card { margin-bottom: 12px; }
.pager { margin-top: 16px; display: flex; justify-content: flex-end; }
.actions { margin-top: 16px; display: flex; gap: 8px; flex-wrap: wrap; }
.id-cell {
display: flex;
align-items: center;
gap: 4px;
flex-wrap: wrap;
line-height: 1.4;
}
.badge-row {
display: flex;
gap: 4px;
flex-wrap: wrap;
margin-top: 4px;
}
.mono {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 12px;
word-break: break-all;
}
.muted { color: #9ca3af; font-size: 12px; margin-right: 2px; }
.text-danger { color: #dc2626; font-weight: 700; }
:deep(.row-pending) > td {
background: #fef2f2 !important;
}
:deep(.row-dissatisfied) > td {
background: #fffbeb !important;
}
:deep(.row-escalate) > td {
background: #fff1f2 !important;
}
.detail-wrap {
color: #1f2937;
font-size: 14px;
line-height: 1.6;
}
.detail-status-bar {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin-bottom: 14px;
padding: 12px;
border-radius: 8px;
background: #f3f4f6;
}
.detail-status-bar.is-pending { background: #fef2f2; }
.detail-status-bar.is-dissatisfied { background: #fffbeb; }
.detail-block {
margin-bottom: 14px;
padding: 12px;
border: 1px solid #e5e7eb;
border-radius: 8px;
background: #fff;
}
.escalate-block {
border-color: #fecaca;
background: #fff7f7;
}
.block-title {
font-weight: 700;
margin-bottom: 8px;
color: #111827;
}
.detail-row {
display: flex;
align-items: flex-start;
gap: 8px;
margin-bottom: 8px;
}
.detail-row .label {
width: 72px;
flex-shrink: 0;
color: #6b7280;
}
.detail-row .value {
flex: 1;
color: #111827;
word-break: break-all;
}
.desc-box {
white-space: pre-wrap;
word-break: break-word;
color: #111827;
background: #f9fafb;
border-radius: 6px;
padding: 10px;
}
.prev-result { margin-top: 8px; }
.muted-label {
font-size: 12px;
color: #6b7280;
margin-bottom: 4px;
}
.shengji-item {
margin-bottom: 10px;
padding-bottom: 10px;
border-bottom: 1px dashed #fecaca;
}
.shengji-item:last-child {
border-bottom: none;
margin-bottom: 0;
padding-bottom: 0;
}
.shengji-head {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 4px;
}
.shengji-head .time {
font-size: 12px;
color: #9ca3af;
}
.shengji-body {
color: #111827;
white-space: pre-wrap;
}
@media (max-width: 1200px) {
.stat-row { grid-template-columns: repeat(3, minmax(0, 1fr)); }
}
</style>
<style>
/* append-to-body 的抽屉不受 scoped 影响,单独保证可读性 */
.gongdan-drawer.el-drawer,
.gongdan-drawer .el-drawer__body,
.gongdan-drawer .el-drawer__header {
color: #1f2937 !important;
background: #fff !important;
}
.gongdan-drawer .el-drawer__title {
color: #111827 !important;
font-weight: 700;
}
</style>