逻辑修改

This commit is contained in:
18631081161 2026-01-24 00:28:20 +08:00
parent 7892907809
commit 5981038a0f
18 changed files with 1118 additions and 72 deletions

View File

@ -1,37 +1,60 @@
<template>
<div class="commissions-container">
<!-- Statistics Cards -->
<el-row :gutter="20" class="stats-row">
<el-row :gutter="16" class="stats-row">
<el-col :span="6">
<el-card class="stat-card">
<el-card class="stat-card rmb-card" shadow="hover">
<div class="stat-content">
<div class="stat-value rmb">¥{{ stats.totalCommissionPaidRmb || stats.totalCommissionPaid }}</div>
<div class="stat-label">累计佣金支出(人民币)</div>
<div class="stat-icon">
<el-icon><Money /></el-icon>
</div>
<div class="stat-info">
<div class="stat-value">¥{{ stats.totalCommissionPaidRmb || stats.totalCommissionPaid }}</div>
<div class="stat-label">累计佣金支出(人民币)</div>
</div>
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card class="stat-card">
<el-card class="stat-card peso-card" shadow="hover">
<div class="stat-content">
<div class="stat-value peso">{{ stats.totalCommissionPaidPeso || '0.00' }}</div>
<div class="stat-label">累计佣金支出(比索)</div>
<div class="stat-icon">
<el-icon><Coin /></el-icon>
</div>
<div class="stat-info">
<div class="stat-value">{{ stats.totalCommissionPaidPeso || '0.00' }}</div>
<div class="stat-label">累计佣金支出(比索)</div>
</div>
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card class="stat-card">
<el-card class="stat-card count-card" shadow="hover">
<div class="stat-content">
<div class="stat-value">{{ stats.totalCommissionCount }}</div>
<div class="stat-label">佣金记录数</div>
<div class="stat-icon">
<el-icon><Document /></el-icon>
</div>
<div class="stat-info">
<div class="stat-value">{{ stats.totalCommissionCount }}</div>
<div class="stat-label">佣金记录数</div>
</div>
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card class="stat-card">
<el-card class="stat-card pending-card" shadow="hover">
<div class="stat-content">
<div class="stat-value rmb">¥{{ stats.pendingWithdrawalRmb || stats.pendingWithdrawal }}</div>
<div class="stat-value peso">{{ stats.pendingWithdrawalPeso || '0.00' }}</div>
<div class="stat-label">待提现金额</div>
<div class="stat-icon">
<el-icon><Wallet /></el-icon>
</div>
<div class="stat-info">
<div class="stat-value dual">
<span class="rmb">¥{{ stats.pendingWithdrawalRmb || stats.pendingWithdrawal }}</span>
<span class="divider">/</span>
<span class="peso">{{ stats.pendingWithdrawalPeso || '0.00' }}</span>
</div>
<div class="stat-label">待提现金额</div>
</div>
</div>
</el-card>
</el-col>
@ -44,13 +67,106 @@
<span>佣金比例配置</span>
</div>
</template>
<el-form :inline="true">
<el-form-item label="当前佣金比例">
<el-tag type="primary" size="large">{{ rateConfig.percentage }}</el-tag>
<!-- 系统默认比例 -->
<div class="config-section">
<h4>系统默认比例</h4>
<el-form :inline="true">
<el-form-item label="当前佣金比例">
<el-tag type="primary" size="large">{{ rateConfig.percentage }}</el-tag>
</el-form-item>
<el-form-item label="设置新比例">
<el-input-number
v-model="newRate"
:min="0"
:max="100"
:precision="2"
:step="0.5"
/>
<span class="rate-suffix">%</span>
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="savingRate" @click="handleSaveRate">保存</el-button>
</el-form-item>
</el-form>
</div>
<el-divider />
<!-- 用户专属比例 -->
<div class="config-section">
<h4>用户专属比例设置</h4>
<p class="section-desc">为特定用户设置专属佣金比例未设置的用户将使用系统默认比例</p>
<!-- 添加用户专属比例 -->
<el-form :inline="true" class="user-rate-form">
<el-form-item label="用户UID">
<el-input v-model="userRateForm.uid" placeholder="输入用户UID" style="width: 120px" />
</el-form-item>
<el-form-item label="佣金比例">
<el-input-number
v-model="userRateForm.rate"
:min="0"
:max="100"
:precision="2"
:step="0.5"
/>
<span class="rate-suffix">%</span>
</el-form-item>
<el-form-item>
<el-button type="success" :loading="savingUserRate" @click="handleAddUserRate">
<el-icon><Plus /></el-icon>
添加
</el-button>
</el-form-item>
</el-form>
<!-- 已设置专属比例的用户列表 -->
<el-table :data="usersWithCustomRates" stripe border size="small" style="margin-top: 16px">
<el-table-column prop="uid" label="UID" width="100" />
<el-table-column prop="nickname" label="昵称" width="150" />
<el-table-column prop="percentage" label="专属比例" width="120">
<template #default="{ row }">
<el-tag type="success">{{ row.percentage }}</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="200">
<template #default="{ row }">
<el-button type="primary" link size="small" @click="handleEditUserRate(row)">
<el-icon><Edit /></el-icon>
修改
</el-button>
<el-button type="danger" link size="small" @click="handleRemoveUserRate(row)">
<el-icon><Delete /></el-icon>
移除
</el-button>
</template>
</el-table-column>
</el-table>
<div v-if="usersWithCustomRates.length === 0" class="empty-tip">
暂无用户设置专属比例
</div>
</div>
<div class="config-hint">
<el-alert
title="注意:修改佣金比例仅对新创建的支付订单生效,不影响已计入的历史佣金记录。用户专属比例优先于系统默认比例。"
type="warning"
:closable="false"
show-icon
/>
</div>
</el-card>
<!-- Edit User Rate Dialog -->
<el-dialog v-model="editUserRateDialogVisible" title="修改用户佣金比例" width="400px">
<el-form label-width="100px">
<el-form-item label="用户">
<span>{{ editingUser?.nickname }} (UID: {{ editingUser?.uid }})</span>
</el-form-item>
<el-form-item label="设置新比例">
<el-form-item label="佣金比例">
<el-input-number
v-model="newRate"
v-model="editUserRateValue"
:min="0"
:max="100"
:precision="2"
@ -58,19 +174,12 @@
/>
<span class="rate-suffix">%</span>
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="savingRate" @click="handleSaveRate">保存</el-button>
</el-form-item>
</el-form>
<div class="config-hint">
<el-alert
title="注意:修改佣金比例仅对新创建的支付订单生效,不影响已计入的历史佣金记录。"
type="warning"
:closable="false"
show-icon
/>
</div>
</el-card>
<template #footer>
<el-button @click="editUserRateDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="savingUserRate" @click="handleSaveEditUserRate">保存</el-button>
</template>
</el-dialog>
<!-- Commissions Table -->
<el-card class="table-card">
@ -150,14 +259,25 @@
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Edit, Delete, Money, Coin, Document, Wallet } from '@element-plus/icons-vue'
import api from '@/utils/api'
// State
const loading = ref(false)
const savingRate = ref(false)
const savingUserRate = ref(false)
const commissions = ref([])
const newRate = ref(2)
const usersWithCustomRates = ref([])
const editUserRateDialogVisible = ref(false)
const editingUser = ref(null)
const editUserRateValue = ref(0)
const userRateForm = reactive({
uid: '',
rate: 5,
})
const stats = reactive({
totalCommissionPaid: '0.00',
@ -201,6 +321,17 @@ async function fetchRateConfig() {
}
}
async function fetchUsersWithCustomRates() {
try {
const response = await api.get('/api/v1/admin/commissions/config/user-commission-rates')
if (response.data.code === 0) {
usersWithCustomRates.value = response.data.data.users || []
}
} catch (error) {
console.error('Failed to fetch users with custom rates:', error)
}
}
async function fetchCommissions() {
loading.value = true
try {
@ -248,6 +379,114 @@ async function handleSaveRate() {
}
}
async function handleAddUserRate() {
if (!userRateForm.uid) {
ElMessage.error('请输入用户UID')
return
}
if (userRateForm.rate < 0 || userRateForm.rate > 100) {
ElMessage.error('佣金比例必须在 0-100% 之间')
return
}
savingUserRate.value = true
try {
// First find user by UID
const userResponse = await api.get('/api/v1/admin/users', {
params: { search: userRateForm.uid, limit: 1 }
})
if (userResponse.data.code !== 0 || !userResponse.data.data.users?.length) {
ElMessage.error('未找到该用户')
return
}
const user = userResponse.data.data.users[0]
// Set user commission rate
const response = await api.put(`/api/v1/admin/commissions/config/user-commission-rate/${user.id}`, {
rate: userRateForm.rate / 100,
})
if (response.data.code === 0) {
ElMessage.success('用户专属比例设置成功')
userRateForm.uid = ''
userRateForm.rate = 5
fetchUsersWithCustomRates()
} else {
ElMessage.error(response.data.error?.message || '设置失败')
}
} catch (error) {
console.error('Failed to add user rate:', error)
ElMessage.error(error.response?.data?.error?.message || '设置失败')
} finally {
savingUserRate.value = false
}
}
function handleEditUserRate(row) {
editingUser.value = row
editUserRateValue.value = row.commissionRate * 100
editUserRateDialogVisible.value = true
}
async function handleSaveEditUserRate() {
if (editUserRateValue.value < 0 || editUserRateValue.value > 100) {
ElMessage.error('佣金比例必须在 0-100% 之间')
return
}
savingUserRate.value = true
try {
const response = await api.put(`/api/v1/admin/commissions/config/user-commission-rate/${editingUser.value.userId}`, {
rate: editUserRateValue.value / 100,
})
if (response.data.code === 0) {
ElMessage.success('用户专属比例已更新')
editUserRateDialogVisible.value = false
fetchUsersWithCustomRates()
} else {
ElMessage.error(response.data.error?.message || '更新失败')
}
} catch (error) {
console.error('Failed to save user rate:', error)
ElMessage.error('更新失败')
} finally {
savingUserRate.value = false
}
}
async function handleRemoveUserRate(row) {
try {
await ElMessageBox.confirm(
`确定要移除用户 "${row.nickname}" (UID: ${row.uid}) 的专属佣金比例吗?移除后将使用系统默认比例。`,
'确认移除',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
}
)
const response = await api.put(`/api/v1/admin/commissions/config/user-commission-rate/${row.userId}`, {
rate: null,
})
if (response.data.code === 0) {
ElMessage.success('已移除用户专属比例')
fetchUsersWithCustomRates()
} else {
ElMessage.error(response.data.error?.message || '移除失败')
}
} catch (error) {
if (error !== 'cancel') {
console.error('Failed to remove user rate:', error)
ElMessage.error('移除失败')
}
}
}
function handlePageChange(page) {
pagination.page = page
fetchCommissions()
@ -268,6 +507,7 @@ function formatDate(date) {
onMounted(() => {
fetchStats()
fetchRateConfig()
fetchUsersWithCustomRates()
fetchCommissions()
})
</script>
@ -279,35 +519,145 @@ onMounted(() => {
}
.stat-card {
.stat-content {
text-align: center;
padding: 10px 0;
border-radius: 12px;
overflow: hidden;
transition: transform 0.3s ease;
.stat-value {
font-size: 28px;
font-weight: bold;
color: #409eff;
&.rmb {
color: #e6a23c;
}
&.peso {
color: #67c23a;
font-size: 20px;
&:hover {
transform: translateY(-4px);
}
.stat-content {
display: flex;
align-items: center;
padding: 8px 0;
.stat-icon {
width: 56px;
height: 56px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
margin-right: 16px;
flex-shrink: 0;
.el-icon {
font-size: 28px;
color: #fff;
}
}
.stat-label {
font-size: 14px;
color: #909399;
margin-top: 8px;
.stat-info {
flex: 1;
min-width: 0;
.stat-value {
font-size: 24px;
font-weight: 600;
line-height: 1.2;
margin-bottom: 4px;
&.dual {
font-size: 18px;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 4px;
.rmb {
color: #e6a23c;
}
.peso {
color: #67c23a;
}
.divider {
color: #c0c4cc;
font-weight: normal;
}
}
}
.stat-label {
font-size: 13px;
color: #909399;
}
}
}
&.rmb-card {
.stat-icon {
background: linear-gradient(135deg, #f6d365 0%, #e6a23c 100%);
}
.stat-value {
color: #e6a23c;
}
}
&.peso-card {
.stat-icon {
background: linear-gradient(135deg, #84fab0 0%, #67c23a 100%);
}
.stat-value {
color: #67c23a;
}
}
&.count-card {
.stat-icon {
background: linear-gradient(135deg, #667eea 0%, #409eff 100%);
}
.stat-value {
color: #409eff;
}
}
&.pending-card {
.stat-icon {
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
}
}
}
.config-card {
margin-bottom: 20px;
border-radius: 12px;
.config-section {
margin-bottom: 20px;
h4 {
margin: 0 0 16px 0;
font-size: 16px;
color: #303133;
font-weight: 600;
}
.section-desc {
font-size: 13px;
color: #909399;
margin: -8px 0 16px 0;
}
.user-rate-form {
background: linear-gradient(135deg, #f5f7fa 0%, #e4e7ed 100%);
padding: 20px;
border-radius: 10px;
border: 1px solid #ebeef5;
}
.empty-tip {
text-align: center;
color: #909399;
padding: 30px;
font-size: 14px;
background: #fafafa;
border-radius: 8px;
margin-top: 16px;
}
}
.rate-suffix {
margin-left: 8px;
@ -321,23 +671,23 @@ onMounted(() => {
}
.table-card {
border-radius: 12px;
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
font-weight: 600;
}
.payment-amount {
color: #e6a23c;
&.peso {
color: #67c23a;
}
font-weight: 500;
}
.commission-amount {
color: #67c23a;
font-weight: bold;
font-weight: 600;
}
.text-muted {

View File

@ -128,7 +128,7 @@
</el-card>
<!-- Create Dialog -->
<el-dialog v-model="createDialogVisible" title="创建支付订单" width="500px">
<el-dialog v-model="createDialogVisible" title="创建支付订单" width="550px">
<el-form ref="createFormRef" :model="createForm" :rules="createRules" label-width="100px">
<el-form-item label="用户ID" prop="userId">
<el-input v-model="createForm.userId" placeholder="输入用户UID6位数字" />
@ -157,6 +157,22 @@
value-format="YYYY-MM-DD HH:mm:ss"
/>
</el-form-item>
<el-form-item label="支付凭证" prop="paymentProof" required>
<el-upload
class="payment-proof-uploader"
:action="uploadUrl"
:headers="uploadHeaders"
:show-file-list="false"
:on-success="handleUploadSuccess"
:on-error="handleUploadError"
:before-upload="beforeUpload"
accept="image/*"
>
<img v-if="createForm.paymentProof" :src="getImageUrl(createForm.paymentProof)" class="payment-proof-image" />
<el-icon v-else class="payment-proof-uploader-icon"><Plus /></el-icon>
</el-upload>
<div class="upload-tip">点击上传支付凭证截图必填</div>
</el-form-item>
<el-form-item label="关联预约单">
<el-input v-model="createForm.appointmentId" placeholder="可选输入预约单ID" />
</el-form-item>
@ -190,6 +206,16 @@
<el-descriptions-item label="服务内容" :span="2">{{ currentOrder.serviceContent }}</el-descriptions-item>
<el-descriptions-item label="支付时间">{{ formatDate(currentOrder.paymentTime) }}</el-descriptions-item>
<el-descriptions-item label="创建时间">{{ formatDate(currentOrder.createdAt) }}</el-descriptions-item>
<el-descriptions-item label="支付凭证" :span="2">
<el-image
v-if="currentOrder.paymentProof"
:src="getImageUrl(currentOrder.paymentProof)"
:preview-src-list="[getImageUrl(currentOrder.paymentProof)]"
fit="contain"
style="width: 120px; height: 120px;"
/>
<span v-else>-</span>
</el-descriptions-item>
<el-descriptions-item label="备注" :span="2">{{ currentOrder.notes || '-' }}</el-descriptions-item>
<el-descriptions-item label="创建人">{{ currentOrder.creator?.realName || currentOrder.creator?.username }}</el-descriptions-item>
<el-descriptions-item label="关联预约单">{{ currentOrder.appointment?.appointmentNo || '-' }}</el-descriptions-item>
@ -232,8 +258,9 @@
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { ref, reactive, computed, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus } from '@element-plus/icons-vue'
import api from '@/utils/api'
// State
@ -246,6 +273,19 @@ const detailsDialogVisible = ref(false)
const currentOrder = ref(null)
const createFormRef = ref(null)
// Upload config
const uploadUrl = computed(() => {
const baseUrl = import.meta.env.VITE_API_BASE_URL || ''
return `${baseUrl}/api/v1/admin/upload/image`
})
const uploadHeaders = computed(() => {
const token = localStorage.getItem('admin_token')
return {
Authorization: token ? `Bearer ${token}` : ''
}
})
const filters = reactive({
userId: '',
status: '',
@ -269,6 +309,7 @@ const createForm = reactive({
amountRmb: null,
serviceContent: '',
paymentTime: '',
paymentProof: '',
appointmentId: '',
notes: '',
})
@ -277,6 +318,7 @@ const createRules = {
userId: [{ required: true, message: '请输入用户ID', trigger: 'blur' }],
serviceContent: [{ required: true, message: '请输入服务内容', trigger: 'blur' }],
paymentTime: [{ required: true, message: '请选择支付时间', trigger: 'change' }],
paymentProof: [{ required: true, message: '请上传支付凭证', trigger: 'change' }],
}
// Methods
@ -359,12 +401,50 @@ function showCreateDialog() {
amountRmb: null,
serviceContent: '',
paymentTime: currentTime,
paymentProof: '',
appointmentId: '',
notes: '',
})
createDialogVisible.value = true
}
// Upload handlers
function getImageUrl(path) {
if (!path) return ''
if (path.startsWith('http')) return path
const baseUrl = import.meta.env.VITE_API_BASE_URL || ''
return `${baseUrl}${path}`
}
function handleUploadSuccess(response) {
if (response.code === 0 && response.data?.url) {
createForm.paymentProof = response.data.url
ElMessage.success('上传成功')
} else {
ElMessage.error(response.error?.message || '上传失败')
}
}
function handleUploadError(error) {
console.error('Upload error:', error)
ElMessage.error('上传失败,请重试')
}
function beforeUpload(file) {
const isImage = file.type.startsWith('image/')
const isLt5M = file.size / 1024 / 1024 < 5
if (!isImage) {
ElMessage.error('只能上传图片文件')
return false
}
if (!isLt5M) {
ElMessage.error('图片大小不能超过 5MB')
return false
}
return true
}
async function handleCreate() {
if (!createFormRef.value) return
@ -376,6 +456,11 @@ async function handleCreate() {
ElMessage.warning('请至少填写一种货币金额(比索或人民币)')
return
}
if (!createForm.paymentProof) {
ElMessage.warning('请上传支付凭证图片')
return
}
await createFormRef.value.validate(async (valid) => {
if (!valid) return
@ -388,6 +473,7 @@ async function handleCreate() {
amountRmb: hasRmb ? createForm.amountRmb : undefined,
serviceContent: createForm.serviceContent,
paymentTime: createForm.paymentTime,
paymentProof: createForm.paymentProof,
appointmentId: createForm.appointmentId || undefined,
notes: createForm.notes || undefined,
})
@ -618,5 +704,42 @@ onMounted(() => {
font-weight: bold;
font-size: 16px;
}
.payment-proof-uploader {
:deep(.el-upload) {
border: 1px dashed #d9d9d9;
border-radius: 6px;
cursor: pointer;
position: relative;
overflow: hidden;
transition: border-color 0.3s;
width: 148px;
height: 148px;
display: flex;
align-items: center;
justify-content: center;
&:hover {
border-color: #409eff;
}
}
}
.payment-proof-uploader-icon {
font-size: 28px;
color: #8c939d;
}
.payment-proof-image {
width: 146px;
height: 146px;
object-fit: contain;
}
.upload-tip {
font-size: 12px;
color: #909399;
margin-top: 8px;
}
}
</style>

View File

@ -178,6 +178,22 @@
<el-descriptions-item label="拒绝原因" :span="2" v-if="withdrawalDetails.rejectionReason">
<span class="rejection-reason">{{ withdrawalDetails.rejectionReason }}</span>
</el-descriptions-item>
<el-descriptions-item label="支付凭证" :span="2" v-if="withdrawalDetails.paymentProof">
<el-image
:src="getImageUrl(withdrawalDetails.paymentProof)"
:preview-src-list="[getImageUrl(withdrawalDetails.paymentProof)]"
fit="contain"
style="width: 200px; height: 200px; cursor: pointer;"
:preview-teleported="true"
>
<template #error>
<div class="image-error">
<el-icon><Picture /></el-icon>
<span>加载失败</span>
</div>
</template>
</el-image>
</el-descriptions-item>
</el-descriptions>
<!-- User Info -->
@ -216,7 +232,7 @@
<el-dialog
v-model="approveDialogVisible"
title="批准提现"
width="450px"
width="500px"
destroy-on-close
>
<div class="approve-dialog-content">
@ -234,7 +250,23 @@
</div>
</template>
</el-alert>
<el-form :model="approveForm" label-width="80px" class="mt-20">
<el-form :model="approveForm" label-width="100px" class="mt-20">
<el-form-item label="支付凭证" required>
<el-upload
class="payment-proof-uploader"
:action="uploadUrl"
:headers="uploadHeaders"
:show-file-list="false"
:on-success="handleApproveUploadSuccess"
:on-error="handleUploadError"
:before-upload="beforeUpload"
accept="image/*"
>
<img v-if="approveForm.paymentProof" :src="getImageUrl(approveForm.paymentProof)" class="payment-proof-image" />
<el-icon v-else class="payment-proof-uploader-icon"><Plus /></el-icon>
</el-upload>
<div class="upload-tip">点击上传支付凭证截图必填</div>
</el-form-item>
<el-form-item label="备注">
<el-input
v-model="approveForm.notes"
@ -298,9 +330,9 @@
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { ref, reactive, computed, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { Picture } from '@element-plus/icons-vue'
import { Picture, Plus } from '@element-plus/icons-vue'
import api from '@/utils/api'
// State
@ -321,6 +353,19 @@ const sortConfig = reactive({
sortOrder: 'DESC'
})
// Upload config
const uploadUrl = computed(() => {
const baseUrl = import.meta.env.VITE_API_BASE_URL || ''
return `${baseUrl}/api/v1/admin/upload/image`
})
const uploadHeaders = computed(() => {
const token = localStorage.getItem('admin_token')
return {
Authorization: token ? `Bearer ${token}` : ''
}
})
// Details dialog state
const detailsDialogVisible = ref(false)
const detailsLoading = ref(false)
@ -335,7 +380,8 @@ const approveForm = reactive({
amount: '',
currency: 'CNY',
userName: '',
notes: ''
notes: '',
paymentProof: ''
})
// Reject dialog state
@ -458,6 +504,7 @@ function handleApprove(row) {
approveForm.currency = row.currency || 'CNY'
approveForm.userName = row.user?.nickname || row.user?.realName || '-'
approveForm.notes = ''
approveForm.paymentProof = ''
approveDialogVisible.value = true
}
@ -470,10 +517,16 @@ function handleApproveFromDetails() {
// Confirm approve
async function confirmApprove() {
if (!approveForm.paymentProof) {
ElMessage.warning('请上传支付凭证图片')
return
}
approving.value = true
try {
const response = await api.put(`/api/v1/admin/withdrawals/${approveForm.withdrawalId}/approve`, {
notes: approveForm.notes
notes: approveForm.notes,
paymentProof: approveForm.paymentProof
})
if (response.data.code === 0) {
@ -490,6 +543,43 @@ async function confirmApprove() {
}
}
// Upload handlers
function getImageUrl(path) {
if (!path) return ''
if (path.startsWith('http')) return path
const baseUrl = import.meta.env.VITE_API_BASE_URL || ''
return `${baseUrl}${path}`
}
function handleApproveUploadSuccess(response) {
if (response.code === 0 && response.data?.url) {
approveForm.paymentProof = response.data.url
ElMessage.success('上传成功')
} else {
ElMessage.error(response.error?.message || '上传失败')
}
}
function handleUploadError(error) {
console.error('Upload error:', error)
ElMessage.error('上传失败,请重试')
}
function beforeUpload(file) {
const isImage = file.type.startsWith('image/')
const isLt5M = file.size / 1024 / 1024 < 5
if (!isImage) {
ElMessage.error('只能上传图片文件')
return false
}
if (!isLt5M) {
ElMessage.error('图片大小不能超过 5MB')
return false
}
return true
}
// Handle reject
function handleReject(row) {
rejectForm.withdrawalId = row.id
@ -769,6 +859,43 @@ onMounted(() => {
.mt-20 {
margin-top: 20px;
}
.payment-proof-uploader {
:deep(.el-upload) {
border: 1px dashed #d9d9d9;
border-radius: 6px;
cursor: pointer;
position: relative;
overflow: hidden;
transition: border-color 0.3s;
width: 148px;
height: 148px;
display: flex;
align-items: center;
justify-content: center;
&:hover {
border-color: #409eff;
}
}
}
.payment-proof-uploader-icon {
font-size: 28px;
color: #8c939d;
}
.payment-proof-image {
width: 146px;
height: 146px;
object-fit: contain;
}
.upload-tip {
font-size: 12px;
color: #909399;
margin-top: 8px;
}
}
}
</style>

View File

@ -0,0 +1,41 @@
/**
* 添加用户佣金比例字段
* 运行: node add-commission-rate-field.js
*/
const { sequelize } = require('./src/config/database');
async function addCommissionRateField() {
try {
console.log('Connecting to database...');
await sequelize.authenticate();
console.log('Database connected.');
// Check if column exists
const [results] = await sequelize.query(`
SELECT COLUMN_NAME
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_NAME = 'user' AND COLUMN_NAME = 'commission_rate'
`);
if (results.length > 0) {
console.log('Column commission_rate already exists.');
} else {
console.log('Adding commission_rate column...');
await sequelize.query(`
ALTER TABLE user
ADD COLUMN commission_rate DECIMAL(5,4) NULL
COMMENT '用户专属佣金比例如0.05表示5%),为空则使用系统默认比例'
`);
console.log('Column commission_rate added successfully.');
}
console.log('Done!');
process.exit(0);
} catch (error) {
console.error('Error:', error);
process.exit(1);
}
}
addCommissionRateField();

View File

@ -0,0 +1,39 @@
/**
* Migration script to add payment_proof field to payment_orders table
*/
const { sequelize } = require('./src/config/database');
async function addPaymentProofField() {
try {
console.log('Connecting to database...');
await sequelize.authenticate();
console.log('Database connected.');
// Check if column already exists
const [results] = await sequelize.query(`
SELECT COLUMN_NAME
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_NAME = 'payment_orders' AND COLUMN_NAME = 'payment_proof'
`);
if (results.length > 0) {
console.log('Column payment_proof already exists in payment_orders table.');
process.exit(0);
}
// Add the column
console.log('Adding payment_proof column to payment_orders table...');
await sequelize.query(`
ALTER TABLE payment_orders
ADD COLUMN payment_proof VARCHAR(500) NULL COMMENT '支付凭证图片URL'
`);
console.log('Successfully added payment_proof column to payment_orders table.');
process.exit(0);
} catch (error) {
console.error('Migration failed:', error);
process.exit(1);
}
}
addPaymentProofField();

View File

@ -0,0 +1,39 @@
/**
* Migration script to add payment_proof field to withdrawal table
*/
const { sequelize } = require('./src/config/database');
async function addPaymentProofField() {
try {
console.log('Connecting to database...');
await sequelize.authenticate();
console.log('Database connected.');
// Check if column already exists
const [results] = await sequelize.query(`
SELECT COLUMN_NAME
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_NAME = 'withdrawal' AND COLUMN_NAME = 'payment_proof'
`);
if (results.length > 0) {
console.log('Column payment_proof already exists in withdrawal table.');
process.exit(0);
}
// Add the column
console.log('Adding payment_proof column to withdrawal table...');
await sequelize.query(`
ALTER TABLE withdrawal
ADD COLUMN payment_proof VARCHAR(500) NULL COMMENT '支付凭证图片URL管理员批准时上传'
`);
console.log('Successfully added payment_proof column to withdrawal table.');
process.exit(0);
} catch (error) {
console.error('Migration failed:', error);
process.exit(1);
}
}
addPaymentProofField();

View File

@ -143,9 +143,156 @@ const setCommissionRate = async (req, res) => {
}
};
/**
* Get user's commission rate
* GET /api/v1/admin/config/user-commission-rate/:userId
*/
const getUserCommissionRate = async (req, res) => {
try {
const { userId } = req.params;
const rate = await commissionConfigService.getUserCommissionRate(userId);
const systemRate = await commissionConfigService.getCommissionRate();
// Get user info
const User = require('../models/User');
const user = await User.findByPk(userId, {
attributes: ['id', 'uid', 'nickname', 'commissionRate'],
});
if (!user) {
return res.status(404).json({
code: 404,
success: false,
error: {
code: 'USER_NOT_FOUND',
message: 'User not found',
},
});
}
const hasCustomRate = user.commissionRate !== null && user.commissionRate !== undefined;
return res.status(200).json({
code: 0,
success: true,
data: {
userId: user.id,
uid: user.uid,
nickname: user.nickname,
commissionRate: rate,
percentage: `${(rate * 100).toFixed(2)}%`,
hasCustomRate,
customRate: hasCustomRate ? parseFloat(user.commissionRate) : null,
systemRate,
systemPercentage: `${(systemRate * 100).toFixed(2)}%`,
},
});
} catch (error) {
return res.status(500).json({
code: 500,
success: false,
error: {
code: 'GET_USER_RATE_ERROR',
message: 'Failed to get user commission rate',
details: error.message,
},
});
}
};
/**
* Set user's commission rate
* PUT /api/v1/admin/config/user-commission-rate/:userId
*/
const setUserCommissionRate = async (req, res) => {
try {
const { userId } = req.params;
const { rate } = req.body;
// rate can be null to clear custom rate
const result = await commissionConfigService.setUserCommissionRate(userId, rate);
return res.status(200).json({
code: 0,
success: true,
data: result,
message: rate === null || rate === ''
? 'User commission rate cleared, will use system default'
: 'User commission rate updated successfully',
});
} catch (error) {
if (error.message === 'User not found') {
return res.status(404).json({
code: 404,
success: false,
error: {
code: 'USER_NOT_FOUND',
message: 'User not found',
},
});
}
if (error.message.includes('Invalid commission rate')) {
return res.status(400).json({
code: 400,
success: false,
error: {
code: 'INVALID_RATE',
message: error.message,
},
});
}
return res.status(500).json({
code: 500,
success: false,
error: {
code: 'SET_USER_RATE_ERROR',
message: 'Failed to set user commission rate',
details: error.message,
},
});
}
};
/**
* Get all users with custom commission rates
* GET /api/v1/admin/config/user-commission-rates
*/
const getUsersWithCustomRates = async (req, res) => {
try {
const users = await commissionConfigService.getUsersWithCustomRates();
const systemRate = await commissionConfigService.getCommissionRate();
return res.status(200).json({
code: 0,
success: true,
data: {
systemRate,
systemPercentage: `${(systemRate * 100).toFixed(2)}%`,
users,
total: users.length,
},
});
} catch (error) {
return res.status(500).json({
code: 500,
success: false,
error: {
code: 'GET_CUSTOM_RATES_ERROR',
message: 'Failed to get users with custom rates',
details: error.message,
},
});
}
};
module.exports = {
getCommissions,
getCommissionStats,
getCommissionRate,
setCommissionRate,
getUserCommissionRate,
setUserCommissionRate,
getUsersWithCustomRates,
};

View File

@ -12,7 +12,7 @@ const paymentOrderService = require('../services/paymentOrderService');
const createPaymentOrder = async (req, res) => {
try {
const adminId = req.adminId || req.admin?.id;
const { userId, appointmentId, amount, amountPeso, amountRmb, serviceContent, paymentTime, notes } = req.body;
const { userId, appointmentId, amount, amountPeso, amountRmb, serviceContent, paymentTime, notes, paymentProof } = req.body;
const result = await paymentOrderService.createPaymentOrder({
userId: userId ? userId.trim() : userId, // Trim whitespace
@ -23,6 +23,7 @@ const createPaymentOrder = async (req, res) => {
serviceContent,
paymentTime,
notes,
paymentProof,
}, adminId);
return res.status(201).json({

View File

@ -82,12 +82,23 @@ const approveWithdrawal = async (req, res) => {
try {
const { id } = req.params;
const adminId = req.admin.userId;
const { notes } = req.body;
const { notes, paymentProof } = req.body;
if (!paymentProof) {
return res.status(400).json({
success: false,
error: {
code: 'PAYMENT_PROOF_REQUIRED',
message: '请上传支付凭证图片',
},
});
}
const withdrawal = await adminWithdrawalService.approveWithdrawal(
id,
adminId,
notes
notes,
paymentProof
);
return res.status(200).json({

View File

@ -97,6 +97,12 @@ const PaymentOrder = sequelize.define('PaymentOrder', {
field: 'cancel_reason',
comment: '取消原因',
},
paymentProof: {
type: DataTypes.STRING(500),
allowNull: true,
field: 'payment_proof',
comment: '支付凭证图片URL',
},
}, {
tableName: 'payment_orders',
timestamps: true,

View File

@ -92,6 +92,12 @@ const User = sequelize.define('User', {
defaultValue: 'active',
allowNull: false,
},
commissionRate: {
type: DataTypes.DECIMAL(5, 4),
allowNull: true,
field: 'commission_rate',
comment: '用户专属佣金比例如0.05表示5%),为空则使用系统默认比例',
},
}, {
tableName: 'user',
timestamps: true,

View File

@ -75,6 +75,12 @@ const Withdrawal = sequelize.define('Withdrawal', {
allowNull: true,
field: 'completed_at',
},
paymentProof: {
type: DataTypes.STRING(500),
allowNull: true,
field: 'payment_proof',
comment: '支付凭证图片URL管理员批准时上传',
},
}, {
tableName: 'withdrawal',
timestamps: true,

View File

@ -59,4 +59,41 @@ router.put(
adminCommissionController.setCommissionRate
);
/**
* @route GET /api/v1/admin/config/user-commission-rates
* @desc Get all users with custom commission rates
* @access Private (Admin)
*/
router.get(
'/config/user-commission-rates',
authenticateAdmin,
requireRole(['super_admin', 'admin']),
adminCommissionController.getUsersWithCustomRates
);
/**
* @route GET /api/v1/admin/config/user-commission-rate/:userId
* @desc Get user's commission rate
* @access Private (Admin)
*/
router.get(
'/config/user-commission-rate/:userId',
authenticateAdmin,
requireRole(['super_admin', 'admin']),
adminCommissionController.getUserCommissionRate
);
/**
* @route PUT /api/v1/admin/config/user-commission-rate/:userId
* @desc Set user's commission rate
* @access Private (Super Admin only)
*/
router.put(
'/config/user-commission-rate/:userId',
authenticateAdmin,
requireRole(['super_admin']),
logAdminOperation,
adminCommissionController.setUserCommissionRate
);
module.exports = router;

View File

@ -37,9 +37,10 @@ const getUserList = async (options = {}) => {
// Build where clause
const where = {};
// Search by nickname, real name, phone, or wechat ID
// Search by uid, nickname, real name, phone, or wechat ID
if (search) {
where[Op.or] = [
{ uid: { [Op.like]: `%${search}%` } },
{ nickname: { [Op.like]: `%${search}%` } },
{ realName: { [Op.like]: `%${search}%` } },
{ phone: { [Op.like]: `%${search}%` } },
@ -370,6 +371,7 @@ const exportUsersToCSV = async (options = {}) => {
if (search) {
where[Op.or] = [
{ uid: { [Op.like]: `%${search}%` } },
{ nickname: { [Op.like]: `%${search}%` } },
{ realName: { [Op.like]: `%${search}%` } },
{ phone: { [Op.like]: `%${search}%` } },

View File

@ -85,6 +85,7 @@ const getWithdrawalList = async (options = {}) => {
reviewedAt: withdrawal.reviewedAt,
rejectionReason: withdrawal.rejectionReason,
completedAt: withdrawal.completedAt,
paymentProof: withdrawal.paymentProof,
createdAt: withdrawal.createdAt,
updatedAt: withdrawal.updatedAt,
user: withdrawal.user ? {
@ -113,9 +114,10 @@ const getWithdrawalList = async (options = {}) => {
* @param {String} withdrawalId - Withdrawal ID
* @param {String} adminId - Admin ID performing the action
* @param {String} notes - Optional notes
* @param {String} paymentProof - Payment proof image URL (required)
* @returns {Promise<Object>} Updated withdrawal
*/
const approveWithdrawal = async (withdrawalId, adminId, notes = '') => {
const approveWithdrawal = async (withdrawalId, adminId, notes = '', paymentProof = '') => {
const transaction = await sequelize.transaction();
try {
@ -147,6 +149,7 @@ const approveWithdrawal = async (withdrawalId, adminId, notes = '') => {
reviewedBy: adminId,
reviewedAt: new Date(),
completedAt: new Date(),
paymentProof: paymentProof || null,
}, { transaction });
await transaction.commit();
@ -169,6 +172,7 @@ const approveWithdrawal = async (withdrawalId, adminId, notes = '') => {
reviewedBy: withdrawal.reviewedBy,
reviewedAt: withdrawal.reviewedAt,
completedAt: withdrawal.completedAt,
paymentProof: withdrawal.paymentProof,
userBalance: currentBalance,
};
} catch (error) {
@ -290,6 +294,7 @@ const getWithdrawalDetails = async (withdrawalId) => {
reviewedAt: withdrawal.reviewedAt,
rejectionReason: withdrawal.rejectionReason,
completedAt: withdrawal.completedAt,
paymentProof: withdrawal.paymentProof,
createdAt: withdrawal.createdAt,
updatedAt: withdrawal.updatedAt,
user: withdrawal.user ? {

View File

@ -1,4 +1,5 @@
const Config = require('../models/Config');
const User = require('../models/User');
/**
* Commission Config Service
@ -30,6 +31,101 @@ const getCommissionRate = async () => {
return rate;
};
/**
* Get commission rate for a specific user
* Returns user's custom rate if set, otherwise returns system default rate
* @param {string} userId - User ID
* @returns {number} Commission rate (e.g., 0.02 for 2%)
*/
const getUserCommissionRate = async (userId) => {
if (!userId) {
return getCommissionRate();
}
const user = await User.findByPk(userId, {
attributes: ['commissionRate'],
});
// If user has custom commission rate, use it
if (user && user.commissionRate !== null && user.commissionRate !== undefined) {
const rate = parseFloat(user.commissionRate);
if (!isNaN(rate) && rate >= 0 && rate <= 1) {
return rate;
}
}
// Otherwise use system default rate
return getCommissionRate();
};
/**
* Set user's custom commission rate
* @param {string} userId - User ID
* @param {number|null} rate - Commission rate (null to clear custom rate)
* @returns {Object} Updated user info
*/
const setUserCommissionRate = async (userId, rate) => {
const user = await User.findByPk(userId);
if (!user) {
throw new Error('User not found');
}
// If rate is null, clear custom rate
if (rate === null || rate === '') {
user.commissionRate = null;
await user.save();
return {
userId: user.id,
uid: user.uid,
nickname: user.nickname,
commissionRate: null,
useSystemDefault: true,
};
}
// Validate rate
const rateValue = parseFloat(rate);
if (isNaN(rateValue) || rateValue < 0 || rateValue > 1) {
throw new Error('Invalid commission rate. Must be between 0 and 1.');
}
user.commissionRate = rateValue;
await user.save();
return {
userId: user.id,
uid: user.uid,
nickname: user.nickname,
commissionRate: rateValue,
percentage: `${(rateValue * 100).toFixed(2)}%`,
useSystemDefault: false,
};
};
/**
* Get users with custom commission rates
* @returns {Array} List of users with custom rates
*/
const getUsersWithCustomRates = async () => {
const users = await User.findAll({
where: {
commissionRate: {
[require('sequelize').Op.ne]: null,
},
},
attributes: ['id', 'uid', 'nickname', 'commissionRate', 'createdAt'],
order: [['createdAt', 'DESC']],
});
return users.map(user => ({
userId: user.id,
uid: user.uid,
nickname: user.nickname,
commissionRate: parseFloat(user.commissionRate),
percentage: `${(parseFloat(user.commissionRate) * 100).toFixed(2)}%`,
}));
};
/**
* Set commission rate
* @param {number} rate - Commission rate (e.g., 0.02 for 2%)
@ -89,7 +185,10 @@ const getCommissionRateConfig = async () => {
module.exports = {
getCommissionRate,
getUserCommissionRate,
setCommissionRate,
setUserCommissionRate,
getUsersWithCustomRates,
getCommissionRateConfig,
DEFAULT_COMMISSION_RATE,
COMMISSION_RATE_KEY,

View File

@ -35,8 +35,8 @@ const calculateCommission = async (paymentOrderId) => {
return null; // User was not invited, no commission
}
// Get current commission rate
const commissionRate = await commissionConfigService.getCommissionRate();
// Get commission rate for the inviter (user-specific or system default)
const commissionRate = await commissionConfigService.getUserCommissionRate(inviteeUser.invitedBy);
// Collect all currency amounts to process
const currencyAmounts = [];

View File

@ -28,7 +28,7 @@ const generateOrderNo = () => {
* @returns {Object} Created payment order with commission info
*/
const createPaymentOrder = async (data, adminId) => {
const { userId, appointmentId, amount, amountPeso, amountRmb, serviceContent, paymentTime, notes } = data;
const { userId, appointmentId, amount, amountPeso, amountRmb, serviceContent, paymentTime, notes, paymentProof } = data;
// Validate required fields
if (!userId) {
@ -49,6 +49,9 @@ const createPaymentOrder = async (data, adminId) => {
if (!paymentTime) {
throw new Error('请选择支付时间');
}
if (!paymentProof) {
throw new Error('请上传支付凭证图片');
}
// Verify user exists - support both UUID and UID (6-digit number)
let user;
@ -112,6 +115,7 @@ const createPaymentOrder = async (data, adminId) => {
serviceContent,
paymentTime: new Date(paymentTime),
notes: notes || null,
paymentProof: paymentProof || null,
createdBy: adminId,
status: 'active',
});
@ -150,6 +154,7 @@ const createPaymentOrder = async (data, adminId) => {
amountRmb: paymentOrder.amountRmb ? parseFloat(paymentOrder.amountRmb).toFixed(2) : null,
serviceContent: paymentOrder.serviceContent,
paymentTime: paymentOrder.paymentTime,
paymentProof: paymentOrder.paymentProof,
notes: paymentOrder.notes,
status: paymentOrder.status,
createdAt: paymentOrder.createdAt,
@ -275,6 +280,7 @@ const getPaymentOrders = async (options = {}) => {
amountRmb: order.amountRmb ? parseFloat(order.amountRmb).toFixed(2) : null,
serviceContent: order.serviceContent,
paymentTime: order.paymentTime,
paymentProof: order.paymentProof,
notes: order.notes,
status: order.status,
creator: order.creator,
@ -364,6 +370,7 @@ const getPaymentOrderById = async (orderId) => {
amountRmb: order.amountRmb ? parseFloat(order.amountRmb).toFixed(2) : null,
serviceContent: order.serviceContent,
paymentTime: order.paymentTime,
paymentProof: order.paymentProof,
notes: order.notes,
status: order.status,
cancelReason: order.cancelReason,