add image backup function and user information detailed display and editing function

This commit is contained in:
2025-03-27 00:55:35 +08:00
parent 86053b6bd8
commit 8be4aca6db
38 changed files with 4827 additions and 386 deletions

9
components.d.ts vendored
View File

@@ -17,6 +17,7 @@ declare module 'vue' {
ACardMeta: typeof import('ant-design-vue/es')['CardMeta']
ACascader: typeof import('ant-design-vue/es')['Cascader']
AccountSetting: typeof import('./src/views/User/AccountSetting/AccountSetting.vue')['default']
AccountSettingBackup: typeof import('./src/views/User/AccountSetting/components/AccountSettingBackup/AccountSettingBackup.vue')['default']
AccountSettingHome: typeof import('./src/views/User/AccountSetting/components/AccountSettingHome/AccountSettingHome.vue')['default']
AccountSettingInfo: typeof import('./src/views/User/AccountSetting/components/AccountSettingInfo/AccountSettingInfo.vue')['default']
AccountSettingSidebar: typeof import('./src/views/User/AccountSetting/components/AccountSettingSidebar/AccountSettingSidebar.vue')['default']
@@ -110,14 +111,17 @@ declare module 'vue' {
DownOutlined: typeof import('@ant-design/icons-vue')['DownOutlined']
DynamicTitle: typeof import('./src/components/DynamicTitle/DynamicTitle.vue')['default']
EditOutlined: typeof import('@ant-design/icons-vue')['EditOutlined']
EmailModal: typeof import('./src/views/User/AccountSetting/components/AccountSettingHome/EmailModal.vue')['default']
EyeInvisibleOutlined: typeof import('@ant-design/icons-vue')['EyeInvisibleOutlined']
EyeOutlined: typeof import('@ant-design/icons-vue')['EyeOutlined']
FileImageOutlined: typeof import('@ant-design/icons-vue')['FileImageOutlined']
FilerobotImageEditor: typeof import('./src/components/FilerobotImageEditor/FilerobotImageEditor.vue')['default']
Folder: typeof import('./src/components/Folder/Folder.vue')['default']
FolderOutlined: typeof import('@ant-design/icons-vue')['FolderOutlined']
ForgetPage: typeof import('./src/views/Forget/ForgetPage.vue')['default']
GradientText: typeof import('./src/components/MyUI/GradientText/GradientText.vue')['default']
HeatmapPro: typeof import('./src/components/HeatmapPro/HeatmapPro.vue')['default']
ImageBed: typeof import('./src/views/ImageBed/index.vue')['default']
ImageEnhancer: typeof import('./src/components/ImageEnhancer/ImageEnhancer.vue')['default']
ImageList: typeof import('./src/views/Photograph/PrivacySpace/ImageList.vue')['default']
ImageShare: typeof import('./src/views/Share/ImageShare/ImageShare.vue')['default']
@@ -126,6 +130,7 @@ declare module 'vue' {
ImageWaterfallList: typeof import('./src/components/ImageWaterfallList/ImageWaterfallList.vue')['default']
InboxOutlined: typeof import('@ant-design/icons-vue')['InboxOutlined']
Index: typeof import('./src/views/Admin/System/Index.vue')['default']
InfoCircleOutlined: typeof import('@ant-design/icons-vue')['InfoCircleOutlined']
LandingPage: typeof import('./src/views/Landing/LandingPage.vue')['default']
LeftOutlined: typeof import('@ant-design/icons-vue')['LeftOutlined']
LinkOutlined: typeof import('@ant-design/icons-vue')['LinkOutlined']
@@ -142,12 +147,14 @@ declare module 'vue' {
LogoutOutlined: typeof import('@ant-design/icons-vue')['LogoutOutlined']
MainPage: typeof import('./src/views/Main/MainPage.vue')['default']
MessageReport: typeof import('./src/components/CommentReply/src/MessageReport/MessageReport.vue')['default']
MinusOutlined: typeof import('@ant-design/icons-vue')['MinusOutlined']
NotFound: typeof import('./src/views/404/NotFound.vue')['default']
OrderedListOutlined: typeof import('@ant-design/icons-vue')['OrderedListOutlined']
PageError403: typeof import('./src/views/Admin/Error/PageError403.vue')['default']
PageError404: typeof import('./src/views/Admin/Error/PageError404.vue')['default']
PageError500: typeof import('./src/views/Admin/Error/PageError500.vue')['default']
ParameterSetting: typeof import('./src/views/Upscale/ParameterSetting.vue')['default']
PasswordModal: typeof import('./src/views/User/AccountSetting/components/AccountSettingHome/PasswordModal.vue')['default']
PeopleAlbumDetail: typeof import('./src/views/Album/PeopleAlbum/PeopleAlbumDetail.vue')['default']
PeopleAlbumIndex: typeof import('./src/views/Album/PeopleAlbum/PeopleAlbumIndex.vue')['default']
PeopleAlbumList: typeof import('./src/views/Album/PeopleAlbum/PeopleAlbumList.vue')['default']
@@ -157,6 +164,7 @@ declare module 'vue' {
PhoalbumDetail: typeof import('./src/views/Album/Phoalbum/PhoalbumDetail.vue')['default']
PhoalbumIndex: typeof import('./src/views/Album/Phoalbum/PhoalbumIndex.vue')['default']
PhoalbumList: typeof import('./src/views/Album/Phoalbum/PhoalbumList.vue')['default']
PhoneModal: typeof import('./src/views/User/AccountSetting/components/AccountSettingHome/PhoneModal.vue')['default']
PhotoStack: typeof import('./src/components/PhotoStack/PhotoStack.vue')['default']
PlusOutlined: typeof import('@ant-design/icons-vue')['PlusOutlined']
PlusSquareOutlined: typeof import('@ant-design/icons-vue')['PlusSquareOutlined']
@@ -201,6 +209,7 @@ declare module 'vue' {
ThingAlbumDetail: typeof import('./src/views/Album/ThingAlbum/ThingAlbumDetail.vue')['default']
ThingAlbumIndex: typeof import('./src/views/Album/ThingAlbum/ThingAlbumIndex.vue')['default']
ThingAlbumList: typeof import('./src/views/Album/ThingAlbum/ThingAlbumList.vue')['default']
ThirdPartyLoginModal: typeof import('./src/views/User/AccountSetting/components/AccountSettingHome/ThirdPartyLoginModal.vue')['default']
Tooltip: typeof import('./src/components/MyUI/Tooltip/Tooltip.vue')['default']
UploadImage: typeof import('./src/views/Upscale/UploadImage.vue')['default']
UploadSetting: typeof import('./src/components/ImageUpload/UploadSetting.vue')['default']

View File

@@ -34,7 +34,7 @@
"@types/file-saver": "^2.0.7",
"@types/json-stringify-safe": "^5.0.3",
"@types/leaflet": "^1.9.16",
"@types/node": "^22.13.10",
"@types/node": "^22.13.11",
"@types/nprogress": "^0.2.3",
"@vladmandic/face-api": "^1.7.15",
"@vuepic/vue-datepicker": "^11.0.2",
@@ -48,7 +48,7 @@
"buffer": "^6.0.3",
"crypto-js": "^4.2.0",
"echarts": "^5.6.0",
"eslint": "9.22.0",
"eslint": "9.23.0",
"exifr": "^7.1.3",
"file-saver": "^2.0.5",
"go-captcha-vue": "^2.0.6",
@@ -85,7 +85,7 @@
"yaml-eslint-parser": "^1.3.0"
},
"devDependencies": {
"@eslint/js": "^9.22.0",
"@eslint/js": "^9.23.0",
"@vitejs/plugin-vue": "^5.2.3",
"eslint-plugin-vue": "^10.0.0",
"globals": "^16.0.0",

55
src/api/storage/backup.ts Normal file
View File

@@ -0,0 +1,55 @@
import {service} from "@/utils/alova/service.ts";
/**
* 从一个存储桶备份到另一个存储桶
* @param sourceProvider 源存储商
* @param sourceBucket 源存储桶
* @param targetProvider 目标存储商
* @param targetBucket 目标存储桶
*/
export const backupStorageApi = (sourceProvider: string, sourceBucket: string, targetProvider: string, targetBucket: string) => {
return service.Post('/api/auth/storage/backup', {
source_provider: sourceProvider,
source_bucket: sourceBucket,
target_provider: targetProvider,
target_bucket: targetBucket,
}, {
meta: {
ignoreToken: false,
signature: false,
},
name: "backup-storage",
});
};
/**
* 获取备份进度
* @param taskId 备份任务ID
*/
export const getBackupProgressApi = (taskId: string) => {
return service.Post('/api/auth/storage/backup/progress', {
task_id: taskId,
}, {
meta: {
ignoreToken: false,
signature: false,
},
name: "get-backup-progress",
});
};
/**
* 取消备份任务
* @param taskId 备份任务ID
*/
export const cancelBackupTaskApi = (taskId: string) => {
return service.Post('/api/auth/storage/backup/cancel', {
task_id: taskId,
}, {
meta: {
ignoreToken: false,
signature: false,
},
name: "cancel-backup-task",
});
};

70
src/api/user/email.ts Normal file
View File

@@ -0,0 +1,70 @@
import {service} from "@/utils/alova/service.ts";
/**
* 发送邮箱验证码
* @param email 邮箱地址
*/
export const sendEmailCaptchaApi = (email: string) => {
return service.Post('/api/user/email/captcha/send', {
email: email,
},
{
meta: {
ignoreToken: false,
signature: true
}
}
);
};
/**
* 绑定邮箱
* @param email 邮箱地址
* @param captcha 验证码
*/
export const bindEmailApi = (email: string, captcha: string) => {
return service.Post('/api/user/email/bind', {
email: email,
captcha: captcha,
},
{
meta: {
ignoreToken: false,
signature: true
}
}
);
};
/**
* 解绑邮箱
*/
export const unbindEmailApi = () => {
return service.Post('/api/user/email/unbind', {},
{
meta: {
ignoreToken: false,
signature: true
}
}
);
};
/**
* 修改邮箱
* @param email 新邮箱地址
* @param captcha 验证码
*/
export const updateEmailApi = (email: string, captcha: string) => {
return service.Post('/api/user/email/update', {
email: email,
captcha: captcha,
},
{
meta: {
ignoreToken: false,
signature: true
}
}
);
};

View File

@@ -0,0 +1 @@
<svg t="1743004595747" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="7337" width="200" height="200"><path d="M512.010445 0.000585A512.281535 512.281535 0 0 0 1.016196 479.339287c-19.250785 305.964609 237.679908 562.895302 543.644517 543.644517A511.98897 511.98897 0 0 0 512.010445 0.000585z" fill="#783FC7" p-id="7338"></path><path d="M214.179147 519.303684a287.298954 287.298954 0 0 0 56.055478 173.31558 287.41598 287.41598 0 0 0 143.532451 107.956532 18.022012 18.022012 0 0 0 5.851302 1.17026 6.904537 6.904537 0 0 0 4.095912-1.17026 8.425876 8.425876 0 0 0 2.340521-1.755391A7.723719 7.723719 0 0 0 427.166559 795.777728v-61.146112a137.271557 137.271557 0 0 1-38.560084-0.58513 67.231466 67.231466 0 0 1-26.213835-8.133311 60.502468 60.502468 0 0 1-16.383647-13.457995 53.305366 53.305366 0 0 1-10.473832-15.154874 135.633192 135.633192 0 0 1-5.266172-13.399483 50.555254 50.555254 0 0 0-3.510782-8.776953 52.135105 52.135105 0 0 0-15.154873-15.740004q-10.532345-7.665206-15.798517-12.287735c-3.452268-2.691599-3.86186-5.441711-1.111747-8.133311q29.256513-15.213387 65.944179 38.501571 19.835916 29.78313 68.869831 17.553907a86.306712 86.306712 0 0 1 23.40521-40.959117q-67.055927-11.702605-99.472143-49.619046a132.824567 132.824567 0 0 1-32.708781-92.216527 128.728655 128.728655 0 0 1 32.123651-87.769538 115.797277 115.797277 0 0 1 2.925651-79.928793 88.120616 88.120616 0 0 1 38.50157 5.851303 181.858482 181.858482 0 0 1 29.256513 13.457996q8.13331 5.851303 14.569743 11.058961a284.13925 284.13925 0 0 1 75.306264-9.303571 287.825571 287.825571 0 0 1 75.24775 9.303571c5.090633-3.86186 10.707884-7.782232 16.968777-11.702605a167.990895 167.990895 0 0 1 28.554357-12.287735 80.162844 80.162844 0 0 1 35.634432-5.207659 118.196311 118.196311 0 0 1 3.510781 78.173402 130.425533 130.425533 0 0 1 32.650268 88.705746 135.691705 135.691705 0 0 1-33.059859 91.923962q-33.293911 37.91644-100.993481 50.204176a80.396897 80.396897 0 0 1 25.687218 60.678007v76.476524c1.170261 6.96305 2.340521 9.888701 3.510781 8.718441s3.276729-0.58513 6.436433 1.75539a293.852412 293.852412 0 0 0 146.282563-107.956531 289.815014 289.815014 0 0 0 56.640608-175.070971 296.309959 296.309959 0 0 0-23.40521-116.148355 303.624088 303.624088 0 0 0-63.662171-94.557049 311.113755 311.113755 0 0 0-95.083666-63.603658 296.485499 296.485499 0 0 0-116.148355-23.405211 302.687879 302.687879 0 0 0-210.646891 86.365226A298.006837 298.006837 0 0 0 238.110974 403.155329a289.697988 289.697988 0 0 0-23.931827 116.148355z" fill="#FFFFFF" p-id="7339"></path></svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

@@ -0,0 +1 @@
<svg t="1742913020264" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="9473" width="200" height="200"><path d="M280.746667 513.621333a82.602667 82.602667 0 1 1-0.512-165.12h0.512a82.602667 82.602667 0 0 1 0 165.12z m710.229333 33.024v99.072h-743.253333a49.493333 49.493333 0 1 1 0-98.986666h194.901333l52.906667-66.133334h429.397333c36.522667 0 66.048 29.610667 66.048 66.048z m8.533333-263.168h-73.472v73.557334a24.490667 24.490667 0 0 1-48.981333 0v-73.557334h-73.557333a24.490667 24.490667 0 1 1 0-48.981333h73.557333v-73.472a24.490667 24.490667 0 0 1 48.981333 0v73.472h73.472a24.490667 24.490667 0 0 1 0 48.981333zM42.666667 0a42.666667 42.666667 0 0 1 42.666666 42.666667V938.666667H0V42.666667A42.666667 42.666667 0 0 1 42.666667 0zM85.333333 688.554667h938.666667v85.333333H85.333333z" fill="#2A375F" p-id="9474"></path><path d="M981.333333 688.554667a42.666667 42.666667 0 0 1 42.666667 42.666666v213.333334h-85.333333v-213.333334a42.666667 42.666667 0 0 1 42.666666-42.666666z" fill="#2A375F" p-id="9475"></path></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

1
src/assets/svgs/scan.svg Normal file
View File

@@ -0,0 +1 @@
<svg t="1742575445234" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="13779" width="200" height="200"><path d="M832 64H192c-19.2 0-32 12.8-32 32v128c0 19.2 12.8 32 32 32s32-12.8 32-32V128h576v96c0 19.2 12.8 32 32 32s32-12.8 32-32V96c0-19.2-12.8-32-32-32zM192 960h640c19.2 0 32-12.8 32-32V800c0-19.2-12.8-32-32-32s-32 12.8-32 32v96H224v-96c0-19.2-12.8-32-32-32s-32 12.8-32 32v128c0 19.2 12.8 32 32 32z" fill="#333333" p-id="13780"></path><path d="M416 320H224c-19.2 0-32 12.8-32 32v320c0 19.2 12.8 32 32 32h192c19.2 0 32-12.8 32-32V352c0-19.2-12.8-32-32-32z m-32 320H256V384h128v256zM800 320H608c-19.2 0-32 12.8-32 32v320c0 19.2 12.8 32 32 32h192c19.2 0 32-12.8 32-32V352c0-19.2-12.8-32-32-32z m-32 320H640V384h128v256z" fill="#00D1C6" p-id="13781"></path></svg>

After

Width:  |  Height:  |  Size: 806 B

View File

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#52c41a" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M4 22h14a2 2 0 0 0 2-2V7.5L14.5 2H6a2 2 0 0 0-2 2v4"/>
<polyline points="14 2 14 8 20 8"/>
<path d="M2 15h10"/>
<path d="M5 12l-3 3 3 3"/>
</svg>

After

Width:  |  Height:  |  Size: 340 B

View File

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#1890ff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M4 22h14a2 2 0 0 0 2-2V7.5L14.5 2H6a2 2 0 0 0-2 2v4"/>
<polyline points="14 2 14 8 20 8"/>
<path d="M12 15h10"/>
<path d="M19 12l3 3-3 3"/>
</svg>

After

Width:  |  Height:  |  Size: 340 B

View File

@@ -3,7 +3,7 @@
<template #extra>
<AFlex :vertical="false" align="center" gap="large" justify="center">
<ATooltip title="手机扫码上传">
<ATooltip title="手机扫码上传" v-if="user.settings.enableMobileUpload">
<AButton type="text" shape="default" size="middle" @click="initWebSocket">
<template #icon>
<APopover placement="bottom" trigger="click">
@@ -23,7 +23,7 @@
</AButton>
</ATooltip>
<AButton type="text" shape="circle" size="middle">
<AButton type="text" shape="circle" size="middle" v-if="user.settings.enableAI">
<template #icon>
<APopover placement="bottom" trigger="click">
<template #content>
@@ -33,6 +33,7 @@
</APopover>
</template>
</AButton>
<ASelect size="middle" style="width: 150px" placeholder="选择上传的相册"
:options="albumList"
v-model:value="upload.albumSelected"
@@ -129,13 +130,15 @@ watch(
* 初始化 WebSocket
*/
function initWebSocket() {
websocket.initialize(wsOptions);
websocket.on("message", async (res: any) => {
if (res && res.code === 200) {
const {data} = res;
console.log(data);
}
});
if (user.settings.enableMobileUpload) {
websocket.initialize(wsOptions);
websocket.on("message", async (res: any) => {
if (res && res.code === 200) {
const {data} = res;
console.log(data);
}
});
}
}

View File

@@ -24,12 +24,35 @@
</APopover>
</div>
<!-- 社区按钮 -->
<div class="button-wrapper">
<ATooltip title="图床" color="#f56c6c">
<AButton type="text" shape="circle" size="large" class="header-menu-item-btn"
@click="router.push('/main/photo/image-bed')">
<template #icon>
<AAvatar size="default" shape="circle" :src="imgBed"/>
</template>
</AButton>
</ATooltip>
</div>
<!-- 工具箱按钮 -->
<div class="button-wrapper">
<ATooltip title="工具箱" color="cyan">
<APopover placement="bottom" trigger="click">
<template #content>
<div class="tool-box-content">
<ACard hoverable class="tool-box-card" @click="router.push('/preview/blur-detect')">
<AFlex :vertical="false" align="center" justify="flex-start" gap="small">
<AAvatar size="large" shape="square" :src="blur"/>
<span class="tool-box-card-title">模糊检测</span>
</AFlex>
</ACard>
<ACard hoverable class="tool-box-card" @click="router.push('/preview/ocr')">
<AFlex :vertical="false" align="center" justify="flex-start" gap="small">
<AAvatar size="large" shape="square" :src="scanIcon"/>
<span class="tool-box-card-title">OCR文字识别</span>
</AFlex>
</ACard>
</div>
</template>
<AButton type="text" shape="circle" size="large" class="header-menu-item-btn">
@@ -186,7 +209,9 @@ import ImageUpload from "@/components/ImageUpload/ImageUpload.vue";
import {getStorageConfigListApi} from "@/api/storage";
import {ProviderIcon} from "@/constant/provider_map.ts";
import toolBox from "@/assets/svgs/tool-box.svg";
import blur from "@/assets/svgs/blur.svg";
import scanIcon from "@/assets/svgs/scan.svg";
import imgBed from "@/assets/svgs/img_bed.svg";
const router = useRouter();
@@ -368,12 +393,34 @@ onMounted(() => {
}
}
.tool-box-content {
width: 150px;
height: 200px;
width: 220px;
padding: 10px;
display: flex;
flex-direction: column;
gap: 10px;
align-items: center;
justify-content: flex-start;
gap: 15px;
.tool-box-card {
width: 100%;
cursor: pointer;
transition: all 0.3s ease;
border-radius: 8px;
overflow: hidden;
&:hover {
transform: translateY(-3px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.tool-box-card-title {
font-size: 14px;
font-weight: bolder;
margin-left: 5px;
}
}
}

View File

@@ -0,0 +1,13 @@
import ImageBed from '@/views/ImageBed/index.vue';
export default [
{
path: '/main/photo/image-bed',
name: 'image-bed',
component: ImageBed,
meta: {
requiresAuth: true,
title: '图床'
},
},
];

View File

@@ -3,6 +3,7 @@ import albums from "@/router/modules/albums.ts";
import recycling_bin from "@/router/modules/recycling_bin.ts";
import share from "@/router/modules/share.ts";
import upscale from "@/router/modules/upscale.ts";
import image_bed from "@/router/modules/image_bed.ts";
export default [
{
@@ -20,6 +21,7 @@ export default [
...recycling_bin,
...share,
...upscale,
...image_bed,
{
path: '/main/photo/search/list',
name: 'photo-search-list',

View File

@@ -6,6 +6,8 @@ import AccountSettingHome from "@/views/User/AccountSetting/components/AccountSe
import AccountSettingInfo from "@/views/User/AccountSetting/components/AccountSettingInfo/AccountSettingInfo.vue";
import AccountSettingStorage
from "@/views/User/AccountSetting/components/AccountSettingStorage/AccountSettingStorage.vue";
import AccountSettingBackup
from "@/views/User/AccountSetting/components/AccountSettingBackup/AccountSettingBackup.vue";
export default [
{
@@ -84,6 +86,15 @@ export default [
requiresAuth: true,
title: '存储管理'
},
},
{
path: '/main/user/setting/backup',
name: 'AccountSettingBackup',
component: AccountSettingBackup,
meta: {
requiresAuth: true,
title: '图像备份'
},
}
],
}

View File

@@ -18,25 +18,11 @@ export const useSystemStore = defineStore(
const privacyPassword = ref<string>('');
const privacyImageData = reactive<Record<string, string>>({});
const getPrivacyImage = (key: string): string | undefined => {
return privacyImageData[key];
};
// 添加密码保持相同API
const addPrivacyImage = (key: string, imageData: string) => {
privacyImageData[key] = imageData;
};
return {
isCollapsed,
admin,
token,
privacyPassword,
privacyImageData,
getPrivacyImage,
addPrivacyImage,
};
},
{
@@ -45,7 +31,7 @@ export const useSystemStore = defineStore(
persist: true,
storage: localForage,
key: 'STORE-SYSTEM',
includePaths: ['isCollapsed', 'admin', "privacyPassword", "privacyImageData"],
includePaths: ['isCollapsed', 'admin', "privacyPassword"],
},
}
);

View File

@@ -598,6 +598,18 @@ export const useUploadStore = defineStore(
await initBlurDetect();
});
function closeAllUploadSetting() {
uploadSetting.nsfw_detection = false;
uploadSetting.gps_detection = false;
uploadSetting.screenshot_detection = false;
uploadSetting.anime_detection = false;
uploadSetting.target_detection = false;
uploadSetting.landscape_detection = false;
uploadSetting.qrcode_detection = false;
uploadSetting.blur_detection = false;
uploadSetting.encrypt = false;
}
return {
openUploadDrawer,
@@ -617,7 +629,8 @@ export const useUploadStore = defineStore(
rejectFile,
removeFile,
blurDetectionControl,
beforeUploadWithWebWorker
beforeUploadWithWebWorker,
closeAllUploadSetting,
};
},
{

View File

@@ -4,6 +4,7 @@ import {message} from "ant-design-vue";
import {useI18n} from "vue-i18n";
import {getGiteeUrl, getGithubUrl, getQQUrl} from "@/api/oauth";
import {userLogoutApi} from "@/api/auth";
import {ref} from "vue";
export const useAuthStore = defineStore(
'user',
@@ -25,6 +26,38 @@ export const useAuthStore = defineStore(
const qqRedirectUrl = ref<string>('');
const router = useRouter();
const {t} = useI18n();
const settings = reactive({
enableAI: true,
enableMobileUpload: true,
publicProfile: true,
enableComment: true,
saveSearchHistory: true
});
// 邮箱弹窗状态
const emailModalState = reactive({
bindEmailVisible: false,
updateEmailVisible: false,
unbindEmailVisible: false
});
// 手机弹窗状态
const phoneModalState = reactive({
bindPhoneVisible: false,
updatePhoneVisible: false,
unbindPhoneVisible: false
});
// 密码弹窗状态
const passwordModalState = reactive({
setPasswordVisible: false,
updatePasswordVisible: false
});
// 第三方登录弹窗状态
const thirdPartyModalState = reactive({
visible: false
});
/**
* Get the redirect url of Github OAuth
@@ -160,10 +193,100 @@ export const useAuthStore = defineStore(
}
// 打开绑定邮箱弹窗
function openBindEmailModal() {
emailModalState.bindEmailVisible = true;
emailModalState.updateEmailVisible = false;
emailModalState.unbindEmailVisible = false;
}
// 打开修改邮箱弹窗
function openUpdateEmailModal() {
emailModalState.bindEmailVisible = false;
emailModalState.updateEmailVisible = true;
emailModalState.unbindEmailVisible = false;
}
// 打开解绑邮箱弹窗
function openUnbindEmailModal() {
emailModalState.bindEmailVisible = false;
emailModalState.updateEmailVisible = false;
emailModalState.unbindEmailVisible = true;
}
// 关闭所有邮箱弹窗
function closeAllEmailModals() {
emailModalState.bindEmailVisible = false;
emailModalState.updateEmailVisible = false;
emailModalState.unbindEmailVisible = false;
}
// 打开绑定手机弹窗
function openBindPhoneModal() {
phoneModalState.bindPhoneVisible = true;
phoneModalState.updatePhoneVisible = false;
phoneModalState.unbindPhoneVisible = false;
}
// 打开修改手机弹窗
function openUpdatePhoneModal() {
phoneModalState.bindPhoneVisible = false;
phoneModalState.updatePhoneVisible = true;
phoneModalState.unbindPhoneVisible = false;
}
// 打开解绑手机弹窗
function openUnbindPhoneModal() {
// 移除解绑手机功能,保留函数但不执行任何操作
// 不再设置unbindPhoneVisible为true
phoneModalState.bindPhoneVisible = false;
phoneModalState.updatePhoneVisible = false;
}
// 关闭所有手机弹窗
function closeAllPhoneModals() {
phoneModalState.bindPhoneVisible = false;
phoneModalState.updatePhoneVisible = false;
phoneModalState.unbindPhoneVisible = false;
}
// 打开设置密码弹窗
function openSetPasswordModal() {
passwordModalState.setPasswordVisible = true;
passwordModalState.updatePasswordVisible = false;
}
// 打开修改密码弹窗
function openUpdatePasswordModal() {
passwordModalState.setPasswordVisible = false;
passwordModalState.updatePasswordVisible = true;
}
// 关闭所有密码弹窗
function closeAllPasswordModals() {
passwordModalState.setPasswordVisible = false;
passwordModalState.updatePasswordVisible = false;
}
// 打开第三方登录弹窗
function openThirdPartyModal() {
thirdPartyModalState.visible = true;
}
// 关闭第三方登录弹窗
function closeThirdPartyModal() {
thirdPartyModalState.visible = false;
}
return {
user,
token,
clientId,
settings,
emailModalState,
phoneModalState,
passwordModalState,
thirdPartyModalState,
getGithubRedirectUrl,
getGiteeRedirectUrl,
getQQRedirectUrl,
@@ -173,6 +296,19 @@ export const useAuthStore = defineStore(
openQQUrl,
clear,
logout,
openBindEmailModal,
openUpdateEmailModal,
openUnbindEmailModal,
closeAllEmailModals,
openBindPhoneModal,
openUpdatePhoneModal,
openUnbindPhoneModal,
closeAllPhoneModals,
openSetPasswordModal,
openUpdatePasswordModal,
closeAllPasswordModals,
openThirdPartyModal,
closeThirdPartyModal
};
},
{
@@ -186,7 +322,7 @@ export const useAuthStore = defineStore(
persist: true,
storage: localStorage,
key: 'STORE-USER',
includePaths: ['user', 'token', "clientId"]
includePaths: ['user', 'token', "clientId","settings","emailModalState","phoneModalState","passwordModalState","thirdPartyModalState"]
}
}
);

View File

@@ -100,11 +100,12 @@ import useStore from "@/store";
defineOptions({name: 'Login'});
const loading = ref(false);
// const router = useRouter();
const router = useRouter();
const captcha = ref(null);
const captchaData = ref<any>({});
const showTextCaptcha = ref(false);
const systemStore = useStore().system;
const formModel = reactive({
account: '',
password: '',
@@ -196,6 +197,9 @@ async function confirmTextCaptcha(dots: any, reset: () => void) {
systemStore.admin.status = status;
systemStore.token.accessToken = access_token;
systemStore.token.expire_at = expire_at;
setTimeout(() => {
router.push('/admin/system/index');
}, 1000);
notification.success({
message: `系统提示`,
duration: 2,

View File

@@ -332,7 +332,7 @@ const handlePermissionModalOk = () => {
const statusText = permissionForm.status === 'active' ? '启用' : '禁用';
const now = new Date().toLocaleString();
permissionList.value.push({
id: newId,
newId,
...permissionForm,
statusText,
createTime: now

View File

@@ -275,11 +275,11 @@ const handleRoleModalOk = () => {
}
} else {
// 新增角色
const newId = Math.max(...roleList.value.map(item => item.id)) + 1;
const id = Math.max(...roleList.value.map(item => item.id)) + 1;
const statusText = roleForm.status === 'active' ? '启用' : '禁用';
const now = new Date().toLocaleString();
roleList.value.push({
id: newId,
id,
...roleForm,
statusText,
createTime: now,

View File

@@ -383,12 +383,12 @@ const handleUserModalOk = () => {
}
} else {
// 新增用户
const newId = Math.max(...userList.value.map(item => item.id)) + 1;
const id = Math.max(...userList.value.map(item => item.id)) + 1;
const roleText = userForm.role === 'admin' ? '管理员' : (userForm.role === 'vip' ? 'VIP用户' : '普通用户');
const statusText = userForm.status === 'active' ? '正常' : (userForm.status === 'inactive' ? '禁用' : '待审核');
const now = new Date().toLocaleString();
userList.value.push({
id: newId,
id,
...userForm,
roleText,
statusText,

View File

@@ -0,0 +1,662 @@
<template>
<div class="image-bed-container">
<AFlex class="image-bed-content" :vertical="false" align="center" justify="flex-start">
<div class="image-bed-content-left">
<ACard class="image-bed-content-left-container">
<div class="image-bed-content-left-upload">
<div class="image-bed-upload-container" ref="containerRef">
<div class="image-bed-upload-content" ref="uploadDraggerRef">
<Spin :spinning="imageBed.uploading" indicator="magic-ring">
<AUploadDragger
v-model:fileList="imageBed.fileList"
:beforeUpload="imageBed.beforeUpload"
:customRequest="imageBed.customUploadRequest"
:directory="false"
:maxCount="10"
:multiple="true"
:disabled="imageBed.uploading || imageBed.isProcessing"
:showUploadList="false">
<div class="image-bed-upload-content-main">
<p class="image-bed-upload-icon">
<FileImageOutlined />
</p>
<p class="image-bed-upload-text">
点击或拖拽图片到此处上传
</p>
<p class="image-bed-upload-hint">
支持JPGPNGGIF等格式单张图片不超过10MB
</p>
</div>
</AUploadDragger>
</Spin>
</div>
</div>
</div>
<ADivider orientation="center" :plain="true">
<span class="image-bed-divider-title">上传设置</span>
</ADivider>
<div class="image-bed-setting">
<div class="image-bed-setting-item">
<AFlex align="center" justify="space-between">
<span class="image-bed-setting-item-name">自动复制链接</span>
<ASwitch v-model:checked="imageBed.settings.autoCopy" />
</AFlex>
</div>
<div class="image-bed-setting-item">
<AFlex align="center" justify="space-between">
<span class="image-bed-setting-item-name">生成缩略图</span>
<ASwitch v-model:checked="imageBed.settings.generateThumb" />
</AFlex>
</div>
<div class="image-bed-setting-item">
<AFlex align="center" justify="space-between">
<span class="image-bed-setting-item-name">链接格式</span>
<ASelect v-model:value="imageBed.settings.linkType" style="width: 120px">
<ASelectOption value="url">URL</ASelectOption>
<ASelectOption value="markdown">Markdown</ASelectOption>
<ASelectOption value="html">HTML</ASelectOption>
<ASelectOption value="bbcode">BBCode</ASelectOption>
</ASelect>
</AFlex>
</div>
<div class="image-bed-setting-item">
<AFlex align="center" justify="space-between">
<span class="image-bed-setting-item-name">存储位置</span>
<ASelect
v-model:value="imageBed.storageSelected"
style="width: 180px"
:options="storageOptions"
:field-names="{label: 'name', value: 'id'}"
/>
</AFlex>
</div>
</div>
</ACard>
</div>
<div class="image-bed-content-right">
<ACard class="image-bed-content-right-container">
<template #title>
<AFlex align="center" justify="space-between">
<span>图片列表</span>
<ARadioGroup v-model:value="imageBed.viewMode" button-style="solid">
<ARadioButton value="grid">
<template #icon><AppstoreOutlined /></template>
网格
</ARadioButton>
<ARadioButton value="list">
<template #icon><BarsOutlined /></template>
列表
</ARadioButton>
</ARadioGroup>
</AFlex>
</template>
<div class="image-bed-list">
<div v-if="imageBed.imageList.length === 0" class="image-bed-empty">
<AEmpty description="暂无图片,请上传" />
</div>
<div v-else-if="imageBed.viewMode === 'grid'" class="image-bed-grid">
<div
v-for="(item, index) in imageBed.imageList"
:key="index"
class="image-bed-grid-item"
@click="imageBed.selectImage(item)"
>
<div class="image-bed-grid-item-container">
<img :src="item.url" :alt="item.name" />
<div class="image-bed-grid-item-overlay">
<AFlex align="center" justify="center" gap="small">
<AButton type="primary" shape="circle" size="small" @click.stop="imageBed.copyLink(item)">
<template #icon><CopyOutlined /></template>
</AButton>
<AButton type="primary" shape="circle" size="small" @click.stop="imageBed.showImageDetail(item)">
<template #icon><EyeOutlined /></template>
</AButton>
<AButton type="primary" danger shape="circle" size="small" @click.stop="imageBed.deleteImage(item)">
<template #icon><DeleteOutlined /></template>
</AButton>
</AFlex>
</div>
</div>
<div class="image-bed-grid-item-info">
<div class="image-bed-grid-item-name" :title="item.name">{{ item.name }}</div>
<div class="image-bed-grid-item-size">{{ item.size }}</div>
</div>
</div>
</div>
<div v-else class="image-bed-table">
<ATable :dataSource="imageBed.imageList" :columns="columns" :pagination="false">
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'preview'">
<img :src="record.url" :alt="record.name" class="image-bed-table-preview" />
</template>
<template v-if="column.key === 'action'">
<AFlex align="center" justify="center" gap="small">
<AButton type="primary" shape="circle" size="small" @click="imageBed.copyLink(record)">
<template #icon><CopyOutlined /></template>
</AButton>
<AButton type="primary" shape="circle" size="small" @click="imageBed.showImageDetail(record)">
<template #icon><EyeOutlined /></template>
</AButton>
<AButton type="primary" danger shape="circle" size="small" @click="imageBed.deleteImage(record)">
<template #icon><DeleteOutlined /></template>
</AButton>
</AFlex>
</template>
</template>
</ATable>
</div>
</div>
</ACard>
</div>
</AFlex>
<!-- 图片详情弹窗 -->
<AModal
v-model:open="imageBed.detailModalVisible"
:title="imageBed.currentImage?.name || '图片详情'"
width="800px"
:footer="null"
centered
>
<div class="image-bed-detail">
<div class="image-bed-detail-preview">
<!-- 图片预览 -->
<div class="image-preview-container">
<img
v-if="imageBed.currentImage"
:src="imageBed.currentImage.url"
:alt="imageBed.currentImage.name"
class="preview-image"
width="200"
height="300"
/>
</div>
</div>
<div class="image-bed-detail-info">
<div class="image-bed-detail-item">
<span class="image-bed-detail-label">文件名</span>
<span class="image-bed-detail-value">{{ imageBed.currentImage?.name }}</span>
</div>
<div class="image-bed-detail-item">
<span class="image-bed-detail-label">大小</span>
<span class="image-bed-detail-value">{{ imageBed.currentImage?.size }}</span>
</div>
<div class="image-bed-detail-item">
<span class="image-bed-detail-label">上传时间</span>
<span class="image-bed-detail-value">{{ imageBed.currentImage?.uploadTime }}</span>
</div>
<div class="image-bed-detail-item">
<span class="image-bed-detail-label">图片链接</span>
<div class="image-bed-detail-link">
<AInput :value="imageBed.getFormattedLink(imageBed.currentImage)" readonly>
<template #addonAfter>
<CopyOutlined @click="imageBed.copyLink(imageBed.currentImage)" />
</template>
</AInput>
</div>
</div>
<div class="image-bed-detail-item" v-if="imageBed.currentImage?.thumbUrl">
<span class="image-bed-detail-label">缩略图链接</span>
<div class="image-bed-detail-link">
<AInput :value="imageBed.getFormattedLink(imageBed.currentImage, true)" readonly>
<template #addonAfter>
<CopyOutlined @click="imageBed.copyLink(imageBed.currentImage, true)" />
</template>
</AInput>
</div>
</div>
</div>
</div>
</AModal>
</div>
</template>
<script setup lang="ts">
import { message } from 'ant-design-vue';
import { FileImageOutlined, CopyOutlined, EyeOutlined, DeleteOutlined, AppstoreOutlined, BarsOutlined} from '@ant-design/icons-vue';
// 存储选项
const storageOptions = ref<any[]>([]);
// 表格列定义
const columns = [
{
title: '预览',
key: 'preview',
width: 100,
},
{
title: '文件名',
dataIndex: 'name',
key: 'name',
},
{
title: '大小',
dataIndex: 'size',
key: 'size',
width: 120,
},
{
title: '上传时间',
dataIndex: 'uploadTime',
key: 'uploadTime',
width: 180,
},
{
title: '操作',
key: 'action',
width: 150,
align: 'center',
},
];
// 图床状态和方法
const imageBed = reactive<any>({
fileList: [],
imageList: [],
uploading: false,
isProcessing: false,
viewMode: 'grid',
detailModalVisible: false,
currentImage: null,
storageSelected: null,
settings: {
autoCopy: true,
generateThumb: true,
linkType: 'url',
},
// 上传前处理
async beforeUpload(file) {
// 检查文件类型
const isImage = file.type.startsWith('image/');
if (!isImage) {
message.error('只能上传图片文件!');
return false;
}
// 检查文件大小
const isLt10M = file.size / 1024 / 1024 < 10;
if (!isLt10M) {
message.error('图片大小不能超过10MB');
return false;
}
imageBed.uploading = true;
return true;
},
// 自定义上传请求
async customUploadRequest(options) {
const { file, onSuccess, onError, onProgress } = options;
try {
// 这里预留API接口调用
// 模拟上传进度
const mockUpload = () => {
let percent = 0;
const interval = setInterval(() => {
percent += 10;
onProgress({ percent });
if (percent >= 100) {
clearInterval(interval);
// 模拟上传成功
const mockResult = {
url: URL.createObjectURL(file),
name: file.name,
size: (file.size / 1024).toFixed(2) + ' KB',
uploadTime: new Date().toLocaleString(),
thumbUrl: imageBed.settings.generateThumb ? URL.createObjectURL(file) : null,
};
imageBed.imageList.unshift(mockResult);
onSuccess(mockResult);
// 自动复制链接
if (imageBed.settings.autoCopy) {
imageBed.copyLink(mockResult);
}
imageBed.uploading = false;
message.success('上传成功!');
}
}, 200);
};
mockUpload();
} catch (error) {
onError(error);
imageBed.uploading = false;
message.error('上传失败!');
}
},
// 复制链接
copyLink(image, isThumb = false) {
const link = imageBed.getFormattedLink(image, isThumb);
navigator.clipboard.writeText(link).then(() => {
message.success('链接已复制到剪贴板!');
}).catch(() => {
message.error('复制失败,请手动复制!');
});
},
// 获取格式化的链接
getFormattedLink(image, isThumb = false) {
const url = isThumb ? image.thumbUrl : image.url;
if (!url) return '';
switch (imageBed.settings.linkType) {
case 'markdown':
return `![${image.name}](${url})`;
case 'html':
return `<img src="${url}" alt="${image.name}" />`;
case 'bbcode':
return `[img]${url}[/img]`;
default:
return url;
}
},
// 显示图片详情
showImageDetail(image) {
imageBed.currentImage = image;
imageBed.detailModalVisible = true;
},
// 选择图片
selectImage(image) {
imageBed.currentImage = image;
},
// 删除图片
deleteImage(image) {
// 这里预留API接口调用
// 模拟删除
const index = imageBed.imageList.findIndex(item => item.url === image.url);
if (index !== -1) {
imageBed.imageList.splice(index, 1);
message.success('删除成功!');
}
},
});
// 获取存储配置列表
const getStorageConfigList = async () => {
// 这里预留API接口调用
// 模拟数据
storageOptions.value = [
{ id: 'aliyun-default', name: 'OSS默认存储' },
{ id: 'qiniu-default', name: '七牛云存储' },
{ id: 'tencent-default', name: '腾讯云存储' },
];
// 默认选择第一个存储
if (storageOptions.value.length > 0) {
imageBed.storageSelected = storageOptions.value[0].id;
}
};
onMounted(() => {
getStorageConfigList();
});
</script>
<style scoped lang="scss">
.image-bed-container {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
.image-bed-content {
width: 100%;
height: 100%;
display: flex;
flex-direction: row;
align-items: flex-start;
justify-content: space-between;
gap: 20px;
.image-bed-content-left {
width: 30%;
height: 100%;
.image-bed-content-left-container {
width: 100%;
height: 100%;
overflow: auto;
.image-bed-divider-title {
font-size: 14px;
color: rgba(126, 126, 135, 0.99);
}
.image-bed-upload-container {
width: 100%;
padding: 10px;
}
.image-bed-upload-content {
width: 100%;
border: 2px dashed #d9d9d9;
border-radius: 8px;
transition: border-color 0.3s;
&:hover {
border-color: #1890ff;
}
}
.image-bed-upload-content-main {
padding: 20px;
text-align: center;
}
.image-bed-upload-icon {
font-size: 48px;
color: #1890ff;
}
.image-bed-upload-text {
margin-top: 10px;
font-size: 16px;
color: rgba(0, 0, 0, 0.85);
}
.image-bed-upload-hint {
margin-top: 5px;
font-size: 12px;
color: rgba(0, 0, 0, 0.45);
}
.image-bed-setting {
padding: 0 10px;
.image-bed-setting-item {
margin-bottom: 16px;
padding: 10px;
background-color: #f9f9f9;
border-radius: 6px;
.image-bed-setting-item-name {
font-size: 14px;
color: rgba(0, 0, 0, 0.65);
}
}
}
}
}
.image-bed-content-right {
width: 70%;
height: 100%;
.image-bed-content-right-container {
width: 100%;
height: 100%;
overflow: auto;
.image-bed-list {
min-height: 400px;
}
.image-bed-empty {
display: flex;
justify-content: center;
align-items: center;
height: 400px;
}
.image-bed-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 16px;
padding: 10px;
.image-bed-grid-item {
border-radius: 8px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
transition: transform 0.3s;
cursor: pointer;
&:hover {
transform: translateY(-5px);
.image-bed-grid-item-overlay {
opacity: 1;
}
}
.image-bed-grid-item-container {
position: relative;
width: 100%;
height: 150px;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
.image-bed-grid-item-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
opacity: 0;
transition: opacity 0.3s;
}
}
.image-bed-grid-item-info {
padding: 8px;
background-color: #fff;
.image-bed-grid-item-name {
font-size: 14px;
color: rgba(0, 0, 0, 0.85);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.image-bed-grid-item-size {
font-size: 12px;
color: rgba(0, 0, 0, 0.45);
margin-top: 4px;
}
}
}
}
.image-bed-table {
.image-bed-table-preview {
width: 60px;
height: 60px;
object-fit: cover;
border-radius: 4px;
}
}
}
}
}
.image-bed-detail {
display: flex;
flex-direction: column;
gap: 20px;
.image-bed-detail-preview {
text-align: center;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
overflow: hidden;
.image-preview-container {
width: 100%;
height: 400px;
display: flex;
align-items: center;
justify-content: center;
background: #f5f5f5;
border-radius: 4px;
overflow: hidden;
.preview-image {
object-fit: contain;
}
}
}
.image-bed-detail-info {
.image-bed-detail-item {
margin-bottom: 16px;
.image-bed-detail-label {
font-size: 14px;
color: rgba(0, 0, 0, 0.65);
margin-right: 8px;
display: inline-block;
width: 80px;
}
.image-bed-detail-value {
font-size: 14px;
color: rgba(0, 0, 0, 0.85);
}
.image-bed-detail-link {
margin-top: 8px;
}
}
}
}
}
@media (max-width: 768px) {
.image-bed-content {
flex-direction: column !important;
.image-bed-content-left,
.image-bed-content-right {
width: 100% !important;
}
.image-bed-content-left {
margin-bottom: 20px;
}
}
}
</style>

View File

@@ -5,9 +5,25 @@
<div class="card-title">
<scan-outlined/>
<span>OCR文字识别</span>
<a-tag color="blue" class="version-tag">增强版</a-tag>
</div>
</template>
<!-- 模型加载状态提示 -->
<a-row :gutter="[16, 16]" v-if="modelLoading">
<a-col :span="24">
<a-alert type="warning" show-icon>
<template #message>模型加载中</template>
<template #description>
<div class="loading-description">
<a-spin size="small" />
<span>OCR模型正在加载中请稍候...</span>
</div>
</template>
</a-alert>
</a-col>
</a-row>
<a-row :gutter="[16, 16]">
<a-col :span="24">
<a-alert type="info" show-icon>
@@ -36,20 +52,93 @@
<a-row :gutter="[16, 16]">
<a-col :span="12">
<div class="canvas-wrapper">
<canvas ref="canvasRef" style="max-width: 100%; height: auto;"></canvas>
<!-- 图片预览区域 -->
<div class="image-preview-controls">
<div class="zoom-controls">
<a-button type="text" @click="zoomOut" :disabled="zoomLevel <= 10">
<template #icon><minus-outlined /></template>
</a-button>
<span class="zoom-level">{{ zoomLevel }}%</span>
<a-button type="text" @click="zoomIn" :disabled="zoomLevel >= 200">
<template #icon><plus-outlined /></template>
</a-button>
</div>
<a-tooltip title="使用鼠标滚轮缩放图片,按住鼠标拖动移动图片">
<info-circle-outlined />
</a-tooltip>
</div>
<div
class="canvas-container"
ref="canvasContainerRef"
@wheel="handleWheel"
@mousedown="startDrag"
@mousemove="onDrag"
@mouseup="stopDrag"
@mouseleave="stopDrag"
>
<canvas ref="canvasRef" :style="`transform: scale(${zoomLevel / 100}) translate(${dragOffset.x}px, ${dragOffset.y}px); transform-origin: top left;`"></canvas>
</div>
</div>
</a-col>
<a-col :span="12">
<a-card title="识别结果" :bordered="false">
<a-card title="识别结果" :bordered="false" class="result-card">
<template #extra>
<a-button type="primary" :loading="recognizing" @click="handleRecognize">
<a-button type="primary" :loading="recognizing" @click="handleRecognize" :disabled="modelLoading">
{{ recognizing ? '识别中...' : '开始识别' }}
</a-button>
</template>
<div class="result-content">
<a-empty v-if="!recognizedText" description="暂无识别结果"/>
<a-empty v-if="!recognizedLines.length" description="暂无识别结果"/>
<div v-else class="text-result">
<p>{{ recognizedText }}</p>
<div class="result-header">
<div class="result-stats">
<a-tag color="blue"> {{ recognizedLines.length }} 行文本</a-tag>
<a-tag v-if="selectedLines.size > 0" color="green">已选择 {{ selectedLines.size }} </a-tag>
</div>
<div class="result-actions">
<a-checkbox :checked="selectAll" @change="toggleSelectAll">全选</a-checkbox>
<a-button type="link" size="small" @click="copySelectedLines" :disabled="selectedLines.size === 0">
<template #icon><copy-outlined /></template>
复制选中
</a-button>
<a-button type="link" size="small" @click="copyAllText">
<template #icon><copy-outlined /></template>
复制全部
</a-button>
</div>
</div>
<a-list
class="result-list"
:data-source="recognizedLines"
:bordered="false"
size="small"
>
<template #renderItem="{ item, index }">
<a-list-item class="result-item">
<div class="result-line">
<a-checkbox
:checked="selectedLines.has(index)"
@change="() => toggleLineSelection(index)"
class="line-checkbox"
/>
<a-tag class="line-number">{{ index + 1 }}</a-tag>
<div class="line-content" :class="{'selected-line': selectedLines.has(index)}">
{{ item }}
</div>
<a-tooltip title="复制此行">
<a-button
type="text"
size="small"
class="copy-btn"
@click="copyLine(item)"
>
<template #icon><copy-outlined /></template>
</a-button>
</a-tooltip>
</div>
</a-list-item>
</template>
</a-list>
</div>
</div>
</a-card>
@@ -61,11 +150,16 @@
</template>
<script setup lang="ts">
import {ref, onMounted} from 'vue';
import {PlusOutlined, ScanOutlined} from '@ant-design/icons-vue';
import {ref, onMounted, computed} from 'vue';
import {
PlusOutlined,
ScanOutlined,
MinusOutlined,
CopyOutlined,
InfoCircleOutlined
} from '@ant-design/icons-vue';
import {message} from 'ant-design-vue';
import type {UploadProps} from 'ant-design-vue';
import * as ocr from '@paddlejs-models/ocr';
// 状态变量
const fileList = ref<any[]>([]);
@@ -73,17 +167,113 @@ const imageUrl = ref<string>('');
const recognizing = ref<boolean>(false);
const recognizedText = ref<string>('');
const canvasRef = ref<HTMLCanvasElement | null>(null);
const canvasContainerRef = ref<HTMLDivElement | null>(null);
const zoomLevel = ref<number>(100); // 默认缩放级别为100%
const modelLoading = ref<boolean>(true); // 模型加载状态
const modelLoaded = ref<boolean>(false); // 模型是否已加载
// 多选复制相关状态
const selectedLines = ref<Set<number>>(new Set()); // 已选择的行索引集合
const selectAll = ref<boolean>(false); // 是否全选
// 拖动相关状态
const isDragging = ref<boolean>(false);
const dragOffset = ref<{x: number, y: number}>({x: 0, y: 0});
const dragStart = ref<{x: number, y: number}>({x: 0, y: 0});
// 防抖函数,用于优化拖动性能
const debounce = (fn: any, delay: number) => {
let timer: number | null = null;
return (...args: any[]) => {
if (timer) window.clearTimeout(timer);
timer = window.setTimeout(() => {
fn(...args);
}, delay);
};
};
// 计算属性:将识别文本按行分割
const recognizedLines = computed(() => {
if (!recognizedText.value) return [];
// 改进文本分割逻辑,处理多种分隔符
// 先按换行符分割,然后对每行检查是否包含逗号、句号等分隔符
const lines = recognizedText.value.split('\n');
let result: string[] = [];
lines.forEach(line => {
if (line.trim() === '') return;
// 检查行长度如果超过50个字符且包含常见分隔符则进一步分割
if (line.length > 50) {
// 使用正则表达式匹配常见中文分隔符(逗号、句号、分号等)
const segments = line.split(/([,,。;;])/g);
let currentSegment = '';
segments.forEach((segment, index) => {
// 如果是分隔符,添加到当前段落并处理
if (segment.match(/[,,。;;]/)) {
currentSegment += segment;
if (currentSegment.trim()) {
result.push(currentSegment.trim());
}
currentSegment = '';
} else {
// 如果当前段落加上新内容会过长,先添加当前段落
if (currentSegment.length + segment.length > 50 && currentSegment.trim()) {
result.push(currentSegment.trim());
currentSegment = segment;
} else {
currentSegment += segment;
}
// 处理最后一个片段
if (index === segments.length - 1 && currentSegment.trim()) {
result.push(currentSegment.trim());
}
}
});
} else {
result.push(line.trim());
}
});
return result;
});
// 懒加载OCR模型
let ocrModule: any = null;
const loadOCRModel = async () => {
if (modelLoaded.value) return;
// 初始化OCR模型
onMounted(async () => {
try {
// '/tfjs/ocr/ch_PP-OCRv2_det_fuse_activation/model.json', '/tfjs/ocr/ch_PP-OCRv2_rec_fuse_activation/model.json'
modelLoading.value = true;
// 动态导入OCR模块
const ocr = await import('@paddlejs-models/ocr');
ocrModule = ocr;
// 使用requestAnimationFrame确保UI渲染完成后再初始化模型避免页面重影
await new Promise(resolve => requestAnimationFrame(resolve));
// 初始化OCR模型 - 移除初始化参数,使用默认配置
await ocr.init();
modelLoaded.value = true;
message.success('OCR模型初始化成功');
} catch (error) {
console.error('OCR模型加载失败:', error);
message.error('OCR模型初始化失败');
console.error(error);
} finally {
modelLoading.value = false;
}
};
// 组件挂载时开始加载模型,但不阻塞页面渲染
onMounted(() => {
// 使用setTimeout将模型加载放到下一个事件循环避免阻塞页面渲染
setTimeout(() => {
loadOCRModel();
}, 100);
});
// 上传前检查文件
@@ -96,6 +286,12 @@ const beforeUpload: UploadProps['beforeUpload'] = (file) => {
// 创建图片URL
imageUrl.value = URL.createObjectURL(file);
// 如果模型还未加载,开始加载
if (!modelLoaded.value && !modelLoading.value) {
loadOCRModel();
}
return false; // 阻止自动上传
};
@@ -110,6 +306,164 @@ const handleRemove = () => {
return true;
};
// 缩放控制函数
const zoomIn = () => {
if (zoomLevel.value < 200) {
zoomLevel.value += 10;
}
};
const zoomOut = () => {
if (zoomLevel.value > 10) {
zoomLevel.value -= 10;
}
};
// 鼠标滚轮缩放 - 以鼠标位置为中心点
const handleWheel = (e: WheelEvent) => {
e.preventDefault(); // 阻止默认滚动行为
// 获取鼠标相对于canvas容器的位置
const rect = canvasContainerRef.value?.getBoundingClientRect();
if (!rect) return;
// 计算鼠标在缩放前的相对位置(相对于容器)
const mouseX = e.clientX - rect.left;
const mouseY = e.clientY - rect.top;
// 计算鼠标在图像上的相对位置(考虑当前缩放和偏移)
const imageX = (mouseX - dragOffset.value.x) / (zoomLevel.value / 100);
const imageY = (mouseY - dragOffset.value.y) / (zoomLevel.value / 100);
// 向上滚动放大,向下滚动缩小
if (e.deltaY < 0 && zoomLevel.value < 200) {
zoomLevel.value += 10;
} else if (e.deltaY > 0 && zoomLevel.value > 10) {
zoomLevel.value -= 10;
} else {
return; // 如果缩放级别没有变化,直接返回
}
// 计算新的偏移量,使鼠标位置保持在图像的同一点上
const newOffsetX = mouseX - imageX * (zoomLevel.value / 100);
const newOffsetY = mouseY - imageY * (zoomLevel.value / 100);
// 更新偏移量
dragOffset.value = {
x: newOffsetX,
y: newOffsetY
};
};
// 拖动相关方法
const startDrag = (e: MouseEvent) => {
isDragging.value = true;
dragStart.value = {
x: e.clientX - dragOffset.value.x,
y: e.clientY - dragOffset.value.y
};
};
// 创建防抖版本的拖动处理函数
const debouncedDragUpdate = debounce((clientX: number, clientY: number) => {
dragOffset.value = {
x: clientX - dragStart.value.x,
y: clientY - dragStart.value.y
};
}, 5); // 5ms的防抖延迟平衡响应性和性能
const onDrag = (e: MouseEvent) => {
if (!isDragging.value) return;
// 使用requestAnimationFrame和防抖函数结合优化拖动性能
requestAnimationFrame(() => {
debouncedDragUpdate(e.clientX, e.clientY);
});
};
const stopDrag = () => {
isDragging.value = false;
};
// 复制单行文本
const copyLine = (text: string) => {
navigator.clipboard.writeText(text)
.then(() => {
message.success('已复制到剪贴板');
})
.catch(() => {
message.error('复制失败');
});
};
// 复制全部文本
const copyAllText = () => {
if (!recognizedText.value) {
message.warning('没有可复制的文本');
return;
}
navigator.clipboard.writeText(recognizedText.value)
.then(() => {
message.success('已复制全部文本到剪贴板');
})
.catch(() => {
message.error('复制失败');
});
};
// 切换行选择状态
const toggleLineSelection = (index: number) => {
if (selectedLines.value.has(index)) {
selectedLines.value.delete(index);
selectAll.value = false;
} else {
selectedLines.value.add(index);
// 检查是否已全选
if (selectedLines.value.size === recognizedLines.value.length) {
selectAll.value = true;
}
}
};
// 切换全选状态
const toggleSelectAll = () => {
selectAll.value = !selectAll.value;
if (selectAll.value) {
// 全选所有行
selectedLines.value = new Set(
recognizedLines.value.map((_, index) => index)
);
} else {
// 取消全选
selectedLines.value.clear();
}
};
// 复制选中的行
const copySelectedLines = () => {
if (selectedLines.value.size === 0) {
message.warning('请先选择要复制的文本行');
return;
}
// 按行索引排序,确保复制的文本顺序正确
const sortedIndices = Array.from(selectedLines.value).sort((a, b) => a - b);
const textToCopy = sortedIndices
.map(index => recognizedLines.value[index])
.join('\n');
navigator.clipboard.writeText(textToCopy)
.then(() => {
message.success(`已复制${selectedLines.value.size}行文本到剪贴板`);
})
.catch(() => {
message.error('复制失败');
});
};
// 执行OCR识别
const handleRecognize = async () => {
if (!imageUrl.value) {
@@ -117,6 +471,16 @@ const handleRecognize = async () => {
return;
}
// 如果模型未加载,先加载模型
if (!modelLoaded.value) {
message.warning('OCR模型正在加载中请稍候...');
if (!modelLoading.value) {
await loadOCRModel();
} else {
return; // 如果正在加载,直接返回
}
}
recognizing.value = true;
try {
const img = new Image();
@@ -128,10 +492,15 @@ const handleRecognize = async () => {
canvasRef.value.width = img.width;
canvasRef.value.height = img.height;
const ctx = canvasRef.value.getContext('2d');
ctx?.clearRect(0, 0, canvasRef.value.width, canvasRef.value.height);
ctx?.drawImage(img, 0, 0);
// 重置拖动状态和缩放级别,使图片居中显示
dragOffset.value = {x: 0, y: 0};
zoomLevel.value = 100;
// 执行OCR识别
const result = await ocr.recognize(img, {
const result = await ocrModule.recognize(img, {
canvas: canvasRef.value,
style: {
strokeStyle: '#FF4D4F',
@@ -140,8 +509,15 @@ const handleRecognize = async () => {
}
});
recognizedText.value = result.text;
message.success('识别完成');
// 确保result.text是字符串类型
recognizedText.value = typeof result.text === 'string' ? result.text : String(result.text || '');
// 如果识别结果为空,显示提示
if (!recognizedText.value.trim()) {
message.warning('未能识别出文字内容,请尝试更清晰的图片');
} else {
message.success(`识别完成,共识别出${recognizedLines.value.length}行文本`);
}
}
} catch (error) {
console.error(error);
@@ -152,54 +528,165 @@ const handleRecognize = async () => {
};
</script>
<style scoped lang="scss">
<style scoped>
.ocr-detection {
padding: 20px;
max-width: 1200px;
margin: 0 auto;
}
.main-card {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
border-radius: 8px;
overflow: hidden;
}
.main-card {
border-radius: 8px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
}
.card-title {
display: flex;
align-items: center;
gap: 8px;
font-size: 18px;
color: var(--primary-color);
}
.card-title {
display: flex;
align-items: center;
gap: 8px;
}
.upload-container {
margin: 20px 0;
background-color: #fafafa;
padding: 20px;
border-radius: 4px;
border: 1px dashed #d9d9d9;
}
.version-tag {
margin-left: 8px;
}
.preview-container {
margin-top: 20px;
}
.loading-description {
display: flex;
align-items: center;
gap: 8px;
}
.canvas-wrapper {
background-color: #fafafa;
padding: 16px;
border-radius: 4px;
border: 1px solid #d9d9d9;
}
.upload-container {
margin: 16px 0;
}
.result-content {
min-height: 200px;
max-height: 400px;
overflow-y: auto;
}
.preview-container {
margin-top: 16px;
}
.text-result {
white-space: pre-wrap;
word-break: break-all;
}
.canvas-wrapper {
border: 1px solid #f0f0f0;
border-radius: 8px;
overflow: hidden;
height: 100%;
display: flex;
flex-direction: column;
}
.image-preview-controls {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 16px;
border-bottom: 1px solid #f0f0f0;
background-color: #fafafa;
}
.zoom-controls {
display: flex;
align-items: center;
gap: 12px;
flex: 1;
}
.zoom-level {
min-width: 50px;
text-align: center;
font-size: 14px;
color: #666;
}
.canvas-container {
flex: 1;
overflow: hidden;
position: relative;
background-color: #f5f5f5;
cursor: grab;
display: flex;
align-items: center;
justify-content: center;
}
.canvas-container:active {
cursor: grabbing;
}
.result-card {
height: 100%;
}
.result-content {
overflow-y: auto;
padding-right: 4px; /* 添加一点右侧内边距,避免滚动条贴边 */
}
.text-result {
display: flex;
flex-direction: column;
gap: 8px;
}
.result-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
flex-wrap: wrap;
gap: 8px;
}
.result-stats {
display: flex;
align-items: center;
gap: 8px;
}
.result-actions {
display: flex;
align-items: center;
gap: 8px;
}
.selected-line {
background-color: rgba(24, 144, 255, 0.1);
border-radius: 4px;
padding: 2px 4px;
}
.line-checkbox {
margin-right: 4px;
}
.result-list {
max-height: 350px;
overflow-y: auto;
}
.result-item {
padding: 4px 0;
}
.result-line {
display: flex;
align-items: flex-start;
gap: 8px;
width: 100%;
}
.line-number {
flex-shrink: 0;
margin-right: 8px;
}
.line-content {
flex: 1;
word-break: break-all;
}
.copy-btn {
opacity: 0.5;
transition: opacity 0.2s;
}
.result-line:hover .copy-btn {
opacity: 1;
}
</style>

View File

@@ -21,7 +21,7 @@
<ImageWaterfallList :image-list="imageList"/>
</div>
</div>
<AFloatButton v-if="imageList && imageList.length > 0" tooltip="评论" :badge="{ count: 0, color: 'green' }"
<AFloatButton v-if="imageList && imageList.length > 0 && userStore.settings.enableComment" tooltip="评论" :badge="{ count: 0, color: 'green' }"
@click="shareStore.setOpenCommentDrawer(true)"
>
<template #icon>
@@ -50,6 +50,7 @@ const route = useRoute();
const imageStore = useStore().image;
const shareStore = useStore().share;
const userStore = useStore().user;
const shareInfo = ref<any>();

View File

@@ -49,7 +49,7 @@
</div>
</div>
<!-- 二维码 -->
<div class="canvas-qr">
<div class="canvas-qr" v-if="user.settings.enableMobileUpload">
<AQrcode :bordered="false" color="rgba(126, 126, 135, 0.48)"
:size="qrcodeSize"
:value="generateQrCodeUrl()"

View File

@@ -33,15 +33,17 @@ const wsOptions = {
};
onMounted(() => {
websocket.initialize(wsOptions);
websocket.on("message", async (res: any) => {
if (res && res.code === 200) {
const {data} = res;
img.src = data;
await upscale.loadImg(img);
upscale.imageData = data;
}
});
if (user.settings.enableMobileUpload) {
websocket.initialize(wsOptions);
websocket.on("message", async (res: any) => {
if (res && res.code === 200) {
const {data} = res;
img.src = data;
await upscale.loadImg(img);
upscale.imageData = data;
}
});
}
});
watch(
() => websocket.readyState,

View File

@@ -20,18 +20,45 @@ import AccountSettingSidebar
.account-setting {
background-color: #eaeef6;
.personal-center-header {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
background-color: white;
position: relative;
z-index: 10;
}
.account-setting-container {
display: flex;
flex-direction: row;
width: 100%;
height: calc(100vh - 70px);
position: relative;
.account-setting-view {
width: calc(100vw - 230px);
height: calc(100vh - 100px);
height: calc(100vh - 120px);
max-height: calc(100vh - 100px);
padding: 15px;
padding: 20px;
overflow: auto;
transition: all 0.3s ease;
&::-webkit-scrollbar {
width: 8px;
}
&::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 4px;
}
&::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 4px;
}
&::-webkit-scrollbar-thumb:hover {
background: #a8a8a8;
}
}
}
}

View File

@@ -0,0 +1,598 @@
<template>
<div class="account-setting-backup">
<div class="account-setting-backup-header">
<span>图像备份</span>
<AButton type="text" size="large" shape="circle" @click="getStorageList">
<template #icon>
<RedoOutlined />
</template>
</AButton>
</div>
<div class="account-setting-backup-body" v-if="storageList && storageList.length > 1">
<div class="backup-container">
<div class="backup-section source-section">
<div class="section-title">
<AAvatar shape="square" size="small" :src="sourceIcon" />
<span>源存储</span>
</div>
<div class="storage-selection">
<AForm layout="vertical">
<AFormItem label="存储商" name="sourceProvider">
<ASelect
v-model:value="sourceStorage.provider"
placeholder="请选择源存储商"
@change="handleSourceProviderChange"
>
<ASelectOption
v-for="item in storageList"
:key="item.provider + '-' + item.bucket"
:value="item.provider + '-' + item.bucket"
>
<AFlex align="center" gap="small">
<AAvatar :size="20" shape="circle" :src="ProviderIcon[item.provider]" />
<span>{{ ProviderNameMap[item.provider] }} - {{ item.bucket }}</span>
</AFlex>
</ASelectOption>
</ASelect>
</AFormItem>
</AForm>
<div class="selected-storage-card" v-if="selectedSourceStorage">
<div class="storage-card-header">
<AAvatar :size="40" shape="circle" :src="ProviderIcon[selectedSourceStorage.provider]" />
<div class="storage-info">
<div class="storage-name">{{ selectedSourceStorage.bucket }}</div>
<ATag :color="ProviderColorMap[selectedSourceStorage.provider]">{{ ProviderNameMap[selectedSourceStorage.provider] }}</ATag>
</div>
</div>
<div class="storage-card-content">
<div class="storage-detail">
<AAvatar size="small" shape="square" :src="bucket" />
<span>{{ selectedSourceStorage.capacity }}GB</span>
</div>
<div class="storage-detail">
<AAvatar size="small" shape="circle" :src="location" />
<span>{{ AliRegionMap[selectedSourceStorage.region] || selectedSourceStorage.region }}</span>
</div>
</div>
</div>
</div>
</div>
<div class="backup-arrow">
<ArrowRightOutlined />
</div>
<div class="backup-section target-section">
<div class="section-title">
<AAvatar shape="square" size="small" :src="targetIcon" />
<span>目标存储</span>
</div>
<div class="storage-selection">
<AForm layout="vertical">
<AFormItem label="存储商" name="targetProvider">
<ASelect
v-model:value="targetStorage.provider"
placeholder="请选择目标存储商"
@change="handleTargetProviderChange"
:disabled="!sourceStorage.provider"
>
<ASelectOption
v-for="item in availableTargetStorages"
:key="item.provider + '-' + item.bucket"
:value="item.provider + '-' + item.bucket"
>
<AFlex align="center" gap="small">
<AAvatar :size="20" shape="circle" :src="ProviderIcon[item.provider]" />
<span>{{ ProviderNameMap[item.provider] }} - {{ item.bucket }}</span>
</AFlex>
</ASelectOption>
</ASelect>
</AFormItem>
</AForm>
<div class="selected-storage-card" v-if="selectedTargetStorage">
<div class="storage-card-header">
<AAvatar :size="40" shape="circle" :src="ProviderIcon[selectedTargetStorage.provider]" />
<div class="storage-info">
<div class="storage-name">{{ selectedTargetStorage.bucket }}</div>
<ATag :color="ProviderColorMap[selectedTargetStorage.provider]">{{ ProviderNameMap[selectedTargetStorage.provider] }}</ATag>
</div>
</div>
<div class="storage-card-content">
<div class="storage-detail">
<AAvatar size="small" shape="square" :src="bucket" />
<span>{{ selectedTargetStorage.capacity }}GB</span>
</div>
<div class="storage-detail">
<AAvatar size="small" shape="circle" :src="location" />
<span>{{ AliRegionMap[selectedTargetStorage.region] || selectedTargetStorage.region }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="backup-action">
<AButton
type="primary"
size="large"
:disabled="!isBackupReady"
@click="startBackup"
:loading="backupInProgress"
>
开始备份
</AButton>
</div>
<AModal v-model:open="backupModalVisible" title="备份进度" :footer="null" :closable="!backupInProgress" :maskClosable="false">
<div class="backup-progress">
<AProgress :percent="backupProgress" status="active" />
<div class="backup-status">{{ backupStatus }}</div>
<div class="backup-actions" v-if="backupInProgress && backupTaskId">
<AButton danger @click="cancelBackup">取消备份</AButton>
</div>
</div>
</AModal>
</div>
<div class="account-setting-backup-empty" v-else>
<AEmpty description="请先添加至少两个存储策略才能进行备份操作" />
<AButton type="primary" @click="goToStoragePage">前往添加存储</AButton>
</div>
</div>
</template>
<script setup lang="ts">
import { RedoOutlined, ArrowRightOutlined } from '@ant-design/icons-vue';
import { listUserStorageConfigApi } from "@/api/storage";
import { backupStorageApi, getBackupProgressApi, cancelBackupTaskApi } from "@/api/storage/backup";
import { message } from "ant-design-vue";
import { AliRegionMap, ProviderColorMap, ProviderIcon, ProviderNameMap } from "@/constant/provider_map.ts";
import bucket from "@/assets/svgs/bucket.svg";
import location from "@/assets/svgs/location-album.svg";
import sourceIcon from "@/assets/svgs/source-storage.svg";
import targetIcon from "@/assets/svgs/target-storage.svg";
const router = useRouter();
const storageList = ref<any[]>([]);
const sourceStorage = ref({ provider: '', bucket: '' });
const targetStorage = ref({ provider: '', bucket: '' });
const selectedSourceStorage = ref<any>(null);
const selectedTargetStorage = ref<any>(null);
const backupModalVisible = ref(false);
const backupProgress = ref(0);
const backupStatus = ref('准备开始备份...');
const backupInProgress = ref(false);
const backupTaskId = ref('');
// 获取存储列表
async function getStorageList() {
const res: any = await listUserStorageConfigApi();
if (res && res.code === 200) {
storageList.value = res.data.records;
// 重置选择
sourceStorage.value = { provider: '', bucket: '' };
targetStorage.value = { provider: '', bucket: '' };
selectedSourceStorage.value = null;
selectedTargetStorage.value = null;
}
}
// 计算可用的目标存储(排除已选择的源存储)
const availableTargetStorages = computed(() => {
if (!sourceStorage.value.provider) return [];
return storageList.value.filter(item => {
const sourceKey = sourceStorage.value.provider;
const itemKey = item.provider + '-' + item.bucket;
return sourceKey !== itemKey;
});
});
// 判断是否可以开始备份
const isBackupReady = computed(() => {
return selectedSourceStorage.value && selectedTargetStorage.value;
});
// 处理源存储商选择变化
function handleSourceProviderChange(value) {
if (!value) {
selectedSourceStorage.value = null;
return;
}
const [provider, bucket] = value.split('-');
const selected = storageList.value.find(item =>
item.provider === provider && item.bucket === bucket
);
if (selected) {
selectedSourceStorage.value = selected;
// 如果目标存储与源存储相同,则清空目标存储
if (targetStorage.value.provider) {
const [targetProvider, targetBucket] = targetStorage.value.provider.split('-');
if (targetProvider === provider && targetBucket === bucket) {
targetStorage.value.provider = '';
selectedTargetStorage.value = null;
}
}
}
}
// 处理目标存储商选择变化
function handleTargetProviderChange(value) {
if (!value) {
selectedTargetStorage.value = null;
return;
}
const [provider, bucket] = value.split('-');
const selected = storageList.value.find(item =>
item.provider === provider && item.bucket === bucket
);
if (selected) {
selectedTargetStorage.value = selected;
}
}
// 开始备份
async function startBackup() {
if (!isBackupReady.value) return;
backupModalVisible.value = true;
backupInProgress.value = true;
backupProgress.value = 0;
backupStatus.value = '正在准备备份...';
try {
// 调用备份API
const sourceProviderInfo = selectedSourceStorage.value.provider;
const sourceBucketInfo = selectedSourceStorage.value.bucket;
const targetProviderInfo = selectedTargetStorage.value.provider;
const targetBucketInfo = selectedTargetStorage.value.bucket;
const res: any = await backupStorageApi(
sourceProviderInfo,
sourceBucketInfo,
targetProviderInfo,
targetBucketInfo
);
if (res && res.code === 200) {
backupTaskId.value = res.data.task_id;
message.success('备份任务已启动');
// 定时获取备份进度
const progressTimer = setInterval(async () => {
if (backupTaskId.value) {
const progressRes: any = await getBackupProgressApi(backupTaskId.value);
if (progressRes && progressRes.code === 200) {
const { progress, status, message: statusMessage } = progressRes.data;
backupProgress.value = progress;
backupStatus.value = statusMessage || status;
// 备份完成
if (progress >= 100 || status === 'completed') {
backupProgress.value = 100;
backupStatus.value = '备份完成!';
backupInProgress.value = false;
backupTaskId.value = '';
clearInterval(progressTimer);
message.success('备份成功!');
setTimeout(() => {
backupModalVisible.value = false;
}, 1500);
}
// 备份失败
if (status === 'failed') {
backupInProgress.value = false;
backupTaskId.value = '';
clearInterval(progressTimer);
message.error(statusMessage || '备份失败');
}
}
} else {
clearInterval(progressTimer);
}
}, 2000);
} else {
message.error(res?.message || '启动备份任务失败');
backupInProgress.value = false;
}
} catch (error) {
console.error('备份错误:', error);
message.error('备份过程中发生错误');
backupInProgress.value = false;
}
}
// 前往存储页面
function goToStoragePage() {
router.push('/main/user/setting/storage');
}
// 取消备份
async function cancelBackup() {
if (!backupTaskId.value) return;
try {
const res: any = await cancelBackupTaskApi(backupTaskId.value);
if (res && res.code === 200) {
message.success('备份任务已取消');
backupInProgress.value = false;
backupTaskId.value = '';
backupStatus.value = '备份已取消';
} else {
message.error(res?.message || '取消备份任务失败');
}
} catch (error) {
console.error('取消备份错误:', error);
message.error('取消备份过程中发生错误');
}
}
onMounted(() => {
getStorageList();
});
</script>
<style scoped lang="scss">
.account-setting-backup {
width: 100%;
height: 100%;
overflow: auto;
.account-setting-backup-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
padding: 16px 20px;
background-color: var(--white-color);
border-radius: 12px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
transition: all 0.3s ease;
span {
font-size: 20px;
font-weight: bold;
color: #333333;
position: relative;
padding-left: 12px;
&::before {
content: '';
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
width: 4px;
height: 18px;
background: #1890ff;
border-radius: 2px;
}
}
.ant-btn {
border-radius: 8px;
height: 40px;
transition: all 0.3s ease;
&:hover {
transform: translateY(-2px);
}
}
}
.account-setting-backup-body {
background-color: var(--white-color);
border-radius: 12px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
padding: 24px;
.backup-container {
display: flex;
justify-content: space-between;
align-items: center;
gap: 20px;
margin-bottom: 30px;
@media (max-width: 768px) {
flex-direction: column;
}
.backup-section {
flex: 1;
background-color: #f9f9f9;
border-radius: 12px;
padding: 20px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
transition: all 0.3s ease;
&:hover {
transform: translateY(-5px);
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.08);
}
.section-title {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 16px;
padding-bottom: 10px;
border-bottom: 1px dashed #e8e8e8;
span {
font-size: 16px;
font-weight: 600;
color: #333;
}
}
.storage-selection {
.selected-storage-card {
margin-top: 16px;
background: linear-gradient(145deg, #ffffff, #f5f7fa);
border-radius: 12px;
padding: 16px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.03);
.storage-card-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 16px;
.storage-info {
.storage-name {
font-size: 16px;
font-weight: 600;
margin-bottom: 4px;
}
}
}
.storage-card-content {
.storage-detail {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 8px;
color: #666;
font-size: 14px;
}
}
}
}
}
.source-section {
border-left: 3px solid #52c41a;
}
.target-section {
border-left: 3px solid #1890ff;
}
.backup-arrow {
font-size: 24px;
color: #1890ff;
display: flex;
justify-content: center;
align-items: center;
width: 40px;
height: 40px;
background-color: #e6f7ff;
border-radius: 50%;
box-shadow: 0 2px 8px rgba(24, 144, 255, 0.15);
animation: pulse 1.5s infinite;
@media (max-width: 768px) {
transform: rotate(90deg);
margin: 10px 0;
}
}
}
.backup-action {
display: flex;
justify-content: center;
margin-top: 30px;
.ant-btn {
height: 48px;
padding: 0 40px;
font-size: 16px;
font-weight: 500;
border-radius: 24px;
background: linear-gradient(135deg, #1890ff, #096dd9);
border: none;
box-shadow: 0 4px 12px rgba(24, 144, 255, 0.3);
transition: all 0.3s ease;
&:hover:not(:disabled) {
transform: translateY(-3px);
box-shadow: 0 8px 20px rgba(24, 144, 255, 0.4);
}
&:disabled {
background: #f5f5f5;
color: #bfbfbf;
box-shadow: none;
}
}
}
.backup-progress {
text-align: center;
.backup-status {
margin-top: 16px;
color: #666;
font-size: 14px;
}
.backup-actions {
margin-top: 20px;
display: flex;
justify-content: center;
.ant-btn {
border-radius: 20px;
height: 36px;
padding: 0 20px;
transition: all 0.3s ease;
&:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(255, 77, 79, 0.2);
}
}
}
}
}
.account-setting-backup-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 20px;
background-color: var(--white-color);
border-radius: 12px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
.ant-btn {
margin-top: 20px;
height: 40px;
border-radius: 20px;
padding: 0 24px;
font-weight: 500;
transition: all 0.3s ease;
&:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(24, 144, 255, 0.2);
}
}
}
}
@keyframes pulse {
0% {
transform: scale(1);
}
50% {
transform: scale(1.1);
}
100% {
transform: scale(1);
}
}
</style>

View File

@@ -2,97 +2,176 @@
<div class="account-setting-home">
<div class="account-setting-home-info">
<div class="account-setting-home-info-avatar">
<AAvatar :size="80" :src="userStore.user.avatar"/>
<AAvatar :size="90" :src="userStore.user.avatar"/>
<div class="user-level-badge">
<img src="/level_icon/icon/lv1.png" alt="level">
</div>
</div>
<div class="account-setting-home-info-content">
<AFlex :vertical="false" align="center" justify="space-between" gap="small">
<AFlex :vertical="false" align="center" justify="flex-start" gap="small" class="user-info-header">
<span class="account-setting-home-info-content-name">{{ userStore.user.nickname }}</span>
<ATag color="success" :bordered="false">正式会员</ATag>
<img src="/level_icon/icon/lv1.png" style="width: 40px" alt="level">
</AFlex>
<AProgress :percent="50" :stroke-color="{
'0%': '#108ee9',
'100%': '#87d068',
}" :showInfo="false" style="width: 500px" :size="15"/>
<AFlex :vertical="false" align="flex-start" justify="space-between" style="width: 500px">
<img src="/level_icon/icon/lv1.png" class="avatar-level-icon" alt="level">
<img src="/level_icon/icon/lv2.png" class="avatar-level-icon" alt="level">
<ATag color="processing" :bordered="false">活跃用户</ATag>
</AFlex>
<div class="level-progress-container">
<span class="level-label">等级进度</span>
<AProgress :percent="50" :stroke-color="{
'0%': '#108ee9',
'100%': '#87d068',
}" :showInfo="false" :size="12"/>
<AFlex :vertical="false" align="center" justify="space-between" class="level-icons">
<div class="level-icon-wrapper current">
<img src="/level_icon/icon/lv1.png" class="avatar-level-icon" alt="level">
<span>当前</span>
</div>
<div class="level-icon-wrapper next">
<img src="/level_icon/icon/lv2.png" class="avatar-level-icon" alt="level">
<span>下一级</span>
</div>
</AFlex>
</div>
</div>
</div>
<ADivider/>
<div class="account-setting-home-stats">
<div class="stat-item">
<AAvatar size="small" :src="storage" />
<div class="stat-content">
<span class="stat-value">2.5GB</span>
<span class="stat-label">已用存储</span>
</div>
</div>
<ADivider type="vertical" class="stat-divider" />
<div class="stat-item">
<AAvatar size="small" :src="image" />
<div class="stat-content">
<span class="stat-value">128</span>
<span class="stat-label">照片数量</span>
</div>
</div>
<ADivider type="vertical" class="stat-divider" />
<div class="stat-item">
<AAvatar size="small" :src="album" />
<div class="stat-content">
<span class="stat-value">12</span>
<span class="stat-label">相册数量</span>
</div>
</div>
</div>
<div class="account-setting-home-content">
<div class="account-setting-home-content-header">
<AAvatar size="default" :src="accountSecurity"/>
<span style="font-size: 20px; font-weight: bold;color: #333">账号安全</span>
<AAvatar size="large" :src="accountSecurity"/>
<span class="section-title">账号安全</span>
<ATag color="warning" class="security-score">安全评分: 85</ATag>
</div>
<div class="account-setting-home-content-section" v-if="securityStatus">
<AFlex :vertical="false" align="center" justify="space-between" :gap="50">
<div class="account-setting-home-content-section-item">
<div class="account-setting-home-content-section-item-avatar">
<AAvatar shape="circle" :size="80" :src="emailSecurity"/>
<div class="security-card">
<div class="security-card-avatar">
<AAvatar shape="circle" :size="60" :src="emailSecurity"/>
</div>
<div class="security-card-content">
<div class="security-card-header">
<span class="security-card-title">我的邮箱</span>
<ATag :color="securityStatus.bind_email ? 'success' : 'error'" class="status-tag">
{{ securityStatus.bind_email ? "已绑定" : "未绑定" }}
</ATag>
</div>
<div class="account-setting-home-content-section-item-content">
<span style="font-size: 18px; font-weight: bold;color: #333;margin-top: 10px">我的邮箱</span>
<span style="font-size: 14px; color: #666;">绑定邮箱后即可使用邮箱登录</span>
<AFlex :vertical="false" align="center" justify="space-between" gap="large">
<AButton type="primary" size="small" :disabled="securityStatus.bind_email">
{{ securityStatus.bind_email ? "已绑定邮箱" : "绑定邮箱" }}
</AButton>
<AButton type="link" size="small">{{ securityStatus.bind_email ? "修改邮箱" : "绑定邮箱" }}</AButton>
</AFlex>
<p class="security-card-desc">绑定邮箱后即可使用邮箱登录</p>
<div class="security-card-actions">
<AButton type="primary" size="small" :disabled="securityStatus.bind_email" @click="handleBindEmail">
{{ securityStatus.bind_email ? "已绑定邮箱" : "绑定邮箱" }}
</AButton>
<AButton type="link" size="small" @click="securityStatus.bind_email ? handleUpdateEmail() : handleBindEmail()">{{ securityStatus.bind_email ? "修改邮箱" : "绑定邮箱" }}</AButton>
<AButton v-if="securityStatus.bind_email" type="link" size="small" danger @click="handleUnbindEmail">解绑邮箱</AButton>
</div>
</div>
<div class="account-setting-home-content-section-item">
<div class="account-setting-home-content-section-item-avatar">
<AAvatar shape="circle" :size="80" :src="phoneSecurity"/>
</div>
<div class="security-card">
<div class="security-card-avatar">
<AAvatar shape="circle" :size="60" :src="phoneSecurity"/>
</div>
<div class="security-card-content">
<div class="security-card-header">
<span class="security-card-title">我的手机</span>
<ATag color="success" class="status-tag">已绑定</ATag>
</div>
<div class="account-setting-home-content-section-item-content">
<span style="font-size: 18px; font-weight: bold;color: #333;margin-top: 10px">我的手机</span>
<span style="font-size: 14px; color: #666;">绑定手机后即可使用手机号登录</span>
<AFlex :vertical="false" align="center" justify="space-between" gap="large">
<AButton type="primary" size="small" :disabled="true">已绑定手机</AButton>
<AButton type="link" size="small">更改手机</AButton>
</AFlex>
<p class="security-card-desc">绑定手机后即可使用手机号登录</p>
<div class="security-card-actions">
<AButton type="primary" size="small" :disabled="securityStatus.bind_phone" @click="handleBindPhone">
{{ securityStatus.bind_phone ? "已绑定手机" : "绑定手机" }}
</AButton>
<AButton type="link" size="small" @click="securityStatus.bind_phone ? handleUpdatePhone() : handleBindPhone()">{{ securityStatus.bind_phone ? "修改手机" : "绑定手机" }}</AButton>
</div>
</div>
</AFlex>
<AFlex :vertical="false" align="center" justify="space-between" :gap="50">
<div class="account-setting-home-content-section-item">
<div class="account-setting-home-content-section-item-avatar">
<AAvatar shape="circle" :size="80" :src="passwordSecurity"/>
</div>
<div class="security-card">
<div class="security-card-avatar">
<AAvatar shape="circle" :size="60" :src="passwordSecurity"/>
</div>
<div class="security-card-content">
<div class="security-card-header">
<span class="security-card-title">我的密保</span>
<ATag :color="securityStatus.set_password ? 'success' : 'error'" class="status-tag">
{{ securityStatus.set_password ? "已设置" : "未设置" }}
</ATag>
</div>
<div class="account-setting-home-content-section-item-content">
<span style="font-size: 18px; font-weight: bold;color: #333;margin-top: 10px">我的密保</span>
<span style="font-size: 14px; color: #666;">设置密保账号更安全</span>
<AFlex :vertical="false" align="center" justify="space-between" gap="large">
<AButton type="primary" size="small" :disabled="securityStatus.set_password">
{{ securityStatus.set_password ? "已设置密保" : "设置密保" }}
</AButton>
<AButton type="link" size="small">{{ securityStatus.set_password ? "修改密保" : "设置密保" }}</AButton>
</AFlex>
<p class="security-card-desc">设置密保账号更安全</p>
<div class="security-card-actions">
<AButton type="primary" size="small" :disabled="securityStatus.set_password" @click="handleSetPassword">
{{ securityStatus.set_password ? "已设置密保" : "设置密保" }}
</AButton>
<AButton type="link" size="small" @click="securityStatus.set_password ? handleUpdatePassword() : handleSetPassword()">{{ securityStatus.set_password ? "修改密保" : "设置密保" }}</AButton>
</div>
</div>
<div class="account-setting-home-content-section-item">
<div class="account-setting-home-content-section-item-avatar">
<AAvatar shape="circle" :size="80" :src="loginSecurity"/>
</div>
<div class="security-card">
<div class="security-card-avatar">
<AAvatar shape="circle" :size="60" :src="loginSecurity"/>
</div>
<div class="security-card-content">
<div class="security-card-header">
<span class="security-card-title">三方登录</span>
<ATag :color="securityStatus.bind_wechet ? 'success' : 'error'" class="status-tag">
{{ securityStatus.bind_wechet ? "已绑定" : "未绑定" }}
</ATag>
</div>
<div class="account-setting-home-content-section-item-content">
<span style="font-size: 18px; font-weight: bold;color: #333;margin-top: 10px">三方登录</span>
<span style="font-size: 14px; color: #666;">绑定三方账号安全登录</span>
<AFlex :vertical="false" align="center" justify="space-between" gap="large">
<AButton type="primary" size="small" :disabled="securityStatus.bind_wechet">
{{ securityStatus.bind_wechet ? "已绑定" : "绑定" }}
</AButton>
<AButton type="link" size="small">{{ securityStatus.bind_wechet ? "管理" : "" }}</AButton>
</AFlex>
<p class="security-card-desc">绑定三方账号安全登录</p>
<div class="security-card-actions">
<AButton type="primary" size="small" :disabled="securityStatus.bind_wechet" @click="handleBindThirdParty">
{{ securityStatus.bind_wechet ? "已绑定" : "绑定" }}
</AButton>
<AButton type="link" size="small" @click="handleManageThirdParty">{{ securityStatus.bind_wechet ? "管理" : "绑定" }}</AButton>
</div>
</div>
</AFlex>
</div>
</div>
</div>
</div>
<!-- 邮箱操作模态窗口 -->
<EmailModal
:currentEmail="securityStatus?.email || ''"
@success="handleEmailSuccess"
/>
<!-- 手机操作模态窗口 -->
<PhoneModal
:currentPhone="securityStatus?.phone || ''"
@success="handlePhoneSuccess"
/>
<!-- 密码操作模态窗口 -->
<PasswordModal
@success="handlePasswordSuccess"
/>
<!-- 第三方登录操作模态窗口 -->
<ThirdPartyLoginModal
@success="handleThirdPartySuccess"
/>
</template>
<script setup lang="ts">
import useStore from "@/store";
@@ -101,15 +180,25 @@ import emailSecurity from "@/assets/svgs/email_security.svg";
import phoneSecurity from "@/assets/svgs/phone_security.svg";
import passwordSecurity from "@/assets/svgs/password_security.svg";
import loginSecurity from "@/assets/svgs/login_security.svg";
import storage from "@/assets/svgs/storage.svg";
import image from "@/assets/svgs/image.svg";
import album from "@/assets/svgs/album.svg";
import {checkSecuritySettingApi} from "@/api/auth";
import EmailModal from "./EmailModal.vue";
import PhoneModal from "./PhoneModal.vue";
import PasswordModal from "./PasswordModal.vue";
import ThirdPartyLoginModal from "./ThirdPartyLoginModal.vue";
import { message } from 'ant-design-vue';
const userStore = useStore().user;
const securityStatus = ref<any>();
// 使用store中的邮箱模态窗口状态
// 不再需要本地状态变量直接使用userStore.emailModalState
async function checkStatus() {
const res: any = await checkSecuritySettingApi();
console.log(res);
if (res && res.code === 200) {
securityStatus.value = res.data;
}
@@ -119,63 +208,293 @@ onMounted(() => {
checkStatus();
});
// 绑定邮箱
const handleBindEmail = () => {
userStore.openBindEmailModal();
};
// 修改邮箱
const handleUpdateEmail = () => {
userStore.openUpdateEmailModal();
};
// 解绑邮箱
const handleUnbindEmail = () => {
userStore.openUnbindEmailModal();
};
// 处理邮箱操作成功后的回调
const handleEmailSuccess = (type: string) => {
if (type === 'bind_email') {
message.success('邮箱绑定成功');
} else if (type === 'update_email') {
message.success('邮箱修改成功');
} else if (type === 'unbind_email') {
message.success('邮箱解绑成功');
}
// 刷新安全状态
checkStatus();
};
// 绑定手机
const handleBindPhone = () => {
userStore.openBindPhoneModal();
};
// 修改手机
const handleUpdatePhone = () => {
userStore.openUpdatePhoneModal();
};
// 处理手机操作成功后的回调
const handlePhoneSuccess = (type: string) => {
if (type === 'bind_phone') {
message.success('手机绑定成功');
} else if (type === 'update_phone') {
message.success('手机修改成功');
} else if (type === 'unbind_phone') {
message.success('手机解绑成功');
}
// 刷新安全状态
checkStatus();
};
// 设置密码
const handleSetPassword = () => {
userStore.openSetPasswordModal();
};
// 修改密码
const handleUpdatePassword = () => {
userStore.openUpdatePasswordModal();
};
// 处理密码操作成功后的回调
const handlePasswordSuccess = (type: string) => {
if (type === 'set_password') {
message.success('密码设置成功');
} else if (type === 'update_password') {
message.success('密码修改成功');
}
// 刷新安全状态
checkStatus();
};
// 绑定第三方账号
const handleBindThirdParty = () => {
userStore.openThirdPartyModal();
};
// 管理第三方账号
const handleManageThirdParty = () => {
userStore.openThirdPartyModal();
};
// 处理第三方账号操作成功后的回调
const handleThirdPartySuccess = (_type: string) => {
// 刷新安全状态
checkStatus();
};
</script>
<style scoped lang="scss">
.account-setting-home {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: flex-start;
padding: 20px;
background-color: #f9f9f9;
border-radius: 16px;
gap: 20px;
.account-setting-home-info {
width: 100%;
height: 150px;
height: auto;
min-height: 140px;
position: relative;
display: flex;
flex-direction: row;
justify-content: flex-start;
align-items: flex-start;
padding-top: 40px;
align-items: center;
background: linear-gradient(135deg, #f5f7fa 0%, #e4edf9 100%);
border-radius: 16px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
overflow: hidden;
&::after {
content: '';
position: absolute;
bottom: -30px;
right: -30px;
width: 150px;
height: 150px;
background-color: rgba(255, 255, 255, 0.1);
border-radius: 50%;
z-index: 0;
}
.account-setting-home-info-avatar {
width: 130px;
height: 100px;
width: 110px;
height: 110px;
display: flex;
justify-content: center;
align-items: flex-start;
align-items: center;
position: relative;
:deep(.ant-avatar) {
border: 4px solid white;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
}
.user-level-badge {
position: absolute;
bottom: 0;
right: 0;
width: 40px;
height: 40px;
background-color: white;
border-radius: 50%;
display: flex;
justify-content: center;
align-items: center;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15);
z-index: 2;
img {
width: 32px;
height: 32px;
}
}
}
.account-setting-home-info-content {
width: calc(100% - 100px);
width: calc(100% - 110px);
height: 100%;
display: flex;
flex-direction: column;
justify-content: flex-start;
justify-content: center;
align-items: flex-start;
gap: 10px;
gap: 14px;
padding-left: 20px;
z-index: 1;
.user-info-header {
width: 100%;
}
.account-setting-home-info-content-name {
font-size: 24px;
font-weight: bold;
color: #333;
margin-right: 12px;
}
.avatar-level-icon {
width: 40px;
.level-progress-container {
width: 100%;
max-width: 450px;
display: flex;
flex-direction: column;
gap: 8px;
.level-label {
font-size: 14px;
color: #666;
font-weight: 500;
}
.level-icons {
width: 100%;
margin-top: 5px;
.level-icon-wrapper {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
span {
font-size: 12px;
color: #999;
}
&.current span {
color: #108ee9;
font-weight: 500;
}
&.next span {
color: #87d068;
}
.avatar-level-icon {
width: 32px;
transition: transform 0.3s ease;
&:hover {
transform: scale(1.1);
}
}
}
}
}
}
}
.account-setting-home-content {
.account-setting-home-stats {
width: 100%;
height: 70%;
height: 80px;
display: flex;
flex-direction: row;
justify-content: space-around;
align-items: center;
background-color: white;
border-radius: 16px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
//padding: 0 20px;
.stat-item {
display: flex;
flex-direction: row;
align-items: center;
gap: 12px;
.stat-content {
display: flex;
flex-direction: column;
gap: 4px;
.stat-value {
font-size: 18px;
font-weight: bold;
color: #333;
}
.stat-label {
font-size: 12px;
color: #999;
}
}
}
.stat-divider {
height: 40px;
}
}
.account-setting-home-content {
width: calc(100% - 40px);
min-height: 300px;
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: flex-start;
gap: 20px;
background-color: white;
border-radius: 16px;
padding: 20px;
//box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
.account-setting-home-content-header {
width: 100%;
@@ -184,47 +503,108 @@ onMounted(() => {
flex-direction: row;
align-items: center;
justify-content: flex-start;
gap: 10px;
gap: 12px;
//border-bottom: 1px solid #f0f0f0;
//padding-bottom: 14px;
//margin-bottom: 14px;
position: relative;
.section-title {
font-size: 18px;
font-weight: bold;
color: #333;
}
.security-score {
position: absolute;
right: 0;
}
}
.account-setting-home-content-section {
width: 100%;
height: 100%;
display: flex;
flex-direction: row;
justify-content: flex-start;
align-items: flex-start;
flex-wrap: wrap;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 20px;
.account-setting-home-content-section-item {
width: 330px;
height: 100px;
.security-card {
height: 160px;
background-color: #fff;
border-radius: 10px;
padding: 10px;
border-radius: 16px;
padding: 16px;
display: flex;
flex-direction: row;
justify-content: flex-start;
align-items: flex-start;
gap: 10px;
gap: 16px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
border: 1px solid #f0f0f0;
transition: all 0.3s ease;
.account-setting-home-content-section-item-avatar {
width: 100px;
&:hover {
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.1);
transform: translateY(-3px);
}
.security-card-avatar {
width: 80px;
height: 100%;
display: flex;
justify-content: center;
align-items: flex-start;
padding-top: 10px;
:deep(.ant-avatar) {
background-color: #f5f7fa;
transition: transform 0.3s ease;
&:hover {
transform: scale(1.05);
}
}
}
.account-setting-home-content-section-item-content {
width: 300px;
.security-card-content {
width: calc(100% - 80px);
height: 100%;
display: flex;
flex-direction: column;
justify-content: flex-start;
justify-content: space-between;
align-items: flex-start;
gap: 10px;
gap: 8px;
.security-card-header {
width: 100%;
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
.security-card-title {
font-size: 16px;
font-weight: bold;
color: #333;
}
.status-tag {
margin-left: auto;
}
}
.security-card-desc {
font-size: 14px;
color: #666;
margin: 0;
}
.security-card-actions {
width: 100%;
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
margin-top: auto;
}
}
}
}

View File

@@ -0,0 +1,284 @@
<template>
<div>
<!-- 绑定邮箱模态窗口 -->
<AModal
:open="userStore.emailModalState.bindEmailVisible"
title="绑定邮箱"
:width="400"
:maskClosable="false"
@cancel="handleCancel"
>
<AForm
:model="formState"
name="bindEmailForm"
:label-col="{ span: 6 }"
:wrapper-col="{ span: 18 }"
autocomplete="off"
@finish="handleBindEmail"
>
<AFormItem
label="邮箱地址"
name="email"
:rules="[{ required: true, message: '请输入邮箱地址' }, { type: 'email', message: '请输入有效的邮箱地址' }]"
>
<AInput v-model:value="formState.email" placeholder="请输入邮箱地址" />
</AFormItem>
<AFormItem label="验证码" name="captcha" :rules="[{ required: true, message: '请输入验证码' }]">
<div style="display: flex; gap: 8px;">
<AInput v-model:value="formState.captcha" placeholder="请输入验证码" style="width: 70%" />
<AButton
type="primary"
:disabled="captchaLoading || captchaCountdown > 0"
:loading="captchaLoading"
@click="handleSendCaptcha"
style="width: 30%"
>
{{ captchaCountdown > 0 ? `${captchaCountdown}s` : '获取验证码' }}
</AButton>
</div>
</AFormItem>
</AForm>
<template #footer>
<AButton key="back" @click="handleCancel">取消</AButton>
<AButton key="submit" type="primary" :loading="submitLoading" @click="handleSubmit" html-type="submit">确定</AButton>
</template>
</AModal>
<!-- 修改邮箱模态窗口 -->
<AModal
:open="userStore.emailModalState.updateEmailVisible"
title="修改邮箱"
:width="400"
:maskClosable="false"
@cancel="handleCancel"
>
<AForm
:model="formState"
name="updateEmailForm"
:label-col="{ span: 6 }"
:wrapper-col="{ span: 18 }"
autocomplete="off"
@finish="handleUpdateEmail"
>
<AFormItem label="当前邮箱">
<AInput :value="currentEmail" disabled />
</AFormItem>
<AFormItem
label="新邮箱地址"
name="email"
:rules="[{ required: true, message: '请输入新邮箱地址' }, { type: 'email', message: '请输入有效的邮箱地址' }]"
>
<AInput v-model:value="formState.email" placeholder="请输入新邮箱地址" />
</AFormItem>
<AFormItem label="验证码" name="captcha" :rules="[{ required: true, message: '请输入验证码' }]">
<div style="display: flex; gap: 8px;">
<AInput v-model:value="formState.captcha" placeholder="请输入验证码" style="width: 70%" />
<AButton
type="primary"
:disabled="captchaLoading || captchaCountdown > 0"
:loading="captchaLoading"
@click="handleSendCaptcha"
style="width: 30%"
>
{{ captchaCountdown > 0 ? `${captchaCountdown}s` : '获取验证码' }}
</AButton>
</div>
</AFormItem>
</AForm>
<template #footer>
<AButton key="back" @click="handleCancel">取消</AButton>
<AButton key="submit" type="primary" :loading="submitLoading" @click="handleSubmit" html-type="submit">确定</AButton>
</template>
</AModal>
<!-- 解绑邮箱确认模态窗口 -->
<AModal
:open="userStore.emailModalState.unbindEmailVisible"
title="解绑邮箱"
:width="400"
:maskClosable="false"
@cancel="handleCancel"
>
<p>确定要解绑当前邮箱吗解绑后将无法使用邮箱登录</p>
<p style="color: #ff4d4f; margin-top: 10px;">当前邮箱: {{ currentEmail }}</p>
<template #footer>
<AButton key="back" @click="handleCancel">取消</AButton>
<AButton key="submit" danger :loading="submitLoading" @click="handleUnbindEmail">确定解绑</AButton>
</template>
</AModal>
</div>
</template>
<script setup lang="ts">
import { message } from 'ant-design-vue';
import { sendEmailCaptchaApi, bindEmailApi, unbindEmailApi, updateEmailApi } from '@/api/user/email';
import useStore from "@/store";
defineProps({
currentEmail: {
type: String,
default: ''
}
});
const emit = defineEmits(['success']);
// 获取用户store
const userStore = useStore().user;
// 表单状态
const formState = reactive({
email: '',
captcha: ''
});
// 加载状态
const captchaLoading = ref(false);
const submitLoading = ref(false);
const captchaCountdown = ref(0);
// 监听store中的状态变化并重置表单
watch(() => userStore.emailModalState.bindEmailVisible, (val) => {
if (!val) resetForm();
});
watch(() => userStore.emailModalState.updateEmailVisible, (val) => {
if (!val) resetForm();
});
// 重置表单
const resetForm = () => {
formState.email = '';
formState.captcha = '';
};
// 取消操作
const handleCancel = () => {
userStore.closeAllEmailModals();
resetForm();
};
// 发送验证码
const handleSendCaptcha = async () => {
// 验证邮箱格式
if (!formState.email) {
message.error('请输入邮箱地址');
return;
}
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(formState.email)) {
message.error('请输入有效的邮箱地址');
return;
}
try {
captchaLoading.value = true;
const res: any = await sendEmailCaptchaApi(formState.email);
if (res && res.code === 200) {
message.success('验证码已发送,请查收');
// 开始倒计时
captchaCountdown.value = 60;
const timer = setInterval(() => {
captchaCountdown.value--;
if (captchaCountdown.value <= 0) {
clearInterval(timer);
}
}, 1000);
} else {
message.error(res?.message || '验证码发送失败');
}
} catch (error) {
console.error('发送验证码失败:', error);
message.error('验证码发送失败,请稍后重试');
} finally {
captchaLoading.value = false;
}
};
// 提交表单
const handleSubmit = () => {
// 根据当前打开的模态窗口类型调用相应的处理函数
if (userStore.emailModalState.bindEmailVisible) {
handleBindEmail();
} else if (userStore.emailModalState.updateEmailVisible) {
handleUpdateEmail();
}
};
// 绑定邮箱
const handleBindEmail = async () => {
if (!formState.email || !formState.captcha) {
message.error('请填写完整信息');
return;
}
try {
submitLoading.value = true;
const res: any = await bindEmailApi(formState.email, formState.captcha);
if (res && res.code === 200) {
message.success('邮箱绑定成功');
emit('success', 'bind_email');
handleCancel();
} else {
message.error(res?.message || '邮箱绑定失败');
}
} catch (error) {
console.error('绑定邮箱失败:', error);
message.error('邮箱绑定失败,请稍后重试');
} finally {
submitLoading.value = false;
}
};
// 更新邮箱
const handleUpdateEmail = async () => {
if (!formState.email || !formState.captcha) {
message.error('请填写完整信息');
return;
}
try {
submitLoading.value = true;
const res: any = await updateEmailApi(formState.email, formState.captcha);
if (res && res.code === 200) {
message.success('邮箱修改成功');
emit('success', 'update_email');
handleCancel();
} else {
message.error(res?.message || '邮箱修改失败');
}
} catch (error) {
console.error('修改邮箱失败:', error);
message.error('邮箱修改失败,请稍后重试');
} finally {
submitLoading.value = false;
}
};
// 解绑邮箱
const handleUnbindEmail = async () => {
try {
submitLoading.value = true;
const res: any = await unbindEmailApi();
if (res && res.code === 200) {
message.success('邮箱解绑成功');
emit('success', 'unbind_email');
handleCancel();
} else {
message.error(res?.message || '邮箱解绑失败');
}
} catch (error) {
console.error('解绑邮箱失败:', error);
message.error('邮箱解绑失败,请稍后重试');
} finally {
submitLoading.value = false;
}
};
</script>

View File

@@ -0,0 +1,201 @@
<template>
<div>
<!-- 设置密码模态窗口 -->
<AModal
:open="userStore.passwordModalState.setPasswordVisible"
title="设置密码"
:width="400"
:maskClosable="false"
@cancel="handleCancel"
>
<AForm
:model="formState"
name="setPasswordForm"
:label-col="{ span: 6 }"
:wrapper-col="{ span: 18 }"
autocomplete="off"
@finish="handleSetPassword"
>
<AFormItem
label="新密码"
name="password"
:rules="[{ required: true, message: '请输入新密码' }, { min: 6, message: '密码长度不能少于6个字符' }]"
>
<AInputPassword v-model:value="formState.password" placeholder="请输入新密码" />
</AFormItem>
<AFormItem
label="确认密码"
name="confirmPassword"
:rules="[
{ required: true, message: '请确认密码' },
{ validator: validateConfirmPassword }
]"
>
<AInputPassword v-model:value="formState.confirmPassword" placeholder="请确认密码" />
</AFormItem>
</AForm>
<template #footer>
<AButton key="back" @click="handleCancel">取消</AButton>
<AButton key="submit" type="primary" :loading="submitLoading" @click="handleSubmit" html-type="submit">确定</AButton>
</template>
</AModal>
<!-- 修改密码模态窗口 -->
<AModal
:open="userStore.passwordModalState.updatePasswordVisible"
title="修改密码"
:width="400"
:maskClosable="false"
@cancel="handleCancel"
>
<AForm
:model="formState"
name="updatePasswordForm"
:label-col="{ span: 6 }"
:wrapper-col="{ span: 18 }"
autocomplete="off"
@finish="handleUpdatePassword"
>
<AFormItem
label="当前密码"
name="oldPassword"
:rules="[{ required: true, message: '请输入当前密码' }]"
>
<AInputPassword v-model:value="formState.oldPassword" placeholder="请输入当前密码" />
</AFormItem>
<AFormItem
label="新密码"
name="password"
:rules="[{ required: true, message: '请输入新密码' }, { min: 6, message: '密码长度不能少于6个字符' }]"
>
<AInputPassword v-model:value="formState.password" placeholder="请输入新密码" />
</AFormItem>
<AFormItem
label="确认密码"
name="confirmPassword"
:rules="[
{ required: true, message: '请确认密码' },
{ validator: validateConfirmPassword }
]"
>
<AInputPassword v-model:value="formState.confirmPassword" placeholder="请确认密码" />
</AFormItem>
</AForm>
<template #footer>
<AButton key="back" @click="handleCancel">取消</AButton>
<AButton key="submit" type="primary" :loading="submitLoading" @click="handleSubmit" html-type="submit">确定</AButton>
</template>
</AModal>
</div>
</template>
<script setup lang="ts">
import { message } from 'ant-design-vue';
import useStore from "@/store";
const emit = defineEmits(['success']);
// 获取用户store
const userStore = useStore().user;
// 表单状态
const formState = reactive({
oldPassword: '',
password: '',
confirmPassword: ''
});
// 加载状态
const submitLoading = ref(false);
// 监听store中的状态变化并重置表单
watch(() => userStore.passwordModalState.setPasswordVisible, (val) => {
if (!val) resetForm();
});
watch(() => userStore.passwordModalState.updatePasswordVisible, (val) => {
if (!val) resetForm();
});
// 重置表单
const resetForm = () => {
formState.oldPassword = '';
formState.password = '';
formState.confirmPassword = '';
};
// 验证确认密码
const validateConfirmPassword = async (_rule: any, value: string) => {
if (value !== formState.password) {
return Promise.reject('两次输入的密码不一致');
}
return Promise.resolve();
};
// 取消操作
const handleCancel = () => {
userStore.closeAllPasswordModals();
resetForm();
};
// 提交表单
const handleSubmit = () => {
// 根据当前打开的模态窗口类型调用相应的处理函数
if (userStore.passwordModalState.setPasswordVisible) {
handleSetPassword();
} else if (userStore.passwordModalState.updatePasswordVisible) {
handleUpdatePassword();
}
};
// 设置密码
const handleSetPassword = async () => {
if (!formState.password || !formState.confirmPassword) {
message.error('请填写完整信息');
return;
}
try {
submitLoading.value = true;
// 这里需要实现设置密码的API调用暂时模拟成功
setTimeout(() => {
message.success('密码设置成功');
emit('success', 'set_password');
handleCancel();
submitLoading.value = false;
}, 1000);
} catch (error) {
console.error('设置密码失败:', error);
message.error('密码设置失败,请稍后重试');
submitLoading.value = false;
}
};
// 修改密码
const handleUpdatePassword = async () => {
if (!formState.oldPassword || !formState.password || !formState.confirmPassword) {
message.error('请填写完整信息');
return;
}
try {
submitLoading.value = true;
// 这里需要实现修改密码的API调用暂时模拟成功
setTimeout(() => {
message.success('密码修改成功');
emit('success', 'update_password');
handleCancel();
submitLoading.value = false;
}, 1000);
} catch (error) {
console.error('修改密码失败:', error);
message.error('密码修改失败,请稍后重试');
submitLoading.value = false;
}
};
</script>

View File

@@ -0,0 +1,272 @@
<template>
<div>
<!-- 绑定手机模态窗口 -->
<AModal
:open="userStore.phoneModalState.bindPhoneVisible"
title="绑定手机"
:width="400"
:maskClosable="false"
@cancel="handleCancel"
>
<AForm
:model="formState"
name="bindPhoneForm"
:label-col="{ span: 6 }"
:wrapper-col="{ span: 18 }"
autocomplete="off"
@finish="handleBindPhone"
>
<AFormItem
label="手机号码"
name="phone"
:rules="[{ required: true, message: '请输入手机号码' }, { pattern: /^1[3-9]\d{9}$/, message: '请输入有效的手机号码' }]"
>
<AInput v-model:value="formState.phone" placeholder="请输入手机号码" />
</AFormItem>
<AFormItem label="验证码" name="captcha" :rules="[{ required: true, message: '请输入验证码' }]">
<div style="display: flex; gap: 8px;">
<AInput v-model:value="formState.captcha" placeholder="请输入验证码" style="width: 70%" />
<AButton
type="primary"
:disabled="captchaLoading || captchaCountdown > 0"
:loading="captchaLoading"
@click="handleSendCaptcha"
style="width: 30%"
>
{{ captchaCountdown > 0 ? `${captchaCountdown}s` : '获取验证码' }}
</AButton>
</div>
</AFormItem>
</AForm>
<template #footer>
<AButton key="back" @click="handleCancel">取消</AButton>
<AButton key="submit" type="primary" :loading="submitLoading" @click="handleSubmit" html-type="submit">确定</AButton>
</template>
</AModal>
<!-- 修改手机模态窗口 -->
<AModal
:open="userStore.phoneModalState.updatePhoneVisible"
title="修改手机"
:width="400"
:maskClosable="false"
@cancel="handleCancel"
>
<AForm
:model="formState"
name="updatePhoneForm"
:label-col="{ span: 6 }"
:wrapper-col="{ span: 18 }"
autocomplete="off"
@finish="handleUpdatePhone"
>
<AFormItem label="当前手机">
<AInput :value="currentPhone" disabled />
</AFormItem>
<AFormItem
label="新手机号码"
name="phone"
:rules="[{ required: true, message: '请输入新手机号码' }, { pattern: /^1[3-9]\d{9}$/, message: '请输入有效的手机号码' }]"
>
<AInput v-model:value="formState.phone" placeholder="请输入新手机号码" />
</AFormItem>
<AFormItem label="验证码" name="captcha" :rules="[{ required: true, message: '请输入验证码' }]">
<div style="display: flex; gap: 8px;">
<AInput v-model:value="formState.captcha" placeholder="请输入验证码" style="width: 70%" />
<AButton
type="primary"
:disabled="captchaLoading || captchaCountdown > 0"
:loading="captchaLoading"
@click="handleSendCaptcha"
style="width: 30%"
>
{{ captchaCountdown > 0 ? `${captchaCountdown}s` : '获取验证码' }}
</AButton>
</div>
</AFormItem>
</AForm>
<template #footer>
<AButton key="back" @click="handleCancel">取消</AButton>
<AButton key="submit" type="primary" :loading="submitLoading" @click="handleSubmit" html-type="submit">确定</AButton>
</template>
</AModal>
<!-- 解绑手机确认模态窗口 -->
<AModal
:open="userStore.phoneModalState.unbindPhoneVisible"
title="解绑手机"
:width="400"
:maskClosable="false"
@cancel="handleCancel"
>
<p>确定要解绑当前手机吗解绑后将无法使用手机号登录</p>
<p style="color: #ff4d4f; margin-top: 10px;">当前手机: {{ currentPhone }}</p>
<template #footer>
<AButton key="back" @click="handleCancel">取消</AButton>
<AButton key="submit" danger :loading="submitLoading" @click="handleUnbindPhone">确定解绑</AButton>
</template>
</AModal>
</div>
</template>
<script setup lang="ts">
import { message } from 'ant-design-vue';
import { sendMessage } from '@/api/user';
import useStore from "@/store";
defineProps({
currentPhone: {
type: String,
default: ''
}
});
const emit = defineEmits(['success']);
// 获取用户store
const userStore = useStore().user;
// 表单状态
const formState = reactive({
phone: '',
captcha: ''
});
// 加载状态
const captchaLoading = ref(false);
const submitLoading = ref(false);
const captchaCountdown = ref(0);
// 监听store中的状态变化并重置表单
watch(() => userStore.phoneModalState.bindPhoneVisible, (val) => {
if (!val) resetForm();
});
watch(() => userStore.phoneModalState.updatePhoneVisible, (val) => {
if (!val) resetForm();
});
// 重置表单
const resetForm = () => {
formState.phone = '';
formState.captcha = '';
};
// 取消操作
const handleCancel = () => {
userStore.closeAllPhoneModals();
resetForm();
};
// 发送验证码
const handleSendCaptcha = async () => {
// 验证手机号格式
if (!formState.phone) {
message.error('请输入手机号码');
return;
}
const phoneRegex = /^1[3-9]\d{9}$/;
if (!phoneRegex.test(formState.phone)) {
message.error('请输入有效的手机号码');
return;
}
try {
captchaLoading.value = true;
// 这里需要实现获取图形验证码的逻辑,暂时模拟
const params = {
phone: formState.phone,
angle: 0,
key: ''
};
const res: any = await sendMessage(params);
if (res && res.code === 200) {
message.success('验证码已发送,请查收');
// 开始倒计时
captchaCountdown.value = 60;
const timer = setInterval(() => {
captchaCountdown.value--;
if (captchaCountdown.value <= 0) {
clearInterval(timer);
}
}, 1000);
} else {
message.error(res?.message || '验证码发送失败');
}
} catch (error) {
console.error('发送验证码失败:', error);
message.error('验证码发送失败,请稍后重试');
} finally {
captchaLoading.value = false;
}
};
// 提交表单
const handleSubmit = () => {
// 根据当前打开的模态窗口类型调用相应的处理函数
if (userStore.phoneModalState.bindPhoneVisible) {
handleBindPhone();
} else if (userStore.phoneModalState.updatePhoneVisible) {
handleUpdatePhone();
}
};
// 绑定手机
const handleBindPhone = async () => {
if (!formState.phone || !formState.captcha) {
message.error('请填写完整信息');
return;
}
try {
submitLoading.value = true;
// 这里需要实现绑定手机的API调用暂时模拟成功
setTimeout(() => {
message.success('手机绑定成功');
emit('success', 'bind_phone');
handleCancel();
submitLoading.value = false;
}, 1000);
} catch (error) {
console.error('绑定手机失败:', error);
message.error('手机绑定失败,请稍后重试');
submitLoading.value = false;
}
};
// 更新手机
const handleUpdatePhone = async () => {
if (!formState.phone || !formState.captcha) {
message.error('请填写完整信息');
return;
}
try {
submitLoading.value = true;
// 这里需要实现更新手机的API调用暂时模拟成功
setTimeout(() => {
message.success('手机修改成功');
emit('success', 'update_phone');
handleCancel();
submitLoading.value = false;
}, 1000);
} catch (error) {
console.error('修改手机失败:', error);
message.error('手机修改失败,请稍后重试');
submitLoading.value = false;
}
};
// 解绑手机
const handleUnbindPhone = async () => {
// 移除解绑手机功能,保留函数但不执行任何操作
handleCancel();
};
</script>

View File

@@ -0,0 +1,365 @@
<template>
<div>
<!-- 第三方登录管理模态窗口 -->
<AModal
:open="userStore.thirdPartyModalState.visible"
title="第三方账号管理"
:width="500"
:maskClosable="false"
@cancel="handleCancel"
>
<div class="third-party-container">
<div class="third-party-item">
<div class="third-party-icon">
<img src="@/assets/svgs/icon-wechat.svg" alt="微信" />
</div>
<div class="third-party-info">
<div class="third-party-name">微信</div>
<div class="third-party-status">
<ATag :color="thirdPartyStatus.wechat ? 'success' : 'default'">
{{ thirdPartyStatus.wechat ? '已绑定' : '未绑定' }}
</ATag>
</div>
</div>
<div class="third-party-action">
<AButton
type="primary"
size="small"
:disabled="thirdPartyStatus.wechat"
@click="handleBindThirdParty('wechat')"
>
{{ thirdPartyStatus.wechat ? '已绑定' : '绑定' }}
</AButton>
<AButton
v-if="thirdPartyStatus.wechat"
type="link"
danger
size="small"
@click="handleUnbindThirdParty('wechat')"
>
解绑
</AButton>
</div>
</div>
<div class="third-party-item">
<div class="third-party-icon">
<img src="@/assets/svgs/icon-qq.svg" alt="QQ" />
</div>
<div class="third-party-info">
<div class="third-party-name">QQ</div>
<div class="third-party-status">
<ATag :color="thirdPartyStatus.qq ? 'success' : 'default'">
{{ thirdPartyStatus.qq ? '已绑定' : '未绑定' }}
</ATag>
</div>
</div>
<div class="third-party-action">
<AButton
type="primary"
size="small"
:disabled="thirdPartyStatus.qq"
@click="handleBindThirdParty('qq')"
>
{{ thirdPartyStatus.qq ? '已绑定' : '绑定' }}
</AButton>
<AButton
v-if="thirdPartyStatus.qq"
type="link"
danger
size="small"
@click="handleUnbindThirdParty('qq')"
>
解绑
</AButton>
</div>
</div>
<div class="third-party-item">
<div class="third-party-icon">
<img src="@/assets/svgs/github.svg" alt="GitHub" />
</div>
<div class="third-party-info">
<div class="third-party-name">GitHub</div>
<div class="third-party-status">
<ATag :color="thirdPartyStatus.github ? 'success' : 'default'">
{{ thirdPartyStatus.github ? '已绑定' : '未绑定' }}
</ATag>
</div>
</div>
<div class="third-party-action">
<AButton
type="primary"
size="small"
:disabled="thirdPartyStatus.github"
@click="handleBindThirdParty('github')"
>
{{ thirdPartyStatus.github ? '已绑定' : '绑定' }}
</AButton>
<AButton
v-if="thirdPartyStatus.github"
type="link"
danger
size="small"
@click="handleUnbindThirdParty('github')"
>
解绑
</AButton>
</div>
</div>
<div class="third-party-item">
<div class="third-party-icon">
<img src="@/assets/svgs/gitee.svg" alt="Gitee" />
</div>
<div class="third-party-info">
<div class="third-party-name">Gitee</div>
<div class="third-party-status">
<ATag :color="thirdPartyStatus.gitee ? 'success' : 'default'">
{{ thirdPartyStatus.gitee ? '已绑定' : '未绑定' }}
</ATag>
</div>
</div>
<div class="third-party-action">
<AButton
type="primary"
size="small"
:disabled="thirdPartyStatus.gitee"
@click="handleBindThirdParty('gitee')"
>
{{ thirdPartyStatus.gitee ? '已绑定' : '绑定' }}
</AButton>
<AButton
v-if="thirdPartyStatus.gitee"
type="link"
danger
size="small"
@click="handleUnbindThirdParty('gitee')"
>
解绑
</AButton>
</div>
</div>
</div>
<template #footer>
<AButton key="back" @click="handleCancel">关闭</AButton>
</template>
</AModal>
<!-- 解绑确认模态窗口 -->
<AModal
v-model:open="confirmUnbindVisible"
title="确认解绑"
:width="400"
:maskClosable="false"
@cancel="cancelUnbind"
>
<p>确定要解绑 <span style="font-weight: bold;">{{ currentUnbindPlatform }}</span> 账号吗解绑后将无法使用该账号登录</p>
<template #footer>
<AButton key="back" @click="cancelUnbind">取消</AButton>
<AButton key="submit" danger :loading="submitLoading" @click="confirmUnbind">确定解绑</AButton>
</template>
</AModal>
</div>
</template>
<script setup lang="ts">
import { message } from 'ant-design-vue';
import useStore from "@/store";
const emit = defineEmits(['success']);
// 获取用户store
const userStore = useStore().user;
// 第三方账号绑定状态
const thirdPartyStatus = reactive({
wechat: false,
qq: false,
github: false,
gitee: false
});
// 解绑确认相关状态
const confirmUnbindVisible = ref(false);
const currentUnbindPlatform = ref('');
const currentUnbindType = ref('');
const submitLoading = ref(false);
// 监听store中的状态变化
watch(() => userStore.thirdPartyModalState.visible, (val) => {
if (val) {
// 当模态窗口打开时,获取最新的绑定状态
updateThirdPartyStatus();
}
});
// 更新第三方账号绑定状态
const updateThirdPartyStatus = () => {
// 这里应该从API获取最新状态暂时使用模拟数据
// 实际项目中应该调用相应的API获取绑定状态
thirdPartyStatus.wechat = Math.random() > 0.5;
thirdPartyStatus.qq = Math.random() > 0.5;
thirdPartyStatus.github = Math.random() > 0.5;
thirdPartyStatus.gitee = Math.random() > 0.5;
};
// 取消操作
const handleCancel = () => {
userStore.closeThirdPartyModal();
};
// 绑定第三方账号
const handleBindThirdParty = (type: string) => {
// 根据不同的平台类型调用不同的绑定方法
switch (type) {
case 'wechat':
// 调用微信绑定API
message.info('正在跳转到微信授权页面...');
break;
case 'qq':
// 调用QQ绑定API
userStore.getQQRedirectUrl();
userStore.openQQUrl();
break;
case 'github':
// 调用GitHub绑定API
userStore.getGithubRedirectUrl();
userStore.openGithubUrl();
break;
case 'gitee':
// 调用Gitee绑定API
userStore.getGiteeRedirectUrl();
userStore.openGiteeUrl();
break;
default:
break;
}
};
// 解绑第三方账号(显示确认窗口)
const handleUnbindThirdParty = (type: string) => {
currentUnbindType.value = type;
// 设置平台名称
switch (type) {
case 'wechat':
currentUnbindPlatform.value = '微信';
break;
case 'qq':
currentUnbindPlatform.value = 'QQ';
break;
case 'github':
currentUnbindPlatform.value = 'GitHub';
break;
case 'gitee':
currentUnbindPlatform.value = 'Gitee';
break;
default:
currentUnbindPlatform.value = '';
break;
}
// 显示确认窗口
confirmUnbindVisible.value = true;
};
// 取消解绑
const cancelUnbind = () => {
confirmUnbindVisible.value = false;
currentUnbindType.value = '';
currentUnbindPlatform.value = '';
};
// 确认解绑
const confirmUnbind = async () => {
if (!currentUnbindType.value) return;
try {
submitLoading.value = true;
// 这里需要实现解绑第三方账号的API调用暂时模拟成功
setTimeout(() => {
message.success(`${currentUnbindPlatform.value}账号解绑成功`);
// 更新状态
switch (currentUnbindType.value) {
case 'wechat':
thirdPartyStatus.wechat = false;
break;
case 'qq':
thirdPartyStatus.qq = false;
break;
case 'github':
thirdPartyStatus.github = false;
break;
case 'gitee':
thirdPartyStatus.gitee = false;
break;
default:
break;
}
emit('success', `unbind_${currentUnbindType.value}`);
cancelUnbind();
submitLoading.value = false;
}, 1000);
} catch (error) {
console.error('解绑第三方账号失败:', error);
message.error('解绑失败,请稍后重试');
submitLoading.value = false;
}
};
</script>
<style scoped>
.third-party-container {
display: flex;
flex-direction: column;
gap: 16px;
}
.third-party-item {
display: flex;
align-items: center;
padding: 12px;
border-radius: 8px;
background-color: #f9f9f9;
transition: all 0.3s ease;
}
.third-party-item:hover {
background-color: #f0f0f0;
transform: translateY(-2px);
}
.third-party-icon {
width: 40px;
height: 40px;
display: flex;
justify-content: center;
align-items: center;
}
.third-party-icon img {
width: 28px;
height: 28px;
}
.third-party-info {
flex: 1;
margin-left: 12px;
}
.third-party-name {
font-weight: 500;
font-size: 16px;
margin-bottom: 4px;
}
.third-party-action {
display: flex;
gap: 8px;
}
</style>

View File

@@ -1,82 +1,137 @@
<template>
<div class="account-setting-info">
<div class="account-setting-info-header">
<span style="font-size: 20px;font-weight: bold;color: #333333;">我的信息</span>
<span>我的信息</span>
</div>
<div class="account-setting-info-body">
<AForm :model="formState" layout="vertical">
<AFormItem label="用户名" name="username">
<AInput v-model:value="formState.username" placeholder="请输入用户名" :clearable="true">
<template #prefix>
<UserOutlined/>
</template>
</AInput>
</AFormItem>
<AFormItem label="昵称" name="nickname">
<AInput v-model:value="formState.nickname" placeholder="请输入昵称">
<template #prefix>
<SmileOutlined/>
</template>
</AInput>
</AFormItem>
<AFormItem label="邮箱" name="email">
<AInput v-model:value="formState.email" placeholder="请输入邮箱">
<template #prefix>
<MailOutlined/>
</template>
</AInput>
</AFormItem>
<AFormItem label="电话" name="phone">
<AInput v-model:value="formState.phone" placeholder="请输入电话">
<template #prefix>
<PhoneOutlined/>
</template>
</AInput>
</AFormItem>
<AFormItem label="性别" name="gender">
<ASelect v-model:value="formState.gender" placeholder="请选择性别">
<template #prefix>
<ManOutlined/>
</template>
<ASelectOption :value="0">未知</ASelectOption>
<ASelectOption :value="1"></ASelectOption>
<ASelectOption :value="2"></ASelectOption>
</ASelect>
</AFormItem>
<AFormItem label="介绍" name="introduce">
<ATextarea v-model:value="formState.introduce" placeholder="请输入个人介绍" :rows="4">
<template #prefix>
<EditOutlined/>
</template>
</ATextarea>
</AFormItem>
<AFormItem label="博客" name="blog">
<AInput v-model:value="formState.blog" placeholder="请输入博客地址">
<template #prefix>
<GlobalOutlined/>
</template>
</AInput>
</AFormItem>
<AFormItem label="地址" name="location">
<AInput v-model:value="formState.location" placeholder="请输入地址">
<template #prefix>
<EnvironmentOutlined/>
</template>
</AInput>
</AFormItem>
<AFormItem label="公司" name="company">
<AInput v-model:value="formState.company" placeholder="请输入公司">
<template #prefix>
<BankOutlined/>
</template>
</AInput>
</AFormItem>
<div class="layout-container">
<AForm :model="formState" layout="vertical" class="form-container">
<div class="form-section">
<div class="section-title">基本信息</div>
<div class="form-grid">
<AFormItem label="用户名" name="username" class="form-item">
<AInput v-model:value="formState.username" placeholder="请输入用户名" :clearable="true" class="custom-input">
<template #prefix>
<UserOutlined class="input-icon"/>
</template>
</AInput>
</AFormItem>
<AFormItem label="昵称" name="nickname" class="form-item">
<AInput v-model:value="formState.nickname" placeholder="请输入昵称" class="custom-input">
<template #prefix>
<SmileOutlined class="input-icon"/>
</template>
</AInput>
</AFormItem>
<AFormItem label="邮箱" name="email" class="form-item">
<AInput v-model:value="formState.email" placeholder="请输入邮箱" class="custom-input">
<template #prefix>
<MailOutlined class="input-icon"/>
</template>
</AInput>
</AFormItem>
<AFormItem label="电话" name="phone" class="form-item">
<AInput v-model:value="formState.phone" placeholder="请输入电话" class="custom-input">
<template #prefix>
<PhoneOutlined class="input-icon"/>
</template>
</AInput>
</AFormItem>
<AFormItem label="性别" name="gender" class="form-item">
<ASelect v-model:value="formState.gender" placeholder="请选择性别" class="custom-select">
<template #prefix>
<ManOutlined class="input-icon"/>
</template>
<ASelectOption :value="0">未知</ASelectOption>
<ASelectOption :value="1"></ASelectOption>
<ASelectOption :value="2"></ASelectOption>
</ASelect>
</AFormItem>
</div>
</div>
<div class="form-section">
<div class="section-title">详细信息</div>
<AFormItem label="介绍" name="introduce" class="form-item full-width">
<ATextarea v-model:value="formState.introduce" placeholder="请输入个人介绍" :rows="4" class="custom-textarea">
<template #prefix>
<EditOutlined class="input-icon"/>
</template>
</ATextarea>
</AFormItem>
<div class="form-grid">
<AFormItem label="博客" name="blog" class="form-item">
<AInput v-model:value="formState.blog" placeholder="请输入博客地址" class="custom-input">
<template #prefix>
<GlobalOutlined class="input-icon"/>
</template>
</AInput>
</AFormItem>
<AFormItem label="地址" name="location" class="form-item">
<AInput v-model:value="formState.location" placeholder="请输入地址" class="custom-input">
<template #prefix>
<EnvironmentOutlined class="input-icon"/>
</template>
</AInput>
</AFormItem>
<AFormItem label="公司" name="company" class="form-item">
<AInput v-model:value="formState.company" placeholder="请输入公司" class="custom-input">
<template #prefix>
<BankOutlined class="input-icon"/>
</template>
</AInput>
</AFormItem>
</div>
</div>
<AFormItem :wrapper-col="{ span: 24 }">
<div class="form-actions">
<AButton type="primary" @click="handleSubmit">保存</AButton>
<AButton class="cancel-btn" @click="handleCancel">取消</AButton>
<AButton type="primary" class="submit-btn" @click="handleSubmit">保存</AButton>
</div>
</AFormItem>
</AForm>
<div class="info-sidebar">
<div class="sidebar-section">
<div class="sidebar-title">个人资料完整度</div>
<div class="profile-completion">
<div class="completion-bar">
<div class="completion-progress" style="width: 65%"></div>
</div>
<div class="completion-text">65% 已完成</div>
</div>
<div class="completion-tips">
<div class="tip-item">
<CheckCircleOutlined class="tip-icon complete" />
<span>基本信息已填写</span>
</div>
<div class="tip-item">
<CloseCircleOutlined class="tip-icon incomplete" />
<span>请完善个人介绍</span>
</div>
<div class="tip-item">
<CloseCircleOutlined class="tip-icon incomplete" />
<span>请添加联系方式</span>
</div>
</div>
</div>
<div class="sidebar-section">
<div class="sidebar-title">填写说明</div>
<div class="sidebar-tips">
<div class="tip-item">
<InfoCircleOutlined class="tip-icon" />
<span>用户名一旦设置不可更改</span>
</div>
<div class="tip-item">
<InfoCircleOutlined class="tip-icon" />
<span>邮箱用于接收重要通知</span>
</div>
<div class="tip-item">
<InfoCircleOutlined class="tip-icon" />
<span>个人介绍将展示在您的个人主页</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<ADivider/>
@@ -93,8 +148,15 @@ import {
GlobalOutlined,
EnvironmentOutlined,
BankOutlined,
CheckCircleOutlined,
CloseCircleOutlined,
InfoCircleOutlined,
} from '@ant-design/icons-vue';
import { useAuthStore } from '@/store/modules/userStore';
import { message } from 'ant-design-vue';
const authStore = useAuthStore();
const formState = reactive({
username: '',
nickname: '',
@@ -107,55 +169,353 @@ const formState = reactive({
company: '',
});
// 初始化表单数据
onMounted(() => {
// 从用户存储中获取数据
if (authStore.user) {
formState.username = authStore.user.username || '';
formState.nickname = authStore.user.nickname || '';
// 假设其他字段也可以从用户存储中获取
// 如果不能,这里只是示例
}
});
const handleSubmit = () => {
// 提交表单逻辑
console.log('表单数据:', formState);
message.success('个人信息保存成功!');
};
const handleCancel = () => {
// 取消操作,可以重置表单或返回上一页
console.log('取消操作');
};
</script>
<style scoped lang="scss">
.account-setting-info {
width: 100%;
height: 100%;
overflow: auto;
width: calc(100% - 72px);
background-color: var(--white-color);
border-radius: 16px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.08);
transition: all 0.3s ease;
&:hover {
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.12);
}
.account-setting-info-header {
padding: 0 24px;
padding: 0 36px;
width: 100%;
height: 50px;
height: 70px;
display: flex;
align-items: center;
border-bottom: 1px solid #e5e5e5;
border-bottom: 1px solid #eaeaea;
background: linear-gradient(135deg, #f8f9fa 0%, #e9f2ff 100%);
border-radius: 16px 16px 0 0;
//position: sticky;
//top: 0;
//z-index: 10;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
span {
font-size: 22px;
font-weight: 600;
color: #222222;
position: relative;
padding-left: 16px;
letter-spacing: 0.5px;
&::before {
content: '';
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
width: 5px;
height: 24px;
background: linear-gradient(to bottom, #1890ff, #36cfc9);
border-radius: 3px;
}
}
}
.account-setting-info-body {
padding: 24px;
padding: 36px;
width: 100%;
height: calc(100% - 50px);
background-color: var(--white-color);
border-radius: 0 0 16px 16px;
:deep(.ant-form) {
max-width: 800px;
.layout-container {
display: flex;
gap: 30px;
}
.ant-form-item {
margin-bottom: 16px;
.form-container {
flex: 1;
max-width: 650px;
}
.form-section {
background-color: #fafafa;
border-radius: 12px;
padding: 24px 28px;
margin-bottom: 32px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
transition: all 0.3s ease;
&:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
transform: translateY(-3px);
}
.section-title {
font-size: 18px;
font-weight: 600;
color: #333;
margin-bottom: 20px;
padding-bottom: 12px;
border-bottom: 1px dashed #e8e8e8;
position: relative;
&::after {
content: '';
position: absolute;
left: 0;
bottom: -1px;
width: 40px;
height: 3px;
background: linear-gradient(to right, #1890ff, #36cfc9);
border-radius: 3px;
}
}
.form-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 20px;
}
.form-item {
margin-bottom: 20px;
transition: all 0.3s ease;
&.full-width {
grid-column: 1 / -1;
}
&:hover {
transform: translateY(-2px);
}
.ant-form-item-label > label {
font-weight: 500;
color: #444;
font-size: 15px;
}
.custom-input,
.custom-select,
.custom-textarea {
border-radius: 10px;
border: 1px solid #e0e0e0;
transition: all 0.3s;
background-color: #fff;
&:hover {
border-color: #40a9ff;
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.08);
}
&:focus {
border-color: #1890ff;
box-shadow: 0 0 0 3px rgba(24, 144, 255, 0.12);
}
}
.ant-input-affix-wrapper {
padding: 0 14px;
height: 44px;
.ant-input {
padding: 8px 0;
border: none;
box-shadow: none;
&:hover, &:focus {
border: none;
box-shadow: none;
}
}
}
.ant-input,
.ant-input-password,
.ant-select-selector {
border-radius: 8px;
height: 44px;
display: flex;
align-items: center;
padding: 0 14px;
}
.input-icon {
margin-right: 10px;
color: #1890ff;
font-size: 16px;
opacity: 0.8;
transition: all 0.3s;
}
&:hover .input-icon {
opacity: 1;
transform: scale(1.1);
}
}
}
.form-actions {
display: flex;
justify-content: center;
margin-top: 24px;
justify-content: flex-start;
gap: 16px;
margin-top: 40px;
.ant-btn {
width: 120px;
height: 40px;
border-radius: 8px;
.submit-btn, .cancel-btn {
min-width: 140px;
height: 46px;
border-radius: 10px;
font-weight: 500;
font-size: 16px;
transition: all 0.3s ease;
display: flex;
align-items: center;
justify-content: center;
&:hover {
transform: translateY(-3px);
}
}
.submit-btn {
background: linear-gradient(to right, #1890ff, #36cfc9);
border: none;
box-shadow: 0 4px 12px rgba(24, 144, 255, 0.2);
&:hover {
box-shadow: 0 6px 16px rgba(24, 144, 255, 0.3);
}
}
.cancel-btn {
background: #f5f5f5;
color: #666;
border: 1px solid #e0e0e0;
&:hover {
background: #f0f0f0;
color: #333;
border-color: #d0d0d0;
}
}
}
}
}
:deep(.ant-divider) {
margin: 32px 0;
opacity: 0.6;
}
.info-sidebar {
width: 300px;
flex-shrink: 0;
.sidebar-section {
background-color: #fafafa;
border-radius: 12px;
padding: 20px;
margin-bottom: 24px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
transition: all 0.3s ease;
&:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
}
.sidebar-title {
font-size: 16px;
font-weight: 600;
color: #333;
margin-bottom: 16px;
position: relative;
padding-left: 12px;
&::before {
content: '';
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
width: 4px;
height: 16px;
background: linear-gradient(to bottom, #1890ff, #36cfc9);
border-radius: 2px;
}
}
.profile-completion {
margin-bottom: 16px;
.completion-bar {
height: 8px;
background-color: #f0f0f0;
border-radius: 4px;
overflow: hidden;
margin-bottom: 8px;
.completion-progress {
height: 100%;
background: linear-gradient(to right, #1890ff, #36cfc9);
border-radius: 4px;
}
}
.completion-text {
font-size: 14px;
color: #1890ff;
font-weight: 500;
text-align: right;
}
}
.completion-tips, .sidebar-tips {
.tip-item {
display: flex;
align-items: center;
margin-bottom: 12px;
font-size: 14px;
.tip-icon {
margin-right: 8px;
font-size: 16px;
&.complete {
color: #52c41a;
}
&.incomplete {
color: #ff4d4f;
}
}
span {
color: #666;
}
}
}
.sidebar-tips {
.tip-item {
.tip-icon {
color: #1890ff;
}
}
}
}

View File

@@ -29,6 +29,12 @@
</template>
<span class="ant-menu-item-title">存储管理</span>
</AMenuItem>
<AMenuItem title="图像备份" key="backup" :style="menuCSSStyle">
<template #icon>
<AAvatar shape="square" size="small" :src="backup"/>
</template>
<span class="ant-menu-item-title">图像备份</span>
</AMenuItem>
</AMenu>
</div>
@@ -39,6 +45,7 @@ import useStore from "@/store";
import home from "@/assets/svgs/home.svg";
import peopleAlbum from "@/assets/svgs/people-album.svg";
import storage from "@/assets/svgs/storage.svg";
import backup from "@/assets/svgs/source-storage.svg";
const menuStore = useStore().menu;
const menuCSSStyle: any = reactive({

View File

@@ -1,20 +1,26 @@
<template>
<div class="account-setting-storage">
<div class="account-setting-storage-header">
<AButton type="primary" size="middle" shape="default" @click="drawerVisible = true">
新增存储
</AButton>
<AButton type="default" size="middle" shape="circle" @click="getStorageList">
<span>存储管理</span>
<AButton type="text" size="large" shape="circle" @click="getStorageList">
<template #icon>
<RedoOutlined />
</template>
</AButton>
</div>
<div class="account-setting-storage-body" v-if="storageList && storageList.length>0">
<StorageCard v-for="(item, index) in storageList" :key="index" :storage="item"/>
<div class="storage-cards-container">
<StorageCard v-for="(item, index) in storageList" :key="index" :storage="item"/>
</div>
<div class="storage-action">
<AButton type="primary" size="large" @click="drawerVisible = true">
新增存储
</AButton>
</div>
</div>
<div class="account-setting-storage-empty" v-else>
<AEmpty description="暂无存储策略"/>
<AButton type="primary" @click="drawerVisible = true">添加存储</AButton>
</div>
<ADrawer v-model:open="drawerVisible" placement="right" width="40%" title="新增存储策略">
<AForm :model="formState" layout="vertical" @finish="onFinish" :rules="rules" ref="formRef"
@@ -164,46 +170,120 @@ onMounted(() => {
</script>
<style scoped lang="scss">
.account-setting-storage {
display: flex;
flex-direction: column;
width: 100%;
.account-setting-storage-header {
width: 100%;
height: 50px;
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
justify-content: flex-start;
gap: 20px;
margin-bottom: 20px;
padding: 16px 20px;
background-color: var(--white-color);
border-radius: 12px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
transition: all 0.3s ease;
span {
font-size: 20px;
font-weight: bold;
color: #333333;
position: relative;
padding-left: 12px;
&::before {
content: '';
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
width: 4px;
height: 18px;
background: #1890ff;
border-radius: 2px;
}
}
.ant-btn {
border-radius: 8px;
height: 40px;
transition: all 0.3s ease;
&:hover {
transform: translateY(-2px);
}
}
}
.account-setting-storage-body {
width: 100%;
height: calc(100vh - 150px);
display: flex;
flex-direction: row;
flex-wrap: wrap;
align-items: flex-start;
align-content: flex-start;
justify-content: flex-start;
gap: 20px;
background-color: var(--white-color);
border-radius: 12px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
padding: 24px;
.storage-cards-container {
display: flex;
flex-wrap: wrap;
gap: 24px;
margin-bottom: 30px;
}
.storage-action {
display: flex;
justify-content: center;
margin-top: 30px;
.ant-btn {
height: 48px;
padding: 0 40px;
font-size: 16px;
font-weight: 500;
border-radius: 24px;
background: linear-gradient(135deg, #1890ff, #096dd9);
border: none;
box-shadow: 0 4px 12px rgba(24, 144, 255, 0.3);
transition: all 0.3s ease;
&:hover {
transform: translateY(-3px);
box-shadow: 0 8px 20px rgba(24, 144, 255, 0.4);
}
}
}
}
.account-setting-storage-empty {
width: 100%;
height: calc(100vh - 150px);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 20px;
background-color: var(--white-color);
border-radius: 12px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
.ant-btn {
margin-top: 20px;
height: 40px;
border-radius: 20px;
padding: 0 24px;
font-weight: 500;
transition: all 0.3s ease;
&:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(24, 144, 255, 0.2);
}
}
}
}
.two-col-form {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 20px;
gap: 24px;
.form-item {
margin-bottom: 16px;
}
}
</style>

View File

@@ -1,18 +1,17 @@
<template>
<div class="account-setting-storage-card">
<div class="account-setting-storage-card" @click="showStorageDetail">
<div class="account-setting-storage-card-header">
<div style="width: 60px; height: 60px;">
<AAvatar :size="60" shape="circle" :src="ProviderIcon[storage.provider]"/>
<div style="width: 40px; height: 40px;">
<AAvatar :size="40" shape="circle" :src="ProviderIcon[storage.provider]"/>
</div>
<AFlex :vertical="true" align="flex-start" justify="space-between"
style="height: 60px;width: 230px;">
<div style="height: 60px;width: 230px;overflow: auto">
<span
style="font-size: 18px; font-weight: bold;">{{ storage.bucket }}</span>
style="height: 40px;width: 170px;">
<div style="width: 170px;overflow: hidden;text-overflow: ellipsis;white-space: nowrap;">
<span style="font-size: 16px; font-weight: bold;">{{ storage.bucket }}</span>
</div>
<AFlex :vertical="false" align="center" justify="flex-start" style="height: 60px;width: 230px;overflow: auto">
<AFlex :vertical="false" align="center" justify="flex-start" style="width: 170px;overflow: hidden;">
<ATag :color="ProviderColorMap[storage.provider]">{{ ProviderNameMap[storage.provider] }}</ATag>
<ATag v-if="storage.endpoint">{{ storage.endpoint }}</ATag>
<ATag v-if="storage.endpoint" style="max-width: 110px;overflow: hidden;text-overflow: ellipsis;">{{ storage.endpoint }}</ATag>
</AFlex>
</AFlex>
<APopconfirm
@@ -25,7 +24,7 @@
<template #icon>
<question-circle-outlined style="color: red"/>
</template>
<AButton type="text" size="small" class="delete-icon"
<AButton @click.stop type="text" size="small" class="delete-icon"
>
<template #icon>
<AAvatar size="small" shape="circle" :src="deleted"/>
@@ -36,17 +35,92 @@
<div class="account-setting-storage-card-content">
<div class="account-setting-storage-card-content-item">
<AAvatar size="small" shape="square" :src="bucket"/>
<span style="color: #999999; font-size: 14px;">{{ storage.capacity }}GB</span>
<span style="color: #999999; font-size: 13px;">{{ storage.capacity }}GB</span>
</div>
<div class="account-setting-storage-card-content-item">
<AAvatar size="small" shape="circle" :src="location"/>
<span style="color: #999999; font-size: 14px;">{{ AliRegionMap[storage.region] }}</span>
<span style="color: #999999; font-size: 13px;white-space: nowrap;overflow: hidden;text-overflow: ellipsis;max-width: 85px;">{{ AliRegionMap[storage.region] }}</span>
</div>
<div class="account-setting-storage-card-content-item">
<AAvatar size="small" shape="circle" :src="time"/>
<span style="color: #999999; font-size: 14px;">{{ storage.created_at }}</span>
<span style="color: #999999; font-size: 13px;">{{ storage.created_at }}</span>
</div>
</div>
<!-- 存储详情模态窗口 -->
<AModal
v-model:open="storageDetailVisible"
title="存储详情"
:width="600"
:footer="null"
@cancel="storageDetailVisible = false"
>
<div class="storage-detail-container">
<!-- 存储基本信息 -->
<div class="storage-detail-header">
<AAvatar :size="60" shape="circle" :src="ProviderIcon[storage.provider]"/>
<div class="storage-detail-title">
<h2>{{ storage.bucket }}</h2>
<div class="storage-detail-tags">
<ATag :color="ProviderColorMap[storage.provider]">{{ ProviderNameMap[storage.provider] }}</ATag>
<ATag v-if="storage.endpoint">{{ storage.endpoint }}</ATag>
</div>
</div>
</div>
<!-- 存储详细信息 -->
<div class="storage-detail-info">
<div class="storage-detail-info-item">
<AAvatar size="small" shape="square" :src="bucket"/>
<span class="storage-detail-label">存储容量:</span>
<span class="storage-detail-value">{{ storage.capacity }}GB</span>
</div>
<div class="storage-detail-info-item">
<AAvatar size="small" shape="circle" :src="location"/>
<span class="storage-detail-label">存储区域:</span>
<span class="storage-detail-value">{{ AliRegionMap[storage.region] }}</span>
</div>
<div class="storage-detail-info-item">
<AAvatar size="small" shape="circle" :src="time"/>
<span class="storage-detail-label">创建时间:</span>
<span class="storage-detail-value">{{ storage.created_at }}</span>
</div>
<div class="storage-detail-info-item" v-if="storage.access_key">
<AAvatar size="small" shape="circle" :src="storage"/>
<span class="storage-detail-label">访问密钥:</span>
<span class="storage-detail-value">{{ storage.access_key.substring(0, 8) }}****</span>
</div>
<div class="storage-detail-info-item" v-if="storage.secret_key">
<AAvatar size="small" shape="circle" :src="storage"/>
<span class="storage-detail-label">访问密钥:</span>
<span class="storage-detail-value">******</span>
</div>
</div>
<!-- 操作按钮 -->
<div class="storage-detail-actions">
<AButton type="primary" @click="testStorageConnection">
<template #icon><check-outlined /></template>
测试连接
</AButton>
<AButton type="default" @click="viewStorageFiles">
<template #icon><folder-outlined /></template>
查看文件
</AButton>
<APopconfirm
title="确认删除?"
ok-text="确认"
cancel-text="取消"
@confirm="deleteStorage(storage.id, storage.provider, storage.bucket); storageDetailVisible = false"
>
<AButton danger>
<template #icon><delete-outlined /></template>
删除存储
</AButton>
</APopconfirm>
</div>
</div>
</AModal>
</div>
</template>
@@ -55,10 +129,11 @@ import bucket from "@/assets/svgs/bucket.svg";
import time from "@/assets/svgs/time.svg";
import location from "@/assets/svgs/location-album.svg";
import deleted from "@/assets/svgs/deleted-circle.svg";
import {message} from "ant-design-vue";
import {CheckOutlined, FolderOutlined, DeleteOutlined} from "@ant-design/icons-vue";
import {deleteStorageConfigApi} from "@/api/storage";
import {AliRegionMap, ProviderColorMap, ProviderIcon, ProviderNameMap} from "@/constant/provider_map.ts";
import {ref} from "vue";
defineProps({
storage: {
@@ -67,6 +142,16 @@ defineProps({
}
});
// 存储详情模态窗口可见性
const storageDetailVisible = ref(false);
/**
* 显示存储详情
*/
function showStorageDetail(_event: MouseEvent) {
storageDetailVisible.value = true;
}
/**
* 删除存储配置
*/
@@ -78,25 +163,41 @@ async function deleteStorage(id: number, provider: string, bucket: string) {
message.error("删除失败");
}
}
/**
* 测试存储连接
*/
function testStorageConnection() {
message.info("测试连接功能正在开发中");
}
/**
* 查看存储文件
*/
function viewStorageFiles() {
message.info("查看文件功能正在开发中");
}
</script>
<style scoped lang="scss">
.account-setting-storage-card {
width: 300px;
height: 100px;
width: 260px;
height: 150px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
border-radius: 10px;
background-color: white;
padding: 10px;
border-radius: 8px;
background: linear-gradient(145deg, #ffffff, #f5f7fa);
padding: 12px;
cursor: pointer;
position: relative;
transition: all 0.2s ease-in-out;
transition: all 0.3s ease;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.03);
&:hover {
box-shadow: 0 0 20px rgba(0, 0, 0, 0.1);
transform: translateY(-5px);
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.08);
}
&:hover .delete-icon {
@@ -106,12 +207,15 @@ async function deleteStorage(id: number, provider: string, bucket: string) {
.account-setting-storage-card-header {
width: 100%;
height: 70px;
height: 55px;
display: flex;
flex-direction: row;
align-items: flex-start;
justify-content: flex-start;
gap: 10px;
gap: 6px;
margin-bottom: 8px;
border-bottom: 1px solid #f0f0f0;
padding-bottom: 6px;
}
.delete-icon {
@@ -129,19 +233,83 @@ async function deleteStorage(id: number, provider: string, bucket: string) {
.account-setting-storage-card-content {
width: 100%;
height: 30px;
display: flex;
flex-direction: row;
align-items: flex-end;
justify-content: space-between;
height: 55px;
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-rows: 1fr 1fr;
gap: 6px;
padding-top: 4px;
.account-setting-storage-card-content-item {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
gap: 10px;
justify-content: flex-start;
gap: 6px;
height: 25px;
}
}
}
// 存储详情模态窗口样式
.storage-detail-container {
padding: 16px;
.storage-detail-header {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 24px;
.storage-detail-title {
h2 {
margin: 0 0 8px 0;
font-size: 20px;
font-weight: 600;
}
.storage-detail-tags {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
}
}
.storage-detail-info {
background-color: #f9f9f9;
border-radius: 8px;
padding: 16px;
margin-bottom: 24px;
.storage-detail-info-item {
display: flex;
align-items: center;
margin-bottom: 12px;
gap: 8px;
&:last-child {
margin-bottom: 0;
}
.storage-detail-label {
color: #666;
font-size: 14px;
width: 80px;
}
.storage-detail-value {
color: #333;
font-size: 14px;
font-weight: 500;
}
}
}
.storage-detail-actions {
display: flex;
justify-content: flex-end;
gap: 12px;
}
}
</style>

View File

@@ -1,32 +1,123 @@
<template>
<div class="user-center-setting">
<h2>用户设置</h2>
<div class="user-center-setting-content">
<div class="user-center-setting-item">
<span class="user-center-setting-item-title">是否开启AI识别</span>
<ASwitch/>
<div class="setting-header">
<h2>用户设置</h2>
<p class="setting-description">您可以在这里管理您的账户设置和偏好</p>
</div>
<div class="setting-section">
<div class="section-title">账户安全</div>
<div class="user-center-setting-content">
<div class="user-center-setting-item">
<div class="setting-item-left">
<img src="@/assets/svgs/account_security.svg" alt="AI识别" class="setting-icon" />
<div class="setting-text">
<span class="user-center-setting-item-title">开启AI识别</span>
<span class="setting-description">允许系统使用AI技术识别您的照片内容</span>
</div>
</div>
<ASwitch v-model:checked="userStore.settings.enableAI">
<template #checkedChildren>
<check-outlined />
</template>
<template #unCheckedChildren>
<close-outlined />
</template>
</ASwitch>
</div>
<div class="user-center-setting-item">
<div class="setting-item-left">
<img src="@/assets/svgs/login_security.svg" alt="手机上传" class="setting-icon" />
<div class="setting-text">
<span class="user-center-setting-item-title">开启手机上传</span>
<span class="setting-description">允许从移动设备上传照片到您的相册</span>
</div>
</div>
<ASwitch v-model:checked="userStore.settings.enableMobileUpload">
<template #checkedChildren>
<check-outlined />
</template>
<template #unCheckedChildren>
<close-outlined />
</template>
</ASwitch>
</div>
</div>
<div class="user-center-setting-item">
<span class="user-center-setting-item-title">是否开启手机上传</span>
<ASwitch/>
</div>
<div class="user-center-setting-item">
<span class="user-center-setting-item-title">是否公开个人资料</span>
<ASwitch/>
</div>
<div class="user-center-setting-item">
<span class="user-center-setting-item-title">是否公开个人资料</span>
<ASwitch/>
</div>
<div class="user-center-setting-item">
<span class="user-center-setting-item-title">是否开启搜索记录</span>
<ASwitch/>
</div>
<div class="setting-section">
<div class="section-title">隐私设置</div>
<div class="user-center-setting-content">
<div class="user-center-setting-item">
<div class="setting-item-left">
<img src="@/assets/svgs/privacy.svg" alt="个人资料" class="setting-icon" />
<div class="setting-text">
<span class="user-center-setting-item-title">公开个人资料</span>
<span class="setting-description">允许其他用户查看您的个人资料信息</span>
</div>
</div>
<ASwitch v-model:checked="userStore.settings.publicProfile">
<template #checkedChildren>
<check-outlined />
</template>
<template #unCheckedChildren>
<close-outlined />
</template>
</ASwitch>
</div>
<div class="user-center-setting-item">
<div class="setting-item-left">
<img src="@/assets/svgs/community.svg" alt="评论" class="setting-icon" />
<div class="setting-text">
<span class="user-center-setting-item-title">开启评论功能</span>
<span class="setting-description">允许其他用户在您的照片下方评论</span>
</div>
</div>
<ASwitch v-model:checked="userStore.settings.enableComment">
<template #checkedChildren>
<check-outlined />
</template>
<template #unCheckedChildren>
<close-outlined />
</template>
</ASwitch>
</div>
<div class="user-center-setting-item">
<div class="setting-item-left">
<img src="@/assets/svgs/search.svg" alt="搜索记录" class="setting-icon" />
<div class="setting-text">
<span class="user-center-setting-item-title">保存搜索记录</span>
<span class="setting-description">保存您的搜索历史以提供更好的推荐</span>
</div>
</div>
<ASwitch v-model:checked="userStore.settings.saveSearchHistory">
<template #checkedChildren>
<check-outlined />
</template>
<template #unCheckedChildren>
<close-outlined />
</template>
</ASwitch>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { CheckOutlined, CloseOutlined } from '@ant-design/icons-vue';
import useStore from "@/store";
const userStore = useStore().user;
// const saveSettings = () => {
// // 保存设置到后端
// };
</script>
<style scoped lang="scss">
.user-center-setting {
@@ -36,30 +127,122 @@
flex-direction: column;
align-items: flex-start;
justify-content: flex-start;
padding: 20px;
.setting-header {
margin-bottom: 30px;
h2 {
font-size: 24px;
font-weight: 600;
margin-bottom: 8px;
color: var(--text-color, #333);
}
.setting-description {
color: #666;
font-size: 14px;
}
}
.setting-section {
width: calc(100% - 80px);
margin-bottom: 30px;
background-color: var(--white-color, #fff);
border-radius: 12px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
padding: 20px;
transition: all 0.3s ease;
&:hover {
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.08);
}
.section-title {
font-size: 18px;
font-weight: 600;
margin-bottom: 20px;
color: var(--text-color, #333);
padding-bottom: 10px;
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
}
}
.user-center-setting-content {
width: 100%;
height: 100%;
display: flex;
flex-direction: row;
flex-wrap: wrap;
align-content: flex-start;
gap: 50px;
flex-direction: column;
gap: 16px;
.user-center-setting-item {
width: 350px;
height: 50px;
width: 100%;
min-height: 60px;
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
padding: 10px 0;
border-bottom: 1px solid rgba(0, 0, 0, 0.03);
.user-center-setting-item-title {
display: inline-block;
font-size: 16px;
font-weight: bold;
line-height: 22px;
color: #333333;
&:last-child {
border-bottom: none;
}
.setting-item-left {
display: flex;
align-items: center;
gap: 12px;
.setting-icon {
width: 28px;
height: 28px;
}
.setting-text {
display: flex;
flex-direction: column;
.user-center-setting-item-title {
display: inline-block;
font-size: 16px;
font-weight: 500;
line-height: 22px;
color: var(--text-color, #333);
margin-bottom: 4px;
}
.setting-description {
font-size: 12px;
color: #999;
line-height: 1.5;
}
}
}
:deep(.ant-switch) {
background-color: rgba(0, 0, 0, 0.25);
&.ant-switch-checked {
background-color: var(--blue, rgba(96,165,250,.9));
}
}
}
}
@media (max-width: 768px) {
padding: 10px;
.setting-section {
padding: 15px;
}
.user-center-setting-item {
flex-direction: column;
align-items: flex-start;
gap: 10px;
.setting-item-left {
width: 100%;
}
}
}