Initial commit: One Pipe System
完整的管理系统,包含账户管理、卡片管理、套餐管理、财务管理等功能模块。 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
127
src/components/business/AgentSelector.vue
Normal file
127
src/components/business/AgentSelector.vue
Normal file
@@ -0,0 +1,127 @@
|
||||
<template>
|
||||
<el-tree-select
|
||||
v-model="selectedValue"
|
||||
:data="agentTreeData"
|
||||
:props="treeProps"
|
||||
:placeholder="placeholder"
|
||||
:clearable="clearable"
|
||||
:disabled="disabled"
|
||||
:filterable="filterable"
|
||||
:check-strictly="checkStrictly"
|
||||
:multiple="multiple"
|
||||
:size="size"
|
||||
:render-after-expand="false"
|
||||
@change="handleChange"
|
||||
@visible-change="handleVisibleChange"
|
||||
>
|
||||
<template #default="{ data }">
|
||||
<div class="agent-node">
|
||||
<span class="agent-name">{{ data.name }}</span>
|
||||
<span v-if="data.level" class="agent-level">{{ data.level }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-tree-select>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import type { Agent } from '@/types/api'
|
||||
|
||||
interface Props {
|
||||
modelValue?: string | number | (string | number)[]
|
||||
placeholder?: string
|
||||
clearable?: boolean
|
||||
disabled?: boolean
|
||||
filterable?: boolean
|
||||
checkStrictly?: boolean
|
||||
multiple?: boolean
|
||||
size?: 'large' | 'default' | 'small'
|
||||
// 预加载的代理商树数据
|
||||
agents?: Agent[]
|
||||
// 远程获取方法
|
||||
fetchMethod?: () => Promise<Agent[]>
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:modelValue', value: string | number | (string | number)[] | undefined): void
|
||||
(e: 'change', value: string | number | (string | number)[] | undefined): void
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
placeholder: '请选择代理商',
|
||||
clearable: true,
|
||||
disabled: false,
|
||||
filterable: true,
|
||||
checkStrictly: false,
|
||||
multiple: false,
|
||||
size: 'default'
|
||||
})
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const loading = ref(false)
|
||||
const agentTreeData = ref<Agent[]>([])
|
||||
|
||||
const treeProps = {
|
||||
value: 'id',
|
||||
label: 'name',
|
||||
children: 'children'
|
||||
}
|
||||
|
||||
const selectedValue = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (val) => {
|
||||
emit('update:modelValue', val)
|
||||
}
|
||||
})
|
||||
|
||||
const handleChange = (value: string | number | (string | number)[] | undefined) => {
|
||||
emit('change', value)
|
||||
}
|
||||
|
||||
const handleVisibleChange = (visible: boolean) => {
|
||||
if (visible && agentTreeData.value.length === 0) {
|
||||
loadAgents()
|
||||
}
|
||||
}
|
||||
|
||||
const loadAgents = async () => {
|
||||
if (props.agents) {
|
||||
agentTreeData.value = props.agents
|
||||
} else if (props.fetchMethod) {
|
||||
loading.value = true
|
||||
try {
|
||||
agentTreeData.value = await props.fetchMethod()
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (props.agents) {
|
||||
agentTreeData.value = props.agents
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.agent-node {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
.agent-name {
|
||||
font-weight: 500;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.agent-level {
|
||||
font-size: 12px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
background-color: var(--el-color-primary-light-9);
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
153
src/components/business/BatchOperationDialog.vue
Normal file
153
src/components/business/BatchOperationDialog.vue
Normal file
@@ -0,0 +1,153 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
v-model="visible"
|
||||
:title="title"
|
||||
:width="width"
|
||||
:close-on-click-modal="false"
|
||||
:close-on-press-escape="false"
|
||||
@closed="handleClosed"
|
||||
>
|
||||
<div class="batch-operation-content">
|
||||
<!-- 选中项提示 -->
|
||||
<el-alert
|
||||
v-if="selectedCount > 0"
|
||||
:title="`已选择 ${selectedCount} 项`"
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
/>
|
||||
|
||||
<!-- 操作表单 -->
|
||||
<el-form
|
||||
ref="formRef"
|
||||
:model="formData"
|
||||
:rules="formRules"
|
||||
label-width="100px"
|
||||
class="operation-form"
|
||||
>
|
||||
<slot name="form" :form-data="formData" />
|
||||
</el-form>
|
||||
|
||||
<!-- 确认提示 -->
|
||||
<el-alert
|
||||
v-if="confirmMessage"
|
||||
:title="confirmMessage"
|
||||
type="warning"
|
||||
:closable="false"
|
||||
show-icon
|
||||
class="confirm-alert"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="dialog-footer">
|
||||
<el-button @click="handleCancel">取消</el-button>
|
||||
<el-button
|
||||
type="primary"
|
||||
:loading="loading"
|
||||
@click="handleConfirm"
|
||||
>
|
||||
确定
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
interface Props {
|
||||
modelValue: boolean
|
||||
title: string
|
||||
width?: string | number
|
||||
selectedCount?: number
|
||||
confirmMessage?: string
|
||||
formRules?: FormRules
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:modelValue', value: boolean): void
|
||||
(e: 'confirm', formData: Record<string, any>): void | Promise<void>
|
||||
(e: 'cancel'): void
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
width: '600px',
|
||||
selectedCount: 0
|
||||
})
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const formRef = ref<FormInstance>()
|
||||
const formData = ref<Record<string, any>>({})
|
||||
const loading = ref(false)
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (val) => {
|
||||
emit('update:modelValue', val)
|
||||
}
|
||||
})
|
||||
|
||||
const handleConfirm = async () => {
|
||||
if (!formRef.value) return
|
||||
|
||||
try {
|
||||
await formRef.value.validate()
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
await emit('confirm', formData.value)
|
||||
visible.value = false
|
||||
ElMessage.success('操作成功')
|
||||
} catch (error) {
|
||||
console.error('批量操作失败:', error)
|
||||
ElMessage.error('操作失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
} catch {
|
||||
ElMessage.warning('请检查表单填写')
|
||||
}
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
visible.value = false
|
||||
emit('cancel')
|
||||
}
|
||||
|
||||
const handleClosed = () => {
|
||||
formRef.value?.resetFields()
|
||||
formData.value = {}
|
||||
}
|
||||
|
||||
// 暴露方法供父组件调用
|
||||
defineExpose({
|
||||
formData
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.batch-operation-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
|
||||
.operation-form {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.confirm-alert {
|
||||
margin-top: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.dialog-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
}
|
||||
</style>
|
||||
293
src/components/business/CardOperationDialog.vue
Normal file
293
src/components/business/CardOperationDialog.vue
Normal file
@@ -0,0 +1,293 @@
|
||||
<template>
|
||||
<ElDialog
|
||||
v-model="visible"
|
||||
:title="title"
|
||||
:width="width"
|
||||
align-center
|
||||
:before-close="handleClose"
|
||||
>
|
||||
<ElForm
|
||||
ref="formRef"
|
||||
:model="formData"
|
||||
:rules="formRules"
|
||||
label-width="100px"
|
||||
>
|
||||
<!-- 选择下拉项表单项 -->
|
||||
<ElFormItem
|
||||
v-if="showSelect"
|
||||
:label="selectLabel"
|
||||
:prop="selectProp"
|
||||
>
|
||||
<ElSelect
|
||||
v-model="formData[selectProp]"
|
||||
:placeholder="selectPlaceholder"
|
||||
filterable
|
||||
remote
|
||||
:remote-method="handleRemoteSearch"
|
||||
:loading="selectLoading"
|
||||
clearable
|
||||
style="width: 100%"
|
||||
>
|
||||
<ElOption
|
||||
v-for="item in selectOptions"
|
||||
:key="item.value"
|
||||
:label="item.label"
|
||||
:value="item.value"
|
||||
/>
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
|
||||
<!-- 充值金额输入框 -->
|
||||
<ElFormItem
|
||||
v-if="showAmount"
|
||||
label="充值金额"
|
||||
prop="amount"
|
||||
>
|
||||
<ElInput
|
||||
v-model="formData.amount"
|
||||
placeholder="请输入充值金额"
|
||||
>
|
||||
<template #append>元</template>
|
||||
</ElInput>
|
||||
</ElFormItem>
|
||||
|
||||
<!-- 备注信息 -->
|
||||
<ElFormItem
|
||||
v-if="showRemark"
|
||||
label="备注"
|
||||
prop="remark"
|
||||
>
|
||||
<ElInput
|
||||
v-model="formData.remark"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
placeholder="请输入备注信息(可选)"
|
||||
/>
|
||||
</ElFormItem>
|
||||
|
||||
<!-- 选中的网卡信息展示 -->
|
||||
<ElFormItem
|
||||
v-if="selectedCards.length > 0"
|
||||
label="选中网卡"
|
||||
>
|
||||
<div class="selected-cards-info">
|
||||
<ElTag type="info">已选择 {{ selectedCards.length }} 张网卡</ElTag>
|
||||
<ElButton
|
||||
type="text"
|
||||
size="small"
|
||||
@click="showCardList = !showCardList"
|
||||
>
|
||||
{{ showCardList ? '收起' : '查看详情' }}
|
||||
</ElButton>
|
||||
</div>
|
||||
<div v-if="showCardList" class="card-list">
|
||||
<div
|
||||
v-for="card in selectedCards.slice(0, 5)"
|
||||
:key="card.id"
|
||||
class="card-item"
|
||||
>
|
||||
{{ card.iccid }}
|
||||
</div>
|
||||
<div v-if="selectedCards.length > 5" class="more-cards">
|
||||
还有 {{ selectedCards.length - 5 }} 张网卡...
|
||||
</div>
|
||||
</div>
|
||||
</ElFormItem>
|
||||
</ElForm>
|
||||
|
||||
<template #footer>
|
||||
<ElButton @click="handleClose">取消</ElButton>
|
||||
<ElButton type="primary" @click="handleConfirm" :loading="confirmLoading">
|
||||
确认
|
||||
</ElButton>
|
||||
</template>
|
||||
</ElDialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ElDialog, ElForm, ElFormItem, ElSelect, ElOption, ElInput, ElButton, ElTag, ElMessage } from 'element-plus'
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
|
||||
interface SelectOption {
|
||||
label: string
|
||||
value: string | number
|
||||
}
|
||||
|
||||
interface SelectedCard {
|
||||
id: number | string
|
||||
iccid: string
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
interface Props {
|
||||
// 弹框基础配置
|
||||
title: string
|
||||
width?: string
|
||||
|
||||
// 下拉选择配置
|
||||
showSelect?: boolean
|
||||
selectLabel?: string
|
||||
selectProp?: string
|
||||
selectPlaceholder?: string
|
||||
|
||||
// 其他表单项配置
|
||||
showAmount?: boolean
|
||||
showRemark?: boolean
|
||||
|
||||
// 选中的网卡
|
||||
selectedCards?: SelectedCard[]
|
||||
|
||||
// 远程搜索方法
|
||||
remoteSearch?: (query: string) => Promise<SelectOption[]>
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:visible', value: boolean): void
|
||||
(e: 'confirm', data: any): void
|
||||
(e: 'close'): void
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
width: '500px',
|
||||
showSelect: true,
|
||||
selectLabel: '请选择',
|
||||
selectProp: 'selectedValue',
|
||||
selectPlaceholder: '请选择选项',
|
||||
showAmount: false,
|
||||
showRemark: true,
|
||||
selectedCards: () => []
|
||||
})
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const visible = defineModel<boolean>('visible', { default: false })
|
||||
|
||||
const formRef = ref<FormInstance>()
|
||||
const selectLoading = ref(false)
|
||||
const confirmLoading = ref(false)
|
||||
const showCardList = ref(false)
|
||||
|
||||
// 表单数据
|
||||
const formData = reactive({
|
||||
selectedValue: '',
|
||||
amount: '',
|
||||
remark: ''
|
||||
})
|
||||
|
||||
// 下拉选项
|
||||
const selectOptions = ref<SelectOption[]>([])
|
||||
|
||||
// 表单验证规则
|
||||
const formRules = computed<FormRules>(() => {
|
||||
const rules: FormRules = {}
|
||||
|
||||
if (props.showSelect) {
|
||||
rules[props.selectProp!] = [
|
||||
{ required: true, message: `请选择${props.selectLabel}`, trigger: 'change' }
|
||||
]
|
||||
}
|
||||
|
||||
if (props.showAmount) {
|
||||
rules.amount = [
|
||||
{ required: true, message: '请输入充值金额', trigger: 'blur' },
|
||||
{ pattern: /^\d+(\.\d{1,2})?$/, message: '请输入正确的金额格式', trigger: 'blur' }
|
||||
]
|
||||
}
|
||||
|
||||
return rules
|
||||
})
|
||||
|
||||
// 处理远程搜索
|
||||
const handleRemoteSearch = async (query: string) => {
|
||||
if (!props.remoteSearch) return
|
||||
|
||||
selectLoading.value = true
|
||||
try {
|
||||
const options = await props.remoteSearch(query)
|
||||
selectOptions.value = options
|
||||
} catch (error) {
|
||||
console.error('远程搜索失败:', error)
|
||||
ElMessage.error('搜索失败,请重试')
|
||||
} finally {
|
||||
selectLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化时加载默认选项
|
||||
const loadDefaultOptions = async () => {
|
||||
if (props.remoteSearch) {
|
||||
// 加载默认数据,传入空字符串以获取默认的10条数据
|
||||
await handleRemoteSearch('')
|
||||
}
|
||||
}
|
||||
|
||||
// 处理确认
|
||||
const handleConfirm = async () => {
|
||||
if (!formRef.value) return
|
||||
|
||||
try {
|
||||
await formRef.value.validate()
|
||||
|
||||
confirmLoading.value = true
|
||||
|
||||
// 发送表单数据
|
||||
const submitData = {
|
||||
...formData,
|
||||
selectedCards: props.selectedCards
|
||||
}
|
||||
|
||||
emit('confirm', submitData)
|
||||
|
||||
} catch (error) {
|
||||
console.error('表单验证失败:', error)
|
||||
} finally {
|
||||
confirmLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 处理关闭
|
||||
const handleClose = () => {
|
||||
formRef.value?.resetFields()
|
||||
showCardList.value = false
|
||||
emit('close')
|
||||
visible.value = false
|
||||
}
|
||||
|
||||
// 监听弹框显示状态
|
||||
watch(visible, (newVal) => {
|
||||
if (newVal) {
|
||||
loadDefaultOptions()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.selected-cards-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.card-list {
|
||||
margin-top: 8px;
|
||||
padding: 8px;
|
||||
background-color: var(--el-fill-color-lighter);
|
||||
border-radius: 4px;
|
||||
max-height: 120px;
|
||||
overflow-y: auto;
|
||||
|
||||
.card-item {
|
||||
padding: 2px 0;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-regular);
|
||||
}
|
||||
|
||||
.more-cards {
|
||||
padding: 2px 0;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-placeholder);
|
||||
font-style: italic;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
25
src/components/business/CardStatusTag.vue
Normal file
25
src/components/business/CardStatusTag.vue
Normal file
@@ -0,0 +1,25 @@
|
||||
<template>
|
||||
<el-tag :type="tagType" :effect="effect" :size="size">
|
||||
{{ statusLabel }}
|
||||
</el-tag>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { getCardStatusLabel, getCardStatusType } from '@/config/constants'
|
||||
import type { CardStatus } from '@/types/api'
|
||||
|
||||
interface Props {
|
||||
status: CardStatus
|
||||
effect?: 'dark' | 'light' | 'plain'
|
||||
size?: 'large' | 'default' | 'small'
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
effect: 'light',
|
||||
size: 'default'
|
||||
})
|
||||
|
||||
const statusLabel = computed(() => getCardStatusLabel(props.status))
|
||||
const tagType = computed(() => getCardStatusType(props.status))
|
||||
</script>
|
||||
118
src/components/business/CommissionDisplay.vue
Normal file
118
src/components/business/CommissionDisplay.vue
Normal file
@@ -0,0 +1,118 @@
|
||||
<template>
|
||||
<div class="commission-display" :class="{ 'is-compact': compact }">
|
||||
<div v-if="!compact" class="commission-item">
|
||||
<span class="commission-label">佣金类型:</span>
|
||||
<span class="commission-value">{{ commissionTypeLabel }}</span>
|
||||
</div>
|
||||
|
||||
<div class="commission-item">
|
||||
<span class="commission-label">{{ compact ? '' : '佣金金额:' }}</span>
|
||||
<span class="commission-value commission-amount">
|
||||
{{ formattedAmount }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="showRate && rate !== undefined" class="commission-item">
|
||||
<span class="commission-label">{{ compact ? '' : '佣金比例:' }}</span>
|
||||
<span class="commission-value">{{ rate }}%</span>
|
||||
</div>
|
||||
|
||||
<div v-if="showStatus && status !== undefined" class="commission-item">
|
||||
<span class="commission-label">{{ compact ? '' : '状态:' }}</span>
|
||||
<el-tag :type="statusType" size="small">
|
||||
{{ statusLabel }}
|
||||
</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { formatMoney } from '@/utils/business'
|
||||
import {
|
||||
CommissionType,
|
||||
WithdrawStatus,
|
||||
getCommissionTypeLabel,
|
||||
getWithdrawStatusLabel,
|
||||
getWithdrawStatusType
|
||||
} from '@/config/constants'
|
||||
|
||||
interface Props {
|
||||
// 佣金金额(单位:分)
|
||||
amount: number
|
||||
// 佣金类型
|
||||
type?: CommissionType
|
||||
// 佣金比例
|
||||
rate?: number
|
||||
// 状态(用于提现记录)
|
||||
status?: WithdrawStatus
|
||||
// 是否显示比例
|
||||
showRate?: boolean
|
||||
// 是否显示状态
|
||||
showStatus?: boolean
|
||||
// 紧凑模式(只显示金额)
|
||||
compact?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
showRate: true,
|
||||
showStatus: false,
|
||||
compact: false
|
||||
})
|
||||
|
||||
const formattedAmount = computed(() => formatMoney(props.amount))
|
||||
|
||||
const commissionTypeLabel = computed(() => {
|
||||
return props.type !== undefined ? getCommissionTypeLabel(props.type) : '-'
|
||||
})
|
||||
|
||||
const statusLabel = computed(() => {
|
||||
return props.status !== undefined ? getWithdrawStatusLabel(props.status) : '-'
|
||||
})
|
||||
|
||||
const statusType = computed(() => {
|
||||
return props.status !== undefined ? getWithdrawStatusType(props.status) : 'info'
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.commission-display {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
|
||||
&.is-compact {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.commission-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 14px;
|
||||
|
||||
.commission-label {
|
||||
color: var(--el-text-color-secondary);
|
||||
margin-right: 8px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.commission-value {
|
||||
color: var(--el-text-color-primary);
|
||||
font-weight: 500;
|
||||
|
||||
&.commission-amount {
|
||||
color: var(--el-color-success);
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.is-compact .commission-item {
|
||||
.commission-label {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
389
src/components/business/ImportDialog.vue
Normal file
389
src/components/business/ImportDialog.vue
Normal file
@@ -0,0 +1,389 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
v-model="visible"
|
||||
:title="title"
|
||||
:width="width"
|
||||
:close-on-click-modal="false"
|
||||
:close-on-press-escape="!uploading"
|
||||
@closed="handleClosed"
|
||||
>
|
||||
<div class="import-dialog-content">
|
||||
<!-- 下载模板 -->
|
||||
<div v-if="templateUrl" class="template-section">
|
||||
<el-alert
|
||||
title="请先下载导入模板,按照模板格式填写数据"
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
>
|
||||
<template #default>
|
||||
<el-button
|
||||
type="primary"
|
||||
link
|
||||
@click="handleDownloadTemplate"
|
||||
>
|
||||
<el-icon><Download /></el-icon>
|
||||
下载模板
|
||||
</el-button>
|
||||
</template>
|
||||
</el-alert>
|
||||
</div>
|
||||
|
||||
<!-- 文件上传 -->
|
||||
<el-upload
|
||||
ref="uploadRef"
|
||||
class="upload-area"
|
||||
drag
|
||||
:action="uploadAction"
|
||||
:before-upload="handleBeforeUpload"
|
||||
:on-success="handleUploadSuccess"
|
||||
:on-error="handleUploadError"
|
||||
:on-progress="handleUploadProgress"
|
||||
:file-list="fileList"
|
||||
:limit="1"
|
||||
:accept="accept"
|
||||
:auto-upload="false"
|
||||
:disabled="uploading"
|
||||
>
|
||||
<el-icon class="upload-icon"><UploadFilled /></el-icon>
|
||||
<div class="upload-text">
|
||||
<div>将文件拖到此处,或<em>点击上传</em></div>
|
||||
<div class="upload-tip">只能上传 {{ acceptText }} 文件,且不超过 {{ maxSizeMB }}MB</div>
|
||||
</div>
|
||||
</el-upload>
|
||||
|
||||
<!-- 上传进度 -->
|
||||
<div v-if="uploading" class="upload-progress">
|
||||
<el-progress :percentage="uploadProgress" />
|
||||
<p>正在上传中,请稍候...</p>
|
||||
</div>
|
||||
|
||||
<!-- 导入结果 -->
|
||||
<div v-if="importResult" class="import-result">
|
||||
<el-alert
|
||||
:title="importResult.message"
|
||||
:type="importResult.success ? 'success' : 'error'"
|
||||
:closable="false"
|
||||
show-icon
|
||||
>
|
||||
<template v-if="importResult.detail" #default>
|
||||
<div class="result-detail">
|
||||
<p v-if="importResult.detail.total">总计:{{ importResult.detail.total }} 条</p>
|
||||
<p v-if="importResult.detail.success">成功:{{ importResult.detail.success }} 条</p>
|
||||
<p v-if="importResult.detail.failed">失败:{{ importResult.detail.failed }} 条</p>
|
||||
</div>
|
||||
</template>
|
||||
</el-alert>
|
||||
|
||||
<!-- 失败详情 -->
|
||||
<div v-if="importResult.errors && importResult.errors.length > 0" class="error-list">
|
||||
<el-divider content-position="left">失败详情</el-divider>
|
||||
<el-scrollbar max-height="200px">
|
||||
<div
|
||||
v-for="(error, index) in importResult.errors"
|
||||
:key="index"
|
||||
class="error-item"
|
||||
>
|
||||
<span class="error-row">第 {{ error.row }} 行:</span>
|
||||
<span class="error-msg">{{ error.message }}</span>
|
||||
</div>
|
||||
</el-scrollbar>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 额外表单字段 -->
|
||||
<el-form
|
||||
v-if="$slots.form"
|
||||
ref="formRef"
|
||||
:model="formData"
|
||||
:rules="formRules"
|
||||
label-width="100px"
|
||||
class="import-form"
|
||||
>
|
||||
<slot name="form" :form-data="formData" />
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="dialog-footer">
|
||||
<el-button @click="handleCancel" :disabled="uploading">
|
||||
{{ importResult ? '关闭' : '取消' }}
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="!importResult"
|
||||
type="primary"
|
||||
:loading="uploading"
|
||||
@click="handleConfirm"
|
||||
>
|
||||
开始导入
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import type { FormInstance, FormRules, UploadInstance, UploadUserFile, UploadProgressEvent } from 'element-plus'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Download, UploadFilled } from '@element-plus/icons-vue'
|
||||
|
||||
interface ImportResult {
|
||||
success: boolean
|
||||
message: string
|
||||
detail?: {
|
||||
total?: number
|
||||
success?: number
|
||||
failed?: number
|
||||
}
|
||||
errors?: Array<{
|
||||
row: number
|
||||
message: string
|
||||
}>
|
||||
}
|
||||
|
||||
interface Props {
|
||||
modelValue: boolean
|
||||
title?: string
|
||||
width?: string | number
|
||||
// 模板下载地址
|
||||
templateUrl?: string
|
||||
// 文件上传地址(如果使用 action 方式)
|
||||
uploadAction?: string
|
||||
// 接受的文件类型
|
||||
accept?: string
|
||||
// 最大文件大小(MB)
|
||||
maxSize?: number
|
||||
// 表单验证规则
|
||||
formRules?: FormRules
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:modelValue', value: boolean): void
|
||||
(e: 'confirm', file: File, formData?: Record<string, any>): Promise<ImportResult> | void
|
||||
(e: 'cancel'): void
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
title: '导入数据',
|
||||
width: '600px',
|
||||
uploadAction: '#',
|
||||
accept: '.xlsx,.xls',
|
||||
maxSize: 10
|
||||
})
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const uploadRef = ref<UploadInstance>()
|
||||
const formRef = ref<FormInstance>()
|
||||
const fileList = ref<UploadUserFile[]>([])
|
||||
const formData = ref<Record<string, any>>({})
|
||||
const uploading = ref(false)
|
||||
const uploadProgress = ref(0)
|
||||
const importResult = ref<ImportResult>()
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (val) => {
|
||||
emit('update:modelValue', val)
|
||||
}
|
||||
})
|
||||
|
||||
const maxSizeMB = computed(() => props.maxSize)
|
||||
const acceptText = computed(() => props.accept.replace(/\./g, '').toUpperCase())
|
||||
|
||||
const handleDownloadTemplate = () => {
|
||||
if (props.templateUrl) {
|
||||
window.open(props.templateUrl, '_blank')
|
||||
}
|
||||
}
|
||||
|
||||
const handleBeforeUpload = (file: File) => {
|
||||
const isValidType = props.accept.split(',').some(type => {
|
||||
const ext = type.trim()
|
||||
return file.name.toLowerCase().endsWith(ext)
|
||||
})
|
||||
|
||||
if (!isValidType) {
|
||||
ElMessage.error(`只能上传 ${acceptText.value} 格式的文件`)
|
||||
return false
|
||||
}
|
||||
|
||||
const isLtSize = file.size / 1024 / 1024 < props.maxSize
|
||||
if (!isLtSize) {
|
||||
ElMessage.error(`文件大小不能超过 ${props.maxSize}MB`)
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
const handleUploadProgress = (event: UploadProgressEvent) => {
|
||||
uploadProgress.value = Math.round(event.percent)
|
||||
}
|
||||
|
||||
const handleUploadSuccess = () => {
|
||||
uploading.value = false
|
||||
ElMessage.success('导入成功')
|
||||
}
|
||||
|
||||
const handleUploadError = () => {
|
||||
uploading.value = false
|
||||
ElMessage.error('导入失败')
|
||||
}
|
||||
|
||||
const handleConfirm = async () => {
|
||||
if (fileList.value.length === 0) {
|
||||
ElMessage.warning('请选择要导入的文件')
|
||||
return
|
||||
}
|
||||
|
||||
// 验证表单(如果有)
|
||||
if (formRef.value) {
|
||||
try {
|
||||
await formRef.value.validate()
|
||||
} catch {
|
||||
ElMessage.warning('请检查表单填写')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const file = fileList.value[0].raw
|
||||
if (!file) return
|
||||
|
||||
uploading.value = true
|
||||
uploadProgress.value = 0
|
||||
|
||||
try {
|
||||
const result = await emit('confirm', file, formData.value)
|
||||
if (result) {
|
||||
importResult.value = result
|
||||
}
|
||||
} catch (error: any) {
|
||||
importResult.value = {
|
||||
success: false,
|
||||
message: error.message || '导入失败'
|
||||
}
|
||||
} finally {
|
||||
uploading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
visible.value = false
|
||||
emit('cancel')
|
||||
}
|
||||
|
||||
const handleClosed = () => {
|
||||
fileList.value = []
|
||||
formRef.value?.resetFields()
|
||||
formData.value = {}
|
||||
uploadProgress.value = 0
|
||||
importResult.value = undefined
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
formData
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.import-dialog-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
|
||||
.template-section {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.upload-area {
|
||||
:deep(.el-upload) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
:deep(.el-upload-dragger) {
|
||||
padding: 40px 20px;
|
||||
}
|
||||
|
||||
.upload-icon {
|
||||
font-size: 48px;
|
||||
color: var(--el-color-primary);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.upload-text {
|
||||
font-size: 14px;
|
||||
color: var(--el-text-color-regular);
|
||||
|
||||
em {
|
||||
color: var(--el-color-primary);
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.upload-tip {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
margin-top: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.upload-progress {
|
||||
text-align: center;
|
||||
padding: 20px 0;
|
||||
|
||||
p {
|
||||
margin-top: 12px;
|
||||
font-size: 14px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.import-result {
|
||||
.result-detail {
|
||||
margin-top: 8px;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
|
||||
p {
|
||||
margin: 4px 0;
|
||||
}
|
||||
}
|
||||
|
||||
.error-list {
|
||||
margin-top: 16px;
|
||||
|
||||
.error-item {
|
||||
padding: 8px 12px;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
background-color: var(--el-fill-color-light);
|
||||
border-radius: 4px;
|
||||
margin-bottom: 8px;
|
||||
|
||||
.error-row {
|
||||
font-weight: 600;
|
||||
color: var(--el-color-danger);
|
||||
}
|
||||
|
||||
.error-msg {
|
||||
color: var(--el-text-color-regular);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.import-form {
|
||||
margin-top: 16px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--el-border-color-lighter);
|
||||
}
|
||||
}
|
||||
|
||||
.dialog-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
}
|
||||
</style>
|
||||
66
src/components/business/OperatorSelect.vue
Normal file
66
src/components/business/OperatorSelect.vue
Normal file
@@ -0,0 +1,66 @@
|
||||
<template>
|
||||
<el-select
|
||||
v-model="selectedValue"
|
||||
:placeholder="placeholder"
|
||||
:clearable="clearable"
|
||||
:disabled="disabled"
|
||||
:multiple="multiple"
|
||||
:filterable="filterable"
|
||||
:size="size"
|
||||
@change="handleChange"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in operatorOptions"
|
||||
:key="item.value"
|
||||
:label="item.label"
|
||||
:value="item.value"
|
||||
>
|
||||
<span :style="{ color: item.color }">{{ item.label }}</span>
|
||||
</el-option>
|
||||
</el-select>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { OPERATOR_OPTIONS } from '@/config/constants'
|
||||
import type { Operator } from '@/types/api'
|
||||
|
||||
interface Props {
|
||||
modelValue?: Operator | Operator[]
|
||||
placeholder?: string
|
||||
clearable?: boolean
|
||||
disabled?: boolean
|
||||
multiple?: boolean
|
||||
filterable?: boolean
|
||||
size?: 'large' | 'default' | 'small'
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:modelValue', value: Operator | Operator[] | undefined): void
|
||||
(e: 'change', value: Operator | Operator[] | undefined): void
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
placeholder: '请选择运营商',
|
||||
clearable: true,
|
||||
disabled: false,
|
||||
multiple: false,
|
||||
filterable: true,
|
||||
size: 'default'
|
||||
})
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const operatorOptions = OPERATOR_OPTIONS
|
||||
|
||||
const selectedValue = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (val) => {
|
||||
emit('update:modelValue', val)
|
||||
}
|
||||
})
|
||||
|
||||
const handleChange = (value: Operator | Operator[] | undefined) => {
|
||||
emit('change', value)
|
||||
}
|
||||
</script>
|
||||
137
src/components/business/PackageSelector.vue
Normal file
137
src/components/business/PackageSelector.vue
Normal file
@@ -0,0 +1,137 @@
|
||||
<template>
|
||||
<el-select
|
||||
v-model="selectedValue"
|
||||
:placeholder="placeholder"
|
||||
:clearable="clearable"
|
||||
:disabled="disabled"
|
||||
:multiple="multiple"
|
||||
:filterable="filterable"
|
||||
:remote="remote"
|
||||
:remote-method="remoteMethod"
|
||||
:loading="loading"
|
||||
:size="size"
|
||||
@change="handleChange"
|
||||
@visible-change="handleVisibleChange"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in packageList"
|
||||
:key="item.id"
|
||||
:label="item.name"
|
||||
:value="item.id"
|
||||
>
|
||||
<div class="package-option">
|
||||
<span class="package-name">{{ item.name }}</span>
|
||||
<span class="package-info">
|
||||
{{ formatFlow(item.flow) }} / {{ formatMoney(item.price) }}
|
||||
</span>
|
||||
</div>
|
||||
</el-option>
|
||||
</el-select>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import type { Package } from '@/types/api'
|
||||
import { formatFlow, formatMoney } from '@/utils/business'
|
||||
|
||||
interface Props {
|
||||
modelValue?: string | number | (string | number)[]
|
||||
placeholder?: string
|
||||
clearable?: boolean
|
||||
disabled?: boolean
|
||||
multiple?: boolean
|
||||
filterable?: boolean
|
||||
remote?: boolean
|
||||
size?: 'large' | 'default' | 'small'
|
||||
// 预加载的套餐列表
|
||||
packages?: Package[]
|
||||
// 远程搜索方法
|
||||
fetchMethod?: (query: string) => Promise<Package[]>
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:modelValue', value: string | number | (string | number)[] | undefined): void
|
||||
(e: 'change', value: string | number | (string | number)[] | undefined): void
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
placeholder: '请选择套餐',
|
||||
clearable: true,
|
||||
disabled: false,
|
||||
multiple: false,
|
||||
filterable: true,
|
||||
remote: false,
|
||||
size: 'default'
|
||||
})
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const loading = ref(false)
|
||||
const packageList = ref<Package[]>([])
|
||||
|
||||
const selectedValue = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (val) => {
|
||||
emit('update:modelValue', val)
|
||||
}
|
||||
})
|
||||
|
||||
const handleChange = (value: string | number | (string | number)[] | undefined) => {
|
||||
emit('change', value)
|
||||
}
|
||||
|
||||
const handleVisibleChange = (visible: boolean) => {
|
||||
if (visible && !props.remote && packageList.value.length === 0) {
|
||||
loadPackages()
|
||||
}
|
||||
}
|
||||
|
||||
const loadPackages = async () => {
|
||||
if (props.packages) {
|
||||
packageList.value = props.packages
|
||||
} else if (props.fetchMethod) {
|
||||
loading.value = true
|
||||
try {
|
||||
packageList.value = await props.fetchMethod('')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const remoteMethod = async (query: string) => {
|
||||
if (!props.fetchMethod) return
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
packageList.value = await props.fetchMethod(query)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (props.packages) {
|
||||
packageList.value = props.packages
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.package-option {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
.package-name {
|
||||
font-weight: 500;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.package-info {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
margin-left: 12px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
11
src/components/business/index.ts
Normal file
11
src/components/business/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* 业务组件统一导出
|
||||
*/
|
||||
|
||||
export { default as CardStatusTag } from './CardStatusTag.vue'
|
||||
export { default as OperatorSelect } from './OperatorSelect.vue'
|
||||
export { default as PackageSelector } from './PackageSelector.vue'
|
||||
export { default as AgentSelector } from './AgentSelector.vue'
|
||||
export { default as CommissionDisplay } from './CommissionDisplay.vue'
|
||||
export { default as BatchOperationDialog } from './BatchOperationDialog.vue'
|
||||
export { default as ImportDialog } from './ImportDialog.vue'
|
||||
Reference in New Issue
Block a user