feat(config): add finance rules, activity rules, and real expert application flow
这个提交包含在:
@@ -469,6 +469,115 @@
|
||||
</a-card>
|
||||
</div>
|
||||
</a-tab-pane>
|
||||
|
||||
<!-- Tab 9: 财务抽成与推广分销规则 -->
|
||||
<a-tab-pane key="finance_rules_config" title="财务抽成与分销规则">
|
||||
<div class="tab-pane-content">
|
||||
<a-alert type="warning" style="margin-bottom: 20px">
|
||||
在此灵活调控全平台陪玩订单抽成比例、二级分销裂变返润比例以及陪伴师/玩家佣金提现限制门槛,小程序端推广说明与提现流程将动态同步。
|
||||
</a-alert>
|
||||
|
||||
<a-card title="平台抽成与裂变分销费率" :bordered="false" style="background: var(--color-fill-2)">
|
||||
<a-form :model="financeRulesForm.valueJson" layout="vertical">
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="8">
|
||||
<a-form-item label="平台订单抽成比例 (%)">
|
||||
<a-input-number v-model="financeRulesForm.valueJson.platformRatePercent" :min="0" :max="50" :precision="1" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="8">
|
||||
<a-form-item label="一级邀请直接返佣比例 (%)">
|
||||
<a-input-number v-model="financeRulesForm.valueJson.firstReferralPercent" :min="0" :max="30" :precision="1" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="8">
|
||||
<a-form-item label="二级邀请间接返佣比例 (%)">
|
||||
<a-input-number v-model="financeRulesForm.valueJson.secondReferralPercent" :min="0" :max="20" :precision="1" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="8">
|
||||
<a-form-item label="单笔提现最低门槛 (元)">
|
||||
<a-input-number v-model="financeRulesForm.valueJson.minWithdrawAmount" :min="1" :max="5000" placeholder="如 50" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="8">
|
||||
<a-form-item label="单笔提现最高上限 (元)">
|
||||
<a-input-number v-model="financeRulesForm.valueJson.maxWithdrawAmount" :min="100" :max="100000" placeholder="如 20000" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="8">
|
||||
<a-form-item label="提现手续费率 (%)">
|
||||
<a-input-number v-model="financeRulesForm.valueJson.withdrawFeePercent" :min="0" :max="10" :precision="1" placeholder="如 0" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-form-item label="小程序提现提示与到账说明文案">
|
||||
<a-textarea v-model="financeRulesForm.valueJson.withdrawDesc" :auto-size="{ minRows: 2, maxRows: 4 }" />
|
||||
</a-form-item>
|
||||
|
||||
<div style="margin-top: 16px">
|
||||
<a-button type="primary" :loading="saving" @click="saveFinanceRulesConfig">
|
||||
保存财务抽成与分销配置
|
||||
</a-button>
|
||||
</div>
|
||||
</a-form>
|
||||
</a-card>
|
||||
</div>
|
||||
</a-tab-pane>
|
||||
|
||||
<!-- Tab 10: 组局发单与预算预设规范 -->
|
||||
<a-tab-pane key="activity_rules_config" title="组局发单与预算规范">
|
||||
<div class="tab-pane-content">
|
||||
<a-alert type="info" style="margin-bottom: 20px">
|
||||
在此设定同城活动组局的招募席位上下限、AA制默认分摊基准,以及房主请客/悬赏发单时展示的快捷赏金与总预算梯队。
|
||||
</a-alert>
|
||||
|
||||
<a-card title="活动席位与预设金额梯队" :bordered="false" style="background: var(--color-fill-2)">
|
||||
<a-form :model="activityRulesForm.valueJson" layout="vertical">
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="8">
|
||||
<a-form-item label="最小招募人数 (人)">
|
||||
<a-input-number v-model="activityRulesForm.valueJson.minParticipants" :min="2" :max="10" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="8">
|
||||
<a-form-item label="最大招募人数 (人)">
|
||||
<a-input-number v-model="activityRulesForm.valueJson.maxParticipants" :min="2" :max="50" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="8">
|
||||
<a-form-item label="默认推荐招募人数 (人)">
|
||||
<a-input-number v-model="activityRulesForm.valueJson.defaultParticipants" :min="2" :max="20" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="12">
|
||||
<a-form-item label="单人出价快捷赏金梯队 (多个金额用逗号隔开,单位: 元)">
|
||||
<a-input v-model="presetPerPersonFeesText" placeholder="如:200, 300, 500, 800, 1000" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item label="包场总预算快捷梯队 (多个金额用逗号隔开,单位: 元)">
|
||||
<a-input v-model="presetTotalFeesText" placeholder="如:500, 1000, 2000, 3000, 5000" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<div style="margin-top: 16px">
|
||||
<a-button type="primary" :loading="saving" @click="saveActivityRulesConfig">
|
||||
保存组局发单规范配置
|
||||
</a-button>
|
||||
</div>
|
||||
</a-form>
|
||||
</a-card>
|
||||
</div>
|
||||
</a-tab-pane>
|
||||
</a-tabs>
|
||||
</a-card>
|
||||
</div>
|
||||
@@ -709,6 +818,59 @@ const saveExpertRulesConfig = () => {
|
||||
saveConfig('expert_rules', expertRulesForm);
|
||||
};
|
||||
|
||||
// P1 财务抽成与分销规则
|
||||
const financeRulesForm = reactive({
|
||||
name: '平台抽成与推广分销规则',
|
||||
description: '平台佣金抽成比例、二级分销返润与佣金提现门槛限制',
|
||||
status: 1,
|
||||
valueJson: {
|
||||
platformRatePercent: 10.0,
|
||||
firstReferralPercent: 5.0,
|
||||
secondReferralPercent: 2.0,
|
||||
minWithdrawAmount: 50,
|
||||
maxWithdrawAmount: 20000,
|
||||
withdrawFeePercent: 0.0,
|
||||
withdrawDesc: '提现将自动发起微信企业付款打款至微信零钱,工作日预计2小时内到账。'
|
||||
}
|
||||
});
|
||||
|
||||
const saveFinanceRulesConfig = () => {
|
||||
saveConfig('finance_rules', financeRulesForm);
|
||||
};
|
||||
|
||||
// P3 组局发单与预算规则
|
||||
const activityRulesForm = reactive({
|
||||
name: '同城组局发单与预算预设规范',
|
||||
description: '招募席位上下限、快捷单人赏金预设与包场预算梯队',
|
||||
status: 1,
|
||||
valueJson: {
|
||||
minParticipants: 2,
|
||||
maxParticipants: 10,
|
||||
defaultParticipants: 4,
|
||||
presetPerPersonFees: [200, 300, 500, 800, 1000],
|
||||
presetTotalFees: [500, 1000, 2000, 3000, 5000],
|
||||
defaultAaFee: 50
|
||||
}
|
||||
});
|
||||
|
||||
const presetPerPersonFeesText = ref('200, 300, 500, 800, 1000');
|
||||
const presetTotalFeesText = ref('500, 1000, 2000, 3000, 5000');
|
||||
|
||||
const saveActivityRulesConfig = () => {
|
||||
const pFees = presetPerPersonFeesText.value
|
||||
.split(/[,,\s]+/)
|
||||
.map(Number)
|
||||
.filter(n => !isNaN(n) && n > 0);
|
||||
const tFees = presetTotalFeesText.value
|
||||
.split(/[,,\s]+/)
|
||||
.map(Number)
|
||||
.filter(n => !isNaN(n) && n > 0);
|
||||
|
||||
if (pFees.length > 0) activityRulesForm.valueJson.presetPerPersonFees = pFees;
|
||||
if (tFees.length > 0) activityRulesForm.valueJson.presetTotalFees = tFees;
|
||||
saveConfig('activity_rules', activityRulesForm);
|
||||
};
|
||||
|
||||
const fetchConfigs = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
@@ -761,6 +923,22 @@ const fetchConfigs = async () => {
|
||||
if (Array.isArray(expertRulesForm.valueJson.candidateTags)) {
|
||||
expertCandidateTags.value = expertRulesForm.valueJson.candidateTags;
|
||||
}
|
||||
} else if (item.configKey === 'finance_rules') {
|
||||
financeRulesForm.name = item.name;
|
||||
financeRulesForm.description = item.description;
|
||||
financeRulesForm.status = item.status;
|
||||
financeRulesForm.valueJson = { ...financeRulesForm.valueJson, ...(item.valueJson || {}) };
|
||||
} else if (item.configKey === 'activity_rules') {
|
||||
activityRulesForm.name = item.name;
|
||||
activityRulesForm.description = item.description;
|
||||
activityRulesForm.status = item.status;
|
||||
activityRulesForm.valueJson = { ...activityRulesForm.valueJson, ...(item.valueJson || {}) };
|
||||
if (Array.isArray(activityRulesForm.valueJson.presetPerPersonFees)) {
|
||||
presetPerPersonFeesText.value = activityRulesForm.valueJson.presetPerPersonFees.join(', ');
|
||||
}
|
||||
if (Array.isArray(activityRulesForm.valueJson.presetTotalFees)) {
|
||||
presetTotalFeesText.value = activityRulesForm.valueJson.presetTotalFees.join(', ');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -763,3 +763,16 @@ export async function getSafetyPlaces() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交大神实名资质入驻认证申请
|
||||
*/
|
||||
export async function applyExpertCert(data) {
|
||||
try {
|
||||
const res = await http.post('/companions/apply', data);
|
||||
return res;
|
||||
} catch (err) {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -80,12 +80,12 @@
|
||||
<view class="stepper-row">
|
||||
<view class="stepper-info">
|
||||
<text class="row-label">招募席位 (人)</text>
|
||||
<text class="sub-label">包含房主在内的组局总人数</text>
|
||||
<text class="sub-label">包含房主在内的组局总人数 ({{ minParticipants }}-{{ maxParticipants }}人)</text>
|
||||
</view>
|
||||
<view class="stepper-box">
|
||||
<button class="step-btn btn-active" :disabled="form.maxParticipants <= 2" @click="changeParticipants(-1)">-</button>
|
||||
<button class="step-btn btn-active" :disabled="form.maxParticipants <= minParticipants" @click="changeParticipants(-1)">-</button>
|
||||
<text class="step-val">{{ form.maxParticipants }} 人</text>
|
||||
<button class="step-btn btn-active" :disabled="form.maxParticipants >= 10" @click="changeParticipants(1)">+</button>
|
||||
<button class="step-btn btn-active" :disabled="form.maxParticipants >= maxParticipants" @click="changeParticipants(1)">+</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -138,7 +138,7 @@
|
||||
<text class="preset-label">快捷赏金:</text>
|
||||
<view class="preset-chips">
|
||||
<view
|
||||
v-for="amt in [200, 300, 500, 800, 1000]"
|
||||
v-for="amt in presetPerPersonFees"
|
||||
:key="amt"
|
||||
class="preset-chip btn-active"
|
||||
:class="{ selected: Number(form.perPersonFee) === amt }"
|
||||
@@ -165,7 +165,7 @@
|
||||
<text class="preset-label">快捷预算:</text>
|
||||
<view class="preset-chips">
|
||||
<view
|
||||
v-for="amt in [500, 1000, 2000, 3000, 5000]"
|
||||
v-for="amt in presetTotalFees"
|
||||
:key="amt"
|
||||
class="preset-chip btn-active"
|
||||
:class="{ selected: Number(form.totalFee) === amt }"
|
||||
@@ -349,11 +349,17 @@ const recommendPublicZones = ref([
|
||||
'北京市海淀区五道口购物中心 (高校商圈)'
|
||||
]);
|
||||
|
||||
const minParticipants = ref(2);
|
||||
const maxParticipants = ref(10);
|
||||
const presetPerPersonFees = ref([200, 300, 500, 800, 1000]);
|
||||
const presetTotalFees = ref([500, 1000, 2000, 3000, 5000]);
|
||||
|
||||
const loadLivePlaces = async () => {
|
||||
try {
|
||||
const [placesList, catList] = await Promise.allSettled([
|
||||
const [placesList, catList, configsRes] = await Promise.allSettled([
|
||||
getSafetyPlaces(),
|
||||
getNavCategories()
|
||||
getNavCategories(),
|
||||
getPlatformConfigs()
|
||||
]);
|
||||
|
||||
if (placesList.status === 'fulfilled' && Array.isArray(placesList.value) && placesList.value.length > 0) {
|
||||
@@ -368,6 +374,21 @@ const loadLivePlaces = async () => {
|
||||
.map((c) => c.name);
|
||||
if (activeCats.length > 0) {
|
||||
games.value = activeCats;
|
||||
if (!games.value.includes(form.value.game)) {
|
||||
form.value.game = games.value[0];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (configsRes.status === 'fulfilled' && configsRes.value?.activity_rules) {
|
||||
const actRules = configsRes.value.activity_rules;
|
||||
if (actRules.minParticipants) minParticipants.value = Number(actRules.minParticipants);
|
||||
if (actRules.maxParticipants) maxParticipants.value = Number(actRules.maxParticipants);
|
||||
if (Array.isArray(actRules.presetPerPersonFees) && actRules.presetPerPersonFees.length > 0) {
|
||||
presetPerPersonFees.value = actRules.presetPerPersonFees;
|
||||
}
|
||||
if (Array.isArray(actRules.presetTotalFees) && actRules.presetTotalFees.length > 0) {
|
||||
presetTotalFees.value = actRules.presetTotalFees;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -386,7 +407,10 @@ const applyRecommendZone = (zone) => {
|
||||
};
|
||||
|
||||
const changeParticipants = (delta) => {
|
||||
form.value.maxParticipants = Math.max(2, Math.min(10, form.value.maxParticipants + delta));
|
||||
form.value.maxParticipants = Math.max(
|
||||
minParticipants.value,
|
||||
Math.min(maxParticipants.value, form.value.maxParticipants + delta)
|
||||
);
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
|
||||
@@ -206,6 +206,74 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 大神实名认证与资质入驻申请弹窗 (任务四:真实资料与后台审核) -->
|
||||
<view class="profile-edit-modal-mask" v-if="showExpertCertModal" @click="showExpertCertModal = false">
|
||||
<view class="profile-edit-modal expert-cert-modal" @click.stop>
|
||||
<view class="modal-header">
|
||||
<text class="modal-title">陪伴师 / 大神入驻实名申请</text>
|
||||
<view class="btn-close btn-active" @click="showExpertCertModal = false">
|
||||
<image class="close-svg" :src="ICONS.close" mode="aspectFit" />
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="modal-body">
|
||||
<view class="cert-alert">
|
||||
<text class="cert-alert-text">提交真实有效资料,平台将在 24 小时内由运营团队人工核验后开通接单工作台。</text>
|
||||
</view>
|
||||
|
||||
<view class="edit-field">
|
||||
<text class="field-label">真实姓名</text>
|
||||
<input class="field-input" v-model="expertCertForm.realName" placeholder="如:张三 (须与身份证一致)" placeholder-class="field-placeholder" />
|
||||
</view>
|
||||
|
||||
<view class="edit-field">
|
||||
<text class="field-label">身份证号码</text>
|
||||
<input class="field-input" v-model="expertCertForm.idCard" maxlength="18" placeholder="18位二代身份证号" placeholder-class="field-placeholder" />
|
||||
</view>
|
||||
|
||||
<view class="edit-field">
|
||||
<text class="field-label">主营陪伴品类</text>
|
||||
<view class="track-chips">
|
||||
<view
|
||||
v-for="trk in ['同城陪吃', 'KTV陪唱', '同城陪喝', '线上电竞', '同城桌游']"
|
||||
:key="trk"
|
||||
class="track-chip btn-active"
|
||||
:class="{ active: expertCertForm.track === trk }"
|
||||
@click="expertCertForm.track = trk"
|
||||
>
|
||||
{{ trk }}
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="edit-field">
|
||||
<text class="field-label">期望服务单价 (元/小时,平台限价: ¥{{ expertPriceMin }} - ¥{{ expertPriceMax }})</text>
|
||||
<input class="field-input price-num-input" type="number" v-model="expertCertForm.hourlyRate" :placeholder="`推荐单价 ¥${expertDefaultRate}`" placeholder-class="field-placeholder" />
|
||||
</view>
|
||||
|
||||
<view class="edit-field" v-if="expertCandidateTags.length > 0">
|
||||
<text class="field-label">技能专长标签 (最多选6项)</text>
|
||||
<view class="tags-chips">
|
||||
<view
|
||||
v-for="tg in expertCandidateTags"
|
||||
:key="tg"
|
||||
class="tag-pill btn-active"
|
||||
:class="{ active: expertCertForm.tags.includes(tg) }"
|
||||
@click="toggleCertTag(tg)"
|
||||
>
|
||||
{{ tg }}
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="modal-footer">
|
||||
<button class="btn-modal btn-cancel btn-active" @click="showExpertCertModal = false">取消</button>
|
||||
<button class="btn-modal btn-save btn-active" @click="submitExpertCertForm">提交审核</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 底部自定义导航栏 (动态自适应发单人4项 / 接单人3项) -->
|
||||
<custom-tab-bar current="user" />
|
||||
|
||||
@@ -546,13 +614,22 @@ const handleRecharge = async () => {
|
||||
|
||||
const handleWithdraw = () => {
|
||||
const comm = Number(user.value.commission || 0);
|
||||
const finRules = platformConfigs.value.finance_rules || {};
|
||||
const minWithdraw = Number(finRules.minWithdrawAmount) || 50;
|
||||
const withdrawDesc = finRules.withdrawDesc || '提现将自动发起微信企业付款打款到微信零钱。';
|
||||
|
||||
if (comm <= 0) {
|
||||
uni.showToast({ title: '暂无可用提现佣金', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
if (comm < minWithdraw) {
|
||||
uni.showToast({ title: `提现金额须满 ¥${minWithdraw} 元起提`, icon: 'none' });
|
||||
return;
|
||||
}
|
||||
|
||||
uni.showModal({
|
||||
title: '佣金提现申请',
|
||||
content: `当前可提现佣金为 ¥${user.value.commission},提现将自动发起微信企业付款打款到微信零钱。`,
|
||||
content: `当前可提现佣金为 ¥${user.value.commission} (最低满¥${minWithdraw}起提)。\n${withdrawDesc}`,
|
||||
confirmText: '确认提现',
|
||||
confirmColor: '#10B981',
|
||||
success: async (res) => {
|
||||
@@ -579,14 +656,99 @@ const handleWithdraw = () => {
|
||||
};
|
||||
|
||||
const showInvitePoster = () => {
|
||||
const finRules = platformConfigs.value.finance_rules || {};
|
||||
const firstRate = finRules.firstReferralPercent != null ? finRules.firstReferralPercent : 5.0;
|
||||
const secondRate = finRules.secondReferralPercent != null ? finRules.secondReferralPercent : 2.0;
|
||||
const totalRate = (Number(firstRate) + Number(secondRate)).toFixed(1);
|
||||
|
||||
uni.showModal({
|
||||
title: '二级分销推广',
|
||||
content: `您的专属邀请码为:PW${user.value.id}\n已成功邀请 ${user.value.invitedCount} 位好友!\n好友下单您将实时享受最高 15% 自动分润提成。`,
|
||||
content: `您的专属邀请码为:PW${user.value.id}\n已成功邀请 ${user.value.invitedCount} 位好友!\n好友下单享受直推 ${firstRate}% + 间推 ${secondRate}%,全网自动分润最高达 ${totalRate}%!`,
|
||||
showCancel: false,
|
||||
confirmText: '保存太阳码'
|
||||
confirmText: '保存推广码'
|
||||
});
|
||||
};
|
||||
|
||||
const showExpertCertModal = ref(false);
|
||||
const expertCertForm = ref({
|
||||
realName: '',
|
||||
idCard: '',
|
||||
track: '同城陪吃',
|
||||
hourlyRate: 88,
|
||||
tags: []
|
||||
});
|
||||
|
||||
const expertPriceMin = computed(() => {
|
||||
return platformConfigs.value.expert_rules?.minHourlyRate || 30;
|
||||
});
|
||||
const expertPriceMax = computed(() => {
|
||||
return platformConfigs.value.expert_rules?.maxHourlyRate || 500;
|
||||
});
|
||||
const expertDefaultRate = computed(() => {
|
||||
return platformConfigs.value.expert_rules?.defaultHourlyRate || 88;
|
||||
});
|
||||
const expertCandidateTags = computed(() => {
|
||||
return platformConfigs.value.expert_rules?.candidateTags || [
|
||||
'国服王者', '声优达人', '幽默风趣', '随叫随到', '暖心倾听',
|
||||
'百星野王', '金牌探店', '量贩麦霸', '微醺搭子'
|
||||
];
|
||||
});
|
||||
|
||||
const toggleCertTag = (tag) => {
|
||||
const idx = expertCertForm.value.tags.indexOf(tag);
|
||||
if (idx > -1) {
|
||||
expertCertForm.value.tags.splice(idx, 1);
|
||||
} else {
|
||||
if (expertCertForm.value.tags.length >= 6) {
|
||||
uni.showToast({ title: '最多选择6个技能标签', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
expertCertForm.value.tags.push(tag);
|
||||
}
|
||||
};
|
||||
|
||||
const submitExpertCertForm = async () => {
|
||||
if (!expertCertForm.value.realName || !expertCertForm.value.realName.trim()) {
|
||||
uni.showToast({ title: '请输入真实姓名', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
if (!expertCertForm.value.idCard || expertCertForm.value.idCard.length < 15) {
|
||||
uni.showToast({ title: '请输入有效身份证号', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
|
||||
const rate = Number(expertCertForm.value.hourlyRate) || expertDefaultRate.value;
|
||||
if (rate < expertPriceMin.value || rate > expertPriceMax.value) {
|
||||
uni.showToast({ title: `单价需在 ¥${expertPriceMin.value} - ¥${expertPriceMax.value} 之间`, icon: 'none' });
|
||||
return;
|
||||
}
|
||||
|
||||
uni.showLoading({ title: '正在提交认证...' });
|
||||
try {
|
||||
await applyExpertCert({
|
||||
userId: user.value.id || 1001,
|
||||
name: expertCertForm.value.realName.trim(),
|
||||
idCard: expertCertForm.value.idCard.trim(),
|
||||
track: expertCertForm.value.track,
|
||||
hourlyRate: rate,
|
||||
tags: expertCertForm.value.tags,
|
||||
avatar: user.value.avatar,
|
||||
gender: user.value.gender === 'male' ? '男' : '女'
|
||||
});
|
||||
setExpertCertStatus(CERT_STATUS.PENDING);
|
||||
showExpertCertModal.value = false;
|
||||
uni.hideLoading();
|
||||
uni.showModal({
|
||||
title: '申请已提交',
|
||||
content: '您的入驻申请已成功提交至平台审核中心,运营团队核准后即可自动开通接单工作台。',
|
||||
showCancel: false,
|
||||
confirmText: '我知道了'
|
||||
});
|
||||
} catch (e) {
|
||||
uni.hideLoading();
|
||||
uni.showToast({ title: e.message || '提交申请失败', icon: 'none' });
|
||||
}
|
||||
};
|
||||
|
||||
const applyExpert = () => {
|
||||
const certStatus = getExpertCertStatus();
|
||||
@@ -606,26 +768,15 @@ const applyExpert = () => {
|
||||
});
|
||||
return;
|
||||
}
|
||||
uni.showModal({
|
||||
title: '申请成为陪玩大神',
|
||||
content: '需开通实名认证以承接线上游戏与同城生活陪伴订单。是否确认提交当前用户实名认证?',
|
||||
confirmText: '提交入驻',
|
||||
cancelText: '取消',
|
||||
confirmColor: '#10B981',
|
||||
success: (res) => {
|
||||
if (res.confirm) {
|
||||
setExpertCertStatus(CERT_STATUS.APPROVED);
|
||||
uni.showToast({
|
||||
title: '实名认证已通过!接单权限已开通',
|
||||
icon: 'success',
|
||||
duration: 1500
|
||||
});
|
||||
uni.navigateTo({
|
||||
url: '/pages/expert/workspace'
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
expertCertForm.value = {
|
||||
realName: '',
|
||||
idCard: '',
|
||||
track: '同城陪吃',
|
||||
hourlyRate: expertDefaultRate.value,
|
||||
tags: []
|
||||
};
|
||||
showExpertCertModal.value = true;
|
||||
};
|
||||
|
||||
const contactService = () => {
|
||||
@@ -1475,6 +1626,73 @@ const navTo = (url) => {
|
||||
}
|
||||
}
|
||||
|
||||
.expert-cert-modal {
|
||||
max-height: 85vh;
|
||||
overflow-y: auto;
|
||||
|
||||
.cert-alert {
|
||||
background: rgba(99, 102, 241, 0.08);
|
||||
border: 1px solid rgba(99, 102, 241, 0.2);
|
||||
border-radius: 12rpx;
|
||||
padding: 16rpx 20rpx;
|
||||
margin-bottom: 24rpx;
|
||||
|
||||
.cert-alert-text {
|
||||
font-size: 24rpx;
|
||||
color: #6366F1;
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
|
||||
.track-chips, .tags-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 14rpx;
|
||||
margin-top: 10rpx;
|
||||
|
||||
.track-chip, .tag-pill {
|
||||
font-size: 24rpx;
|
||||
padding: 12rpx 24rpx;
|
||||
border-radius: 30rpx;
|
||||
background: var(--bg-card, #1E293B);
|
||||
color: #94A3B8;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&.active {
|
||||
background: #6366F1;
|
||||
color: #FFFFFF;
|
||||
border-color: #6366F1;
|
||||
box-shadow: 0 4rpx 12rpx rgba(99, 102, 241, 0.35);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.theme-warm .expert-cert-modal {
|
||||
.cert-alert {
|
||||
background: #FFF7ED;
|
||||
border-color: #FDBA74;
|
||||
|
||||
.cert-alert-text {
|
||||
color: #EA580C;
|
||||
}
|
||||
}
|
||||
|
||||
.track-chips .track-chip, .tags-chips .tag-pill {
|
||||
background: #FDFBF7;
|
||||
color: #78716C;
|
||||
border-color: #E7E2D8;
|
||||
|
||||
&.active {
|
||||
background: linear-gradient(135deg, #EA580C, #F97316);
|
||||
color: #FFFFFF;
|
||||
border-color: #EA580C;
|
||||
box-shadow: 0 4rpx 14rpx rgba(234, 88, 12, 0.35);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes glowSpin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
|
||||
@@ -721,6 +721,25 @@ async function seedInitialDataIfEmpty(conn: any): Promise<void> {
|
||||
]
|
||||
});
|
||||
|
||||
const financeRulesVal = JSON.stringify({
|
||||
platformRatePercent: 10.0,
|
||||
firstReferralPercent: 5.0,
|
||||
secondReferralPercent: 2.0,
|
||||
minWithdrawAmount: 50,
|
||||
maxWithdrawAmount: 20000,
|
||||
withdrawFeePercent: 0.0,
|
||||
withdrawDesc: '提现将自动发起微信企业付款打款至微信零钱,工作日预计2小时内到账。'
|
||||
});
|
||||
|
||||
const activityRulesVal = JSON.stringify({
|
||||
minParticipants: 2,
|
||||
maxParticipants: 10,
|
||||
defaultParticipants: 4,
|
||||
presetPerPersonFees: [200, 300, 500, 800, 1000],
|
||||
presetTotalFees: [500, 1000, 2000, 3000, 5000],
|
||||
defaultAaFee: 50
|
||||
});
|
||||
|
||||
const [cfgItemRows]: any = await conn.query('SELECT COUNT(*) as count FROM platform_configs');
|
||||
if (cfgItemRows[0].count === 0) {
|
||||
const customerServiceVal = JSON.stringify({
|
||||
@@ -758,8 +777,15 @@ async function seedInitialDataIfEmpty(conn: any): Promise<void> {
|
||||
('faq_list', '帮助中心与常见问题FAQ', ?, '小程序端帮助中心常见问答与指引', 1),
|
||||
('security_words', '私下引流与违规自定义敏感词库', ?, '全平台发帖、评论及订单防私下交易关键词实时过滤', 1),
|
||||
('home_popup', '小程序首页运营全局弹窗', ?, '节日活动推广、新用户专享首单优惠全局浮层弹窗', 1),
|
||||
('expert_rules', '大神技能标签库与定价区间规范', ?, '陪玩大神入驻审核、定价上下限限制与官方推荐技能标签库', 1);
|
||||
`, [customerServiceVal, safetyNoticeVal, protocolVal, communityTopicsVal, communityNoticeVal, faqListVal, securityWordsVal, homePopupVal, expertRulesVal]);
|
||||
('expert_rules', '大神技能标签库与定价区间规范', ?, '陪玩大神入驻审核、定价上下限限制与官方推荐技能标签库', 1),
|
||||
('finance_rules', '平台抽成与推广分销规则', ?, '平台佣金抽成比例、二级分销返润与佣金提现门槛限制', 1),
|
||||
('activity_rules', '同城组局发单与预算预设规范', ?, '招募席位上下限、快捷单人赏金预设与包场预算梯队', 1);
|
||||
`, [
|
||||
customerServiceVal, safetyNoticeVal, protocolVal,
|
||||
communityTopicsVal, communityNoticeVal, faqListVal,
|
||||
securityWordsVal, homePopupVal, expertRulesVal,
|
||||
financeRulesVal, activityRulesVal
|
||||
]);
|
||||
} else {
|
||||
// 增量保证各核心配置项完整就绪
|
||||
const [existingRows]: any = await conn.query('SELECT config_key FROM platform_configs');
|
||||
@@ -800,5 +826,17 @@ async function seedInitialDataIfEmpty(conn: any): Promise<void> {
|
||||
VALUES ('expert_rules', '大神技能标签库与定价区间规范', ?, '陪玩大神入驻审核、定价上下限限制与官方推荐技能标签库', 1)
|
||||
`, [expertRulesVal]);
|
||||
}
|
||||
if (!existingKeys.has('finance_rules')) {
|
||||
await conn.query(`
|
||||
INSERT INTO platform_configs (config_key, name, value_json, description, status)
|
||||
VALUES ('finance_rules', '平台抽成与推广分销规则', ?, '平台佣金抽成比例、二级分销返润与佣金提现门槛限制', 1)
|
||||
`, [financeRulesVal]);
|
||||
}
|
||||
if (!existingKeys.has('activity_rules')) {
|
||||
await conn.query(`
|
||||
INSERT INTO platform_configs (config_key, name, value_json, description, status)
|
||||
VALUES ('activity_rules', '同城组局发单与预算预设规范', ?, '招募席位上下限、快捷单人赏金预设与包场预算梯队', 1)
|
||||
`, [activityRulesVal]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -549,6 +549,44 @@ export class MysqlRepository {
|
||||
return this.getCompanionById(id);
|
||||
}
|
||||
|
||||
async applyCompanion(data: {
|
||||
userId: number;
|
||||
name: string;
|
||||
idCard: string;
|
||||
track: string;
|
||||
hourlyRate?: number;
|
||||
tags?: string[];
|
||||
avatar?: string;
|
||||
gender?: string;
|
||||
}): Promise<any> {
|
||||
const name = data.name.trim();
|
||||
const track = data.track || '同城陪吃';
|
||||
const hourlyRate = Number(data.hourlyRate) || 88;
|
||||
const tagsJson = JSON.stringify(data.tags || []);
|
||||
const avatar = data.avatar || 'https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=300';
|
||||
const gender = data.gender || '女';
|
||||
const maskedIdCard = data.idCard.length >= 8
|
||||
? data.idCard.substring(0, 6) + '********' + data.idCard.substring(data.idCard.length - 4)
|
||||
: '110101********0000';
|
||||
|
||||
const [existing]: any = await pool.query('SELECT * FROM companions WHERE name = ?', [name]);
|
||||
if (existing && existing.length > 0) {
|
||||
await pool.query(`
|
||||
UPDATE companions
|
||||
SET track = ?, hourly_rate = ?, tags = ?, avatar = ?, gender = ?, audit_status = '待审核', status = '待审核', id_card_masked = ?
|
||||
WHERE id = ?
|
||||
`, [track, hourlyRate, tagsJson, avatar, gender, maskedIdCard, existing[0].id]);
|
||||
return this.getCompanionById(existing[0].id);
|
||||
}
|
||||
|
||||
const [res]: any = await pool.query(`
|
||||
INSERT INTO companions (name, avatar, gender, age, track, rank_name, hourly_rate, voice_duration, voice_title, rating, order_count, status, audit_status, id_card_masked, tags, apply_time)
|
||||
VALUES (?, ?, ?, 23, ?, '申请中陪伴师', ?, 15, '热情语音介绍', 5.0, 0, '待审核', '待审核', ?, ?, NOW())
|
||||
`, [name, avatar, gender, track, hourlyRate, maskedIdCard, tagsJson]);
|
||||
|
||||
return this.getCompanionById(res.insertId);
|
||||
}
|
||||
|
||||
async auditCompanion(id: number, action: 'approve' | 'reject', reason?: string): Promise<any> {
|
||||
const auditStatus = action === 'approve' ? '已通过' : '已驳回';
|
||||
const status = action === 'approve' ? '接单中' : '已驳回';
|
||||
@@ -1824,6 +1862,20 @@ export class MysqlRepository {
|
||||
if (isNaN(amount) || amount <= 0) {
|
||||
throw new Error('提现金额必须大于0');
|
||||
}
|
||||
|
||||
// 动态读取财务提现配置限制门槛
|
||||
let minWithdraw = 50;
|
||||
try {
|
||||
const fConfig = await this.getPlatformConfigByKey('finance_rules');
|
||||
if (fConfig?.valueJson?.minWithdrawAmount) {
|
||||
minWithdraw = Number(fConfig.valueJson.minWithdrawAmount);
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
if (amount < minWithdraw) {
|
||||
throw new Error(`提现金额不能低于平台最低门槛 ¥${minWithdraw}`);
|
||||
}
|
||||
|
||||
const conn = await pool.getConnection();
|
||||
try {
|
||||
await conn.beginTransaction();
|
||||
|
||||
@@ -550,6 +550,57 @@ app.get('/api/companions/:id', async (req: Request, res: Response) => {
|
||||
}
|
||||
});
|
||||
|
||||
// 大神资质与实名入驻真实申请接口
|
||||
app.post('/api/companions/apply', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { userId, name, idCard, track, hourlyRate, tags, avatar, gender } = req.body;
|
||||
if (!name || !name.trim()) {
|
||||
return res.status(400).json({ code: 40000, message: '真实姓名不能为空' });
|
||||
}
|
||||
if (!idCard || !idCard.trim()) {
|
||||
return res.status(400).json({ code: 40000, message: '身份证号不能为空' });
|
||||
}
|
||||
|
||||
// 微信内容安全检测
|
||||
const sec = contentSecurityService.checkText(`${name} ${track || ''} ${(tags || []).join(' ')}`);
|
||||
if (!sec.pass) {
|
||||
return res.status(400).json({ code: sec.code, message: sec.message });
|
||||
}
|
||||
|
||||
// 校验价格与标签
|
||||
const expertRulesConfig = await repo.getPlatformConfigByKey('expert_rules');
|
||||
const rules = expertRulesConfig?.valueJson || { minHourlyRate: 30, maxHourlyRate: 500, maxTagsCount: 6 };
|
||||
if (hourlyRate) {
|
||||
const rateNum = Number(hourlyRate);
|
||||
if (rateNum < (rules.minHourlyRate || 30) || rateNum > (rules.maxHourlyRate || 500)) {
|
||||
return res.status(400).json({
|
||||
code: 40000,
|
||||
message: `服务单价需在 ¥${rules.minHourlyRate || 30} - ¥${rules.maxHourlyRate || 500} 之间`
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const companion = await repo.applyCompanion({
|
||||
userId: Number(userId) || 1001,
|
||||
name: name.trim(),
|
||||
idCard: idCard.trim(),
|
||||
track: track || '同城陪吃',
|
||||
hourlyRate: Number(hourlyRate) || rules.defaultHourlyRate || 88,
|
||||
tags: Array.isArray(tags) ? tags.slice(0, rules.maxTagsCount || 6) : [],
|
||||
avatar,
|
||||
gender
|
||||
});
|
||||
|
||||
res.json({
|
||||
code: 20000,
|
||||
data: companion,
|
||||
message: '大神实名认证申请已提交,等待运营人工审核'
|
||||
});
|
||||
} catch (err: any) {
|
||||
res.status(400).json({ code: 40000, message: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/companions/:id/audit', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const companionId = Number(req.params.id);
|
||||
|
||||
@@ -221,5 +221,47 @@ describe('ContentSecurityService & Compliance Architecture (TDD 微信内容安
|
||||
expect(emojiRegex.test(content)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('7. 财务分销规则、组局预算规范与大神入驻申请闭环验证 (finance_rules & activity_rules)', () => {
|
||||
it('MySQL 数据库与种子数据必须注册 finance_rules 与 activity_rules 配置项', () => {
|
||||
const mysqlPath = path.join(rootDir, 'server', 'src', 'db', 'mysql.ts');
|
||||
const content = fs.readFileSync(mysqlPath, 'utf-8');
|
||||
expect(content).toContain('finance_rules');
|
||||
expect(content).toContain('platformRatePercent');
|
||||
expect(content).toContain('firstReferralPercent');
|
||||
expect(content).toContain('activity_rules');
|
||||
expect(content).toContain('minParticipants');
|
||||
expect(content).toContain('presetPerPersonFees');
|
||||
});
|
||||
|
||||
it('管理后台 platform-config 必须包含 Tab 9 财务规则与 Tab 10 组局预算面板', () => {
|
||||
const adminConfigPath = path.join(rootDir, 'admin', 'arco-design-pro-vite', 'src', 'views', 'safety', 'platform-config', 'index.vue');
|
||||
const content = fs.readFileSync(adminConfigPath, 'utf-8');
|
||||
expect(content).toContain('finance_rules_config');
|
||||
expect(content).toContain('财务抽成与分销规则');
|
||||
expect(content).toContain('activity_rules_config');
|
||||
expect(content).toContain('组局发单与预算规范');
|
||||
expect(content).toContain('financeRulesForm');
|
||||
expect(content).toContain('activityRulesForm');
|
||||
});
|
||||
|
||||
it('服务端路由必须提供 POST /api/companions/apply 真实申请接口且校验姓名/身份证/安全', () => {
|
||||
const serverIndexPath = path.join(rootDir, 'server', 'src', 'index.ts');
|
||||
const content = fs.readFileSync(serverIndexPath, 'utf-8');
|
||||
expect(content).toContain("app.post('/api/companions/apply'");
|
||||
expect(content).toContain('真实姓名不能为空');
|
||||
expect(content).toContain('身份证号不能为空');
|
||||
});
|
||||
|
||||
it('小程序个人中心与发起组局页面必须动态绑定配置并实现门槛保护', () => {
|
||||
const userIndexContent = fs.readFileSync(path.join(rootDir, 'pwuniapp', 'pages', 'user', 'index.vue'), 'utf-8');
|
||||
const createActContent = fs.readFileSync(path.join(rootDir, 'pwuniapp', 'pages', 'activity', 'create.vue'), 'utf-8');
|
||||
expect(userIndexContent).toContain('applyExpertCert');
|
||||
expect(userIndexContent).toContain('showExpertCertModal');
|
||||
expect(userIndexContent).toContain('finance_rules');
|
||||
expect(createActContent).toContain('presetPerPersonFees');
|
||||
expect(createActContent).toContain('activity_rules');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
在新工单中引用
屏蔽一个用户