账号管理

This commit is contained in:
zhang zhuo 2025-06-25 18:10:56 +08:00
parent b390fb6b0b
commit 3ff4fa97a6
17 changed files with 1218 additions and 19 deletions

View File

@ -12,6 +12,7 @@
"dependencies": { "dependencies": {
"@element-plus/icons-vue": "^2.3.1", "@element-plus/icons-vue": "^2.3.1",
"axios": "^1.9.0", "axios": "^1.9.0",
"cropperjs": "1.5.13",
"crypto-js": "^4.2.0", "crypto-js": "^4.2.0",
"element-plus": "^2.9.10", "element-plus": "^2.9.10",
"nprogress": "^0.2.0", "nprogress": "^0.2.0",

View File

@ -16,4 +16,16 @@ export default {
logout: async function(){ logout: async function(){
return await http.get("logout"); return await http.get("logout");
}, },
saveTag: async function(data = {}) {
return await http.post("save/tag", data);
},
saveInfo: async function(data = {}) {
return await http.post("save/info", data);
},
loginLog: async function(data){
return await http.get("log/login", data);
},
savePass: async function(data = {}) {
return await http.post("save/pass", data);
},
} }

View File

@ -2,36 +2,36 @@ import http from "@/utils/request"
export default { export default {
account: { account: {
list: async function(data={}){ list: async function (data = {}) {
return await http.get("account/list", data); return await http.get("account/list", data);
}, },
add: async function(data={}){ add: async function (data = {}) {
return await http.post("account/add", data); return await http.post("account/add", data);
}, },
edit: async function(data={}){ edit: async function (data = {}) {
return await http.put("account/edit", data); return await http.put("account/edit", data);
}, },
del: async function(data={}){ del: async function (data = {}) {
return await http.delete("account/del", data); return await http.delete("account/del", data);
}, },
}, },
menu: { menu: {
list: async function(data={}){ list: async function (data = {}) {
return await http.get("menu/list", data); return await http.get("menu/list", data);
}, },
add: async function(data = {}){ add: async function (data = {}) {
return await http.post("menu/add", data); return await http.post("menu/add", data);
}, },
edit: async function(data = {}){ edit: async function (data = {}) {
return await http.put("menu/edit", data); return await http.put("menu/edit", data);
}, },
del: async function(data = {}){ del: async function (data = {}) {
return await http.delete("menu/del", data); return await http.delete("menu/del", data);
}, },
option: async function(data={}){ option: async function (data = {}) {
return await http.get("menu/option", data); return await http.get("menu/option", data);
}, },
quick: async function(data = {}){ quick: async function (data = {}) {
return await http.post("menu/quick", data); return await http.post("menu/quick", data);
}, },
}, },
@ -86,4 +86,7 @@ export default {
return await http.get("post/option", data); return await http.get("post/option", data);
}, },
}, },
upload: async function (data, config = {}) {
return await http.post("upload", data, config);
},
} }

View File

@ -0,0 +1,3 @@
<template>
<svg t="1750820731881" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="11613" width="256" height="256"><path d="M511.232 246.954667a32 32 0 0 1 31.850667 28.906666l0.149333 3.093334-0.021333 120.106666h74.666666v-41.493333a32 32 0 0 1 63.872-3.093333l0.149334 3.093333v41.813333a160 160 0 0 1 150.016 154.389334l0.085333 5.312v255.978666h32a32 32 0 0 1 3.072 63.872l-3.072 0.149334h-682.666667a32 32 0 0 1-3.072-63.872l3.072-0.128 32-0.021334v-256a160.064 160.064 0 0 1 127.210667-156.629333v-67.754667a32 32 0 0 1 63.872-3.072l0.149333 3.072-0.021333 64.384h74.666667v-120.106666a32 32 0 0 1 32-32zM768 662.464H277.333333v141.930667h490.666667v-141.930667z m-96-199.381333h-298.666667a96 96 0 0 0-95.893333 91.477333l-0.106667 4.522667v39.381333h490.666667v-39.381333a96 96 0 0 0-86.890667-95.573334l-4.586666-0.341333-4.522667-0.085333z m-22.101333-221.12a32 32 0 0 1 31.850666 28.928l0.149334 3.072v7.786666a32 32 0 0 1-63.850667 3.072l-0.149333-3.072v-7.786666a32 32 0 0 1 32-32z m-277.333334-24.384a32 32 0 0 1 31.850667 28.928l0.149333 3.072v10.837333a32 32 0 0 1-63.850666 3.072l-0.149334-3.072v-10.837333a32 32 0 0 1 32-32zM511.232 149.333333a32 32 0 0 1 31.850667 28.928l0.149333 3.072v15.082667a32 32 0 0 1-63.850667 3.072l-0.149333-3.072V181.333333a32 32 0 0 1 32-32z" p-id="11614"></path></svg>
</template>

3
src/assets/icons/Sex.vue Normal file
View File

@ -0,0 +1,3 @@
<template>
<svg t="1750822072505" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="5214" width="256" height="256"><path d="M519.658935 673.068396c81.129762 0 157.397796-31.572066 214.762295-88.937588 118.423133-118.421087 118.423133-311.085084 0-429.507194C677.057755 97.259114 600.788697 65.668629 519.658935 65.668629c-81.128739 0-157.39882 31.590486-214.744899 88.954985-118.402667 118.42211-118.402667 311.086108 0 429.507194C362.279558 641.496329 438.548616 673.068396 519.658935 673.068396zM346.474594 196.183148c46.267766-46.266743 107.764376-71.734788 173.184341-71.734788 65.419965 0 126.937041 25.468045 173.203784 71.734788 95.499028 95.500051 95.499028 250.889097 0 346.389148-46.266743 46.245254-107.765399 71.713298-173.203784 71.713298-65.420989 0-126.916575-25.468045-173.184341-71.713298C250.995009 447.072245 250.995009 291.682175 346.474594 196.183148z" p-id="5215"></path><path d="M793.986861 800.462854 549.061592 800.462854l0.008186-71.2743c0-16.22452-13.164834-29.389354-29.391401-29.389354s-29.389354 13.163811-29.389354 29.389354l-0.007163 71.2743L245.349429 800.462854c-16.225543 0-29.389354 13.163811-29.389354 29.390377s13.163811 29.392424 29.389354 29.392424l244.925269 0-0.00614 71.236438c0 16.22452 13.163811 29.389354 29.390377 29.389354s29.391401-13.163811 29.391401-29.389354l0.007163-71.236438L793.986861 859.245655c16.225543 0 29.391401-13.165858 29.391401-29.392424S810.213427 800.462854 793.986861 800.462854z" p-id="5216"></path></svg>
</template>

View File

@ -0,0 +1,75 @@
<template>
<div class="pi-cropper">
<div class="pi-cropper__img">
<img :src="src" ref="imgRef">
</div>
<div class="pi-cropper__preview">
<h4>图像预览</h4>
<div class="pi-cropper__preview__img" ref="previewRef"></div>
</div>
</div>
</template>
<script setup>
import Cropper from 'cropperjs'
import 'cropperjs/dist/cropper.css'
import {ref, watch, onMounted} from "vue";
defineExpose({
getCropFile
})
const props = defineProps({
src: { type: String, default: "" },
compress: {type: Number, default: 1},
aspectRatio: {type: Number, default: NaN},
})
const imgRef = ref(null)
const previewRef = ref(null)
let crop = ref(null)
watch(() => props.aspectRatio, (val) => {
crop.value.setAspectRatio(val)
})
onMounted(() => {
init()
})
function init(){
crop.value = new Cropper(imgRef.value, {
viewMode: 2,
dragMode: 'move',
responsive: false,
aspectRatio: props.aspectRatio,
preview: previewRef.value
})
}
function setAspectRatio(aspectRatio){
crop.value.setAspectRatio(aspectRatio)
}
function getCropData(cb, type='image/jpeg'){
cb(crop.value.getCroppedCanvas().toDataURL(type, props.compress))
}
function getCropBlob(cb, type='image/jpeg'){
crop.value.getCroppedCanvas().toBlob((blob) => {
cb(blob)
}, type, props.compress)
}
function getCropFile(cb, fileName='fileName.jpg', type='image/jpeg'){
crop.value.getCroppedCanvas().toBlob((blob) => {
let file = new File([blob], fileName, {type: type})
cb(file)
}, type, props.compress)
}
</script>
<style scoped>
.pi-cropper {height:300px;}
.pi-cropper__img {height:100%;width:400px;float: left;background: #EBEEF5;}
.pi-cropper__img img {display: none;}
.pi-cropper__preview {width: 120px;margin-left: 20px;float: left;}
.pi-cropper__preview h4 {font-weight: normal;font-size: 12px;color: #999;margin-bottom: 20px;}
.pi-cropper__preview__img {overflow: hidden;width: 120px;height: 120px;border: 1px solid #ebeef5;}
</style>

View File

@ -0,0 +1,79 @@
<template>
<div class="pi-password-strength">
<div class="pi-password-strength-bar" :class="`pi-password-strength-level-${level}`"></div>
</div>
</template>
<script setup>
import {ref, watch, onMounted} from "vue";
const props = defineProps({
modelValue: { type: String, default: "" },
})
let level = ref(0)
watch(() => props.modelValue, () => {
strength(props.modelValue)
})
onMounted(() => {
strength(props.modelValue)
})
function strength(v){
var _level = 0
//
var has_length = v.length >= 6
//
var has_number = /\d/.test(v)
//
var has_lovercase = /[a-z]/.test(v)
//
var has_uppercase = /[A-Z]/.test(v)
//3
var no_continuity = !/(\w)\1{2}/.test(v)
//
var has_special = /[`~!@#$%^&*()_+<>?:"{},./;'[\]]/.test(v)
if(v.length <= 0){
_level = 0
level.value = _level
return false
}
if(!has_length){
_level = 1
level.value = _level
return false
}
if(has_number){
_level += 1
}
if(has_lovercase){
_level += 1
}
if(has_uppercase){
_level += 1
}
if(no_continuity){
_level += 1
}
if(has_special){
_level += 1
}
level.value = _level
}
</script>
<style scoped>
.pi-password-strength {height: 5px;width: 100%;background: var(--el-color-info-light-5);border-radius: 5px;position: relative;margin:10px 0;}
.pi-password-strength:before {left: 20%;}
.pi-password-strength:after {right: 20%;}
.pi-password-strength:before, .pi-password-strength:after {position: absolute;content: "";display: block;width: 20%;height: inherit;border: 5px solid var(--el-bg-color-overlay);border-top: 0;border-bottom: 0;z-index: 1;background-color: transparent;box-sizing: border-box;}
.pi-password-strength-bar {position: absolute;height: inherit;width: 0%;border-radius: inherit;transition: width .5s ease-in-out,background .25s;background: transparent;}
.pi-password-strength-level-1 {width: 20%;background-color: var(--el-color-error);}
.pi-password-strength-level-2 {width: 40%;background-color: var(--el-color-error);}
.pi-password-strength-level-3 {width: 60%;background-color: var(--el-color-warning);}
.pi-password-strength-level-4 {width: 80%;background-color: var(--el-color-success);}
.pi-password-strength-level-5 {width: 100%;background-color: var(--el-color-success);}
</style>

View File

@ -1,6 +1,6 @@
<template> <template>
<el-container> <el-container>
<el-header> <el-header v-if="!hideAct">
<div class="left-panel"> <div class="left-panel">
<slot name="do"></slot> <slot name="do"></slot>
</div> </div>
@ -125,6 +125,7 @@ const props = defineProps({
remoteSummary: {type: Boolean, default: false}, remoteSummary: {type: Boolean, default: false},
hidePagination: {type: Boolean, default: false}, hidePagination: {type: Boolean, default: false},
hideDo: {type: Boolean, default: false}, hideDo: {type: Boolean, default: false},
hideAct: {type: Boolean, default: false},
hideRefresh: {type: Boolean, default: false}, hideRefresh: {type: Boolean, default: false},
hideSetting: {type: Boolean, default: false}, hideSetting: {type: Boolean, default: false},
paginationLayout: {type: String, default: config.paginationLayout}, paginationLayout: {type: String, default: config.paginationLayout},

View File

@ -0,0 +1,192 @@
<template>
<div class="pi-upload-file">
<el-upload
:disabled="disabled"
:auto-upload="autoUpload"
:action="action"
:name="name"
:data="data"
:http-request="request"
v-model:file-list="defaultFileList"
:show-file-list="showFileList"
:drag="drag"
:accept="accept"
:multiple="multiple"
:limit="limit"
:before-upload="before"
:on-success="success"
:on-error="error"
:on-preview="handlePreview"
:on-exceed="handleExceed">
<slot>
<el-button type="primary" :disabled="disabled">上传</el-button>
</slot>
<template #tip>
<div v-if="tip" class="el-upload__tip">{{tip}}</div>
</template>
</el-upload>
<span style="display:none!important"><el-input v-model="value"></el-input></span>
</div>
</template>
<script setup>
import config from "@/config/upload"
import {ref, watch, onMounted, getCurrentInstance} from "vue";
const props = defineProps({
modelValue: { type: [String, Array], default: "" },
tip: { type: String, default: "" },
action: { type: String, default: "" },
apiObj: { type: Function, default: () => {} },
name: { type: String, default: config.filename },
data: { type: Object, default: () => {} },
accept: { type: String, default: "" },
maxSize: { type: Number, default: config.maxSizeFile },
limit: { type: Number, default: 0 },
autoUpload: { type: Boolean, default: true },
showFileList: { type: Boolean, default: true },
drag: { type: Boolean, default: false },
multiple: { type: Boolean, default: true },
disabled: { type: Boolean, default: false },
onSuccess: { type: Function, default: () => { return true } }
})
const emit = defineEmits(['update:modelValue'])
const {proxy} = getCurrentInstance()
let value = ref('')
let defaultFileList = ref([])
watch(() => props.modelValue, (val) => {
if(Array.isArray(val)){
if (JSON.stringify(val) != JSON.stringify(formatArr(defaultFileList.value))) {
defaultFileList.value = val
value.value = val
}
}else{
if (val != toStr(defaultFileList.value)) {
defaultFileList.value = toArr(val)
value.value = val
}
}
})
watch(defaultFileList, (val) => {
emit('update:modelValue', Array.isArray(props.modelValue) ? formatArr(val) : toStr(val))
value.value = toStr(val)
}, {deep: true})
onMounted(() => {
defaultFileList.value = Array.isArray(props.modelValue) ? props.modelValue : toArr(props.modelValue)
value.value = props.modelValue
})
//
function toArr(str){
var _arr = []
var arr = str.split(",")
arr.forEach(item => {
if(item){
var urlArr = item.split('/');
var fileName = urlArr[urlArr.length - 1]
_arr.push({
name: fileName,
url: item
})
}
})
return _arr
}
//
function toStr(arr){
return arr.map(v => v.url).join(",")
}
//
function formatArr(arr){
var _arr = []
arr.forEach(item => {
if(item){
_arr.push({
name: item.name,
url: item.url
})
}
})
return _arr
}
function before(file){
const maxSize = file.size / 1024 / 1024 < props.maxSize;
if (!maxSize) {
proxy.$message.warning(`上传文件大小不能超过 ${props.maxSize}MB!`);
return false;
}
}
function success(res, file){
var os = props.onSuccess(res, file)
if(os!=undefined && os==false){
return false
}
file.name = res.fileName
file.url = res.src
}
function error(err){
proxy.$notify.error({
title: '上传文件未成功',
message: err
})
}
function beforeRemove(uploadFile){
return proxy.$confirm(`是否移除 ${uploadFile.name} ?`, '提示', {
type: 'warning',
}).then(() => {
return true
}).catch(() => {
return false
})
}
function handleExceed(){
proxy.$message.warning(`当前设置最多上传 ${props.limit} 个文件,请移除后上传!`)
}
function handlePreview(uploadFile){
window.open(uploadFile.url)
}
function request(param){
var apiObj = config.apiObjFile;
if(props.apiObj){
apiObj = props.apiObj;
}
const data = new FormData();
data.append(param.filename, param.file);
for (const key in param.data) {
data.append(key, param.data[key]);
}
apiObj(data, {
onUploadProgress: e => {
const complete = parseInt(((e.loaded / e.total) * 100) | 0, 10)
param.onProgress({percent: complete})
}
}).then(res => {
var response = config.parseData(res.data);
if(res.code == config.successCode){
param.onSuccess(response)
}else{
param.onError(res.msg || "未知错误")
}
}).catch(err => {
param.onError(err)
})
}
</script>
<style scoped>
.el-form-item.is-error .pi-upload-file:deep(.el-upload-dragger) {border-color: var(--el-color-danger);}
.pi-upload-file {width: 100%;}
.pi-upload-file:deep(.el-upload-list__item) {transition: none !important;}
</style>

View File

@ -0,0 +1,285 @@
<template>
<div class="pi-upload" :class="{'pi-upload-round':round}" :style="style">
<div v-if="file && file.status != 'success'" class="pi-upload__uploading">
<div class="pi-upload__progress">
<el-progress :percentage="file.percentage" :text-inside="true" :stroke-width="16"/>
</div>
<el-image class="image" :src="file.tempFile" fit="cover"></el-image>
</div>
<div v-if="file && file.status=='success'" class="pi-upload__img">
<el-image class="image" :src="file.url" :preview-src-list="[file.url]" fit="cover" preview-teleported :z-index="9999">
<template #placeholder>
<div class="pi-upload__img-slot">
Loading...
</div>
</template>
</el-image>
<div class="pi-upload__img-actions" v-if="!disabled">
<span class="del" @click="handleRemove()"><el-icon><el-icon-delete /></el-icon></span>
</div>
</div>
<el-upload v-if="!file" class="uploader" ref="uploaderRef"
:auto-upload="cropper?false:autoUpload"
:disabled="disabled"
:show-file-list="showFileList"
:action="action"
:name="name"
:data="data"
:accept="accept"
:limit="1"
:http-request="request"
:on-change="change"
:before-upload="before"
:on-success="success"
:on-error="error"
:on-exceed="handleExceed">
<slot>
<div class="el-upload--picture-card" :style="style">
<div class="file-empty">
<el-icon><component :is="icon" /></el-icon>
<h4 v-if="title">{{title}}</h4>
</div>
</div>
</slot>
</el-upload>
<span style="display:none!important"><el-input v-model="value"></el-input></span>
<el-dialog title="剪裁" draggable v-model="cropperDialogVisible" :width="580" @closed="cropperClosed" destroy-on-close>
<pi-cropper :src="cropperFile.tempCropperFile" :compress="compress" :aspectRatio="aspectRatio" ref="cropperRef"></pi-cropper>
<template #footer>
<el-button @click="cropperDialogVisible=false" > </el-button>
<el-button type="primary" @click="cropperSave"> </el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import {defineAsyncComponent, ref, watch, onMounted, getCurrentInstance, nextTick} from 'vue'
import { genFileId } from 'element-plus'
import config from "@/config/upload"
const piCropper = defineAsyncComponent(() => import('@/components/piCropper'))
const emit = defineEmits(['update:modelValue'])
const cropperRef = ref(null)
const uploaderRef = ref(null)
const {proxy} = getCurrentInstance()
const props = defineProps({
modelValue: { type: String, default: "" },
height: {type: Number, default: 148},
width: {type: Number, default: 148},
title: { type: String, default: "" },
icon: { type: String, default: "el-icon-plus" },
action: { type: String, default: "" },
apiObj: { type: Object, default: () => {} },
name: { type: String, default: config.filename },
data: { type: Object, default: () => {} },
accept: { type: String, default: "image/gif, image/jpeg, image/png" },
maxSize: { type: Number, default: config.maxSizeFile },
limit: { type: Number, default: 1 },
autoUpload: { type: Boolean, default: true },
showFileList: { type: Boolean, default: false },
disabled: { type: Boolean, default: false },
round: { type: Boolean, default: false },
onSuccess: { type: Function, default: () => { return true } },
cropper: { type: Boolean, default: false },
compress: {type: Number, default: 1},
aspectRatio: {type: Number, default: NaN}
})
let value = ref("")
let file = ref(null)
let style = ref({
width: props.width + "px",
height: props.height + "px"
})
let cropperDialogVisible = ref(false)
let cropperFile = ref(null)
watch(() => props.modelValue, function (val) {
value.value = val
newFile(val)
})
watch(value, function (val) {
emit('update:modelValue', val)
})
onMounted(() => {
value.value = props.modelValue
newFile(props.modelValue)
})
function newFile(url){
if(url){
file.value = {
status: "success",
url: url
}
}else{
file.value = null
}
}
function cropperSave(){
cropperRef.value.getCropFile(f => {
f.uid = cropperFile.value.uid
cropperFile.value.raw = f
file.value = cropperFile.value
file.value.tempFile = URL.createObjectURL(file.value.raw)
uploaderRef.value.submit()
}, cropperFile.value.name, cropperFile.value.type)
cropperDialogVisible.value = false
}
function cropperClosed(){
URL.revokeObjectURL(cropperFile.value.tempCropperFile)
delete cropperFile.value.tempCropperFile
}
function handleRemove(){
clearFiles()
}
function clearFiles(){
URL.revokeObjectURL(file.value.tempFile)
value.value = ""
file.value = null
nextTick(()=>{
uploaderRef.value.clearFiles()
})
}
function change(f, files){
if(files.length > 1){
files.splice(0, 1)
}
if(props.cropper && f.status=='ready'){
const acceptIncludes = ["image/gif", "image/jpeg", "image/png"].includes(f.raw.type)
if(!acceptIncludes){
proxy.$notify.warning({
title: '上传文件警告',
message: '选择的文件非图像类文件'
})
return false
}
cropperFile.value = f
cropperFile.value.tempCropperFile = URL.createObjectURL(f.raw)
cropperDialogVisible.value = true
return false
}
file.value = f
if(f.status=='ready'){
f.tempFile = URL.createObjectURL(f.raw)
}
}
function before(file){
const acceptIncludes = props.accept.replace(/\s/g,"").split(",").includes(file.type)
if(!acceptIncludes){
proxy.$notify.warning({
title: '上传文件警告',
message: '选择的文件非图像类文件'
})
clearFiles()
return false
}
const maxSize = file.size / 1024 / 1024 < props.maxSize;
if (!maxSize) {
proxy.$message.warning(`上传文件大小不能超过 ${props.maxSize}MB!`);
clearFiles()
return false
}
}
function handleExceed(files){
const file = files[0]
file.uid = genFileId()
uploaderRef.value.handleStart(file)
}
function success(res, f){
//blob
URL.revokeObjectURL(f.tempFile)
delete f.tempFile
var os = props.onSuccess(res, f)
if(os!=undefined && os==false){
proxy.$nextTick(() => {
file.value = null
value.value = ""
})
return false
}
f.url = res.src
value.value = f.url
}
function error(err){
nextTick(()=>{
clearFiles()
})
proxy.$notify.error({
title: '上传文件未成功',
message: err
})
}
function request(param){
var apiObj = config.apiObj;
if(props.apiObj){
apiObj = props.apiObj;
}
const data = new FormData();
data.append(param.filename, param.file);
for (const key in param.data) {
data.append(key, param.data[key]);
}
apiObj(data, {
onUploadProgress: e => {
const complete = parseInt(((e.loaded / e.total) * 100) | 0, 10)
param.onProgress({percent: complete})
}
}).then(res => {
if (res.code === config.successCode) {
var response = config.parseData(res.data);
param.onSuccess(response)
}else {
param.onError(res.msg)
}
}).catch(err => {
param.onError(err)
})
}
</script>
<style scoped>
.el-form-item.is-error .pi-upload .el-upload--picture-card {border-color: var(--el-color-danger);}
.pi-upload .el-upload--picture-card {border-radius: 0;}
.pi-upload .uploader,.pi-upload:deep(.el-upload) {width: 100%;height: 100%;}
.pi-upload__img {width: 100%;height: 100%;position: relative;}
.pi-upload__img .image {width: 100%;height: 100%;}
.pi-upload__img-actions {position: absolute;top:0;right: 0;display: none;}
.pi-upload__img-actions span {display: flex;justify-content: center;align-items: center;width: 25px;height:25px;cursor: pointer;color: #fff;}
.pi-upload__img-actions span i {font-size: 12px;}
.pi-upload__img-actions .del {background: #F56C6C;}
.pi-upload__img:hover .pi-upload__img-actions {display: block;}
.pi-upload__img-slot {display: flex;justify-content: center;align-items: center;width: 100%;height: 100%;font-size: 12px;background-color: var(--el-fill-color-lighter);}
.pi-upload__uploading {width: 100%;height: 100%;position: relative;}
.pi-upload__progress {position: absolute;width: 100%;height: 100%;display: flex;justify-content: center;align-items: center;background-color: var(--el-overlay-color-lighter);z-index: 1;padding:10px;}
.pi-upload__progress .el-progress {width: 100%;}
.pi-upload__uploading .image {width: 100%;height: 100%;}
.pi-upload .file-empty {width: 100%;height: 100%;display: flex;justify-content: center;align-items: center;flex-direction: column;}
.pi-upload .file-empty i {font-size: 28px;}
.pi-upload .file-empty h4 {font-size: 12px;font-weight: normal;color: #8c939d;margin-top: 8px;}
.pi-upload.pi-upload-round {border-radius: 50%;overflow: hidden;}
.pi-upload.pi-upload-round .el-upload--picture-card {border-radius: 50%;}
.pi-upload.pi-upload-round .pi-upload__img-actions {top: auto;left: 0;right: 0;bottom: 0;}
.pi-upload.pi-upload-round .pi-upload__img-actions span {width: 100%;}
</style>

View File

@ -0,0 +1,257 @@
<template>
<div class="pi-upload-multiple">
<el-upload ref="uploaderRef" list-type="picture-card"
:auto-upload="autoUpload"
:disabled="disabled"
:action="action"
:name="name"
:data="data"
:http-request="request"
v-model:file-list="defaultFileList"
:show-file-list="showFileList"
:accept="accept"
:multiple="multiple"
:limit="limit"
:before-upload="before"
:on-success="success"
:on-error="error"
:on-preview="handlePreview"
:on-exceed="handleExceed">
<slot>
<el-icon><el-icon-plus/></el-icon>
</slot>
<template #tip>
<div v-if="tip" class="el-upload__tip">{{tip}}</div>
</template>
<template #file="{ file }">
<div class="pi-upload-list-item">
<el-image class="el-upload-list__item-thumbnail" :src="file.url" fit="cover" :preview-src-list="preview" :initial-index="preview.findIndex(n=>n==file.url)" hide-on-click-modal append-to-body :z-index="9999">
<template #placeholder>
<div class="pi-upload-multiple-image-slot">
Loading...
</div>
</template>
</el-image>
<div v-if="!disabled && file.status=='success'" class="pi-upload__item-actions">
<span class="del" @click="handleRemove(file)"><el-icon><el-icon-delete /></el-icon></span>
</div>
<div v-if="file.status=='ready' || file.status=='uploading'" class="pi-upload__item-progress">
<el-progress :percentage="file.percentage" :text-inside="true" :stroke-width="16"/>
</div>
</div>
</template>
</el-upload>
<span style="display:none!important"><el-input v-model="value"></el-input></span>
</div>
</template>
<script setup>
import config from "@/config/upload"
import Sortable from 'sortablejs'
import {ref, getCurrentInstance, watch, computed, onMounted} from "vue"
const emit = defineEmits(['update:modelValue'])
const {proxy} = getCurrentInstance()
const uploaderRef = ref(null)
const props = defineProps({
modelValue: { type: [String, Array], default: "" },
tip: { type: String, default: "" },
action: { type: String, default: "" },
apiObj: { type: Object, default: () => {} },
name: { type: String, default: config.filename },
data: { type: Object, default: () => {} },
accept: { type: String, default: "image/gif, image/jpeg, image/png" },
maxSize: { type: Number, default: config.maxSizeFile },
limit: { type: Number, default: 0 },
autoUpload: { type: Boolean, default: true },
showFileList: { type: Boolean, default: true },
multiple: { type: Boolean, default: true },
disabled: { type: Boolean, default: false },
draggable: { type: Boolean, default: false },
onSuccess: { type: Function, default: () => { return true } }
})
let value = ref("")
let defaultFileList = ref([])
watch(() => props.modelValue, (val) => {
if(Array.isArray(val)){
if (JSON.stringify(val) != JSON.stringify(formatArr(defaultFileList.value))) {
defaultFileList.value = val
value.value = val
}
}else{
if (val != toStr(defaultFileList.value)) {
defaultFileList.value = toArr(val)
value.value = val
}
}
})
watch(defaultFileList, (val) => {
emit('update:modelValue', Array.isArray(props.modelValue) ? formatArr(val) : toStr(val))
value.value = toStr(val)
}, {deep: true})
const preview = computed(() => {
return defaultFileList.value.map(v => v.url)
})
onMounted(() => {
if (props.modelValue) {
defaultFileList.value = Array.isArray(props.modelValue) ? props.modelValue : toArr(props.modelValue)
}
value.value = props.modelValue
if(!props.disabled && props.draggable){
rowDrop()
}
})
//
function toArr(str){
var _arr = [];
var arr = str.split(",");
arr.forEach(item => {
if(item){
var urlArr = item.split('/');
var fileName = urlArr[urlArr.length - 1]
_arr.push({
name: fileName,
url: item
})
}
})
return _arr;
}
//
function toStr(arr){
return arr.map(v => v.url).join(",")
}
//
function formatArr(arr){
var _arr = []
arr.forEach(item => {
if(item){
_arr.push({
name: item.name,
url: item.url
})
}
})
return _arr
}
//
function rowDrop(){
const itemBox = uploaderRef.value.$el.querySelector('.el-upload-list')
Sortable.create(itemBox, {
handle: ".el-upload-list__item",
animation: 200,
ghostClass: "ghost",
onEnd({ newIndex, oldIndex }) {
const tableData = defaultFileList.value
const currRow = tableData.splice(oldIndex, 1)[0]
tableData.splice(newIndex, 0, currRow)
}
})
}
function before(file){
if(!['image/jpeg','image/png','image/gif'].includes(file.type)){
proxy.$message.warning(`选择的文件类型 ${file.type} 非图像类文件`);
return false;
}
const maxSize = file.size / 1024 / 1024 < props.maxSize;
if (!maxSize) {
proxy.$message.warning(`上传文件大小不能超过 ${props.maxSize}MB!`);
return false;
}
}
function success(res, file){
var os = props.onSuccess(res, file)
if(os!=undefined && os==false){
return false
}
var response = config.parseData(res)
file.name = response.src
file.url = response.src
}
function error(err){
proxy.$notify.error({
title: '上传文件未成功',
message: err
})
}
function beforeRemove(uploadFile){
return proxy.$confirm(`是否移除 ${uploadFile.name} ?`, '提示', {
type: 'warning',
}).then(() => {
return true
}).catch(() => {
return false
})
}
function handleRemove(file){
uploaderRef.value.handleRemove(file)
//this.defaultFileList.splice(this.defaultFileList.findIndex(item => item.uid===file.uid), 1)
}
function handleExceed(){
proxy.$message.warning(`当前设置最多上传 ${props.limit} 个文件,请移除后上传!`)
}
function handlePreview(uploadFile){
window.open(uploadFile.url)
}
function request(param){
var apiObj = config.apiObj;
if(props.apiObj){
apiObj = props.apiObj;
}
const data = new FormData();
data.append(param.filename, param.file);
for (const key in param.data) {
data.append(key, param.data[key]);
}
apiObj(data, {
onUploadProgress: e => {
const complete = parseInt(((e.loaded / e.total) * 100) | 0, 10)
param.onProgress({percent: complete})
}
}).then(res => {
var response = config.parseData(res);
if(response.code == config.successCode){
param.onSuccess(res)
}else{
param.onError(response.msg || "未知错误")
}
}).catch(err => {
param.onError(err)
})
}
</script>
<style scoped>
.el-form-item.is-error .pi-upload-multiple:deep(.el-upload--picture-card) {border-color: var(--el-color-danger);}
:deep(.el-upload-list__item) {transition:none;border-radius: 0;}
.pi-upload-multiple:deep(.el-upload-list__item.el-list-leave-active) {position: static!important;}
.pi-upload-multiple:deep(.el-upload--picture-card) {border-radius: 0;}
.pi-upload-list-item {width: 100%;height: 100%;position: relative;}
.pi-upload-multiple .el-image {display: block;}
.pi-upload-multiple .el-image:deep(img) {-webkit-user-drag: none;}
.pi-upload-multiple-image-slot {display: flex;justify-content: center;align-items: center;width: 100%;height: 100%;font-size: 12px;}
.pi-upload-multiple .el-upload-list__item:hover .pi-upload__item-actions{display: block;}
.pi-upload__item-actions {position: absolute;top:0;right: 0;display: none;}
.pi-upload__item-actions span {display: flex;justify-content: center;align-items: center;;width: 25px;height:25px;cursor: pointer;color: #fff;}
.pi-upload__item-actions span i {font-size: 12px;}
.pi-upload__item-actions .del {background: #F56C6C;}
.pi-upload__item-progress {position: absolute;width: 100%;height: 100%;top: 0;left: 0;background-color: var(--el-overlay-color-lighter);}
</style>

18
src/config/upload.ts Normal file
View File

@ -0,0 +1,18 @@
import API from "@/api";
//上传配置
export default {
apiObj: API.system.upload, //上传请求API对象
filename: "file", //form请求时文件的key
successCode: 0, //请求完成代码
maxSize: 10, //最大文件大小 默认10MB
parseData: function (res) {
return {
fileName: res.fileName,
src: res.filePath,
}
},
apiObjFile: API.system.upload, //附件上传请求API对象
maxSizeFile: 10 //最大文件大小 默认10MB
}

View File

@ -178,7 +178,7 @@ function closeHandle() {
// //
function handleUser(command) { function handleUser(command) {
if (command === "uc") { if (command === "uc") {
router.push({path: '/usercenter'}); router.push({path: '/system/user'});
} }
if (command === "cmd") { if (command === "cmd") {
router.push({path: '/cmd'}); router.push({path: '/cmd'});

View File

@ -12,6 +12,7 @@ import errorHandler from "@/utils/errorHandler";
import piDialog from "@/components/piDialog" import piDialog from "@/components/piDialog"
import piTable from "@/components/piTable" import piTable from "@/components/piTable"
import piPage from "@/components/piPage" import piPage from "@/components/piPage"
import piUpload from "@/components/piUpload"
export default { export default {
install(app: App) { install(app: App) {
@ -19,6 +20,7 @@ export default {
app.component('piDialog', piDialog) app.component('piDialog', piDialog)
app.component('piTable', piTable) app.component('piTable', piTable)
app.component('piPage', piPage) app.component('piPage', piPage)
app.component('piUpload', piUpload)
//注册全局指令 //注册全局指令
app.directive('auth', auth) app.directive('auth', auth)

View File

@ -143,6 +143,24 @@ const tools = {
} }
} }
return arr return arr
},
getBrowser(userAgent: string) {
// 检测浏览器类型和版本
if (/Opera|OPR/.test(userAgent)) {
return 'Opera';
} else if (/Edg/.test(userAgent)) {
return 'Edge';
} else if (/Chrome/.test(userAgent)) {
return 'Chrome';
} else if (/Safari/.test(userAgent)) {
return 'Safari';
} else if (/Firefox/.test(userAgent)) {
return 'Firefox';
} else if (/MSIE|Trident/.test(userAgent)) {
return 'IE';
} else {
return '未知'
}
} }
} }

View File

@ -3,13 +3,128 @@
<el-row :gutter="20"> <el-row :gutter="20">
<el-col :lg="8" :xs="24" :sm="24" :md="24"> <el-col :lg="8" :xs="24" :sm="24" :md="24">
<el-card shadow="never" class="pi-left"> <el-card shadow="never" class="pi-left">
<el-avatar :src="userInfo.avatar" :size="64">{{nicknameF}}</el-avatar> <div class="head">
<p style="font-size: 20px;font-weight: bold;margin: 8px;">{{userInfo.nickname||userInfo.username}}</p> <el-avatar :src="userInfo.avatar" :size="64">{{ nicknameF }}</el-avatar>
<p>{{userInfo.bio}}</p> <p style="font-size: 24px;font-weight: bold;margin: 8px;">
{{ userInfo.nickname || userInfo.username }}</p>
<p style="font-size: 14px; color: #888888;">{{ userInfo.bio }}</p>
</div>
<div class="down">
<div class="item">
<!--岗位-->
<el-icon size="15">
<component :is="'pi-icon-post'"/>
</el-icon>
<el-text>{{ userInfo.posts.map(item => item.post_name)?.join(" && ") }}</el-text>
</div>
<div class="item">
<!--生日-->
<el-icon size="16">
<component :is="'pi-icon-birthday'"/>
</el-icon>
<el-text>{{ userInfo.birthday || "保密" }}</el-text>
</div>
<div class="item">
<!--性别-->
<el-icon size="16">
<component :is="'pi-icon-sex'"/>
</el-icon>
<el-text>{{ sex }}</el-text>
</div>
<div class="item">
<!--部门-->
<el-icon size="16">
<component :is="'pi-icon-dept'"/>
</el-icon>
<el-text>{{ userInfo.dept?.join(" - ") }}</el-text>
</div>
<el-divider></el-divider>
<!--标签-->
<div class="tags">
<el-tag v-for="tag in tags" :key="tag" closable @close="removeTag(tag)">{{ tag }}</el-tag>
<el-input
v-if="tagVisible"
v-model="tagInput"
ref="tagRef"
size="small"
@keyup.enter="saveTag"
@blur="saveTag"
style="width: 120px; margin-bottom: 10px;"
/>
<el-button type="primary" plain v-else size="small" @click="tagShow"
icon="el-icon-plus" style="margin-bottom: 10px;"></el-button>
</div>
</div>
</el-card> </el-card>
</el-col> </el-col>
<el-col :lg="16" :xs="24" :sm="24" :md="24"> <el-col :lg="16" :xs="24" :sm="24" :md="24">
<el-card shadow="never"> <el-card shadow="never">
<el-tabs class="pi-right">
<el-tab-pane label="基本信息" name="0">
<el-form ref="infoRef" :model="form" style="max-width: 500px;" label-width="120px">
<el-form-item label="头像" prop="avatar">
<pi-upload v-model="form.avatar" title="头像" :cropper="true" :compress="1" :aspectRatio="1/1"></pi-upload>
</el-form-item>
<el-form-item label="昵称" prop="nickname">
<el-input v-model="form.nickname"></el-input>
</el-form-item>
<el-form-item label="性别" prop="sex">
<el-select v-model="form.sex">
<el-option label="保密" :value="0" disabled/>
<el-option label="男" :value="1" />
<el-option label="女" :value="2" />
</el-select>
</el-form-item>
<el-form-item label="生日" prop="birthday">
<el-date-picker v-model="form.birthday" type="date" style="width: 100%;" value-format="YYYY-MM-DD"/>
</el-form-item>
<el-form-item label="介绍" prop="bio">
<el-input v-model="form.bio" type="textarea"></el-input>
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="isSaveing" @click="saveInfo"> </el-button>
</el-form-item>
</el-form>
</el-tab-pane>
<el-tab-pane label="修改密码" name="1">
<el-alert title="密码更新成功后,您将被重定向到登录页面,您可以使用新密码重新登录。" type="info" show-icon style="margin-bottom: 15px;"/>
<el-form ref="passRef" :model="form2" :rules="rules" label-width="120px" style="max-width: 500px;">
<el-form-item label="当前密码" prop="old_password">
<el-input v-model="form2.old_password" type="password" show-password placeholder="请输入当前密码"></el-input>
<div class="el-form-item-msg">必须提供当前登录用户密码才能进行更改</div>
</el-form-item>
<el-form-item label="新密码" prop="new_password">
<el-input v-model="form2.new_password" type="password" show-password placeholder="请输入新密码"></el-input>
<pi-password-strength v-model="form2.new_password"></pi-password-strength>
<div class="el-form-item-msg">请输入包含英文数字的6位以上密码</div>
</el-form-item>
<el-form-item label="确认新密码" prop="confirm_password">
<el-input v-model="form2.confirm_password" type="password" show-password placeholder="请再次输入新密码"></el-input>
</el-form-item>
<el-form-item>
<el-button :loading="isSaveing" type="primary" @click="savePass">保存密码</el-button>
</el-form-item>
</el-form>
</el-tab-pane>
<el-tab-pane label="登录日志" name="2">
<pi-table :apiObj="api.auth.loginLog" stripe hide-act :page-size="10">
<el-table-column label="#" type="index" width="50"></el-table-column>
<el-table-column label="操作" prop="title"></el-table-column>
<el-table-column label="状态" prop="code">
<template #default="scope">
{{scope.row.code == 0 ? '成功' : '失败'}}
</template>
</el-table-column>
<el-table-column label="IP" prop="ip"></el-table-column>
<el-table-column label="UA" prop="ua">
<template #default="scope">
{{tools.getBrowser(scope.row.ua)}}
</template>
</el-table-column>
<el-table-column label="时间" prop="create_time"></el-table-column>
</pi-table>
</el-tab-pane>
</el-tabs>
</el-card> </el-card>
</el-col> </el-col>
</el-row> </el-row>
@ -18,29 +133,164 @@
<script setup> <script setup>
import tools from "@/utils/tools" import tools from "@/utils/tools"
import api from "@/api/index.js"; import api from "@/api/index";
import {ref, onMounted} from "vue"; import {ref, onMounted, nextTick, getCurrentInstance, computed} from "vue";
import piPasswordStrength from '@/components/piPasswordStrength'
defineOptions({ defineOptions({
name: "systemUser" name: "systemUser"
}) })
const {proxy} = getCurrentInstance()
let userInfo = ref(tools.data.get("USER_INFO")); let userInfo = ref(tools.data.get("USER_INFO"));
const passRef = ref(null)
let nickname = userInfo.value.nickname || userInfo.value.username; let nickname = userInfo.value.nickname || userInfo.value.username;
let nicknameF = nickname.substring(0, 1); let nicknameF = nickname.substring(0, 1);
let tagVisible = ref(false)
let tagInput = ref("")
let tags = ref()
const tagRef = ref(null)
const form = ref({})
const isSaveing = ref(false)
const form2 = ref({
old_password: "",
new_password: "",
confirm_password: ""
})
const rules = ref({
old_password: [
{ required: true, message: '请输入当前密码'}
],
new_password: [
{ required: true, message: '请输入新密码'},
{ min: 6, max: 30, message: '密码长度在6-30位之间' },
{validator: (rule, value, callback) => {
if(value === form2.value.old_password) {
callback(new Error('新旧密码不能一致'));
} else {
callback();
}
}}
],
confirm_password: [
{ required: true, message: '请再次输入新密码'},
{ min: 6, max: 30, message: '密码长度在6-30位之间' },
{validator: (rule, value, callback) => {
if (value !== form2.value.new_password) {
callback(new Error('两次输入密码不一致'));
} else {
callback();
}
}}
]
})
onMounted(() => { onMounted(() => {
loadUser() loadUser()
}) })
const sex = computed(() => {
if (userInfo.value.sex == 1) {
return '男'
}else if (userInfo.value.sex == 2) {
return '女'
}else {
return '保密'
}
})
async function loadUser() { async function loadUser() {
const res = await api.auth.info() const res = await api.auth.info()
userInfo.value = res.data userInfo.value = res.data
tags.value = userInfo.value.tags ? userInfo.value.tags?.split(",") : []
Object.assign(form.value, res.data)
} }
async function saveTag() {
tagVisible.value = false
if (tagInput.value) {
tags.value.push(tagInput.value)
tagInput.value = ""
await saveTags()
}
}
async function removeTag(tag) {
tags.value.splice(tags.value.indexOf(tag), 1)
await saveTags()
}
function tagShow() {
tagVisible.value = true
nextTick(() => {
tagRef.value.input?.focus()
})
}
async function saveTags() {
const res = await api.auth.saveTag({tags: tags.value?.toString()})
proxy.$message.success(res.msg)
}
async function saveInfo() {
isSaveing.value = true
const res = await api.auth.saveInfo(form.value)
proxy.$message.success(res.msg)
Object.assign(userInfo.value, form.value)
tools.data.set("USER_INFO", userInfo.value)
isSaveing.value = false
}
function sexOption(v) {
console.log(v)
}
async function savePass() {
const validate = await passRef.value.validate().catch(() => {});
if(!validate){ return false }
const res = await api.auth.savePass(form2.value)
proxy.$message.success(res.msg)
}
</script> </script>
<style scoped> <style lang="scss" scoped>
.pi-left {text-align: center;} .pi-left {
padding: 30px 60px;
}
.pi-left .head {
text-align: center;
}
.pi-left .down {
margin: 30px 0
}
.pi-left .down .item {
margin-top: 10px;
display: flex;
align-items: center
}
.pi-left .down .item .el-icon {
margin-right: 10px;
}
.pi-left .down .tags .el-tag {
margin-bottom: 10px;
}
.pi-left .down .tags .el-tag + .el-tag {
margin-right: 10px;
margin-left: 0;
}
.pi-left .down .tags span:first-of-type {
margin-right: 10px;
}
.pi-left .down .tags span:last-of-type {
margin-right: 10px;
}
</style> </style>