|
|
|
|
@ -0,0 +1,368 @@
|
|
|
|
|
<template>
|
|
|
|
|
<el-dialog
|
|
|
|
|
:visible.sync="dialogVisible"
|
|
|
|
|
title="批量上传头像"
|
|
|
|
|
width="900px"
|
|
|
|
|
top="5vh"
|
|
|
|
|
@close="handleClose"
|
|
|
|
|
>
|
|
|
|
|
<div class="tip-box">
|
|
|
|
|
<p>1. 请先选择目标课程,仅会匹配该课程下的学员。</p>
|
|
|
|
|
<p>2. 图片文件名需与学员姓名完全一致,如 <code>张三.jpg</code>。</p>
|
|
|
|
|
<p>3. 仅「可更新」状态的记录会被导入;未匹配、重名、无效文件会自动跳过。</p>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<el-form label-width="90px" size="small">
|
|
|
|
|
<el-form-item label="目标课程" required>
|
|
|
|
|
<el-select
|
|
|
|
|
v-model="courseId"
|
|
|
|
|
filterable
|
|
|
|
|
clearable
|
|
|
|
|
placeholder="请选择课程"
|
|
|
|
|
style="width: 100%;"
|
|
|
|
|
@change="handleCourseChange"
|
|
|
|
|
>
|
|
|
|
|
<el-option
|
|
|
|
|
v-for="item in courseOptions"
|
|
|
|
|
:key="item.id"
|
|
|
|
|
:label="formatCourseLabel(item)"
|
|
|
|
|
:value="item.id"
|
|
|
|
|
/>
|
|
|
|
|
</el-select>
|
|
|
|
|
</el-form-item>
|
|
|
|
|
</el-form>
|
|
|
|
|
|
|
|
|
|
<el-upload
|
|
|
|
|
ref="uploadRef"
|
|
|
|
|
drag
|
|
|
|
|
action="#"
|
|
|
|
|
multiple
|
|
|
|
|
accept=".png,.jpg,.jpeg,.bmp,.svg,.webp"
|
|
|
|
|
:auto-upload="false"
|
|
|
|
|
:file-list="fileList"
|
|
|
|
|
:on-change="handleFileChange"
|
|
|
|
|
:on-remove="handleFileRemove"
|
|
|
|
|
>
|
|
|
|
|
<i class="el-icon-upload"></i>
|
|
|
|
|
<div class="el-upload__text">将图片拖到此处,或<em>点击选择</em></div>
|
|
|
|
|
<div slot="tip" class="el-upload__tip">支持 jpg/png 等图片,单张不超过 500KB</div>
|
|
|
|
|
</el-upload>
|
|
|
|
|
|
|
|
|
|
<div class="action-row">
|
|
|
|
|
<el-button
|
|
|
|
|
type="primary"
|
|
|
|
|
size="small"
|
|
|
|
|
:loading="previewLoading"
|
|
|
|
|
:disabled="!courseId || rawFiles.length === 0"
|
|
|
|
|
@click="handlePreview"
|
|
|
|
|
>
|
|
|
|
|
预览匹配
|
|
|
|
|
</el-button>
|
|
|
|
|
<span v-if="stats.ready" class="stats-text">
|
|
|
|
|
可更新 {{ stats.ready }} 条,
|
|
|
|
|
未匹配 {{ stats.unmatched }} 条,
|
|
|
|
|
重名 {{ stats.duplicate }} 条,
|
|
|
|
|
无效 {{ stats.invalid }} 条
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<el-table
|
|
|
|
|
v-if="previewList.length"
|
|
|
|
|
:data="previewList"
|
|
|
|
|
border
|
|
|
|
|
size="small"
|
|
|
|
|
max-height="360"
|
|
|
|
|
style="width: 100%; margin-top: 12px;"
|
|
|
|
|
>
|
|
|
|
|
<el-table-column prop="filename" label="文件名" min-width="140" />
|
|
|
|
|
<el-table-column prop="name" label="匹配姓名" width="100" />
|
|
|
|
|
<el-table-column prop="username" label="学员姓名" width="100" />
|
|
|
|
|
<el-table-column prop="mobile" label="手机号" width="120" />
|
|
|
|
|
<el-table-column prop="no" label="学号" width="120" />
|
|
|
|
|
<el-table-column label="状态" width="110">
|
|
|
|
|
<template slot-scope="scope">
|
|
|
|
|
<el-tag :type="statusTagType(scope.row.status)" size="mini">
|
|
|
|
|
{{ scope.row.status_text }}
|
|
|
|
|
</el-tag>
|
|
|
|
|
</template>
|
|
|
|
|
</el-table-column>
|
|
|
|
|
<el-table-column label="当前头像" width="90" align="center">
|
|
|
|
|
<template slot-scope="scope">
|
|
|
|
|
<el-image
|
|
|
|
|
v-if="scope.row.current_headimgurl"
|
|
|
|
|
:src="scope.row.current_headimgurl"
|
|
|
|
|
style="width: 40px; height: 40px; border-radius: 50%;"
|
|
|
|
|
fit="cover"
|
|
|
|
|
/>
|
|
|
|
|
<span v-else>-</span>
|
|
|
|
|
</template>
|
|
|
|
|
</el-table-column>
|
|
|
|
|
<el-table-column label="重名候选" min-width="180">
|
|
|
|
|
<template slot-scope="scope">
|
|
|
|
|
<div v-if="scope.row.candidates && scope.row.candidates.length">
|
|
|
|
|
<div v-for="item in scope.row.candidates" :key="item.user_id" class="candidate-item">
|
|
|
|
|
{{ item.username }} / {{ item.mobile || '无手机号' }}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<span v-else>-</span>
|
|
|
|
|
</template>
|
|
|
|
|
</el-table-column>
|
|
|
|
|
</el-table>
|
|
|
|
|
|
|
|
|
|
<span slot="footer" class="dialog-footer">
|
|
|
|
|
<el-button @click="dialogVisible = false">取消</el-button>
|
|
|
|
|
<el-button
|
|
|
|
|
type="primary"
|
|
|
|
|
:loading="importLoading"
|
|
|
|
|
:disabled="readyItems.length === 0"
|
|
|
|
|
@click="handleImport"
|
|
|
|
|
>
|
|
|
|
|
确认导入({{ readyItems.length }})
|
|
|
|
|
</el-button>
|
|
|
|
|
</span>
|
|
|
|
|
</el-dialog>
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
<script>
|
|
|
|
|
import axios from 'axios'
|
|
|
|
|
import { getToken } from '@/utils/auth'
|
|
|
|
|
import { index as courseIndex } from '@/api/course/index.js'
|
|
|
|
|
import { batchHeadimgPreview, batchHeadimgImport } from '@/api/student/index.js'
|
|
|
|
|
|
|
|
|
|
export default {
|
|
|
|
|
name: 'BatchHeadimgImport',
|
|
|
|
|
data() {
|
|
|
|
|
return {
|
|
|
|
|
dialogVisible: false,
|
|
|
|
|
courseId: '',
|
|
|
|
|
courseOptions: [],
|
|
|
|
|
fileList: [],
|
|
|
|
|
rawFiles: [],
|
|
|
|
|
previewList: [],
|
|
|
|
|
stats: {
|
|
|
|
|
ready: 0,
|
|
|
|
|
unmatched: 0,
|
|
|
|
|
duplicate: 0,
|
|
|
|
|
invalid: 0
|
|
|
|
|
},
|
|
|
|
|
previewLoading: false,
|
|
|
|
|
importLoading: false
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
computed: {
|
|
|
|
|
readyItems() {
|
|
|
|
|
return this.previewList.filter(item => item.status === 'ready')
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
methods: {
|
|
|
|
|
async show() {
|
|
|
|
|
this.dialogVisible = true
|
|
|
|
|
if (!this.courseOptions.length) {
|
|
|
|
|
await this.getCourseList()
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
handleClose() {
|
|
|
|
|
this.resetData()
|
|
|
|
|
},
|
|
|
|
|
resetData() {
|
|
|
|
|
this.courseId = ''
|
|
|
|
|
this.fileList = []
|
|
|
|
|
this.rawFiles = []
|
|
|
|
|
this.previewList = []
|
|
|
|
|
this.stats = { ready: 0, unmatched: 0, duplicate: 0, invalid: 0 }
|
|
|
|
|
this.previewLoading = false
|
|
|
|
|
this.importLoading = false
|
|
|
|
|
this.$refs.uploadRef && this.$refs.uploadRef.clearFiles()
|
|
|
|
|
},
|
|
|
|
|
formatCourseLabel(item) {
|
|
|
|
|
const year = item.year ? `${item.year} ` : ''
|
|
|
|
|
const typeName = item.type_detail && item.type_detail.name ? `${item.type_detail.name} | ` : ''
|
|
|
|
|
return `${year}${typeName}${item.name}`
|
|
|
|
|
},
|
|
|
|
|
async getCourseList() {
|
|
|
|
|
const res = await courseIndex({
|
|
|
|
|
page: 1,
|
|
|
|
|
page_size: 999,
|
|
|
|
|
sort_name: 'id',
|
|
|
|
|
sort_type: 'DESC',
|
|
|
|
|
show_relation: ['typeDetail']
|
|
|
|
|
})
|
|
|
|
|
this.courseOptions = res.data || []
|
|
|
|
|
},
|
|
|
|
|
handleCourseChange() {
|
|
|
|
|
this.previewList = []
|
|
|
|
|
this.stats = { ready: 0, unmatched: 0, duplicate: 0, invalid: 0 }
|
|
|
|
|
},
|
|
|
|
|
handleFileChange(file, fileList) {
|
|
|
|
|
this.fileList = fileList
|
|
|
|
|
this.rawFiles = fileList.map(item => item.raw).filter(Boolean)
|
|
|
|
|
this.previewList = []
|
|
|
|
|
this.stats = { ready: 0, unmatched: 0, duplicate: 0, invalid: 0 }
|
|
|
|
|
},
|
|
|
|
|
handleFileRemove(file, fileList) {
|
|
|
|
|
this.fileList = fileList
|
|
|
|
|
this.rawFiles = fileList.map(item => item.raw).filter(Boolean)
|
|
|
|
|
this.previewList = []
|
|
|
|
|
this.stats = { ready: 0, unmatched: 0, duplicate: 0, invalid: 0 }
|
|
|
|
|
},
|
|
|
|
|
statusTagType(status) {
|
|
|
|
|
const map = {
|
|
|
|
|
ready: 'success',
|
|
|
|
|
unmatched: 'info',
|
|
|
|
|
duplicate: 'warning',
|
|
|
|
|
invalid: 'danger'
|
|
|
|
|
}
|
|
|
|
|
return map[status] || 'info'
|
|
|
|
|
},
|
|
|
|
|
async handlePreview() {
|
|
|
|
|
if (!this.courseId) {
|
|
|
|
|
this.$message.warning('请先选择课程')
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if (!this.rawFiles.length) {
|
|
|
|
|
this.$message.warning('请先选择图片')
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const formData = new FormData()
|
|
|
|
|
formData.append('course_id', this.courseId)
|
|
|
|
|
this.rawFiles.forEach(file => {
|
|
|
|
|
formData.append('files[]', file)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
this.previewLoading = true
|
|
|
|
|
try {
|
|
|
|
|
const res = await batchHeadimgPreview(formData)
|
|
|
|
|
this.previewList = res.list || []
|
|
|
|
|
this.stats = Object.assign({
|
|
|
|
|
ready: 0,
|
|
|
|
|
unmatched: 0,
|
|
|
|
|
duplicate: 0,
|
|
|
|
|
invalid: 0
|
|
|
|
|
}, res.stats || {})
|
|
|
|
|
|
|
|
|
|
if (!this.previewList.length) {
|
|
|
|
|
this.$message.warning('没有可预览的数据')
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.$message.success(`预览完成,可更新 ${this.stats.ready} 条`)
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error(error)
|
|
|
|
|
} finally {
|
|
|
|
|
this.previewLoading = false
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
findRawFile(filename) {
|
|
|
|
|
return this.rawFiles.find(file => file.name === filename)
|
|
|
|
|
},
|
|
|
|
|
async uploadSingleFile(file) {
|
|
|
|
|
const formData = new FormData()
|
|
|
|
|
formData.append('file', file)
|
|
|
|
|
const res = await axios.post(
|
|
|
|
|
`${process.env.VUE_APP_BASE_API}/api/admin/upload-file`,
|
|
|
|
|
formData,
|
|
|
|
|
{
|
|
|
|
|
headers: {
|
|
|
|
|
Authorization: `Bearer ${getToken()}`
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
const data = res.data
|
|
|
|
|
if (data && data.errcode) {
|
|
|
|
|
throw new Error(data.errmsg || '上传失败')
|
|
|
|
|
}
|
|
|
|
|
return data.url
|
|
|
|
|
},
|
|
|
|
|
async handleImport() {
|
|
|
|
|
if (!this.readyItems.length) {
|
|
|
|
|
this.$message.warning('没有可导入的数据')
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.importLoading = true
|
|
|
|
|
const items = []
|
|
|
|
|
const failedUploads = []
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
for (const item of this.readyItems) {
|
|
|
|
|
const file = this.findRawFile(item.filename)
|
|
|
|
|
if (!file) {
|
|
|
|
|
failedUploads.push(`${item.filename}:找不到本地文件`)
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
try {
|
|
|
|
|
const headimgurl = await this.uploadSingleFile(file)
|
|
|
|
|
items.push({
|
|
|
|
|
user_id: item.user_id,
|
|
|
|
|
headimgurl
|
|
|
|
|
})
|
|
|
|
|
} catch (error) {
|
|
|
|
|
failedUploads.push(`${item.filename}:${error.message || '上传失败'}`)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!items.length) {
|
|
|
|
|
this.$message.error('图片上传全部失败,未执行导入')
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const res = await batchHeadimgImport({
|
|
|
|
|
course_id: this.courseId,
|
|
|
|
|
items
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
let message = `成功更新 ${res.updated_count || items.length} 名学员头像`
|
|
|
|
|
if (failedUploads.length) {
|
|
|
|
|
message += `,${failedUploads.length} 张图片上传失败`
|
|
|
|
|
}
|
|
|
|
|
if (res.failed_count) {
|
|
|
|
|
message += `,${res.failed_count} 条写入失败`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.$message.success(message)
|
|
|
|
|
this.dialogVisible = false
|
|
|
|
|
this.$emit('refresh')
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error(error)
|
|
|
|
|
} finally {
|
|
|
|
|
this.importLoading = false
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
<style scoped lang="scss">
|
|
|
|
|
.tip-box {
|
|
|
|
|
margin-bottom: 16px;
|
|
|
|
|
padding: 10px 12px;
|
|
|
|
|
background: #f4f8ff;
|
|
|
|
|
border-radius: 4px;
|
|
|
|
|
color: #606266;
|
|
|
|
|
font-size: 13px;
|
|
|
|
|
line-height: 1.7;
|
|
|
|
|
|
|
|
|
|
p {
|
|
|
|
|
margin: 0;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.action-row {
|
|
|
|
|
display: flex;
|
|
|
|
|
align-items: center;
|
|
|
|
|
margin-top: 12px;
|
|
|
|
|
gap: 12px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.stats-text {
|
|
|
|
|
color: #606266;
|
|
|
|
|
font-size: 13px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.candidate-item {
|
|
|
|
|
line-height: 1.6;
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
color: #909399;
|
|
|
|
|
}
|
|
|
|
|
</style>
|