feat: 资金冻结规则改为选项式条件与可增减分期

去掉 JSON 手填,支持勾选条件、自由添加分期步,并配置用户可见冻结说明。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
XingQue
2026-07-26 19:36:11 +08:00
parent bbb43f8bed
commit e09e89d9be

View File

@@ -9,7 +9,7 @@
type="warning"
:closable="false"
show-icon
title="默认关闭。未开启时结算全额可提现,与现网一致。仅对当前顶部所选俱乐部生效。"
title="打开「启用冻结」并保存后,该角色新入账立即按规则冻结;旧单仍按当时快照解冻。关闭则新入账全额可提现。定时任务约每 5 分钟扫描到期/条件解冻。"
style="margin-bottom: 16px"
/>
<div v-if="clubId" class="club-hint">当前俱乐部{{ clubId }}</div>
@@ -24,16 +24,17 @@
</el-tabs>
<div v-if="form" class="form-card">
<el-form label-width="140px">
<el-form label-width="148px">
<el-divider content-position="left">开关与冻结额</el-divider>
<el-form-item label="启用冻结">
<el-switch v-model="form.enabled" />
<span class="tip">关闭=该角色入账不冻结</span>
<el-switch v-model="form.enabled" active-text="已开启" inactive-text="关闭" />
<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 :label="3">比例 + 单笔上限</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item v-if="form.freeze_mode !== 2" label="冻结比例">
@@ -45,7 +46,7 @@
</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>
<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" />
@@ -57,50 +58,173 @@
<el-checkbox label="kaohe_fee">考核代收费</el-checkbox>
</el-checkbox-group>
</el-form-item>
<el-form-item label="用户可见说明">
<el-input
v-model="form.display_reason"
type="textarea"
:rows="3"
maxlength="255"
show-word-limit
placeholder="例:为保障订单售后与纠纷处理,部分佣金将暂存为冻结保障金,按规则分期解冻后自动转入可提现。"
/>
<div class="tip block">小程序冻结旁?点开后展示这段文字</div>
</el-form-item>
<el-divider content-position="left">解冻触发</el-divider>
<el-form-item label="解冻触发">
<el-select v-model="form.unfreeze_trigger" style="width: 260px">
<el-option label="仅到期" value="time_only" />
<el-select v-model="form.unfreeze_trigger" style="width: 280px">
<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-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-input-number v-model="form.freeze_min_days" :min="0" />
<span class="tip">一次全解 / 仅条件模式未满此天数不解</span>
</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" />
<template v-if="form.schedule_mode === 'installment'">
<el-form-item label="分期计划">
<div class="steps-wrap">
<div class="steps-toolbar">
<el-button type="primary" link @click="addStep">+ 添加一期</el-button>
<span class="tip">可任意加减最后一期建议选剩余全部吃干净尾差</span>
</div>
<el-table :data="steps" border size="small" style="width: 100%">
<el-table-column label="期数" width="70">
<template #default="{ $index }">{{ $index + 1 }}</template>
</el-table-column>
<el-table-column label="满多少天可解" width="140">
<template #default="{ row }">
<el-input-number v-model="row.after_days" :min="0" size="small" />
</template>
</el-table-column>
<el-table-column label="解冻方式" width="140">
<template #default="{ row }">
<el-select v-model="row.type" size="small">
<el-option label="按比例" value="ratio" />
<el-option label="固定金额" value="fixed" />
<el-option label="剩余全部" value="remain" />
</el-select>
</template>
</el-table-column>
<el-table-column label="数值" width="140">
<template #default="{ row }">
<el-input-number
v-if="row.type !== 'remain'"
v-model="row.value"
:min="0"
:max="row.type === 'ratio' ? 1 : 999999"
:step="row.type === 'ratio' ? 0.05 : 1"
:precision="row.type === 'ratio' ? 2 : 2"
size="small"
/>
<span v-else class="tip">全部剩余</span>
</template>
</el-table-column>
<el-table-column label="本步需达标" width="110">
<template #default="{ row }">
<el-switch v-model="row.need_condition" />
</template>
</el-table-column>
<el-table-column label="操作" width="90">
<template #default="{ $index }">
<el-button
link
type="danger"
:disabled="steps.length <= 1"
@click="removeStep($index)"
>删除</el-button>
</template>
</el-table-column>
</el-table>
</div>
</el-form-item>
</template>
<el-divider content-position="left">达标条件选项</el-divider>
<el-form-item label="启用条件">
<div class="cond-list">
<div class="cond-row">
<el-checkbox v-model="cond.enable_min_completed_orders">最低完成单量</el-checkbox>
<el-input-number
v-model="cond.min_completed_orders"
:min="0"
:disabled="!cond.enable_min_completed_orders"
size="small"
/>
<span class="tip">窗口内完成单 此值</span>
</div>
<div class="cond-row">
<el-checkbox v-model="cond.enable_max_fine_rate">最高罚款率</el-checkbox>
<el-input-number
v-model="cond.max_fine_rate"
:min="0"
:max="1"
:step="0.01"
:precision="4"
:disabled="!cond.enable_max_fine_rate"
size="small"
/>
<span class="tip">0.05 = 罚款率不超过 5%</span>
<span class="tip">窗口</span>
<el-input-number
v-model="cond.fine_rate_window_days"
:min="1"
:disabled="!cond.enable_max_fine_rate"
size="small"
/>
<span class="tip"></span>
</div>
<div class="cond-row">
<el-checkbox v-model="cond.enable_max_complaint_rate">最高投诉率</el-checkbox>
<el-input-number
v-model="cond.max_complaint_rate"
:min="0"
:max="1"
:step="0.01"
:precision="4"
:disabled="!cond.enable_max_complaint_rate"
size="small"
/>
<span class="tip">窗口</span>
<el-input-number
v-model="cond.complaint_rate_window_days"
:min="1"
:disabled="!cond.enable_max_complaint_rate"
size="small"
/>
<span class="tip"></span>
</div>
<div class="cond-row">
<el-checkbox v-model="cond.enable_min_valid_invites">最低有效邀请</el-checkbox>
<el-input-number
v-model="cond.min_valid_invites"
:min="0"
:disabled="!cond.enable_min_valid_invites"
size="small"
/>
<span class="tip">管事/组长更适用</span>
</div>
</div>
</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-button type="primary" :loading="saving" @click="save">
{{ form.enabled ? '保存并启用' : '保存保持关闭' }}
</el-button>
</el-form-item>
</el-form>
</div>
@@ -121,10 +245,66 @@ const clubId = ref('')
const roles = ref([])
const activeRole = ref('dashou')
const form = ref(null)
const installmentText = ref('')
const conditionText = ref('')
const steps = ref([])
const cond = reactive({
enable_min_completed_orders: false,
min_completed_orders: 30,
enable_max_fine_rate: false,
max_fine_rate: 0.05,
fine_rate_window_days: 30,
enable_max_complaint_rate: false,
max_complaint_rate: 0.05,
complaint_rate_window_days: 30,
enable_min_valid_invites: false,
min_valid_invites: 0,
})
const preview = reactive({ available: '100', frozen: '0' })
function defaultStep() {
return { after_days: 7, type: 'ratio', value: 0.3, need_condition: false }
}
function syncCondFromJson(cj) {
const c = cj || {}
const minOrders = Number(c.min_completed_orders || 0)
const maxFine = Number(c.max_fine_rate ?? 1)
const maxComp = Number(c.max_complaint_rate ?? 1)
const minInv = Number(c.min_valid_invites || 0)
cond.enable_min_completed_orders = c.enable_min_completed_orders != null
? !!c.enable_min_completed_orders
: minOrders > 0
cond.min_completed_orders = minOrders || 30
cond.enable_max_fine_rate = c.enable_max_fine_rate != null
? !!c.enable_max_fine_rate
: maxFine >= 0 && maxFine < 1
cond.max_fine_rate = maxFine < 1 ? maxFine : 0.05
cond.fine_rate_window_days = Number(c.fine_rate_window_days || 30)
cond.enable_max_complaint_rate = c.enable_max_complaint_rate != null
? !!c.enable_max_complaint_rate
: maxComp >= 0 && maxComp < 1
cond.max_complaint_rate = maxComp < 1 ? maxComp : 0.05
cond.complaint_rate_window_days = Number(c.complaint_rate_window_days || 30)
cond.enable_min_valid_invites = c.enable_min_valid_invites != null
? !!c.enable_min_valid_invites
: minInv > 0
cond.min_valid_invites = minInv
}
function buildConditionJson() {
return {
enable_min_completed_orders: !!cond.enable_min_completed_orders,
min_completed_orders: Number(cond.min_completed_orders || 0),
enable_max_fine_rate: !!cond.enable_max_fine_rate,
max_fine_rate: Number(cond.max_fine_rate || 0),
fine_rate_window_days: Number(cond.fine_rate_window_days || 30),
enable_max_complaint_rate: !!cond.enable_max_complaint_rate,
max_complaint_rate: Number(cond.max_complaint_rate || 0),
complaint_rate_window_days: Number(cond.complaint_rate_window_days || 30),
enable_min_valid_invites: !!cond.enable_min_valid_invites,
min_valid_invites: Number(cond.min_valid_invites || 0),
}
}
function syncFormFromRole(role) {
const item = roles.value.find((r) => r.role === role)
if (!item) {
@@ -139,9 +319,18 @@ function syncFormFromRole(role) {
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'],
display_reason: item.display_reason || item.remark || '',
}
installmentText.value = JSON.stringify(item.installment_json || {}, null, 2)
conditionText.value = JSON.stringify(item.condition_json || {}, null, 2)
const rawSteps = item.installment_json?.steps
steps.value = Array.isArray(rawSteps) && rawSteps.length
? rawSteps.map((s) => ({
after_days: Number(s.after_days || 0),
type: s.type || 'ratio',
value: Number(s.value ?? (s.type === 'remain' ? 1 : 0.3)),
need_condition: !!s.need_condition,
}))
: [defaultStep(), { after_days: 14, type: 'ratio', value: 0.3, need_condition: true }, { after_days: 30, type: 'remain', value: 1, need_condition: true }]
syncCondFromJson(item.condition_json)
preview.available = item.preview_100?.available || '100'
preview.frozen = item.preview_100?.frozen || '0'
}
@@ -150,6 +339,15 @@ function onTabChange(name) {
syncFormFromRole(name)
}
function addStep() {
steps.value.push(defaultStep())
}
function removeStep(index) {
if (steps.value.length <= 1) return
steps.value.splice(index, 1)
}
async function loadConfig() {
loading.value = true
try {
@@ -204,20 +402,14 @@ watch(
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 格式错误')
if (!form.value.source_scope?.length) {
ElMessage.error('请至少选择一个入账来源')
return
}
const installment_json = form.value.schedule_mode === 'installment'
? { steps: steps.value.map((s, i) => ({ ...s, seq: i + 1 })) }
: { steps: [{ seq: 1, after_days: form.value.freeze_min_days || 0, type: 'remain', value: 1, need_condition: false }] }
saving.value = true
try {
const res = await request.post(JITUAN_API.fundFreezeConfig, {
@@ -235,11 +427,14 @@ async function save() {
schedule_mode: form.value.schedule_mode,
freeze_min_days: form.value.freeze_min_days,
installment_json,
condition_json,
remark: form.value.remark,
condition_json: buildConditionJson(),
display_reason: form.value.display_reason,
remark: form.value.display_reason,
})
if (res.code === 0) {
ElMessage.success('已保存')
ElMessage.success(form.value.enabled
? '已保存并启用:新入账将按规则冻结,解冻由定时任务自动处理'
: '已保存(未启用):新入账仍全额可提现')
await loadConfig()
} else {
ElMessage.error(res.msg || '保存失败')
@@ -279,4 +474,27 @@ onMounted(loadConfig)
color: #888;
font-size: 12px;
}
.tip.block {
display: block;
margin: 6px 0 0;
}
.steps-wrap {
width: 100%;
}
.steps-toolbar {
display: flex;
align-items: center;
margin-bottom: 8px;
}
.cond-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.cond-row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
}
</style>