chore: 保留后台当前版本(工单菜单、支付通道配置、聊天相关改动)
UFO 适配开工前的检查点,便于回滚。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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
729
src/views/gongdan/List.vue
Normal 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>
|
||||
Reference in New Issue
Block a user