feat: 资金冻结客服三页(规则/明细/风险指标)

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
XingQue
2026-07-26 18:55:31 +08:00
parent 12bdea7234
commit bbb43f8bed
6 changed files with 632 additions and 1 deletions

View File

@@ -39,6 +39,21 @@ const routes = [
component: () => import('@/views/finance/FinanceData.vue'),
meta: { title: '财务数据' }
},
{
path: 'finance/fund-freeze-config',
component: () => import('@/views/finance/FundFreezeConfig.vue'),
meta: { title: '资金冻结规则' }
},
{
path: 'finance/fund-freeze-ledger',
component: () => import('@/views/finance/FundFreezeLedger.vue'),
meta: { title: '资金冻结明细' }
},
{
path: 'finance/fund-risk-stats',
component: () => import('@/views/finance/FundFreezeRisk.vue'),
meta: { title: '罚款投诉指标' }
},
// ========== 考核管理 ==========
{

View File

@@ -125,6 +125,9 @@ export function shouldShowAllMenus(access) {
const KEFU_MENU_ROWS = [
{ page_id: 'finance.bankuai', parent_id: 'finance', path: '/finance/bankuai', sort_order: 11, perm_codes: ['bankuai'] },
{ page_id: 'finance.data', parent_id: 'finance', path: '/finance/data', sort_order: 12, perm_codes: ['caiwu'] },
{ page_id: 'finance.fund-freeze-config', parent_id: 'finance', path: '/finance/fund-freeze-config', sort_order: 13, perm_codes: ['djcfg001'] },
{ page_id: 'finance.fund-freeze-ledger', parent_id: 'finance', path: '/finance/fund-freeze-ledger', sort_order: 14, perm_codes: ['djled001'] },
{ page_id: 'finance.fund-risk-stats', parent_id: 'finance', path: '/finance/fund-risk-stats', sort_order: 15, perm_codes: ['djrisk001'] },
{ page_id: 'assessment.examiner', parent_id: 'assessment', path: '/assessment/examiner', sort_order: 21, perm_codes: ['kaohepeizhi'] },
{ page_id: 'assessment.config', parent_id: 'assessment', path: '/assessment/config', sort_order: 22, perm_codes: ['kaohepeizhi'] },
{ page_id: 'assessment.record', parent_id: 'assessment', path: '/assessment/record', sort_order: 23, perm_codes: ['kaohepeizhi'] },
@@ -172,7 +175,7 @@ const KEFU_MENU_ROWS = [
const KEFU_MENU_PARENT_IDS = {
admin: ['admin.role', 'admin.data-scope', 'admin.club-config', 'admin.order-grab-share', 'admin.user', 'admin.operation-log'],
finance: ['finance.bankuai', 'finance.data'],
finance: ['finance.bankuai', 'finance.data', 'finance.fund-freeze-config', 'finance.fund-freeze-ledger', 'finance.fund-risk-stats'],
assessment: ['assessment.examiner', 'assessment.config', 'assessment.record'],
order: ['order.platform', 'order.merchant'],
'cross-platform': ['cross-platform.order', 'cross-platform.settings', 'cross-platform.data', 'cross-platform.pre'],
@@ -423,6 +426,9 @@ export const JITUAN_API = {
adminAssignments: '/jituan/houtai/admin-assignments',
clubManage: '/jituan/houtai/club-manage',
orderGrabLink: '/jituan/houtai/order-grab-link',
fundFreezeConfig: '/jituan/houtai/fund-freeze-config',
fundFreezeLedger: '/jituan/houtai/fund-freeze-ledger',
fundFreezeRisk: '/jituan/houtai/fund-freeze-risk',
paymentChannelManage: '/jituan/houtai/payment-channel-manage',
clubCertUpload: '/jituan/houtai/club-cert-upload',
memberList: '/jituan/houtai/hthqhylb',

View File

@@ -26,6 +26,9 @@
</template>
<el-menu-item v-if="canShow('finance.bankuai') && isAllClubScope" index="/finance/bankuai">板块配置</el-menu-item>
<el-menu-item v-if="canShow('finance.data')" index="/finance/data">财务数据</el-menu-item>
<el-menu-item v-if="canShow('finance.fund-freeze-config')" index="/finance/fund-freeze-config">资金冻结规则</el-menu-item>
<el-menu-item v-if="canShow('finance.fund-freeze-ledger')" index="/finance/fund-freeze-ledger">资金冻结明细</el-menu-item>
<el-menu-item v-if="canShow('finance.fund-risk-stats')" index="/finance/fund-risk-stats">罚款投诉指标</el-menu-item>
</el-sub-menu>

View File

@@ -0,0 +1,282 @@
<template>
<div class="fund-freeze-config" v-loading="loading">
<ClubScopeHint />
<div class="page-header">
<h2 class="page-title">资金冻结规则</h2>
<el-button @click="loadConfig">刷新</el-button>
</div>
<el-alert
type="warning"
:closable="false"
show-icon
title="默认关闭。未开启时结算全额进可提现,与现网一致。仅对当前顶部所选俱乐部生效。"
style="margin-bottom: 16px"
/>
<div v-if="clubId" class="club-hint">当前俱乐部{{ clubId }}</div>
<el-tabs v-model="activeRole" @tab-change="onTabChange">
<el-tab-pane
v-for="item in roles"
:key="item.role"
:label="item.role_name"
:name="item.role"
/>
</el-tabs>
<div v-if="form" class="form-card">
<el-form label-width="140px">
<el-form-item label="启用冻结">
<el-switch v-model="form.enabled" />
<span class="tip">关闭=该角色入账不冻结</span>
</el-form-item>
<el-form-item label="冻结方式">
<el-radio-group v-model="form.freeze_mode">
<el-radio :label="1">按比例</el-radio>
<el-radio :label="2">固定额</el-radio>
<el-radio :label="3">比例+上限</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item v-if="form.freeze_mode !== 2" label="冻结比例">
<el-input-number v-model="form.freeze_rate" :min="0" :max="1" :step="0.01" :precision="4" />
<span class="tip">0.2 = 20%</span>
</el-form-item>
<el-form-item v-if="form.freeze_mode === 2" label="固定冻结额">
<el-input-number v-model="form.freeze_fixed" :min="0" :precision="2" />
</el-form-item>
<el-form-item v-if="form.freeze_mode === 3" label="单笔上限">
<el-input-number v-model="form.freeze_cap" :min="0" :precision="2" />
<span class="tip">0=不限</span>
</el-form-item>
<el-form-item label="最低入账才冻">
<el-input-number v-model="form.min_credit_to_freeze" :min="0" :precision="2" />
</el-form-item>
<el-form-item label="入账来源">
<el-checkbox-group v-model="form.source_scope">
<el-checkbox label="order_settle">订单结算</el-checkbox>
<el-checkbox label="fenhong">分红/分佣</el-checkbox>
<el-checkbox label="kaohe_fee">考核代收费</el-checkbox>
</el-checkbox-group>
</el-form-item>
<el-form-item label="解冻触发">
<el-select v-model="form.unfreeze_trigger" style="width: 260px">
<el-option label="仅到期" value="time_only" />
<el-option label="仅条件达标" value="condition_only" />
<el-option label="到期且达标" value="time_and_condition" />
<el-option label="到期或达标" value="time_or_condition" />
<el-option label="仅手动" value="manual_only" />
</el-select>
</el-form-item>
<el-form-item label="解冻节奏">
<el-radio-group v-model="form.schedule_mode">
<el-radio label="all_at_once">一次全解</el-radio>
<el-radio label="installment">分期解冻</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="最短冻结天数">
<el-input-number v-model="form.freeze_min_days" :min="0" />
<span class="tip">一次全解时生效分期以各步 after_days 为准</span>
</el-form-item>
<el-form-item v-if="form.schedule_mode === 'installment'" label="分期 JSON">
<el-input
v-model="installmentText"
type="textarea"
:rows="6"
placeholder='{"steps":[{"seq":1,"after_days":7,"type":"ratio","value":0.3,"need_condition":false}]}'
/>
</el-form-item>
<el-form-item label="条件 JSON">
<el-input
v-model="conditionText"
type="textarea"
:rows="4"
placeholder='{"min_completed_orders":30,"max_fine_rate":0.05,"fine_rate_window_days":30}'
/>
</el-form-item>
<el-form-item label="备注">
<el-input v-model="form.remark" maxlength="200" />
</el-form-item>
<el-form-item label="试算(入账100)">
<span>可提现 {{ preview.available }} / 冻结 {{ preview.frozen }}</span>
<el-button link type="primary" @click="refreshPreview">重算</el-button>
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="saving" @click="save">保存该角色规则</el-button>
</el-form-item>
</el-form>
</div>
</div>
</template>
<script setup>
import { ref, reactive, onMounted, watch } from 'vue'
import { ElMessage } from 'element-plus'
import request from '@/utils/request'
import { JITUAN_API } from '@/utils/club-context'
import ClubScopeHint from '@/components/ClubScopeHint.vue'
const username = localStorage.getItem('username') || ''
const loading = ref(false)
const saving = ref(false)
const clubId = ref('')
const roles = ref([])
const activeRole = ref('dashou')
const form = ref(null)
const installmentText = ref('')
const conditionText = ref('')
const preview = reactive({ available: '100', frozen: '0' })
function syncFormFromRole(role) {
const item = roles.value.find((r) => r.role === role)
if (!item) {
form.value = null
return
}
form.value = {
...item,
freeze_rate: Number(item.freeze_rate || 0),
freeze_fixed: Number(item.freeze_fixed || 0),
freeze_cap: Number(item.freeze_cap || 0),
min_credit_to_freeze: Number(item.min_credit_to_freeze || 0),
freeze_min_days: Number(item.freeze_min_days || 0),
source_scope: Array.isArray(item.source_scope) ? [...item.source_scope] : ['order_settle'],
}
installmentText.value = JSON.stringify(item.installment_json || {}, null, 2)
conditionText.value = JSON.stringify(item.condition_json || {}, null, 2)
preview.available = item.preview_100?.available || '100'
preview.frozen = item.preview_100?.frozen || '0'
}
function onTabChange(name) {
syncFormFromRole(name)
}
async function loadConfig() {
loading.value = true
try {
const res = await request.post(JITUAN_API.fundFreezeConfig, { username, action: 'get' })
if (res.code === 403) {
ElMessage.error(res.msg || '无权限')
return
}
if (res.code !== 0) {
ElMessage.error(res.msg || '加载失败')
return
}
clubId.value = res.data?.club_id || ''
roles.value = res.data?.roles || []
if (!roles.value.find((r) => r.role === activeRole.value) && roles.value[0]) {
activeRole.value = roles.value[0].role
}
syncFormFromRole(activeRole.value)
} catch {
ElMessage.error('网络错误')
} finally {
loading.value = false
}
}
function refreshPreview() {
if (!form.value) return
const credit = 100
let frozen = 0
const rate = Number(form.value.freeze_rate || 0)
const fixed = Number(form.value.freeze_fixed || 0)
const cap = Number(form.value.freeze_cap || 0)
if (!form.value.enabled) {
preview.available = '100.00'
preview.frozen = '0.00'
return
}
if (form.value.freeze_mode === 2) frozen = fixed
else {
frozen = credit * rate
if (form.value.freeze_mode === 3 && cap > 0) frozen = Math.min(frozen, cap)
}
frozen = Math.max(0, Math.min(credit, frozen))
preview.frozen = frozen.toFixed(2)
preview.available = (credit - frozen).toFixed(2)
}
watch(
() => [form.value?.enabled, form.value?.freeze_mode, form.value?.freeze_rate, form.value?.freeze_fixed, form.value?.freeze_cap],
() => refreshPreview(),
)
async function save() {
if (!form.value) return
let installment_json = {}
let condition_json = {}
try {
installment_json = installmentText.value ? JSON.parse(installmentText.value) : {}
} catch {
ElMessage.error('分期 JSON 格式错误')
return
}
try {
condition_json = conditionText.value ? JSON.parse(conditionText.value) : {}
} catch {
ElMessage.error('条件 JSON 格式错误')
return
}
saving.value = true
try {
const res = await request.post(JITUAN_API.fundFreezeConfig, {
username,
action: 'save',
role: form.value.role,
enabled: form.value.enabled,
freeze_mode: form.value.freeze_mode,
freeze_rate: form.value.freeze_rate,
freeze_fixed: form.value.freeze_fixed,
freeze_cap: form.value.freeze_cap,
min_credit_to_freeze: form.value.min_credit_to_freeze,
source_scope: form.value.source_scope,
unfreeze_trigger: form.value.unfreeze_trigger,
schedule_mode: form.value.schedule_mode,
freeze_min_days: form.value.freeze_min_days,
installment_json,
condition_json,
remark: form.value.remark,
})
if (res.code === 0) {
ElMessage.success('已保存')
await loadConfig()
} else {
ElMessage.error(res.msg || '保存失败')
}
} catch {
ElMessage.error('网络错误')
} finally {
saving.value = false
}
}
onMounted(loadConfig)
</script>
<style scoped>
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.page-title {
margin: 0;
font-size: 20px;
}
.club-hint {
color: #666;
margin-bottom: 12px;
}
.form-card {
background: #fff;
padding: 16px 20px 8px;
border-radius: 8px;
}
.tip {
margin-left: 10px;
color: #888;
font-size: 12px;
}
</style>

View File

@@ -0,0 +1,195 @@
<template>
<div class="fund-freeze-ledger" v-loading="loading">
<ClubScopeHint />
<div class="page-header">
<h2 class="page-title">资金冻结明细</h2>
<el-button @click="fetchList">刷新</el-button>
</div>
<div class="filters">
<el-select v-model="filters.role" clearable placeholder="角色" style="width: 120px">
<el-option label="打手" value="dashou" />
<el-option label="管事" value="guanshi" />
<el-option label="组长" value="zuzhang" />
<el-option label="考核官" value="shenheguan" />
</el-select>
<el-select v-model="filters.status" clearable placeholder="状态" style="width: 130px">
<el-option label="冻结中" :value="1" />
<el-option label="部分解冻" :value="2" />
<el-option label="已解冻" :value="3" />
<el-option label="已作废" :value="4" />
</el-select>
<el-input v-model="filters.keyword" clearable placeholder="用户ID/订单号" style="width: 200px" />
<el-button type="primary" @click="onSearch">查询</el-button>
</div>
<el-table :data="list" border style="margin-top: 12px">
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="user_id" label="用户" width="110" />
<el-table-column prop="role_name" label="角色" width="80" />
<el-table-column prop="amount_total" label="冻结总额" width="100" />
<el-table-column prop="amount_unfrozen" label="已解冻" width="90" />
<el-table-column prop="amount_remain" label="剩余" width="90" />
<el-table-column prop="status_label" label="状态" width="90" />
<el-table-column prop="order_id" label="订单" min-width="140" show-overflow-tooltip />
<el-table-column prop="frozen_at" label="冻结时间" min-width="160" />
<el-table-column prop="next_eligible_at" label="下次可解" min-width="160" />
<el-table-column label="操作" width="220" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="showEvents(row)">流水</el-button>
<el-button
v-if="row.status === 1 || row.status === 2"
link
type="warning"
@click="doUnfreeze(row, 'step')"
>解一步</el-button>
<el-button
v-if="row.status === 1 || row.status === 2"
link
type="danger"
@click="doUnfreeze(row, 'all')"
>全解</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="onPage"
/>
</div>
<el-dialog v-model="eventsVisible" title="解冻流水" width="640px">
<el-table :data="events" border size="small">
<el-table-column prop="step_seq" label="步" width="60" />
<el-table-column prop="amount" label="金额" width="90" />
<el-table-column prop="trigger" label="触发" width="90" />
<el-table-column prop="reason" label="说明" min-width="140" />
<el-table-column prop="unfrozen_at" label="时间" min-width="160" />
</el-table>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import request from '@/utils/request'
import { JITUAN_API } from '@/utils/club-context'
import ClubScopeHint from '@/components/ClubScopeHint.vue'
const username = localStorage.getItem('username') || ''
const loading = ref(false)
const list = ref([])
const total = ref(0)
const page = ref(1)
const pageSize = 20
const filters = reactive({ role: '', status: '', keyword: '' })
const eventsVisible = ref(false)
const events = ref([])
async function fetchList() {
loading.value = true
try {
const res = await request.post(JITUAN_API.fundFreezeLedger, {
username,
action: 'list',
page: page.value,
page_size: pageSize,
role: filters.role || undefined,
status: filters.status === '' || filters.status === null ? undefined : filters.status,
keyword: filters.keyword || undefined,
})
if (res.code === 403) {
ElMessage.error(res.msg || '无权限')
return
}
if (res.code !== 0) {
ElMessage.error(res.msg || '加载失败')
return
}
list.value = res.data?.list || []
total.value = res.data?.total || 0
} catch {
ElMessage.error('网络错误')
} finally {
loading.value = false
}
}
function onSearch() {
page.value = 1
fetchList()
}
function onPage(p) {
page.value = p
fetchList()
}
async function showEvents(row) {
try {
const res = await request.post(JITUAN_API.fundFreezeLedger, {
username,
action: 'events',
ledger_id: row.id,
})
if (res.code === 0) {
events.value = res.data?.list || []
eventsVisible.value = true
} else {
ElMessage.error(res.msg || '加载流水失败')
}
} catch {
ElMessage.error('网络错误')
}
}
async function doUnfreeze(row, mode) {
try {
await ElMessageBox.confirm(
mode === 'all' ? `确认全部解冻剩余 ${row.amount_remain}` : `确认解冻当前分期一步?`,
'手动解冻',
{ type: 'warning' },
)
} catch {
return
}
try {
const res = await request.post(JITUAN_API.fundFreezeLedger, {
username,
action: 'unfreeze',
ledger_id: row.id,
mode,
reason: mode === 'all' ? '后台手动全部解冻' : '后台手动解冻一步',
})
if (res.code === 0) {
ElMessage.success('解冻成功')
fetchList()
} else {
ElMessage.error(res.msg || '解冻失败')
}
} catch {
ElMessage.error('网络错误')
}
}
onMounted(fetchList)
</script>
<style scoped>
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.page-title { margin: 0; font-size: 20px; }
.filters { display: flex; gap: 8px; flex-wrap: wrap; }
.pager { margin-top: 16px; display: flex; justify-content: flex-end; }
</style>

View File

@@ -0,0 +1,130 @@
<template>
<div class="fund-freeze-risk" v-loading="loading">
<ClubScopeHint />
<div class="page-header">
<h2 class="page-title">罚款投诉指标</h2>
<el-button @click="fetchList">刷新</el-button>
</div>
<el-alert
type="info"
:closable="false"
show-icon
title="展示角色扩展表上的风险快照与冻结池。日汇总任务上线后快照会自动刷新;当前可先看冻结金额。"
style="margin-bottom: 12px"
/>
<div class="filters">
<el-select v-model="filters.role" style="width: 120px" @change="onSearch">
<el-option label="打手" value="dashou" />
<el-option label="管事" value="guanshi" />
<el-option label="组长" value="zuzhang" />
<el-option label="考核官" value="shenheguan" />
</el-select>
<el-input v-model="filters.keyword" clearable placeholder="用户ID" style="width: 180px" />
<el-checkbox v-model="filters.only_frozen">仅看有冻结</el-checkbox>
<el-button type="primary" @click="onSearch">查询</el-button>
</div>
<el-table :data="list" border style="margin-top: 12px">
<el-table-column prop="user_id" label="用户ID" width="110" />
<el-table-column prop="nicheng" label="昵称" width="120" />
<el-table-column prop="role_name" label="角色" width="80" />
<el-table-column prop="available" label="可提现" width="100" />
<el-table-column prop="dongjie_yue" label="冻结池" width="100" />
<el-table-column label="罚款率" width="100">
<template #default="{ row }">{{ formatRate(row.risk_fine_rate) }}</template>
</el-table-column>
<el-table-column label="投诉率" width="100">
<template #default="{ row }">{{ formatRate(row.risk_complaint_rate) }}</template>
</el-table-column>
<el-table-column prop="risk_completed_orders" label="窗口完成单" width="110" />
<el-table-column prop="risk_valid_invites" label="有效邀请" width="100" />
<el-table-column prop="risk_stat_at" label="快照时间" min-width="160" />
</el-table>
<div class="pager">
<el-pagination
background
layout="total, prev, pager, next"
:total="total"
:page-size="pageSize"
:current-page="page"
@current-change="onPage"
/>
</div>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import request from '@/utils/request'
import { JITUAN_API } from '@/utils/club-context'
import ClubScopeHint from '@/components/ClubScopeHint.vue'
const username = localStorage.getItem('username') || ''
const loading = ref(false)
const list = ref([])
const total = ref(0)
const page = ref(1)
const pageSize = 20
const filters = reactive({ role: 'dashou', keyword: '', only_frozen: false })
function formatRate(v) {
const n = Number(v || 0)
return `${(n * 100).toFixed(2)}%`
}
async function fetchList() {
loading.value = true
try {
const res = await request.post(JITUAN_API.fundFreezeRisk, {
username,
action: 'list',
page: page.value,
page_size: pageSize,
role: filters.role,
keyword: filters.keyword || undefined,
only_frozen: filters.only_frozen,
})
if (res.code === 403) {
ElMessage.error(res.msg || '无权限')
return
}
if (res.code !== 0) {
ElMessage.error(res.msg || '加载失败')
return
}
list.value = res.data?.list || []
total.value = res.data?.total || 0
} catch {
ElMessage.error('网络错误')
} finally {
loading.value = false
}
}
function onSearch() {
page.value = 1
fetchList()
}
function onPage(p) {
page.value = p
fetchList()
}
onMounted(fetchList)
</script>
<style scoped>
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.page-title { margin: 0; font-size: 20px; }
.filters { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
.pager { margin-top: 16px; display: flex; justify-content: flex-end; }
</style>