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:
sexygoat
2026-01-22 16:35:33 +08:00
commit 222e5bb11a
495 changed files with 145440 additions and 0 deletions

View File

@@ -0,0 +1,522 @@
<template>
<ArtTableFullScreen>
<div class="account-page" id="table-full-screen">
<!-- 搜索栏 -->
<ArtSearchBar
v-model:filter="formFilters"
:items="formItems"
:show-expand="false"
@reset="handleReset"
@search="handleSearch"
></ArtSearchBar>
<ElCard shadow="never" class="art-table-card">
<!-- 表格头部 -->
<ArtTableHeader
:columnList="columnOptions"
v-model:columns="columnChecks"
@refresh="handleRefresh"
>
<template #left>
<ElButton @click="showDialog('add')">新增账号</ElButton>
</template>
</ArtTableHeader>
<!-- 表格 -->
<ArtTable
ref="tableRef"
row-key="ID"
:loading="loading"
:data="tableData"
:currentPage="pagination.currentPage"
:pageSize="pagination.pageSize"
:total="pagination.total"
:marginTop="10"
@selection-change="handleSelectionChange"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
>
<template #default>
<ElTableColumn v-for="col in columns" :key="col.prop || col.type" v-bind="col" />
</template>
</ArtTable>
<ElDialog
v-model="dialogVisible"
:title="dialogType === 'add' ? '添加账号' : '编辑账号'"
width="500px"
>
<ElForm ref="formRef" :model="formData" :rules="rules" label-width="100px">
<ElFormItem label="账号名称" prop="username">
<ElInput v-model="formData.username" placeholder="请输入账号名称" />
</ElFormItem>
<ElFormItem v-if="dialogType === 'add'" label="密码" prop="password">
<ElInput
v-model="formData.password"
type="password"
placeholder="请输入密码"
show-password
/>
</ElFormItem>
<ElFormItem label="手机号" prop="phone">
<ElInput v-model="formData.phone" placeholder="请输入手机号" maxlength="11" />
</ElFormItem>
<ElFormItem label="账号类型" prop="user_type">
<ElSelect
v-model="formData.user_type"
placeholder="请选择账号类型"
style="width: 100%"
>
<ElOption label="超级管理员" :value="1" />
<ElOption label="平台用户" :value="2" />
<ElOption label="代理账号" :value="3" />
<ElOption label="企业账号" :value="4" />
</ElSelect>
</ElFormItem>
</ElForm>
<template #footer>
<div class="dialog-footer">
<ElButton @click="dialogVisible = false">取消</ElButton>
<ElButton type="primary" @click="handleSubmit">提交</ElButton>
</div>
</template>
</ElDialog>
<!-- 分配角色对话框 -->
<ElDialog v-model="roleDialogVisible" title="分配角色" width="500px">
<ElRadioGroup v-model="selectedRole" class="role-radio-group">
<div v-for="role in allRoles" :key="role.ID" class="role-radio-item">
<ElRadio :label="role.ID">
{{ role.role_name }}
<ElTag
:type="role.role_type === 1 ? 'primary' : 'success'"
size="small"
style="margin-left: 8px"
>
{{ role.role_type === 1 ? '平台角色' : '客户角色' }}
</ElTag>
</ElRadio>
</div>
</ElRadioGroup>
<template #footer>
<div class="dialog-footer">
<ElButton @click="roleDialogVisible = false">取消</ElButton>
<ElButton type="primary" @click="handleAssignRoles" :loading="roleSubmitLoading"
>提交</ElButton
>
</div>
</template>
</ElDialog>
</ElCard>
</div>
</ArtTableFullScreen>
</template>
<script setup lang="ts">
import { h } from 'vue'
import { FormInstance, ElSwitch } from 'element-plus'
import { ElMessageBox, ElMessage } from 'element-plus'
import type { FormRules } from 'element-plus'
import { useCheckedColumns } from '@/composables/useCheckedColumns'
import ArtButtonTable from '@/components/core/forms/ArtButtonTable.vue'
import { AccountService } from '@/api/modules/account'
import { RoleService } from '@/api/modules/role'
import type { SearchFormItem } from '@/types'
import type { PlatformRole } from '@/types/api'
import { formatDateTime } from '@/utils/business/format'
import { CommonStatus, getStatusText } from '@/config/constants'
defineOptions({ name: 'Account' }) // 定义组件名称,用于 KeepAlive 缓存控制
const dialogType = ref('add')
const dialogVisible = ref(false)
const roleDialogVisible = ref(false)
const loading = ref(false)
const roleSubmitLoading = ref(false)
const currentAccountId = ref<number>(0)
const selectedRole = ref<number | null>(null)
const allRoles = ref<PlatformRole[]>([])
// 定义表单搜索初始值
const initialSearchState = {
name: '',
phone: ''
}
// 响应式表单数据
const formFilters = reactive({ ...initialSearchState })
const pagination = reactive({
currentPage: 1,
pageSize: 20,
total: 0
})
// 表格数据
const tableData = ref<any[]>([])
// 表格实例引用
const tableRef = ref()
// 选中的行数据
const selectedRows = ref<any[]>([])
// 重置表单
const handleReset = () => {
Object.assign(formFilters, { ...initialSearchState })
pagination.currentPage = 1 // 重置到第一页
getAccountList()
}
// 搜索处理
const handleSearch = () => {
console.log('搜索参数:', formFilters)
pagination.currentPage = 1 // 搜索时重置到第一页
getAccountList()
}
// 表单配置项
const formItems: SearchFormItem[] = [
{
label: '账号名称',
prop: 'name',
type: 'input',
config: {
clearable: true,
placeholder: '请输入账号名称'
}
},
{
label: '手机号',
prop: 'phone',
type: 'input',
config: {
clearable: true,
placeholder: '请输入手机号'
}
}
]
// 列配置
const columnOptions = [
{ label: 'ID', prop: 'ID' },
{ label: '账号名称', prop: 'username' },
{ label: '手机号', prop: 'phone' },
{ label: '账号类型', prop: 'user_type_name' },
{ label: '状态', prop: 'status' },
{ label: '创建时间', prop: 'CreatedAt' },
{ label: '操作', prop: 'operation' }
]
// 显示对话框
const showDialog = (type: string, row?: any) => {
dialogVisible.value = true
dialogType.value = type
// 重置表单验证状态
if (formRef.value) {
formRef.value.resetFields()
}
if (type === 'edit' && row) {
formData.id = row.ID
formData.username = row.username
formData.phone = row.phone
formData.user_type = row.user_type
formData.password = ''
} else {
formData.id = ''
formData.username = ''
formData.phone = ''
formData.user_type = 2
formData.password = ''
}
}
// 删除账号
const deleteAccount = (row: any) => {
ElMessageBox.confirm(`确定要删除账号 ${row.username} 吗?`, '删除账号', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'error'
})
.then(async () => {
try {
await AccountService.deleteAccount(row.ID)
ElMessage.success('删除成功')
getAccountList()
} catch (error) {
console.error(error)
}
})
.catch(() => {
// 账号取消删除
})
}
// 动态列配置
const { columnChecks, columns } = useCheckedColumns(() => [
{
prop: 'ID',
label: 'ID',
width: 80
},
{
prop: 'username',
label: '账号名称',
minWidth: 120
},
{
prop: 'phone',
label: '手机号',
width: 130
},
{
prop: 'user_type',
label: '账号类型',
width: 120,
formatter: (row: any) => {
const typeMap: Record<number, string> = {
1: '超级管理员',
2: '平台用户',
3: '代理账号',
4: '企业账号'
}
return typeMap[row.user_type] || '-'
}
},
{
prop: 'status',
label: '状态',
width: 100,
formatter: (row: any) => {
return h(ElSwitch, {
modelValue: row.status,
activeValue: CommonStatus.ENABLED,
inactiveValue: CommonStatus.DISABLED,
activeText: getStatusText(CommonStatus.ENABLED),
inactiveText: getStatusText(CommonStatus.DISABLED),
inlinePrompt: true,
'onUpdate:modelValue': (val: number) => handleStatusChange(row, val)
})
}
},
{
prop: 'CreatedAt',
label: '创建时间',
width: 180,
formatter: (row: any) => formatDateTime(row.CreatedAt)
},
{
prop: 'operation',
label: '操作',
width: 180,
fixed: 'right',
formatter: (row: any) => {
return h('div', { style: 'display: flex; gap: 8px;' }, [
h(ArtButtonTable, {
icon: '&#xe72b;',
onClick: () => showRoleDialog(row)
}),
h(ArtButtonTable, {
type: 'edit',
onClick: () => showDialog('edit', row)
}),
h(ArtButtonTable, {
type: 'delete',
onClick: () => deleteAccount(row)
})
])
}
}
])
// 表单实例
const formRef = ref<FormInstance>()
// 表单数据
const formData = reactive({
id: '',
username: '',
password: '',
phone: '',
user_type: 2
})
onMounted(() => {
getAccountList()
loadAllRoles()
})
// 加载所有角色列表
const loadAllRoles = async () => {
try {
const res = await RoleService.getRoles({ page: 1, pageSize: 100 })
if (res.code === 0) {
allRoles.value = res.data.items || []
}
} catch (error) {
console.error('获取角色列表失败:', error)
}
}
// 显示分配角色对话框
const showRoleDialog = async (row: any) => {
currentAccountId.value = row.ID
selectedRole.value = null
// 先加载当前账号的角色,再打开对话框
try {
const res = await AccountService.getAccountRoles(row.ID)
if (res.code === 0) {
// 提取角色ID只取第一个角色
const roles = res.data || []
selectedRole.value = roles.length > 0 ? roles[0].ID : null
// 数据加载完成后再打开对话框
roleDialogVisible.value = true
}
} catch (error) {
console.error('获取账号角色失败:', error)
}
}
// 提交分配角色
const handleAssignRoles = async () => {
if (selectedRole.value === null) {
ElMessage.warning('请选择一个角色')
return
}
roleSubmitLoading.value = true
try {
// 将单个角色ID包装成数组传给后端
await AccountService.assignRolesToAccount(currentAccountId.value, [selectedRole.value])
ElMessage.success('分配角色成功')
roleDialogVisible.value = false
} catch (error) {
console.error(error)
} finally {
roleSubmitLoading.value = false
}
}
// 获取账号列表
const getAccountList = async () => {
loading.value = true
try {
const params = {
page: pagination.currentPage,
pageSize: pagination.pageSize,
keyword: formFilters.name || formFilters.phone || undefined
}
const res = await AccountService.getAccounts(params)
if (res.code === 0) {
tableData.value = res.data.items || []
pagination.total = res.data.total || 0
}
} catch (error) {
console.error('获取账号列表失败:', error)
} finally {
loading.value = false
}
}
const handleRefresh = () => {
getAccountList()
}
// 处理表格行选择变化
const handleSelectionChange = (selection: any[]) => {
selectedRows.value = selection
}
// 表单验证规则
const rules = reactive<FormRules>({
username: [
{ required: true, message: '请输入账号名称', trigger: 'blur' },
{ min: 3, max: 50, message: '长度在 3 到 50 个字符', trigger: 'blur' }
],
password: [
{ required: true, message: '请输入密码', trigger: 'blur' },
{ min: 8, max: 32, message: '密码长度为 8-32 个字符', trigger: 'blur' }
],
phone: [
{ required: true, message: '请输入手机号', trigger: 'blur' },
{ len: 11, message: '手机号必须为 11 位', trigger: 'blur' },
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号格式', trigger: 'blur' }
],
user_type: [{ required: true, message: '请选择账号类型', trigger: 'change' }]
})
// 提交表单
const handleSubmit = async () => {
if (!formRef.value) return
await formRef.value.validate(async (valid) => {
if (valid) {
try {
const data: any = {
username: formData.username,
phone: formData.phone,
user_type: formData.user_type
}
if (dialogType.value === 'add') {
data.password = formData.password
await AccountService.createAccount(data)
ElMessage.success('添加成功')
} else {
await AccountService.updateAccount(Number(formData.id), data)
ElMessage.success('更新成功')
}
dialogVisible.value = false
getAccountList()
} catch (error) {
console.error(error)
}
}
})
}
// 处理表格分页变化
const handleSizeChange = (newPageSize: number) => {
pagination.pageSize = newPageSize
getAccountList()
}
const handleCurrentChange = (newCurrentPage: number) => {
pagination.currentPage = newCurrentPage
getAccountList()
}
// 状态切换
const handleStatusChange = async (row: any, newStatus: number) => {
const oldStatus = row.status
// 先更新UI
row.status = newStatus
try {
await AccountService.updateAccount(row.ID, { status: newStatus })
ElMessage.success('状态切换成功')
} catch (error) {
// 切换失败,恢复原状态
row.status = oldStatus
console.error(error)
}
}
</script>
<style lang="scss" scoped>
.account-page {
// 账号管理页面样式
}
.role-radio-group {
width: 100%;
}
.role-radio-item {
margin-bottom: 16px;
padding: 8px;
}
</style>

View File

@@ -0,0 +1,468 @@
<template>
<div class="page-content">
<ElRow>
<ElCol :xs="24" :sm="12" :lg="6">
<ElInput v-model="searchQuery" placeholder="代理商名称/联系人" clearable></ElInput>
</ElCol>
<div style="width: 12px"></div>
<ElCol :xs="24" :sm="12" :lg="6">
<ElSelect v-model="levelFilter" placeholder="代理商等级" clearable style="width: 100%">
<ElOption label="一级代理" value="1" />
<ElOption label="二级代理" value="2" />
<ElOption label="三级代理" value="3" />
</ElSelect>
</ElCol>
<div style="width: 12px"></div>
<ElCol :xs="24" :sm="12" :lg="6" class="el-col2">
<ElButton v-ripple @click="handleSearch">搜索</ElButton>
<ElButton v-ripple @click="showDialog('add')">新增代理商</ElButton>
</ElCol>
</ElRow>
<ArtTable :data="filteredData" index row-key="id">
<template #default>
<ElTableColumn label="代理商名称" prop="agentName" min-width="150" />
<ElTableColumn label="代理商编码" prop="agentCode" />
<ElTableColumn label="等级" prop="level">
<template #default="scope">
<ElTag :type="getLevelTagType(scope.row.level)">
{{ getLevelText(scope.row.level) }}
</ElTag>
</template>
</ElTableColumn>
<ElTableColumn label="上级代理" prop="parentName" show-overflow-tooltip />
<ElTableColumn label="联系人" prop="contactPerson" />
<ElTableColumn label="联系电话" prop="contactPhone" />
<ElTableColumn label="账号数量" prop="accountCount" />
<ElTableColumn label="网卡数量" prop="simCardCount" />
<ElTableColumn label="佣金总额" prop="totalCommission">
<template #default="scope"> ¥{{ scope.row.totalCommission.toFixed(2) }} </template>
</ElTableColumn>
<ElTableColumn label="状态" prop="status">
<template #default="scope">
<ElTag :type="scope.row.status === 'active' ? 'success' : 'info'">
{{ scope.row.status === 'active' ? '启用' : '禁用' }}
</ElTag>
</template>
</ElTableColumn>
<ElTableColumn label="创建时间" prop="createTime" width="180" />
<ElTableColumn fixed="right" label="操作" width="280">
<template #default="scope">
<el-button link @click="viewAccountList(scope.row)">账号管理</el-button>
<el-button link @click="viewCommission(scope.row)">佣金配置</el-button>
<el-button link @click="showDialog('edit', scope.row)">编辑</el-button>
<el-button link @click="handleDelete(scope.row)">删除</el-button>
</template>
</ElTableColumn>
</template>
</ArtTable>
<!-- 新增/编辑对话框 -->
<ElDialog
v-model="dialogVisible"
:title="dialogType === 'add' ? '新增代理商' : '编辑代理商'"
width="700px"
align-center
>
<ElForm ref="formRef" :model="form" :rules="rules" label-width="120px">
<ElRow :gutter="20">
<ElCol :span="12">
<ElFormItem label="代理商名称" prop="agentName">
<ElInput v-model="form.agentName" placeholder="请输入代理商名称" />
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="代理商编码" prop="agentCode">
<ElInput v-model="form.agentCode" placeholder="请输入代理商编码" />
</ElFormItem>
</ElCol>
</ElRow>
<ElRow :gutter="20">
<ElCol :span="12">
<ElFormItem label="代理商等级" prop="level">
<ElSelect v-model="form.level" placeholder="请选择代理商等级" style="width: 100%">
<ElOption label="一级代理" :value="1" />
<ElOption label="二级代理" :value="2" />
<ElOption label="三级代理" :value="3" />
</ElSelect>
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="上级代理" prop="parentId">
<ElSelect v-model="form.parentId" placeholder="请选择上级代理" clearable style="width: 100%">
<ElOption label="无" :value="null" />
<ElOption
v-for="agent in parentAgentOptions"
:key="agent.id"
:label="agent.agentName"
:value="agent.id"
/>
</ElSelect>
</ElFormItem>
</ElCol>
</ElRow>
<ElRow :gutter="20">
<ElCol :span="12">
<ElFormItem label="联系人" prop="contactPerson">
<ElInput v-model="form.contactPerson" placeholder="请输入联系人" />
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="联系电话" prop="contactPhone">
<ElInput v-model="form.contactPhone" placeholder="请输入联系电话" />
</ElFormItem>
</ElCol>
</ElRow>
<ElRow :gutter="20">
<ElCol :span="12">
<ElFormItem label="邮箱" prop="email">
<ElInput v-model="form.email" placeholder="请输入邮箱" />
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="公司地址" prop="address">
<ElInput v-model="form.address" placeholder="请输入公司地址" />
</ElFormItem>
</ElCol>
</ElRow>
<ElFormItem label="备注" prop="remark">
<ElInput v-model="form.remark" type="textarea" :rows="2" placeholder="请输入备注信息" />
</ElFormItem>
<ElFormItem label="状态">
<ElSwitch v-model="form.status" active-value="active" inactive-value="inactive" />
</ElFormItem>
</ElForm>
<template #footer>
<div class="dialog-footer">
<ElButton @click="dialogVisible = false">取消</ElButton>
<ElButton type="primary" @click="handleSubmit(formRef)">提交</ElButton>
</div>
</template>
</ElDialog>
<!-- 账号管理对话框 -->
<ElDialog v-model="accountDialogVisible" title="代理商账号管理" width="900px" align-center>
<div style="margin-bottom: 16px">
<ElButton type="primary" size="small" @click="addSubAccount">创建子账号</ElButton>
</div>
<ArtTable :data="currentAgentAccounts" index>
<template #default>
<ElTableColumn label="账号" prop="username" />
<ElTableColumn label="真实姓名" prop="realName" />
<ElTableColumn label="手机号" prop="phone" />
<ElTableColumn label="角色" prop="role" />
<ElTableColumn label="状态" prop="status">
<template #default="scope">
<ElTag :type="scope.row.status === 'active' ? 'success' : 'info'">
{{ scope.row.status === 'active' ? '正常' : '禁用' }}
</ElTag>
</template>
</ElTableColumn>
<ElTableColumn label="创建时间" prop="createTime" />
<ElTableColumn fixed="right" label="操作" width="150">
<template #default="scope">
<el-button link>编辑</el-button>
<el-button link>禁用</el-button>
</template>
</ElTableColumn>
</template>
</ArtTable>
</ElDialog>
<!-- 佣金配置对话框 -->
<ElDialog v-model="commissionDialogVisible" title="佣金配置" width="600px" align-center>
<ElForm label-width="120px">
<ElFormItem label="佣金模式">
<ElRadioGroup v-model="commissionForm.mode">
<ElRadio value="fixed">固定佣金</ElRadio>
<ElRadio value="percent">比例佣金</ElRadio>
</ElRadioGroup>
</ElFormItem>
<ElFormItem v-if="commissionForm.mode === 'fixed'" label="固定金额">
<ElInputNumber v-model="commissionForm.fixedAmount" :min="0" :precision="2" />
<span style="margin-left: 8px">/</span>
</ElFormItem>
<ElFormItem v-if="commissionForm.mode === 'percent'" label="佣金比例">
<ElInputNumber v-model="commissionForm.percent" :min="0" :max="100" :precision="2" />
<span style="margin-left: 8px">%</span>
</ElFormItem>
<ElFormItem label="结算周期">
<ElSelect v-model="commissionForm.settlementCycle" style="width: 100%">
<ElOption label="实时结算" value="realtime" />
<ElOption label="日结" value="daily" />
<ElOption label="周结" value="weekly" />
<ElOption label="月结" value="monthly" />
</ElSelect>
</ElFormItem>
</ElForm>
<template #footer>
<div class="dialog-footer">
<ElButton @click="commissionDialogVisible = false">取消</ElButton>
<ElButton type="primary" @click="saveCommissionConfig">保存</ElButton>
</div>
</template>
</ElDialog>
</div>
</template>
<script setup lang="ts">
import { ElMessage, ElMessageBox } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
defineOptions({ name: 'AgentManagement' })
interface Agent {
id?: string
agentName: string
agentCode: string
level: number
parentId?: string | null
parentName?: string
contactPerson: string
contactPhone: string
email?: string
address?: string
remark?: string
accountCount?: number
simCardCount?: number
totalCommission?: number
status: 'active' | 'inactive'
createTime?: string
}
// Mock 数据
const mockData = ref<Agent[]>([
{
id: '1',
agentName: '华东区总代理',
agentCode: 'AGENT_HD_001',
level: 1,
parentId: null,
parentName: '无',
contactPerson: '张三',
contactPhone: '13800138001',
email: 'zhangsan@example.com',
address: '上海市浦东新区',
accountCount: 5,
simCardCount: 1000,
totalCommission: 158900.50,
status: 'active',
createTime: '2026-01-01 10:00:00'
},
{
id: '2',
agentName: '江苏省代理',
agentCode: 'AGENT_JS_001',
level: 2,
parentId: '1',
parentName: '华东区总代理',
contactPerson: '李四',
contactPhone: '13800138002',
email: 'lisi@example.com',
address: '江苏省南京市',
accountCount: 3,
simCardCount: 500,
totalCommission: 78500.00,
status: 'active',
createTime: '2026-01-05 11:00:00'
},
{
id: '3',
agentName: '南京市代理',
agentCode: 'AGENT_NJ_001',
level: 3,
parentId: '2',
parentName: '江苏省代理',
contactPerson: '王五',
contactPhone: '13800138003',
email: 'wangwu@example.com',
address: '江苏省南京市玄武区',
accountCount: 2,
simCardCount: 200,
totalCommission: 32800.00,
status: 'active',
createTime: '2026-01-10 12:00:00'
}
])
const searchQuery = ref('')
const levelFilter = ref('')
const dialogVisible = ref(false)
const accountDialogVisible = ref(false)
const commissionDialogVisible = ref(false)
const dialogType = ref<'add' | 'edit'>('add')
const formRef = ref<FormInstance>()
const form = reactive<Agent>({
agentName: '',
agentCode: '',
level: 1,
parentId: null,
contactPerson: '',
contactPhone: '',
email: '',
address: '',
remark: '',
status: 'active'
})
const commissionForm = reactive({
mode: 'fixed',
fixedAmount: 10,
percent: 5,
settlementCycle: 'monthly'
})
const currentAgentAccounts = ref([
{
username: 'agent001',
realName: '张三',
phone: '13800138001',
role: '管理员',
status: 'active',
createTime: '2026-01-01 10:00:00'
}
])
const rules = reactive<FormRules>({
agentName: [{ required: true, message: '请输入代理商名称', trigger: 'blur' }],
agentCode: [{ required: true, message: '请输入代理商编码', trigger: 'blur' }],
level: [{ required: true, message: '请选择代理商等级', trigger: 'change' }],
contactPerson: [{ required: true, message: '请输入联系人', trigger: 'blur' }],
contactPhone: [
{ required: true, message: '请输入联系电话', trigger: 'blur' },
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号码', trigger: 'blur' }
]
})
const parentAgentOptions = computed(() => {
return mockData.value.filter((item) => item.level < form.level)
})
const filteredData = computed(() => {
let data = mockData.value
if (searchQuery.value) {
data = data.filter(
(item) =>
item.agentName.includes(searchQuery.value) || item.contactPerson.includes(searchQuery.value)
)
}
if (levelFilter.value) {
data = data.filter((item) => item.level === parseInt(levelFilter.value))
}
return data
})
const getLevelText = (level: number) => {
const levelMap: Record<number, string> = { 1: '一级代理', 2: '二级代理', 3: '三级代理' }
return levelMap[level] || '未知'
}
const getLevelTagType = (level: number) => {
const typeMap: Record<number, string> = { 1: '', 2: 'success', 3: 'warning' }
return typeMap[level] || 'info'
}
const handleSearch = () => {
// 搜索逻辑已通过 computed 实现
}
const showDialog = (type: 'add' | 'edit', row?: Agent) => {
dialogType.value = type
dialogVisible.value = true
if (type === 'edit' && row) {
Object.assign(form, row)
} else {
resetForm()
}
}
const resetForm = () => {
Object.assign(form, {
agentName: '',
agentCode: '',
level: 1,
parentId: null,
contactPerson: '',
contactPhone: '',
email: '',
address: '',
remark: '',
status: 'active'
})
}
const handleSubmit = async (formEl: FormInstance | undefined) => {
if (!formEl) return
await formEl.validate((valid) => {
if (valid) {
if (dialogType.value === 'add') {
const parentAgent = mockData.value.find((item) => item.id === form.parentId)
mockData.value.push({
...form,
id: Date.now().toString(),
parentName: parentAgent ? parentAgent.agentName : '无',
accountCount: 0,
simCardCount: 0,
totalCommission: 0,
createTime: new Date().toLocaleString('zh-CN')
})
ElMessage.success('新增成功')
} else {
const index = mockData.value.findIndex((item) => item.id === form.id)
if (index !== -1) {
const parentAgent = mockData.value.find((item) => item.id === form.parentId)
mockData.value[index] = {
...form,
parentName: parentAgent ? parentAgent.agentName : '无'
}
}
ElMessage.success('修改成功')
}
dialogVisible.value = false
formEl.resetFields()
}
})
}
const handleDelete = (row: Agent) => {
ElMessageBox.confirm('确定删除该代理商吗?删除后其下属代理商和账号也将被删除。', '删除确认', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'error'
}).then(() => {
const index = mockData.value.findIndex((item) => item.id === row.id)
if (index !== -1) {
mockData.value.splice(index, 1)
}
ElMessage.success('删除成功')
})
}
const viewAccountList = (row: Agent) => {
accountDialogVisible.value = true
// 实际应用中应该根据代理商ID加载账号列表
}
const viewCommission = (row: Agent) => {
commissionDialogVisible.value = true
// 实际应用中应该根据代理商ID加载佣金配置
}
const addSubAccount = () => {
ElMessage.info('创建子账号功能')
}
const saveCommissionConfig = () => {
ElMessage.success('佣金配置保存成功')
commissionDialogVisible.value = false
}
</script>
<style lang="scss" scoped>
.page-content {
// 自定义样式
}
</style>

View File

@@ -0,0 +1,276 @@
<template>
<div class="page-content">
<ElRow>
<ElCol :xs="24" :sm="12" :lg="6">
<ElInput v-model="searchQuery" placeholder="账号/姓名/手机号" clearable></ElInput>
</ElCol>
<div style="width: 12px"></div>
<ElCol :xs="24" :sm="12" :lg="6">
<ElSelect v-model="typeFilter" placeholder="客户类型" clearable style="width: 100%">
<ElOption label="代理商" value="agent" />
<ElOption label="企业客户" value="enterprise" />
</ElSelect>
</ElCol>
<div style="width: 12px"></div>
<ElCol :xs="24" :sm="12" :lg="6">
<ElSelect v-model="statusFilter" placeholder="账号状态" clearable style="width: 100%">
<ElOption label="正常" value="active" />
<ElOption label="禁用" value="disabled" />
<ElOption label="已锁定" value="locked" />
</ElSelect>
</ElCol>
<div style="width: 12px"></div>
<ElCol :xs="24" :sm="12" :lg="6" class="el-col2">
<ElButton v-ripple @click="handleSearch">搜索</ElButton>
</ElCol>
</ElRow>
<ArtTable :data="filteredData" index>
<template #default>
<ElTableColumn label="账号" prop="username" min-width="120" />
<ElTableColumn label="真实姓名" prop="realName" />
<ElTableColumn label="客户类型" prop="customerType">
<template #default="scope">
<ElTag :type="scope.row.customerType === 'agent' ? '' : 'success'">
{{ scope.row.customerType === 'agent' ? '代理商' : '企业客户' }}
</ElTag>
</template>
</ElTableColumn>
<ElTableColumn label="所属组织" prop="organizationName" show-overflow-tooltip />
<ElTableColumn label="手机号" prop="phone" />
<ElTableColumn label="邮箱" prop="email" show-overflow-tooltip />
<ElTableColumn label="登录次数" prop="loginCount" />
<ElTableColumn label="最后登录" prop="lastLoginTime" width="180" />
<ElTableColumn label="状态" prop="status">
<template #default="scope">
<ElTag :type="getStatusTagType(scope.row.status)">
{{ getStatusText(scope.row.status) }}
</ElTag>
</template>
</ElTableColumn>
<ElTableColumn label="创建时间" prop="createTime" width="180" />
<ElTableColumn fixed="right" label="操作" width="280">
<template #default="scope">
<el-button link @click="viewDetail(scope.row)">详情</el-button>
<el-button link @click="unbindPhone(scope.row)">解绑手机</el-button>
<el-button link @click="resetPassword(scope.row)">重置密码</el-button>
<el-button
link
:type="scope.row.status === 'active' ? 'danger' : 'primary'"
@click="toggleStatus(scope.row)"
>
{{ scope.row.status === 'active' ? '禁用' : '启用' }}
</el-button>
</template>
</ElTableColumn>
</template>
</ArtTable>
<!-- 操作记录对话框 -->
<ElDialog v-model="detailDialogVisible" title="账号详情" width="800px" align-center>
<ElDescriptions :column="2" border>
<ElDescriptionsItem label="账号">{{ currentAccount?.username }}</ElDescriptionsItem>
<ElDescriptionsItem label="真实姓名">{{ currentAccount?.realName }}</ElDescriptionsItem>
<ElDescriptionsItem label="客户类型">{{
currentAccount?.customerType === 'agent' ? '代理商' : '企业客户'
}}</ElDescriptionsItem>
<ElDescriptionsItem label="所属组织">{{
currentAccount?.organizationName
}}</ElDescriptionsItem>
<ElDescriptionsItem label="手机号">{{ currentAccount?.phone }}</ElDescriptionsItem>
<ElDescriptionsItem label="邮箱">{{ currentAccount?.email }}</ElDescriptionsItem>
<ElDescriptionsItem label="注册时间">{{ currentAccount?.createTime }}</ElDescriptionsItem>
<ElDescriptionsItem label="最后登录">{{
currentAccount?.lastLoginTime
}}</ElDescriptionsItem>
<ElDescriptionsItem label="登录次数">{{ currentAccount?.loginCount }}</ElDescriptionsItem>
<ElDescriptionsItem label="状态">
<ElTag :type="getStatusTagType(currentAccount?.status || 'active')">
{{ getStatusText(currentAccount?.status || 'active') }}
</ElTag>
</ElDescriptionsItem>
</ElDescriptions>
<ElDivider>操作记录</ElDivider>
<ArtTable :data="operationRecords" index max-height="300">
<template #default>
<ElTableColumn label="操作类型" prop="operationType" />
<ElTableColumn label="操作描述" prop="description" />
<ElTableColumn label="操作人" prop="operator" />
<ElTableColumn label="操作时间" prop="operateTime" width="180" />
</template>
</ArtTable>
</ElDialog>
</div>
</template>
<script setup lang="ts">
import { ElMessage, ElMessageBox } from 'element-plus'
defineOptions({ name: 'CustomerAccount' })
interface CustomerAccount {
id: string
username: string
realName: string
customerType: 'agent' | 'enterprise'
organizationName: string
phone: string
email: string
loginCount: number
lastLoginTime: string
status: 'active' | 'disabled' | 'locked'
createTime: string
}
// Mock 数据
const mockData = ref<CustomerAccount[]>([
{
id: '1',
username: 'agent001',
realName: '张三',
customerType: 'agent',
organizationName: '华东区总代理',
phone: '13800138001',
email: 'zhangsan@example.com',
loginCount: 328,
lastLoginTime: '2026-01-09 08:30:00',
status: 'active',
createTime: '2026-01-01 10:00:00'
},
{
id: '2',
username: 'enterprise001',
realName: '李四',
customerType: 'enterprise',
organizationName: '某某科技有限公司',
phone: '13800138002',
email: 'lisi@example.com',
loginCount: 156,
lastLoginTime: '2026-01-08 18:45:00',
status: 'active',
createTime: '2026-01-03 11:00:00'
},
{
id: '3',
username: 'agent002',
realName: '王五',
customerType: 'agent',
organizationName: '江苏省代理',
phone: '13800138003',
email: 'wangwu@example.com',
loginCount: 89,
lastLoginTime: '2026-01-07 14:20:00',
status: 'disabled',
createTime: '2026-01-05 12:00:00'
}
])
const operationRecords = ref([
{
operationType: '重置密码',
description: '管理员重置登录密码',
operator: 'admin',
operateTime: '2026-01-08 10:00:00'
},
{
operationType: '解绑手机',
description: '解绑原手机号并重新绑定',
operator: 'admin',
operateTime: '2026-01-06 15:30:00'
}
])
const searchQuery = ref('')
const typeFilter = ref('')
const statusFilter = ref('')
const detailDialogVisible = ref(false)
const currentAccount = ref<CustomerAccount | null>(null)
const filteredData = computed(() => {
let data = mockData.value
if (searchQuery.value) {
const query = searchQuery.value.toLowerCase()
data = data.filter(
(item) =>
item.username.toLowerCase().includes(query) ||
item.realName.includes(query) ||
item.phone.includes(query)
)
}
if (typeFilter.value) {
data = data.filter((item) => item.customerType === typeFilter.value)
}
if (statusFilter.value) {
data = data.filter((item) => item.status === statusFilter.value)
}
return data
})
const getStatusText = (status: string) => {
const statusMap: Record<string, string> = {
active: '正常',
disabled: '禁用',
locked: '已锁定'
}
return statusMap[status] || '未知'
}
const getStatusTagType = (status: string) => {
const typeMap: Record<string, string> = {
active: 'success',
disabled: 'info',
locked: 'danger'
}
return typeMap[status] || 'info'
}
const handleSearch = () => {
// 搜索逻辑已通过 computed 实现
}
const viewDetail = (row: CustomerAccount) => {
currentAccount.value = row
detailDialogVisible.value = true
}
const unbindPhone = (row: CustomerAccount) => {
ElMessageBox.confirm(`确定要解绑账号 ${row.username} 的手机号吗?`, '解绑确认', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
ElMessage.success('手机号解绑成功')
})
}
const resetPassword = (row: CustomerAccount) => {
ElMessageBox.confirm(`确定要重置账号 ${row.username} 的密码吗?新密码将发送至其手机。`, '重置密码', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
ElMessage.success('密码重置成功,新密码已发送至手机')
})
}
const toggleStatus = (row: CustomerAccount) => {
const action = row.status === 'active' ? '禁用' : '启用'
ElMessageBox.confirm(`确定要${action}账号 ${row.username} 吗?`, `${action}确认`, {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
row.status = row.status === 'active' ? 'disabled' : 'active'
ElMessage.success(`${action}成功`)
})
}
</script>
<style lang="scss" scoped>
.page-content {
:deep(.el-descriptions__label) {
font-weight: 500;
}
}
</style>

View File

@@ -0,0 +1,441 @@
<template>
<div class="page-content">
<!-- 统计卡片 -->
<ElRow :gutter="20" style="margin-bottom: 20px">
<ElCol :xs="24" :sm="12" :lg="6">
<ElCard shadow="hover" class="stat-card">
<div class="stat-content">
<div class="stat-label">客户总数</div>
<div class="stat-value">{{ statistics.totalCustomers }}</div>
</div>
<el-icon class="stat-icon" style="color: var(--el-color-primary)"><User /></el-icon>
</ElCard>
</ElCol>
<ElCol :xs="24" :sm="12" :lg="6">
<ElCard shadow="hover" class="stat-card">
<div class="stat-content">
<div class="stat-label">累计佣金</div>
<div class="stat-value" style="color: var(--el-color-success)">
¥{{ statistics.totalCommission.toFixed(2) }}
</div>
</div>
<el-icon class="stat-icon" style="color: var(--el-color-success)"><Money /></el-icon>
</ElCard>
</ElCol>
<ElCol :xs="24" :sm="12" :lg="6">
<ElCard shadow="hover" class="stat-card">
<div class="stat-content">
<div class="stat-label">已提现</div>
<div class="stat-value" style="color: var(--el-color-warning)">
¥{{ statistics.totalWithdrawn.toFixed(2) }}
</div>
</div>
<el-icon class="stat-icon" style="color: var(--el-color-warning)"><WalletFilled /></el-icon>
</ElCard>
</ElCol>
<ElCol :xs="24" :sm="12" :lg="6">
<ElCard shadow="hover" class="stat-card">
<div class="stat-content">
<div class="stat-label">待提现</div>
<div class="stat-value" style="color: var(--el-color-danger)">
¥{{ statistics.pendingWithdrawal.toFixed(2) }}
</div>
</div>
<el-icon class="stat-icon" style="color: var(--el-color-danger)"><Wallet /></el-icon>
</ElCard>
</ElCol>
</ElRow>
<!-- 搜索和筛选区 -->
<ElRow :gutter="12">
<ElCol :xs="24" :sm="12" :lg="6">
<ElInput v-model="searchQuery" placeholder="客户名称/手机号" clearable />
</ElCol>
<ElCol :xs="24" :sm="12" :lg="6">
<ElSelect v-model="customerTypeFilter" placeholder="客户类型" clearable style="width: 100%">
<ElOption label="全部" value="" />
<ElOption label="代理商" value="agent" />
<ElOption label="企业客户" value="enterprise" />
</ElSelect>
</ElCol>
<ElCol :xs="24" :sm="12" :lg="6">
<ElSelect v-model="commissionRangeFilter" placeholder="佣金范围" clearable style="width: 100%">
<ElOption label="全部" value="" />
<ElOption label="0-1000元" value="0-1000" />
<ElOption label="1000-5000元" value="1000-5000" />
<ElOption label="5000-10000元" value="5000-10000" />
<ElOption label="10000元以上" value="10000+" />
</ElSelect>
</ElCol>
<ElCol :xs="24" :sm="12" :lg="6" class="el-col2">
<ElButton v-ripple @click="handleSearch">搜索</ElButton>
<ElButton v-ripple @click="exportData">导出</ElButton>
</ElCol>
</ElRow>
<!-- 客户佣金列表 -->
<ArtTable :data="filteredData" index style="margin-top: 20px">
<template #default>
<ElTableColumn label="客户名称" prop="customerName" min-width="150" show-overflow-tooltip />
<ElTableColumn label="客户类型" prop="customerType" width="120">
<template #default="scope">
<ElTag :type="scope.row.customerType === 'agent' ? 'warning' : 'primary'">
{{ scope.row.customerType === 'agent' ? '代理商' : '企业客户' }}
</ElTag>
</template>
</ElTableColumn>
<ElTableColumn label="联系电话" prop="phone" width="130" />
<ElTableColumn label="累计佣金" prop="totalCommission" width="130" sortable>
<template #default="scope">
<span style="color: var(--el-color-success); font-weight: 600">
¥{{ scope.row.totalCommission.toFixed(2) }}
</span>
</template>
</ElTableColumn>
<ElTableColumn label="已提现" prop="withdrawnAmount" width="130" sortable>
<template #default="scope">
<span style="color: var(--el-color-warning)">
¥{{ scope.row.withdrawnAmount.toFixed(2) }}
</span>
</template>
</ElTableColumn>
<ElTableColumn label="待提现" prop="pendingAmount" width="130" sortable>
<template #default="scope">
<span style="color: var(--el-color-danger); font-weight: 600">
¥{{ scope.row.pendingAmount.toFixed(2) }}
</span>
</template>
</ElTableColumn>
<ElTableColumn label="提现次数" prop="withdrawalCount" width="100" align="center" />
<ElTableColumn label="卡片数量" prop="cardCount" width="100" align="center">
<template #default="scope">
<span style="color: var(--el-color-primary)">{{ scope.row.cardCount }}</span>
</template>
</ElTableColumn>
<ElTableColumn label="最近提现" prop="lastWithdrawalTime" width="180" />
<ElTableColumn label="注册时间" prop="registerTime" width="180" />
<ElTableColumn fixed="right" label="操作" width="220">
<template #default="scope">
<el-button link :icon="View" @click="viewCommissionDetail(scope.row)">佣金详情</el-button>
<el-button link :icon="List" @click="viewWithdrawalHistory(scope.row)">提现记录</el-button>
</template>
</ElTableColumn>
</template>
</ArtTable>
<!-- 佣金详情对话框 -->
<ElDialog v-model="commissionDialogVisible" title="佣金详情" width="900px" align-center>
<ElDescriptions :column="2" border style="margin-bottom: 20px">
<ElDescriptionsItem label="客户名称">{{ currentCustomer.customerName }}</ElDescriptionsItem>
<ElDescriptionsItem label="客户类型">
<ElTag :type="currentCustomer.customerType === 'agent' ? 'warning' : 'primary'">
{{ currentCustomer.customerType === 'agent' ? '代理商' : '企业客户' }}
</ElTag>
</ElDescriptionsItem>
<ElDescriptionsItem label="累计佣金">
<span style="color: var(--el-color-success); font-weight: 600">
¥{{ currentCustomer.totalCommission.toFixed(2) }}
</span>
</ElDescriptionsItem>
<ElDescriptionsItem label="已提现">
<span style="color: var(--el-color-warning)">
¥{{ currentCustomer.withdrawnAmount.toFixed(2) }}
</span>
</ElDescriptionsItem>
<ElDescriptionsItem label="待提现">
<span style="color: var(--el-color-danger); font-weight: 600">
¥{{ currentCustomer.pendingAmount.toFixed(2) }}
</span>
</ElDescriptionsItem>
<ElDescriptionsItem label="提现次数">{{ currentCustomer.withdrawalCount }}</ElDescriptionsItem>
</ElDescriptions>
<ElDivider content-position="left">佣金明细</ElDivider>
<ArtTable :data="commissionDetails" index>
<template #default>
<ElTableColumn label="来源" prop="source" width="120" />
<ElTableColumn label="订单号" prop="orderNo" width="180" />
<ElTableColumn label="佣金金额" prop="amount" width="120">
<template #default="scope">
<span style="color: var(--el-color-success)">+¥{{ scope.row.amount.toFixed(2) }}</span>
</template>
</ElTableColumn>
<ElTableColumn label="佣金比例" prop="rate" width="100" />
<ElTableColumn label="订单金额" prop="orderAmount" width="120" />
<ElTableColumn label="获得时间" prop="createTime" width="180" />
<ElTableColumn label="状态" prop="status" width="100">
<template #default="scope">
<ElTag v-if="scope.row.status === 'completed'" type="success">已结算</ElTag>
<ElTag v-else-if="scope.row.status === 'pending'" type="warning">待结算</ElTag>
</template>
</ElTableColumn>
</template>
</ArtTable>
<template #footer>
<ElButton @click="commissionDialogVisible = false">关闭</ElButton>
</template>
</ElDialog>
<!-- 提现记录对话框 -->
<ElDialog v-model="withdrawalDialogVisible" title="提现记录" width="900px" align-center>
<ArtTable :data="withdrawalHistory" index>
<template #default>
<ElTableColumn label="提现单号" prop="withdrawalNo" width="180" />
<ElTableColumn label="提现金额" prop="amount" width="120">
<template #default="scope">
<span style="color: var(--el-color-danger); font-weight: 600">
¥{{ scope.row.amount.toFixed(2) }}
</span>
</template>
</ElTableColumn>
<ElTableColumn label="手续费" prop="fee" width="100">
<template #default="scope"> ¥{{ scope.row.fee.toFixed(2) }} </template>
</ElTableColumn>
<ElTableColumn label="实际到账" prop="actualAmount" width="120">
<template #default="scope">
<span style="color: var(--el-color-success)">
¥{{ scope.row.actualAmount.toFixed(2) }}
</span>
</template>
</ElTableColumn>
<ElTableColumn label="提现方式" prop="method" width="100" />
<ElTableColumn label="状态" prop="status" width="100">
<template #default="scope">
<ElTag v-if="scope.row.status === 'completed'" type="success">已完成</ElTag>
<ElTag v-else-if="scope.row.status === 'processing'" type="warning">处理中</ElTag>
<ElTag v-else-if="scope.row.status === 'pending'" type="info">待审核</ElTag>
<ElTag v-else type="danger">已拒绝</ElTag>
</template>
</ElTableColumn>
<ElTableColumn label="申请时间" prop="applyTime" width="180" />
<ElTableColumn label="完成时间" prop="completeTime" width="180">
<template #default="scope">
{{ scope.row.completeTime || '-' }}
</template>
</ElTableColumn>
</template>
</ArtTable>
<template #footer>
<ElButton @click="withdrawalDialogVisible = false">关闭</ElButton>
</template>
</ElDialog>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { ElMessage } from 'element-plus'
import { View, List, User, Money, WalletFilled, Wallet } from '@element-plus/icons-vue'
defineOptions({ name: 'CustomerCommission' })
interface CustomerCommission {
id: string
customerName: string
customerType: 'agent' | 'enterprise'
phone: string
totalCommission: number
withdrawnAmount: number
pendingAmount: number
withdrawalCount: number
cardCount: number
lastWithdrawalTime: string
registerTime: string
}
const searchQuery = ref('')
const customerTypeFilter = ref('')
const commissionRangeFilter = ref('')
const commissionDialogVisible = ref(false)
const withdrawalDialogVisible = ref(false)
const statistics = reactive({
totalCustomers: 156,
totalCommission: 580000.00,
totalWithdrawn: 420000.00,
pendingWithdrawal: 160000.00
})
const mockData = ref<CustomerCommission[]>([
{
id: '1',
customerName: '华东区总代理',
customerType: 'agent',
phone: '13800138000',
totalCommission: 85000.00,
withdrawnAmount: 70000.00,
pendingAmount: 15000.00,
withdrawalCount: 12,
cardCount: 500,
lastWithdrawalTime: '2026-01-08 10:00:00',
registerTime: '2025-06-01 09:00:00'
},
{
id: '2',
customerName: '深圳市科技有限公司',
customerType: 'enterprise',
phone: '13900139000',
totalCommission: 45000.00,
withdrawnAmount: 30000.00,
pendingAmount: 15000.00,
withdrawalCount: 8,
cardCount: 300,
lastWithdrawalTime: '2026-01-05 14:30:00',
registerTime: '2025-07-15 10:00:00'
},
{
id: '3',
customerName: '北京智能制造',
customerType: 'enterprise',
phone: '13700137000',
totalCommission: 68000.00,
withdrawnAmount: 55000.00,
pendingAmount: 13000.00,
withdrawalCount: 10,
cardCount: 450,
lastWithdrawalTime: '2026-01-07 16:00:00',
registerTime: '2025-08-20 11:00:00'
}
])
const currentCustomer = ref<CustomerCommission>({
id: '',
customerName: '',
customerType: 'agent',
phone: '',
totalCommission: 0,
withdrawnAmount: 0,
pendingAmount: 0,
withdrawalCount: 0,
cardCount: 0,
lastWithdrawalTime: '',
registerTime: ''
})
const commissionDetails = ref([
{
id: '1',
source: '套餐销售',
orderNo: 'ORD202601090001',
amount: 150.00,
rate: '10%',
orderAmount: '¥1,500.00',
createTime: '2026-01-09 09:30:00',
status: 'completed'
},
{
id: '2',
source: '卡片激活',
orderNo: 'ORD202601080025',
amount: 80.00,
rate: '8%',
orderAmount: '¥1,000.00',
createTime: '2026-01-08 15:20:00',
status: 'completed'
}
])
const withdrawalHistory = ref([
{
id: '1',
withdrawalNo: 'WD202601080001',
amount: 10000.00,
fee: 20.00,
actualAmount: 9980.00,
method: '银行卡',
status: 'completed',
applyTime: '2026-01-08 10:00:00',
completeTime: '2026-01-08 15:30:00'
},
{
id: '2',
withdrawalNo: 'WD202601050002',
amount: 5000.00,
fee: 10.00,
actualAmount: 4990.00,
method: '支付宝',
status: 'completed',
applyTime: '2026-01-05 14:00:00',
completeTime: '2026-01-05 18:20:00'
}
])
const filteredData = computed(() => {
let data = mockData.value
if (searchQuery.value) {
data = data.filter(
(item) =>
item.customerName.includes(searchQuery.value) ||
item.phone.includes(searchQuery.value)
)
}
if (customerTypeFilter.value) {
data = data.filter((item) => item.customerType === customerTypeFilter.value)
}
if (commissionRangeFilter.value) {
data = data.filter((item) => {
const commission = item.totalCommission
if (commissionRangeFilter.value === '0-1000') return commission >= 0 && commission < 1000
if (commissionRangeFilter.value === '1000-5000') return commission >= 1000 && commission < 5000
if (commissionRangeFilter.value === '5000-10000') return commission >= 5000 && commission < 10000
if (commissionRangeFilter.value === '10000+') return commission >= 10000
return true
})
}
return data
})
const handleSearch = () => {}
const exportData = () => {
ElMessage.success('数据导出中...')
}
const viewCommissionDetail = (row: CustomerCommission) => {
currentCustomer.value = { ...row }
commissionDialogVisible.value = true
}
const viewWithdrawalHistory = (row: CustomerCommission) => {
currentCustomer.value = { ...row }
withdrawalDialogVisible.value = true
}
</script>
<style lang="scss" scoped>
.page-content {
.stat-card {
:deep(.el-card__body) {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20px;
}
.stat-content {
.stat-label {
font-size: 14px;
color: var(--el-text-color-secondary);
margin-bottom: 8px;
}
.stat-value {
font-size: 24px;
font-weight: 600;
color: var(--el-text-color-primary);
}
}
.stat-icon {
font-size: 40px;
opacity: 0.6;
}
}
}
</style>

View File

@@ -0,0 +1,240 @@
<template>
<div class="page-content">
<ElRow>
<ElCol :xs="24" :sm="12" :lg="6">
<ElInput v-model="searchQuery" placeholder="角色名称" clearable></ElInput>
</ElCol>
<div style="width: 12px"></div>
<ElCol :xs="24" :sm="12" :lg="6" class="el-col2">
<ElButton v-ripple @click="handleSearch">搜索</ElButton>
<ElButton v-ripple @click="showDialog('add')">新增角色</ElButton>
</ElCol>
</ElRow>
<ArtTable :data="filteredData" index>
<template #default>
<ElTableColumn label="角色名称" prop="roleName" />
<ElTableColumn label="角色编码" prop="roleCode" />
<ElTableColumn label="能力边界" prop="abilities">
<template #default="scope">
<ElTag
v-for="(ability, index) in scope.row.abilities"
:key="index"
size="small"
style="margin-right: 4px"
>
{{ ability }}
</ElTag>
</template>
</ElTableColumn>
<ElTableColumn label="描述" prop="description" show-overflow-tooltip />
<ElTableColumn label="状态" prop="status">
<template #default="scope">
<ElTag :type="scope.row.status === 'active' ? 'success' : 'info'">
{{ scope.row.status === 'active' ? '启用' : '禁用' }}
</ElTag>
</template>
</ElTableColumn>
<ElTableColumn label="创建时间" prop="createTime" width="180" />
<ElTableColumn fixed="right" label="操作" width="180">
<template #default="scope">
<el-button link @click="showDialog('edit', scope.row)">编辑</el-button>
<el-button link @click="handleDelete(scope.row)">删除</el-button>
</template>
</ElTableColumn>
</template>
</ArtTable>
<!-- 新增/编辑对话框 -->
<ElDialog
v-model="dialogVisible"
:title="dialogType === 'add' ? '新增客户角色' : '编辑客户角色'"
width="600px"
align-center
>
<ElForm ref="formRef" :model="form" :rules="rules" label-width="120px">
<ElFormItem label="角色名称" prop="roleName">
<ElInput v-model="form.roleName" placeholder="请输入角色名称" />
</ElFormItem>
<ElFormItem label="角色编码" prop="roleCode">
<ElInput v-model="form.roleCode" placeholder="请输入角色编码" />
</ElFormItem>
<ElFormItem label="能力边界" prop="abilities">
<ElCheckboxGroup v-model="form.abilities">
<ElCheckbox label="查看网卡">查看网卡</ElCheckbox>
<ElCheckbox label="操作网卡">操作网卡</ElCheckbox>
<ElCheckbox label="查看套餐">查看套餐</ElCheckbox>
<ElCheckbox label="购买套餐">购买套餐</ElCheckbox>
<ElCheckbox label="查看设备">查看设备</ElCheckbox>
<ElCheckbox label="管理设备">管理设备</ElCheckbox>
<ElCheckbox label="查看佣金">查看佣金</ElCheckbox>
<ElCheckbox label="提现佣金">提现佣金</ElCheckbox>
</ElCheckboxGroup>
</ElFormItem>
<ElFormItem label="描述" prop="description">
<ElInput v-model="form.description" type="textarea" :rows="3" placeholder="请输入角色描述" />
</ElFormItem>
<ElFormItem label="状态">
<ElSwitch v-model="form.status" active-value="active" inactive-value="inactive" />
</ElFormItem>
</ElForm>
<template #footer>
<div class="dialog-footer">
<ElButton @click="dialogVisible = false">取消</ElButton>
<ElButton type="primary" @click="handleSubmit(formRef)">提交</ElButton>
</div>
</template>
</ElDialog>
</div>
</template>
<script setup lang="ts">
import { ElMessage, ElMessageBox } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
defineOptions({ name: 'CustomerRole' })
interface CustomerRole {
id?: string
roleName: string
roleCode: string
abilities: string[]
description: string
status: 'active' | 'inactive'
createTime?: string
}
// Mock 数据
const mockData = ref<CustomerRole[]>([
{
id: '1',
roleName: '标准客户',
roleCode: 'CUSTOMER_STANDARD',
abilities: ['查看网卡', '查看套餐', '查看设备'],
description: '标准客户角色,拥有基本查看权限',
status: 'active',
createTime: '2026-01-01 10:00:00'
},
{
id: '2',
roleName: 'VIP客户',
roleCode: 'CUSTOMER_VIP',
abilities: ['查看网卡', '操作网卡', '查看套餐', '购买套餐', '查看设备', '管理设备'],
description: 'VIP客户角色拥有更多操作权限',
status: 'active',
createTime: '2026-01-02 11:00:00'
},
{
id: '3',
roleName: '企业客户',
roleCode: 'CUSTOMER_ENTERPRISE',
abilities: ['查看网卡', '操作网卡', '查看套餐', '购买套餐', '查看设备', '管理设备', '查看佣金'],
description: '企业客户角色,拥有完整业务权限',
status: 'active',
createTime: '2026-01-03 12:00:00'
}
])
const searchQuery = ref('')
const dialogVisible = ref(false)
const dialogType = ref<'add' | 'edit'>('add')
const formRef = ref<FormInstance>()
const form = reactive<CustomerRole>({
roleName: '',
roleCode: '',
abilities: [],
description: '',
status: 'active'
})
const rules = reactive<FormRules>({
roleName: [{ required: true, message: '请输入角色名称', trigger: 'blur' }],
roleCode: [{ required: true, message: '请输入角色编码', trigger: 'blur' }],
abilities: [{ required: true, message: '请至少选择一项能力', trigger: 'change' }]
})
const filteredData = computed(() => {
if (!searchQuery.value) return mockData.value
return mockData.value.filter((item) =>
item.roleName.toLowerCase().includes(searchQuery.value.toLowerCase())
)
})
const handleSearch = () => {
// 搜索逻辑已通过 computed 实现
}
const showDialog = (type: 'add' | 'edit', row?: CustomerRole) => {
dialogType.value = type
dialogVisible.value = true
if (type === 'edit' && row) {
form.id = row.id
form.roleName = row.roleName
form.roleCode = row.roleCode
form.abilities = [...row.abilities]
form.description = row.description
form.status = row.status
} else {
resetForm()
}
}
const resetForm = () => {
form.id = undefined
form.roleName = ''
form.roleCode = ''
form.abilities = []
form.description = ''
form.status = 'active'
}
const handleSubmit = async (formEl: FormInstance | undefined) => {
if (!formEl) return
await formEl.validate((valid) => {
if (valid) {
if (dialogType.value === 'add') {
mockData.value.push({
...form,
id: Date.now().toString(),
createTime: new Date().toLocaleString('zh-CN')
})
ElMessage.success('新增成功')
} else {
const index = mockData.value.findIndex((item) => item.id === form.id)
if (index !== -1) {
mockData.value[index] = { ...form }
}
ElMessage.success('修改成功')
}
dialogVisible.value = false
formEl.resetFields()
}
})
}
const handleDelete = (row: CustomerRole) => {
ElMessageBox.confirm('确定删除该客户角色吗?', '删除确认', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'error'
}).then(() => {
const index = mockData.value.findIndex((item) => item.id === row.id)
if (index !== -1) {
mockData.value.splice(index, 1)
}
ElMessage.success('删除成功')
})
}
</script>
<style lang="scss" scoped>
.page-content {
:deep(.el-checkbox) {
margin-right: 20px;
margin-bottom: 10px;
}
}
</style>

View File

@@ -0,0 +1,359 @@
<template>
<ArtTableFullScreen>
<div class="customer-management-page" id="table-full-screen">
<!-- 搜索栏 -->
<ArtSearchBar
v-model:filter="formFilters"
:items="formItems"
@reset="handleReset"
@search="handleSearch"
></ArtSearchBar>
<ElCard shadow="never" class="art-table-card">
<!-- 表格头部 -->
<ArtTableHeader
:columnList="columnOptions"
v-model:columns="columnChecks"
@refresh="handleRefresh"
>
<template #left>
<ElButton type="primary" @click="handleSearch">查询</ElButton>
<ElButton @click="exportExcel">导出excel</ElButton>
</template>
</ArtTableHeader>
<!-- 表格 -->
<ArtTable
ref="tableRef"
row-key="id"
:loading="loading"
:data="tableData"
:currentPage="pagination.currentPage"
:pageSize="pagination.pageSize"
:total="pagination.total"
:marginTop="10"
@selection-change="handleSelectionChange"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
>
<template #default>
<ElTableColumn v-for="col in columns" :key="col.prop || col.type" v-bind="col" />
</template>
</ArtTable>
</ElCard>
</div>
</ArtTableFullScreen>
</template>
<script setup lang="ts">
import { h } from 'vue'
import { ElMessage } from 'element-plus'
import { useCheckedColumns } from '@/composables/useCheckedColumns'
import ArtButtonTable from '@/components/core/forms/ArtButtonTable.vue'
import { SearchChangeParams, SearchFormItem } from '@/types'
defineOptions({ name: 'CustomerManagement' })
const loading = ref(false)
// 定义表单搜索初始值
const initialSearchState = {
distributorName: '',
distributorAccount: ''
}
// 响应式表单数据
const formFilters = reactive({ ...initialSearchState })
const pagination = reactive({
currentPage: 1,
pageSize: 20,
total: 0
})
// 表格数据
const tableData = ref<any[]>([])
// 表格实例引用
const tableRef = ref()
// 选中的行数据
const selectedRows = ref<any[]>([])
// 模拟数据 - 客户管理(代理商)
const mockData = [
{
id: 1,
distributorName: '北京优享科技有限公司',
distributorAccount: 'bjyx2024',
totalCommission: 12560.8,
instantCommission: 8320.5,
pendingCommission: 4240.3,
prepaidCommission: 2400.0,
withdrawnCommission: 6800.2,
withdrawingCommission: 1520.3,
availableCommission: 2000.0
},
{
id: 2,
distributorName: '上海智联通信技术公司',
distributorAccount: 'shzl2024',
totalCommission: 18750.25,
instantCommission: 12500.75,
pendingCommission: 6249.5,
prepaidCommission: 3600.0,
withdrawnCommission: 8900.25,
withdrawingCommission: 2100.0,
availableCommission: 3750.0
},
{
id: 3,
distributorName: '广州物联网络有限公司',
distributorAccount: 'gzwl2024',
totalCommission: 9876.4,
instantCommission: 6584.3,
pendingCommission: 3292.1,
prepaidCommission: 1800.0,
withdrawnCommission: 4938.2,
withdrawingCommission: 980.2,
availableCommission: 1958.0
},
{
id: 4,
distributorName: '深圳云联科技股份公司',
distributorAccount: 'szyl2024',
totalCommission: 24500.6,
instantCommission: 16333.73,
pendingCommission: 8166.87,
prepaidCommission: 4800.0,
withdrawnCommission: 11225.3,
withdrawingCommission: 2450.06,
availableCommission: 4900.12
},
{
id: 5,
distributorName: '杭州通达网络服务公司',
distributorAccount: 'hztd2024',
totalCommission: 15200.35,
instantCommission: 10133.57,
pendingCommission: 5066.78,
prepaidCommission: 2900.0,
withdrawnCommission: 7600.18,
withdrawingCommission: 1520.04,
availableCommission: 3040.07
}
]
// 重置表单
const handleReset = () => {
Object.assign(formFilters, { ...initialSearchState })
pagination.currentPage = 1
getCustomerList()
}
// 搜索处理
const handleSearch = () => {
console.log('搜索参数:', formFilters)
pagination.currentPage = 1
getCustomerList()
}
// 表单项变更处理
const handleFormChange = (params: SearchChangeParams): void => {
console.log('表单项变更:', params)
}
// 表单配置项
const formItems: SearchFormItem[] = [
{
label: '代理商名称',
prop: 'distributorName',
type: 'input',
config: {
clearable: true,
placeholder: '请输入代理商名称'
},
onChange: handleFormChange
},
{
label: '代理商账号',
prop: 'distributorAccount',
type: 'input',
config: {
clearable: true,
placeholder: '请输入代理商账号'
},
onChange: handleFormChange
}
]
// 列配置
const columnOptions = [
{ label: '勾选', type: 'selection' },
{ label: '代理商名称', prop: 'distributorName' },
{ label: '代理商账号', prop: 'distributorAccount' },
{ label: '总佣金', prop: 'totalCommission' },
{ label: '已秒返佣金', prop: 'instantCommission' },
{ label: '未秒返佣金', prop: 'pendingCommission' },
{ label: '预存佣金', prop: 'prepaidCommission' },
{ label: '已提现佣金', prop: 'withdrawnCommission' },
{ label: '提现中佣金', prop: 'withdrawingCommission' },
{ label: '可提现佣金', prop: 'availableCommission' },
{ label: '操作', prop: 'operation' }
]
// 导出Excel
const exportExcel = () => {
if (selectedRows.value.length === 0) {
ElMessage.warning('请先选择要导出的数据')
return
}
ElMessage.success(`导出 ${selectedRows.value.length} 条客户记录`)
}
// 查看提现详情
const viewWithdrawDetail = (row: any) => {
ElMessage.info(`查看 ${row.distributorName} 的提现详情`)
}
// 查看佣金明细
const viewCommissionDetail = (row: any) => {
ElMessage.info(`查看 ${row.distributorName} 的佣金明细`)
}
// 查看自动提现详情
const viewAutoWithdrawDetail = (row: any) => {
ElMessage.info(`查看 ${row.distributorName} 的自动提现详情`)
}
// 动态列配置
const { columnChecks, columns } = useCheckedColumns(() => [
{ type: 'selection' },
{
prop: 'distributorName',
label: '代理商名称',
minWidth: 180
},
{
prop: 'distributorAccount',
label: '代理商账号',
width: 120
},
{
prop: 'totalCommission',
label: '总佣金',
width: 120,
formatter: (row) => `¥${row.totalCommission.toFixed(2)}`
},
{
prop: 'instantCommission',
label: '已秒返佣金',
width: 120,
formatter: (row) => `¥${row.instantCommission.toFixed(2)}`
},
{
prop: 'pendingCommission',
label: '未秒返佣金',
width: 120,
formatter: (row) => `¥${row.pendingCommission.toFixed(2)}`
},
{
prop: 'prepaidCommission',
label: '预存佣金',
width: 120,
formatter: (row) => `¥${row.prepaidCommission.toFixed(2)}`
},
{
prop: 'withdrawnCommission',
label: '已提现佣金',
width: 120,
formatter: (row) => `¥${row.withdrawnCommission.toFixed(2)}`
},
{
prop: 'withdrawingCommission',
label: '提现中佣金',
width: 120,
formatter: (row) => `¥${row.withdrawingCommission.toFixed(2)}`
},
{
prop: 'availableCommission',
label: '可提现佣金',
width: 120,
formatter: (row) => `¥${row.availableCommission.toFixed(2)}`
},
{
prop: 'operation',
label: '操作',
width: 200,
formatter: (row: any) => {
return h('div', { style: 'display: flex; gap: 5px; flex-wrap: wrap;' }, [
h(ArtButtonTable, {
type: 'view',
text: '提现详情',
onClick: () => viewWithdrawDetail(row)
}),
h(ArtButtonTable, {
type: 'detail',
text: '佣金明细',
onClick: () => viewCommissionDetail(row)
}),
h(ArtButtonTable, {
type: 'info',
text: '自动提现详情',
onClick: () => viewAutoWithdrawDetail(row)
})
])
}
}
])
onMounted(() => {
getCustomerList()
})
// 获取客户列表
const getCustomerList = async () => {
loading.value = true
try {
// 模拟API调用
await new Promise((resolve) => setTimeout(resolve, 500))
const startIndex = (pagination.currentPage - 1) * pagination.pageSize
const endIndex = startIndex + pagination.pageSize
const paginatedData = mockData.slice(startIndex, endIndex)
tableData.value = paginatedData
pagination.total = mockData.length
loading.value = false
} catch (error) {
console.error('获取客户列表失败:', error)
loading.value = false
}
}
const handleRefresh = () => {
getCustomerList()
}
// 处理表格行选择变化
const handleSelectionChange = (selection: any[]) => {
selectedRows.value = selection
}
// 处理表格分页变化
const handleSizeChange = (newPageSize: number) => {
pagination.pageSize = newPageSize
getCustomerList()
}
const handleCurrentChange = (newCurrentPage: number) => {
pagination.currentPage = newCurrentPage
getCustomerList()
}
</script>
<style lang="scss" scoped>
.customer-management-page {
// 可以添加特定样式
}
</style>

View File

@@ -0,0 +1,498 @@
<template>
<div class="page-content">
<!-- 搜索和操作区 -->
<ElRow :gutter="12">
<ElCol :xs="24" :sm="12" :lg="6">
<ElInput v-model="searchQuery" placeholder="企业名称/联系人" clearable />
</ElCol>
<ElCol :xs="24" :sm="12" :lg="6">
<ElSelect v-model="statusFilter" placeholder="状态筛选" clearable style="width: 100%">
<ElOption label="全部" value="" />
<ElOption label="正常" value="active" />
<ElOption label="禁用" value="disabled" />
<ElOption label="待审核" value="pending" />
</ElSelect>
</ElCol>
<ElCol :xs="24" :sm="12" :lg="6">
<ElSelect v-model="roleFilter" placeholder="角色筛选" clearable style="width: 100%">
<ElOption label="全部" value="" />
<ElOption label="企业管理员" value="admin" />
<ElOption label="企业用户" value="user" />
</ElSelect>
</ElCol>
<ElCol :xs="24" :sm="12" :lg="6" class="el-col2">
<ElButton v-ripple @click="handleSearch">搜索</ElButton>
<ElButton v-ripple type="primary" @click="showDialog('add')">新增企业客户</ElButton>
</ElCol>
</ElRow>
<!-- 企业客户列表 -->
<ArtTable :data="filteredData" index style="margin-top: 20px">
<template #default>
<ElTableColumn label="企业名称" prop="enterpriseName" min-width="180" show-overflow-tooltip />
<ElTableColumn label="统一社会信用代码" prop="creditCode" width="180" />
<ElTableColumn label="联系人" prop="contactPerson" width="120" />
<ElTableColumn label="联系电话" prop="contactPhone" width="130" />
<ElTableColumn label="所属角色" prop="roleName" width="120">
<template #default="scope">
<ElTag>{{ scope.row.roleName }}</ElTag>
</template>
</ElTableColumn>
<ElTableColumn label="卡片数量" prop="cardCount" width="100">
<template #default="scope">
<span style="color: var(--el-color-primary)">{{ scope.row.cardCount }}</span>
</template>
</ElTableColumn>
<ElTableColumn label="设备数量" prop="deviceCount" width="100">
<template #default="scope">
<span style="color: var(--el-color-success)">{{ scope.row.deviceCount }}</span>
</template>
</ElTableColumn>
<ElTableColumn label="账户余额" prop="balance" width="120">
<template #default="scope"> ¥{{ scope.row.balance.toFixed(2) }} </template>
</ElTableColumn>
<ElTableColumn label="状态" prop="status" width="100">
<template #default="scope">
<ElTag v-if="scope.row.status === 'active'" type="success">正常</ElTag>
<ElTag v-else-if="scope.row.status === 'disabled'" type="danger">禁用</ElTag>
<ElTag v-else-if="scope.row.status === 'pending'" type="warning">待审核</ElTag>
</template>
</ElTableColumn>
<ElTableColumn label="创建时间" prop="createTime" width="180" />
<ElTableColumn fixed="right" label="操作" width="260">
<template #default="scope">
<el-button link :icon="View" @click="viewDetail(scope.row)">详情</el-button>
<el-button link @click="showDialog('edit', scope.row)">编辑</el-button>
<el-button
link
:type="scope.row.status === 'active' ? 'warning' : 'success'"
@click="toggleStatus(scope.row)"
>
{{ scope.row.status === 'active' ? '禁用' : '启用' }}
</el-button>
<el-button link type="danger" @click="handleDelete(scope.row)">删除</el-button>
</template>
</ElTableColumn>
</template>
</ArtTable>
<!-- 新增/编辑企业客户对话框 -->
<ElDialog
v-model="dialogVisible"
:title="dialogType === 'add' ? '新增企业客户' : '编辑企业客户'"
width="700px"
align-center
>
<ElForm ref="formRef" :model="form" :rules="rules" label-width="140px">
<ElDivider content-position="left">企业信息</ElDivider>
<ElFormItem label="企业名称" prop="enterpriseName">
<ElInput v-model="form.enterpriseName" placeholder="请输入企业名称" />
</ElFormItem>
<ElFormItem label="统一社会信用代码" prop="creditCode">
<ElInput v-model="form.creditCode" placeholder="请输入统一社会信用代码" maxlength="18" />
</ElFormItem>
<ElFormItem label="企业地址" prop="address">
<ElInput v-model="form.address" placeholder="请输入企业地址" />
</ElFormItem>
<ElFormItem label="营业执照" prop="businessLicense">
<ElUpload
:action="uploadUrl"
:limit="1"
list-type="picture-card"
accept="image/*"
>
<el-icon><Plus /></el-icon>
</ElUpload>
<div style="color: var(--el-text-color-secondary); font-size: 12px">支持 JPGPNG 格式</div>
</ElFormItem>
<ElDivider content-position="left">联系人信息</ElDivider>
<ElFormItem label="联系人" prop="contactPerson">
<ElInput v-model="form.contactPerson" placeholder="请输入联系人姓名" />
</ElFormItem>
<ElFormItem label="联系电话" prop="contactPhone">
<ElInput v-model="form.contactPhone" placeholder="请输入联系电话" maxlength="11" />
</ElFormItem>
<ElFormItem label="联系邮箱" prop="contactEmail">
<ElInput v-model="form.contactEmail" placeholder="请输入联系邮箱" />
</ElFormItem>
<ElDivider content-position="left">账号配置</ElDivider>
<ElFormItem label="登录账号" prop="username">
<ElInput v-model="form.username" placeholder="请输入登录账号" :disabled="dialogType === 'edit'" />
<div style="color: var(--el-text-color-secondary); font-size: 12px">只能登录企业端</div>
</ElFormItem>
<ElFormItem v-if="dialogType === 'add'" label="登录密码" prop="password">
<ElInput v-model="form.password" type="password" placeholder="请输入登录密码" show-password />
</ElFormItem>
<ElFormItem label="分配角色" prop="roleId">
<ElSelect v-model="form.roleId" placeholder="请选择客户角色" style="width: 100%">
<ElOption
v-for="role in availableRoles"
:key="role.id"
:label="role.roleName"
:value="role.id"
/>
</ElSelect>
<div style="color: var(--el-text-color-secondary); font-size: 12px">角色决定企业客户的能力边界</div>
</ElFormItem>
<ElFormItem label="初始余额" prop="initialBalance">
<ElInputNumber v-model="form.initialBalance" :min="0" :precision="2" style="width: 100%" />
<span style="margin-left: 8px"></span>
</ElFormItem>
<ElFormItem label="备注" prop="remark">
<ElInput v-model="form.remark" type="textarea" :rows="3" placeholder="请输入备注信息" />
</ElFormItem>
<ElFormItem label="状态">
<ElSwitch v-model="form.status" active-value="active" inactive-value="disabled" />
</ElFormItem>
</ElForm>
<template #footer>
<div class="dialog-footer">
<ElButton @click="dialogVisible = false">取消</ElButton>
<ElButton type="primary" @click="handleSubmit(formRef)">提交</ElButton>
</div>
</template>
</ElDialog>
<!-- 企业详情对话框 -->
<ElDialog v-model="detailDialogVisible" title="企业客户详情" width="800px" align-center>
<ElDescriptions :column="2" border>
<ElDescriptionsItem label="企业名称">{{ currentDetail.enterpriseName }}</ElDescriptionsItem>
<ElDescriptionsItem label="统一社会信用代码">{{ currentDetail.creditCode }}</ElDescriptionsItem>
<ElDescriptionsItem label="联系人">{{ currentDetail.contactPerson }}</ElDescriptionsItem>
<ElDescriptionsItem label="联系电话">{{ currentDetail.contactPhone }}</ElDescriptionsItem>
<ElDescriptionsItem label="联系邮箱">{{ currentDetail.contactEmail }}</ElDescriptionsItem>
<ElDescriptionsItem label="企业地址" :span="2">{{ currentDetail.address }}</ElDescriptionsItem>
<ElDescriptionsItem label="所属角色">
<ElTag>{{ currentDetail.roleName }}</ElTag>
</ElDescriptionsItem>
<ElDescriptionsItem label="登录账号">{{ currentDetail.username }}</ElDescriptionsItem>
<ElDescriptionsItem label="卡片数量">
<span style="color: var(--el-color-primary)">{{ currentDetail.cardCount }}</span>
</ElDescriptionsItem>
<ElDescriptionsItem label="设备数量">
<span style="color: var(--el-color-success)">{{ currentDetail.deviceCount }}</span>
</ElDescriptionsItem>
<ElDescriptionsItem label="账户余额">¥{{ currentDetail.balance.toFixed(2) }}</ElDescriptionsItem>
<ElDescriptionsItem label="状态">
<ElTag v-if="currentDetail.status === 'active'" type="success">正常</ElTag>
<ElTag v-else-if="currentDetail.status === 'disabled'" type="danger">禁用</ElTag>
<ElTag v-else-if="currentDetail.status === 'pending'" type="warning">待审核</ElTag>
</ElDescriptionsItem>
<ElDescriptionsItem label="创建时间">{{ currentDetail.createTime }}</ElDescriptionsItem>
<ElDescriptionsItem label="最后登录">{{ currentDetail.lastLoginTime || '未登录' }}</ElDescriptionsItem>
<ElDescriptionsItem label="备注" :span="2">{{ currentDetail.remark || '无' }}</ElDescriptionsItem>
</ElDescriptions>
<template #footer>
<ElButton @click="detailDialogVisible = false">关闭</ElButton>
</template>
</ElDialog>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { View, Plus } from '@element-plus/icons-vue'
import type { FormInstance, FormRules } from 'element-plus'
defineOptions({ name: 'EnterpriseCustomer' })
interface EnterpriseCustomer {
id: string
enterpriseName: string
creditCode: string
address: string
contactPerson: string
contactPhone: string
contactEmail: string
username: string
roleId: string
roleName: string
cardCount: number
deviceCount: number
balance: number
status: 'active' | 'disabled' | 'pending'
createTime: string
lastLoginTime?: string
remark?: string
}
const searchQuery = ref('')
const statusFilter = ref('')
const roleFilter = ref('')
const dialogVisible = ref(false)
const detailDialogVisible = ref(false)
const dialogType = ref<'add' | 'edit'>('add')
const formRef = ref<FormInstance>()
const uploadUrl = ref('/api/upload/image')
const form = reactive({
enterpriseName: '',
creditCode: '',
address: '',
businessLicense: '',
contactPerson: '',
contactPhone: '',
contactEmail: '',
username: '',
password: '',
roleId: '',
initialBalance: 0,
remark: '',
status: 'active'
})
const rules = reactive<FormRules>({
enterpriseName: [{ required: true, message: '请输入企业名称', trigger: 'blur' }],
creditCode: [
{ required: true, message: '请输入统一社会信用代码', trigger: 'blur' },
{ len: 18, message: '统一社会信用代码为18位', trigger: 'blur' }
],
contactPerson: [{ required: true, message: '请输入联系人', trigger: 'blur' }],
contactPhone: [
{ required: true, message: '请输入联系电话', trigger: 'blur' },
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号', trigger: 'blur' }
],
contactEmail: [
{ required: true, message: '请输入联系邮箱', trigger: 'blur' },
{ type: 'email', message: '请输入正确的邮箱格式', trigger: 'blur' }
],
username: [{ required: true, message: '请输入登录账号', trigger: 'blur' }],
password: [
{ required: true, message: '请输入登录密码', trigger: 'blur' },
{ min: 6, max: 20, message: '密码长度为6-20位', trigger: 'blur' }
],
roleId: [{ required: true, message: '请选择客户角色', trigger: 'change' }]
})
const availableRoles = ref([
{ id: '1', roleName: '企业管理员' },
{ id: '2', roleName: '企业用户' },
{ id: '3', roleName: '企业财务' }
])
const mockData = ref<EnterpriseCustomer[]>([
{
id: '1',
enterpriseName: '深圳市科技有限公司',
creditCode: '91440300MA5DXXXX01',
address: '深圳市南山区科技园',
contactPerson: '张经理',
contactPhone: '13800138000',
contactEmail: 'zhang@company.com',
username: 'shenzhen_tech',
roleId: '1',
roleName: '企业管理员',
cardCount: 500,
deviceCount: 450,
balance: 15000.00,
status: 'active',
createTime: '2026-01-01 10:00:00',
lastLoginTime: '2026-01-09 09:30:00',
remark: '重要客户'
},
{
id: '2',
enterpriseName: '北京智能制造有限公司',
creditCode: '91110000MA5DXXXX02',
address: '北京市海淀区中关村',
contactPerson: '李总监',
contactPhone: '13900139000',
contactEmail: 'li@manufacturing.com',
username: 'beijing_smart',
roleId: '1',
roleName: '企业管理员',
cardCount: 800,
deviceCount: 750,
balance: 28000.00,
status: 'active',
createTime: '2026-01-03 14:00:00',
lastLoginTime: '2026-01-09 08:15:00'
},
{
id: '3',
enterpriseName: '上海物联网科技公司',
creditCode: '91310000MA5DXXXX03',
address: '上海市浦东新区张江高科技园区',
contactPerson: '王主管',
contactPhone: '13700137000',
contactEmail: 'wang@iot-shanghai.com',
username: 'shanghai_iot',
roleId: '2',
roleName: '企业用户',
cardCount: 300,
deviceCount: 280,
balance: 8500.00,
status: 'pending',
createTime: '2026-01-08 16:00:00'
}
])
const currentDetail = ref<EnterpriseCustomer>({
id: '',
enterpriseName: '',
creditCode: '',
address: '',
contactPerson: '',
contactPhone: '',
contactEmail: '',
username: '',
roleId: '',
roleName: '',
cardCount: 0,
deviceCount: 0,
balance: 0,
status: 'active',
createTime: ''
})
const filteredData = computed(() => {
let data = mockData.value
if (searchQuery.value) {
data = data.filter(
(item) =>
item.enterpriseName.includes(searchQuery.value) ||
item.contactPerson.includes(searchQuery.value) ||
item.contactPhone.includes(searchQuery.value)
)
}
if (statusFilter.value) {
data = data.filter((item) => item.status === statusFilter.value)
}
if (roleFilter.value) {
data = data.filter((item) => item.roleId === roleFilter.value)
}
return data
})
const handleSearch = () => {}
const showDialog = (type: 'add' | 'edit', row?: EnterpriseCustomer) => {
dialogType.value = type
dialogVisible.value = true
if (type === 'edit' && row) {
Object.assign(form, {
enterpriseName: row.enterpriseName,
creditCode: row.creditCode,
address: row.address,
contactPerson: row.contactPerson,
contactPhone: row.contactPhone,
contactEmail: row.contactEmail,
username: row.username,
roleId: row.roleId,
initialBalance: row.balance,
remark: row.remark,
status: row.status
})
} else {
Object.assign(form, {
enterpriseName: '',
creditCode: '',
address: '',
businessLicense: '',
contactPerson: '',
contactPhone: '',
contactEmail: '',
username: '',
password: '',
roleId: '',
initialBalance: 0,
remark: '',
status: 'active'
})
}
}
const handleSubmit = async (formEl: FormInstance | undefined) => {
if (!formEl) return
await formEl.validate((valid) => {
if (valid) {
if (dialogType.value === 'add') {
const selectedRole = availableRoles.value.find((r) => r.id === form.roleId)
mockData.value.unshift({
id: Date.now().toString(),
enterpriseName: form.enterpriseName,
creditCode: form.creditCode,
address: form.address,
contactPerson: form.contactPerson,
contactPhone: form.contactPhone,
contactEmail: form.contactEmail,
username: form.username,
roleId: form.roleId,
roleName: selectedRole?.roleName || '',
cardCount: 0,
deviceCount: 0,
balance: form.initialBalance,
status: form.status as any,
createTime: new Date().toLocaleString('zh-CN'),
remark: form.remark
})
ElMessage.success('企业客户创建成功')
} else {
ElMessage.success('企业客户更新成功')
}
dialogVisible.value = false
formEl.resetFields()
}
})
}
const viewDetail = (row: EnterpriseCustomer) => {
currentDetail.value = { ...row }
detailDialogVisible.value = true
}
const toggleStatus = (row: EnterpriseCustomer) => {
const action = row.status === 'active' ? '禁用' : '启用'
ElMessageBox.confirm(`确定${action}该企业客户吗?`, `${action}确认`, {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
row.status = row.status === 'active' ? 'disabled' : 'active'
ElMessage.success(`${action}成功`)
})
}
const handleDelete = (row: EnterpriseCustomer) => {
ElMessageBox.confirm('删除后无法恢复,确定要删除该企业客户吗?', '删除确认', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'error'
}).then(() => {
const index = mockData.value.findIndex((item) => item.id === row.id)
if (index !== -1) mockData.value.splice(index, 1)
ElMessage.success('删除成功')
})
}
</script>
<style lang="scss" scoped>
.page-content {
:deep(.el-upload-list--picture-card .el-upload-list__item) {
width: 148px;
height: 148px;
}
}
</style>

View File

@@ -0,0 +1,669 @@
<template>
<ArtTableFullScreen>
<div class="platform-account-page" id="table-full-screen">
<!-- 搜索栏 -->
<ArtSearchBar
v-model:filter="searchForm"
:items="searchFormItems"
:show-expand="false"
@reset="handleReset"
@search="handleSearch"
></ArtSearchBar>
<ElCard shadow="never" class="art-table-card">
<!-- 表格头部 -->
<ArtTableHeader
:columnList="columnOptions"
v-model:columns="columnChecks"
@refresh="handleRefresh"
>
<template #left>
<ElButton @click="showDialog('add')">新增平台账号</ElButton>
</template>
</ArtTableHeader>
<!-- 表格 -->
<ArtTable
ref="tableRef"
row-key="ID"
:loading="loading"
:data="tableData"
:currentPage="pagination.currentPage"
:pageSize="pagination.pageSize"
:total="pagination.total"
:marginTop="10"
@selection-change="handleSelectionChange"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
>
<template #default>
<ElTableColumn v-for="col in columns" :key="col.prop || col.type" v-bind="col" />
</template>
</ArtTable>
<!-- 新增/编辑对话框 -->
<ElDialog
v-model="dialogVisible"
:title="dialogType === 'add' ? '新增平台账号' : '编辑平台账号'"
width="500px"
>
<ElForm ref="formRef" :model="formData" :rules="rules" label-width="120px">
<ElFormItem label="账号名称" prop="username">
<ElInput
v-model="formData.username"
placeholder="请输入账号名称"
:disabled="dialogType === 'edit'"
/>
</ElFormItem>
<ElFormItem v-if="dialogType === 'add'" label="密码" prop="password">
<ElInput
v-model="formData.password"
type="password"
placeholder="请输入密码"
show-password
/>
</ElFormItem>
<ElFormItem label="手机号" prop="phone">
<ElInput v-model="formData.phone" placeholder="请输入手机号" maxlength="11" />
</ElFormItem>
<ElFormItem label="账号类型" prop="user_type">
<ElSelect
v-model="formData.user_type"
placeholder="请选择账号类型"
style="width: 100%"
:disabled="dialogType === 'edit'"
>
<ElOption label="超级管理员" :value="1" />
<ElOption label="平台用户" :value="2" />
<ElOption label="代理账号" :value="3" />
<ElOption label="企业账号" :value="4" />
</ElSelect>
</ElFormItem>
<ElFormItem v-if="formData.user_type === 3" label="关联店铺ID" prop="shop_id">
<ElInputNumber
v-model="formData.shop_id"
:min="1"
placeholder="请输入店铺ID"
style="width: 100%"
/>
</ElFormItem>
<ElFormItem v-if="formData.user_type === 4" label="关联企业ID" prop="enterprise_id">
<ElInputNumber
v-model="formData.enterprise_id"
:min="1"
placeholder="请输入企业ID"
style="width: 100%"
/>
</ElFormItem>
<ElFormItem v-if="dialogType === 'edit'" label="状态">
<ElSwitch
v-model="formData.status"
:active-value="CommonStatus.ENABLED"
:inactive-value="CommonStatus.DISABLED"
/>
</ElFormItem>
</ElForm>
<template #footer>
<div class="dialog-footer">
<ElButton @click="dialogVisible = false">取消</ElButton>
<ElButton type="primary" @click="handleSubmit" :loading="submitLoading"
>提交</ElButton
>
</div>
</template>
</ElDialog>
<!-- 分配角色对话框 -->
<ElDialog v-model="roleDialogVisible" title="分配角色" width="500px">
<ElCheckboxGroup v-model="selectedRoles">
<div v-for="role in allRoles" :key="role.ID" style="margin-bottom: 12px">
<ElCheckbox :label="role.ID">{{ role.role_name }}</ElCheckbox>
</div>
</ElCheckboxGroup>
<template #footer>
<div class="dialog-footer">
<ElButton @click="roleDialogVisible = false">取消</ElButton>
<ElButton type="primary" @click="handleAssignRoles" :loading="roleSubmitLoading"
>提交</ElButton
>
</div>
</template>
</ElDialog>
<!-- 修改密码对话框 -->
<ElDialog v-model="passwordDialogVisible" title="修改密码" width="400px">
<ElForm ref="passwordFormRef" :model="passwordForm" :rules="passwordRules">
<ElFormItem label="新密码" prop="new_password">
<ElInput
v-model="passwordForm.new_password"
type="password"
placeholder="请输入新密码"
show-password
/>
</ElFormItem>
</ElForm>
<template #footer>
<div class="dialog-footer">
<ElButton @click="passwordDialogVisible = false">取消</ElButton>
<ElButton
type="primary"
@click="handleChangePassword"
:loading="passwordSubmitLoading"
>提交</ElButton
>
</div>
</template>
</ElDialog>
</ElCard>
</div>
</ArtTableFullScreen>
</template>
<script setup lang="ts">
import { h } from 'vue'
import { FormInstance, ElMessage, ElMessageBox, ElTag, ElSwitch } from 'element-plus'
import type { FormRules } from 'element-plus'
import { useCheckedColumns } from '@/composables/useCheckedColumns'
import ArtButtonTable from '@/components/core/forms/ArtButtonTable.vue'
import { PlatformAccountService, RoleService } from '@/api/modules'
import type { SearchFormItem } from '@/types'
import type { PlatformRole, PlatformAccountResponse } from '@/types/api'
import { formatDateTime } from '@/utils/business/format'
import { CommonStatus, getStatusText, STATUS_SELECT_OPTIONS } from '@/config/constants'
defineOptions({ name: 'PlatformAccount' }) // 定义组件名称,用于 KeepAlive 缓存控制
const dialogType = ref('add')
const dialogVisible = ref(false)
const roleDialogVisible = ref(false)
const passwordDialogVisible = ref(false)
const loading = ref(false)
const submitLoading = ref(false)
const roleSubmitLoading = ref(false)
const passwordSubmitLoading = ref(false)
const currentAccountId = ref<number>(0)
const selectedRoles = ref<number[]>([])
const allRoles = ref<PlatformRole[]>([])
// 定义表单搜索初始值
const initialSearchState = {
username: '',
phone: '',
status: undefined as number | undefined
}
// 响应式表单数据
const searchForm = reactive({ ...initialSearchState })
const pagination = reactive({
currentPage: 1,
pageSize: 20,
total: 0
})
// 表格数据
const tableData = ref<PlatformAccountResponse[]>([])
// 表格实例引用
const tableRef = ref()
// 选中的行数据
const selectedRows = ref<any[]>([])
// 重置表单
const handleReset = () => {
Object.assign(searchForm, { ...initialSearchState })
pagination.currentPage = 1 // 重置到第一页
getAccountList()
}
// 搜索处理
const handleSearch = () => {
console.log('搜索参数:', searchForm)
pagination.currentPage = 1 // 搜索时重置到第一页
getAccountList()
}
// 表单配置项
const searchFormItems: SearchFormItem[] = [
{
label: '账号名称',
prop: 'username',
type: 'input',
config: {
clearable: true,
placeholder: '请输入账号名称'
}
},
{
label: '手机号',
prop: 'phone',
type: 'input',
config: {
clearable: true,
placeholder: '请输入手机号'
}
},
{
label: '状态',
prop: 'status',
type: 'select',
options: STATUS_SELECT_OPTIONS,
config: {
clearable: true,
placeholder: '请选择状态'
}
}
]
// 列配置
const columnOptions = [
{ label: 'ID', prop: 'ID' },
{ label: '账号名称', prop: 'username' },
{ label: '手机号', prop: 'phone' },
{ label: '账号类型', prop: 'user_type' },
{ label: '状态', prop: 'status' },
{ label: '创建时间', prop: 'CreatedAt' },
{ label: '操作', prop: 'operation' }
]
// 显示对话框
const showDialog = (type: string, row?: PlatformAccountResponse) => {
dialogVisible.value = true
dialogType.value = type
// 重置表单验证状态
if (formRef.value) {
formRef.value.resetFields()
}
if (type === 'edit' && row) {
formData.id = row.ID
formData.username = row.username
formData.phone = row.phone
formData.user_type = row.user_type
formData.enterprise_id = row.enterprise_id || null
formData.shop_id = row.shop_id || null
formData.status = row.status
formData.password = ''
} else {
formData.id = 0
formData.username = ''
formData.phone = ''
formData.user_type = 2
formData.enterprise_id = null
formData.shop_id = null
formData.status = CommonStatus.ENABLED
formData.password = ''
}
}
// 删除账号
const deleteAccount = (row: PlatformAccountResponse) => {
ElMessageBox.confirm(`确定要删除平台账号 ${row.username} 吗?`, '删除平台账号', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'error'
})
.then(async () => {
try {
await PlatformAccountService.deletePlatformAccount(row.ID)
ElMessage.success('删除成功')
getAccountList()
} catch (error) {
console.error(error)
}
})
.catch(() => {
// 账号取消删除
})
}
// 显示修改密码对话框
const showPasswordDialog = (row: PlatformAccountResponse) => {
currentAccountId.value = row.ID
passwordForm.new_password = ''
passwordDialogVisible.value = true
if (passwordFormRef.value) {
passwordFormRef.value.resetFields()
}
}
// 动态列配置
const { columnChecks, columns } = useCheckedColumns(() => [
{
prop: 'ID',
label: 'ID',
width: 80
},
{
prop: 'username',
label: '账号名称',
minWidth: 120
},
{
prop: 'phone',
label: '手机号',
width: 130
},
{
prop: 'user_type',
label: '账号类型',
width: 120,
formatter: (row: PlatformAccountResponse) => {
const typeMap: Record<number, string> = {
1: '超级管理员',
2: '平台用户',
3: '代理账号',
4: '企业账号'
}
return typeMap[row.user_type] || '-'
}
},
{
prop: 'status',
label: '状态',
width: 100,
formatter: (row: PlatformAccountResponse) => {
return h(ElSwitch, {
modelValue: row.status,
activeValue: CommonStatus.ENABLED,
inactiveValue: CommonStatus.DISABLED,
activeText: getStatusText(CommonStatus.ENABLED),
inactiveText: getStatusText(CommonStatus.DISABLED),
inlinePrompt: true,
'onUpdate:modelValue': (val: number) => handleStatusChange(row, val)
})
}
},
{
prop: 'CreatedAt',
label: '创建时间',
width: 180,
formatter: (row: PlatformAccountResponse) => formatDateTime(row.CreatedAt)
},
{
prop: 'operation',
label: '操作',
width: 240,
fixed: 'right',
formatter: (row: PlatformAccountResponse) => {
return h('div', { style: 'display: flex; gap: 8px;' }, [
h(ArtButtonTable, {
icon: '&#xe72b;',
onClick: () => showRoleDialog(row)
}),
h(ArtButtonTable, {
icon: '&#xe722;',
onClick: () => showPasswordDialog(row)
}),
h(ArtButtonTable, {
type: 'edit',
onClick: () => showDialog('edit', row)
}),
h(ArtButtonTable, {
type: 'delete',
onClick: () => deleteAccount(row)
})
])
}
}
])
// 表单实例
const formRef = ref<FormInstance>()
const passwordFormRef = ref<FormInstance>()
// 表单数据
const formData = reactive({
id: 0,
username: '',
password: '',
phone: '',
user_type: 2,
enterprise_id: null as number | null,
shop_id: null as number | null,
status: CommonStatus.ENABLED
})
// 密码表单数据
const passwordForm = reactive({
new_password: ''
})
onMounted(() => {
getAccountList()
loadAllRoles()
})
// 加载所有角色列表
const loadAllRoles = async () => {
try {
const res = await RoleService.getRoles({ page: 1, pageSize: 100 })
if (res.code === 0) {
allRoles.value = res.data.items || []
}
} catch (error) {
console.error('获取角色列表失败:', error)
}
}
// 显示分配角色对话框
const showRoleDialog = async (row: PlatformAccountResponse) => {
currentAccountId.value = row.ID
selectedRoles.value = []
// 先加载当前账号的角色,再打开对话框
try {
const res = await PlatformAccountService.getPlatformAccountRoles(row.ID)
if (res.code === 0) {
// 提取角色ID数组
const roles = res.data || []
selectedRoles.value = roles.map((role: any) => role.ID)
// 数据加载完成后再打开对话框
roleDialogVisible.value = true
}
} catch (error) {
console.error('获取账号角色失败:', error)
}
}
// 提交分配角色
const handleAssignRoles = async () => {
roleSubmitLoading.value = true
try {
await PlatformAccountService.assignRolesToPlatformAccount(currentAccountId.value, {
role_ids: selectedRoles.value
})
ElMessage.success('分配角色成功')
roleDialogVisible.value = false
} catch (error) {
console.error(error)
} finally {
roleSubmitLoading.value = false
}
}
// 提交修改密码
const handleChangePassword = async () => {
if (!passwordFormRef.value) return
await passwordFormRef.value.validate(async (valid) => {
if (valid) {
passwordSubmitLoading.value = true
try {
await PlatformAccountService.changePlatformAccountPassword(currentAccountId.value, {
new_password: passwordForm.new_password
})
ElMessage.success('修改密码成功')
passwordDialogVisible.value = false
} catch (error) {
console.error(error)
} finally {
passwordSubmitLoading.value = false
}
}
})
}
// 获取账号列表
const getAccountList = async () => {
loading.value = true
try {
const params = {
page: pagination.currentPage,
page_size: pagination.pageSize,
username: searchForm.username || undefined,
phone: searchForm.phone || undefined,
status: searchForm.status
}
const res = await PlatformAccountService.getPlatformAccounts(params)
if (res.code === 0) {
tableData.value = res.data.items || []
pagination.total = res.data.total || 0
}
} catch (error) {
console.error('获取平台账号列表失败:', error)
} finally {
loading.value = false
}
}
const handleRefresh = () => {
getAccountList()
}
// 处理表格行选择变化
const handleSelectionChange = (selection: any[]) => {
selectedRows.value = selection
}
// 表单验证规则
const rules = reactive<FormRules>({
username: [
{ required: true, message: '请输入账号名称', trigger: 'blur' },
{ min: 3, max: 50, message: '长度在 3 到 50 个字符', trigger: 'blur' }
],
password: [
{ required: true, message: '请输入密码', trigger: 'blur' },
{ min: 8, max: 32, message: '密码长度为 8-32 个字符', trigger: 'blur' }
],
phone: [
{ required: true, message: '请输入手机号', trigger: 'blur' },
{ len: 11, message: '手机号必须为 11 位', trigger: 'blur' },
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号格式', trigger: 'blur' }
],
user_type: [{ required: true, message: '请选择账号类型', trigger: 'change' }],
shop_id: [
{
validator: (rule: any, value: any, callback: any) => {
if (formData.user_type === 3 && !value) {
callback(new Error('代理账号必须关联店铺ID'))
} else {
callback()
}
},
trigger: 'blur'
}
],
enterprise_id: [
{
validator: (rule: any, value: any, callback: any) => {
if (formData.user_type === 4 && !value) {
callback(new Error('企业账号必须关联企业ID'))
} else {
callback()
}
},
trigger: 'blur'
}
]
})
// 密码验证规则
const passwordRules = reactive<FormRules>({
new_password: [
{ required: true, message: '请输入新密码', trigger: 'blur' },
{ min: 8, max: 32, message: '密码长度为 8-32 个字符', trigger: 'blur' }
]
})
// 提交表单
const handleSubmit = async () => {
if (!formRef.value) return
await formRef.value.validate(async (valid) => {
if (valid) {
submitLoading.value = true
try {
if (dialogType.value === 'add') {
const data: any = {
username: formData.username,
password: formData.password,
phone: formData.phone,
user_type: formData.user_type
}
// 根据账号类型添加相应的字段
if (formData.user_type === 3) {
data.shop_id = formData.shop_id
} else if (formData.user_type === 4) {
data.enterprise_id = formData.enterprise_id
}
await PlatformAccountService.createPlatformAccount(data)
ElMessage.success('新增成功')
} else {
const data: any = {
username: formData.username,
phone: formData.phone,
status: formData.status
}
await PlatformAccountService.updatePlatformAccount(formData.id, data)
ElMessage.success('更新成功')
}
dialogVisible.value = false
getAccountList()
} catch (error) {
console.error(error)
} finally {
submitLoading.value = false
}
}
})
}
// 处理表格分页变化
const handleSizeChange = (newPageSize: number) => {
pagination.pageSize = newPageSize
getAccountList()
}
const handleCurrentChange = (newCurrentPage: number) => {
pagination.currentPage = newCurrentPage
getAccountList()
}
// 状态切换
const handleStatusChange = async (row: any, newStatus: number) => {
const oldStatus = row.status
// 先更新UI
row.status = newStatus
try {
await PlatformAccountService.updatePlatformAccount(row.ID, { status: newStatus })
ElMessage.success('状态切换成功')
} catch (error) {
// 切换失败,恢复原状态
row.status = oldStatus
console.error(error)
}
}
</script>
<style lang="scss" scoped>
.platform-account-page {
// 平台账号管理页面样式
}
</style>

View File

@@ -0,0 +1,482 @@
<template>
<ArtTableFullScreen>
<div class="shop-account-page" id="table-full-screen">
<!-- 搜索栏 -->
<ArtSearchBar
v-model:filter="searchForm"
:items="searchFormItems"
@reset="handleReset"
@search="handleSearch"
></ArtSearchBar>
<ElCard shadow="never" class="art-table-card">
<!-- 表格头部 -->
<ArtTableHeader
:columnList="columnOptions"
v-model:columns="columnChecks"
@refresh="handleRefresh"
>
<template #left>
<ElButton @click="showDialog('add')">新增代理账号</ElButton>
</template>
</ArtTableHeader>
<!-- 表格 -->
<ArtTable
ref="tableRef"
row-key="id"
:loading="loading"
:data="tableData"
:currentPage="pagination.currentPage"
:pageSize="pagination.pageSize"
:total="pagination.total"
:marginTop="10"
@selection-change="handleSelectionChange"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
>
<template #default>
<ElTableColumn v-for="col in columns" :key="col.prop || col.type" v-bind="col" />
</template>
</ArtTable>
<!-- 新增/编辑对话框 -->
<ElDialog
v-model="dialogVisible"
:title="dialogType === 'add' ? '新增代理账号' : '编辑代理账号'"
width="500px"
>
<ElForm ref="formRef" :model="formData" :rules="rules" label-width="120px">
<ElFormItem label="用户名" prop="username">
<ElInput v-model="formData.username" placeholder="请输入用户名" />
</ElFormItem>
<ElFormItem v-if="dialogType === 'add'" label="密码" prop="password">
<ElInput v-model="formData.password" type="password" placeholder="请输入密码" show-password />
</ElFormItem>
<ElFormItem label="手机号" prop="phone">
<ElInput v-model="formData.phone" placeholder="请输入手机号" maxlength="11" />
</ElFormItem>
<ElFormItem label="店铺ID" prop="shop_id">
<ElInputNumber v-model="formData.shop_id" :min="1" placeholder="请输入店铺ID" style="width: 100%" />
</ElFormItem>
</ElForm>
<template #footer>
<div class="dialog-footer">
<ElButton @click="dialogVisible = false">取消</ElButton>
<ElButton type="primary" @click="handleSubmit" :loading="submitLoading">提交</ElButton>
</div>
</template>
</ElDialog>
<!-- 修改密码对话框 -->
<ElDialog
v-model="passwordDialogVisible"
title="重置密码"
width="400px"
>
<ElForm ref="passwordFormRef" :model="passwordForm" :rules="passwordRules">
<ElFormItem label="新密码" prop="new_password">
<ElInput v-model="passwordForm.new_password" type="password" placeholder="请输入新密码" show-password />
</ElFormItem>
</ElForm>
<template #footer>
<div class="dialog-footer">
<ElButton @click="passwordDialogVisible = false">取消</ElButton>
<ElButton type="primary" @click="handleChangePassword" :loading="passwordSubmitLoading">提交</ElButton>
</div>
</template>
</ElDialog>
</ElCard>
</div>
</ArtTableFullScreen>
</template>
<script setup lang="ts">
import { h } from 'vue'
import { FormInstance, ElMessage, ElMessageBox, ElSwitch } from 'element-plus'
import type { FormRules } from 'element-plus'
import { useCheckedColumns } from '@/composables/useCheckedColumns'
import ArtButtonTable from '@/components/core/forms/ArtButtonTable.vue'
import { ShopAccountService } from '@/api/modules'
import type { SearchFormItem } from '@/types'
import type { ShopAccountResponse } from '@/types/api'
import { formatDateTime } from '@/utils/business/format'
import { CommonStatus, getStatusText, STATUS_SELECT_OPTIONS } from '@/config/constants'
defineOptions({ name: 'ShopAccount' }) // 定义组件名称,用于 KeepAlive 缓存控制
const dialogType = ref('add')
const dialogVisible = ref(false)
const passwordDialogVisible = ref(false)
const loading = ref(false)
const submitLoading = ref(false)
const passwordSubmitLoading = ref(false)
const currentAccountId = ref<number>(0)
// 定义表单搜索初始值
const initialSearchState = {
username: '',
phone: '',
shop_id: undefined as number | undefined,
status: undefined as number | undefined
}
// 响应式表单数据
const searchForm = reactive({ ...initialSearchState })
const pagination = reactive({
currentPage: 1,
pageSize: 20,
total: 0
})
// 表格数据
const tableData = ref<ShopAccountResponse[]>([])
// 表格实例引用
const tableRef = ref()
// 选中的行数据
const selectedRows = ref<any[]>([])
// 重置表单
const handleReset = () => {
Object.assign(searchForm, { ...initialSearchState })
pagination.currentPage = 1 // 重置到第一页
getAccountList()
}
// 搜索处理
const handleSearch = () => {
console.log('搜索参数:', searchForm)
pagination.currentPage = 1 // 搜索时重置到第一页
getAccountList()
}
// 表单配置项
const searchFormItems: SearchFormItem[] = [
{
label: '用户名',
prop: 'username',
type: 'input',
config: {
clearable: true,
placeholder: '请输入用户名'
}
},
{
label: '手机号',
prop: 'phone',
type: 'input',
config: {
clearable: true,
placeholder: '请输入手机号'
}
},
{
label: '店铺ID',
prop: 'shop_id',
type: 'input',
config: {
clearable: true,
placeholder: '请输入店铺ID'
}
},
{
label: '状态',
prop: 'status',
type: 'select',
options: STATUS_SELECT_OPTIONS,
config: {
clearable: true,
placeholder: '请选择状态'
}
}
]
// 列配置
const columnOptions = [
{ label: 'ID', prop: 'id' },
{ label: '用户名', prop: 'username' },
{ label: '手机号', prop: 'phone' },
{ label: '店铺ID', prop: 'shop_id' },
{ label: '状态', prop: 'status' },
{ label: '创建时间', prop: 'created_at' },
{ label: '操作', prop: 'operation' }
]
// 显示对话框
const showDialog = (type: string, row?: ShopAccountResponse) => {
dialogVisible.value = true
dialogType.value = type
// 重置表单验证状态
if (formRef.value) {
formRef.value.resetFields()
}
if (type === 'edit' && row) {
formData.id = row.id
formData.username = row.username
formData.phone = row.phone
formData.shop_id = row.shop_id
formData.password = ''
} else {
formData.id = 0
formData.username = ''
formData.phone = ''
formData.shop_id = 0
formData.password = ''
}
}
// 显示修改密码对话框
const showPasswordDialog = (row: ShopAccountResponse) => {
currentAccountId.value = row.id
passwordForm.new_password = ''
passwordDialogVisible.value = true
if (passwordFormRef.value) {
passwordFormRef.value.resetFields()
}
}
// 状态切换处理
const handleStatusChange = async (row: ShopAccountResponse, newStatus: number) => {
const oldStatus = row.status
// 先更新UI
row.status = newStatus
try {
await ShopAccountService.updateShopAccountStatus(row.id, { status: newStatus })
ElMessage.success('状态切换成功')
} catch (error) {
// 切换失败,恢复原状态
row.status = oldStatus
console.error(error)
}
}
// 动态列配置
const { columnChecks, columns } = useCheckedColumns(() => [
{
prop: 'id',
label: 'ID',
width: 80
},
{
prop: 'username',
label: '用户名',
minWidth: 120
},
{
prop: 'phone',
label: '手机号',
width: 130
},
{
prop: 'shop_id',
label: '店铺ID',
width: 100
},
{
prop: 'status',
label: '状态',
width: 100,
formatter: (row: ShopAccountResponse) => {
return h(ElSwitch, {
modelValue: row.status,
activeValue: CommonStatus.ENABLED,
inactiveValue: CommonStatus.DISABLED,
activeText: getStatusText(CommonStatus.ENABLED),
inactiveText: getStatusText(CommonStatus.DISABLED),
inlinePrompt: true,
'onUpdate:modelValue': (val: number) => handleStatusChange(row, val)
})
}
},
{
prop: 'created_at',
label: '创建时间',
width: 180,
formatter: (row: ShopAccountResponse) => formatDateTime(row.created_at)
},
{
prop: 'operation',
label: '操作',
width: 120,
fixed: 'right',
formatter: (row: ShopAccountResponse) => {
return h('div', { style: 'display: flex; gap: 8px;' }, [
h(ArtButtonTable, {
icon: '&#xe722;',
onClick: () => showPasswordDialog(row)
}),
h(ArtButtonTable, {
type: 'edit',
onClick: () => showDialog('edit', row)
})
])
}
}
])
// 表单实例
const formRef = ref<FormInstance>()
const passwordFormRef = ref<FormInstance>()
// 表单数据
const formData = reactive({
id: 0,
username: '',
password: '',
phone: '',
shop_id: 0
})
// 密码表单数据
const passwordForm = reactive({
new_password: ''
})
onMounted(() => {
getAccountList()
})
// 提交修改密码
const handleChangePassword = async () => {
if (!passwordFormRef.value) return
await passwordFormRef.value.validate(async (valid) => {
if (valid) {
passwordSubmitLoading.value = true
try {
await ShopAccountService.updateShopAccountPassword(currentAccountId.value, {
new_password: passwordForm.new_password
})
ElMessage.success('重置密码成功')
passwordDialogVisible.value = false
} catch (error) {
console.error(error)
} finally {
passwordSubmitLoading.value = false
}
}
})
}
// 获取账号列表
const getAccountList = async () => {
loading.value = true
try {
const params = {
page: pagination.currentPage,
page_size: pagination.pageSize,
username: searchForm.username || undefined,
phone: searchForm.phone || undefined,
shop_id: searchForm.shop_id,
status: searchForm.status
}
const res = await ShopAccountService.getShopAccounts(params)
if (res.code === 0) {
tableData.value = res.data.items || []
pagination.total = res.data.total || 0
}
} catch (error) {
console.error('获取代理账号列表失败:', error)
} finally {
loading.value = false
}
}
const handleRefresh = () => {
getAccountList()
}
// 处理表格行选择变化
const handleSelectionChange = (selection: any[]) => {
selectedRows.value = selection
}
// 表单验证规则
const rules = reactive<FormRules>({
username: [
{ required: true, message: '请输入用户名', trigger: 'blur' },
{ min: 3, max: 50, message: '长度在 3 到 50 个字符', trigger: 'blur' }
],
password: [
{ required: true, message: '请输入密码', trigger: 'blur' },
{ min: 8, max: 32, message: '密码长度为 8-32 个字符', trigger: 'blur' }
],
phone: [
{ required: true, message: '请输入手机号', trigger: 'blur' },
{ len: 11, message: '手机号必须为 11 位', trigger: 'blur' },
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号格式', trigger: 'blur' }
],
shop_id: [
{ required: true, message: '请输入店铺ID', trigger: 'blur' },
{ type: 'number', min: 1, message: '店铺ID必须大于0', trigger: 'blur' }
]
})
// 密码验证规则
const passwordRules = reactive<FormRules>({
new_password: [
{ required: true, message: '请输入新密码', trigger: 'blur' },
{ min: 8, max: 32, message: '密码长度为 8-32 个字符', trigger: 'blur' }
]
})
// 提交表单
const handleSubmit = async () => {
if (!formRef.value) return
await formRef.value.validate(async (valid) => {
if (valid) {
submitLoading.value = true
try {
if (dialogType.value === 'add') {
const data = {
username: formData.username,
password: formData.password,
phone: formData.phone,
shop_id: formData.shop_id
}
await ShopAccountService.createShopAccount(data)
ElMessage.success('新增成功')
} else {
const data = {
username: formData.username
}
await ShopAccountService.updateShopAccount(formData.id, data)
ElMessage.success('更新成功')
}
dialogVisible.value = false
getAccountList()
} catch (error) {
console.error(error)
} finally {
submitLoading.value = false
}
}
})
}
// 处理表格分页变化
const handleSizeChange = (newPageSize: number) => {
pagination.pageSize = newPageSize
getAccountList()
}
const handleCurrentChange = (newCurrentPage: number) => {
pagination.currentPage = newCurrentPage
getAccountList()
}
</script>
<style lang="scss" scoped>
.shop-account-page {
// 代理账号管理页面样式
}
</style>

View File

@@ -0,0 +1,269 @@
<template>
<div class="page-content">
<h1 class="title">留言墙</h1>
<p class="desc">每一份留言都记录了您的想法也为我们提供了珍贵的回忆</p>
<div class="list">
<ul class="offset">
<li
class="comment-box"
v-for="item in commentList"
:key="item.id"
:style="{ background: randomColor() }"
@click="openDrawer(item)"
>
<p class="date">{{ item.date }}</p>
<p class="content">{{ item.content }}</p>
<div class="bottom">
<div class="left">
<span><i class="iconfont-sys">&#xe6eb;</i>{{ item.collection }}</span>
<span><i class="iconfont-sys">&#xe6e9;</i>{{ item.comment }}</span>
</div>
<div class="right">
<span>{{ item.userName }}</span>
</div>
</div>
</li>
</ul>
</div>
<ElDrawer
lDrawer
v-model="showDrawer"
:lock-scroll="false"
:size="360"
modal-class="comment-modal"
>
<template #header>
<h4>详情</h4>
</template>
<template #default>
<div class="drawer-default">
<div class="comment-box" :style="{ background: randomColor() }">
<p class="date">{{ clickItem.date }}</p>
<p class="content">{{ clickItem.content }}</p>
<div class="bottom">
<div class="left">
<span><i class="iconfont-sys">&#xe6eb;</i>{{ clickItem.collection }}</span>
<span><i class="iconfont-sys">&#xe6e9;</i>{{ clickItem.comment }}</span>
</div>
<div class="right">
<span>{{ clickItem.userName }}</span>
</div>
</div>
</div>
<!-- 评论组件 -->
<CommentWidget />
</div>
</template>
<template #footer>
<div>
<!-- <ElButton @click="cancelClick">cancel</ElButton> -->
<!-- <ElButton type="primary" @click="confirmClick">confirm</ElButton> -->
</div>
</template>
</ElDrawer>
</div>
</template>
<script setup lang="ts">
import { commentList } from '@/mock/temp/commentList'
const showDrawer = ref(false)
defineOptions({ name: 'ArticleComment' })
// const colorList = reactive([
// 'rgba(216, 248, 255, 0.8)',
// 'rgba(253, 223, 217, 0.8)',
// 'rgba(252, 230, 240, 0.8)',
// 'rgba(211, 248, 240, 0.8)',
// 'rgba(255, 234, 188, 0.8)',
// 'rgba(245, 225, 255, 0.8)',
// 'rgba(225, 230, 254, 0.8)'
// ])
const colorList = reactive([
'#D8F8FF',
'#FDDFD9',
'#FCE6F0',
'#D3F8F0',
'#FFEABC',
'#F5E1FF',
'#E1E6FE'
])
let lastColor: string | null = null
const randomColor = () => {
let newColor: string
do {
const index = Math.floor(Math.random() * colorList.length)
newColor = colorList[index]
} while (newColor === lastColor)
lastColor = newColor
return newColor
}
const clickItem = ref({
id: 1,
date: '2024-9-3',
content: '加油学好Node 自己写个小Demo',
collection: 5,
comment: 8,
userName: '匿名'
})
const openDrawer = (item: any) => {
showDrawer.value = true
clickItem.value = item
}
</script>
<style lang="scss" scoped>
.page-content {
background-color: transparent !important;
box-shadow: none !important;
:deep(.comment-modal) {
background-color: transparent;
}
.title {
margin-top: 20px;
font-size: 36px;
font-weight: 500;
}
.desc {
margin-top: 15px;
font-size: 14px;
color: var(--art-text-gray-600);
}
.list {
margin-top: 40px;
.offset {
display: flex;
flex-wrap: wrap;
width: calc(100% + 16px);
}
}
.comment-box {
position: relative;
box-sizing: border-box;
width: calc(20% - 16px);
aspect-ratio: 16 / 12;
padding: 16px;
margin: 0 16px 16px 0;
cursor: pointer;
background-color: #eae2cb;
transition: all 0.3s;
&:hover {
transform: translateY(-5px);
}
.date {
font-size: 12px;
color: #949494;
}
.content {
margin-top: 16px;
font-size: 14px;
color: #333;
}
.bottom {
position: absolute;
bottom: 16px;
left: 0;
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 0 16px;
.left {
display: flex;
align-items: center;
span {
display: flex;
align-items: center;
margin-right: 20px;
font-size: 12px;
color: #949494;
i {
margin-right: 5px;
}
}
}
.right {
span {
font-size: 14px;
color: #333;
}
}
}
}
.drawer-default {
.comment-box {
width: 100%;
&:hover {
transform: translateY(0);
}
}
}
}
@media screen and (max-width: $device-notebook) {
.page-content {
.comment-box {
width: calc(25% - 16px);
}
}
}
@media screen and (max-width: $device-ipad-pro) {
.page-content {
.comment-box {
width: calc(33.333% - 16px);
}
}
}
@media screen and (max-width: $device-ipad) {
.page-content {
.comment-box {
width: calc(50% - 16px);
}
}
}
@media screen and (max-width: $device-phone) {
.page-content {
.comment-box {
width: calc(100% - 16px);
}
}
}
.dark {
.page-content {
.comment-box {
color: #333 !important;
}
}
}
</style>

View File

@@ -0,0 +1,122 @@
<template>
<div class="article-detail page-content">
<div class="content">
<h1>{{ articleTitle }}</h1>
<div class="markdown-body" v-highlight v-html="articleHtml"></div>
</div>
<ArtBackToTop />
</div>
</template>
<script setup lang="ts">
import '@/assets/styles/markdown.scss'
import '@/assets/styles/one-dark-pro.scss'
import { useCommon } from '@/composables/useCommon'
import axios from 'axios'
// import 'highlight.js/styles/atom-one-dark.css';
// import 'highlight.js/styles/vs2015.css';
defineOptions({ name: 'ArticleDetail' })
const articleId = ref(0)
const router = useRoute()
const articleTitle = ref('')
const articleHtml = ref('')
onMounted(() => {
useCommon().scrollToTop()
articleId.value = Number(router.query.id)
getArticleDetail()
})
const getArticleDetail = async () => {
if (articleId.value) {
const res = await axios.get('https://www.qiniu.lingchen.kim/blog_detail.json')
if (res.data.code === 200) {
articleTitle.value = res.data.data.title
articleHtml.value = res.data.data.html_content
}
// const res = await ArticleService.getArticleDetail(articleId.value)
// if (res.code === ApiStatus.success) {
// articleTitle.value = res.data.title;;
// articleHtml.value = res.data.html_content;
// }
}
}
</script>
<style lang="scss">
.article-detail {
.content {
max-width: 800px;
margin: auto;
margin-top: 60px;
.markdown-body {
margin-top: 60px;
img {
width: 100%;
border: 1px solid var(--art-gray-200);
}
pre {
position: relative;
&:hover {
.copy-button {
opacity: 1;
}
}
&::before {
position: absolute;
top: 0;
left: 50px;
width: 1px;
height: 100%;
content: '';
background: #0a0a0e;
}
}
.code-wrapper {
overflow-x: auto;
}
.line-number {
position: sticky;
left: 0;
z-index: 2;
box-sizing: border-box;
display: inline-block;
width: 50px;
margin-right: 10px;
font-size: 14px;
color: #9e9e9e;
text-align: center;
}
.copy-button {
position: absolute;
top: 6px;
right: 6px;
z-index: 1;
width: 40px;
height: 40px;
font-size: 20px;
line-height: 40px;
color: #999;
text-align: center;
cursor: pointer;
background-color: #000;
border: none;
border-radius: 8px;
opacity: 0;
transition: all 0.2s;
}
}
}
}
</style>

View File

@@ -0,0 +1,381 @@
<template>
<div class="page-content article-list">
<ElRow justify="space-between" :gutter="10">
<ElCol :lg="6" :md="6" :sm="14" :xs="16">
<ElInput
v-model="searchVal"
:prefix-icon="Search"
clearable
placeholder="输入文章标题查询"
@keyup.enter="searchArticle"
/>
</ElCol>
<ElCol :lg="12" :md="12" :sm="0" :xs="0">
<div class="custom-segmented">
<ElSegmented v-model="yearVal" :options="options" @change="searchArticleByYear" />
</div>
</ElCol>
<ElCol :lg="6" :md="6" :sm="10" :xs="6" style="display: flex; justify-content: end">
<ElButton @click="toAddArticle" v-auth="'add'">新增文章</ElButton>
</ElCol>
</ElRow>
<div class="list">
<div class="offset">
<div class="item" v-for="item in articleList" :key="item.id" @click="toDetail(item)">
<!-- 骨架屏 -->
<ElSkeleton animated :loading="isLoading" style="width: 100%; height: 100%">
<template #template>
<div class="top">
<ElSkeletonItem
variant="image"
style="width: 100%; height: 100%; border-radius: 10px"
/>
<div style="padding: 16px 0">
<ElSkeletonItem variant="p" style="width: 80%" />
<ElSkeletonItem variant="p" style="width: 40%; margin-top: 10px" />
</div>
</div>
</template>
<template #default>
<div class="top">
<ElImage class="cover" :src="item.home_img" lazy fit="cover">
<template #error>
<div class="image-slot">
<ElIcon><icon-picture /></ElIcon>
</div>
</template>
</ElImage>
<span class="type">{{ item.type_name }}</span>
</div>
<div class="bottom">
<h2>{{ item.title }}</h2>
<div class="info">
<div class="text">
<i class="iconfont-sys">&#xe6f7;</i>
<span>{{ useDateFormat(item.create_time, 'YYYY-MM-DD') }}</span>
<div class="line"></div>
<i class="iconfont-sys">&#xe689;</i>
<span>{{ item.count }}</span>
</div>
<ElButton v-auth="'edit'" size="small" @click.stop="toEdit(item)">编辑</ElButton>
</div>
</div>
</template>
</ElSkeleton>
</div>
</div>
</div>
<div style="margin-top: 16vh" v-if="showEmpty">
<ElEmpty :description="`未找到相关数据 ${EmojiText[0]}`" />
</div>
<div style="display: flex; justify-content: center; margin-top: 20px">
<ElPagination
size="default"
background
v-model:current-page="currentPage"
:page-size="pageSize"
:pager-count="9"
layout="prev, pager, next, total,jumper"
:total="total"
:hide-on-single-page="true"
@current-change="handleCurrentChange"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { Picture as IconPicture } from '@element-plus/icons-vue'
import { ref, onMounted, computed } from 'vue'
import { router } from '@/router'
import { useDateFormat } from '@vueuse/core'
import { Search } from '@element-plus/icons-vue'
import EmojiText from '@/utils/ui/emojo'
import { ArticleList } from '@/mock/temp/articleList'
import { useCommon } from '@/composables/useCommon'
import { RoutesAlias } from '@/router/routesAlias'
import { ArticleType } from '@/api/modules'
defineOptions({ name: 'ArticleList' })
const yearVal = ref('All')
const options = ['All', '2024', '2023', '2022', '2021', '2020', '2019']
const searchVal = ref('')
const articleList = ref<ArticleType[]>([])
const currentPage = ref(1)
const pageSize = ref(40)
// const lastPage = ref(0)
const total = ref(0)
const isLoading = ref(true)
const showEmpty = computed(() => {
return articleList.value.length === 0 && !isLoading.value
})
onMounted(() => {
getArticleList({ backTop: false })
})
// 搜索文章
const searchArticle = () => {
getArticleList({ backTop: true })
}
// 根据年份查询文章
const searchArticleByYear = () => {
getArticleList({ backTop: true })
}
const getArticleList = async ({ backTop = false }) => {
isLoading.value = true
// let year = yearVal.value
if (searchVal.value) {
yearVal.value = 'All'
}
if (yearVal.value === 'All') {
// year = ''
}
// const params = {
// page: currentPage.value,
// size: pageSize.value,
// searchVal: searchVal.value,
// year
// }
articleList.value = ArticleList
isLoading.value = false
if (backTop) {
useCommon().scrollToTop()
}
// const res = await ArticleService.getArticleList(params)
// if (res.code === ApiStatus.success) {
// currentPage.value = res.currentPage
// pageSize.value = res.pageSize
// lastPage.value = res.lastPage
// total.value = res.total
// articleList.value = res.data
// // setTimeout(() => {
// isLoading.value = false
// // }, 3000)
// if (searchVal.value) {
// searchVal.value = ''
// }
// }
}
const handleCurrentChange = (val: number) => {
currentPage.value = val
getArticleList({ backTop: true })
}
const toDetail = (item: ArticleType) => {
router.push({
path: RoutesAlias.ArticleDetail,
query: {
id: item.id
}
})
}
const toEdit = (item: ArticleType) => {
router.push({
path: RoutesAlias.ArticlePublish,
query: {
id: item.id
}
})
}
const toAddArticle = () => {
router.push({
path: RoutesAlias.ArticlePublish
})
}
</script>
<style lang="scss" scoped>
.article-list {
.custom-segmented .el-segmented {
height: 40px;
padding: 6px;
--el-border-radius-base: 8px;
}
.list {
margin-top: 20px;
.offset {
display: flex;
flex-wrap: wrap;
width: calc(100% + 20px);
.item {
box-sizing: border-box;
width: calc(20% - 20px);
margin: 0 20px 20px 0;
cursor: pointer;
border: 1px solid var(--art-border-color);
border-radius: calc(var(--custom-radius) / 2 + 2px) !important;
&:hover {
.el-button {
opacity: 1 !important;
}
}
.top {
position: relative;
aspect-ratio: 16/9.5;
.cover {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
object-fit: cover;
background: var(--art-gray-200);
border-radius: calc(var(--custom-radius) / 2 + 2px)
calc(var(--custom-radius) / 2 + 2px) 0 0;
.image-slot {
font-size: 26px;
color: var(--art-gray-400);
}
}
.type {
position: absolute;
top: 5px;
right: 5px;
padding: 5px 4px;
font-size: 12px;
color: rgba(#fff, 0.8);
background: rgba($color: #000, $alpha: 60%);
border-radius: 4px;
}
}
.bottom {
padding: 5px 10px;
h2 {
font-size: 16px;
font-weight: 500;
color: #333;
@include ellipsis();
}
.info {
display: flex;
justify-content: space-between;
width: 100%;
height: 25px;
margin-top: 6px;
line-height: 25px;
.text {
display: flex;
align-items: center;
color: var(--art-text-gray-600);
i {
margin-right: 5px;
font-size: 14px;
}
span {
font-size: 13px;
color: var(--art-gray-600);
}
.line {
width: 1px;
height: 12px;
margin: 0 15px;
background-color: var(--art-border-dashed-color);
}
}
.el-button {
opacity: 0;
transition: all 0.3s;
}
}
}
}
}
}
}
@media only screen and (max-width: $device-notebook) {
.article-list {
.list {
.offset {
.item {
width: calc(25% - 20px);
}
}
}
}
}
@media only screen and (max-width: $device-ipad-pro) {
.article-list {
.list {
.offset {
.item {
width: calc(33.333% - 20px);
.bottom {
h2 {
font-size: 16px;
}
}
}
}
}
}
}
@media only screen and (max-width: $device-ipad) {
.article-list {
.list {
.offset {
.item {
width: calc(50% - 20px);
}
}
}
}
}
@media only screen and (max-width: $device-phone) {
.article-list {
.list {
.offset {
.item {
width: calc(100% - 20px);
}
}
}
}
}
</style>

View File

@@ -0,0 +1,412 @@
<template>
<div class="article-edit">
<div>
<div class="editor-wrap">
<!-- 文章标题类型 -->
<ElRow :gutter="10">
<ElCol :span="18">
<ElInput
v-model.trim="articleName"
placeholder="请输入文章标题最多100个字符"
maxlength="100"
/>
</ElCol>
<ElCol :span="6">
<ElSelect v-model="articleType" placeholder="请选择文章类型" filterable>
<ElOption
v-for="item in articleTypes"
:key="item.id"
:label="item.name"
:value="item.id"
/>
</ElSelect>
</ElCol>
</ElRow>
<!-- 富文本编辑器 -->
<ArtWangEditor class="el-top" v-model="editorHtml" />
<div class="form-wrap">
<h2>发布设置</h2>
<!-- 图片上传 -->
<ElForm>
<ElFormItem label="封面">
<div class="el-top upload-container">
<ElUpload
class="cover-uploader"
:action="uploadImageUrl"
:headers="uploadHeaders"
:show-file-list="false"
:on-success="onSuccess"
:on-error="onError"
:before-upload="beforeUpload"
>
<div v-if="!cover" class="upload-placeholder">
<ElIcon class="upload-icon"><Plus /></ElIcon>
<div class="upload-text">点击上传封面</div>
</div>
<img v-else :src="cover" class="cover-image" />
</ElUpload>
<div class="el-upload__tip">建议尺寸 16:9jpg/png 格式</div>
</div>
</ElFormItem>
<ElFormItem label="可见">
<ElSwitch v-model="visible" />
</ElFormItem>
</ElForm>
<div style="display: flex; justify-content: flex-end">
<ElButton type="primary" @click="submit" style="width: 100px">
{{ pageMode === PageModeEnum.Edit ? '保存' : '发布' }}
</ElButton>
</div>
</div>
</div>
</div>
<!-- <div class="outline-wrap">
<div class="item" v-for="(item, index) in outlineList" :key="index">
<p :class="`level${item.level}`">{{ item.text }}</p>
</div>
</div> -->
</div>
</template>
<script setup lang="ts">
import { Plus } from '@element-plus/icons-vue'
import { ArticleService } from '@/api/articleApi'
import { ApiStatus } from '@/utils/http/status'
import { ElMessage } from 'element-plus'
import { useUserStore } from '@/store/modules/user'
import EmojiText from '@/utils/ui/emojo'
import { PageModeEnum } from '@/enums/formEnum'
import axios from 'axios'
import { useCommon } from '@/composables/useCommon'
defineOptions({ name: 'ArticlePublish' })
const route = useRoute()
const router = useRouter()
const userStore = useUserStore()
let { accessToken } = userStore
// 上传路径
const uploadImageUrl = `${import.meta.env.VITE_API_URL}/api/common/upload`
// 传递 token
const uploadHeaders = { Authorization: accessToken }
let pageMode: PageModeEnum = PageModeEnum.Add // 页面类型 新增 编辑
const articleName = ref('') // 文章标题
const articleType = ref() // 文章类型
const articleTypes = ref() // 类型列表
const editorHtml = ref('') // 编辑器内容
const createDate = ref('') // 创建时间
const cover = ref('') // 图片
const visible = ref(true) // 可见
// const outlineList = ref()
onMounted(() => {
useCommon().scrollToTop()
getArticleTypes()
initPageMode()
})
// 初始化页面类型 新增 编辑
const initPageMode = () => {
const { id } = route.query
pageMode = id ? PageModeEnum.Edit : PageModeEnum.Add
if (pageMode === PageModeEnum.Edit && id) {
initEditArticle(Number(id))
} else {
initAddArticle()
}
}
// 初始化编辑文章的逻辑
const initEditArticle = (id: number) => {
articleId = id
getArticleDetail()
}
// 初始化新增文章逻辑
const initAddArticle = () => {
createDate.value = formDate(useNow().value)
}
// 获取文章类型
const getArticleTypes = async () => {
try {
const response = await axios.get('https://www.qiniu.lingchen.kim/classify.json')
if (response.data.code === 200) {
articleTypes.value = response.data.data
}
} catch (error) {
console.error('Error fetching JSON data:', error)
}
// try {
// const res = await ArticleService.getArticleTypes({})
// if (res.code === ApiStatus.success) {
// articleTypes.value = res.data
// }
// } catch (err) { }
}
// 获取文章详情内容
let articleId: number = 0
const getArticleDetail = async () => {
const res = await axios.get('https://www.qiniu.lingchen.kim/blog_list.json')
if (res.data.code === ApiStatus.success) {
let { title, blog_class, html_content } = res.data.data
articleName.value = title
articleType.value = Number(blog_class)
editorHtml.value = html_content
}
// const res = await ArticleService.getArticleDetail(articleId)
// if (res.code === ApiStatus.success) {
// let { title, blog_class, create_time, home_img, html_content } = res.data
// articleName.value = title
// articleType.value = Number(blog_class)
// editorHtml.value = html_content
// cover.value = home_img
// createDate.value = formDate(create_time)
// // getOutline(html_content)
// }
}
// const getOutline = (content: string) => {
// const regex = /<h([1-3])>(.*?)<\/h\1>/g
// const headings = []
// let match
// while ((match = regex.exec(content)) !== null) {
// headings.push({ level: match[1], text: match[2] })
// }
// outlineList.value = headings
// }
// 提交
const submit = () => {
if (pageMode === PageModeEnum.Edit) {
editArticle()
} else {
addArticle()
}
}
// 格式化日期
const formDate = (date: string | Date): string => {
return useDateFormat(date, 'YYYY-MM-DD').value
}
// 验证输入
const validateArticle = () => {
if (!articleName.value) {
ElMessage.error(`请输入文章标题`)
return false
}
if (!articleType.value) {
ElMessage.error(`请选择文章类型`)
return false
}
if (editorHtml.value === '<p><br></p>') {
ElMessage.error(`请输入文章内容`)
return false
}
if (!cover.value) {
ElMessage.error(`请上传图片`)
return false
}
return true
}
// 构建参数
const buildParams = () => {
return {
title: articleName.value,
html_content: editorHtml.value,
home_img: cover.value,
blog_class: articleType.value,
create_time: createDate.value
}
}
// 添加文章
const addArticle = async () => {
try {
if (!validateArticle()) return
editorHtml.value = delCodeTrim(editorHtml.value)
const params = buildParams()
const res = await ArticleService.addArticle(params)
if (res.code === ApiStatus.success) {
ElMessage.success(`发布成功 ${EmojiText[200]}`)
goBack()
}
} catch (err) {
console.error(err)
}
}
// 编辑文章
const editArticle = async () => {
try {
if (!validateArticle()) return
editorHtml.value = delCodeTrim(editorHtml.value)
const params = buildParams()
const res = await ArticleService.editArticle(articleId, params)
if (res.code === ApiStatus.success) {
ElMessage.success(`修改成功 ${EmojiText[200]}`)
goBack()
}
} catch (err) {
console.error(err)
}
}
const delCodeTrim = (content: string): string => {
return content.replace(/(\s*)<\/code>/g, '</code>')
}
const onSuccess = (response: any) => {
cover.value = response.data.url
ElMessage.success(`图片上传成功 ${EmojiText[200]}`)
}
const onError = () => {
ElMessage.error(`图片上传失败 ${EmojiText[500]}`)
}
// 返回上一页
const goBack = () => {
setTimeout(() => {
router.go(-1)
}, 800)
}
// 添加上传前的校验
const beforeUpload = (file: File) => {
const isImage = file.type.startsWith('image/')
const isLt2M = file.size / 1024 / 1024 < 2
if (!isImage) {
ElMessage.error('只能上传图片文件!')
return false
}
if (!isLt2M) {
ElMessage.error('图片大小不能超过 2MB!')
return false
}
return true
}
</script>
<style lang="scss" scoped>
.article-edit {
.editor-wrap {
max-width: 1000px;
margin: 20px auto;
.el-top {
margin-top: 10px;
}
.form-wrap {
padding: 20px;
margin-top: 20px;
background-color: var(--art-main-bg-color);
border: 1px solid var(--art-border-color);
border-radius: calc(var(--custom-radius) / 2 + 2px) !important;
h2 {
margin-bottom: 20px;
font-size: 20px;
font-weight: 500;
}
}
}
.outline-wrap {
box-sizing: border-box;
width: 280px;
padding: 20px;
border: 1px solid #e3e3e3;
border-radius: 8px;
.item {
p {
height: 30px;
font-size: 13px;
line-height: 30px;
cursor: pointer;
}
.level3 {
padding-left: 10px;
}
}
}
.upload-container {
.cover-uploader {
position: relative;
overflow: hidden;
cursor: pointer;
border-radius: 6px;
transition: var(--el-transition-duration);
&:hover {
border-color: var(--el-color-primary);
}
.upload-placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 260px;
height: 160px;
border: 1px dashed #d9d9d9;
border-radius: 6px;
.upload-icon {
font-size: 28px;
color: #8c939d;
}
.upload-text {
margin-top: 8px;
font-size: 14px;
color: #8c939d;
}
}
.cover-image {
display: block;
width: 260px;
height: 160px;
object-fit: cover;
}
}
.el-upload__tip {
margin-top: 8px;
font-size: 12px;
color: #666;
}
}
}
</style>

View File

@@ -0,0 +1,442 @@
<template>
<div class="page-content">
<!-- 操作提示 -->
<ElAlert type="info" :closable="false" style="margin-bottom: 20px">
<template #title>
<div style="line-height: 1.8">
<p><strong>资产分配说明</strong></p>
<p>1. <strong>网卡批量分配</strong>仅分配选中的网卡资产</p>
<p>2. <strong>设备批量分配</strong>仅分配选中的设备资产</p>
<p>3. <strong>网卡+设备分配</strong>如果网卡有绑定设备将同时分配网卡和设备</p>
<p>4. 分配后资产所有权将转移至目标代理商</p>
</div>
</template>
</ElAlert>
<!-- 分配模式选择 -->
<ElCard shadow="never" style="margin-bottom: 20px">
<ElRadioGroup v-model="assignMode" size="large">
<ElRadioButton value="sim">网卡批量分配</ElRadioButton>
<ElRadioButton value="device">设备批量分配</ElRadioButton>
<ElRadioButton value="both">网卡+设备分配</ElRadioButton>
</ElRadioGroup>
</ElCard>
<!-- 网卡分配 -->
<ElCard v-if="assignMode === 'sim' || assignMode === 'both'" shadow="never" style="margin-bottom: 20px">
<template #header>
<div style="display: flex; justify-content: space-between; align-items: center">
<span style="font-weight: 500">选择网卡资产</span>
<ElButton type="primary" size="small" :disabled="selectedSims.length === 0" @click="showAssignDialog('sim')">
分配选中的 {{ selectedSims.length }} 张网卡
</ElButton>
</div>
</template>
<ElRow :gutter="12" style="margin-bottom: 16px">
<ElCol :xs="24" :sm="12" :lg="6">
<ElInput v-model="simSearchQuery" placeholder="ICCID/IMSI" clearable />
</ElCol>
<ElCol :xs="24" :sm="12" :lg="6">
<ElSelect v-model="simStatusFilter" placeholder="状态筛选" clearable style="width: 100%">
<ElOption label="全部" value="" />
<ElOption label="激活" value="active" />
<ElOption label="未激活" value="inactive" />
<ElOption label="停机" value="suspended" />
</ElSelect>
</ElCol>
<ElCol :xs="24" :sm="12" :lg="6">
<ElButton v-ripple @click="searchSims">搜索</ElButton>
</ElCol>
</ElRow>
<ArtTable :data="filteredSimData" index @selection-change="handleSimSelectionChange">
<template #default>
<ElTableColumn type="selection" width="55" />
<ElTableColumn label="ICCID" prop="iccid" width="200" />
<ElTableColumn label="IMSI" prop="imsi" width="180" />
<ElTableColumn label="运营商" prop="operator" width="100">
<template #default="scope">
<ElTag size="small">{{ scope.row.operator }}</ElTag>
</template>
</ElTableColumn>
<ElTableColumn label="状态" prop="status" width="100">
<template #default="scope">
<ElTag v-if="scope.row.status === 'active'" type="success" size="small">激活</ElTag>
<ElTag v-else-if="scope.row.status === 'inactive'" type="info" size="small">未激活</ElTag>
<ElTag v-else type="warning" size="small">停机</ElTag>
</template>
</ElTableColumn>
<ElTableColumn label="绑定设备" prop="deviceCode" width="150">
<template #default="scope">
<ElTag v-if="scope.row.deviceCode" type="primary" size="small">
{{ scope.row.deviceCode }}
</ElTag>
<span v-else style="color: var(--el-text-color-secondary)">未绑定</span>
</template>
</ElTableColumn>
<ElTableColumn label="剩余流量" prop="remainData" width="120" />
<ElTableColumn label="到期时间" prop="expireTime" width="180" />
</template>
</ArtTable>
</ElCard>
<!-- 设备分配 -->
<ElCard v-if="assignMode === 'device'" shadow="never">
<template #header>
<div style="display: flex; justify-content: space-between; align-items: center">
<span style="font-weight: 500">选择设备资产</span>
<ElButton type="primary" size="small" :disabled="selectedDevices.length === 0" @click="showAssignDialog('device')">
分配选中的 {{ selectedDevices.length }} 个设备
</ElButton>
</div>
</template>
<ElRow :gutter="12" style="margin-bottom: 16px">
<ElCol :xs="24" :sm="12" :lg="6">
<ElInput v-model="deviceSearchQuery" placeholder="设备编号/名称" clearable />
</ElCol>
<ElCol :xs="24" :sm="12" :lg="6">
<ElSelect v-model="deviceTypeFilter" placeholder="设备类型" clearable style="width: 100%">
<ElOption label="全部" value="" />
<ElOption label="GPS定位器" value="gps" />
<ElOption label="智能水表" value="water_meter" />
<ElOption label="智能电表" value="electric_meter" />
</ElSelect>
</ElCol>
<ElCol :xs="24" :sm="12" :lg="6">
<ElButton v-ripple @click="searchDevices">搜索</ElButton>
</ElCol>
</ElRow>
<ArtTable :data="filteredDeviceData" index @selection-change="handleDeviceSelectionChange">
<template #default>
<ElTableColumn type="selection" width="55" />
<ElTableColumn label="设备编号" prop="deviceCode" width="180" />
<ElTableColumn label="设备名称" prop="deviceName" min-width="180" />
<ElTableColumn label="设备类型" prop="deviceType" width="120">
<template #default="scope">
<ElTag size="small">{{ getDeviceTypeText(scope.row.deviceType) }}</ElTag>
</template>
</ElTableColumn>
<ElTableColumn label="绑定ICCID" prop="iccid" width="200">
<template #default="scope">
<ElTag v-if="scope.row.iccid" type="success" size="small">
{{ scope.row.iccid }}
</ElTag>
<span v-else style="color: var(--el-text-color-secondary)">未绑定</span>
</template>
</ElTableColumn>
<ElTableColumn label="在线状态" prop="onlineStatus" width="100">
<template #default="scope">
<ElTag v-if="scope.row.onlineStatus === 'online'" type="success" size="small">在线</ElTag>
<ElTag v-else type="info" size="small">离线</ElTag>
</template>
</ElTableColumn>
<ElTableColumn label="创建时间" prop="createTime" width="180" />
</template>
</ArtTable>
</ElCard>
<!-- 分配对话框 -->
<ElDialog v-model="assignDialogVisible" title="资产分配" width="600px" align-center>
<ElForm ref="formRef" :model="assignForm" :rules="assignRules" label-width="120px">
<ElFormItem label="分配类型">
<ElTag v-if="assignForm.type === 'sim'" type="primary">网卡资产</ElTag>
<ElTag v-else-if="assignForm.type === 'device'" type="success">设备资产</ElTag>
<ElTag v-else type="warning">网卡+设备</ElTag>
</ElFormItem>
<ElFormItem label="分配数量">
<div>
<span v-if="assignForm.type === 'sim'" style="font-size: 18px; font-weight: 600; color: var(--el-color-primary)">
{{ selectedSims.length }} 张网卡
</span>
<span v-else-if="assignForm.type === 'device'" style="font-size: 18px; font-weight: 600; color: var(--el-color-success)">
{{ selectedDevices.length }} 个设备
</span>
<span v-else style="font-size: 18px; font-weight: 600">
{{ selectedSims.length }} 张网卡 + {{ selectedSims.filter(s => s.deviceCode).length }} 个设备
</span>
</div>
</ElFormItem>
<ElFormItem label="目标代理商" prop="targetAgentId">
<ElSelect
v-model="assignForm.targetAgentId"
placeholder="请选择目标代理商"
filterable
style="width: 100%"
>
<ElOption
v-for="agent in agentList"
:key="agent.id"
:label="`${agent.agentName} (等级${agent.level})`"
:value="agent.id"
/>
</ElSelect>
</ElFormItem>
<ElFormItem label="分配说明" prop="remark">
<ElInput
v-model="assignForm.remark"
type="textarea"
:rows="3"
placeholder="请输入分配说明"
/>
</ElFormItem>
<ElAlert type="warning" :closable="false">
分配后资产所有权将转移至目标代理商原账号将无法管理这些资产
</ElAlert>
</ElForm>
<template #footer>
<div class="dialog-footer">
<ElButton @click="assignDialogVisible = false">取消</ElButton>
<ElButton type="primary" @click="handleAssignSubmit">确认分配</ElButton>
</div>
</template>
</ElDialog>
<!-- 分配记录 -->
<ElCard shadow="never" style="margin-top: 20px">
<template #header>
<span style="font-weight: 500">最近分配记录</span>
</template>
<ArtTable :data="assignHistory" index>
<template #default>
<ElTableColumn label="分配批次号" prop="batchNo" width="180" />
<ElTableColumn label="分配类型" prop="type" width="120">
<template #default="scope">
<ElTag v-if="scope.row.type === 'sim'" type="primary" size="small">网卡</ElTag>
<ElTag v-else-if="scope.row.type === 'device'" type="success" size="small">设备</ElTag>
<ElTag v-else type="warning" size="small">网卡+设备</ElTag>
</template>
</ElTableColumn>
<ElTableColumn label="分配数量" prop="quantity" width="100" />
<ElTableColumn label="目标代理商" prop="targetAgentName" min-width="150" />
<ElTableColumn label="分配说明" prop="remark" min-width="200" show-overflow-tooltip />
<ElTableColumn label="分配时间" prop="assignTime" width="180" />
<ElTableColumn label="操作人" prop="operator" width="100" />
</template>
</ArtTable>
</ElCard>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
defineOptions({ name: 'AssetAssign' })
interface SimCard {
id: string
iccid: string
imsi: string
operator: string
status: string
deviceCode?: string
remainData: string
expireTime: string
}
interface Device {
id: string
deviceCode: string
deviceName: string
deviceType: string
iccid?: string
onlineStatus: string
createTime: string
}
const assignMode = ref('sim')
const simSearchQuery = ref('')
const simStatusFilter = ref('')
const deviceSearchQuery = ref('')
const deviceTypeFilter = ref('')
const assignDialogVisible = ref(false)
const formRef = ref<FormInstance>()
const selectedSims = ref<SimCard[]>([])
const selectedDevices = ref<Device[]>([])
const assignForm = reactive({
type: 'sim',
targetAgentId: '',
remark: ''
})
const assignRules = reactive<FormRules>({
targetAgentId: [{ required: true, message: '请选择目标代理商', trigger: 'change' }]
})
const agentList = ref([
{ id: '1', agentName: '华东区总代理', level: 1 },
{ id: '2', agentName: '华南区代理', level: 2 },
{ id: '3', agentName: '华北区代理', level: 1 }
])
const simMockData = ref<SimCard[]>([
{
id: '1',
iccid: '89860123456789012345',
imsi: '460012345678901',
operator: '中国移动',
status: 'active',
deviceCode: 'DEV001',
remainData: '50GB',
expireTime: '2026-12-31'
},
{
id: '2',
iccid: '89860123456789012346',
imsi: '460012345678902',
operator: '中国联通',
status: 'active',
remainData: '80GB',
expireTime: '2026-11-30'
}
])
const deviceMockData = ref<Device[]>([
{
id: '1',
deviceCode: 'DEV001',
deviceName: 'GPS定位器-001',
deviceType: 'gps',
iccid: '89860123456789012345',
onlineStatus: 'online',
createTime: '2026-01-01 10:00:00'
},
{
id: '2',
deviceCode: 'DEV002',
deviceName: '智能水表-002',
deviceType: 'water_meter',
iccid: '89860123456789012346',
onlineStatus: 'offline',
createTime: '2026-01-02 11:00:00'
}
])
const assignHistory = ref([
{
id: '1',
batchNo: 'ASSIGN202601090001',
type: 'sim',
quantity: 100,
targetAgentName: '华东区总代理',
remark: '批量分配给华东区',
assignTime: '2026-01-09 10:00:00',
operator: 'admin'
},
{
id: '2',
batchNo: 'ASSIGN202601080001',
type: 'both',
quantity: 50,
targetAgentName: '华南区代理',
remark: '网卡和设备一起分配',
assignTime: '2026-01-08 14:00:00',
operator: 'admin'
}
])
const filteredSimData = computed(() => {
let data = simMockData.value
if (simSearchQuery.value) {
data = data.filter(
(item) => item.iccid.includes(simSearchQuery.value) || item.imsi.includes(simSearchQuery.value)
)
}
if (simStatusFilter.value) {
data = data.filter((item) => item.status === simStatusFilter.value)
}
return data
})
const filteredDeviceData = computed(() => {
let data = deviceMockData.value
if (deviceSearchQuery.value) {
data = data.filter(
(item) => item.deviceCode.includes(deviceSearchQuery.value) || item.deviceName.includes(deviceSearchQuery.value)
)
}
if (deviceTypeFilter.value) {
data = data.filter((item) => item.deviceType === deviceTypeFilter.value)
}
return data
})
const getDeviceTypeText = (type: string) => {
const map: Record<string, string> = {
gps: 'GPS定位器',
water_meter: '智能水表',
electric_meter: '智能电表'
}
return map[type] || type
}
const searchSims = () => {}
const searchDevices = () => {}
const handleSimSelectionChange = (rows: SimCard[]) => {
selectedSims.value = rows
}
const handleDeviceSelectionChange = (rows: Device[]) => {
selectedDevices.value = rows
}
const showAssignDialog = (type: string) => {
assignForm.type = type
assignDialogVisible.value = true
}
const handleAssignSubmit = async () => {
if (!formRef.value) return
await formRef.value.validate((valid) => {
if (valid) {
const agent = agentList.value.find((a) => a.id === assignForm.targetAgentId)
let quantity = 0
if (assignForm.type === 'sim') {
quantity = selectedSims.value.length
} else if (assignForm.type === 'device') {
quantity = selectedDevices.value.length
} else {
quantity = selectedSims.value.length
}
assignHistory.value.unshift({
id: Date.now().toString(),
batchNo: `ASSIGN${Date.now()}`,
type: assignForm.type,
quantity,
targetAgentName: agent?.agentName || '',
remark: assignForm.remark,
assignTime: new Date().toLocaleString('zh-CN'),
operator: 'admin'
})
assignDialogVisible.value = false
formRef.value.resetFields()
selectedSims.value = []
selectedDevices.value = []
ElMessage.success('资产分配成功')
}
})
}
</script>
<style lang="scss" scoped>
.page-content {
:deep(.el-radio-button__inner) {
padding: 12px 20px;
}
}
</style>

View File

@@ -0,0 +1,523 @@
<template>
<div class="page-content">
<!-- 搜索和筛选区 -->
<ElRow :gutter="12">
<ElCol :xs="24" :sm="12" :lg="6">
<ElInput v-model="searchQuery" placeholder="申请单号/ICCID" clearable />
</ElCol>
<ElCol :xs="24" :sm="12" :lg="6">
<ElSelect v-model="statusFilter" placeholder="状态筛选" clearable style="width: 100%">
<ElOption label="全部" value="" />
<ElOption label="待处理" value="pending" />
<ElOption label="处理中" value="processing" />
<ElOption label="已完成" value="completed" />
<ElOption label="已拒绝" value="rejected" />
</ElSelect>
</ElCol>
<ElCol :xs="24" :sm="12" :lg="6">
<ElDatePicker
v-model="dateRange"
type="daterange"
range-separator=""
start-placeholder="开始日期"
end-placeholder="结束日期"
style="width: 100%"
/>
</ElCol>
<ElCol :xs="24" :sm="12" :lg="6" class="el-col2">
<ElButton v-ripple @click="handleSearch">搜索</ElButton>
<ElButton v-ripple @click="exportData">导出</ElButton>
</ElCol>
</ElRow>
<!-- 统计卡片 -->
<ElRow :gutter="20" style="margin: 20px 0">
<ElCol :xs="24" :sm="12" :lg="6">
<ElCard shadow="hover" class="stat-card">
<div class="stat-content">
<div class="stat-label">待处理</div>
<div class="stat-value" style="color: var(--el-color-warning)">{{ statistics.pending }}</div>
</div>
<el-icon class="stat-icon" style="color: var(--el-color-warning)"><Clock /></el-icon>
</ElCard>
</ElCol>
<ElCol :xs="24" :sm="12" :lg="6">
<ElCard shadow="hover" class="stat-card">
<div class="stat-content">
<div class="stat-label">处理中</div>
<div class="stat-value" style="color: var(--el-color-primary)">{{ statistics.processing }}</div>
</div>
<el-icon class="stat-icon" style="color: var(--el-color-primary)"><Loading /></el-icon>
</ElCard>
</ElCol>
<ElCol :xs="24" :sm="12" :lg="6">
<ElCard shadow="hover" class="stat-card">
<div class="stat-content">
<div class="stat-label">已完成</div>
<div class="stat-value" style="color: var(--el-color-success)">{{ statistics.completed }}</div>
</div>
<el-icon class="stat-icon" style="color: var(--el-color-success)"><CircleCheck /></el-icon>
</ElCard>
</ElCol>
<ElCol :xs="24" :sm="12" :lg="6">
<ElCard shadow="hover" class="stat-card">
<div class="stat-content">
<div class="stat-label">已拒绝</div>
<div class="stat-value" style="color: var(--el-color-danger)">{{ statistics.rejected }}</div>
</div>
<el-icon class="stat-icon" style="color: var(--el-color-danger)"><CircleClose /></el-icon>
</ElCard>
</ElCol>
</ElRow>
<!-- 换卡申请列表 -->
<ArtTable :data="filteredData" index>
<template #default>
<ElTableColumn label="申请单号" prop="requestNo" width="180" />
<ElTableColumn label="旧卡ICCID" prop="oldIccid" width="200" />
<ElTableColumn label="申请人" prop="applicant" width="120" />
<ElTableColumn label="联系电话" prop="phone" width="130" />
<ElTableColumn label="换卡原因" prop="reason" min-width="180" show-overflow-tooltip />
<ElTableColumn label="新卡ICCID" prop="newIccid" width="200">
<template #default="scope">
<ElTag v-if="scope.row.newIccid" type="success" size="small">
{{ scope.row.newIccid }}
</ElTag>
<span v-else style="color: var(--el-text-color-secondary)">待填充</span>
</template>
</ElTableColumn>
<ElTableColumn label="状态" prop="status" width="100">
<template #default="scope">
<ElTag v-if="scope.row.status === 'pending'" type="warning">待处理</ElTag>
<ElTag v-else-if="scope.row.status === 'processing'" type="primary">处理中</ElTag>
<ElTag v-else-if="scope.row.status === 'completed'" type="success">已完成</ElTag>
<ElTag v-else type="danger">已拒绝</ElTag>
</template>
</ElTableColumn>
<ElTableColumn label="申请时间" prop="applyTime" width="180" />
<ElTableColumn fixed="right" label="操作" width="240">
<template #default="scope">
<el-button link :icon="View" @click="viewDetail(scope.row)">详情</el-button>
<el-button
v-if="scope.row.status === 'pending'"
link
type="primary"
@click="handleProcess(scope.row)"
>
处理
</el-button>
<el-button
v-if="scope.row.status === 'processing'"
link
type="success"
@click="fillNewIccid(scope.row)"
>
填充新卡
</el-button>
<el-button
v-if="scope.row.status === 'pending'"
link
type="danger"
@click="handleReject(scope.row)"
>
拒绝
</el-button>
</template>
</ElTableColumn>
</template>
</ArtTable>
<!-- 详情对话框 -->
<ElDialog v-model="detailDialogVisible" title="换卡申请详情" width="800px" align-center>
<ElDescriptions :column="2" border>
<ElDescriptionsItem label="申请单号">{{ currentRequest.requestNo }}</ElDescriptionsItem>
<ElDescriptionsItem label="申请人">{{ currentRequest.applicant }}</ElDescriptionsItem>
<ElDescriptionsItem label="联系电话">{{ currentRequest.phone }}</ElDescriptionsItem>
<ElDescriptionsItem label="申请时间">{{ currentRequest.applyTime }}</ElDescriptionsItem>
<ElDescriptionsItem label="旧卡ICCID" :span="2">
<ElTag type="warning">{{ currentRequest.oldIccid }}</ElTag>
</ElDescriptionsItem>
<ElDescriptionsItem label="新卡ICCID" :span="2">
<ElTag v-if="currentRequest.newIccid" type="success">{{ currentRequest.newIccid }}</ElTag>
<span v-else style="color: var(--el-text-color-secondary)">待填充</span>
</ElDescriptionsItem>
<ElDescriptionsItem label="换卡原因" :span="2">
{{ currentRequest.reason }}
</ElDescriptionsItem>
<ElDescriptionsItem label="详细说明" :span="2">
{{ currentRequest.description || '无' }}
</ElDescriptionsItem>
<ElDescriptionsItem label="状态">
<ElTag v-if="currentRequest.status === 'pending'" type="warning">待处理</ElTag>
<ElTag v-else-if="currentRequest.status === 'processing'" type="primary">处理中</ElTag>
<ElTag v-else-if="currentRequest.status === 'completed'" type="success">已完成</ElTag>
<ElTag v-else type="danger">已拒绝</ElTag>
</ElDescriptionsItem>
<ElDescriptionsItem label="处理人">
{{ currentRequest.processor || '-' }}
</ElDescriptionsItem>
<ElDescriptionsItem label="处理时间" :span="2">
{{ currentRequest.processTime || '-' }}
</ElDescriptionsItem>
<ElDescriptionsItem v-if="currentRequest.rejectReason" label="拒绝原因" :span="2">
<span style="color: var(--el-color-danger)">{{ currentRequest.rejectReason }}</span>
</ElDescriptionsItem>
</ElDescriptions>
<template #footer>
<ElButton @click="detailDialogVisible = false">关闭</ElButton>
</template>
</ElDialog>
<!-- 填充新卡对话框 -->
<ElDialog v-model="fillDialogVisible" title="填充新卡ICCID" width="500px" align-center>
<ElForm ref="fillFormRef" :model="fillForm" :rules="fillRules" label-width="100px">
<ElFormItem label="旧卡ICCID">
<ElInput :value="currentRequest.oldIccid" disabled />
</ElFormItem>
<ElFormItem label="新卡ICCID" prop="newIccid">
<ElInput v-model="fillForm.newIccid" placeholder="请输入新卡ICCID" maxlength="20" />
</ElFormItem>
<ElFormItem label="验证新卡">
<ElButton @click="validateNewIccid">验证ICCID</ElButton>
<div v-if="validationResult" style="margin-top: 8px">
<ElTag v-if="validationResult === 'success'" type="success" size="small">
验证通过该卡可用
</ElTag>
<ElTag v-else type="danger" size="small">
验证失败{{ validationMessage }}
</ElTag>
</div>
</ElFormItem>
<ElFormItem label="备注">
<ElInput v-model="fillForm.remark" type="textarea" :rows="3" placeholder="请输入备注信息" />
</ElFormItem>
<ElAlert type="info" :closable="false">
填充新卡后系统将自动完成换卡操作旧卡将被停用
</ElAlert>
</ElForm>
<template #footer>
<div class="dialog-footer">
<ElButton @click="fillDialogVisible = false">取消</ElButton>
<ElButton type="primary" @click="handleFillSubmit">确认填充</ElButton>
</div>
</template>
</ElDialog>
<!-- 拒绝对话框 -->
<ElDialog v-model="rejectDialogVisible" title="拒绝换卡申请" width="500px" align-center>
<ElForm ref="rejectFormRef" :model="rejectForm" :rules="rejectRules" label-width="100px">
<ElFormItem label="拒绝原因" prop="reason">
<ElInput
v-model="rejectForm.reason"
type="textarea"
:rows="4"
placeholder="请输入拒绝原因"
/>
</ElFormItem>
</ElForm>
<template #footer>
<div class="dialog-footer">
<ElButton @click="rejectDialogVisible = false">取消</ElButton>
<ElButton type="danger" @click="handleRejectSubmit">确认拒绝</ElButton>
</div>
</template>
</ElDialog>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { View, Clock, Loading as LoadingIcon, CircleCheck, CircleClose } from '@element-plus/icons-vue'
import type { FormInstance, FormRules } from 'element-plus'
defineOptions({ name: 'CardReplacementRequest' })
interface ReplacementRequest {
id: string
requestNo: string
oldIccid: string
newIccid?: string
applicant: string
phone: string
reason: string
description?: string
status: 'pending' | 'processing' | 'completed' | 'rejected'
applyTime: string
processor?: string
processTime?: string
rejectReason?: string
}
const searchQuery = ref('')
const statusFilter = ref('')
const dateRange = ref<[Date, Date] | null>(null)
const detailDialogVisible = ref(false)
const fillDialogVisible = ref(false)
const rejectDialogVisible = ref(false)
const fillFormRef = ref<FormInstance>()
const rejectFormRef = ref<FormInstance>()
const validationResult = ref<string>('')
const validationMessage = ref('')
const statistics = reactive({
pending: 15,
processing: 8,
completed: 102,
rejected: 5
})
const fillForm = reactive({
newIccid: '',
remark: ''
})
const fillRules = reactive<FormRules>({
newIccid: [
{ required: true, message: '请输入新卡ICCID', trigger: 'blur' },
{ len: 20, message: 'ICCID长度必须为20位', trigger: 'blur' }
]
})
const rejectForm = reactive({
reason: ''
})
const rejectRules = reactive<FormRules>({
reason: [{ required: true, message: '请输入拒绝原因', trigger: 'blur' }]
})
const mockData = ref<ReplacementRequest[]>([
{
id: '1',
requestNo: 'REP202601090001',
oldIccid: '89860123456789012345',
applicant: '张三',
phone: '13800138000',
reason: '卡片损坏',
description: '卡片物理损坏无法使用',
status: 'pending',
applyTime: '2026-01-09 09:30:00'
},
{
id: '2',
requestNo: 'REP202601080002',
oldIccid: '89860123456789012346',
newIccid: '89860123456789012350',
applicant: '李四',
phone: '13900139000',
reason: '信号不稳定',
description: '长期信号不稳定,影响使用',
status: 'processing',
applyTime: '2026-01-08 14:20:00',
processor: 'admin'
},
{
id: '3',
requestNo: 'REP202601070003',
oldIccid: '89860123456789012347',
newIccid: '89860123456789012351',
applicant: '王五',
phone: '13700137000',
reason: '卡片丢失',
description: '卡片意外丢失',
status: 'completed',
applyTime: '2026-01-07 10:00:00',
processor: 'admin',
processTime: '2026-01-07 15:30:00'
},
{
id: '4',
requestNo: 'REP202601060004',
oldIccid: '89860123456789012348',
applicant: '赵六',
phone: '13600136000',
reason: '套餐到期',
description: '套餐到期需要换新卡',
status: 'rejected',
applyTime: '2026-01-06 11:00:00',
processor: 'admin',
processTime: '2026-01-06 12:00:00',
rejectReason: '套餐到期应该续费而不是换卡'
}
])
const currentRequest = ref<ReplacementRequest>({
id: '',
requestNo: '',
oldIccid: '',
applicant: '',
phone: '',
reason: '',
status: 'pending',
applyTime: ''
})
const filteredData = computed(() => {
let data = mockData.value
if (searchQuery.value) {
data = data.filter(
(item) =>
item.requestNo.includes(searchQuery.value) ||
item.oldIccid.includes(searchQuery.value) ||
(item.newIccid && item.newIccid.includes(searchQuery.value))
)
}
if (statusFilter.value) {
data = data.filter((item) => item.status === statusFilter.value)
}
return data
})
const handleSearch = () => {}
const exportData = () => {
ElMessage.success('数据导出中...')
}
const viewDetail = (row: ReplacementRequest) => {
currentRequest.value = { ...row }
detailDialogVisible.value = true
}
const handleProcess = (row: ReplacementRequest) => {
ElMessageBox.confirm('确定要处理该换卡申请吗?', '处理确认', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'info'
}).then(() => {
row.status = 'processing'
row.processor = 'admin'
ElMessage.success('已标记为处理中')
})
}
const fillNewIccid = (row: ReplacementRequest) => {
currentRequest.value = { ...row }
fillForm.newIccid = ''
fillForm.remark = ''
validationResult.value = ''
fillDialogVisible.value = true
}
const validateNewIccid = () => {
if (!fillForm.newIccid) {
ElMessage.warning('请先输入新卡ICCID')
return
}
if (fillForm.newIccid.length !== 20) {
validationResult.value = 'error'
validationMessage.value = 'ICCID长度必须为20位'
return
}
// 模拟验证
setTimeout(() => {
const exists = mockData.value.some((item) => item.oldIccid === fillForm.newIccid)
if (exists) {
validationResult.value = 'error'
validationMessage.value = '该ICCID已被使用'
} else {
validationResult.value = 'success'
validationMessage.value = ''
ElMessage.success('验证通过')
}
}, 500)
}
const handleFillSubmit = async () => {
if (!fillFormRef.value) return
if (validationResult.value !== 'success') {
ElMessage.warning('请先验证新卡ICCID')
return
}
await fillFormRef.value.validate((valid) => {
if (valid) {
const index = mockData.value.findIndex((item) => item.id === currentRequest.value.id)
if (index !== -1) {
mockData.value[index].newIccid = fillForm.newIccid
mockData.value[index].status = 'completed'
mockData.value[index].processTime = new Date().toLocaleString('zh-CN')
statistics.processing--
statistics.completed++
}
fillDialogVisible.value = false
ElMessage.success('新卡填充成功,换卡操作已完成')
}
})
}
const handleReject = (row: ReplacementRequest) => {
currentRequest.value = { ...row }
rejectForm.reason = ''
rejectDialogVisible.value = true
}
const handleRejectSubmit = async () => {
if (!rejectFormRef.value) return
await rejectFormRef.value.validate((valid) => {
if (valid) {
const index = mockData.value.findIndex((item) => item.id === currentRequest.value.id)
if (index !== -1) {
mockData.value[index].status = 'rejected'
mockData.value[index].rejectReason = rejectForm.reason
mockData.value[index].processor = 'admin'
mockData.value[index].processTime = new Date().toLocaleString('zh-CN')
statistics.pending--
statistics.rejected++
}
rejectDialogVisible.value = false
ElMessage.success('已拒绝该换卡申请')
}
})
}
</script>
<style lang="scss" scoped>
.page-content {
.stat-card {
:deep(.el-card__body) {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20px;
}
.stat-content {
.stat-label {
font-size: 14px;
color: var(--el-text-color-secondary);
margin-bottom: 8px;
}
.stat-value {
font-size: 24px;
font-weight: 600;
color: var(--el-text-color-primary);
}
}
.stat-icon {
font-size: 40px;
opacity: 0.6;
}
}
}
</style>

View File

@@ -0,0 +1,63 @@
<template>
<div class="login register">
<LoginLeftView></LoginLeftView>
<div class="right-wrap">
<div class="header">
<ArtLogo class="icon" />
<h1>{{ systemName }}</h1>
</div>
<div class="login-wrap">
<div class="form">
<h3 class="title">{{ $t('forgetPassword.title') }}</h3>
<p class="sub-title">{{ $t('forgetPassword.subTitle') }}</p>
<div class="input-wrap">
<span class="input-label" v-if="showInputLabel">账号</span>
<ElInput :placeholder="$t('forgetPassword.placeholder')" v-model.trim="username" />
</div>
<div style="margin-top: 15px">
<ElButton
class="login-btn"
type="primary"
@click="register"
:loading="loading"
v-ripple
>
{{ $t('forgetPassword.submitBtnText') }}
</ElButton>
</div>
<div style="margin-top: 15px">
<ElButton class="back-btn" plain @click="toLogin">
{{ $t('forgetPassword.backBtnText') }}
</ElButton>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import AppConfig from '@/config'
import { RoutesAlias } from '@/router/routesAlias'
defineOptions({ name: 'ForgetPassword' })
const router = useRouter()
const showInputLabel = ref(false)
const systemName = AppConfig.systemInfo.name
const username = ref('')
const loading = ref(false)
const register = async () => {}
const toLogin = () => {
router.push(RoutesAlias.Login)
}
</script>
<style lang="scss" scoped>
@use '../login/index';
</style>

View File

@@ -0,0 +1,232 @@
@use '@styles/variables.scss' as *;
.login {
box-sizing: border-box;
display: flex;
width: 100%;
height: 100vh;
.el-input__inner {
&:focus {
border: 1px solid #4e83fd;
}
}
.el-input--medium .el-input__inner {
height: var(--el-component-custom-height);
line-height: var(--el-component-custom-height);
}
.right-wrap {
position: relative;
flex: 1;
height: 100%;
.top-right-wrap {
position: fixed;
top: 30px;
right: 30px;
z-index: 100;
display: flex;
align-items: center;
justify-content: flex-end;
.btn {
display: inline-block;
padding: 5px;
margin-left: 15px;
cursor: pointer;
user-select: none;
transition: all 0.3s;
i {
font-size: 18px;
}
&:hover {
color: var(--main-color) !important;
}
}
}
.header {
display: none;
}
.login-wrap {
position: absolute;
inset: 0;
width: 440px;
height: 610px;
padding: 0 5px;
margin: auto;
overflow: hidden;
background-size: cover;
border-radius: 5px;
.form {
box-sizing: border-box;
height: 100%;
padding: 40px 0;
widows: 100%;
.title {
margin-left: -2px;
font-size: 34px;
font-weight: 600;
color: var(--art-text-gray-900) !important;
}
.sub-title {
margin-top: 10px;
font-size: 14px;
color: var(--art-text-gray-500) !important;
}
.input-wrap {
margin-top: 25px;
.input-label {
display: block;
padding-bottom: 8px;
font-size: 15px;
font-weight: 500;
color: var(--art-text-gray-800);
}
}
.account-select :deep(.el-select__wrapper),
.el-input,
.login-btn {
height: 40px !important;
}
.drag-verify {
position: relative;
padding-bottom: 20px;
margin-top: 25px;
.drag-verify-content {
position: relative;
z-index: 2;
box-sizing: border-box;
user-select: none;
border-radius: 8px;
transition: all 0.3s;
&.error {
border-color: #f56c6c;
}
}
.error-text {
position: absolute;
top: 0;
z-index: 1;
margin-top: 10px;
font-size: 13px;
color: #f56c6c;
transition: all 0.3s;
&.show-error-text {
transform: translateY(40px);
}
}
}
.forget-password {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 14px;
color: var(--art-text-gray-500);
a {
color: var(--main-color);
text-decoration: none;
}
}
.login-btn {
width: 100%;
height: 40px !important;
color: #fff;
border: 0;
}
.back-btn {
width: 100%;
height: 40px !important;
}
.footer {
margin-top: 20px;
font-size: 14px;
color: var(--art-text-gray-800);
a {
color: var(--main-color);
text-decoration: none;
}
}
}
}
}
}
@media only screen and (max-width: $device-ipad-pro) {
.login {
width: 100%;
height: 100vh;
.right-wrap {
margin: auto;
.login-wrap {
position: relative;
width: 440px;
height: auto;
padding: 0;
border-radius: 0;
box-shadow: none;
.form {
margin-top: 10vh;
}
}
}
}
}
@media only screen and (max-width: $device-phone) {
.login {
position: fixed;
top: 0;
.right-wrap {
box-sizing: border-box;
width: 100% !important;
padding: 0 30px;
margin: auto;
.login-wrap {
width: 100%;
.form {
margin-top: 12vh;
.input-wrap {
.input-label {
display: none;
}
}
.input-wrap,
.drag-verify {
margin-top: 20px;
}
}
}
}
}
}

View File

@@ -0,0 +1,184 @@
<template>
<div class="login">
<LoginLeftView></LoginLeftView>
<div class="right-wrap">
<div class="top-right-wrap">
<div class="btn theme-btn" @click="toggleTheme">
<i class="iconfont-sys">
{{ isDark ? '&#xe6b5;' : '&#xe725;' }}
</i>
</div>
<ElDropdown @command="changeLanguage" popper-class="langDropDownStyle">
<div class="btn language-btn">
<i class="iconfont-sys icon-language">&#xe611;</i>
</div>
<template #dropdown>
<ElDropdownMenu>
<div v-for="lang in languageOptions" :key="lang.value" class="lang-btn-item">
<ElDropdownItem
:command="lang.value"
:class="{ 'is-selected': locale === lang.value }"
>
<span class="menu-txt">{{ lang.label }}</span>
<i v-if="locale === lang.value" class="iconfont-sys icon-check">&#xe621;</i>
</ElDropdownItem>
</div>
</ElDropdownMenu>
</template>
</ElDropdown>
</div>
<div class="header">
<ArtLogo class="icon" />
<h1>{{ systemName }}</h1>
</div>
<div class="login-wrap">
<div class="form">
<h3 class="title">{{ $t('login.title') }}</h3>
<p class="sub-title">{{ $t('login.subTitle') }}</p>
<ElForm
ref="formRef"
:model="formData"
:rules="rules"
@keyup.enter="handleSubmit"
style="margin-top: 25px"
>
<!-- Mock 账号选择仅在开发模式且使用 Mock 时显示 -->
<ElFormItem prop="account" v-if="false">
<ElSelect v-model="formData.account" @change="setupAccount" class="account-select">
<ElOption
v-for="account in mockAccounts"
:key="account.key"
:label="account.label"
:value="account.key"
>
<span>{{ account.label }}</span>
</ElOption>
</ElSelect>
</ElFormItem>
<ElFormItem prop="username">
<ElInput :placeholder="$t('login.placeholder[0]')" v-model.trim="formData.username" />
</ElFormItem>
<ElFormItem prop="password">
<ElInput
:placeholder="$t('login.placeholder[1]')"
v-model.trim="formData.password"
type="password"
show-password
radius="8px"
autocomplete="off"
/>
</ElFormItem>
<div class="drag-verify">
<div class="drag-verify-content" :class="{ error: !isPassing && isClickPass }">
<ArtDragVerify
ref="dragVerify"
v-model:value="isPassing"
:width="width < 500 ? 328 : 438"
:text="$t('login.sliderText')"
textColor="var(--art-gray-800)"
:successText="$t('login.sliderSuccessText')"
:progressBarBg="getCssVar('--el-color-primary')"
background="var(--art-gray-200)"
handlerBg="var(--art-main-bg-color)"
/>
</div>
<p class="error-text" :class="{ 'show-error-text': !isPassing && isClickPass }">{{
$t('login.placeholder[2]')
}}</p>
</div>
<div class="forget-password">
<ElCheckbox v-model="formData.rememberPassword">{{
$t('login.rememberPwd')
}}</ElCheckbox>
<RouterLink :to="RoutesAlias.ForgetPassword">{{ $t('login.forgetPwd') }}</RouterLink>
</div>
<div style="margin-top: 30px">
<ElButton
class="login-btn"
type="primary"
@click="handleSubmit"
:loading="loading"
v-ripple
>
{{ $t('login.btnText') }}
</ElButton>
</div>
<div class="footer">
<p>
{{ $t('login.noAccount') }}
<RouterLink :to="RoutesAlias.Register">{{ $t('login.register') }}</RouterLink>
</p>
</div>
</ElForm>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import AppConfig from '@/config'
import { RoutesAlias } from '@/router/routesAlias'
import { getCssVar } from '@/utils/ui'
import { languageOptions } from '@/locales'
import { LanguageEnum, SystemThemeEnum } from '@/enums/appEnum'
import { useI18n } from 'vue-i18n'
import { useSettingStore } from '@/store/modules/setting'
import { useUserStore } from '@/store/modules/user'
import { useTheme } from '@/composables/useTheme'
import { useLogin } from '@/composables/useLogin'
defineOptions({ name: 'Login' })
const { t, locale } = useI18n()
const settingStore = useSettingStore()
const userStore = useUserStore()
const { isDark, systemThemeType } = storeToRefs(settingStore)
// 使用登录 Composable
const {
formRef,
formData,
rules,
loading,
isPassing,
isClickPass,
mockAccounts,
setupAccount,
handleLogin
} = useLogin()
const dragVerify = ref()
const systemName = AppConfig.systemInfo.name
const { width } = useWindowSize()
// 处理提交
const handleSubmit = async () => {
await handleLogin()
// 重置拖拽验证
if (dragVerify.value) {
dragVerify.value.reset()
}
}
// 切换语言
const changeLanguage = (lang: LanguageEnum) => {
if (locale.value === lang) return
locale.value = lang
userStore.setLanguage(lang)
}
// 切换主题
const toggleTheme = () => {
let { LIGHT, DARK } = SystemThemeEnum
useTheme().switchThemeStyles(systemThemeType.value === LIGHT ? DARK : LIGHT)
}
</script>
<style lang="scss" scoped>
@use './index';
</style>

View File

@@ -0,0 +1,29 @@
.register {
.right-wrap {
.login-wrap {
.form {
.el-form {
margin-top: 20px;
}
.privacy-policy {
margin-top: 15px;
:deep(.el-checkbox__label) {
color: #333 !important;
}
a {
color: var(--main-color);
text-decoration: none;
}
}
.register-btn {
width: 100%;
height: 40px !important;
}
}
}
}
}

View File

@@ -0,0 +1,173 @@
<template>
<div class="login register">
<LoginLeftView></LoginLeftView>
<div class="right-wrap">
<div class="header">
<ArtLogo class="icon" />
<h1>{{ systemName }}</h1>
</div>
<div class="login-wrap">
<div class="form">
<h3 class="title">{{ $t('register.title') }}</h3>
<p class="sub-title">{{ $t('register.subTitle') }}</p>
<ElForm ref="formRef" :model="formData" :rules="rules" label-position="top">
<ElFormItem prop="username">
<ElInput
v-model.trim="formData.username"
:placeholder="$t('register.placeholder[0]')"
/>
</ElFormItem>
<ElFormItem prop="password">
<ElInput
v-model.trim="formData.password"
:placeholder="$t('register.placeholder[1]')"
type="password"
autocomplete="off"
/>
</ElFormItem>
<ElFormItem prop="confirmPassword">
<ElInput
v-model.trim="formData.confirmPassword"
:placeholder="$t('register.placeholder[2]')"
type="password"
autocomplete="off"
@keyup.enter="register"
/>
</ElFormItem>
<ElFormItem prop="agreement">
<ElCheckbox v-model="formData.agreement">
{{ $t('register.agreeText') }}
<router-link
style="color: var(--main-color); text-decoration: none"
to="/privacy-policy"
>{{ $t('register.privacyPolicy') }}</router-link
>
</ElCheckbox>
</ElFormItem>
<div style="margin-top: 15px">
<ElButton
class="register-btn"
type="primary"
@click="register"
:loading="loading"
v-ripple
>
{{ $t('register.submitBtnText') }}
</ElButton>
</div>
<div class="footer">
<p>
{{ $t('register.hasAccount') }}
<router-link :to="RoutesAlias.Login">{{ $t('register.toLogin') }}</router-link>
</p>
</div>
</ElForm>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import AppConfig from '@/config'
import { RoutesAlias } from '@/router/routesAlias'
import { ElMessage } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import { useI18n } from 'vue-i18n'
defineOptions({ name: 'Register' })
const { t } = useI18n()
const router = useRouter()
const formRef = ref<FormInstance>()
const systemName = AppConfig.systemInfo.name
const loading = ref(false)
const formData = reactive({
username: '',
password: '',
confirmPassword: '',
agreement: false
})
const validatePass = (rule: any, value: string, callback: any) => {
if (value === '') {
callback(new Error(t('register.placeholder[1]')))
} else {
if (formData.confirmPassword !== '') {
formRef.value?.validateField('confirmPassword')
}
callback()
}
}
const validatePass2 = (rule: any, value: string, callback: any) => {
if (value === '') {
callback(new Error(t('register.rule[0]')))
} else if (value !== formData.password) {
callback(new Error(t('register.rule[1]')))
} else {
callback()
}
}
const rules = reactive<FormRules>({
username: [
{ required: true, message: t('register.placeholder[0]'), trigger: 'blur' },
{ min: 3, max: 20, message: t('register.rule[2]'), trigger: 'blur' }
],
password: [
{ required: true, validator: validatePass, trigger: 'blur' },
{ min: 6, message: t('register.rule[3]'), trigger: 'blur' }
],
confirmPassword: [{ required: true, validator: validatePass2, trigger: 'blur' }],
agreement: [
{
validator: (rule: any, value: boolean, callback: any) => {
if (!value) {
callback(new Error(t('register.rule[4]')))
} else {
callback()
}
},
trigger: 'change'
}
]
})
const register = async () => {
if (!formRef.value) return
try {
await formRef.value.validate()
loading.value = true
// 模拟注册请求
setTimeout(() => {
loading.value = false
ElMessage.success('注册成功')
toLogin()
}, 1000)
} catch (error) {
console.log('验证失败', error)
}
}
const toLogin = () => {
setTimeout(() => {
router.push(RoutesAlias.Login)
}, 1000)
}
</script>
<style lang="scss" scoped>
@use '../login/index' as login;
@use './index' as register;
</style>

View File

@@ -0,0 +1,546 @@
<template>
<div class="page-content">
<!-- 搜索和操作区 -->
<ElRow :gutter="12">
<ElCol :xs="24" :sm="12" :lg="6">
<ElInput v-model="searchQuery" placeholder="通知标题/内容" clearable />
</ElCol>
<ElCol :xs="24" :sm="12" :lg="6">
<ElSelect v-model="statusFilter" placeholder="状态筛选" clearable style="width: 100%">
<ElOption label="全部" value="" />
<ElOption label="待发送" value="pending" />
<ElOption label="发送中" value="sending" />
<ElOption label="已发送" value="sent" />
<ElOption label="发送失败" value="failed" />
</ElSelect>
</ElCol>
<ElCol :xs="24" :sm="12" :lg="6">
<ElSelect v-model="typeFilter" placeholder="通知类型" clearable style="width: 100%">
<ElOption label="全部" value="" />
<ElOption label="卡片更换" value="replace" />
<ElOption label="卡片激活" value="activate" />
<ElOption label="卡片停用" value="deactivate" />
<ElOption label="套餐变更" value="plan_change" />
</ElSelect>
</ElCol>
<ElCol :xs="24" :sm="12" :lg="6" class="el-col2">
<ElButton v-ripple @click="handleSearch">搜索</ElButton>
<ElButton v-ripple type="primary" @click="showDialog('add')">新增通知</ElButton>
</ElCol>
</ElRow>
<!-- 通知列表 -->
<ArtTable :data="filteredData" index style="margin-top: 20px">
<template #default>
<ElTableColumn type="selection" width="55" />
<ElTableColumn label="通知标题" prop="title" min-width="200" show-overflow-tooltip />
<ElTableColumn label="通知类型" prop="type" width="120">
<template #default="scope">
<ElTag :type="getTypeTagType(scope.row.type)">
{{ getTypeText(scope.row.type) }}
</ElTag>
</template>
</ElTableColumn>
<ElTableColumn label="目标用户数" prop="targetCount" width="120">
<template #default="scope">
<span style="color: var(--el-color-primary)">{{ scope.row.targetCount }}</span>
</template>
</ElTableColumn>
<ElTableColumn label="已发送" prop="sentCount" width="100">
<template #default="scope">
<span style="color: var(--el-color-success)">{{ scope.row.sentCount }}</span>
</template>
</ElTableColumn>
<ElTableColumn label="失败数" prop="failCount" width="100">
<template #default="scope">
<span style="color: var(--el-color-danger)">{{ scope.row.failCount }}</span>
</template>
</ElTableColumn>
<ElTableColumn label="发送进度" prop="progress" width="150">
<template #default="scope">
<ElProgress
:percentage="scope.row.progress"
:status="scope.row.status === 'failed' ? 'exception' : scope.row.status === 'sent' ? 'success' : undefined"
/>
</template>
</ElTableColumn>
<ElTableColumn label="状态" prop="status" width="120">
<template #default="scope">
<ElTag v-if="scope.row.status === 'pending'" type="info">待发送</ElTag>
<ElTag v-else-if="scope.row.status === 'sending'" type="warning">
<el-icon class="is-loading"><Loading /></el-icon>
发送中
</ElTag>
<ElTag v-else-if="scope.row.status === 'sent'" type="success">已发送</ElTag>
<ElTag v-else-if="scope.row.status === 'failed'" type="danger">发送失败</ElTag>
</template>
</ElTableColumn>
<ElTableColumn label="发送方式" prop="sendMethod" width="120">
<template #default="scope">
<div style="display: flex; gap: 4px">
<ElTag v-for="method in scope.row.sendMethods" :key="method" size="small">
{{ getSendMethodText(method) }}
</ElTag>
</div>
</template>
</ElTableColumn>
<ElTableColumn label="创建时间" prop="createTime" width="180" />
<ElTableColumn label="发送时间" prop="sendTime" width="180">
<template #default="scope">
{{ scope.row.sendTime || '-' }}
</template>
</ElTableColumn>
<ElTableColumn fixed="right" label="操作" width="240">
<template #default="scope">
<el-button link :icon="View" @click="viewDetail(scope.row)">查看详情</el-button>
<el-button
v-if="scope.row.status === 'pending'"
link
type="primary"
:icon="Promotion"
@click="handleSend(scope.row)"
>
立即发送
</el-button>
<el-button
v-if="scope.row.status === 'pending'"
link
@click="showDialog('edit', scope.row)"
>
编辑
</el-button>
<el-button
v-if="scope.row.status === 'pending' || scope.row.status === 'failed'"
link
type="danger"
@click="handleDelete(scope.row)"
>
删除
</el-button>
</template>
</ElTableColumn>
</template>
</ArtTable>
<!-- 新增/编辑通知对话框 -->
<ElDialog
v-model="dialogVisible"
:title="dialogType === 'add' ? '新增换卡通知' : '编辑换卡通知'"
width="700px"
align-center
>
<ElForm ref="formRef" :model="form" :rules="rules" label-width="120px">
<ElFormItem label="通知标题" prop="title">
<ElInput v-model="form.title" placeholder="请输入通知标题" maxlength="50" show-word-limit />
</ElFormItem>
<ElFormItem label="通知类型" prop="type">
<ElRadioGroup v-model="form.type">
<ElRadio value="replace">卡片更换</ElRadio>
<ElRadio value="activate">卡片激活</ElRadio>
<ElRadio value="deactivate">卡片停用</ElRadio>
<ElRadio value="plan_change">套餐变更</ElRadio>
</ElRadioGroup>
</ElFormItem>
<ElFormItem label="通知内容" prop="content">
<ElInput
v-model="form.content"
type="textarea"
:rows="4"
placeholder="请输入通知内容"
maxlength="500"
show-word-limit
/>
</ElFormItem>
<ElFormItem label="目标用户" prop="targetType">
<ElRadioGroup v-model="form.targetType">
<ElRadio value="all">全部用户</ElRadio>
<ElRadio value="specific">指定用户</ElRadio>
<ElRadio value="batch">批量导入</ElRadio>
</ElRadioGroup>
</ElFormItem>
<ElFormItem v-if="form.targetType === 'specific'" label="用户列表">
<ElSelect v-model="form.targetUsers" multiple placeholder="请选择目标用户" style="width: 100%">
<ElOption label="张三 (13800138000)" value="user1" />
<ElOption label="李四 (13900139000)" value="user2" />
<ElOption label="王五 (13700137000)" value="user3" />
</ElSelect>
</ElFormItem>
<ElFormItem v-if="form.targetType === 'batch'" label="用户文件">
<ElUpload
:action="uploadUrl"
:limit="1"
:on-exceed="handleExceed"
accept=".xlsx,.xls,.txt"
>
<ElButton type="primary">选择文件</ElButton>
<template #tip>
<div class="el-upload__tip">支持 Excel TXT 格式每行一个用户手机号</div>
</template>
</ElUpload>
</ElFormItem>
<ElFormItem label="发送方式" prop="sendMethods">
<ElCheckboxGroup v-model="form.sendMethods">
<ElCheckbox value="sms">短信</ElCheckbox>
<ElCheckbox value="email">邮件</ElCheckbox>
<ElCheckbox value="app">App推送</ElCheckbox>
</ElCheckboxGroup>
</ElFormItem>
<ElFormItem label="定时发送">
<ElSwitch v-model="form.scheduleSend" />
<span style="margin-left: 8px; color: var(--el-text-color-secondary)">
{{ form.scheduleSend ? '启用定时发送' : '立即发送' }}
</span>
</ElFormItem>
<ElFormItem v-if="form.scheduleSend" label="发送时间" prop="scheduleTime">
<ElDatePicker
v-model="form.scheduleTime"
type="datetime"
placeholder="选择发送时间"
style="width: 100%"
/>
</ElFormItem>
</ElForm>
<template #footer>
<div class="dialog-footer">
<ElButton @click="dialogVisible = false">取消</ElButton>
<ElButton type="primary" @click="handleSubmit(formRef)">
{{ form.scheduleSend ? '创建定时任务' : '保存' }}
</ElButton>
</div>
</template>
</ElDialog>
<!-- 详情对话框 -->
<ElDialog v-model="detailDialogVisible" title="通知详情" width="800px" align-center>
<ElDescriptions :column="2" border>
<ElDescriptionsItem label="通知标题">{{ currentDetail.title }}</ElDescriptionsItem>
<ElDescriptionsItem label="通知类型">
<ElTag :type="getTypeTagType(currentDetail.type)">
{{ getTypeText(currentDetail.type) }}
</ElTag>
</ElDescriptionsItem>
<ElDescriptionsItem label="目标用户数">{{ currentDetail.targetCount }}</ElDescriptionsItem>
<ElDescriptionsItem label="已发送数">
<span style="color: var(--el-color-success)">{{ currentDetail.sentCount }}</span>
</ElDescriptionsItem>
<ElDescriptionsItem label="失败数">
<span style="color: var(--el-color-danger)">{{ currentDetail.failCount }}</span>
</ElDescriptionsItem>
<ElDescriptionsItem label="发送状态">
<ElTag v-if="currentDetail.status === 'sent'" type="success">已发送</ElTag>
<ElTag v-else-if="currentDetail.status === 'sending'" type="warning">发送中</ElTag>
<ElTag v-else-if="currentDetail.status === 'failed'" type="danger">发送失败</ElTag>
<ElTag v-else type="info">待发送</ElTag>
</ElDescriptionsItem>
<ElDescriptionsItem label="发送方式" :span="2">
<ElTag v-for="method in currentDetail.sendMethods" :key="method" size="small" style="margin-right: 4px">
{{ getSendMethodText(method) }}
</ElTag>
</ElDescriptionsItem>
<ElDescriptionsItem label="创建时间">{{ currentDetail.createTime }}</ElDescriptionsItem>
<ElDescriptionsItem label="发送时间">{{ currentDetail.sendTime || '-' }}</ElDescriptionsItem>
<ElDescriptionsItem label="通知内容" :span="2">
{{ currentDetail.content }}
</ElDescriptionsItem>
</ElDescriptions>
<template #footer>
<ElButton @click="detailDialogVisible = false">关闭</ElButton>
</template>
</ElDialog>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { View, Loading, Promotion } from '@element-plus/icons-vue'
import type { FormInstance, FormRules, UploadProps } from 'element-plus'
defineOptions({ name: 'CardChangeNotice' })
interface Notice {
id: string
title: string
type: 'replace' | 'activate' | 'deactivate' | 'plan_change'
content: string
targetCount: number
sentCount: number
failCount: number
progress: number
status: 'pending' | 'sending' | 'sent' | 'failed'
sendMethods: string[]
createTime: string
sendTime?: string
}
const searchQuery = ref('')
const statusFilter = ref('')
const typeFilter = ref('')
const dialogVisible = ref(false)
const detailDialogVisible = ref(false)
const dialogType = ref<'add' | 'edit'>('add')
const formRef = ref<FormInstance>()
const uploadUrl = ref('/api/batch/upload-users')
const form = reactive({
title: '',
type: 'replace',
content: '',
targetType: 'all',
targetUsers: [] as string[],
sendMethods: ['sms'],
scheduleSend: false,
scheduleTime: null as Date | null
})
const rules = reactive<FormRules>({
title: [{ required: true, message: '请输入通知标题', trigger: 'blur' }],
type: [{ required: true, message: '请选择通知类型', trigger: 'change' }],
content: [{ required: true, message: '请输入通知内容', trigger: 'blur' }],
targetType: [{ required: true, message: '请选择目标用户类型', trigger: 'change' }],
sendMethods: [
{
type: 'array',
required: true,
message: '请至少选择一种发送方式',
trigger: 'change'
}
]
})
const mockData = ref<Notice[]>([
{
id: '1',
title: 'SIM卡更换通知-2026年1月批次',
type: 'replace',
content: '尊敬的用户您的物联网卡将于近期进行更换新卡将在3个工作日内寄出请注意查收。',
targetCount: 1000,
sentCount: 980,
failCount: 20,
progress: 100,
status: 'sent',
sendMethods: ['sms', 'app'],
createTime: '2026-01-09 09:00:00',
sendTime: '2026-01-09 10:00:00'
},
{
id: '2',
title: '卡片激活成功通知',
type: 'activate',
content: '恭喜!您的物联网卡已成功激活,现在可以正常使用了。',
targetCount: 500,
sentCount: 350,
failCount: 5,
progress: 71,
status: 'sending',
sendMethods: ['sms', 'email'],
createTime: '2026-01-09 11:30:00'
},
{
id: '3',
title: '套餐变更提醒',
type: 'plan_change',
content: '您好您的套餐将于2026年2月1日起变更为新套餐详情请登录系统查看。',
targetCount: 800,
sentCount: 0,
failCount: 0,
progress: 0,
status: 'pending',
sendMethods: ['sms'],
createTime: '2026-01-09 14:00:00'
}
])
const currentDetail = ref<Notice>({
id: '',
title: '',
type: 'replace',
content: '',
targetCount: 0,
sentCount: 0,
failCount: 0,
progress: 0,
status: 'pending',
sendMethods: [],
createTime: ''
})
const filteredData = computed(() => {
let data = mockData.value
if (searchQuery.value) {
data = data.filter(
(item) => item.title.includes(searchQuery.value) || item.content.includes(searchQuery.value)
)
}
if (statusFilter.value) {
data = data.filter((item) => item.status === statusFilter.value)
}
if (typeFilter.value) {
data = data.filter((item) => item.type === typeFilter.value)
}
return data
})
const getTypeText = (type: string) => {
const map: Record<string, string> = {
replace: '卡片更换',
activate: '卡片激活',
deactivate: '卡片停用',
plan_change: '套餐变更'
}
return map[type] || '未知'
}
const getTypeTagType = (type: string) => {
const map: Record<string, any> = {
replace: 'warning',
activate: 'success',
deactivate: 'danger',
plan_change: 'primary'
}
return map[type] || ''
}
const getSendMethodText = (method: string) => {
const map: Record<string, string> = {
sms: '短信',
email: '邮件',
app: 'App'
}
return map[method] || method
}
const handleSearch = () => {}
const showDialog = (type: 'add' | 'edit', row?: Notice) => {
dialogType.value = type
dialogVisible.value = true
if (type === 'edit' && row) {
Object.assign(form, {
title: row.title,
type: row.type,
content: row.content,
targetType: 'all',
sendMethods: row.sendMethods,
scheduleSend: false,
scheduleTime: null
})
} else {
Object.assign(form, {
title: '',
type: 'replace',
content: '',
targetType: 'all',
targetUsers: [],
sendMethods: ['sms'],
scheduleSend: false,
scheduleTime: null
})
}
}
const handleSubmit = async (formEl: FormInstance | undefined) => {
if (!formEl) return
await formEl.validate((valid) => {
if (valid) {
if (dialogType.value === 'add') {
mockData.value.unshift({
id: Date.now().toString(),
title: form.title,
type: form.type as any,
content: form.content,
targetCount: form.targetType === 'all' ? 1000 : form.targetUsers.length,
sentCount: 0,
failCount: 0,
progress: 0,
status: 'pending',
sendMethods: form.sendMethods,
createTime: new Date().toLocaleString('zh-CN')
})
ElMessage.success('通知创建成功')
} else {
ElMessage.success('通知更新成功')
}
dialogVisible.value = false
formEl.resetFields()
}
})
}
const handleSend = (row: Notice) => {
ElMessageBox.confirm(`确定立即发送通知"${row.title}"吗?`, '发送确认', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
row.status = 'sending'
ElMessage.success('通知发送中...')
// 模拟发送进度
const timer = setInterval(() => {
if (row.progress < 100) {
row.progress += 10
row.sentCount = Math.floor((row.targetCount * row.progress) / 100)
} else {
clearInterval(timer)
row.status = 'sent'
row.sendTime = new Date().toLocaleString('zh-CN')
ElMessage.success('通知发送完成')
}
}, 500)
})
}
const handleDelete = (row: Notice) => {
ElMessageBox.confirm('确定删除该通知吗?', '删除确认', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'error'
}).then(() => {
const index = mockData.value.findIndex((item) => item.id === row.id)
if (index !== -1) mockData.value.splice(index, 1)
ElMessage.success('删除成功')
})
}
const viewDetail = (row: Notice) => {
currentDetail.value = { ...row }
detailDialogVisible.value = true
}
const handleExceed: UploadProps['onExceed'] = () => {
ElMessage.warning('最多只能上传1个文件')
}
</script>
<style lang="scss" scoped>
.page-content {
:deep(.is-loading) {
animation: rotating 2s linear infinite;
margin-right: 4px;
}
@keyframes rotating {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
}
</style>

View File

@@ -0,0 +1,480 @@
<template>
<div class="page-content">
<!-- 导入操作区 -->
<ElCard shadow="never">
<template #header>
<span style="font-weight: 500">批量导入设备</span>
</template>
<ElRow :gutter="20">
<ElCol :xs="24" :lg="12">
<ElAlert type="info" :closable="false" style="margin-bottom: 20px">
<template #title>
<div style="line-height: 1.8">
<p><strong>导入说明</strong></p>
<p>1. 请先下载模板文件按照模板格式填写设备信息</p>
<p>2. 支持 Excel 格式.xlsx, .xls单次最多导入 500 </p>
<p>3. 必填字段设备编号设备名称设备类型ICCID绑定网卡</p>
<p>4. ICCID 必须在系统中已存在否则导入失败</p>
<p>5. 设备编号重复将自动跳过</p>
</div>
</template>
</ElAlert>
<ElButton type="primary" :icon="Download" @click="downloadTemplate">下载导入模板</ElButton>
</ElCol>
<ElCol :xs="24" :lg="12">
<div class="upload-area">
<ElUpload
ref="uploadRef"
drag
:action="uploadUrl"
:on-change="handleFileChange"
:on-success="handleUploadSuccess"
:on-error="handleUploadError"
:auto-upload="false"
:limit="1"
accept=".xlsx,.xls"
>
<el-icon class="el-icon--upload"><upload-filled /></el-icon>
<div class="el-upload__text">将文件拖到此处<em>点击上传</em></div>
<template #tip>
<div class="el-upload__tip">只能上传 xlsx/xls 文件且不超过 5MB</div>
</template>
</ElUpload>
<div style="margin-top: 16px; text-align: center">
<ElButton type="success" :loading="uploading" :disabled="!fileList.length" @click="submitUpload">
开始导入
</ElButton>
<ElButton @click="clearFiles">清空</ElButton>
</div>
</div>
</ElCol>
</ElRow>
</ElCard>
<!-- 导入统计 -->
<ElRow :gutter="20" style="margin-top: 20px">
<ElCol :xs="24" :sm="12" :lg="6">
<ElCard shadow="hover" class="stat-card">
<div class="stat-content">
<div class="stat-label">今日导入</div>
<div class="stat-value">1,250</div>
</div>
<el-icon class="stat-icon" style="color: var(--el-color-primary)"><Upload /></el-icon>
</ElCard>
</ElCol>
<ElCol :xs="24" :sm="12" :lg="6">
<ElCard shadow="hover" class="stat-card">
<div class="stat-content">
<div class="stat-label">成功绑定</div>
<div class="stat-value" style="color: var(--el-color-success)">1,180</div>
</div>
<el-icon class="stat-icon" style="color: var(--el-color-success)"><SuccessFilled /></el-icon>
</ElCard>
</ElCol>
<ElCol :xs="24" :sm="12" :lg="6">
<ElCard shadow="hover" class="stat-card">
<div class="stat-content">
<div class="stat-label">导入失败</div>
<div class="stat-value" style="color: var(--el-color-danger)">70</div>
</div>
<el-icon class="stat-icon" style="color: var(--el-color-danger)"><CircleCloseFilled /></el-icon>
</ElCard>
</ElCol>
<ElCol :xs="24" :sm="12" :lg="6">
<ElCard shadow="hover" class="stat-card">
<div class="stat-content">
<div class="stat-label">成功率</div>
<div class="stat-value">94.4%</div>
</div>
<el-icon class="stat-icon" style="color: var(--el-color-warning)"><TrendCharts /></el-icon>
</ElCard>
</ElCol>
</ElRow>
<!-- 导入记录 -->
<ElCard shadow="never" style="margin-top: 20px">
<template #header>
<div style="display: flex; justify-content: space-between; align-items: center">
<span style="font-weight: 500">导入记录</span>
<div>
<ElSelect v-model="statusFilter" placeholder="状态筛选" style="width: 120px; margin-right: 12px" clearable>
<ElOption label="全部" value="" />
<ElOption label="处理中" value="processing" />
<ElOption label="完成" value="success" />
<ElOption label="失败" value="failed" />
</ElSelect>
<ElButton size="small" @click="refreshList">刷新</ElButton>
</div>
</div>
</template>
<ArtTable :data="filteredRecords" index>
<template #default>
<ElTableColumn label="导入批次号" prop="batchNo" width="180" />
<ElTableColumn label="文件名" prop="fileName" min-width="200" show-overflow-tooltip />
<ElTableColumn label="设备总数" prop="totalCount" width="100" />
<ElTableColumn label="成功数" prop="successCount" width="100">
<template #default="scope">
<span style="color: var(--el-color-success)">{{ scope.row.successCount }}</span>
</template>
</ElTableColumn>
<ElTableColumn label="失败数" prop="failCount" width="100">
<template #default="scope">
<span style="color: var(--el-color-danger)">{{ scope.row.failCount }}</span>
</template>
</ElTableColumn>
<ElTableColumn label="已绑定ICCID" prop="bindCount" width="120">
<template #default="scope">
<ElTag type="success" size="small">{{ scope.row.bindCount }}</ElTag>
</template>
</ElTableColumn>
<ElTableColumn label="导入状态" prop="status" width="120">
<template #default="scope">
<ElTag v-if="scope.row.status === 'processing'" type="warning">
<el-icon class="is-loading"><Loading /></el-icon>
处理中
</ElTag>
<ElTag v-else-if="scope.row.status === 'success'" type="success">完成</ElTag>
<ElTag v-else-if="scope.row.status === 'failed'" type="danger">失败</ElTag>
<ElTag v-else type="info">待处理</ElTag>
</template>
</ElTableColumn>
<ElTableColumn label="导入进度" prop="progress" width="150">
<template #default="scope">
<ElProgress
:percentage="scope.row.progress"
:status="scope.row.status === 'failed' ? 'exception' : undefined"
/>
</template>
</ElTableColumn>
<ElTableColumn label="导入时间" prop="importTime" width="180" />
<ElTableColumn label="操作人" prop="operator" width="120" />
<ElTableColumn fixed="right" label="操作" width="200">
<template #default="scope">
<el-button link :icon="View" @click="viewDetail(scope.row)">查看详情</el-button>
<el-button
v-if="scope.row.failCount > 0"
link
type="primary"
:icon="Download"
@click="downloadFailData(scope.row)"
>
失败数据
</el-button>
</template>
</ElTableColumn>
</template>
</ArtTable>
</ElCard>
<!-- 导入详情对话框 -->
<ElDialog v-model="detailDialogVisible" title="设备导入详情" width="900px" align-center>
<ElDescriptions :column="2" border>
<ElDescriptionsItem label="批次号">{{ currentDetail.batchNo }}</ElDescriptionsItem>
<ElDescriptionsItem label="文件名">{{ currentDetail.fileName }}</ElDescriptionsItem>
<ElDescriptionsItem label="设备总数">{{ currentDetail.totalCount }}</ElDescriptionsItem>
<ElDescriptionsItem label="成功导入">
<span style="color: var(--el-color-success)">{{ currentDetail.successCount }}</span>
</ElDescriptionsItem>
<ElDescriptionsItem label="导入失败">
<span style="color: var(--el-color-danger)">{{ currentDetail.failCount }}</span>
</ElDescriptionsItem>
<ElDescriptionsItem label="已绑定ICCID">
<ElTag type="success">{{ currentDetail.bindCount }}</ElTag>
</ElDescriptionsItem>
<ElDescriptionsItem label="导入时间">{{ currentDetail.importTime }}</ElDescriptionsItem>
<ElDescriptionsItem label="操作人">{{ currentDetail.operator }}</ElDescriptionsItem>
</ElDescriptions>
<ElDivider content-position="left">失败明细</ElDivider>
<div v-if="currentDetail.failReasons && currentDetail.failReasons.length" style="max-height: 300px; overflow-y: auto">
<ElTable :data="currentDetail.failReasons" border size="small">
<ElTableColumn label="行号" prop="row" width="80" />
<ElTableColumn label="设备编号" prop="deviceCode" width="150" />
<ElTableColumn label="ICCID" prop="iccid" width="200" />
<ElTableColumn label="失败原因" prop="message" min-width="200" />
</ElTable>
</div>
<ElEmpty v-else description="无失败记录" />
<template #footer>
<ElButton @click="detailDialogVisible = false">关闭</ElButton>
<ElButton v-if="currentDetail.failCount > 0" type="primary" :icon="Download" @click="downloadFailData(currentDetail)">
下载失败数据
</ElButton>
</template>
</ElDialog>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { ElMessage } from 'element-plus'
import {
Download,
UploadFilled,
View,
Loading,
Upload,
SuccessFilled,
CircleCloseFilled,
TrendCharts
} from '@element-plus/icons-vue'
import type { UploadInstance, UploadRawFile } from 'element-plus'
defineOptions({ name: 'DeviceImport' })
interface FailReason {
row: number
deviceCode: string
iccid: string
message: string
}
interface ImportRecord {
id: string
batchNo: string
fileName: string
totalCount: number
successCount: number
failCount: number
bindCount: number
status: 'pending' | 'processing' | 'success' | 'failed'
progress: number
importTime: string
operator: string
failReasons?: FailReason[]
}
const uploadRef = ref<UploadInstance>()
const uploadUrl = ref('/api/batch/device-import')
const fileList = ref<UploadRawFile[]>([])
const uploading = ref(false)
const detailDialogVisible = ref(false)
const statusFilter = ref('')
const importRecords = ref<ImportRecord[]>([
{
id: '1',
batchNo: 'DEV20260109001',
fileName: '设备导入模板_20260109.xlsx',
totalCount: 300,
successCount: 285,
failCount: 15,
bindCount: 285,
status: 'success',
progress: 100,
importTime: '2026-01-09 11:30:00',
operator: 'admin',
failReasons: [
{ row: 12, deviceCode: 'DEV001', iccid: '89860123456789012345', message: 'ICCID 不存在' },
{ row: 23, deviceCode: 'DEV002', iccid: '89860123456789012346', message: '设备编号已存在' },
{ row: 45, deviceCode: '', iccid: '89860123456789012347', message: '设备编号为空' },
{ row: 67, deviceCode: 'DEV003', iccid: '', message: 'ICCID 为空' },
{ row: 89, deviceCode: 'DEV004', iccid: '89860123456789012348', message: '设备类型不存在' }
]
},
{
id: '2',
batchNo: 'DEV20260108001',
fileName: '智能水表设备批量导入.xlsx',
totalCount: 150,
successCount: 150,
failCount: 0,
bindCount: 150,
status: 'success',
progress: 100,
importTime: '2026-01-08 14:20:00',
operator: 'admin'
},
{
id: '3',
batchNo: 'DEV20260107001',
fileName: 'GPS定位器导入.xlsx',
totalCount: 200,
successCount: 180,
failCount: 20,
bindCount: 180,
status: 'success',
progress: 100,
importTime: '2026-01-07 10:15:00',
operator: 'operator01',
failReasons: [
{ row: 10, deviceCode: 'GPS001', iccid: '89860123456789012349', message: 'ICCID 已被其他设备绑定' },
{ row: 20, deviceCode: 'GPS002', iccid: '89860123456789012350', message: 'ICCID 状态异常' }
]
}
])
const currentDetail = ref<ImportRecord>({
id: '',
batchNo: '',
fileName: '',
totalCount: 0,
successCount: 0,
failCount: 0,
bindCount: 0,
status: 'pending',
progress: 0,
importTime: '',
operator: ''
})
const filteredRecords = computed(() => {
if (!statusFilter.value) return importRecords.value
return importRecords.value.filter((item) => item.status === statusFilter.value)
})
const downloadTemplate = () => {
ElMessage.success('模板下载中...')
setTimeout(() => {
ElMessage.success('设备导入模板下载成功')
}, 1000)
}
const handleFileChange = (file: any, files: any[]) => {
fileList.value = files
}
const clearFiles = () => {
uploadRef.value?.clearFiles()
fileList.value = []
}
const submitUpload = async () => {
if (!fileList.value.length) {
ElMessage.warning('请先选择文件')
return
}
uploading.value = true
ElMessage.info('正在导入设备并绑定ICCID请稍候...')
// 模拟上传和导入过程
setTimeout(() => {
const newRecord: ImportRecord = {
id: Date.now().toString(),
batchNo: `DEV${new Date().getTime()}`,
fileName: fileList.value[0].name,
totalCount: 100,
successCount: 95,
failCount: 5,
bindCount: 95,
status: 'success',
progress: 100,
importTime: new Date().toLocaleString('zh-CN'),
operator: 'admin',
failReasons: [
{ row: 12, deviceCode: 'TEST001', iccid: '89860123456789012351', message: 'ICCID 不存在' },
{ row: 34, deviceCode: 'TEST002', iccid: '89860123456789012352', message: '设备类型无效' }
]
}
importRecords.value.unshift(newRecord)
uploading.value = false
clearFiles()
ElMessage.success(
`导入完成!成功 ${newRecord.successCount} 条,失败 ${newRecord.failCount} 条,已绑定 ${newRecord.bindCount} 个ICCID`
)
}, 2000)
}
const handleUploadSuccess = () => {
ElMessage.success('上传成功')
}
const handleUploadError = () => {
uploading.value = false
ElMessage.error('上传失败')
}
const refreshList = () => {
ElMessage.success('刷新成功')
}
const viewDetail = (row: ImportRecord) => {
currentDetail.value = { ...row }
detailDialogVisible.value = true
}
const downloadFailData = (row: ImportRecord) => {
ElMessage.info(`正在下载批次 ${row.batchNo} 的失败数据...`)
setTimeout(() => {
ElMessage.success('失败数据下载完成')
}, 1000)
}
</script>
<style lang="scss" scoped>
.page-content {
.upload-area {
:deep(.el-upload) {
width: 100%;
}
:deep(.el-upload-dragger) {
width: 100%;
}
}
:deep(.el-icon--upload) {
font-size: 67px;
color: var(--el-text-color-placeholder);
margin-bottom: 16px;
}
:deep(.el-upload__text) {
color: var(--el-text-color-regular);
font-size: 14px;
em {
color: var(--el-color-primary);
font-style: normal;
}
}
:deep(.is-loading) {
animation: rotating 2s linear infinite;
}
@keyframes rotating {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
.stat-card {
:deep(.el-card__body) {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20px;
}
.stat-content {
.stat-label {
font-size: 14px;
color: var(--el-text-color-secondary);
margin-bottom: 8px;
}
.stat-value {
font-size: 24px;
font-weight: 600;
color: var(--el-text-color-primary);
}
}
.stat-icon {
font-size: 40px;
opacity: 0.6;
}
}
}
</style>

View File

@@ -0,0 +1,366 @@
<template>
<div class="page-content">
<!-- 导入操作区 -->
<ElCard shadow="never">
<template #header>
<span style="font-weight: 500">批量导入网卡</span>
</template>
<ElRow :gutter="20">
<ElCol :xs="24" :lg="12">
<ElAlert type="info" :closable="false" style="margin-bottom: 20px">
<template #title>
<div style="line-height: 1.8">
<p><strong>导入说明</strong></p>
<p>1. 请先下载模板文件按照模板格式填写网卡信息</p>
<p>2. 支持 Excel 格式.xlsx, .xls单次最多导入 1000 </p>
<p>3. 必填字段ICCID运营商套餐类型流量规格</p>
<p>4. 导入后系统将自动校验数据重复 ICCID 将跳过</p>
</div>
</template>
</ElAlert>
<ElButton type="primary" :icon="Download" @click="downloadTemplate">下载导入模板</ElButton>
</ElCol>
<ElCol :xs="24" :lg="12">
<div class="upload-area">
<ElUpload
ref="uploadRef"
drag
:action="uploadUrl"
:on-change="handleFileChange"
:on-success="handleUploadSuccess"
:on-error="handleUploadError"
:auto-upload="false"
:limit="1"
accept=".xlsx,.xls"
>
<el-icon class="el-icon--upload"><upload-filled /></el-icon>
<div class="el-upload__text">将文件拖到此处<em>点击上传</em></div>
<template #tip>
<div class="el-upload__tip">只能上传 xlsx/xls 文件且不超过 5MB</div>
</template>
</ElUpload>
<div style="margin-top: 16px; text-align: center">
<ElButton type="success" :loading="uploading" :disabled="!fileList.length" @click="submitUpload">
开始导入
</ElButton>
<ElButton @click="clearFiles">清空</ElButton>
</div>
</div>
</ElCol>
</ElRow>
</ElCard>
<!-- 导入记录 -->
<ElCard shadow="never" style="margin-top: 20px">
<template #header>
<div style="display: flex; justify-content: space-between; align-items: center">
<span style="font-weight: 500">导入记录</span>
<ElButton size="small" @click="refreshList">刷新</ElButton>
</div>
</template>
<ArtTable :data="importRecords" index>
<template #default>
<ElTableColumn label="导入批次号" prop="batchNo" width="180" />
<ElTableColumn label="文件名" prop="fileName" min-width="200" show-overflow-tooltip />
<ElTableColumn label="总条数" prop="totalCount" width="100" />
<ElTableColumn label="成功数" prop="successCount" width="100">
<template #default="scope">
<span style="color: var(--el-color-success)">{{ scope.row.successCount }}</span>
</template>
</ElTableColumn>
<ElTableColumn label="失败数" prop="failCount" width="100">
<template #default="scope">
<span style="color: var(--el-color-danger)">{{ scope.row.failCount }}</span>
</template>
</ElTableColumn>
<ElTableColumn label="导入状态" prop="status" width="120">
<template #default="scope">
<ElTag v-if="scope.row.status === 'processing'" type="warning">
<el-icon class="is-loading"><Loading /></el-icon>
处理中
</ElTag>
<ElTag v-else-if="scope.row.status === 'success'" type="success">完成</ElTag>
<ElTag v-else-if="scope.row.status === 'failed'" type="danger">失败</ElTag>
<ElTag v-else type="info">待处理</ElTag>
</template>
</ElTableColumn>
<ElTableColumn label="导入进度" prop="progress" width="150">
<template #default="scope">
<ElProgress
:percentage="scope.row.progress"
:status="scope.row.status === 'failed' ? 'exception' : undefined"
/>
</template>
</ElTableColumn>
<ElTableColumn label="导入时间" prop="importTime" width="180" />
<ElTableColumn label="操作人" prop="operator" width="120" />
<ElTableColumn fixed="right" label="操作" width="180">
<template #default="scope">
<el-button link :icon="View" @click="viewDetail(scope.row)">查看详情</el-button>
<el-button
v-if="scope.row.failCount > 0"
link
type="primary"
:icon="Download"
@click="downloadFailData(scope.row)"
>
下载失败数据
</el-button>
</template>
</ElTableColumn>
</template>
</ArtTable>
</ElCard>
<!-- 导入详情对话框 -->
<ElDialog v-model="detailDialogVisible" title="导入详情" width="800px" align-center>
<ElDescriptions :column="2" border>
<ElDescriptionsItem label="批次号">{{ currentDetail.batchNo }}</ElDescriptionsItem>
<ElDescriptionsItem label="文件名">{{ currentDetail.fileName }}</ElDescriptionsItem>
<ElDescriptionsItem label="总条数">{{ currentDetail.totalCount }}</ElDescriptionsItem>
<ElDescriptionsItem label="成功数">
<span style="color: var(--el-color-success)">{{ currentDetail.successCount }}</span>
</ElDescriptionsItem>
<ElDescriptionsItem label="失败数">
<span style="color: var(--el-color-danger)">{{ currentDetail.failCount }}</span>
</ElDescriptionsItem>
<ElDescriptionsItem label="跳过数">{{ currentDetail.skipCount || 0 }}</ElDescriptionsItem>
<ElDescriptionsItem label="导入时间">{{ currentDetail.importTime }}</ElDescriptionsItem>
<ElDescriptionsItem label="操作人">{{ currentDetail.operator }}</ElDescriptionsItem>
<ElDescriptionsItem label="失败原因" :span="2">
<div v-if="currentDetail.failReasons && currentDetail.failReasons.length">
<div v-for="(reason, index) in currentDetail.failReasons" :key="index" style="margin-bottom: 4px">
<ElTag type="danger" size="small">{{ reason.row }}</ElTag>
{{ reason.message }}
</div>
</div>
<span v-else style="color: var(--el-text-color-secondary)"></span>
</ElDescriptionsItem>
</ElDescriptions>
<template #footer>
<ElButton @click="detailDialogVisible = false">关闭</ElButton>
</template>
</ElDialog>
</div>
</template>
<script setup lang="ts">
import { ElMessage } from 'element-plus'
import { Download, UploadFilled, View, Loading } from '@element-plus/icons-vue'
import type { UploadInstance, UploadRawFile } from 'element-plus'
defineOptions({ name: 'SimImport' })
interface ImportRecord {
id: string
batchNo: string
fileName: string
totalCount: number
successCount: number
failCount: number
skipCount?: number
status: 'pending' | 'processing' | 'success' | 'failed'
progress: number
importTime: string
operator: string
failReasons?: Array<{ row: number; message: string }>
}
const uploadRef = ref<UploadInstance>()
const uploadUrl = ref('/api/batch/sim-import')
const fileList = ref<UploadRawFile[]>([])
const uploading = ref(false)
const detailDialogVisible = ref(false)
const importRecords = ref<ImportRecord[]>([
{
id: '1',
batchNo: 'IMP20260109001',
fileName: '网卡导入模板_20260109.xlsx',
totalCount: 500,
successCount: 495,
failCount: 5,
skipCount: 0,
status: 'success',
progress: 100,
importTime: '2026-01-09 10:30:00',
operator: 'admin',
failReasons: [
{ row: 23, message: 'ICCID 格式错误' },
{ row: 45, message: 'ICCID 已存在' },
{ row: 67, message: '套餐类型不存在' },
{ row: 89, message: '流量规格格式错误' },
{ row: 123, message: '运营商代码无效' }
]
},
{
id: '2',
batchNo: 'IMP20260108001',
fileName: '网卡批量导入.xlsx',
totalCount: 1000,
successCount: 1000,
failCount: 0,
skipCount: 0,
status: 'success',
progress: 100,
importTime: '2026-01-08 15:20:00',
operator: 'admin'
},
{
id: '3',
batchNo: 'IMP20260107001',
fileName: '测试数据.xlsx',
totalCount: 200,
successCount: 150,
failCount: 50,
skipCount: 0,
status: 'success',
progress: 100,
importTime: '2026-01-07 09:15:00',
operator: 'operator01',
failReasons: [
{ row: 10, message: 'ICCID 重复' },
{ row: 20, message: '运营商字段为空' }
]
}
])
const currentDetail = ref<ImportRecord>({
id: '',
batchNo: '',
fileName: '',
totalCount: 0,
successCount: 0,
failCount: 0,
status: 'pending',
progress: 0,
importTime: '',
operator: ''
})
const downloadTemplate = () => {
ElMessage.success('模板下载中...')
// 实际项目中应该调用下载接口
setTimeout(() => {
ElMessage.success('模板下载成功')
}, 1000)
}
const handleFileChange = (file: any, files: any[]) => {
fileList.value = files
}
const clearFiles = () => {
uploadRef.value?.clearFiles()
fileList.value = []
}
const submitUpload = async () => {
if (!fileList.value.length) {
ElMessage.warning('请先选择文件')
return
}
uploading.value = true
ElMessage.info('正在导入,请稍候...')
// 模拟上传和导入过程
setTimeout(() => {
const newRecord: ImportRecord = {
id: Date.now().toString(),
batchNo: `IMP${new Date().getTime()}`,
fileName: fileList.value[0].name,
totalCount: 300,
successCount: 295,
failCount: 5,
skipCount: 0,
status: 'success',
progress: 100,
importTime: new Date().toLocaleString('zh-CN'),
operator: 'admin',
failReasons: [
{ row: 12, message: 'ICCID 格式错误' },
{ row: 34, message: 'ICCID 已存在' }
]
}
importRecords.value.unshift(newRecord)
uploading.value = false
clearFiles()
ElMessage.success(`导入完成!成功 ${newRecord.successCount} 条,失败 ${newRecord.failCount}`)
}, 2000)
}
const handleUploadSuccess = () => {
ElMessage.success('上传成功')
}
const handleUploadError = () => {
uploading.value = false
ElMessage.error('上传失败')
}
const refreshList = () => {
ElMessage.success('刷新成功')
}
const viewDetail = (row: ImportRecord) => {
currentDetail.value = { ...row }
detailDialogVisible.value = true
}
const downloadFailData = (row: ImportRecord) => {
ElMessage.info(`正在下载批次 ${row.batchNo} 的失败数据...`)
setTimeout(() => {
ElMessage.success('下载完成')
}, 1000)
}
</script>
<style lang="scss" scoped>
.page-content {
.upload-area {
:deep(.el-upload) {
width: 100%;
}
:deep(.el-upload-dragger) {
width: 100%;
}
}
:deep(.el-icon--upload) {
font-size: 67px;
color: var(--el-text-color-placeholder);
margin-bottom: 16px;
}
:deep(.el-upload__text) {
color: var(--el-text-color-regular);
font-size: 14px;
em {
color: var(--el-color-primary);
font-style: normal;
}
}
:deep(.is-loading) {
animation: rotating 2s linear infinite;
}
@keyframes rotating {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
}
</style>

View File

@@ -0,0 +1,383 @@
<template>
<ArtTableFullScreen>
<div class="card-template-page" id="table-full-screen">
<!-- 搜索栏 -->
<ArtSearchBar
v-model:filter="formFilters"
:items="formItems"
@reset="handleReset"
@search="handleSearch"
></ArtSearchBar>
<ElCard shadow="never" class="art-table-card">
<!-- 表格头部 -->
<ArtTableHeader
:columnList="columnOptions"
v-model:columns="columnChecks"
@refresh="handleRefresh"
>
<template #left>
<ElButton type="primary" @click="showDialog('add')">新增</ElButton>
<ElButton @click="batchOperation" :disabled="selectedRows.length === 0"
>批量操作</ElButton
>
</template>
</ArtTableHeader>
<!-- 表格 -->
<ArtTable
ref="tableRef"
row-key="id"
:loading="loading"
:data="tableData"
:currentPage="pagination.currentPage"
:pageSize="pagination.pageSize"
:total="pagination.total"
:marginTop="10"
@selection-change="handleSelectionChange"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
>
<template #default>
<ElTableColumn v-for="col in columns" :key="col.prop || col.type" v-bind="col" />
</template>
</ArtTable>
<!-- 操作对话框 -->
<ElDialog
v-model="dialogVisible"
:title="dialogType === 'add' ? '新增' : '编辑'"
width="400px"
align-center
>
<ElForm ref="formRef" :model="formData" :rules="rules" label-width="100px">
<ElFormItem label="名称" prop="name">
<ElInput v-model="formData.name" placeholder="请输入名称" />
</ElFormItem>
<ElFormItem label="描述" prop="description">
<ElInput
v-model="formData.description"
type="textarea"
placeholder="请输入描述"
:rows="3"
/>
</ElFormItem>
</ElForm>
<template #footer>
<div class="dialog-footer">
<ElButton @click="dialogVisible = false">取消</ElButton>
<ElButton type="primary" @click="handleSubmit">提交</ElButton>
</div>
</template>
</ElDialog>
</ElCard>
</div>
</ArtTableFullScreen>
</template>
<script setup lang="ts">
import { h } from 'vue'
import { ElTag, ElMessageBox, ElMessage, FormInstance } from 'element-plus'
import type { FormRules } from 'element-plus'
import { useCheckedColumns } from '@/composables/useCheckedColumns'
import ArtButtonTable from '@/components/core/forms/ArtButtonTable.vue'
import { SearchChangeParams, SearchFormItem } from '@/types'
defineOptions({ name: 'CardTemplate' })
const dialogType = ref('add')
const dialogVisible = ref(false)
const loading = ref(false)
// 定义表单搜索初始值
const initialSearchState = {
name: '',
status: ''
}
// 响应式表单数据
const formFilters = reactive({ ...initialSearchState })
const pagination = reactive({
currentPage: 1,
pageSize: 20,
total: 0
})
// 表格数据
const tableData = ref<any[]>([])
// 表格实例引用
const tableRef = ref()
// 选中的行数据
const selectedRows = ref<any[]>([])
// 模拟数据
const mockData = [
{
id: 1,
name: '示例项目1',
description: '这是一个示例项目',
status: '1',
statusName: '正常',
createTime: '2024-11-07 10:00:00'
},
{
id: 2,
name: '示例项目2',
description: '这是另一个示例项目',
status: '2',
statusName: '禁用',
createTime: '2024-11-06 15:30:00'
}
]
// 重置表单
const handleReset = () => {
Object.assign(formFilters, { ...initialSearchState })
pagination.currentPage = 1
getDataList()
}
// 搜索处理
const handleSearch = () => {
console.log('搜索参数:', formFilters)
pagination.currentPage = 1
getDataList()
}
// 表单项变更处理
const handleFormChange = (params: SearchChangeParams): void => {
console.log('表单项变更:', params)
}
// 表单配置项
const formItems: SearchFormItem[] = [
{
label: '名称',
prop: 'name',
type: 'input',
config: {
clearable: true,
placeholder: '请输入名称'
},
onChange: handleFormChange
},
{
label: '状态',
prop: 'status',
type: 'select',
config: {
clearable: true,
placeholder: '请选择状态'
},
options: () => [
{ label: '正常', value: '1' },
{ label: '禁用', value: '2' }
],
onChange: handleFormChange
}
]
// 列配置
const columnOptions = [
{ label: '勾选', type: 'selection' },
{ label: 'ID', prop: 'id' },
{ label: '名称', prop: 'name' },
{ label: '描述', prop: 'description' },
{ label: '状态', prop: 'status' },
{ label: '创建时间', prop: 'createTime' },
{ label: '操作', prop: 'operation' }
]
// 获取标签类型
const getTagType = (status: string) => {
switch (status) {
case '1':
return 'success'
case '2':
return 'danger'
default:
return 'info'
}
}
// 构建标签文本
const buildTagText = (status: string) => {
switch (status) {
case '1':
return '正常'
case '2':
return '禁用'
default:
return '未知'
}
}
// 显示对话框
const showDialog = (type: string, row?: any) => {
dialogVisible.value = true
dialogType.value = type
if (formRef.value) {
formRef.value.resetFields()
}
if (type === 'edit' && row) {
formData.name = row.name
formData.description = row.description
} else {
formData.name = ''
formData.description = ''
}
}
// 删除操作
const deleteItem = () => {
ElMessageBox.confirm('确定要删除该项目吗?', '删除确认', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'error'
}).then(() => {
ElMessage.success('删除成功')
getDataList()
})
}
// 批量操作
const batchOperation = () => {
if (selectedRows.value.length === 0) {
ElMessage.warning('请先选择要操作的项目')
return
}
ElMessage.info(`已选择 ${selectedRows.value.length} 个项目`)
}
// 动态列配置
const { columnChecks, columns } = useCheckedColumns(() => [
{ type: 'selection' },
{
prop: 'id',
label: 'ID',
width: 80
},
{
prop: 'name',
label: '名称',
minWidth: 120
},
{
prop: 'description',
label: '描述',
minWidth: 200
},
{
prop: 'status',
label: '状态',
width: 100,
formatter: (row) => {
return h(ElTag, { type: getTagType(row.status) }, () => buildTagText(row.status))
}
},
{
prop: 'createTime',
label: '创建时间',
width: 160
},
{
prop: 'operation',
label: '操作',
width: 150,
formatter: (row: any) => {
return h('div', [
h(ArtButtonTable, {
type: 'edit',
onClick: () => showDialog('edit', row)
}),
h(ArtButtonTable, {
type: 'delete',
onClick: () => deleteItem()
})
])
}
}
])
// 表单实例
const formRef = ref<FormInstance>()
// 表单数据
const formData = reactive({
name: '',
description: ''
})
onMounted(() => {
getDataList()
})
// 获取数据列表
const getDataList = async () => {
loading.value = true
try {
// 模拟API调用
await new Promise((resolve) => setTimeout(resolve, 500))
const startIndex = (pagination.currentPage - 1) * pagination.pageSize
const endIndex = startIndex + pagination.pageSize
const paginatedData = mockData.slice(startIndex, endIndex)
tableData.value = paginatedData
pagination.total = mockData.length
loading.value = false
} catch (error) {
console.error('获取数据列表失败:', error)
loading.value = false
}
}
const handleRefresh = () => {
getDataList()
}
// 处理表格行选择变化
const handleSelectionChange = (selection: any[]) => {
selectedRows.value = selection
}
// 表单验证规则
const rules = reactive<FormRules>({
name: [{ required: true, message: '请输入名称', trigger: 'blur' }]
})
// 提交表单
const handleSubmit = async () => {
if (!formRef.value) return
await formRef.value.validate((valid) => {
if (valid) {
ElMessage.success(dialogType.value === 'add' ? '添加成功' : '更新成功')
dialogVisible.value = false
getDataList()
}
})
}
// 处理表格分页变化
const handleSizeChange = (newPageSize: number) => {
pagination.pageSize = newPageSize
getDataList()
}
const handleCurrentChange = (newCurrentPage: number) => {
pagination.currentPage = newCurrentPage
getDataList()
}
</script>
<style lang="scss" scoped>
.card-template-page {
// 可以添加特定样式
}
</style>

View File

@@ -0,0 +1,436 @@
<template>
<ArtTableFullScreen>
<div class="card-assign-page" id="table-full-screen">
<ElCard shadow="never" class="art-table-card">
<!-- 表格头部 -->
<ArtTableHeader
:columnList="columnOptions"
v-model:columns="columnChecks"
@refresh="handleRefresh"
>
<template #left>
<ElButton type="primary" @click="showDistributeDialog">分销网卡</ElButton>
</template>
</ArtTableHeader>
<!-- 表格 -->
<ArtTable
ref="tableRef"
row-key="id"
:loading="loading"
:data="tableData"
:currentPage="pagination.currentPage"
:pageSize="pagination.pageSize"
:total="pagination.total"
:marginTop="10"
@selection-change="handleSelectionChange"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
>
<template #default>
<ElTableColumn v-for="col in columns" :key="col.prop || col.type" v-bind="col" />
</template>
</ArtTable>
<!-- 分销网卡对话框 -->
<ElDialog
v-model="dialogVisible"
title="分销网卡"
width="500px"
align-center
:close-on-click-modal="false"
>
<ElForm ref="formRef" :model="formData" :rules="rules" label-width="120px">
<ElFormItem label="上传Excel文件" prop="excelFile">
<ElUpload
ref="uploadRef"
:limit="1"
:on-exceed="handleExceed"
:before-upload="beforeUpload"
:on-change="handleFileChange"
:auto-upload="false"
accept=".xlsx,.xls"
drag
>
<ElIcon class="el-icon--upload"><UploadFilled /></ElIcon>
<div class="el-upload__text"> 将文件拖到此处<em>点击上传</em> </div>
<template #tip>
<div class="el-upload__tip"> 只能上传 xlsx/xls 文件且不超过 10MB </div>
</template>
</ElUpload>
</ElFormItem>
<ElFormItem label="选择代理商" prop="agent">
<ElSelect
v-model="formData.agent"
placeholder="请选择代理商"
style="width: 100%"
clearable
>
<ElOption
v-for="agent in agentOptions"
:key="agent.value"
:label="agent.label"
:value="agent.value"
/>
</ElSelect>
</ElFormItem>
</ElForm>
<template #footer>
<div class="dialog-footer">
<ElButton @click="dialogVisible = false">取消</ElButton>
<ElButton type="primary" @click="handleSubmit" :loading="submitLoading">
确认分销
</ElButton>
</div>
</template>
</ElDialog>
</ElCard>
</div>
</ArtTableFullScreen>
</template>
<script setup lang="ts">
import { h } from 'vue'
import {
ElTag,
ElMessage,
ElMessageBox,
ElUpload,
ElIcon,
ElSelect,
ElOption
} from 'element-plus'
import { UploadFilled } from '@element-plus/icons-vue'
import type { FormInstance, UploadInstance, UploadRawFile, UploadFile } from 'element-plus'
import type { FormRules } from 'element-plus'
import { useCheckedColumns } from '@/composables/useCheckedColumns'
import ArtButtonTable from '@/components/core/forms/ArtButtonTable.vue'
defineOptions({ name: 'CardAssign' })
const dialogVisible = ref(false)
const loading = ref(false)
const submitLoading = ref(false)
const pagination = reactive({
currentPage: 1,
pageSize: 20,
total: 0
})
// 表格数据
const tableData = ref<any[]>([])
// 表格实例引用
const tableRef = ref()
// 选中的行数据
const selectedRows = ref<any[]>([])
// 表单实例
const formRef = ref<FormInstance>()
const uploadRef = ref<UploadInstance>()
// 表单数据
const formData = reactive({
excelFile: null as File | null,
agent: ''
})
// 代理商选项
const agentOptions = ref([
{ label: '张丽丽', value: 'zhangll' },
{ label: '王小明', value: 'wangxm' },
{ label: 'HNSXKJ', value: 'hnsxkj' },
{ label: '孔丽娟', value: 'konglj' },
{ label: '李佳音', value: 'lijy' },
{ label: '赵强', value: 'zhaoq' }
])
// 模拟数据
const mockData = [
{
id: 1,
importTime: '2025-11-07 15:01:37',
packageName: '随意联畅玩年卡套餐12个月',
importCount: 1000,
successCount: 998,
failCount: 2,
distributor: '张丽丽',
operator: '张若暄'
},
{
id: 2,
importTime: '2025-11-05 12:07:41',
packageName: '如意包年3G流量包',
importCount: 500,
successCount: 500,
failCount: 0,
distributor: '王小明',
operator: '张若暄'
},
{
id: 3,
importTime: '2025-11-03 11:21:42',
packageName: 'Y-NB专享套餐',
importCount: 200,
successCount: 195,
failCount: 5,
distributor: 'HNSXKJ',
operator: '张若暄'
},
{
id: 4,
importTime: '2025-10-29 16:01:16',
packageName: '100G全国流量月卡套餐',
importCount: 2500,
successCount: 2500,
failCount: 0,
distributor: '未分销',
operator: '张若暄'
},
{
id: 5,
importTime: '2025-10-22 15:44:31',
packageName: '广电飞悦卡无预存50G30天',
importCount: 800,
successCount: 795,
failCount: 5,
distributor: '孔丽娟',
operator: '孔丽娟'
}
]
// 列配置
const columnOptions = [
{ label: '勾选', type: 'selection' },
{ label: '导入时间', prop: 'importTime' },
{ label: '套餐名称', prop: 'packageName' },
{ label: '导入张数(张)', prop: 'importCount' },
{ label: '导入成功张数(张)', prop: 'successCount' },
{ label: '导入失败张数(张)', prop: 'failCount' },
{ label: '分销商', prop: 'distributor' },
{ label: '操作人', prop: 'operator' },
{ label: '操作', prop: 'operation' }
]
// 查看详情
const viewDetails = (row: any) => {
ElMessage.info(`查看 ${row.packageName} 的分配详情`)
}
// 删除记录
const deleteRecord = (row: any) => {
ElMessageBox.confirm(`确定要删除该分配记录吗?`, '删除确认', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
.then(() => {
ElMessage.success('删除成功')
getCardAssignList()
})
.catch(() => {
ElMessage.info('已取消删除')
})
}
// 动态列配置
const { columnChecks, columns } = useCheckedColumns(() => [
{ type: 'selection' },
{
prop: 'importTime',
label: '导入时间',
width: 180
},
{
prop: 'packageName',
label: '套餐名称',
minWidth: 200
},
{
prop: 'importCount',
label: '导入张数(张)',
width: 130
},
{
prop: 'successCount',
label: '导入成功张数(张)',
width: 150
},
{
prop: 'failCount',
label: '导入失败张数(张)',
width: 150,
formatter: (row) => {
return h(ElTag, { type: row.failCount > 0 ? 'danger' : 'success' }, () => row.failCount)
}
},
{
prop: 'distributor',
label: '分销商',
width: 120
},
{
prop: 'operator',
label: '操作人',
width: 100
},
{
prop: 'operation',
label: '操作',
width: 150,
formatter: (row: any) => {
return h('div', { class: 'operation-buttons' }, [
h(ArtButtonTable, {
text: '查看',
onClick: () => viewDetails(row)
}),
h(ArtButtonTable, {
text: '删除',
onClick: () => deleteRecord(row)
})
])
}
}
])
onMounted(() => {
getCardAssignList()
})
// 获取网卡分配列表
const getCardAssignList = async () => {
loading.value = true
try {
// 模拟API调用
await new Promise((resolve) => setTimeout(resolve, 500))
const startIndex = (pagination.currentPage - 1) * pagination.pageSize
const endIndex = startIndex + pagination.pageSize
const paginatedData = mockData.slice(startIndex, endIndex)
tableData.value = paginatedData
pagination.total = mockData.length
loading.value = false
} catch (error) {
console.error('获取网卡分配列表失败:', error)
loading.value = false
}
}
const handleRefresh = () => {
getCardAssignList()
}
// 处理表格行选择变化
const handleSelectionChange = (selection: any[]) => {
selectedRows.value = selection
}
// 处理表格分页变化
const handleSizeChange = (newPageSize: number) => {
pagination.pageSize = newPageSize
getCardAssignList()
}
const handleCurrentChange = (newCurrentPage: number) => {
pagination.currentPage = newCurrentPage
getCardAssignList()
}
// 显示分销对话框
const showDistributeDialog = () => {
dialogVisible.value = true
// 重置表单
if (formRef.value) {
formRef.value.resetFields()
}
formData.excelFile = null
formData.agent = ''
}
// 文件上传限制
const handleExceed = () => {
ElMessage.warning('最多只能上传一个文件')
}
// 文件上传前检查
const beforeUpload = (file: UploadRawFile) => {
const isExcel =
file.type === 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' ||
file.type === 'application/vnd.ms-excel'
const isLt10M = file.size / 1024 / 1024 < 10
if (!isExcel) {
ElMessage.error('只能上传 Excel 文件!')
return false
}
if (!isLt10M) {
ElMessage.error('上传文件大小不能超过 10MB!')
return false
}
return false // 阻止自动上传
}
// 文件变化处理
const handleFileChange = (file: UploadFile) => {
if (file.raw) {
formData.excelFile = file.raw
}
}
// 表单验证规则
const rules = reactive<FormRules>({
excelFile: [{ required: true, message: '请上传Excel文件', trigger: 'change' }],
agent: [{ required: true, message: '请选择代理商', trigger: 'change' }]
})
// 提交表单
const handleSubmit = async () => {
if (!formRef.value) return
// 检查文件是否上传
if (!formData.excelFile) {
ElMessage.error('请先上传Excel文件')
return
}
await formRef.value.validate((valid) => {
if (valid) {
submitLoading.value = true
// 模拟提交过程
setTimeout(() => {
ElMessage.success(
`已成功分销给代理商:${agentOptions.value.find((a) => a.value === formData.agent)?.label}`
)
dialogVisible.value = false
submitLoading.value = false
getCardAssignList()
}, 2000)
}
})
}
</script>
<style lang="scss" scoped>
.card-assign-page {
// 可以添加特定样式
}
:deep(.operation-buttons) {
display: flex;
gap: 8px;
}
.dialog-footer {
text-align: right;
}
:deep(.el-upload-dragger) {
padding: 40px;
}
</style>

View File

@@ -0,0 +1,549 @@
<template>
<ArtTableFullScreen>
<div class="card-change-card-page" id="table-full-screen">
<!-- 搜索栏 -->
<ArtSearchBar
v-model:filter="formFilters"
:items="formItems"
@reset="handleReset"
@search="handleSearch"
></ArtSearchBar>
<ElCard shadow="never" class="art-table-card">
<!-- 表格头部 -->
<ArtTableHeader
:columnList="columnOptions"
v-model:columns="columnChecks"
@refresh="handleRefresh"
>
<template #left>
<ElButton type="primary" @click="handleSearch">搜索</ElButton>
<ElButton type="success" @click="showAddDialog">新增</ElButton>
<ElButton @click="exportExcel">导出excel</ElButton>
</template>
</ArtTableHeader>
<!-- 表格 -->
<ArtTable
ref="tableRef"
row-key="id"
:loading="loading"
:data="tableData"
:currentPage="pagination.currentPage"
:pageSize="pagination.pageSize"
:total="pagination.total"
:marginTop="10"
@selection-change="handleSelectionChange"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
>
<template #default>
<ElTableColumn v-for="col in columns" :key="col.prop || col.type" v-bind="col" />
</template>
</ArtTable>
<!-- 新增换卡网卡对话框 -->
<ElDialog
v-model="dialogVisible"
title="新增换卡网卡"
width="500px"
align-center
:close-on-click-modal="false"
>
<ElForm ref="formRef" :model="formData" :rules="rules" label-width="120px">
<ElFormItem label="ICCID" prop="iccid">
<ElInput v-model="formData.iccid" placeholder="请输入ICCID" clearable />
</ElFormItem>
<ElFormItem label="是否收费" prop="isCharged">
<ElSelect
v-model="formData.isCharged"
placeholder="请选择是否收费"
style="width: 100%"
clearable
@change="handleChargeChange"
>
<ElOption label="收费" value="true" />
<ElOption label="免费" value="false" />
</ElSelect>
</ElFormItem>
<ElFormItem label="收费金额" prop="chargeAmount" v-if="formData.isCharged === 'true'">
<ElInputNumber
v-model="formData.chargeAmount"
:min="0"
:max="9999"
:precision="2"
placeholder="请输入收费金额"
style="width: 100%"
/>
</ElFormItem>
</ElForm>
<template #footer>
<div class="dialog-footer">
<ElButton @click="dialogVisible = false">取消</ElButton>
<ElButton type="primary" @click="handleSubmit" :loading="submitLoading">
确认新增
</ElButton>
</div>
</template>
</ElDialog>
</ElCard>
</div>
</ArtTableFullScreen>
</template>
<script setup lang="ts">
import { h } from 'vue'
import { ElTag, ElMessage, ElMessageBox, ElSelect, ElOption, ElInputNumber } from 'element-plus'
import type { FormInstance } from 'element-plus'
import type { FormRules } from 'element-plus'
import { useCheckedColumns } from '@/composables/useCheckedColumns'
import ArtButtonTable from '@/components/core/forms/ArtButtonTable.vue'
import { SearchChangeParams, SearchFormItem } from '@/types'
defineOptions({ name: 'CardChangeCard' })
const dialogVisible = ref(false)
const loading = ref(false)
const submitLoading = ref(false)
// 定义表单搜索初始值
const initialSearchState = {
cardNumber: '',
accessNumber: '',
cardCompany: '',
operationDateRange: '',
isCharged: ''
}
// 响应式表单数据
const formFilters = reactive({ ...initialSearchState })
const pagination = reactive({
currentPage: 1,
pageSize: 20,
total: 0
})
// 表格数据
const tableData = ref<any[]>([])
// 表格实例引用
const tableRef = ref()
// 选中的行数据
const selectedRows = ref<any[]>([])
// 表单实例
const formRef = ref<FormInstance>()
// 新增表单数据
const formData = reactive({
iccid: '',
isCharged: '',
chargeAmount: 0
})
// 模拟数据
const mockData = [
{
id: 1,
cardNumber: '89860621370079892035',
accessNumber: '1440012345678',
cardCompany: '联通2',
agentLevel: '一级代理',
operator: '张若暄',
operationTime: '2025-11-08 10:30:00',
isCharged: '收费',
amount: '50.00'
},
{
id: 2,
cardNumber: '89860621370079892036',
accessNumber: '1440012345679',
cardCompany: 'SXKJ-NB',
agentLevel: '二级代理',
operator: '孔丽娟',
operationTime: '2025-11-07 14:15:00',
isCharged: '免费',
amount: '0.00'
},
{
id: 3,
cardNumber: '89860621370079892037',
accessNumber: '1440012345680',
cardCompany: '联通36',
agentLevel: '终端用户',
operator: '李佳音',
operationTime: '2025-11-06 09:45:00',
isCharged: '收费',
amount: '30.00'
},
{
id: 4,
cardNumber: '89860621370079892038',
accessNumber: '1440012345681',
cardCompany: '联通1-1',
agentLevel: '一级代理',
operator: '赵强',
operationTime: '2025-11-05 16:20:00',
isCharged: '收费',
amount: '100.00'
},
{
id: 5,
cardNumber: '89860621370079892039',
accessNumber: '1440012345682',
cardCompany: '广电4',
agentLevel: '分销商',
operator: '张若暄',
operationTime: '2025-11-04 11:30:00',
isCharged: '免费',
amount: '0.00'
}
]
// 重置表单
const handleReset = () => {
Object.assign(formFilters, { ...initialSearchState })
pagination.currentPage = 1
getCardChangeCardList()
}
// 搜索处理
const handleSearch = () => {
console.log('搜索参数:', formFilters)
pagination.currentPage = 1
getCardChangeCardList()
}
// 表单项变更处理
const handleFormChange = (params: SearchChangeParams): void => {
console.log('表单项变更:', params)
}
// 表单配置项
const formItems: SearchFormItem[] = [
{
label: '卡号',
prop: 'cardNumber',
type: 'input',
config: {
clearable: true,
placeholder: '请输入卡号'
},
onChange: handleFormChange
},
{
label: '接入号',
prop: 'accessNumber',
type: 'input',
config: {
clearable: true,
placeholder: '请输入接入号'
},
onChange: handleFormChange
},
{
label: '开卡公司',
prop: 'cardCompany',
type: 'select',
config: {
clearable: true,
placeholder: '全部'
},
options: () => [
{ label: '联通2', value: 'unicom2' },
{ label: '联通36', value: 'unicom36' },
{ label: 'SXKJ-NB', value: 'sxkj_nb' },
{ label: '联通1-1', value: 'unicom1_1' },
{ label: '联通8', value: 'unicom8' },
{ label: '移动21', value: 'mobile21' },
{ label: '广电4', value: 'gdtv4' },
{ label: '电信9', value: 'telecom9' }
],
onChange: handleFormChange
},
{
label: '操作时间',
prop: 'operationDateRange',
type: 'daterange',
config: {
type: 'daterange',
startPlaceholder: '开始时间',
endPlaceholder: '结束时间'
},
onChange: handleFormChange
},
{
label: '是否收费',
prop: 'isCharged',
type: 'select',
config: {
clearable: true,
placeholder: '全部'
},
options: () => [
{ label: '收费', value: 'charged' },
{ label: '免费', value: 'free' }
],
onChange: handleFormChange
}
]
// 列配置
const columnOptions = [
{ label: '勾选', type: 'selection' },
{ label: '卡号', prop: 'cardNumber' },
{ label: '接入号', prop: 'accessNumber' },
{ label: '开卡公司', prop: 'cardCompany' },
{ label: '代理商层级', prop: 'agentLevel' },
{ label: '操作人', prop: 'operator' },
{ label: '操作时间', prop: 'operationTime' },
{ label: '是否收费', prop: 'isCharged' },
{ label: '金额', prop: 'amount' },
{ label: '操作', prop: 'operation' }
]
// 获取收费状态标签类型
const getChargeStatusType = (status: string) => {
switch (status) {
case '收费':
return 'warning'
case '免费':
return 'success'
default:
return 'info'
}
}
// 导出Excel
const exportExcel = () => {
if (selectedRows.value.length === 0) {
ElMessage.warning('请先选择要导出的数据')
return
}
ElMessage.success(`导出 ${selectedRows.value.length} 条换卡网卡记录`)
}
// 显示新增对话框
const showAddDialog = () => {
dialogVisible.value = true
// 重置表单
if (formRef.value) {
formRef.value.resetFields()
}
formData.iccid = ''
formData.isCharged = ''
formData.chargeAmount = 0
}
// 收费方式变化处理
const handleChargeChange = (value: string) => {
if (value === 'false') {
formData.chargeAmount = 0
}
}
// 查看详情
const viewDetails = (row: any) => {
ElMessage.info(`查看换卡网卡详情: ${row.cardNumber}`)
}
// 编辑记录
const editRecord = (row: any) => {
ElMessage.info(`编辑换卡网卡记录: ${row.cardNumber}`)
}
// 删除记录
const deleteRecord = (row: any) => {
ElMessageBox.confirm(`确定要删除该换卡网卡记录吗?`, '删除确认', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
.then(() => {
ElMessage.success('删除成功')
getCardChangeCardList()
})
.catch(() => {
ElMessage.info('已取消删除')
})
}
// 动态列配置
const { columnChecks, columns } = useCheckedColumns(() => [
{ type: 'selection' },
{
prop: 'cardNumber',
label: '卡号',
width: 200
},
{
prop: 'accessNumber',
label: '接入号'
},
{
prop: 'cardCompany',
label: '开卡公司'
},
{
prop: 'agentLevel',
label: '代理商层级'
},
{
prop: 'operator',
label: '操作人'
},
{
prop: 'operationTime',
label: '操作时间',
width: 160
},
{
prop: 'isCharged',
label: '是否收费',
formatter: (row) => {
return h(ElTag, { type: getChargeStatusType(row.isCharged) }, () => row.isCharged)
}
},
{
prop: 'amount',
label: '金额',
formatter: (row) => `¥${row.amount}`
},
{
prop: 'operation',
label: '操作',
width: 220,
formatter: (row: any) => {
return h('div', { class: 'operation-buttons' }, [
h(ArtButtonTable, {
text: '查看',
onClick: () => viewDetails(row)
}),
h(ArtButtonTable, {
text: '编辑',
onClick: () => editRecord(row)
}),
h(ArtButtonTable, {
text: '删除',
onClick: () => deleteRecord(row)
})
])
}
}
])
onMounted(() => {
getCardChangeCardList()
})
// 获取换卡网卡列表
const getCardChangeCardList = async () => {
loading.value = true
try {
// 模拟API调用
await new Promise((resolve) => setTimeout(resolve, 500))
const startIndex = (pagination.currentPage - 1) * pagination.pageSize
const endIndex = startIndex + pagination.pageSize
const paginatedData = mockData.slice(startIndex, endIndex)
tableData.value = paginatedData
pagination.total = mockData.length
loading.value = false
} catch (error) {
console.error('获取换卡网卡列表失败:', error)
loading.value = false
}
}
const handleRefresh = () => {
getCardChangeCardList()
}
// 处理表格行选择变化
const handleSelectionChange = (selection: any[]) => {
selectedRows.value = selection
}
// 处理表格分页变化
const handleSizeChange = (newPageSize: number) => {
pagination.pageSize = newPageSize
getCardChangeCardList()
}
const handleCurrentChange = (newCurrentPage: number) => {
pagination.currentPage = newCurrentPage
getCardChangeCardList()
}
// 表单验证规则
const rules = reactive<FormRules>({
iccid: [
{ required: true, message: '请输入ICCID', trigger: 'blur' },
{ min: 15, max: 20, message: 'ICCID长度在 15 到 20 个字符', trigger: 'blur' }
],
isCharged: [{ required: true, message: '请选择是否收费', trigger: 'change' }],
chargeAmount: [
{
required: true,
message: '请输入收费金额',
trigger: 'blur',
validator: (rule, value, callback) => {
if (formData.isCharged === 'true' && (!value || value <= 0)) {
callback(new Error('收费时金额必须大于0'))
} else {
callback()
}
}
}
]
})
// 提交表单
const handleSubmit = async () => {
if (!formRef.value) return
await formRef.value.validate((valid) => {
if (valid) {
submitLoading.value = true
// 模拟提交过程
setTimeout(() => {
const chargeText = formData.isCharged === 'true' ? '收费' : '免费'
const amountText =
formData.isCharged === 'true' ? `,金额:¥${formData.chargeAmount}` : ''
ElMessage.success(
`新增换卡网卡成功ICCID${formData.iccid}${chargeText}${amountText}`
)
dialogVisible.value = false
submitLoading.value = false
getCardChangeCardList()
}, 2000)
}
})
}
</script>
<style lang="scss" scoped>
.card-change-card-page {
// 可以添加特定样式
}
:deep(.operation-buttons) {
display: flex;
gap: 8px;
}
.dialog-footer {
text-align: right;
}
:deep(.el-form-item) {
margin-bottom: 20px;
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,607 @@
<template>
<ArtTableFullScreen>
<div class="card-list-page" id="table-full-screen">
<!-- 搜索栏 -->
<ArtSearchBar
v-model:filter="formFilters"
:items="formItems"
@reset="handleReset"
@search="handleSearch"
></ArtSearchBar>
<ElCard shadow="never" class="art-table-card">
<!-- 数据视图组件 -->
<ArtDataViewer
ref="dataViewerRef"
:data="tableData"
:loading="loading"
:table-columns="columns"
:descriptions-fields="descriptionsFields"
:descriptions-columns="2"
:pagination="pagination"
:card-title-field="'importBatch'"
:label-width="'150px'"
:field-columns="columnChecks"
:show-card-actions="true"
:show-card-selection="true"
:default-view="currentView"
@selection-change="handleSelectionChange"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
@view-change="handleViewChange"
>
<template #header-left>
<ElButton type="primary" @click="showDialog('add')">导入网卡</ElButton>
<ElButton @click="showDialog('single')">单卡导入</ElButton>
</template>
<template #header-right>
<ArtTableHeader
:columnList="columnOptions"
v-model:columns="columnChecks"
v-model:currentView="currentView"
@refresh="handleRefresh"
@viewChange="handleViewChange"
:show-title="false"
:show-view-toggle="true"
/>
</template>
<template #card-actions="{ item }">
<ArtButtonTable type="view" text="查看失败" @click="viewFailures(item)" />
</template>
</ArtDataViewer>
<!-- 网卡信息对话框 -->
<ElDialog
v-model="dialogVisible"
:title="dialogType === 'add' ? '导入网卡' : dialogType === 'single' ? '单卡导入' : '编辑'"
width="40%"
align-center
>
<ElForm ref="formRef" :model="formData" :rules="rules" label-width="100px">
<ElFormItem label="ICCID" prop="iccid">
<ElInput v-model="formData.iccid" placeholder="请输入ICCID" />
</ElFormItem>
<ElFormItem label="IMSI" prop="imsi">
<ElInput v-model="formData.imsi" placeholder="请输入IMSI" />
</ElFormItem>
<ElFormItem label="手机号码" prop="msisdn">
<ElInput v-model="formData.msisdn" placeholder="请输入手机号码" />
</ElFormItem>
<ElFormItem label="运营商" prop="operator">
<ElSelect v-model="formData.operator" placeholder="请选择运营商">
<ElOption label="中国移动" value="mobile" />
<ElOption label="中国联通" value="unicom" />
<ElOption label="中国电信" value="telecom" />
</ElSelect>
</ElFormItem>
<ElFormItem label="网络类型" prop="networkType">
<ElSelect v-model="formData.networkType" placeholder="请选择网络类型">
<ElOption label="2G" value="2G" />
<ElOption label="3G" value="3G" />
<ElOption label="4G" value="4G" />
<ElOption label="5G" value="5G" />
</ElSelect>
</ElFormItem>
<ElFormItem label="状态" prop="status">
<ElSelect v-model="formData.status" placeholder="请选择状态">
<ElOption label="激活" value="1" />
<ElOption label="停用" value="2" />
<ElOption label="测试" value="3" />
<ElOption label="库存" value="4" />
</ElSelect>
</ElFormItem>
</ElForm>
<template #footer>
<div class="dialog-footer">
<ElButton @click="dialogVisible = false">取消</ElButton>
<ElButton type="primary" @click="handleSubmit">提交</ElButton>
</div>
</template>
</ElDialog>
</ElCard>
</div>
</ArtTableFullScreen>
</template>
<script setup lang="ts">
import { h } from 'vue'
import { ElDialog, FormInstance, ElTag } from 'element-plus'
import { ElMessageBox, ElMessage } from 'element-plus'
import type { FormRules } from 'element-plus'
import { useCheckedColumns } from '@/composables/useCheckedColumns'
import ArtButtonTable from '@/components/core/forms/ArtButtonTable.vue'
import ArtDataViewer from '@/components/core/views/ArtDataViewer.vue'
import { SearchChangeParams, SearchFormItem } from '@/types'
defineOptions({ name: 'CardList' })
const dialogType = ref('add')
const dialogVisible = ref(false)
const loading = ref(false)
// 当前视图模式
const currentView = ref('table')
// 定义表单搜索初始值
const initialSearchState = {
operator: '',
distributor: '',
importDateRange: '',
cardCompany: ''
}
// 响应式表单数据
const formFilters = reactive({ ...initialSearchState })
const pagination = reactive({
currentPage: 1,
pageSize: 20,
total: 0
})
// 表格数据
const tableData = ref<any[]>([])
// 表格实例引用
const tableRef = ref()
// 数据视图组件引用
const dataViewerRef = ref()
// 选中的行数据
const selectedRows = ref<any[]>([])
// 模拟数据
const mockData = [
{
id: 1,
importTime: '2025-11-07 15:01:37.0',
importBatch: '650',
importCount: 1,
successCount: 1,
failCount: 0,
distributorName: '未分销',
packageName: '随意联畅玩年卡套餐12个月',
cardCompanyName: '联通2',
operatorName: '中国联通',
cardType: '月卡',
operatorUser: '张若暄'
},
{
id: 2,
importTime: '2025-11-05 12:07:41.0',
importBatch: '649',
importCount: 100,
successCount: 100,
failCount: 0,
distributorName: '未分销',
packageName: '如意包年3G流量包',
cardCompanyName: '联通36',
operatorName: '中国联通',
cardType: '月卡',
operatorUser: '张若暄'
},
{
id: 3,
importTime: '2025-11-03 11:21:42.0',
importBatch: '648',
importCount: 200,
successCount: 200,
failCount: 0,
distributorName: 'HNSXKJ',
packageName: 'Y-NB专享套餐',
cardCompanyName: 'SXKJ-NB',
operatorName: 'GS移动',
cardType: '月卡',
operatorUser: '张若暄'
},
{
id: 4,
importTime: '2025-10-29 16:01:16.0',
importBatch: '647',
importCount: 2500,
successCount: 2500,
failCount: 0,
distributorName: '未分销',
packageName: '100G全国流量月卡套餐',
cardCompanyName: '联通1-1',
operatorName: '中国联通',
cardType: '月卡',
operatorUser: '张若暄'
},
{
id: 5,
importTime: '2025-10-22 15:44:31.0',
importBatch: '644',
importCount: 500,
successCount: 499,
failCount: 1,
distributorName: '未分销',
packageName: '广电飞悦卡无预存50G30天',
cardCompanyName: '广电4',
operatorName: 'GDWL',
cardType: '月卡',
operatorUser: '孔丽娟'
}
]
// 重置表单
const handleReset = () => {
Object.assign(formFilters, { ...initialSearchState })
pagination.currentPage = 1
getCardList()
}
// 搜索处理
const handleSearch = () => {
console.log('搜索参数:', formFilters)
pagination.currentPage = 1
getCardList()
}
// 表单项变更处理
const handleFormChange = (params: SearchChangeParams): void => {
console.log('表单项变更:', params)
}
// 表单配置项
const formItems: SearchFormItem[] = [
{
label: '运营商',
prop: 'operator',
type: 'select',
config: {
clearable: true,
placeholder: '全部'
},
options: () => [
{ label: '中国移动', value: 'mobile' },
{ label: '中国联通', value: 'unicom' },
{ label: '中国电信', value: 'telecom' },
{ label: 'GS移动', value: 'gs_mobile' },
{ label: 'DC物联', value: 'dc_iot' },
{ label: 'GDWL', value: 'gdwl' },
{ label: 'GS联通', value: 'gs_unicom' },
{ label: 'GS电信', value: 'gs_telecom' }
],
onChange: handleFormChange
},
{
label: '分销商',
prop: 'distributor',
type: 'input',
config: {
clearable: true,
placeholder: '请输入分销商'
},
onChange: handleFormChange
},
{
label: '导入时间',
prop: 'importDateRange',
type: 'daterange',
config: {
type: 'daterange',
startPlaceholder: '开始时间',
endPlaceholder: '结束时间'
},
onChange: handleFormChange
},
{
label: '开卡公司',
prop: 'cardCompany',
type: 'select',
config: {
clearable: true,
placeholder: '全部'
},
options: () => [
{ label: '联通2', value: 'unicom2' },
{ label: '联通36', value: 'unicom36' },
{ label: 'SXKJ-NB', value: 'sxkj_nb' },
{ label: '联通1-1', value: 'unicom1_1' },
{ label: '联通8', value: 'unicom8' },
{ label: '新移动22', value: 'new_mobile22' },
{ label: '广电4', value: 'gdtv4' },
{ label: '移动21', value: 'mobile21' },
{ label: '联通38', value: 'unicom38' },
{ label: '电信9', value: 'telecom9' },
{ label: '联通10-1', value: 'unicom10_1' }
],
onChange: handleFormChange
}
]
// 列配置
const columnOptions = [
{ label: '勾选', type: 'selection' },
{ label: '导入时间', prop: 'importTime' },
{ label: '导入批次', prop: 'importBatch' },
{ label: '导入张数(张)', prop: 'importCount' },
{ label: '导入成功张数(张)', prop: 'successCount' },
{ label: '导入失败张数(张)', prop: 'failCount' },
{ label: '分销代理商', prop: 'distributorName' },
{ label: '套餐名称', prop: 'packageName' },
{ label: '开卡公司', prop: 'cardCompanyName' },
{ label: '运营商', prop: 'operatorName' },
{ label: '卡类型', prop: 'cardType' },
{ label: '操作人', prop: 'operatorUser' },
{ label: '操作', prop: 'operation' }
]
// 描述字段配置
const descriptionsFields = [
{ prop: 'importTime', label: '导入时间' },
{ prop: 'importBatch', label: '导入批次' },
{ prop: 'importCount', label: '导入张数(张)' },
{ prop: 'successCount', label: '导入成功张数(张)' },
{
prop: 'failCount',
label: '导入失败张数(张)',
formatter: (row: any) => {
const type = row.failCount > 0 ? 'danger' : 'success'
return `<el-tag type="${type}" size="small">${row.failCount}</el-tag>`
}
},
{ prop: 'distributorName', label: '分销代理商' },
{ prop: 'packageName', label: '套餐名称', span: 1 },
{ prop: 'cardCompanyName', label: '开卡公司' },
{ prop: 'operatorName', label: '运营商' },
{ prop: 'cardType', label: '卡类型' },
{ prop: 'operatorUser', label: '操作人' }
]
// 获取标签类型
const getTagType = (status: string) => {
switch (status) {
case '1':
return 'success'
case '2':
return 'danger'
case '3':
return 'warning'
case '4':
return 'info'
default:
return 'info'
}
}
// 构建标签文本
const buildTagText = (status: string) => {
switch (status) {
case '1':
return '激活'
case '2':
return '停用'
case '3':
return '测试'
case '4':
return '库存'
default:
return '未知'
}
}
// 显示对话框
const showDialog = (type: string, row?: any) => {
dialogVisible.value = true
dialogType.value = type
if (formRef.value) {
formRef.value.resetFields()
}
if (type === 'edit' && row) {
formData.iccid = row.iccid
formData.imsi = row.imsi
formData.msisdn = row.msisdn
formData.operator = row.operator
formData.networkType = row.networkType
formData.status = row.status
} else {
formData.iccid = ''
formData.imsi = ''
formData.msisdn = ''
formData.operator = ''
formData.networkType = ''
formData.status = ''
}
}
// 查看失败
const viewFailures = (row: any) => {
if (row.failCount > 0) {
ElMessage.info(`查看导入批次 ${row.importBatch} 的失败记录`)
// 这里可以跳转到失败详情页面或显示失败详情对话框
} else {
ElMessage.info('该批次没有失败记录')
}
}
// 批量操作
const batchOperation = () => {
if (selectedRows.value.length === 0) {
ElMessage.warning('请先选择要操作的导入记录')
return
}
ElMessage.info(`已选择 ${selectedRows.value.length} 条导入记录`)
}
// 动态列配置
const { columnChecks, columns } = useCheckedColumns(() => [
{ type: 'selection' },
{
prop: 'importTime',
label: '导入时间',
width: 180
},
{
prop: 'importBatch',
label: '导入批次',
width: 100
},
{
prop: 'importCount',
label: '导入张数(张)',
width: 130
},
{
prop: 'successCount',
label: '导入成功张数(张)',
width: 150
},
{
prop: 'failCount',
label: '导入失败张数(张)',
width: 150
},
{
prop: 'distributorName',
label: '分销代理商',
width: 120
},
{
prop: 'packageName',
label: '套餐名称',
minWidth: 200
},
{
prop: 'cardCompanyName',
label: '开卡公司',
width: 120
},
{
prop: 'operatorName',
label: '运营商',
width: 100
},
{
prop: 'cardType',
label: '卡类型',
width: 80
},
{
prop: 'operatorUser',
label: '操作人',
width: 100
},
{
prop: 'operation',
label: '操作',
width: 120,
formatter: (row: any) => {
return h('div', [
h(ArtButtonTable, {
type: 'view',
text: '查看失败',
onClick: () => viewFailures(row)
})
])
}
}
])
// 表单实例
const formRef = ref<FormInstance>()
// 表单数据
const formData = reactive({
iccid: '',
imsi: '',
msisdn: '',
operator: '',
networkType: '',
status: ''
})
onMounted(() => {
getCardList()
})
// 获取网卡列表
const getCardList = async () => {
loading.value = true
try {
// 模拟API调用
await new Promise((resolve) => setTimeout(resolve, 500))
const startIndex = (pagination.currentPage - 1) * pagination.pageSize
const endIndex = startIndex + pagination.pageSize
const paginatedData = mockData.slice(startIndex, endIndex)
tableData.value = paginatedData
pagination.total = mockData.length
loading.value = false
} catch (error) {
console.error('获取网卡列表失败:', error)
loading.value = false
}
}
const handleRefresh = () => {
getCardList()
}
// 处理表格行选择变化
const handleSelectionChange = (selection: any[]) => {
selectedRows.value = selection
}
// 表单验证规则
const rules = reactive<FormRules>({
iccid: [
{ required: true, message: '请输入ICCID', trigger: 'blur' },
{ min: 19, max: 20, message: 'ICCID长度应为19-20位', trigger: 'blur' }
],
imsi: [
{ required: true, message: '请输入IMSI', trigger: 'blur' },
{ min: 15, max: 15, message: 'IMSI长度应为15位', trigger: 'blur' }
],
msisdn: [
{ required: true, message: '请输入手机号码', trigger: 'blur' },
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号格式', trigger: 'blur' }
],
operator: [{ required: true, message: '请选择运营商', trigger: 'change' }],
networkType: [{ required: true, message: '请选择网络类型', trigger: 'change' }],
status: [{ required: true, message: '请选择状态', trigger: 'change' }]
})
// 提交表单
const handleSubmit = async () => {
if (!formRef.value) return
await formRef.value.validate((valid) => {
if (valid) {
ElMessage.success(dialogType.value === 'add' ? '添加成功' : '更新成功')
dialogVisible.value = false
getCardList()
}
})
}
// 处理表格分页变化
const handleSizeChange = (newPageSize: number) => {
pagination.pageSize = newPageSize
getCardList()
}
const handleCurrentChange = (newCurrentPage: number) => {
pagination.currentPage = newCurrentPage
getCardList()
}
// 处理视图切换
const handleViewChange = (view: string) => {
console.log('视图切换到:', view)
// 可以在这里添加视图切换的额外逻辑,比如保存用户偏好
}
</script>
<style lang="scss" scoped>
.card-list-page {
// Card list page styles
}
</style>

View File

@@ -0,0 +1,695 @@
<template>
<ArtTableFullScreen>
<div class="card-replacement-page" id="table-full-screen">
<!-- 搜索栏 -->
<ArtSearchBar
v-model:filter="formFilters"
:items="formItems"
@reset="handleReset"
@search="handleSearch"
></ArtSearchBar>
<ElCard shadow="never" class="art-table-card">
<!-- 表格头部 -->
<ArtTableHeader
:columnList="columnOptions"
v-model:columns="columnChecks"
@refresh="handleRefresh"
>
<template #left>
<ElButton type="primary" @click="handleSearch">搜索</ElButton>
<ElButton @click="exportExcel">导出excel</ElButton>
<ElButton type="success" @click="showImportDialog">导入excel</ElButton>
</template>
</ArtTableHeader>
<!-- 表格 -->
<ArtTable
ref="tableRef"
row-key="id"
:loading="loading"
:data="tableData"
:currentPage="pagination.currentPage"
:pageSize="pagination.pageSize"
:total="pagination.total"
:marginTop="10"
@selection-change="handleSelectionChange"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
>
<template #default>
<ElTableColumn v-for="col in columns" :key="col.prop || col.type" v-bind="col" />
</template>
</ArtTable>
<!-- 导入Excel对话框 -->
<ElDialog
v-model="importDialogVisible"
title="导入换卡记录"
width="500px"
align-center
:close-on-click-modal="false"
>
<!-- 顶部下载模板按钮 -->
<div class="template-section">
<ElButton type="primary" @click="downloadTemplate" icon="Download"> 下载模板 </ElButton>
<span class="template-tip">请先下载模板按模板格式填写后上传</span>
</div>
<ElDivider />
<ElForm
ref="importFormRef"
:model="importFormData"
:rules="importRules"
label-width="120px"
>
<ElFormItem label="上传Excel文件" prop="excelFile">
<ElUpload
ref="uploadRef"
:limit="1"
:on-exceed="handleExceed"
:before-upload="beforeUpload"
:on-change="handleFileChange"
:auto-upload="false"
accept=".xlsx,.xls"
drag
>
<ElIcon class="el-icon--upload"><UploadFilled /></ElIcon>
<div class="el-upload__text"> 将文件拖到此处<em>点击上传</em> </div>
<template #tip>
<div class="el-upload__tip"> 只能上传 xlsx/xls 文件且不超过 10MB </div>
</template>
</ElUpload>
</ElFormItem>
<ElFormItem label="备注说明" prop="remark">
<ElInput
v-model="importFormData.remark"
type="textarea"
placeholder="请输入备注说明(可选)"
:rows="3"
maxlength="200"
show-word-limit
/>
</ElFormItem>
</ElForm>
<template #footer>
<div class="dialog-footer">
<ElButton @click="importDialogVisible = false">取消</ElButton>
<ElButton type="primary" @click="handleImportSubmit" :loading="importLoading">
确认导入
</ElButton>
</div>
</template>
</ElDialog>
</ElCard>
</div>
</ArtTableFullScreen>
</template>
<script setup lang="ts">
import { h } from 'vue'
import { ElTag, ElMessage, ElMessageBox, ElUpload, ElIcon, ElDivider } from 'element-plus'
import { UploadFilled, Download } from '@element-plus/icons-vue'
import type { FormInstance, UploadInstance, UploadRawFile, UploadFile } from 'element-plus'
import type { FormRules } from 'element-plus'
import { useCheckedColumns } from '@/composables/useCheckedColumns'
import ArtButtonTable from '@/components/core/forms/ArtButtonTable.vue'
import { SearchChangeParams, SearchFormItem } from '@/types'
defineOptions({ name: 'CardReplacement' })
const importDialogVisible = ref(false)
const loading = ref(false)
const importLoading = ref(false)
// 定义表单搜索初始值
const initialSearchState = {
processResult: '',
cardCompany: '',
oldSimNumber: '',
newSimNumber: '',
newCardOperator: '',
submitDateRange: ''
}
// 响应式表单数据
const formFilters = reactive({ ...initialSearchState })
const pagination = reactive({
currentPage: 1,
pageSize: 20,
total: 0
})
// 表格数据
const tableData = ref<any[]>([])
// 表格实例引用
const tableRef = ref()
// 选中的行数据
const selectedRows = ref<any[]>([])
// 导入表单实例
const importFormRef = ref<FormInstance>()
const uploadRef = ref<UploadInstance>()
// 导入表单数据
const importFormData = reactive({
excelFile: null as File | null,
remark: ''
})
// 模拟数据
const mockData = [
{
id: 1,
iccid: '89860621370079892035',
currentPackage: '随意联畅玩年卡套餐12个月',
cardCompany: '联通2',
hierarchyRelation: '一级代理->二级代理->终端用户',
recipientName: '张丽丽',
recipientPhone: '138****5678',
recipientAddress: '北京市朝阳区建国门外大街1号',
newSimNumber: '1440012345778',
processor: '张若暄',
processTime: '2025-11-08 14:30:00',
processDescription: '用户申请换卡,原卡损坏',
processStatus: '处理完成',
submitTime: '2025-11-08 10:00:00'
},
{
id: 2,
iccid: '89860621370079892036',
currentPackage: '如意包年3G流量包',
cardCompany: '联通36',
hierarchyRelation: '总代理->分销商',
recipientName: '王小明',
recipientPhone: '139****1234',
recipientAddress: '上海市浦东新区陆家嘴环路1000号',
newSimNumber: '1440012345779',
processor: '孔丽娟',
processTime: '2025-11-07 16:15:00',
processDescription: '卡槽损坏需要更换新卡',
processStatus: '处理中',
submitTime: '2025-11-07 09:30:00'
},
{
id: 3,
iccid: '89860621370079892037',
currentPackage: 'Y-NB专享套餐',
cardCompany: 'SXKJ-NB',
hierarchyRelation: '直营',
recipientName: 'HNSXKJ',
recipientPhone: '135****9876',
recipientAddress: '广州市天河区珠江新城花城大道5号',
newSimNumber: '1440012345780',
processor: '李佳音',
processTime: '2025-11-06 11:20:00',
processDescription: '设备升级需要更换新规格SIM卡',
processStatus: '审核中',
submitTime: '2025-11-06 08:45:00'
},
{
id: 4,
iccid: '89860621370079892038',
currentPackage: '100G全国流量月卡套餐',
cardCompany: '联通1-1',
hierarchyRelation: '一级代理->终端用户',
recipientName: '赵强',
recipientPhone: '137****5555',
recipientAddress: '深圳市南山区科技园南区深南大道10000号',
newSimNumber: '1440012345781',
processor: '张若暄',
processTime: '',
processDescription: '',
processStatus: '待处理',
submitTime: '2025-11-05 15:10:00'
},
{
id: 5,
iccid: '89860621370079892039',
currentPackage: '广电飞悦卡无预存50G30天',
cardCompany: '广电4',
hierarchyRelation: '二级代理->终端用户',
recipientName: '李丽',
recipientPhone: '133****7777',
recipientAddress: '杭州市西湖区文三路90号',
newSimNumber: '1440012345782',
processor: '赵强',
processTime: '2025-11-04 09:30:00',
processDescription: '换卡申请已拒绝,原因:信息不完整',
processStatus: '已拒绝',
submitTime: '2025-11-04 08:00:00'
}
]
// 重置表单
const handleReset = () => {
Object.assign(formFilters, { ...initialSearchState })
pagination.currentPage = 1
getCardReplacementList()
}
// 搜索处理
const handleSearch = () => {
console.log('搜索参数:', formFilters)
pagination.currentPage = 1
getCardReplacementList()
}
// 表单项变更处理
const handleFormChange = (params: SearchChangeParams): void => {
console.log('表单项变更:', params)
}
// 表单配置项
const formItems: SearchFormItem[] = [
{
label: '处理结果',
prop: 'processResult',
type: 'select',
config: {
clearable: true,
placeholder: '全部'
},
options: () => [
{ label: '处理完成', value: 'completed' },
{ label: '处理中', value: 'processing' },
{ label: '审核中', value: 'reviewing' },
{ label: '待处理', value: 'pending' },
{ label: '已拒绝', value: 'rejected' }
],
onChange: handleFormChange
},
{
label: '开卡公司',
prop: 'cardCompany',
type: 'select',
config: {
clearable: true,
placeholder: '全部'
},
options: () => [
{ label: '联通2', value: 'unicom2' },
{ label: '联通36', value: 'unicom36' },
{ label: 'SXKJ-NB', value: 'sxkj_nb' },
{ label: '联通1-1', value: 'unicom1_1' },
{ label: '联通8', value: 'unicom8' },
{ label: '移动21', value: 'mobile21' },
{ label: '广电4', value: 'gdtv4' },
{ label: '电信9', value: 'telecom9' }
],
onChange: handleFormChange
},
{
label: '换卡SIM号',
prop: 'oldSimNumber',
type: 'input',
config: {
clearable: true,
placeholder: '请输入原SIM号'
},
onChange: handleFormChange
},
{
label: '新SIM号',
prop: 'newSimNumber',
type: 'input',
config: {
clearable: true,
placeholder: '请输入新SIM号'
},
onChange: handleFormChange
},
{
label: '新卡运营商',
prop: 'newCardOperator',
type: 'select',
config: {
clearable: true,
placeholder: '全部'
},
options: () => [
{ label: '中国移动', value: 'mobile' },
{ label: '中国联通', value: 'unicom' },
{ label: '中国电信', value: 'telecom' },
{ label: 'GS移动', value: 'gs_mobile' },
{ label: 'DC物联', value: 'dc_iot' },
{ label: 'GDWL', value: 'gdwl' }
],
onChange: handleFormChange
},
{
label: '提交时间',
prop: 'submitDateRange',
type: 'daterange',
config: {
type: 'daterange',
startPlaceholder: '开始时间',
endPlaceholder: '结束时间'
},
onChange: handleFormChange
}
]
// 列配置
const columnOptions = [
{ label: '勾选', type: 'selection' },
{ label: 'ICCID号', prop: 'iccid' },
{ label: '当前套餐', prop: 'currentPackage' },
{ label: '开卡公司', prop: 'cardCompany' },
{ label: '层级关系', prop: 'hierarchyRelation' },
{ label: '收货人姓名', prop: 'recipientName' },
{ label: '收货人电话', prop: 'recipientPhone' },
{ label: '收货人地址', prop: 'recipientAddress' },
{ label: '新sim号', prop: 'newSimNumber' },
{ label: '处理人', prop: 'processor' },
{ label: '处理时间', prop: 'processTime' },
{ label: '处理描述', prop: 'processDescription' },
{ label: '处理状态', prop: 'processStatus' },
{ label: '提交时间', prop: 'submitTime' },
{ label: '操作', prop: 'operation' }
]
// 获取处理状态标签类型
const getProcessStatusType = (status: string) => {
switch (status) {
case '处理完成':
return 'success'
case '处理中':
return 'warning'
case '审核中':
return 'info'
case '待处理':
return 'primary'
case '已拒绝':
return 'danger'
default:
return 'info'
}
}
// 导出Excel
const exportExcel = () => {
if (selectedRows.value.length === 0) {
ElMessage.warning('请先选择要导出的数据')
return
}
ElMessage.success(`导出 ${selectedRows.value.length} 条换卡记录`)
}
// 显示导入对话框
const showImportDialog = () => {
importDialogVisible.value = true
// 重置表单
if (importFormRef.value) {
importFormRef.value.resetFields()
}
importFormData.excelFile = null
importFormData.remark = ''
}
// 下载模板
const downloadTemplate = () => {
ElMessage.success('正在下载换卡记录导入模板...')
// 这里可以实现实际的模板下载功能
}
// 查看详情
const viewDetails = (row: any) => {
ElMessage.info(`查看换卡详情: ${row.iccid}`)
}
// 编辑记录
const editRecord = (row: any) => {
ElMessage.info(`编辑换卡记录: ${row.iccid}`)
}
// 删除记录
const deleteRecord = (row: any) => {
ElMessageBox.confirm(`确定要删除该换卡记录吗?`, '删除确认', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
.then(() => {
ElMessage.success('删除成功')
getCardReplacementList()
})
.catch(() => {
ElMessage.info('已取消删除')
})
}
// 处理换卡申请
const processApplication = (row: any) => {
if (row.processStatus === '处理完成' || row.processStatus === '已拒绝') {
ElMessage.warning('该申请已处理完成')
return
}
ElMessage.info(`处理换卡申请: ${row.iccid}`)
// 这里可以打开处理对话框或跳转到处理页面
}
// 动态列配置
const { columnChecks, columns } = useCheckedColumns(() => [
{ type: 'selection' },
{
prop: 'iccid',
label: 'ICCID号',
minWidth: 180
},
{
prop: 'currentPackage',
label: '当前套餐',
minWidth: 200
},
{
prop: 'cardCompany',
label: '开卡公司',
width: 120
},
{
prop: 'hierarchyRelation',
label: '层级关系',
minWidth: 150
},
{
prop: 'recipientName',
label: '收货人姓名',
width: 120
},
{
prop: 'recipientPhone',
label: '收货人电话',
width: 130
},
{
prop: 'recipientAddress',
label: '收货人地址',
minWidth: 200
},
{
prop: 'newSimNumber',
label: '新sim号',
width: 140
},
{
prop: 'processor',
label: '处理人',
width: 100
},
{
prop: 'processTime',
label: '处理时间',
width: 160
},
{
prop: 'processDescription',
label: '处理描述',
minWidth: 200
},
{
prop: 'processStatus',
label: '处理状态',
width: 120,
formatter: (row) => {
return h(ElTag, { type: getProcessStatusType(row.processStatus) }, () => row.processStatus)
}
},
{
prop: 'submitTime',
label: '提交时间',
width: 160
},
{
prop: 'operation',
label: '操作',
width: 280,
formatter: (row: any) => {
return h('div', { class: 'operation-buttons' }, [
h(ArtButtonTable, {
text: '查看',
onClick: () => viewDetails(row)
}),
h(ArtButtonTable, {
text: '处理',
disabled: row.processStatus === '处理完成' || row.processStatus === '已拒绝',
onClick: () => processApplication(row)
}),
h(ArtButtonTable, {
text: '编辑',
onClick: () => editRecord(row)
}),
h(ArtButtonTable, {
text: '删除',
onClick: () => deleteRecord(row)
})
])
}
}
])
onMounted(() => {
getCardReplacementList()
})
// 获取换卡管理列表
const getCardReplacementList = async () => {
loading.value = true
try {
// 模拟API调用
await new Promise((resolve) => setTimeout(resolve, 500))
const startIndex = (pagination.currentPage - 1) * pagination.pageSize
const endIndex = startIndex + pagination.pageSize
const paginatedData = mockData.slice(startIndex, endIndex)
tableData.value = paginatedData
pagination.total = mockData.length
loading.value = false
} catch (error) {
console.error('获取换卡管理列表失败:', error)
loading.value = false
}
}
const handleRefresh = () => {
getCardReplacementList()
}
// 处理表格行选择变化
const handleSelectionChange = (selection: any[]) => {
selectedRows.value = selection
}
// 处理表格分页变化
const handleSizeChange = (newPageSize: number) => {
pagination.pageSize = newPageSize
getCardReplacementList()
}
const handleCurrentChange = (newCurrentPage: number) => {
pagination.currentPage = newCurrentPage
getCardReplacementList()
}
// 文件上传限制
const handleExceed = () => {
ElMessage.warning('最多只能上传一个文件')
}
// 文件上传前检查
const beforeUpload = (file: UploadRawFile) => {
const isExcel =
file.type === 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' ||
file.type === 'application/vnd.ms-excel'
const isLt10M = file.size / 1024 / 1024 < 10
if (!isExcel) {
ElMessage.error('只能上传 Excel 文件!')
return false
}
if (!isLt10M) {
ElMessage.error('上传文件大小不能超过 10MB!')
return false
}
return false // 阻止自动上传
}
// 文件变化处理
const handleFileChange = (file: UploadFile) => {
if (file.raw) {
importFormData.excelFile = file.raw
}
}
// 导入表单验证规则
const importRules = reactive<FormRules>({
excelFile: [{ required: true, message: '请上传Excel文件', trigger: 'change' }]
})
// 提交导入
const handleImportSubmit = async () => {
if (!importFormRef.value) return
// 检查文件是否上传
if (!importFormData.excelFile) {
ElMessage.error('请先上传Excel文件')
return
}
await importFormRef.value.validate((valid) => {
if (valid) {
importLoading.value = true
// 模拟导入过程
setTimeout(() => {
ElMessage.success('换卡记录导入成功!')
importDialogVisible.value = false
importLoading.value = false
getCardReplacementList()
}, 2000)
}
})
}
</script>
<style lang="scss" scoped>
.card-replacement-page {
// 可以添加特定样式
}
.template-section {
display: flex;
gap: 12px;
align-items: center;
margin-bottom: 16px;
.template-tip {
font-size: 12px;
color: #909399;
}
}
:deep(.operation-buttons) {
display: flex;
gap: 8px;
}
.dialog-footer {
text-align: right;
}
:deep(.el-upload-dragger) {
padding: 40px;
}
:deep(.el-form-item) {
margin-bottom: 20px;
}
</style>

View File

@@ -0,0 +1,460 @@
<template>
<ArtTableFullScreen>
<div class="card-shutdown-page" id="table-full-screen">
<ElCard shadow="never" class="art-table-card">
<!-- 表格头部 -->
<ArtTableHeader
:columnList="columnOptions"
v-model:columns="columnChecks"
@refresh="handleRefresh"
>
<template #left>
<ElButton type="primary" @click="showShutdownDialog">设置停机时间</ElButton>
</template>
</ArtTableHeader>
<!-- 表格 -->
<ArtTable
ref="tableRef"
row-key="id"
:loading="loading"
:data="tableData"
:currentPage="pagination.currentPage"
:pageSize="pagination.pageSize"
:total="pagination.total"
:marginTop="10"
@selection-change="handleSelectionChange"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
>
<template #default>
<ElTableColumn v-for="col in columns" :key="col.prop || col.type" v-bind="col" />
</template>
</ArtTable>
<!-- 设置停机时间对话框 -->
<ElDialog
v-model="dialogVisible"
title="设置停机时间"
width="500px"
align-center
:close-on-click-modal="false"
>
<ElForm ref="formRef" :model="formData" :rules="rules" label-width="120px">
<ElFormItem label="停机类型" prop="shutdownType">
<ElSelect
v-model="formData.shutdownType"
placeholder="请选择停机类型"
style="width: 100%"
clearable
>
<ElOption label="立即停机" value="immediate" />
<ElOption label="定时停机" value="scheduled" />
<ElOption label="批量停机" value="batch" />
</ElSelect>
</ElFormItem>
<ElFormItem
label="停机时间"
prop="shutdownTime"
v-if="formData.shutdownType === 'scheduled'"
>
<ElDatePicker
v-model="formData.shutdownTime"
type="datetime"
placeholder="请选择停机时间"
style="width: 100%"
format="YYYY-MM-DD HH:mm:ss"
value-format="YYYY-MM-DD HH:mm:ss"
/>
</ElFormItem>
<ElFormItem label="停机原因" prop="reason">
<ElInput
v-model="formData.reason"
type="textarea"
placeholder="请输入停机原因"
:rows="3"
maxlength="200"
show-word-limit
/>
</ElFormItem>
<ElFormItem label="影响范围" prop="affectedCards">
<ElInputNumber
v-model="formData.affectedCards"
:min="1"
:max="10000"
placeholder="请输入影响的网卡数量"
style="width: 100%"
/>
</ElFormItem>
<ElFormItem label="通知方式" prop="notifyMethod">
<ElCheckboxGroup v-model="formData.notifyMethod">
<ElCheckbox label="短信通知">短信通知</ElCheckbox>
<ElCheckbox label="邮件通知">邮件通知</ElCheckbox>
<ElCheckbox label="系统通知">系统通知</ElCheckbox>
</ElCheckboxGroup>
</ElFormItem>
</ElForm>
<template #footer>
<div class="dialog-footer">
<ElButton @click="dialogVisible = false">取消</ElButton>
<ElButton type="primary" @click="handleSubmit" :loading="submitLoading">
确认设置
</ElButton>
</div>
</template>
</ElDialog>
</ElCard>
</div>
</ArtTableFullScreen>
</template>
<script setup lang="ts">
import { h } from 'vue'
import {
ElTag,
ElMessage,
ElMessageBox,
ElSelect,
ElOption,
ElDatePicker,
ElInputNumber,
ElCheckboxGroup,
ElCheckbox
} from 'element-plus'
import type { FormInstance } from 'element-plus'
import type { FormRules } from 'element-plus'
import { useCheckedColumns } from '@/composables/useCheckedColumns'
import ArtButtonTable from '@/components/core/forms/ArtButtonTable.vue'
defineOptions({ name: 'CardShutdown' })
const dialogVisible = ref(false)
const loading = ref(false)
const submitLoading = ref(false)
const pagination = reactive({
currentPage: 1,
pageSize: 20,
total: 0
})
// 表格数据
const tableData = ref<any[]>([])
// 表格实例引用
const tableRef = ref()
// 选中的行数据
const selectedRows = ref<any[]>([])
// 表单实例
const formRef = ref<FormInstance>()
// 表单数据
const formData = reactive({
shutdownType: '',
shutdownTime: '',
reason: '',
affectedCards: 1,
notifyMethod: [] as string[]
})
// 模拟数据
const mockData = [
{
id: 1,
totalCount: 1000,
operationTime: '2025-11-08 14:30:00',
successCount: 998,
failureCount: 2,
operationType: '立即停机',
operator: '张若暄',
reason: '系统维护'
},
{
id: 2,
totalCount: 500,
operationTime: '2025-11-07 10:15:00',
successCount: 500,
failureCount: 0,
operationType: '定时停机',
operator: '孔丽娟',
reason: '网络升级'
},
{
id: 3,
totalCount: 200,
operationTime: '2025-11-06 16:45:00',
successCount: 195,
failureCount: 5,
operationType: '批量停机',
operator: '张若暄',
reason: '安全检查'
},
{
id: 4,
totalCount: 1500,
operationTime: '2025-11-05 09:30:00',
successCount: 1500,
failureCount: 0,
operationType: '立即停机',
operator: '李佳音',
reason: '应急处理'
},
{
id: 5,
totalCount: 800,
operationTime: '2025-11-04 13:20:00',
successCount: 792,
failureCount: 8,
operationType: '定时停机',
operator: '张若暄',
reason: '定期维护'
}
]
// 列配置
const columnOptions = [
{ label: '勾选', type: 'selection' },
{ label: '总数', prop: 'totalCount' },
{ label: '时间', prop: 'operationTime' },
{ label: '成功', prop: 'successCount' },
{ label: '失败', prop: 'failureCount' },
{ label: '操作类型', prop: 'operationType' },
{ label: '操作', prop: 'operation' }
]
// 获取操作类型标签类型
const getOperationTagType = (type: string) => {
switch (type) {
case '立即停机':
return 'danger'
case '定时停机':
return 'warning'
case '批量停机':
return 'info'
default:
return 'primary'
}
}
// 查看详情
const viewDetails = (row: any) => {
ElMessage.info(`查看停机操作的详细信息: ${row.operationType}`)
}
// 删除记录
const deleteRecord = (row: any) => {
ElMessageBox.confirm(`确定要删除该停机记录吗?`, '删除确认', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
.then(() => {
ElMessage.success('删除成功')
getShutdownList()
})
.catch(() => {
ElMessage.info('已取消删除')
})
}
// 重新执行
const retryOperation = (row: any) => {
ElMessageBox.confirm(`确定要重新执行该停机操作吗?`, '操作确认', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'info'
})
.then(() => {
ElMessage.success('重新执行成功')
getShutdownList()
})
.catch(() => {
ElMessage.info('已取消操作')
})
}
// 动态列配置
const { columnChecks, columns } = useCheckedColumns(() => [
{ type: 'selection' },
{
prop: 'totalCount',
label: '总数',
formatter: (row) => `${row.totalCount} `
},
{
prop: 'operationTime',
label: '时间'
},
{
prop: 'successCount',
label: '成功',
formatter: (row) => {
return h(ElTag, { type: 'success' }, () => `${row.successCount}`)
}
},
{
prop: 'failureCount',
label: '失败',
formatter: (row) => {
return h(
ElTag,
{ type: row.failureCount > 0 ? 'danger' : 'success' },
() => `${row.failureCount}`
)
}
},
{
prop: 'operationType',
label: '操作类型',
formatter: (row) => {
return h(ElTag, { type: getOperationTagType(row.operationType) }, () => row.operationType)
}
},
{
prop: 'operation',
label: '操作',
width: 220,
formatter: (row: any) => {
return h('div', { class: 'operation-buttons' }, [
h(ArtButtonTable, {
text: '查看',
onClick: () => viewDetails(row)
}),
h(ArtButtonTable, {
text: '重试',
onClick: () => retryOperation(row)
}),
h(ArtButtonTable, {
text: '删除',
onClick: () => deleteRecord(row)
})
])
}
}
])
onMounted(() => {
getShutdownList()
})
// 获取停机管理列表
const getShutdownList = async () => {
loading.value = true
try {
// 模拟API调用
await new Promise((resolve) => setTimeout(resolve, 500))
const startIndex = (pagination.currentPage - 1) * pagination.pageSize
const endIndex = startIndex + pagination.pageSize
const paginatedData = mockData.slice(startIndex, endIndex)
tableData.value = paginatedData
pagination.total = mockData.length
loading.value = false
} catch (error) {
console.error('获取停机管理列表失败:', error)
loading.value = false
}
}
const handleRefresh = () => {
getShutdownList()
}
// 处理表格行选择变化
const handleSelectionChange = (selection: any[]) => {
selectedRows.value = selection
}
// 处理表格分页变化
const handleSizeChange = (newPageSize: number) => {
pagination.pageSize = newPageSize
getShutdownList()
}
const handleCurrentChange = (newCurrentPage: number) => {
pagination.currentPage = newCurrentPage
getShutdownList()
}
// 显示停机设置对话框
const showShutdownDialog = () => {
dialogVisible.value = true
// 重置表单
if (formRef.value) {
formRef.value.resetFields()
}
formData.shutdownType = ''
formData.shutdownTime = ''
formData.reason = ''
formData.affectedCards = 1
formData.notifyMethod = []
}
// 表单验证规则
const rules = reactive<FormRules>({
shutdownType: [{ required: true, message: '请选择停机类型', trigger: 'change' }],
shutdownTime: [{ required: true, message: '请选择停机时间', trigger: 'change' }],
reason: [
{ required: true, message: '请输入停机原因', trigger: 'blur' },
{ min: 5, max: 200, message: '停机原因长度在 5 到 200 个字符', trigger: 'blur' }
],
affectedCards: [{ required: true, message: '请输入影响的网卡数量', trigger: 'blur' }]
})
// 提交表单
const handleSubmit = async () => {
if (!formRef.value) return
// 如果是定时停机,验证停机时间
if (formData.shutdownType === 'scheduled' && !formData.shutdownTime) {
ElMessage.error('定时停机必须选择停机时间')
return
}
await formRef.value.validate((valid) => {
if (valid) {
submitLoading.value = true
// 模拟提交过程
setTimeout(() => {
ElMessage.success(
`停机设置成功!类型:${formData.shutdownType},影响网卡:${formData.affectedCards}`
)
dialogVisible.value = false
submitLoading.value = false
getShutdownList()
}, 2000)
}
})
}
</script>
<style lang="scss" scoped>
.card-shutdown-page {
// 可以添加特定样式
}
:deep(.operation-buttons) {
display: flex;
gap: 8px;
}
.dialog-footer {
text-align: right;
}
:deep(.el-form-item) {
margin-bottom: 20px;
}
</style>

View File

@@ -0,0 +1,739 @@
<template>
<ArtTableFullScreen>
<div class="card-transfer-page" id="table-full-screen">
<!-- 搜索栏 -->
<ArtSearchBar
v-model:filter="formFilters"
:items="formItems"
@reset="handleReset"
@search="handleSearch"
></ArtSearchBar>
<ElCard shadow="never" class="art-table-card">
<!-- 表格头部 -->
<ArtTableHeader
:columnList="columnOptions"
v-model:columns="columnChecks"
@refresh="handleRefresh"
>
<template #left>
<ElButton type="primary" @click="handleSearch">搜索</ElButton>
<ElButton type="success" @click="showBatchTransferDialog">批量转卡</ElButton>
<ElButton @click="exportData">导出</ElButton>
</template>
</ArtTableHeader>
<!-- 表格 -->
<ArtTable
ref="tableRef"
row-key="id"
:loading="loading"
:data="tableData"
:currentPage="pagination.currentPage"
:pageSize="pagination.pageSize"
:total="pagination.total"
:marginTop="10"
@selection-change="handleSelectionChange"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
>
<template #default>
<ElTableColumn v-for="col in columns" :key="col.prop || col.type" v-bind="col" />
</template>
</ArtTable>
<!-- 批量转卡对话框 -->
<ElDialog
v-model="dialogVisible"
title="批量转卡"
width="600px"
align-center
:close-on-click-modal="false"
>
<ElForm ref="formRef" :model="formData" :rules="rules" label-width="120px">
<ElFormItem label="转卡类型" prop="transferType">
<ElSelect
v-model="formData.transferType"
placeholder="请选择转卡类型"
style="width: 100%"
clearable
>
<ElOption label="运营商转接" value="operator_transfer" />
<ElOption label="套餐转换" value="package_transfer" />
<ElOption label="紧急转接" value="emergency_transfer" />
<ElOption label="升级转接" value="upgrade_transfer" />
</ElSelect>
</ElFormItem>
<ElFormItem label="目标运营商" prop="targetOperator">
<ElSelect
v-model="formData.targetOperator"
placeholder="请选择目标运营商"
style="width: 100%"
clearable
>
<ElOption
v-for="operator in operatorOptions"
:key="operator.value"
:label="operator.label"
:value="operator.value"
/>
</ElSelect>
</ElFormItem>
<ElFormItem label="目标套餐" prop="targetPackage">
<ElSelect
v-model="formData.targetPackage"
placeholder="请选择目标套餐"
style="width: 100%"
clearable
>
<ElOption
v-for="pkg in packageOptions"
:key="pkg.value"
:label="pkg.label"
:value="pkg.value"
/>
</ElSelect>
</ElFormItem>
<ElFormItem label="生效时间" prop="effectiveTime">
<ElDatePicker
v-model="formData.effectiveTime"
type="datetime"
placeholder="请选择生效时间"
style="width: 100%"
format="YYYY-MM-DD HH:mm:ss"
value-format="YYYY-MM-DD HH:mm:ss"
/>
</ElFormItem>
<ElFormItem label="是否保留流量" prop="keepTraffic">
<ElRadioGroup v-model="formData.keepTraffic">
<ElRadio :label="true">保留剩余流量</ElRadio>
<ElRadio :label="false">清零重新计算</ElRadio>
</ElRadioGroup>
</ElFormItem>
<ElFormItem label="转卡原因" prop="reason">
<ElInput
v-model="formData.reason"
type="textarea"
placeholder="请输入转卡原因"
:rows="3"
maxlength="200"
show-word-limit
/>
</ElFormItem>
</ElForm>
<template #footer>
<div class="dialog-footer">
<ElButton @click="dialogVisible = false">取消</ElButton>
<ElButton type="primary" @click="handleSubmit" :loading="submitLoading">
确认转卡
</ElButton>
</div>
</template>
</ElDialog>
</ElCard>
</div>
</ArtTableFullScreen>
</template>
<script setup lang="ts">
import { h } from 'vue'
import {
ElTag,
ElMessage,
ElMessageBox,
ElSelect,
ElOption,
ElDatePicker,
ElRadioGroup,
ElRadio
} from 'element-plus'
import type { FormInstance } from 'element-plus'
import type { FormRules } from 'element-plus'
import { useCheckedColumns } from '@/composables/useCheckedColumns'
import ArtButtonTable from '@/components/core/forms/ArtButtonTable.vue'
import { SearchChangeParams, SearchFormItem } from '@/types'
defineOptions({ name: 'CardTransfer' })
const dialogVisible = ref(false)
const loading = ref(false)
const submitLoading = ref(false)
// 定义表单搜索初始值
const initialSearchState = {
oldOperator: '',
transferType: '',
iccid: '',
transferDateRange: '',
newOperator: '',
status: ''
}
// 响应式表单数据
const formFilters = reactive({ ...initialSearchState })
const pagination = reactive({
currentPage: 1,
pageSize: 20,
total: 0
})
// 表格数据
const tableData = ref<any[]>([])
// 表格实例引用
const tableRef = ref()
// 选中的行数据
const selectedRows = ref<any[]>([])
// 表单实例
const formRef = ref<FormInstance>()
// 表单数据
const formData = reactive({
transferType: '',
targetOperator: '',
targetPackage: '',
effectiveTime: '',
keepTraffic: true,
reason: ''
})
// 运营商选项
const operatorOptions = ref([
{ label: '中国移动', value: 'mobile' },
{ label: '中国联通', value: 'unicom' },
{ label: '中国电信', value: 'telecom' },
{ label: 'GS移动', value: 'gs_mobile' },
{ label: 'DC物联', value: 'dc_iot' },
{ label: 'GDWL', value: 'gdwl' },
{ label: 'GS联通', value: 'gs_unicom' },
{ label: 'GS电信', value: 'gs_telecom' }
])
// 套餐选项
const packageOptions = ref([
{ label: '随意联畅玩年卡套餐12个月', value: 'package1' },
{ label: '如意包年3G流量包', value: 'package2' },
{ label: 'Y-NB专享套餐', value: 'package3' },
{ label: '100G全国流量月卡套餐', value: 'package4' },
{ label: '广电飞悦卡无预存50G30天', value: 'package5' },
{ label: '5G畅享套餐', value: 'package6' },
{ label: '移动物联网专用套餐', value: 'package7' },
{ label: '电信天翼套餐', value: 'package8' }
])
// 模拟数据
const mockData = [
{
id: 1,
oldCardNumber: '89860621370079892035',
oldAccessNumber: '1440012345678',
newCardNumber: '89860621370079892135',
newAccessNumber: '1440012345778',
transferType: '运营商转接',
oldOperator: '中国移动',
newOperator: '中国联通',
packageName: '随意联畅玩年卡套餐12个月',
totalTraffic: '100GB',
usedTraffic: '25.5GB',
transferTime: '2025-11-08 10:30:00',
operator: '张若暄',
status: '转接成功'
},
{
id: 2,
oldCardNumber: '89860621370079892036',
oldAccessNumber: '1440012345679',
newCardNumber: '89860621370079892136',
newAccessNumber: '1440012345779',
transferType: '套餐转换',
oldOperator: '中国联通',
newOperator: '中国联通',
packageName: '如意包年3G流量包',
totalTraffic: '50GB',
usedTraffic: '12.8GB',
transferTime: '2025-11-07 14:15:00',
operator: '孔丽娟',
status: '转接进行中'
},
{
id: 3,
oldCardNumber: '89860621370079892037',
oldAccessNumber: '1440012345680',
newCardNumber: '89860621370079892137',
newAccessNumber: '1440012345780',
transferType: '紧急转接',
oldOperator: 'GS移动',
newOperator: '中国移动',
packageName: 'Y-NB专享套餐',
totalTraffic: '30GB',
usedTraffic: '28.9GB',
transferTime: '2025-11-06 09:45:00',
operator: '李佳音',
status: '转接失败'
},
{
id: 4,
oldCardNumber: '89860621370079892038',
oldAccessNumber: '1440012345681',
newCardNumber: '89860621370079892138',
newAccessNumber: '1440012345781',
transferType: '升级转接',
oldOperator: '中国电信',
newOperator: '中国电信',
packageName: '100G全国流量月卡套餐',
totalTraffic: '100GB',
usedTraffic: '0GB',
transferTime: '2025-11-05 16:20:00',
operator: '赵强',
status: '等待确认'
},
{
id: 5,
oldCardNumber: '89860621370079892039',
oldAccessNumber: '1440012345682',
newCardNumber: '89860621370079892139',
newAccessNumber: '1440012345782',
transferType: '运营商转接',
oldOperator: 'GDWL',
newOperator: '中国联通',
packageName: '广电飞悦卡无预存50G30天',
totalTraffic: '50GB',
usedTraffic: '35.2GB',
transferTime: '2025-11-04 11:30:00',
operator: '张若暄',
status: '转接成功'
}
]
// 重置表单
const handleReset = () => {
Object.assign(formFilters, { ...initialSearchState })
pagination.currentPage = 1
getCardTransferList()
}
// 搜索处理
const handleSearch = () => {
console.log('搜索参数:', formFilters)
pagination.currentPage = 1
getCardTransferList()
}
// 表单项变更处理
const handleFormChange = (params: SearchChangeParams): void => {
console.log('表单项变更:', params)
}
// 表单配置项
const formItems: SearchFormItem[] = [
{
label: '老卡运营商',
prop: 'oldOperator',
type: 'select',
config: {
clearable: true,
placeholder: '全部'
},
options: () => operatorOptions.value,
onChange: handleFormChange
},
{
label: '类型',
prop: 'transferType',
type: 'select',
config: {
clearable: true,
placeholder: '全部'
},
options: () => [
{ label: '运营商转接', value: 'operator_transfer' },
{ label: '套餐转换', value: 'package_transfer' },
{ label: '紧急转接', value: 'emergency_transfer' },
{ label: '升级转接', value: 'upgrade_transfer' }
],
onChange: handleFormChange
},
{
label: 'ICCID',
prop: 'iccid',
type: 'input',
config: {
clearable: true,
placeholder: '请输入ICCID'
},
onChange: handleFormChange
},
{
label: '转卡时间',
prop: 'transferDateRange',
type: 'daterange',
config: {
type: 'daterange',
startPlaceholder: '开始时间',
endPlaceholder: '结束时间'
},
onChange: handleFormChange
},
{
label: '新卡运营商',
prop: 'newOperator',
type: 'select',
config: {
clearable: true,
placeholder: '全部'
},
options: () => operatorOptions.value,
onChange: handleFormChange
},
{
label: '状态',
prop: 'status',
type: 'select',
config: {
clearable: true,
placeholder: '全部'
},
options: () => [
{ label: '转接成功', value: 'success' },
{ label: '转接进行中', value: 'in_progress' },
{ label: '转接失败', value: 'failed' },
{ label: '等待确认', value: 'pending' }
],
onChange: handleFormChange
}
]
// 列配置
const columnOptions = [
{ label: '勾选', type: 'selection' },
{ label: '老卡卡号', prop: 'oldCardNumber' },
{ label: '老卡接入号', prop: 'oldAccessNumber' },
{ label: '新卡卡号', prop: 'newCardNumber' },
{ label: '新卡接入号', prop: 'newAccessNumber' },
{ label: '类型', prop: 'transferType' },
{ label: '老卡运营商', prop: 'oldOperator' },
{ label: '新卡运营商', prop: 'newOperator' },
{ label: '套餐名称', prop: 'packageName' },
{ label: '总流量', prop: 'totalTraffic' },
{ label: '转卡已使用流量', prop: 'usedTraffic' },
{ label: '转卡时间', prop: 'transferTime' },
{ label: '操作人', prop: 'operator' },
{ label: '状态', prop: 'status' },
{ label: '操作', prop: 'operation' }
]
// 获取状态标签类型
const getStatusType = (status: string) => {
switch (status) {
case '转接成功':
return 'success'
case '转接进行中':
return 'warning'
case '转接失败':
return 'danger'
case '等待确认':
return 'info'
default:
return 'info'
}
}
// 获取转卡类型标签类型
const getTransferTypeTagType = (type: string) => {
switch (type) {
case '运营商转接':
return 'primary'
case '套餐转换':
return 'success'
case '紧急转接':
return 'danger'
case '升级转接':
return 'warning'
default:
return 'info'
}
}
// 导出数据
const exportData = () => {
if (selectedRows.value.length === 0) {
ElMessage.warning('请先选择要导出的数据')
return
}
ElMessage.success(`导出 ${selectedRows.value.length} 条转卡记录`)
}
// 查看详情
const viewDetails = (row: any) => {
ElMessage.info(`查看转卡详情: ${row.oldCardNumber} -> ${row.newCardNumber}`)
}
// 取消转卡
const cancelTransfer = (row: any) => {
if (row.status === '转接成功') {
ElMessage.warning('已成功转接的记录无法取消')
return
}
ElMessageBox.confirm(`确定要取消该转卡操作吗?`, '取消确认', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
.then(() => {
ElMessage.success('转卡操作已取消')
getCardTransferList()
})
.catch(() => {
ElMessage.info('已取消操作')
})
}
// 重新转卡
const retryTransfer = (row: any) => {
if (row.status !== '转接失败') {
ElMessage.warning('只有失败的记录才能重新转卡')
return
}
ElMessageBox.confirm(`确定要重新执行转卡操作吗?`, '操作确认', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'info'
})
.then(() => {
ElMessage.success('重新转卡操作已开始')
getCardTransferList()
})
.catch(() => {
ElMessage.info('已取消操作')
})
}
// 动态列配置
const { columnChecks, columns } = useCheckedColumns(() => [
{ type: 'selection' },
{
prop: 'oldCardNumber',
label: '老卡卡号',
minWidth: 180
},
{
prop: 'oldAccessNumber',
label: '老卡接入号',
width: 140
},
{
prop: 'newCardNumber',
label: '新卡卡号',
minWidth: 180
},
{
prop: 'newAccessNumber',
label: '新卡接入号',
width: 140
},
{
prop: 'transferType',
label: '类型',
width: 120,
formatter: (row) => {
return h(ElTag, { type: getTransferTypeTagType(row.transferType) }, () => row.transferType)
}
},
{
prop: 'oldOperator',
label: '老卡运营商',
width: 120
},
{
prop: 'newOperator',
label: '新卡运营商',
width: 120
},
{
prop: 'packageName',
label: '套餐名称',
minWidth: 200
},
{
prop: 'totalTraffic',
label: '总流量',
width: 100
},
{
prop: 'usedTraffic',
label: '转卡已使用流量',
width: 140
},
{
prop: 'transferTime',
label: '转卡时间',
width: 160
},
{
prop: 'operator',
label: '操作人',
width: 100
},
{
prop: 'status',
label: '状态',
width: 120,
formatter: (row) => {
return h(ElTag, { type: getStatusType(row.status) }, () => row.status)
}
},
{
prop: 'operation',
label: '操作',
width: 220,
formatter: (row: any) => {
return h('div', { class: 'operation-buttons' }, [
h(ArtButtonTable, {
text: '查看',
onClick: () => viewDetails(row)
}),
h(ArtButtonTable, {
text: '重试',
disabled: row.status !== '转接失败',
onClick: () => retryTransfer(row)
}),
h(ArtButtonTable, {
text: '取消',
disabled: row.status === '转接成功',
onClick: () => cancelTransfer(row)
})
])
}
}
])
onMounted(() => {
getCardTransferList()
})
// 获取网卡转接列表
const getCardTransferList = async () => {
loading.value = true
try {
// 模拟API调用
await new Promise((resolve) => setTimeout(resolve, 500))
const startIndex = (pagination.currentPage - 1) * pagination.pageSize
const endIndex = startIndex + pagination.pageSize
const paginatedData = mockData.slice(startIndex, endIndex)
tableData.value = paginatedData
pagination.total = mockData.length
loading.value = false
} catch (error) {
console.error('获取网卡转接列表失败:', error)
loading.value = false
}
}
const handleRefresh = () => {
getCardTransferList()
}
// 处理表格行选择变化
const handleSelectionChange = (selection: any[]) => {
selectedRows.value = selection
}
// 处理表格分页变化
const handleSizeChange = (newPageSize: number) => {
pagination.pageSize = newPageSize
getCardTransferList()
}
const handleCurrentChange = (newCurrentPage: number) => {
pagination.currentPage = newCurrentPage
getCardTransferList()
}
// 显示批量转卡对话框
const showBatchTransferDialog = () => {
if (selectedRows.value.length === 0) {
ElMessage.warning('请先选择要转卡的记录')
return
}
dialogVisible.value = true
// 重置表单
if (formRef.value) {
formRef.value.resetFields()
}
formData.transferType = ''
formData.targetOperator = ''
formData.targetPackage = ''
formData.effectiveTime = ''
formData.keepTraffic = true
formData.reason = ''
}
// 表单验证规则
const rules = reactive<FormRules>({
transferType: [{ required: true, message: '请选择转卡类型', trigger: 'change' }],
targetOperator: [{ required: true, message: '请选择目标运营商', trigger: 'change' }],
targetPackage: [{ required: true, message: '请选择目标套餐', trigger: 'change' }],
effectiveTime: [{ required: true, message: '请选择生效时间', trigger: 'change' }],
reason: [
{ required: true, message: '请输入转卡原因', trigger: 'blur' },
{ min: 5, max: 200, message: '转卡原因长度在 5 到 200 个字符', trigger: 'blur' }
]
})
// 提交表单
const handleSubmit = async () => {
if (!formRef.value) return
await formRef.value.validate((valid) => {
if (valid) {
submitLoading.value = true
// 模拟提交过程
setTimeout(() => {
const targetOperatorName = operatorOptions.value.find(
(op) => op.value === formData.targetOperator
)?.label
const targetPackageName = packageOptions.value.find(
(pkg) => pkg.value === formData.targetPackage
)?.label
ElMessage.success(
`批量转卡操作提交成功!目标运营商:${targetOperatorName},目标套餐:${targetPackageName},影响${selectedRows.value.length}张网卡`
)
dialogVisible.value = false
submitLoading.value = false
getCardTransferList()
}, 2000)
}
})
}
</script>
<style lang="scss" scoped>
.card-transfer-page {
// 可以添加特定样式
}
:deep(.operation-buttons) {
display: flex;
gap: 8px;
}
.dialog-footer {
text-align: right;
}
:deep(.el-form-item) {
margin-bottom: 20px;
}
</style>

View File

@@ -0,0 +1,699 @@
<template>
<ArtTableFullScreen>
<div class="my-cards-page" id="table-full-screen">
<!-- 搜索栏 -->
<ArtSearchBar
v-model:filter="formFilters"
:items="formItems"
@reset="handleReset"
@search="handleSearch"
></ArtSearchBar>
<ElCard shadow="never" class="art-table-card">
<!-- 表格头部 -->
<ArtTableHeader
:columnList="columnOptions"
v-model:columns="columnChecks"
@refresh="handleRefresh"
>
<template #left>
<ElButton @click="exportExcel">导出excel</ElButton>
<ElButton @click="cardDistribution">网卡分销</ElButton>
<ElButton @click="batchRecharge">批量充值</ElButton>
<ElButton @click="cardRecycle">网卡回收</ElButton>
</template>
</ArtTableHeader>
<!-- 表格 -->
<ArtTable
ref="tableRef"
row-key="id"
:loading="loading"
:data="tableData"
:currentPage="pagination.currentPage"
:pageSize="pagination.pageSize"
:total="pagination.total"
:marginTop="10"
@selection-change="handleSelectionChange"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
>
<template #default>
<ElTableColumn v-for="col in columns" :key="col.prop || col.type" v-bind="col" />
</template>
</ArtTable>
</ElCard>
<!-- 网卡分销弹框 -->
<CardOperationDialog
v-model:visible="distributionDialogVisible"
title="网卡分销"
select-label="代理商"
select-placeholder="请搜索并选择代理商"
:selected-cards="selectedRows"
:remote-search="searchAgents"
@confirm="handleDistributionConfirm"
@close="handleDialogClose"
/>
<!-- 批量充值弹框 -->
<CardOperationDialog
v-model:visible="rechargeDialogVisible"
title="批量充值"
select-label="套餐"
select-placeholder="请搜索并选择套餐"
:show-amount="true"
:selected-cards="selectedRows"
:remote-search="searchPackages"
@confirm="handleRechargeConfirm"
@close="handleDialogClose"
/>
</div>
</ArtTableFullScreen>
</template>
<script setup lang="ts">
import { h } from 'vue'
import { ElTag, ElMessage, ElMessageBox } from 'element-plus'
import { useCheckedColumns } from '@/composables/useCheckedColumns'
import ArtButtonTable from '@/components/core/forms/ArtButtonTable.vue'
import { SearchChangeParams, SearchFormItem } from '@/types'
import CardOperationDialog from '@/components/business/CardOperationDialog.vue'
defineOptions({ name: 'MyCards' })
const loading = ref(false)
// 弹框状态
const distributionDialogVisible = ref(false)
const rechargeDialogVisible = ref(false)
// 定义表单搜索初始值
const initialSearchState = {
distributor: '',
cardStatus: '',
importDateRange: '',
cardCompany: '',
virtualNumber: '',
iccid: '',
iccidRange: '',
cardPackage: '',
virtualNumberRange: ''
}
// 响应式表单数据
const formFilters = reactive({ ...initialSearchState })
const pagination = reactive({
currentPage: 1,
pageSize: 20,
total: 0
})
// 表格数据
const tableData = ref<any[]>([])
// 表格实例引用
const tableRef = ref()
// 选中的行数据
const selectedRows = ref<any[]>([])
// 模拟数据
const mockData = [
{
id: 1,
cardCompany: '联通2',
iccid: '89860621370079892035',
virtualNumber: '10655001234',
expireTime: '2025-12-31',
packageName: '随意联畅玩年卡套餐12个月',
distributorName: '张丽丽',
cardStatus: '正常'
},
{
id: 2,
cardCompany: '联通36',
iccid: '89860621370079892036',
virtualNumber: '10655001235',
expireTime: '2025-11-30',
packageName: '如意包年3G流量包',
distributorName: '王小明',
cardStatus: '正常'
},
{
id: 3,
cardCompany: 'SXKJ-NB',
iccid: '89860621370079892037',
virtualNumber: '10655001236',
expireTime: '2025-10-31',
packageName: 'Y-NB专享套餐',
distributorName: 'HNSXKJ',
cardStatus: '待激活'
},
{
id: 4,
cardCompany: '联通1-1',
iccid: '89860621370079892038',
virtualNumber: '10655001237',
expireTime: '2026-01-31',
packageName: '100G全国流量月卡套餐',
distributorName: '未分销',
cardStatus: '正常'
},
{
id: 5,
cardCompany: '广电4',
iccid: '89860621370079892039',
virtualNumber: '10655001238',
expireTime: '2025-08-31',
packageName: '广电飞悦卡无预存50G30天',
distributorName: '孔丽娟',
cardStatus: '停机'
},
{
id: 6,
cardCompany: '联通8',
iccid: '89860621370079892040',
virtualNumber: '10655001239',
expireTime: '2025-09-15',
packageName: '5G畅享套餐',
distributorName: '李佳音',
cardStatus: '正常'
},
{
id: 7,
cardCompany: '移动21',
iccid: '89860621370079892041',
virtualNumber: '10655001240',
expireTime: '2025-07-20',
packageName: '移动物联网专用套餐',
distributorName: '赵强',
cardStatus: '已过期'
},
{
id: 8,
cardCompany: '电信9',
iccid: '89860621370079892042',
virtualNumber: '10655001241',
expireTime: '2026-02-28',
packageName: '电信天翼套餐',
distributorName: '未分销',
cardStatus: '正常'
}
]
// 重置表单
const handleReset = () => {
Object.assign(formFilters, { ...initialSearchState })
pagination.currentPage = 1
getMyCardsList()
}
// 搜索处理
const handleSearch = () => {
console.log('搜索参数:', formFilters)
pagination.currentPage = 1
getMyCardsList()
}
// 表单项变更处理
const handleFormChange = (params: SearchChangeParams): void => {
console.log('表单项变更:', params)
}
// 表单配置项
const formItems: SearchFormItem[] = [
{
label: '分销商',
prop: 'distributor',
type: 'input',
config: {
clearable: true,
placeholder: '请输入分销商'
},
onChange: handleFormChange
},
{
label: '网卡状态',
prop: 'cardStatus',
type: 'select',
config: {
clearable: true,
placeholder: '全部'
},
options: () => [
{ label: '正常', value: 'normal' },
{ label: '待激活', value: 'pending' },
{ label: '停机', value: 'suspended' },
{ label: '已过期', value: 'expired' },
{ label: '注销', value: 'cancelled' }
],
onChange: handleFormChange
},
{
label: '导入时间',
prop: 'importDateRange',
type: 'daterange',
config: {
type: 'daterange',
startPlaceholder: '开始时间',
endPlaceholder: '结束时间'
},
onChange: handleFormChange
},
{
label: '开卡公司',
prop: 'cardCompany',
type: 'select',
config: {
clearable: true,
placeholder: '全部'
},
options: () => [
{ label: '联通2', value: 'unicom2' },
{ label: '联通36', value: 'unicom36' },
{ label: 'SXKJ-NB', value: 'sxkj_nb' },
{ label: '联通1-1', value: 'unicom1_1' },
{ label: '联通8', value: 'unicom8' },
{ label: '移动21', value: 'mobile21' },
{ label: '广电4', value: 'gdtv4' },
{ label: '电信9', value: 'telecom9' }
],
onChange: handleFormChange
},
{
label: '虚拟号',
prop: 'virtualNumber',
type: 'input',
config: {
clearable: true,
placeholder: '请输入虚拟号'
},
onChange: handleFormChange
},
{
label: 'ICCID号',
prop: 'iccid',
type: 'input',
config: {
clearable: true,
placeholder: '请输入ICCID号'
},
onChange: handleFormChange
},
{
label: 'ICCID号段',
prop: 'iccidRange',
type: 'input',
config: {
clearable: true,
placeholder: '请输入ICCID号段8986001-8986999'
},
onChange: handleFormChange
},
{
label: '卡套餐',
prop: 'cardPackage',
type: 'select',
config: {
clearable: true,
placeholder: '全部'
},
options: () => [
{ label: '随意联畅玩年卡套餐', value: 'package1' },
{ label: '如意包年3G流量包', value: 'package2' },
{ label: 'Y-NB专享套餐', value: 'package3' },
{ label: '100G全国流量月卡套餐', value: 'package4' },
{ label: '广电飞悦卡无预存50G', value: 'package5' },
{ label: '5G畅享套餐', value: 'package6' },
{ label: '移动物联网专用套餐', value: 'package7' },
{ label: '电信天翼套餐', value: 'package8' }
],
onChange: handleFormChange
},
{
label: '虚拟号段',
prop: 'virtualNumberRange',
type: 'input',
config: {
clearable: true,
placeholder: '请输入虚拟号段10655001-10655999'
},
onChange: handleFormChange
}
]
// 列配置
const columnOptions = [
{ label: '勾选', type: 'selection' },
{ label: '开卡公司', prop: 'cardCompany' },
{ label: 'ICCID卡号', prop: 'iccid' },
{ label: '虚拟号', prop: 'virtualNumber' },
{ label: '到期时间', prop: 'expireTime' },
{ label: '套餐', prop: 'packageName' },
{ label: '分销商姓名', prop: 'distributorName' },
{ label: '卡状态', prop: 'cardStatus' },
{ label: '操作', prop: 'operation' }
]
// 获取卡状态标签类型
const getCardStatusType = (status: string) => {
switch (status) {
case '正常':
return 'success'
case '待激活':
return 'warning'
case '停机':
case '注销':
return 'danger'
case '已过期':
return 'info'
default:
return 'info'
}
}
// 导出Excel
const exportExcel = () => {
if (selectedRows.value.length === 0) {
ElMessage.warning('请先选择要导出的数据')
return
}
ElMessage.success(`导出 ${selectedRows.value.length} 条记录到Excel`)
}
// 网卡分销
const cardDistribution = () => {
if (selectedRows.value.length === 0) {
ElMessage.warning('请先选择要分销的网卡')
return
}
distributionDialogVisible.value = true
}
// 批量充值
const batchRecharge = () => {
if (selectedRows.value.length === 0) {
ElMessage.warning('请先选择要充值的网卡')
return
}
rechargeDialogVisible.value = true
}
// 网卡回收
const cardRecycle = () => {
if (selectedRows.value.length === 0) {
ElMessage.warning('请先选择要回收的网卡')
return
}
ElMessageBox.confirm(
`确定要回收所选的 ${selectedRows.value.length} 张网卡吗?回收后将无法恢复。`,
'网卡回收确认',
{
confirmButtonText: '确认回收',
cancelButtonText: '取消',
type: 'warning',
dangerouslyUseHTMLString: false
}
)
.then(() => {
handleCardRecycle()
})
.catch(() => {
ElMessage.info('已取消回收操作')
})
}
// 查看详情
const viewDetails = (row: any) => {
ElMessage.info(`查看网卡 ${row.iccid} 的详细信息`)
}
// 编辑网卡
const editCard = (row: any) => {
ElMessage.info(`编辑网卡 ${row.iccid}`)
}
// 删除网卡
const deleteCard = (row: any) => {
ElMessageBox.confirm(`确定要删除ICCID为 ${row.iccid} 的网卡吗?`, '删除确认', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
.then(() => {
ElMessage.success(`已删除网卡 ${row.iccid}`)
getMyCardsList()
})
.catch(() => {
ElMessage.info('已取消删除')
})
}
// 动态列配置
const { columnChecks, columns } = useCheckedColumns(() => [
{ type: 'selection' },
{
prop: 'cardCompany',
label: '开卡公司',
width: 120
},
{
prop: 'iccid',
label: 'ICCID卡号',
minWidth: 180
},
{
prop: 'virtualNumber',
label: '虚拟号',
width: 120
},
{
prop: 'expireTime',
label: '到期时间',
width: 120
},
{
prop: 'packageName',
label: '套餐',
minWidth: 200
},
{
prop: 'distributorName',
label: '分销商姓名',
width: 120
},
{
prop: 'cardStatus',
label: '卡状态',
width: 100,
formatter: (row) => {
return h(ElTag, { type: getCardStatusType(row.cardStatus) }, () => row.cardStatus)
}
},
{
prop: 'operation',
label: '操作',
width: 220,
formatter: (row: any) => {
return h('div', { class: 'operation-buttons' }, [
h(ArtButtonTable, {
text: '查看',
onClick: () => viewDetails(row)
}),
h(ArtButtonTable, {
text: '编辑',
onClick: () => editCard(row)
}),
h(ArtButtonTable, {
text: '删除',
onClick: () => deleteCard(row)
})
])
}
}
])
onMounted(() => {
getMyCardsList()
})
// 获取我的网卡列表
const getMyCardsList = async () => {
loading.value = true
try {
// 模拟API调用
await new Promise((resolve) => setTimeout(resolve, 500))
const startIndex = (pagination.currentPage - 1) * pagination.pageSize
const endIndex = startIndex + pagination.pageSize
const paginatedData = mockData.slice(startIndex, endIndex)
tableData.value = paginatedData
pagination.total = mockData.length
loading.value = false
} catch (error) {
console.error('获取我的网卡列表失败:', error)
loading.value = false
}
}
const handleRefresh = () => {
getMyCardsList()
}
// 处理表格行选择变化
const handleSelectionChange = (selection: any[]) => {
selectedRows.value = selection
}
// 处理表格分页变化
const handleSizeChange = (newPageSize: number) => {
pagination.pageSize = newPageSize
getMyCardsList()
}
const handleCurrentChange = (newCurrentPage: number) => {
pagination.currentPage = newCurrentPage
getMyCardsList()
}
// 远程搜索代理商
const searchAgents = async (query: string) => {
// 模拟API调用
await new Promise((resolve) => setTimeout(resolve, 300))
// 模拟代理商数据
const allAgents = [
{ label: '张丽丽代理商', value: 'agent_001' },
{ label: '王小明代理商', value: 'agent_002' },
{ label: 'HNSXKJ代理商', value: 'agent_003' },
{ label: '孔丽娟代理商', value: 'agent_004' },
{ label: '李四代理商', value: 'agent_005' },
{ label: '赵六代理商', value: 'agent_006' },
{ label: '刘备代理商', value: 'agent_007' },
{ label: '关羽代理商', value: 'agent_008' },
{ label: '张飞代理商', value: 'agent_009' },
{ label: '赵云代理商', value: 'agent_010' },
{ label: '黄忠代理商', value: 'agent_011' },
{ label: '马超代理商', value: 'agent_012' },
{ label: '诸葛亮代理商', value: 'agent_013' },
{ label: '周瑜代理商', value: 'agent_014' },
{ label: '孙权代理商', value: 'agent_015' }
]
// 根据查询条件过滤
let filteredAgents = allAgents
if (query) {
filteredAgents = allAgents.filter((agent) =>
agent.label.toLowerCase().includes(query.toLowerCase())
)
}
// 返回前10条数据
return filteredAgents.slice(0, 10)
}
// 远程搜索套餐
const searchPackages = async (query: string) => {
// 模拟API调用
await new Promise((resolve) => setTimeout(resolve, 300))
// 模拟套餐数据
const allPackages = [
{ label: '随意联畅玩年卡套餐12个月', value: 'package_001' },
{ label: '如意包年3G流量包', value: 'package_002' },
{ label: 'Y-NB专享套餐', value: 'package_003' },
{ label: '100G全国流量月卡套餐', value: 'package_004' },
{ label: '广电飞悦卡无预存50G30天', value: 'package_005' },
{ label: '移动畅享套餐50G', value: 'package_006' },
{ label: '联通大王卡19元', value: 'package_007' },
{ label: '电信星卡29元', value: 'package_008' },
{ label: '移动无限流量卡99元', value: 'package_009' },
{ label: '联通冰淇淋套餐199元', value: 'package_010' },
{ label: '电信天翼云卡59元', value: 'package_011' },
{ label: '移动神州行卡39元', value: 'package_012' },
{ label: '联通腾讯王卡免流', value: 'package_013' },
{ label: '电信达量降速套餐', value: 'package_014' },
{ label: '广电5G畅享套餐', value: 'package_015' }
]
// 根据查询条件过滤
let filteredPackages = allPackages
if (query) {
filteredPackages = allPackages.filter((pkg) =>
pkg.label.toLowerCase().includes(query.toLowerCase())
)
}
// 返回前10条数据
return filteredPackages.slice(0, 10)
}
// 处理网卡分销确认
const handleDistributionConfirm = async (data: any) => {
try {
// 模拟API调用
await new Promise((resolve) => setTimeout(resolve, 1000))
ElMessage.success(`成功将 ${data.selectedCards.length} 张网卡分销给代理商`)
distributionDialogVisible.value = false
// 刷新列表
getMyCardsList()
} catch (error) {
ElMessage.error('分销操作失败,请重试')
}
}
// 处理批量充值确认
const handleRechargeConfirm = async (data: any) => {
try {
// 模拟API调用
await new Promise((resolve) => setTimeout(resolve, 1000))
ElMessage.success(`成功为 ${data.selectedCards.length} 张网卡充值 ${data.amount}`)
rechargeDialogVisible.value = false
// 刷新列表
getMyCardsList()
} catch (error) {
ElMessage.error('充值操作失败,请重试')
}
}
// 处理网卡回收
const handleCardRecycle = async () => {
try {
// 模拟API调用
await new Promise((resolve) => setTimeout(resolve, 1000))
ElMessage.success(`成功回收 ${selectedRows.value.length} 张网卡`)
// 从列表中移除回收的网卡
const recycledIds = selectedRows.value.map((row) => row.id)
tableData.value = tableData.value.filter((item) => !recycledIds.includes(item.id))
selectedRows.value = []
} catch (error) {
ElMessage.error('回收操作失败,请重试')
}
}
// 处理弹框关闭
const handleDialogClose = () => {
// 可以在这里添加额外的关闭逻辑
}
</script>
<style lang="scss" scoped>
.my-cards-page {
// 可以添加特定样式
}
:deep(.operation-buttons) {
display: flex;
gap: 8px;
}
</style>

View File

@@ -0,0 +1,714 @@
<template>
<ArtTableFullScreen>
<div class="offline-batch-recharge-page" id="table-full-screen">
<!-- 搜索栏 -->
<ArtSearchBar
v-model:filter="formFilters"
:items="formItems"
@reset="handleReset"
@search="handleSearch"
></ArtSearchBar>
<ElCard shadow="never" class="art-table-card">
<!-- 数据视图组件 -->
<ArtDataViewer
ref="dataViewerRef"
:data="tableData"
:loading="loading"
:table-columns="columns"
:descriptions-fields="descriptionsFields"
:descriptions-columns="2"
:pagination="pagination"
:label-width="'120px'"
:field-columns="columnChecks"
:show-card-actions="true"
:show-card-selection="true"
:default-view="currentView"
@selection-change="handleSelectionChange"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
@view-change="handleViewChange"
>
<template #header-left>
<ElButton type="primary" @click="handleSearch">搜索查询</ElButton>
<ElButton type="success" @click="showBatchRechargeDialog">批量充值</ElButton>
<ElButton @click="exportExcel">导出excel</ElButton>
</template>
<template #header-right>
<ArtTableHeader
:columnList="columnOptions"
v-model:columns="columnChecks"
v-model:currentView="currentView"
@refresh="handleRefresh"
@viewChange="handleViewChange"
:show-title="false"
:show-view-toggle="true"
/>
</template>
<template #card-actions="{ item }">
<ArtButtonTable
text="查看"
@click="viewDetails(item)"
/>
<ArtButtonTable
text="重试"
@click="retryRecharge(item)"
/>
<ArtButtonTable
text="删除"
@click="deleteRecord(item)"
/>
</template>
</ArtDataViewer>
<!-- 批量充值对话框 -->
<ElDialog
v-model="dialogVisible"
title="批量充值"
width="600px"
align-center
:close-on-click-modal="false"
>
<!-- 顶部下载模板按钮 -->
<div class="template-section">
<ElButton type="primary" @click="downloadTemplate" icon="Download"> 下载模板 </ElButton>
<span class="template-tip">请先下载模板按模板格式填写后上传</span>
</div>
<ElDivider />
<ElForm ref="formRef" :model="formData" :rules="rules" label-width="120px">
<ElFormItem label="上传Excel文件" prop="excelFile">
<ElUpload
ref="uploadRef"
:limit="1"
:on-exceed="handleExceed"
:before-upload="beforeUpload"
:on-change="handleFileChange"
:auto-upload="false"
accept=".xlsx,.xls"
drag
>
<ElIcon class="el-icon--upload"><UploadFilled /></ElIcon>
<div class="el-upload__text"> 将文件拖到此处<em>点击上传</em> </div>
<template #tip>
<div class="el-upload__tip"> 只能上传 xlsx/xls 文件且不超过 10MB </div>
</template>
</ElUpload>
</ElFormItem>
<ElFormItem label="选择套餐" prop="packageId">
<ElSelect
v-model="formData.packageId"
placeholder="请选择套餐"
style="width: 100%"
clearable
>
<ElOption
v-for="pkg in packageOptions"
:key="pkg.value"
:label="pkg.label"
:value="pkg.value"
/>
</ElSelect>
</ElFormItem>
<ElFormItem label="生效方式" prop="effectiveMethod">
<ElSelect
v-model="formData.effectiveMethod"
placeholder="请选择生效方式"
style="width: 100%"
clearable
>
<ElOption
v-for="method in effectiveMethods"
:key="method.value"
:label="method.label"
:value="method.value"
/>
</ElSelect>
</ElFormItem>
<ElFormItem
label="生效时间"
prop="effectiveTime"
v-if="formData.effectiveMethod === 'scheduled'"
>
<ElDatePicker
v-model="formData.effectiveTime"
type="datetime"
placeholder="请选择生效时间"
style="width: 100%"
format="YYYY-MM-DD HH:mm:ss"
value-format="YYYY-MM-DD HH:mm:ss"
/>
</ElFormItem>
<ElFormItem label="备注说明" prop="remark">
<ElInput
v-model="formData.remark"
type="textarea"
placeholder="请输入备注说明(可选)"
:rows="3"
maxlength="200"
show-word-limit
/>
</ElFormItem>
</ElForm>
<template #footer>
<div class="dialog-footer">
<ElButton @click="dialogVisible = false">取消</ElButton>
<ElButton type="primary" @click="handleSubmit" :loading="submitLoading">
确认充值
</ElButton>
</div>
</template>
</ElDialog>
</ElCard>
</div>
</ArtTableFullScreen>
</template>
<script setup lang="ts">
import { h } from 'vue'
import {
ElTag,
ElMessage,
ElMessageBox,
ElUpload,
ElIcon,
ElSelect,
ElOption,
ElDatePicker,
ElDivider,
ElButton
} from 'element-plus'
import { UploadFilled, Download } from '@element-plus/icons-vue'
import type { FormInstance, UploadInstance, UploadRawFile, UploadFile } from 'element-plus'
import type { FormRules } from 'element-plus'
import { useCheckedColumns } from '@/composables/useCheckedColumns'
import ArtButtonTable from '@/components/core/forms/ArtButtonTable.vue'
import ArtDataViewer from '@/components/core/views/ArtDataViewer.vue'
import { SearchChangeParams, SearchFormItem } from '@/types'
defineOptions({ name: 'OfflineBatchRecharge' })
const dialogVisible = ref(false)
const loading = ref(false)
const submitLoading = ref(false)
// 当前视图模式
const currentView = ref('table')
// 数据视图组件引用
const dataViewerRef = ref()
// 定义表单搜索初始值
const initialSearchState = {
importDateRange: ''
}
// 响应式表单数据
const formFilters = reactive({ ...initialSearchState })
const pagination = reactive({
currentPage: 1,
pageSize: 20,
total: 0
})
// 表格数据
const tableData = ref<any[]>([])
// 表格实例引用
const tableRef = ref()
// 选中的行数据
const selectedRows = ref<any[]>([])
// 表单实例
const formRef = ref<FormInstance>()
const uploadRef = ref<UploadInstance>()
// 表单数据
const formData = reactive({
excelFile: null as File | null,
packageId: '',
effectiveMethod: '',
effectiveTime: '',
remark: ''
})
// 套餐选项
const packageOptions = ref([
{ label: '随意联畅玩年卡套餐12个月', value: 'package1' },
{ label: '如意包年3G流量包', value: 'package2' },
{ label: 'Y-NB专享套餐', value: 'package3' },
{ label: '100G全国流量月卡套餐', value: 'package4' },
{ label: '广电飞悦卡无预存50G30天', value: 'package5' },
{ label: '5G畅享套餐', value: 'package6' },
{ label: '移动物联网专用套餐', value: 'package7' },
{ label: '电信天翼套餐', value: 'package8' }
])
// 生效方式选项
const effectiveMethods = ref([
{ label: '立即生效', value: 'immediate' },
{ label: '定时生效', value: 'scheduled' },
{ label: '次月生效', value: 'next_month' },
{ label: '手动激活', value: 'manual' }
])
// 模拟数据
const mockData = [
{
id: 1,
iccidStart: '89860621370079892001',
iccidEnd: '89860621370079892100',
importTime: '2025-11-08 10:30:00',
importCount: 100,
successCount: 98,
failureCount: 2,
packageName: '随意联畅玩年卡套餐12个月',
packageCount: 100,
operator: '张若暄'
},
{
id: 2,
iccidStart: '89860621370079892101',
iccidEnd: '89860621370079892200',
importTime: '2025-11-07 14:15:00',
importCount: 100,
successCount: 100,
failureCount: 0,
packageName: '如意包年3G流量包',
packageCount: 100,
operator: '孔丽娟'
},
{
id: 3,
iccidStart: '89860621370079892201',
iccidEnd: '89860621370079892250',
importTime: '2025-11-06 09:45:00',
importCount: 50,
successCount: 45,
failureCount: 5,
packageName: 'Y-NB专享套餐',
packageCount: 50,
operator: '李佳音'
},
{
id: 4,
iccidStart: '89860621370079892251',
iccidEnd: '89860621370079892350',
importTime: '2025-11-05 16:20:00',
importCount: 100,
successCount: 97,
failureCount: 3,
packageName: '100G全国流量月卡套餐',
packageCount: 100,
operator: '张若暄'
},
{
id: 5,
iccidStart: '89860621370079892351',
iccidEnd: '89860621370079892400',
importTime: '2025-11-04 11:30:00',
importCount: 50,
successCount: 50,
failureCount: 0,
packageName: '广电飞悦卡无预存50G30天',
packageCount: 50,
operator: '赵强'
}
]
// 重置表单
const handleReset = () => {
Object.assign(formFilters, { ...initialSearchState })
pagination.currentPage = 1
getOfflineBatchRechargeList()
}
// 搜索处理
const handleSearch = () => {
console.log('搜索参数:', formFilters)
pagination.currentPage = 1
getOfflineBatchRechargeList()
}
// 表单项变更处理
const handleFormChange = (params: SearchChangeParams): void => {
console.log('表单项变更:', params)
}
// 表单配置项
const formItems: SearchFormItem[] = [
{
label: '导入时间',
prop: 'importDateRange',
type: 'daterange',
config: {
type: 'daterange',
startPlaceholder: '开始时间',
endPlaceholder: '结束时间'
},
onChange: handleFormChange
}
]
// 列配置
const columnOptions = [
{ label: '勾选', type: 'selection' },
{ label: 'ICCID开始号段', prop: 'iccidStart' },
{ label: 'ICCID结束号段', prop: 'iccidEnd' },
{ label: '导入时间', prop: 'importTime' },
{ label: '导入数量', prop: 'importCount' },
{ label: '成功数量', prop: 'successCount' },
{ label: '失败数量', prop: 'failureCount' },
{ label: '套餐名称', prop: 'packageName' },
{ label: '套餐数量', prop: 'packageCount' },
{ label: '操作人', prop: 'operator' },
{ label: '操作', prop: 'operation' }
]
// 描述字段配置
const descriptionsFields = [
{ prop: 'iccidStart', label: 'ICCID开始号段' },
{ prop: 'iccidEnd', label: 'ICCID结束号段' },
{ prop: 'importTime', label: '导入时间' },
{ prop: 'importCount', label: '导入数量', formatter: (row: any) => `${row.importCount}` },
{
prop: 'successCount',
label: '成功数量',
formatter: (row: any) => {
return `<el-tag type="success" size="small">${row.successCount} 张</el-tag>`
}
},
{
prop: 'failureCount',
label: '失败数量',
formatter: (row: any) => {
const type = row.failureCount > 0 ? 'danger' : 'success'
return `<el-tag type="${type}" size="small">${row.failureCount} 张</el-tag>`
}
},
{ prop: 'packageName', label: '套餐名称', span: 2 },
{ prop: 'packageCount', label: '套餐数量', formatter: (row: any) => `${row.packageCount}` },
{ prop: 'operator', label: '操作人' }
]
// 导出Excel
const exportExcel = () => {
if (selectedRows.value.length === 0) {
ElMessage.warning('请先选择要导出的数据')
return
}
ElMessage.success(`导出 ${selectedRows.value.length} 条记录到Excel`)
}
// 下载模板
const downloadTemplate = () => {
ElMessage.success('正在下载充值模板文件...')
// 这里可以实现实际的模板下载功能
}
// 查看详情
const viewDetails = (row: any) => {
ElMessage.info(`查看充值记录详情: ${row.iccidStart} - ${row.iccidEnd}`)
}
// 删除记录
const deleteRecord = (row: any) => {
ElMessageBox.confirm(`确定要删除该充值记录吗?`, '删除确认', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
.then(() => {
ElMessage.success('删除成功')
getOfflineBatchRechargeList()
})
.catch(() => {
ElMessage.info('已取消删除')
})
}
// 重新充值
const retryRecharge = (row: any) => {
ElMessageBox.confirm(`确定要重新执行该批次的充值操作吗?`, '操作确认', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'info'
})
.then(() => {
ElMessage.success('重新充值操作已开始')
getOfflineBatchRechargeList()
})
.catch(() => {
ElMessage.info('已取消操作')
})
}
// 动态列配置
const { columnChecks, columns } = useCheckedColumns(() => [
{ type: 'selection' },
{
prop: 'iccidStart',
label: 'ICCID开始号段',
minWidth: 220
},
{
prop: 'iccidEnd',
label: 'ICCID结束号段',
minWidth: 220
},
{
prop: 'importTime',
label: '导入时间',
width: 160
},
{
prop: 'importCount',
label: '导入数量',
width: 100,
formatter: (row) => `${row.importCount}`
},
{
prop: 'successCount',
label: '成功数量',
width: 100,
formatter: (row) => {
return h(ElTag, { type: 'success' }, () => `${row.successCount}`)
}
},
{
prop: 'failureCount',
label: '失败数量',
width: 100,
formatter: (row) => {
return h(
ElTag,
{ type: row.failureCount > 0 ? 'danger' : 'success' },
() => `${row.failureCount}`
)
}
},
{
prop: 'packageName',
label: '套餐名称',
minWidth: 200
},
{
prop: 'packageCount',
label: '套餐数量',
width: 100,
formatter: (row) => `${row.packageCount}`
},
{
prop: 'operator',
label: '操作人',
width: 100
},
{
prop: 'operation',
label: '操作',
width: 220,
formatter: (row: any) => {
return h('div', { class: 'operation-buttons' }, [
h(ArtButtonTable, {
text: '查看',
onClick: () => viewDetails(row)
}),
h(ArtButtonTable, {
text: '重试',
onClick: () => retryRecharge(row)
}),
h(ArtButtonTable, {
text: '删除',
onClick: () => deleteRecord(row)
})
])
}
}
])
onMounted(() => {
getOfflineBatchRechargeList()
})
// 获取线下批量充值列表
const getOfflineBatchRechargeList = async () => {
loading.value = true
try {
// 模拟API调用
await new Promise((resolve) => setTimeout(resolve, 500))
const startIndex = (pagination.currentPage - 1) * pagination.pageSize
const endIndex = startIndex + pagination.pageSize
const paginatedData = mockData.slice(startIndex, endIndex)
tableData.value = paginatedData
pagination.total = mockData.length
loading.value = false
} catch (error) {
console.error('获取线下批量充值列表失败:', error)
loading.value = false
}
}
const handleRefresh = () => {
getOfflineBatchRechargeList()
}
// 处理表格行选择变化
const handleSelectionChange = (selection: any[]) => {
selectedRows.value = selection
}
// 处理表格分页变化
const handleSizeChange = (newPageSize: number) => {
pagination.pageSize = newPageSize
getOfflineBatchRechargeList()
}
const handleCurrentChange = (newCurrentPage: number) => {
pagination.currentPage = newCurrentPage
getOfflineBatchRechargeList()
}
// 处理视图切换
const handleViewChange = (view: string) => {
console.log('视图切换到:', view)
// 可以在这里添加视图切换的额外逻辑,比如保存用户偏好
}
// 显示批量充值对话框
const showBatchRechargeDialog = () => {
dialogVisible.value = true
// 重置表单
if (formRef.value) {
formRef.value.resetFields()
}
formData.excelFile = null
formData.packageId = ''
formData.effectiveMethod = ''
formData.effectiveTime = ''
formData.remark = ''
}
// 文件上传限制
const handleExceed = () => {
ElMessage.warning('最多只能上传一个文件')
}
// 文件上传前检查
const beforeUpload = (file: UploadRawFile) => {
const isExcel =
file.type === 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' ||
file.type === 'application/vnd.ms-excel'
const isLt10M = file.size / 1024 / 1024 < 10
if (!isExcel) {
ElMessage.error('只能上传 Excel 文件!')
return false
}
if (!isLt10M) {
ElMessage.error('上传文件大小不能超过 10MB!')
return false
}
return false // 阻止自动上传
}
// 文件变化处理
const handleFileChange = (file: UploadFile) => {
if (file.raw) {
formData.excelFile = file.raw
}
}
// 表单验证规则
const rules = reactive<FormRules>({
excelFile: [{ required: true, message: '请上传Excel文件', trigger: 'change' }],
packageId: [{ required: true, message: '请选择套餐', trigger: 'change' }],
effectiveMethod: [{ required: true, message: '请选择生效方式', trigger: 'change' }],
effectiveTime: [{ required: true, message: '请选择生效时间', trigger: 'change' }]
})
// 提交表单
const handleSubmit = async () => {
if (!formRef.value) return
// 检查文件是否上传
if (!formData.excelFile) {
ElMessage.error('请先上传Excel文件')
return
}
// 如果是定时生效,验证生效时间
if (formData.effectiveMethod === 'scheduled' && !formData.effectiveTime) {
ElMessage.error('定时生效必须选择生效时间')
return
}
await formRef.value.validate((valid) => {
if (valid) {
submitLoading.value = true
// 模拟提交过程
setTimeout(() => {
const packageName = packageOptions.value.find(
(p) => p.value === formData.packageId
)?.label
const effectiveMethodName = effectiveMethods.value.find(
(m) => m.value === formData.effectiveMethod
)?.label
ElMessage.success(
`批量充值提交成功!套餐:${packageName},生效方式:${effectiveMethodName}`
)
dialogVisible.value = false
submitLoading.value = false
getOfflineBatchRechargeList()
}, 2000)
}
})
}
</script>
<style lang="scss" scoped>
.offline-batch-recharge-page {
// 可以添加特定样式
}
.template-section {
display: flex;
gap: 12px;
align-items: center;
margin-bottom: 16px;
.template-tip {
font-size: 12px;
color: #909399;
}
}
:deep(.operation-buttons) {
display: flex;
gap: 8px;
}
.dialog-footer {
text-align: right;
}
:deep(.el-upload-dragger) {
padding: 40px;
}
:deep(.el-form-item) {
margin-bottom: 20px;
}
</style>

View File

@@ -0,0 +1,689 @@
<template>
<ArtTableFullScreen>
<div class="package-gift-page" id="table-full-screen">
<!-- 搜索栏 -->
<ArtSearchBar
v-model:filter="formFilters"
:items="formItems"
@reset="handleReset"
@search="handleSearch"
></ArtSearchBar>
<ElCard shadow="never" class="art-table-card">
<!-- 表格头部 -->
<ArtTableHeader
:columnList="columnOptions"
v-model:columns="columnChecks"
@refresh="handleRefresh"
>
<template #left>
<ElButton type="primary" @click="handleSearch">搜索</ElButton>
<ElButton type="success" @click="showBatchImportDialog">批量导入</ElButton>
<ElButton type="danger" @click="batchDelete">批量删除</ElButton>
<ElButton @click="exportExcel">导出excel</ElButton>
</template>
</ArtTableHeader>
<!-- 表格 -->
<ArtTable
ref="tableRef"
row-key="id"
:loading="loading"
:data="tableData"
:currentPage="pagination.currentPage"
:pageSize="pagination.pageSize"
:total="pagination.total"
:marginTop="10"
@selection-change="handleSelectionChange"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
>
<template #default>
<ElTableColumn v-for="col in columns" :key="col.prop || col.type" v-bind="col" />
</template>
</ArtTable>
<!-- 批量导入对话框 -->
<ElDialog
v-model="importDialogVisible"
title="批量导入套餐赠送"
width="500px"
align-center
:close-on-click-modal="false"
>
<!-- 顶部下载模板按钮 -->
<div class="template-section">
<ElButton type="primary" @click="downloadTemplate" icon="Download"> 下载模板 </ElButton>
<span class="template-tip">请先下载模板按模板格式填写后上传</span>
</div>
<ElDivider />
<ElForm
ref="importFormRef"
:model="importFormData"
:rules="importRules"
label-width="120px"
>
<ElFormItem label="上传Excel文件" prop="excelFile">
<ElUpload
ref="uploadRef"
:limit="1"
:on-exceed="handleExceed"
:before-upload="beforeUpload"
:on-change="handleFileChange"
:auto-upload="false"
accept=".xlsx,.xls"
drag
>
<ElIcon class="el-icon--upload"><UploadFilled /></ElIcon>
<div class="el-upload__text"> 将文件拖到此处<em>点击上传</em> </div>
<template #tip>
<div class="el-upload__tip"> 只能上传 xlsx/xls 文件且不超过 10MB </div>
</template>
</ElUpload>
</ElFormItem>
<ElFormItem label="备注说明" prop="remark">
<ElInput
v-model="importFormData.remark"
type="textarea"
placeholder="请输入备注说明(可选)"
:rows="3"
maxlength="200"
show-word-limit
/>
</ElFormItem>
</ElForm>
<template #footer>
<div class="dialog-footer">
<ElButton @click="importDialogVisible = false">取消</ElButton>
<ElButton type="primary" @click="handleImportSubmit" :loading="importLoading">
确认导入
</ElButton>
</div>
</template>
</ElDialog>
</ElCard>
</div>
</ArtTableFullScreen>
</template>
<script setup lang="ts">
import { h } from 'vue'
import { ElTag, ElMessage, ElMessageBox, ElUpload, ElIcon, ElDivider } from 'element-plus'
import { UploadFilled, Download } from '@element-plus/icons-vue'
import type { FormInstance, UploadInstance, UploadRawFile, UploadFile } from 'element-plus'
import type { FormRules } from 'element-plus'
import { useCheckedColumns } from '@/composables/useCheckedColumns'
import ArtButtonTable from '@/components/core/forms/ArtButtonTable.vue'
import { SearchChangeParams, SearchFormItem } from '@/types'
defineOptions({ name: 'PackageGift' })
const importDialogVisible = ref(false)
const loading = ref(false)
const importLoading = ref(false)
// 定义表单搜索初始值
const initialSearchState = {
iccid: '',
accessNumber: '',
cardCompany: '',
isReceived: '',
endDateRange: ''
}
// 响应式表单数据
const formFilters = reactive({ ...initialSearchState })
const pagination = reactive({
currentPage: 1,
pageSize: 20,
total: 0
})
// 表格数据
const tableData = ref<any[]>([])
// 表格实例引用
const tableRef = ref()
// 选中的行数据
const selectedRows = ref<any[]>([])
// 导入表单实例
const importFormRef = ref<FormInstance>()
const uploadRef = ref<UploadInstance>()
// 导入表单数据
const importFormData = reactive({
excelFile: null as File | null,
remark: ''
})
// 模拟数据
const mockData = [
{
id: 1,
iccid: '89860621370079892035',
accessNumber: '1440012345678',
giftPackage: '随意联畅玩年卡套餐12个月',
cardCompany: '联通2',
isReceived: '已领取',
operator: '张若暄',
operationTime: '2025-11-08 10:30:00',
receiveTime: '2025-11-08 14:20:00',
importStatus: '导入成功',
failureReason: ''
},
{
id: 2,
iccid: '89860621370079892036',
accessNumber: '1440012345679',
giftPackage: 'Y-NB专享套餐',
cardCompany: 'SXKJ-NB',
isReceived: '未领取',
operator: '孔丽娟',
operationTime: '2025-11-07 14:15:00',
receiveTime: '',
importStatus: '导入成功',
failureReason: ''
},
{
id: 3,
iccid: '89860621370079892037',
accessNumber: '1440012345680',
giftPackage: '如意包年3G流量包',
cardCompany: '联通36',
isReceived: '已过期',
operator: '李佳音',
operationTime: '2025-11-06 09:45:00',
receiveTime: '',
importStatus: '导入成功',
failureReason: ''
},
{
id: 4,
iccid: '89860621370079892038',
accessNumber: '1440012345681',
giftPackage: '100G全国流量月卡套餐',
cardCompany: '联通1-1',
isReceived: '未领取',
operator: '赵强',
operationTime: '2025-11-05 16:20:00',
receiveTime: '',
importStatus: '导入失败',
failureReason: 'ICCID格式错误'
},
{
id: 5,
iccid: '89860621370079892039',
accessNumber: '1440012345682',
giftPackage: '广电飞悦卡无预存50G30天',
cardCompany: '广电4',
isReceived: '已领取',
operator: '张若暄',
operationTime: '2025-11-04 11:30:00',
receiveTime: '2025-11-05 08:15:00',
importStatus: '导入成功',
failureReason: ''
}
]
// 重置表单
const handleReset = () => {
Object.assign(formFilters, { ...initialSearchState })
pagination.currentPage = 1
getPackageGiftList()
}
// 搜索处理
const handleSearch = () => {
console.log('搜索参数:', formFilters)
pagination.currentPage = 1
getPackageGiftList()
}
// 表单项变更处理
const handleFormChange = (params: SearchChangeParams): void => {
console.log('表单项变更:', params)
}
// 表单配置项
const formItems: SearchFormItem[] = [
{
label: 'ICCID号',
prop: 'iccid',
type: 'input',
config: {
clearable: true,
placeholder: '请输入ICCID号'
},
onChange: handleFormChange
},
{
label: '接入号',
prop: 'accessNumber',
type: 'input',
config: {
clearable: true,
placeholder: '请输入接入号'
},
onChange: handleFormChange
},
{
label: '开卡公司',
prop: 'cardCompany',
type: 'select',
config: {
clearable: true,
placeholder: '全部'
},
options: () => [
{ label: '联通2', value: 'unicom2' },
{ label: '联通36', value: 'unicom36' },
{ label: 'SXKJ-NB', value: 'sxkj_nb' },
{ label: '联通1-1', value: 'unicom1_1' },
{ label: '联通8', value: 'unicom8' },
{ label: '移动21', value: 'mobile21' },
{ label: '广电4', value: 'gdtv4' },
{ label: '电信9', value: 'telecom9' }
],
onChange: handleFormChange
},
{
label: '是否领取',
prop: 'isReceived',
type: 'select',
config: {
clearable: true,
placeholder: '全部'
},
options: () => [
{ label: '已领取', value: 'received' },
{ label: '未领取', value: 'not_received' },
{ label: '已过期', value: 'expired' }
],
onChange: handleFormChange
},
{
label: '结束时间',
prop: 'endDateRange',
type: 'daterange',
config: {
type: 'daterange',
startPlaceholder: '开始时间',
endPlaceholder: '结束时间'
},
onChange: handleFormChange
}
]
// 列配置
const columnOptions = [
{ label: '勾选', type: 'selection' },
{ label: 'ICCID', prop: 'iccid' },
{ label: '接入号码', prop: 'accessNumber' },
{ label: '赠送套餐', prop: 'giftPackage' },
{ label: '开卡公司', prop: 'cardCompany' },
{ label: '是否领取', prop: 'isReceived' },
{ label: '操作人', prop: 'operator' },
{ label: '操作时间', prop: 'operationTime' },
{ label: '领取时间', prop: 'receiveTime' },
{ label: '导入状态', prop: 'importStatus' },
{ label: '失败原因', prop: 'failureReason' },
{ label: '操作', prop: 'operation' }
]
// 获取是否领取标签类型
const getReceiveStatusType = (status: string) => {
switch (status) {
case '已领取':
return 'success'
case '未领取':
return 'warning'
case '已过期':
return 'danger'
default:
return 'info'
}
}
// 获取导入状态标签类型
const getImportStatusType = (status: string) => {
switch (status) {
case '导入成功':
return 'success'
case '导入失败':
return 'danger'
default:
return 'info'
}
}
// 导出Excel
const exportExcel = () => {
if (selectedRows.value.length === 0) {
ElMessage.warning('请先选择要导出的数据')
return
}
ElMessage.success(`导出 ${selectedRows.value.length} 条套餐赠送记录`)
}
// 批量删除
const batchDelete = () => {
if (selectedRows.value.length === 0) {
ElMessage.warning('请先选择要删除的数据')
return
}
ElMessageBox.confirm(
`确定要删除选中的 ${selectedRows.value.length} 条套餐赠送记录吗?`,
'批量删除确认',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}
)
.then(() => {
ElMessage.success(`批量删除 ${selectedRows.value.length} 条记录成功`)
getPackageGiftList()
})
.catch(() => {
ElMessage.info('已取消删除')
})
}
// 显示导入对话框
const showBatchImportDialog = () => {
importDialogVisible.value = true
// 重置表单
if (importFormRef.value) {
importFormRef.value.resetFields()
}
importFormData.excelFile = null
importFormData.remark = ''
}
// 下载模板
const downloadTemplate = () => {
ElMessage.success('正在下载套餐赠送导入模板...')
// 这里可以实现实际的模板下载功能
}
// 查看详情
const viewDetails = (row: any) => {
ElMessage.info(`查看套餐赠送详情: ${row.iccid}`)
}
// 编辑记录
const editRecord = (row: any) => {
ElMessage.info(`编辑套餐赠送记录: ${row.iccid}`)
}
// 删除记录
const deleteRecord = (row: any) => {
ElMessageBox.confirm(`确定要删除该套餐赠送记录吗?`, '删除确认', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
.then(() => {
ElMessage.success('删除成功')
getPackageGiftList()
})
.catch(() => {
ElMessage.info('已取消删除')
})
}
// 手动发放
const manualGrant = (row: any) => {
if (row.isReceived === '已领取') {
ElMessage.warning('该套餐已被领取')
return
}
ElMessageBox.confirm(`确定要手动发放该套餐吗?`, '发放确认', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'info'
})
.then(() => {
ElMessage.success('套餐发放成功')
getPackageGiftList()
})
.catch(() => {
ElMessage.info('已取消发放')
})
}
// 动态列配置
const { columnChecks, columns } = useCheckedColumns(() => [
{ type: 'selection' },
{
prop: 'iccid',
label: 'ICCID',
minWidth: 180
},
{
prop: 'accessNumber',
label: '接入号码',
width: 140
},
{
prop: 'giftPackage',
label: '赠送套餐',
minWidth: 200
},
{
prop: 'cardCompany',
label: '开卡公司',
width: 120
},
{
prop: 'isReceived',
label: '是否领取',
width: 100,
formatter: (row) => {
return h(ElTag, { type: getReceiveStatusType(row.isReceived) }, () => row.isReceived)
}
},
{
prop: 'operator',
label: '操作人',
width: 100
},
{
prop: 'operationTime',
label: '操作时间',
width: 160
},
{
prop: 'receiveTime',
label: '领取时间',
width: 160,
formatter: (row) => row.receiveTime || '未领取'
},
{
prop: 'importStatus',
label: '导入状态',
width: 100,
formatter: (row) => {
return h(ElTag, { type: getImportStatusType(row.importStatus) }, () => row.importStatus)
}
},
{
prop: 'failureReason',
label: '失败原因',
width: 140,
formatter: (row) => row.failureReason || '-'
},
{
prop: 'operation',
label: '操作',
width: 280,
formatter: (row: any) => {
return h('div', { class: 'operation-buttons' }, [
h(ArtButtonTable, {
text: '查看',
onClick: () => viewDetails(row)
}),
h(ArtButtonTable, {
text: '发放',
disabled: row.isReceived === '已领取',
onClick: () => manualGrant(row)
}),
h(ArtButtonTable, {
text: '编辑',
onClick: () => editRecord(row)
}),
h(ArtButtonTable, {
text: '删除',
onClick: () => deleteRecord(row)
})
])
}
}
])
onMounted(() => {
getPackageGiftList()
})
// 获取套餐赠送列表
const getPackageGiftList = async () => {
loading.value = true
try {
// 模拟API调用
await new Promise((resolve) => setTimeout(resolve, 500))
const startIndex = (pagination.currentPage - 1) * pagination.pageSize
const endIndex = startIndex + pagination.pageSize
const paginatedData = mockData.slice(startIndex, endIndex)
tableData.value = paginatedData
pagination.total = mockData.length
loading.value = false
} catch (error) {
console.error('获取套餐赠送列表失败:', error)
loading.value = false
}
}
const handleRefresh = () => {
getPackageGiftList()
}
// 处理表格行选择变化
const handleSelectionChange = (selection: any[]) => {
selectedRows.value = selection
}
// 处理表格分页变化
const handleSizeChange = (newPageSize: number) => {
pagination.pageSize = newPageSize
getPackageGiftList()
}
const handleCurrentChange = (newCurrentPage: number) => {
pagination.currentPage = newCurrentPage
getPackageGiftList()
}
// 文件上传限制
const handleExceed = () => {
ElMessage.warning('最多只能上传一个文件')
}
// 文件上传前检查
const beforeUpload = (file: UploadRawFile) => {
const isExcel =
file.type === 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' ||
file.type === 'application/vnd.ms-excel'
const isLt10M = file.size / 1024 / 1024 < 10
if (!isExcel) {
ElMessage.error('只能上传 Excel 文件!')
return false
}
if (!isLt10M) {
ElMessage.error('上传文件大小不能超过 10MB!')
return false
}
return false // 阻止自动上传
}
// 文件变化处理
const handleFileChange = (file: UploadFile) => {
if (file.raw) {
importFormData.excelFile = file.raw
}
}
// 导入表单验证规则
const importRules = reactive<FormRules>({
excelFile: [{ required: true, message: '请上传Excel文件', trigger: 'change' }]
})
// 提交导入
const handleImportSubmit = async () => {
if (!importFormRef.value) return
// 检查文件是否上传
if (!importFormData.excelFile) {
ElMessage.error('请先上传Excel文件')
return
}
await importFormRef.value.validate((valid) => {
if (valid) {
importLoading.value = true
// 模拟导入过程
setTimeout(() => {
ElMessage.success('套餐赠送导入成功!')
importDialogVisible.value = false
importLoading.value = false
getPackageGiftList()
}, 2000)
}
})
}
</script>
<style lang="scss" scoped>
.package-gift-page {
// 可以添加特定样式
}
.template-section {
display: flex;
gap: 12px;
align-items: center;
margin-bottom: 16px;
.template-tip {
font-size: 12px;
color: #909399;
}
}
:deep(.operation-buttons) {
display: flex;
gap: 8px;
}
.dialog-footer {
text-align: right;
}
:deep(.el-upload-dragger) {
padding: 40px;
}
:deep(.el-form-item) {
margin-bottom: 20px;
}
</style>

View File

@@ -0,0 +1,468 @@
<template>
<ArtTableFullScreen>
<div class="single-card-page" id="table-full-screen">
<!-- 网卡信息卡片 -->
<ElCard shadow="never" class="card-info-card" style="margin-bottom: 16px">
<template #header>
<span>网卡信息</span>
</template>
<ElForm :model="cardInfo" label-width="120px" :inline="false">
<ElRow :gutter="24">
<ElCol :span="8">
<ElFormItem label="ICCID:">
<span>{{ cardInfo.iccid }}</span>
</ElFormItem>
</ElCol>
<ElCol :span="8">
<ElFormItem label="IMSI:">
<span>{{ cardInfo.imsi }}</span>
</ElFormItem>
</ElCol>
<ElCol :span="8">
<ElFormItem label="手机号码:">
<span>{{ cardInfo.msisdn }}</span>
</ElFormItem>
</ElCol>
</ElRow>
<ElRow :gutter="24">
<ElCol :span="8">
<ElFormItem label="运营商:">
<ElTag :type="getOperatorTagType(cardInfo.operator)">{{
cardInfo.operatorName
}}</ElTag>
</ElFormItem>
</ElCol>
<ElCol :span="8">
<ElFormItem label="网络类型:">
<span>{{ cardInfo.networkType }}</span>
</ElFormItem>
</ElCol>
<ElCol :span="8">
<ElFormItem label="状态:">
<ElTag :type="getStatusTagType(cardInfo.status)">{{ cardInfo.statusName }}</ElTag>
</ElFormItem>
</ElCol>
</ElRow>
</ElForm>
</ElCard>
<!-- 操作区域 -->
<ElCard shadow="never" class="operation-card" style="margin-bottom: 16px">
<template #header>
<span>操作区域</span>
</template>
<div class="operation-buttons">
<ElButton type="primary" @click="activateCard" :disabled="cardInfo.status === '1'"
>激活网卡</ElButton
>
<ElButton type="warning" @click="suspendCard" :disabled="cardInfo.status === '2'"
>停用网卡</ElButton
>
<ElButton type="success" @click="showRechargeDialog">充值</ElButton>
<ElButton type="info" @click="queryTraffic">流量查询</ElButton>
<ElButton type="danger" @click="resetCard">重置网卡</ElButton>
<ElButton @click="diagnoseCard">网卡诊断</ElButton>
</div>
</ElCard>
<!-- 流量信息 -->
<ElCard shadow="never" class="traffic-card" style="margin-bottom: 16px">
<template #header>
<span>流量信息</span>
</template>
<ElRow :gutter="24">
<ElCol :span="6">
<div class="traffic-item">
<div class="traffic-value">{{ trafficInfo.totalTraffic }}</div>
<div class="traffic-label">总流量</div>
</div>
</ElCol>
<ElCol :span="6">
<div class="traffic-item">
<div class="traffic-value used">{{ trafficInfo.usedTraffic }}</div>
<div class="traffic-label">已用流量</div>
</div>
</ElCol>
<ElCol :span="6">
<div class="traffic-item">
<div class="traffic-value remaining">{{ trafficInfo.remainingTraffic }}</div>
<div class="traffic-label">剩余流量</div>
</div>
</ElCol>
<ElCol :span="6">
<div class="traffic-item">
<div class="traffic-value percentage">{{ trafficInfo.usagePercentage }}%</div>
<div class="traffic-label">使用率</div>
</div>
</ElCol>
</ElRow>
<ElProgress :percentage="parseInt(trafficInfo.usagePercentage)" style="margin-top: 16px" />
</ElCard>
<!-- 使用记录 -->
<ElCard shadow="never" class="art-table-card">
<template #header>
<span>使用记录</span>
<ElButton style="float: right" @click="refreshUsageRecords">刷新</ElButton>
</template>
<ArtTable
ref="tableRef"
row-key="id"
:loading="loading"
:data="usageRecords"
:currentPage="pagination.currentPage"
:pageSize="pagination.pageSize"
:total="pagination.total"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
>
<template #default>
<ElTableColumn v-for="col in usageColumns" :key="col.prop || col.type" v-bind="col" />
</template>
</ArtTable>
</ElCard>
<!-- 充值对话框 -->
<ElDialog v-model="rechargeDialogVisible" title="网卡充值" width="400px" align-center>
<ElForm
ref="rechargeFormRef"
:model="rechargeForm"
:rules="rechargeRules"
label-width="100px"
>
<ElFormItem label="充值金额" prop="amount">
<ElInput v-model="rechargeForm.amount" placeholder="请输入充值金额">
<template #append></template>
</ElInput>
</ElFormItem>
<ElFormItem label="充值方式" prop="method">
<ElSelect v-model="rechargeForm.method" placeholder="请选择充值方式">
<ElOption label="支付宝" value="alipay" />
<ElOption label="微信支付" value="wechat" />
<ElOption label="银联支付" value="unionpay" />
</ElSelect>
</ElFormItem>
</ElForm>
<template #footer>
<ElButton @click="rechargeDialogVisible = false">取消</ElButton>
<ElButton type="primary" @click="handleRecharge">确认充值</ElButton>
</template>
</ElDialog>
</div>
</ArtTableFullScreen>
</template>
<script setup lang="ts">
import { h } from 'vue'
import { ElTag, ElMessageBox, ElMessage, FormInstance } from 'element-plus'
import { useRoute } from 'vue-router'
import type { FormRules } from 'element-plus'
defineOptions({ name: 'SingleCard' })
const loading = ref(false)
const rechargeDialogVisible = ref(false)
const route = useRoute()
// 网卡信息
const cardInfo = reactive({
iccid: '89860123456789012345',
imsi: '460012345678901',
msisdn: '13800138001',
operator: 'mobile',
operatorName: '中国移动',
networkType: '4G',
status: '1',
statusName: '激活',
activatedDate: '2024-01-15',
expiryDate: '2025-01-15'
})
// 流量信息
const trafficInfo = reactive({
totalTraffic: '10GB',
usedTraffic: '2.5GB',
remainingTraffic: '7.5GB',
usagePercentage: '25'
})
const pagination = reactive({
currentPage: 1,
pageSize: 10,
total: 0
})
// 使用记录
const usageRecords = ref([
{
id: 1,
date: '2024-11-07',
time: '14:30:25',
dataUsage: '125.6MB',
fee: '0.12',
location: '北京市朝阳区'
},
{
id: 2,
date: '2024-11-07',
time: '13:45:12',
dataUsage: '256.8MB',
fee: '0.26',
location: '北京市朝阳区'
}
])
// 充值表单
const rechargeForm = reactive({
amount: '',
method: ''
})
const rechargeFormRef = ref<FormInstance>()
// 表格列配置
const usageColumns = [
{
prop: 'date',
label: '日期',
width: 120
},
{
prop: 'time',
label: '时间',
width: 100
},
{
prop: 'dataUsage',
label: '流量使用量',
width: 120
},
{
prop: 'fee',
label: '费用(元)',
width: 100,
formatter: (row: any) => `¥${row.fee}`
},
{
prop: 'location',
label: '位置',
minWidth: 140
}
]
// 获取运营商标签类型
const getOperatorTagType = (operator: string) => {
switch (operator) {
case 'mobile':
return 'success'
case 'unicom':
return 'primary'
case 'telecom':
return 'warning'
default:
return 'info'
}
}
// 获取状态标签类型
const getStatusTagType = (status: string) => {
switch (status) {
case '1':
return 'success'
case '2':
return 'danger'
case '3':
return 'warning'
case '4':
return 'info'
default:
return 'info'
}
}
// 激活网卡
const activateCard = () => {
ElMessageBox.confirm('确定要激活该网卡吗?', '激活网卡', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'info'
}).then(() => {
cardInfo.status = '1'
cardInfo.statusName = '激活'
ElMessage.success('网卡激活成功')
})
}
// 停用网卡
const suspendCard = () => {
ElMessageBox.confirm('确定要停用该网卡吗?', '停用网卡', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
cardInfo.status = '2'
cardInfo.statusName = '停用'
ElMessage.success('网卡停用成功')
})
}
// 显示充值对话框
const showRechargeDialog = () => {
rechargeDialogVisible.value = true
if (rechargeFormRef.value) {
rechargeFormRef.value.resetFields()
}
}
// 流量查询
const queryTraffic = () => {
ElMessage.info('正在查询流量信息...')
// 这里可以调用API查询最新的流量信息
}
// 重置网卡
const resetCard = () => {
ElMessageBox.confirm('确定要重置该网卡吗?重置后网卡需要重新激活。', '重置网卡', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'error'
}).then(() => {
ElMessage.success('网卡重置成功')
})
}
// 网卡诊断
const diagnoseCard = () => {
ElMessage.info('正在进行网卡诊断...')
setTimeout(() => {
ElMessage.success('网卡诊断完成,网卡状态正常')
}, 2000)
}
// 充值验证规则
const rechargeRules: FormRules = {
amount: [
{ required: true, message: '请输入充值金额', trigger: 'blur' },
{ pattern: /^\d+(\.\d{1,2})?$/, message: '请输入正确的金额格式', trigger: 'blur' }
],
method: [{ required: true, message: '请选择充值方式', trigger: 'change' }]
}
// 处理充值
const handleRecharge = async () => {
if (!rechargeFormRef.value) return
await rechargeFormRef.value.validate((valid) => {
if (valid) {
ElMessage.success('充值成功')
rechargeDialogVisible.value = false
}
})
}
// 刷新使用记录
const refreshUsageRecords = () => {
ElMessage.info('正在刷新使用记录...')
// 这里可以调用API获取最新的使用记录
}
// 处理表格分页变化
const handleSizeChange = (newPageSize: number) => {
pagination.pageSize = newPageSize
// 重新获取数据
}
const handleCurrentChange = (newCurrentPage: number) => {
pagination.currentPage = newCurrentPage
// 重新获取数据
}
onMounted(() => {
// 检查是否有传递的ICCID参数
const iccidFromQuery = route.query.iccid as string
if (iccidFromQuery) {
// 如果有ICCID参数更新卡片信息
cardInfo.iccid = iccidFromQuery
// 可以在这里根据ICCID获取完整的卡片信息
console.log('从网卡明细跳转ICCID:', iccidFromQuery)
// 模拟根据ICCID获取卡片详细信息
loadCardInfoByIccid(iccidFromQuery)
}
// 初始化数据
pagination.total = usageRecords.value.length
})
// 根据ICCID加载卡片信息
const loadCardInfoByIccid = async (iccid: string) => {
loading.value = true
try {
// 这里应该调用API根据ICCID获取卡片详细信息
// 模拟API调用
await new Promise((resolve) => setTimeout(resolve, 500))
// 模拟更新卡片信息实际应该从API获取
Object.assign(cardInfo, {
iccid: iccid,
imsi: '460012345678901',
msisdn: '13800138001',
operator: 'mobile',
operatorName: '中国移动',
networkType: '4G',
status: '1',
statusName: '激活',
activatedDate: '2024-01-15',
expiryDate: '2025-01-15'
})
ElMessage.success(`已加载ICCID ${iccid} 的详细信息`)
} catch (error) {
console.error('获取卡片信息失败:', error)
ElMessage.error('获取卡片信息失败')
} finally {
loading.value = false
}
}
</script>
<style lang="scss" scoped>
.single-card-page {
.operation-buttons {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.traffic-card {
.traffic-item {
padding: 16px;
text-align: center;
.traffic-value {
font-size: 24px;
font-weight: bold;
color: var(--el-color-primary);
&.used {
color: var(--el-color-warning);
}
&.remaining {
color: var(--el-color-success);
}
&.percentage {
color: var(--el-color-info);
}
}
.traffic-label {
margin-top: 8px;
font-size: 14px;
color: var(--el-text-color-regular);
}
}
}
}
</style>

View File

@@ -0,0 +1,52 @@
<template>
<div class="page-content">
<h3 class="table-title"><i class="iconfont-sys">&#xe74d;</i>更新日志</h3>
<ArtTable :data="upgradeLogList" :pagination="false">
<ElTableColumn label="版本号" prop="version" width="200" />
<ElTableColumn label="内容">
<template #default="scope">
<div class="title">{{ scope.row.title }}</div>
<div v-if="scope.row.detail" style="margin-top: 10px">
<div class="detail-item" v-for="(item, index) in scope.row.detail" :key="index">
{{ index + 1 }}. {{ item }}
</div>
</div>
</template>
</ElTableColumn>
<ElTableColumn label="时间" prop="date" />
</ArtTable>
</div>
</template>
<script setup lang="ts">
import ArtTable from '@/components/core/tables/ArtTable.vue'
import { upgradeLogList } from '@/mock/upgrade/changeLog'
defineOptions({ name: 'ChangeLog' })
</script>
<style lang="scss" scoped>
.page-content {
.table-title {
display: flex;
align-items: center;
padding: 10px 0 0;
font-size: 18px;
font-weight: 500;
i {
margin-right: 10px;
font-size: 24px;
}
}
.title {
color: var(--art-gray-800);
}
.detail-item {
color: var(--art-gray-600);
}
}
</style>

View File

@@ -0,0 +1,53 @@
<template>
<div class="analysis-dashboard">
<el-row :gutter="20">
<el-col :xl="14" :lg="15" :xs="24">
<TodaySales />
</el-col>
<el-col :xl="10" :lg="9" :xs="24">
<VisitorInsights />
</el-col>
</el-row>
<el-row :gutter="20" class="mt-20">
<el-col :xl="10" :lg="10" :xs="24">
<TotalRevenue />
</el-col>
<el-col :xl="7" :lg="7" :xs="24">
<CustomerSatisfaction />
</el-col>
<el-col :xl="7" :lg="7" :xs="24">
<TargetVsReality />
</el-col>
</el-row>
<el-row :gutter="20" class="mt-20">
<el-col :xl="10" :lg="10" :xs="24">
<TopProducts />
</el-col>
<el-col :xl="7" :lg="7" :xs="24">
<SalesMappingByCountry />
</el-col>
<el-col :xl="7" :lg="7" :xs="24">
<VolumeServiceLevel />
</el-col>
</el-row>
</div>
</template>
<script setup lang="ts">
import TodaySales from './widget/TodaySales.vue'
import VisitorInsights from './widget/VisitorInsights.vue'
import TotalRevenue from './widget/TotalRevenue.vue'
import CustomerSatisfaction from './widget/CustomerSatisfaction.vue'
import TargetVsReality from './widget/TargetVsReality.vue'
import TopProducts from './widget/TopProducts.vue'
import SalesMappingByCountry from './widget/SalesMappingByCountry.vue'
import VolumeServiceLevel from './widget/VolumeServiceLevel.vue'
defineOptions({ name: 'Analysis' })
</script>
<style lang="scss" scoped>
@use './style';
</style>

View File

@@ -0,0 +1,61 @@
.analysis-dashboard {
padding-bottom: 20px;
:deep(.custom-card) {
background: var(--art-main-bg-color);
border-radius: calc(var(--custom-radius) + 4px) !important;
}
// 卡片头部
:deep(.custom-card-header) {
position: relative;
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: space-between;
padding: 18px 20px;
.title {
font-size: 20px;
font-weight: 400;
color: var(--art-text-gray-900);
}
.subtitle {
position: absolute;
bottom: 2px;
left: 21px;
font-size: 13px;
color: var(--art-gray-600);
}
}
.el-card {
border: 1px solid #e8ebf1;
box-shadow: none;
}
.mt-20 {
margin-top: 20px;
}
}
.dark {
.analysis-dashboard {
:deep(.custom-card) {
box-shadow: 0 4px 20px rgb(0 0 0 / 50%);
}
}
}
@media (width <= 1200px) {
.analysis-dashboard {
.mt-20 {
margin-top: 0;
}
:deep(.custom-card) {
margin-bottom: 20px;
}
}
}

View File

@@ -0,0 +1,134 @@
<template>
<div class="custom-card art-custom-card customer-satisfaction">
<div class="custom-card-header">
<span class="title">{{ t('analysis.customerSatisfaction.title') }}</span>
</div>
<div class="custom-card-body">
<div ref="chartRef" style="height: 300px; margin-top: 10px"></div>
</div>
</div>
</template>
<script setup lang="ts">
import * as echarts from 'echarts'
import { useI18n } from 'vue-i18n'
import { useChart } from '@/composables/useChart'
const { t } = useI18n()
const { chartRef, isDark, initChart } = useChart()
const options: () => echarts.EChartsOption = () => ({
grid: {
top: 30,
right: 20,
bottom: 50,
left: 20,
containLabel: true
},
tooltip: {
trigger: 'axis',
confine: true
},
legend: {
data: [
t('analysis.customerSatisfaction.legend.lastMonth'),
t('analysis.customerSatisfaction.legend.thisMonth')
],
bottom: 0,
textStyle: {
fontSize: 12,
color: isDark.value ? '#808290' : '#222B45'
}
},
xAxis: {
type: 'category',
boundaryGap: false,
data: ['Week 0', 'Week 1', 'Week 2', 'Week 3', 'Week 4', 'Week 5', 'Week 6'],
axisLine: { show: false },
axisTick: { show: false },
axisLabel: { show: false } // 隐藏 x 轴标签
},
yAxis: {
type: 'value',
axisLine: { show: false },
axisTick: { show: false },
axisLabel: { show: false },
splitLine: {
show: false // 将 show 设置为 false 以去除水平线条
}
},
series: [
{
name: t('analysis.customerSatisfaction.legend.lastMonth'),
type: 'line',
smooth: true,
data: [1800, 2800, 1800, 2300, 2600, 2500, 3000],
areaStyle: {
opacity: 0.8,
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: 'rgba(0,157,255,0.33)' },
{ offset: 1, color: 'rgba(255,255,255,0)' }
])
},
lineStyle: {
width: 2,
color: '#0086E1'
},
symbol: 'none',
itemStyle: {
color: '#0095FF'
}
},
{
name: t('analysis.customerSatisfaction.legend.thisMonth'),
type: 'line',
smooth: true,
data: [4000, 3500, 4300, 3700, 4500, 3500, 4000],
areaStyle: {
opacity: 0.8,
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: 'rgba(147,241,180,0.33)' },
{ offset: 1, color: 'rgba(255,255,255,0)' }
])
},
lineStyle: {
width: 2,
color: '#14DEB9'
},
symbol: 'none',
itemStyle: {
color: '#14DEB9'
}
}
]
})
watch(isDark, () => {
initChart(options())
})
onMounted(() => {
initChart(options())
})
</script>
<style lang="scss" scoped>
.custom-card {
height: 400px;
&-body {
padding: 10px 0;
}
}
@media (max-width: $device-notebook) {
.custom-card {
height: 350px;
&-body {
> div {
height: 260px !important;
}
}
}
}
</style>

View File

@@ -0,0 +1,80 @@
<template>
<div class="custom-card art-custom-card sales-mapping-country">
<div class="custom-card-header">
<span class="title">{{ t('analysis.salesMappingCountry.title') }}</span>
</div>
<div class="custom-card-body">
<div ref="chartRef" class="sales-mapping-chart"></div>
</div>
</div>
</template>
<script lang="ts" setup>
import type { EChartsOption } from 'echarts'
import { useChart } from '@/composables/useChart'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const { chartRef, initChart, isDark } = useChart()
const chartData = [
{ value: 1048, name: 'Beijing', itemStyle: { color: 'rgba(99, 102, 241, 0.9)' } },
{ value: 735, name: 'Shanghai', itemStyle: { color: 'rgba(134, 239, 172, 0.9)' } },
{ value: 580, name: 'Guangzhou', itemStyle: { color: 'rgba(253, 224, 71, 0.9)' } },
{ value: 484, name: 'Shenzhen', itemStyle: { color: 'rgba(248, 113, 113, 0.9)' } },
{ value: 300, name: 'Chengdu', itemStyle: { color: 'rgba(125, 211, 252, 0.9)' } }
]
const options: () => EChartsOption = () => ({
tooltip: {
trigger: 'item'
},
series: [
{
name: 'Sales Mapping',
type: 'pie',
radius: ['40%', '60%'],
avoidLabelOverlap: false,
padAngle: 5,
itemStyle: {
borderRadius: 10
},
label: {
show: false,
position: 'center'
},
emphasis: {
label: {
show: true,
fontSize: 40,
fontWeight: 'bold'
}
},
labelLine: {
show: false
},
data: chartData
}
]
})
watch(isDark, () => {
initChart(options())
})
onMounted(() => {
initChart(options())
})
</script>
<style lang="scss" scoped>
.sales-mapping-country {
height: 330px;
.sales-mapping-chart {
width: 100%;
height: 260px;
}
}
</style>

View File

@@ -0,0 +1,230 @@
<template>
<div class="custom-card art-custom-card target-vs-reality">
<div class="custom-card-header">
<span class="title">{{ t('analysis.targetVsReality.title') }}</span>
</div>
<div class="custom-card-body">
<div ref="chartRef" style="height: 160px"></div>
</div>
<div class="custom-card-footer">
<div class="total-item">
<div class="label">
<i class="iconfont-sys">&#xe77f;</i>
<div class="label-text">
<span>{{ t('analysis.targetVsReality.realitySales.label') }}</span>
<span>{{ t('analysis.targetVsReality.realitySales.sublabel') }}</span>
</div>
</div>
<div class="value text-color-green">8,823</div>
</div>
<div class="total-item">
<div class="label">
<i class="iconfont-sys">&#xe77c;</i>
<div class="label-text">
<span>{{ t('analysis.targetVsReality.targetSales.label') }}</span>
<span>{{ t('analysis.targetVsReality.targetSales.sublabel') }}</span>
</div>
</div>
<div class="value text-color-orange">12,122</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useChart } from '@/composables/useChart'
import { EChartsOption } from 'echarts'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const { chartRef, isDark, initChart } = useChart()
const options: () => EChartsOption = () => ({
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'shadow'
}
},
grid: {
top: 10,
right: 0,
bottom: 0,
left: 0,
containLabel: true
},
xAxis: {
type: 'category',
data: ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'June', 'July'],
axisLabel: {
color: '#7B91B0'
},
axisLine: {
show: false // 隐藏 x 轴线
},
axisTick: {
show: false // 隐藏刻度线
}
},
yAxis: {
type: 'value',
axisLabel: {
show: false // 隐藏 y 轴文字
},
splitLine: {
show: false // 隐藏 y 轴分割线
},
axisLine: {
show: false // 隐藏 y 轴线
}
},
series: [
{
name: 'Reality Sales',
type: 'bar',
data: [8000, 7000, 6000, 8500, 9000, 10000, 9500],
barWidth: '15',
itemStyle: {
borderRadius: [4, 4, 0, 0],
color: '#2B8DFA'
}
},
{
name: 'Target Sales',
type: 'bar',
data: [10000, 9000, 11000, 10000, 12000, 12500, 11500],
barWidth: '15',
itemStyle: {
borderRadius: [4, 4, 4, 4],
color: '#95E0FB'
}
}
]
})
watch(isDark, () => {
initChart(options())
})
onMounted(() => {
initChart(options())
})
</script>
<style lang="scss" scoped>
.custom-card {
height: 400px;
&-body {
padding: 20px;
}
&-footer {
box-sizing: border-box;
padding: 0 20px;
margin-top: 15px;
.total-item {
display: flex;
margin-bottom: 20px;
text-align: center;
&:first-of-type .label .iconfont-sys {
color: #2b8dfa !important;
background-color: #e6f7ff !important;
}
&:last-of-type .label .iconfont-sys {
color: #1cb8fc !important;
background-color: #e6f7ff !important;
}
.label {
display: flex;
align-items: center;
justify-content: flex-start;
width: 60%;
font-size: 14px;
color: #606266;
.iconfont-sys {
width: 40px;
height: 40px;
margin-right: 12px;
font-size: 18px;
line-height: 40px;
text-align: center;
background-color: #f2f2f2;
border-radius: 6px;
}
.label-text {
display: flex;
flex-direction: column;
align-items: flex-start;
span {
&:first-of-type {
font-size: 16px;
color: var(--art-text-gray-800);
}
&:last-of-type {
margin-top: 4px;
font-size: 12px;
color: #737791;
}
}
}
}
.value {
font-size: 18px;
font-weight: 400;
&.text-color-green {
color: #2b8dfa !important;
}
&.text-color-orange {
color: #1cb8fc !important;
}
}
}
}
}
@media (max-width: $device-notebook) {
.custom-card {
height: 350px;
&-body {
padding-top: 10px;
> div {
height: 140px !important;
}
}
&-footer {
margin-top: 0;
}
}
}
.dark {
.custom-card {
&-footer {
.total-item {
&:first-of-type .label .iconfont-sys {
background-color: #222 !important;
}
&:last-of-type .label .iconfont-sys {
background-color: #222 !important;
}
}
}
}
}
</style>

View File

@@ -0,0 +1,196 @@
<template>
<div class="custom-card art-custom-card today-sales">
<div class="custom-card-header">
<span class="title">{{ t('analysis.todaySales.title') }}</span>
<span class="subtitle">{{ t('analysis.todaySales.subtitle') }}</span>
<div class="export-btn">
<i class="iconfont-sys">&#xe6d1;</i>
<span>{{ t('analysis.todaySales.export') }}</span>
</div>
</div>
<div class="sales-summary">
<el-row :gutter="20">
<el-col :span="6" :xs="24" v-for="(item, index) in salesData" :key="index">
<div :class="['sales-card art-custom-card']">
<i class="iconfont-sys" :class="item.class" v-html="item.iconfont"></i>
<h2>
<CountTo
class="number box-title"
:endVal="item.value"
:duration="1000"
separator=""
></CountTo>
</h2>
<p>{{ item.label }}</p>
<small>{{ item.change }} {{ t('analysis.todaySales.fromYesterday') }}</small>
</div>
</el-col>
</el-row>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { CountTo } from 'vue3-count-to'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const salesData = ref([
{
label: t('analysis.todaySales.cards.totalSales.label'),
value: 999,
change: t('analysis.todaySales.cards.totalSales.change'),
iconfont: '&#xe7d9',
class: 'bg-primary'
},
{
label: t('analysis.todaySales.cards.totalOrder.label'),
value: 300,
change: t('analysis.todaySales.cards.totalOrder.change'),
iconfont: '&#xe70f',
class: 'bg-warning'
},
{
label: t('analysis.todaySales.cards.productSold.label'),
value: 56,
change: t('analysis.todaySales.cards.productSold.change'),
iconfont: '&#xe712',
class: 'bg-error'
},
{
label: t('analysis.todaySales.cards.newCustomers.label'),
value: 68,
change: t('analysis.todaySales.cards.newCustomers.change'),
iconfont: '&#xe77f',
class: 'bg-success'
}
])
</script>
<style lang="scss" scoped>
.today-sales {
height: 330px;
.export-btn {
display: flex;
align-items: center;
justify-content: center;
min-width: 66px;
padding: 6px 0;
color: var(--art-gray-600);
cursor: pointer;
border: 1px solid var(--art-border-dashed-color);
border-radius: 6px;
transition: all 0.3s;
&:hover {
color: var(--main-color);
border-color: var(--main-color);
}
.iconfont-sys {
margin-right: 5px;
font-size: 10px;
}
span {
font-size: 12px;
}
}
.sales-summary {
padding: 20px;
.sales-card {
display: flex;
flex-direction: column;
justify-content: center;
height: 220px;
padding: 0 20px;
overflow: hidden;
border-radius: calc(var(--custom-radius) / 2 + 4px) !important;
.iconfont-sys {
width: 48px;
height: 48px;
font-size: 20px;
line-height: 48px;
color: #fff;
color: var(--el-color-primary);
text-align: center;
background-color: var(--el-color-primary-light-9);
border-radius: 10px;
}
h2 {
margin-top: 10px;
font-size: 26px;
font-weight: 400;
color: var(--art-text-gray-900) !important;
}
p {
margin-top: 10px;
font-size: 16px;
color: var(--art-text-gray-700) !important;
@include ellipsis;
}
small {
display: block;
margin-top: 10px;
color: var(--art-text-gray-500) !important;
@include ellipsis;
}
}
}
}
// 暗黑模式降低颜色强度
.dark {
.today-sales {
.sales-summary {
.sales-card {
.iconfont-sys {
&.red,
&.yellow,
&.green,
&.purple {
background-color: #222 !important;
}
}
}
}
}
}
@media (max-width: $device-notebook) {
.today-sales {
height: 280px;
.sales-summary {
.sales-card {
height: 170px;
}
}
}
}
@media (width <= 768px) {
.today-sales {
height: auto;
.sales-summary {
padding-bottom: 0;
.sales-card {
margin-bottom: 20px;
}
}
}
}
</style>

View File

@@ -0,0 +1,115 @@
<template>
<div class="custom-card art-custom-card top-products">
<div class="custom-card-header">
<span class="title">{{ t('analysis.topProducts.title') }}</span>
</div>
<div class="custom-card-body">
<art-table
:data="products"
style="width: 100%"
:pagination="false"
size="large"
:border="false"
:stripe="false"
:show-header-background="false"
>
<el-table-column prop="name" :label="t('analysis.topProducts.columns.name')" width="200" />
<el-table-column prop="popularity" :label="t('analysis.topProducts.columns.popularity')">
<template #default="scope">
<el-progress
:percentage="scope.row.popularity"
:color="getColor(scope.row.popularity)"
:stroke-width="5"
:show-text="false"
/>
</template>
</el-table-column>
<el-table-column prop="sales" :label="t('analysis.topProducts.columns.sales')" width="80">
<template #default="scope">
<span
:style="{
color: getColor(scope.row.popularity),
backgroundColor: `rgba(${hexToRgb(getColor(scope.row.popularity))}, 0.08)`,
border: '1px solid',
padding: '3px 6px',
borderRadius: '4px',
fontSize: '12px'
}"
>{{ scope.row.sales }}</span
>
</template>
</el-table-column>
</art-table>
</div>
</div>
</template>
<script setup lang="ts">
import { hexToRgb } from '@/utils/ui'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
// 使用 computed 来创建响应式的产品数据
const products = computed(() => [
{
name: t('analysis.topProducts.products.homeDecor.name'),
popularity: 10,
sales: t('analysis.topProducts.products.homeDecor.sales')
},
{
name: t('analysis.topProducts.products.disneyBag.name'),
popularity: 29,
sales: t('analysis.topProducts.products.disneyBag.sales')
},
{
name: t('analysis.topProducts.products.bathroom.name'),
popularity: 65,
sales: t('analysis.topProducts.products.bathroom.sales')
},
{
name: t('analysis.topProducts.products.smartwatch.name'),
popularity: 32,
sales: t('analysis.topProducts.products.smartwatch.sales')
},
{
name: t('analysis.topProducts.products.fitness.name'),
popularity: 78,
sales: t('analysis.topProducts.products.fitness.sales')
},
{
name: t('analysis.topProducts.products.earbuds.name'),
popularity: 41,
sales: t('analysis.topProducts.products.earbuds.sales')
}
])
const getColor = (percentage: number) => {
if (percentage < 25) return '#00E096'
if (percentage < 50) return '#0095FF'
if (percentage < 75) return '#884CFF'
return '#FE8F0E'
}
</script>
<style lang="scss" scoped>
.custom-card {
height: 330px;
overflow-y: scroll;
// 隐藏滚动条
&::-webkit-scrollbar {
display: none;
}
&-body {
padding: 0 6px;
}
}
@media (width <= 1200px) {
.custom-card {
height: auto;
}
}
</style>

View File

@@ -0,0 +1,129 @@
<template>
<div class="custom-card art-custom-card total-revenue">
<div class="custom-card-header">
<span class="title">{{ t('analysis.totalRevenue.title') }}</span>
</div>
<div class="custom-card-body">
<div ref="chartRef" style="height: 300px"></div>
</div>
</div>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { EChartsOption } from 'echarts'
import { useChart } from '@/composables/useChart'
const { t } = useI18n()
const { chartRef, isDark, initChart } = useChart()
// 创建图表选项
const options: () => EChartsOption = () => ({
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'shadow'
}
},
grid: {
top: 20,
right: 3,
bottom: 40,
left: 3,
containLabel: true
},
legend: {
data: [
t('analysis.totalRevenue.legend.onlineSales'),
t('analysis.totalRevenue.legend.offlineSales')
],
bottom: 0,
icon: 'circle',
itemWidth: 10,
itemHeight: 10,
itemGap: 15,
textStyle: {
fontSize: 12,
color: isDark.value ? '#808290' : '#222B45'
}
},
xAxis: {
type: 'category',
data: ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'],
axisLine: {
show: false
},
axisTick: {
show: false
},
axisLabel: {
color: isDark.value ? '#808290' : '#7B91B0'
}
},
yAxis: {
type: 'value',
axisLine: { show: false },
axisTick: { show: false },
splitLine: {
lineStyle: {
color: isDark.value ? 'rgba(255, 255, 255, 0.1)' : '#EFF1F3',
width: 0.8
}
},
axisLabel: { color: isDark.value ? '#808290' : '#7B91B0' }
},
series: [
{
name: t('analysis.totalRevenue.legend.onlineSales'),
type: 'bar',
data: [12, 13, 5, 15, 10, 15, 18],
barWidth: '15',
itemStyle: {
color: '#0095FF',
borderRadius: [4, 4, 4, 4]
}
},
{
name: t('analysis.totalRevenue.legend.offlineSales'),
type: 'bar',
data: [10, 11, 20, 5, 11, 13, 10],
barWidth: '15',
itemStyle: {
color: '#95E0FB',
borderRadius: [4, 4, 4, 4]
}
}
]
})
watch(isDark, () => {
initChart(options())
})
onMounted(() => {
initChart(options())
})
</script>
<style lang="scss" scoped>
.custom-card {
height: 400px;
&-body {
padding: 20px;
}
}
@media (max-width: $device-notebook) {
.custom-card {
height: 350px;
&-body {
> div {
height: 260px !important;
}
}
}
}
</style>

View File

@@ -0,0 +1,143 @@
<template>
<div class="custom-card art-custom-card visitor-insights">
<div class="custom-card-header">
<span class="title">{{ t('analysis.visitorInsights.title') }}</span>
</div>
<div class="card-body">
<div ref="chartRef" style="height: 250px"></div>
</div>
</div>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { useChart } from '@/composables/useChart'
import { EChartsOption } from 'echarts'
const { t } = useI18n()
const { chartRef, isDark, initChart } = useChart()
const { width } = useWindowSize()
const options: () => EChartsOption = () => {
return {
tooltip: {
trigger: 'axis'
},
grid: {
top: 20,
right: 20,
bottom: width.value < 600 ? 80 : 40,
left: 20,
containLabel: true
},
legend: {
data: [
t('analysis.visitorInsights.legend.loyalCustomers'),
t('analysis.visitorInsights.legend.newCustomers')
],
bottom: 0,
left: 'center',
itemWidth: 14,
itemHeight: 14,
textStyle: {
fontSize: 12,
color: isDark.value ? '#808290' : '#222B45'
},
icon: 'roundRect',
itemStyle: {
borderRadius: 4
}
},
xAxis: {
type: 'category',
data: ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'],
axisLine: { show: false },
axisTick: { show: false },
axisLabel: { color: isDark.value ? '#808290' : '#7B91B0' }
},
yAxis: {
type: 'value',
axisLine: { show: false },
axisTick: { show: false },
splitLine: {
show: true,
lineStyle: {
color: isDark.value ? 'rgba(255, 255, 255, 0.1)' : '#EFF1F3',
width: 0.8
}
},
axisLabel: { color: isDark.value ? '#808290' : '#7B91B0' }
},
series: [
{
name: t('analysis.visitorInsights.legend.loyalCustomers'),
type: 'line',
smooth: true,
symbol: 'none',
data: [260, 200, 150, 130, 180, 270, 340, 380, 300, 220, 170, 130],
lineStyle: {
color: '#2B8DFA',
width: 3
},
itemStyle: {
color: '#2B8DFA'
}
},
{
name: t('analysis.visitorInsights.legend.newCustomers'),
type: 'line',
smooth: true,
symbol: 'none',
data: [280, 350, 300, 250, 230, 210, 240, 280, 320, 350, 300, 200],
lineStyle: {
color: '#49BEFF',
width: 3
},
itemStyle: {
color: '#49BEFF'
}
}
]
}
}
watch(isDark, () => {
initChart(options())
})
onMounted(() => {
initChart(options())
})
</script>
<style lang="scss" scoped>
.visitor-insights {
height: 330px;
}
@media (max-width: $device-notebook) {
.visitor-insights {
height: 280px;
.card-body {
> div {
height: 210px !important;
}
}
}
}
@media (max-width: $device-phone) {
.visitor-insights {
height: 315px;
.card-body {
> div {
height: 240px !important;
}
}
}
}
</style>

View File

@@ -0,0 +1,125 @@
<template>
<div class="custom-card art-custom-card volume-service-level">
<div class="custom-card-header">
<span class="title">{{ t('analysis.volumeServiceLevel.title') }}</span>
</div>
<div class="custom-card-body">
<div ref="chartRef" class="chart-container"></div>
</div>
</div>
</template>
<script lang="ts" setup>
import { useI18n } from 'vue-i18n'
import { useChart } from '@/composables/useChart'
import { EChartsOption } from 'echarts'
const { t } = useI18n()
const { chartRef, isDark, initChart } = useChart()
// 模拟数据
const chartData = [
{ volume: 800, services: 400 },
{ volume: 1000, services: 600 },
{ volume: 750, services: 300 },
{ volume: 600, services: 250 },
{ volume: 450, services: 200 },
{ volume: 500, services: 300 }
]
const options: () => EChartsOption = () => ({
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'shadow'
}
},
legend: {
data: [
t('analysis.volumeServiceLevel.legend.volume'),
t('analysis.volumeServiceLevel.legend.services')
],
bottom: 20,
itemWidth: 10,
itemHeight: 10,
icon: 'circle',
textStyle: {
fontSize: 12,
color: isDark.value ? '#808290' : '#222B45'
}
},
grid: {
left: '20',
right: '20',
bottom: '60',
top: '30',
containLabel: true
},
xAxis: {
type: 'category',
data: chartData.map((_, index) => `${index + 1}`),
axisLine: {
show: true,
lineStyle: {
color: isDark.value ? 'rgba(255, 255, 255, 0.1)' : '#EFF1F3',
width: 0.8
}
},
axisTick: { show: false },
axisLabel: { show: false }
},
yAxis: {
type: 'value',
axisLine: { show: false },
axisTick: { show: false },
splitLine: { show: false },
axisLabel: { show: false }
},
series: [
{
name: t('analysis.volumeServiceLevel.legend.volume'),
type: 'bar',
stack: 'total',
data: chartData.map((item) => item.volume),
itemStyle: {
color: '#0095FF',
borderRadius: [0, 0, 4, 4]
},
barWidth: '15'
},
{
name: t('analysis.volumeServiceLevel.legend.services'),
type: 'bar',
stack: 'total',
data: chartData.map((item) => item.services),
itemStyle: {
color: '#95E0FB',
borderRadius: [4, 4, 0, 0]
},
barWidth: '50%'
}
]
})
watch(isDark, () => {
initChart(options())
})
onMounted(() => {
initChart(options())
})
</script>
<style lang="scss" scoped>
.volume-service-level {
height: 330px;
.custom-card-body {
padding: 20px;
}
.chart-container {
height: 250px;
}
}
</style>

View File

@@ -0,0 +1,47 @@
<template>
<div class="console">
<CardList></CardList>
<el-row :gutter="20">
<el-col :sm="24" :md="12" :lg="10">
<ActiveUser />
</el-col>
<el-col :sm="24" :md="12" :lg="14">
<SalesOverview />
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :sm="24" :md="24" :lg="12">
<NewUser />
</el-col>
<el-col :sm="24" :md="12" :lg="6">
<Dynamic />
</el-col>
<el-col :sm="24" :md="12" :lg="6">
<TodoList />
</el-col>
</el-row>
<AboutProject />
</div>
</template>
<script setup lang="ts">
import CardList from './widget/CardList.vue'
import ActiveUser from './widget/ActiveUser.vue'
import SalesOverview from './widget/SalesOverview.vue'
import NewUser from './widget/NewUser.vue'
import Dynamic from './widget/Dynamic.vue'
import TodoList from './widget/TodoList.vue'
import AboutProject from './widget/AboutProject.vue'
import { useCommon } from '@/composables/useCommon'
defineOptions({ name: 'Console' })
useCommon().scrollToTop()
</script>
<style lang="scss" scoped>
@use './style';
</style>

View File

@@ -0,0 +1,43 @@
@use '@styles/variables.scss' as *;
.console {
--card-spacing: 20px;
// 卡片头部
:deep(.card-header) {
display: flex;
justify-content: space-between;
padding: 20px 25px 5px 0;
.title {
h4 {
font-size: 18px;
font-weight: 500;
color: var(--art-gray-900) !important;
}
p {
margin-top: 3px;
font-size: 13px;
color: var(--art-gray-600) !important;
span {
margin-left: 10px;
color: #52c41a;
}
}
}
}
// 设置卡片背景色、圆角、间隙
:deep(.card-list .card),
.card {
margin-bottom: var(--card-spacing);
background: var(--art-main-bg-color);
border-radius: calc(var(--custom-radius) + 4px) !important;
}
@media screen and (max-width: $device-phone) {
--card-spacing: 15px;
}
}

View File

@@ -0,0 +1,139 @@
<template>
<div class="card about-project art-custom-card">
<div>
<h2 class="box-title">关于项目</h2>
<p>{{ systemName }} 是一款专注于用户体验和视觉设计的后台管理系统模版</p>
<p>使用了 Vue3TypeScriptViteElement Plus 等前沿技术</p>
<div class="button-wrap">
<div class="btn art-custom-card" @click="goPage(WEB_LINKS.DOCS)">
<span>项目官网</span>
<i class="iconfont-sys">&#xe703;</i>
</div>
<div class="btn art-custom-card" @click="goPage(WEB_LINKS.INTRODUCE)">
<span>文档</span>
<i class="iconfont-sys">&#xe703;</i>
</div>
<div class="btn art-custom-card" @click="goPage(WEB_LINKS.GITHUB_HOME)">
<span>Github</span>
<i class="iconfont-sys">&#xe703;</i>
</div>
<div class="btn art-custom-card" @click="goPage(WEB_LINKS.BLOG)">
<span>博客</span>
<i class="iconfont-sys">&#xe703;</i>
</div>
</div>
</div>
<img class="right-img" src="@imgs/draw/draw1.png" />
</div>
</template>
<script setup lang="ts">
import AppConfig from '@/config'
import { WEB_LINKS } from '@/utils/constants'
const systemName = AppConfig.systemInfo.name
const goPage = (url: string) => {
window.open(url)
}
</script>
<style lang="scss" scoped>
.about-project {
box-sizing: border-box;
display: flex;
justify-content: space-between;
height: 300px;
padding: 20px;
h2 {
margin-top: 10px;
font-size: 20px;
font-weight: 500;
color: var(--art-gray-900) !important;
}
p {
margin-top: 5px;
font-size: 14px;
color: var(--art-gray-600);
}
}
.button-wrap {
display: flex;
flex-wrap: wrap;
width: 600px;
margin-top: 35px;
}
.btn {
display: flex;
align-items: center;
justify-content: space-between;
width: 240px;
height: 50px;
padding: 0 15px;
margin: 0 15px 15px 0;
font-size: 14px;
color: var(--art-gray-800);
cursor: pointer;
background: var(--art-bg-color);
border-radius: calc(var(--custom-radius) / 2 + 2px) !important;
transition: all 0.3s;
&:hover {
box-shadow: 0 5px 10px rgb(0 0 0 / 5%);
transform: translateY(-4px);
}
}
// 响应式设计 - iPad Pro及以下
@media screen and (max-width: $device-ipad-pro) {
.about-project {
height: auto;
}
.button-wrap {
width: 470px;
margin-top: 20px;
}
.btn {
width: 180px;
}
.right-img {
width: 300px;
height: 230px;
}
}
// 响应式设计 - iPad垂直及以下
@media screen and (max-width: $device-ipad-vertical) {
.button-wrap {
width: 100%;
}
.btn {
width: 190px;
}
.right-img {
display: none;
}
}
// 响应式设计 - 手机端
@media screen and (max-width: $device-phone) {
.about-project {
padding: 0 15px;
}
.btn {
width: 100%;
margin-right: 0;
}
}
</style>

View File

@@ -0,0 +1,176 @@
<template>
<div class="card art-custom-card">
<div class="chart" ref="chartRef"></div>
<div class="text">
<h3 class="box-title">用户概述</h3>
<p class="subtitle">比上周 <span class="text-success">+23%</span></p>
<p class="subtitle">我们为您创建了多个选项可将它们组合在一起并定制为像素完美的页面</p>
</div>
<div class="list">
<div v-for="(item, index) in list" :key="index">
<p>{{ item.num }}</p>
<p class="subtitle">{{ item.name }}</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import * as echarts from 'echarts'
import { getCssVar } from '@/utils/ui'
import { useChart } from '@/composables/useChart'
import { EChartsOption } from 'echarts'
const {
chartRef,
isDark,
initChart,
getAxisLineStyle,
getAxisLabelStyle,
getAxisTickStyle,
getSplitLineStyle
} = useChart()
const list = [
{ name: '总用户量', num: '32k' },
{ name: '总访问量', num: '128k' },
{ name: '日访问量', num: '1.2k' },
{ name: '周同比', num: '+5%' }
]
const options: () => EChartsOption = () => {
return {
grid: {
top: 15,
right: 0,
bottom: 0,
left: 0,
containLabel: true
},
tooltip: {
trigger: 'item'
},
xAxis: {
type: 'category',
data: [1, 2, 3, 4, 5, 6, 7, 8, 9],
axisTick: getAxisTickStyle(),
axisLine: getAxisLineStyle(true),
axisLabel: getAxisLabelStyle(true)
},
yAxis: {
axisLabel: getAxisLabelStyle(true),
axisLine: getAxisLineStyle(!isDark.value),
splitLine: getSplitLineStyle(true)
},
series: [
{
data: [160, 100, 150, 80, 190, 100, 175, 120, 160],
type: 'bar',
itemStyle: {
borderRadius: 4,
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{
offset: 0,
color: getCssVar('--el-color-primary-light-4')
},
{
offset: 1,
color: getCssVar('--el-color-primary')
}
])
},
barWidth: '50%',
animationDelay: (idx) => idx * 50 + 300,
animationDuration: (idx) => 1500 - idx * 50,
animationEasing: 'quarticOut' // 推荐动画: quarticOut exponentialOut quinticOut backOut
}
]
}
}
watch(isDark, () => {
initChart(options())
})
onMounted(() => {
initChart(options())
})
</script>
<style lang="scss" scoped>
.card {
box-sizing: border-box;
width: 100%;
height: 420px;
padding: 16px;
.chart {
box-sizing: border-box;
width: 100%;
height: 220px;
padding: 20px 0 20px 20px;
border-radius: calc(var(--custom-radius) / 2 + 4px) !important;
}
.text {
margin-left: 3px;
h3 {
margin-top: 20px;
font-size: 18px;
font-weight: 500;
}
p {
margin-top: 5px;
font-size: 14px;
&:last-of-type {
height: 42px;
margin-top: 5px;
}
}
}
.list {
display: flex;
justify-content: space-between;
margin-left: 3px;
> div {
flex: 1;
p {
font-weight: 400;
&:first-of-type {
font-size: 24px;
color: var(--art-gray-900);
}
&:last-of-type {
font-size: 13px;
}
}
}
}
}
.dark {
.card {
.chart {
background: none;
}
}
}
@media screen and (max-width: $device-phone) {
.dark {
.card {
.chart {
padding: 15px 0 0 !important;
}
}
}
}
</style>

View File

@@ -0,0 +1,161 @@
<template>
<el-row :gutter="20" :style="{ marginTop: showWorkTab ? '0' : '10px' }" class="card-list">
<el-col v-for="(item, index) in dataList" :key="index" :sm="12" :md="6" :lg="6">
<div class="card art-custom-card">
<span class="des subtitle">{{ item.des }}</span>
<CountTo
class="number box-title"
:endVal="item.num"
:duration="1000"
separator=""
></CountTo>
<div class="change-box">
<span class="change-text">较上周</span>
<span
class="change"
:class="[item.change.indexOf('+') === -1 ? 'text-danger' : 'text-success']"
>
{{ item.change }}
</span>
</div>
<i class="iconfont-sys" v-html="item.icon"></i>
</div>
</el-col>
</el-row>
</template>
<script setup lang="ts">
import { reactive } from 'vue'
import { storeToRefs } from 'pinia'
import { useSettingStore } from '@/store/modules/setting'
import { CountTo } from 'vue3-count-to'
const { showWorkTab } = storeToRefs(useSettingStore())
const dataList = reactive([
{
des: '总访问次数',
icon: '&#xe721;',
startVal: 0,
duration: 1000,
num: 9120,
change: '+20%'
},
{
des: '在线访客数',
icon: '&#xe724;',
startVal: 0,
duration: 1000,
num: 182,
change: '+10%'
},
{
des: '点击量',
icon: '&#xe7aa;',
startVal: 0,
duration: 1000,
num: 9520,
change: '-12%'
},
{
des: '新用户',
icon: '&#xe82a;',
startVal: 0,
duration: 1000,
num: 156,
change: '+30%'
}
])
</script>
<style lang="scss" scoped>
.card-list {
box-sizing: border-box;
display: flex;
flex-wrap: wrap;
background-color: transparent !important;
.art-custom-card {
position: relative;
box-sizing: border-box;
display: flex;
flex-direction: column;
justify-content: center;
width: 100%;
height: 140px;
padding: 0 18px;
list-style: none;
transition: all 0.3s ease;
$icon-size: 52px;
.iconfont-sys {
position: absolute;
top: 0;
right: 20px;
bottom: 0;
width: $icon-size;
height: $icon-size;
margin: auto;
overflow: hidden;
font-size: 22px;
line-height: $icon-size;
color: var(--el-color-primary) !important;
text-align: center;
background-color: var(--el-color-primary-light-9);
border-radius: 12px;
}
.des {
display: block;
height: 14px;
font-size: 14px;
line-height: 14px;
}
.number {
display: block;
margin-top: 10px;
font-size: 28px;
font-weight: 400;
}
.change-box {
display: flex;
align-items: center;
margin-top: 10px;
.change-text {
display: block;
font-size: 13px;
color: var(--art-text-gray-600);
}
.change {
display: block;
margin-left: 5px;
font-size: 13px;
font-weight: bold;
&.text-success {
color: var(--el-color-success);
}
&.text-danger {
color: var(--el-color-danger);
}
}
}
}
}
.dark {
.card-list {
.art-custom-card {
.iconfont-sys {
background-color: #232323 !important;
}
}
}
}
</style>

View File

@@ -0,0 +1,100 @@
<template>
<div class="card art-custom-card">
<div class="card-header">
<div class="title">
<h4 class="box-title">动态</h4>
<p class="subtitle">新增<span class="text-success">+6</span></p>
</div>
</div>
<div class="list">
<div v-for="(item, index) in list" :key="index">
<span class="user">{{ item.username }}</span>
<span class="type">{{ item.type }}</span>
<span class="target">{{ item.target }}</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { reactive } from 'vue-demi'
const list = reactive([
{
username: '中小鱼',
type: '关注了',
target: '誶誶淰'
},
{
username: '何小荷',
type: '发表文章',
target: 'Vue3 + Typescript + Vite 项目实战笔记'
},
{
username: '誶誶淰',
type: '提出问题',
target: '主题可以配置吗'
},
{
username: '发呆草',
type: '兑换了物品',
target: '《奇特的一生》'
},
{
username: '甜筒',
type: '关闭了问题',
target: '发呆草'
},
{
username: '冷月呆呆',
type: '兑换了物品',
target: '《高效人士的七个习惯》'
}
])
</script>
<style lang="scss" scoped>
.card {
box-sizing: border-box;
width: 100%;
height: 510px;
padding: 0 25px;
.header {
display: flex;
justify-content: space-between;
padding: 20px 0 0;
}
.list {
height: calc(100% - 100px);
margin-top: 10px;
overflow: hidden;
> div {
height: 70px;
overflow: hidden;
line-height: 70px;
border-bottom: 1px solid var(--art-border-color);
span {
font-size: 13px;
}
.user {
font-weight: 500;
color: var(--art-text-gray-800);
}
.type {
margin: 0 8px;
}
.target {
color: var(--main-color);
}
}
}
}
</style>

View File

@@ -0,0 +1,176 @@
<template>
<div class="card art-custom-card">
<div class="card-header">
<div class="title">
<h4 class="box-title">新用户</h4>
<p class="subtitle">这个月增长<span class="text-success">+20%</span></p>
</div>
<el-radio-group v-model="radio2">
<el-radio-button value="本月" label="本月"></el-radio-button>
<el-radio-button value="上月" label="上月"></el-radio-button>
<el-radio-button value="今年" label="今年"></el-radio-button>
</el-radio-group>
</div>
<art-table
class="table"
:data="tableData"
:pagination="false"
size="large"
:border="false"
:stripe="false"
:show-header-background="false"
>
<template #default>
<el-table-column label="头像" prop="avatar" width="150px">
<template #default="scope">
<div style="display: flex; align-items: center">
<img class="avatar" :src="scope.row.avatar" />
<span class="user-name">{{ scope.row.username }}</span>
</div>
</template>
</el-table-column>
<el-table-column label="地区" prop="province" />
<el-table-column label="性别" prop="avatar">
<template #default="scope">
<div style="display: flex; align-items: center">
<span style="margin-left: 10px">{{ scope.row.sex === 1 ? '男' : '女' }}</span>
</div>
</template>
</el-table-column>
<el-table-column label="进度" width="240">
<template #default="scope">
<el-progress :percentage="scope.row.pro" :color="scope.row.color" :stroke-width="4" />
</template>
</el-table-column>
</template>
</art-table>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref, reactive } from 'vue-demi'
import avatar1 from '@/assets/img/avatar/avatar1.webp'
import avatar2 from '@/assets/img/avatar/avatar2.webp'
import avatar3 from '@/assets/img/avatar/avatar3.webp'
import avatar4 from '@/assets/img/avatar/avatar4.webp'
import avatar5 from '@/assets/img/avatar/avatar5.webp'
import avatar6 from '@/assets/img/avatar/avatar6.webp'
const radio2 = ref('本月')
const tableData = reactive([
{
username: '中小鱼',
province: '北京',
sex: 0,
age: 22,
percentage: 60,
pro: 0,
color: 'rgb(var(--art-primary)) !important',
avatar: avatar1
},
{
username: '何小荷',
province: '深圳',
sex: 1,
age: 21,
percentage: 20,
pro: 0,
color: 'rgb(var(--art-secondary)) !important',
avatar: avatar2
},
{
username: '誶誶淰',
province: '上海',
sex: 1,
age: 23,
percentage: 60,
pro: 0,
color: 'rgb(var(--art-warning)) !important',
avatar: avatar3
},
{
username: '发呆草',
province: '长沙',
sex: 0,
age: 28,
percentage: 50,
pro: 0,
color: 'rgb(var(--art-info)) !important',
avatar: avatar4
},
{
username: '甜筒',
province: '浙江',
sex: 1,
age: 26,
percentage: 70,
pro: 0,
color: 'rgb(var(--art-error)) !important',
avatar: avatar5
},
{
username: '冷月呆呆',
province: '湖北',
sex: 1,
age: 25,
percentage: 90,
pro: 0,
color: 'rgb(var(--art-success)) !important',
avatar: avatar6
}
])
onMounted(() => {
addAnimation()
})
const addAnimation = () => {
setTimeout(() => {
for (let i = 0; i < tableData.length; i++) {
let item = tableData[i]
tableData[i].pro = item.percentage
}
}, 100)
}
</script>
<style lang="scss">
.card {
// 进度动画
.el-progress-bar__inner {
transition: all 1s !important;
}
.el-radio-button__original-radio:checked + .el-radio-button__inner {
color: var(--el-color-primary) !important;
background: transparent !important;
}
}
</style>
<style lang="scss" scoped>
.card {
width: 100%;
height: 510px;
overflow: hidden;
.card-header {
padding-left: 25px !important;
}
:deep(.el-table__body tr:last-child td) {
border-bottom: none !important;
}
.avatar {
width: 36px;
height: 36px;
border-radius: 6px;
}
.user-name {
margin-left: 10px;
}
}
</style>

View File

@@ -0,0 +1,143 @@
<template>
<div class="card art-custom-card">
<div class="card-header">
<div class="title">
<h4 class="box-title">访问量</h4>
<p class="subtitle">今年增长<span class="text-success">+15%</span></p>
</div>
</div>
<div class="chart" ref="chartRef"></div>
</div>
</template>
<script setup lang="ts">
import * as echarts from 'echarts'
import { hexToRgba, getCssVar } from '@/utils/ui'
import { EChartsOption } from 'echarts'
import { useChart } from '@/composables/useChart'
const {
chartRef,
isDark,
initChart,
updateChart,
getAxisLabelStyle,
getAxisLineStyle,
getAxisTickStyle,
getSplitLineStyle
} = useChart()
// 定义真实数据
const realData = [50, 25, 40, 20, 70, 35, 65, 30, 35, 20, 40, 44]
// 初始化动画函数
const initChartWithAnimation = () => {
// 首先初始化图表数据为0
initChart(options(true))
updateChart(options(false))
}
watch(isDark, () => {
initChart(options())
})
onMounted(() => {
initChartWithAnimation()
})
const options: (isInitial?: boolean) => EChartsOption = (isInitial) => {
const isInit = isInitial || false
return {
// 添加动画配置
animation: true,
animationDuration: 0,
animationDurationUpdate: 0,
grid: {
left: '2.2%',
right: '3%',
bottom: '0%',
top: '5px',
containLabel: true
},
tooltip: {
trigger: 'axis'
},
xAxis: {
type: 'category',
boundaryGap: false,
data: [
'1月',
'2月',
'3月',
'4月',
'5月',
'6月',
'7月',
'8月',
'9月',
'10月',
'11月',
'12月'
],
axisTick: getAxisTickStyle(),
axisLabel: getAxisLabelStyle(true),
axisLine: getAxisLineStyle(true)
},
yAxis: {
type: 'value',
min: 0,
max: realData.reduce((prev, curr) => Math.max(prev, curr), 0),
axisLabel: getAxisLabelStyle(true),
axisLine: getAxisLineStyle(!isDark.value),
splitLine: getSplitLineStyle(true)
},
series: [
{
name: '访客',
color: getCssVar('--main-color'),
type: 'line',
stack: '总量',
data: isInit ? new Array(12).fill(0) : realData,
smooth: true,
symbol: 'none',
lineStyle: {
width: 2.2
},
areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{
offset: 0,
color: hexToRgba(getCssVar('--el-color-primary'), 0.15).rgba
},
{
offset: 1,
color: hexToRgba(getCssVar('--el-color-primary'), 0.01).rgba
}
])
},
animationDuration: 0,
animationDurationUpdate: 1500
}
]
}
}
</script>
<style lang="scss" scoped>
.card {
box-sizing: border-box;
width: 100%;
height: 420px;
padding: 20px 0 30px;
.card-header {
padding: 0 18px !important;
}
.chart {
width: 100%;
height: calc(100% - 80px);
margin-top: 30px;
}
}
</style>

View File

@@ -0,0 +1,102 @@
<template>
<div class="card art-custom-card">
<div class="card-header">
<div class="title">
<h4 class="box-title">代办事项</h4>
<p class="subtitle">待处理<span class="text-danger">3</span></p>
</div>
</div>
<div class="list">
<div v-for="(item, index) in list" :key="index">
<p class="title">{{ item.username }}</p>
<p class="date subtitle">{{ item.date }}</p>
<el-checkbox v-model="item.complate" />
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { reactive } from 'vue-demi'
const list = reactive([
{
username: '查看今天工作内容',
date: '上午 09:30',
complate: true
},
{
username: '回复邮件',
date: '上午 10:30',
complate: true
},
{
username: '工作汇报整理',
date: '上午 11:00',
complate: true
},
{
username: '产品需求会议',
date: '下午 02:00',
complate: false
},
{
username: '整理会议内容',
date: '下午 03:30',
complate: false
},
{
username: '明天工作计划',
date: '下午 06:30',
complate: false
}
])
</script>
<style lang="scss" scoped>
.card {
box-sizing: border-box;
width: 100%;
height: 510px;
padding: 0 25px;
.list {
height: calc(100% - 90px);
margin-top: 10px;
overflow: hidden;
> div {
position: relative;
display: flex;
flex-direction: column;
justify-content: center;
height: 70px;
overflow: hidden;
border-bottom: 1px solid var(--art-border-color);
p {
font-size: 13px;
}
.title {
font-size: 14px;
}
.date {
margin-top: 6px;
font-size: 12px;
font-weight: 400;
}
.el-checkbox {
position: absolute;
top: 0;
right: 10px;
bottom: 0;
margin: auto;
}
}
}
}
</style>

View File

@@ -0,0 +1,80 @@
<template>
<div class="ecommerce">
<el-row :gutter="20">
<el-col :sm="24" :md="24" :lg="16">
<Banner />
</el-col>
<el-col :sm="12" :md="12" :lg="4">
<TotalOrderVolume />
</el-col>
<el-col :sm="12" :md="12" :lg="4">
<TotalProducts />
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :sm="12" :md="12" :lg="8">
<SalesTrend />
</el-col>
<el-col :sm="12" :md="12" :lg="8">
<SalesClassification />
</el-col>
<el-col :sm="24" :md="24" :lg="8">
<el-row :gutter="20">
<el-col :sm="24" :md="12" :lg="12">
<ProductSales />
</el-col>
<el-col :sm="24" :md="12" :lg="12">
<SalesGrowth />
</el-col>
<el-col :span="24" class="no-margin-bottom">
<CartConversionRate />
</el-col>
</el-row>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :sm="24" :md="12" :lg="8">
<HotCommodity />
</el-col>
<el-col :sm="24" :md="12" :lg="8">
<AnnualSales />
</el-col>
<el-col :sm="24" :md="24" :lg="8">
<TransactionList />
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :md="24" :lg="8">
<RecentTransaction />
</el-col>
<el-col :md="24" :lg="16" class="no-margin-bottom">
<HotProductsList />
</el-col>
</el-row>
</div>
</template>
<script setup lang="ts">
import Banner from './widget/Banner.vue'
import TotalOrderVolume from './widget/TotalOrderVolume.vue'
import TotalProducts from './widget/TotalProducts.vue'
import SalesTrend from './widget/SalesTrend.vue'
import SalesClassification from './widget/SalesClassification.vue'
import TransactionList from './widget/TransactionList.vue'
import HotCommodity from './widget/HotCommodity.vue'
import RecentTransaction from './widget/RecentTransaction.vue'
import AnnualSales from './widget/AnnualSales.vue'
import ProductSales from './widget/ProductSales.vue'
import SalesGrowth from './widget/SalesGrowth.vue'
import CartConversionRate from './widget/CartConversionRate.vue'
import HotProductsList from './widget/HotProductsList.vue'
defineOptions({ name: 'Ecommerce' })
</script>
<style lang="scss" scoped>
@use './style';
</style>

View File

@@ -0,0 +1,72 @@
.ecommerce {
:deep(.card) {
box-sizing: border-box;
padding: 20px;
background-color: var(--art-main-bg-color);
border-radius: var(--custom-radius);
.card-header {
padding-bottom: 15px;
.title {
font-size: 18px;
font-weight: 500;
color: var(--art-gray-900);
i {
margin-left: 10px;
}
}
.subtitle {
font-size: 14px;
color: var(--art-gray-500);
}
}
}
:deep(.icon-text-widget) {
display: flex;
justify-content: space-around;
.item {
display: flex;
align-items: center;
.icon {
display: flex;
align-items: center;
justify-content: center;
width: 42px;
height: 42px;
margin-right: 10px;
line-height: 42px;
color: var(--main-color);
background-color: var(--el-color-primary-light-9);
border-radius: 8px;
i {
font-size: 20px;
}
}
.content {
p {
font-size: 18px;
}
span {
font-size: 14px;
}
}
}
}
.no-margin-bottom {
margin-bottom: 0 !important;
}
.el-col {
margin-bottom: 20px;
}
}

View File

@@ -0,0 +1,37 @@
<template>
<div class="card art-custom-card yearly-card" style="height: 28.2rem">
<div class="card-header">
<p class="title">年度销售额</p>
<p class="subtitle">按季度统计</p>
</div>
<ArtBarChart
:showAxisLabel="false"
:showAxisLine="false"
:showSplitLine="false"
:data="[50, 80, 50, 90, 60, 70, 50]"
barWidth="26px"
height="16rem"
/>
<div class="icon-text-widget" style="margin-top: 50px">
<div class="item">
<div class="icon">
<i class="iconfont-sys">&#xe718;</i>
</div>
<div class="content">
<p>¥200,858</p>
<span>线上销售</span>
</div>
</div>
<div class="item">
<div class="icon">
<i class="iconfont-sys">&#xe70c;</i>
</div>
<div class="content">
<p>¥102,927</p>
<span>线下销售</span>
</div>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,73 @@
<template>
<ArtBasicBanner
class="banner"
:title="`欢迎回来 ${userInfo.userName}`"
:showButton="false"
backgroundColor="var(--el-color-primary-light-9)"
titleColor="var(--art-gray-900)"
subtitleColor="#666"
style="height: 13.3rem"
:backgroundImage="bannerCover"
:showDecoration="false"
imgWidth="18rem"
imgBottom="-7.5rem"
:showMeteors="true"
@click="handleBannerClick"
>
<div class="banner-slot">
<div class="item">
<p class="title">¥2,340<i class="iconfont-sys text-success">&#xe8d5;</i></p>
<p class="subtitle">今日销售额</p>
</div>
<div class="item">
<p class="title">35%<i class="iconfont-sys text-success">&#xe8d5;</i></p>
<p class="subtitle">较昨日</p>
</div>
</div>
</ArtBasicBanner>
</template>
<script setup lang="ts">
import bannerCover from '@imgs/login/lf_icon2.webp'
import { useUserStore } from '@/store/modules/user'
const userStore = useUserStore()
const userInfo = computed(() => userStore.getUserInfo)
const handleBannerClick = () => {}
</script>
<style lang="scss" scoped>
.banner {
.banner-slot {
display: flex;
.item {
margin-right: 30px;
&:first-of-type {
padding-right: 30px;
border-right: 1px solid var(--art-gray-300);
}
.title {
font-size: 30px;
color: var(--art-gray-900) !important;
i {
position: relative;
top: -10px;
margin-left: 10px;
font-size: 16px;
}
}
.subtitle {
margin-top: 4px;
font-size: 14px;
color: var(--art-gray-700) !important;
}
}
}
}
</style>

View File

@@ -0,0 +1,11 @@
<template>
<ArtLineChartCard
class="margin-bottom-0"
:value="2545"
label="购物车转化率"
:percentage="1.2"
:height="13.5"
:chartData="[120, 132, 101, 134, 90, 230, 210]"
:showAreaColor="true"
/>
</template>

View File

@@ -0,0 +1,107 @@
<template>
<div class="card art-custom-card weekly-card" style="height: 28.2rem">
<div class="card-header">
<p class="title">热销商品</p>
<p class="subtitle">本周销售排行</p>
</div>
<ArtLineChart
:showAxisLabel="false"
:showAxisLine="false"
:showSplitLine="false"
:showAreaColor="true"
:data="[8, 40, 82, 35, 90, 52, 35]"
height="9rem"
/>
<div class="content">
<div class="item" v-for="item in weeklyList" :key="item.title">
<div class="icon" :class="item.color">
<i class="iconfont-sys">&#xe718;</i>
</div>
<div class="text">
<p class="title">{{ item.title }}</p>
<span class="subtitle">{{ item.subtitle }}</span>
</div>
<div class="value" :class="item.color">
<span>+{{ item.value }}</span>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
const weeklyList = [
{
icon: '&#xe718;',
title: '智能手表Pro',
subtitle: '电子产品',
value: '1,286件',
color: 'bg-primary'
},
{
icon: '&#xe70c;',
title: '时尚连衣裙',
subtitle: '女装服饰',
value: '892件',
color: 'bg-success'
},
{
icon: '&#xe813;',
title: '厨房小家电',
subtitle: '家居用品',
value: '756件',
color: 'bg-error'
}
]
</script>
<style lang="scss" scoped>
.weekly-card {
.content {
margin-top: 40px;
.item {
display: flex;
align-items: center;
margin-top: 20px;
.icon {
width: 42px;
height: 42px;
line-height: 42px;
text-align: center;
background-color: var(--el-color-primary-light-9);
border-radius: 8px;
i {
font-size: 20px;
}
}
.text {
margin-left: 10px;
.title {
font-size: 14px;
font-weight: 500;
color: var(--art-gray-800);
}
.subtitle {
font-size: 14px;
color: var(--art-gray-600);
}
}
.value {
padding: 6px 12px;
margin-left: auto;
font-size: 14px;
text-align: center;
background-color: var(--el-color-primary-light-9);
border-radius: 4px;
}
}
}
}
</style>

View File

@@ -0,0 +1,231 @@
<template>
<div class="card art-custom-card" style="height: 27.8rem">
<div class="card-header">
<p class="title">热销产品</p>
<p class="subtitle">本月销售情况</p>
</div>
<div class="table">
<el-scrollbar style="height: 21.55rem">
<art-table
:data="tableData"
:pagination="false"
style="margin-top: 0 !important"
size="large"
:border="false"
:stripe="false"
:show-header-background="false"
>
<template #default>
<el-table-column label="产品" prop="product" width="220px">
<template #default="scope">
<div style="display: flex; align-items: center">
<img class="product-image" :src="scope.row.image" />
<div class="product-info">
<div class="product-name">{{ scope.row.name }}</div>
<div class="product-category">{{ scope.row.category }}</div>
</div>
</div>
</template>
</el-table-column>
<el-table-column label="价格" prop="price">
<template #default="scope">
<span class="price">¥{{ scope.row.price.toLocaleString() }}</span>
</template>
</el-table-column>
<el-table-column label="库存" prop="stock">
<template #default="scope">
<div class="stock-badge" :class="getStockClass(scope.row.stock)">
{{ getStockStatus(scope.row.stock) }}
</div>
</template>
</el-table-column>
<el-table-column label="销量" prop="sales" />
<el-table-column label="销售趋势" width="240">
<template #default="scope">
<el-progress
:percentage="scope.row.pro"
:color="scope.row.color"
:stroke-width="4"
/>
</template>
</el-table-column>
</template>
</art-table>
</el-scrollbar>
</div>
</div>
</template>
<script setup lang="ts">
import { reactive, onMounted } from 'vue'
// 导入产品图片
import product1 from '@/assets/img/3d/icon1.webp'
import product2 from '@/assets/img/3d/icon2.webp'
import product3 from '@/assets/img/3d/icon3.webp'
import product4 from '@/assets/img/3d/icon4.webp'
import product5 from '@/assets/img/3d/icon5.webp'
import product6 from '@/assets/img/3d/icon6.webp'
const tableData = reactive([
{
name: '智能手表 Pro',
category: '电子设备',
price: 1299,
stock: 156,
sales: 423,
percentage: 75,
pro: 0,
color: 'rgb(var(--art-primary)) !important',
image: product1
},
{
name: '无线蓝牙耳机',
category: '音频设备',
price: 499,
stock: 89,
sales: 652,
percentage: 85,
pro: 0,
color: 'rgb(var(--art-success)) !important',
image: product2
},
{
name: '机械键盘',
category: '电脑配件',
price: 399,
stock: 12,
sales: 238,
percentage: 45,
pro: 0,
color: 'rgb(var(--art-warning)) !important',
image: product3
},
{
name: '超薄笔记本电脑',
category: '电子设备',
price: 5999,
stock: 0,
sales: 126,
percentage: 30,
pro: 0,
color: 'rgb(var(--art-error)) !important',
image: product4
},
{
name: '智能音箱',
category: '智能家居',
price: 799,
stock: 45,
sales: 321,
percentage: 60,
pro: 0,
color: 'rgb(var(--art-info)) !important',
image: product5
},
{
name: '游戏手柄',
category: '游戏配件',
price: 299,
stock: 78,
sales: 489,
percentage: 70,
pro: 0,
color: 'rgb(var(--art-secondary)) !important',
image: product6
}
])
// 根据库存获取状态文本
const getStockStatus = (stock: number) => {
if (stock === 0) return '缺货'
if (stock < 20) return '低库存'
if (stock < 50) return '适中'
return '充足'
}
// 根据库存获取状态类名
const getStockClass = (stock: number) => {
if (stock === 0) return 'out-of-stock'
if (stock < 20) return 'low-stock'
if (stock < 50) return 'medium-stock'
return 'in-stock'
}
onMounted(() => {
addAnimation()
})
const addAnimation = () => {
setTimeout(() => {
for (let i = 0; i < tableData.length; i++) {
let item = tableData[i]
tableData[i].pro = item.percentage
}
}, 100)
}
</script>
<style lang="scss" scoped>
.table {
width: 100%;
.card-header {
padding-left: 25px !important;
}
.product-image {
width: 50px;
height: 50px;
object-fit: cover;
border-radius: 6px;
}
.product-info {
display: flex;
flex-direction: column;
margin-left: 12px;
}
.product-name {
font-weight: 500;
}
.product-category {
font-size: 12px;
color: #64748b;
}
.price {
font-weight: 600;
}
.stock-badge {
display: inline-block;
padding: 4px 8px;
font-size: 12px;
font-weight: 500;
border-radius: 4px;
}
.in-stock {
color: rgb(var(--art-success));
background-color: rgba(var(--art-success-rgb), 0.1);
}
.medium-stock {
color: rgb(var(--art-info));
background-color: rgba(var(--art-info-rgb), 0.1);
}
.low-stock {
color: rgb(var(--art-warning));
background-color: rgba(var(--art-warning-rgb), 0.1);
}
.out-of-stock {
color: rgb(var(--art-error));
background-color: rgba(var(--art-error-rgb), 0.1);
}
}
</style>

View File

@@ -0,0 +1,19 @@
<template>
<div class="card art-custom-card" style="height: 11rem">
<div class="card-header">
<p class="title" style="font-size: 24px"
>14.5k<i class="iconfont-sys text-success">&#xe8d5;</i></p
>
<p class="subtitle">销售量</p>
</div>
<ArtBarChart
:showAxisLabel="false"
:showAxisLine="false"
:showSplitLine="false"
:data="[50, 80, 50, 90, 60, 70, 50]"
barWidth="16px"
height="4rem"
/>
</div>
</template>

View File

@@ -0,0 +1,41 @@
<template>
<ArtTimelineListCard :list="timelineData" title="最近交易" subtitle="今日订单动态" />
</template>
<script setup lang="ts">
const timelineData = [
{
time: '上午 09:30',
status: 'rgb(73, 190, 255)',
content: '收到订单 #38291 支付 ¥385.90'
},
{
time: '上午 10:00',
status: 'rgb(54, 158, 255)',
content: '新商品上架',
code: 'SKU-3467'
},
{
time: '上午 12:00',
status: 'rgb(103, 232, 207)',
content: '向供应商支付了 ¥6495.00'
},
{
time: '下午 14:30',
status: 'rgb(255, 193, 7)',
content: '促销活动开始',
code: 'PROMO-2023'
},
{
time: '下午 15:45',
status: 'rgb(255, 105, 105)',
content: '订单取消提醒',
code: 'ORD-9876'
},
{
time: '下午 17:00',
status: 'rgb(103, 232, 207)',
content: '完成日销售报表'
}
]
</script>

View File

@@ -0,0 +1,41 @@
<template>
<div class="card art-custom-card sales-card" style="height: 26rem">
<div class="card-header">
<p class="title">销售分类</p>
<p class="subtitle">按产品类别</p>
</div>
<ArtRingChart
:data="[
{ value: 30, name: '电子产品' },
{ value: 55, name: '服装鞋包' },
{ value: 36, name: '家居用品' }
]"
:color="['#4C87F3', '#EDF2FF', '#8BD8FC']"
:radius="['70%', '80%']"
height="16.5rem"
:showLabel="false"
:borderRadius="0"
centerText="¥300,458"
/>
<div class="icon-text-widget">
<div class="item">
<div class="icon">
<i class="iconfont-sys">&#xe718;</i>
</div>
<div class="content">
<p>¥500,458</p>
<span>总收入</span>
</div>
</div>
<div class="item">
<div class="icon">
<i class="iconfont-sys">&#xe70c;</i>
</div>
<div class="content">
<p>¥130,580</p>
<span>净利润</span>
</div>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,20 @@
<template>
<div class="card art-custom-card" style="height: 11rem">
<div class="card-header">
<p class="title" style="font-size: 24px"
>12%<i class="iconfont-sys text-success">&#xe8d5;</i></p
>
<p class="subtitle">增长</p>
</div>
<ArtLineChart
:showAreaColor="true"
:showAxisLabel="false"
:showAxisLine="false"
:showSplitLine="false"
:data="[50, 85, 65, 95, 75, 130, 180]"
barWidth="16px"
height="4rem"
/>
</div>
</template>

View File

@@ -0,0 +1,15 @@
<template>
<div class="card art-custom-card" style="height: 26rem">
<div class="card-header">
<p class="title">销售趋势</p>
<p class="subtitle">月度销售对比</p>
</div>
<ArtDualBarCompareChart
:topData="[50, 80, 120, 90, 60]"
:bottomData="[30, 60, 90, 70, 40]"
:xAxisData="['一月', '二月', '三月', '四月', '五月']"
height="18rem"
:barWidth="16"
/>
</div>
</template>

View File

@@ -0,0 +1,20 @@
<template>
<div class="card art-custom-card" style="height: 13.3rem">
<div class="card-header">
<p class="title" style="font-size: 24px">205,216</p>
<p class="subtitle">总订单量</p>
</div>
<ArtRingChart
:data="[
{ value: 30, name: '已完成' },
{ value: 25, name: '处理中' },
{ value: 45, name: '待发货' }
]"
:color="['#4C87F3', '#93F1B4', '#8BD8FC']"
:radius="['56%', '76%']"
height="7rem"
:showLabel="false"
:borderRadius="0"
/>
</div>
</template>

View File

@@ -0,0 +1,16 @@
<template>
<div class="card art-custom-card" style="height: 13.3rem">
<div class="card-header">
<p class="title" style="font-size: 24px">55,231</p>
<p class="subtitle">商品总数</p>
</div>
<ArtBarChart
:showAxisLabel="false"
:showAxisLine="false"
:showSplitLine="false"
:data="[50, 80, 40, 90, 60, 70]"
height="7rem"
barWidth="18px"
/>
</div>
</template>

View File

@@ -0,0 +1,52 @@
<template>
<ArtDataListCard
:maxCount="4"
:list="dataList"
title="最近活动"
subtitle="订单处理状态"
:showMoreButton="true"
@more="handleMore"
/>
</template>
<script setup lang="ts">
const dataList = [
{
title: '新订单 #38291',
status: '待处理',
time: '5分钟',
class: 'bg-primary',
icon: '&#xe6f2;'
},
{
title: '退款申请 #12845',
status: '处理中',
time: '10分钟',
class: 'bg-secondary',
icon: '&#xe806;'
},
{
title: '客户投诉处理',
status: '待处理',
time: '15分钟',
class: 'bg-warning',
icon: '&#xe6fb;'
},
{
title: '库存不足提醒',
status: '紧急',
time: '20分钟',
class: 'bg-danger',
icon: '&#xe813;'
},
{
title: '订单 #29384 已发货',
status: '已完成',
time: '20分钟',
class: 'bg-success',
icon: '&#xe70c;'
}
]
const handleMore = () => {}
</script>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,15 @@
<template>
<ArtException
:data="{
title: '403',
desc: $t('exceptionPage.403'),
btnText: $t('exceptionPage.gohome'),
imgUrl
}"
/>
</template>
<script setup lang="ts">
import imgUrl from '@imgs/state/403.png'
defineOptions({ name: 'Exception403' })
</script>

View File

@@ -0,0 +1,15 @@
<template>
<ArtException
:data="{
title: '404',
desc: $t('exceptionPage.404'),
btnText: $t('exceptionPage.gohome'),
imgUrl
}"
/>
</template>
<script setup lang="ts">
import imgUrl from '@imgs/state/404.png'
defineOptions({ name: 'Exception404' })
</script>

View File

@@ -0,0 +1,15 @@
<template>
<ArtException
:data="{
title: '500',
desc: $t('exceptionPage.500'),
btnText: $t('exceptionPage.gohome'),
imgUrl
}"
/>
</template>
<script setup lang="ts">
import imgUrl from '@imgs/state/500.png'
defineOptions({ name: 'Exception500' })
</script>

View File

@@ -0,0 +1,210 @@
<template>
<div class="page-content">
<ElCard shadow="never">
<!-- 搜索栏 -->
<ElForm :inline="true" :model="searchForm" class="search-form">
<ElFormItem label="客户账号">
<ElInput v-model="searchForm.accountNo" placeholder="请输入客户账号" clearable style="width: 200px" />
</ElFormItem>
<ElFormItem label="客户名称">
<ElInput v-model="searchForm.customerName" placeholder="请输入客户名称" clearable style="width: 200px" />
</ElFormItem>
<ElFormItem label="客户类型">
<ElSelect v-model="searchForm.customerType" placeholder="请选择" clearable style="width: 150px">
<ElOption label="代理商" value="agent" />
<ElOption label="企业客户" value="enterprise" />
</ElSelect>
</ElFormItem>
<ElFormItem>
<ElButton type="primary" @click="handleSearch">查询</ElButton>
<ElButton @click="handleReset">重置</ElButton>
</ElFormItem>
</ElForm>
<!-- 数据表格 -->
<ArtTable :data="tableData" index stripe>
<template #default>
<ElTableColumn label="客户账号" prop="accountNo" min-width="150" />
<ElTableColumn label="客户名称" prop="customerName" min-width="150" />
<ElTableColumn label="客户类型" prop="customerType" width="120">
<template #default="scope">
<ElTag :type="scope.row.customerType === 'agent' ? 'success' : 'primary'">
{{ scope.row.customerType === 'agent' ? '代理商' : '企业客户' }}
</ElTag>
</template>
</ElTableColumn>
<ElTableColumn label="佣金总额" prop="totalCommission" width="150">
<template #default="scope"> ¥{{ scope.row.totalCommission.toFixed(2) }} </template>
</ElTableColumn>
<ElTableColumn label="可提现金额" prop="availableAmount" width="150">
<template #default="scope">
<span style="color: var(--el-color-success)"> ¥{{ scope.row.availableAmount.toFixed(2) }} </span>
</template>
</ElTableColumn>
<ElTableColumn label="待入账金额" prop="pendingAmount" width="150">
<template #default="scope">
<span style="color: var(--el-color-warning)"> ¥{{ scope.row.pendingAmount.toFixed(2) }} </span>
</template>
</ElTableColumn>
<ElTableColumn label="已提现金额" prop="withdrawnAmount" width="150">
<template #default="scope"> ¥{{ scope.row.withdrawnAmount.toFixed(2) }} </template>
</ElTableColumn>
<ElTableColumn label="提现次数" prop="withdrawCount" width="100" />
<ElTableColumn label="最后提现时间" prop="lastWithdrawTime" width="180" />
<ElTableColumn label="操作" width="180" fixed="right">
<template #default="scope">
<ElButton link type="primary" @click="handleViewDetail(scope.row)">查看详情</ElButton>
<ElButton link type="primary" @click="handleViewFlow(scope.row)">流水记录</ElButton>
</template>
</ElTableColumn>
</template>
</ArtTable>
<!-- 分页 -->
<ElPagination
v-model:current-page="pagination.page"
v-model:page-size="pagination.pageSize"
:total="pagination.total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
style="margin-top: 20px; justify-content: flex-end"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
</ElCard>
<!-- 详情对话框 -->
<ElDialog v-model="detailDialogVisible" title="客户账号详情" width="800px" align-center>
<ElDescriptions :column="2" border>
<ElDescriptionsItem label="客户账号">{{ currentRow?.accountNo }}</ElDescriptionsItem>
<ElDescriptionsItem label="客户名称">{{ currentRow?.customerName }}</ElDescriptionsItem>
<ElDescriptionsItem label="客户类型">
<ElTag :type="currentRow?.customerType === 'agent' ? 'success' : 'primary'">
{{ currentRow?.customerType === 'agent' ? '代理商' : '企业客户' }}
</ElTag>
</ElDescriptionsItem>
<ElDescriptionsItem label="联系电话">{{ currentRow?.phone }}</ElDescriptionsItem>
<ElDescriptionsItem label="佣金总额">¥{{ currentRow?.totalCommission.toFixed(2) }}</ElDescriptionsItem>
<ElDescriptionsItem label="可提现金额">
<span style="color: var(--el-color-success)"> ¥{{ currentRow?.availableAmount.toFixed(2) }} </span>
</ElDescriptionsItem>
<ElDescriptionsItem label="待入账金额">
<span style="color: var(--el-color-warning)"> ¥{{ currentRow?.pendingAmount.toFixed(2) }} </span>
</ElDescriptionsItem>
<ElDescriptionsItem label="已提现金额">¥{{ currentRow?.withdrawnAmount.toFixed(2) }}</ElDescriptionsItem>
<ElDescriptionsItem label="提现次数">{{ currentRow?.withdrawCount }}</ElDescriptionsItem>
<ElDescriptionsItem label="最后提现时间">{{ currentRow?.lastWithdrawTime }}</ElDescriptionsItem>
<ElDescriptionsItem label="注册时间">{{ currentRow?.createTime }}</ElDescriptionsItem>
<ElDescriptionsItem label="备注" :span="2">{{ currentRow?.remark || '无' }}</ElDescriptionsItem>
</ElDescriptions>
</ElDialog>
</div>
</template>
<script setup lang="ts">
import { ElMessage } from 'element-plus'
defineOptions({ name: 'CustomerAccount' })
const searchForm = reactive({
accountNo: '',
customerName: '',
customerType: ''
})
const pagination = reactive({
page: 1,
pageSize: 20,
total: 100
})
const detailDialogVisible = ref(false)
const currentRow = ref<any>(null)
// 模拟表格数据
const tableData = ref([
{
id: '1',
accountNo: 'ACC20260001',
customerName: '深圳市科技有限公司',
customerType: 'agent',
phone: '13800138000',
totalCommission: 158900.5,
availableAmount: 58900.5,
pendingAmount: 50000.0,
withdrawnAmount: 50000.0,
withdrawCount: 12,
lastWithdrawTime: '2026-01-08 15:00:00',
createTime: '2025-06-01 10:00:00',
remark: '优质代理商'
},
{
id: '2',
accountNo: 'ACC20260002',
customerName: '广州智能设备公司',
customerType: 'enterprise',
phone: '13900139000',
totalCommission: 89600.0,
availableAmount: 35600.0,
pendingAmount: 24000.0,
withdrawnAmount: 30000.0,
withdrawCount: 8,
lastWithdrawTime: '2026-01-05 10:30:00',
createTime: '2025-07-15 14:20:00',
remark: ''
},
{
id: '3',
accountNo: 'ACC20260003',
customerName: '北京物联网代理',
customerType: 'agent',
phone: '13700137000',
totalCommission: 256700.0,
availableAmount: 106700.0,
pendingAmount: 80000.0,
withdrawnAmount: 70000.0,
withdrawCount: 15,
lastWithdrawTime: '2026-01-09 09:15:00',
createTime: '2025-05-10 09:00:00',
remark: '金牌代理商'
}
])
const handleSearch = () => {
ElMessage.success('查询成功')
}
const handleReset = () => {
searchForm.accountNo = ''
searchForm.customerName = ''
searchForm.customerType = ''
ElMessage.info('已重置')
}
const handleViewDetail = (row: any) => {
currentRow.value = row
detailDialogVisible.value = true
}
const handleViewFlow = (row: any) => {
ElMessage.info(`查看 ${row.customerName} 的流水记录`)
}
const handleSizeChange = (size: number) => {
pagination.pageSize = size
ElMessage.info(`每页显示 ${size}`)
}
const handleCurrentChange = (page: number) => {
pagination.page = page
ElMessage.info(`当前第 ${page}`)
}
</script>
<style lang="scss" scoped>
.page-content {
.search-form {
margin-bottom: 20px;
}
}
</style>

View File

@@ -0,0 +1,305 @@
<template>
<div class="page-content">
<!-- 统计卡片 -->
<ElRow :gutter="20" style="margin-bottom: 20px">
<ElCol :xs="24" :sm="12" :lg="6">
<ElCard shadow="hover">
<div class="stat-card">
<div class="stat-icon" style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%)">
<i class="iconfont-sys">&#xe71d;</i>
</div>
<div class="stat-content">
<div class="stat-label">佣金总额</div>
<div class="stat-value">¥{{ accountInfo.totalCommission.toFixed(2) }}</div>
</div>
</div>
</ElCard>
</ElCol>
<ElCol :xs="24" :sm="12" :lg="6">
<ElCard shadow="hover">
<div class="stat-card">
<div class="stat-icon" style="background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%)">
<i class="iconfont-sys">&#xe71e;</i>
</div>
<div class="stat-content">
<div class="stat-label">可提现金额</div>
<div class="stat-value">¥{{ accountInfo.availableAmount.toFixed(2) }}</div>
</div>
</div>
</ElCard>
</ElCol>
<ElCol :xs="24" :sm="12" :lg="6">
<ElCard shadow="hover">
<div class="stat-card">
<div class="stat-icon" style="background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%)">
<i class="iconfont-sys">&#xe720;</i>
</div>
<div class="stat-content">
<div class="stat-label">待入账金额</div>
<div class="stat-value">¥{{ accountInfo.pendingAmount.toFixed(2) }}</div>
</div>
</div>
</ElCard>
</ElCol>
<ElCol :xs="24" :sm="12" :lg="6">
<ElCard shadow="hover">
<div class="stat-card">
<div class="stat-icon" style="background: linear-gradient(135deg, #fa709a 0%, #fee140 100%)">
<i class="iconfont-sys">&#xe71f;</i>
</div>
<div class="stat-content">
<div class="stat-label">已提现金额</div>
<div class="stat-value">¥{{ accountInfo.withdrawnAmount.toFixed(2) }}</div>
</div>
</div>
</ElCard>
</ElCol>
</ElRow>
<!-- 操作按钮 -->
<ElRow style="margin-bottom: 20px">
<ElButton type="primary" @click="showWithdrawDialog">申请提现</ElButton>
<ElButton @click="viewWithdrawHistory">提现记录</ElButton>
</ElRow>
<!-- 收支流水 -->
<ElCard shadow="never">
<template #header>
<div style="display: flex; justify-content: space-between; align-items: center">
<span style="font-weight: 500">收支流水</span>
<ElRadioGroup v-model="flowType" size="small">
<ElRadioButton value="all">全部</ElRadioButton>
<ElRadioButton value="income">收入</ElRadioButton>
<ElRadioButton value="withdraw">提现</ElRadioButton>
</ElRadioGroup>
</div>
</template>
<ArtTable :data="filteredFlowData" index max-height="500">
<template #default>
<ElTableColumn label="流水号" prop="flowNo" min-width="180" />
<ElTableColumn label="类型" prop="type">
<template #default="scope">
<ElTag :type="scope.row.type === 'income' ? 'success' : 'warning'">
{{ scope.row.type === 'income' ? '收入' : '提现' }}
</ElTag>
</template>
</ElTableColumn>
<ElTableColumn label="金额" prop="amount">
<template #default="scope">
<span :style="{ color: scope.row.type === 'income' ? 'var(--el-color-success)' : 'var(--el-color-danger)' }">
{{ scope.row.type === 'income' ? '+' : '-' }}¥{{ scope.row.amount.toFixed(2) }}
</span>
</template>
</ElTableColumn>
<ElTableColumn label="余额" prop="balance">
<template #default="scope"> ¥{{ scope.row.balance.toFixed(2) }} </template>
</ElTableColumn>
<ElTableColumn label="说明" prop="description" show-overflow-tooltip />
<ElTableColumn label="时间" prop="createTime" width="180" />
</template>
</ArtTable>
</ElCard>
<!-- 提现申请对话框 -->
<ElDialog v-model="withdrawDialogVisible" title="申请提现" width="600px" align-center>
<ElForm ref="withdrawFormRef" :model="withdrawForm" :rules="withdrawRules" label-width="120px">
<ElFormItem label="可提现金额">
<span style="color: var(--el-color-success); font-size: 20px; font-weight: 500">
¥{{ accountInfo.availableAmount.toFixed(2) }}
</span>
</ElFormItem>
<ElFormItem label="提现金额" prop="amount">
<ElInputNumber
v-model="withdrawForm.amount"
:min="1"
:max="accountInfo.availableAmount"
:precision="2"
style="width: 100%"
/>
</ElFormItem>
<ElFormItem label="手续费">
<span>¥{{ calculatedFee.toFixed(2) }}</span>
<span style="margin-left: 8px; color: var(--el-text-color-secondary)">
(费率: {{ feeRate * 100 }}%)
</span>
</ElFormItem>
<ElFormItem label="实际到账">
<span style="color: var(--el-color-success); font-size: 18px; font-weight: 500">
¥{{ actualAmount.toFixed(2) }}
</span>
</ElFormItem>
<ElFormItem label="收款银行" prop="bankName">
<ElSelect v-model="withdrawForm.bankName" placeholder="请选择收款银行" style="width: 100%">
<ElOption label="中国工商银行" value="工商银行" />
<ElOption label="中国建设银行" value="建设银行" />
<ElOption label="中国农业银行" value="农业银行" />
<ElOption label="中国银行" value="中国银行" />
<ElOption label="招商银行" value="招商银行" />
</ElSelect>
</ElFormItem>
<ElFormItem label="银行账户" prop="bankAccount">
<ElInput v-model="withdrawForm.bankAccount" placeholder="请输入银行账户" />
</ElFormItem>
<ElFormItem label="开户姓名" prop="accountName">
<ElInput v-model="withdrawForm.accountName" placeholder="请输入开户姓名" />
</ElFormItem>
</ElForm>
<template #footer>
<div class="dialog-footer">
<ElButton @click="withdrawDialogVisible = false">取消</ElButton>
<ElButton type="primary" @click="submitWithdraw">提交申请</ElButton>
</div>
</template>
</ElDialog>
</div>
</template>
<script setup lang="ts">
import { ElMessage } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
defineOptions({ name: 'MyAccount' })
const accountInfo = reactive({
totalCommission: 158900.5,
availableAmount: 58900.5,
pendingAmount: 50000.0,
withdrawnAmount: 50000.0
})
const flowType = ref('all')
const withdrawDialogVisible = ref(false)
const withdrawFormRef = ref<FormInstance>()
const feeRate = 0.002 // 手续费率 0.2%
const withdrawForm = reactive({
amount: 0,
bankName: '',
bankAccount: '',
accountName: ''
})
const withdrawRules = reactive<FormRules>({
amount: [
{ required: true, message: '请输入提现金额', trigger: 'blur' },
{
validator: (rule, value, callback) => {
if (value > accountInfo.availableAmount) {
callback(new Error('提现金额不能大于可提现金额'))
} else if (value < 1) {
callback(new Error('提现金额不能小于1元'))
} else {
callback()
}
},
trigger: 'blur'
}
],
bankName: [{ required: true, message: '请选择收款银行', trigger: 'change' }],
bankAccount: [{ required: true, message: '请输入银行账户', trigger: 'blur' }],
accountName: [{ required: true, message: '请输入开户姓名', trigger: 'blur' }]
})
const flowData = ref([
{
id: '1',
flowNo: 'FL202601090001',
type: 'income',
amount: 1580.0,
balance: 58900.5,
description: '套餐销售佣金',
createTime: '2026-01-09 10:30:00'
},
{
id: '2',
flowNo: 'FL202601080001',
type: 'withdraw',
amount: 5000.0,
balance: 57320.5,
description: '提现到账',
createTime: '2026-01-08 15:00:00'
},
{
id: '3',
flowNo: 'FL202601070001',
type: 'income',
amount: 2360.0,
balance: 62320.5,
description: '号卡分配佣金',
createTime: '2026-01-07 14:20:00'
}
])
const calculatedFee = computed(() => {
return withdrawForm.amount * feeRate
})
const actualAmount = computed(() => {
return withdrawForm.amount - calculatedFee.value
})
const filteredFlowData = computed(() => {
if (flowType.value === 'all') return flowData.value
return flowData.value.filter((item) => item.type === flowType.value)
})
const showWithdrawDialog = () => {
withdrawForm.amount = 0
withdrawForm.bankName = ''
withdrawForm.bankAccount = ''
withdrawForm.accountName = ''
withdrawDialogVisible.value = true
}
const submitWithdraw = async () => {
if (!withdrawFormRef.value) return
await withdrawFormRef.value.validate((valid) => {
if (valid) {
ElMessage.success('提现申请提交成功,请等待审核')
withdrawDialogVisible.value = false
}
})
}
const viewWithdrawHistory = () => {
ElMessage.info('查看提现记录')
}
</script>
<style lang="scss" scoped>
.page-content {
.stat-card {
display: flex;
align-items: center;
gap: 16px;
.stat-icon {
width: 60px;
height: 60px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 28px;
}
.stat-content {
flex: 1;
.stat-label {
font-size: 14px;
color: var(--el-text-color-secondary);
margin-bottom: 8px;
}
.stat-value {
font-size: 24px;
font-weight: 600;
color: var(--el-text-color-primary);
}
}
}
}
</style>

View File

@@ -0,0 +1,220 @@
<template>
<div class="page-content">
<ElCard shadow="never">
<template #header>
<span style="font-weight: 500">提现参数配置</span>
</template>
<ElForm ref="formRef" :model="form" :rules="rules" label-width="150px" style="max-width: 800px">
<ElFormItem label="最低提现金额" prop="minAmount">
<ElInputNumber v-model="form.minAmount" :min="1" :precision="2" />
<span style="margin-left: 8px"></span>
</ElFormItem>
<ElFormItem label="手续费模式" prop="feeMode">
<ElRadioGroup v-model="form.feeMode">
<ElRadio value="fixed">固定手续费</ElRadio>
<ElRadio value="percent">比例手续费</ElRadio>
</ElRadioGroup>
</ElFormItem>
<ElFormItem v-if="form.feeMode === 'fixed'" label="固定手续费" prop="fixedFee">
<ElInputNumber v-model="form.fixedFee" :min="0" :precision="2" />
<span style="margin-left: 8px">/</span>
</ElFormItem>
<ElFormItem v-if="form.feeMode === 'percent'" label="手续费比例" prop="feePercent">
<ElInputNumber v-model="form.feePercent" :min="0" :max="100" :precision="2" />
<span style="margin-left: 8px">%</span>
</ElFormItem>
<ElFormItem label="单日提现次数" prop="dailyLimit">
<ElInputNumber v-model="form.dailyLimit" :min="1" :max="10" />
<span style="margin-left: 8px"></span>
</ElFormItem>
<ElFormItem label="提现到账时间" prop="arrivalTime">
<ElSelect v-model="form.arrivalTime" style="width: 200px">
<ElOption label="实时到账" value="realtime" />
<ElOption label="2小时内到账" value="2hours" />
<ElOption label="24小时内到账" value="24hours" />
<ElOption label="T+1到账" value="t1" />
<ElOption label="T+3到账" value="t3" />
</ElSelect>
</ElFormItem>
<ElFormItem label="工作日提现" prop="workdayOnly">
<ElSwitch v-model="form.workdayOnly" />
<span style="margin-left: 8px; color: var(--el-text-color-secondary)">
{{ form.workdayOnly ? '仅工作日可提现' : '每天都可提现' }}
</span>
</ElFormItem>
<ElFormItem label="提现时间段" prop="timeRange">
<ElTimePicker
v-model="form.timeRange"
is-range
range-separator=""
start-placeholder="开始时间"
end-placeholder="结束时间"
format="HH:mm"
/>
</ElFormItem>
<ElFormItem label="配置说明" prop="description">
<ElInput
v-model="form.description"
type="textarea"
:rows="3"
placeholder="请输入配置说明,如配置生效时间等"
/>
</ElFormItem>
<ElFormItem>
<ElButton type="primary" @click="handleSave">保存配置</ElButton>
<ElButton @click="resetForm">重置</ElButton>
</ElFormItem>
</ElForm>
</ElCard>
<ElCard shadow="never" style="margin-top: 20px">
<template #header>
<span style="font-weight: 500">配置历史记录</span>
</template>
<ArtTable :data="historyData" index>
<template #default>
<ElTableColumn label="配置时间" prop="configTime" width="180" />
<ElTableColumn label="最低金额" prop="minAmount">
<template #default="scope"> ¥{{ scope.row.minAmount.toFixed(2) }} </template>
</ElTableColumn>
<ElTableColumn label="手续费" prop="fee">
<template #default="scope">
{{
scope.row.feeMode === 'fixed'
? `¥${scope.row.fixedFee.toFixed(2)}/笔`
: `${scope.row.feePercent}%`
}}
</template>
</ElTableColumn>
<ElTableColumn label="单日限制" prop="dailyLimit">
<template #default="scope"> {{ scope.row.dailyLimit }}/ </template>
</ElTableColumn>
<ElTableColumn label="到账时间" prop="arrivalTime">
<template #default="scope">
{{ getArrivalTimeText(scope.row.arrivalTime) }}
</template>
</ElTableColumn>
<ElTableColumn label="配置人" prop="operator" />
<ElTableColumn label="状态" prop="status">
<template #default="scope">
<ElTag :type="scope.row.status === 'active' ? 'success' : 'info'">
{{ scope.row.status === 'active' ? '当前生效' : '已过期' }}
</ElTag>
</template>
</ElTableColumn>
</template>
</ArtTable>
</ElCard>
</div>
</template>
<script setup lang="ts">
import { ElMessage } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
defineOptions({ name: 'WithdrawalSettings' })
const formRef = ref<FormInstance>()
const form = reactive({
minAmount: 100,
feeMode: 'percent',
fixedFee: 2,
feePercent: 0.2,
dailyLimit: 3,
arrivalTime: '24hours',
workdayOnly: false,
timeRange: [new Date(2024, 0, 1, 0, 0), new Date(2024, 0, 1, 23, 59)],
description: ''
})
const rules = reactive<FormRules>({
minAmount: [{ required: true, message: '请输入最低提现金额', trigger: 'blur' }],
feeMode: [{ required: true, message: '请选择手续费模式', trigger: 'change' }],
dailyLimit: [{ required: true, message: '请输入单日提现次数', trigger: 'blur' }],
arrivalTime: [{ required: true, message: '请选择到账时间', trigger: 'change' }]
})
const historyData = ref([
{
id: '1',
configTime: '2026-01-09 10:00:00',
minAmount: 100,
feeMode: 'percent',
fixedFee: 0,
feePercent: 0.2,
dailyLimit: 3,
arrivalTime: '24hours',
operator: 'admin',
status: 'active'
},
{
id: '2',
configTime: '2026-01-01 10:00:00',
minAmount: 50,
feeMode: 'fixed',
fixedFee: 2.0,
feePercent: 0,
dailyLimit: 5,
arrivalTime: 't1',
operator: 'admin',
status: 'expired'
}
])
const getArrivalTimeText = (value: string) => {
const map: Record<string, string> = {
realtime: '实时到账',
'2hours': '2小时内',
'24hours': '24小时内',
t1: 'T+1',
t3: 'T+3'
}
return map[value] || '未知'
}
const handleSave = async () => {
if (!formRef.value) return
await formRef.value.validate((valid) => {
if (valid) {
// 添加到历史记录
historyData.value.unshift({
id: Date.now().toString(),
configTime: new Date().toLocaleString('zh-CN'),
...form,
operator: 'admin',
status: 'active'
})
// 将之前的配置标记为过期
historyData.value.slice(1).forEach((item) => {
item.status = 'expired'
})
ElMessage.success('配置保存成功')
}
})
}
const resetForm = () => {
formRef.value?.resetFields()
}
</script>
<style lang="scss" scoped>
.page-content {
:deep(.el-card__header) {
padding: 16px 20px;
border-bottom: 1px solid var(--el-border-color-light);
}
}
</style>

View File

@@ -0,0 +1,351 @@
<template>
<div class="page-content">
<ElRow>
<ElCol :xs="24" :sm="12" :lg="6">
<ElInput v-model="searchQuery" placeholder="申请人/手机号" clearable></ElInput>
</ElCol>
<div style="width: 12px"></div>
<ElCol :xs="24" :sm="12" :lg="6">
<ElSelect v-model="statusFilter" placeholder="审核状态" clearable style="width: 100%">
<ElOption label="待审核" value="pending" />
<ElOption label="已通过" value="approved" />
<ElOption label="已拒绝" value="rejected" />
</ElSelect>
</ElCol>
<div style="width: 12px"></div>
<ElCol :xs="24" :sm="12" :lg="6">
<ElDatePicker
v-model="dateRange"
type="daterange"
range-separator=""
start-placeholder="开始日期"
end-placeholder="结束日期"
style="width: 100%"
/>
</ElCol>
<div style="width: 12px"></div>
<ElCol :xs="24" :sm="12" :lg="6" class="el-col2">
<ElButton v-ripple @click="handleSearch">搜索</ElButton>
<ElButton v-ripple @click="handleBatchApprove" :disabled="selectedIds.length === 0"
>批量审核</ElButton
>
</ElCol>
</ElRow>
<ArtTable :data="filteredData" index @selection-change="handleSelectionChange">
<template #default>
<ElTableColumn type="selection" width="55" />
<ElTableColumn label="申请单号" prop="orderNo" min-width="180" />
<ElTableColumn label="申请人" prop="applicantName" />
<ElTableColumn label="客户类型" prop="customerType">
<template #default="scope">
<ElTag :type="scope.row.customerType === 'agent' ? '' : 'success'">
{{ scope.row.customerType === 'agent' ? '代理商' : '企业客户' }}
</ElTag>
</template>
</ElTableColumn>
<ElTableColumn label="提现金额" prop="amount">
<template #default="scope">
<span style="color: var(--el-color-danger); font-weight: 500">
¥{{ scope.row.amount.toFixed(2) }}
</span>
</template>
</ElTableColumn>
<ElTableColumn label="手续费" prop="fee">
<template #default="scope"> ¥{{ scope.row.fee.toFixed(2) }} </template>
</ElTableColumn>
<ElTableColumn label="实际到账" prop="actualAmount">
<template #default="scope">
<span style="color: var(--el-color-success); font-weight: 500">
¥{{ scope.row.actualAmount.toFixed(2) }}
</span>
</template>
</ElTableColumn>
<ElTableColumn label="收款账户" prop="bankAccount" show-overflow-tooltip />
<ElTableColumn label="状态" prop="status">
<template #default="scope">
<ElTag :type="getStatusTagType(scope.row.status)">
{{ getStatusText(scope.row.status) }}
</ElTag>
</template>
</ElTableColumn>
<ElTableColumn label="申请时间" prop="createTime" width="180" />
<ElTableColumn fixed="right" label="操作" width="180">
<template #default="scope">
<el-button link @click="viewDetail(scope.row)">详情</el-button>
<el-button
v-if="scope.row.status === 'pending'"
link
type="success"
@click="handleApprove(scope.row)"
>通过</el-button
>
<el-button
v-if="scope.row.status === 'pending'"
link
type="danger"
@click="handleReject(scope.row)"
>拒绝</el-button
>
</template>
</ElTableColumn>
</template>
</ArtTable>
<!-- 详情对话框 -->
<ElDialog v-model="detailDialogVisible" title="提现申请详情" width="700px" align-center>
<ElDescriptions :column="2" border>
<ElDescriptionsItem label="申请单号">{{ currentItem?.orderNo }}</ElDescriptionsItem>
<ElDescriptionsItem label="申请人">{{ currentItem?.applicantName }}</ElDescriptionsItem>
<ElDescriptionsItem label="手机号">{{ currentItem?.phone }}</ElDescriptionsItem>
<ElDescriptionsItem label="客户类型">
{{ currentItem?.customerType === 'agent' ? '代理商' : '企业客户' }}
</ElDescriptionsItem>
<ElDescriptionsItem label="提现金额">
<span style="color: var(--el-color-danger); font-weight: 500">
¥{{ currentItem?.amount.toFixed(2) }}
</span>
</ElDescriptionsItem>
<ElDescriptionsItem label="手续费">¥{{ currentItem?.fee.toFixed(2) }}</ElDescriptionsItem>
<ElDescriptionsItem label="实际到账">
<span style="color: var(--el-color-success); font-weight: 500">
¥{{ currentItem?.actualAmount.toFixed(2) }}
</span>
</ElDescriptionsItem>
<ElDescriptionsItem label="收款银行">{{ currentItem?.bankName }}</ElDescriptionsItem>
<ElDescriptionsItem label="收款账户" :span="2">{{
currentItem?.bankAccount
}}</ElDescriptionsItem>
<ElDescriptionsItem label="开户姓名">{{ currentItem?.accountName }}</ElDescriptionsItem>
<ElDescriptionsItem label="申请时间">{{ currentItem?.createTime }}</ElDescriptionsItem>
<ElDescriptionsItem label="审核状态">
<ElTag :type="getStatusTagType(currentItem?.status || 'pending')">
{{ getStatusText(currentItem?.status || 'pending') }}
</ElTag>
</ElDescriptionsItem>
<ElDescriptionsItem v-if="currentItem?.status !== 'pending'" label="审核时间">{{
currentItem?.auditTime
}}</ElDescriptionsItem>
<ElDescriptionsItem v-if="currentItem?.rejectReason" label="拒绝原因" :span="2">{{
currentItem?.rejectReason
}}</ElDescriptionsItem>
</ElDescriptions>
</ElDialog>
<!-- 拒绝对话框 -->
<ElDialog v-model="rejectDialogVisible" title="拒绝提现" width="500px" align-center>
<ElForm ref="rejectFormRef" :model="rejectForm" :rules="rejectRules" label-width="100px">
<ElFormItem label="拒绝原因" prop="reason">
<ElInput
v-model="rejectForm.reason"
type="textarea"
:rows="4"
placeholder="请输入拒绝原因"
/>
</ElFormItem>
</ElForm>
<template #footer>
<div class="dialog-footer">
<ElButton @click="rejectDialogVisible = false">取消</ElButton>
<ElButton type="primary" @click="confirmReject">确认拒绝</ElButton>
</div>
</template>
</ElDialog>
</div>
</template>
<script setup lang="ts">
import { ElMessage, ElMessageBox } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
defineOptions({ name: 'WithdrawalManagement' })
interface WithdrawalItem {
id: string
orderNo: string
applicantName: string
phone: string
customerType: 'agent' | 'enterprise'
amount: number
fee: number
actualAmount: number
bankName: string
bankAccount: string
accountName: string
status: 'pending' | 'approved' | 'rejected'
createTime: string
auditTime?: string
rejectReason?: string
}
const mockData = ref<WithdrawalItem[]>([
{
id: '1',
orderNo: 'WD202601090001',
applicantName: '张三',
phone: '13800138001',
customerType: 'agent',
amount: 5000.0,
fee: 10.0,
actualAmount: 4990.0,
bankName: '中国工商银行',
bankAccount: '6222 **** **** 1234',
accountName: '张三',
status: 'pending',
createTime: '2026-01-09 10:00:00'
},
{
id: '2',
orderNo: 'WD202601090002',
applicantName: '李四',
phone: '13800138002',
customerType: 'enterprise',
amount: 3000.0,
fee: 6.0,
actualAmount: 2994.0,
bankName: '中国建设银行',
bankAccount: '6227 **** **** 5678',
accountName: '李四',
status: 'approved',
createTime: '2026-01-08 14:30:00',
auditTime: '2026-01-08 15:00:00'
},
{
id: '3',
orderNo: 'WD202601090003',
applicantName: '王五',
phone: '13800138003',
customerType: 'agent',
amount: 2000.0,
fee: 4.0,
actualAmount: 1996.0,
bankName: '中国农业银行',
bankAccount: '6228 **** **** 9012',
accountName: '王五',
status: 'rejected',
createTime: '2026-01-07 16:20:00',
auditTime: '2026-01-07 17:00:00',
rejectReason: '账户信息与实名不符'
}
])
const searchQuery = ref('')
const statusFilter = ref('')
const dateRange = ref<[Date, Date] | null>(null)
const selectedIds = ref<string[]>([])
const detailDialogVisible = ref(false)
const rejectDialogVisible = ref(false)
const currentItem = ref<WithdrawalItem | null>(null)
const rejectFormRef = ref<FormInstance>()
const rejectForm = reactive({
reason: ''
})
const rejectRules = reactive<FormRules>({
reason: [{ required: true, message: '请输入拒绝原因', trigger: 'blur' }]
})
const filteredData = computed(() => {
let data = mockData.value
if (searchQuery.value) {
data = data.filter(
(item) => item.applicantName.includes(searchQuery.value) || item.phone.includes(searchQuery.value)
)
}
if (statusFilter.value) {
data = data.filter((item) => item.status === statusFilter.value)
}
return data
})
const getStatusText = (status: string) => {
const map: Record<string, string> = {
pending: '待审核',
approved: '已通过',
rejected: '已拒绝'
}
return map[status] || '未知'
}
const getStatusTagType = (status: string) => {
const map: Record<string, string> = {
pending: 'warning',
approved: 'success',
rejected: 'danger'
}
return map[status] || 'info'
}
const handleSearch = () => {}
const handleSelectionChange = (selection: WithdrawalItem[]) => {
selectedIds.value = selection.map((item) => item.id)
}
const viewDetail = (row: WithdrawalItem) => {
currentItem.value = row
detailDialogVisible.value = true
}
const handleApprove = (row: WithdrawalItem) => {
ElMessageBox.confirm(
`确认通过提现申请?金额:¥${row.amount.toFixed(2)},实际到账:¥${row.actualAmount.toFixed(2)}`,
'审核确认',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'success'
}
).then(() => {
row.status = 'approved'
row.auditTime = new Date().toLocaleString('zh-CN')
ElMessage.success('审核通过')
})
}
const handleReject = (row: WithdrawalItem) => {
currentItem.value = row
rejectForm.reason = ''
rejectDialogVisible.value = true
}
const confirmReject = async () => {
if (!rejectFormRef.value) return
await rejectFormRef.value.validate((valid) => {
if (valid && currentItem.value) {
currentItem.value.status = 'rejected'
currentItem.value.auditTime = new Date().toLocaleString('zh-CN')
currentItem.value.rejectReason = rejectForm.reason
rejectDialogVisible.value = false
ElMessage.success('已拒绝提现申请')
}
})
}
const handleBatchApprove = () => {
ElMessageBox.confirm(`确认批量审核 ${selectedIds.value.length} 条提现申请吗?`, '批量审核', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
selectedIds.value.forEach((id) => {
const item = mockData.value.find((i) => i.id === id)
if (item && item.status === 'pending') {
item.status = 'approved'
item.auditTime = new Date().toLocaleString('zh-CN')
}
})
ElMessage.success('批量审核成功')
selectedIds.value = []
})
}
</script>
<style lang="scss" scoped>
.page-content {
:deep(.el-descriptions__label) {
font-weight: 500;
}
}
</style>

26
src/views/index/index.vue Normal file
View File

@@ -0,0 +1,26 @@
<template>
<ArtLayouts>
<!-- 顶栏水平/混合菜单 -->
<ArtHeaderBar />
<!-- 左侧/双列菜单 -->
<ArtSidebarMenu />
<!-- 页面内容 -->
<ArtPageContent />
<!-- 设置面板 -->
<ArtSettingsPanel />
<!-- 全局搜索 -->
<ArtGlobalSearch />
<!-- 屏幕锁定 -->
<ArtScreenLock />
<!-- 聊天窗口 -->
<ArtChatWindow />
<!-- 礼花效果 -->
<ArtFireworksEffect />
<!-- 水印效果 -->
<ArtWatermark />
</ArtLayouts>
</template>
<style lang="scss" scoped>
@use './style';
</style>

View File

@@ -0,0 +1,47 @@
@use '@/assets/styles/variables' as *;
.layouts {
box-sizing: border-box;
width: 100%;
min-height: 100vh;
overflow: hidden;
background: var(--art-bg-color);
transition: padding 0.3s ease-in-out;
.layout-content {
box-sizing: border-box;
width: calc(100% - 40px);
margin: auto;
// 子页面默认style
:deep(.page-content) {
position: relative;
box-sizing: border-box;
padding: 20px;
overflow: hidden;
background: var(--art-main-bg-color);
border-radius: calc(var(--custom-radius) / 2 + 2px) !important;
}
}
}
@media only screen and (max-width: $device-ipad) {
.layouts {
width: 100%;
min-height: 100vh;
padding-left: 0 !important;
overflow-y: scroll;
.layout-content {
width: calc(100% - 20px);
}
}
}
@media only screen and (max-width: $device-phone) {
.layouts {
.layout-content {
width: calc(100% - 32px);
}
}
}

View File

@@ -0,0 +1,867 @@
<template>
<div class="single-card-page">
<!-- 卡片内容区域 -->
<div v-if="cardInfo" class="card-content-area slide-in">
<!-- 主要内容区域 -->
<div class="main-content-layout">
<!-- 第一行流量统计 -->
<div class="row full-width">
<ElCard shadow="never" class="info-card traffic-info">
<template #header>
<div class="card-header">
<span>流量统计</span>
</div>
</template>
<!-- 流量使用情况 -->
<div class="traffic-overview horizontal">
<!-- 左侧主要流量指标 -->
<div class="traffic-left">
<div class="traffic-stats-grid">
<ElCard shadow="never" class="stat-card">
<div class="stat-label">套餐系列</div>
<div class="stat-value">{{ cardInfo.packageSeries || '--' }}</div>
</ElCard>
<ElCard shadow="never" class="stat-card">
<div class="stat-label">套餐总流量</div>
<div class="stat-value">{{ cardInfo.packageTotalFlow || '--' }}</div>
</ElCard>
<ElCard shadow="never" class="stat-card">
<div class="stat-label">已使用流量</div>
<div class="stat-value">{{ cardInfo.usedFlow || '--' }}</div>
</ElCard>
<ElCard shadow="never" class="stat-card">
<div class="stat-label">已使用流量</div>
<div class="stat-value">{{ cardInfo.realUsedFlow || '--' }}</div>
</ElCard>
<ElCard shadow="never" class="stat-card">
<div class="stat-label">实际流量</div>
<div class="stat-value">{{ cardInfo.actualFlow || '--' }}</div>
</ElCard>
<ElCard shadow="never" class="stat-card">
<div class="stat-label">剩余流量</div>
<div class="stat-value">{{ cardInfo.remainFlow || '--' }}</div>
</ElCard>
<ElCard shadow="never" class="stat-card">
<div class="stat-label">已使用流量百分比</div>
<div class="stat-value">{{ cardInfo.usedFlowPercentage || '未设置' }}</div>
</ElCard>
</div>
</div>
</div>
</ElCard>
</div>
<!-- 第二行设备信息和业务信息 -->
<div class="row two-columns">
<!-- 左侧设备信息 -->
<div class="col">
<ElCard shadow="never" class="info-card basic-info">
<template #header>
<div class="card-header">
<span>设备信息</span>
</div>
</template>
<ElDescriptions :column="2" border>
<ElDescriptionsItem label="ICCID">{{ cardInfo?.iccid || '--' }}</ElDescriptionsItem>
<ElDescriptionsItem label="接入号">{{
cardInfo?.accessNumber || '--'
}}</ElDescriptionsItem>
<ElDescriptionsItem label="IMEI">{{ cardInfo?.imei || '--' }}</ElDescriptionsItem>
<ElDescriptionsItem label="网卡状态">
<ElTag :type="getStatusType(cardInfo?.cardStatus)" size="small">
{{ cardInfo?.cardStatus || '--' }}
</ElTag>
</ElDescriptionsItem>
<ElDescriptionsItem label="状态">{{
cardInfo?.operatorStatus || '--'
}}</ElDescriptionsItem>
<ElDescriptionsItem label="卡类型">{{
cardInfo?.cardType || '--'
}}</ElDescriptionsItem>
<ElDescriptionsItem label="虚拟号">{{
cardInfo?.virtualNumber || '--'
}}</ElDescriptionsItem>
<ElDescriptionsItem label="手机绑定">{{
cardInfo?.phoneBind || '--'
}}</ElDescriptionsItem>
<ElDescriptionsItem label="过期时间">{{
cardInfo?.expireTime || '--'
}}</ElDescriptionsItem>
</ElDescriptions>
</ElCard>
</div>
<!-- 右侧业务信息 -->
<div class="col">
<ElCard shadow="never" class="info-card business-info">
<template #header>
<div class="card-header">
<span>业务信息</span>
</div>
</template>
<ElDescriptions :column="2" border>
<ElDescriptionsItem label="运营商">{{
cardInfo?.operator || '--'
}}</ElDescriptionsItem>
<ElDescriptionsItem label="运营商实名">{{
cardInfo?.operatorRealName || '--'
}}</ElDescriptionsItem>
<ElDescriptionsItem label="国政通实名">
<ElTag :type="cardInfo?.realNameAuth ? 'success' : 'danger'" size="small">
{{ cardInfo?.realNameAuth ? '是' : '否' }}
</ElTag>
</ElDescriptionsItem>
<ElDescriptionsItem label="供应商">{{
cardInfo?.supplier || '--'
}}</ElDescriptionsItem>
<ElDescriptionsItem label="代理商">{{
cardInfo?.agent || '--'
}}</ElDescriptionsItem>
<ElDescriptionsItem label="流量池子">{{
cardInfo?.trafficPool || '--'
}}</ElDescriptionsItem>
<ElDescriptionsItem label="钱包余额">{{
cardInfo?.walletBalance || '--'
}}</ElDescriptionsItem>
<ElDescriptionsItem label="钱包支付密码状态">{{
cardInfo?.walletPasswordStatus || '--'
}}</ElDescriptionsItem>
<ElDescriptionsItem label="导入时间">{{
cardInfo?.importTime || '--'
}}</ElDescriptionsItem>
</ElDescriptions>
</ElCard>
</div>
</div>
<!-- 第三行当前套餐 -->
<div class="row full-width">
<ElCard shadow="never" class="info-card package-info">
<template #header>
<div class="card-header">
<span>当前套餐</span>
</div>
</template>
<div class="package-table-wrapper">
<ElTable :data="cardInfo.packageList || []" class="package-table">
<ElTableColumn
prop="packageName"
label="套餐名称"
min-width="200"
show-overflow-tooltip
/>
<ElTableColumn prop="packageType" label="类型" width="100" />
<ElTableColumn prop="totalFlow" label="总流量" width="100" />
<ElTableColumn prop="usedFlow" label="已用" width="100" />
<ElTableColumn prop="remainFlow" label="剩余" width="100" />
<ElTableColumn prop="expireTime" label="到期时间" width="120" />
<ElTableColumn prop="status" label="状态" width="100">
<template #default="scope">
<ElTag :type="getPackageStatusType(scope.row.status)" size="small">
{{ scope.row.status }}
</ElTag>
</template>
</ElTableColumn>
</ElTable>
</div>
</ElCard>
</div>
<!-- 第四行常规操作 -->
<div class="row two-columns">
<!-- 左侧操作 -->
<div class="col">
<ElCard shadow="never" class="info-card operation-card">
<div class="operations-grid">
<!-- 主要操作 -->
<div class="operation-group primary-operations">
<h4 class="group-title">主要操作</h4>
<div class="operation-buttons">
<ElButton @click="handleOperation('recharge')" class="operation-btn">
套餐充值
</ElButton>
<ElButton @click="handleOperation('activate')" class="operation-btn">
激活
</ElButton>
<ElButton @click="handleOperation('suspend')" class="operation-btn">
保号停机
</ElButton>
<ElButton @click="handleOperation('resume')" class="operation-btn">
保号复机
</ElButton>
</div>
</div>
<!-- 管理操作 -->
<div class="operation-group management-operations">
<h4 class="group-title">管理操作</h4>
<div class="operation-buttons">
<ElButton @click="handleOperation('rebind')" class="operation-btn"
>机卡重绑</ElButton
>
<ElButton @click="handleOperation('changeExpire')" class="operation-btn"
>更改过期时间</ElButton
>
<ElButton @click="handleOperation('transferCard')" class="operation-btn"
>转新卡</ElButton
>
<ElButton @click="handleOperation('adjustTraffic')" class="operation-btn"
>增减流量</ElButton
>
<ElButton @click="handleOperation('speedLimit')" class="operation-btn"
>单卡限速</ElButton
>
<ElButton @click="handleOperation('instantLimit')" class="operation-btn"
>即时限速</ElButton
>
</div>
</div>
</div>
</ElCard>
</div>
<!-- 右侧操作 -->
<div class="col">
<ElCard shadow="never" class="info-card operation-card">
<div class="operations-grid">
<!-- 查询操作 -->
<div class="operation-group query-operations">
<h4 class="group-title">查询记录</h4>
<div class="operation-buttons">
<ElButton @click="handleOperation('trafficDetail')" class="operation-btn"
>流量详单</ElButton
>
<ElButton @click="handleOperation('suspendRecord')" class="operation-btn"
>停复机记录</ElButton
>
<ElButton @click="handleOperation('orderHistory')" class="operation-btn"
>往期订单</ElButton
>
</div>
</div>
<!-- 其他操作 -->
<div class="operation-group other-operations">
<h4 class="group-title">其他操作</h4>
<div class="operation-buttons">
<ElButton @click="handleOperation('changeBalance')" class="operation-btn"
>变更钱包余额</ElButton
>
<ElButton @click="handleOperation('resetPassword')" class="operation-btn"
>重置支付密码</ElButton
>
<ElButton @click="handleOperation('renewRecharge')" class="operation-btn"
>续充</ElButton
>
<ElButton @click="handleOperation('deviceOperation')" class="operation-btn"
>设备操作</ElButton
>
<ElButton @click="handleOperation('recoverFromRoaming')" class="operation-btn"
>窜卡复机</ElButton
>
<ElButton @click="handleOperation('roaming')" class="operation-btn"
>窜卡</ElButton
>
</div>
</div>
</div>
</ElCard>
</div>
</div>
</div>
</div>
<!-- 加载状态 -->
<!--<div v-else-if="loading" class="loading-state">-->
<!-- <ElSkeleton :rows="10" animated />-->
<!--</div>-->
<!-- 空状态 -->
<!--<div v-else class="empty-state">-->
<!-- <ElEmpty description="暂无卡片数据" />-->
<!--</div>-->
</div>
</template>
<script setup lang="ts">
import {
ElTag,
ElMessage,
ElTable,
ElTableColumn,
ElProgress,
ElEmpty,
ElSkeleton,
ElDescriptions,
ElDescriptionsItem
} from 'element-plus'
import { useRoute } from 'vue-router'
defineOptions({ name: 'SingleCard' })
const route = useRoute()
const loading = ref(true)
// 卡片信息
const cardInfo = ref<any>(null)
// 模拟卡片数据
const mockCardData = {
iccid: '8986062357007989203',
accessNumber: '1440012345678',
imei: '860123456789012',
expireTime: '2025-12-31',
operator: '中国联通',
cardStatus: '正常',
cardType: '流量卡',
supplier: '华为技术有限公司',
importTime: '2024-01-15 10:30:00',
phoneBind: '138****5678',
trafficPool: '全国流量池',
agent: '张丽丽',
operatorStatus: '激活',
operatorRealName: '已实名',
walletBalance: '50.00元',
walletPasswordStatus: '已设置',
realNameAuth: true,
virtualNumber: '10655****1234',
// 流量信息 - 根据提供的数据更新
packageSeries: 'UFI设备',
packageTotalFlow: '3072000MB', // 套餐总流量
usedFlow: '196.16MB', // 已使用流量
remainFlow: '3071803.84MB', // 剩余流量
usedFlowPercentage: '未设置', // 增加已使用流量百分比
realUsedFlow: '196.16MB', // 已使用流量(真)
actualFlow: '196.16MB', // 实际流量
packageList: [
{
packageName: '随意联畅玩年卡套餐12个月',
packageType: '年卡套餐',
totalFlow: '3072000MB',
usedFlow: '196.16MB',
remainFlow: '3071803.84MB',
expireTime: '2026-11-07',
status: '正常'
}
]
}
// 获取卡片详情
const fetchCardDetail = async () => {
try {
loading.value = true
// 模拟API调用
setTimeout(() => {
cardInfo.value = { ...mockCardData }
loading.value = false
}, 500)
} catch (error) {
ElMessage.error('获取卡片详情失败')
loading.value = false
}
}
// 页面初始化时加载数据
onMounted(() => {
fetchCardDetail()
})
// 获取状态标签类型
const getStatusType = (status: string) => {
switch (status) {
case '正常':
case '激活':
return 'success'
case '停机':
case '暂停':
return 'warning'
case '注销':
case '异常':
return 'danger'
default:
return 'info'
}
}
// 获取套餐状态标签类型
const getPackageStatusType = (status: string) => {
switch (status) {
case '正常':
case '生效':
return 'success'
case '未生效':
return 'warning'
case '已过期':
return 'danger'
default:
return 'info'
}
}
// 获取进度条颜色
const getProgressColor = (percentage: number) => {
if (percentage < 50) return '#67c23a'
if (percentage < 80) return '#e6a23c'
return '#f56c6c'
}
// 处理刷新操作
const handleRefresh = () => {
fetchCardDetail()
}
// 处理操作按钮点击
const handleOperation = (operation: string) => {
if (!cardInfo.value) {
ElMessage.warning('请先查询卡片信息')
return
}
const operationNames: Record<string, string> = {
recharge: '套餐充值',
activate: '激活',
resume: '保号复机',
suspend: '保号停机',
rebind: '机卡重绑',
trafficDetail: '流量详单',
changeExpire: '更改过期时间',
transferCard: '转新卡',
suspendRecord: '停复机记录',
orderHistory: '往期订单',
speedLimit: '单卡限速',
recoverFromRoaming: '窜卡复机',
roaming: '窜卡',
adjustTraffic: '增减流量',
changeBalance: '变更钱包余额',
resetPassword: '重置支付密码',
instantLimit: '即时限速',
renewRecharge: '续充',
deviceOperation: '设备操作'
}
ElMessage.info(`执行${operationNames[operation] || operation}操作`)
}
</script>
<style lang="scss" scoped>
.single-card-page {
padding: 20px 0;
// 卡片内容区域
.card-content-area {
&.slide-in {
animation: slideInUp 0.6s ease-out;
}
}
// 主内容布局
.main-content-layout {
display: flex;
flex-direction: column;
gap: 24px;
// 行布局
.row {
display: flex;
gap: 24px;
&.full-width {
.info-card {
margin-bottom: 0;
}
}
&.two-columns {
.col {
flex: 1;
display: flex;
flex-direction: column;
.info-card {
margin-bottom: 0;
height: 100%;
}
}
}
}
// 响应式调整
@media (max-width: 1200px) {
.row.two-columns {
flex-direction: column;
gap: 16px;
.col {
.info-card {
height: auto;
}
}
}
}
@media (max-width: 768px) {
gap: 16px;
.row {
gap: 16px;
}
}
}
// 信息卡片通用样式
.info-card {
width: 100%;
:deep(.el-card__header) {
padding: 20px 24px 16px;
.card-header {
display: flex;
align-items: center;
gap: 8px;
font-size: 16px;
font-weight: 600;
color: var(--el-text-color-primary, #1f2937);
i {
font-size: 18px;
color: #667eea;
}
}
}
:deep(.el-card__body) {
padding: 24px;
}
}
// 流量信息卡片
.traffic-info {
// 流量概览
.traffic-overview {
&.horizontal {
display: flex;
gap: 24px;
align-items: stretch;
}
// 左侧:流量统计网格
.traffic-left {
flex: 1;
min-width: 0;
.traffic-stats-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 12px;
.stat-card {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 20px 16px;
:deep(.el-card__body) {
padding: 0;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.stat-label {
text-align: center;
font-size: 14px;
font-weight: 500;
color: var(--el-text-color-regular);
margin-bottom: 8px;
line-height: 1.4;
}
.stat-value {
text-align: center;
font-size: 16px;
font-weight: 600;
color: var(--el-text-color-primary);
line-height: 1.2;
word-break: break-all;
}
}
}
}
// 中间:使用率显示
.traffic-center {
flex: 1;
min-width: 0;
.usage-display {
background: var(--el-bg-color, #ffffff);
border: 1px solid var(--el-border-color-light);
border-radius: 12px;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 20px;
.usage-title {
font-size: 14px;
color: var(--el-text-color-regular);
margin-bottom: 12px;
font-weight: 500;
}
.usage-chart {
.chart-value {
font-size: 32px;
font-weight: 700;
color: #667eea;
line-height: 1;
}
}
}
}
// 右侧:套餐信息
.traffic-right {
flex: 1;
min-width: 0;
.package-display {
background: var(--el-bg-color, #ffffff);
border: 1px solid var(--el-border-color-light);
border-radius: 12px;
height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
padding: 20px;
text-align: center;
.package-label {
font-size: 14px;
color: var(--el-text-color-regular);
margin-bottom: 8px;
font-weight: 500;
}
.package-name {
font-size: 16px;
font-weight: 600;
color: var(--el-text-color-primary);
margin-bottom: 8px;
line-height: 1.4;
}
.package-actual {
font-size: 14px;
color: #667eea;
font-weight: 500;
}
}
}
}
}
// 套餐信息卡片
.package-info {
.package-table-wrapper {
.package-table {
:deep(.el-table__header) {
th {
color: var(--el-text-color-primary, #374151);
font-weight: 600;
}
}
}
}
}
// 操作卡片
.operation-card {
:deep(.el-card__body) {
display: flex;
flex-direction: column;
}
.operations-grid {
flex: 1;
display: flex;
flex-direction: column;
.operation-group {
margin-bottom: 24px;
&:last-child {
margin-bottom: 0;
flex: 1;
}
.group-title {
font-size: 14px;
font-weight: 600;
color: var(--el-text-color-primary, #374151);
margin: 0 0 16px 0;
padding-bottom: 8px;
border-bottom: 2px solid var(--el-border-color-light, #e5e7eb);
}
.operation-buttons {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-start;
gap: 10px;
.operation-btn {
margin-left: 0;
margin-right: 0;
}
}
}
}
}
// 加载和空状态
.loading-state,
.empty-state {
display: flex;
align-items: center;
justify-content: center;
background: var(--el-bg-color, #ffffff);
border-radius: 16px;
margin: 24px auto;
}
// 响应式设计
@media (max-width: 768px) {
padding: 16px;
.main-content-layout {
gap: 16px;
.row {
gap: 16px;
&.two-columns {
flex-direction: column;
}
}
}
.info-card {
:deep(.el-card__body) {
padding: 16px;
}
:deep(.el-card__header) {
padding: 16px 16px 12px;
}
}
.traffic-info .traffic-overview {
&.horizontal {
flex-direction: column;
gap: 16px;
}
.traffic-left .traffic-stats-grid {
grid-template-columns: repeat(2, 1fr);
gap: 8px;
.stat-card {
padding: 12px 8px;
:deep(.el-card__body) {
padding: 0;
}
.stat-label {
font-size: 12px;
margin-bottom: 6px;
}
.stat-value {
font-size: 14px;
}
}
}
.traffic-center .usage-display {
padding: 16px;
.usage-chart .chart-value {
font-size: 24px;
}
}
.traffic-right .package-display {
padding: 16px;
.package-name {
font-size: 14px;
}
.package-actual {
font-size: 13px;
}
}
}
//.operation-card .operations-grid .operation-group .operation-buttons {
// grid-template-columns: 1fr;
//}
}
@media (max-width: 480px) {
.traffic-left .traffic-stats-grid {
grid-template-columns: 1fr;
gap: 6px;
.stat-card {
padding: 10px 6px;
.stat-label {
font-size: 11px;
margin-bottom: 4px;
}
.stat-value {
font-size: 13px;
}
}
}
}
// 动画效果
@keyframes slideInUp {
from {
opacity: 0;
transform: translateY(30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
// 加载动画
.loading-state {
:deep(.el-skeleton) {
width: 100%;
}
}
// 深色模式特定样式
html.dark & {
background: var(--el-bg-color-page);
.traffic-details .info-item {
background: var(--el-fill-color);
}
}
}
</style>

View File

@@ -0,0 +1,47 @@
<template>
<div class="iframe-container" v-loading="isLoading">
<iframe
ref="iframeRef"
:src="iframeUrl"
frameborder="0"
class="iframe-content"
@load="handleIframeLoad"
></iframe>
</div>
</template>
<script setup lang="ts">
import { getIframeRoutes } from '@/router/utils/menuToRouter'
const route = useRoute()
const isLoading = ref(true)
const iframeUrl = ref('')
const iframeRef = ref<HTMLIFrameElement | null>(null)
onMounted(() => {
const iframeRoute = getIframeRoutes().find((item: any) => item.path === route.path)
if (iframeRoute?.meta) {
iframeUrl.value = iframeRoute.meta.link || ''
}
})
const handleIframeLoad = () => {
isLoading.value = false
}
</script>
<style scoped>
.iframe-container {
box-sizing: border-box;
width: 100%;
height: 100%;
}
.iframe-content {
width: 100%;
height: 100%;
min-height: calc(100vh - 120px);
border: none;
}
</style>

View File

@@ -0,0 +1,611 @@
<template>
<ArtTableFullScreen>
<div class="package-assign-page" id="table-full-screen">
<!-- 搜索栏 -->
<ArtSearchBar
v-model:filter="formFilters"
:items="formItems"
@reset="handleReset"
@search="handleSearch"
></ArtSearchBar>
<ElCard shadow="never" class="art-table-card">
<!-- 表格头部 -->
<ArtTableHeader
:columnList="columnOptions"
v-model:columns="columnChecks"
@refresh="handleRefresh"
>
<template #left>
<ElButton type="primary" @click="handleSearch">查询</ElButton>
<ElButton type="success" @click="showAssignDialog">分配套餐</ElButton>
<ElButton @click="exportExcel">导出excel</ElButton>
</template>
</ArtTableHeader>
<!-- 表格 -->
<ArtTable
ref="tableRef"
row-key="id"
:loading="loading"
:data="tableData"
:currentPage="pagination.currentPage"
:pageSize="pagination.pageSize"
:total="pagination.total"
:marginTop="10"
@selection-change="handleSelectionChange"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
>
<template #default>
<ElTableColumn v-for="col in columns" :key="col.prop || col.type" v-bind="col" />
</template>
</ArtTable>
<!-- 分配套餐对话框 -->
<ElDialog
v-model="assignDialogVisible"
title="分配套餐"
width="600px"
align-center
:close-on-click-modal="false"
>
<ElForm
ref="assignFormRef"
:model="assignFormData"
:rules="assignRules"
label-width="120px"
>
<ElFormItem label="分销商" prop="distributor">
<ElSelect
v-model="assignFormData.distributor"
placeholder="请选择分销商"
style="width: 100%"
clearable
>
<ElOption label="北京优享科技有限公司" value="beijing_youxiang" />
<ElOption label="上海智联通信技术公司" value="shanghai_zhilian" />
<ElOption label="广州物联网络有限公司" value="guangzhou_wulian" />
<ElOption label="深圳云联科技股份公司" value="shenzhen_yunlian" />
<ElOption label="杭州通达网络服务公司" value="hangzhou_tongda" />
</ElSelect>
</ElFormItem>
<ElFormItem label="套餐系列" prop="packageSeries">
<ElSelect
v-model="assignFormData.packageSeries"
placeholder="请选择套餐系列"
style="width: 100%"
clearable
>
<ElOption label="畅玩系列" value="changwan" />
<ElOption label="如意系列" value="ruyi" />
<ElOption label="NB专享" value="nb_special" />
<ElOption label="大流量系列" value="big_data" />
<ElOption label="广电系列" value="gdtv" />
</ElSelect>
</ElFormItem>
<ElFormItem label="套餐名称" prop="packageName">
<ElSelect
v-model="assignFormData.packageName"
placeholder="请选择套餐名称"
style="width: 100%"
clearable
>
<ElOption label="随意联畅玩年卡套餐" value="changwan_yearly" />
<ElOption label="如意包年3G流量包" value="ruyi_3g" />
<ElOption label="Y-NB专享套餐" value="nb_special_package" />
<ElOption label="100G全国流量月卡套餐" value="big_data_100g" />
<ElOption label="广电飞悦卡无预存50G" value="gdtv_50g" />
</ElSelect>
</ElFormItem>
<ElFormItem label="分销金额" prop="distributionAmount">
<ElInputNumber
v-model="assignFormData.distributionAmount"
:min="0"
:max="9999"
:precision="2"
placeholder="请输入分销金额"
style="width: 100%"
/>
</ElFormItem>
<ElFormItem label="分销比例" prop="distributionRatio">
<ElInputNumber
v-model="assignFormData.distributionRatio"
:min="0"
:max="100"
:precision="1"
placeholder="请输入分销比例"
style="width: 100%"
/>
<span style="margin-left: 8px">%</span>
</ElFormItem>
<ElFormItem label="备注说明" prop="remark">
<ElInput
v-model="assignFormData.remark"
type="textarea"
placeholder="请输入备注说明(可选)"
:rows="3"
maxlength="200"
show-word-limit
/>
</ElFormItem>
</ElForm>
<template #footer>
<div class="dialog-footer">
<ElButton @click="assignDialogVisible = false">取消</ElButton>
<ElButton type="primary" @click="handleAssignSubmit" :loading="assignLoading">
确认分配
</ElButton>
</div>
</template>
</ElDialog>
</ElCard>
</div>
</ArtTableFullScreen>
</template>
<script setup lang="ts">
import { h } from 'vue'
import { ElTag, ElMessage, ElMessageBox, ElInputNumber } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import { useCheckedColumns } from '@/composables/useCheckedColumns'
import ArtButtonTable from '@/components/core/forms/ArtButtonTable.vue'
import { SearchChangeParams, SearchFormItem } from '@/types'
defineOptions({ name: 'PackageAssign' })
const assignDialogVisible = ref(false)
const loading = ref(false)
const assignLoading = ref(false)
// 定义表单搜索初始值
const initialSearchState = {
distributor: '',
assignStatus: '',
packageName: '',
packageSeries: ''
}
// 响应式表单数据
const formFilters = reactive({ ...initialSearchState })
const pagination = reactive({
currentPage: 1,
pageSize: 20,
total: 0
})
// 表格数据
const tableData = ref<any[]>([])
// 表格实例引用
const tableRef = ref()
// 选中的行数据
const selectedRows = ref<any[]>([])
// 分配表单实例
const assignFormRef = ref<FormInstance>()
// 分配表单数据
const assignFormData = reactive({
distributor: '',
packageSeries: '',
packageName: '',
distributionAmount: 0,
distributionRatio: 0,
remark: ''
})
// 模拟数据
const mockData = [
{
id: 1,
packageSeries: '畅玩系列',
packageName: '随意联畅玩年卡套餐',
packageTraffic: 120,
packageType: '年套餐',
salesAmount: 168.0,
distributionAmount: 150.0,
distributionRatio: 10.7,
costPrice: 120.0,
assignStatus: '已分配',
distributorName: '北京优享科技有限公司'
},
{
id: 2,
packageSeries: '如意系列',
packageName: '如意包年3G流量包',
packageTraffic: 36,
packageType: '年套餐',
salesAmount: 98.0,
distributionAmount: 85.0,
distributionRatio: 13.3,
costPrice: 80.0,
assignStatus: '未分配',
distributorName: ''
},
{
id: 3,
packageSeries: 'NB专享',
packageName: 'Y-NB专享套餐',
packageTraffic: 24,
packageType: '月套餐',
salesAmount: 25.0,
distributionAmount: 22.0,
distributionRatio: 12.0,
costPrice: 20.0,
assignStatus: '已分配',
distributorName: '广州物联网络有限公司'
},
{
id: 4,
packageSeries: '大流量系列',
packageName: '100G全国流量月卡套餐',
packageTraffic: 100,
packageType: '月套餐',
salesAmount: 59.0,
distributionAmount: 50.0,
distributionRatio: 15.3,
costPrice: 45.0,
assignStatus: '已分配',
distributorName: '深圳云联科技股份公司'
},
{
id: 5,
packageSeries: '广电系列',
packageName: '广电飞悦卡无预存50G',
packageTraffic: 50,
packageType: '月套餐',
salesAmount: 39.0,
distributionAmount: 33.0,
distributionRatio: 15.4,
costPrice: 30.0,
assignStatus: '未分配',
distributorName: ''
}
]
// 重置表单
const handleReset = () => {
Object.assign(formFilters, { ...initialSearchState })
pagination.currentPage = 1
getPackageAssignList()
}
// 搜索处理
const handleSearch = () => {
console.log('搜索参数:', formFilters)
pagination.currentPage = 1
getPackageAssignList()
}
// 表单项变更处理
const handleFormChange = (params: SearchChangeParams): void => {
console.log('表单项变更:', params)
}
// 表单配置项
const formItems: SearchFormItem[] = [
{
label: '分销商',
prop: 'distributor',
type: 'select',
config: {
clearable: true,
placeholder: '全部'
},
options: () => [
{ label: '北京优享科技有限公司', value: 'beijing_youxiang' },
{ label: '上海智联通信技术公司', value: 'shanghai_zhilian' },
{ label: '广州物联网络有限公司', value: 'guangzhou_wulian' },
{ label: '深圳云联科技股份公司', value: 'shenzhen_yunlian' },
{ label: '杭州通达网络服务公司', value: 'hangzhou_tongda' }
],
onChange: handleFormChange
},
{
label: '分配状态',
prop: 'assignStatus',
type: 'select',
config: {
clearable: true,
placeholder: '全部'
},
options: () => [
{ label: '已分配', value: 'assigned' },
{ label: '未分配', value: 'unassigned' },
{ label: '分配中', value: 'assigning' }
],
onChange: handleFormChange
},
{
label: '套餐名称',
prop: 'packageName',
type: 'input',
config: {
clearable: true,
placeholder: '请输入套餐名称'
},
onChange: handleFormChange
},
{
label: '套餐系列',
prop: 'packageSeries',
type: 'select',
config: {
clearable: true,
placeholder: '全部'
},
options: () => [
{ label: '畅玩系列', value: 'changwan' },
{ label: '如意系列', value: 'ruyi' },
{ label: 'NB专享', value: 'nb_special' },
{ label: '大流量系列', value: 'big_data' },
{ label: '广电系列', value: 'gdtv' }
],
onChange: handleFormChange
}
]
// 列配置
const columnOptions = [
{ label: '勾选', type: 'selection' },
{ label: '套餐系列', prop: 'packageSeries' },
{ label: '套餐名称', prop: 'packageName' },
{ label: '套餐流量(GB)', prop: 'packageTraffic' },
{ label: '套餐类型', prop: 'packageType' },
{ label: '套餐销售金额', prop: 'salesAmount' },
{ label: '套餐分销金额', prop: 'distributionAmount' },
{ label: '分销比例', prop: 'distributionRatio' },
{ label: '成本价', prop: 'costPrice' },
{ label: '分配状态', prop: 'assignStatus' },
{ label: '操作', prop: 'operation' }
]
// 获取分配状态标签类型
const getAssignStatusType = (status: string) => {
switch (status) {
case '已分配':
return 'success'
case '未分配':
return 'warning'
case '分配中':
return 'primary'
default:
return 'info'
}
}
// 导出Excel
const exportExcel = () => {
if (selectedRows.value.length === 0) {
ElMessage.warning('请先选择要导出的数据')
return
}
ElMessage.success(`导出 ${selectedRows.value.length} 条套餐分配记录`)
}
// 显示分配对话框
const showAssignDialog = () => {
assignDialogVisible.value = true
// 重置表单
if (assignFormRef.value) {
assignFormRef.value.resetFields()
}
assignFormData.distributor = ''
assignFormData.packageSeries = ''
assignFormData.packageName = ''
assignFormData.distributionAmount = 0
assignFormData.distributionRatio = 0
assignFormData.remark = ''
}
// 编辑分配
const editAssign = (row: any) => {
ElMessage.info(`编辑分配: ${row.packageName}`)
}
// 取消分配
const cancelAssign = (row: any) => {
ElMessageBox.confirm(`确定要取消分配套餐"${row.packageName}"吗?`, '取消分配确认', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
.then(() => {
ElMessage.success('取消分配成功')
getPackageAssignList()
})
.catch(() => {
ElMessage.info('已取消操作')
})
}
// 动态列配置
const { columnChecks, columns } = useCheckedColumns(() => [
{ type: 'selection' },
{
prop: 'packageSeries',
label: '套餐系列',
width: 120
},
{
prop: 'packageName',
label: '套餐名称',
minWidth: 180
},
{
prop: 'packageTraffic',
label: '套餐流量(GB)',
width: 120,
formatter: (row) => `${row.packageTraffic}GB`
},
{
prop: 'packageType',
label: '套餐类型',
width: 100
},
{
prop: 'salesAmount',
label: '套餐销售金额',
width: 120,
formatter: (row) => `¥${row.salesAmount.toFixed(2)}`
},
{
prop: 'distributionAmount',
label: '套餐分销金额',
width: 120,
formatter: (row) => (row.distributionAmount ? `¥${row.distributionAmount.toFixed(2)}` : '-')
},
{
prop: 'distributionRatio',
label: '分销比例',
width: 100,
formatter: (row) => (row.distributionRatio ? `${row.distributionRatio}%` : '-')
},
{
prop: 'costPrice',
label: '成本价',
width: 100,
formatter: (row) => `¥${row.costPrice.toFixed(2)}`
},
{
prop: 'assignStatus',
label: '分配状态',
width: 100,
formatter: (row) => {
return h(ElTag, { type: getAssignStatusType(row.assignStatus) }, () => row.assignStatus)
}
},
{
prop: 'operation',
label: '操作',
width: 180,
formatter: (row: any) => {
const buttons = []
if (row.assignStatus === '已分配') {
buttons.push(
h(ArtButtonTable, {
text: '编辑',
onClick: () => editAssign(row)
}),
h(ArtButtonTable, {
text: '取消分配',
onClick: () => cancelAssign(row)
})
)
} else {
buttons.push(
h(ArtButtonTable, {
text: '分配',
onClick: () => showAssignDialog()
})
)
}
return h('div', { class: 'operation-buttons' }, buttons)
}
}
])
onMounted(() => {
getPackageAssignList()
})
// 获取套餐分配列表
const getPackageAssignList = async () => {
loading.value = true
try {
// 模拟API调用
await new Promise((resolve) => setTimeout(resolve, 500))
const startIndex = (pagination.currentPage - 1) * pagination.pageSize
const endIndex = startIndex + pagination.pageSize
const paginatedData = mockData.slice(startIndex, endIndex)
tableData.value = paginatedData
pagination.total = mockData.length
loading.value = false
} catch (error) {
console.error('获取套餐分配列表失败:', error)
loading.value = false
}
}
const handleRefresh = () => {
getPackageAssignList()
}
// 处理表格行选择变化
const handleSelectionChange = (selection: any[]) => {
selectedRows.value = selection
}
// 处理表格分页变化
const handleSizeChange = (newPageSize: number) => {
pagination.pageSize = newPageSize
getPackageAssignList()
}
const handleCurrentChange = (newCurrentPage: number) => {
pagination.currentPage = newCurrentPage
getPackageAssignList()
}
// 分配表单验证规则
const assignRules = reactive<FormRules>({
distributor: [{ required: true, message: '请选择分销商', trigger: 'change' }],
packageSeries: [{ required: true, message: '请选择套餐系列', trigger: 'change' }],
packageName: [{ required: true, message: '请选择套餐名称', trigger: 'change' }],
distributionAmount: [{ required: true, message: '请输入分销金额', trigger: 'blur' }],
distributionRatio: [{ required: true, message: '请输入分销比例', trigger: 'blur' }]
})
// 提交分配
const handleAssignSubmit = async () => {
if (!assignFormRef.value) return
await assignFormRef.value.validate((valid) => {
if (valid) {
assignLoading.value = true
// 模拟分配过程
setTimeout(() => {
ElMessage.success(
`套餐分配成功!分销商:${assignFormData.distributor},套餐:${assignFormData.packageName}`
)
assignDialogVisible.value = false
assignLoading.value = false
getPackageAssignList()
}, 2000)
}
})
}
</script>
<style lang="scss" scoped>
.package-assign-page {
// 可以添加特定样式
}
:deep(.operation-buttons) {
display: flex;
gap: 8px;
}
.dialog-footer {
text-align: right;
}
:deep(.el-form-item) {
margin-bottom: 20px;
}
</style>

View File

@@ -0,0 +1,523 @@
<template>
<ArtTableFullScreen>
<div class="package-batch-page" id="table-full-screen">
<!-- 搜索栏 -->
<ArtSearchBar
v-model:filter="formFilters"
:items="formItems"
@reset="handleReset"
@search="handleSearch"
></ArtSearchBar>
<ElCard shadow="never" class="art-table-card">
<!-- 表格头部 -->
<ArtTableHeader
:columnList="columnOptions"
v-model:columns="columnChecks"
@refresh="handleRefresh"
>
<template #left>
<ElButton type="primary" @click="handleSearch">查询</ElButton>
<ElButton type="success" @click="showImportDialog">导入</ElButton>
</template>
</ArtTableHeader>
<!-- 表格 -->
<ArtTable
ref="tableRef"
row-key="id"
:loading="loading"
:data="tableData"
:currentPage="pagination.currentPage"
:pageSize="pagination.pageSize"
:total="pagination.total"
:marginTop="10"
@selection-change="handleSelectionChange"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
>
<template #default>
<ElTableColumn v-for="col in columns" :key="col.prop || col.type" v-bind="col" />
</template>
</ArtTable>
<!-- 导入对话框 -->
<ElDialog
v-model="importDialogVisible"
title="批量导入套餐"
width="500px"
align-center
:close-on-click-modal="false"
>
<!-- 顶部下载模板按钮 -->
<div class="template-section">
<ElButton type="primary" @click="downloadTemplate" icon="Download"> 下载模板 </ElButton>
<span class="template-tip">请先下载模板按模板格式填写后上传</span>
</div>
<ElDivider />
<ElForm
ref="importFormRef"
:model="importFormData"
:rules="importRules"
label-width="120px"
>
<ElFormItem label="导入类型" prop="importType">
<ElSelect
v-model="importFormData.importType"
placeholder="请选择导入类型"
style="width: 100%"
clearable
>
<ElOption label="套餐批量导入" value="package_batch" />
<ElOption label="套餐更新导入" value="package_update" />
<ElOption label="套餐停用导入" value="package_disable" />
<ElOption label="套餐启用导入" value="package_enable" />
</ElSelect>
</ElFormItem>
<ElFormItem label="上传Excel文件" prop="excelFile">
<ElUpload
ref="uploadRef"
:limit="1"
:on-exceed="handleExceed"
:before-upload="beforeUpload"
:on-change="handleFileChange"
:auto-upload="false"
accept=".xlsx,.xls"
drag
>
<ElIcon class="el-icon--upload"><UploadFilled /></ElIcon>
<div class="el-upload__text"> 将文件拖到此处<em>点击上传</em> </div>
<template #tip>
<div class="el-upload__tip"> 只能上传 xlsx/xls 文件且不超过 10MB </div>
</template>
</ElUpload>
</ElFormItem>
<ElFormItem label="备注说明" prop="remark">
<ElInput
v-model="importFormData.remark"
type="textarea"
placeholder="请输入备注说明(可选)"
:rows="3"
maxlength="200"
show-word-limit
/>
</ElFormItem>
</ElForm>
<template #footer>
<div class="dialog-footer">
<ElButton @click="importDialogVisible = false">取消</ElButton>
<ElButton type="primary" @click="handleImportSubmit" :loading="importLoading">
确认导入
</ElButton>
</div>
</template>
</ElDialog>
</ElCard>
</div>
</ArtTableFullScreen>
</template>
<script setup lang="ts">
import { h } from 'vue'
import { ElTag, ElMessage, ElUpload, ElIcon, ElDivider } from 'element-plus'
import { UploadFilled, Download } from '@element-plus/icons-vue'
import type { FormInstance, UploadInstance, UploadRawFile, UploadFile } from 'element-plus'
import type { FormRules } from 'element-plus'
import { useCheckedColumns } from '@/composables/useCheckedColumns'
import ArtButtonTable from '@/components/core/forms/ArtButtonTable.vue'
import { SearchChangeParams, SearchFormItem } from '@/types'
defineOptions({ name: 'PackageBatch' })
const importDialogVisible = ref(false)
const loading = ref(false)
const importLoading = ref(false)
// 定义表单搜索初始值
const initialSearchState = {
importType: ''
}
// 响应式表单数据
const formFilters = reactive({ ...initialSearchState })
const pagination = reactive({
currentPage: 1,
pageSize: 20,
total: 0
})
// 表格数据
const tableData = ref<any[]>([])
// 表格实例引用
const tableRef = ref()
// 选中的行数据
const selectedRows = ref<any[]>([])
// 导入表单实例
const importFormRef = ref<FormInstance>()
const uploadRef = ref<UploadInstance>()
// 导入表单数据
const importFormData = reactive({
importType: '',
excelFile: null as File | null,
remark: ''
})
// 模拟数据
const mockData = [
{
id: 1,
importTotal: 1000,
success: 980,
failed: 20,
importType: '套餐批量导入',
operator: '张若暄',
operationTime: '2025-11-08 10:30:00'
},
{
id: 2,
importTotal: 500,
success: 500,
failed: 0,
importType: '套餐更新导入',
operator: '孔丽娟',
operationTime: '2025-11-07 14:15:00'
},
{
id: 3,
importTotal: 300,
success: 285,
failed: 15,
importType: '套餐停用导入',
operator: '李佳音',
operationTime: '2025-11-06 09:45:00'
},
{
id: 4,
importTotal: 800,
success: 795,
failed: 5,
importType: '套餐启用导入',
operator: '赵强',
operationTime: '2025-11-05 16:20:00'
},
{
id: 5,
importTotal: 150,
success: 145,
failed: 5,
importType: '套餐批量导入',
operator: '张若暄',
operationTime: '2025-11-04 11:30:00'
}
]
// 重置表单
const handleReset = () => {
Object.assign(formFilters, { ...initialSearchState })
pagination.currentPage = 1
getBatchImportList()
}
// 搜索处理
const handleSearch = () => {
console.log('搜索参数:', formFilters)
pagination.currentPage = 1
getBatchImportList()
}
// 表单项变更处理
const handleFormChange = (params: SearchChangeParams): void => {
console.log('表单项变更:', params)
}
// 表单配置项
const formItems: SearchFormItem[] = [
{
label: '导入类型',
prop: 'importType',
type: 'select',
config: {
clearable: true,
placeholder: '全部'
},
options: () => [
{ label: '套餐批量导入', value: 'package_batch' },
{ label: '套餐更新导入', value: 'package_update' },
{ label: '套餐停用导入', value: 'package_disable' },
{ label: '套餐启用导入', value: 'package_enable' }
],
onChange: handleFormChange
}
]
// 列配置
const columnOptions = [
{ label: '勾选', type: 'selection' },
{ label: '导入总数', prop: 'importTotal' },
{ label: '成功', prop: 'success' },
{ label: '失败', prop: 'failed' },
{ label: '导入类型', prop: 'importType' },
{ label: '操作人', prop: 'operator' },
{ label: '操作时间', prop: 'operationTime' },
{ label: '操作', prop: 'operation' }
]
// 获取导入类型标签类型
const getImportTypeTagType = (type: string) => {
switch (type) {
case '套餐批量导入':
return 'primary'
case '套餐更新导入':
return 'success'
case '套餐停用导入':
return 'danger'
case '套餐启用导入':
return 'warning'
default:
return 'info'
}
}
// 显示导入对话框
const showImportDialog = () => {
importDialogVisible.value = true
// 重置表单
if (importFormRef.value) {
importFormRef.value.resetFields()
}
importFormData.importType = ''
importFormData.excelFile = null
importFormData.remark = ''
}
// 下载模板
const downloadTemplate = () => {
ElMessage.success('正在下载批量导入模板...')
// 这里可以实现实际的模板下载功能
}
// 查看详情
const viewDetails = (row: any) => {
ElMessage.info(`查看导入详情: ${row.importType} - 导入总数:${row.importTotal}`)
}
// 重新导入
const retryImport = (row: any) => {
ElMessage.info(`重新导入: ${row.importType}`)
}
// 删除记录
const deleteRecord = (row: any) => {
ElMessage.info(`删除导入记录: ${row.id}`)
}
// 动态列配置
const { columnChecks, columns } = useCheckedColumns(() => [
{ type: 'selection' },
{
prop: 'importTotal',
label: '导入总数',
formatter: (row) => `${row.importTotal} `
},
{
prop: 'success',
label: '成功',
formatter: (row) => {
return h(ElTag, { type: 'success' }, () => `${row.success}`)
}
},
{
prop: 'failed',
label: '失败',
formatter: (row) => {
return h(ElTag, { type: row.failed > 0 ? 'danger' : 'success' }, () => `${row.failed}`)
}
},
{
prop: 'importType',
label: '导入类型',
formatter: (row) => {
return h(ElTag, { type: getImportTypeTagType(row.importType) }, () => row.importType)
}
},
{
prop: 'operator',
label: '操作人'
},
{
prop: 'operationTime',
label: '操作时间'
},
{
prop: 'operation',
label: '操作',
width: 220,
formatter: (row: any) => {
return h('div', { class: 'operation-buttons' }, [
h(ArtButtonTable, {
text: '查看',
onClick: () => viewDetails(row)
}),
h(ArtButtonTable, {
text: '重试',
onClick: () => retryImport(row)
}),
h(ArtButtonTable, {
text: '删除',
onClick: () => deleteRecord(row)
})
])
}
}
])
onMounted(() => {
getBatchImportList()
})
// 获取批量导入列表
const getBatchImportList = async () => {
loading.value = true
try {
// 模拟API调用
await new Promise((resolve) => setTimeout(resolve, 500))
const startIndex = (pagination.currentPage - 1) * pagination.pageSize
const endIndex = startIndex + pagination.pageSize
const paginatedData = mockData.slice(startIndex, endIndex)
tableData.value = paginatedData
pagination.total = mockData.length
loading.value = false
} catch (error) {
console.error('获取批量导入列表失败:', error)
loading.value = false
}
}
const handleRefresh = () => {
getBatchImportList()
}
// 处理表格行选择变化
const handleSelectionChange = (selection: any[]) => {
selectedRows.value = selection
}
// 处理表格分页变化
const handleSizeChange = (newPageSize: number) => {
pagination.pageSize = newPageSize
getBatchImportList()
}
const handleCurrentChange = (newCurrentPage: number) => {
pagination.currentPage = newCurrentPage
getBatchImportList()
}
// 文件上传限制
const handleExceed = () => {
ElMessage.warning('最多只能上传一个文件')
}
// 文件上传前检查
const beforeUpload = (file: UploadRawFile) => {
const isExcel =
file.type === 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' ||
file.type === 'application/vnd.ms-excel'
const isLt10M = file.size / 1024 / 1024 < 10
if (!isExcel) {
ElMessage.error('只能上传 Excel 文件!')
return false
}
if (!isLt10M) {
ElMessage.error('上传文件大小不能超过 10MB!')
return false
}
return false // 阻止自动上传
}
// 文件变化处理
const handleFileChange = (file: UploadFile) => {
if (file.raw) {
importFormData.excelFile = file.raw
}
}
// 导入表单验证规则
const importRules = reactive<FormRules>({
importType: [{ required: true, message: '请选择导入类型', trigger: 'change' }],
excelFile: [{ required: true, message: '请上传Excel文件', trigger: 'change' }]
})
// 提交导入
const handleImportSubmit = async () => {
if (!importFormRef.value) return
// 检查文件是否上传
if (!importFormData.excelFile) {
ElMessage.error('请先上传Excel文件')
return
}
await importFormRef.value.validate((valid) => {
if (valid) {
importLoading.value = true
// 模拟导入过程
setTimeout(() => {
ElMessage.success(`${importFormData.importType}批量导入提交成功!`)
importDialogVisible.value = false
importLoading.value = false
getBatchImportList()
}, 2000)
}
})
}
</script>
<style lang="scss" scoped>
.package-batch-page {
// 可以添加特定样式
}
.template-section {
display: flex;
gap: 12px;
align-items: center;
margin-bottom: 16px;
.template-tip {
font-size: 12px;
color: #909399;
}
}
:deep(.operation-buttons) {
display: flex;
gap: 8px;
}
.dialog-footer {
text-align: right;
}
:deep(.el-upload-dragger) {
padding: 40px;
}
:deep(.el-form-item) {
margin-bottom: 20px;
}
</style>

View File

@@ -0,0 +1,585 @@
<template>
<ArtTableFullScreen>
<div class="package-change-page" id="table-full-screen">
<!-- 搜索栏 -->
<ArtSearchBar
v-model:filter="formFilters"
:items="formItems"
@reset="handleReset"
@search="handleSearch"
></ArtSearchBar>
<ElCard shadow="never" class="art-table-card">
<!-- 表格头部 -->
<ArtTableHeader
:columnList="columnOptions"
v-model:columns="columnChecks"
@refresh="handleRefresh"
>
<template #left>
<ElButton type="primary" @click="handleSearch">查询</ElButton>
<ElButton @click="exportExcel">导出excel</ElButton>
</template>
</ArtTableHeader>
<!-- 表格 -->
<ArtTable
ref="tableRef"
row-key="id"
:loading="loading"
:data="tableData"
:currentPage="pagination.currentPage"
:pageSize="pagination.pageSize"
:total="pagination.total"
:marginTop="10"
@selection-change="handleSelectionChange"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
>
<template #default>
<ElTableColumn v-for="col in columns" :key="col.prop || col.type" v-bind="col" />
</template>
</ArtTable>
<!-- 套餐变更对话框 -->
<ElDialog
v-model="changeDialogVisible"
title="套餐变更"
width="600px"
align-center
:close-on-click-modal="false"
>
<ElForm
ref="changeFormRef"
:model="changeFormData"
:rules="changeRules"
label-width="120px"
>
<ElFormItem label="当前套餐" prop="currentPackage">
<ElInput
v-model="changeFormData.currentPackage"
placeholder="当前套餐"
readonly
style="width: 100%"
/>
</ElFormItem>
<ElFormItem label="变更套餐" prop="newPackage">
<ElSelect
v-model="changeFormData.newPackage"
placeholder="请选择要变更的套餐"
style="width: 100%"
clearable
>
<ElOption label="随意联畅玩年卡套餐" value="changwan_yearly" />
<ElOption label="如意包年3G流量包" value="ruyi_3g" />
<ElOption label="Y-NB专享套餐" value="nb_special" />
<ElOption label="100G全国流量月卡套餐" value="big_data_100g" />
<ElOption label="广电飞悦卡无预存50G" value="gdtv_50g" />
<ElOption label="联通大王卡" value="unicom_dawang" />
<ElOption label="移动花卡宝藏版" value="mobile_huaka" />
<ElOption label="电信星卡" value="telecom_star" />
</ElSelect>
</ElFormItem>
<ElFormItem label="变更原因" prop="changeReason">
<ElSelect
v-model="changeFormData.changeReason"
placeholder="请选择变更原因"
style="width: 100%"
clearable
>
<ElOption label="用户主动变更" value="user_request" />
<ElOption label="套餐到期升级" value="package_upgrade" />
<ElOption label="业务需求调整" value="business_adjustment" />
<ElOption label="系统自动变更" value="system_auto" />
<ElOption label="其他原因" value="other" />
</ElSelect>
</ElFormItem>
<ElFormItem label="备注说明" prop="remark">
<ElInput
v-model="changeFormData.remark"
type="textarea"
placeholder="请输入备注说明(可选)"
:rows="3"
maxlength="200"
show-word-limit
/>
</ElFormItem>
</ElForm>
<template #footer>
<div class="dialog-footer">
<ElButton @click="changeDialogVisible = false">取消</ElButton>
<ElButton type="primary" @click="handleChangeSubmit" :loading="changeLoading">
确认变更
</ElButton>
</div>
</template>
</ElDialog>
</ElCard>
</div>
</ArtTableFullScreen>
</template>
<script setup lang="ts">
import { h } from 'vue'
import { ElTag, ElMessage, ElMessageBox } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import { useCheckedColumns } from '@/composables/useCheckedColumns'
import ArtButtonTable from '@/components/core/forms/ArtButtonTable.vue'
import { SearchChangeParams, SearchFormItem } from '@/types'
defineOptions({ name: 'PackageChange' })
const changeDialogVisible = ref(false)
const loading = ref(false)
const changeLoading = ref(false)
// 定义表单搜索初始值
const initialSearchState = {
isDistribution: '',
cardStatus: '',
cardType: '',
cardCompany: '',
distributor: '',
iccid: '',
accessNumber: '',
virtualNumber: ''
}
// 响应式表单数据
const formFilters = reactive({ ...initialSearchState })
const pagination = reactive({
currentPage: 1,
pageSize: 20,
total: 0
})
// 表格数据
const tableData = ref<any[]>([])
// 表格实例引用
const tableRef = ref()
// 选中的行数据
const selectedRows = ref<any[]>([])
// 套餐变更表单实例
const changeFormRef = ref<FormInstance>()
// 套餐变更表单数据
const changeFormData = reactive({
currentPackage: '',
newPackage: '',
changeReason: '',
remark: ''
})
// 选中的行数据
const selectedRowData = ref<any>({})
// 模拟数据
const mockData = [
{
id: 1,
iccid: '89860621370079892035',
accessNumber: '1440012345678',
virtualNumber: '100001',
cardCompany: '联通',
expiryDate: '2025-12-31',
packageName: '随意联畅玩年卡套餐',
cardType: '2G/3G/4G',
distributorName: '北京优享科技有限公司',
cardStatus: '正常'
},
{
id: 2,
iccid: '89860621370079892036',
accessNumber: '1440012345679',
virtualNumber: '100002',
cardCompany: '移动',
expiryDate: '2025-11-30',
packageName: '如意包年3G流量包',
cardType: 'NB-IoT',
distributorName: '上海智联通信技术公司',
cardStatus: '停机'
},
{
id: 3,
iccid: '89860621370079892037',
accessNumber: '1440012345680',
virtualNumber: '100003',
cardCompany: '电信',
expiryDate: '2026-01-15',
packageName: 'Y-NB专享套餐',
cardType: '4G/5G',
distributorName: '广州物联网络有限公司',
cardStatus: '正常'
},
{
id: 4,
iccid: '89860621370079892038',
accessNumber: '1440012345681',
virtualNumber: '100004',
cardCompany: '联通',
expiryDate: '2025-10-20',
packageName: '100G全国流量月卡套餐',
cardType: '2G/3G/4G',
distributorName: '深圳云联科技股份公司',
cardStatus: '欠费停机'
},
{
id: 5,
iccid: '89860621370079892039',
accessNumber: '1440012345682',
virtualNumber: '100005',
cardCompany: '广电',
expiryDate: '2025-09-10',
packageName: '广电飞悦卡无预存50G',
cardType: '4G/5G',
distributorName: '杭州通达网络服务公司',
cardStatus: '正常'
}
]
// 重置表单
const handleReset = () => {
Object.assign(formFilters, { ...initialSearchState })
pagination.currentPage = 1
getPackageChangeList()
}
// 搜索处理
const handleSearch = () => {
console.log('搜索参数:', formFilters)
pagination.currentPage = 1
getPackageChangeList()
}
// 表单项变更处理
const handleFormChange = (params: SearchChangeParams): void => {
console.log('表单项变更:', params)
}
// 表单配置项
const formItems: SearchFormItem[] = [
{
label: '是否分销',
prop: 'isDistribution',
type: 'select',
config: {
clearable: true,
placeholder: '全部'
},
options: () => [
{ label: '是', value: 'yes' },
{ label: '否', value: 'no' }
],
onChange: handleFormChange
},
{
label: '卡状态',
prop: 'cardStatus',
type: 'select',
config: {
clearable: true,
placeholder: '全部'
},
options: () => [
{ label: '正常', value: 'normal' },
{ label: '停机', value: 'suspended' },
{ label: '欠费停机', value: 'overdue_suspended' },
{ label: '已销户', value: 'closed' },
{ label: '测试期', value: 'testing' }
],
onChange: handleFormChange
},
{
label: '卡片类型',
prop: 'cardType',
type: 'select',
config: {
clearable: true,
placeholder: '全部'
},
options: () => [
{ label: '2G/3G/4G', value: '2g_3g_4g' },
{ label: '4G/5G', value: '4g_5g' },
{ label: 'NB-IoT', value: 'nb_iot' },
{ label: 'eMTC', value: 'emtc' }
],
onChange: handleFormChange
},
{
label: '开卡公司',
prop: 'cardCompany',
type: 'select',
config: {
clearable: true,
placeholder: '全部'
},
options: () => [
{ label: '联通', value: 'unicom' },
{ label: '移动', value: 'mobile' },
{ label: '电信', value: 'telecom' },
{ label: '广电', value: 'gdtv' }
],
onChange: handleFormChange
},
{
label: '分销商',
prop: 'distributor',
type: 'input',
config: {
clearable: true,
placeholder: '请输入分销商'
},
onChange: handleFormChange
},
{
label: 'ICCID号',
prop: 'iccid',
type: 'input',
config: {
clearable: true,
placeholder: '请输入ICCID号'
},
onChange: handleFormChange
},
{
label: '接入号',
prop: 'accessNumber',
type: 'input',
config: {
clearable: true,
placeholder: '请输入接入号'
},
onChange: handleFormChange
},
{
label: '虚拟号',
prop: 'virtualNumber',
type: 'input',
config: {
clearable: true,
placeholder: '请输入虚拟号'
},
onChange: handleFormChange
}
]
// 列配置
const columnOptions = [
{ label: '勾选', type: 'selection' },
{ label: 'ICCID号', prop: 'iccid' },
{ label: '接入号码', prop: 'accessNumber' },
{ label: '虚拟号', prop: 'virtualNumber' },
{ label: '开卡公司', prop: 'cardCompany' },
{ label: '到期时间', prop: 'expiryDate' },
{ label: '套餐', prop: 'packageName' },
{ label: '卡片类型', prop: 'cardType' },
{ label: '分销商姓名', prop: 'distributorName' },
{ label: '卡状态', prop: 'cardStatus' },
{ label: '操作', prop: 'operation' }
]
// 获取卡状态标签类型
const getCardStatusType = (status: string) => {
switch (status) {
case '正常':
return 'success'
case '停机':
return 'warning'
case '欠费停机':
return 'danger'
case '已销户':
return 'info'
case '测试期':
return 'primary'
default:
return 'info'
}
}
// 导出Excel
const exportExcel = () => {
if (selectedRows.value.length === 0) {
ElMessage.warning('请先选择要导出的数据')
return
}
ElMessage.success(`导出 ${selectedRows.value.length} 条套餐变更记录`)
}
// 显示套餐变更对话框
const showChangeDialog = (row: any) => {
changeDialogVisible.value = true
selectedRowData.value = row
// 重置表单
if (changeFormRef.value) {
changeFormRef.value.resetFields()
}
changeFormData.currentPackage = row.packageName
changeFormData.newPackage = ''
changeFormData.changeReason = ''
changeFormData.remark = ''
}
// 动态列配置
const { columnChecks, columns } = useCheckedColumns(() => [
{ type: 'selection' },
{
prop: 'iccid',
label: 'ICCID号',
minWidth: 180
},
{
prop: 'accessNumber',
label: '接入号码',
width: 140
},
{
prop: 'virtualNumber',
label: '虚拟号',
width: 100
},
{
prop: 'cardCompany',
label: '开卡公司',
width: 100
},
{
prop: 'expiryDate',
label: '到期时间',
width: 120
},
{
prop: 'packageName',
label: '套餐',
minWidth: 180
},
{
prop: 'cardType',
label: '卡片类型',
width: 120
},
{
prop: 'distributorName',
label: '分销商姓名',
minWidth: 180
},
{
prop: 'cardStatus',
label: '卡状态',
width: 100,
formatter: (row) => {
return h(ElTag, { type: getCardStatusType(row.cardStatus) }, () => row.cardStatus)
}
},
{
prop: 'operation',
label: '操作',
width: 120,
formatter: (row: any) => {
return h('div', { class: 'operation-buttons' }, [
h(ArtButtonTable, {
text: '套餐变更',
onClick: () => showChangeDialog(row)
})
])
}
}
])
onMounted(() => {
getPackageChangeList()
})
// 获取套餐变更列表
const getPackageChangeList = async () => {
loading.value = true
try {
// 模拟API调用
await new Promise((resolve) => setTimeout(resolve, 500))
const startIndex = (pagination.currentPage - 1) * pagination.pageSize
const endIndex = startIndex + pagination.pageSize
const paginatedData = mockData.slice(startIndex, endIndex)
tableData.value = paginatedData
pagination.total = mockData.length
loading.value = false
} catch (error) {
console.error('获取套餐变更列表失败:', error)
loading.value = false
}
}
const handleRefresh = () => {
getPackageChangeList()
}
// 处理表格行选择变化
const handleSelectionChange = (selection: any[]) => {
selectedRows.value = selection
}
// 处理表格分页变化
const handleSizeChange = (newPageSize: number) => {
pagination.pageSize = newPageSize
getPackageChangeList()
}
const handleCurrentChange = (newCurrentPage: number) => {
pagination.currentPage = newCurrentPage
getPackageChangeList()
}
// 套餐变更表单验证规则
const changeRules = reactive<FormRules>({
newPackage: [{ required: true, message: '请选择要变更的套餐', trigger: 'change' }],
changeReason: [{ required: true, message: '请选择变更原因', trigger: 'change' }]
})
// 提交套餐变更
const handleChangeSubmit = async () => {
if (!changeFormRef.value) return
await changeFormRef.value.validate((valid) => {
if (valid) {
changeLoading.value = true
// 模拟变更过程
setTimeout(() => {
ElMessage.success(
`套餐变更成功ICCID${selectedRowData.value.iccid},从${changeFormData.currentPackage}变更为${changeFormData.newPackage}`
)
changeDialogVisible.value = false
changeLoading.value = false
getPackageChangeList()
}, 2000)
}
})
}
</script>
<style lang="scss" scoped>
.package-change-page {
// 可以添加特定样式
}
:deep(.operation-buttons) {
display: flex;
gap: 8px;
}
.dialog-footer {
text-align: right;
}
:deep(.el-form-item) {
margin-bottom: 20px;
}
</style>

View File

@@ -0,0 +1,452 @@
<template>
<ArtTableFullScreen>
<div class="package-commission-page" id="table-full-screen">
<!-- 搜索栏 -->
<ArtSearchBar
v-model:filter="formFilters"
:items="formItems"
@reset="handleReset"
@search="handleSearch"
></ArtSearchBar>
<ElCard shadow="never" class="art-table-card">
<!-- 表格头部 -->
<ArtTableHeader
:columnList="columnOptions"
v-model:columns="columnChecks"
@refresh="handleRefresh"
>
<template #left>
<ElButton type="primary" @click="handleSearch">搜索</ElButton>
<ElButton type="success" @click="showAddDialog">新增</ElButton>
<ElButton @click="exportExcel">导出excel</ElButton>
</template>
</ArtTableHeader>
<!-- 表格 -->
<ArtTable
ref="tableRef"
row-key="id"
:loading="loading"
:data="tableData"
:currentPage="pagination.currentPage"
:pageSize="pagination.pageSize"
:total="pagination.total"
:marginTop="10"
@selection-change="handleSelectionChange"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
>
<template #default>
<ElTableColumn v-for="col in columns" :key="col.prop || col.type" v-bind="col" />
</template>
</ArtTable>
<!-- 新增套餐佣金网卡对话框 -->
<ElDialog
v-model="addDialogVisible"
title="新增套餐佣金网卡"
width="500px"
align-center
:close-on-click-modal="false"
>
<ElForm ref="addFormRef" :model="addFormData" :rules="addRules" label-width="120px">
<ElFormItem label="卡号" prop="cardNumber">
<ElInput v-model="addFormData.cardNumber" placeholder="请输入卡号" clearable />
</ElFormItem>
<ElFormItem label="月份" prop="month">
<ElDatePicker
v-model="addFormData.month"
type="month"
placeholder="请选择月份"
style="width: 100%"
format="YYYY-MM"
value-format="YYYY-MM"
/>
</ElFormItem>
</ElForm>
<template #footer>
<div class="dialog-footer">
<ElButton @click="addDialogVisible = false">取消</ElButton>
<ElButton type="primary" @click="handleAddSubmit" :loading="addLoading">
确认新增
</ElButton>
</div>
</template>
</ElDialog>
</ElCard>
</div>
</ArtTableFullScreen>
</template>
<script setup lang="ts">
import { h } from 'vue'
import { ElTag, ElMessage, ElMessageBox, ElDatePicker } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import { useCheckedColumns } from '@/composables/useCheckedColumns'
import ArtButtonTable from '@/components/core/forms/ArtButtonTable.vue'
import { SearchChangeParams, SearchFormItem } from '@/types'
defineOptions({ name: 'PackageCommission' })
const addDialogVisible = ref(false)
const loading = ref(false)
const addLoading = ref(false)
// 定义表单搜索初始值
const initialSearchState = {
cardNumber: '',
virtualNumber: '',
createDateRange: []
}
// 响应式表单数据
const formFilters = reactive({ ...initialSearchState })
const pagination = reactive({
currentPage: 1,
pageSize: 20,
total: 0
})
// 表格数据
const tableData = ref<any[]>([])
// 表格实例引用
const tableRef = ref()
// 选中的行数据
const selectedRows = ref<any[]>([])
// 新增表单实例
const addFormRef = ref<FormInstance>()
// 新增表单数据
const addFormData = reactive({
cardNumber: '',
month: ''
})
// 模拟数据
const mockData = [
{
id: 1,
cardNumber: '89860621370079892035',
accessNumber: '1440012345678',
virtualNumber: '100001',
cardCompany: '联通',
month: '2025-11',
createTime: '2025-11-08 10:30:00',
creator: '张若暄'
},
{
id: 2,
cardNumber: '89860621370079892036',
accessNumber: '1440012345679',
virtualNumber: '100002',
cardCompany: '移动',
month: '2025-11',
createTime: '2025-11-07 14:15:00',
creator: '孔丽娟'
},
{
id: 3,
cardNumber: '89860621370079892037',
accessNumber: '1440012345680',
virtualNumber: '100003',
cardCompany: '电信',
month: '2025-11',
createTime: '2025-11-06 09:45:00',
creator: '李佳音'
},
{
id: 4,
cardNumber: '89860621370079892038',
accessNumber: '1440012345681',
virtualNumber: '100004',
cardCompany: '联通',
month: '2025-10',
createTime: '2025-10-20 16:20:00',
creator: '赵强'
},
{
id: 5,
cardNumber: '89860621370079892039',
accessNumber: '1440012345682',
virtualNumber: '100005',
cardCompany: '广电',
month: '2025-10',
createTime: '2025-10-15 11:30:00',
creator: '张若暄'
}
]
// 重置表单
const handleReset = () => {
Object.assign(formFilters, { ...initialSearchState })
pagination.currentPage = 1
getCommissionCardList()
}
// 搜索处理
const handleSearch = () => {
console.log('搜索参数:', formFilters)
pagination.currentPage = 1
getCommissionCardList()
}
// 表单项变更处理
const handleFormChange = (params: SearchChangeParams): void => {
console.log('表单项变更:', params)
}
// 表单配置项
const formItems: SearchFormItem[] = [
{
label: '卡号',
prop: 'cardNumber',
type: 'input',
config: {
clearable: true,
placeholder: '请输入卡号'
},
onChange: handleFormChange
},
{
label: '虚拟号',
prop: 'virtualNumber',
type: 'input',
config: {
clearable: true,
placeholder: '请输入虚拟号'
},
onChange: handleFormChange
},
{
label: '创建时间',
prop: 'createDateRange',
type: 'daterange',
config: {
type: 'daterange',
startPlaceholder: '开始时间',
endPlaceholder: '结束时间'
},
onChange: handleFormChange
}
]
// 列配置
const columnOptions = [
{ label: '勾选', type: 'selection' },
{ label: '卡号', prop: 'cardNumber' },
{ label: '接入号', prop: 'accessNumber' },
{ label: '虚拟号', prop: 'virtualNumber' },
{ label: '开卡公司', prop: 'cardCompany' },
{ label: '月份', prop: 'month' },
{ label: '创建时间', prop: 'createTime' },
{ label: '创建人', prop: 'creator' },
{ label: '操作', prop: 'operation' }
]
// 导出Excel
const exportExcel = () => {
if (selectedRows.value.length === 0) {
ElMessage.warning('请先选择要导出的数据')
return
}
ElMessage.success(`导出 ${selectedRows.value.length} 条套餐佣金网卡记录`)
}
// 显示新增对话框
const showAddDialog = () => {
addDialogVisible.value = true
// 重置表单
if (addFormRef.value) {
addFormRef.value.resetFields()
}
addFormData.cardNumber = ''
addFormData.month = ''
}
// 查看详情
const viewDetails = (row: any) => {
ElMessage.info(`查看详情: ${row.cardNumber}`)
}
// 编辑记录
const editRecord = (row: any) => {
ElMessage.info(`编辑记录: ${row.cardNumber}`)
}
// 删除记录
const deleteRecord = (row: any) => {
ElMessageBox.confirm(`确定要删除卡号为"${row.cardNumber}"的佣金记录吗?`, '删除确认', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
.then(() => {
ElMessage.success('删除成功')
getCommissionCardList()
})
.catch(() => {
ElMessage.info('已取消删除')
})
}
// 动态列配置
const { columnChecks, columns } = useCheckedColumns(() => [
{ type: 'selection' },
{
prop: 'cardNumber',
label: '卡号',
minWidth: 180
},
{
prop: 'accessNumber',
label: '接入号',
width: 140
},
{
prop: 'virtualNumber',
label: '虚拟号',
width: 100
},
{
prop: 'cardCompany',
label: '开卡公司',
width: 100
},
{
prop: 'month',
label: '月份',
width: 100
},
{
prop: 'createTime',
label: '创建时间',
width: 160
},
{
prop: 'creator',
label: '创建人',
width: 100
},
{
prop: 'operation',
label: '操作',
width: 220,
formatter: (row: any) => {
return h('div', { class: 'operation-buttons' }, [
h(ArtButtonTable, {
text: '查看',
onClick: () => viewDetails(row)
}),
h(ArtButtonTable, {
text: '编辑',
onClick: () => editRecord(row)
}),
h(ArtButtonTable, {
text: '删除',
onClick: () => deleteRecord(row)
})
])
}
}
])
onMounted(() => {
getCommissionCardList()
})
// 获取套餐佣金网卡列表
const getCommissionCardList = async () => {
loading.value = true
try {
// 模拟API调用
await new Promise((resolve) => setTimeout(resolve, 500))
const startIndex = (pagination.currentPage - 1) * pagination.pageSize
const endIndex = startIndex + pagination.pageSize
const paginatedData = mockData.slice(startIndex, endIndex)
tableData.value = paginatedData
pagination.total = mockData.length
loading.value = false
} catch (error) {
console.error('获取套餐佣金网卡列表失败:', error)
loading.value = false
}
}
const handleRefresh = () => {
getCommissionCardList()
}
// 处理表格行选择变化
const handleSelectionChange = (selection: any[]) => {
selectedRows.value = selection
}
// 处理表格分页变化
const handleSizeChange = (newPageSize: number) => {
pagination.pageSize = newPageSize
getCommissionCardList()
}
const handleCurrentChange = (newCurrentPage: number) => {
pagination.currentPage = newCurrentPage
getCommissionCardList()
}
// 新增表单验证规则
const addRules = reactive<FormRules>({
cardNumber: [
{ required: true, message: '请输入卡号', trigger: 'blur' },
{ min: 15, max: 20, message: '卡号长度在 15 到 20 个字符', trigger: 'blur' }
],
month: [{ required: true, message: '请选择月份', trigger: 'change' }]
})
// 提交新增
const handleAddSubmit = async () => {
if (!addFormRef.value) return
await addFormRef.value.validate((valid) => {
if (valid) {
addLoading.value = true
// 模拟新增过程
setTimeout(() => {
ElMessage.success(
`新增套餐佣金网卡成功!卡号:${addFormData.cardNumber},月份:${addFormData.month}`
)
addDialogVisible.value = false
addLoading.value = false
getCommissionCardList()
}, 2000)
}
})
}
</script>
<style lang="scss" scoped>
.package-commission-page {
// 可以添加特定样式
}
:deep(.operation-buttons) {
display: flex;
gap: 8px;
}
.dialog-footer {
text-align: right;
}
:deep(.el-form-item) {
margin-bottom: 20px;
}
</style>

View File

@@ -0,0 +1,953 @@
<template>
<ArtTableFullScreen>
<div class="package-create-page" id="table-full-screen">
<!-- 搜索栏 -->
<ArtSearchBar
v-model:filter="formFilters"
:items="formItems"
@reset="handleReset"
@search="handleSearch"
></ArtSearchBar>
<ElCard shadow="never" class="art-table-card">
<!-- 数据视图组件 -->
<ArtDataViewer
ref="dataViewerRef"
:data="tableData"
:loading="loading"
:table-columns="columns"
:descriptions-fields="descriptionsFields"
:descriptions-columns="2"
:pagination="pagination"
:label-width="'140px'"
:field-columns="columnChecks"
:show-card-actions="true"
:show-card-selection="true"
:default-view="currentView"
@selection-change="handleSelectionChange"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
@view-change="handleViewChange"
>
<template #header-left>
<ElButton type="success" @click="showCreateDialog">新增套餐</ElButton>
<ElButton @click="exportExcel">导出excel</ElButton>
</template>
<template #header-right>
<ArtTableHeader
:columnList="columnOptions"
v-model:columns="columnChecks"
v-model:currentView="currentView"
@refresh="handleRefresh"
@viewChange="handleViewChange"
:show-title="false"
:show-view-toggle="true"
/>
</template>
<template #card-actions="{ item }">
<ElButton type="primary" size="small" @click="editPackage(item)">
编辑
</ElButton>
<ElButton type="warning" size="small" @click="offlinePackage(item)">
下架
</ElButton>
<ElButton type="success" size="small" @click="copyPackage(item)">
复制套餐
</ElButton>
</template>
</ArtDataViewer>
<!-- 新增套餐对话框 -->
<ElDialog
v-model="dialogVisible"
title="新增套餐"
width="800px"
align-center
:close-on-click-modal="false"
>
<ElForm ref="formRef" :model="formData" :rules="rules" label-width="140px">
<ElRow :gutter="24">
<ElCol :span="12">
<ElFormItem label="套餐系列" prop="series">
<ElSelect
v-model="formData.series"
placeholder="请选择套餐系列"
style="width: 100%"
>
<ElOption label="畅玩系列" value="changwan" />
<ElOption label="如意系列" value="ruyi" />
<ElOption label="NB专享" value="nb_special" />
<ElOption label="大流量系列" value="big_data" />
<ElOption label="广电系列" value="gdtv" />
</ElSelect>
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="余额充值梯度" prop="balanceRechargeGradient">
<ElSelect
v-model="formData.balanceRechargeGradient"
placeholder="请选择充值梯度"
style="width: 100%"
>
<ElOption label="10,20,50,100" value="10,20,50,100" />
<ElOption label="5,10,20,50" value="5,10,20,50" />
<ElOption label="10,50,100" value="10,50,100" />
<ElOption label="20,50,100,200" value="20,50,100,200" />
<ElOption label="10,30,50" value="10,30,50" />
</ElSelect>
</ElFormItem>
</ElCol>
</ElRow>
<ElRow :gutter="24">
<ElCol :span="12">
<ElFormItem label="套餐名称" prop="name">
<ElInput v-model="formData.name" placeholder="请输入套餐名称" />
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="套餐类型" prop="type">
<ElSelect
v-model="formData.type"
placeholder="请选择套餐类型"
style="width: 100%"
>
<ElOption label="月套餐" value="monthly" />
<ElOption label="年套餐" value="yearly" />
<ElOption label="流量包" value="data_package" />
<ElOption label="季度套餐" value="quarterly" />
</ElSelect>
</ElFormItem>
</ElCol>
</ElRow>
<ElRow :gutter="24">
<ElCol :span="12">
<ElFormItem label="本月次月加餐包" prop="monthlyNextMonthAddon">
<ElSelect
v-model="formData.monthlyNextMonthAddon"
placeholder="请选择加餐包支持"
style="width: 100%"
>
<ElOption label="支持" value="support" />
<ElOption label="不支持" value="not_support" />
</ElSelect>
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="本月最大充值数量" prop="maxMonthlyRecharge">
<ElInputNumber
v-model="formData.maxMonthlyRecharge"
:min="1"
:max="1000"
placeholder="请输入本月最大充值数量"
style="width: 100%"
/>
</ElFormItem>
</ElCol>
</ElRow>
<ElRow :gutter="24">
<ElCol :span="12">
<ElFormItem label="次月最大充值数量" prop="maxNextMonthRecharge">
<ElInputNumber
v-model="formData.maxNextMonthRecharge"
:min="1"
:max="1000"
placeholder="请输入次月最大充值数量"
style="width: 100%"
/>
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="套餐数量" prop="packageCount">
<ElInputNumber
v-model="formData.packageCount"
:min="1"
:max="999999"
placeholder="请输入套餐数量"
style="width: 100%"
/>
</ElFormItem>
</ElCol>
</ElRow>
<ElRow :gutter="24">
<ElCol :span="12">
<ElFormItem label="成本价" prop="costPrice">
<ElInputNumber
v-model="formData.costPrice"
:min="0"
:max="9999"
:precision="2"
placeholder="请输入成本价"
style="width: 100%"
/>
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="套餐金额" prop="packageAmount">
<ElInputNumber
v-model="formData.packageAmount"
:min="0"
:max="9999"
:precision="2"
placeholder="请输入套餐金额"
style="width: 100%"
/>
</ElFormItem>
</ElCol>
</ElRow>
</ElForm>
<template #footer>
<div class="dialog-footer">
<ElButton @click="dialogVisible = false">取消</ElButton>
<ElButton type="primary" @click="handleSubmit" :loading="submitLoading">
确认新增
</ElButton>
</div>
</template>
</ElDialog>
</ElCard>
</div>
</ArtTableFullScreen>
</template>
<script setup lang="ts">
import { h } from 'vue'
import { ElTag, ElMessage, ElMessageBox, ElInputNumber } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import { useCheckedColumns } from '@/composables/useCheckedColumns'
import ArtButtonTable from '@/components/core/forms/ArtButtonTable.vue'
import ArtDataViewer from '@/components/core/views/ArtDataViewer.vue'
import { SearchChangeParams, SearchFormItem } from '@/types'
defineOptions({ name: 'PackageCreate' })
const dialogVisible = ref(false)
const loading = ref(false)
const submitLoading = ref(false)
// 当前视图模式
const currentView = ref('table')
// 数据视图组件引用
const dataViewerRef = ref()
// 定义表单搜索初始值
const initialSearchState = {
packageType: '',
packageName: '',
packageCode: '',
status: '',
packageSeries: '',
levelOneMerge: '',
levelTwoMerge: ''
}
// 响应式表单数据
const formFilters = reactive({ ...initialSearchState })
const pagination = reactive({
currentPage: 1,
pageSize: 20,
total: 0
})
// 表格数据
const tableData = ref<any[]>([])
// 表格实例引用
const tableRef = ref()
// 选中的行数据
const selectedRows = ref<any[]>([])
// 表单实例
const formRef = ref<FormInstance>()
// 新增表单数据
const formData = reactive({
series: '',
balanceRechargeGradient: '',
name: '',
type: '',
monthlyNextMonthAddon: '',
maxMonthlyRecharge: 0,
maxNextMonthRecharge: 0,
packageCount: 0,
costPrice: 0,
packageAmount: 0
})
// 模拟数据
const mockData = [
{
id: 1,
packageSeries: '畅玩系列',
packageName: '随意联畅玩年卡套餐',
packageTraffic: 120,
packageCount: 1000,
packageCountType: '张',
packageType: '年套餐',
packageAmount: '168.00',
description: '年卡套餐含120GB流量',
frontendSort: 1,
sort: 1,
maxMonthlyRecharge: 50,
maxNextMonthRecharge: 50,
monthlyNextMonthAddon: '支持',
status: '启用',
levelOneMerge: '是',
levelOneMergeCode: 'L1_001',
levelTwoMerge: '否',
levelTwoMergeCode: '',
isSpecial: '否',
creator: '张若暄',
modifier: '张若暄',
addTime: '2025-11-08 10:30:00',
balanceRechargeGradient: '10,20,50,100',
packageCode: 'PKG_001'
},
{
id: 2,
packageSeries: '如意系列',
packageName: '如意包年3G流量包',
packageTraffic: 36,
packageCount: 800,
packageCountType: '张',
packageType: '年套餐',
packageAmount: '98.00',
description: '年包3GB月套餐',
frontendSort: 2,
sort: 2,
maxMonthlyRecharge: 30,
maxNextMonthRecharge: 30,
monthlyNextMonthAddon: '支持',
status: '启用',
levelOneMerge: '是',
levelOneMergeCode: 'L1_002',
levelTwoMerge: '是',
levelTwoMergeCode: 'L2_001',
isSpecial: '否',
creator: '孔丽娟',
modifier: '孔丽娟',
addTime: '2025-11-07 14:15:00',
balanceRechargeGradient: '5,10,20,50',
packageCode: 'PKG_002'
},
{
id: 3,
packageSeries: 'NB专享',
packageName: 'Y-NB专享套餐',
packageTraffic: 24,
packageCount: 500,
packageCountType: '张',
packageType: '月套餐',
packageAmount: '25.00',
description: 'NB-IoT专用套餐',
frontendSort: 3,
sort: 3,
maxMonthlyRecharge: 100,
maxNextMonthRecharge: 100,
monthlyNextMonthAddon: '不支持',
status: '停用',
levelOneMerge: '否',
levelOneMergeCode: '',
levelTwoMerge: '否',
levelTwoMergeCode: '',
isSpecial: '是',
creator: '李佳音',
modifier: '李佳音',
addTime: '2025-11-06 09:45:00',
balanceRechargeGradient: '10,50,100',
packageCode: 'PKG_003'
},
{
id: 4,
packageSeries: '大流量系列',
packageName: '100G全国流量月卡套餐',
packageTraffic: 100,
packageCount: 1200,
packageCountType: '张',
packageType: '月套餐',
packageAmount: '59.00',
description: '100GB大流量月卡',
frontendSort: 4,
sort: 4,
maxMonthlyRecharge: 80,
maxNextMonthRecharge: 80,
monthlyNextMonthAddon: '支持',
status: '启用',
levelOneMerge: '是',
levelOneMergeCode: 'L1_003',
levelTwoMerge: '否',
levelTwoMergeCode: '',
isSpecial: '否',
creator: '赵强',
modifier: '赵强',
addTime: '2025-11-05 16:20:00',
balanceRechargeGradient: '20,50,100,200',
packageCode: 'PKG_004'
},
{
id: 5,
packageSeries: '广电系列',
packageName: '广电飞悦卡无预存50G',
packageTraffic: 50,
packageCount: 600,
packageCountType: '张',
packageType: '月套餐',
packageAmount: '39.00',
description: '广电30天50GB流量',
frontendSort: 5,
sort: 5,
maxMonthlyRecharge: 60,
maxNextMonthRecharge: 60,
monthlyNextMonthAddon: '支持',
status: '启用',
levelOneMerge: '否',
levelOneMergeCode: '',
levelTwoMerge: '是',
levelTwoMergeCode: 'L2_002',
isSpecial: '是',
creator: '张若暄',
modifier: '张若暄',
addTime: '2025-11-04 11:30:00',
balanceRechargeGradient: '10,30,50',
packageCode: 'PKG_005'
}
]
// 重置表单
const handleReset = () => {
Object.assign(formFilters, { ...initialSearchState })
pagination.currentPage = 1
getPackageList()
}
// 搜索处理
const handleSearch = () => {
console.log('搜索参数:', formFilters)
pagination.currentPage = 1
getPackageList()
}
// 表单项变更处理
const handleFormChange = (params: SearchChangeParams): void => {
console.log('表单项变更:', params)
}
// 表单配置项
const formItems: SearchFormItem[] = [
{
label: '套餐类型',
prop: 'packageType',
type: 'select',
config: {
clearable: true,
placeholder: '全部'
},
options: () => [
{ label: '月套餐', value: 'monthly' },
{ label: '年套餐', value: 'yearly' },
{ label: '流量包', value: 'data_package' },
{ label: '季度套餐', value: 'quarterly' }
],
onChange: handleFormChange
},
{
label: '套餐名称',
prop: 'packageName',
type: 'input',
config: {
clearable: true,
placeholder: '请输入套餐名称'
},
onChange: handleFormChange
},
{
label: '套餐编码',
prop: 'packageCode',
type: 'input',
config: {
clearable: true,
placeholder: '请输入套餐编码'
},
onChange: handleFormChange
},
{
label: '状态',
prop: 'status',
type: 'select',
config: {
clearable: true,
placeholder: '全部'
},
options: () => [
{ label: '启用', value: 'active' },
{ label: '停用', value: 'inactive' }
],
onChange: handleFormChange
},
{
label: '套餐系列',
prop: 'packageSeries',
type: 'input',
config: {
clearable: true,
placeholder: '请输入套餐系列'
},
onChange: handleFormChange
},
{
label: '一级合并',
prop: 'levelOneMerge',
type: 'select',
config: {
clearable: true,
placeholder: '全部'
},
options: () => [
{ label: '是', value: 'yes' },
{ label: '否', value: 'no' }
],
onChange: handleFormChange
},
{
label: '二级合并',
prop: 'levelTwoMerge',
type: 'select',
config: {
clearable: true,
placeholder: '全部'
},
options: () => [
{ label: '是', value: 'yes' },
{ label: '否', value: 'no' }
],
onChange: handleFormChange
}
]
// 列配置
const columnOptions = [
{ label: '勾选', type: 'selection' },
{ label: '套餐系列', prop: 'packageSeries' },
{ label: '套餐名称', prop: 'packageName' },
{ label: '套餐流量(GB)', prop: 'packageTraffic' },
{ label: '套餐数量', prop: 'packageCount' },
{ label: '套餐数量类型', prop: 'packageCountType' },
{ label: '套餐类型', prop: 'packageType' },
{ label: '套餐金额', prop: 'packageAmount' },
{ label: '描述', prop: 'description' },
{ label: '前端排序', prop: 'frontendSort' },
{ label: '排序', prop: 'sort' },
{ label: '本月最大充值数量', prop: 'maxMonthlyRecharge' },
{ label: '次月最大充值数量', prop: 'maxNextMonthRecharge' },
{ label: '本月次月加餐包', prop: 'monthlyNextMonthAddon' },
{ label: '状态', prop: 'status' },
{ label: '一级是否合并', prop: 'levelOneMerge' },
{ label: '一级合并编号', prop: 'levelOneMergeCode' },
{ label: '二级是否合并', prop: 'levelTwoMerge' },
{ label: '二级合并编号', prop: 'levelTwoMergeCode' },
{ label: '是否特殊', prop: 'isSpecial' },
{ label: '创建人', prop: 'creator' },
{ label: '修改人', prop: 'modifier' },
{ label: '添加时间', prop: 'addTime' },
{ label: '余额充值梯度', prop: 'balanceRechargeGradient' },
{ label: '套餐编码', prop: 'packageCode' },
{ label: '操作', prop: 'operation' }
]
// 描述字段配置(用于卡片视图)
const descriptionsFields = [
{ prop: 'packageName', label: '套餐名称', span: 2 },
{ prop: 'packageSeries', label: '套餐系列' },
{ prop: 'packageCode', label: '套餐编码' },
{
prop: 'packageTraffic',
label: '套餐流量',
formatter: (row: any) => `${row.packageTraffic}GB`
},
{
prop: 'packageCount',
label: '套餐数量',
formatter: (row: any) => `${row.packageCount}${row.packageCountType}`
},
{ prop: 'packageType', label: '套餐类型' },
{
prop: 'packageAmount',
label: '套餐金额',
formatter: (row: any) => `¥${row.packageAmount}`
},
{ prop: 'description', label: '描述', span: 2 },
{
prop: 'status',
label: '状态',
formatter: (row: any) => {
const type = getStatusType(row.status)
return `<el-tag type="${type}" size="small">${row.status}</el-tag>`
}
},
{
prop: 'monthlyNextMonthAddon',
label: '本月次月加餐包',
formatter: (row: any) => {
const type = row.monthlyNextMonthAddon === '支持' ? 'success' : 'info'
return `<el-tag type="${type}" size="small">${row.monthlyNextMonthAddon}</el-tag>`
}
},
{ prop: 'maxMonthlyRecharge', label: '本月最大充值数量' },
{ prop: 'maxNextMonthRecharge', label: '次月最大充值数量' },
{
prop: 'levelOneMerge',
label: '一级是否合并',
formatter: (row: any) => {
const type = getMergeType(row.levelOneMerge)
return `<el-tag type="${type}" size="small">${row.levelOneMerge}</el-tag>`
}
},
{ prop: 'levelOneMergeCode', label: '一级合并编号', formatter: (row: any) => row.levelOneMergeCode || '-' },
{
prop: 'levelTwoMerge',
label: '二级是否合并',
formatter: (row: any) => {
const type = getMergeType(row.levelTwoMerge)
return `<el-tag type="${type}" size="small">${row.levelTwoMerge}</el-tag>`
}
},
{ prop: 'levelTwoMergeCode', label: '二级合并编号', formatter: (row: any) => row.levelTwoMergeCode || '-' },
{
prop: 'isSpecial',
label: '是否特殊',
formatter: (row: any) => {
const type = getSpecialType(row.isSpecial)
return `<el-tag type="${type}" size="small">${row.isSpecial}</el-tag>`
}
},
{ prop: 'creator', label: '创建人' },
{ prop: 'addTime', label: '添加时间', span: 2 },
{ prop: 'balanceRechargeGradient', label: '余额充值梯度' },
{ prop: 'frontendSort', label: '前端排序' },
{ prop: 'sort', label: '排序' }
]
// 获取状态标签类型
const getStatusType = (status: string) => {
switch (status) {
case '启用':
return 'success'
case '停用':
return 'danger'
default:
return 'info'
}
}
// 获取是否合并标签类型
const getMergeType = (merge: string) => {
switch (merge) {
case '是':
return 'success'
case '否':
return 'info'
default:
return 'info'
}
}
// 获取是否特殊标签类型
const getSpecialType = (special: string) => {
switch (special) {
case '是':
return 'warning'
case '否':
return 'info'
default:
return 'info'
}
}
// 导出Excel
const exportExcel = () => {
if (selectedRows.value.length === 0) {
ElMessage.warning('请先选择要导出的数据')
return
}
ElMessage.success(`导出 ${selectedRows.value.length} 条套餐记录`)
}
// 显示新增对话框
const showCreateDialog = () => {
dialogVisible.value = true
// 重置表单
if (formRef.value) {
formRef.value.resetFields()
}
formData.series = ''
formData.balanceRechargeGradient = ''
formData.name = ''
formData.type = ''
formData.monthlyNextMonthAddon = ''
formData.maxMonthlyRecharge = 0
formData.maxNextMonthRecharge = 0
formData.packageCount = 0
formData.costPrice = 0
formData.packageAmount = 0
}
// 编辑套餐
const editPackage = (row: any) => {
ElMessage.info(`编辑套餐: ${row.packageName}`)
}
// 下架套餐
const offlinePackage = (row: any) => {
ElMessageBox.confirm(`确定要下架套餐"${row.packageName}"吗?`, '下架确认', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
.then(() => {
ElMessage.success('套餐下架成功')
getPackageList()
})
.catch(() => {
ElMessage.info('已取消下架')
})
}
// 复制套餐
const copyPackage = (row: any) => {
ElMessage.info(`复制套餐: ${row.packageName}`)
}
// 动态列配置
const { columnChecks, columns } = useCheckedColumns(() => [
{ type: 'selection' },
{
prop: 'packageSeries',
label: '套餐系列',
width: 120
},
{
prop: 'packageName',
label: '套餐名称',
minWidth: 180
},
{
prop: 'packageTraffic',
label: '套餐流量(GB)',
width: 120,
formatter: (row) => `${row.packageTraffic}GB`
},
{
prop: 'packageCount',
label: '套餐数量',
width: 100,
formatter: (row) => `${row.packageCount}${row.packageCountType}`
},
{
prop: 'packageType',
label: '套餐类型',
width: 100
},
{
prop: 'packageAmount',
label: '套餐金额',
width: 100,
formatter: (row) => `¥${row.packageAmount}`
},
{
prop: 'status',
label: '状态',
width: 80,
formatter: (row) => {
return h(ElTag, { type: getStatusType(row.status) }, () => row.status)
}
},
{
prop: 'levelOneMerge',
label: '一级是否合并',
width: 120,
formatter: (row) => {
return h(ElTag, { type: getMergeType(row.levelOneMerge) }, () => row.levelOneMerge)
}
},
{
prop: 'levelOneMergeCode',
label: '一级合并编号',
width: 120,
formatter: (row) => row.levelOneMergeCode || '-'
},
{
prop: 'levelTwoMerge',
label: '二级是否合并',
width: 120,
formatter: (row) => {
return h(ElTag, { type: getMergeType(row.levelTwoMerge) }, () => row.levelTwoMerge)
}
},
{
prop: 'levelTwoMergeCode',
label: '二级合并编号',
width: 120,
formatter: (row) => row.levelTwoMergeCode || '-'
},
{
prop: 'isSpecial',
label: '是否特殊',
width: 100,
formatter: (row) => {
return h(ElTag, { type: getSpecialType(row.isSpecial) }, () => row.isSpecial)
}
},
{
prop: 'creator',
label: '创建人',
width: 100
},
{
prop: 'addTime',
label: '添加时间',
width: 160
},
{
prop: 'packageCode',
label: '套餐编码',
width: 120
},
{
prop: 'operation',
label: '操作',
width: 240,
formatter: (row: any) => {
return h('div', { class: 'operation-buttons' }, [
h(ArtButtonTable, {
text: '编辑',
onClick: () => editPackage(row)
}),
h(ArtButtonTable, {
text: '下架',
onClick: () => offlinePackage(row)
}),
h(ArtButtonTable, {
text: '复制套餐',
onClick: () => copyPackage(row)
})
])
}
}
])
onMounted(() => {
getPackageList()
})
// 获取套餐列表
const getPackageList = async () => {
loading.value = true
try {
// 模拟API调用
await new Promise((resolve) => setTimeout(resolve, 500))
const startIndex = (pagination.currentPage - 1) * pagination.pageSize
const endIndex = startIndex + pagination.pageSize
const paginatedData = mockData.slice(startIndex, endIndex)
tableData.value = paginatedData
pagination.total = mockData.length
loading.value = false
} catch (error) {
console.error('获取套餐列表失败:', error)
loading.value = false
}
}
const handleRefresh = () => {
getPackageList()
}
// 处理表格行选择变化
const handleSelectionChange = (selection: any[]) => {
selectedRows.value = selection
}
// 处理表格分页变化
const handleSizeChange = (newPageSize: number) => {
pagination.pageSize = newPageSize
getPackageList()
}
const handleCurrentChange = (newCurrentPage: number) => {
pagination.currentPage = newCurrentPage
getPackageList()
}
// 处理视图切换
const handleViewChange = (view: string) => {
console.log('视图切换到:', view)
// 可以在这里添加视图切换的额外逻辑,比如保存用户偏好
}
// 表单验证规则
const rules = reactive<FormRules>({
series: [{ required: true, message: '请选择套餐系列', trigger: 'change' }],
balanceRechargeGradient: [{ required: true, message: '请选择余额充值梯度', trigger: 'change' }],
name: [
{ required: true, message: '请输入套餐名称', trigger: 'blur' },
{ min: 2, max: 50, message: '套餐名称长度在 2 到 50 个字符', trigger: 'blur' }
],
type: [{ required: true, message: '请选择套餐类型', trigger: 'change' }],
monthlyNextMonthAddon: [{ required: true, message: '请选择本月次月加餐包', trigger: 'change' }],
maxMonthlyRecharge: [{ required: true, message: '请输入本月最大充值数量', trigger: 'blur' }],
maxNextMonthRecharge: [{ required: true, message: '请输入次月最大充值数量', trigger: 'blur' }],
packageCount: [{ required: true, message: '请输入套餐数量', trigger: 'blur' }],
costPrice: [{ required: true, message: '请输入成本价', trigger: 'blur' }],
packageAmount: [{ required: true, message: '请输入套餐金额', trigger: 'blur' }]
})
// 提交表单
const handleSubmit = async () => {
if (!formRef.value) return
await formRef.value.validate((valid) => {
if (valid) {
submitLoading.value = true
// 模拟提交过程
setTimeout(() => {
ElMessage.success(
`新增套餐成功!套餐名称:${formData.name},套餐系列:${formData.series}`
)
dialogVisible.value = false
submitLoading.value = false
getPackageList()
}, 2000)
}
})
}
</script>
<style lang="scss" scoped>
.package-create-page {
// 可以添加特定样式
}
:deep(.operation-buttons) {
display: flex;
gap: 8px;
}
.dialog-footer {
text-align: right;
}
:deep(.el-form-item) {
margin-bottom: 20px;
}
</style>

View File

@@ -0,0 +1,510 @@
<template>
<ArtTableFullScreen>
<div class="package-list-page" id="table-full-screen">
<!-- 搜索栏 -->
<ArtSearchBar
v-model:filter="formFilters"
:items="formItems"
@reset="handleReset"
@search="handleSearch"
></ArtSearchBar>
<ElCard shadow="never" class="art-table-card">
<!-- 表格头部 -->
<ArtTableHeader
:columnList="columnOptions"
v-model:columns="columnChecks"
@refresh="handleRefresh"
>
<template #left>
<ElButton type="primary" @click="handleSearch">查询</ElButton>
<ElButton @click="exportExcel">导出excel</ElButton>
</template>
</ArtTableHeader>
<!-- 表格 -->
<ArtTable
ref="tableRef"
row-key="id"
:loading="loading"
:data="tableData"
:currentPage="pagination.currentPage"
:pageSize="pagination.pageSize"
:total="pagination.total"
:marginTop="10"
@selection-change="handleSelectionChange"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
>
<template #default>
<ElTableColumn v-for="col in columns" :key="col.prop || col.type" v-bind="col" />
</template>
</ArtTable>
</ElCard>
</div>
</ArtTableFullScreen>
</template>
<script setup lang="ts">
import { h } from 'vue'
import { ElTag, ElMessageBox, ElMessage } from 'element-plus'
import { useCheckedColumns } from '@/composables/useCheckedColumns'
import ArtButtonTable from '@/components/core/forms/ArtButtonTable.vue'
import { SearchChangeParams, SearchFormItem } from '@/types'
defineOptions({ name: 'PackageList' })
const loading = ref(false)
// 定义表单搜索初始值
const initialSearchState = {
agentName: '',
packageType: '',
status: '',
packageName: '',
category: '',
agentAccount: '',
packageSeries: ''
}
// 响应式表单数据
const formFilters = reactive({ ...initialSearchState })
const pagination = reactive({
currentPage: 1,
pageSize: 20,
total: 0
})
// 表格数据
const tableData = ref<any[]>([])
// 表格实例引用
const tableRef = ref()
// 选中的行数据
const selectedRows = ref<any[]>([])
// 模拟数据
const mockData = [
{
id: 1,
packageSeries: '畅玩系列',
agentName: '北京优享科技有限公司',
agentAccount: 'BJ_YXKJ_001',
packageName: '随意联畅玩年卡套餐',
packageTraffic: 120,
packageType: '年套餐',
costPrice: 120.0,
officialSuggestedPrice: 168.0,
mySalesAmount: 158.0,
sort: 1,
category: '物联网套餐',
packageCode: 'PKG_001',
status: '上架'
},
{
id: 2,
packageSeries: '如意系列',
agentName: '上海智联通信技术公司',
agentAccount: 'SH_ZLTX_002',
packageName: '如意包年3G流量包',
packageTraffic: 36,
packageType: '年套餐',
costPrice: 80.0,
officialSuggestedPrice: 98.0,
mySalesAmount: 95.0,
sort: 2,
category: '流量套餐',
packageCode: 'PKG_002',
status: '下架'
},
{
id: 3,
packageSeries: 'NB专享',
agentName: '广州物联网络有限公司',
agentAccount: 'GZ_WLWL_003',
packageName: 'Y-NB专享套餐',
packageTraffic: 24,
packageType: '月套餐',
costPrice: 20.0,
officialSuggestedPrice: 25.0,
mySalesAmount: 24.0,
sort: 3,
category: 'NB-IoT套餐',
packageCode: 'PKG_003',
status: '上架'
},
{
id: 4,
packageSeries: '大流量系列',
agentName: '深圳云联科技股份公司',
agentAccount: 'SZ_YLKJ_004',
packageName: '100G全国流量月卡套餐',
packageTraffic: 100,
packageType: '月套餐',
costPrice: 45.0,
officialSuggestedPrice: 59.0,
mySalesAmount: 55.0,
sort: 4,
category: '大流量套餐',
packageCode: 'PKG_004',
status: '上架'
},
{
id: 5,
packageSeries: '广电系列',
agentName: '杭州通达网络服务公司',
agentAccount: 'HZ_TDWL_005',
packageName: '广电飞悦卡无预存50G',
packageTraffic: 50,
packageType: '月套餐',
costPrice: 30.0,
officialSuggestedPrice: 39.0,
mySalesAmount: 36.0,
sort: 5,
category: '广电套餐',
packageCode: 'PKG_005',
status: '下架'
}
]
// 重置表单
const handleReset = () => {
Object.assign(formFilters, { ...initialSearchState })
pagination.currentPage = 1
getPackageList()
}
// 搜索处理
const handleSearch = () => {
console.log('搜索参数:', formFilters)
pagination.currentPage = 1
getPackageList()
}
// 表单项变更处理
const handleFormChange = (params: SearchChangeParams): void => {
console.log('表单项变更:', params)
}
// 表单配置项
const formItems: SearchFormItem[] = [
{
label: '代理商名称',
prop: 'agentName',
type: 'input',
config: {
clearable: true,
placeholder: '请输入代理商名称'
},
onChange: handleFormChange
},
{
label: '套餐类型',
prop: 'packageType',
type: 'select',
config: {
clearable: true,
placeholder: '全部'
},
options: () => [
{ label: '月套餐', value: 'monthly' },
{ label: '年套餐', value: 'yearly' },
{ label: '流量包', value: 'data_package' },
{ label: '季度套餐', value: 'quarterly' }
],
onChange: handleFormChange
},
{
label: '状态',
prop: 'status',
type: 'select',
config: {
clearable: true,
placeholder: '全部'
},
options: () => [
{ label: '上架', value: 'online' },
{ label: '下架', value: 'offline' }
],
onChange: handleFormChange
},
{
label: '套餐名称',
prop: 'packageName',
type: 'input',
config: {
clearable: true,
placeholder: '请输入套餐名称'
},
onChange: handleFormChange
},
{
label: '类别',
prop: 'category',
type: 'select',
config: {
clearable: true,
placeholder: '全部'
},
options: () => [
{ label: '物联网套餐', value: 'iot_package' },
{ label: '流量套餐', value: 'data_package' },
{ label: 'NB-IoT套餐', value: 'nb_iot_package' },
{ label: '大流量套餐', value: 'big_data_package' },
{ label: '广电套餐', value: 'gdtv_package' }
],
onChange: handleFormChange
},
{
label: '代理商账号',
prop: 'agentAccount',
type: 'input',
config: {
clearable: true,
placeholder: '请输入代理商账号'
},
onChange: handleFormChange
},
{
label: '套餐系列',
prop: 'packageSeries',
type: 'input',
config: {
clearable: true,
placeholder: '请输入套餐系列'
},
onChange: handleFormChange
}
]
// 列配置
const columnOptions = [
{ label: '勾选', type: 'selection' },
{ label: '套餐系列', prop: 'packageSeries' },
{ label: '代理商名称', prop: 'agentName' },
{ label: '代理商账号', prop: 'agentAccount' },
{ label: '套餐名称', prop: 'packageName' },
{ label: '套餐流量(GB)', prop: 'packageTraffic' },
{ label: '套餐类型', prop: 'packageType' },
{ label: '套餐成本价', prop: 'costPrice' },
{ label: '官方建议销售价格', prop: 'officialSuggestedPrice' },
{ label: '我的销售金额', prop: 'mySalesAmount' },
{ label: '排序', prop: 'sort' },
{ label: '所属类别', prop: 'category' },
{ label: '套餐编码', prop: 'packageCode' },
{ label: '状态', prop: 'status' },
{ label: '操作', prop: 'operation' }
]
// 获取状态标签类型
const getStatusType = (status: string) => {
switch (status) {
case '上架':
return 'success'
case '下架':
return 'danger'
default:
return 'info'
}
}
// 导出Excel
const exportExcel = () => {
if (selectedRows.value.length === 0) {
ElMessage.warning('请先选择要导出的数据')
return
}
ElMessage.success(`导出 ${selectedRows.value.length} 条套餐记录`)
}
// 编辑套餐
const editPackage = (row: any) => {
ElMessage.info(`编辑套餐: ${row.packageName}`)
}
// 上架套餐
const onlinePackage = (row: any) => {
if (row.status === '上架') {
ElMessage.warning('该套餐已经上架')
return
}
ElMessageBox.confirm(`确定要上架套餐"${row.packageName}"吗?`, '上架确认', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'info'
})
.then(() => {
ElMessage.success('套餐上架成功')
getPackageList()
})
.catch(() => {
ElMessage.info('已取消上架')
})
}
// 下架套餐
const offlinePackage = (row: any) => {
if (row.status === '下架') {
ElMessage.warning('该套餐已经下架')
return
}
ElMessageBox.confirm(`确定要下架套餐"${row.packageName}"吗?`, '下架确认', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
.then(() => {
ElMessage.success('套餐下架成功')
getPackageList()
})
.catch(() => {
ElMessage.info('已取消下架')
})
}
// 动态列配置
const { columnChecks, columns } = useCheckedColumns(() => [
{ type: 'selection' },
{
prop: 'packageSeries',
label: '套餐系列',
width: 120
},
{
prop: 'agentName',
label: '代理商名称',
minWidth: 200
},
{
prop: 'agentAccount',
label: '代理商账号',
width: 140
},
{
prop: 'packageName',
label: '套餐名称',
minWidth: 180
},
{
prop: 'packageTraffic',
label: '套餐流量(GB)',
width: 120,
formatter: (row) => `${row.packageTraffic}GB`
},
{
prop: 'packageType',
label: '套餐类型',
width: 100
},
{
prop: 'costPrice',
label: '套餐成本价',
width: 110,
formatter: (row) => `¥${row.costPrice.toFixed(2)}`
},
{
prop: 'officialSuggestedPrice',
label: '官方建议销售价格',
width: 150,
formatter: (row) => `¥${row.officialSuggestedPrice.toFixed(2)}`
},
{
prop: 'mySalesAmount',
label: '我的销售金额',
width: 120,
formatter: (row) => `¥${row.mySalesAmount.toFixed(2)}`
},
{
prop: 'sort',
label: '排序',
width: 80
},
{
prop: 'category',
label: '所属类别',
width: 120
},
{
prop: 'packageCode',
label: '套餐编码',
width: 120
},
{
prop: 'status',
label: '状态',
width: 80,
formatter: (row) => {
return h(ElTag, { type: getStatusType(row.status) }, () => row.status)
}
},
{
prop: 'operation',
label: '操作',
width: 150,
formatter: (row: any) => {
return h('div', { class: 'operation-buttons' }, [
h(ArtButtonTable, {
text: '编辑',
onClick: () => editPackage(row)
}),
h(ArtButtonTable, {
text: row.status === '上架' ? '下架' : '上架',
onClick: () => (row.status === '上架' ? offlinePackage(row) : onlinePackage(row))
})
])
}
}
])
onMounted(() => {
getPackageList()
})
// 获取套餐列表
const getPackageList = async () => {
loading.value = true
try {
// 模拟API调用
await new Promise((resolve) => setTimeout(resolve, 500))
const startIndex = (pagination.currentPage - 1) * pagination.pageSize
const endIndex = startIndex + pagination.pageSize
const paginatedData = mockData.slice(startIndex, endIndex)
tableData.value = paginatedData
pagination.total = mockData.length
loading.value = false
} catch (error) {
console.error('获取套餐列表失败:', error)
loading.value = false
}
}
const handleRefresh = () => {
getPackageList()
}
// 处理表格行选择变化
const handleSelectionChange = (selection: any[]) => {
selectedRows.value = selection
}
// 处理表格分页变化
const handleSizeChange = (newPageSize: number) => {
pagination.pageSize = newPageSize
getPackageList()
}
const handleCurrentChange = (newCurrentPage: number) => {
pagination.currentPage = newCurrentPage
getPackageList()
}
</script>
<style lang="scss" scoped>
.card-template-page {
// 可以添加特定样式
}
</style>

View File

@@ -0,0 +1,462 @@
<template>
<ArtTableFullScreen>
<div class="package-series-page" id="table-full-screen">
<!-- 搜索栏 -->
<ArtSearchBar
v-model:filter="formFilters"
:items="formItems"
@reset="handleReset"
@search="handleSearch"
></ArtSearchBar>
<ElCard shadow="never" class="art-table-card">
<!-- 表格头部 -->
<ArtTableHeader
:columnList="columnOptions"
v-model:columns="columnChecks"
@refresh="handleRefresh"
>
<template #left>
<ElButton type="primary" @click="handleSearch">搜索</ElButton>
<ElButton type="success" @click="showAddDialog">新增</ElButton>
</template>
</ArtTableHeader>
<!-- 表格 -->
<ArtTable
ref="tableRef"
row-key="id"
:loading="loading"
:data="tableData"
:currentPage="pagination.currentPage"
:pageSize="pagination.pageSize"
:total="pagination.total"
:marginTop="10"
@selection-change="handleSelectionChange"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
>
<template #default>
<ElTableColumn v-for="col in columns" :key="col.prop || col.type" v-bind="col" />
</template>
</ArtTable>
<!-- 新增套餐系列对话框 -->
<ElDialog
v-model="addDialogVisible"
title="新增套餐系列"
width="500px"
align-center
:close-on-click-modal="false"
>
<ElForm ref="addFormRef" :model="addFormData" :rules="addRules" label-width="120px">
<ElFormItem label="系列名称" prop="seriesName">
<ElInput v-model="addFormData.seriesName" placeholder="请输入系列名称" clearable />
</ElFormItem>
<ElFormItem label="包含套餐" prop="packageNames">
<ElSelect
v-model="addFormData.packageNames"
placeholder="请选择要包含的套餐"
style="width: 100%"
multiple
clearable
>
<ElOption label="随意联畅玩年卡套餐" value="changwan_yearly" />
<ElOption label="随意联畅玩月卡套餐" value="changwan_monthly" />
<ElOption label="如意包年3G流量包" value="ruyi_3g" />
<ElOption label="如意包月流量包" value="ruyi_monthly" />
<ElOption label="Y-NB专享套餐" value="nb_special" />
<ElOption label="NB-IoT基础套餐" value="nb_basic" />
<ElOption label="100G全国流量月卡套餐" value="big_data_100g" />
<ElOption label="200G超值流量包" value="big_data_200g" />
<ElOption label="广电飞悦卡无预存50G" value="gdtv_50g" />
<ElOption label="广电天翼卡" value="gdtv_tianyi" />
</ElSelect>
</ElFormItem>
<ElFormItem label="系列描述" prop="description">
<ElInput
v-model="addFormData.description"
type="textarea"
placeholder="请输入系列描述(可选)"
:rows="3"
maxlength="200"
show-word-limit
/>
</ElFormItem>
</ElForm>
<template #footer>
<div class="dialog-footer">
<ElButton @click="addDialogVisible = false">取消</ElButton>
<ElButton type="primary" @click="handleAddSubmit" :loading="addLoading">
确认新增
</ElButton>
</div>
</template>
</ElDialog>
</ElCard>
</div>
</ArtTableFullScreen>
</template>
<script setup lang="ts">
import { h } from 'vue'
import { ElTag, ElMessage, ElMessageBox } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import { useCheckedColumns } from '@/composables/useCheckedColumns'
import ArtButtonTable from '@/components/core/forms/ArtButtonTable.vue'
import { SearchChangeParams, SearchFormItem } from '@/types'
defineOptions({ name: 'PackageSeries' })
const addDialogVisible = ref(false)
const loading = ref(false)
const addLoading = ref(false)
// 定义表单搜索初始值
const initialSearchState = {
seriesName: ''
}
// 响应式表单数据
const formFilters = reactive({ ...initialSearchState })
const pagination = reactive({
currentPage: 1,
pageSize: 20,
total: 0
})
// 表格数据
const tableData = ref<any[]>([])
// 表格实例引用
const tableRef = ref()
// 选中的行数据
const selectedRows = ref<any[]>([])
// 新增表单实例
const addFormRef = ref<FormInstance>()
// 新增表单数据
const addFormData = reactive({
seriesName: '',
packageNames: [] as string[],
description: ''
})
// 模拟数据
const mockData = [
{
id: 1,
seriesName: '畅玩系列',
operator: '张若暄',
operationTime: '2025-11-08 10:30:00',
status: '启用'
},
{
id: 2,
seriesName: '如意系列',
operator: '孔丽娟',
operationTime: '2025-11-07 14:15:00',
status: '启用'
},
{
id: 3,
seriesName: 'NB专享',
operator: '李佳音',
operationTime: '2025-11-06 09:45:00',
status: '禁用'
},
{
id: 4,
seriesName: '大流量系列',
operator: '赵强',
operationTime: '2025-11-05 16:20:00',
status: '启用'
},
{
id: 5,
seriesName: '广电系列',
operator: '张若暄',
operationTime: '2025-11-04 11:30:00',
status: '禁用'
}
]
// 重置表单
const handleReset = () => {
Object.assign(formFilters, { ...initialSearchState })
pagination.currentPage = 1
getPackageSeriesList()
}
// 搜索处理
const handleSearch = () => {
console.log('搜索参数:', formFilters)
pagination.currentPage = 1
getPackageSeriesList()
}
// 表单项变更处理
const handleFormChange = (params: SearchChangeParams): void => {
console.log('表单项变更:', params)
}
// 表单配置项
const formItems: SearchFormItem[] = [
{
label: '系列名称',
prop: 'seriesName',
type: 'input',
config: {
clearable: true,
placeholder: '请输入系列名称'
},
onChange: handleFormChange
}
]
// 列配置
const columnOptions = [
{ label: '勾选', type: 'selection' },
{ label: '系列名称', prop: 'seriesName' },
{ label: '操作人', prop: 'operator' },
{ label: '操作时间', prop: 'operationTime' },
{ label: '状态', prop: 'status' },
{ label: '操作', prop: 'operation' }
]
// 获取状态标签类型
const getStatusType = (status: string) => {
switch (status) {
case '启用':
return 'success'
case '禁用':
return 'danger'
default:
return 'info'
}
}
// 显示新增对话框
const showAddDialog = () => {
addDialogVisible.value = true
// 重置表单
if (addFormRef.value) {
addFormRef.value.resetFields()
}
addFormData.seriesName = ''
addFormData.packageNames = []
addFormData.description = ''
}
// 启用系列
const enableSeries = (row: any) => {
ElMessageBox.confirm(`确定要启用套餐系列"${row.seriesName}"吗?`, '启用确认', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'info'
})
.then(() => {
ElMessage.success('启用成功')
getPackageSeriesList()
})
.catch(() => {
ElMessage.info('已取消启用')
})
}
// 禁用系列
const disableSeries = (row: any) => {
ElMessageBox.confirm(`确定要禁用套餐系列"${row.seriesName}"吗?`, '禁用确认', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
.then(() => {
ElMessage.success('禁用成功')
getPackageSeriesList()
})
.catch(() => {
ElMessage.info('已取消禁用')
})
}
// 删除系列
const deleteSeries = (row: any) => {
ElMessageBox.confirm(
`确定要删除套餐系列"${row.seriesName}"吗?删除后将无法恢复。`,
'删除确认',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'error'
}
)
.then(() => {
ElMessage.success('删除成功')
getPackageSeriesList()
})
.catch(() => {
ElMessage.info('已取消删除')
})
}
// 动态列配置
const { columnChecks, columns } = useCheckedColumns(() => [
{ type: 'selection' },
{
prop: 'seriesName',
label: '系列名称',
minWidth: 180
},
{
prop: 'operator',
label: '操作人',
width: 120
},
{
prop: 'operationTime',
label: '操作时间',
width: 160
},
{
prop: 'status',
label: '状态',
width: 100,
formatter: (row) => {
return h(ElTag, { type: getStatusType(row.status) }, () => row.status)
}
},
{
prop: 'operation',
label: '操作',
width: 180,
formatter: (row: any) => {
const buttons = []
if (row.status === '启用') {
buttons.push(
h(ArtButtonTable, {
text: '禁用',
onClick: () => disableSeries(row)
})
)
} else {
buttons.push(
h(ArtButtonTable, {
text: '启用',
onClick: () => enableSeries(row)
})
)
}
buttons.push(
h(ArtButtonTable, {
text: '删除',
onClick: () => deleteSeries(row)
})
)
return h('div', { class: 'operation-buttons' }, buttons)
}
}
])
onMounted(() => {
getPackageSeriesList()
})
// 获取套餐系列列表
const getPackageSeriesList = async () => {
loading.value = true
try {
// 模拟API调用
await new Promise((resolve) => setTimeout(resolve, 500))
const startIndex = (pagination.currentPage - 1) * pagination.pageSize
const endIndex = startIndex + pagination.pageSize
const paginatedData = mockData.slice(startIndex, endIndex)
tableData.value = paginatedData
pagination.total = mockData.length
loading.value = false
} catch (error) {
console.error('获取套餐系列列表失败:', error)
loading.value = false
}
}
const handleRefresh = () => {
getPackageSeriesList()
}
// 处理表格行选择变化
const handleSelectionChange = (selection: any[]) => {
selectedRows.value = selection
}
// 处理表格分页变化
const handleSizeChange = (newPageSize: number) => {
pagination.pageSize = newPageSize
getPackageSeriesList()
}
const handleCurrentChange = (newCurrentPage: number) => {
pagination.currentPage = newCurrentPage
getPackageSeriesList()
}
// 新增表单验证规则
const addRules = reactive<FormRules>({
seriesName: [
{ required: true, message: '请输入系列名称', trigger: 'blur' },
{ min: 2, max: 20, message: '系列名称长度在 2 到 20 个字符', trigger: 'blur' }
],
packageNames: [{ required: true, message: '请选择要包含的套餐', trigger: 'change' }]
})
// 提交新增
const handleAddSubmit = async () => {
if (!addFormRef.value) return
await addFormRef.value.validate((valid) => {
if (valid) {
addLoading.value = true
// 模拟新增过程
setTimeout(() => {
ElMessage.success(
`新增套餐系列成功!系列名称:${addFormData.seriesName},包含套餐:${addFormData.packageNames.length}`
)
addDialogVisible.value = false
addLoading.value = false
getPackageSeriesList()
}, 2000)
}
})
}
</script>
<style lang="scss" scoped>
.package-series-page {
// 可以添加特定样式
}
:deep(.operation-buttons) {
display: flex;
gap: 8px;
}
.dialog-footer {
text-align: right;
}
:deep(.el-form-item) {
margin-bottom: 20px;
}
</style>

View File

@@ -0,0 +1,628 @@
<template>
<ArtTableFullScreen>
<div class="shop-page" id="table-full-screen">
<!-- 搜索栏 -->
<ArtSearchBar
v-model:filter="searchForm"
:items="searchFormItems"
@reset="handleReset"
@search="handleSearch"
></ArtSearchBar>
<ElCard shadow="never" class="art-table-card">
<!-- 表格头部 -->
<ArtTableHeader
:columnList="columnOptions"
v-model:columns="columnChecks"
@refresh="handleRefresh"
>
<template #left>
<ElButton @click="showDialog('add')">新增店铺</ElButton>
</template>
</ArtTableHeader>
<!-- 表格 -->
<ArtTable
ref="tableRef"
row-key="id"
:loading="loading"
:data="tableData"
:currentPage="pagination.currentPage"
:pageSize="pagination.pageSize"
:total="pagination.total"
:marginTop="10"
@selection-change="handleSelectionChange"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
>
<template #default>
<ElTableColumn v-for="col in columns" :key="col.prop || col.type" v-bind="col" />
</template>
</ArtTable>
<!-- 新增/编辑对话框 -->
<ElDialog
v-model="dialogVisible"
:title="dialogType === 'add' ? '新增店铺' : '编辑店铺'"
width="800px"
>
<ElForm ref="formRef" :model="formData" :rules="rules" label-width="100px">
<ElRow :gutter="20">
<ElCol :span="12">
<ElFormItem label="店铺名称" prop="shop_name">
<ElInput v-model="formData.shop_name" placeholder="请输入店铺名称" />
</ElFormItem>
</ElCol>
<ElCol :span="12" v-if="dialogType === 'add'">
<ElFormItem label="店铺编号" prop="shop_code">
<ElInput v-model="formData.shop_code" placeholder="请输入店铺编号" />
</ElFormItem>
</ElCol>
</ElRow>
<ElRow :gutter="20" v-if="dialogType === 'add'">
<ElCol :span="12">
<ElFormItem label="上级店铺ID" prop="parent_id">
<ElInputNumber v-model="formData.parent_id" :min="1" placeholder="一级店铺可不填" style="width: 100%" clearable />
</ElFormItem>
</ElCol>
</ElRow>
<ElRow :gutter="20">
<ElCol :span="12">
<ElFormItem label="省份" prop="province">
<ElInput v-model="formData.province" placeholder="请输入省份" />
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="城市" prop="city">
<ElInput v-model="formData.city" placeholder="请输入城市" />
</ElFormItem>
</ElCol>
</ElRow>
<ElRow :gutter="20">
<ElCol :span="12">
<ElFormItem label="区县" prop="district">
<ElInput v-model="formData.district" placeholder="请输入区县" />
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="详细地址" prop="address">
<ElInput v-model="formData.address" placeholder="请输入详细地址" />
</ElFormItem>
</ElCol>
</ElRow>
<ElRow :gutter="20">
<ElCol :span="12">
<ElFormItem label="联系人" prop="contact_name">
<ElInput v-model="formData.contact_name" placeholder="请输入联系人姓名" />
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="联系电话" prop="contact_phone">
<ElInput v-model="formData.contact_phone" placeholder="请输入联系电话" maxlength="11" />
</ElFormItem>
</ElCol>
</ElRow>
<!-- 新增店铺时的初始账号信息 -->
<template v-if="dialogType === 'add'">
<ElDivider content-position="left">初始账号信息</ElDivider>
<ElRow :gutter="20">
<ElCol :span="12">
<ElFormItem label="用户名" prop="init_username">
<ElInput v-model="formData.init_username" placeholder="请输入初始账号用户名" />
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="密码" prop="init_password">
<ElInput v-model="formData.init_password" type="password" placeholder="请输入初始账号密码" show-password />
</ElFormItem>
</ElCol>
</ElRow>
<ElRow :gutter="20">
<ElCol :span="12">
<ElFormItem label="手机号" prop="init_phone">
<ElInput v-model="formData.init_phone" placeholder="请输入初始账号手机号" maxlength="11" />
</ElFormItem>
</ElCol>
</ElRow>
</template>
<ElFormItem v-if="dialogType === 'edit'" label="状态">
<ElSwitch
v-model="formData.status"
:active-value="CommonStatus.ENABLED"
:inactive-value="CommonStatus.DISABLED"
/>
</ElFormItem>
</ElForm>
<template #footer>
<div class="dialog-footer">
<ElButton @click="dialogVisible = false">取消</ElButton>
<ElButton type="primary" @click="handleSubmit" :loading="submitLoading">提交</ElButton>
</div>
</template>
</ElDialog>
</ElCard>
</div>
</ArtTableFullScreen>
</template>
<script setup lang="ts">
import { h } from 'vue'
import { FormInstance, ElMessage, ElMessageBox, ElTag, ElSwitch } from 'element-plus'
import type { FormRules } from 'element-plus'
import { useCheckedColumns } from '@/composables/useCheckedColumns'
import ArtButtonTable from '@/components/core/forms/ArtButtonTable.vue'
import { ShopService } from '@/api/modules'
import type { SearchFormItem } from '@/types'
import type { ShopResponse } from '@/types/api'
import { formatDateTime } from '@/utils/business/format'
import { CommonStatus, getStatusText, STATUS_SELECT_OPTIONS } from '@/config/constants'
defineOptions({ name: 'Shop' })
const dialogType = ref('add')
const dialogVisible = ref(false)
const loading = ref(false)
const submitLoading = ref(false)
// 定义表单搜索初始值
const initialSearchState = {
shop_name: '',
shop_code: '',
parent_id: undefined as number | undefined,
level: undefined as number | undefined,
status: undefined as number | undefined
}
// 响应式表单数据
const searchForm = reactive({ ...initialSearchState })
const pagination = reactive({
currentPage: 1,
pageSize: 20,
total: 0
})
// 表格数据
const tableData = ref<ShopResponse[]>([])
// 表格实例引用
const tableRef = ref()
// 选中的行数据
const selectedRows = ref<any[]>([])
// 重置表单
const handleReset = () => {
Object.assign(searchForm, { ...initialSearchState })
pagination.currentPage = 1
getShopList()
}
// 搜索处理
const handleSearch = () => {
pagination.currentPage = 1
getShopList()
}
// 表单配置项
const searchFormItems: SearchFormItem[] = [
{
label: '店铺名称',
prop: 'shop_name',
type: 'input',
config: {
clearable: true,
placeholder: '请输入店铺名称'
}
},
{
label: '店铺编号',
prop: 'shop_code',
type: 'input',
config: {
clearable: true,
placeholder: '请输入店铺编号'
}
},
{
label: '上级ID',
prop: 'parent_id',
type: 'input',
config: {
clearable: true,
placeholder: '请输入上级店铺ID'
}
},
{
label: '店铺层级',
prop: 'level',
type: 'select',
options: [
{ label: '1级', value: 1 },
{ label: '2级', value: 2 },
{ label: '3级', value: 3 },
{ label: '4级', value: 4 },
{ label: '5级', value: 5 },
{ label: '6级', value: 6 },
{ label: '7级', value: 7 }
],
config: {
clearable: true,
placeholder: '请选择店铺层级'
}
},
{
label: '状态',
prop: 'status',
type: 'select',
options: STATUS_SELECT_OPTIONS,
config: {
clearable: true,
placeholder: '请选择状态'
}
}
]
// 列配置
const columnOptions = [
{ label: 'ID', prop: 'id' },
{ label: '店铺名称', prop: 'shop_name' },
{ label: '店铺编号', prop: 'shop_code' },
{ label: '层级', prop: 'level' },
{ label: '上级ID', prop: 'parent_id' },
{ label: '所在地区', prop: 'region' },
{ label: '联系人', prop: 'contact_name' },
{ label: '联系电话', prop: 'contact_phone' },
{ label: '状态', prop: 'status' },
{ label: '创建时间', prop: 'created_at' },
{ label: '操作', prop: 'operation' }
]
// 显示对话框
const showDialog = (type: string, row?: ShopResponse) => {
dialogVisible.value = true
dialogType.value = type
// 重置表单验证状态
if (formRef.value) {
formRef.value.resetFields()
}
if (type === 'edit' && row) {
formData.id = row.id
formData.shop_name = row.shop_name
formData.shop_code = ''
formData.parent_id = null
formData.province = row.province || ''
formData.city = row.city || ''
formData.district = row.district || ''
formData.address = row.address || ''
formData.contact_name = row.contact_name || ''
formData.contact_phone = row.contact_phone || ''
formData.status = row.status
formData.init_username = ''
formData.init_password = ''
formData.init_phone = ''
} else {
formData.id = 0
formData.shop_name = ''
formData.shop_code = ''
formData.parent_id = null
formData.province = ''
formData.city = ''
formData.district = ''
formData.address = ''
formData.contact_name = ''
formData.contact_phone = ''
formData.status = CommonStatus.ENABLED
formData.init_username = ''
formData.init_password = ''
formData.init_phone = ''
}
}
// 删除店铺
const deleteShop = (row: ShopResponse) => {
ElMessageBox.confirm(`确定要删除店铺 ${row.shop_name} 吗?`, '删除店铺', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'error'
})
.then(async () => {
try {
await ShopService.deleteShop(row.id)
ElMessage.success('删除成功')
getShopList()
} catch (error) {
console.error(error)
}
})
.catch(() => {
// 用户取消删除
})
}
// 动态列配置
const { columnChecks, columns } = useCheckedColumns(() => [
{
prop: 'id',
label: 'ID',
width: 80
},
{
prop: 'shop_name',
label: '店铺名称',
minWidth: 150
},
{
prop: 'shop_code',
label: '店铺编号',
width: 120
},
{
prop: 'level',
label: '层级',
width: 80,
formatter: (row: ShopResponse) => {
return h(ElTag, { type: 'info', size: 'small' }, () => `${row.level}`)
}
},
{
prop: 'parent_id',
label: '上级ID',
width: 100,
formatter: (row: ShopResponse) => row.parent_id || '-'
},
{
prop: 'region',
label: '所在地区',
minWidth: 180,
formatter: (row: ShopResponse) => {
const parts: string[] = []
if (row.province) parts.push(row.province)
if (row.city) parts.push(row.city)
if (row.district) parts.push(row.district)
return parts.length > 0 ? parts.join(' / ') : '-'
}
},
{
prop: 'contact_name',
label: '联系人',
width: 100
},
{
prop: 'contact_phone',
label: '联系电话',
width: 130
},
{
prop: 'status',
label: '状态',
width: 100,
formatter: (row: ShopResponse) => {
return h(ElSwitch, {
modelValue: row.status,
activeValue: CommonStatus.ENABLED,
inactiveValue: CommonStatus.DISABLED,
activeText: getStatusText(CommonStatus.ENABLED),
inactiveText: getStatusText(CommonStatus.DISABLED),
inlinePrompt: true,
'onUpdate:modelValue': (val: number) => handleStatusChange(row, val)
})
}
},
{
prop: 'created_at',
label: '创建时间',
width: 180,
formatter: (row: ShopResponse) => formatDateTime(row.created_at)
},
{
prop: 'operation',
label: '操作',
width: 150,
fixed: 'right',
formatter: (row: ShopResponse) => {
return h('div', { style: 'display: flex; gap: 8px;' }, [
h(ArtButtonTable, {
type: 'edit',
onClick: () => showDialog('edit', row)
}),
h(ArtButtonTable, {
type: 'delete',
onClick: () => deleteShop(row)
})
])
}
}
])
// 表单实例
const formRef = ref<FormInstance>()
// 表单数据
const formData = reactive({
id: 0,
shop_name: '',
shop_code: '',
parent_id: null as number | null,
province: '',
city: '',
district: '',
address: '',
contact_name: '',
contact_phone: '',
status: CommonStatus.ENABLED,
init_username: '',
init_password: '',
init_phone: ''
})
onMounted(() => {
getShopList()
})
// 获取店铺列表
const getShopList = async () => {
loading.value = true
try {
const params = {
page: pagination.currentPage,
page_size: pagination.pageSize,
shop_name: searchForm.shop_name || undefined,
shop_code: searchForm.shop_code || undefined,
parent_id: searchForm.parent_id,
level: searchForm.level,
status: searchForm.status
}
const res = await ShopService.getShops(params)
if (res.code === 0) {
tableData.value = res.data.items || []
pagination.total = res.data.total || 0
}
} catch (error) {
console.error('获取店铺列表失败:', error)
} finally {
loading.value = false
}
}
const handleRefresh = () => {
getShopList()
}
// 处理表格行选择变化
const handleSelectionChange = (selection: any[]) => {
selectedRows.value = selection
}
// 表单验证规则
const rules = reactive<FormRules>({
shop_name: [
{ required: true, message: '请输入店铺名称', trigger: 'blur' },
{ min: 1, max: 100, message: '长度在 1 到 100 个字符', trigger: 'blur' }
],
shop_code: [
{ required: true, message: '请输入店铺编号', trigger: 'blur' },
{ min: 1, max: 50, message: '长度在 1 到 50 个字符', trigger: 'blur' }
],
address: [
{ max: 255, message: '地址不能超过255个字符', trigger: 'blur' }
],
contact_name: [
{ max: 50, message: '联系人姓名不能超过50个字符', trigger: 'blur' }
],
contact_phone: [
{ len: 11, message: '联系电话必须为 11 位', trigger: 'blur' },
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号格式', trigger: 'blur' }
],
init_username: [
{ required: true, message: '请输入初始账号用户名', trigger: 'blur' },
{ min: 3, max: 50, message: '长度在 3 到 50 个字符', trigger: 'blur' }
],
init_password: [
{ required: true, message: '请输入初始账号密码', trigger: 'blur' },
{ min: 8, max: 32, message: '密码长度为 8-32 个字符', trigger: 'blur' }
],
init_phone: [
{ required: true, message: '请输入初始账号手机号', trigger: 'blur' },
{ len: 11, message: '手机号必须为 11 位', trigger: 'blur' },
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号格式', trigger: 'blur' }
]
})
// 提交表单
const handleSubmit = async () => {
if (!formRef.value) return
await formRef.value.validate(async (valid) => {
if (valid) {
submitLoading.value = true
try {
if (dialogType.value === 'add') {
const data: any = {
shop_name: formData.shop_name,
shop_code: formData.shop_code,
init_username: formData.init_username,
init_password: formData.init_password,
init_phone: formData.init_phone
}
// 可选字段
if (formData.parent_id) data.parent_id = formData.parent_id
if (formData.province) data.province = formData.province
if (formData.city) data.city = formData.city
if (formData.district) data.district = formData.district
if (formData.address) data.address = formData.address
if (formData.contact_name) data.contact_name = formData.contact_name
if (formData.contact_phone) data.contact_phone = formData.contact_phone
await ShopService.createShop(data)
ElMessage.success('新增成功')
} else {
const data: any = {
shop_name: formData.shop_name,
status: formData.status
}
// 可选字段
if (formData.province) data.province = formData.province
if (formData.city) data.city = formData.city
if (formData.district) data.district = formData.district
if (formData.address) data.address = formData.address
if (formData.contact_name) data.contact_name = formData.contact_name
if (formData.contact_phone) data.contact_phone = formData.contact_phone
await ShopService.updateShop(formData.id, data)
ElMessage.success('更新成功')
}
dialogVisible.value = false
getShopList()
} catch (error) {
console.error(error)
} finally {
submitLoading.value = false
}
}
})
}
// 处理表格分页变化
const handleSizeChange = (newPageSize: number) => {
pagination.pageSize = newPageSize
getShopList()
}
const handleCurrentChange = (newCurrentPage: number) => {
pagination.currentPage = newCurrentPage
getShopList()
}
// 状态切换
const handleStatusChange = async (row: ShopResponse, newStatus: number) => {
const oldStatus = row.status
// 先更新UI
row.status = newStatus
try {
await ShopService.updateShop(row.id, { status: newStatus })
ElMessage.success('状态切换成功')
} catch (error) {
// 切换失败,恢复原状态
row.status = oldStatus
console.error(error)
}
}
</script>
<style lang="scss" scoped>
.shop-page {
// 店铺管理页面样式
}
</style>

View File

@@ -0,0 +1,430 @@
<template>
<div class="page-content">
<!-- 搜索和操作区 -->
<ElRow :gutter="12">
<ElCol :xs="24" :sm="12" :lg="6">
<ElInput v-model="searchQuery" placeholder="产品名称/ICCID" clearable />
</ElCol>
<ElCol :xs="24" :sm="12" :lg="6">
<ElSelect v-model="operatorFilter" placeholder="运营商筛选" clearable style="width: 100%">
<ElOption label="全部" value="" />
<ElOption label="中国移动" value="CMCC" />
<ElOption label="中国联通" value="CUCC" />
<ElOption label="中国电信" value="CTCC" />
</ElSelect>
</ElCol>
<ElCol :xs="24" :sm="12" :lg="6">
<ElSelect v-model="statusFilter" placeholder="分配状态" clearable style="width: 100%">
<ElOption label="全部" value="" />
<ElOption label="已分配" value="assigned" />
<ElOption label="未分配" value="unassigned" />
</ElSelect>
</ElCol>
<ElCol :xs="24" :sm="12" :lg="6" class="el-col2">
<ElButton v-ripple @click="handleSearch">搜索</ElButton>
<ElButton v-ripple type="primary" @click="showAssignDialog">批量分配</ElButton>
</ElCol>
</ElRow>
<!-- 号卡产品列表 -->
<ArtTable :data="filteredData" index style="margin-top: 20px" @selection-change="handleSelectionChange">
<template #default>
<ElTableColumn type="selection" width="55" />
<ElTableColumn label="产品名称" prop="productName" min-width="180" show-overflow-tooltip />
<ElTableColumn label="运营商" prop="operator" width="100">
<template #default="scope">
<ElTag :type="getOperatorTagType(scope.row.operator)">
{{ getOperatorText(scope.row.operator) }}
</ElTag>
</template>
</ElTableColumn>
<ElTableColumn label="套餐规格" prop="packageSpec" min-width="150" />
<ElTableColumn label="产品价格" prop="price" width="120">
<template #default="scope"> ¥{{ scope.row.price.toFixed(2) }} </template>
</ElTableColumn>
<ElTableColumn label="库存数量" prop="stock" width="100" align="center">
<template #default="scope">
<span :style="{ color: scope.row.stock < 100 ? 'var(--el-color-danger)' : '' }">
{{ scope.row.stock }}
</span>
</template>
</ElTableColumn>
<ElTableColumn label="已分配数量" prop="assignedCount" width="120" align="center">
<template #default="scope">
<span style="color: var(--el-color-primary)">{{ scope.row.assignedCount }}</span>
</template>
</ElTableColumn>
<ElTableColumn label="分配状态" prop="assignStatus" width="100">
<template #default="scope">
<ElTag v-if="scope.row.assignedCount > 0" type="success">已分配</ElTag>
<ElTag v-else type="info">未分配</ElTag>
</template>
</ElTableColumn>
<ElTableColumn fixed="right" label="操作" width="200">
<template #default="scope">
<el-button link :icon="View" @click="viewAssignDetail(scope.row)">分配记录</el-button>
<el-button link type="primary" @click="assignToAgent(scope.row)">分配</el-button>
</template>
</ElTableColumn>
</template>
</ArtTable>
<!-- 分配对话框 -->
<ElDialog v-model="assignDialogVisible" title="分配号卡产品" width="600px" align-center>
<ElForm ref="formRef" :model="assignForm" :rules="assignRules" label-width="120px">
<ElFormItem label="选择代理商" prop="agentId">
<ElSelect
v-model="assignForm.agentId"
placeholder="请选择代理商"
filterable
style="width: 100%"
@change="handleAgentChange"
>
<ElOption
v-for="agent in agentList"
:key="agent.id"
:label="`${agent.agentName} (${agent.phone})`"
:value="agent.id"
/>
</ElSelect>
</ElFormItem>
<ElFormItem label="分配数量" prop="quantity">
<ElInputNumber
v-model="assignForm.quantity"
:min="1"
:max="currentProduct.stock"
style="width: 100%"
/>
<div style="color: var(--el-text-color-secondary); font-size: 12px; margin-top: 4px">
当前库存{{ currentProduct.stock }}
</div>
</ElFormItem>
<ElFormItem label="分佣模式" prop="commissionMode">
<ElRadioGroup v-model="assignForm.commissionMode">
<ElRadio value="fixed">固定佣金</ElRadio>
<ElRadio value="percent">比例佣金</ElRadio>
<ElRadio value="template">使用模板</ElRadio>
</ElRadioGroup>
</ElFormItem>
<ElFormItem v-if="assignForm.commissionMode === 'fixed'" label="固定金额" prop="fixedAmount">
<ElInputNumber v-model="assignForm.fixedAmount" :min="0" :precision="2" style="width: 100%" />
<span style="margin-left: 8px">/</span>
</ElFormItem>
<ElFormItem v-if="assignForm.commissionMode === 'percent'" label="佣金比例" prop="percent">
<ElInputNumber v-model="assignForm.percent" :min="0" :max="100" :precision="2" style="width: 100%" />
<span style="margin-left: 8px">%</span>
</ElFormItem>
<ElFormItem v-if="assignForm.commissionMode === 'template'" label="分佣模板" prop="templateId">
<ElSelect v-model="assignForm.templateId" placeholder="请选择分佣模板" style="width: 100%">
<ElOption
v-for="template in commissionTemplates"
:key="template.id"
:label="template.templateName"
:value="template.id"
>
<span>{{ template.templateName }}</span>
<span style="float: right; color: var(--el-text-color-secondary); font-size: 12px">
{{ template.mode === 'fixed' ? `¥${template.value}元/张` : `${template.value}%` }}
</span>
</ElOption>
</ElSelect>
</ElFormItem>
<ElFormItem label="特殊折扣" prop="discount">
<ElInputNumber v-model="assignForm.discount" :min="0" :max="100" :precision="2" style="width: 100%" />
<span style="margin-left: 8px">%</span>
<div style="color: var(--el-text-color-secondary); font-size: 12px; margin-top: 4px">
0表示无折扣设置后代理商可以此折扣价格销售
</div>
</ElFormItem>
<ElFormItem label="备注" prop="remark">
<ElInput v-model="assignForm.remark" type="textarea" :rows="3" placeholder="请输入备注信息" />
</ElFormItem>
</ElForm>
<template #footer>
<div class="dialog-footer">
<ElButton @click="assignDialogVisible = false">取消</ElButton>
<ElButton type="primary" @click="handleAssignSubmit">确认分配</ElButton>
</div>
</template>
</ElDialog>
<!-- 分配记录对话框 -->
<ElDialog v-model="detailDialogVisible" title="分配记录" width="900px" align-center>
<ArtTable :data="assignRecords" index>
<template #default>
<ElTableColumn label="代理商名称" prop="agentName" min-width="150" />
<ElTableColumn label="分配数量" prop="quantity" width="100" align="center" />
<ElTableColumn label="分佣模式" prop="commissionMode" width="120">
<template #default="scope">
<ElTag v-if="scope.row.commissionMode === 'fixed'" type="warning">固定佣金</ElTag>
<ElTag v-else-if="scope.row.commissionMode === 'percent'" type="success">比例佣金</ElTag>
<ElTag v-else>模板佣金</ElTag>
</template>
</ElTableColumn>
<ElTableColumn label="佣金规则" prop="commissionRule" width="150" />
<ElTableColumn label="特殊折扣" prop="discount" width="100">
<template #default="scope">
{{ scope.row.discount > 0 ? `${scope.row.discount}%` : '无' }}
</template>
</ElTableColumn>
<ElTableColumn label="分配时间" prop="assignTime" width="180" />
<ElTableColumn label="操作人" prop="operator" width="100" />
<ElTableColumn fixed="right" label="操作" width="120">
<template #default="scope">
<el-button link type="danger" @click="handleCancelAssign(scope.row)">取消分配</el-button>
</template>
</ElTableColumn>
</template>
</ArtTable>
<template #footer>
<ElButton @click="detailDialogVisible = false">关闭</ElButton>
</template>
</ElDialog>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { View } from '@element-plus/icons-vue'
import type { FormInstance, FormRules } from 'element-plus'
defineOptions({ name: 'SimCardAssign' })
interface SimCardProduct {
id: string
productName: string
operator: string
packageSpec: string
price: number
stock: number
assignedCount: number
}
const searchQuery = ref('')
const operatorFilter = ref('')
const statusFilter = ref('')
const assignDialogVisible = ref(false)
const detailDialogVisible = ref(false)
const formRef = ref<FormInstance>()
const selectedRows = ref<SimCardProduct[]>([])
const currentProduct = ref<SimCardProduct>({
id: '',
productName: '',
operator: '',
packageSpec: '',
price: 0,
stock: 0,
assignedCount: 0
})
const assignForm = reactive({
agentId: '',
quantity: 1,
commissionMode: 'percent',
fixedAmount: 0,
percent: 10,
templateId: '',
discount: 0,
remark: ''
})
const assignRules = reactive<FormRules>({
agentId: [{ required: true, message: '请选择代理商', trigger: 'change' }],
quantity: [{ required: true, message: '请输入分配数量', trigger: 'blur' }],
commissionMode: [{ required: true, message: '请选择分佣模式', trigger: 'change' }]
})
const agentList = ref([
{ id: '1', agentName: '华东区总代理', phone: '13800138000', level: 1 },
{ id: '2', agentName: '华南区代理', phone: '13900139000', level: 2 },
{ id: '3', agentName: '华北区代理', phone: '13700137000', level: 1 }
])
const commissionTemplates = ref([
{ id: '1', templateName: '标准代理商佣金', mode: 'percent', value: 10 },
{ id: '2', templateName: '特殊套餐固定佣金', mode: 'fixed', value: 50 },
{ id: '3', templateName: '高端代理商佣金', mode: 'percent', value: 15 }
])
const mockData = ref<SimCardProduct[]>([
{
id: '1',
productName: '移动4G流量卡-月包100GB',
operator: 'CMCC',
packageSpec: '100GB/月有效期1年',
price: 80.00,
stock: 1000,
assignedCount: 500
},
{
id: '2',
productName: '联通5G流量卡-季包300GB',
operator: 'CUCC',
packageSpec: '300GB/季有效期1年',
price: 220.00,
stock: 500,
assignedCount: 200
},
{
id: '3',
productName: '电信物联网卡-年包1TB',
operator: 'CTCC',
packageSpec: '1TB/年有效期2年',
price: 800.00,
stock: 80,
assignedCount: 0
}
])
const assignRecords = ref([
{
id: '1',
agentName: '华东区总代理',
quantity: 200,
commissionMode: 'percent',
commissionRule: '10%',
discount: 5,
assignTime: '2026-01-08 10:00:00',
operator: 'admin'
},
{
id: '2',
agentName: '华南区代理',
quantity: 150,
commissionMode: 'fixed',
commissionRule: '¥5.00/张',
discount: 0,
assignTime: '2026-01-07 14:30:00',
operator: 'admin'
}
])
const filteredData = computed(() => {
let data = mockData.value
if (searchQuery.value) {
data = data.filter((item) => item.productName.includes(searchQuery.value))
}
if (operatorFilter.value) {
data = data.filter((item) => item.operator === operatorFilter.value)
}
if (statusFilter.value) {
if (statusFilter.value === 'assigned') {
data = data.filter((item) => item.assignedCount > 0)
} else if (statusFilter.value === 'unassigned') {
data = data.filter((item) => item.assignedCount === 0)
}
}
return data
})
const getOperatorText = (operator: string) => {
const map: Record<string, string> = {
CMCC: '中国移动',
CUCC: '中国联通',
CTCC: '中国电信'
}
return map[operator] || operator
}
const getOperatorTagType = (operator: string) => {
const map: Record<string, any> = {
CMCC: 'success',
CUCC: 'primary',
CTCC: 'warning'
}
return map[operator] || ''
}
const handleSearch = () => {}
const handleSelectionChange = (rows: SimCardProduct[]) => {
selectedRows.value = rows
}
const showAssignDialog = () => {
if (selectedRows.value.length === 0) {
ElMessage.warning('请先选择要分配的产品')
return
}
if (selectedRows.value.length > 1) {
ElMessage.warning('批量分配功能开发中,请单个选择')
return
}
currentProduct.value = selectedRows.value[0]
assignDialogVisible.value = true
}
const assignToAgent = (row: SimCardProduct) => {
currentProduct.value = row
assignDialogVisible.value = true
}
const handleAgentChange = () => {
// 可以根据代理商自动填充默认佣金设置
}
const handleAssignSubmit = async () => {
if (!formRef.value) return
await formRef.value.validate((valid) => {
if (valid) {
if (assignForm.quantity > currentProduct.value.stock) {
ElMessage.error('分配数量不能超过库存数量')
return
}
// 更新分配数量
currentProduct.value.assignedCount += assignForm.quantity
currentProduct.value.stock -= assignForm.quantity
assignDialogVisible.value = false
formRef.value.resetFields()
ElMessage.success('分配成功')
}
})
}
const viewAssignDetail = (row: SimCardProduct) => {
currentProduct.value = row
detailDialogVisible.value = true
}
const handleCancelAssign = (row: any) => {
ElMessageBox.confirm('取消分配后,该代理商将无法继续销售此产品,确定取消吗?', '取消分配', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
// 恢复库存
currentProduct.value.stock += row.quantity
currentProduct.value.assignedCount -= row.quantity
const index = assignRecords.value.findIndex((item) => item.id === row.id)
if (index !== -1) assignRecords.value.splice(index, 1)
ElMessage.success('取消分配成功')
})
}
</script>
<style lang="scss" scoped>
.page-content {
:deep(.el-select-dropdown__item) {
display: flex;
justify-content: space-between;
}
}
</style>

View File

@@ -0,0 +1,275 @@
<template>
<div class="page-content">
<ElRow>
<ElCol :xs="24" :sm="12" :lg="6">
<ElInput v-model="searchQuery" placeholder="号卡名称/编码" clearable></ElInput>
</ElCol>
<div style="width: 12px"></div>
<ElCol :xs="24" :sm="12" :lg="6">
<ElSelect v-model="operatorFilter" placeholder="运营商" clearable style="width: 100%">
<ElOption label="中国移动" value="cmcc" />
<ElOption label="中国联通" value="cucc" />
<ElOption label="中国电信" value="ctcc" />
</ElSelect>
</ElCol>
<div style="width: 12px"></div>
<ElCol :xs="24" :sm="12" :lg="6" class="el-col2">
<ElButton v-ripple @click="handleSearch">搜索</ElButton>
<ElButton v-ripple @click="showDialog('add')">新增号卡</ElButton>
</ElCol>
</ElRow>
<ArtTable :data="filteredData" index>
<template #default>
<ElTableColumn label="号卡名称" prop="cardName" min-width="150" />
<ElTableColumn label="号卡编码" prop="cardCode" />
<ElTableColumn label="运营商" prop="operator">
<template #default="scope">
<ElTag :type="getOperatorTagType(scope.row.operator)">
{{ getOperatorText(scope.row.operator) }}
</ElTag>
</template>
</ElTableColumn>
<ElTableColumn label="套餐类型" prop="packageType" />
<ElTableColumn label="月租(元)" prop="monthlyFee">
<template #default="scope"> ¥{{ scope.row.monthlyFee.toFixed(2) }} </template>
</ElTableColumn>
<ElTableColumn label="库存" prop="stock" />
<ElTableColumn label="状态" prop="status">
<template #default="scope">
<ElTag :type="scope.row.status === 'online' ? 'success' : 'info'">
{{ scope.row.status === 'online' ? '上架' : '下架' }}
</ElTag>
</template>
</ElTableColumn>
<ElTableColumn label="创建时间" prop="createTime" width="180" />
<ElTableColumn fixed="right" label="操作" width="180">
<template #default="scope">
<el-button link @click="showDialog('edit', scope.row)">编辑</el-button>
<el-button
link
:type="scope.row.status === 'online' ? 'danger' : 'primary'"
@click="toggleStatus(scope.row)"
>
{{ scope.row.status === 'online' ? '下架' : '上架' }}
</el-button>
</template>
</ElTableColumn>
</template>
</ArtTable>
<ElDialog
v-model="dialogVisible"
:title="dialogType === 'add' ? '新增号卡' : '编辑号卡'"
width="700px"
align-center
>
<ElForm ref="formRef" :model="form" :rules="rules" label-width="120px">
<ElRow :gutter="20">
<ElCol :span="12">
<ElFormItem label="号卡名称" prop="cardName">
<ElInput v-model="form.cardName" placeholder="请输入号卡名称" />
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="号卡编码" prop="cardCode">
<ElInput v-model="form.cardCode" placeholder="请输入号卡编码" />
</ElFormItem>
</ElCol>
</ElRow>
<ElRow :gutter="20">
<ElCol :span="12">
<ElFormItem label="运营商" prop="operator">
<ElSelect v-model="form.operator" placeholder="请选择运营商" style="width: 100%">
<ElOption label="中国移动" value="cmcc" />
<ElOption label="中国联通" value="cucc" />
<ElOption label="中国电信" value="ctcc" />
</ElSelect>
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="套餐类型" prop="packageType">
<ElInput v-model="form.packageType" placeholder="例如:流量套餐" />
</ElFormItem>
</ElCol>
</ElRow>
<ElRow :gutter="20">
<ElCol :span="12">
<ElFormItem label="月租(元)" prop="monthlyFee">
<ElInputNumber v-model="form.monthlyFee" :min="0" :precision="2" style="width: 100%" />
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="初始库存" prop="stock">
<ElInputNumber v-model="form.stock" :min="0" style="width: 100%" />
</ElFormItem>
</ElCol>
</ElRow>
<ElFormItem label="号卡描述" prop="description">
<ElInput v-model="form.description" type="textarea" :rows="3" placeholder="请输入号卡描述" />
</ElFormItem>
<ElFormItem label="状态">
<ElSwitch v-model="form.status" active-value="online" inactive-value="offline" />
<span style="margin-left: 8px; color: var(--el-text-color-secondary)">{{
form.status === 'online' ? '上架' : '下架'
}}</span>
</ElFormItem>
</ElForm>
<template #footer>
<div class="dialog-footer">
<ElButton @click="dialogVisible = false">取消</ElButton>
<ElButton type="primary" @click="handleSubmit(formRef)">提交</ElButton>
</div>
</template>
</ElDialog>
</div>
</template>
<script setup lang="ts">
import { ElMessage, ElMessageBox } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
defineOptions({ name: 'SimCard' })
interface SimCard {
id?: string
cardName: string
cardCode: string
operator: string
packageType: string
monthlyFee: number
stock: number
description?: string
status: 'online' | 'offline'
createTime?: string
}
const mockData = ref<SimCard[]>([
{
id: '1',
cardName: '移动流量卡30GB',
cardCode: 'CARD_CMCC_30GB',
operator: 'cmcc',
packageType: '流量套餐',
monthlyFee: 29.9,
stock: 1000,
description: '移动30GB流量卡全国通用',
status: 'online',
createTime: '2026-01-01 10:00:00'
},
{
id: '2',
cardName: '联通流量卡50GB',
cardCode: 'CARD_CUCC_50GB',
operator: 'cucc',
packageType: '流量套餐',
monthlyFee: 49.9,
stock: 800,
description: '联通50GB流量卡全国通用',
status: 'online',
createTime: '2026-01-02 11:00:00'
}
])
const searchQuery = ref('')
const operatorFilter = ref('')
const dialogVisible = ref(false)
const dialogType = ref<'add' | 'edit'>('add')
const formRef = ref<FormInstance>()
const form = reactive<SimCard>({
cardName: '',
cardCode: '',
operator: '',
packageType: '',
monthlyFee: 0,
stock: 0,
description: '',
status: 'online'
})
const rules = reactive<FormRules>({
cardName: [{ required: true, message: '请输入号卡名称', trigger: 'blur' }],
cardCode: [{ required: true, message: '请输入号卡编码', trigger: 'blur' }],
operator: [{ required: true, message: '请选择运营商', trigger: 'change' }],
monthlyFee: [{ required: true, message: '请输入月租', trigger: 'blur' }]
})
const filteredData = computed(() => {
let data = mockData.value
if (searchQuery.value) {
data = data.filter(
(item) => item.cardName.includes(searchQuery.value) || item.cardCode.includes(searchQuery.value)
)
}
if (operatorFilter.value) {
data = data.filter((item) => item.operator === operatorFilter.value)
}
return data
})
const getOperatorText = (operator: string) => {
const map: Record<string, string> = { cmcc: '中国移动', cucc: '中国联通', ctcc: '中国电信' }
return map[operator] || '未知'
}
const getOperatorTagType = (operator: string) => {
const map: Record<string, string> = { cmcc: '', cucc: 'success', ctcc: 'warning' }
return map[operator] || 'info'
}
const handleSearch = () => {}
const showDialog = (type: 'add' | 'edit', row?: SimCard) => {
dialogType.value = type
dialogVisible.value = true
if (type === 'edit' && row) {
Object.assign(form, row)
} else {
Object.assign(form, {
cardName: '',
cardCode: '',
operator: '',
packageType: '',
monthlyFee: 0,
stock: 0,
description: '',
status: 'online'
})
}
}
const handleSubmit = async (formEl: FormInstance | undefined) => {
if (!formEl) return
await formEl.validate((valid) => {
if (valid) {
if (dialogType.value === 'add') {
mockData.value.push({
...form,
id: Date.now().toString(),
createTime: new Date().toLocaleString('zh-CN')
})
ElMessage.success('新增成功')
} else {
const index = mockData.value.findIndex((item) => item.id === form.id)
if (index !== -1) mockData.value[index] = { ...form }
ElMessage.success('修改成功')
}
dialogVisible.value = false
formEl.resetFields()
}
})
}
const toggleStatus = (row: SimCard) => {
const action = row.status === 'online' ? '下架' : '上架'
ElMessageBox.confirm(`确定要${action}该号卡吗?`, `${action}确认`, {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
row.status = row.status === 'online' ? 'offline' : 'online'
ElMessage.success(`${action}成功`)
})
}
</script>

View File

@@ -0,0 +1,22 @@
<template>
<ArtResultPage
type="fail"
title="提交失败"
message="请核对并修改以下信息后,再重新提交。"
iconCode="&#xe665;"
>
<template #result-content>
<p>您提交的内容有如下错误</p>
<p><i class="icon iconfont-sys">&#xe71a;</i>您的账户已被冻结</p>
<p><i class="icon iconfont-sys">&#xe71a;</i>您的账户还不具备申请资格</p>
</template>
<template #buttons>
<el-button type="primary" v-ripple>返回修改</el-button>
<el-button v-ripple>查看</el-button>
</template>
</ArtResultPage>
</template>
<script setup lang="ts">
defineOptions({ name: 'ResultFail' })
</script>

View File

@@ -0,0 +1,21 @@
<template>
<ArtResultPage
type="success"
title="提交成功"
message="提交结果页用于反馈一系列操作任务的处理结果,如果仅是简单操作,使用 Message 全局提示反馈即可。灰色区域可以显示一些补充的信息。"
iconCode="&#xe617;"
>
<template #result-content>
<p>已提交申请等待部门审核</p>
</template>
<template #buttons>
<el-button type="primary" v-ripple>返回修改</el-button>
<el-button v-ripple>查看</el-button>
<el-button v-ripple>打印</el-button>
</template>
</ArtResultPage>
</template>
<script setup lang="ts">
defineOptions({ name: 'ResultSuccess' })
</script>

View File

@@ -0,0 +1,287 @@
<template>
<div class="page-content server">
<div class="list">
<div class="middle">
<div class="item" v-for="item in serverList" :key="item.name">
<div class="header">
<span class="name">{{ item.name }}</span>
<span class="ip">{{ item.ip }}</span>
</div>
<div class="box">
<div class="left">
<img src="@imgs/safeguard/server.png" alt="服务器" />
<ElButtonGroup class="ml-4">
<ElButton type="primary" size="default">开机</ElButton>
<ElButton type="danger" size="default">关机</ElButton>
<ElButton type="warning" size="default">重启</ElButton>
</ElButtonGroup>
</div>
<div class="right">
<div>
<p>CPU</p>
<ElProgress :percentage="item.cup" :text-inside="true" :stroke-width="17" />
</div>
<div>
<p>RAM</p>
<ElProgress
:percentage="item.memory"
status="success"
:text-inside="true"
:stroke-width="17"
/>
</div>
<div>
<p>SWAP</p>
<ElProgress
:percentage="item.swap"
status="warning"
:text-inside="true"
:stroke-width="17"
/>
</div>
<div>
<p>DISK</p>
<ElProgress
:percentage="item.disk"
status="success"
:text-inside="true"
:stroke-width="17"
/>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { reactive, onMounted, onUnmounted } from 'vue'
defineOptions({ name: 'SafeguardServer' })
interface ServerInfo {
name: string
ip: string
cup: number
memory: number
swap: number
disk: number
}
const serverList = reactive<ServerInfo[]>([
{
name: '开发服务器',
ip: '192.168.1.100',
cup: 85,
memory: 65,
swap: 45,
disk: 92
},
{
name: '测试服务器',
ip: '192.168.1.101',
cup: 32,
memory: 78,
swap: 90,
disk: 45
},
{
name: '预发布服务器',
ip: '192.168.1.102',
cup: 95,
memory: 42,
swap: 67,
disk: 88
},
{
name: '线上服务器',
ip: '192.168.1.103',
cup: 58,
memory: 93,
swap: 25,
disk: 73
}
])
// 生成随机数据的函数
function generateRandomValue(min = 0, max = 100): number {
return Math.floor(Math.random() * (max - min + 1)) + min
}
// 更新服务器数据
function updateServerData() {
serverList.forEach((server) => {
server.cup = generateRandomValue()
server.memory = generateRandomValue()
server.swap = generateRandomValue()
server.disk = generateRandomValue()
})
}
// 修改 timer 类型为 number | null
let timer: number | null = null
onMounted(() => {
timer = window.setInterval(updateServerData, 3000)
})
onUnmounted(() => {
if (timer !== null) {
window.clearInterval(timer)
timer = null
}
})
</script>
<style lang="scss" scoped>
.server {
.list {
width: 100%;
.middle {
display: flex;
flex-wrap: wrap;
width: calc(100% + 20px);
.item {
box-sizing: border-box;
width: calc(50% - 20px);
margin: 0 20px 20px 0;
border: 1px solid var(--el-border-color-light);
border-radius: 4px;
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20px;
border-bottom: 1px solid var(--el-border-color-light);
.name {
font-size: 15px;
font-weight: 500;
}
.ip {
font-size: 14px;
color: var(--el-text-color-secondary);
}
}
.box {
display: flex;
align-items: center;
padding: 40px;
.left {
margin: 0 40px;
img {
display: block;
width: 190px;
}
.el-button-group {
display: flex;
justify-content: center;
margin-top: -10px;
}
}
.right {
flex: 1;
margin-top: 5px;
> div {
margin: 15px 0;
p {
margin-bottom: 4px;
font-size: 14px;
}
}
}
}
}
}
}
}
@media (max-width: $device-notebook) {
.server {
.list {
.middle {
.item {
.header {
padding: 10px 20px;
}
.box {
padding: 20px;
.left {
margin: 0 20px 0 0;
}
.right {
margin-top: 0;
}
}
}
}
}
}
}
@media (max-width: $device-ipad-pro) {
.server {
.list {
.middle {
.item {
width: 100%;
}
}
}
}
}
@media (max-width: $device-phone) {
.server {
.list {
.middle {
.item {
width: 100%;
.header {
padding: 10px 20px;
}
.box {
display: block;
padding: 20px;
.left {
margin: 0;
img {
width: 150px;
margin: 0 auto;
}
.el-button-group {
margin-top: 10px;
}
}
.right {
margin-top: 30px;
}
}
}
}
}
}
}
</style>

View File

@@ -0,0 +1,295 @@
<template>
<div class="page-content">
<ElRow>
<ElCol :xs="24" :sm="12" :lg="6">
<ElInput v-model="searchQuery" placeholder="模板名称" clearable></ElInput>
</ElCol>
<div style="width: 12px"></div>
<ElCol :xs="24" :sm="12" :lg="6" class="el-col2">
<ElButton v-ripple @click="handleSearch">搜索</ElButton>
<ElButton v-ripple @click="showDialog('add')">新增模板</ElButton>
</ElCol>
</ElRow>
<ArtTable :data="filteredData" index>
<template #default>
<ElTableColumn label="模板名称" prop="templateName" />
<ElTableColumn label="分佣模式" prop="commissionMode">
<template #default="scope">
<ElTag :type="scope.row.commissionMode === 'fixed' ? '' : 'success'">
{{ scope.row.commissionMode === 'fixed' ? '固定佣金' : '比例佣金' }}
</ElTag>
</template>
</ElTableColumn>
<ElTableColumn label="佣金规则" prop="rule">
<template #default="scope">
{{
scope.row.commissionMode === 'fixed'
? `¥${scope.row.fixedAmount.toFixed(2)}/笔`
: `${scope.row.percent}%`
}}
</template>
</ElTableColumn>
<ElTableColumn label="适用范围" prop="scope" show-overflow-tooltip />
<ElTableColumn label="应用次数" prop="usageCount" />
<ElTableColumn label="状态" prop="status">
<template #default="scope">
<ElTag :type="scope.row.status === 'active' ? 'success' : 'info'">
{{ scope.row.status === 'active' ? '启用' : '禁用' }}
</ElTag>
</template>
</ElTableColumn>
<ElTableColumn label="创建时间" prop="createTime" width="180" />
<ElTableColumn fixed="right" label="操作" width="220">
<template #default="scope">
<el-button link @click="viewUsage(scope.row)">应用记录</el-button>
<el-button link @click="showDialog('edit', scope.row)">编辑</el-button>
<el-button link type="danger" @click="handleDelete(scope.row)">删除</el-button>
</template>
</ElTableColumn>
</template>
</ArtTable>
<!-- 新增/编辑对话框 -->
<ElDialog
v-model="dialogVisible"
:title="dialogType === 'add' ? '新增分佣模板' : '编辑分佣模板'"
width="600px"
align-center
>
<ElForm ref="formRef" :model="form" :rules="rules" label-width="120px">
<ElFormItem label="模板名称" prop="templateName">
<ElInput v-model="form.templateName" placeholder="请输入模板名称" />
</ElFormItem>
<ElFormItem label="分佣模式" prop="commissionMode">
<ElRadioGroup v-model="form.commissionMode">
<ElRadio value="fixed">固定佣金</ElRadio>
<ElRadio value="percent">比例佣金</ElRadio>
</ElRadioGroup>
</ElFormItem>
<ElFormItem v-if="form.commissionMode === 'fixed'" label="固定金额" prop="fixedAmount">
<ElInputNumber v-model="form.fixedAmount" :min="0" :precision="2" style="width: 100%" />
<span style="margin-left: 8px">/</span>
</ElFormItem>
<ElFormItem v-if="form.commissionMode === 'percent'" label="佣金比例" prop="percent">
<ElInputNumber v-model="form.percent" :min="0" :max="100" :precision="2" style="width: 100%" />
<span style="margin-left: 8px">%</span>
</ElFormItem>
<ElFormItem label="适用范围" prop="scope">
<ElInput v-model="form.scope" placeholder="例如:全部套餐、特定代理商等" />
</ElFormItem>
<ElFormItem label="分佣说明" prop="description">
<ElInput v-model="form.description" type="textarea" :rows="3" placeholder="请输入分佣规则说明" />
</ElFormItem>
<ElFormItem label="状态">
<ElSwitch v-model="form.status" active-value="active" inactive-value="inactive" />
</ElFormItem>
</ElForm>
<template #footer>
<div class="dialog-footer">
<ElButton @click="dialogVisible = false">取消</ElButton>
<ElButton type="primary" @click="handleSubmit(formRef)">提交</ElButton>
</div>
</template>
</ElDialog>
<!-- 应用记录对话框 -->
<ElDialog v-model="usageDialogVisible" title="模板应用记录" width="900px" align-center>
<ArtTable :data="usageRecords" index>
<template #default>
<ElTableColumn label="应用对象" prop="targetName" />
<ElTableColumn label="对象类型" prop="targetType">
<template #default="scope">
<ElTag :type="scope.row.targetType === 'agent' ? '' : 'success'">
{{ scope.row.targetType === 'agent' ? '代理商' : '套餐' }}
</ElTag>
</template>
</ElTableColumn>
<ElTableColumn label="应用时间" prop="applyTime" width="180" />
<ElTableColumn label="操作人" prop="operator" />
<ElTableColumn label="状态" prop="status">
<template #default="scope">
<ElTag :type="scope.row.status === 'active' ? 'success' : 'info'">
{{ scope.row.status === 'active' ? '生效中' : '已失效' }}
</ElTag>
</template>
</ElTableColumn>
</template>
</ArtTable>
</ElDialog>
</div>
</template>
<script setup lang="ts">
import { ElMessage, ElMessageBox } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
defineOptions({ name: 'CommissionTemplate' })
interface Template {
id?: string
templateName: string
commissionMode: 'fixed' | 'percent'
fixedAmount: number
percent: number
scope: string
description?: string
usageCount?: number
status: 'active' | 'inactive'
createTime?: string
}
const mockData = ref<Template[]>([
{
id: '1',
templateName: '标准代理商佣金',
commissionMode: 'percent',
fixedAmount: 0,
percent: 10,
scope: '全部套餐',
description: '适用于一级代理商的标准佣金模板',
usageCount: 25,
status: 'active',
createTime: '2026-01-01 10:00:00'
},
{
id: '2',
templateName: '特殊套餐固定佣金',
commissionMode: 'fixed',
fixedAmount: 50,
percent: 0,
scope: '高端套餐系列',
description: '适用于高端套餐的固定佣金',
usageCount: 8,
status: 'active',
createTime: '2026-01-05 11:00:00'
}
])
const usageRecords = ref([
{
id: '1',
targetName: '华东区总代理',
targetType: 'agent',
applyTime: '2026-01-02 10:00:00',
operator: 'admin',
status: 'active'
}
])
const searchQuery = ref('')
const dialogVisible = ref(false)
const usageDialogVisible = ref(false)
const dialogType = ref<'add' | 'edit'>('add')
const formRef = ref<FormInstance>()
const form = reactive<Template>({
templateName: '',
commissionMode: 'percent',
fixedAmount: 0,
percent: 0,
scope: '',
description: '',
status: 'active'
})
const rules = reactive<FormRules>({
templateName: [{ required: true, message: '请输入模板名称', trigger: 'blur' }],
commissionMode: [{ required: true, message: '请选择分佣模式', trigger: 'change' }],
fixedAmount: [
{
validator: (rule, value, callback) => {
if (form.commissionMode === 'fixed' && value <= 0) {
callback(new Error('固定金额必须大于0'))
} else {
callback()
}
},
trigger: 'blur'
}
],
percent: [
{
validator: (rule, value, callback) => {
if (form.commissionMode === 'percent' && (value <= 0 || value > 100)) {
callback(new Error('比例必须在0-100之间'))
} else {
callback()
}
},
trigger: 'blur'
}
],
scope: [{ required: true, message: '请输入适用范围', trigger: 'blur' }]
})
const filteredData = computed(() => {
if (!searchQuery.value) return mockData.value
return mockData.value.filter((item) => item.templateName.includes(searchQuery.value))
})
const handleSearch = () => {}
const showDialog = (type: 'add' | 'edit', row?: Template) => {
dialogType.value = type
dialogVisible.value = true
if (type === 'edit' && row) {
Object.assign(form, row)
} else {
Object.assign(form, {
templateName: '',
commissionMode: 'percent',
fixedAmount: 0,
percent: 0,
scope: '',
description: '',
status: 'active'
})
}
}
const handleSubmit = async (formEl: FormInstance | undefined) => {
if (!formEl) return
await formEl.validate((valid) => {
if (valid) {
if (dialogType.value === 'add') {
mockData.value.push({
...form,
id: Date.now().toString(),
usageCount: 0,
createTime: new Date().toLocaleString('zh-CN')
})
ElMessage.success('新增成功')
} else {
const index = mockData.value.findIndex((item) => item.id === form.id)
if (index !== -1) mockData.value[index] = { ...form }
ElMessage.success('修改成功')
}
dialogVisible.value = false
formEl.resetFields()
}
})
}
const handleDelete = (row: Template) => {
ElMessageBox.confirm('确定删除该模板吗?', '删除确认', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'error'
}).then(() => {
const index = mockData.value.findIndex((item) => item.id === row.id)
if (index !== -1) mockData.value.splice(index, 1)
ElMessage.success('删除成功')
})
}
const viewUsage = (row: Template) => {
usageDialogVisible.value = true
}
</script>

View File

@@ -0,0 +1,301 @@
<template>
<div class="page-content">
<!-- API密钥管理 -->
<ElCard shadow="never">
<template #header>
<div style="display: flex; justify-content: space-between; align-items: center">
<span style="font-weight: 500">API密钥管理</span>
<ElButton type="primary" size="small" @click="showCreateDialog">生成新密钥</ElButton>
</div>
</template>
<ArtTable :data="apiKeyList" index>
<template #default>
<ElTableColumn label="密钥名称" prop="keyName" />
<ElTableColumn label="AppKey" prop="appKey" min-width="200">
<template #default="scope">
<div style="display: flex; align-items: center; gap: 8px">
<code style="color: var(--el-color-primary)">{{ scope.row.appKey }}</code>
<ElButton link :icon="CopyDocument" @click="copyToClipboard(scope.row.appKey)" />
</div>
</template>
</ElTableColumn>
<ElTableColumn label="AppSecret" prop="appSecret" min-width="200">
<template #default="scope">
<div style="display: flex; align-items: center; gap: 8px">
<code>{{ scope.row.showSecret ? scope.row.appSecret : '••••••••••••••••' }}</code>
<ElButton link :icon="View" @click="scope.row.showSecret = !scope.row.showSecret" />
<ElButton link :icon="CopyDocument" @click="copyToClipboard(scope.row.appSecret)" />
</div>
</template>
</ElTableColumn>
<ElTableColumn label="权限" prop="permissions">
<template #default="scope">
<ElTag v-for="(perm, index) in scope.row.permissions" :key="index" size="small" style="margin-right: 4px">
{{ perm }}
</ElTag>
</template>
</ElTableColumn>
<ElTableColumn label="状态" prop="status">
<template #default="scope">
<ElTag :type="scope.row.status === 'active' ? 'success' : 'info'">
{{ scope.row.status === 'active' ? '启用' : '禁用' }}
</ElTag>
</template>
</ElTableColumn>
<ElTableColumn label="创建时间" prop="createTime" width="180" />
<ElTableColumn fixed="right" label="操作" width="180">
<template #default="scope">
<el-button link @click="handleResetKey(scope.row)">重置密钥</el-button>
<el-button link type="danger" @click="handleDelete(scope.row)">删除</el-button>
</template>
</ElTableColumn>
</template>
</ArtTable>
</ElCard>
<!-- Webhook配置 -->
<ElCard shadow="never" style="margin-top: 20px">
<template #header>
<span style="font-weight: 500">Webhook配置</span>
</template>
<ElForm :model="webhookForm" label-width="120px" style="max-width: 800px">
<ElFormItem label="回调地址">
<ElInput v-model="webhookForm.url" placeholder="https://your-domain.com/webhook">
<template #prepend>POST</template>
</ElInput>
</ElFormItem>
<ElFormItem label="签名密钥">
<ElInput
v-model="webhookForm.secret"
:type="showWebhookSecret ? 'text' : 'password'"
placeholder="用于验证webhook请求签名"
>
<template #append>
<ElButton :icon="View" @click="showWebhookSecret = !showWebhookSecret" />
</template>
</ElInput>
</ElFormItem>
<ElFormItem label="事件订阅">
<ElCheckboxGroup v-model="webhookForm.events">
<ElCheckbox value="order.created">订单创建</ElCheckbox>
<ElCheckbox value="order.paid">订单支付</ElCheckbox>
<ElCheckbox value="card.activated">卡片激活</ElCheckbox>
<ElCheckbox value="card.expired">卡片过期</ElCheckbox>
<ElCheckbox value="recharge.success">充值成功</ElCheckbox>
</ElCheckboxGroup>
</ElFormItem>
<ElFormItem>
<ElButton type="primary" @click="saveWebhook">保存配置</ElButton>
<ElButton @click="testWebhook">测试推送</ElButton>
</ElFormItem>
</ElForm>
</ElCard>
<!-- API调用统计 -->
<ElCard shadow="never" style="margin-top: 20px">
<template #header>
<span style="font-weight: 500">API调用统计最近7天</span>
</template>
<ElRow :gutter="20">
<ElCol :xs="24" :sm="12" :lg="6">
<div class="stat-box">
<div class="stat-label">总调用次数</div>
<div class="stat-value">12,580</div>
</div>
</ElCol>
<ElCol :xs="24" :sm="12" :lg="6">
<div class="stat-box">
<div class="stat-label">成功次数</div>
<div class="stat-value" style="color: var(--el-color-success)">12,453</div>
</div>
</ElCol>
<ElCol :xs="24" :sm="12" :lg="6">
<div class="stat-box">
<div class="stat-label">失败次数</div>
<div class="stat-value" style="color: var(--el-color-danger)">127</div>
</div>
</ElCol>
<ElCol :xs="24" :sm="12" :lg="6">
<div class="stat-box">
<div class="stat-label">成功率</div>
<div class="stat-value">99.0%</div>
</div>
</ElCol>
</ElRow>
</ElCard>
<!-- 生成密钥对话框 -->
<ElDialog v-model="createDialogVisible" title="生成新密钥" width="500px" align-center>
<ElForm ref="createFormRef" :model="createForm" :rules="createRules" label-width="100px">
<ElFormItem label="密钥名称" prop="keyName">
<ElInput v-model="createForm.keyName" placeholder="请输入密钥名称" />
</ElFormItem>
<ElFormItem label="权限设置">
<ElCheckboxGroup v-model="createForm.permissions">
<ElCheckbox value="读取">读取</ElCheckbox>
<ElCheckbox value="写入">写入</ElCheckbox>
<ElCheckbox value="删除">删除</ElCheckbox>
</ElCheckboxGroup>
</ElFormItem>
</ElForm>
<template #footer>
<div class="dialog-footer">
<ElButton @click="createDialogVisible = false">取消</ElButton>
<ElButton type="primary" @click="handleCreateKey">生成</ElButton>
</div>
</template>
</ElDialog>
</div>
</template>
<script setup lang="ts">
import { ElMessage, ElMessageBox } from 'element-plus'
import { CopyDocument, View } from '@element-plus/icons-vue'
import type { FormInstance, FormRules } from 'element-plus'
defineOptions({ name: 'DeveloperApi' })
interface ApiKey {
id: string
keyName: string
appKey: string
appSecret: string
permissions: string[]
status: 'active' | 'inactive'
createTime: string
showSecret?: boolean
}
const apiKeyList = ref<ApiKey[]>([
{
id: '1',
keyName: '生产环境密钥',
appKey: 'ak_prod_1234567890abcdef',
appSecret: 'sk_prod_abcdefghijklmnopqrstuvwxyz123456',
permissions: ['读取', '写入'],
status: 'active',
createTime: '2026-01-01 10:00:00',
showSecret: false
}
])
const webhookForm = reactive({
url: 'https://your-domain.com/webhook',
secret: 'webhook_secret_key_123456',
events: ['order.created', 'order.paid']
})
const showWebhookSecret = ref(false)
const createDialogVisible = ref(false)
const createFormRef = ref<FormInstance>()
const createForm = reactive({
keyName: '',
permissions: ['读取']
})
const createRules = reactive<FormRules>({
keyName: [{ required: true, message: '请输入密钥名称', trigger: 'blur' }]
})
const copyToClipboard = (text: string) => {
navigator.clipboard.writeText(text).then(() => {
ElMessage.success('已复制到剪贴板')
})
}
const showCreateDialog = () => {
createForm.keyName = ''
createForm.permissions = ['读取']
createDialogVisible.value = true
}
const handleCreateKey = async () => {
if (!createFormRef.value) return
await createFormRef.value.validate((valid) => {
if (valid) {
const newKey: ApiKey = {
id: Date.now().toString(),
keyName: createForm.keyName,
appKey: `ak_${Date.now()}`,
appSecret: `sk_${Math.random().toString(36).substring(2)}`,
permissions: createForm.permissions,
status: 'active',
createTime: new Date().toLocaleString('zh-CN'),
showSecret: false
}
apiKeyList.value.push(newKey)
createDialogVisible.value = false
ElMessage.success('密钥生成成功,请妥善保管')
}
})
}
const handleResetKey = (row: ApiKey) => {
ElMessageBox.confirm('重置后原密钥将失效,确定要重置吗?', '重置密钥', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
row.appSecret = `sk_${Math.random().toString(36).substring(2)}`
ElMessage.success('密钥重置成功')
})
}
const handleDelete = (row: ApiKey) => {
ElMessageBox.confirm('删除后无法恢复,确定要删除吗?', '删除密钥', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'error'
}).then(() => {
const index = apiKeyList.value.findIndex((item) => item.id === row.id)
if (index !== -1) apiKeyList.value.splice(index, 1)
ElMessage.success('删除成功')
})
}
const saveWebhook = () => {
ElMessage.success('Webhook配置保存成功')
}
const testWebhook = () => {
ElMessage.info('正在发送测试推送...')
setTimeout(() => {
ElMessage.success('测试推送成功')
}, 1500)
}
</script>
<style lang="scss" scoped>
.page-content {
.stat-box {
padding: 20px;
background: var(--el-bg-color);
border-radius: 8px;
text-align: center;
.stat-label {
font-size: 14px;
color: var(--el-text-color-secondary);
margin-bottom: 8px;
}
.stat-value {
font-size: 28px;
font-weight: 600;
color: var(--el-text-color-primary);
}
}
:deep(.el-checkbox) {
margin-right: 20px;
margin-bottom: 10px;
}
}
</style>

View File

@@ -0,0 +1,207 @@
<template>
<div class="page-content">
<ElCard shadow="never">
<template #header>
<span style="font-weight: 500">支付商户配置</span>
</template>
<ElForm ref="formRef" :model="form" :rules="rules" label-width="150px" style="max-width: 900px">
<ElDivider content-position="left">基础信息</ElDivider>
<ElFormItem label="商户名称" prop="merchantName">
<ElInput v-model="form.merchantName" placeholder="请输入商户名称" />
</ElFormItem>
<ElFormItem label="商户编号" prop="merchantId">
<ElInput v-model="form.merchantId" placeholder="请输入商户编号" />
</ElFormItem>
<ElDivider content-position="left">API配置</ElDivider>
<ElFormItem label="AppID" prop="appId">
<ElInput v-model="form.appId" placeholder="请输入AppID">
<template #append>
<ElButton :icon="View" @click="toggleShow('appId')">
{{ showFields.appId ? '隐藏' : '显示' }}
</ElButton>
</template>
</ElInput>
</ElFormItem>
<ElFormItem label="AppSecret" prop="appSecret">
<ElInput
v-model="form.appSecret"
:type="showFields.appSecret ? 'text' : 'password'"
placeholder="请输入AppSecret"
>
<template #append>
<ElButton :icon="View" @click="toggleShow('appSecret')">
{{ showFields.appSecret ? '隐藏' : '显示' }}
</ElButton>
</template>
</ElInput>
</ElFormItem>
<ElFormItem label="API密钥" prop="apiKey">
<ElInput
v-model="form.apiKey"
:type="showFields.apiKey ? 'text' : 'password'"
placeholder="请输入API密钥"
>
<template #append>
<ElButton :icon="View" @click="toggleShow('apiKey')">
{{ showFields.apiKey ? '隐藏' : '显示' }}
</ElButton>
</template>
</ElInput>
</ElFormItem>
<ElDivider content-position="left">回调配置</ElDivider>
<ElFormItem label="支付回调地址" prop="notifyUrl">
<ElInput v-model="form.notifyUrl" placeholder="https://your-domain.com/api/notify">
<template #prepend>POST</template>
</ElInput>
</ElFormItem>
<ElFormItem label="退款回调地址" prop="refundNotifyUrl">
<ElInput v-model="form.refundNotifyUrl" placeholder="https://your-domain.com/api/refund-notify">
<template #prepend>POST</template>
</ElInput>
</ElFormItem>
<ElDivider content-position="left">支付方式</ElDivider>
<ElFormItem label="启用的支付方式">
<ElCheckboxGroup v-model="form.paymentMethods">
<ElCheckbox value="wechat">
<div style="display: flex; align-items: center; gap: 8px">
<span style="color: #09bb07; font-size: 20px">💬</span>
<span>微信支付</span>
</div>
</ElCheckbox>
<ElCheckbox value="alipay">
<div style="display: flex; align-items: center; gap: 8px">
<span style="color: #1677ff; font-size: 20px">💳</span>
<span>支付宝</span>
</div>
</ElCheckbox>
<ElCheckbox value="bank">
<div style="display: flex; align-items: center; gap: 8px">
<span style="font-size: 20px">🏦</span>
<span>银行卡</span>
</div>
</ElCheckbox>
</ElCheckboxGroup>
</ElFormItem>
<ElFormItem label="测试模式">
<ElSwitch v-model="form.testMode" />
<span style="margin-left: 8px; color: var(--el-text-color-secondary)">
{{ form.testMode ? '开启(使用沙箱环境)' : '关闭(生产环境)' }}
</span>
</ElFormItem>
<ElFormItem>
<ElButton type="primary" @click="handleSave">保存配置</ElButton>
<ElButton @click="handleTest">测试连接</ElButton>
<ElButton @click="resetForm">重置</ElButton>
</ElFormItem>
</ElForm>
</ElCard>
<ElCard shadow="never" style="margin-top: 20px">
<template #header>
<span style="font-weight: 500">配置说明</span>
</template>
<ElAlert type="info" :closable="false">
<template #title>
<div style="line-height: 1.8">
<p><strong>AppID/AppSecret</strong>: 从支付服务商后台获取</p>
<p><strong>API密钥</strong>: 用于签名验证请妥善保管</p>
<p><strong>回调地址</strong>: 支付完成后支付平台会向该地址发送支付结果通知</p>
<p><strong>测试模式</strong>: 开启后使用沙箱环境不会产生真实交易</p>
</div>
</template>
</ElAlert>
</ElCard>
</div>
</template>
<script setup lang="ts">
import { ElMessage } from 'element-plus'
import { View } from '@element-plus/icons-vue'
import type { FormInstance, FormRules } from 'element-plus'
defineOptions({ name: 'PaymentMerchant' })
const formRef = ref<FormInstance>()
const form = reactive({
merchantName: '某某科技有限公司',
merchantId: 'MCH123456789',
appId: 'wx1234567890abcdef',
appSecret: '********************************',
apiKey: '********************************',
notifyUrl: 'https://your-domain.com/api/payment/notify',
refundNotifyUrl: 'https://your-domain.com/api/payment/refund-notify',
paymentMethods: ['wechat', 'alipay'],
testMode: true
})
const showFields = reactive({
appId: false,
appSecret: false,
apiKey: false
})
const rules = reactive<FormRules>({
merchantName: [{ required: true, message: '请输入商户名称', trigger: 'blur' }],
merchantId: [{ required: true, message: '请输入商户编号', trigger: 'blur' }],
appId: [{ required: true, message: '请输入AppID', trigger: 'blur' }],
appSecret: [{ required: true, message: '请输入AppSecret', trigger: 'blur' }],
apiKey: [{ required: true, message: '请输入API密钥', trigger: 'blur' }],
notifyUrl: [
{ required: true, message: '请输入支付回调地址', trigger: 'blur' },
{ type: 'url', message: '请输入正确的URL地址', trigger: 'blur' }
]
})
const toggleShow = (field: 'appId' | 'appSecret' | 'apiKey') => {
showFields[field] = !showFields[field]
}
const handleSave = async () => {
if (!formRef.value) return
await formRef.value.validate((valid) => {
if (valid) {
ElMessage.success('配置保存成功')
}
})
}
const handleTest = () => {
ElMessage.info('正在测试连接...')
setTimeout(() => {
ElMessage.success('连接测试成功')
}, 1500)
}
const resetForm = () => {
formRef.value?.resetFields()
}
</script>
<style lang="scss" scoped>
.page-content {
:deep(.el-checkbox) {
margin-right: 30px;
margin-bottom: 12px;
}
:deep(.el-divider__text) {
font-weight: 500;
color: var(--el-text-color-primary);
}
}
</style>

View File

@@ -0,0 +1,661 @@
<template>
<ArtTableFullScreen>
<div class="menu-page" id="table-full-screen">
<!-- 搜索栏 -->
<ArtSearchBar
v-model:filter="formFilters"
:items="formItems"
:showExpand="false"
@reset="handleReset"
@search="handleSearch"
></ArtSearchBar>
<ElCard shadow="never" class="art-table-card">
<!-- 表格头部 -->
<ArtTableHeader
:columnList="columnOptions"
:showZebra="false"
v-model:columns="columnChecks"
@refresh="handleRefresh"
>
<template #left>
<!-- 按钮权限后端控制模式使用自定义指令 -->
<ElButton v-auth="'add'" @click="showModel('menu', null, true)" v-ripple>
添加菜单
</ElButton>
<ElButton @click="toggleExpand" v-ripple>
{{ isExpanded ? '收起' : '展开' }}
</ElButton>
<!-- 按钮权限前端控制模式使用 hasAuth 方法 -->
<!-- <ElButton v-if="hasAuth('B_CODE1')" @click="showModel('menu', null, true)" v-ripple>
添加菜单
</ElButton> -->
</template>
</ArtTableHeader>
<!-- 表格 -->
<ArtTable
rowKey="path"
ref="tableRef"
:loading="loading"
:data="filteredTableData"
:marginTop="10"
:stripe="false"
>
<template #default>
<ElTableColumn v-for="col in columns" :key="col.prop || col.type" v-bind="col" />
</template>
</ArtTable>
<ElDialog :title="dialogTitle" v-model="dialogVisible" width="700px" align-center>
<ElForm ref="formRef" :model="form" :rules="rules" label-width="85px">
<ElFormItem label="菜单类型">
<ElRadioGroup v-model="labelPosition" :disabled="disableMenuType">
<ElRadioButton value="menu" label="menu">菜单</ElRadioButton>
<ElRadioButton value="button" label="button">权限</ElRadioButton>
</ElRadioGroup>
</ElFormItem>
<template v-if="labelPosition === 'menu'">
<ElRow :gutter="20">
<ElCol :span="12">
<ElFormItem label="菜单名称" prop="name">
<ElInput v-model="form.name" placeholder="菜单名称"></ElInput>
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="路由地址" prop="path">
<ElInput v-model="form.path" placeholder="路由地址"></ElInput>
</ElFormItem>
</ElCol>
</ElRow>
<ElRow :gutter="20">
<ElCol :span="12">
<ElFormItem label="权限标识" prop="label">
<ElInput v-model="form.label" placeholder="权限标识"></ElInput>
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="图标" prop="icon">
<ArtIconSelector :iconType="iconType" :defaultIcon="form.icon" width="229px" />
</ElFormItem>
</ElCol>
</ElRow>
<ElRow :gutter="20">
<ElCol :span="12">
<ElFormItem label="菜单排序" prop="sort" style="width: 100%">
<ElInputNumber
v-model="form.sort"
style="width: 100%"
@change="handleChange"
:min="1"
controls-position="right"
/>
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="外部链接" prop="link">
<ElInput
v-model="form.link"
placeholder="外部链接/内嵌地址(https://www.baidu.com)"
></ElInput>
</ElFormItem>
</ElCol>
</ElRow>
<ElRow :gutter="20">
<ElCol :span="5">
<ElFormItem label="是否启用" prop="isEnable">
<ElSwitch v-model="form.isEnable"></ElSwitch>
</ElFormItem>
</ElCol>
<ElCol :span="5">
<ElFormItem label="页面缓存" prop="keepAlive">
<ElSwitch v-model="form.keepAlive"></ElSwitch>
</ElFormItem>
</ElCol>
<ElCol :span="5">
<ElFormItem label="是否显示" prop="isHidden">
<ElSwitch v-model="form.isHidden"></ElSwitch>
</ElFormItem>
</ElCol>
<ElCol :span="5">
<ElFormItem label="是否内嵌" prop="isMenu">
<ElSwitch v-model="form.isIframe"></ElSwitch>
</ElFormItem>
</ElCol>
</ElRow>
</template>
<template v-if="labelPosition === 'button'">
<ElRow :gutter="20">
<ElCol :span="12">
<ElFormItem label="权限名称" prop="authName">
<ElInput v-model="form.authName" placeholder="权限名称"></ElInput>
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="权限标识" prop="authLabel">
<ElInput v-model="form.authLabel" placeholder="权限标识"></ElInput>
</ElFormItem>
</ElCol>
</ElRow>
<ElRow :gutter="20">
<ElCol :span="12">
<ElFormItem label="权限排序" prop="authSort" style="width: 100%">
<ElInputNumber
v-model="form.authSort"
style="width: 100%"
@change="handleChange"
:min="1"
controls-position="right"
/>
</ElFormItem>
</ElCol>
</ElRow>
</template>
</ElForm>
<template #footer>
<span class="dialog-footer">
<ElButton @click="dialogVisible = false"> </ElButton>
<ElButton type="primary" @click="submitForm()"> </ElButton>
</span>
</template>
</ElDialog>
</ElCard>
</div>
</ArtTableFullScreen>
</template>
<script setup lang="ts">
import { useMenuStore } from '@/store/modules/menu'
import type { FormInstance, FormRules } from 'element-plus'
import { ElMessage, ElMessageBox } from 'element-plus'
import { IconTypeEnum } from '@/enums/appEnum'
import { formatMenuTitle } from '@/router/utils/utils'
import ArtButtonTable from '@/components/core/forms/ArtButtonTable.vue'
import { useCheckedColumns } from '@/composables/useCheckedColumns'
import { ElPopover, ElButton } from 'element-plus'
import { AppRouteRecord } from '@/types/router'
import { useAuth } from '@/composables/useAuth'
import { SearchFormItem } from '@/types'
defineOptions({ name: 'Menus' })
const { hasAuth } = useAuth()
const { menuList } = storeToRefs(useMenuStore())
const loading = ref(false)
// 定义表单搜索初始值
const initialSearchState = {
name: '',
route: ''
}
// 响应式表单数据
const formFilters = reactive({ ...initialSearchState })
// 增加实际应用的搜索条件状态
const appliedFilters = reactive({ ...initialSearchState })
// 重置表单
const handleReset = () => {
Object.assign(formFilters, { ...initialSearchState })
Object.assign(appliedFilters, { ...initialSearchState })
getTableData()
}
// 搜索处理
const handleSearch = () => {
// 将当前输入的筛选条件应用到实际搜索
Object.assign(appliedFilters, { ...formFilters })
getTableData()
}
// 表单配置项
const formItems: SearchFormItem[] = [
{
label: '菜单名称',
prop: 'name',
type: 'input',
config: {
clearable: true
}
},
{
label: '路由地址',
prop: 'route',
type: 'input',
config: {
clearable: true
}
}
]
// 列配置
const columnOptions = [
{ label: '勾选', type: 'selection' },
{ label: '用户名', prop: 'avatar' },
{ label: '手机号', prop: 'mobile' },
{ label: '性别', prop: 'sex' },
{ label: '部门', prop: 'dep' },
{ label: '状态', prop: 'status' },
{ label: '创建日期', prop: 'create_time' },
{ label: '操作', prop: 'operation' }
]
// 构建菜单类型标签
const buildMenuTypeTag = (row: AppRouteRecord) => {
if (row.children && row.children.length > 0) {
return 'info'
} else if (row.meta?.link && row.meta?.isIframe) {
return 'success'
} else if (row.path) {
return 'primary'
} else if (row.meta?.link) {
return 'warning'
}
}
// 构建菜单类型文本
const buildMenuTypeText = (row: AppRouteRecord) => {
if (row.children && row.children.length > 0) {
return '目录'
} else if (row.meta?.link && row.meta?.isIframe) {
return '内嵌'
} else if (row.path) {
return '菜单'
} else if (row.meta?.link) {
return '外链'
}
}
// 动态列配置
const { columnChecks, columns } = useCheckedColumns(() => [
{
prop: 'meta.title',
label: '菜单名称',
minWidth: 120,
formatter: (row: AppRouteRecord) => {
return formatMenuTitle(row.meta?.title)
}
},
{
prop: 'type',
label: '菜单类型',
formatter: (row: AppRouteRecord) => {
return h(ElTag, { type: buildMenuTypeTag(row) }, () => buildMenuTypeText(row))
}
},
{
prop: 'path',
label: '路由',
formatter: (row: AppRouteRecord) => {
return row.meta?.link || row.path || ''
}
},
{
prop: 'meta.authList',
label: '可操作权限',
formatter: (row: AppRouteRecord) => {
return h(
'div',
{},
row.meta.authList?.map((item: { title: string; auth_mark: string }, index: number) => {
return h(
ElPopover,
{
placement: 'top-start',
title: '操作',
width: 200,
trigger: 'click',
key: index
},
{
default: () =>
h('div', { style: 'margin: 0; text-align: right' }, [
h(
ElButton,
{
size: 'small',
type: 'primary',
onClick: () => showModel('button', item)
},
{ default: () => '编辑' }
),
h(
ElButton,
{
size: 'small',
type: 'danger',
onClick: () => deleteAuth()
},
{ default: () => '删除' }
)
]),
reference: () => h(ElButton, { class: 'small-btn' }, { default: () => item.title })
}
)
})
)
}
},
{
prop: 'date',
label: '编辑时间',
formatter: () => '2022-3-12 12:00:00'
},
{
prop: 'status',
label: '隐藏菜单',
formatter: (row) => {
return h(ElTag, { type: row.meta.isHide ? 'danger' : 'info' }, () =>
row.meta.isHide ? '是' : '否'
)
}
},
{
prop: 'operation',
label: '操作',
width: 180,
formatter: (row: AppRouteRecord) => {
return h('div', [
hasAuth('B_CODE1') &&
h(ArtButtonTable, {
type: 'add',
onClick: () => showModel('menu')
}),
hasAuth('B_CODE2') &&
h(ArtButtonTable, {
type: 'edit',
onClick: () => showDialog('edit', row)
}),
hasAuth('B_CODE3') &&
h(ArtButtonTable, {
type: 'delete',
onClick: () => deleteMenu()
})
])
}
}
])
const handleRefresh = () => {
getTableData()
}
const dialogVisible = ref(false)
const form = reactive({
// 菜单
name: '',
path: '',
label: '',
icon: '',
isEnable: true,
sort: 1,
isMenu: true,
keepAlive: true,
isHidden: true,
link: '',
isIframe: false,
// 权限 (修改这部分)
authName: '',
authLabel: '',
authIcon: '',
authSort: 1
})
const iconType = ref(IconTypeEnum.UNICODE)
const labelPosition = ref('menu')
const rules = reactive<FormRules>({
name: [
{ required: true, message: '请输入菜单名称', trigger: 'blur' },
{ min: 2, max: 20, message: '长度在 2 到 20 个字符', trigger: 'blur' }
],
path: [{ required: true, message: '请输入路由地址', trigger: 'blur' }],
label: [{ required: true, message: '输入权限标识', trigger: 'blur' }],
// 修改这部分
authName: [{ required: true, message: '请输入权限名称', trigger: 'blur' }],
authLabel: [{ required: true, message: '请输入权限权限标识', trigger: 'blur' }]
})
const tableData = ref<AppRouteRecord[]>([])
onMounted(() => {
getTableData()
})
const getTableData = () => {
loading.value = true
setTimeout(() => {
tableData.value = menuList.value
loading.value = false
}, 500)
}
// 过滤后的表格数据
const filteredTableData = computed(() => {
// 递归搜索函数
const searchMenu = (items: AppRouteRecord[]): AppRouteRecord[] => {
return items.filter((item) => {
// 获取搜索关键词,转换为小写并去除首尾空格
const searchName = appliedFilters.name?.toLowerCase().trim() || ''
const searchRoute = appliedFilters.route?.toLowerCase().trim() || ''
// 获取菜单标题和路径,确保它们存在
const menuTitle = formatMenuTitle(item.meta?.title || '').toLowerCase()
const menuPath = (item.path || '').toLowerCase()
// 使用 includes 进行模糊匹配
const nameMatch = !searchName || menuTitle.includes(searchName)
const routeMatch = !searchRoute || menuPath.includes(searchRoute)
// 如果有子菜单,递归搜索
if (item.children && item.children.length > 0) {
const matchedChildren = searchMenu(item.children)
// 如果子菜单有匹配项,保留当前菜单
if (matchedChildren.length > 0) {
item.children = matchedChildren
return true
}
}
return nameMatch && routeMatch
})
}
return searchMenu(tableData.value)
})
const isEdit = ref(false)
const formRef = ref<FormInstance>()
const dialogTitle = computed(() => {
const type = labelPosition.value === 'menu' ? '菜单' : '权限'
return isEdit.value ? `编辑${type}` : `新建${type}`
})
const showDialog = (type: string, row: AppRouteRecord) => {
showModel('menu', row, true)
}
const handleChange = () => {}
const submitForm = async () => {
if (!formRef.value) return
await formRef.value.validate(async (valid) => {
if (valid) {
try {
// const menuStore = useMenuStore()
// const params =
// labelPosition.value === 'menu'
// ? {
// title: form.name,
// path: form.path,
// name: form.label,
// icon: form.icon,
// sort: form.sort,
// isEnable: form.isEnable,
// isMenu: form.isMenu,
// keepAlive: form.keepAlive,
// isHidden: form.isHidden,
// link: form.link
// }
// : {
// title: form.authName,
// name: form.authLabel,
// icon: form.authIcon,
// sort: form.authSort
// }
if (isEdit.value) {
// await menuStore.updateMenu(params)
} else {
// await menuStore.addMenu(params)
}
ElMessage.success(`${isEdit.value ? '编辑' : '新增'}成功`)
dialogVisible.value = false
} catch (error) {
console.error(error)
}
}
})
}
const showModel = (type: string, row?: any, lock: boolean = false) => {
dialogVisible.value = true
labelPosition.value = type
isEdit.value = false
lockMenuType.value = lock
resetForm()
if (row) {
isEdit.value = true
nextTick(() => {
// 回显数据
if (type === 'menu') {
// 菜单数据回显
form.name = formatMenuTitle(row.meta.title)
form.path = row.path
form.label = row.name
form.icon = row.meta.icon
form.sort = row.meta.sort || 1
form.isMenu = row.meta.isMenu
form.keepAlive = row.meta.keepAlive
form.isHidden = row.meta.isHidden || true
form.isEnable = row.meta.isEnable || true
form.link = row.meta.link
form.isIframe = row.meta.isIframe || false
} else {
// 权限按钮数据回显
form.authName = row.title
form.authLabel = row.auth_mark
form.authIcon = row.icon || ''
form.authSort = row.sort || 1
}
})
}
}
const resetForm = () => {
formRef.value?.resetFields()
Object.assign(form, {
// 菜单
name: '',
path: '',
label: '',
icon: '',
sort: 1,
isMenu: true,
keepAlive: true,
isHidden: true,
link: '',
isIframe: false,
// 权限
authName: '',
authLabel: '',
authIcon: '',
authSort: 1
})
}
const deleteMenu = async () => {
try {
await ElMessageBox.confirm('确定要删除该菜单吗?删除后无法恢复', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
ElMessage.success('删除成功')
} catch (error) {
if (error !== 'cancel') {
console.error(error)
}
}
}
const deleteAuth = async () => {
try {
await ElMessageBox.confirm('确定要删除该权限吗?删除后无法恢复', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
ElMessage.success('删除成功')
} catch (error) {
if (error !== 'cancel') {
console.error(error)
}
}
}
// 修改计算属性,增加锁定控制参数
const disableMenuType = computed(() => {
// 编辑权限时锁定为权限类型
if (isEdit.value && labelPosition.value === 'button') return true
// 编辑菜单时锁定为菜单类型
if (isEdit.value && labelPosition.value === 'menu') return true
// 顶部添加菜单按钮时锁定为菜单类型
if (!isEdit.value && labelPosition.value === 'menu' && lockMenuType.value) return true
return false
})
// 添加一个控制变量
const lockMenuType = ref(false)
const isExpanded = ref(false)
const tableRef = ref()
const toggleExpand = () => {
isExpanded.value = !isExpanded.value
nextTick(() => {
if (tableRef.value) {
tableRef.value[isExpanded.value ? 'expandAll' : 'collapseAll']()
}
})
}
</script>
<style lang="scss" scoped>
.menu-page {
.svg-icon {
width: 1.8em;
height: 1.8em;
overflow: hidden;
vertical-align: -8px;
fill: currentcolor;
}
:deep(.small-btn) {
height: 30px !important;
padding: 0 10px !important;
font-size: 12px !important;
}
}
</style>

View File

@@ -0,0 +1,5 @@
<template>
<div class="page-content">
<h1>菜单-1</h1>
</div>
</template>

Some files were not shown because too many files have changed in this diff Show More