feat: 个人信息修改

This commit is contained in:
2026-06-22 12:31:04 +08:00
parent 1a175bfdd0
commit ec66afaa72
9 changed files with 1595 additions and 13 deletions

24
components.d.ts vendored
View File

@@ -12,25 +12,27 @@ export {}
/* prettier-ignore */ /* prettier-ignore */
declare module 'vue' { declare module 'vue' {
export interface GlobalComponents { export interface GlobalComponents {
AvatarCropper: typeof import('./src/components/baseLayouts/AvatarCropper.vue')['default']
CreateRoomDialog: typeof import('./src/components/baseLayouts/CreateRoomDialog.vue')['default'] CreateRoomDialog: typeof import('./src/components/baseLayouts/CreateRoomDialog.vue')['default']
EditProfileDialog: typeof import('./src/components/baseLayouts/EditProfileDialog.vue')['default']
ElAvatar: typeof import('element-plus/es')['ElAvatar'] ElAvatar: typeof import('element-plus/es')['ElAvatar']
ElBadge: typeof import('element-plus/es')['ElBadge'] ElBadge: typeof import('element-plus/es')['ElBadge']
ElButton: typeof import('element-plus/es')['ElButton'] ElButton: typeof import('element-plus/es')['ElButton']
ElCheckbox: typeof import('element-plus/es')['ElCheckbox'] ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
ElDialog: typeof import('element-plus/es')['ElDialog'] ElDialog: typeof import('element-plus/es')['ElDialog']
ElForm: typeof import('element-plus/es')['ElForm'] ElForm: typeof import('element-plus/es')['ElForm']
ElFormItem: typeof import('element-plus/es')['ElFormItem'] ElFormItem: typeof import('element-plus/es')['ElFormItem']
ElIcon: typeof import('element-plus/es')['ElIcon'] ElIcon: typeof import('element-plus/es')['ElIcon']
ElInput: typeof import('element-plus/es')['ElInput'] ElInput: typeof import('element-plus/es')['ElInput']
ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
ElInputOtp: typeof import('element-plus/es')['ElInputOtp'] ElInputOtp: typeof import('element-plus/es')['ElInputOtp']
ElMenu: typeof import('element-plus/es')['ElMenu']
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
ElOption: typeof import('element-plus/es')['ElOption'] ElOption: typeof import('element-plus/es')['ElOption']
ElPopover: typeof import('element-plus/es')['ElPopover'] ElPopover: typeof import('element-plus/es')['ElPopover']
ElRadio: typeof import('element-plus/es')['ElRadio']
ElRadioButton: typeof import('element-plus/es')['ElRadioButton']
ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
ElSelect: typeof import('element-plus/es')['ElSelect'] ElSelect: typeof import('element-plus/es')['ElSelect']
ElSubmenu: typeof import('element-plus/es')['ElSubmenu']
ElSubMenu: typeof import('element-plus/es')['ElSubMenu']
ElSwitch: typeof import('element-plus/es')['ElSwitch']
ElTooltip: typeof import('element-plus/es')['ElTooltip'] ElTooltip: typeof import('element-plus/es')['ElTooltip']
GameList: typeof import('./src/components/baseLayouts/GameList.vue')['default'] GameList: typeof import('./src/components/baseLayouts/GameList.vue')['default']
Lobby: typeof import('./src/components/baseLayouts/Lobby.vue')['default'] Lobby: typeof import('./src/components/baseLayouts/Lobby.vue')['default']
@@ -43,25 +45,27 @@ declare module 'vue' {
// For TSX support // For TSX support
declare global { declare global {
const AvatarCropper: typeof import('./src/components/baseLayouts/AvatarCropper.vue')['default']
const CreateRoomDialog: typeof import('./src/components/baseLayouts/CreateRoomDialog.vue')['default'] const CreateRoomDialog: typeof import('./src/components/baseLayouts/CreateRoomDialog.vue')['default']
const EditProfileDialog: typeof import('./src/components/baseLayouts/EditProfileDialog.vue')['default']
const ElAvatar: typeof import('element-plus/es')['ElAvatar'] const ElAvatar: typeof import('element-plus/es')['ElAvatar']
const ElBadge: typeof import('element-plus/es')['ElBadge'] const ElBadge: typeof import('element-plus/es')['ElBadge']
const ElButton: typeof import('element-plus/es')['ElButton'] const ElButton: typeof import('element-plus/es')['ElButton']
const ElCheckbox: typeof import('element-plus/es')['ElCheckbox'] const ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
const ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
const ElDialog: typeof import('element-plus/es')['ElDialog'] const ElDialog: typeof import('element-plus/es')['ElDialog']
const ElForm: typeof import('element-plus/es')['ElForm'] const ElForm: typeof import('element-plus/es')['ElForm']
const ElFormItem: typeof import('element-plus/es')['ElFormItem'] const ElFormItem: typeof import('element-plus/es')['ElFormItem']
const ElIcon: typeof import('element-plus/es')['ElIcon'] const ElIcon: typeof import('element-plus/es')['ElIcon']
const ElInput: typeof import('element-plus/es')['ElInput'] const ElInput: typeof import('element-plus/es')['ElInput']
const ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
const ElInputOtp: typeof import('element-plus/es')['ElInputOtp'] const ElInputOtp: typeof import('element-plus/es')['ElInputOtp']
const ElMenu: typeof import('element-plus/es')['ElMenu']
const ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
const ElOption: typeof import('element-plus/es')['ElOption'] const ElOption: typeof import('element-plus/es')['ElOption']
const ElPopover: typeof import('element-plus/es')['ElPopover'] const ElPopover: typeof import('element-plus/es')['ElPopover']
const ElRadio: typeof import('element-plus/es')['ElRadio']
const ElRadioButton: typeof import('element-plus/es')['ElRadioButton']
const ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
const ElSelect: typeof import('element-plus/es')['ElSelect'] const ElSelect: typeof import('element-plus/es')['ElSelect']
const ElSubmenu: typeof import('element-plus/es')['ElSubmenu']
const ElSubMenu: typeof import('element-plus/es')['ElSubMenu']
const ElSwitch: typeof import('element-plus/es')['ElSwitch']
const ElTooltip: typeof import('element-plus/es')['ElTooltip'] const ElTooltip: typeof import('element-plus/es')['ElTooltip']
const GameList: typeof import('./src/components/baseLayouts/GameList.vue')['default'] const GameList: typeof import('./src/components/baseLayouts/GameList.vue')['default']
const Lobby: typeof import('./src/components/baseLayouts/Lobby.vue')['default'] const Lobby: typeof import('./src/components/baseLayouts/Lobby.vue')['default']

142
package-lock.json generated
View File

@@ -9,6 +9,7 @@
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"axios": "^1.17.0", "axios": "^1.17.0",
"cropperjs": "^2.1.1",
"element-plus": "^2.14.2", "element-plus": "^2.14.2",
"pinia": "^3.0.4", "pinia": "^3.0.4",
"pinia-plugin-persistedstate": "^4.7.1", "pinia-plugin-persistedstate": "^4.7.1",
@@ -20,6 +21,7 @@
"@element-plus/icons-vue": "^2.3.2", "@element-plus/icons-vue": "^2.3.2",
"@tsconfig/node24": "^24.0.4", "@tsconfig/node24": "^24.0.4",
"@types/node": "^24.12.2", "@types/node": "^24.12.2",
"@types/qrcode": "^1.5.6",
"@vitejs/plugin-vue": "^6.0.6", "@vitejs/plugin-vue": "^6.0.6",
"@vitejs/plugin-vue-jsx": "^5.1.5", "@vitejs/plugin-vue-jsx": "^5.1.5",
"@vue/tsconfig": "^0.9.1", "@vue/tsconfig": "^0.9.1",
@@ -499,6 +501,126 @@
"devOptional": true, "devOptional": true,
"license": "(Apache-2.0 AND BSD-3-Clause)" "license": "(Apache-2.0 AND BSD-3-Clause)"
}, },
"node_modules/@cropper/element": {
"version": "2.1.1",
"resolved": "https://registry.npmmirror.com/@cropper/element/-/element-2.1.1.tgz",
"integrity": "sha512-pthgIQq3PFAFRGUts96yrMgmMY/4rS/zKEq/Vvw7L0Ur09MgzrUg15z4k96K53bJ8XvNeXdQ0qIDw90gh4Xcug==",
"license": "MIT",
"dependencies": {
"@cropper/utils": "^2.1.1"
}
},
"node_modules/@cropper/element-canvas": {
"version": "2.1.1",
"resolved": "https://registry.npmmirror.com/@cropper/element-canvas/-/element-canvas-2.1.1.tgz",
"integrity": "sha512-QHiHnPHsykkc4salOiCLOIKWPK3sLQ1SzpJYFE/yQPvqTPu6ERZNcxVwB1x7WhNN+SdP0S0CEHPYIkgIfUo7VA==",
"license": "MIT",
"dependencies": {
"@cropper/element": "^2.1.1",
"@cropper/utils": "^2.1.1"
}
},
"node_modules/@cropper/element-crosshair": {
"version": "2.1.1",
"resolved": "https://registry.npmmirror.com/@cropper/element-crosshair/-/element-crosshair-2.1.1.tgz",
"integrity": "sha512-DtZnOiY2RZSizDZzMUvya3wZ55YnWclf3hmIrLW6jdcFlQkYEbX6bkOyOJzQmEX0yIbmwMkXfFcMkEZ9dE1rJQ==",
"license": "MIT",
"dependencies": {
"@cropper/element": "^2.1.1",
"@cropper/utils": "^2.1.1"
}
},
"node_modules/@cropper/element-grid": {
"version": "2.1.1",
"resolved": "https://registry.npmmirror.com/@cropper/element-grid/-/element-grid-2.1.1.tgz",
"integrity": "sha512-MZdPBh5QMU10T78nqG+5cK+gq4DHoWZvdOo+/wZ9/z3VH0u21sROeHOCnf0QAdxguPHSSFsPjzARK+ffS4Loog==",
"license": "MIT",
"dependencies": {
"@cropper/element": "^2.1.1",
"@cropper/utils": "^2.1.1"
}
},
"node_modules/@cropper/element-handle": {
"version": "2.1.1",
"resolved": "https://registry.npmmirror.com/@cropper/element-handle/-/element-handle-2.1.1.tgz",
"integrity": "sha512-TmSSphyMr2NEA0DqUTf9PCN8kcpzM0BxreOMyzbfuq1NUUePV1LxYgSOUT7dftMjZnTcu8cHQeFYa69U2BZnBA==",
"license": "MIT",
"dependencies": {
"@cropper/element": "^2.1.1",
"@cropper/utils": "^2.1.1"
}
},
"node_modules/@cropper/element-image": {
"version": "2.1.1",
"resolved": "https://registry.npmmirror.com/@cropper/element-image/-/element-image-2.1.1.tgz",
"integrity": "sha512-cldzN3hVJo0Luui+FDSIkmbQtwIjxWiTcTMLCMSi3hqIq6lc7YOBz+rC5R8AYnYV7YvsWonE6PrJZS+iZpBkdQ==",
"license": "MIT",
"dependencies": {
"@cropper/element": "^2.1.1",
"@cropper/element-canvas": "^2.1.1",
"@cropper/utils": "^2.1.1"
}
},
"node_modules/@cropper/element-selection": {
"version": "2.1.1",
"resolved": "https://registry.npmmirror.com/@cropper/element-selection/-/element-selection-2.1.1.tgz",
"integrity": "sha512-QtMYhcuR+8JG3QEv8iSRCfQEVYljor61vLPrnGCFIkDJhvcyEWDx4H/Kfn6Jrdo90PyXf2VbpTG7TgH8i/EZFg==",
"license": "MIT",
"dependencies": {
"@cropper/element": "^2.1.1",
"@cropper/element-canvas": "^2.1.1",
"@cropper/element-image": "^2.1.1",
"@cropper/utils": "^2.1.1"
}
},
"node_modules/@cropper/element-shade": {
"version": "2.1.1",
"resolved": "https://registry.npmmirror.com/@cropper/element-shade/-/element-shade-2.1.1.tgz",
"integrity": "sha512-MhJVc3jC87TjQu4EfBnNFddL3LxvA+Wzjy1Qhyi0wNdeTAzs97uYJ3KwGVtvzPYP/4JaSU4JDpw+BfIdUqR0bg==",
"license": "MIT",
"dependencies": {
"@cropper/element": "^2.1.1",
"@cropper/element-canvas": "^2.1.1",
"@cropper/element-selection": "^2.1.1",
"@cropper/utils": "^2.1.1"
}
},
"node_modules/@cropper/element-viewer": {
"version": "2.1.1",
"resolved": "https://registry.npmmirror.com/@cropper/element-viewer/-/element-viewer-2.1.1.tgz",
"integrity": "sha512-RpXTGW/rTtJVNd3/R3imdCBJzyZRhA/OeWNo7RID4qW7UYEiL6Lwh7Six3QhFIci/IaUAyL9NAAN10av2YWqog==",
"license": "MIT",
"dependencies": {
"@cropper/element": "^2.1.1",
"@cropper/element-canvas": "^2.1.1",
"@cropper/element-image": "^2.1.1",
"@cropper/element-selection": "^2.1.1",
"@cropper/utils": "^2.1.1"
}
},
"node_modules/@cropper/elements": {
"version": "2.1.1",
"resolved": "https://registry.npmmirror.com/@cropper/elements/-/elements-2.1.1.tgz",
"integrity": "sha512-cHShk4l6iExM4S6B1xx7+09gtDs+kdxWqe7Yv6DEbAyWhxVIVJyJAPpKlzLfA5MYnlSnkvCvY66hLLQPwooRMQ==",
"license": "MIT",
"dependencies": {
"@cropper/element": "^2.1.1",
"@cropper/element-canvas": "^2.1.1",
"@cropper/element-crosshair": "^2.1.1",
"@cropper/element-grid": "^2.1.1",
"@cropper/element-handle": "^2.1.1",
"@cropper/element-image": "^2.1.1",
"@cropper/element-selection": "^2.1.1",
"@cropper/element-shade": "^2.1.1",
"@cropper/element-viewer": "^2.1.1"
}
},
"node_modules/@cropper/utils": {
"version": "2.1.1",
"resolved": "https://registry.npmmirror.com/@cropper/utils/-/utils-2.1.1.tgz",
"integrity": "sha512-0eQs08WyQUNFMfU3ieE091dEQkrW0YdgZ3n6Thnk1EUQv45ypfiL+MevHwr7UzNnUlqUYR2FNSCgE6qBxK0sFw==",
"license": "MIT"
},
"node_modules/@ctrl/tinycolor": { "node_modules/@ctrl/tinycolor": {
"version": "4.2.0", "version": "4.2.0",
"resolved": "https://registry.npmmirror.com/@ctrl/tinycolor/-/tinycolor-4.2.0.tgz", "resolved": "https://registry.npmmirror.com/@ctrl/tinycolor/-/tinycolor-4.2.0.tgz",
@@ -1647,6 +1769,16 @@
"undici-types": "~7.18.0" "undici-types": "~7.18.0"
} }
}, },
"node_modules/@types/qrcode": {
"version": "1.5.6",
"resolved": "https://registry.npmmirror.com/@types/qrcode/-/qrcode-1.5.6.tgz",
"integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/web-bluetooth": { "node_modules/@types/web-bluetooth": {
"version": "0.0.21", "version": "0.0.21",
"resolved": "https://registry.npmmirror.com/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz", "resolved": "https://registry.npmmirror.com/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz",
@@ -2373,6 +2505,16 @@
"url": "https://github.com/sponsors/mesqueeb" "url": "https://github.com/sponsors/mesqueeb"
} }
}, },
"node_modules/cropperjs": {
"version": "2.1.1",
"resolved": "https://registry.npmmirror.com/cropperjs/-/cropperjs-2.1.1.tgz",
"integrity": "sha512-FDJMarkY+/SepYarPZsvkG2LmI2PElecciMFnvBiBIoKnFYua/scprC5qejCLLyuX2jEqJRS2njbAsHxfjtIXA==",
"license": "MIT",
"dependencies": {
"@cropper/elements": "^2.1.1",
"@cropper/utils": "^2.1.1"
}
},
"node_modules/cross-spawn": { "node_modules/cross-spawn": {
"version": "7.0.6", "version": "7.0.6",
"resolved": "https://registry.npmmirror.com/cross-spawn/-/cross-spawn-7.0.6.tgz", "resolved": "https://registry.npmmirror.com/cross-spawn/-/cross-spawn-7.0.6.tgz",

View File

@@ -13,6 +13,7 @@
}, },
"dependencies": { "dependencies": {
"axios": "^1.17.0", "axios": "^1.17.0",
"cropperjs": "^2.1.1",
"element-plus": "^2.14.2", "element-plus": "^2.14.2",
"pinia": "^3.0.4", "pinia": "^3.0.4",
"pinia-plugin-persistedstate": "^4.7.1", "pinia-plugin-persistedstate": "^4.7.1",

110
pnpm-lock.yaml generated
View File

@@ -11,6 +11,9 @@ importers:
axios: axios:
specifier: ^1.17.0 specifier: ^1.17.0
version: 1.17.0 version: 1.17.0
cropperjs:
specifier: ^2.1.1
version: 2.1.1
element-plus: element-plus:
specifier: ^2.14.2 specifier: ^2.14.2
version: 2.14.2(vue@3.5.35(typescript@6.0.3)) version: 2.14.2(vue@3.5.35(typescript@6.0.3))
@@ -245,6 +248,39 @@ packages:
'@bufbuild/protobuf@2.12.0': '@bufbuild/protobuf@2.12.0':
resolution: {integrity: sha512-B/XlCaFIP8LOwzo+bz5uFzATYokcwCKQcghqnlfwSmM5eX/qTkvDBnDPs+gXtX/RyjxJ4DRikECcPJbyALA8FA==} resolution: {integrity: sha512-B/XlCaFIP8LOwzo+bz5uFzATYokcwCKQcghqnlfwSmM5eX/qTkvDBnDPs+gXtX/RyjxJ4DRikECcPJbyALA8FA==}
'@cropper/element-canvas@2.1.1':
resolution: {integrity: sha512-QHiHnPHsykkc4salOiCLOIKWPK3sLQ1SzpJYFE/yQPvqTPu6ERZNcxVwB1x7WhNN+SdP0S0CEHPYIkgIfUo7VA==}
'@cropper/element-crosshair@2.1.1':
resolution: {integrity: sha512-DtZnOiY2RZSizDZzMUvya3wZ55YnWclf3hmIrLW6jdcFlQkYEbX6bkOyOJzQmEX0yIbmwMkXfFcMkEZ9dE1rJQ==}
'@cropper/element-grid@2.1.1':
resolution: {integrity: sha512-MZdPBh5QMU10T78nqG+5cK+gq4DHoWZvdOo+/wZ9/z3VH0u21sROeHOCnf0QAdxguPHSSFsPjzARK+ffS4Loog==}
'@cropper/element-handle@2.1.1':
resolution: {integrity: sha512-TmSSphyMr2NEA0DqUTf9PCN8kcpzM0BxreOMyzbfuq1NUUePV1LxYgSOUT7dftMjZnTcu8cHQeFYa69U2BZnBA==}
'@cropper/element-image@2.1.1':
resolution: {integrity: sha512-cldzN3hVJo0Luui+FDSIkmbQtwIjxWiTcTMLCMSi3hqIq6lc7YOBz+rC5R8AYnYV7YvsWonE6PrJZS+iZpBkdQ==}
'@cropper/element-selection@2.1.1':
resolution: {integrity: sha512-QtMYhcuR+8JG3QEv8iSRCfQEVYljor61vLPrnGCFIkDJhvcyEWDx4H/Kfn6Jrdo90PyXf2VbpTG7TgH8i/EZFg==}
'@cropper/element-shade@2.1.1':
resolution: {integrity: sha512-MhJVc3jC87TjQu4EfBnNFddL3LxvA+Wzjy1Qhyi0wNdeTAzs97uYJ3KwGVtvzPYP/4JaSU4JDpw+BfIdUqR0bg==}
'@cropper/element-viewer@2.1.1':
resolution: {integrity: sha512-RpXTGW/rTtJVNd3/R3imdCBJzyZRhA/OeWNo7RID4qW7UYEiL6Lwh7Six3QhFIci/IaUAyL9NAAN10av2YWqog==}
'@cropper/element@2.1.1':
resolution: {integrity: sha512-pthgIQq3PFAFRGUts96yrMgmMY/4rS/zKEq/Vvw7L0Ur09MgzrUg15z4k96K53bJ8XvNeXdQ0qIDw90gh4Xcug==}
'@cropper/elements@2.1.1':
resolution: {integrity: sha512-cHShk4l6iExM4S6B1xx7+09gtDs+kdxWqe7Yv6DEbAyWhxVIVJyJAPpKlzLfA5MYnlSnkvCvY66hLLQPwooRMQ==}
'@cropper/utils@2.1.1':
resolution: {integrity: sha512-0eQs08WyQUNFMfU3ieE091dEQkrW0YdgZ3n6Thnk1EUQv45ypfiL+MevHwr7UzNnUlqUYR2FNSCgE6qBxK0sFw==}
'@ctrl/tinycolor@4.2.0': '@ctrl/tinycolor@4.2.0':
resolution: {integrity: sha512-kzyuwOAQnXJNLS9PSyrk0CWk35nWJW/zl/6KvnTBMFK65gm7U1/Z5BqjxeapjZCIhQcM/DsrEmcbRwDyXyXK4A==} resolution: {integrity: sha512-kzyuwOAQnXJNLS9PSyrk0CWk35nWJW/zl/6KvnTBMFK65gm7U1/Z5BqjxeapjZCIhQcM/DsrEmcbRwDyXyXK4A==}
engines: {node: '>=14'} engines: {node: '>=14'}
@@ -891,6 +927,9 @@ packages:
resolution: {integrity: sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==} resolution: {integrity: sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==}
engines: {node: '>=18'} engines: {node: '>=18'}
cropperjs@2.1.1:
resolution: {integrity: sha512-FDJMarkY+/SepYarPZsvkG2LmI2PElecciMFnvBiBIoKnFYua/scprC5qejCLLyuX2jEqJRS2njbAsHxfjtIXA==}
cross-spawn@7.0.6: cross-spawn@7.0.6:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'} engines: {node: '>= 8'}
@@ -2077,6 +2116,72 @@ snapshots:
'@bufbuild/protobuf@2.12.0': {} '@bufbuild/protobuf@2.12.0': {}
'@cropper/element-canvas@2.1.1':
dependencies:
'@cropper/element': 2.1.1
'@cropper/utils': 2.1.1
'@cropper/element-crosshair@2.1.1':
dependencies:
'@cropper/element': 2.1.1
'@cropper/utils': 2.1.1
'@cropper/element-grid@2.1.1':
dependencies:
'@cropper/element': 2.1.1
'@cropper/utils': 2.1.1
'@cropper/element-handle@2.1.1':
dependencies:
'@cropper/element': 2.1.1
'@cropper/utils': 2.1.1
'@cropper/element-image@2.1.1':
dependencies:
'@cropper/element': 2.1.1
'@cropper/element-canvas': 2.1.1
'@cropper/utils': 2.1.1
'@cropper/element-selection@2.1.1':
dependencies:
'@cropper/element': 2.1.1
'@cropper/element-canvas': 2.1.1
'@cropper/element-image': 2.1.1
'@cropper/utils': 2.1.1
'@cropper/element-shade@2.1.1':
dependencies:
'@cropper/element': 2.1.1
'@cropper/element-canvas': 2.1.1
'@cropper/element-selection': 2.1.1
'@cropper/utils': 2.1.1
'@cropper/element-viewer@2.1.1':
dependencies:
'@cropper/element': 2.1.1
'@cropper/element-canvas': 2.1.1
'@cropper/element-image': 2.1.1
'@cropper/element-selection': 2.1.1
'@cropper/utils': 2.1.1
'@cropper/element@2.1.1':
dependencies:
'@cropper/utils': 2.1.1
'@cropper/elements@2.1.1':
dependencies:
'@cropper/element': 2.1.1
'@cropper/element-canvas': 2.1.1
'@cropper/element-crosshair': 2.1.1
'@cropper/element-grid': 2.1.1
'@cropper/element-handle': 2.1.1
'@cropper/element-image': 2.1.1
'@cropper/element-selection': 2.1.1
'@cropper/element-shade': 2.1.1
'@cropper/element-viewer': 2.1.1
'@cropper/utils@2.1.1': {}
'@ctrl/tinycolor@4.2.0': {} '@ctrl/tinycolor@4.2.0': {}
'@element-plus/icons-vue@2.3.2(vue@3.5.35(typescript@6.0.3))': '@element-plus/icons-vue@2.3.2(vue@3.5.35(typescript@6.0.3))':
@@ -2659,6 +2764,11 @@ snapshots:
dependencies: dependencies:
is-what: 5.5.0 is-what: 5.5.0
cropperjs@2.1.1:
dependencies:
'@cropper/elements': 2.1.1
'@cropper/utils': 2.1.1
cross-spawn@7.0.6: cross-spawn@7.0.6:
dependencies: dependencies:
path-key: 3.1.1 path-key: 3.1.1

View File

@@ -70,6 +70,61 @@ export function getUserInfo() {
return requestClient.get<UserInfoResponse>('/user/info') return requestClient.get<UserInfoResponse>('/user/info')
} }
/** 更新用户信息请求(所有字段可选,传什么更新什么) */
export interface UpdateUserInfoRequest {
/** 用户昵称,最长 16 位 */
nickName?: string
/** 头像图片 URL 地址 */
avatar?: string
/** 个性签名,最长 64 位 */
signature?: string
/** 年龄 */
age?: number
/** 性别1=男2=女 */
sex?: number
/** 生日ISO 8601 日期格式,如 "1998-06-15" */
birthday?: string
}
/**
* 更新当前登录用户信息
*
* 所有字段可选,传什么更新什么。
*
* @param data - 更新请求
* @returns 更新后的用户信息
*/
export function updateUserInfo(data: UpdateUserInfoRequest) {
return requestClient.put<UserInfoResponse>('/user/info', data)
}
/**
* 上传用户头像
*
* 接收裁剪后的图片文件,保存后返回更新后的用户信息。
*
* @param file - 头像文件JPEG/PNG/WebP/GIF≤2MB
* @returns 更新后的用户信息
*/
export function uploadAvatar(file: File) {
const formData = new FormData()
formData.append('file', file)
return requestClient.post<UserInfoResponse>('/user/avatar', formData, {
headers: { 'Content-Type': 'multipart/form-data' }
} as any)
}
/**
* 删除用户头像
*
* 删除磁盘文件并将 DB 中 avatar 置空。
*
* @returns 更新后的用户信息avatar 为 null
*/
export function deleteAvatar() {
return requestClient.delete<UserInfoResponse>('/user/avatar')
}
/** /**
* 更新当前登录用户在线状态 * 更新当前登录用户在线状态
* *

View File

@@ -0,0 +1,243 @@
<template>
<Teleport to="body">
<div class="avatar-cropper-overlay" @click.self="handleCancel">
<div class="avatar-cropper">
<!-- 标题栏 -->
<div class="avatar-cropper__header">
<span class="avatar-cropper__title">裁剪头像</span>
<el-icon :size="20" class="avatar-cropper__close" @click="handleCancel">
<Close />
</el-icon>
</div>
<!-- 裁剪画布容器 -->
<div ref="containerRef" class="avatar-cropper__canvas-wrapper">
<img
ref="imageRef"
:src="imageSrc"
alt="裁剪预览"
class="avatar-cropper__image"
/>
</div>
<!-- 底部操作 -->
<div class="avatar-cropper__footer">
<span class="avatar-cropper__hint">拖拽或缩放调整裁剪区域</span>
<div class="avatar-cropper__actions">
<el-button @click="handleCancel">取消</el-button>
<el-button type="primary" :loading="confirming" @click="handleConfirm">确认</el-button>
</div>
</div>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount, nextTick } from 'vue'
import { Close } from '@element-plus/icons-vue'
import Cropper, { type CropperSelection } from 'cropperjs'
/** 裁剪输出配置 */
const CROP_SIZE = 200
/** JPEG 导出质量 */
const CROP_QUALITY = 0.85
/** 允许的图片 MIME 类型 */
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'image/gif'] as const
/** 文件大小上限 */
const MAX_FILE_SIZE = 2 * 1024 * 1024
/** cropperjs v2 模板:预配置 1:1 裁剪框 */
const CROPPER_TEMPLATE = `
<cropper-canvas background>
<cropper-image></cropper-image>
<cropper-shade hidden></cropper-shade>
<cropper-handle action="select" plain></cropper-handle>
<cropper-selection
aspect-ratio="1"
initial-coverage="0.9"
movable
resizable
zoomable
outlined
></cropper-selection>
<cropper-grid role="grid" covered></cropper-grid>
<cropper-crosshair centered></cropper-crosshair>
<cropper-handle action="move" theme-color="rgba(255, 255, 255, 0.35)"></cropper-handle>
</cropper-canvas>
`
const props = defineProps<{
/** 要裁剪的图片文件 */
file: File
}>()
const emit = defineEmits<{
(e: 'confirm', blob: Blob): void
(e: 'cancel'): void
}>()
const containerRef = ref<HTMLDivElement>()
const imageRef = ref<HTMLImageElement>()
const imageSrc = ref('')
const confirming = ref(false)
let cropper: Cropper | null = null
/** 校验文件 */
function validateFile(file: File): string | null {
if (!(ALLOWED_TYPES as readonly string[]).includes(file.type)) {
return '仅支持 JPG、PNG、WebP、GIF 格式'
}
if (file.size > MAX_FILE_SIZE) {
return '文件大小不能超过 2MB'
}
return null
}
onMounted(async () => {
const error = validateFile(props.file)
if (error) {
emit('cancel')
return
}
imageSrc.value = URL.createObjectURL(props.file)
await nextTick()
if (!imageRef.value || !containerRef.value) return
cropper = new Cropper(imageRef.value, {
container: containerRef.value,
template: CROPPER_TEMPLATE
})
})
onBeforeUnmount(() => {
if (cropper) {
cropper.destroy()
cropper = null
}
if (imageSrc.value) {
URL.revokeObjectURL(imageSrc.value)
}
})
/** 确认裁剪:异步导出 Canvas → Blob */
async function handleConfirm() {
if (!cropper) return
confirming.value = true
try {
const selection: CropperSelection | null = cropper.getCropperSelection()
if (!selection) {
emit('cancel')
return
}
const canvas = await selection.$toCanvas({
width: CROP_SIZE,
height: CROP_SIZE
})
canvas.toBlob(
(blob) => {
if (!blob) {
emit('cancel')
return
}
emit('confirm', blob)
},
'image/jpeg',
CROP_QUALITY
)
} catch {
emit('cancel')
} finally {
confirming.value = false
}
}
function handleCancel() {
emit('cancel')
}
</script>
<style scoped lang="scss">
.avatar-cropper-overlay {
position: fixed;
inset: 0;
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
background-color: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(4px);
}
.avatar-cropper {
display: flex;
flex-direction: column;
width: 440px;
max-width: 90vw;
background: var(--bg-surface);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-lg);
overflow: hidden;
}
.avatar-cropper__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-md) var(--space-lg);
border-bottom: 1px solid var(--border-default);
}
.avatar-cropper__title {
font-size: 16px;
font-weight: 700;
color: var(--text-primary);
}
.avatar-cropper__close {
cursor: pointer;
color: var(--text-tertiary);
transition: color 0.15s ease;
&:hover {
color: var(--text-primary);
}
}
.avatar-cropper__canvas-wrapper {
width: 100%;
min-height: 300px;
max-height: 400px;
background: var(--bg-body);
overflow: hidden;
}
.avatar-cropper__image {
display: block;
max-width: 100%;
max-height: 400px;
}
.avatar-cropper__footer {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-sm) var(--space-lg) var(--space-md);
}
.avatar-cropper__hint {
font-size: 12px;
color: var(--text-tertiary);
}
.avatar-cropper__actions {
display: flex;
gap: var(--space-sm);
}
</style>

View File

@@ -0,0 +1,977 @@
<template>
<el-dialog
v-model="visible"
width="640px"
:close-on-click-modal="false"
:show-close="false"
class="edit-profile-dialog"
@closed="handleClosed"
>
<template #header>
<div class="ep-dialog__header">
<span class="ep-dialog__title">用户设置</span>
<span class="ep-dialog__subtitle">管理你的个人资料与偏好</span>
</div>
</template>
<div class="ep-layout">
<!-- 左侧导航 -->
<nav class="ep-nav">
<div
class="ep-nav__item"
:class="{ 'is-active': activeTab === 'profile' }"
@click="activeTab = 'profile'"
>
<span class="ep-nav__icon"
><el-icon :size="18"><User /></el-icon
></span>
<span class="ep-nav__text">个人信息</span>
<span v-if="activeTab === 'profile'" class="ep-nav__indicator" />
</div>
<div
class="ep-nav__item"
:class="{ 'is-active': activeTab === 'settings' }"
@click="activeTab = 'settings'"
>
<span class="ep-nav__icon"
><el-icon :size="18"><Setting /></el-icon
></span>
<span class="ep-nav__text">全局设置</span>
<span v-if="activeTab === 'settings'" class="ep-nav__indicator" />
</div>
</nav>
<!-- 右侧内容 -->
<div class="ep-content">
<Transition name="tab-fade" mode="out-in">
<!-- ========== 个人信息 ========== -->
<div v-if="activeTab === 'profile'" key="profile" class="ep-tab">
<!-- 头像 签名元素渐变光环 -->
<div class="ep-avatar-block">
<div
class="ep-avatar__ring"
:class="{ 'has-avatar': hasAvatar }"
@click="triggerFilePicker"
title="点击更换头像"
>
<el-avatar
:size="88"
shape="circle"
:src="avatarDisplaySrc"
class="ep-avatar__img"
/>
<div class="ep-avatar__overlay">
<el-icon :size="22"><Camera /></el-icon>
</div>
</div>
<div class="ep-avatar__meta">
<span class="ep-avatar__name">{{ form.nickName || '你的昵称' }}</span>
<span class="ep-avatar__hint">点击头像更换支持 JPG / PNG / WebP</span>
<div class="ep-avatar__btns">
<el-button size="small" round @click="triggerFilePicker">
<el-icon :size="14"><Upload /></el-icon>
更换
</el-button>
<el-button
v-if="hasAvatar"
size="small"
round
class="ep-avatar__remove-btn"
@click="handleRemoveAvatar"
>
<el-icon :size="14"><Delete /></el-icon>
移除
</el-button>
</div>
</div>
</div>
<input
ref="fileInputRef"
type="file"
accept="image/jpeg,image/png,image/webp,image/gif"
class="ep-file-input"
@change="handleFileChange"
/>
<AvatarCropper
v-if="cropperFile"
:file="cropperFile"
@confirm="handleCropConfirm"
@cancel="handleCropCancel"
/>
<!-- 表单 -->
<el-form
ref="formRef"
:model="form"
:rules="rules"
label-width="56px"
label-position="left"
size="large"
class="ep-form"
>
<el-form-item label="昵称" prop="nickName">
<el-input
v-model="form.nickName"
placeholder="给自己起个响亮的名字"
maxlength="16"
show-word-limit
/>
</el-form-item>
<el-form-item label="签名" prop="signature">
<el-input
v-model="form.signature"
type="textarea"
placeholder="用一句话介绍自己…"
maxlength="64"
show-word-limit
:autosize="{ minRows: 2, maxRows: 4 }"
/>
</el-form-item>
<el-form-item label="性别">
<el-radio-group v-model="form.sex" class="ep-radio-group">
<el-radio-button :value="0">保密</el-radio-button>
<el-radio-button :value="1">
<el-icon :size="14"><Male /></el-icon>
</el-radio-button>
<el-radio-button :value="2">
<el-icon :size="14"><Female /></el-icon>
</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item label="生日">
<el-date-picker
v-model="form.birthday"
type="date"
placeholder="选择日期"
value-format="YYYY-MM-DD"
:disabled-date="disabledDate"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="年龄">
<div class="ep-age">
<span v-if="form.birthday" class="ep-age__num">{{ calculatedAge }}</span>
<span v-if="form.birthday" class="ep-age__unit"></span>
<span v-else class="ep-age__hint">选择生日后自动计算</span>
</div>
</el-form-item>
</el-form>
</div>
<!-- ========== 全局设置 ========== -->
<div v-else key="settings" class="ep-tab">
<div class="ep-placeholder">
<div class="ep-placeholder__icon">
<el-icon :size="36"><Setting /></el-icon>
</div>
<h3 class="ep-placeholder__title">全局设置</h3>
<p class="ep-placeholder__desc">更多个性化选项即将上线敬请期待</p>
<div class="ep-feature-list">
<div class="ep-feature-card">
<span class="ep-feature-card__icon"
><el-icon :size="20"><Bell /></el-icon
></span>
<div class="ep-feature-card__body">
<span class="ep-feature-card__title">消息通知</span>
<span class="ep-feature-card__desc">管理游戏邀请与系统消息</span>
</div>
</div>
<div class="ep-feature-card">
<span class="ep-feature-card__icon"
><el-icon :size="20"><Brush /></el-icon
></span>
<div class="ep-feature-card__body">
<span class="ep-feature-card__title">主题切换</span>
<span class="ep-feature-card__desc">选择你喜欢的颜色方案</span>
</div>
</div>
<div class="ep-feature-card">
<span class="ep-feature-card__icon"
><el-icon :size="20"><Lock /></el-icon
></span>
<div class="ep-feature-card__body">
<span class="ep-feature-card__title">隐私设置</span>
<span class="ep-feature-card__desc">控制谁可以查看你的信息</span>
</div>
</div>
</div>
</div>
</div>
</Transition>
</div>
</div>
<template v-if="activeTab === 'profile'" #footer>
<div class="ep-dialog__footer">
<span class="ep-dialog__footer-hint">修改将在保存后生效</span>
<div class="ep-dialog__footer-actions">
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="handleSave">保存</el-button>
</div>
</div>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { ref, reactive, computed, watch, nextTick } from 'vue'
import type { FormInstance, FormRules } from 'element-plus'
import { ElMessage } from 'element-plus'
import {
Setting,
User,
Camera,
Upload,
Delete,
Male,
Female,
Bell,
Brush,
Lock
} from '@element-plus/icons-vue'
import { useAccountStore } from '@/stores'
import { storeToRefs } from 'pinia'
import AvatarCropper from './AvatarCropper.vue'
/** 编辑资料表单数据 */
interface EditProfileForm {
nickName: string
signature: string
sex: number | null
age: number | null
birthday: string | null
}
const props = defineProps<{
modelValue: boolean
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void
}>()
/** 对话框可见性v-model 双向绑定) */
const visible = computed({
get: () => props.modelValue,
set: (val: boolean) => emit('update:modelValue', val)
})
const accountStore = useAccountStore()
const { accountInfo } = storeToRefs(accountStore)
const formRef = ref<FormInstance>()
const fileInputRef = ref<HTMLInputElement>()
const saving = ref(false)
/** 当前激活的导航 tab */
const activeTab = ref<'profile' | 'settings'>('profile')
/** 表单数据 */
const form = reactive<EditProfileForm>({
nickName: '',
signature: '',
sex: null,
age: null,
birthday: null
})
/** 表单校验规则 */
const rules: FormRules = {
nickName: [{ max: 16, message: '昵称不超过 16 个字符', trigger: 'blur' }],
signature: [{ max: 64, message: '签名不超过 64 个字符', trigger: 'blur' }]
}
// ========== 头像裁剪相关 ==========
/** 待裁剪的文件(非 null 时显示 AvatarCropper */
const cropperFile = ref<File | null>(null)
/** 裁剪后的 Blob待上传 */
const croppedBlob = ref<Blob | null>(null)
/** 裁剪结果的预览 Object URL */
const croppedPreviewUrl = ref('')
/** 是否已标记移除头像 */
const avatarRemoved = ref(false)
/** 当前是否有头像(含自定义头像和默认 SVG */
const hasAvatar = computed(() => {
const av = accountInfo.value?.avatar
return !!(av && av.length > 0)
})
/** 头像显示源:裁剪预览 > 移除状态 > store 数据 */
const avatarDisplaySrc = computed(() => {
if (avatarRemoved.value) return ''
if (croppedPreviewUrl.value) return croppedPreviewUrl.value
return accountInfo.value?.avatar || ''
})
/** 根据生日计算年龄(周岁) */
function calcAge(birthday: string | null): number | null {
if (!birthday) return null
const birth = new Date(birthday)
const now = new Date()
let age = now.getFullYear() - birth.getFullYear()
const monthDiff = now.getMonth() - birth.getMonth()
if (monthDiff < 0 || (monthDiff === 0 && now.getDate() < birth.getDate())) {
age--
}
return age
}
/** 由生日自动计算的年龄(只读) */
const calculatedAge = computed(() => calcAge(form.birthday))
/** 生日变化时同步计算年龄到表单数据(用于提交) */
watch(
() => form.birthday,
(val) => {
form.age = calcAge(val)
}
)
/** 未来日期不可选(生日不能是未来) */
function disabledDate(date: Date): boolean {
return date.getTime() > Date.now()
}
// ========== 初始化表单 ==========
watch(
() => props.modelValue,
(val) => {
if (val) initForm()
}
)
/** 从 store 填充表单 */
function initForm() {
const info = accountInfo.value
form.nickName = info?.nickName || ''
form.signature = info?.signature || ''
form.sex = info?.sex != null && [1, 2].includes(info.sex) ? info.sex : 0
form.age = info?.age ?? null
form.birthday = info?.birthday || null
form.age = calcAge(form.birthday)
// 重置头像状态
releaseCroppedPreview()
croppedBlob.value = null
avatarRemoved.value = false
activeTab.value = 'profile'
}
/** 清理裁剪预览 Object URL */
function releaseCroppedPreview() {
if (croppedPreviewUrl.value) {
URL.revokeObjectURL(croppedPreviewUrl.value)
croppedPreviewUrl.value = ''
}
}
// ========== 头像操作 ==========
/** 触发文件选择器 */
function triggerFilePicker() {
fileInputRef.value?.click()
}
/** 文件选择后打开裁剪弹窗 */
function handleFileChange(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
if (!file) return
// 基础校验AvatarCropper 内部还会再校验)
const MAX_SIZE = 2 * 1024 * 1024
const ALLOWED = ['image/jpeg', 'image/png', 'image/webp', 'image/gif']
if (!ALLOWED.includes(file.type)) {
ElMessage.warning('仅支持 JPG、PNG、WebP、GIF 格式')
clearFileInput()
return
}
if (file.size > MAX_SIZE) {
ElMessage.warning('文件大小不能超过 2MB')
clearFileInput()
return
}
cropperFile.value = file
clearFileInput()
}
/** 清空 file input允许重复选择同一文件 */
function clearFileInput() {
if (fileInputRef.value) {
fileInputRef.value.value = ''
}
}
/** 裁剪确认:接收 Blob生成预览 */
function handleCropConfirm(blob: Blob) {
releaseCroppedPreview()
croppedBlob.value = blob
croppedPreviewUrl.value = URL.createObjectURL(blob)
avatarRemoved.value = false
cropperFile.value = null
}
/** 取消裁剪 */
function handleCropCancel() {
cropperFile.value = null
clearFileInput()
}
/** 移除头像 */
function handleRemoveAvatar() {
releaseCroppedPreview()
croppedBlob.value = null
avatarRemoved.value = true
}
// ========== 保存 ==========
async function handleSave() {
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) return
saving.value = true
try {
// 1. 头像变更优先处理
if (avatarRemoved.value) {
await accountStore.removeAvatar()
} else if (croppedBlob.value) {
// Blob → File
const file = new File([croppedBlob.value], 'avatar.jpg', {
type: croppedBlob.value.type || 'image/jpeg'
})
await accountStore.uploadAvatar(file)
}
// 2. 更新文本字段(只传变更的字段)
const info = accountInfo.value
const payload: Record<string, unknown> = {}
if (form.nickName !== (info?.nickName || '')) {
payload.nickName = form.nickName
}
if (form.signature !== (info?.signature || '')) {
payload.signature = form.signature
}
if (form.sex !== (info?.sex ?? 0)) {
payload.sex = form.sex === 0 ? null : form.sex
}
if (form.age !== (info?.age ?? null)) {
payload.age = form.age
}
if (form.birthday !== (info?.birthday || null)) {
payload.birthday = form.birthday
}
if (Object.keys(payload).length > 0) {
await accountStore.updateProfile(payload)
}
visible.value = false
ElMessage.success('个人资料已更新')
} catch {
ElMessage.error('保存失败,请重试')
} finally {
saving.value = false
}
}
/** 弹窗关闭动画结束后重置表单 */
function handleClosed() {
releaseCroppedPreview()
croppedBlob.value = null
avatarRemoved.value = false
formRef.value?.resetFields()
}
</script>
<style scoped lang="scss">
/* ============================================================
* 用户设置弹窗 — 样式
* 设计方向:"精奢游戏大厅",高端棋牌平台质感
* 签名元素:头像渐变光环,呼应 Auth 页面径向渐变品牌语言
* ============================================================ */
.ep-layout {
display: flex;
height: 460px;
}
/* ========== Tab 过渡 ========== */
.tab-fade-enter-active,
.tab-fade-leave-active {
transition:
opacity 0.2s ease,
transform 0.2s ease;
}
.tab-fade-enter-from {
opacity: 0;
transform: translateY(6px);
}
.tab-fade-leave-to {
opacity: 0;
transform: translateY(-6px);
}
/* ========== 左侧导航 ========== */
.ep-nav {
display: flex;
flex-direction: column;
width: 148px;
flex-shrink: 0;
padding: var(--space-md) var(--space-sm);
background-color: var(--bg-sidebar);
border-right: 1px solid var(--border-light);
gap: 2px;
}
.ep-nav__item {
position: relative;
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 13px;
color: var(--text-secondary);
transition:
background-color 0.15s ease,
color 0.15s ease;
&:hover {
background-color: var(--bg-surface-hover);
color: var(--text-primary);
}
&.is-active {
background-color: var(--bg-surface);
color: var(--color-primary);
font-weight: 600;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
}
}
.ep-nav__icon {
display: flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
border-radius: var(--radius-sm);
flex-shrink: 0;
transition: background-color 0.15s ease;
.ep-nav__item.is-active & {
background-color: var(--color-primary-light);
}
}
.ep-nav__text {
white-space: nowrap;
}
/* 激活态左侧指示条 */
.ep-nav__indicator {
position: absolute;
left: -8px;
top: 50%;
transform: translateY(-50%);
width: 3px;
height: 20px;
border-radius: 0 2px 2px 0;
background: linear-gradient(180deg, var(--color-primary), #818cf8);
}
/* ========== 右侧内容区 ========== */
.ep-content {
flex: 1;
min-width: 0;
overflow-y: auto;
overflow-x: hidden;
&::-webkit-scrollbar {
width: 5px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: var(--scrollbar-thumb);
border-radius: 10px;
&:hover {
background: var(--text-tertiary);
}
}
}
.ep-tab {
padding: var(--space-lg);
}
/* ========== 头像区块(签名元素) ========== */
.ep-avatar-block {
display: flex;
align-items: center;
gap: var(--space-lg);
padding: var(--space-xs) 0 var(--space-lg);
margin-bottom: var(--space-md);
border-bottom: 1px solid var(--border-light);
}
/* 渐变光环 —— 与 Auth 页面径向渐变呼应 */
.ep-avatar__ring {
position: relative;
cursor: pointer;
flex-shrink: 0;
border-radius: 50%;
padding: 3px;
background: linear-gradient(
135deg,
rgba(79, 70, 229, 0.3),
rgba(59, 130, 246, 0.3),
rgba(16, 185, 129, 0.25)
);
transition:
transform 0.25s cubic-bezier(0.34, 1.56, 0.64, 1),
box-shadow 0.25s ease;
&:hover {
transform: scale(1.05);
box-shadow: 0 0 20px rgba(79, 70, 229, 0.15);
}
/* 无头像时不显示光环 = 纯装饰 */
&.has-avatar {
background: linear-gradient(135deg, var(--color-primary), #6366f1, var(--color-info), #34d399);
}
}
.ep-avatar__img {
display: block;
border: 3px solid var(--bg-surface);
border-radius: 50%;
transition: border-color 0.2s ease;
.ep-avatar__ring:hover & {
border-color: rgba(255, 255, 255, 0.6);
}
}
.ep-avatar__overlay {
position: absolute;
inset: 3px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
background-color: rgba(15, 23, 42, 0.4);
color: #fff;
opacity: 0;
transition: opacity 0.2s ease;
pointer-events: none;
.ep-avatar__ring:hover & {
opacity: 1;
}
}
.ep-avatar__meta {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.ep-avatar__name {
font-size: 16px;
font-weight: 700;
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ep-avatar__hint {
font-size: 12px;
color: var(--text-tertiary);
}
.ep-avatar__btns {
display: flex;
gap: var(--space-xs);
margin-top: var(--space-sm);
}
.ep-avatar__remove-btn {
color: var(--text-tertiary);
border-color: var(--border-default);
&:hover {
color: var(--color-danger);
border-color: var(--color-danger);
background-color: var(--color-danger-light);
}
}
.ep-file-input {
display: none;
}
/* ========== 表单 ========== */
.ep-form {
--el-form-label-font-size: 13px;
--el-form-label-color: var(--text-secondary);
}
.ep-form :deep(.el-form-item) {
margin-bottom: 20px;
}
.ep-form :deep(.el-form-item__label) {
font-weight: 500;
}
/* 输入框 & 文本域:自定义焦点环 */
.ep-form :deep(.el-input__wrapper),
.ep-form :deep(.el-textarea__inner) {
border-radius: var(--radius-sm);
box-shadow: 0 0 0 1px var(--border-default) inset;
transition: box-shadow 0.2s ease;
&:hover {
box-shadow: 0 0 0 1px var(--text-tertiary) inset;
}
}
.ep-form :deep(.el-input__wrapper.is-focus),
.ep-form :deep(.el-textarea__inner:focus) {
box-shadow:
0 0 0 3px rgba(79, 70, 229, 0.1) inset,
0 0 0 1px var(--color-primary) inset;
}
/* 单选按钮组 */
.ep-radio-group :deep(.el-radio-button__inner) {
display: flex;
align-items: center;
gap: 4px;
padding: 8px 14px;
border-radius: 0;
transition: all 0.2s ease;
}
.ep-radio-group :deep(.el-radio-button:first-child .el-radio-button__inner) {
border-radius: var(--radius-sm) 0 0 var(--radius-sm);
}
.ep-radio-group :deep(.el-radio-button:last-child .el-radio-button__inner) {
border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
}
/* 年龄展示 */
.ep-age {
display: flex;
align-items: baseline;
gap: 4px;
}
.ep-age__num {
font-size: 28px;
font-weight: 700;
color: var(--color-primary);
line-height: 1;
letter-spacing: -1px;
}
.ep-age__unit {
font-size: 14px;
color: var(--text-secondary);
}
.ep-age__hint {
font-size: 13px;
color: var(--text-tertiary);
}
/* ========== 全局设置占位页 ========== */
.ep-placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 380px;
gap: var(--space-sm);
}
.ep-placeholder__icon {
display: flex;
align-items: center;
justify-content: center;
width: 72px;
height: 72px;
border-radius: 50%;
background:
radial-gradient(circle at 30% 30%, rgba(79, 70, 229, 0.08), transparent 70%), var(--bg-sidebar);
color: var(--text-tertiary);
margin-bottom: var(--space-xs);
}
.ep-placeholder__title {
font-size: 18px;
font-weight: 700;
color: var(--text-primary);
margin: 0;
}
.ep-placeholder__desc {
font-size: 13px;
color: var(--text-tertiary);
margin: 0;
}
.ep-feature-list {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: var(--space-lg);
width: 100%;
max-width: 300px;
}
.ep-feature-card {
display: flex;
align-items: center;
gap: 14px;
padding: 14px 16px;
border-radius: var(--radius-md);
background-color: var(--bg-sidebar);
border: 1px solid var(--border-light);
cursor: default;
transition:
background-color 0.2s ease,
border-color 0.2s ease,
box-shadow 0.2s ease;
&:hover {
background-color: var(--bg-surface);
border-color: var(--border-default);
box-shadow: var(--shadow-sm);
}
}
.ep-feature-card__icon {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border-radius: var(--radius-sm);
background-color: var(--color-primary-light);
color: var(--color-primary);
flex-shrink: 0;
}
.ep-feature-card__body {
display: flex;
flex-direction: column;
gap: 2px;
}
.ep-feature-card__title {
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
}
.ep-feature-card__desc {
font-size: 12px;
color: var(--text-tertiary);
}
</style>
<style lang="scss">
/* ============================================================
* 弹窗全局样式(非 scoped覆盖 el-dialog 默认样式)
* ============================================================ */
.edit-profile-dialog {
.el-dialog {
border-radius: var(--radius-lg);
overflow: hidden;
}
.el-dialog__header {
position: relative;
padding: var(--space-lg) var(--space-lg) var(--space-md);
border-bottom: 1px solid var(--border-default);
/* 顶部渐变装饰线 —— 呼应 Auth 页面背景渐变 */
&::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 3px;
background: linear-gradient(90deg, var(--color-primary), #818cf8, var(--color-info), #34d399);
}
}
.el-dialog__body {
padding: 0;
}
.el-dialog__footer {
padding: var(--space-md) var(--space-lg);
border-top: 1px solid var(--border-default);
background-color: var(--bg-sidebar);
}
}
/* 弹窗标题区 */
.ep-dialog__header {
display: flex;
flex-direction: column;
gap: 4px;
}
.ep-dialog__title {
font-size: 18px;
font-weight: 700;
color: var(--text-primary);
line-height: 1.3;
}
.ep-dialog__subtitle {
font-size: 13px;
color: var(--text-tertiary);
}
/* 弹窗底部 */
.ep-dialog__footer {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
}
.ep-dialog__footer-hint {
font-size: 12px;
color: var(--text-tertiary);
}
.ep-dialog__footer-actions {
display: flex;
gap: var(--space-sm);
}
</style>

View File

@@ -2,6 +2,7 @@
<div class="user-bar"> <div class="user-bar">
<div class="container"> <div class="container">
<el-popover <el-popover
v-model:visible="showUserPanel"
trigger="click" trigger="click"
:width="280" :width="280"
:show-arrow="false" :show-arrow="false"
@@ -124,16 +125,20 @@
<el-icon :size="26"><Setting /></el-icon> <el-icon :size="26"><Setting /></el-icon>
</div> </div>
</div> </div>
<!-- 编辑个人资料弹窗 -->
<EditProfileDialog v-model="showEditProfile" />
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed, ref } from 'vue'
import { storeToRefs } from 'pinia' import { storeToRefs } from 'pinia'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { Setting, EditPen, ArrowRight, Check, DocumentCopy, SwitchButton } from '@element-plus/icons-vue' import { Setting, EditPen, ArrowRight, Check, DocumentCopy, SwitchButton } from '@element-plus/icons-vue'
import { useAccountStore } from '@/stores' import { useAccountStore } from '@/stores'
import { UserStatus, type UserStatusType } from '@/api/modules/user' import { UserStatus, type UserStatusType } from '@/api/modules/user'
import EditProfileDialog from './EditProfileDialog.vue'
/** 在线状态选项 */ /** 在线状态选项 */
interface StatusOption { interface StatusOption {
@@ -155,6 +160,9 @@ const statusMap = new Map<UserStatusType, StatusOption>(STATUS_OPTIONS.map((s) =
const accountStore = useAccountStore() const accountStore = useAccountStore()
const { accountInfo } = storeToRefs(accountStore) const { accountInfo } = storeToRefs(accountStore)
const showEditProfile = ref(false)
const showUserPanel = ref(false)
const statusOptions = STATUS_OPTIONS const statusOptions = STATUS_OPTIONS
const currentStatus = computed<StatusOption>(() => { const currentStatus = computed<StatusOption>(() => {
@@ -167,7 +175,9 @@ const displayName = computed(() => accountInfo.value?.nickName || '未登录')
const signatureText = computed(() => accountInfo.value?.signature || '这个人很懒,什么都没写...') const signatureText = computed(() => accountInfo.value?.signature || '这个人很懒,什么都没写...')
const handleEditProfile = () => { const handleEditProfile = () => {
ElMessage.info('编辑个人资料功能开发中') // 先关闭 popover再打开弹窗避免浮层残留
showUserPanel.value = false
showEditProfile.value = true
} }
const selectStatus = async (option: StatusOption) => { const selectStatus = async (option: StatusOption) => {

View File

@@ -7,7 +7,15 @@ import {
type LoginRequest, type LoginRequest,
type RegisterRequest type RegisterRequest
} from '@/api/modules/account' } from '@/api/modules/account'
import { getUserInfo, updateStatus as updateStatusApi, type UserStatusType } from '@/api/modules/user' import {
getUserInfo,
updateUserInfo as updateUserInfoApi,
uploadAvatar as uploadAvatarApi,
deleteAvatar as deleteAvatarApi,
updateStatus as updateStatusApi,
type UserStatusType,
type UpdateUserInfoRequest
} from '@/api/modules/user'
/** 账户信息 */ /** 账户信息 */
export interface AccountState { export interface AccountState {
@@ -109,6 +117,35 @@ export const useAccountStore = defineStore(
return accessToken.value return accessToken.value
} }
/**
* 更新当前用户的个人资料
* 调用后端接口持久化,并同步更新本地 accountInfo
* @param data - 要更新的字段(所有字段可选)
*/
const updateProfile = async (data: UpdateUserInfoRequest) => {
const res = await updateUserInfoApi(data)
accountInfo.value = res.data
}
/**
* 上传用户头像
* 调用后端接口上传文件并更新 DB同步更新本地 accountInfo
* @param file - 头像文件
*/
const uploadAvatar = async (file: File) => {
const res = await uploadAvatarApi(file)
accountInfo.value = res.data
}
/**
* 删除用户头像
* 调用后端接口删除文件并清空 DB同步更新本地 accountInfo
*/
const removeAvatar = async () => {
const res = await deleteAvatarApi()
accountInfo.value = res.data
}
/** /**
* 更新当前用户的在线状态 * 更新当前用户的在线状态
* 调用后端接口持久化,并同步更新本地 accountInfo * 调用后端接口持久化,并同步更新本地 accountInfo
@@ -141,6 +178,9 @@ export const useAccountStore = defineStore(
logout, logout,
refreshAccessToken, refreshAccessToken,
fetchUserInfo, fetchUserInfo,
updateProfile,
uploadAvatar,
removeAvatar,
updateUserStatus updateUserStatus
} }
}, },