头像批量上传

master
lion 4 weeks ago
parent ffee751ea9
commit b1cd19d2a3

@ -168,4 +168,23 @@ export function specialImportPreview(data) {
url: '/api/admin/users/excel-show-special',
data
})
}
// 批量头像预览
export function batchHeadimgPreview(data) {
return request({
method: 'post',
url: '/api/admin/users/batch-headimg-preview',
data,
isLoading: false
})
}
// 批量头像导入
export function batchHeadimgImport(data) {
return request({
method: 'post',
url: '/api/admin/users/batch-headimg-import',
data
})
}

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

@ -3,12 +3,38 @@
<xy-dialog ref="dialog" :width="70" :is-show.sync="isShow" :type="'form'" :title="type === 'add' ? '新增学员' : '编辑学员'"
:form="form" :rules='rules' @submit="submit">
<template v-slot:username>
<div class="xy-table-item">
<div class="xy-table-item-label" style="font-weight: bold">
<span style="color: red;font-weight: bold;padding-right: 4px;">*</span>姓名
</div>
<div class="xy-table-item-content">
<el-input v-model="form.username" placeholder="请输入姓名" clearable style="width: 100%;"></el-input>
<div class="username-headimg-row">
<div class="xy-table-item username-field">
<div class="xy-table-item-label" style="font-weight: bold">
<span style="color: red;font-weight: bold;padding-right: 4px;">*</span>姓名
</div>
<div class="xy-table-item-content">
<el-input v-model="form.username" placeholder="请输入姓名" clearable style="width: 100%;"></el-input>
</div>
</div>
<div class="xy-table-item headimg-field">
<div class="xy-table-item-label" style="font-weight: bold">
头像
</div>
<div class="xy-table-item-content">
<el-upload
:action="action"
:headers="uploadHeaders"
class="headimg-upload"
:limit="1"
list-type="picture-card"
accept=".png,.jpg,.jpeg,.bmp,.svg,.webp"
:file-list="headimgList"
ref="headimgUpload"
:auto-upload="true"
:before-upload="beforeHeadimgUpload"
:on-exceed="onHeadimgExceed"
:on-success="uploadHeadimgSuccess"
:on-remove="uploadHeadimgRemove">
<i class="el-icon-plus"></i>
</el-upload>
<div class="el-upload__tip">支持 jpg/png 格式大小不超过 500KB</div>
</div>
</div>
</div>
</template>
@ -360,6 +386,7 @@
<script>
import formMixin from "@/mixin/formMixin.js";
import { getToken } from '@/utils/auth'
import {
show,
save
@ -370,6 +397,11 @@
return {
isShow: false,
type: 'add',
action: `${process.env.VUE_APP_UPLOAD_API}`,
uploadHeaders: {
Authorization: `Bearer ${getToken()}`
},
headimgList: [],
rules: {},
typeList:[],
companyTypeList:[],
@ -377,6 +409,7 @@
talentTagsList:[],
form:{
username:'',
headimgurl:'',
sex:"",
birthday:"",
mobile:"",
@ -411,6 +444,34 @@
}
},
methods: {
beforeHeadimgUpload(file) {
const isImage = file.type.includes('image')
const isLt500K = file.size / 1024 <= 500
if (!isImage) {
this.$message.error('请上传正确的图片格式文件')
}
if (!isLt500K) {
this.$message.error('上传文件大小不能超过 500KB')
}
return isImage && isLt500K
},
onHeadimgExceed() {
this.$Message.warning('头像只能上传一张')
},
uploadHeadimgSuccess(response, file, fileList) {
this.form.headimgurl = response.url || ''
this.headimgList = fileList
},
uploadHeadimgRemove(file, fileList) {
this.form.headimgurl = ''
this.headimgList = fileList
},
initHeadimgList(url) {
this.headimgList = url ? [{
name: 'avatar.jpg',
url
}] : []
},
submit() {
if(this.id){
this.form.id = this.id
@ -449,6 +510,7 @@
this.companyTypeList = res.company_type?res.company_type.split(","):[]
this.fromList = res.from?res.from.split(","):[]
this.talentTagsList = res.talent_tags?res.talent_tags.split(","):[]
this.initHeadimgList(res.headimgurl || this.form.headimgurl)
})
},
},
@ -465,8 +527,10 @@
this.companyTypeList = []
this.fromList = []
this.talentTagsList = []
this.headimgList = []
this.form = {
username:'',
headimgurl:'',
sex:"",
birthday:"",
mobile:"",
@ -505,5 +569,39 @@
</script>
<style lang="scss" scoped>
::v-deep .username {
flex-basis: 100%;
}
.username-headimg-row {
display: flex;
align-items: flex-start;
width: 100%;
}
.username-field,
.headimg-field {
flex: 1;
min-width: 0;
}
.headimg-upload {
::v-deep .el-upload--picture-card {
width: 80px;
height: 80px;
line-height: 78px;
}
::v-deep .el-upload-list--picture-card .el-upload-list__item {
width: 80px;
height: 80px;
}
}
.el-upload__tip {
margin-top: 4px;
font-size: 12px;
color: #909399;
line-height: 1.4;
}
</style>

@ -239,6 +239,7 @@
<el-button type="primary" size="small" @click="importTable"></el-button>
<el-button type="primary" size="small" @click="openSpecialImport"></el-button>
<el-button type="primary" size="small" @click="exportExcel"></el-button>
<el-button type="primary" size="small" @click="openBatchHeadimgImport"></el-button>
<el-button type="primary" size="small" @click="openBatchUpdateModal"></el-button>
</div>
</div>
@ -385,6 +386,7 @@
<imports ref="imports" :table-name="'users'" @refresh="getList"></imports>
<special-import ref="specialImport" @refresh="getList"></special-import>
<batch-update v-model="batchUpdateVisible" :selected-users="seleSchoolmates" @success="handleBatchUpdateSuccess"></batch-update>
<batch-headimg-import ref="batchHeadimgImport" @refresh="getList"></batch-headimg-import>
</div>
</template>
@ -393,6 +395,7 @@
import studentDetail from './components/detail.vue';
import editDetail from './components/editDetail.vue';
import batchUpdate from './components/batchUpdate.vue';
import batchHeadimgImport from './components/batchHeadimgImport.vue';
import specialImport from './components/specialImport.vue';
import myMixins from "@/mixin/selectMixin.js";
@ -418,7 +421,8 @@
editDetail,
imports,
specialImport,
batchUpdate
batchUpdate,
batchHeadimgImport
},
data() {
return {
@ -783,6 +787,9 @@
openSpecialImport() {
this.$refs.specialImport.show()
},
openBatchHeadimgImport() {
this.$refs.batchHeadimgImport.show()
},
pageIndexChange(e) {
this.select.page = e
this.getList()

Loading…
Cancel
Save