feat: 新增个人中心页面

This commit is contained in:
valarchie 2023-07-25 19:49:16 +08:00
parent fb14f5d075
commit 167d4122b7
32 changed files with 1093 additions and 5 deletions

View File

@ -47,8 +47,10 @@
"nprogress": "^0.2.0",
"path": "^0.12.7",
"pinia": "^2.1.4",
"qrcode": "^1.5.3",
"pinyin-pro": "^3.15.2",
"cropperjs": "^1.5.13",
"vue-tippy": "^6.2.0",
"qrcode": "^1.5.3",
"qs": "^6.11.2",
"responsive-storage": "^2.2.0",
"sortablejs": "^1.15.0",

39
pnpm-lock.yaml generated
View File

@ -28,6 +28,7 @@ specifiers:
autoprefixer: ^10.4.14
axios: ^1.4.0
cloc: ^2.11.0
cropperjs: ^1.5.13
crypto-js: ^4.1.1
cssnano: ^6.0.1
dayjs: ^1.11.8
@ -86,6 +87,7 @@ specifiers:
vue: ^3.3.4
vue-eslint-parser: ^9.3.1
vue-router: ^4.2.2
vue-tippy: ^6.2.0
vue-tsc: ^1.8.1
vue-types: ^5.1.0
xlsx: ^0.18.5
@ -98,6 +100,7 @@ dependencies:
"@vueuse/motion": 2.0.0_vue@3.3.4
animate.css: 4.1.1
axios: 1.4.0
cropperjs: 1.5.13
crypto-js: 4.1.1
dayjs: 1.11.8
echarts: 5.4.2
@ -117,6 +120,7 @@ dependencies:
typeit: 8.7.1
vue: 3.3.4
vue-router: 4.2.2_vue@3.3.4
vue-tippy: 6.2.0_vue@3.3.4
vue-types: 5.1.0_vue@3.3.4
xlsx: 0.18.5
@ -1447,6 +1451,13 @@ packages:
dev: true
optional: true
/@popperjs/core/2.11.8:
resolution:
{
integrity: sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==
}
dev: false
/@pureadmin/descriptions/1.1.1_element-plus@2.3.6:
resolution:
{
@ -3150,6 +3161,13 @@ packages:
}
dev: true
/cropperjs/1.5.13:
resolution:
{
integrity: sha512-by7jKAo73y5/Do0K6sxdTKHgndY0NMjG2bEdgeJxycbcmHuCiMXqw8sxy5C5Y5WTOTcDGmbT7Sr5CgKOXR06OA==
}
dev: false
/cross-spawn/7.0.3:
resolution:
{
@ -8676,6 +8694,15 @@ packages:
readable-stream: 3.6.2
dev: true
/tippy.js/6.3.7:
resolution:
{
integrity: sha512-E1d3oP2emgJ9dRQZdf3Kkn0qJgI6ZLpyS5z6ZkY1DF3kaQaBsGZsndEpHwx+eC+tYM41HaSNvNtLx8tU57FzTQ==
}
dependencies:
"@popperjs/core": 2.11.8
dev: false
/to-fast-properties/2.0.0:
resolution:
{
@ -9178,6 +9205,18 @@ packages:
he: 1.2.0
dev: true
/vue-tippy/6.2.0_vue@3.3.4:
resolution:
{
integrity: sha512-UytUItp2ZDLXUwAotmioz02uLQoaAl5iVM+5yKsQWrXr29L9ivavtkL684FqbmOfbeCypBw+rVKsXhwdnCt/Cg==
}
peerDependencies:
vue: ^3.2.0
dependencies:
tippy.js: 6.3.7
vue: 3.3.4
dev: false
/vue-tsc/1.8.1_typescript@5.0.4:
resolution:
{

View File

@ -36,11 +36,41 @@ export type TokenDTO = {
};
export type CurrentLoginUserDTO = {
userInfo: any;
userInfo: CurrentUserInfoDTO;
roleKey: string;
permissions: Set<string>;
};
/**
* User
*/
export interface CurrentUserInfoDTO {
avatar?: string;
createTime?: Date;
creatorId?: number;
creatorName?: string;
deptId?: number;
deptName?: string;
email?: string;
loginDate?: Date;
loginIp?: string;
nickName?: string;
phoneNumber?: string;
postId?: number;
postName?: string;
remark?: string;
roleId?: number;
roleName?: string;
sex?: number;
status?: number;
updaterId?: number;
updaterName?: string;
updateTime?: Date;
userId?: number;
username?: string;
userType?: number;
}
export type DictionaryData = {
label: string;
value: number;

View File

@ -56,6 +56,26 @@ export interface UserRequest {
username?: string;
}
/**
* UpdateProfileCommand
*/
export interface UserProfileRequest {
email?: string;
nickName?: string;
phoneNumber?: string;
sex?: number;
userId?: number;
}
/**
* ResetPasswordCommand
*/
export interface ResetPasswordRequest {
newPassword?: string;
oldPassword?: string;
userId?: number;
}
/**
*
*/
@ -120,3 +140,37 @@ export const exportUserExcelApi = (params: UserQuery, fileName: string) => {
params
});
};
/** 用户头像上传 */
export const uploadUserAvatarApi = data => {
return http.request<ResponseData<void>>(
"post",
"/system/user/profile/avatar",
{
data
},
{
headers: {
"Content-Type": "multipart/form-data"
}
}
);
};
/** 更改用户资料 */
export const updateUserProfileApi = (data?: UserProfileRequest) => {
return http.request<ResponseData<void>>("put", "/system/user/profile", {
data
});
};
/** 更改当前用户密码 */
export const updateCurrentUserPasswordApi = (data?: ResetPasswordRequest) => {
return http.request<ResponseData<void>>(
"put",
"/system/user/profile/password",
{
data
}
);
};

View File

@ -0,0 +1,7 @@
import reCropper from "./src";
import { withInstall } from "@pureadmin/utils";
/** 图片裁剪组件 */
export const ReCropper = withInstall(reCropper);
export default ReCropper;

View File

@ -0,0 +1,11 @@
@import "cropperjs/dist/cropper.css";
@import "tippy.js/dist/tippy.css";
@import "tippy.js/themes/light.css";
@import "tippy.js/animations/perspective.css";
.re-circled {
.cropper-view-box,
.cropper-face {
border-radius: 50%;
}
}

View File

@ -0,0 +1,439 @@
import "./circled.css";
import Cropper from "cropperjs";
import { ElUpload } from "element-plus";
import type { CSSProperties } from "vue";
import { useResizeObserver } from "@vueuse/core";
import { longpress } from "@/directives/longpress";
import { useTippy, directive as tippy } from "vue-tippy";
import { delay, debounce, isArray, downloadByBase64 } from "@pureadmin/utils";
import {
ref,
unref,
computed,
PropType,
onMounted,
onUnmounted,
defineComponent
} from "vue";
import {
Reload,
Upload,
ArrowH,
ArrowV,
ArrowUp,
ArrowDown,
ArrowLeft,
ChangeIcon,
ArrowRight,
RotateLeft,
SearchPlus,
RotateRight,
SearchMinus,
DownloadIcon
} from "./svg";
type Options = Cropper.Options;
const defaultOptions: Options = {
aspectRatio: 1,
zoomable: true,
zoomOnTouch: true,
zoomOnWheel: true,
cropBoxMovable: true,
cropBoxResizable: true,
toggleDragModeOnDblclick: true,
autoCrop: true,
background: true,
highlight: true,
center: true,
responsive: true,
restore: true,
checkCrossOrigin: true,
checkOrientation: true,
scalable: true,
modal: true,
guides: true,
movable: true,
rotatable: true
};
const props = {
src: { type: String, required: true },
alt: { type: String },
circled: { type: Boolean, default: false },
realTimePreview: { type: Boolean, default: true },
height: { type: [String, Number], default: "360px" },
crossorigin: {
type: String as PropType<"" | "anonymous" | "use-credentials" | undefined>,
default: undefined
},
imageStyle: { type: Object as PropType<CSSProperties>, default: () => ({}) },
options: { type: Object as PropType<Options>, default: () => ({}) }
};
export default defineComponent({
name: "ReCropper",
props,
setup(props, { attrs, emit }) {
const tippyElRef = ref<ElRef<HTMLImageElement>>();
const imgElRef = ref<ElRef<HTMLImageElement>>();
const cropper = ref<Nullable<Cropper>>();
const isReady = ref(false);
const imgBase64 = ref();
const inCircled = ref(props.circled);
const inSrc = ref(props.src);
let scaleX = 1;
let scaleY = 1;
const debounceRealTimeCroppered = debounce(realTimeCroppered, 80);
const getImageStyle = computed((): CSSProperties => {
return {
height: props.height,
maxWidth: "100%",
...props.imageStyle
};
});
const getClass = computed(() => {
return [
attrs.class,
{
["re-circled"]: inCircled.value
}
];
});
const iconClass = computed(() => {
return [
"p-[6px]",
"h-[30px]",
"w-[30px]",
"outline-none",
"rounded-[4px]",
"cursor-pointer",
"hover:bg-[rgba(0,0,0,0.06)]"
];
});
const getWrapperStyle = computed((): CSSProperties => {
return { height: `${props.height}`.replace(/px/, "") + "px" };
});
onMounted(init);
onUnmounted(() => {
cropper.value?.destroy();
});
useResizeObserver(tippyElRef, () => {
handCropper("reset");
});
async function init() {
const imgEl = unref(imgElRef);
if (!imgEl) return;
cropper.value = new Cropper(imgEl, {
...defaultOptions,
ready: () => {
isReady.value = true;
realTimeCroppered();
delay(400).then(() => emit("readied", cropper.value));
},
crop() {
debounceRealTimeCroppered();
},
zoom() {
debounceRealTimeCroppered();
},
cropmove() {
debounceRealTimeCroppered();
},
...props.options
});
}
function realTimeCroppered() {
props.realTimePreview && croppered();
}
function croppered() {
if (!cropper.value) return;
const canvas = inCircled.value
? getRoundedCanvas()
: cropper.value.getCroppedCanvas();
// https://developer.mozilla.org/zh-CN/docs/Web/API/HTMLCanvasElement/toBlob
canvas.toBlob(blob => {
if (!blob) return;
const fileReader: FileReader = new FileReader();
fileReader.readAsDataURL(blob);
fileReader.onloadend = e => {
if (!e.target?.result || !blob) return;
imgBase64.value = e.target.result;
emit("cropper", {
base64: e.target.result,
blob,
info: { size: blob.size, ...cropper.value.getData() }
});
};
fileReader.onerror = () => {
emit("error");
};
});
}
function getRoundedCanvas() {
const sourceCanvas = cropper.value!.getCroppedCanvas();
const canvas = document.createElement("canvas");
const context = canvas.getContext("2d")!;
const width = sourceCanvas.width;
const height = sourceCanvas.height;
canvas.width = width;
canvas.height = height;
context.imageSmoothingEnabled = true;
context.drawImage(sourceCanvas, 0, 0, width, height);
context.globalCompositeOperation = "destination-in";
context.beginPath();
context.arc(
width / 2,
height / 2,
Math.min(width, height) / 2,
0,
2 * Math.PI,
true
);
context.fill();
return canvas;
}
function handCropper(event: string, arg?: number | Array<number>) {
if (event === "scaleX") {
scaleX = arg = scaleX === -1 ? 1 : -1;
}
if (event === "scaleY") {
scaleY = arg = scaleY === -1 ? 1 : -1;
}
arg && isArray(arg)
? cropper.value?.[event]?.(...arg)
: cropper.value?.[event]?.(arg);
}
function beforeUpload(file) {
const reader = new FileReader();
reader.readAsDataURL(file);
inSrc.value = "";
reader.onload = e => {
inSrc.value = e.target?.result as string;
};
reader.onloadend = () => {
init();
};
return false;
}
const menuContent = defineComponent({
directives: {
tippy,
longpress
},
setup() {
return () => (
<div class="flex flex-wrap w-[60px] justify-between">
<ElUpload
accept="image/*"
show-file-list={false}
before-upload={beforeUpload}
>
<Upload
class={iconClass.value}
v-tippy={{
content: "上传",
placement: "left-start"
}}
/>
</ElUpload>
<DownloadIcon
class={iconClass.value}
v-tippy={{
content: "下载",
placement: "right-start"
}}
onClick={() => downloadByBase64(imgBase64.value, "cropping.png")}
/>
<ChangeIcon
class={iconClass.value}
v-tippy={{
content: "圆形、矩形裁剪",
placement: "left-start"
}}
onClick={() => {
inCircled.value = !inCircled.value;
realTimeCroppered();
}}
/>
<Reload
class={iconClass.value}
v-tippy={{
content: "重置",
placement: "right-start"
}}
onClick={() => handCropper("reset")}
/>
<ArrowUp
class={iconClass.value}
v-tippy={{
content: "上移(可长按)",
placement: "left-start"
}}
v-longpress={[() => handCropper("move", [0, -10]), "0:100"]}
/>
<ArrowDown
class={iconClass.value}
v-tippy={{
content: "下移(可长按)",
placement: "right-start"
}}
v-longpress={[() => handCropper("move", [0, 10]), "0:100"]}
/>
<ArrowLeft
class={iconClass.value}
v-tippy={{
content: "左移(可长按)",
placement: "left-start"
}}
v-longpress={[() => handCropper("move", [-10, 0]), "0:100"]}
/>
<ArrowRight
class={iconClass.value}
v-tippy={{
content: "右移(可长按)",
placement: "right-start"
}}
v-longpress={[() => handCropper("move", [10, 0]), "0:100"]}
/>
<ArrowH
class={iconClass.value}
v-tippy={{
content: "水平翻转",
placement: "left-start"
}}
onClick={() => handCropper("scaleX", -1)}
/>
<ArrowV
class={iconClass.value}
v-tippy={{
content: "垂直翻转",
placement: "right-start"
}}
onClick={() => handCropper("scaleY", -1)}
/>
<RotateLeft
class={iconClass.value}
v-tippy={{
content: "逆时针旋转",
placement: "left-start"
}}
onClick={() => handCropper("rotate", -45)}
/>
<RotateRight
class={iconClass.value}
v-tippy={{
content: "顺时针旋转",
placement: "right-start"
}}
onClick={() => handCropper("rotate", 45)}
/>
<SearchPlus
class={iconClass.value}
v-tippy={{
content: "放大(可长按)",
placement: "left-start"
}}
v-longpress={[() => handCropper("zoom", 0.1), "0:100"]}
/>
<SearchMinus
class={iconClass.value}
v-tippy={{
content: "缩小(可长按)",
placement: "right-start"
}}
v-longpress={[() => handCropper("zoom", -0.1), "0:100"]}
/>
</div>
);
}
});
function onContextmenu(event) {
event.preventDefault();
const { show, setProps } = useTippy(tippyElRef, {
content: menuContent,
arrow: false,
theme: "light",
trigger: "manual",
interactive: true,
appendTo: "parent",
// hideOnClick: false,
animation: "perspective",
placement: "bottom-start"
});
setProps({
getReferenceClientRect: () => ({
width: 0,
height: 0,
top: event.clientY,
bottom: event.clientY,
left: event.clientX,
right: event.clientX
})
});
show();
}
return {
inSrc,
props,
imgElRef,
tippyElRef,
getClass,
getWrapperStyle,
getImageStyle,
isReady,
croppered,
onContextmenu
};
},
render() {
const {
inSrc,
isReady,
getClass,
getImageStyle,
onContextmenu,
getWrapperStyle
} = this;
const { alt, crossorigin } = this.props;
return inSrc ? (
<div
ref="tippyElRef"
class={getClass}
style={getWrapperStyle}
onContextmenu={event => onContextmenu(event)}
>
<img
v-show={isReady}
ref="imgElRef"
style={getImageStyle}
src={inSrc}
alt={alt}
crossorigin={crossorigin}
/>
</div>
) : null;
}
});

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 1024 1024"><path fill="currentColor" d="M862 465.3h-81c-4.6 0-9 2-12.1 5.5L550 723.1V160c0-4.4-3.6-8-8-8h-60c-4.4 0-8 3.6-8 8v563.1L255.1 470.8c-3-3.5-7.4-5.5-12.1-5.5h-81c-6.8 0-10.5 8.1-6 13.2L487.9 861a31.96 31.96 0 0 0 48.3 0L868 478.5c4.5-5.2.8-13.2-6-13.2z"/></svg>

After

Width:  |  Height:  |  Size: 347 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" class="icon" viewBox="0 0 1024 1024"><path d="m296.992 216.992-272 272L3.008 512l21.984 23.008 272 272 46.016-46.016L126.016 544h772L680.992 760.992l46.016 46.016 272-272L1020.992 512l-21.984-23.008-272-272-46.048 46.048L898.016 480h-772l216.96-216.992z"/></svg>

After

Width:  |  Height:  |  Size: 325 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 1024 1024"><path fill="currentColor" d="M872 474H286.9l350.2-304c5.6-4.9 2.2-14-5.2-14h-88.5c-3.9 0-7.6 1.4-10.5 3.9L155 487.8a31.96 31.96 0 0 0 0 48.3L535.1 866c1.5 1.3 3.3 2 5.2 2h91.5c7.4 0 10.8-9.2 5.2-14L286.9 550H872c4.4 0 8-3.6 8-8v-60c0-4.4-3.6-8-8-8z"/></svg>

After

Width:  |  Height:  |  Size: 344 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 1024 1024"><path fill="currentColor" d="M869 487.8 491.2 159.9c-2.9-2.5-6.6-3.9-10.5-3.9h-88.5c-7.4 0-10.8 9.2-5.2 14l350.2 304H152c-4.4 0-8 3.6-8 8v60c0 4.4 3.6 8 8 8h585.1L386.9 854c-5.6 4.9-2.2 14 5.2 14h91.5c1.9 0 3.8-.7 5.2-2L869 536.2a32.07 32.07 0 0 0 0-48.4z"/></svg>

After

Width:  |  Height:  |  Size: 351 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 1024 1024"><path fill="currentColor" d="M868 545.5 536.1 163a31.96 31.96 0 0 0-48.3 0L156 545.5a7.97 7.97 0 0 0 6 13.2h81c4.6 0 9-2 12.1-5.5L474 300.9V864c0 4.4 3.6 8 8 8h60c4.4 0 8-3.6 8-8V300.9l218.9 252.3c3 3.5 7.4 5.5 12.1 5.5h81c6.8 0 10.5-8 6-13.2z"/></svg>

After

Width:  |  Height:  |  Size: 339 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" class="icon" viewBox="0 0 1024 1024"><path d="m512 67.008-23.008 21.984-256 256 46.048 46.048L480 190.016v644L279.008 632.96l-46.048 46.08 256 256 23.008 21.984 23.008-21.984 256-256-46.016-46.016L544 834.016v-644l200.992 200.96 46.016-45.984-256-256z"/></svg>

After

Width:  |  Height:  |  Size: 323 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" class="icon" viewBox="0 0 1024 1024"><path d="M956.8 988.8H585.6c-16 0-25.6-9.6-25.6-28.8V576c0-16 9.6-28.8 25.6-28.8h371.2c16 0 25.6 9.6 25.6 28.8v384c0 16-9.6 28.8-25.6 28.8zM608 937.6h326.4V598.4H608v339.2zm-121.6 44.8C262.4 982.4 144 848 144 595.2c0-19.2 9.6-28.8 25.6-28.8s25.6 12.8 25.6 28.8c0 220.8 96 326.4 288 326.4 16 0 25.6 12.8 25.6 28.8s-6.4 32-22.4 32z"/><path d="M262.4 694.4c-6.4 0-9.6-3.2-16-6.4L160 601.6c-9.6-9.6-9.6-22.4 0-28.8s22.4-9.6 28.8 0l86.4 86.4c9.6 9.6 9.6 22.4 0 28.8-3.2 3.2-6.4 6.4-12.8 6.4z"/><path d="M86.4 694.4c-6.4 0-9.6-3.2-16-6.4-9.6-9.6-9.6-22.4 0-28.8l86.4-86.4c9.6-9.6 22.4-9.6 28.8 0 9.6 9.6 9.6 22.4 0 28.8L99.2 688c-3.2 3.2-6.4 6.4-12.8 6.4zm790.4-249.6c-16 0-28.8-12.8-28.8-32 0-224-99.2-336-300.8-336-16 0-28.8-12.8-28.8-32s9.6-32 28.8-32c233.6 0 355.2 137.6 355.2 396.8 0 22.4-9.6 35.2-25.6 35.2z"/><path d="M876.8 448c-6.4 0-9.6-3.2-16-6.4l-86.4-86.4c-9.6-9.6-9.6-22.4 0-28.8s22.4-9.6 28.8 0l86.4 86.4c9.6 9.6 9.6 22.4 0 28.8 0 3.2-6.4 6.4-12.8 6.4z"/><path d="M876.8 448c-6.4 0-9.6-3.2-16-6.4-9.6-9.6-9.6-22.4 0-28.8l86.4-86.4c9.6-9.6 22.4-9.6 28.8 0s9.6 22.4 0 28.8l-86.4 86.4c-3.2 3.2-6.4 6.4-12.8 6.4zM288 524.8C156.8 524.8 48 416 48 278.4S156.8 35.2 288 35.2 528 144 528 281.6 419.2 524.8 288 524.8zm-3.2-432c-99.2 0-179.2 83.2-179.2 185.6S185.6 464 284.8 464 464 380.8 464 278.4 384 92.8 284.8 92.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 1024 1024"><path fill="currentColor" d="M505.7 661a8 8 0 0 0 12.6 0l112-141.7c4.1-5.2.4-12.9-6.3-12.9h-74.1V168c0-4.4-3.6-8-8-8h-60c-4.4 0-8 3.6-8 8v338.3H400c-6.7 0-10.4 7.7-6.3 12.9l112 141.8zM878 626h-60c-4.4 0-8 3.6-8 8v154H214V634c0-4.4-3.6-8-8-8h-60c-4.4 0-8 3.6-8 8v198c0 17.7 14.3 32 32 32h684c17.7 0 32-14.3 32-32V634c0-4.4-3.6-8-8-8z"/></svg>

After

Width:  |  Height:  |  Size: 428 B

View File

@ -0,0 +1,31 @@
import Reload from "./reload.svg?component";
import Upload from "./upload.svg?component";
import ArrowH from "./arrow-h.svg?component";
import ArrowV from "./arrow-v.svg?component";
import ArrowUp from "./arrow-up.svg?component";
import ChangeIcon from "./change.svg?component";
import ArrowDown from "./arrow-down.svg?component";
import ArrowLeft from "./arrow-left.svg?component";
import DownloadIcon from "./download.svg?component";
import ArrowRight from "./arrow-right.svg?component";
import RotateLeft from "./rotate-left.svg?component";
import SearchPlus from "./search-plus.svg?component";
import RotateRight from "./rotate-right.svg?component";
import SearchMinus from "./search-minus.svg?component";
export {
Reload,
Upload,
ArrowH,
ArrowV,
ArrowUp,
ArrowDown,
ArrowLeft,
ChangeIcon,
ArrowRight,
RotateLeft,
SearchPlus,
RotateRight,
SearchMinus,
DownloadIcon
};

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 1024 1024"><path fill="currentColor" d="M168 504.2c1-43.7 10-86.1 26.9-126 17.3-41 42.1-77.7 73.7-109.4S337 212.3 378 195c42.4-17.9 87.4-27 133.9-27s91.5 9.1 133.8 27A341.5 341.5 0 0 1 755 268.8c9.9 9.9 19.2 20.4 27.8 31.4l-60.2 47a8 8 0 0 0 3 14.1l175.7 43c5 1.2 9.9-2.6 9.9-7.7l.8-180.9c0-6.7-7.7-10.5-12.9-6.3l-56.4 44.1C765.8 155.1 646.2 92 511.8 92 282.7 92 96.3 275.6 92 503.8a8 8 0 0 0 8 8.2h60c4.4 0 7.9-3.5 8-7.8zm756 7.8h-60c-4.4 0-7.9 3.5-8 7.8-1 43.7-10 86.1-26.9 126-17.3 41-42.1 77.8-73.7 109.4A342.45 342.45 0 0 1 512.1 856a342.24 342.24 0 0 1-243.2-100.8c-9.9-9.9-19.2-20.4-27.8-31.4l60.2-47a8 8 0 0 0-3-14.1l-175.7-43c-5-1.2-9.9 2.6-9.9 7.7l-.7 181c0 6.7 7.7 10.5 12.9 6.3l56.4-44.1C258.2 868.9 377.8 932 512.2 932c229.2 0 415.5-183.7 419.8-411.8a8 8 0 0 0-8-8.2z"/></svg>

After

Width:  |  Height:  |  Size: 865 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 1024 1024"><path fill="currentColor" d="M672 418H144c-17.7 0-32 14.3-32 32v414c0 17.7 14.3 32 32 32h528c17.7 0 32-14.3 32-32V450c0-17.7-14.3-32-32-32zm-44 402H188V494h440v326z"/><path fill="currentColor" d="M819.3 328.5c-78.8-100.7-196-153.6-314.6-154.2l-.2-64c0-6.5-7.6-10.1-12.6-6.1l-128 101c-4 3.1-3.9 9.1 0 12.3L492 318.6c5.1 4 12.7.4 12.6-6.1v-63.9c12.9.1 25.9.9 38.8 2.5 42.1 5.2 82.1 18.2 119 38.7 38.1 21.2 71.2 49.7 98.4 84.3 27.1 34.7 46.7 73.7 58.1 115.8 11 40.7 14 82.7 8.9 124.8-.7 5.4-1.4 10.8-2.4 16.1h74.9c14.8-103.6-11.3-213-81-302.3z"/></svg>

After

Width:  |  Height:  |  Size: 636 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 1024 1024"><path fill="currentColor" d="M480.5 251.2c13-1.6 25.9-2.4 38.8-2.5v63.9c0 6.5 7.5 10.1 12.6 6.1L660 217.6c4-3.2 4-9.2 0-12.3l-128-101c-5.1-4-12.6-.4-12.6 6.1l-.2 64c-118.6.5-235.8 53.4-314.6 154.2-69.6 89.2-95.7 198.6-81.1 302.4h74.9c-.9-5.3-1.7-10.7-2.4-16.1-5.1-42.1-2.1-84.1 8.9-124.8 11.4-42.2 31-81.1 58.1-115.8 27.2-34.7 60.3-63.2 98.4-84.3 37-20.6 76.9-33.6 119.1-38.8z"/><path fill="currentColor" d="M880 418H352c-17.7 0-32 14.3-32 32v414c0 17.7 14.3 32 32 32h528c17.7 0 32-14.3 32-32V450c0-17.7-14.3-32-32-32zm-44 402H396V494h440v326z"/></svg>

After

Width:  |  Height:  |  Size: 639 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 1024 1024"><path fill="currentColor" d="M637 443H325c-4.4 0-8 3.6-8 8v60c0 4.4 3.6 8 8 8h312c4.4 0 8-3.6 8-8v-60c0-4.4-3.6-8-8-8zm284 424L775 721c122.1-148.9 113.6-369.5-26-509-148-148.1-388.4-148.1-537 0-148.1 148.6-148.1 389 0 537 139.5 139.6 360.1 148.1 509 26l146 146c3.2 2.8 8.3 2.8 11 0l43-43c2.8-2.7 2.8-7.8 0-11zM696 696c-118.8 118.7-311.2 118.7-430 0-118.7-118.8-118.7-311.2 0-430 118.8-118.7 311.2-118.7 430 0 118.7 118.8 118.7 311.2 0 430z"/></svg>

After

Width:  |  Height:  |  Size: 535 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 1024 1024"><path fill="currentColor" d="M637 443H519V309c0-4.4-3.6-8-8-8h-60c-4.4 0-8 3.6-8 8v134H325c-4.4 0-8 3.6-8 8v60c0 4.4 3.6 8 8 8h118v134c0 4.4 3.6 8 8 8h60c4.4 0 8-3.6 8-8V519h118c4.4 0 8-3.6 8-8v-60c0-4.4-3.6-8-8-8zm284 424L775 721c122.1-148.9 113.6-369.5-26-509-148-148.1-388.4-148.1-537 0-148.1 148.6-148.1 389 0 537 139.5 139.6 360.1 148.1 509 26l146 146c3.2 2.8 8.3 2.8 11 0l43-43c2.8-2.7 2.8-7.8 0-11zM696 696c-118.8 118.7-311.2 118.7-430 0-118.7-118.8-118.7-311.2 0-430 118.8-118.7 311.2-118.7 430 0 118.7 118.8 118.7 311.2 0 430z"/></svg>

After

Width:  |  Height:  |  Size: 631 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 1024 1024"><path fill="currentColor" d="M400 317.7h73.9V656c0 4.4 3.6 8 8 8h60c4.4 0 8-3.6 8-8V317.7H624c6.7 0 10.4-7.7 6.3-12.9L518.3 163a8 8 0 0 0-12.6 0l-112 141.7c-4.1 5.3-.4 13 6.3 13zM878 626h-60c-4.4 0-8 3.6-8 8v154H214V634c0-4.4-3.6-8-8-8h-60c-4.4 0-8 3.6-8 8v198c0 17.7 14.3 32 32 32h684c17.7 0 32-14.3 32-32V634c0-4.4-3.6-8-8-8z"/></svg>

After

Width:  |  Height:  |  Size: 423 B

View File

@ -12,6 +12,7 @@ const {
layout,
device,
logout,
userProfile,
onPanel,
pureApp,
username,
@ -51,6 +52,15 @@ const {
<p v-if="username" class="dark:text-white">{{ username }}</p>
</span>
<template #dropdown>
<el-dropdown-menu class="logout">
<el-dropdown-item @click="userProfile">
<IconifyIconOffline
:icon="LogoutCircleRLine"
style="margin: 5px"
/>
个人中心
</el-dropdown-item>
</el-dropdown-menu>
<el-dropdown-menu class="logout">
<el-dropdown-item @click="logout">
<IconifyIconOffline

View File

@ -80,7 +80,7 @@ export function useNav() {
/** 个人中心 */
function userProfile() {
router.push("/system/user/profile");
router.push("/global/user/profile");
}
function backTopMenu() {

View File

@ -0,0 +1,20 @@
export default {
path: "/global",
redirect: "/global/user/profile",
meta: {
icon: "checkboxCircleLine",
title: "首页",
rank: 0,
showLink: false
},
children: [
{
path: "/global/user/profile",
name: "Success",
component: () => import("@/views/system/user/profile/index.vue"),
meta: {
title: "个人中心"
}
}
]
} as RouteConfigsTable;

View File

@ -26,5 +26,21 @@ export default [
component: () => import("@/layout/redirect.vue")
}
]
},
{
path: "/system/user/profile",
component: Layout,
meta: {
title: "个人中心",
showLink: false,
rank: 102
},
children: [
{
path: "/",
name: "Redirect",
component: () => import("@/views/system/user/profile/index.vue")
}
]
}
] as Array<RouteConfigsTable>;

View File

@ -30,7 +30,9 @@ export const useUserStore = defineStore({
dictionaryMap:
storageLocal().getItem<Map<String, Map<String, DictionaryData>>>(
dictionaryMapKey
) ?? new Map()
) ?? new Map(),
currentUserInfo:
storageSession().getItem<TokenDTO>(sessionKey)?.currentUser.userInfo ?? {}
}),
actions: {
/** 存储用户名 */

View File

@ -356,7 +356,7 @@ export function useHook() {
onMounted(async () => {
onSearch();
const deptResponse = getDeptListApi();
const deptResponse = await getDeptListApi();
deptTreeList.value = await setDisabledForTreeOptions(
handleTree(deptResponse.data),
"status"

View File

@ -0,0 +1,99 @@
<script setup lang="ts">
import resetPwd from "./resetPwd.vue";
import userInfo from "./userInfo.vue";
import userAvatar from "./userAvatar.vue";
// import userAvatar from "./userAvatar";
// import { getUserProfile } from '@/api/system/user';
// import * as userApi from "@/api/system/userApi";
import { reactive, ref } from "vue";
import dayjs from "dayjs";
import { useUserStoreHook } from "@/store/modules/user";
const activeTab = ref("userinfo");
const state = reactive({
user: {},
roleName: {},
postName: {}
});
/** 用户名 */
const currentUserInfo = useUserStoreHook()?.currentUserInfo;
state.user = currentUserInfo;
console.log(currentUserInfo);
function getUser() {
// userApi.getUserProfile().then(response => {
// state.user = response.user;
// state.roleName = response.roleName;
// state.postName = response.postName;
// });
}
getUser();
</script>
<template>
<div class="app-container">
<el-row :gutter="20">
<el-col :span="6" :xs="24">
<el-card class="box-card">
<template v-slot:header>
<div class="clearfix">
<span>个人信息</span>
</div>
</template>
<div>
<div class="text-center">
<userAvatar :user="state.user" />
</div>
<el-row>
<el-descriptions :column="1">
<el-descriptions-item label="用户名称">{{
currentUserInfo.username
}}</el-descriptions-item>
<el-descriptions-item label="手机号码">{{
currentUserInfo.phoneNumber
}}</el-descriptions-item>
<el-descriptions-item label="用户邮箱">{{
currentUserInfo.email
}}</el-descriptions-item>
<el-descriptions-item label="部门 / 职位">
{{ currentUserInfo.deptName }} /
{{ currentUserInfo.postName }}
</el-descriptions-item>
<el-descriptions-item label="角色">
{{ currentUserInfo.roleName }}
</el-descriptions-item>
<el-descriptions-item label="创建日期">
{{
dayjs(currentUserInfo.createTime).format(
"YYYY-MM-DD HH:mm:ss"
)
}}
</el-descriptions-item>
</el-descriptions>
</el-row>
</div>
</el-card>
</el-col>
<el-col :span="18" :xs="24">
<el-card>
<template v-slot:header>
<div class="clearfix">
<span>基本资料</span>
</div>
</template>
<el-tabs v-model="activeTab">
<el-tab-pane label="基本资料" name="userinfo">
<userInfo :user="state.user" />
</el-tab-pane>
<el-tab-pane label="修改密码" name="resetPwd">
<resetPwd :user="state.user" />
</el-tab-pane>
</el-tabs>
</el-card>
</el-col>
</el-row>
</div>
</template>

View File

@ -0,0 +1,89 @@
<script setup lang="ts">
import { reactive, ref, toRaw } from "vue";
import {
updateCurrentUserPasswordApi,
ResetPasswordRequest
} from "@/api/system/user";
import { FormInstance } from "element-plus";
import { message } from "@/utils/message";
// const { proxy } = getCurrentInstance();
const user = reactive<ResetPasswordRequest>({
oldPassword: undefined,
newPassword: undefined,
confirmPassword: undefined
});
const pwdRef = ref<FormInstance>();
const equalToPassword = (rule, value, callback) => {
if (user.newPassword !== value) {
callback(new Error("两次输入的密码不一致"));
} else {
callback();
}
};
const rules = ref({
oldPassword: [{ required: true, message: "旧密码不能为空", trigger: "blur" }],
newPassword: [
{ required: true, message: "新密码不能为空", trigger: "blur" },
{
min: 6,
max: 20,
message: "长度在 6 到 20 个字符",
trigger: "blur"
}
],
confirmPassword: [
{ required: true, message: "确认密码不能为空", trigger: "blur" },
{ required: true, validator: equalToPassword, trigger: "blur" }
]
});
/** 提交按钮 */
function submit() {
console.log(user);
pwdRef.value.validate(valid => {
if (valid) {
updateCurrentUserPasswordApi(toRaw(user)).then(() => {
message("修改成功", {
type: "success"
});
});
}
});
}
</script>
<template>
<el-form ref="pwdRef" :model="user" :rules="rules" label-width="80px">
<el-form-item label="旧密码" prop="oldPassword">
<el-input
v-model="user.oldPassword"
placeholder="请输入旧密码"
type="password"
show-password
/>
</el-form-item>
<el-form-item label="新密码" prop="newPassword">
<el-input
v-model="user.newPassword"
placeholder="请输入新密码"
type="password"
show-password
/>
</el-form-item>
<el-form-item label="确认密码" prop="confirmPassword">
<el-input
v-model="user.confirmPassword"
placeholder="请确认密码"
type="password"
show-password
/>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="submit">保存</el-button>
</el-form-item>
</el-form>
</template>

View File

@ -0,0 +1,136 @@
<script setup lang="ts">
import ReCropper from "@/components/ReCropper";
import { formatBytes } from "@pureadmin/utils";
import { ref } from "vue";
import { uploadUserAvatarApi } from "@/api/system/user";
import { useUserStoreHook } from "@/store/modules/user";
// import * as userApi from "@/api/system/userApi";
import { message } from "@/utils/message";
const currentUser = useUserStoreHook().currentUserInfo;
const infos = ref();
const imgBlob = ref();
const refCropper = ref();
const showPopover = ref(false);
const cropperImg = ref<string>("");
cropperImg.value = import.meta.env.VITE_APP_BASE_API + currentUser.avatar;
function onCropper({ base64, blob, info }) {
console.log(blob);
infos.value = info;
imgBlob.value = blob;
cropperImg.value = base64;
}
const open = ref(false);
const visible = ref(false);
//
// const options = reactive({
// img: avatarUrl, //
// autoCrop: true, //
// autoCropWidth: 200, //
// autoCropHeight: 200, //
// fixedBox: true, //
// previews: {} //
// });
/** 上传图片 */
function uploadImg() {
const formData = new FormData();
formData.append("avatarfile", imgBlob.value);
uploadUserAvatarApi(formData).then(() => {
open.value = false;
message("上传图片成功", {
type: "success"
});
visible.value = false;
});
}
</script>
<template>
<div class="user-info-head" @click="open = true">
<el-avatar :size="120" :src="cropperImg" />
</div>
<el-dialog
title="修改头像"
v-model="open"
width="900px"
append-to-body
@opened="visible = true"
@close="visible = false"
>
<el-card shadow="never">
<template #header>
<div class="card-header">
<span class="font-medium"> 右键下面左侧裁剪区开启功能菜单 </span>
</div>
</template>
<el-popover
:visible="showPopover"
placement="right"
width="300px"
:teleported="false"
>
<template #reference>
<ReCropper
ref="refCropper"
class="w-[500px]"
:src="cropperImg"
circled
@cropper="onCropper"
@readied="showPopover = true"
/>
</template>
<div class="flex flex-wrap justify-center items-center text-center">
<el-image
v-if="cropperImg"
:src="cropperImg"
:preview-src-list="Array.of(cropperImg)"
fit="cover"
/>
<div v-if="infos" class="mt-1">
<p>
图像大小{{ parseInt(infos.width) }} ×
{{ parseInt(infos.height) }}像素
</p>
<p>
文件大小{{ formatBytes(infos.size) }}{{ infos.size }} 字节
</p>
</div>
</div>
</el-popover>
</el-card>
<template #footer>
<div>
<el-button @click="open = false">取消</el-button>
<el-button type="primary" @click="uploadImg">保存</el-button>
</div>
</template>
</el-dialog>
</template>
<style lang="scss" scoped>
.user-info-head {
position: relative;
display: inline-block;
height: 120px;
}
.user-info-head:hover::after {
position: absolute;
inset: 0;
font-size: 24px;
font-style: normal;
line-height: 110px;
color: #eee;
cursor: pointer;
content: "+";
background: rgb(0 0 0 / 50%);
border-radius: 50%;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
</style>

View File

@ -0,0 +1,89 @@
<script setup lang="ts">
// import { updateUserProfile } from '@/api/system/userApi';
// import * as userApi from "@/api/system/userApi";
import { ref, reactive } from "vue";
import { updateUserProfileApi, UserProfileRequest } from "@/api/system/user";
import { message } from "@/utils/message";
import { FormInstance } from "element-plus";
defineOptions({
name: "SystemUserProfile"
});
const userRef = ref<FormInstance>();
const props = defineProps({
user: {
type: Object
}
});
const userModel = reactive<UserProfileRequest>({
nickname: props.user.nickname,
phoneNumber: props.user.phoneNumber,
email: props.user.email,
sex: props.user.sex
});
console.log(userModel);
console.log(props.user);
// const { proxy } = getCurrentInstance();
const rules = ref({
nickName: [{ required: true, message: "用户昵称不能为空", trigger: "blur" }],
email: [
{ required: true, message: "邮箱地址不能为空", trigger: "blur" },
{
type: "email",
message: "请输入正确的邮箱地址",
trigger: ["blur", "change"]
}
],
phoneNumber: [
{ required: true, message: "手机号码不能为空", trigger: "blur" },
{
pattern: /^1[3|4|5|6|7|8|9][0-9]\d{8}$/,
message: "请输入正确的手机号码",
trigger: "blur"
}
]
});
/** 提交按钮 */
function submit() {
console.log(userRef.value);
userRef.value.validate(valid => {
if (valid) {
updateUserProfileApi(userModel).then(() => {
message("修改成功", {
type: "success"
});
});
}
});
}
</script>
<template>
<el-form ref="userRef" :model="userModel" :rules="rules" label-width="80px">
<el-form-item label="用户昵称">
<el-input v-model="userModel.nickname" maxlength="30" />
</el-form-item>
<el-form-item label="手机号码">
<el-input v-model="userModel.phoneNumber" maxlength="11" />
</el-form-item>
<el-form-item label="邮箱">
<el-input v-model="userModel.email" maxlength="50" />
</el-form-item>
<el-form-item label="性别">
<el-radio-group v-model="userModel.sex">
<el-radio :label="0"></el-radio>
<el-radio :label="1"></el-radio>
</el-radio-group>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="submit">保存</el-button>
</el-form-item>
</el-form>
</template>