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,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>