分账页面

This commit is contained in:
凌尛 2026-06-16 10:42:24 +08:00
parent 1d2a7d9a30
commit 9096e22899
3 changed files with 749 additions and 0 deletions

50
src/api/splitAccount.js Normal file
View File

@ -0,0 +1,50 @@
import request from '@/utils/request'
/**
* 分账配置 - API
*/
/**
* 查询分账配置列表
* @param {Object} params - { page, page_size }
* @returns {Promise}
*/
export const fetchSplitAccountList = (params = {}) => {
return request.get('/split-account-config/list', { params })
}
/**
* 查询分账配置详情
* @param {number|string} id - 配置ID
* @returns {Promise}
*/
export const fetchSplitAccountDetail = (id) => {
return request.get('/split-account-config/detail', { params: { id } })
}
/**
* 新增分账配置
* @param {Object} data - { rule_name, rule_value, status, description }
* @returns {Promise}
*/
export const createSplitAccount = (data) => {
return request.post('/split-account-config/create', data)
}
/**
* 修改分账配置
* @param {Object} data - { id, rule_name, rule_value, status, description }
* @returns {Promise}
*/
export const updateSplitAccount = (data) => {
return request.put('/split-account-config/update', data)
}
/**
* 删除分账配置
* @param {number|string} id - 配置ID
* @returns {Promise}
*/
export const deleteSplitAccount = (id) => {
return request.delete('/split-account-config/delete', { params: { id } })
}

View File

@ -0,0 +1,356 @@
<template>
<div class="split-account-config">
<!-- 页面头部 -->
<div class="page-header" style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;">
<h3 style="margin:0;">分账配置</h3>
<el-button type="primary" @click="handleAdd">新增配置</el-button>
</div>
<!-- 搜索条件 -->
<div style="display:flex;gap:12px;margin-bottom:12px;">
<el-input v-model="keyword" placeholder="配置名称搜索" clearable style="width:240px;" @keyup.enter="handleSearch" @clear="handleSearch" />
<el-button type="primary" @click="handleSearch">查询</el-button>
</div>
<!-- 列表 -->
<el-table :data="configList" v-loading="loading" stripe border style="width:100%;">
<el-table-column prop="id" label="ID" width="60" align="center" />
<el-table-column prop="rule_name" label="配置名称" min-width="140" />
<el-table-column label="分账规则" min-width="260">
<template #default="{ row }">
<div v-if="row.rule_value" style="font-size:13px;line-height:1.8;">
<div v-for="(item, i) in row.rule_value" :key="i">
{{ item.product_type }}{{ (item.ratio * 100) }}% + {{ item.add_amount }}
</div>
</div>
<span v-else style="color:#999;">-</span>
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="80" align="center">
<template #default="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'info'" size="small">
{{ row.status === 1 ? '启用' : '禁用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="description" label="描述" min-width="160" show-overflow-tooltip />
<el-table-column prop="created_by" label="创建人" width="100" align="center" />
<el-table-column label="操作" width="180" fixed="right" align="center">
<template #default="{ row }">
<el-button type="primary" link size="small" @click="handleEdit(row)">编辑</el-button>
<el-button type="danger" link size="small" @click="handleDelete(row)">删除</el-button>
<el-switch
v-model="row._status"
:active-value="1"
:inactive-value="0"
size="small"
@change="(val) => handleStatusChange(row, val)"
style="margin-left:8px;"
/>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div style="display:flex;justify-content:flex-end;margin-top:16px;">
<el-pagination
v-model:current-page="page"
:page-size="pageSize"
:total="total"
layout="prev, pager, next, total"
@current-change="fetchList"
/>
</div>
<!-- 新增/编辑弹窗 -->
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="560px" :close-on-click-modal="false">
<el-form ref="formRef" :model="form" :rules="formRules" label-width="100px">
<el-form-item label="配置名称" prop="rule_name">
<el-input v-model="form.rule_name" placeholder="请输入配置名称" maxlength="50" />
</el-form-item>
<div class="rule-items-section" style="background:#fafafa;padding:12px 16px;border-radius:6px;margin-bottom:18px;">
<div class="rule-item" v-for="(item, index) in form.rule_items" :key="index" style="margin-bottom:12px;">
<div style="font-weight:600;margin-bottom:8px;font-size:14px;">{{ item.product_type }}</div>
<div style="display:flex;gap:16px;">
<el-form-item
:label="'分账百分比'"
:prop="'rule_items.' + index + '.ratio'"
:rules="[{ required: true, message: '请输入百分比', trigger: 'blur' }]"
label-width="90px"
>
<el-input v-model="item.ratio" placeholder="如 30" style="width:140px;" />
</el-form-item>
<el-form-item
:label="'增加金额'"
:prop="'rule_items.' + index + '.add_amount'"
:rules="[{ required: true, message: '请输入金额', trigger: 'blur' }]"
label-width="90px"
>
<el-input v-model="item.add_amount" placeholder="如 1" style="width:140px;" />
</el-form-item>
</div>
</div>
</div>
<el-form-item label="配置描述" prop="description">
<el-input v-model="form.description" type="textarea" :rows="3" placeholder="请输入配置描述" maxlength="200" />
</el-form-item>
<el-form-item label="状态" prop="status">
<el-radio-group v-model="form.status">
<el-radio :value="1">启用</el-radio>
<el-radio :value="0">禁用</el-radio>
</el-radio-group>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSubmit" :loading="submitLoading">确定</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
fetchSplitAccountList,
createSplitAccount,
updateSplitAccount,
deleteSplitAccount
} from '@/api/splitAccount'
//
const configList = ref([])
const loading = ref(false)
const page = ref(1)
const pageSize = ref(20)
const total = ref(0)
const keyword = ref('')
//
const dialogVisible = ref(false)
const dialogTitle = ref('')
const submitLoading = ref(false)
const formRef = ref(null)
const isEdit = ref(false)
const editId = ref(null)
//
const defaultRuleItems = () => [
{ product_type: '仓库方', ratio: '', add_amount: '' },
{ product_type: '分润方', ratio: '', add_amount: '' }
]
const form = reactive({
rule_name: '',
rule_items: defaultRuleItems(),
description: '',
status: 1
})
const formRules = {
rule_name: [
{ required: true, message: '请输入配置名称', trigger: 'blur' },
{ max: 50, message: '不能超过50个字符', trigger: 'blur' }
]
}
//
const fetchList = async () => {
loading.value = true
try {
const res = await fetchSplitAccountList({ keyword: keyword.value || undefined, page: page.value, page_size: pageSize.value })
const data = res.data
if (data && data.list) {
// rule_value JSON
const list = data.list.map(item => {
let ruleValue = item.rule_value
if (typeof ruleValue === 'string') {
try {
ruleValue = JSON.parse(ruleValue)
} catch (e) {
ruleValue = []
}
}
return {
...item,
rule_value: ruleValue,
_status: item.status
}
})
configList.value = list
total.value = data.total || list.length
} else if (Array.isArray(data)) {
const list = data.map(item => {
let ruleValue = item.rule_value
if (typeof ruleValue === 'string') {
try {
ruleValue = JSON.parse(ruleValue)
} catch (e) {
ruleValue = []
}
}
return {
...item,
rule_value: ruleValue,
_status: item.status
}
})
configList.value = list
total.value = list.length
} else {
configList.value = []
total.value = 0
}
} catch (e) {
console.error('获取分账配置列表失败', e)
} finally {
loading.value = false
}
}
//
const handleSearch = () => {
page.value = 1
fetchList()
}
//
const handleAdd = () => {
isEdit.value = false
editId.value = null
dialogTitle.value = '新增配置'
form.rule_name = ''
form.rule_items = defaultRuleItems()
form.description = ''
form.status = 1
dialogVisible.value = true
}
//
const handleEdit = (row) => {
isEdit.value = true
editId.value = row.id
dialogTitle.value = '编辑配置'
form.rule_name = row.rule_name || ''
// rule_value
const items = row.rule_value
if (items && items.length >= 2) {
form.rule_items = items.map(item => ({
product_type: item.product_type,
ratio: item.ratio !== undefined && item.ratio !== null ? String(Number(item.ratio) * 100) : '',
add_amount: item.add_amount !== undefined && item.add_amount !== null ? String(item.add_amount) : ''
}))
} else {
form.rule_items = defaultRuleItems()
}
form.description = row.description || ''
form.status = row.status
dialogVisible.value = true
}
//
const handleDelete = (row) => {
ElMessageBox.confirm('确定删除配置「' + row.rule_name + '」吗?此操作不可恢复。', '确认删除', {
confirmButtonText: '确定删除',
cancelButtonText: '取消',
type: 'warning'
}).then(async () => {
try {
await deleteSplitAccount(row.id)
ElMessage.success('删除成功')
fetchList()
} catch (e) {
ElMessage.error('删除失败')
}
}).catch(() => {})
}
//
const handleStatusChange = async (row, val) => {
try {
await updateSplitAccount({
id: row.id,
rule_name: row.rule_name,
rule_value: typeof row.rule_value === 'object' ? JSON.stringify(row.rule_value) : row.rule_value,
status: val,
description: row.description || ''
})
row.status = val
ElMessage.success(val === 1 ? '已启用' : '已禁用')
} catch (e) {
row._status = row.status
ElMessage.error('操作失败')
}
}
// /
const handleSubmit = async () => {
const valid = await formRef.value.validate().catch(() => false)
if (!valid) return
// rule_items
for (let i = 0; i < form.rule_items.length; i++) {
const item = form.rule_items[i]
if (!item.ratio && item.ratio !== 0) {
ElMessage.warning('请填写 ' + item.product_type + ' 的分账百分比')
return
}
if (item.add_amount === '' && item.add_amount !== 0) {
ElMessage.warning('请填写 ' + item.product_type + ' 的增加金额')
return
}
}
submitLoading.value = true
try {
const ruleValue = JSON.stringify(form.rule_items.map(item => ({
product_type: item.product_type,
ratio: typeof item.ratio === 'string' ? String(Number(item.ratio) / 100) : String(Number(item.ratio) / 100),
add_amount: typeof item.add_amount === 'string' ? item.add_amount : String(item.add_amount)
})))
const payload = {
rule_name: form.rule_name,
rule_value: ruleValue,
status: form.status,
description: form.description
}
if (isEdit.value) {
payload.id = editId.value
await updateSplitAccount(payload)
ElMessage.success('修改成功')
} else {
await createSplitAccount(payload)
ElMessage.success('新增成功')
}
dialogVisible.value = false
fetchList()
} catch (e) {
console.error('提交失败', e)
} finally {
submitLoading.value = false
}
}
onMounted(() => {
fetchList()
})
</script>
<style scoped>
.split-account-config {
padding: 20px;
}
.rule-items-section {
border: 1px solid #e8e8e8;
}
.rule-item:last-child {
margin-bottom: 0 !important;
}
</style>

View File

@ -0,0 +1,343 @@
<template>
<div class="split-account-employee">
<div class="page-header">
<h3>分账设置</h3>
</div>
<el-table :data="employeeList" v-loading="loading" stripe border style="width:100%;">
<el-table-column prop="id" label="ID" width="60" align="center" />
<el-table-column prop="username" label="登录账号" width="150" />
<el-table-column prop="name" label="姓名" width="120" />
<el-table-column prop="phone" label="手机号" width="140" />
<el-table-column prop="from" label="来源" width="80" align="center" />
<el-table-column prop="code" label="机械码" min-width="160" show-overflow-tooltip />
<el-table-column label="过期时间" width="160" align="center">
<template #default="{ row }">
{{ row.expire_time ? formatTime(row.expire_time) : '-' }}
</template>
</el-table-column>
<el-table-column label="关联配置" min-width="280">
<template #default="{ row }">
<div v-if="row.configData" class="config-preview">
<div class="config-preview-name">{{ row.configData.rule_name }}</div>
<div class="config-preview-rules">
<span
v-for="(rule, i) in row.configData.rule_value"
:key="i"
class="config-preview-tag"
:class="rule.product_type === '仓库方' ? 'wh' : 'pf'"
>
{{ rule.product_type }}
<i>{{ (rule.ratio * 100) }}%</i>
<i>+{{ rule.add_amount }}</i>
</span>
</div>
</div>
<span v-else style="color:#999;">未配置</span>
</template>
</el-table-column>
<el-table-column label="创建时间" width="160" align="center">
<template #default="{ row }">
{{ formatTime(row.created_at) }}
</template>
</el-table-column>
<el-table-column label="操作" width="100" fixed="right" align="center">
<template #default="{ row }">
<el-button type="primary" link size="small" @click="handleConfig(row)">设置</el-button>
</template>
</el-table-column>
</el-table>
<el-dialog v-model="dialogVisible" width="620px" :close-on-click-modal="false" class="config-dialog">
<template #header>
<div class="dialog-header">
<el-icon size="20" color="#409eff"><Setting /></el-icon>
<span>分账配置 {{ dialogEmployee.name }}</span>
</div>
</template>
<div v-if="configList.length === 0" class="empty-state">
<el-icon size="48" color="#d9d9d9"><WarningFilled /></el-icon>
<p>暂无可用配置</p>
<span>请先前往分账配置页面添加规则</span>
</div>
<div v-else class="config-list">
<div class="config-list-label">请选择要关联的分账规则</div>
<div
v-for="item in configList"
:key="item.id"
class="config-card"
:class="{ selected: selectedConfigId === item.id }"
@click="selectedConfigId = item.id"
>
<div class="config-card-left">
<div class="radio-circle" :class="{ checked: selectedConfigId === item.id }">
<el-icon v-if="selectedConfigId === item.id" size="12" color="#fff"><Check /></el-icon>
</div>
</div>
<div class="config-card-body">
<div class="config-card-name">{{ item.rule_name }}</div>
<div class="config-card-rules">
<span
v-for="(rule, i) in item.rule_value"
:key="i"
class="rule-tag"
:class="rule.product_type === '仓库方' ? 'warehouse' : 'profit'"
>
{{ rule.product_type }}
<em>{{ (rule.ratio * 100) }}%</em>
<em>+{{ rule.add_amount }}¥</em>
</span>
</div>
<div v-if="item.description" class="config-card-desc">{{ item.description }}</div>
</div>
</div>
</div>
<template #footer>
<div class="dialog-footer">
<el-button @click="dialogVisible = false" round>取消</el-button>
<el-button type="primary" @click="handleSaveConfig" :disabled="selectedConfigId === null" round>确认关联</el-button>
</div>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { Setting, WarningFilled, Check } from '@element-plus/icons-vue'
import { fetchSplitAccountList } from '@/api/splitAccount'
const loading = ref(false)
const employeeList = ref([])
const dialogVisible = ref(false)
const dialogEmployee = ref({})
const selectedConfigId = ref(null)
const configList = ref([])
const mockEmployees = [
{ id: 2, username: '18904056800', name: '18904056800', phone: '18904056800', from: 'ERP', code: '', expire_time: 0, created_at: 1778145625, updated_at: 1781510239, deleted_at: 0, configId: null },
{ id: 3, username: 'init_00001', name: '测试C', phone: '13800138000', from: '手动录入', code: 'MACHINE-CODE-001', expire_time: 1800000000, created_at: 1781508621, updated_at: 1781508621, deleted_at: 0, configId: 1 },
{ id: 4, username: 'init_00002', name: '张三', phone: '13900139000', from: 'ERP', code: '', expire_time: 0, created_at: 1781500000, updated_at: 1781500000, deleted_at: 0, configId: 2 },
{ id: 5, username: 'init_00003', name: '李四', phone: '13700137000', from: '手动录入', code: 'MACHINE-CODE-002', expire_time: 1805000000, created_at: 1781510000, updated_at: 1781510000, deleted_at: 0, configId: null }
]
const formatTime = (ts) => {
if (!ts) return '-'
const d = new Date(ts * 1000)
const pad = n => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
}
const getConfigData = (configId) => {
if (!configId) return null
return configList.value.find(c => c.id === configId) || null
}
onMounted(async () => {
loading.value = true
try {
const res = await fetchSplitAccountList({ page: 1, page_size: 100 })
if (res.code === 200 && res.data?.list) {
configList.value = res.data.list
}
} catch (e) {
console.error('获取分账配置失败', e)
}
employeeList.value = mockEmployees.map(e => ({ ...e, configName: getConfigData(e.configId)?.rule_name || '', configData: getConfigData(e.configId) }))
loading.value = false
})
const handleConfig = (row) => {
dialogEmployee.value = row
selectedConfigId.value = row.configId
dialogVisible.value = true
}
const handleSaveConfig = () => {
const emp = employeeList.value.find(e => e.id === dialogEmployee.value.id)
if (emp) {
emp.configId = selectedConfigId.value
emp.configData = getConfigData(selectedConfigId.value)
emp.configName = emp.configData?.rule_name || ''
}
dialogVisible.value = false
ElMessage.success('关联配置已更新')
}
</script>
<style scoped>
.split-account-employee {
padding: 20px;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.page-header h3 {
margin: 0;
font-size: 18px;
color: #303133;
}
.dialog-header {
display: flex;
align-items: center;
gap: 8px;
font-size: 16px;
font-weight: 600;
}
.empty-state {
text-align: center;
padding: 50px 0;
color: #999;
}
.empty-state p {
margin: 12px 0 4px;
font-size: 15px;
color: #666;
}
.empty-state span {
font-size: 13px;
}
.config-list {
padding: 4px 0;
}
.config-list-label {
font-size: 13px;
color: #666;
margin-bottom: 12px;
}
.config-card {
display: flex;
align-items: stretch;
gap: 14px;
padding: 16px 18px;
margin-bottom: 10px;
border: 1.5px solid #ebeef5;
border-radius: 10px;
cursor: pointer;
transition: all 0.25s ease;
background: #fff;
}
.config-card:hover {
border-color: #b3d8ff;
box-shadow: 0 2px 12px rgba(64,158,255,0.10);
}
.config-card.selected {
border-color: #409eff;
background: linear-gradient(135deg, #ecf5ff 0%, #f5f8ff 100%);
box-shadow: 0 2px 16px rgba(64,158,255,0.18);
}
.config-card-left {
display: flex;
align-items: center;
padding-right: 2px;
}
.radio-circle {
width: 20px;
height: 20px;
border-radius: 50%;
border: 2px solid #c0c4cc;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
transition: all 0.25s ease;
}
.radio-circle.checked {
border-color: #409eff;
background: #409eff;
}
.config-card-body {
flex: 1;
min-width: 0;
}
.config-card-name {
font-size: 15px;
font-weight: 600;
color: #303133;
margin-bottom: 6px;
}
.config-card-rules {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 6px;
}
.rule-tag {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 12px;
padding: 3px 10px;
border-radius: 4px;
background: #f5f7fa;
color: #606266;
}
.rule-tag em {
font-style: normal;
font-weight: 600;
}
.rule-tag.warehouse {
background: #e6f7ff;
color: #1890ff;
}
.rule-tag.profit {
background: #fff7e6;
color: #fa8c16;
}
.config-card-desc {
font-size: 12px;
color: #909399;
line-height: 1.5;
}
/* config preview in table */
.config-preview {
line-height: 1.5;
}
.config-preview-name {
font-size: 13px;
font-weight: 600;
color: #303133;
margin-bottom: 3px;
}
.config-preview-rules {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.config-preview-tag {
display: inline-flex;
align-items: center;
gap: 2px;
font-size: 11px;
padding: 1px 7px;
border-radius: 3px;
background: #f5f7fa;
color: #606266;
}
.config-preview-tag i {
font-style: normal;
font-weight: 600;
}
.config-preview-tag.wh {
background: #e6f7ff;
color: #1890ff;
}
.config-preview-tag.pf {
background: #fff7e6;
color: #fa8c16;
}
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 10px;
}
</style>