✨ add image blur detection and background management pages
This commit is contained in:
@@ -307,6 +307,11 @@
|
||||
"watchThrottled": true,
|
||||
"watchTriggerable": true,
|
||||
"watchWithFilter": true,
|
||||
"whenever": true
|
||||
"whenever": true,
|
||||
"createRef": true,
|
||||
"onElementRemoval": true,
|
||||
"useCountdown": true,
|
||||
"usePreferredReducedTransparency": true,
|
||||
"useSSRWidth": true
|
||||
}
|
||||
}
|
||||
|
2
auto-import.d.ts
vendored
2
auto-import.d.ts
vendored
@@ -26,6 +26,7 @@ declare global {
|
||||
const createInjectionState: typeof import('@vueuse/core')['createInjectionState']
|
||||
const createPinia: typeof import('pinia')['createPinia']
|
||||
const createReactiveFn: typeof import('@vueuse/core')['createReactiveFn']
|
||||
const createRef: typeof import('@vueuse/core')['createRef']
|
||||
const createReusableTemplate: typeof import('@vueuse/core')['createReusableTemplate']
|
||||
const createSharedComposable: typeof import('@vueuse/core')['createSharedComposable']
|
||||
const createTemplatePromise: typeof import('@vueuse/core')['createTemplatePromise']
|
||||
@@ -337,6 +338,7 @@ declare module 'vue' {
|
||||
readonly createInjectionState: UnwrapRef<typeof import('@vueuse/core')['createInjectionState']>
|
||||
readonly createPinia: UnwrapRef<typeof import('pinia')['createPinia']>
|
||||
readonly createReactiveFn: UnwrapRef<typeof import('@vueuse/core')['createReactiveFn']>
|
||||
readonly createRef: UnwrapRef<typeof import('@vueuse/core')['createRef']>
|
||||
readonly createReusableTemplate: UnwrapRef<typeof import('@vueuse/core')['createReusableTemplate']>
|
||||
readonly createSharedComposable: UnwrapRef<typeof import('@vueuse/core')['createSharedComposable']>
|
||||
readonly createTemplatePromise: UnwrapRef<typeof import('@vueuse/core')['createTemplatePromise']>
|
||||
|
42
components.d.ts
vendored
42
components.d.ts
vendored
@@ -8,6 +8,7 @@ export {}
|
||||
/* prettier-ignore */
|
||||
declare module 'vue' {
|
||||
export interface GlobalComponents {
|
||||
AAlert: typeof import('ant-design-vue/es')['Alert']
|
||||
AAvatar: typeof import('ant-design-vue/es')['Avatar']
|
||||
AAvatarGroup: typeof import('ant-design-vue/es')['AvatarGroup']
|
||||
ABadge: typeof import('ant-design-vue/es')['Badge']
|
||||
@@ -21,8 +22,11 @@ declare module 'vue' {
|
||||
AccountSettingSidebar: typeof import('./src/views/User/AccountSetting/components/AccountSettingSidebar/AccountSettingSidebar.vue')['default']
|
||||
AccountSettingStorage: typeof import('./src/views/User/AccountSetting/components/AccountSettingStorage/AccountSettingStorage.vue')['default']
|
||||
ACheckbox: typeof import('ant-design-vue/es')['Checkbox']
|
||||
ACheckboxGroup: typeof import('ant-design-vue/es')['CheckboxGroup']
|
||||
ACol: typeof import('ant-design-vue/es')['Col']
|
||||
AConfigProvider: typeof import('ant-design-vue/es')['ConfigProvider']
|
||||
ADescriptions: typeof import('ant-design-vue/es')['Descriptions']
|
||||
ADescriptionsItem: typeof import('ant-design-vue/es')['DescriptionsItem']
|
||||
ADivider: typeof import('ant-design-vue/es')['Divider']
|
||||
ADrawer: typeof import('ant-design-vue/es')['Drawer']
|
||||
ADropdown: typeof import('ant-design-vue/es')['Dropdown']
|
||||
@@ -37,6 +41,7 @@ declare module 'vue' {
|
||||
AInput: typeof import('ant-design-vue/es')['Input']
|
||||
AInputNumber: typeof import('ant-design-vue/es')['InputNumber']
|
||||
AInputPassword: typeof import('ant-design-vue/es')['InputPassword']
|
||||
AInputSearch: typeof import('ant-design-vue/es')['InputSearch']
|
||||
AlbumCard: typeof import('./src/views/Album/Phoalbum/AlbumCard.vue')['default']
|
||||
AlbumShareModal: typeof import('./src/views/Album/Phoalbum/AlbumShareModal.vue')['default']
|
||||
AList: typeof import('ant-design-vue/es')['List']
|
||||
@@ -53,14 +58,19 @@ declare module 'vue' {
|
||||
AProgress: typeof import('ant-design-vue/es')['Progress']
|
||||
AQrcode: typeof import('ant-design-vue/es')['QRCode']
|
||||
ARadio: typeof import('ant-design-vue/es')['Radio']
|
||||
ARadioButton: typeof import('ant-design-vue/es')['RadioButton']
|
||||
ARadioGroup: typeof import('ant-design-vue/es')['RadioGroup']
|
||||
ARangePicker: typeof import('ant-design-vue/es')['RangePicker']
|
||||
AResult: typeof import('ant-design-vue/es')['Result']
|
||||
ARow: typeof import('ant-design-vue/es')['Row']
|
||||
ASelect: typeof import('ant-design-vue/es')['Select']
|
||||
ASelectOption: typeof import('ant-design-vue/es')['SelectOption']
|
||||
ASkeleton: typeof import('ant-design-vue/es')['Skeleton']
|
||||
ASlider: typeof import('ant-design-vue/es')['Slider']
|
||||
ASpace: typeof import('ant-design-vue/es')['Space']
|
||||
ASpin: typeof import('ant-design-vue/es')['Spin']
|
||||
AStatistic: typeof import('ant-design-vue/es')['Statistic']
|
||||
ASubMenu: typeof import('ant-design-vue/es')['SubMenu']
|
||||
ASwitch: typeof import('ant-design-vue/es')['Switch']
|
||||
ATable: typeof import('ant-design-vue/es')['Table']
|
||||
ATabPane: typeof import('ant-design-vue/es')['TabPane']
|
||||
@@ -68,9 +78,14 @@ declare module 'vue' {
|
||||
ATag: typeof import('ant-design-vue/es')['Tag']
|
||||
ATextarea: typeof import('ant-design-vue/es')['Textarea']
|
||||
ATooltip: typeof import('ant-design-vue/es')['Tooltip']
|
||||
ATree: typeof import('ant-design-vue/es')['Tree']
|
||||
ATreeSelect: typeof import('ant-design-vue/es')['TreeSelect']
|
||||
ATypography: typeof import('ant-design-vue/es')['Typography']
|
||||
ATypographyParagraph: typeof import('ant-design-vue/es')['TypographyParagraph']
|
||||
AUpload: typeof import('ant-design-vue/es')['Upload']
|
||||
AUploadDragger: typeof import('ant-design-vue/es')['UploadDragger']
|
||||
BackgroundAnimation: typeof import('./src/components/BackgroundAnimation/BackgroundAnimation.vue')['default']
|
||||
BasicSettings: typeof import('./src/views/Admin/System/Pages/BasicSettings.vue')['default']
|
||||
BgColorsOutlined: typeof import('@ant-design/icons-vue')['BgColorsOutlined']
|
||||
BlockOutlined: typeof import('@ant-design/icons-vue')['BlockOutlined']
|
||||
BoxDog: typeof import('./src/components/BoxDog/BoxDog.vue')['default']
|
||||
@@ -89,12 +104,12 @@ declare module 'vue' {
|
||||
CommentReply: typeof import('./src/components/CommentReply/index.vue')['default']
|
||||
CommonPhoneUpload: typeof import('./src/views/Phone/CommonPhoneUpload/CommonPhoneUpload.vue')['default']
|
||||
CompareImage: typeof import('./src/views/Upscale/CompareImage.vue')['default']
|
||||
Dashboard: typeof import('./src/views/Admin/System/Pages/Dashboard.vue')['default']
|
||||
DeleteOutlined: typeof import('@ant-design/icons-vue')['DeleteOutlined']
|
||||
DownloadOutlined: typeof import('@ant-design/icons-vue')['DownloadOutlined']
|
||||
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']
|
||||
EllipsisOutlined: typeof import('@ant-design/icons-vue')['EllipsisOutlined']
|
||||
EyeInvisibleOutlined: typeof import('@ant-design/icons-vue')['EyeInvisibleOutlined']
|
||||
EyeOutlined: typeof import('@ant-design/icons-vue')['EyeOutlined']
|
||||
FileImageOutlined: typeof import('@ant-design/icons-vue')['FileImageOutlined']
|
||||
@@ -102,12 +117,14 @@ declare module 'vue' {
|
||||
Folder: typeof import('./src/components/Folder/Folder.vue')['default']
|
||||
ForgetPage: typeof import('./src/views/Forget/ForgetPage.vue')['default']
|
||||
GradientText: typeof import('./src/components/MyUI/GradientText/GradientText.vue')['default']
|
||||
Heatmap: typeof import('./src/components/Heatmap/Heatmap.vue')['default']
|
||||
HeatmapPro: typeof import('./src/components/HeatmapPro/HeatmapPro.vue')['default']
|
||||
ImageEnhancer: typeof import('./src/components/ImageEnhancer/ImageEnhancer.vue')['default']
|
||||
ImageShare: typeof import('./src/views/Share/ImageShare/ImageShare.vue')['default']
|
||||
ImageToolbar: typeof import('./src/components/ImageToolbar/ImageToolbar.vue')['default']
|
||||
ImageUpload: typeof import('./src/components/ImageUpload/ImageUpload.vue')['default']
|
||||
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']
|
||||
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']
|
||||
@@ -117,17 +134,22 @@ declare module 'vue' {
|
||||
LocationAlbumIndex: typeof import('./src/views/Album/LocationAlbum/LocationAlbumIndex.vue')['default']
|
||||
LocationAlbumList: typeof import('./src/views/Album/LocationAlbum/LocationAlbumList.vue')['default']
|
||||
LockOutlined: typeof import('@ant-design/icons-vue')['LockOutlined']
|
||||
Login: typeof import('./src/views/Admin/Auth/Login.vue')['default']
|
||||
LoginFooter: typeof import('./src/views/Login/LoginFooter.vue')['default']
|
||||
LoginPage: typeof import('./src/views/Login/LoginPage.vue')['default']
|
||||
MainPage: typeof import('./src/views/Main/MainPage.vue')['default']
|
||||
MessageReport: typeof import('./src/components/CommentReply/src/MessageReport/MessageReport.vue')['default']
|
||||
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']
|
||||
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']
|
||||
PeopleAlbumToolbar: typeof import('./src/views/Album/PeopleAlbum/PeopleAlbumToolbar.vue')['default']
|
||||
PermissionSetting: typeof import('./src/views/Admin/System/Pages/PermissionSetting.vue')['default']
|
||||
PersonalCenter: typeof import('./src/views/User/PersonalCenter/PersonalCenter.vue')['default']
|
||||
PhoalbumDetail: typeof import('./src/views/Album/Phoalbum/PhoalbumDetail.vue')['default']
|
||||
PhoalbumIndex: typeof import('./src/views/Album/Phoalbum/PhoalbumIndex.vue')['default']
|
||||
@@ -136,6 +158,7 @@ declare module 'vue' {
|
||||
PlusOutlined: typeof import('@ant-design/icons-vue')['PlusOutlined']
|
||||
PlusSquareOutlined: typeof import('@ant-design/icons-vue')['PlusSquareOutlined']
|
||||
Popover: typeof import('./src/components/MyUI/Popover/Popover.vue')['default']
|
||||
PreviewBlurDetect: typeof import('./src/views/Preview/PreviewBlurDetect.vue')['default']
|
||||
QrcodeOutlined: typeof import('@ant-design/icons-vue')['QrcodeOutlined']
|
||||
QRLogin: typeof import('./src/views/QRLogin/QRLogin.vue')['default']
|
||||
QRLoginFooter: typeof import('./src/views/QRLogin/QRLoginFooter.vue')['default']
|
||||
@@ -147,13 +170,14 @@ declare module 'vue' {
|
||||
ReplyInput: typeof import('./src/components/CommentReply/src/ReplyInput/ReplyInput.vue')['default']
|
||||
ReplyList: typeof import('./src/components/CommentReply/src/ReplyList/ReplyList.vue')['default']
|
||||
ReplyReply: typeof import('./src/components/CommentReply/src/ReplyReplyInput/ReplyReply.vue')['default']
|
||||
RoleManagement: typeof import('./src/views/Admin/System/Pages/RoleManagement.vue')['default']
|
||||
RouterLink: typeof import('vue-router')['RouterLink']
|
||||
RouterView: typeof import('vue-router')['RouterView']
|
||||
SafetyOutlined: typeof import('@ant-design/icons-vue')['SafetyOutlined']
|
||||
SearchOutlined: typeof import('@ant-design/icons-vue')['SearchOutlined']
|
||||
SearchResult: typeof import('./src/views/Photograph/SearchResult/SearchResult.vue')['default']
|
||||
SecuritySettings: typeof import('./src/views/Admin/System/Pages/SecuritySettings.vue')['default']
|
||||
SendOutlined: typeof import('@ant-design/icons-vue')['SendOutlined']
|
||||
SettingOutlined: typeof import('@ant-design/icons-vue')['SettingOutlined']
|
||||
SharePhoneUpload: typeof import('./src/views/Phone/SharePhoneUpload/SharePhoneUpload.vue')['default']
|
||||
ShareSidebar: typeof import('./src/views/Share/ShareViewList/ShareSidebar.vue')['default']
|
||||
ShareUpload: typeof import('./src/views/Share/ImageShare/ShareUpload.vue')['default']
|
||||
@@ -161,7 +185,13 @@ declare module 'vue' {
|
||||
Spin: typeof import('./src/components/MyUI/Spin/Spin.vue')['default']
|
||||
StarButton: typeof import('./src/components/StarButton/StarButton.vue')['default']
|
||||
StorageCard: typeof import('./src/views/User/AccountSetting/components/AccountSettingStorage/StorageCard.vue')['default']
|
||||
StorageManagement: typeof import('./src/views/Admin/System/Pages/StorageManagement.vue')['default']
|
||||
System: typeof import('./src/views/Admin/System/index.vue')['default']
|
||||
SystemHeader: typeof import('./src/views/Admin/System/Components/SystemHeader.vue')['default']
|
||||
SystemLogs: typeof import('./src/views/Admin/System/Pages/SystemLogs.vue')['default']
|
||||
SystemSidebar: typeof import('./src/views/Admin/System/Components/SystemSidebar.vue')['default']
|
||||
TabletOutlined: typeof import('@ant-design/icons-vue')['TabletOutlined']
|
||||
TestView: typeof import('./src/views/Preview/TestView.vue')['default']
|
||||
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']
|
||||
@@ -170,14 +200,14 @@ declare module 'vue' {
|
||||
UploadSetting: typeof import('./src/components/ImageUpload/UploadSetting.vue')['default']
|
||||
Upscale: typeof import('./src/views/Upscale/index.vue')['default']
|
||||
UpscalePhoneUpload: typeof import('./src/views/Phone/UpscalePhoneUpload/UpscalePhoneUpload.vue')['default']
|
||||
UserCenterAnalysis: typeof import('./src/views/User/PersonalCenter/components/UserCenterAnalysis/UserCenterAnalysis.vue')['default']
|
||||
UserAnalysis: typeof import('./src/views/Admin/System/Pages/UserAnalysis.vue')['default']
|
||||
UserCenterDynamic: typeof import('./src/views/User/PersonalCenter/components/UserCenterDynamic/UserCenterDynamic.vue')['default']
|
||||
UserCenterHome: typeof import('./src/views/User/PersonalCenter/components/UserCenterHome/UserCenterHome.vue')['default']
|
||||
UserCenterInfo: typeof import('./src/views/User/PersonalCenter/components/UserCenterInfo/UserCenterInfo.vue')['default']
|
||||
UserCenterSetting: typeof import('./src/views/User/PersonalCenter/components/UserCenterSetting/UserCenterSetting.vue')['default']
|
||||
UserCenterShare: typeof import('./src/views/User/PersonalCenter/components/UserCenterShare/UserCenterShare.vue')['default']
|
||||
UserInfoCard: typeof import('./src/components/CommentReply/src/UserInfoCard/UserInfoCard.vue')['default']
|
||||
UserList: typeof import('./src/views/Admin/System/Pages/UserList.vue')['default']
|
||||
UserOutlined: typeof import('@ant-design/icons-vue')['UserOutlined']
|
||||
VisitStatistics: typeof import('./src/views/Admin/System/Pages/VisitStatistics.vue')['default']
|
||||
VueCompareImage: typeof import('./src/components/VueCompareImage/VueCompareImage.vue')['default']
|
||||
WarningOutlined: typeof import('@ant-design/icons-vue')['WarningOutlined']
|
||||
}
|
||||
|
41
package.json
41
package.json
@@ -12,8 +12,7 @@
|
||||
"dependencies": {
|
||||
"@alova/adapter-axios": "^2.0.13",
|
||||
"@ant-design/icons-vue": "^7.0.1",
|
||||
"@fcli/vue-calendar-map": "^1.0.2",
|
||||
"@intlify/eslint-plugin-vue-i18n": "^3.2.0",
|
||||
"@intlify/eslint-plugin-vue-i18n": "^4.0.0",
|
||||
"@mediapipe/face_detection": "^0.4.1646425229",
|
||||
"@mediapipe/face_mesh": "^0.4.1633559619",
|
||||
"@tensorflow-models/coco-ssd": "^2.2.3",
|
||||
@@ -32,27 +31,28 @@
|
||||
"@types/crypto-js": "^4.2.2",
|
||||
"@types/file-saver": "^2.0.7",
|
||||
"@types/json-stringify-safe": "^5.0.3",
|
||||
"@types/node": "^22.13.5",
|
||||
"@types/node": "^22.13.10",
|
||||
"@types/nprogress": "^0.2.3",
|
||||
"@vladmandic/face-api": "^1.7.15",
|
||||
"@vuepic/vue-datepicker": "^11.0.1",
|
||||
"@vueuse/core": "^12.7.0",
|
||||
"@vueuse/integrations": "^12.7.0",
|
||||
"alova": "^3.2.9",
|
||||
"@vueuse/core": "^13.0.0",
|
||||
"@vueuse/integrations": "^13.0.0",
|
||||
"alova": "^3.2.10",
|
||||
"animejs": "^3.2.2",
|
||||
"ant-design-vue": "^4.2.6",
|
||||
"axios": "^1.8.1",
|
||||
"axios": "^1.8.3",
|
||||
"browser-image-compression": "^2.0.2",
|
||||
"buffer": "^6.0.3",
|
||||
"crypto-js": "^4.2.0",
|
||||
"echarts": "^5.6.0",
|
||||
"eslint": "9.21.0",
|
||||
"eslint": "9.22.0",
|
||||
"exifr": "^7.1.3",
|
||||
"file-saver": "^2.0.5",
|
||||
"go-captcha-vue": "^2.0.6",
|
||||
"gsap": "^3.12.7",
|
||||
"jsencrypt": "^3.3.2",
|
||||
"json-stringify-safe": "^5.0.1",
|
||||
"jsonc-eslint-parser": "^2.4.0",
|
||||
"jszip": "^3.10.1",
|
||||
"less": "^4.2.2",
|
||||
"localforage": "^1.10.0",
|
||||
@@ -62,34 +62,37 @@
|
||||
"nsfwjs": "^4.2.1",
|
||||
"opencv-qr": "^0.7.0",
|
||||
"pinia": "^3.0.1",
|
||||
"pinia-plugin-persistedstate-2": "^2.0.28",
|
||||
"pinia-plugin-persistedstate-2": "^2.0.29",
|
||||
"qr-scanner-wechat": "^0.1.3",
|
||||
"rimraf": "^6.0.1",
|
||||
"seedrandom": "^3.0.5",
|
||||
"swiper": "^11.2.4",
|
||||
"swiper": "^11.2.5",
|
||||
"unplugin-auto-import": "^19.1.1",
|
||||
"upscaler": "^1.0.0-beta.19",
|
||||
"vite-plugin-compression": "^0.5.1",
|
||||
"vite-plugin-html": "^3.2.2",
|
||||
"vite-plugin-node-polyfills": "^0.23.0",
|
||||
"vue": "^3.5.13",
|
||||
"vue-dompurify-html": "^5.2.0",
|
||||
"vue-i18n": "^11.1.1",
|
||||
"vue-eslint-parser": "^10.1.1",
|
||||
"vue-i18n": "^11.1.2",
|
||||
"vue-router": "^4.5.0",
|
||||
"ws": "^8.18.1"
|
||||
"ws": "^8.18.1",
|
||||
"yaml-eslint-parser": "^1.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.21.0",
|
||||
"@eslint/js": "^9.22.0",
|
||||
"@vitejs/plugin-vue": "^5.2.1",
|
||||
"eslint-plugin-vue": "^9.32.0",
|
||||
"eslint-plugin-vue": "^10.0.0",
|
||||
"globals": "^16.0.0",
|
||||
"sass": "^1.85.1",
|
||||
"typescript": "^5.7.3",
|
||||
"typescript-eslint": "^8.25.0",
|
||||
"typescript": "^5.8.2",
|
||||
"typescript-eslint": "^8.26.1",
|
||||
"unplugin-vue-components": "^28.4.1",
|
||||
"vite": "^6.2.0",
|
||||
"vite-plugin-bundle-obfuscator": "1.4.1",
|
||||
"vite": "^6.2.1",
|
||||
"vite-plugin-bundle-obfuscator": "1.4.2",
|
||||
"vite-plugin-chunk-split": "^0.5.0",
|
||||
"vue-tsc": "2.2.4"
|
||||
"vue-tsc": "2.2.8"
|
||||
},
|
||||
"overrides": {
|
||||
"vite-plugin-chunk-split": {
|
||||
|
@@ -4,7 +4,7 @@ import {service} from "@/utils/alova/service.ts";
|
||||
* 上传分享图片
|
||||
* @param formData
|
||||
*/
|
||||
export const shareImageUploadApi = (formData) => {
|
||||
export const shareImageUploadApi = (formData: any) => {
|
||||
return service.Post('/api/auth/share/upload', {...formData}, {
|
||||
meta: {
|
||||
ignoreToken: false,
|
||||
@@ -48,11 +48,13 @@ export const queryShareRecordListApi = (dataRequest: string[]) => {
|
||||
ignoreToken: false,
|
||||
signature: false,
|
||||
},
|
||||
hitSource: ["upload-share-image", "delete-share-record"]
|
||||
});
|
||||
};
|
||||
/**
|
||||
* 查询分享信息
|
||||
* @param invite_code
|
||||
* @param access_password
|
||||
*/
|
||||
export const queryShareInfoApi = (invite_code: string, access_password: string) => {
|
||||
return service.Post('/api/auth/share/info', {
|
||||
@@ -82,6 +84,7 @@ export const queryShareOverviewApi = () => {
|
||||
ignoreToken: false,
|
||||
signature: false,
|
||||
},
|
||||
hitSource: ["upload-share-image", "delete-share-record"]
|
||||
});
|
||||
};
|
||||
/**
|
||||
@@ -100,5 +103,6 @@ export const deleteShareRecordApi = (id: number, invite_code: string, album_id:
|
||||
ignoreToken: false,
|
||||
signature: false,
|
||||
},
|
||||
name: 'delete-share-record',
|
||||
});
|
||||
};
|
||||
|
66
src/assets/svgs/background.svg
Normal file
66
src/assets/svgs/background.svg
Normal file
@@ -0,0 +1,66 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg width="1361px" height="609px" viewBox="0 0 1361 609" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<defs></defs>
|
||||
<g id="Ant-Design-Pro-3.0" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||
<g id="账户密码登录-校验" transform="translate(-79.000000, -82.000000)">
|
||||
<g id="Group-21" transform="translate(77.000000, 73.000000)">
|
||||
<g id="Group-18" opacity="0.8" transform="translate(74.901416, 569.699158) rotate(-7.000000) translate(-74.901416, -569.699158) translate(4.901416, 525.199158)">
|
||||
<ellipse id="Oval-11" fill="#CFDAE6" opacity="0.25" cx="63.5748792" cy="32.468367" rx="21.7830479" ry="21.766008"></ellipse>
|
||||
<ellipse id="Oval-3" fill="#CFDAE6" opacity="0.599999964" cx="5.98746479" cy="13.8668601" rx="5.2173913" ry="5.21330997"></ellipse>
|
||||
<path d="M38.1354514,88.3520215 C43.8984227,88.3520215 48.570234,83.6838647 48.570234,77.9254015 C48.570234,72.1669383 43.8984227,67.4987816 38.1354514,67.4987816 C32.3724801,67.4987816 27.7006688,72.1669383 27.7006688,77.9254015 C27.7006688,83.6838647 32.3724801,88.3520215 38.1354514,88.3520215 Z" id="Oval-3-Copy" fill="#CFDAE6" opacity="0.45"></path>
|
||||
<path d="M64.2775582,33.1704963 L119.185836,16.5654915" id="Path-12" stroke="#CFDAE6" stroke-width="1.73913043" stroke-linecap="round" stroke-linejoin="round"></path>
|
||||
<path d="M42.1431708,26.5002681 L7.71190162,14.5640702" id="Path-16" stroke="#E0B4B7" stroke-width="0.702678964" opacity="0.7" stroke-linecap="round" stroke-linejoin="round" stroke-dasharray="1.405357899873153,2.108036953469981"></path>
|
||||
<path d="M63.9262187,33.521561 L43.6721326,69.3250951" id="Path-15" stroke="#BACAD9" stroke-width="0.702678964" stroke-linecap="round" stroke-linejoin="round" stroke-dasharray="1.405357899873153,2.108036953469981"></path>
|
||||
<g id="Group-17" transform="translate(126.850922, 13.543654) rotate(30.000000) translate(-126.850922, -13.543654) translate(117.285705, 4.381889)" fill="#CFDAE6">
|
||||
<ellipse id="Oval-4" opacity="0.45" cx="9.13482653" cy="9.12768076" rx="9.13482653" ry="9.12768076"></ellipse>
|
||||
<path d="M18.2696531,18.2553615 C18.2696531,13.2142826 14.1798519,9.12768076 9.13482653,9.12768076 C4.08980114,9.12768076 0,13.2142826 0,18.2553615 L18.2696531,18.2553615 Z" id="Oval-4" transform="translate(9.134827, 13.691521) scale(-1, -1) translate(-9.134827, -13.691521) "></path>
|
||||
</g>
|
||||
</g>
|
||||
<g id="Group-14" transform="translate(216.294700, 123.725600) rotate(-5.000000) translate(-216.294700, -123.725600) translate(106.294700, 35.225600)">
|
||||
<ellipse id="Oval-2" fill="#CFDAE6" opacity="0.25" cx="29.1176471" cy="29.1402439" rx="29.1176471" ry="29.1402439"></ellipse>
|
||||
<ellipse id="Oval-2" fill="#CFDAE6" opacity="0.3" cx="29.1176471" cy="29.1402439" rx="21.5686275" ry="21.5853659"></ellipse>
|
||||
<ellipse id="Oval-2-Copy" stroke="#CFDAE6" opacity="0.4" cx="179.019608" cy="138.146341" rx="23.7254902" ry="23.7439024"></ellipse>
|
||||
<ellipse id="Oval-2" fill="#BACAD9" opacity="0.5" cx="29.1176471" cy="29.1402439" rx="10.7843137" ry="10.7926829"></ellipse>
|
||||
<path d="M29.1176471,39.9329268 L29.1176471,18.347561 C23.1616351,18.347561 18.3333333,23.1796097 18.3333333,29.1402439 C18.3333333,35.1008781 23.1616351,39.9329268 29.1176471,39.9329268 Z" id="Oval-2" fill="#BACAD9"></path>
|
||||
<g id="Group-9" opacity="0.45" transform="translate(172.000000, 131.000000)" fill="#E6A1A6">
|
||||
<ellipse id="Oval-2-Copy-2" cx="7.01960784" cy="7.14634146" rx="6.47058824" ry="6.47560976"></ellipse>
|
||||
<path d="M0.549019608,13.6219512 C4.12262681,13.6219512 7.01960784,10.722722 7.01960784,7.14634146 C7.01960784,3.56996095 4.12262681,0.670731707 0.549019608,0.670731707 L0.549019608,13.6219512 Z" id="Oval-2-Copy-2" transform="translate(3.784314, 7.146341) scale(-1, 1) translate(-3.784314, -7.146341) "></path>
|
||||
</g>
|
||||
<ellipse id="Oval-10" fill="#CFDAE6" cx="218.382353" cy="138.685976" rx="1.61764706" ry="1.61890244"></ellipse>
|
||||
<ellipse id="Oval-10-Copy-2" fill="#E0B4B7" opacity="0.35" cx="179.558824" cy="175.381098" rx="1.61764706" ry="1.61890244"></ellipse>
|
||||
<ellipse id="Oval-10-Copy" fill="#E0B4B7" opacity="0.35" cx="180.098039" cy="102.530488" rx="2.15686275" ry="2.15853659"></ellipse>
|
||||
<path d="M28.9985381,29.9671598 L171.151018,132.876024" id="Path-11" stroke="#CFDAE6" opacity="0.8"></path>
|
||||
</g>
|
||||
<g id="Group-10" opacity="0.799999952" transform="translate(1054.100635, 36.659317) rotate(-11.000000) translate(-1054.100635, -36.659317) translate(1026.600635, 4.659317)">
|
||||
<ellipse id="Oval-7" stroke="#CFDAE6" stroke-width="0.941176471" cx="43.8135593" cy="32" rx="11.1864407" ry="11.2941176"></ellipse>
|
||||
<g id="Group-12" transform="translate(34.596774, 23.111111)" fill="#BACAD9">
|
||||
<ellipse id="Oval-7" opacity="0.45" cx="9.18534718" cy="8.88888889" rx="8.47457627" ry="8.55614973"></ellipse>
|
||||
<path d="M9.18534718,17.4450386 C13.8657264,17.4450386 17.6599235,13.6143199 17.6599235,8.88888889 C17.6599235,4.16345787 13.8657264,0.332739156 9.18534718,0.332739156 L9.18534718,17.4450386 Z" id="Oval-7"></path>
|
||||
</g>
|
||||
<path d="M34.6597385,24.809694 L5.71666084,4.76878945" id="Path-2" stroke="#CFDAE6" stroke-width="0.941176471"></path>
|
||||
<ellipse id="Oval" stroke="#CFDAE6" stroke-width="0.941176471" cx="3.26271186" cy="3.29411765" rx="3.26271186" ry="3.29411765"></ellipse>
|
||||
<ellipse id="Oval-Copy" fill="#F7E1AD" cx="2.79661017" cy="61.1764706" rx="2.79661017" ry="2.82352941"></ellipse>
|
||||
<path d="M34.6312443,39.2922712 L5.06366663,59.785082" id="Path-10" stroke="#CFDAE6" stroke-width="0.941176471"></path>
|
||||
</g>
|
||||
<g id="Group-19" opacity="0.33" transform="translate(1282.537219, 446.502867) rotate(-10.000000) translate(-1282.537219, -446.502867) translate(1142.537219, 327.502867)">
|
||||
<g id="Group-17" transform="translate(141.333539, 104.502742) rotate(275.000000) translate(-141.333539, -104.502742) translate(129.333539, 92.502742)" fill="#BACAD9">
|
||||
<circle id="Oval-4" opacity="0.45" cx="11.6666667" cy="11.6666667" r="11.6666667"></circle>
|
||||
<path d="M23.3333333,23.3333333 C23.3333333,16.8900113 18.1099887,11.6666667 11.6666667,11.6666667 C5.22334459,11.6666667 0,16.8900113 0,23.3333333 L23.3333333,23.3333333 Z" id="Oval-4" transform="translate(11.666667, 17.500000) scale(-1, -1) translate(-11.666667, -17.500000) "></path>
|
||||
</g>
|
||||
<circle id="Oval-5-Copy-6" fill="#CFDAE6" cx="201.833333" cy="87.5" r="5.83333333"></circle>
|
||||
<path d="M143.5,88.8126685 L155.070501,17.6038544" id="Path-17" stroke="#BACAD9" stroke-width="1.16666667"></path>
|
||||
<path d="M17.5,37.3333333 L127.466252,97.6449735" id="Path-18" stroke="#BACAD9" stroke-width="1.16666667"></path>
|
||||
<polyline id="Path-19" stroke="#CFDAE6" stroke-width="1.16666667" points="143.902597 120.302281 174.935455 231.571342 38.5 147.510847 126.366941 110.833333"></polyline>
|
||||
<path d="M159.833333,99.7453842 L195.416667,89.25" id="Path-20" stroke="#E0B4B7" stroke-width="1.16666667" opacity="0.6"></path>
|
||||
<path d="M205.333333,82.1372105 L238.719406,36.1666667" id="Path-24" stroke="#BACAD9" stroke-width="1.16666667"></path>
|
||||
<path d="M266.723424,132.231988 L207.083333,90.4166667" id="Path-25" stroke="#CFDAE6" stroke-width="1.16666667"></path>
|
||||
<circle id="Oval-5" fill="#C1D1E0" cx="156.916667" cy="8.75" r="8.75"></circle>
|
||||
<circle id="Oval-5-Copy-3" fill="#C1D1E0" cx="39.0833333" cy="148.75" r="5.25"></circle>
|
||||
<circle id="Oval-5-Copy-2" fill-opacity="0.6" fill="#D1DEED" cx="8.75" cy="33.25" r="8.75"></circle>
|
||||
<circle id="Oval-5-Copy-4" fill-opacity="0.6" fill="#D1DEED" cx="243.833333" cy="30.3333333" r="5.83333333"></circle>
|
||||
<circle id="Oval-5-Copy-5" fill="#E0B4B7" cx="175.583333" cy="232.75" r="5.25"></circle>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 8.0 KiB |
1
src/assets/svgs/blur.svg
Normal file
1
src/assets/svgs/blur.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg t="1741685780511" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="7068" width="200" height="200"><path d="M434.56512 170.61888v682.496h-85.504v-682.496z" fill="#EEEEEE" p-id="7069"></path><path d="M519.81824 170.86976v682.496h-85.504v-682.496z" fill="#DDDDDD" p-id="7070"></path><path d="M605.07136 170.60864v682.496h-85.504v-682.496z" fill="#999999" p-id="7071"></path><path d="M690.32448 170.85952v682.496h-85.504v-682.496z" fill="#777777" p-id="7072"></path><path d="M775.68 295.936c-24.064-29.184-52.736-54.272-85.504-74.24v581.12c32.256-19.968 61.44-45.056 85.504-74.24V295.936z" fill="#555555" p-id="7073"></path><path d="M775.68 295.936v432.64c48.64-58.88 77.824-134.144 77.824-216.064s-29.184-157.696-77.824-216.576z" fill="#444444" p-id="7074"></path><path d="M512 85.504c-234.496 0-426.496 192-426.496 426.496s192 426.496 426.496 426.496 426.496-192 426.496-426.496-192-426.496-426.496-426.496z m0 768c-187.904 0-341.504-153.6-341.504-341.504s153.6-341.504 341.504-341.504 341.504 153.6 341.504 341.504-153.6 341.504-341.504 341.504z" fill="#333333" p-id="7075"></path></svg>
|
After Width: | Height: | Size: 1.1 KiB |
@@ -45,7 +45,7 @@
|
||||
<div class="upload-container">
|
||||
<AUploadDragger
|
||||
v-model:fileList="upload.fileList"
|
||||
:beforeUpload="upload.beforeUpload"
|
||||
:beforeUpload="upload.beforeUploadWithWebWorker"
|
||||
:customRequest="customUploadRequest"
|
||||
:directory="false"
|
||||
:maxCount="10"
|
||||
|
@@ -113,6 +113,34 @@
|
||||
</template>
|
||||
</ASwitch>
|
||||
</div>
|
||||
<div class="upload-setting-item">
|
||||
<AFlex :vertical="false" align="center" justify="flex-start" gap="middle">
|
||||
<AAvatar size="default" shape="square" :src="lock"/>
|
||||
<span class="upload-setting-item-name">图像加密</span>
|
||||
</AFlex>
|
||||
<ASwitch v-model:checked="uploadStore.uploadSetting.encrypt">
|
||||
<template #checkedChildren>
|
||||
<check-outlined/>
|
||||
</template>
|
||||
<template #unCheckedChildren>
|
||||
<close-outlined/>
|
||||
</template>
|
||||
</ASwitch>
|
||||
</div>
|
||||
<div class="upload-setting-item">
|
||||
<AFlex :vertical="false" align="center" justify="flex-start" gap="middle">
|
||||
<AAvatar size="default" shape="square" :src="blur"/>
|
||||
<span class="upload-setting-item-name">模糊检测</span>
|
||||
</AFlex>
|
||||
<ASwitch v-model:checked="uploadStore.uploadSetting.blur_detection">
|
||||
<template #checkedChildren>
|
||||
<check-outlined/>
|
||||
</template>
|
||||
<template #unCheckedChildren>
|
||||
<close-outlined/>
|
||||
</template>
|
||||
</ASwitch>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
@@ -125,7 +153,8 @@ import gps from "@/assets/svgs/gps.svg";
|
||||
import target from "@/assets/svgs/target.svg";
|
||||
import qr from "@/assets/svgs/qr.svg";
|
||||
import face_detection from "@/assets/svgs/face_detection.svg";
|
||||
|
||||
import lock from "@/assets/svgs/lock.svg";
|
||||
import blur from "@/assets/svgs/blur.svg";
|
||||
const uploadStore = useStore().upload;
|
||||
|
||||
|
||||
|
@@ -1,446 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
// utilities
|
||||
import type {CSSProperties} from 'vue';
|
||||
import {computed, getCurrentInstance, onBeforeUnmount, onMounted, ref, toRefs, watch} from 'vue';
|
||||
|
||||
// prop types
|
||||
export interface Props {
|
||||
aspectRatio?: 'taller' | 'wider'
|
||||
handle?: string | number | boolean
|
||||
handleSize?: number
|
||||
hover?: boolean
|
||||
slideOnClick?: boolean
|
||||
keyboard?: boolean
|
||||
keyboardStep?: number
|
||||
leftImage: string
|
||||
leftImageAlt?: string
|
||||
leftImageCss?: object
|
||||
leftImageLabel?: string
|
||||
onSliderPositionChange?: (position: number) => void
|
||||
rightImage: string
|
||||
rightImageAlt?: string
|
||||
rightImageCss?: object
|
||||
rightImageLabel?: string
|
||||
skeleton?: string | number | boolean
|
||||
sliderLineColor?: string
|
||||
sliderLineWidth?: number
|
||||
sliderPositionPercentage?: number
|
||||
vertical?: boolean
|
||||
}
|
||||
|
||||
// props
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
keyboard: false,
|
||||
keyboardStep: 0.01,
|
||||
hover: false,
|
||||
slideOnClick: true,
|
||||
handleSize: 40,
|
||||
sliderLineWidth: 2,
|
||||
sliderPositionPercentage: 0.5,
|
||||
vertical: false,
|
||||
onSliderPositionChange: () => {
|
||||
},
|
||||
sliderLineColor: '#ffffff',
|
||||
aspectRatio: 'wider',
|
||||
});
|
||||
|
||||
// emits
|
||||
const emit = defineEmits<{
|
||||
(e: 'slideStart', pos: number): void
|
||||
(e: 'slideEnd', pos: number): void
|
||||
(e: 'isSliding', state: boolean): void
|
||||
}>();
|
||||
|
||||
// variables
|
||||
const {
|
||||
aspectRatio,
|
||||
leftImage,
|
||||
leftImageAlt,
|
||||
leftImageLabel,
|
||||
leftImageCss,
|
||||
rightImage,
|
||||
rightImageAlt,
|
||||
rightImageLabel,
|
||||
rightImageCss,
|
||||
hover,
|
||||
handle,
|
||||
handleSize,
|
||||
sliderLineWidth,
|
||||
sliderPositionPercentage,
|
||||
skeleton,
|
||||
sliderLineColor,
|
||||
vertical,
|
||||
onSliderPositionChange,
|
||||
slideOnClick,
|
||||
keyboard,
|
||||
keyboardStep
|
||||
} = toRefs(props);
|
||||
|
||||
const componentId = Math.random().toString(36).substring(2, 9);
|
||||
|
||||
const horizontal = !vertical.value;
|
||||
const containerRef = ref();
|
||||
const rightImageRef = ref<HTMLImageElement | null>(null);
|
||||
const leftImageRef = ref<HTMLImageElement | null>(null);
|
||||
const sliderPosition = ref(sliderPositionPercentage.value);
|
||||
const containerWidth = ref(0);
|
||||
const containerHeight = ref(0);
|
||||
const leftImgLoaded = ref(false);
|
||||
const rightImgLoaded = ref(false);
|
||||
const isSliding = ref(false);
|
||||
|
||||
// computed refs
|
||||
const allImagesLoaded = computed(() => leftImgLoaded.value && rightImgLoaded.value);
|
||||
// Introduce refs(rightImageClip|leftImageClip) to correct bug caused when shifting from deprecated
|
||||
// css property 'clip' to 'clipPath'. clip-path:inset works as paddings or margin
|
||||
// so when right image clip reduces, left image clip has to increase for the comparison
|
||||
// effect to work
|
||||
const rightImageClip = computed(() => sliderPosition.value);
|
||||
const leftImageClip = computed(() => 1 - sliderPosition.value);
|
||||
|
||||
// computed styles
|
||||
const containerStyle = computed((): CSSProperties => {
|
||||
return {
|
||||
display: allImagesLoaded.value ? 'flex' : 'none',
|
||||
height: `${containerHeight.value}px`,
|
||||
};
|
||||
});
|
||||
|
||||
const rightImageStyle = computed((): CSSProperties => {
|
||||
return {
|
||||
clipPath: horizontal ? `inset(0px 0px 0px ${containerWidth.value * rightImageClip.value}px)` : `inset(${containerHeight.value * rightImageClip.value}px 0px 0px 0px)`,
|
||||
...rightImageCss,
|
||||
};
|
||||
});
|
||||
|
||||
const leftImageStyle = computed((): CSSProperties => {
|
||||
return {
|
||||
clipPath: horizontal ? `inset(0px ${containerWidth.value * leftImageClip.value}px 0px 0px)` : `inset(0px 0px ${containerHeight.value * leftImageClip.value}px 0px)`,
|
||||
...leftImageCss,
|
||||
};
|
||||
});
|
||||
|
||||
const sliderStyle = computed((): CSSProperties => {
|
||||
return {
|
||||
cursor: !hover.value && horizontal ? 'ew-resize !important' : !hover.value && !horizontal ? 'ns-resize !important' : undefined,
|
||||
flexDirection: horizontal ? 'column' : 'row',
|
||||
height: horizontal ? '100%' : `${handleSize.value}px`,
|
||||
left: horizontal ? `${containerWidth.value * sliderPosition.value - handleSize.value / 2}px` : '0',
|
||||
top: horizontal ? '0' : `${containerHeight.value * sliderPosition.value - handleSize.value / 2}px`,
|
||||
width: horizontal ? `${handleSize.value}px` : '100%',
|
||||
};
|
||||
});
|
||||
|
||||
const lineStyle = computed((): CSSProperties => {
|
||||
return {
|
||||
background: sliderLineColor.value,
|
||||
height: horizontal ? '100%' : `${sliderLineWidth.value}px`,
|
||||
width: horizontal ? `${sliderLineWidth.value}px` : '100%',
|
||||
};
|
||||
});
|
||||
|
||||
const handleDefaultStyle = computed((): CSSProperties => {
|
||||
return {
|
||||
border: `${sliderLineWidth.value}px solid ${sliderLineColor.value}`,
|
||||
height: `${handleSize.value}px`,
|
||||
width: `${handleSize.value}px`,
|
||||
transform: horizontal ? 'none' : 'rotate(90deg)',
|
||||
};
|
||||
});
|
||||
|
||||
const leftArrowStyle = computed((): CSSProperties => {
|
||||
return {
|
||||
border: `inset ${handleSize.value * 0.15}px rgba(0,0,0,0)`,
|
||||
borderRight: `${handleSize.value * 0.15}px solid ${sliderLineColor.value}`,
|
||||
marginLeft: `-${handleSize.value * 0.25}px`, // for IE11
|
||||
marginRight: `${handleSize.value * 0.25}px`,
|
||||
};
|
||||
});
|
||||
|
||||
const rightArrowStyle = computed((): CSSProperties => {
|
||||
return {
|
||||
border: `inset ${handleSize.value * 0.15}px rgba(0,0,0,0)`,
|
||||
borderLeft: `${handleSize.value * 0.15}px solid ${sliderLineColor.value}`,
|
||||
marginRight: `-${handleSize.value * 0.25}px`, // for IE11
|
||||
};
|
||||
});
|
||||
|
||||
const leftLabelStyle = computed((): CSSProperties => {
|
||||
return {
|
||||
left: horizontal ? '5%' : '50%',
|
||||
opacity: isSliding.value ? 0 : 1,
|
||||
top: horizontal ? '50%' : '3%',
|
||||
transform: horizontal ? 'translate(0,-50%)' : 'translate(-50%, 0)',
|
||||
borderRadius: '10px',
|
||||
};
|
||||
});
|
||||
|
||||
const rightLabelStyle = computed((): CSSProperties => {
|
||||
return {
|
||||
opacity: isSliding.value ? 0 : 1,
|
||||
left: horizontal ? 'unset' : '50%',
|
||||
right: horizontal ? '5%' : 'unset',
|
||||
top: horizontal ? '50%' : 'unset',
|
||||
bottom: horizontal ? 'unset' : '3%',
|
||||
transform: horizontal ? 'translate(0,-50%)' : 'translate(-50%, 0)',
|
||||
borderRadius: '10px',
|
||||
};
|
||||
});
|
||||
|
||||
const leftLabelContainerStyle = computed((): CSSProperties => {
|
||||
return {
|
||||
clip: horizontal ? `rect(auto, ${containerWidth.value * sliderPosition.value}px, auto, auto)` : `rect(auto, auto, ${containerHeight.value * sliderPosition.value}px, auto)`,
|
||||
};
|
||||
});
|
||||
|
||||
const rightLabelContainerStyle = computed((): CSSProperties => {
|
||||
return {
|
||||
clipPath: horizontal ? `inset(0px 0px 0px ${containerWidth.value * rightImageClip.value}px)` : `inset(${containerHeight.value * rightImageClip.value}px 0px 0px 0px)`,
|
||||
};
|
||||
});
|
||||
|
||||
function handleSliding(event: MouseEvent | TouchEvent | KeyboardEvent) {
|
||||
const e = event as TouchEvent;
|
||||
|
||||
// Calc cursor position from the:
|
||||
// - left edge of the viewport (for horizontal)
|
||||
// - top edge of the viewport (for vertical)
|
||||
// @ts-expect-error it is necessary
|
||||
const cursorXfromViewport = e.touches ? e.touches[0].pageX : e.pageX;
|
||||
// @ts-expect-error it is necessary
|
||||
const cursorYfromViewport = e.touches ? e.touches[0].pageY : e.pageY;
|
||||
|
||||
// Calc Cursor Position from the:
|
||||
// - left edge of the window (for horizontal)
|
||||
// - top edge of the window (for vertical)
|
||||
// to consider any page scrolling
|
||||
const cursorXfromWindow = cursorXfromViewport - window.scrollX;
|
||||
const cursorYfromWindow = cursorYfromViewport - window.scrollY;
|
||||
|
||||
// Calc Cursor Position from the left edge of the image
|
||||
const imagePosition = rightImageRef.value!.getBoundingClientRect();
|
||||
let pos = horizontal ? cursorXfromWindow - imagePosition.left : cursorYfromWindow - imagePosition.top;
|
||||
|
||||
// Set minimum and maximum value-to-prevent the slider from overflowing
|
||||
const minPos = sliderLineWidth.value / 2;
|
||||
const maxPos = horizontal ? containerWidth.value - sliderLineWidth.value / 2 : containerHeight.value - sliderLineWidth.value / 2;
|
||||
|
||||
if (pos < minPos)
|
||||
pos = minPos;
|
||||
if (pos > maxPos)
|
||||
pos = maxPos;
|
||||
|
||||
sliderPosition.value = horizontal ? pos / containerWidth.value : pos / containerHeight.value;
|
||||
|
||||
if (onSliderPositionChange.value)
|
||||
onSliderPositionChange.value(horizontal ? pos / containerWidth.value : pos / containerHeight.value);
|
||||
}
|
||||
|
||||
function startSliding(e: MouseEvent | TouchEvent | KeyboardEvent) {
|
||||
isSliding.value = true;
|
||||
emit('slideStart', sliderPosition.value);
|
||||
emit('isSliding', isSliding.value);
|
||||
|
||||
if (!horizontal)
|
||||
e.preventDefault(); // prevent all default + mobile scrolling if vertical
|
||||
else if (!('touches' in e))
|
||||
e.preventDefault(); // prevent default except from mobile scrolling
|
||||
|
||||
// Slide the image even if you just click or tap (not drag)
|
||||
if (slideOnClick.value)
|
||||
handleSliding(e);
|
||||
|
||||
window.addEventListener('mousemove', handleSliding);
|
||||
window.addEventListener('touchmove', handleSliding);
|
||||
window.addEventListener('mouseup', finishSliding);
|
||||
window.addEventListener('touchend', finishSliding);
|
||||
}
|
||||
|
||||
function finishSliding() {
|
||||
isSliding.value = false;
|
||||
emit('slideEnd', sliderPosition.value);
|
||||
emit('isSliding', isSliding.value);
|
||||
|
||||
window.removeEventListener('mousemove', handleSliding);
|
||||
window.removeEventListener('touchmove', handleSliding);
|
||||
window.removeEventListener('mouseup', finishSliding);
|
||||
window.removeEventListener('touchend', finishSliding);
|
||||
}
|
||||
|
||||
function handleFocusIn() {
|
||||
if (keyboard.value)
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
}
|
||||
|
||||
function handleFocusOut() {
|
||||
if (keyboard.value)
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
}
|
||||
|
||||
function handleOnClick() {
|
||||
if (keyboard.value)
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
}
|
||||
|
||||
function handleOnClickOutside(event: KeyboardEvent | MouseEvent) {
|
||||
if (containerRef.value && !containerRef.value.contains(event.target)) {
|
||||
// The click is outside the container, remove the event listener
|
||||
containerRef.value.blur();
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === 'ArrowDown' && !horizontal) {
|
||||
e.preventDefault();
|
||||
if ((sliderPosition.value + keyboardStep.value) > 1)
|
||||
sliderPosition.value = 1;
|
||||
else
|
||||
sliderPosition.value += keyboardStep.value;
|
||||
} else if (e.key === 'ArrowUp' && !horizontal) {
|
||||
e.preventDefault();
|
||||
if ((sliderPosition.value - keyboardStep.value) < 0)
|
||||
sliderPosition.value = 0;
|
||||
else
|
||||
sliderPosition.value -= keyboardStep.value;
|
||||
} else if (e.key === 'ArrowLeft' && horizontal) {
|
||||
e.preventDefault();
|
||||
if ((sliderPosition.value - keyboardStep.value) < 0)
|
||||
sliderPosition.value = 0;
|
||||
else
|
||||
sliderPosition.value -= keyboardStep.value;
|
||||
} else if (e.key === 'ArrowRight' && horizontal) {
|
||||
e.preventDefault();
|
||||
if ((sliderPosition.value + keyboardStep.value) > 1)
|
||||
sliderPosition.value = 1;
|
||||
else
|
||||
sliderPosition.value += keyboardStep.value;
|
||||
} else {
|
||||
// do something
|
||||
}
|
||||
}
|
||||
|
||||
function forceRenderHover(): void {
|
||||
const instance = getCurrentInstance();
|
||||
instance?.proxy?.$forceUpdate();
|
||||
const containerElement = containerRef.value;
|
||||
if (props.hover) {
|
||||
containerElement?.addEventListener('mousemove', startSliding);
|
||||
containerElement?.addEventListener('mouseleave', finishSliding);
|
||||
} else {
|
||||
containerElement?.removeEventListener('mousemove', startSliding);
|
||||
containerElement?.removeEventListener('mouseleave', finishSliding);
|
||||
|
||||
containerElement?.addEventListener('mouseup', finishSliding);
|
||||
containerElement?.addEventListener('touchend', finishSliding);
|
||||
// containerElement?.addEventListener('mouseleave', finishSliding)
|
||||
}
|
||||
}
|
||||
|
||||
// Make the component responsive
|
||||
onMounted(() => {
|
||||
const containerElement = containerRef.value;
|
||||
const resizeObserver = new ResizeObserver(([entry]) => {
|
||||
containerWidth.value = entry.target.getBoundingClientRect().width;
|
||||
});
|
||||
resizeObserver.observe(containerElement);
|
||||
|
||||
return () => resizeObserver.disconnect();
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
const containerElement = containerRef.value;
|
||||
// had to include this here, binding it with the container with the if hover prop doesn't work for some reason
|
||||
if (props.hover) {
|
||||
containerElement?.addEventListener('mousemove', startSliding); // 03
|
||||
containerElement?.addEventListener('mouseleave', finishSliding); // 04
|
||||
}
|
||||
|
||||
window.addEventListener('click', handleOnClickOutside);
|
||||
// containerElement?.addEventListener('mouseleave', finishSliding)
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
const containerElement = containerRef.value;
|
||||
|
||||
containerElement?.removeEventListener('mousemove', startSliding);
|
||||
containerElement?.removeEventListener('mouseleave', finishSliding);
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
window.removeEventListener('click', handleOnClickOutside);
|
||||
window.removeEventListener('mousemove', handleSliding);
|
||||
window.removeEventListener('touchmove', handleSliding);
|
||||
window.removeEventListener('mouseup', finishSliding);
|
||||
window.removeEventListener('touchend', finishSliding);
|
||||
});
|
||||
|
||||
// Watch for changes in leftImage
|
||||
watch(leftImageRef, () => {
|
||||
|
||||
leftImgLoaded.value = !!leftImageRef.value?.complete;
|
||||
});
|
||||
|
||||
// Watch for changes in rightImage
|
||||
watch(rightImageRef, () => {
|
||||
|
||||
rightImgLoaded.value = !!rightImageRef.value?.complete;
|
||||
});
|
||||
|
||||
// since hover is the only listener set on mount, we need to rerender component if the value changes
|
||||
watch(hover, () => {
|
||||
forceRenderHover();
|
||||
});
|
||||
|
||||
// Calculate container height
|
||||
watch(
|
||||
[() => containerWidth.value, () => leftImgLoaded.value, () => rightImgLoaded.value],
|
||||
() => {
|
||||
const leftImageWidthHeightRatio = leftImageRef.value!.naturalHeight / leftImageRef.value!.naturalWidth;
|
||||
const rightImageWidthHeightRatio = rightImageRef.value!.naturalHeight / rightImageRef.value!.naturalWidth;
|
||||
|
||||
const idealWidthHeightRatio
|
||||
= aspectRatio.value === 'taller' ? Math.max(leftImageWidthHeightRatio, rightImageWidthHeightRatio) : Math.min(leftImageWidthHeightRatio, rightImageWidthHeightRatio);
|
||||
|
||||
containerHeight.value = containerWidth.value * idealWidthHeightRatio;
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="skeleton && !allImagesLoaded" data-testid="skeleton" :style="containerStyle" v-html="skeleton"/>
|
||||
<div
|
||||
v-else :id="componentId" ref="containerRef" class="vci--container" tabindex="0" data-testid="vci-container"
|
||||
:style="containerStyle" @click="handleOnClick" @touchstart="startSliding" @touchend="finishSliding"
|
||||
@focusin="handleFocusIn" @focusout="handleFocusOut" @mousedown="startSliding" @mouseup="finishSliding"
|
||||
>
|
||||
<img
|
||||
ref="rightImageRef" class="vci--right-image" :alt="rightImageAlt" data-testid="right-image" :src="rightImage"
|
||||
:style="rightImageStyle" @load="rightImgLoaded = true"
|
||||
>
|
||||
<img
|
||||
ref="leftImageRef" class="vci--left-image" :alt="leftImageAlt" data-testid="left-image" :src="leftImage"
|
||||
:style="leftImageStyle" @load="leftImgLoaded = true"
|
||||
>
|
||||
<div class="vci--slider" :style="sliderStyle">
|
||||
<div class="vci--slider-line" :style="lineStyle"/>
|
||||
<div v-if="handle" class="vci--custom-handle" v-html="handle"/>
|
||||
<div v-else class="vci--default-handle" :style="handleDefaultStyle">
|
||||
<div class="vci--left-arrow" :style="leftArrowStyle"/>
|
||||
<div class="vci--right-arrow" :style="rightArrowStyle"/>
|
||||
</div>
|
||||
<div class="vci--slider-line" :style="lineStyle"/>
|
||||
</div>
|
||||
<div v-if="leftImageLabel" class="vci--left-label-container" :style="leftLabelContainerStyle">
|
||||
<div class="vci--left-label" data-testid="left-image-label" :style="leftLabelStyle">
|
||||
{{ leftImageLabel }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="rightImageLabel" class="vci--right-label-container" :style="rightLabelContainerStyle">
|
||||
<div class="vci--right-label" data-testid="right-image-label" :style="rightLabelStyle">
|
||||
{{ rightImageLabel }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<style scoped lang="scss" src="./index.scss">
|
||||
|
||||
</style>
|
@@ -1,93 +0,0 @@
|
||||
.vci--container {
|
||||
box-sizing: border-box;
|
||||
position: relative;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.vci--right-image {
|
||||
display: flex;
|
||||
position: absolute;
|
||||
object-fit: cover;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.vci--left-image {
|
||||
display: flex;
|
||||
position: absolute;
|
||||
object-fit: cover;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.vci--slider {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.vci--slider-line {
|
||||
flex: 0 1 auto;
|
||||
box-shadow: 0px 3px 1px -2px rgba(0, 0, 0, 0.2), 0px 2px 2px 0px rgba(0, 0, 0, 0.14), 0px 1px 5px 0px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
.vci--custom-handle {
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex: 1 0 auto;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: auto;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.vci--default-handle {
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex: 1 0 auto;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
border-radius: 100%;
|
||||
box-shadow: 0px 3px 1px -2px rgba(0, 0, 0, 0.2), 0px 2px 2px 0px rgba(0, 0, 0, 0.14), 0px 1px 5px 0px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
.vci--left-arrow {
|
||||
height: 0px;
|
||||
width: 0px;
|
||||
}
|
||||
|
||||
.vci--right-arrow {
|
||||
height: 0px;
|
||||
width: 0px;
|
||||
}
|
||||
|
||||
.vci--left-label {
|
||||
position: absolute;
|
||||
padding: 10px 20px;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
color: white;
|
||||
transition: opacity 0.1s ease-out;
|
||||
}
|
||||
|
||||
.vci--right-label {
|
||||
position: absolute;
|
||||
padding: 10px 20px;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
color: white;
|
||||
transition: opacity 0.1s ease-out;
|
||||
}
|
||||
|
||||
.vci--right-label-container {
|
||||
position: absolute;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.vci--left-label-container {
|
||||
position: absolute;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
@@ -1,3 +0,0 @@
|
||||
import VueCompareImage from './VueCompareImage.vue';
|
||||
|
||||
export {VueCompareImage};
|
@@ -220,30 +220,30 @@ export const LABEL_TO_CATEGORY = new Map<string, { en: string, zh: string }>([
|
||||
]);
|
||||
|
||||
// 获取标签所属大类的函数,支持英文和中文返回
|
||||
export function getCategoryByLabel(label: string, lang: 'en' | 'zh' = 'en'): string | undefined {
|
||||
export function getCategoryByLabel(label: string, lang: 'en' | 'zh' = 'en'): string | null {
|
||||
const category = LABEL_TO_CATEGORY.get(label);
|
||||
return category ? category[lang] : undefined;
|
||||
return category ? category[lang] : null;
|
||||
}
|
||||
|
||||
// 获取标签所属大类的函数,支持英文和中文返回
|
||||
export function getCategoryName(label: string, lang: 'en' | 'zh' = 'en'): string | undefined {
|
||||
export function getCategoryName(label: string, lang: 'en' | 'zh' = 'en'): string | null {
|
||||
const category = CATEGORIES[label];
|
||||
return category ? category[lang] : undefined;
|
||||
return category ? category[lang] : null;
|
||||
}
|
||||
|
||||
// 获取标签的小分类名称
|
||||
export function getLabelName(label: string, lang: 'en' | 'zh' = 'en'): string | undefined {
|
||||
export function getLabelName(label: string, lang: 'en' | 'zh' = 'en'): string | null {
|
||||
const labelInfo = LABELS[label];
|
||||
return labelInfo ? labelInfo[lang] : undefined;
|
||||
return labelInfo ? labelInfo[lang] : null;
|
||||
}
|
||||
|
||||
// 获取标签的中文名称
|
||||
export const getZhCategoryNameByEnName = (enName: string): string | undefined => {
|
||||
export const getZhCategoryNameByEnName = (enName: string): string | null => {
|
||||
const category = Object.values(CATEGORIES).find(cat => cat.en.toLowerCase() === enName.toLowerCase());
|
||||
return category?.zh;
|
||||
return category?.zh || null;
|
||||
};
|
||||
// 获取标签的中文名称
|
||||
export const getZhLabelNameByEnName = (enName: string): string | undefined => {
|
||||
export const getZhLabelNameByEnName = (enName: string): string | null => {
|
||||
const labelInfo = LABELS[enName.toLowerCase()];
|
||||
return labelInfo?.zh;
|
||||
};
|
||||
|
@@ -10,8 +10,6 @@ import {createPersistedStatePlugin} from 'pinia-plugin-persistedstate-2';
|
||||
import VueDOMPurifyHTML from 'vue-dompurify-html';
|
||||
import {registerDirectives} from "@/directives";
|
||||
|
||||
import VueCalendarMap from '@fcli/vue-calendar-map';
|
||||
|
||||
const pinia: Pinia = createPinia();
|
||||
const installPersistedStatePlugin = createPersistedStatePlugin();
|
||||
pinia.use((context) => installPersistedStatePlugin(context));
|
||||
@@ -22,5 +20,4 @@ app.use(router);
|
||||
app.use(i18n);
|
||||
app.use(GoCaptcha);
|
||||
app.use(VueDOMPurifyHTML);
|
||||
app.use(VueCalendarMap);
|
||||
app.mount('#app');
|
||||
|
11
src/router/modules/preview.ts
Normal file
11
src/router/modules/preview.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export default [
|
||||
{
|
||||
path: '/preview/blur-detect',
|
||||
name: 'PreviewBlurDetect',
|
||||
component: () => import('@/views/Preview/PreviewBlurDetect.vue'),
|
||||
meta: {
|
||||
requiresAuth: false,
|
||||
title: 'PreviewBlurDetect',
|
||||
}
|
||||
},
|
||||
];
|
122
src/router/modules/system.ts
Normal file
122
src/router/modules/system.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import Dashboard from '@/views/Admin/System/Pages/Dashboard.vue';
|
||||
import UserList from '@/views/Admin/System/Pages/UserList.vue';
|
||||
import PermissionSetting from '@/views/Admin/System/Pages/PermissionSetting.vue';
|
||||
import RoleManagement from '@/views/Admin/System/Pages/RoleManagement.vue';
|
||||
|
||||
export default [
|
||||
{
|
||||
path: '/admin/login',
|
||||
name: 'admin-login',
|
||||
component: () => import('@/views/Admin/Auth/Login.vue'),
|
||||
meta: {
|
||||
requiresAuth: false,
|
||||
title: 'Auth Login',
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/admin/system/index',
|
||||
name: 'admin-system',
|
||||
redirect: '/admin/system/dashboard',
|
||||
component: () => import('@/views/Admin/System/Index.vue'),
|
||||
meta: {
|
||||
requiresAuth: false,
|
||||
title: '管理首页',
|
||||
},
|
||||
children: [
|
||||
{
|
||||
path: '/admin/system/dashboard',
|
||||
name: 'admin-system-dashboard',
|
||||
component: Dashboard,
|
||||
meta: {
|
||||
requiresAuth: false,
|
||||
title: '管理首页',
|
||||
}
|
||||
}, {
|
||||
path: '/admin/system/user',
|
||||
name: 'admin-system-user',
|
||||
component: UserList,
|
||||
meta: {
|
||||
requiresAuth: false,
|
||||
title: '用户列表',
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/admin/system/permission',
|
||||
name: 'admin-system-permission',
|
||||
component: PermissionSetting,
|
||||
meta: {
|
||||
requiresAuth: false,
|
||||
title: '权限列表',
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/admin/system/role',
|
||||
name: 'admin-system-role',
|
||||
component: RoleManagement,
|
||||
meta: {
|
||||
requiresAuth: false,
|
||||
title: '角色列表',
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/admin/system/basic',
|
||||
name: 'admin-system-basic-setting',
|
||||
component: () => import('@/views/Admin/System/Pages/BasicSettings.vue'),
|
||||
meta: {
|
||||
requiresAuth: false,
|
||||
title: '基本设置',
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/admin/system/security',
|
||||
name: 'admin-system-security-setting',
|
||||
component: () => import('@/views/Admin/System/Pages/SecuritySettings.vue'),
|
||||
meta: {
|
||||
requiresAuth: false,
|
||||
title: '安全设置',
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/admin/system/log',
|
||||
name: 'admin-system-log',
|
||||
component: () => import('@/views/Admin/System/Pages/SystemLogs.vue'),
|
||||
meta: {
|
||||
requiresAuth: false,
|
||||
title: '系统日志',
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/admin/system/analysis',
|
||||
name: 'admin-system-analysis',
|
||||
component: () => import('@/views/Admin/System/Pages/UserAnalysis.vue'),
|
||||
meta: {
|
||||
requiresAuth: false,
|
||||
title: '用户分析',
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/admin/system/visit',
|
||||
name: 'admin-system-visit',
|
||||
component: () => import('@/views/Admin/System/Pages/VisitStatistics.vue'),
|
||||
meta: {
|
||||
requiresAuth: false,
|
||||
title: '访问统计',
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/admin/system/storage',
|
||||
name: 'admin-system-storage',
|
||||
component: () => import('@/views/Admin/System/Pages/StorageManagement.vue'),
|
||||
meta: {
|
||||
requiresAuth: false,
|
||||
title: '存储管理',
|
||||
}
|
||||
},
|
||||
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
];
|
||||
|
||||
|
@@ -9,6 +9,8 @@ import mainRouter from "./modules/main_router.ts";
|
||||
import i18n from "@/locales";
|
||||
import phone_upload from "@/router/modules/phone_upload.ts";
|
||||
import user from "@/router/modules/user.ts";
|
||||
import system from "@/router/modules/system.ts";
|
||||
import preview from "@/router/modules/preview.ts";
|
||||
|
||||
const routes: Array<RouteRecordRaw> = [
|
||||
...login,
|
||||
@@ -17,10 +19,12 @@ const routes: Array<RouteRecordRaw> = [
|
||||
...mainRouter,
|
||||
...phone_upload,
|
||||
...user,
|
||||
...system,
|
||||
...preview,
|
||||
{
|
||||
path: '/:pathMatch(.*)',
|
||||
redirect: '/404',
|
||||
}
|
||||
},
|
||||
];
|
||||
|
||||
const router: Router = createRouter({
|
||||
|
@@ -9,6 +9,7 @@ import {useUploadStore} from "@/store/modules/uploadStore.ts";
|
||||
import {useImageStore} from "@/store/modules/imageStore.ts";
|
||||
import {useShareStore} from "@/store/modules/shareStore.ts";
|
||||
import {useSearchStore} from "@/store/modules/searchStore.ts";
|
||||
import {useSystemStore} from "@/store/modules/systemStore.ts";
|
||||
|
||||
export default function useStore() {
|
||||
return {
|
||||
@@ -23,5 +24,6 @@ export default function useStore() {
|
||||
image: useImageStore(),
|
||||
share: useShareStore(),
|
||||
search: useSearchStore(),
|
||||
system: useSystemStore(),
|
||||
};
|
||||
}
|
||||
|
@@ -342,7 +342,7 @@ export const useCommentStore = defineStore(
|
||||
persistedState: {
|
||||
persist: true,
|
||||
storage: localForage,
|
||||
key: 'comment',
|
||||
key: 'STORE-COMMENT',
|
||||
includePaths: ["emojiList", "commentList", "replyVisibility", "commentMap"]
|
||||
}
|
||||
}
|
||||
|
@@ -55,7 +55,6 @@ export const useImageStore = defineStore(
|
||||
const imageEditVisible = ref<boolean>(false);
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* 获取人脸列表
|
||||
*/
|
||||
@@ -138,7 +137,7 @@ export const useImageStore = defineStore(
|
||||
persistedState: {
|
||||
persist: true,
|
||||
storage: localForage,
|
||||
key: 'image',
|
||||
key: 'STORE-IMAGE',
|
||||
includePaths: [
|
||||
"tabActiveKey",
|
||||
"tabMap",
|
||||
|
@@ -15,7 +15,7 @@ export const langStore = defineStore(
|
||||
persistedState: {
|
||||
persist: true,
|
||||
storage: localStorage,
|
||||
key: 'lang',
|
||||
key: 'STORE-LANGUAGE',
|
||||
includePaths: ['lang']
|
||||
}
|
||||
}
|
||||
|
@@ -17,7 +17,7 @@ export const useMenuStore = defineStore(
|
||||
persistedState: {
|
||||
persist: true,
|
||||
storage: localStorage,
|
||||
key: 'menu',
|
||||
key: 'STORE-MENU',
|
||||
includePaths: ['currentMenu', 'userCenterMenu', 'accountSettingMenu']
|
||||
}
|
||||
}
|
||||
|
@@ -57,7 +57,7 @@ export const useSearchStore = defineStore(
|
||||
persistedState: {
|
||||
persist: true,
|
||||
storage: localStorage,
|
||||
key: 'search',
|
||||
key: 'STORE-SEARCH',
|
||||
includePaths: ['searchOption', 'options']
|
||||
}
|
||||
}
|
||||
|
@@ -31,7 +31,7 @@ export const useShareStore = defineStore(
|
||||
persistedState: {
|
||||
persist: true,
|
||||
storage: sessionStorage,
|
||||
key: 'share',
|
||||
key: 'STORE-SHARE',
|
||||
includePaths: ['sharePassword']
|
||||
}
|
||||
}
|
||||
|
19
src/store/modules/systemStore.ts
Normal file
19
src/store/modules/systemStore.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
export const useSystemStore = defineStore(
|
||||
'system',
|
||||
() => {
|
||||
const isCollapsed = ref<boolean>(false);
|
||||
|
||||
return {
|
||||
isCollapsed,
|
||||
};
|
||||
},
|
||||
{
|
||||
// 开启数据持久化
|
||||
persistedState: {
|
||||
persist: true,
|
||||
storage: localStorage,
|
||||
key: 'STORE-SYSTEM',
|
||||
includePaths: ['isCollapsed']
|
||||
}
|
||||
}
|
||||
);
|
@@ -40,7 +40,7 @@ export const useThemeStore = defineStore(
|
||||
persistedState: {
|
||||
persist: true,
|
||||
storage: localStorage,
|
||||
key: 'theme',
|
||||
key: 'STORE-THEME',
|
||||
includePaths: ['themeName', 'darkMode']
|
||||
}
|
||||
}
|
||||
|
@@ -1,7 +1,7 @@
|
||||
// import localforage from 'localforage';
|
||||
|
||||
import imageCompression from "browser-image-compression";
|
||||
import {message, type UploadProps} from "ant-design-vue";
|
||||
import {message, notification, type UploadProps} from "ant-design-vue";
|
||||
import {initNSFWJs, predictNSFW} from "@/utils/tfjs/nsfw.ts";
|
||||
import i18n from "@/locales";
|
||||
|
||||
@@ -13,13 +13,25 @@ import {getCategoryByLabel} from "@/constant/coco_ssd_label_category.ts";
|
||||
import exifr from "exifr";
|
||||
import isScreenshot from "@/utils/imageUtils/isScreenshot.ts";
|
||||
import {ready, scan} from 'qr-scanner-wechat';
|
||||
import {initBlurDetect, detectBlurFromFile} from '@/utils/imageBlurDetect/blurDetect';
|
||||
import {fileToImageData} from "@/utils/file/image-converter.ts";
|
||||
|
||||
// Web Worker图像分析响应接口
|
||||
interface ImageAnalysisResponse {
|
||||
isNSFW?: boolean;
|
||||
isAnime?: boolean;
|
||||
landscape?: string | null;
|
||||
tagName?: string | null;
|
||||
topCategory?: string | undefined;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface UploadPredictResult {
|
||||
isAnime: boolean;
|
||||
tagName: string | null;
|
||||
landscape: 'building' | 'forest' | 'glacier' | 'mountain' | 'sea' | 'street' | null;
|
||||
isScreenshot: boolean;
|
||||
topCategory: string | undefined;
|
||||
topCategory: string | null;
|
||||
width: number | null;
|
||||
height: number | null;
|
||||
latitude: number | null;
|
||||
@@ -60,8 +72,36 @@ export const useUploadStore = defineStore(
|
||||
target_detection: false,
|
||||
qrcode_detection: true,
|
||||
face_detection: true,
|
||||
encrypt: false,
|
||||
blur_detection: true,
|
||||
});
|
||||
|
||||
// 用于控制模糊检测后的流程控制
|
||||
const blurDetectionControl = reactive({
|
||||
isPaused: false,
|
||||
continuePromise: null as Promise<boolean> | null,
|
||||
continueResolve: null as ((value: boolean) => void) | null,
|
||||
// 继续执行上传流程
|
||||
continueUpload(enhance = false) {
|
||||
if (this.continueResolve) {
|
||||
this.continueResolve(enhance);
|
||||
this.isPaused = false;
|
||||
this.continuePromise = null;
|
||||
this.continueResolve = null;
|
||||
}
|
||||
},
|
||||
// 创建一个新的暂停Promise
|
||||
createPausePromise() {
|
||||
this.isPaused = true;
|
||||
this.continuePromise = new Promise<boolean>((resolve) => {
|
||||
this.continueResolve = resolve;
|
||||
});
|
||||
return this.continuePromise;
|
||||
}
|
||||
});
|
||||
// 模糊检测阈值
|
||||
const thresholdValue = ref<number>(200);
|
||||
|
||||
const storageSelected = ref<any[]>([]);
|
||||
|
||||
const albumSelected = ref<number>();
|
||||
@@ -72,7 +112,7 @@ export const useUploadStore = defineStore(
|
||||
const image = new Image();
|
||||
// 压缩图片配置
|
||||
const options = reactive({
|
||||
maxSizeMB: 0.2,
|
||||
maxSizeMB: 0.4,
|
||||
maxWidthOrHeight: 750,
|
||||
maxIteration: 2,
|
||||
useWebWorker: true,
|
||||
@@ -205,6 +245,48 @@ export const useUploadStore = defineStore(
|
||||
currentProgress += stepIncrement;
|
||||
}
|
||||
|
||||
// 模糊检测
|
||||
if (uploadSetting.blur_detection) {
|
||||
const result = await detectBlurFromFile(file, thresholdValue.value);
|
||||
if (result.isBlurry) {
|
||||
// 显示通知并暂停执行
|
||||
notification.open({
|
||||
message: "检测到图片模糊,是否继续上传?",
|
||||
type: "warning",
|
||||
style: {
|
||||
color: "rgba(96,165,250,.9)",
|
||||
cursor: "pointer",
|
||||
},
|
||||
duration: 3,
|
||||
btn:
|
||||
h('a-button', {
|
||||
type: 'primary',
|
||||
onClick: () => {
|
||||
// 继续上传
|
||||
blurDetectionControl.continueUpload(false);
|
||||
notification.close('blur-notification');
|
||||
}
|
||||
}, '继续上传'),
|
||||
key: 'blur-notification',
|
||||
onClose: () => {
|
||||
// 如果通知被关闭但没有点击按钮,默认继续上传
|
||||
if (blurDetectionControl.isPaused) {
|
||||
blurDetectionControl.continueUpload(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 创建暂停Promise并等待用户操作
|
||||
const shouldEnhance = await blurDetectionControl.createPausePromise();
|
||||
|
||||
// 这里可以添加处理的代码
|
||||
if (shouldEnhance) { /* empty */
|
||||
}
|
||||
}
|
||||
await smoothUpdateProgress(currentProgress + stepIncrement, 500);
|
||||
currentProgress += stepIncrement;
|
||||
}
|
||||
|
||||
// 动漫检测
|
||||
if (uploadSetting.anime_detection) {
|
||||
const prediction = await animePredictImagePro(image);
|
||||
@@ -222,17 +304,11 @@ export const useUploadStore = defineStore(
|
||||
if (uploadSetting.target_detection) {
|
||||
const cocoResults: any = await cocoSsdPredict(image);
|
||||
if (cocoResults.length > 0) {
|
||||
// 取置信度最高的结果
|
||||
// 如果只有一个结果,直接取第一个
|
||||
if (cocoResults.length === 1) {
|
||||
predictResult.topCategory = getCategoryByLabel(cocoResults[0].class);
|
||||
predictResult.tagName = cocoResults[0].class;
|
||||
} else {
|
||||
// 多个结果时,按 score 排序,取置信度最高的结果
|
||||
const sortedResults = cocoResults.sort((a, b) => b.score - a.score);
|
||||
predictResult.topCategory = getCategoryByLabel(sortedResults[0].class);
|
||||
predictResult.tagName = sortedResults[0].class;
|
||||
}
|
||||
// 多个结果时,按 score 排序,取置信度最高的结果
|
||||
const sortedResults = cocoResults.sort((a, b) => b.score - a.score);
|
||||
predictResult.topCategory = getCategoryByLabel(sortedResults[0].class);
|
||||
predictResult.tagName = sortedResults[0].class;
|
||||
|
||||
}
|
||||
await smoothUpdateProgress(currentProgress + stepIncrement, 500);
|
||||
currentProgress += stepIncrement;
|
||||
@@ -260,6 +336,214 @@ export const useUploadStore = defineStore(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传前的预处理
|
||||
* @param file
|
||||
* @param _fileList
|
||||
*/
|
||||
async function beforeUploadWithWebWorker(file: File, _fileList: File[]) {
|
||||
predicting.value = true;
|
||||
clearPredictResult();
|
||||
progressPercent.value = 0; // 初始化进度条
|
||||
progressStatus.value = 'active'; // 开始状态
|
||||
// 压缩图片
|
||||
// const compressedFile = await imageCompression(file, options);
|
||||
|
||||
const imageData: ImageData = await fileToImageData(file);
|
||||
|
||||
predictResult.width = imageData.width;
|
||||
predictResult.height = imageData.height;
|
||||
|
||||
// 更新进度条函数,逐步增加
|
||||
const smoothUpdateProgress = async (targetPercent, duration) => {
|
||||
const increment = (targetPercent - progressPercent.value) / (duration / 50);
|
||||
return new Promise((resolve) => {
|
||||
const interval = setInterval(() => {
|
||||
if (progressPercent.value >= targetPercent) {
|
||||
clearInterval(interval);
|
||||
resolve(false);
|
||||
} else {
|
||||
progressPercent.value = Math.min(progressPercent.value + increment, targetPercent);
|
||||
}
|
||||
}, 50);
|
||||
});
|
||||
};
|
||||
|
||||
try {
|
||||
// 创建Web Worker进行图像分析
|
||||
const worker = new Worker(new URL('@/workers/image-analysis/image-analysis.worker.ts', import.meta.url), {type: 'module'});
|
||||
|
||||
// 计算启用的检测步骤及进度分配
|
||||
const enabledSteps = [
|
||||
uploadSetting.nsfw_detection,
|
||||
uploadSetting.anime_detection,
|
||||
uploadSetting.landscape_detection,
|
||||
uploadSetting.target_detection,
|
||||
uploadSetting.gps_detection,
|
||||
uploadSetting.screenshot_detection,
|
||||
uploadSetting.qrcode_detection,
|
||||
uploadSetting.blur_detection
|
||||
].filter(Boolean).length;
|
||||
|
||||
const stepIncrement = enabledSteps > 0 ? Math.floor(100 / (enabledSteps + 1)) : 100;
|
||||
let currentProgress = 0;
|
||||
|
||||
// 二维码检测
|
||||
if (uploadSetting.qrcode_detection) {
|
||||
await ready();
|
||||
const result = await scan(imageData);
|
||||
if (result.text) {
|
||||
predictResult.hasQrcode = true;
|
||||
}
|
||||
await smoothUpdateProgress(currentProgress + stepIncrement, 500);
|
||||
currentProgress += stepIncrement;
|
||||
}
|
||||
|
||||
// GPS 数据提取
|
||||
if (uploadSetting.gps_detection) {
|
||||
const gpsData = await extractGPSExifData(file);
|
||||
if (gpsData) {
|
||||
predictResult.longitude = gpsData.longitude;
|
||||
predictResult.latitude = gpsData.latitude;
|
||||
}
|
||||
await smoothUpdateProgress(currentProgress + stepIncrement, 500);
|
||||
currentProgress += stepIncrement;
|
||||
}
|
||||
|
||||
// 截图检测
|
||||
if (uploadSetting.screenshot_detection) {
|
||||
predictResult.isScreenshot = await isScreenshot(file);
|
||||
await smoothUpdateProgress(currentProgress + stepIncrement, 500);
|
||||
currentProgress += stepIncrement;
|
||||
}
|
||||
|
||||
// 模糊检测
|
||||
if (uploadSetting.blur_detection) {
|
||||
const result = await detectBlurFromFile(file, thresholdValue.value);
|
||||
if (result.isBlurry) {
|
||||
// 显示通知并暂停执行
|
||||
notification.open({
|
||||
message: "检测到图片模糊,是否继续上传?",
|
||||
type: "warning",
|
||||
style: {
|
||||
color: "rgba(96,165,250,.9)",
|
||||
cursor: "pointer",
|
||||
},
|
||||
duration: 3,
|
||||
btn:
|
||||
h('a-button', {
|
||||
type: 'primary',
|
||||
onClick: () => {
|
||||
// 继续上传
|
||||
blurDetectionControl.continueUpload(false);
|
||||
notification.close('blur-notification');
|
||||
}
|
||||
}, '继续上传'),
|
||||
key: 'blur-notification',
|
||||
onClose: () => {
|
||||
// 如果通知被关闭但没有点击按钮,默认继续上传
|
||||
if (blurDetectionControl.isPaused) {
|
||||
blurDetectionControl.continueUpload(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 创建暂停Promise并等待用户操作
|
||||
const shouldEnhance = await blurDetectionControl.createPausePromise();
|
||||
|
||||
// 这里可以添加处理的代码
|
||||
if (shouldEnhance) { /* empty */
|
||||
}
|
||||
}
|
||||
await smoothUpdateProgress(currentProgress + stepIncrement, 500);
|
||||
currentProgress += stepIncrement;
|
||||
}
|
||||
|
||||
// 使用Web Worker进行图像分析(NSFW、动漫、景观、目标检测)
|
||||
if (uploadSetting.nsfw_detection || uploadSetting.anime_detection ||
|
||||
uploadSetting.landscape_detection || uploadSetting.target_detection) {
|
||||
|
||||
// 创建一个Promise来处理Worker的响应
|
||||
const workerPromise = new Promise<ImageAnalysisResponse>((resolve, reject) => {
|
||||
worker.onmessage = (e) => {
|
||||
resolve(e.data);
|
||||
};
|
||||
|
||||
worker.onerror = (error) => {
|
||||
reject(error);
|
||||
};
|
||||
});
|
||||
|
||||
// 发送数据到Worker
|
||||
worker.postMessage({
|
||||
imageData: imageData.data.buffer,
|
||||
width: imageData.width,
|
||||
height: imageData.height,
|
||||
settings: {
|
||||
nsfw_detection: uploadSetting.nsfw_detection,
|
||||
anime_detection: uploadSetting.anime_detection,
|
||||
landscape_detection: uploadSetting.landscape_detection,
|
||||
target_detection: uploadSetting.target_detection
|
||||
},
|
||||
}, [imageData.data.buffer]);
|
||||
|
||||
// 等待Worker处理结果
|
||||
const analysisResult = await workerPromise;
|
||||
|
||||
// 处理Worker返回的结果
|
||||
if (analysisResult.error) {
|
||||
console.error('Worker处理图像时出错:', analysisResult.error);
|
||||
} else {
|
||||
// NSFW检测结果处理
|
||||
if (uploadSetting.nsfw_detection && analysisResult.isNSFW) {
|
||||
message.error(i18n.global.t('comment.illegalImage'));
|
||||
progressStatus.value = 'exception';
|
||||
fileList.value.pop();
|
||||
predicting.value = false;
|
||||
worker.terminate();
|
||||
return false;
|
||||
}
|
||||
|
||||
// 动漫检测结果处理
|
||||
if (uploadSetting.anime_detection) {
|
||||
predictResult.isAnime = analysisResult.isAnime || false;
|
||||
}
|
||||
|
||||
// 景观识别结果处理
|
||||
if (uploadSetting.landscape_detection && analysisResult.landscape) {
|
||||
predictResult.landscape = analysisResult.landscape as 'building' | 'forest' | 'glacier' | 'mountain' | 'sea' | 'street' | null;
|
||||
}
|
||||
|
||||
// 目标检测结果处理
|
||||
if (uploadSetting.target_detection) {
|
||||
predictResult.tagName = analysisResult.tagName || null;
|
||||
predictResult.topCategory = analysisResult.topCategory || null;
|
||||
}
|
||||
}
|
||||
|
||||
// 终止Worker
|
||||
worker.terminate();
|
||||
|
||||
await smoothUpdateProgress(currentProgress + stepIncrement, 500);
|
||||
currentProgress += stepIncrement;
|
||||
}
|
||||
|
||||
// 确保进度条到100%
|
||||
if (currentProgress < 100) {
|
||||
await smoothUpdateProgress(100, 500);
|
||||
}
|
||||
|
||||
predicting.value = false;
|
||||
return true;
|
||||
|
||||
} catch (error) {
|
||||
console.error('识别过程中发生错误:', error);
|
||||
predicting.value = false;
|
||||
progressPercent.value = 0; // 重置进度条
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 提取 EXIF 数据
|
||||
@@ -311,6 +595,7 @@ export const useUploadStore = defineStore(
|
||||
|
||||
onMounted(async () => {
|
||||
await ready();
|
||||
await initBlurDetect();
|
||||
});
|
||||
|
||||
|
||||
@@ -331,6 +616,8 @@ export const useUploadStore = defineStore(
|
||||
fileList,
|
||||
rejectFile,
|
||||
removeFile,
|
||||
blurDetectionControl,
|
||||
beforeUploadWithWebWorker
|
||||
};
|
||||
},
|
||||
{
|
||||
@@ -338,7 +625,7 @@ export const useUploadStore = defineStore(
|
||||
persistedState: {
|
||||
persist: true,
|
||||
storage: localStorage,
|
||||
key: 'upload',
|
||||
key: 'STORE-UPLOAD',
|
||||
includePaths: ["storageSelected", "albumSelected", "uploadSetting"]
|
||||
}
|
||||
}
|
||||
|
@@ -187,7 +187,6 @@ export const useUpscaleStore = defineStore(
|
||||
wasmModule.value._free(sourcePtr);
|
||||
wasmModule.value._free(targetPtr);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -232,7 +231,7 @@ export const useUpscaleStore = defineStore(
|
||||
persistedState: {
|
||||
persist: false,
|
||||
storage: localForage,
|
||||
key: 'upscale',
|
||||
key: 'STORE-UPSCALE',
|
||||
includePaths: [],
|
||||
}
|
||||
}
|
||||
|
@@ -170,7 +170,7 @@ export const useAuthStore = defineStore(
|
||||
persistedState: {
|
||||
persist: true,
|
||||
storage: localStorage,
|
||||
key: 'user',
|
||||
key: 'STORE-USER',
|
||||
includePaths: ['user', 'token', "clientId"]
|
||||
}
|
||||
}
|
||||
|
132
src/utils/file/image-converter.ts
Normal file
132
src/utils/file/image-converter.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
// image-converter.ts
|
||||
import {ref} from 'vue';
|
||||
|
||||
|
||||
/**
|
||||
* 图片加载状态
|
||||
*/
|
||||
export const imageLoading = ref(false);
|
||||
|
||||
/**
|
||||
* 将File类型的图片转换为ArrayBuffer
|
||||
* @param file 图片文件
|
||||
* @returns Promise 包含图片的宽度、高度和ArrayBuffer数据
|
||||
*/
|
||||
export const fileToArrayBuffer = (file: File): Promise<{ width: number; height: number; buffer: ArrayBuffer }> => {
|
||||
imageLoading.value = true;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
// 检查文件类型
|
||||
if (!file.type.startsWith('image/')) {
|
||||
imageLoading.value = false;
|
||||
reject(new Error('文件类型必须是图片'));
|
||||
return;
|
||||
}
|
||||
|
||||
// 创建FileReader实例读取文件
|
||||
const reader = new FileReader();
|
||||
|
||||
// 读取完成后的处理
|
||||
reader.onload = (event) => {
|
||||
const arrayBuffer = event.target?.result as ArrayBuffer;
|
||||
|
||||
// 创建一个临时的Image对象来获取图片尺寸
|
||||
const img = new Image();
|
||||
const url = URL.createObjectURL(file);
|
||||
|
||||
img.onload = () => {
|
||||
// 获取图片尺寸
|
||||
const width = img.width;
|
||||
const height = img.height;
|
||||
|
||||
// 释放URL对象
|
||||
URL.revokeObjectURL(url);
|
||||
imageLoading.value = false;
|
||||
|
||||
// 返回包含尺寸和数据的对象
|
||||
resolve({
|
||||
width,
|
||||
height,
|
||||
buffer: arrayBuffer
|
||||
});
|
||||
};
|
||||
|
||||
img.onerror = () => {
|
||||
URL.revokeObjectURL(url);
|
||||
imageLoading.value = false;
|
||||
reject(new Error('图片加载失败'));
|
||||
};
|
||||
|
||||
img.src = url;
|
||||
};
|
||||
|
||||
reader.onerror = () => {
|
||||
imageLoading.value = false;
|
||||
reject(new Error('文件读取失败'));
|
||||
};
|
||||
|
||||
// 开始读取文件为ArrayBuffer
|
||||
reader.readAsArrayBuffer(file);
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* 将 File 对象转换为 ImageData
|
||||
* @param {File} file - 用户上传的文件
|
||||
* @returns {Promise<ImageData>} - 返回解析后的 ImageData
|
||||
*/
|
||||
export async function fileToImageData(file: File): Promise<ImageData> {
|
||||
// 1. 验证文件类型
|
||||
if (!file.type.startsWith('image/')) {
|
||||
throw new Error('文件类型必须是图像');
|
||||
}
|
||||
|
||||
// 2. 创建 Image 对象
|
||||
const img = await loadImage(file);
|
||||
|
||||
// 3. 创建 Canvas 并绘制图像
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = img.naturalWidth;
|
||||
canvas.height = img.naturalHeight;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) {
|
||||
throw new Error('无法创建 Canvas 上下文');
|
||||
}
|
||||
|
||||
// 确保 img 是 HTMLImageElement
|
||||
if (!(img instanceof HTMLImageElement)) {
|
||||
throw new Error('加载的图像不是有效的 HTMLImageElement');
|
||||
}
|
||||
|
||||
// 绘制图像到 Canvas
|
||||
ctx.drawImage(img, 0, 0);
|
||||
|
||||
// 4. 提取 ImageData
|
||||
return ctx.getImageData(0, 0, canvas.width, canvas.height);
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载 File 为 Image 对象
|
||||
* @param {File} file - 用户上传的文件
|
||||
* @returns {Promise<HTMLImageElement>} - 返回加载完成的 Image 对象
|
||||
*/
|
||||
function loadImage(file: File): Promise<HTMLImageElement> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
const url = URL.createObjectURL(file);
|
||||
|
||||
img.onload = () => {
|
||||
URL.revokeObjectURL(url); // 释放内存
|
||||
resolve(img);
|
||||
};
|
||||
|
||||
img.onerror = (err) => {
|
||||
URL.revokeObjectURL(url);
|
||||
reject(new Error(`图像加载失败: ${err}`));
|
||||
};
|
||||
|
||||
img.src = url;
|
||||
});
|
||||
}
|
168
src/utils/imageBlurDetect/blurDetect.ts
Normal file
168
src/utils/imageBlurDetect/blurDetect.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
// blurDetect.ts
|
||||
import Module from './blur_detect.js';
|
||||
|
||||
/**
|
||||
* 模糊检测模块 - 使用WebAssembly实现的图像模糊度检测
|
||||
*/
|
||||
|
||||
// 模块初始化状态
|
||||
let isModuleInitialized = false;
|
||||
let moduleInstance: any = null;
|
||||
|
||||
/**
|
||||
* 初始化模糊检测模块
|
||||
* @returns Promise<void>
|
||||
*/
|
||||
export async function initBlurDetect(): Promise<void> {
|
||||
if (isModuleInitialized) return;
|
||||
|
||||
try {
|
||||
moduleInstance = await Module();
|
||||
isModuleInitialized = true;
|
||||
console.log('模糊检测模块初始化成功');
|
||||
} catch (error) {
|
||||
console.error('模糊检测模块初始化失败:', error);
|
||||
throw new Error('模糊检测模块初始化失败');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测图像是否模糊
|
||||
* @param imageData - Canvas ImageData对象
|
||||
* @param threshold - 模糊度阈值,默认为200,值越小越容易被判定为模糊
|
||||
* @returns boolean - true表示图像模糊,false表示图像清晰
|
||||
*/
|
||||
export function isBlur(imageData: ImageData, threshold = 200): boolean {
|
||||
if (!isModuleInitialized || !moduleInstance) {
|
||||
throw new Error('模糊检测模块未初始化,请先调用initBlurDetect()');
|
||||
}
|
||||
|
||||
const ptr = moduleInstance._malloc(imageData.data.length);
|
||||
if (!ptr) throw new Error('内存分配失败');
|
||||
|
||||
try {
|
||||
moduleInstance.HEAPU8.set(imageData.data, ptr);
|
||||
const variance = moduleInstance._laplacian_blur_detect(
|
||||
ptr,
|
||||
imageData.width,
|
||||
imageData.height
|
||||
);
|
||||
|
||||
if (variance < 0) throw new Error('模糊检测失败');
|
||||
return variance < threshold;
|
||||
} finally {
|
||||
moduleInstance._free(ptr);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取图像的模糊度值
|
||||
* @param imageData - Canvas ImageData对象
|
||||
* @returns number - 模糊度值,值越小表示图像越模糊
|
||||
*/
|
||||
export function getBlurValue(imageData: ImageData): number {
|
||||
if (!isModuleInitialized || !moduleInstance) {
|
||||
throw new Error('模糊检测模块未初始化,请先调用initBlurDetect()');
|
||||
}
|
||||
|
||||
const ptr = moduleInstance._malloc(imageData.data.length);
|
||||
if (!ptr) throw new Error('内存分配失败');
|
||||
|
||||
try {
|
||||
moduleInstance.HEAPU8.set(imageData.data, ptr);
|
||||
const variance = moduleInstance._laplacian_blur_detect(
|
||||
ptr,
|
||||
imageData.width,
|
||||
imageData.height
|
||||
);
|
||||
|
||||
if (variance < 0) throw new Error('模糊检测失败');
|
||||
return variance;
|
||||
} finally {
|
||||
moduleInstance._free(ptr);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从Image或Canvas元素创建ImageData
|
||||
* @param source - HTMLImageElement或HTMLCanvasElement
|
||||
* @returns ImageData
|
||||
*/
|
||||
export function createImageDataFromSource(source: HTMLImageElement | HTMLCanvasElement): ImageData {
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
if (!ctx) {
|
||||
throw new Error('无法创建Canvas上下文');
|
||||
}
|
||||
|
||||
canvas.width = source.width || (source as HTMLImageElement).naturalWidth || 300;
|
||||
canvas.height = source.height || (source as HTMLImageElement).naturalHeight || 200;
|
||||
|
||||
ctx.drawImage(source, 0, 0, canvas.width, canvas.height);
|
||||
return ctx.getImageData(0, 0, canvas.width, canvas.height);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从URL加载图像并检测模糊度
|
||||
* @param url - 图像URL
|
||||
* @param threshold - 模糊度阈值
|
||||
* @returns Promise<{isBlurry: boolean, blurValue: number}>
|
||||
*/
|
||||
export async function detectBlurFromUrl(url: string, threshold = 200): Promise<{isBlurry: boolean, blurValue: number}> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.crossOrigin = 'anonymous';
|
||||
|
||||
img.onload = () => {
|
||||
try {
|
||||
const imageData = createImageDataFromSource(img);
|
||||
const blurValue = getBlurValue(imageData);
|
||||
const isBlurry = blurValue < threshold;
|
||||
|
||||
resolve({
|
||||
isBlurry,
|
||||
blurValue
|
||||
});
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
};
|
||||
|
||||
img.onerror = () => {
|
||||
reject(new Error('图像加载失败'));
|
||||
};
|
||||
|
||||
img.src = url;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 从File对象检测模糊度
|
||||
* @param file - 图像文件
|
||||
* @param threshold - 模糊度阈值
|
||||
* @returns Promise<{isBlurry: boolean, blurValue: number}>
|
||||
*/
|
||||
export async function detectBlurFromFile(file: File, threshold = 200): Promise<{isBlurry: boolean, blurValue: number}> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!file.type.startsWith('image/')) {
|
||||
reject(new Error('文件不是图像类型'));
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = new FileReader();
|
||||
|
||||
reader.onload = (event) => {
|
||||
const url = event.target?.result as string;
|
||||
detectBlurFromUrl(url, threshold)
|
||||
.then(resolve)
|
||||
.catch(reject);
|
||||
};
|
||||
|
||||
reader.onerror = () => {
|
||||
reject(new Error('文件读取失败'));
|
||||
};
|
||||
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
76
src/utils/imageBlurDetect/blur_detect.c
Normal file
76
src/utils/imageBlurDetect/blur_detect.c
Normal file
@@ -0,0 +1,76 @@
|
||||
// blur_detect.c
|
||||
// emcc blur_detect.c -O3 -o blur_detect.js -s WASM=1 -s EXPORTED_FUNCTIONS="['_laplacian_blur_detect', '_malloc', '_free']" -s EXPORTED_RUNTIME_METHODS="['HEAPU8', 'setValue']" -s ALLOW_MEMORY_GROWTH=1 -s MODULARIZE=1 -s EXPORT_ES6=1 -s ENVIRONMENT='web'
|
||||
// blur_detect.c
|
||||
#include <stdint.h>
|
||||
#include <math.h>
|
||||
#include <stdlib.h>
|
||||
|
||||
#define RESTRICT __restrict__ // 兼容各编译器的限制指针
|
||||
|
||||
// 内存池优化(线程不安全,单线程环境专用)
|
||||
static uint8_t* gray_buffer = NULL;
|
||||
static size_t buffer_size = 0;
|
||||
|
||||
// 灰度转换优化(循环展开+寄存器重用)
|
||||
static inline void rgb2gray(uint8_t* RESTRICT gray,
|
||||
const uint8_t* RESTRICT rgba,
|
||||
int total_pixels) {
|
||||
for (int i = 0; i < total_pixels; i += 4) {
|
||||
// 一次处理4个像素(16字节输入)
|
||||
const uint8_t* p = rgba + i*4;
|
||||
gray[i] = (9798*p[0] + 19235*p[1] + 3736*p[2]) >> 15;
|
||||
gray[i+1] = (9798*p[4] + 19235*p[5] + 3736*p[6]) >> 15;
|
||||
gray[i+2] = (9798*p[8] + 19235*p[9] + 3736*p[10]) >> 15;
|
||||
gray[i+3] = (9798*p[12] + 19235*p[13] + 3736*p[14]) >> 15;
|
||||
}
|
||||
}
|
||||
|
||||
// 主检测函数(内存访问优化版)
|
||||
double laplacian_blur_detect(const uint8_t* RESTRICT rgba,
|
||||
int width, int height) {
|
||||
const int gray_size = width * height;
|
||||
|
||||
// 内存池管理(避免重复分配)
|
||||
if (gray_size > buffer_size) {
|
||||
free(gray_buffer);
|
||||
gray_buffer = (uint8_t*)malloc(gray_size);
|
||||
if (!gray_buffer) return NAN; // 使用NaN表示错误
|
||||
buffer_size = gray_size;
|
||||
}
|
||||
|
||||
// 灰度转换
|
||||
rgb2gray(gray_buffer, rgba, width * height);
|
||||
|
||||
// 卷积计算参数
|
||||
const int8_t kernel[9] = {0,1,0,1,-4,1,0,1,0};
|
||||
const int stride = width;
|
||||
int64_t variance = 0;
|
||||
const int h_bound = height-1, w_bound = width-1;
|
||||
|
||||
// 内存局部性优化:按行缓存指针
|
||||
for (int y = 1; y < h_bound; ++y) {
|
||||
const uint8_t* row_prev = gray_buffer + (y-1)*stride;
|
||||
const uint8_t* row_curr = row_prev + stride;
|
||||
const uint8_t* row_next = row_curr + stride;
|
||||
|
||||
// 列计算(展开循环减少分支预测)
|
||||
for (int x = 1; x < w_bound; ++x) {
|
||||
// 寄存器缓存邻域像素
|
||||
const uint8_t p0 = row_prev[x-1], p1 = row_prev[x], p2 = row_prev[x+1];
|
||||
const uint8_t p3 = row_curr[x-1], p4 = row_curr[x], p5 = row_curr[x+1];
|
||||
const uint8_t p6 = row_next[x-1], p7 = row_next[x], p8 = row_next[x+1];
|
||||
|
||||
// 展开卷积运算
|
||||
const int sum =
|
||||
p0*kernel[0] + p1*kernel[1] + p2*kernel[2] +
|
||||
p3*kernel[3] + p4*kernel[4] + p5*kernel[5] +
|
||||
p6*kernel[6] + p7*kernel[7] + p8*kernel[8];
|
||||
|
||||
variance += sum * sum;
|
||||
}
|
||||
}
|
||||
|
||||
return variance / (double)((width-2)*(height-2));
|
||||
}
|
||||
|
||||
// emcc blur_detect.c -O3 -o blur_detect.js -s WASM=1 -s EXPORTED_FUNCTIONS="['_laplacian_blur_detect', '_malloc', '_free']" -s EXPORTED_RUNTIME_METHODS="['HEAPU8', 'setValue']" -s ALLOW_MEMORY_GROWTH=1 -s MODULARIZE=1 -s EXPORT_ES6=1 -s ENVIRONMENT='web' -s STRICT=1 -flto
|
18
src/utils/imageBlurDetect/blur_detect.js
Normal file
18
src/utils/imageBlurDetect/blur_detect.js
Normal file
File diff suppressed because one or more lines are too long
BIN
src/utils/imageBlurDetect/blur_detect.wasm
Normal file
BIN
src/utils/imageBlurDetect/blur_detect.wasm
Normal file
Binary file not shown.
@@ -37,29 +37,45 @@ export async function animePredictImagePro(imageElement) {
|
||||
|
||||
// 进行推理
|
||||
const prediction: any = model.predict(imageNormalized);
|
||||
|
||||
|
||||
const predictedClass = tf.argMax(prediction, 1).dataSync()[0];
|
||||
// const predictedClassConfidence = await prediction.dataSync()[predictedClass].toFixed(2);
|
||||
// console.log(`预测结果: ${predictedClassName}(${predictedClassConfidence})`);
|
||||
return ['Anime', 'Furry', 'Neutral'][predictedClass];
|
||||
}
|
||||
|
||||
// export async function animePredictImagePro(width: number, height: number, uint8Array: Uint8Array) {
|
||||
//
|
||||
// const model: any = await loadModel();
|
||||
// // 将图片转换为张量
|
||||
// const tensor = tf.tensor3d(uint8Array, [height, width, 3], 'int32').toFloat();
|
||||
// const imageResized = tf.image.resizeBilinear(tensor, [224, 224]);
|
||||
// const imageReshaped = imageResized.reshape([1, 224, 224, 3]);
|
||||
// const imageNormalized = imageReshaped.div(255);
|
||||
//
|
||||
// // 进行推理
|
||||
// const prediction: any = model.predict(imageNormalized);
|
||||
//
|
||||
//
|
||||
// const predictedClass = tf.argMax(prediction, 1).dataSync()[0];
|
||||
// // const predictedClassConfidence = await prediction.dataSync()[predictedClass].toFixed(2);
|
||||
// // console.log(`预测结果: ${predictedClassName}(${predictedClassConfidence})`);
|
||||
// return ['Anime', 'Furry', 'Neutral'][predictedClass];
|
||||
// }
|
||||
// 接收张量进行推理的函数
|
||||
export async function animePredictTensor(tensor: tf.Tensor3D | tf.Tensor4D) {
|
||||
const model: any = await loadAnimeClassifierProModel();
|
||||
|
||||
// 确保张量是4D的 [batch, height, width, channels]
|
||||
let processedTensor: tf.Tensor4D;
|
||||
if (tensor.rank === 3) {
|
||||
// 如果是3D张量 [height, width, channels],转换为4D
|
||||
processedTensor = tf.expandDims(tensor, 0) as tf.Tensor4D;
|
||||
} else {
|
||||
processedTensor = tensor as tf.Tensor4D;
|
||||
}
|
||||
|
||||
// 调整大小、归一化
|
||||
const imageResized = tf.image.resizeBilinear(processedTensor, [224, 224]);
|
||||
const imageNormalized = imageResized.div(255);
|
||||
|
||||
// 进行推理
|
||||
const prediction: any = model.predict(imageNormalized);
|
||||
|
||||
const predictedClass = tf.argMax(prediction, 1).dataSync()[0];
|
||||
return ['Anime', 'Furry', 'Neutral'][predictedClass];
|
||||
}
|
||||
|
||||
// 接收ImageData进行推理的函数
|
||||
export async function animePredictImageData(imageData: ImageData) {
|
||||
const model: any = await loadAnimeClassifierProModel();
|
||||
// 将ImageData转换为张量
|
||||
const tensor = tf.browser.fromPixels(imageData).toFloat();
|
||||
const imageResized = tf.image.resizeBilinear(tensor, [224, 224]);
|
||||
const imageReshaped = imageResized.reshape([1, 224, 224, 3]);
|
||||
const imageNormalized = imageReshaped.div(255);
|
||||
|
||||
// 进行推理
|
||||
const prediction: any = model.predict(imageNormalized);
|
||||
const predictedClass = tf.argMax(prediction, 1).dataSync()[0];
|
||||
return ['Anime', 'Furry', 'Neutral'][predictedClass];
|
||||
}
|
||||
|
@@ -5,7 +5,7 @@ export async function loadLandscapeRecognitionModel() {
|
||||
const modelName = 'landscape_recognition';
|
||||
const modelUrl = '/tfjs/landscape_recognition/model.json';
|
||||
let model: tf.LayersModel;
|
||||
tf.setBackend('webgl');
|
||||
await tf.setBackend('webgl');
|
||||
try {
|
||||
// 尝试从 IndexedDB 加载模型
|
||||
model = await tf.loadLayersModel(`indexeddb://${modelName}-model`);
|
||||
@@ -34,6 +34,52 @@ export const predictLandscape = async (imgElement) => {
|
||||
return getCategory(results.dataSync().indexOf(results.max().dataSync()[0]));
|
||||
};
|
||||
|
||||
export const predictLandscapeTensor = async (tensor: tf.Tensor3D | tf.Tensor4D) => {
|
||||
if (!tensor) return;
|
||||
const model = await loadLandscapeRecognitionModel();
|
||||
|
||||
// 确保输入张量的尺寸正确
|
||||
let processedTensor = tensor;
|
||||
if (tensor.shape[0] !== 150 || tensor.shape[1] !== 150) {
|
||||
processedTensor = tf.image.resizeBilinear(tensor, [150, 150]);
|
||||
}
|
||||
|
||||
// 确保张量是float32类型
|
||||
if (processedTensor.dtype !== 'float32') {
|
||||
processedTensor = tf.cast(processedTensor, 'float32');
|
||||
}
|
||||
|
||||
// 归一化处理
|
||||
const offset = tf.scalar(127.5);
|
||||
const normalized = processedTensor.sub(offset).div(offset);
|
||||
|
||||
// 确保张量形状正确,添加批次维度
|
||||
let batched = normalized;
|
||||
if (normalized.shape.length === 3) {
|
||||
batched = normalized.reshape([1, 150, 150, 3]);
|
||||
}
|
||||
|
||||
const results: any = model.predict(batched);
|
||||
return getCategory(results.dataSync().indexOf(results.max().dataSync()[0]));
|
||||
};
|
||||
|
||||
// 接收ImageData进行推理的函数
|
||||
export const predictLandscapeImageData = async (imageData: ImageData) => {
|
||||
if (!imageData) return;
|
||||
const model = await loadLandscapeRecognitionModel();
|
||||
|
||||
// 将ImageData转换为张量
|
||||
const img = tf.cast(tf.browser.fromPixels(imageData), 'float32').resizeBilinear([150, 150]);
|
||||
|
||||
// 归一化处理
|
||||
const offset = tf.scalar(127.5);
|
||||
const normalized = img.sub(offset).div(offset);
|
||||
const batched = normalized.reshape([1, 150, 150, 3]);
|
||||
|
||||
const results: any = model.predict(batched);
|
||||
return getCategory(results.dataSync().indexOf(results.max().dataSync()[0]));
|
||||
};
|
||||
|
||||
const getCategory = (index: number) => {
|
||||
switch (index) {
|
||||
case 0:
|
||||
|
@@ -77,10 +77,7 @@ export async function loadCocoSsdModel() {
|
||||
}
|
||||
|
||||
// 加载 COCO SSD 模型的工具函数
|
||||
// 使用提取的加载模型工具函数
|
||||
export async function cocoSsdPredict(image) {
|
||||
// 初始化 TensorFlow.js
|
||||
tf.setBackend('webgl');
|
||||
export async function cocoSsdPredict(image: any) {
|
||||
if (!(await initializeTensorFlow())) {
|
||||
return [];
|
||||
}
|
||||
|
243
src/views/Admin/Auth/Login.vue
Normal file
243
src/views/Admin/Auth/Login.vue
Normal file
@@ -0,0 +1,243 @@
|
||||
<template>
|
||||
<div class="login-container">
|
||||
<div class="main">
|
||||
<div class="main-container">
|
||||
<div class="main-header">
|
||||
<div class="title">
|
||||
<img
|
||||
src="@/assets/svgs/logo-album.svg"
|
||||
class="logo"
|
||||
alt="logo"
|
||||
>
|
||||
<span>系统管理</span>
|
||||
</div>
|
||||
<div class="description">
|
||||
<span>欢迎使用系统管理</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="main-content">
|
||||
<AForm>
|
||||
<div style="padding: 1px; margin: 5px 0">
|
||||
<AFormItem v-bind="formUse.validateInfos.username">
|
||||
<AInput
|
||||
v-model:value="formModel.username"
|
||||
placeholder="请输入账号"
|
||||
size="large"
|
||||
type="text"
|
||||
@pressEnter="doLogin"
|
||||
>
|
||||
<template #prefix>
|
||||
<UserOutlined :style="formStates.username ? { color: '#c0c0c0' } : {}"/>
|
||||
</template>
|
||||
</AInput>
|
||||
</AFormItem>
|
||||
</div>
|
||||
|
||||
<div style="padding: 1px">
|
||||
<AFormItem v-bind="formUse.validateInfos.password">
|
||||
<AInput
|
||||
v-model:value="formModel.password"
|
||||
placeholder="请输入密码"
|
||||
size="large"
|
||||
type="password"
|
||||
@pressEnter="doLogin"
|
||||
>
|
||||
<template #prefix>
|
||||
<LockOutlined :style="formStates.password ? { color: '#c0c0c0' } : {}"/>
|
||||
</template>
|
||||
</AInput>
|
||||
</AFormItem>
|
||||
</div>
|
||||
|
||||
<div style="padding: 0 5px; overflow: hidden">
|
||||
<ACheckbox v-model:checked="formModel.rememberMe">
|
||||
自动登录
|
||||
</ACheckbox>
|
||||
</div>
|
||||
|
||||
<AFormItem style="margin: 30px 0 0">
|
||||
<AButton
|
||||
size="large"
|
||||
type="primary"
|
||||
class="login-button"
|
||||
:loading="loading"
|
||||
:disabled="loading"
|
||||
@click="doLogin"
|
||||
>
|
||||
登录
|
||||
</AButton>
|
||||
</AFormItem>
|
||||
</AForm>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<div class="copyright">
|
||||
Copyright © 2025 Schisandra Cloud Album Admin. All Rights Reserved.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {UserOutlined, LockOutlined} from '@ant-design/icons-vue';
|
||||
// import {notification} from 'ant-design-vue';
|
||||
import Form from 'ant-design-vue/es/form';
|
||||
|
||||
|
||||
defineOptions({name: 'Login'});
|
||||
|
||||
const loading = ref(false);
|
||||
// const router = useRouter();
|
||||
|
||||
const formModel = reactive({
|
||||
username: '',
|
||||
password: '',
|
||||
rememberMe: true,
|
||||
});
|
||||
|
||||
const formRules = reactive({
|
||||
username: [
|
||||
{
|
||||
required: true,
|
||||
message: '请输入用户名',
|
||||
},
|
||||
],
|
||||
password: [
|
||||
{
|
||||
required: true,
|
||||
message: '请输入密码',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const formStates = reactive({
|
||||
username: computed(() => formUse.validateInfos.username.validateStatus !== 'error'),
|
||||
password: computed(() => formUse.validateInfos.password.validateStatus !== 'error'),
|
||||
});
|
||||
|
||||
const formUse = Form.useForm(
|
||||
formModel,
|
||||
formRules,
|
||||
);
|
||||
|
||||
const doLogin = async () => {
|
||||
try {
|
||||
await formUse.validate();
|
||||
|
||||
// const success = (_: any) => {
|
||||
// notification.success({
|
||||
// message: '系统提示',
|
||||
// duration: 0.8,
|
||||
// description: `欢迎回来`,
|
||||
// onClose: () => {
|
||||
// loading.value = false;
|
||||
// router.push({path: '/'});
|
||||
// },
|
||||
// });
|
||||
// };
|
||||
//
|
||||
// const failure = (err: any) => {
|
||||
// if (err.message) {
|
||||
// notification.error({
|
||||
// message: '系统提示',
|
||||
// duration: 0.8,
|
||||
// description: err.message,
|
||||
// onClose: () => {
|
||||
// loading.value = false;
|
||||
// },
|
||||
// });
|
||||
// }
|
||||
// setTimeout(() => {
|
||||
// loading.value = false;
|
||||
// }, 500);
|
||||
// };
|
||||
|
||||
loading.value = true;
|
||||
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.login-container {
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-flow: row nowrap;
|
||||
justify-content: center;
|
||||
background: #f0f2f5 url(@/assets/svgs/background.svg);
|
||||
|
||||
& > .main {
|
||||
width: 420px;
|
||||
height: 100%;
|
||||
flex: 0 0 auto;
|
||||
position: relative;
|
||||
|
||||
& > .main-container {
|
||||
height: 450px;
|
||||
margin: auto 0;
|
||||
overflow: hidden;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 45px;
|
||||
right: 45px;
|
||||
bottom: 90px;
|
||||
|
||||
& > .main-header {
|
||||
width: calc(100% - 64px);
|
||||
height: auto;
|
||||
margin: 0 auto 48px;
|
||||
|
||||
& > .title {
|
||||
font-size: 33px;
|
||||
color: rgba(0, 0, 0, 0.85);
|
||||
text-align: center;
|
||||
font-family: Avenir, Helvetica Neue, Arial, Helvetica, sans-serif;
|
||||
font-weight: 600;
|
||||
|
||||
& > .logo {
|
||||
height: 44px;
|
||||
vertical-align: top;
|
||||
margin-right: 16px;
|
||||
border-style: none;
|
||||
}
|
||||
}
|
||||
|
||||
& > .description {
|
||||
font-size: 15px;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
text-align: center;
|
||||
margin-top: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
& > .main-content {
|
||||
button.login-button {
|
||||
padding: 0 15px;
|
||||
width: calc(100% - 6px);
|
||||
margin: 0 3px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
& > .footer {
|
||||
width: 100%;
|
||||
padding: 0 16px;
|
||||
margin: 48px 0 24px;
|
||||
text-align: center;
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
|
||||
.copyright {
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
35
src/views/Admin/Error/PageError403.vue
Normal file
35
src/views/Admin/Error/PageError403.vue
Normal file
@@ -0,0 +1,35 @@
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<AResult
|
||||
title="403"
|
||||
status="403"
|
||||
subTitle="抱歉,您无权访问此页!"
|
||||
>
|
||||
<template #extra>
|
||||
<AButton
|
||||
type="primary"
|
||||
@click="$router.push({ path: '/index' })"
|
||||
>
|
||||
返回首页
|
||||
</AButton>
|
||||
</template>
|
||||
</AResult>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineOptions({ name: 'PageError403' })
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.page-container {
|
||||
display: flex;
|
||||
flex-flow: column nowrap;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
position: absolute;
|
||||
margin-top: -38px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
35
src/views/Admin/Error/PageError404.vue
Normal file
35
src/views/Admin/Error/PageError404.vue
Normal file
@@ -0,0 +1,35 @@
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<AResult
|
||||
title="404"
|
||||
status="404"
|
||||
subTitle="抱歉,您访问的页面不存在!"
|
||||
>
|
||||
<template #extra>
|
||||
<AButton
|
||||
type="primary"
|
||||
@click="$router.push({ path: '/index' })"
|
||||
>
|
||||
返回首页
|
||||
</AButton>
|
||||
</template>
|
||||
</AResult>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineOptions({ name: 'PageError404' })
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.page-container {
|
||||
display: flex;
|
||||
flex-flow: column nowrap;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
position: absolute;
|
||||
margin-top: -38px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
35
src/views/Admin/Error/PageError500.vue
Normal file
35
src/views/Admin/Error/PageError500.vue
Normal file
@@ -0,0 +1,35 @@
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<AResult
|
||||
title="500"
|
||||
status="500"
|
||||
subTitle="抱歉,服务器反馈异常!"
|
||||
>
|
||||
<template #extra>
|
||||
<AButton
|
||||
type="primary"
|
||||
@click="$router.push({ path: '/index' })"
|
||||
>
|
||||
返回首页
|
||||
</AButton>
|
||||
</template>
|
||||
</AResult>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineOptions({ name: 'PageError500' })
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.page-container {
|
||||
display: flex;
|
||||
flex-flow: column nowrap;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
position: absolute;
|
||||
margin-top: -38px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
172
src/views/Admin/System/Components/SystemHeader.vue
Normal file
172
src/views/Admin/System/Components/SystemHeader.vue
Normal file
@@ -0,0 +1,172 @@
|
||||
<template>
|
||||
<div class="system-header">
|
||||
<div class="system-header-left">
|
||||
<div class="logo">
|
||||
<img src="/public/logo.svg" alt="Logo"/>
|
||||
<h1>管理后台系统</h1>
|
||||
</div>
|
||||
<div class="collapse-btn" @click="toggleCollapse">
|
||||
<MenuFoldOutlined v-if="systemStore.isCollapsed"/>
|
||||
<MenuUnfoldOutlined v-else/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="system-header-right">
|
||||
<div class="search-box">
|
||||
<AInput placeholder="搜索..." allowClear>
|
||||
<template #prefix>
|
||||
<SearchOutlined/>
|
||||
</template>
|
||||
</AInput>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<ATooltip title="消息通知">
|
||||
<ABadge :count="5" :dot="false">
|
||||
<BellOutlined class="action-icon"/>
|
||||
</ABadge>
|
||||
</ATooltip>
|
||||
<ATooltip title="全屏">
|
||||
<ExpandOutlined class="action-icon" @click="toggleFullscreen"/>
|
||||
</ATooltip>
|
||||
<ADropdown>
|
||||
<div class="user-info">
|
||||
<AAvatar :size="32" :src="userAvatar || 'https://joeschmoe.io/api/v1/random'"/>
|
||||
<span class="username">管理员</span>
|
||||
</div>
|
||||
<template #overlay>
|
||||
<AMenu>
|
||||
<AMenuItem key="profile">
|
||||
<UserOutlined/>
|
||||
<span>个人中心</span>
|
||||
</AMenuItem>
|
||||
<AMenuItem key="logout">
|
||||
<LogoutOutlined/>
|
||||
<span>退出登录</span>
|
||||
</AMenuItem>
|
||||
</AMenu>
|
||||
</template>
|
||||
</ADropdown>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {ref} from 'vue';
|
||||
import {
|
||||
MenuFoldOutlined,
|
||||
MenuUnfoldOutlined,
|
||||
SearchOutlined,
|
||||
BellOutlined,
|
||||
ExpandOutlined,
|
||||
UserOutlined,
|
||||
LogoutOutlined
|
||||
} from '@ant-design/icons-vue';
|
||||
import useStore from "@/store";
|
||||
|
||||
const userAvatar = ref('');
|
||||
const systemStore = useStore().system;
|
||||
|
||||
const toggleCollapse = () => {
|
||||
systemStore.isCollapsed = !systemStore.isCollapsed;
|
||||
};
|
||||
|
||||
|
||||
const toggleFullscreen = () => {
|
||||
if (!document.fullscreenElement) {
|
||||
document.documentElement.requestFullscreen();
|
||||
} else {
|
||||
if (document.exitFullscreen) {
|
||||
document.exitFullscreen();
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.system-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
height: 64px;
|
||||
padding: 0 24px;
|
||||
background-color: #fff;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1);
|
||||
z-index: 10;
|
||||
|
||||
.system-header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-right: 24px;
|
||||
|
||||
img {
|
||||
height: 32px;
|
||||
width: 32px;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: rgba(0, 0, 0, 0.85);
|
||||
}
|
||||
}
|
||||
|
||||
.collapse-btn {
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
padding: 0 12px;
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
transition: color 0.3s;
|
||||
|
||||
&:hover {
|
||||
color: #1890ff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.system-header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.search-box {
|
||||
margin-right: 16px;
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.action-icon {
|
||||
font-size: 18px;
|
||||
padding: 0 12px;
|
||||
cursor: pointer;
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
transition: color 0.3s;
|
||||
|
||||
&:hover {
|
||||
color: #1890ff;
|
||||
}
|
||||
}
|
||||
|
||||
.user-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
margin-left: 12px;
|
||||
|
||||
.username {
|
||||
margin-left: 8px;
|
||||
font-size: 14px;
|
||||
color: rgba(0, 0, 0, 0.85);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
132
src/views/Admin/System/Components/SystemSidebar.vue
Normal file
132
src/views/Admin/System/Components/SystemSidebar.vue
Normal file
@@ -0,0 +1,132 @@
|
||||
<template>
|
||||
<div class="system-sidebar">
|
||||
<AMenu
|
||||
:selectedKeys="[selectedKey]"
|
||||
:openKeys="openKeys"
|
||||
mode="inline"
|
||||
:inline-collapsed="systemStore.isCollapsed"
|
||||
@select="handleSelect"
|
||||
@openChange="handleOpenChange"
|
||||
>
|
||||
<AMenuItem key="dashboard">
|
||||
<template #icon>
|
||||
<DashboardOutlined/>
|
||||
</template>
|
||||
<span>仪表盘</span>
|
||||
</AMenuItem>
|
||||
|
||||
<ASubMenu key="user">
|
||||
<template #icon>
|
||||
<UserOutlined/>
|
||||
</template>
|
||||
<template #title>用户管理</template>
|
||||
<AMenuItem key="user">用户列表</AMenuItem>
|
||||
<AMenuItem key="role">角色管理</AMenuItem>
|
||||
<AMenuItem key="permission">权限设置</AMenuItem>
|
||||
</ASubMenu>
|
||||
|
||||
<ASubMenu key="system">
|
||||
<template #icon>
|
||||
<SettingOutlined/>
|
||||
</template>
|
||||
<template #title>系统设置</template>
|
||||
<AMenuItem key="basic">基础设置</AMenuItem>
|
||||
<AMenuItem key="security">安全设置</AMenuItem>
|
||||
<AMenuItem key="log">系统日志</AMenuItem>
|
||||
</ASubMenu>
|
||||
|
||||
<ASubMenu key="statistics">
|
||||
<template #icon>
|
||||
<BarChartOutlined/>
|
||||
</template>
|
||||
<template #title>统计分析</template>
|
||||
<AMenuItem key="visit">访问统计</AMenuItem>
|
||||
<AMenuItem key="analysis">用户分析</AMenuItem>
|
||||
</ASubMenu>
|
||||
<ASubMenu key="storage">
|
||||
<template #icon>
|
||||
<BarChartOutlined/>
|
||||
</template>
|
||||
<template #title>存储管理</template>
|
||||
<AMenuItem key="storage">存储管理</AMenuItem>
|
||||
</ASubMenu>
|
||||
</AMenu>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {ref, watch} from 'vue';
|
||||
import {useRouter, useRoute} from 'vue-router';
|
||||
import {
|
||||
DashboardOutlined,
|
||||
UserOutlined,
|
||||
SettingOutlined,
|
||||
BarChartOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
import useStore from "@/store";
|
||||
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const systemStore = useStore().system;
|
||||
const selectedKey = ref('dashboard');
|
||||
const openKeys = ref<string[]>([]);
|
||||
|
||||
|
||||
// 处理菜单项选择
|
||||
const handleSelect = ({key}: { key: string }) => {
|
||||
selectedKey.value = key;
|
||||
// 这里可以根据key进行路由跳转
|
||||
router.push(`/admin/system/${key}`);
|
||||
};
|
||||
|
||||
// 处理子菜单展开/收起
|
||||
const handleOpenChange = (keys: string[]) => {
|
||||
// 如果菜单是展开的,则允许多个子菜单同时展开
|
||||
if (!systemStore.isCollapsed) {
|
||||
openKeys.value = keys;
|
||||
}
|
||||
};
|
||||
|
||||
// 根据当前路由设置选中的菜单项
|
||||
const setMenuByRoute = () => {
|
||||
const path = route.path;
|
||||
// 从路径中提取菜单key
|
||||
const pathSegments = path.split('/');
|
||||
if (pathSegments.length > 2) {
|
||||
const menuKey = pathSegments[2]; // 假设路径格式为 /system/[menuKey]
|
||||
selectedKey.value = menuKey;
|
||||
|
||||
// 设置打开的子菜单
|
||||
const parentKey = menuKey.split('-')[0];
|
||||
if (parentKey && parentKey !== menuKey) {
|
||||
openKeys.value = [parentKey];
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 初始化时设置菜单
|
||||
setMenuByRoute();
|
||||
|
||||
// 监听路由变化
|
||||
watch(() => route.path, setMenuByRoute);
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.system-sidebar {
|
||||
height: 100%;
|
||||
transition: width 0.3s;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
|
||||
:deep(.ant-menu) {
|
||||
height: 100%;
|
||||
border-right: 0;
|
||||
}
|
||||
|
||||
:deep(.ant-menu-item), :deep(.ant-menu-submenu-title) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
</style>
|
36
src/views/Admin/System/Index.vue
Normal file
36
src/views/Admin/System/Index.vue
Normal file
@@ -0,0 +1,36 @@
|
||||
<template>
|
||||
<div class="system-admin">
|
||||
<SystemHeader/>
|
||||
<div class="system-container">
|
||||
<SystemSidebar/>
|
||||
<div class="system-content">
|
||||
<router-view/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.system-admin {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
background-color: #f0f2f5;
|
||||
|
||||
.system-container {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
|
||||
.system-content {
|
||||
flex: 1;
|
||||
padding: 24px;
|
||||
overflow: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
258
src/views/Admin/System/Pages/BasicSettings.vue
Normal file
258
src/views/Admin/System/Pages/BasicSettings.vue
Normal file
@@ -0,0 +1,258 @@
|
||||
<template>
|
||||
<div class="basic-settings">
|
||||
<a-card title="基础设置" :bordered="false">
|
||||
<a-form
|
||||
:model="settingsForm"
|
||||
:rules="settingsRules"
|
||||
:label-col="{ span: 4 }"
|
||||
:wrapper-col="{ span: 16 }"
|
||||
ref="settingsFormRef"
|
||||
>
|
||||
<a-form-item label="系统名称" name="systemName">
|
||||
<a-input v-model:value="settingsForm.systemName" placeholder="请输入系统名称" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="系统Logo" name="logoUrl">
|
||||
<div class="logo-upload-wrapper">
|
||||
<div class="logo-preview" v-if="settingsForm.logoUrl">
|
||||
<img :src="settingsForm.logoUrl" alt="系统Logo" />
|
||||
<a-button type="link" danger @click="removeLogo">移除</a-button>
|
||||
</div>
|
||||
<a-upload
|
||||
v-if="!settingsForm.logoUrl"
|
||||
name="logo"
|
||||
list-type="picture-card"
|
||||
:show-upload-list="false"
|
||||
:before-upload="beforeLogoUpload"
|
||||
@change="handleLogoChange"
|
||||
>
|
||||
<div>
|
||||
<div style="margin-top: 8px">上传Logo</div>
|
||||
</div>
|
||||
</a-upload>
|
||||
</div>
|
||||
<div class="upload-hint">建议尺寸: 200px * 60px,格式: PNG, JPG, SVG</div>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="系统主题" name="theme">
|
||||
<a-radio-group v-model:value="settingsForm.theme">
|
||||
<a-radio value="light">浅色主题</a-radio>
|
||||
<a-radio value="dark">深色主题</a-radio>
|
||||
<a-radio value="auto">跟随系统</a-radio>
|
||||
</a-radio-group>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="主题色" name="primaryColor">
|
||||
<div class="color-picker-wrapper">
|
||||
<div
|
||||
v-for="color in predefinedColors"
|
||||
:key="color"
|
||||
class="color-block"
|
||||
:style="{ backgroundColor: color }"
|
||||
:class="{ active: settingsForm.primaryColor === color }"
|
||||
@click="settingsForm.primaryColor = color"
|
||||
></div>
|
||||
</div>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="页面布局" name="layout">
|
||||
<a-radio-group v-model:value="settingsForm.layout">
|
||||
<a-radio value="side">侧边菜单</a-radio>
|
||||
<a-radio value="top">顶部菜单</a-radio>
|
||||
<a-radio value="mix">混合菜单</a-radio>
|
||||
</a-radio-group>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="系统语言" name="language">
|
||||
<a-select v-model:value="settingsForm.language" placeholder="请选择系统语言">
|
||||
<a-select-option value="zh-CN">简体中文</a-select-option>
|
||||
<a-select-option value="en-US">English</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="版权信息" name="copyright">
|
||||
<a-input v-model:value="settingsForm.copyright" placeholder="请输入版权信息" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="备案信息" name="icp">
|
||||
<a-input v-model:value="settingsForm.icp" placeholder="请输入备案信息" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item :wrapper-col="{ span: 16, offset: 4 }">
|
||||
<a-button type="primary" @click="handleSaveSettings">保存设置</a-button>
|
||||
<a-button style="margin-left: 10px" @click="resetSettings">重置</a-button>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue';
|
||||
import { message } from 'ant-design-vue';
|
||||
|
||||
// 预定义的主题色
|
||||
const predefinedColors = [
|
||||
'#1890ff', // 蓝色
|
||||
'#52c41a', // 绿色
|
||||
'#faad14', // 黄色
|
||||
'#f5222d', // 红色
|
||||
'#722ed1', // 紫色
|
||||
'#13c2c2', // 青色
|
||||
'#eb2f96', // 粉色
|
||||
'#fadb14', // 亮黄色
|
||||
];
|
||||
|
||||
// 表单引用
|
||||
const settingsFormRef = ref();
|
||||
|
||||
// 表单数据
|
||||
const settingsForm = reactive({
|
||||
systemName: '云相册管理系统',
|
||||
logoUrl: '',
|
||||
theme: 'light',
|
||||
primaryColor: '#1890ff',
|
||||
layout: 'side',
|
||||
language: 'zh-CN',
|
||||
copyright: '© 2023 云相册管理系统',
|
||||
icp: '粤ICP备XXXXXXXX号',
|
||||
});
|
||||
|
||||
// 表单验证规则
|
||||
const settingsRules = {
|
||||
systemName: [
|
||||
{ required: true, message: '请输入系统名称', trigger: 'blur' },
|
||||
{ max: 50, message: '系统名称不能超过50个字符', trigger: 'blur' },
|
||||
],
|
||||
copyright: [
|
||||
{ max: 100, message: '版权信息不能超过100个字符', trigger: 'blur' },
|
||||
],
|
||||
icp: [
|
||||
{ max: 50, message: '备案信息不能超过50个字符', trigger: 'blur' },
|
||||
],
|
||||
};
|
||||
|
||||
// 加载状态
|
||||
const loading = ref(false);
|
||||
|
||||
// 上传Logo前的校验
|
||||
const beforeLogoUpload = (file: File) => {
|
||||
const isJpgOrPngOrSvg = file.type === 'image/jpeg' || file.type === 'image/png' || file.type === 'image/svg+xml';
|
||||
if (!isJpgOrPngOrSvg) {
|
||||
message.error('只能上传JPG/PNG/SVG格式的图片!');
|
||||
}
|
||||
const isLt2M = file.size / 1024 / 1024 < 2;
|
||||
if (!isLt2M) {
|
||||
message.error('图片大小不能超过2MB!');
|
||||
}
|
||||
return isJpgOrPngOrSvg && isLt2M;
|
||||
};
|
||||
|
||||
// 处理Logo上传变化
|
||||
const handleLogoChange = (info: any) => {
|
||||
if (info.file.status === 'uploading') {
|
||||
loading.value = true;
|
||||
return;
|
||||
}
|
||||
if (info.file.status === 'done') {
|
||||
// 这里应该是实际的API响应处理
|
||||
// 模拟上传成功后获取URL
|
||||
getBase64(info.file.originFileObj, (url: string) => {
|
||||
loading.value = false;
|
||||
settingsForm.logoUrl = url;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 移除Logo
|
||||
const removeLogo = () => {
|
||||
settingsForm.logoUrl = '';
|
||||
};
|
||||
|
||||
// 将文件转换为Base64
|
||||
const getBase64 = (img: File, callback: Function) => {
|
||||
const reader = new FileReader();
|
||||
reader.addEventListener('load', () => callback(reader.result));
|
||||
reader.readAsDataURL(img);
|
||||
};
|
||||
|
||||
// 保存设置
|
||||
const handleSaveSettings = () => {
|
||||
settingsFormRef.value.validate().then(() => {
|
||||
loading.value = true;
|
||||
// 这里应该是实际的API调用
|
||||
setTimeout(() => {
|
||||
message.success('设置保存成功');
|
||||
loading.value = false;
|
||||
}, 500);
|
||||
}).catch(() => {
|
||||
// 表单验证失败
|
||||
});
|
||||
};
|
||||
|
||||
// 重置设置
|
||||
const resetSettings = () => {
|
||||
settingsFormRef.value.resetFields();
|
||||
};
|
||||
|
||||
// 初始化
|
||||
onMounted(() => {
|
||||
// 这里应该是获取当前系统设置的API调用
|
||||
loading.value = true;
|
||||
setTimeout(() => {
|
||||
// 模拟API响应,实际应用中应该使用真实数据
|
||||
loading.value = false;
|
||||
}, 500);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.basic-settings {
|
||||
.logo-upload-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.logo-preview {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
|
||||
img {
|
||||
max-width: 200px;
|
||||
max-height: 60px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.upload-hint {
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
font-size: 12px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.color-picker-wrapper {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
|
||||
.color-block {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
border: 2px solid transparent;
|
||||
|
||||
&.active {
|
||||
border-color: #000;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
343
src/views/Admin/System/Pages/Dashboard.vue
Normal file
343
src/views/Admin/System/Pages/Dashboard.vue
Normal file
@@ -0,0 +1,343 @@
|
||||
<template>
|
||||
<div class="dashboard-container">
|
||||
<div class="dashboard-header">
|
||||
<h2>系统仪表盘</h2>
|
||||
<div class="dashboard-header-right">
|
||||
<ARadioGroup v-model:value="timeRange" button-style="solid">
|
||||
<ARadioButton value="today">今日</ARadioButton>
|
||||
<ARadioButton value="week">本周</ARadioButton>
|
||||
<ARadioButton value="month">本月</ARadioButton>
|
||||
<ARadioButton value="year">全年</ARadioButton>
|
||||
</ARadioGroup>
|
||||
<AButton type="primary" @click="refreshData">
|
||||
<template #icon>
|
||||
<ReloadOutlined/>
|
||||
</template>
|
||||
刷新
|
||||
</AButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-cards">
|
||||
<ARow :gutter="[16, 16]">
|
||||
<ACol :xs="24" :sm="12" :md="12" :lg="6">
|
||||
<ACard>
|
||||
<template #title>
|
||||
<div class="card-title">
|
||||
<TeamOutlined class="card-icon user"/>
|
||||
<span>用户总数</span>
|
||||
</div>
|
||||
</template>
|
||||
<div class="card-content">
|
||||
<div class="card-value">{{ formatNumber(statistics.userCount) }}</div>
|
||||
<div class="card-footer">
|
||||
<span class="trend up">
|
||||
<ArrowUpOutlined/> {{ statistics.userIncrease }}%
|
||||
</span>
|
||||
<span class="period">较上期</span>
|
||||
</div>
|
||||
</div>
|
||||
</ACard>
|
||||
</ACol>
|
||||
|
||||
<ACol :xs="24" :sm="12" :md="12" :lg="6">
|
||||
<ACard>
|
||||
<template #title>
|
||||
<div class="card-title">
|
||||
<FileOutlined class="card-icon content"/>
|
||||
<span>内容数量</span>
|
||||
</div>
|
||||
</template>
|
||||
<div class="card-content">
|
||||
<div class="card-value">{{ formatNumber(statistics.contentCount) }}</div>
|
||||
<div class="card-footer">
|
||||
<span class="trend up">
|
||||
<ArrowUpOutlined/> {{ statistics.contentIncrease }}%
|
||||
</span>
|
||||
<span class="period">较上期</span>
|
||||
</div>
|
||||
</div>
|
||||
</ACard>
|
||||
</ACol>
|
||||
|
||||
<ACol :xs="24" :sm="12" :md="12" :lg="6">
|
||||
<ACard>
|
||||
<template #title>
|
||||
<div class="card-title">
|
||||
<EyeOutlined class="card-icon visit"/>
|
||||
<span>访问量</span>
|
||||
</div>
|
||||
</template>
|
||||
<div class="card-content">
|
||||
<div class="card-value">{{ formatNumber(statistics.visitCount) }}</div>
|
||||
<div class="card-footer">
|
||||
<span class="trend down">
|
||||
<ArrowDownOutlined/> {{ statistics.visitDecrease }}%
|
||||
</span>
|
||||
<span class="period">较上期</span>
|
||||
</div>
|
||||
</div>
|
||||
</ACard>
|
||||
</ACol>
|
||||
|
||||
<ACol :xs="24" :sm="12" :md="12" :lg="6">
|
||||
<ACard>
|
||||
<template #title>
|
||||
<div class="card-title">
|
||||
<LikeOutlined class="card-icon like"/>
|
||||
<span>好评率</span>
|
||||
</div>
|
||||
</template>
|
||||
<div class="card-content">
|
||||
<div class="card-value">{{ statistics.likeRate }}%</div>
|
||||
<div class="card-footer">
|
||||
<span class="trend up">
|
||||
<ArrowUpOutlined/> {{ statistics.likeIncrease }}%
|
||||
</span>
|
||||
<span class="period">较上期</span>
|
||||
</div>
|
||||
</div>
|
||||
</ACard>
|
||||
</ACol>
|
||||
</ARow>
|
||||
</div>
|
||||
|
||||
<div class="chart-section">
|
||||
<ARow :gutter="[16, 16]">
|
||||
<ACol :span="16">
|
||||
<ACard title="访问量趋势" :bordered="false">
|
||||
<div class="chart-container" style="height: 350px">
|
||||
<!-- 这里可以集成图表库,如ECharts或AntV -->
|
||||
<div class="chart-placeholder">访问量趋势图表</div>
|
||||
</div>
|
||||
</ACard>
|
||||
</ACol>
|
||||
<ACol :span="8">
|
||||
<ACard title="用户分布" :bordered="false">
|
||||
<div class="chart-container" style="height: 350px">
|
||||
<!-- 这里可以集成图表库,如ECharts或AntV -->
|
||||
<div class="chart-placeholder">用户分布图表</div>
|
||||
</div>
|
||||
</ACard>
|
||||
</ACol>
|
||||
</ARow>
|
||||
</div>
|
||||
|
||||
<div class="table-section">
|
||||
<ACard title="最近活动" :bordered="false">
|
||||
<ATable :columns="columns" :data-source="activities" :pagination="{ pageSize: 5 }"/>
|
||||
</ACard>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {ref, reactive, onMounted} from 'vue';
|
||||
import {
|
||||
TeamOutlined,
|
||||
FileOutlined,
|
||||
EyeOutlined,
|
||||
LikeOutlined,
|
||||
ArrowUpOutlined,
|
||||
ArrowDownOutlined, ReloadOutlined
|
||||
} from '@ant-design/icons-vue';
|
||||
|
||||
// 时间范围选择
|
||||
const timeRange = ref('today');
|
||||
|
||||
// 模拟统计数据
|
||||
const statistics = reactive({
|
||||
userCount: 8846,
|
||||
userIncrease: 12.5,
|
||||
contentCount: 2356,
|
||||
contentIncrease: 8.2,
|
||||
visitCount: 132567,
|
||||
visitDecrease: 2.8,
|
||||
likeRate: 96.8,
|
||||
likeIncrease: 1.2
|
||||
});
|
||||
|
||||
// 格式化数字
|
||||
const formatNumber = (num: number) => {
|
||||
return num >= 10000 ? (num / 10000).toFixed(1) + '万' : num.toString();
|
||||
};
|
||||
|
||||
// 表格列定义
|
||||
const columns = [
|
||||
{
|
||||
title: '用户',
|
||||
dataIndex: 'user',
|
||||
key: 'user',
|
||||
},
|
||||
{
|
||||
title: '活动类型',
|
||||
dataIndex: 'type',
|
||||
key: 'type',
|
||||
},
|
||||
{
|
||||
title: '详情',
|
||||
dataIndex: 'detail',
|
||||
key: 'detail',
|
||||
},
|
||||
{
|
||||
title: '时间',
|
||||
dataIndex: 'time',
|
||||
key: 'time',
|
||||
},
|
||||
];
|
||||
|
||||
// 模拟活动数据
|
||||
const activities = [
|
||||
{
|
||||
key: '1',
|
||||
user: '张三',
|
||||
type: '登录',
|
||||
detail: '用户登录系统',
|
||||
time: '2023-05-20 08:30:00',
|
||||
},
|
||||
{
|
||||
key: '2',
|
||||
user: '李四',
|
||||
type: '发布',
|
||||
detail: '发布了新文章《系统使用指南》',
|
||||
time: '2023-05-20 09:15:32',
|
||||
},
|
||||
{
|
||||
key: '3',
|
||||
user: '王五',
|
||||
type: '评论',
|
||||
detail: '评论了文章《产品更新说明》',
|
||||
time: '2023-05-20 10:22:15',
|
||||
},
|
||||
{
|
||||
key: '4',
|
||||
user: '赵六',
|
||||
type: '上传',
|
||||
detail: '上传了5张新图片',
|
||||
time: '2023-05-20 11:45:03',
|
||||
},
|
||||
{
|
||||
key: '5',
|
||||
user: '管理员',
|
||||
type: '系统',
|
||||
detail: '系统自动备份完成',
|
||||
time: '2023-05-20 12:00:00',
|
||||
},
|
||||
];
|
||||
|
||||
// 刷新数据
|
||||
const refreshData = () => {
|
||||
// 这里可以根据timeRange的值请求不同时间范围的数据
|
||||
console.log('刷新数据,时间范围:', timeRange.value);
|
||||
// 模拟数据加载
|
||||
// 实际应用中应该调用API获取数据
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
refreshData();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.dashboard-container {
|
||||
.dashboard-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.dashboard-header-right {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.stat-cards {
|
||||
margin-bottom: 24px;
|
||||
|
||||
.card-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.card-icon {
|
||||
font-size: 20px;
|
||||
margin-right: 8px;
|
||||
|
||||
&.user {
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
&.content {
|
||||
color: #52c41a;
|
||||
}
|
||||
|
||||
&.visit {
|
||||
color: #722ed1;
|
||||
}
|
||||
|
||||
&.like {
|
||||
color: #fa8c16;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.card-content {
|
||||
.card-value {
|
||||
font-size: 28px;
|
||||
font-weight: bold;
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.card-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.trend {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-right: 8px;
|
||||
|
||||
&.up {
|
||||
color: #52c41a;
|
||||
}
|
||||
|
||||
&.down {
|
||||
color: #f5222d;
|
||||
}
|
||||
}
|
||||
|
||||
.period {
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.chart-section {
|
||||
margin-bottom: 24px;
|
||||
|
||||
.chart-container {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
.chart-placeholder {
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.table-section {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
}
|
||||
</style>
|
380
src/views/Admin/System/Pages/PermissionSetting.vue
Normal file
380
src/views/Admin/System/Pages/PermissionSetting.vue
Normal file
@@ -0,0 +1,380 @@
|
||||
<template>
|
||||
<div class="permission-list-container">
|
||||
<div class="permission-list-header">
|
||||
<h2>权限列表</h2>
|
||||
<div class="permission-list-header-right">
|
||||
<AButton type="primary" @click="handleAddPermission">
|
||||
<template #icon>
|
||||
<PlusOutlined/>
|
||||
</template>
|
||||
新增权限
|
||||
</AButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ACard>
|
||||
<div class="table-search-wrapper">
|
||||
<AForm layout="inline" :model="searchForm">
|
||||
<AFormItem label="权限名称">
|
||||
<AInput v-model:value="searchForm.name" placeholder="请输入权限名称" allowClear/>
|
||||
</AFormItem>
|
||||
<AFormItem label="权限代码">
|
||||
<AInput v-model:value="searchForm.code" placeholder="请输入权限代码" allowClear/>
|
||||
</AFormItem>
|
||||
<AFormItem label="状态">
|
||||
<ASelect v-model:value="searchForm.status" placeholder="请选择状态" style="width: 120px" allowClear>
|
||||
<ASelectOption value="active">启用</ASelectOption>
|
||||
<ASelectOption value="inactive">禁用</ASelectOption>
|
||||
</ASelect>
|
||||
</AFormItem>
|
||||
<AFormItem>
|
||||
<AButton type="primary" @click="handleSearch">
|
||||
<template #icon>
|
||||
<SearchOutlined/>
|
||||
</template>
|
||||
搜索
|
||||
</AButton>
|
||||
<AButton style="margin-left: 8px" @click="resetSearch">
|
||||
<template #icon>
|
||||
<ReloadOutlined/>
|
||||
</template>
|
||||
重置
|
||||
</AButton>
|
||||
</AFormItem>
|
||||
</AForm>
|
||||
</div>
|
||||
|
||||
<ATable
|
||||
:columns="columns"
|
||||
:data-source="permissionList"
|
||||
:loading="loading"
|
||||
:pagination="pagination"
|
||||
@change="handleTableChange"
|
||||
rowKey="id"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'status'">
|
||||
<ATag :color="record.status === 'active' ? 'green' : 'red'">
|
||||
{{ record.statusText }}
|
||||
</ATag>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'action'">
|
||||
<ASpace>
|
||||
<AButton type="link" size="small" @click="handleEditPermission(record)">编辑</AButton>
|
||||
<APopconfirm
|
||||
title="确定要删除此权限吗?"
|
||||
ok-text="确定"
|
||||
cancel-text="取消"
|
||||
@confirm="handleDeletePermission(record)"
|
||||
>
|
||||
<AButton type="link" danger size="small">删除</AButton>
|
||||
</APopconfirm>
|
||||
</ASpace>
|
||||
</template>
|
||||
</template>
|
||||
</ATable>
|
||||
</ACard>
|
||||
|
||||
<!-- 权限编辑对话框 -->
|
||||
<AModal
|
||||
v-model:visible="permissionModalVisible"
|
||||
:title="modalTitle"
|
||||
@ok="handlePermissionModalOk"
|
||||
@cancel="handlePermissionModalCancel"
|
||||
:confirmLoading="modalLoading"
|
||||
>
|
||||
<AForm :model="permissionForm" :rules="permissionFormRules" ref="permissionFormRef" :label-col="{ span: 6 }"
|
||||
:wrapper-col="{ span: 16 }">
|
||||
<AFormItem label="权限名称" name="name">
|
||||
<AInput v-model:value="permissionForm.name" placeholder="请输入权限名称"/>
|
||||
</AFormItem>
|
||||
<AFormItem label="权限代码" name="code">
|
||||
<AInput v-model:value="permissionForm.code" placeholder="请输入权限代码"/>
|
||||
</AFormItem>
|
||||
<AFormItem label="权限描述" name="description">
|
||||
<ATextarea v-model:value="permissionForm.description" placeholder="请输入权限描述" :rows="4"/>
|
||||
</AFormItem>
|
||||
<AFormItem label="状态" name="status">
|
||||
<ASelect v-model:value="permissionForm.status" placeholder="请选择状态">
|
||||
<ASelectOption value="active">启用</ASelectOption>
|
||||
<ASelectOption value="inactive">禁用</ASelectOption>
|
||||
</ASelect>
|
||||
</AFormItem>
|
||||
</AForm>
|
||||
</AModal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {ref, reactive, onMounted} from 'vue';
|
||||
import {message} from 'ant-design-vue';
|
||||
import {
|
||||
PlusOutlined,
|
||||
SearchOutlined,
|
||||
ReloadOutlined
|
||||
} from '@ant-design/icons-vue';
|
||||
|
||||
// 搜索表单
|
||||
const searchForm = reactive({
|
||||
name: '',
|
||||
code: '',
|
||||
status: undefined
|
||||
});
|
||||
|
||||
// 表格加载状态
|
||||
const loading = ref(false);
|
||||
|
||||
// 表格分页配置
|
||||
const pagination = reactive({
|
||||
current: 1,
|
||||
pageSize: 10,
|
||||
total: 0,
|
||||
showSizeChanger: true,
|
||||
showQuickJumper: true,
|
||||
showTotal: (total: number) => `共 ${total} 条记录`
|
||||
});
|
||||
|
||||
// 表格列定义
|
||||
const columns = [
|
||||
{
|
||||
title: 'ID',
|
||||
dataIndex: 'id',
|
||||
key: 'id',
|
||||
width: 80
|
||||
},
|
||||
{
|
||||
title: '权限名称',
|
||||
dataIndex: 'name',
|
||||
key: 'name'
|
||||
},
|
||||
{
|
||||
title: '权限代码',
|
||||
dataIndex: 'code',
|
||||
key: 'code'
|
||||
},
|
||||
{
|
||||
title: '权限描述',
|
||||
dataIndex: 'description',
|
||||
key: 'description',
|
||||
ellipsis: true
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'statusText',
|
||||
key: 'status'
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
dataIndex: 'createTime',
|
||||
key: 'createTime',
|
||||
sorter: true
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 150
|
||||
}
|
||||
];
|
||||
|
||||
// 模拟权限数据
|
||||
const permissionList = ref<any[]>([
|
||||
{
|
||||
id: 1,
|
||||
name: '用户管理',
|
||||
code: 'user:manage',
|
||||
description: '用户的增删改查权限',
|
||||
status: 'active',
|
||||
statusText: '启用',
|
||||
createTime: '2023-01-01 12:00:00'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: '角色管理',
|
||||
code: 'role:manage',
|
||||
description: '角色的增删改查权限',
|
||||
status: 'active',
|
||||
statusText: '启用',
|
||||
createTime: '2023-01-05 10:20:00'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: '权限管理',
|
||||
code: 'permission:manage',
|
||||
description: '权限的增删改查权限',
|
||||
status: 'active',
|
||||
statusText: '启用',
|
||||
createTime: '2023-02-15 09:30:00'
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: '系统设置',
|
||||
code: 'system:setting',
|
||||
description: '系统参数设置权限',
|
||||
status: 'inactive',
|
||||
statusText: '禁用',
|
||||
createTime: '2023-03-20 14:50:00'
|
||||
}
|
||||
]);
|
||||
|
||||
// 权限表单相关
|
||||
const permissionModalVisible = ref(false);
|
||||
const modalTitle = ref('新增权限');
|
||||
const modalLoading = ref(false);
|
||||
const permissionFormRef = ref();
|
||||
const permissionForm = reactive({
|
||||
id: undefined,
|
||||
name: '',
|
||||
code: '',
|
||||
description: '',
|
||||
status: 'active'
|
||||
});
|
||||
|
||||
// 表单验证规则
|
||||
const permissionFormRules = {
|
||||
name: [
|
||||
{required: true, message: '请输入权限名称', trigger: 'blur'},
|
||||
{min: 2, max: 50, message: '权限名称长度应为2-50个字符', trigger: 'blur'}
|
||||
],
|
||||
code: [
|
||||
{required: true, message: '请输入权限代码', trigger: 'blur'},
|
||||
{pattern: /^[a-z]+:[a-z]+$/, message: '权限代码格式应为xxx:xxx', trigger: 'blur'}
|
||||
],
|
||||
description: [
|
||||
{max: 200, message: '描述最多200个字符', trigger: 'blur'}
|
||||
],
|
||||
status: [
|
||||
{required: true, message: '请选择状态', trigger: 'change'}
|
||||
]
|
||||
};
|
||||
|
||||
// 搜索处理
|
||||
const handleSearch = () => {
|
||||
pagination.current = 1;
|
||||
fetchPermissionList();
|
||||
};
|
||||
|
||||
// 重置搜索
|
||||
const resetSearch = () => {
|
||||
searchForm.name = '';
|
||||
searchForm.code = '';
|
||||
searchForm.status = undefined;
|
||||
handleSearch();
|
||||
};
|
||||
|
||||
// 表格变化处理(分页、排序、筛选)
|
||||
const handleTableChange = (pag: any, _filters: any, _sorter: any) => {
|
||||
pagination.current = pag.current;
|
||||
pagination.pageSize = pag.pageSize;
|
||||
fetchPermissionList();
|
||||
};
|
||||
|
||||
// 获取权限列表数据
|
||||
const fetchPermissionList = () => {
|
||||
loading.value = true;
|
||||
// 这里应该是实际的API调用
|
||||
setTimeout(() => {
|
||||
// 模拟API响应
|
||||
pagination.total = permissionList.value.length;
|
||||
loading.value = false;
|
||||
}, 500);
|
||||
};
|
||||
|
||||
// 新增权限
|
||||
const handleAddPermission = () => {
|
||||
modalTitle.value = '新增权限';
|
||||
permissionForm.id = undefined;
|
||||
permissionForm.name = '';
|
||||
permissionForm.code = '';
|
||||
permissionForm.description = '';
|
||||
permissionForm.status = 'active';
|
||||
permissionModalVisible.value = true;
|
||||
};
|
||||
|
||||
// 编辑权限
|
||||
const handleEditPermission = (record: any) => {
|
||||
modalTitle.value = '编辑权限';
|
||||
permissionForm.id = record.id;
|
||||
permissionForm.name = record.name;
|
||||
permissionForm.code = record.code;
|
||||
permissionForm.description = record.description;
|
||||
permissionForm.status = record.status;
|
||||
permissionModalVisible.value = true;
|
||||
};
|
||||
|
||||
// 删除权限
|
||||
const handleDeletePermission = (record: any) => {
|
||||
message.success(`删除权限:${record.name}`);
|
||||
// 实际应用中应该调用删除API
|
||||
permissionList.value = permissionList.value.filter(item => item.id !== record.id);
|
||||
};
|
||||
|
||||
// 权限表单提交
|
||||
const handlePermissionModalOk = () => {
|
||||
permissionFormRef.value.validate().then(() => {
|
||||
modalLoading.value = true;
|
||||
// 这里应该是实际的API调用
|
||||
setTimeout(() => {
|
||||
if (permissionForm.id) {
|
||||
// 编辑现有权限
|
||||
const index = permissionList.value.findIndex(item => item.id === permissionForm.id);
|
||||
if (index !== -1) {
|
||||
const statusText = permissionForm.status === 'active' ? '启用' : '禁用';
|
||||
permissionList.value[index] = {
|
||||
...permissionList.value[index],
|
||||
...permissionForm,
|
||||
statusText
|
||||
};
|
||||
message.success('权限编辑成功');
|
||||
}
|
||||
} else {
|
||||
// 新增权限
|
||||
const newId: any = Math.max(...permissionList.value.map(item => item.id)) + 1;
|
||||
const statusText = permissionForm.status === 'active' ? '启用' : '禁用';
|
||||
const now = new Date().toLocaleString();
|
||||
permissionList.value.push({
|
||||
id: newId,
|
||||
...permissionForm,
|
||||
statusText,
|
||||
createTime: now
|
||||
});
|
||||
message.success('权限添加成功');
|
||||
}
|
||||
permissionModalVisible.value = false;
|
||||
modalLoading.value = false;
|
||||
}, 500);
|
||||
}).catch(() => {
|
||||
// 表单验证失败
|
||||
});
|
||||
};
|
||||
|
||||
// 取消权限表单
|
||||
const handlePermissionModalCancel = () => {
|
||||
permissionModalVisible.value = false;
|
||||
};
|
||||
|
||||
// 初始化
|
||||
onMounted(() => {
|
||||
fetchPermissionList();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.permission-list-container {
|
||||
.permission-list-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.table-search-wrapper {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
}
|
||||
</style>
|
427
src/views/Admin/System/Pages/RoleManagement.vue
Normal file
427
src/views/Admin/System/Pages/RoleManagement.vue
Normal file
@@ -0,0 +1,427 @@
|
||||
<template>
|
||||
<div class="role-management">
|
||||
<a-card title="角色管理" :bordered="false">
|
||||
<template #extra>
|
||||
<a-button type="primary" @click="handleAddRole">新增角色</a-button>
|
||||
</template>
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:data-source="roleList"
|
||||
:loading="loading"
|
||||
:pagination="pagination"
|
||||
@change="handleTableChange"
|
||||
rowKey="id"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'status'">
|
||||
<a-tag :color="record.status === 'active' ? 'green' : 'red'">
|
||||
{{ record.statusText }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template v-if="column.key === 'action'">
|
||||
<a-space>
|
||||
<a-button type="link" @click="handleEditRole(record)">编辑</a-button>
|
||||
<a-button type="link" @click="handleSetPermissions(record)">权限设置</a-button>
|
||||
<a-popconfirm
|
||||
title="确定要删除此角色吗?"
|
||||
ok-text="确定"
|
||||
cancel-text="取消"
|
||||
@confirm="handleDeleteRole(record)"
|
||||
>
|
||||
<a-button type="link" danger>删除</a-button>
|
||||
</a-popconfirm>
|
||||
</a-space>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
|
||||
<!-- 角色表单对话框 -->
|
||||
<a-modal
|
||||
v-model:visible="roleModalVisible"
|
||||
:title="modalTitle"
|
||||
:confirm-loading="modalLoading"
|
||||
@ok="handleRoleModalOk"
|
||||
@cancel="handleRoleModalCancel"
|
||||
>
|
||||
<a-form
|
||||
ref="roleFormRef"
|
||||
:model="roleForm"
|
||||
:rules="roleRules"
|
||||
:label-col="{ span: 4 }"
|
||||
:wrapper-col="{ span: 20 }"
|
||||
>
|
||||
<a-form-item label="角色名称" name="name">
|
||||
<a-input v-model:value="roleForm.name" placeholder="请输入角色名称" />
|
||||
</a-form-item>
|
||||
<a-form-item label="角色编码" name="code">
|
||||
<a-input v-model:value="roleForm.code" placeholder="请输入角色编码" />
|
||||
</a-form-item>
|
||||
<a-form-item label="角色描述" name="description">
|
||||
<a-textarea v-model:value="roleForm.description" placeholder="请输入角色描述" />
|
||||
</a-form-item>
|
||||
<a-form-item label="状态" name="status">
|
||||
<a-radio-group v-model:value="roleForm.status">
|
||||
<a-radio value="active">启用</a-radio>
|
||||
<a-radio value="inactive">禁用</a-radio>
|
||||
</a-radio-group>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
|
||||
<!-- 权限设置对话框 -->
|
||||
<a-modal
|
||||
v-model:visible="permissionModalVisible"
|
||||
title="权限设置"
|
||||
:confirm-loading="permissionModalLoading"
|
||||
@ok="handlePermissionModalOk"
|
||||
@cancel="handlePermissionModalCancel"
|
||||
width="800px"
|
||||
>
|
||||
<template v-if="currentRole">
|
||||
<p>为角色 <strong>{{ currentRole.name }}</strong> 设置权限:</p>
|
||||
<a-table
|
||||
:columns="permissionColumns"
|
||||
:data-source="permissionList"
|
||||
:row-selection="{
|
||||
selectedRowKeys: selectedPermissions,
|
||||
onChange: onPermissionSelectionChange
|
||||
}"
|
||||
rowKey="id"
|
||||
size="small"
|
||||
></a-table>
|
||||
</template>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue';
|
||||
import { message } from 'ant-design-vue';
|
||||
|
||||
// 表格列定义
|
||||
const columns = [
|
||||
{
|
||||
title: 'ID',
|
||||
dataIndex: 'id',
|
||||
key: 'id',
|
||||
},
|
||||
{
|
||||
title: '角色名称',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
},
|
||||
{
|
||||
title: '角色编码',
|
||||
dataIndex: 'code',
|
||||
key: 'code',
|
||||
},
|
||||
{
|
||||
title: '描述',
|
||||
dataIndex: 'description',
|
||||
key: 'description',
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
dataIndex: 'createTime',
|
||||
key: 'createTime',
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
},
|
||||
];
|
||||
|
||||
// 权限表格列定义
|
||||
const permissionColumns = [
|
||||
{
|
||||
title: '权限名称',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
},
|
||||
{
|
||||
title: '权限编码',
|
||||
dataIndex: 'code',
|
||||
key: 'code',
|
||||
},
|
||||
{
|
||||
title: '描述',
|
||||
dataIndex: 'description',
|
||||
key: 'description',
|
||||
},
|
||||
];
|
||||
|
||||
// 状态数据
|
||||
const loading = ref(false);
|
||||
const roleList = ref<any[]>([]);
|
||||
const pagination = reactive({
|
||||
current: 1,
|
||||
pageSize: 10,
|
||||
total: 0,
|
||||
showSizeChanger: true,
|
||||
showTotal: (total: number) => `共 ${total} 条`,
|
||||
});
|
||||
|
||||
// 角色表单相关
|
||||
const roleModalVisible = ref(false);
|
||||
const modalLoading = ref(false);
|
||||
const modalTitle = ref('新增角色');
|
||||
const roleFormRef = ref();
|
||||
const roleForm = reactive({
|
||||
id: null,
|
||||
name: '',
|
||||
code: '',
|
||||
description: '',
|
||||
status: 'active',
|
||||
permissions: [] as number[],
|
||||
});
|
||||
|
||||
const roleRules = {
|
||||
name: [{ required: true, message: '请输入角色名称', trigger: 'blur' }],
|
||||
code: [{ required: true, message: '请输入角色编码', trigger: 'blur' }],
|
||||
status: [{ required: true, message: '请选择状态', trigger: 'change' }],
|
||||
};
|
||||
|
||||
// 权限设置相关
|
||||
const permissionModalVisible = ref(false);
|
||||
const permissionModalLoading = ref(false);
|
||||
const currentRole = ref<any>(null);
|
||||
const selectedPermissions = ref<number[]>([]);
|
||||
const permissionList = ref<any[]>([]);
|
||||
|
||||
// 获取角色列表
|
||||
const fetchRoleList = () => {
|
||||
loading.value = true;
|
||||
// 模拟API调用
|
||||
setTimeout(() => {
|
||||
// 模拟API响应
|
||||
pagination.total = roleList.value.length;
|
||||
loading.value = false;
|
||||
}, 500);
|
||||
};
|
||||
|
||||
// 新增角色
|
||||
const handleAddRole = () => {
|
||||
modalTitle.value = '新增角色';
|
||||
roleForm.id = null;
|
||||
roleForm.name = '';
|
||||
roleForm.code = '';
|
||||
roleForm.description = '';
|
||||
roleForm.status = 'active';
|
||||
roleForm.permissions = [];
|
||||
roleModalVisible.value = true;
|
||||
};
|
||||
|
||||
// 表格变化处理(分页、排序、筛选)
|
||||
const handleTableChange = (pag: any, _filters: any, _sorter: any) => {
|
||||
pagination.current = pag.current;
|
||||
pagination.pageSize = pag.pageSize;
|
||||
fetchRoleList();
|
||||
};
|
||||
|
||||
// 编辑角色
|
||||
const handleEditRole = (record: any) => {
|
||||
modalTitle.value = '编辑角色';
|
||||
roleForm.id = record.id;
|
||||
roleForm.name = record.name;
|
||||
roleForm.code = record.code;
|
||||
roleForm.description = record.description;
|
||||
roleForm.status = record.status;
|
||||
roleForm.permissions = record.permissions;
|
||||
roleModalVisible.value = true;
|
||||
};
|
||||
|
||||
// 删除角色
|
||||
const handleDeleteRole = (record: any) => {
|
||||
message.success(`删除角色:${record.name}`);
|
||||
// 实际应用中应该调用删除API
|
||||
roleList.value = roleList.value.filter(item => item.id !== record.id);
|
||||
};
|
||||
|
||||
// 设置角色权限
|
||||
const handleSetPermissions = (record: any) => {
|
||||
currentRole.value = record;
|
||||
selectedPermissions.value = [...record.permissions];
|
||||
permissionModalVisible.value = true;
|
||||
};
|
||||
|
||||
// 权限选择变更
|
||||
const onPermissionSelectionChange = (selectedRowKeys: number[]) => {
|
||||
selectedPermissions.value = selectedRowKeys;
|
||||
};
|
||||
|
||||
// 角色表单提交
|
||||
const handleRoleModalOk = () => {
|
||||
roleFormRef.value.validate().then(() => {
|
||||
modalLoading.value = true;
|
||||
// 这里应该是实际的API调用
|
||||
setTimeout(() => {
|
||||
if (roleForm.id) {
|
||||
// 编辑现有角色
|
||||
const index = roleList.value.findIndex(item => item.id === roleForm.id);
|
||||
if (index !== -1) {
|
||||
const statusText = roleForm.status === 'active' ? '启用' : '禁用';
|
||||
roleList.value[index] = {
|
||||
...roleList.value[index],
|
||||
...roleForm,
|
||||
statusText
|
||||
};
|
||||
message.success('角色编辑成功');
|
||||
}
|
||||
} else {
|
||||
// 新增角色
|
||||
const newId = Math.max(...roleList.value.map(item => item.id)) + 1;
|
||||
const statusText = roleForm.status === 'active' ? '启用' : '禁用';
|
||||
const now = new Date().toLocaleString();
|
||||
roleList.value.push({
|
||||
id: newId,
|
||||
...roleForm,
|
||||
statusText,
|
||||
createTime: now,
|
||||
permissions: []
|
||||
});
|
||||
message.success('角色添加成功');
|
||||
}
|
||||
roleModalVisible.value = false;
|
||||
modalLoading.value = false;
|
||||
}, 500);
|
||||
}).catch(() => {
|
||||
// 表单验证失败
|
||||
});
|
||||
};
|
||||
|
||||
// 取消角色表单
|
||||
const handleRoleModalCancel = () => {
|
||||
roleModalVisible.value = false;
|
||||
};
|
||||
|
||||
// 权限设置对话框确认
|
||||
const handlePermissionModalOk = () => {
|
||||
permissionModalLoading.value = true;
|
||||
// 这里应该是实际的API调用
|
||||
setTimeout(() => {
|
||||
// 更新角色的权限
|
||||
const index = roleList.value.findIndex(item => item.id === currentRole.value.id);
|
||||
if (index !== -1) {
|
||||
roleList.value[index].permissions = [...selectedPermissions.value];
|
||||
message.success(`角色 ${currentRole.value.name} 的权限设置已更新`);
|
||||
}
|
||||
permissionModalVisible.value = false;
|
||||
permissionModalLoading.value = false;
|
||||
}, 500);
|
||||
};
|
||||
|
||||
// 取消权限设置
|
||||
const handlePermissionModalCancel = () => {
|
||||
permissionModalVisible.value = false;
|
||||
};
|
||||
|
||||
// 初始化
|
||||
onMounted(() => {
|
||||
fetchRoleList();
|
||||
});
|
||||
|
||||
// 模拟角色数据
|
||||
roleList.value = [
|
||||
{
|
||||
id: 1,
|
||||
name: '超级管理员',
|
||||
code: 'super_admin',
|
||||
description: '系统最高权限角色',
|
||||
status: 'active',
|
||||
statusText: '启用',
|
||||
createTime: '2023-01-01 12:00:00',
|
||||
permissions: [1, 2, 3, 4, 5]
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: '内容管理员',
|
||||
code: 'content_admin',
|
||||
description: '负责内容审核和管理',
|
||||
status: 'active',
|
||||
statusText: '启用',
|
||||
createTime: '2023-01-15 10:20:00',
|
||||
permissions: [2, 3]
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: '用户管理员',
|
||||
code: 'user_admin',
|
||||
description: '负责用户管理',
|
||||
status: 'active',
|
||||
statusText: '启用',
|
||||
createTime: '2023-02-05 09:30:00',
|
||||
permissions: [4, 5]
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: '只读角色',
|
||||
code: 'readonly',
|
||||
description: '只有查看权限',
|
||||
status: 'inactive',
|
||||
statusText: '禁用',
|
||||
createTime: '2023-03-10 14:50:00',
|
||||
permissions: [1]
|
||||
}
|
||||
];
|
||||
|
||||
// 模拟权限数据
|
||||
permissionList.value = [
|
||||
{
|
||||
id: 1,
|
||||
name: '查看数据',
|
||||
code: 'view_data',
|
||||
description: '查看系统数据的权限'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: '编辑内容',
|
||||
code: 'edit_content',
|
||||
description: '编辑系统内容的权限'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: '审核内容',
|
||||
code: 'review_content',
|
||||
description: '审核系统内容的权限'
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: '用户管理',
|
||||
code: 'manage_users',
|
||||
description: '管理系统用户的权限'
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
name: '系统设置',
|
||||
code: 'system_settings',
|
||||
description: '修改系统设置的权限'
|
||||
}
|
||||
];
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.role-management {
|
||||
.role-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.table-search-wrapper {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
}
|
||||
</style>
|
215
src/views/Admin/System/Pages/SecuritySettings.vue
Normal file
215
src/views/Admin/System/Pages/SecuritySettings.vue
Normal file
@@ -0,0 +1,215 @@
|
||||
<template>
|
||||
<div class="security-settings">
|
||||
<a-card title="安全设置" :bordered="false">
|
||||
<a-form
|
||||
:model="securityForm"
|
||||
:rules="securityRules"
|
||||
:label-col="{ span: 4 }"
|
||||
:wrapper-col="{ span: 16 }"
|
||||
ref="securityFormRef"
|
||||
>
|
||||
<!-- 密码策略设置 -->
|
||||
<a-divider orientation="left">密码策略</a-divider>
|
||||
|
||||
<a-form-item label="密码最小长度" name="passwordMinLength">
|
||||
<a-input-number
|
||||
v-model:value="securityForm.passwordMinLength"
|
||||
:min="6"
|
||||
:max="20"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="密码复杂度要求" name="passwordComplexity">
|
||||
<a-checkbox-group v-model:value="securityForm.passwordComplexity">
|
||||
<a-checkbox value="uppercase">必须包含大写字母</a-checkbox>
|
||||
<a-checkbox value="lowercase">必须包含小写字母</a-checkbox>
|
||||
<a-checkbox value="numbers">必须包含数字</a-checkbox>
|
||||
<a-checkbox value="special">必须包含特殊字符</a-checkbox>
|
||||
</a-checkbox-group>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="密码过期时间" name="passwordExpireDays">
|
||||
<a-input-number
|
||||
v-model:value="securityForm.passwordExpireDays"
|
||||
:min="0"
|
||||
:max="365"
|
||||
style="width: 100%"
|
||||
placeholder="0表示永不过期"
|
||||
/>
|
||||
<div class="form-item-hint">单位:天,0表示永不过期</div>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="禁止使用历史密码" name="passwordHistoryCount">
|
||||
<a-input-number
|
||||
v-model:value="securityForm.passwordHistoryCount"
|
||||
:min="0"
|
||||
:max="10"
|
||||
style="width: 100%"
|
||||
placeholder="0表示不限制"
|
||||
/>
|
||||
<div class="form-item-hint">禁止重复使用最近N次使用过的密码,0表示不限制</div>
|
||||
</a-form-item>
|
||||
|
||||
<!-- 登录安全设置 -->
|
||||
<a-divider orientation="left">登录安全</a-divider>
|
||||
|
||||
<a-form-item label="登录失败锁定" name="loginLockThreshold">
|
||||
<a-input-number
|
||||
v-model:value="securityForm.loginLockThreshold"
|
||||
:min="0"
|
||||
:max="10"
|
||||
style="width: 100%"
|
||||
placeholder="0表示不锁定"
|
||||
/>
|
||||
<div class="form-item-hint">连续登录失败N次后锁定账号,0表示不锁定</div>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="锁定时间" name="loginLockDuration">
|
||||
<a-input-number
|
||||
v-model:value="securityForm.loginLockDuration"
|
||||
:min="1"
|
||||
:max="1440"
|
||||
style="width: 100%"
|
||||
:disabled="securityForm.loginLockThreshold === 0"
|
||||
/>
|
||||
<div class="form-item-hint">账号锁定持续时间,单位:分钟</div>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="会话超时时间" name="sessionTimeout">
|
||||
<a-input-number
|
||||
v-model:value="securityForm.sessionTimeout"
|
||||
:min="5"
|
||||
:max="1440"
|
||||
style="width: 100%"
|
||||
/>
|
||||
<div class="form-item-hint">用户无操作超过此时间后自动退出登录,单位:分钟</div>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="启用验证码" name="enableCaptcha">
|
||||
<a-switch v-model:checked="securityForm.enableCaptcha" />
|
||||
<div class="form-item-hint">登录时是否需要输入验证码</div>
|
||||
</a-form-item>
|
||||
|
||||
<!-- IP访问控制 -->
|
||||
<a-divider orientation="left">IP访问控制</a-divider>
|
||||
|
||||
<a-form-item label="IP白名单" name="ipWhitelist">
|
||||
<a-textarea
|
||||
v-model:value="securityForm.ipWhitelist"
|
||||
placeholder="每行输入一个IP地址或IP段,例如:192.168.1.1或192.168.1.0/24,留空表示不限制"
|
||||
:rows="4"
|
||||
/>
|
||||
<div class="form-item-hint">只允许列表中的IP地址访问系统,留空表示不限制</div>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="IP黑名单" name="ipBlacklist">
|
||||
<a-textarea
|
||||
v-model:value="securityForm.ipBlacklist"
|
||||
placeholder="每行输入一个IP地址或IP段,例如:192.168.1.1或192.168.1.0/24"
|
||||
:rows="4"
|
||||
/>
|
||||
<div class="form-item-hint">禁止列表中的IP地址访问系统</div>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item :wrapper-col="{ span: 16, offset: 4 }">
|
||||
<a-button type="primary" @click="handleSaveSettings">保存设置</a-button>
|
||||
<a-button style="margin-left: 10px" @click="resetSettings">重置</a-button>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue';
|
||||
import { message } from 'ant-design-vue';
|
||||
|
||||
// 表单引用
|
||||
const securityFormRef = ref();
|
||||
|
||||
// 表单数据
|
||||
const securityForm = reactive({
|
||||
// 密码策略
|
||||
passwordMinLength: 8,
|
||||
passwordComplexity: ['lowercase', 'numbers'],
|
||||
passwordExpireDays: 90,
|
||||
passwordHistoryCount: 3,
|
||||
|
||||
// 登录安全
|
||||
loginLockThreshold: 5,
|
||||
loginLockDuration: 30,
|
||||
sessionTimeout: 30,
|
||||
enableCaptcha: true,
|
||||
|
||||
// IP访问控制
|
||||
ipWhitelist: '',
|
||||
ipBlacklist: '',
|
||||
});
|
||||
|
||||
// 表单验证规则
|
||||
const securityRules = {
|
||||
passwordMinLength: [
|
||||
{ required: true, message: '请设置密码最小长度', trigger: 'change' },
|
||||
],
|
||||
passwordExpireDays: [
|
||||
{ required: true, message: '请设置密码过期时间', trigger: 'change' },
|
||||
],
|
||||
loginLockThreshold: [
|
||||
{ required: true, message: '请设置登录失败锁定阈值', trigger: 'change' },
|
||||
],
|
||||
sessionTimeout: [
|
||||
{ required: true, message: '请设置会话超时时间', trigger: 'change' },
|
||||
],
|
||||
};
|
||||
|
||||
// 加载状态
|
||||
const loading = ref(false);
|
||||
|
||||
// 保存设置
|
||||
const handleSaveSettings = () => {
|
||||
securityFormRef.value.validate().then(() => {
|
||||
loading.value = true;
|
||||
// 这里应该是实际的API调用
|
||||
setTimeout(() => {
|
||||
message.success('安全设置保存成功');
|
||||
loading.value = false;
|
||||
}, 500);
|
||||
}).catch(() => {
|
||||
// 表单验证失败
|
||||
});
|
||||
};
|
||||
|
||||
// 重置设置
|
||||
const resetSettings = () => {
|
||||
securityFormRef.value.resetFields();
|
||||
};
|
||||
|
||||
// 初始化
|
||||
onMounted(() => {
|
||||
// 这里应该是获取当前安全设置的API调用
|
||||
loading.value = true;
|
||||
setTimeout(() => {
|
||||
// 模拟API响应,实际应用中应该使用真实数据
|
||||
loading.value = false;
|
||||
}, 500);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.security-settings {
|
||||
.form-item-hint {
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
font-size: 12px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.ant-divider {
|
||||
margin: 16px 0;
|
||||
|
||||
&:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
747
src/views/Admin/System/Pages/StorageManagement.vue
Normal file
747
src/views/Admin/System/Pages/StorageManagement.vue
Normal file
@@ -0,0 +1,747 @@
|
||||
<template>
|
||||
<div class="storage-management">
|
||||
<a-card title="存储管理" :bordered="false">
|
||||
<!-- 搜索和操作按钮区域 -->
|
||||
<div class="table-operations">
|
||||
<a-space>
|
||||
<a-input-search
|
||||
v-model:value="searchKeyword"
|
||||
placeholder="搜索用户ID或存储桶名称"
|
||||
style="width: 250px"
|
||||
@search="handleSearch"
|
||||
/>
|
||||
<a-button type="primary" @click="showAddModal">
|
||||
<template #icon>
|
||||
<plus-outlined/>
|
||||
</template>
|
||||
添加存储配置
|
||||
</a-button>
|
||||
<a-button @click="refreshTable">
|
||||
<template #icon>
|
||||
<reload-outlined/>
|
||||
</template>
|
||||
刷新
|
||||
</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
|
||||
<!-- 存储配置表格 -->
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:data-source="tableData"
|
||||
:loading="loading"
|
||||
:pagination="pagination"
|
||||
@change="handleTableChange"
|
||||
row-key="id"
|
||||
>
|
||||
<!-- 供应商列 -->
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'provider'">
|
||||
<a-tag :color="getProviderColor(record.provider)">
|
||||
{{ getProviderName(record.provider) }}
|
||||
</a-tag>
|
||||
</template>
|
||||
|
||||
<!-- 容量列 -->
|
||||
<template v-if="column.key === 'capacity'">
|
||||
{{ record.capacity }} GB
|
||||
</template>
|
||||
|
||||
<!-- 密钥列 -->
|
||||
<template v-if="column.key === 'access_key' || column.key === 'secret_key'">
|
||||
<a-button type="link" @click="showVerifyModal(record, column.key)">
|
||||
<eye-outlined/>
|
||||
查看
|
||||
</a-button>
|
||||
</template>
|
||||
|
||||
<!-- 操作列 -->
|
||||
<template v-if="column.key === 'action'">
|
||||
<a-space>
|
||||
<a-button type="primary" size="small" @click="handleEdit(record)">
|
||||
<template #icon>
|
||||
<edit-outlined/>
|
||||
</template>
|
||||
编辑
|
||||
</a-button>
|
||||
<a-popconfirm
|
||||
title="确定要删除此存储配置吗?"
|
||||
ok-text="确定"
|
||||
cancel-text="取消"
|
||||
@confirm="handleDelete(record)"
|
||||
>
|
||||
<a-button type="primary" danger size="small">
|
||||
<template #icon>
|
||||
<delete-outlined/>
|
||||
</template>
|
||||
删除
|
||||
</a-button>
|
||||
</a-popconfirm>
|
||||
</a-space>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
|
||||
<!-- 添加/编辑存储配置的对话框 -->
|
||||
<a-modal
|
||||
v-model:visible="modalVisible"
|
||||
:title="modalTitle"
|
||||
@ok="handleModalOk"
|
||||
@cancel="handleModalCancel"
|
||||
:confirm-loading="modalLoading"
|
||||
>
|
||||
<a-form
|
||||
:model="formState"
|
||||
:rules="rules"
|
||||
ref="formRef"
|
||||
:label-col="{ span: 6 }"
|
||||
:wrapper-col="{ span: 16 }"
|
||||
>
|
||||
<a-form-item label="用户ID" name="user_id">
|
||||
<a-input v-model:value="formState.user_id" placeholder="请输入用户ID"/>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="存储商" name="provider">
|
||||
<a-select v-model:value="formState.provider" placeholder="请选择存储商">
|
||||
<a-select-option v-for="provider in providers" :key="provider.value" :value="provider.value">
|
||||
{{ provider.label }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="地址" name="endpoint">
|
||||
<a-input v-model:value="formState.endpoint" placeholder="请输入地址">
|
||||
<template #addonBefore>
|
||||
<a-select v-model:value="protocol" style="width: 90px">
|
||||
<a-select-option value="http://">Http://</a-select-option>
|
||||
<a-select-option value="https://">Https://</a-select-option>
|
||||
</a-select>
|
||||
</template>
|
||||
</a-input>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="密钥Key" name="access_key">
|
||||
<a-input v-model:value="formState.access_key" placeholder="请输入密钥Key"/>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="密钥Secret" name="secret_key">
|
||||
<a-input-password v-model:value="formState.secret_key" placeholder="请输入密钥Secret"/>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="存储桶" name="bucket">
|
||||
<a-input v-model:value="formState.bucket" placeholder="请输入存储桶名称"/>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="地域" name="region">
|
||||
<a-select v-model:value="formState.region" placeholder="请选择地域" :disabled="!formState.provider">
|
||||
<a-select-option
|
||||
v-for="region in regionsByProvider[formState.provider] || []"
|
||||
:key="region.value"
|
||||
:value="region.value"
|
||||
>
|
||||
{{ region.label }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="容量(GB)" name="capacity">
|
||||
<a-input-number style="width: 100%" v-model:value="formState.capacity" placeholder="请输入容量"/>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
|
||||
<!-- 验证身份查看敏感信息的对话框 -->
|
||||
<a-modal
|
||||
v-model:visible="verifyModalVisible"
|
||||
title="身份验证"
|
||||
@ok="handleVerifyOk"
|
||||
@cancel="() => verifyModalVisible = false"
|
||||
:confirm-loading="verifyLoading"
|
||||
>
|
||||
<a-tabs v-model:activeKey="verifyType">
|
||||
<a-tab-pane key="phone" tab="手机验证">
|
||||
<a-form layout="vertical">
|
||||
<a-form-item label="手机号码">
|
||||
<a-input v-model:value="verifyPhone" placeholder="请输入管理员手机号码"/>
|
||||
</a-form-item>
|
||||
<a-form-item label="验证码">
|
||||
<a-input-search
|
||||
v-model:value="verifyCode"
|
||||
placeholder="请输入验证码"
|
||||
:loading="sendingCode"
|
||||
@search="sendVerifyCode"
|
||||
enter-button="获取验证码"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-tab-pane>
|
||||
<a-tab-pane key="email" tab="邮箱验证">
|
||||
<a-form layout="vertical">
|
||||
<a-form-item label="邮箱地址">
|
||||
<a-input v-model:value="verifyEmail" placeholder="请输入管理员邮箱地址"/>
|
||||
</a-form-item>
|
||||
<a-form-item label="验证码">
|
||||
<a-input-search
|
||||
v-model:value="verifyCode"
|
||||
placeholder="请输入验证码"
|
||||
:loading="sendingCode"
|
||||
@search="sendVerifyCode"
|
||||
enter-button="获取验证码"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-tab-pane>
|
||||
</a-tabs>
|
||||
</a-modal>
|
||||
|
||||
<!-- 显示敏感信息的对话框 -->
|
||||
<a-modal
|
||||
v-model:visible="sensitiveInfoVisible"
|
||||
title="敏感信息"
|
||||
@ok="() => sensitiveInfoVisible = false"
|
||||
:footer="null"
|
||||
>
|
||||
<a-result status="success" :title="`${sensitiveInfoType === 'access_key' ? '密钥Key' : '密钥Secret'}`">
|
||||
<template #extra>
|
||||
<a-typography>
|
||||
<a-typography-paragraph copyable>
|
||||
{{ sensitiveInfoValue }}
|
||||
</a-typography-paragraph>
|
||||
</a-typography>
|
||||
<a-button type="primary" @click="() => sensitiveInfoVisible = false">关闭</a-button>
|
||||
</template>
|
||||
</a-result>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {ref, reactive, onMounted} from 'vue';
|
||||
import {message} from 'ant-design-vue';
|
||||
import {
|
||||
PlusOutlined,
|
||||
ReloadOutlined,
|
||||
EditOutlined,
|
||||
DeleteOutlined,
|
||||
EyeOutlined
|
||||
} from '@ant-design/icons-vue';
|
||||
import {ProviderColorMap, ProviderNameMap} from '@/constant/provider_map.ts';
|
||||
|
||||
// 表格列定义
|
||||
const columns = [
|
||||
{
|
||||
title: 'ID',
|
||||
dataIndex: 'id',
|
||||
key: 'id',
|
||||
width: 80,
|
||||
},
|
||||
{
|
||||
title: '用户ID',
|
||||
dataIndex: 'user_id',
|
||||
key: 'user_id',
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
title: '供应商',
|
||||
dataIndex: 'provider',
|
||||
key: 'provider',
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
title: '存储桶',
|
||||
dataIndex: 'bucket',
|
||||
key: 'bucket',
|
||||
width: 150,
|
||||
},
|
||||
{
|
||||
title: '地址',
|
||||
dataIndex: 'endpoint',
|
||||
key: 'endpoint',
|
||||
width: 200,
|
||||
ellipsis: true,
|
||||
},
|
||||
{
|
||||
title: '地域',
|
||||
dataIndex: 'region',
|
||||
key: 'region',
|
||||
width: 150,
|
||||
},
|
||||
{
|
||||
title: '容量',
|
||||
dataIndex: 'capacity',
|
||||
key: 'capacity',
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
title: '密钥Key',
|
||||
dataIndex: 'access_key',
|
||||
key: 'access_key',
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
title: '密钥Secret',
|
||||
dataIndex: 'secret_key',
|
||||
key: 'secret_key',
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
dataIndex: 'created_at',
|
||||
key: 'created_at',
|
||||
width: 180,
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
fixed: 'right',
|
||||
width: 180,
|
||||
},
|
||||
];
|
||||
|
||||
// 存储供应商列表
|
||||
const providers = [
|
||||
{value: 'ali', label: '阿里云OSS'},
|
||||
{value: 'tencent', label: '腾讯云COS'},
|
||||
{value: 'minio', label: 'Minio'},
|
||||
{value: 'huawei', label: '华为云OBS'},
|
||||
];
|
||||
|
||||
// 各供应商的地域列表
|
||||
const regionsByProvider = {
|
||||
ali: [
|
||||
{value: 'oss-cn-hangzhou', label: '华东1(杭州)'},
|
||||
{value: 'oss-cn-shanghai', label: '华东2(上海)'},
|
||||
{value: 'oss-cn-qingdao', label: '华北1(青岛)'},
|
||||
{value: 'oss-cn-shenzhen', label: '华南1(深圳)'},
|
||||
],
|
||||
tencent: [
|
||||
{value: 'ap-beijing', label: '北京'},
|
||||
{value: 'ap-shanghai', label: '上海'},
|
||||
{value: 'ap-guangzhou', label: '广州'},
|
||||
{value: 'ap-chengdu', label: '成都'},
|
||||
],
|
||||
huawei: [
|
||||
{value: 'cn-north-1', label: '华北-北京一'},
|
||||
{value: 'cn-east-3', label: '华东-上海一'},
|
||||
{value: 'cn-south-1', label: '华南-广州'},
|
||||
],
|
||||
minio: [
|
||||
{value: 'us-east-1', label: '默认地域'},
|
||||
{value: 'custom', label: '自定义地域'},
|
||||
],
|
||||
};
|
||||
|
||||
// 表格数据和加载状态
|
||||
const tableData = ref<any[]>([]);
|
||||
const loading = ref(false);
|
||||
const searchKeyword = ref('');
|
||||
|
||||
// 分页配置
|
||||
const pagination = reactive({
|
||||
current: 1,
|
||||
pageSize: 10,
|
||||
total: 0,
|
||||
showSizeChanger: true,
|
||||
showTotal: (total: number) => `共 ${total} 条记录`,
|
||||
});
|
||||
|
||||
// 表单相关
|
||||
const formRef = ref();
|
||||
const protocol = ref('https://');
|
||||
const modalVisible = ref(false);
|
||||
const modalLoading = ref(false);
|
||||
const modalTitle = ref('添加存储配置');
|
||||
const isEdit = ref(false);
|
||||
const currentRecord = ref(null);
|
||||
|
||||
// 表单状态和验证规则
|
||||
const formState = reactive({
|
||||
user_id: '',
|
||||
provider: '',
|
||||
endpoint: '',
|
||||
access_key: '',
|
||||
secret_key: '',
|
||||
bucket: '',
|
||||
region: '',
|
||||
capacity: null,
|
||||
});
|
||||
|
||||
const rules = {
|
||||
user_id: [{required: true, message: '用户ID不能为空'}],
|
||||
provider: [{required: true, message: '存储商不能为空'}],
|
||||
bucket: [{required: true, message: '存储桶不能为空'}],
|
||||
endpoint: [{required: true, message: '地址不能为空'}],
|
||||
access_key: [{required: true, message: '密钥Key不能为空'}],
|
||||
secret_key: [{required: true, message: '密钥Secret不能为空'}],
|
||||
region: [{required: true, message: '地域不能为空'}],
|
||||
capacity: [{required: true, message: '容量不能为空'}],
|
||||
};
|
||||
|
||||
// 验证相关
|
||||
const verifyModalVisible = ref(false);
|
||||
const verifyLoading = ref(false);
|
||||
const sendingCode = ref(false);
|
||||
const verifyType = ref('phone');
|
||||
const verifyPhone = ref('');
|
||||
const verifyEmail = ref('');
|
||||
const verifyCode = ref('');
|
||||
const sensitiveInfoVisible = ref(false);
|
||||
const sensitiveInfoType = ref('');
|
||||
const sensitiveInfoValue = ref('');
|
||||
const verifyingRecord = ref<any>(null);
|
||||
|
||||
// 获取供应商名称和颜色
|
||||
const getProviderName = (provider: string) => {
|
||||
return ProviderNameMap[provider] || provider;
|
||||
};
|
||||
|
||||
const getProviderColor = (provider: string) => {
|
||||
return ProviderColorMap[provider] || 'blue';
|
||||
};
|
||||
|
||||
// 加载存储配置列表
|
||||
const fetchStorageList = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
// 使用模拟数据替代API调用
|
||||
// const res: any = await listUserStorageConfigApi();
|
||||
|
||||
// 模拟API响应数据
|
||||
const mockData = {
|
||||
code: 200,
|
||||
data: {
|
||||
records: [
|
||||
{
|
||||
id: 1,
|
||||
user_id: '10001',
|
||||
provider: 'ali',
|
||||
bucket: 'user-photos-10001',
|
||||
endpoint: 'https://oss-cn-hangzhou.aliyuncs.com',
|
||||
region: 'oss-cn-hangzhou',
|
||||
capacity: 100,
|
||||
access_key: 'LTAI4G2KxxxxxxxxxxxxxxxJZC',
|
||||
secret_key: 'PkT4FxxxxxxxxxxxxxxxxxxxxxxRLh',
|
||||
created_at: '2023-05-15 10:23:45'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
user_id: '10002',
|
||||
provider: 'tencent',
|
||||
bucket: 'cloud-album-10002',
|
||||
endpoint: 'https://cos.ap-shanghai.myqcloud.com',
|
||||
region: 'ap-shanghai',
|
||||
capacity: 200,
|
||||
access_key: 'AKIDQq7xxxxxxxxxxxxxxxx1Ic',
|
||||
secret_key: 'VtBcXxxxxxxxxxxxxxxxxxxxxxxxQ',
|
||||
created_at: '2023-06-20 14:35:12'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
user_id: '10003',
|
||||
provider: 'huawei',
|
||||
bucket: 'photos-storage-10003',
|
||||
endpoint: 'https://obs.cn-north-1.myhuaweicloud.com',
|
||||
region: 'cn-north-1',
|
||||
capacity: 150,
|
||||
access_key: 'HWOBTJ1xxxxxxxxxxxxxxxxxxxx',
|
||||
secret_key: 'WpC9Dxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
|
||||
created_at: '2023-07-05 09:12:30'
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
user_id: '10001',
|
||||
provider: 'minio',
|
||||
bucket: 'private-photos-10001',
|
||||
endpoint: 'http://minio.example.com:9000',
|
||||
region: 'us-east-1',
|
||||
capacity: 50,
|
||||
access_key: 'minioadmin',
|
||||
secret_key: 'minioadmin123',
|
||||
created_at: '2023-08-10 16:45:22'
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
user_id: '10004',
|
||||
provider: 'ali',
|
||||
bucket: 'backup-photos-10004',
|
||||
endpoint: 'https://oss-cn-shenzhen.aliyuncs.com',
|
||||
region: 'oss-cn-shenzhen',
|
||||
capacity: 300,
|
||||
access_key: 'LTAI4G3Bxxxxxxxxxxxxxxxc',
|
||||
secret_key: 'ZpR7Fxxxxxxxxxxxxxxxxxxxxxxx',
|
||||
created_at: '2023-09-01 11:30:15'
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
user_id: '10005',
|
||||
provider: 'tencent',
|
||||
bucket: 'album-storage-10005',
|
||||
endpoint: 'https://cos.ap-guangzhou.myqcloud.com',
|
||||
region: 'ap-guangzhou',
|
||||
capacity: 250,
|
||||
access_key: 'AKIDZq8xxxxxxxxxxxxxxxx2Jd',
|
||||
secret_key: 'BtDcYxxxxxxxxxxxxxxxxxxxxxxxR',
|
||||
created_at: '2023-09-15 13:20:40'
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
user_id: '10002',
|
||||
provider: 'huawei',
|
||||
bucket: 'album-backup-10002',
|
||||
endpoint: 'https://obs.cn-east-3.myhuaweicloud.com',
|
||||
region: 'cn-east-3',
|
||||
capacity: 120,
|
||||
access_key: 'HWOBTJ2xxxxxxxxxxxxxxxxxxxx',
|
||||
secret_key: 'KpL8Dxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
|
||||
created_at: '2023-10-05 15:40:18'
|
||||
},
|
||||
{
|
||||
id: 8,
|
||||
user_id: '10006',
|
||||
provider: 'minio',
|
||||
bucket: 'test-photos-10006',
|
||||
endpoint: 'http://localhost:9000',
|
||||
region: 'custom',
|
||||
capacity: 80,
|
||||
access_key: 'testadmin',
|
||||
secret_key: 'testpassword',
|
||||
created_at: '2023-11-20 08:55:33'
|
||||
}
|
||||
],
|
||||
total: 8
|
||||
}
|
||||
};
|
||||
|
||||
const res = mockData;
|
||||
|
||||
if (res && res.code === 200) {
|
||||
// 如果有搜索关键词,进行过滤
|
||||
if (searchKeyword.value) {
|
||||
const keyword = searchKeyword.value.toLowerCase();
|
||||
tableData.value = res.data.records.filter(item =>
|
||||
item.user_id.toLowerCase().includes(keyword) ||
|
||||
item.bucket.toLowerCase().includes(keyword)
|
||||
);
|
||||
pagination.total = tableData.value.length;
|
||||
} else {
|
||||
tableData.value = res.data.records || [];
|
||||
pagination.total = res.data.total || 0;
|
||||
}
|
||||
} else {
|
||||
message.error('获取存储配置列表失败');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取存储配置列表出错:', error);
|
||||
message.error('获取存储配置列表出错');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 表格变化处理
|
||||
const handleTableChange = (pag: any) => {
|
||||
pagination.current = pag.current;
|
||||
pagination.pageSize = pag.pageSize;
|
||||
fetchStorageList();
|
||||
};
|
||||
|
||||
// 搜索处理
|
||||
const handleSearch = () => {
|
||||
pagination.current = 1;
|
||||
fetchStorageList();
|
||||
};
|
||||
|
||||
// 刷新表格
|
||||
const refreshTable = () => {
|
||||
searchKeyword.value = '';
|
||||
pagination.current = 1;
|
||||
fetchStorageList();
|
||||
};
|
||||
|
||||
// 显示添加模态框
|
||||
const showAddModal = () => {
|
||||
isEdit.value = false;
|
||||
modalTitle.value = '添加存储配置';
|
||||
resetForm();
|
||||
modalVisible.value = true;
|
||||
};
|
||||
|
||||
// 编辑处理
|
||||
const handleEdit = (record: any) => {
|
||||
isEdit.value = true;
|
||||
currentRecord.value = {...record};
|
||||
modalTitle.value = '编辑存储配置';
|
||||
|
||||
// 填充表单数据
|
||||
Object.keys(formState).forEach(key => {
|
||||
if (key in record) {
|
||||
formState[key] = record[key];
|
||||
}
|
||||
});
|
||||
|
||||
// 处理协议前缀
|
||||
if (formState.endpoint && formState.endpoint.startsWith('http://')) {
|
||||
protocol.value = 'http://';
|
||||
formState.endpoint = formState.endpoint.substring(7);
|
||||
} else if (formState.endpoint && formState.endpoint.startsWith('https://')) {
|
||||
protocol.value = 'https://';
|
||||
formState.endpoint = formState.endpoint.substring(8);
|
||||
}
|
||||
|
||||
modalVisible.value = true;
|
||||
};
|
||||
|
||||
// 删除处理
|
||||
const handleDelete = async (record: any) => {
|
||||
loading.value = true;
|
||||
try {
|
||||
// 使用模拟数据替代API调用
|
||||
// const res: any = await deleteStorageConfigApi(record.id, record.provider, record.bucket);
|
||||
|
||||
// 模拟API响应
|
||||
const res = {
|
||||
code: 200,
|
||||
message: '删除成功',
|
||||
data: null
|
||||
};
|
||||
|
||||
if (res && res.code === 200) {
|
||||
message.success('删除成功');
|
||||
// 模拟删除操作,从tableData中移除该记录
|
||||
tableData.value = tableData.value.filter((item: any) => item.id !== record.id);
|
||||
pagination.total -= 1;
|
||||
} else {
|
||||
message.error('删除失败');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('删除存储配置出错:', error);
|
||||
message.error('删除存储配置出错');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 模态框确认
|
||||
const handleModalOk = () => {
|
||||
formRef.value.validate().then(async () => {
|
||||
modalLoading.value = true;
|
||||
|
||||
// 处理endpoint,添加协议前缀
|
||||
const formData = {...formState};
|
||||
formData.endpoint = protocol.value + formData.endpoint;
|
||||
|
||||
try {
|
||||
// 使用模拟数据替代API调用
|
||||
// const res: any = await addStorageConfigApi(formData);
|
||||
|
||||
// 模拟API响应
|
||||
const res = {
|
||||
code: 200,
|
||||
message: '操作成功',
|
||||
data: null
|
||||
};
|
||||
|
||||
if (res && res.code === 200) {
|
||||
message.success(isEdit.value ? '编辑成功' : '添加成功');
|
||||
modalVisible.value = false;
|
||||
resetForm();
|
||||
fetchStorageList();
|
||||
} else {
|
||||
message.error(isEdit.value ? '编辑失败' : '添加失败');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(isEdit.value ? '编辑存储配置出错:' : '添加存储配置出错:', error);
|
||||
message.error(isEdit.value ? '编辑存储配置出错' : '添加存储配置出错');
|
||||
} finally {
|
||||
modalLoading.value = false;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// 模态框取消
|
||||
const handleModalCancel = () => {
|
||||
modalVisible.value = false;
|
||||
resetForm();
|
||||
};
|
||||
|
||||
// 重置表单
|
||||
const resetForm = () => {
|
||||
formRef.value?.resetFields();
|
||||
protocol.value = 'https://';
|
||||
};
|
||||
|
||||
// 显示验证模态框
|
||||
const showVerifyModal = (record: any, type: string) => {
|
||||
verifyingRecord.value = record;
|
||||
sensitiveInfoType.value = type;
|
||||
verifyModalVisible.value = true;
|
||||
verifyType.value = 'phone';
|
||||
verifyPhone.value = '';
|
||||
verifyEmail.value = '';
|
||||
verifyCode.value = '';
|
||||
};
|
||||
|
||||
// 发送验证码
|
||||
const sendVerifyCode = () => {
|
||||
if (verifyType.value === 'phone' && !verifyPhone.value) {
|
||||
message.error('请输入手机号码');
|
||||
return;
|
||||
}
|
||||
if (verifyType.value === 'email' && !verifyEmail.value) {
|
||||
message.error('请输入邮箱地址');
|
||||
return;
|
||||
}
|
||||
|
||||
sendingCode.value = true;
|
||||
|
||||
// 模拟发送验证码
|
||||
setTimeout(() => {
|
||||
sendingCode.value = false;
|
||||
message.success('验证码已发送');
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
// 验证确认
|
||||
const handleVerifyOk = () => {
|
||||
if (!verifyCode.value) {
|
||||
message.error('请输入验证码');
|
||||
return;
|
||||
}
|
||||
|
||||
verifyLoading.value = true;
|
||||
|
||||
// 模拟验证过程
|
||||
setTimeout(() => {
|
||||
verifyLoading.value = false;
|
||||
verifyModalVisible.value = false;
|
||||
|
||||
// 显示敏感信息
|
||||
sensitiveInfoValue.value = verifyingRecord.value[sensitiveInfoType.value];
|
||||
sensitiveInfoVisible.value = true;
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
// 初始化
|
||||
onMounted(() => {
|
||||
fetchStorageList();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.storage-management {
|
||||
.table-operations {
|
||||
margin-bottom: 16px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.ant-table {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.ant-tag {
|
||||
margin-right: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
410
src/views/Admin/System/Pages/SystemLogs.vue
Normal file
410
src/views/Admin/System/Pages/SystemLogs.vue
Normal file
@@ -0,0 +1,410 @@
|
||||
<template>
|
||||
<div class="system-logs">
|
||||
<a-card title="系统日志" :bordered="false">
|
||||
<!-- 搜索表单 -->
|
||||
<div class="table-search-wrapper">
|
||||
<a-form layout="inline" :model="searchForm">
|
||||
<a-form-item label="日志类型">
|
||||
<a-select v-model:value="searchForm.logType" placeholder="请选择日志类型" style="width: 150px" allowClear>
|
||||
<a-select-option value="operation">操作日志</a-select-option>
|
||||
<a-select-option value="login">登录日志</a-select-option>
|
||||
<a-select-option value="error">错误日志</a-select-option>
|
||||
<a-select-option value="system">系统日志</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="操作人">
|
||||
<a-input v-model:value="searchForm.operator" placeholder="请输入操作人" allowClear />
|
||||
</a-form-item>
|
||||
<a-form-item label="IP地址">
|
||||
<a-input v-model:value="searchForm.ipAddress" placeholder="请输入IP地址" allowClear />
|
||||
</a-form-item>
|
||||
<a-form-item label="操作时间">
|
||||
<a-range-picker v-model:value="searchForm.timeRange" style="width: 240px" />
|
||||
</a-form-item>
|
||||
<a-form-item>
|
||||
<a-button type="primary" @click="handleSearch">
|
||||
<template #icon>
|
||||
<search-outlined />
|
||||
</template>
|
||||
搜索
|
||||
</a-button>
|
||||
<a-button style="margin-left: 8px" @click="resetSearch">
|
||||
<template #icon>
|
||||
<reload-outlined />
|
||||
</template>
|
||||
重置
|
||||
</a-button>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div class="table-operations">
|
||||
<a-button type="primary" @click="handleExportLogs">
|
||||
<template #icon>
|
||||
<download-outlined />
|
||||
</template>
|
||||
导出日志
|
||||
</a-button>
|
||||
<a-button danger style="margin-left: 8px" @click="handleClearLogs">
|
||||
<template #icon>
|
||||
<delete-outlined />
|
||||
</template>
|
||||
清空日志
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
<!-- 日志表格 -->
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:data-source="logList"
|
||||
:loading="loading"
|
||||
:pagination="pagination"
|
||||
@change="handleTableChange"
|
||||
rowKey="id"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'logType'">
|
||||
<a-tag :color="getLogTypeColor(record.logType)">
|
||||
{{ getLogTypeText(record.logType) }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template v-if="column.key === 'status'">
|
||||
<a-tag :color="record.status === 'success' ? 'green' : 'red'">
|
||||
{{ record.status === 'success' ? '成功' : '失败' }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template v-if="column.key === 'action'">
|
||||
<a-button type="link" @click="handleViewLogDetail(record)">详情</a-button>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
|
||||
<!-- 日志详情对话框 -->
|
||||
<a-modal
|
||||
v-model:visible="logDetailVisible"
|
||||
title="日志详情"
|
||||
width="700px"
|
||||
:footer="null"
|
||||
>
|
||||
<template v-if="currentLog">
|
||||
<a-descriptions bordered :column="1">
|
||||
<a-descriptions-item label="日志ID">{{ currentLog.id }}</a-descriptions-item>
|
||||
<a-descriptions-item label="日志类型">
|
||||
<a-tag :color="getLogTypeColor(currentLog.logType)">
|
||||
{{ getLogTypeText(currentLog.logType) }}
|
||||
</a-tag>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="操作人">{{ currentLog.operator }}</a-descriptions-item>
|
||||
<a-descriptions-item label="操作模块">{{ currentLog.module }}</a-descriptions-item>
|
||||
<a-descriptions-item label="操作内容">{{ currentLog.operation }}</a-descriptions-item>
|
||||
<a-descriptions-item label="操作状态">
|
||||
<a-tag :color="currentLog.status === 'success' ? 'green' : 'red'">
|
||||
{{ currentLog.status === 'success' ? '成功' : '失败' }}
|
||||
</a-tag>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="IP地址">{{ currentLog.ipAddress }}</a-descriptions-item>
|
||||
<a-descriptions-item label="操作时间">{{ currentLog.operateTime }}</a-descriptions-item>
|
||||
<a-descriptions-item label="详细信息" v-if="currentLog.details">
|
||||
<div class="log-details">
|
||||
<pre>{{ currentLog.details }}</pre>
|
||||
</div>
|
||||
</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
</template>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue';
|
||||
import { message, Modal } from 'ant-design-vue';
|
||||
import { SearchOutlined, ReloadOutlined, DownloadOutlined, DeleteOutlined } from '@ant-design/icons-vue';
|
||||
|
||||
// 表格列定义
|
||||
const columns = [
|
||||
{
|
||||
title: 'ID',
|
||||
dataIndex: 'id',
|
||||
key: 'id',
|
||||
width: 80,
|
||||
},
|
||||
{
|
||||
title: '日志类型',
|
||||
dataIndex: 'logType',
|
||||
key: 'logType',
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
title: '操作人',
|
||||
dataIndex: 'operator',
|
||||
key: 'operator',
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
title: '操作模块',
|
||||
dataIndex: 'module',
|
||||
key: 'module',
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
title: '操作内容',
|
||||
dataIndex: 'operation',
|
||||
key: 'operation',
|
||||
ellipsis: true,
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
width: 80,
|
||||
},
|
||||
{
|
||||
title: 'IP地址',
|
||||
dataIndex: 'ipAddress',
|
||||
key: 'ipAddress',
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
title: '操作时间',
|
||||
dataIndex: 'operateTime',
|
||||
key: 'operateTime',
|
||||
width: 180,
|
||||
sorter: true,
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 80,
|
||||
fixed: 'right',
|
||||
},
|
||||
];
|
||||
|
||||
// 状态数据
|
||||
const loading = ref(false);
|
||||
const logList = ref<any[]>([]);
|
||||
const pagination = reactive({
|
||||
current: 1,
|
||||
pageSize: 10,
|
||||
total: 0,
|
||||
showSizeChanger: true,
|
||||
showTotal: (total: number) => `共 ${total} 条`,
|
||||
});
|
||||
|
||||
// 搜索表单
|
||||
const searchForm = reactive({
|
||||
logType: undefined,
|
||||
operator: '',
|
||||
ipAddress: '',
|
||||
timeRange: [],
|
||||
});
|
||||
|
||||
// 日志详情相关
|
||||
const logDetailVisible = ref(false);
|
||||
const currentLog = ref<any>(null);
|
||||
|
||||
// 获取日志列表
|
||||
const fetchLogList = () => {
|
||||
loading.value = true;
|
||||
// 模拟API调用
|
||||
setTimeout(() => {
|
||||
// 模拟API响应
|
||||
pagination.total = logList.value.length;
|
||||
loading.value = false;
|
||||
}, 500);
|
||||
};
|
||||
|
||||
// 搜索处理
|
||||
const handleSearch = () => {
|
||||
pagination.current = 1;
|
||||
fetchLogList();
|
||||
};
|
||||
|
||||
// 重置搜索
|
||||
const resetSearch = () => {
|
||||
searchForm.logType = undefined;
|
||||
searchForm.operator = '';
|
||||
searchForm.ipAddress = '';
|
||||
searchForm.timeRange = [];
|
||||
pagination.current = 1;
|
||||
fetchLogList();
|
||||
};
|
||||
|
||||
// 表格变化处理(分页、排序、筛选)
|
||||
const handleTableChange = (pag: any, _filters: any, sorter: any) => {
|
||||
pagination.current = pag.current;
|
||||
pagination.pageSize = pag.pageSize;
|
||||
|
||||
// 处理排序
|
||||
if (sorter.field && sorter.order) {
|
||||
// 实际应用中应该将排序参数传递给API
|
||||
console.log('排序字段:', sorter.field);
|
||||
console.log('排序方式:', sorter.order);
|
||||
}
|
||||
|
||||
fetchLogList();
|
||||
};
|
||||
|
||||
// 查看日志详情
|
||||
const handleViewLogDetail = (record: any) => {
|
||||
currentLog.value = record;
|
||||
logDetailVisible.value = true;
|
||||
};
|
||||
|
||||
// 导出日志
|
||||
const handleExportLogs = () => {
|
||||
loading.value = true;
|
||||
// 模拟导出操作
|
||||
setTimeout(() => {
|
||||
message.success('日志导出成功');
|
||||
loading.value = false;
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
// 清空日志
|
||||
const handleClearLogs = () => {
|
||||
Modal.confirm({
|
||||
title: '确认操作',
|
||||
content: '确定要清空所有日志吗?此操作不可恢复!',
|
||||
okText: '确定',
|
||||
okType: 'danger',
|
||||
cancelText: '取消',
|
||||
onOk() {
|
||||
loading.value = true;
|
||||
// 模拟清空操作
|
||||
setTimeout(() => {
|
||||
logList.value = [];
|
||||
pagination.total = 0;
|
||||
message.success('日志已清空');
|
||||
loading.value = false;
|
||||
}, 1000);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// 获取日志类型颜色
|
||||
const getLogTypeColor = (type: string) => {
|
||||
const colorMap: Record<string, string> = {
|
||||
operation: 'blue',
|
||||
login: 'green',
|
||||
error: 'red',
|
||||
system: 'orange',
|
||||
};
|
||||
return colorMap[type] || 'default';
|
||||
};
|
||||
|
||||
// 获取日志类型文本
|
||||
const getLogTypeText = (type: string) => {
|
||||
const textMap: Record<string, string> = {
|
||||
operation: '操作日志',
|
||||
login: '登录日志',
|
||||
error: '错误日志',
|
||||
system: '系统日志',
|
||||
};
|
||||
return textMap[type] || '未知类型';
|
||||
};
|
||||
|
||||
// 初始化
|
||||
onMounted(() => {
|
||||
// 模拟日志数据
|
||||
logList.value = [
|
||||
{
|
||||
id: 1,
|
||||
logType: 'login',
|
||||
operator: 'admin',
|
||||
module: '用户认证',
|
||||
operation: '用户登录',
|
||||
status: 'success',
|
||||
ipAddress: '192.168.1.100',
|
||||
operateTime: '2023-05-15 10:30:45',
|
||||
details: '{"browser":"Chrome","os":"Windows","device":"Desktop"}',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
logType: 'operation',
|
||||
operator: 'admin',
|
||||
module: '用户管理',
|
||||
operation: '创建新用户:user01',
|
||||
status: 'success',
|
||||
ipAddress: '192.168.1.100',
|
||||
operateTime: '2023-05-15 11:20:15',
|
||||
details: '{"userId":101,"username":"user01","email":"user01@example.com"}',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
logType: 'operation',
|
||||
operator: 'admin',
|
||||
module: '角色管理',
|
||||
operation: '修改角色权限:编辑者',
|
||||
status: 'success',
|
||||
ipAddress: '192.168.1.100',
|
||||
operateTime: '2023-05-15 14:05:30',
|
||||
details: '{"roleId":2,"permissions":["view","edit"]}',
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
logType: 'error',
|
||||
operator: 'user01',
|
||||
module: '文件上传',
|
||||
operation: '上传文件失败',
|
||||
status: 'failed',
|
||||
ipAddress: '192.168.1.101',
|
||||
operateTime: '2023-05-16 09:15:22',
|
||||
details: '{"error":"文件大小超过限制","fileSize":"15MB","maxSize":"10MB"}',
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
logType: 'system',
|
||||
operator: 'system',
|
||||
module: '系统维护',
|
||||
operation: '系统自动备份',
|
||||
status: 'success',
|
||||
ipAddress: '127.0.0.1',
|
||||
operateTime: '2023-05-16 23:00:00',
|
||||
details: '{"backupFile":"backup_20230516.zip","size":"256MB"}',
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
logType: 'login',
|
||||
operator: 'user01',
|
||||
module: '用户认证',
|
||||
operation: '用户登录失败',
|
||||
status: 'failed',
|
||||
ipAddress: '192.168.1.102',
|
||||
operateTime: '2023-05-17 08:45:10',
|
||||
details: '{"reason":"密码错误","attempts":3}',
|
||||
},
|
||||
];
|
||||
|
||||
fetchLogList();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.system-logs {
|
||||
.table-search-wrapper {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.table-operations {
|
||||
margin-bottom: 16px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.log-details {
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
background-color: #f5f5f5;
|
||||
padding: 8px;
|
||||
border-radius: 4px;
|
||||
|
||||
pre {
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
590
src/views/Admin/System/Pages/UserAnalysis.vue
Normal file
590
src/views/Admin/System/Pages/UserAnalysis.vue
Normal file
@@ -0,0 +1,590 @@
|
||||
<template>
|
||||
<div class="user-analysis">
|
||||
<a-card title="用户分析" :bordered="false">
|
||||
<!-- 搜索表单 -->
|
||||
<div class="table-search-wrapper">
|
||||
<a-form layout="inline" :model="searchForm">
|
||||
<a-form-item label="时间范围">
|
||||
<a-range-picker v-model:value="searchForm.timeRange" style="width: 240px" />
|
||||
</a-form-item>
|
||||
<a-form-item label="用户类型">
|
||||
<a-select v-model:value="searchForm.userType" placeholder="请选择用户类型" style="width: 150px" allowClear>
|
||||
<a-select-option value="all">全部用户</a-select-option>
|
||||
<a-select-option value="new">新注册用户</a-select-option>
|
||||
<a-select-option value="active">活跃用户</a-select-option>
|
||||
<a-select-option value="inactive">不活跃用户</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="注册来源">
|
||||
<a-select v-model:value="searchForm.registerSource" placeholder="请选择注册来源" style="width: 150px" allowClear>
|
||||
<a-select-option value="web">网站</a-select-option>
|
||||
<a-select-option value="app">移动应用</a-select-option>
|
||||
<a-select-option value="wechat">微信</a-select-option>
|
||||
<a-select-option value="invite">邀请注册</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item>
|
||||
<a-button type="primary" @click="handleSearch">
|
||||
<template #icon>
|
||||
<search-outlined />
|
||||
</template>
|
||||
搜索
|
||||
</a-button>
|
||||
<a-button style="margin-left: 8px" @click="resetSearch">
|
||||
<template #icon>
|
||||
<reload-outlined />
|
||||
</template>
|
||||
重置
|
||||
</a-button>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div class="table-operations">
|
||||
<a-button type="primary" @click="handleExportData">
|
||||
<template #icon>
|
||||
<download-outlined />
|
||||
</template>
|
||||
导出数据
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
<!-- 统计卡片 -->
|
||||
<div class="stat-cards">
|
||||
<a-row :gutter="[16, 16]">
|
||||
<a-col :xs="24" :sm="12" :md="12" :lg="6">
|
||||
<a-card>
|
||||
<template #title>
|
||||
<div class="card-title">
|
||||
<team-outlined class="card-icon user"/>
|
||||
<span>总用户数</span>
|
||||
</div>
|
||||
</template>
|
||||
<div class="card-content">
|
||||
<div class="card-value">{{ formatNumber(statistics.totalUsers) }}</div>
|
||||
<div class="card-footer">
|
||||
<span class="trend up">
|
||||
<arrow-up-outlined/> {{ statistics.userIncrease }}%
|
||||
</span>
|
||||
<span class="period">较上期</span>
|
||||
</div>
|
||||
</div>
|
||||
</a-card>
|
||||
</a-col>
|
||||
|
||||
<a-col :xs="24" :sm="12" :md="12" :lg="6">
|
||||
<a-card>
|
||||
<template #title>
|
||||
<div class="card-title">
|
||||
<user-add-outlined class="card-icon new"/>
|
||||
<span>新增用户</span>
|
||||
</div>
|
||||
</template>
|
||||
<div class="card-content">
|
||||
<div class="card-value">{{ formatNumber(statistics.newUsers) }}</div>
|
||||
<div class="card-footer">
|
||||
<span class="trend up">
|
||||
<arrow-up-outlined/> {{ statistics.newUserIncrease }}%
|
||||
</span>
|
||||
<span class="period">较上期</span>
|
||||
</div>
|
||||
</div>
|
||||
</a-card>
|
||||
</a-col>
|
||||
|
||||
<a-col :xs="24" :sm="12" :md="12" :lg="6">
|
||||
<a-card>
|
||||
<template #title>
|
||||
<div class="card-title">
|
||||
<fire-outlined class="card-icon active"/>
|
||||
<span>活跃用户</span>
|
||||
</div>
|
||||
</template>
|
||||
<div class="card-content">
|
||||
<div class="card-value">{{ formatNumber(statistics.activeUsers) }}</div>
|
||||
<div class="card-footer">
|
||||
<span class="trend up">
|
||||
<arrow-up-outlined/> {{ statistics.activeUserIncrease }}%
|
||||
</span>
|
||||
<span class="period">较上期</span>
|
||||
</div>
|
||||
</div>
|
||||
</a-card>
|
||||
</a-col>
|
||||
|
||||
<a-col :xs="24" :sm="12" :md="12" :lg="6">
|
||||
<a-card>
|
||||
<template #title>
|
||||
<div class="card-title">
|
||||
<interaction-outlined class="card-icon retention"/>
|
||||
<span>留存率</span>
|
||||
</div>
|
||||
</template>
|
||||
<div class="card-content">
|
||||
<div class="card-value">{{ statistics.retentionRate }}%</div>
|
||||
<div class="card-footer">
|
||||
<span class="trend up">
|
||||
<arrow-up-outlined/> {{ statistics.retentionIncrease }}%
|
||||
</span>
|
||||
<span class="period">较上期</span>
|
||||
</div>
|
||||
</div>
|
||||
</a-card>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</div>
|
||||
|
||||
<!-- 图表区域 -->
|
||||
<div class="chart-section" style="margin-top: 16px">
|
||||
<a-row :gutter="[16, 16]">
|
||||
<a-col :xs="24" :sm="24" :md="12">
|
||||
<a-card title="用户增长趋势" :bordered="false">
|
||||
<div class="chart-container" style="height: 350px">
|
||||
<!-- 这里可以集成图表库,如ECharts或AntV -->
|
||||
<div class="chart-placeholder">用户增长趋势图表</div>
|
||||
</div>
|
||||
</a-card>
|
||||
</a-col>
|
||||
<a-col :xs="24" :sm="24" :md="12">
|
||||
<a-card title="用户设备分布" :bordered="false">
|
||||
<div class="chart-container" style="height: 350px">
|
||||
<!-- 这里可以集成图表库,如ECharts或AntV -->
|
||||
<div class="chart-placeholder">用户设备分布图表</div>
|
||||
</div>
|
||||
</a-card>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</div>
|
||||
|
||||
<!-- 用户活跃度分析表格 -->
|
||||
<a-card title="用户活跃度分析" :bordered="false" style="margin-top: 16px">
|
||||
<a-table
|
||||
:columns="activityColumns"
|
||||
:data-source="userActivity"
|
||||
:loading="loading"
|
||||
:pagination="pagination"
|
||||
@change="handleTableChange"
|
||||
rowKey="id"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'activityLevel'">
|
||||
<a-tag :color="getActivityLevelColor(record.activityLevel)">
|
||||
{{ getActivityLevelText(record.activityLevel) }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template v-if="column.key === 'trend'">
|
||||
<span :class="['trend', record.trend > 0 ? 'up' : 'down']">
|
||||
<template v-if="record.trend > 0">
|
||||
<arrow-up-outlined /> +{{ record.trend }}%
|
||||
</template>
|
||||
<template v-else>
|
||||
<arrow-down-outlined /> {{ record.trend }}%
|
||||
</template>
|
||||
</span>
|
||||
</template>
|
||||
<template v-if="column.key === 'action'">
|
||||
<a-button type="link" @click="handleViewUserDetail(record)">详情</a-button>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
</a-card>
|
||||
|
||||
<!-- 用户详情对话框 -->
|
||||
<a-modal
|
||||
v-model:visible="userDetailVisible"
|
||||
title="用户详情"
|
||||
width="700px"
|
||||
:footer="null"
|
||||
>
|
||||
<template v-if="currentUser">
|
||||
<a-descriptions bordered :column="1">
|
||||
<a-descriptions-item label="用户ID">{{ currentUser.id }}</a-descriptions-item>
|
||||
<a-descriptions-item label="用户名">{{ currentUser.username }}</a-descriptions-item>
|
||||
<a-descriptions-item label="注册时间">{{ currentUser.registerTime }}</a-descriptions-item>
|
||||
<a-descriptions-item label="最近登录">{{ currentUser.lastLoginTime }}</a-descriptions-item>
|
||||
<a-descriptions-item label="活跃度">
|
||||
<a-tag :color="getActivityLevelColor(currentUser.activityLevel)">
|
||||
{{ getActivityLevelText(currentUser.activityLevel) }}
|
||||
</a-tag>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="登录设备">{{ currentUser.device }}</a-descriptions-item>
|
||||
<a-descriptions-item label="地域">{{ currentUser.location }}</a-descriptions-item>
|
||||
<a-descriptions-item label="活跃趋势">
|
||||
<div class="chart-container" style="height: 200px">
|
||||
<!-- 这里可以集成图表库,如ECharts或AntV -->
|
||||
<div class="chart-placeholder">用户活跃趋势图表</div>
|
||||
</div>
|
||||
</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
</template>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue';
|
||||
import { message } from 'ant-design-vue';
|
||||
import {
|
||||
SearchOutlined,
|
||||
ReloadOutlined,
|
||||
DownloadOutlined,
|
||||
TeamOutlined,
|
||||
UserAddOutlined,
|
||||
FireOutlined,
|
||||
InteractionOutlined,
|
||||
ArrowUpOutlined,
|
||||
ArrowDownOutlined
|
||||
} from '@ant-design/icons-vue';
|
||||
|
||||
// 搜索表单
|
||||
const searchForm = reactive({
|
||||
timeRange: [],
|
||||
userType: undefined,
|
||||
registerSource: undefined,
|
||||
});
|
||||
|
||||
// 状态数据
|
||||
const loading = ref(false);
|
||||
const pagination = reactive({
|
||||
current: 1,
|
||||
pageSize: 10,
|
||||
total: 0,
|
||||
showSizeChanger: true,
|
||||
showTotal: (total: number) => `共 ${total} 条`,
|
||||
});
|
||||
|
||||
// 统计数据
|
||||
const statistics = reactive({
|
||||
totalUsers: 98765,
|
||||
userIncrease: 12.5,
|
||||
newUsers: 2345,
|
||||
newUserIncrease: 18.2,
|
||||
activeUsers: 45678,
|
||||
activeUserIncrease: 8.7,
|
||||
retentionRate: 76.3,
|
||||
retentionIncrease: 5.2,
|
||||
});
|
||||
|
||||
// 用户活跃度表格列定义
|
||||
const activityColumns = [
|
||||
{
|
||||
title: '用户ID',
|
||||
dataIndex: 'id',
|
||||
key: 'id',
|
||||
width: 80,
|
||||
},
|
||||
{
|
||||
title: '用户名',
|
||||
dataIndex: 'username',
|
||||
key: 'username',
|
||||
},
|
||||
{
|
||||
title: '注册时间',
|
||||
dataIndex: 'registerTime',
|
||||
key: 'registerTime',
|
||||
width: 180,
|
||||
sorter: true,
|
||||
},
|
||||
{
|
||||
title: '最近登录',
|
||||
dataIndex: 'lastLoginTime',
|
||||
key: 'lastLoginTime',
|
||||
width: 180,
|
||||
sorter: true,
|
||||
},
|
||||
{
|
||||
title: '活跃度',
|
||||
dataIndex: 'activityLevel',
|
||||
key: 'activityLevel',
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
title: '登录次数',
|
||||
dataIndex: 'loginCount',
|
||||
key: 'loginCount',
|
||||
width: 100,
|
||||
sorter: true,
|
||||
},
|
||||
{
|
||||
title: '趋势',
|
||||
dataIndex: 'trend',
|
||||
key: 'trend',
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 80,
|
||||
fixed: 'right',
|
||||
},
|
||||
];
|
||||
|
||||
// 用户活跃度数据
|
||||
const userActivity = ref<any[]>([]);
|
||||
|
||||
// 用户详情相关
|
||||
const userDetailVisible = ref(false);
|
||||
const currentUser = ref<any>(null);
|
||||
|
||||
// 格式化数字
|
||||
const formatNumber = (num: number) => {
|
||||
return num >= 10000 ? (num / 10000).toFixed(1) + '万' : num.toString();
|
||||
};
|
||||
|
||||
// 获取活跃度颜色
|
||||
const getActivityLevelColor = (level: string) => {
|
||||
const colorMap: Record<string, string> = {
|
||||
high: 'green',
|
||||
medium: 'blue',
|
||||
low: 'orange',
|
||||
inactive: 'red',
|
||||
};
|
||||
return colorMap[level] || 'default';
|
||||
};
|
||||
|
||||
// 获取活跃度文本
|
||||
const getActivityLevelText = (level: string) => {
|
||||
const textMap: Record<string, string> = {
|
||||
high: '高',
|
||||
medium: '中',
|
||||
low: '低',
|
||||
inactive: '不活跃',
|
||||
};
|
||||
return textMap[level] || '未知';
|
||||
};
|
||||
|
||||
// 表格变化处理
|
||||
const handleTableChange = (pag: any, filters: any, sorter: any) => {
|
||||
pagination.current = pag.current;
|
||||
pagination.pageSize = pag.pageSize;
|
||||
|
||||
// 处理排序
|
||||
const sortField = sorter.field;
|
||||
const sortOrder = sorter.order;
|
||||
|
||||
// 重新加载数据
|
||||
loadUserActivityData(sortField, sortOrder, filters);
|
||||
};
|
||||
|
||||
// 搜索处理
|
||||
const handleSearch = () => {
|
||||
pagination.current = 1;
|
||||
loadUserActivityData();
|
||||
message.success('搜索成功');
|
||||
};
|
||||
|
||||
// 重置搜索
|
||||
const resetSearch = () => {
|
||||
searchForm.timeRange = [];
|
||||
searchForm.userType = undefined;
|
||||
searchForm.registerSource = undefined;
|
||||
pagination.current = 1;
|
||||
loadUserActivityData();
|
||||
message.success('已重置搜索条件');
|
||||
};
|
||||
|
||||
// 导出数据
|
||||
const handleExportData = () => {
|
||||
loading.value = true;
|
||||
|
||||
// 模拟导出数据过程
|
||||
setTimeout(() => {
|
||||
loading.value = false;
|
||||
message.success('数据导出成功');
|
||||
// 实际项目中这里应该调用后端API获取导出文件
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
// 查看用户详情
|
||||
const handleViewUserDetail = (record: any) => {
|
||||
currentUser.value = record;
|
||||
userDetailVisible.value = true;
|
||||
};
|
||||
|
||||
// 加载用户活跃度数据
|
||||
const loadUserActivityData = (sortField?: string, sortOrder?: string, filters?: any) => {
|
||||
loading.value = true;
|
||||
|
||||
// 构建查询参数
|
||||
const params: any = {
|
||||
page: pagination.current,
|
||||
pageSize: pagination.pageSize,
|
||||
sortField,
|
||||
sortOrder,
|
||||
...filters
|
||||
};
|
||||
|
||||
// 添加搜索条件
|
||||
if (searchForm.timeRange && searchForm.timeRange.length === 2) {
|
||||
params.startTime = searchForm.timeRange[0]?.format('YYYY-MM-DD');
|
||||
params.endTime = searchForm.timeRange[1]?.format('YYYY-MM-DD');
|
||||
}
|
||||
|
||||
if (searchForm.userType) {
|
||||
params.userType = searchForm.userType;
|
||||
}
|
||||
|
||||
if (searchForm.registerSource) {
|
||||
params.registerSource = searchForm.registerSource;
|
||||
}
|
||||
|
||||
// 模拟API请求
|
||||
setTimeout(() => {
|
||||
// 模拟数据
|
||||
const mockData = Array.from({ length: 50 }, (_, index) => {
|
||||
const id = index + 1;
|
||||
const activityLevels = ['high', 'medium', 'low', 'inactive'];
|
||||
const activityLevel = activityLevels[Math.floor(Math.random() * activityLevels.length)];
|
||||
const trend = Math.floor(Math.random() * 40) - 20; // -20到20之间的随机数
|
||||
|
||||
return {
|
||||
id,
|
||||
username: `用户${id}`,
|
||||
registerTime: `2023-${Math.floor(Math.random() * 12) + 1}-${Math.floor(Math.random() * 28) + 1}`,
|
||||
lastLoginTime: `2023-${Math.floor(Math.random() * 12) + 1}-${Math.floor(Math.random() * 28) + 1} ${Math.floor(Math.random() * 24)}:${Math.floor(Math.random() * 60)}`,
|
||||
activityLevel,
|
||||
loginCount: Math.floor(Math.random() * 100),
|
||||
trend,
|
||||
device: Math.random() > 0.5 ? '移动端' : '桌面端',
|
||||
location: ['北京', '上海', '广州', '深圳', '杭州'][Math.floor(Math.random() * 5)]
|
||||
};
|
||||
});
|
||||
|
||||
// 应用排序
|
||||
if (sortField && sortOrder) {
|
||||
mockData.sort((a, b) => {
|
||||
const compareResult = a[sortField] > b[sortField] ? 1 : -1;
|
||||
return sortOrder === 'ascend' ? compareResult : -compareResult;
|
||||
});
|
||||
}
|
||||
|
||||
// 应用筛选
|
||||
let filteredData = [...mockData];
|
||||
if (params.userType && params.userType !== 'all') {
|
||||
if (params.userType === 'active') {
|
||||
filteredData = filteredData.filter(item => ['high', 'medium'].includes(item.activityLevel));
|
||||
} else if (params.userType === 'inactive') {
|
||||
filteredData = filteredData.filter(item => ['low', 'inactive'].includes(item.activityLevel));
|
||||
} else if (params.userType === 'new') {
|
||||
// 假设最近30天注册的为新用户
|
||||
const thirtyDaysAgo = new Date();
|
||||
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
|
||||
filteredData = filteredData.filter(item => new Date(item.registerTime) >= thirtyDaysAgo);
|
||||
}
|
||||
}
|
||||
|
||||
// 更新数据和分页
|
||||
userActivity.value = filteredData.slice(
|
||||
(pagination.current - 1) * pagination.pageSize,
|
||||
pagination.current * pagination.pageSize
|
||||
);
|
||||
pagination.total = filteredData.length;
|
||||
loading.value = false;
|
||||
}, 500);
|
||||
};
|
||||
|
||||
// 页面加载时获取数据
|
||||
onMounted(() => {
|
||||
loadUserActivityData();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.user-analysis {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.table-search-wrapper {
|
||||
margin-bottom: 16px;
|
||||
padding: 16px;
|
||||
background-color: #fafafa;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.table-operations {
|
||||
margin-bottom: 16px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.stat-cards {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.card-icon {
|
||||
margin-right: 8px;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.card-icon.user {
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
.card-icon.new {
|
||||
color: #52c41a;
|
||||
}
|
||||
|
||||
.card-icon.active {
|
||||
color: #fa8c16;
|
||||
}
|
||||
|
||||
.card-icon.retention {
|
||||
color: #722ed1;
|
||||
}
|
||||
|
||||
.card-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.card-value {
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.card-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.trend {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.trend.up {
|
||||
color: #52c41a;
|
||||
}
|
||||
|
||||
.trend.down {
|
||||
color: #f5222d;
|
||||
}
|
||||
|
||||
.period {
|
||||
color: #8c8c8c;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.chart-container {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background-color: #f5f5f5;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.chart-placeholder {
|
||||
color: #8c8c8c;
|
||||
font-size: 14px;
|
||||
}
|
||||
</style>
|
438
src/views/Admin/System/Pages/UserList.vue
Normal file
438
src/views/Admin/System/Pages/UserList.vue
Normal file
@@ -0,0 +1,438 @@
|
||||
<template>
|
||||
<div class="user-list-container">
|
||||
<div class="user-list-header">
|
||||
<h2>用户列表</h2>
|
||||
<div class="user-list-header-right">
|
||||
<AButton type="primary" @click="handleAddUser">
|
||||
<template #icon>
|
||||
<PlusOutlined />
|
||||
</template>
|
||||
新增用户
|
||||
</AButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ACard>
|
||||
<div class="table-search-wrapper">
|
||||
<AForm layout="inline" :model="searchForm">
|
||||
<AFormItem label="用户名">
|
||||
<AInput v-model:value="searchForm.username" placeholder="请输入用户名" allowClear />
|
||||
</AFormItem>
|
||||
<AFormItem label="手机号">
|
||||
<AInput v-model:value="searchForm.phone" placeholder="请输入手机号" allowClear />
|
||||
</AFormItem>
|
||||
<AFormItem label="状态">
|
||||
<ASelect v-model:value="searchForm.status" placeholder="请选择状态" style="width: 120px" allowClear>
|
||||
<ASelectOption value="active">正常</ASelectOption>
|
||||
<ASelectOption value="inactive">禁用</ASelectOption>
|
||||
<ASelectOption value="pending">待审核</ASelectOption>
|
||||
</ASelect>
|
||||
</AFormItem>
|
||||
<AFormItem label="角色">
|
||||
<ASelect v-model:value="searchForm.role" placeholder="请选择角色" style="width: 120px" allowClear>
|
||||
<ASelectOption value="admin">管理员</ASelectOption>
|
||||
<ASelectOption value="user">普通用户</ASelectOption>
|
||||
<ASelectOption value="vip">VIP用户</ASelectOption>
|
||||
</ASelect>
|
||||
</AFormItem>
|
||||
<AFormItem>
|
||||
<AButton type="primary" @click="handleSearch">
|
||||
<template #icon>
|
||||
<SearchOutlined />
|
||||
</template>
|
||||
搜索
|
||||
</AButton>
|
||||
<AButton style="margin-left: 8px" @click="resetSearch">
|
||||
<template #icon>
|
||||
<ReloadOutlined />
|
||||
</template>
|
||||
重置
|
||||
</AButton>
|
||||
</AFormItem>
|
||||
</AForm>
|
||||
</div>
|
||||
|
||||
<ATable
|
||||
:columns="columns"
|
||||
:data-source="userList"
|
||||
:loading="loading"
|
||||
:pagination="pagination"
|
||||
@change="handleTableChange"
|
||||
rowKey="id"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'status'">
|
||||
<ATag :color="record.status === 'active' ? 'green' : (record.status === 'inactive' ? 'red' : 'orange')">
|
||||
{{ record.statusText }}
|
||||
</ATag>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'action'">
|
||||
<ASpace>
|
||||
<AButton type="link" size="small" @click="handleEditUser(record)">编辑</AButton>
|
||||
<AButton type="link" size="small" @click="handleViewUser(record)">查看</AButton>
|
||||
<APopconfirm
|
||||
title="确定要删除此用户吗?"
|
||||
ok-text="确定"
|
||||
cancel-text="取消"
|
||||
@confirm="handleDeleteUser(record)"
|
||||
>
|
||||
<AButton type="link" danger size="small">删除</AButton>
|
||||
</APopconfirm>
|
||||
</ASpace>
|
||||
</template>
|
||||
</template>
|
||||
</ATable>
|
||||
</ACard>
|
||||
|
||||
<!-- 用户编辑对话框 -->
|
||||
<AModal
|
||||
v-model:visible="userModalVisible"
|
||||
:title="modalTitle"
|
||||
@ok="handleUserModalOk"
|
||||
@cancel="handleUserModalCancel"
|
||||
:confirmLoading="modalLoading"
|
||||
>
|
||||
<AForm :model="userForm" :rules="userFormRules" ref="userFormRef" :label-col="{ span: 6 }" :wrapper-col="{ span: 16 }">
|
||||
<AFormItem label="用户名" name="username">
|
||||
<AInput v-model:value="userForm.username" placeholder="请输入用户名" />
|
||||
</AFormItem>
|
||||
<AFormItem label="手机号" name="phone">
|
||||
<AInput v-model:value="userForm.phone" placeholder="请输入手机号" />
|
||||
</AFormItem>
|
||||
<AFormItem label="邮箱" name="email">
|
||||
<AInput v-model:value="userForm.email" placeholder="请输入邮箱" />
|
||||
</AFormItem>
|
||||
<AFormItem label="角色" name="role">
|
||||
<ASelect v-model:value="userForm.role" placeholder="请选择角色">
|
||||
<ASelectOption value="admin">管理员</ASelectOption>
|
||||
<ASelectOption value="user">普通用户</ASelectOption>
|
||||
<ASelectOption value="vip">VIP用户</ASelectOption>
|
||||
</ASelect>
|
||||
</AFormItem>
|
||||
<AFormItem label="状态" name="status">
|
||||
<ASelect v-model:value="userForm.status" placeholder="请选择状态">
|
||||
<ASelectOption value="active">正常</ASelectOption>
|
||||
<ASelectOption value="inactive">禁用</ASelectOption>
|
||||
<ASelectOption value="pending">待审核</ASelectOption>
|
||||
</ASelect>
|
||||
</AFormItem>
|
||||
</AForm>
|
||||
</AModal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue';
|
||||
import { message } from 'ant-design-vue';
|
||||
import {
|
||||
PlusOutlined,
|
||||
SearchOutlined,
|
||||
ReloadOutlined
|
||||
} from '@ant-design/icons-vue';
|
||||
|
||||
// 搜索表单
|
||||
const searchForm = reactive({
|
||||
username: '',
|
||||
phone: '',
|
||||
status: undefined,
|
||||
role: undefined
|
||||
});
|
||||
|
||||
// 表格加载状态
|
||||
const loading = ref(false);
|
||||
|
||||
// 表格分页配置
|
||||
const pagination = reactive({
|
||||
current: 1,
|
||||
pageSize: 10,
|
||||
total: 0,
|
||||
showSizeChanger: true,
|
||||
showQuickJumper: true,
|
||||
showTotal: (total: number) => `共 ${total} 条记录`
|
||||
});
|
||||
|
||||
// 表格列定义
|
||||
const columns = [
|
||||
{
|
||||
title: 'ID',
|
||||
dataIndex: 'id',
|
||||
key: 'id',
|
||||
width: 80
|
||||
},
|
||||
{
|
||||
title: '用户名',
|
||||
dataIndex: 'username',
|
||||
key: 'username'
|
||||
},
|
||||
{
|
||||
title: '手机号',
|
||||
dataIndex: 'phone',
|
||||
key: 'phone'
|
||||
},
|
||||
{
|
||||
title: '邮箱',
|
||||
dataIndex: 'email',
|
||||
key: 'email'
|
||||
},
|
||||
{
|
||||
title: '角色',
|
||||
dataIndex: 'roleText',
|
||||
key: 'role'
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'statusText',
|
||||
key: 'status'
|
||||
},
|
||||
{
|
||||
title: '注册时间',
|
||||
dataIndex: 'createTime',
|
||||
key: 'createTime',
|
||||
sorter: true
|
||||
},
|
||||
{
|
||||
title: '最后登录',
|
||||
dataIndex: 'lastLoginTime',
|
||||
key: 'lastLoginTime',
|
||||
sorter: true
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 200
|
||||
}
|
||||
];
|
||||
|
||||
// 模拟用户数据
|
||||
const userList = ref<any[]>([
|
||||
{
|
||||
id: 1,
|
||||
username: 'admin',
|
||||
phone: '13800138000',
|
||||
email: 'admin@example.com',
|
||||
role: 'admin',
|
||||
roleText: '管理员',
|
||||
status: 'active',
|
||||
statusText: '正常',
|
||||
createTime: '2023-01-01 12:00:00',
|
||||
lastLoginTime: '2023-05-20 08:30:00'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
username: 'zhangsan',
|
||||
phone: '13800138001',
|
||||
email: 'zhangsan@example.com',
|
||||
role: 'user',
|
||||
roleText: '普通用户',
|
||||
status: 'active',
|
||||
statusText: '正常',
|
||||
createTime: '2023-01-05 10:20:00',
|
||||
lastLoginTime: '2023-05-19 15:40:00'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
username: 'lisi',
|
||||
phone: '13800138002',
|
||||
email: 'lisi@example.com',
|
||||
role: 'vip',
|
||||
roleText: 'VIP用户',
|
||||
status: 'inactive',
|
||||
statusText: '禁用',
|
||||
createTime: '2023-02-15 09:30:00',
|
||||
lastLoginTime: '2023-04-10 11:20:00'
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
username: 'wangwu',
|
||||
phone: '13800138003',
|
||||
email: 'wangwu@example.com',
|
||||
role: 'user',
|
||||
roleText: '普通用户',
|
||||
status: 'pending',
|
||||
statusText: '待审核',
|
||||
createTime: '2023-03-20 14:50:00',
|
||||
lastLoginTime: '2023-03-20 14:50:00'
|
||||
}
|
||||
]);
|
||||
|
||||
// 用户表单相关
|
||||
const userModalVisible = ref(false);
|
||||
const modalTitle = ref('新增用户');
|
||||
const modalLoading = ref(false);
|
||||
const userFormRef = ref();
|
||||
const userForm = reactive({
|
||||
id: undefined,
|
||||
username: '',
|
||||
phone: '',
|
||||
email: '',
|
||||
role: 'user',
|
||||
status: 'active'
|
||||
});
|
||||
|
||||
// 表单验证规则
|
||||
const userFormRules = {
|
||||
username: [
|
||||
{ required: true, message: '请输入用户名', trigger: 'blur' },
|
||||
{ min: 3, max: 20, message: '用户名长度应为3-20个字符', trigger: 'blur' }
|
||||
],
|
||||
phone: [
|
||||
{ required: true, message: '请输入手机号', trigger: 'blur' },
|
||||
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号', trigger: 'blur' }
|
||||
],
|
||||
email: [
|
||||
{ required: true, message: '请输入邮箱', trigger: 'blur' },
|
||||
{ type: 'email', message: '请输入正确的邮箱格式', trigger: 'blur' }
|
||||
],
|
||||
role: [
|
||||
{ required: true, message: '请选择角色', trigger: 'change' }
|
||||
],
|
||||
status: [
|
||||
{ required: true, message: '请选择状态', trigger: 'change' }
|
||||
]
|
||||
};
|
||||
|
||||
// 搜索处理
|
||||
const handleSearch = () => {
|
||||
pagination.current = 1;
|
||||
fetchUserList();
|
||||
};
|
||||
|
||||
// 重置搜索
|
||||
const resetSearch = () => {
|
||||
searchForm.username = '';
|
||||
searchForm.phone = '';
|
||||
searchForm.status = undefined;
|
||||
searchForm.role = undefined;
|
||||
handleSearch();
|
||||
};
|
||||
|
||||
// 表格变化处理(分页、排序、筛选)
|
||||
const handleTableChange = (pag: any, _filters: any, _sorter: any) => {
|
||||
pagination.current = pag.current;
|
||||
pagination.pageSize = pag.pageSize;
|
||||
fetchUserList();
|
||||
};
|
||||
|
||||
// 获取用户列表数据
|
||||
const fetchUserList = () => {
|
||||
loading.value = true;
|
||||
// 这里应该是实际的API调用
|
||||
setTimeout(() => {
|
||||
// 模拟API响应
|
||||
pagination.total = userList.value.length;
|
||||
loading.value = false;
|
||||
}, 500);
|
||||
};
|
||||
|
||||
// 新增用户
|
||||
const handleAddUser = () => {
|
||||
modalTitle.value = '新增用户';
|
||||
userForm.id = undefined;
|
||||
userForm.username = '';
|
||||
userForm.phone = '';
|
||||
userForm.email = '';
|
||||
userForm.role = 'user';
|
||||
userForm.status = 'active';
|
||||
userModalVisible.value = true;
|
||||
};
|
||||
|
||||
// 编辑用户
|
||||
const handleEditUser = (record: any) => {
|
||||
modalTitle.value = '编辑用户';
|
||||
userForm.id = record.id;
|
||||
userForm.username = record.username;
|
||||
userForm.phone = record.phone;
|
||||
userForm.email = record.email;
|
||||
userForm.role = record.role;
|
||||
userForm.status = record.status;
|
||||
userModalVisible.value = true;
|
||||
};
|
||||
|
||||
// 查看用户详情
|
||||
const handleViewUser = (record: any) => {
|
||||
message.info(`查看用户:${record.username}`);
|
||||
// 这里可以跳转到用户详情页或打开详情对话框
|
||||
};
|
||||
|
||||
// 删除用户
|
||||
const handleDeleteUser = (record: any) => {
|
||||
message.success(`删除用户:${record.username}`);
|
||||
// 实际应用中应该调用删除API
|
||||
userList.value = userList.value.filter(item => item.id !== record.id);
|
||||
};
|
||||
|
||||
// 用户表单提交
|
||||
const handleUserModalOk = () => {
|
||||
userFormRef.value.validate().then(() => {
|
||||
modalLoading.value = true;
|
||||
// 这里应该是实际的API调用
|
||||
setTimeout(() => {
|
||||
if (userForm.id) {
|
||||
// 编辑现有用户
|
||||
const index = userList.value.findIndex(item => item.id === userForm.id);
|
||||
if (index !== -1) {
|
||||
const roleText = userForm.role === 'admin' ? '管理员' : (userForm.role === 'vip' ? 'VIP用户' : '普通用户');
|
||||
const statusText = userForm.status === 'active' ? '正常' : (userForm.status === 'inactive' ? '禁用' : '待审核');
|
||||
userList.value[index] = {
|
||||
...userList.value[index],
|
||||
...userForm,
|
||||
roleText,
|
||||
statusText
|
||||
};
|
||||
message.success('用户编辑成功');
|
||||
}
|
||||
} else {
|
||||
// 新增用户
|
||||
const newId = 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,
|
||||
...userForm,
|
||||
roleText,
|
||||
statusText,
|
||||
createTime: now,
|
||||
lastLoginTime: now
|
||||
});
|
||||
message.success('用户添加成功');
|
||||
}
|
||||
userModalVisible.value = false;
|
||||
modalLoading.value = false;
|
||||
}, 500);
|
||||
}).catch(() => {
|
||||
// 表单验证失败
|
||||
});
|
||||
};
|
||||
|
||||
// 取消用户表单
|
||||
const handleUserModalCancel = () => {
|
||||
userModalVisible.value = false;
|
||||
};
|
||||
|
||||
// 初始化
|
||||
onMounted(() => {
|
||||
fetchUserList();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.user-list-container {
|
||||
.user-list-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.table-search-wrapper {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
}
|
||||
</style>
|
602
src/views/Admin/System/Pages/VisitStatistics.vue
Normal file
602
src/views/Admin/System/Pages/VisitStatistics.vue
Normal file
@@ -0,0 +1,602 @@
|
||||
<template>
|
||||
<div class="visit-statistics">
|
||||
<a-card title="访问统计" :bordered="false">
|
||||
<!-- 搜索表单 -->
|
||||
<div class="table-search-wrapper">
|
||||
<a-form layout="inline" :model="searchForm">
|
||||
<a-form-item label="时间范围">
|
||||
<a-range-picker v-model:value="searchForm.timeRange" style="width: 240px" />
|
||||
</a-form-item>
|
||||
<a-form-item label="访问来源">
|
||||
<a-select v-model:value="searchForm.source" placeholder="请选择访问来源" style="width: 150px" allowClear>
|
||||
<a-select-option value="all">全部来源</a-select-option>
|
||||
<a-select-option value="direct">直接访问</a-select-option>
|
||||
<a-select-option value="search">搜索引擎</a-select-option>
|
||||
<a-select-option value="social">社交媒体</a-select-option>
|
||||
<a-select-option value="referral">外部链接</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="设备类型">
|
||||
<a-select v-model:value="searchForm.device" placeholder="请选择设备类型" style="width: 150px" allowClear>
|
||||
<a-select-option value="all">全部设备</a-select-option>
|
||||
<a-select-option value="desktop">桌面端</a-select-option>
|
||||
<a-select-option value="mobile">移动端</a-select-option>
|
||||
<a-select-option value="tablet">平板设备</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item>
|
||||
<a-button type="primary" @click="handleSearch">
|
||||
<template #icon>
|
||||
<search-outlined />
|
||||
</template>
|
||||
搜索
|
||||
</a-button>
|
||||
<a-button style="margin-left: 8px" @click="resetSearch">
|
||||
<template #icon>
|
||||
<reload-outlined />
|
||||
</template>
|
||||
重置
|
||||
</a-button>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div class="table-operations">
|
||||
<a-button type="primary" @click="handleExportData">
|
||||
<template #icon>
|
||||
<download-outlined />
|
||||
</template>
|
||||
导出数据
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
<!-- 统计卡片 -->
|
||||
<div class="stat-cards">
|
||||
<a-row :gutter="[16, 16]">
|
||||
<a-col :xs="24" :sm="12" :md="12" :lg="6">
|
||||
<a-card>
|
||||
<template #title>
|
||||
<div class="card-title">
|
||||
<eye-outlined class="card-icon visits"/>
|
||||
<span>总访问量</span>
|
||||
</div>
|
||||
</template>
|
||||
<div class="card-content">
|
||||
<div class="card-value">{{ formatNumber(statistics.totalVisits) }}</div>
|
||||
<div class="card-footer">
|
||||
<span class="trend up">
|
||||
<arrow-up-outlined/> {{ statistics.visitsIncrease }}%
|
||||
</span>
|
||||
<span class="period">较上期</span>
|
||||
</div>
|
||||
</div>
|
||||
</a-card>
|
||||
</a-col>
|
||||
|
||||
<a-col :xs="24" :sm="12" :md="12" :lg="6">
|
||||
<a-card>
|
||||
<template #title>
|
||||
<div class="card-title">
|
||||
<user-outlined class="card-icon visitors"/>
|
||||
<span>访客数</span>
|
||||
</div>
|
||||
</template>
|
||||
<div class="card-content">
|
||||
<div class="card-value">{{ formatNumber(statistics.uniqueVisitors) }}</div>
|
||||
<div class="card-footer">
|
||||
<span class="trend up">
|
||||
<arrow-up-outlined/> {{ statistics.visitorsIncrease }}%
|
||||
</span>
|
||||
<span class="period">较上期</span>
|
||||
</div>
|
||||
</div>
|
||||
</a-card>
|
||||
</a-col>
|
||||
|
||||
<a-col :xs="24" :sm="12" :md="12" :lg="6">
|
||||
<a-card>
|
||||
<template #title>
|
||||
<div class="card-title">
|
||||
<clock-circle-outlined class="card-icon duration"/>
|
||||
<span>平均停留时间</span>
|
||||
</div>
|
||||
</template>
|
||||
<div class="card-content">
|
||||
<div class="card-value">{{ statistics.avgDuration }}分钟</div>
|
||||
<div class="card-footer">
|
||||
<span class="trend up">
|
||||
<arrow-up-outlined/> {{ statistics.durationIncrease }}%
|
||||
</span>
|
||||
<span class="period">较上期</span>
|
||||
</div>
|
||||
</div>
|
||||
</a-card>
|
||||
</a-col>
|
||||
|
||||
<a-col :xs="24" :sm="12" :md="12" :lg="6">
|
||||
<a-card>
|
||||
<template #title>
|
||||
<div class="card-title">
|
||||
<percentage-outlined class="card-icon bounce"/>
|
||||
<span>跳出率</span>
|
||||
</div>
|
||||
</template>
|
||||
<div class="card-content">
|
||||
<div class="card-value">{{ statistics.bounceRate }}%</div>
|
||||
<div class="card-footer">
|
||||
<span class="trend down">
|
||||
<arrow-down-outlined/> {{ statistics.bounceDecrease }}%
|
||||
</span>
|
||||
<span class="period">较上期</span>
|
||||
</div>
|
||||
</div>
|
||||
</a-card>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</div>
|
||||
|
||||
<!-- 图表区域 -->
|
||||
<div class="chart-section" style="margin-top: 16px">
|
||||
<a-row :gutter="[16, 16]">
|
||||
<a-col :xs="24" :sm="24" :md="12">
|
||||
<a-card title="访问量趋势" :bordered="false">
|
||||
<div class="chart-container" style="height: 350px">
|
||||
<!-- 这里可以集成图表库,如ECharts或AntV -->
|
||||
<div class="chart-placeholder">访问量趋势图表</div>
|
||||
</div>
|
||||
</a-card>
|
||||
</a-col>
|
||||
<a-col :xs="24" :sm="24" :md="12">
|
||||
<a-card title="访问来源分布" :bordered="false">
|
||||
<div class="chart-container" style="height: 350px">
|
||||
<!-- 这里可以集成图表库,如ECharts或AntV -->
|
||||
<div class="chart-placeholder">访问来源分布图表</div>
|
||||
</div>
|
||||
</a-card>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</div>
|
||||
|
||||
<!-- 访问记录表格 -->
|
||||
<a-card title="访问记录分析" :bordered="false" style="margin-top: 16px">
|
||||
<a-table
|
||||
:columns="visitColumns"
|
||||
:data-source="visitRecords"
|
||||
:loading="loading"
|
||||
:pagination="pagination"
|
||||
@change="handleTableChange"
|
||||
rowKey="id"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'source'">
|
||||
<a-tag :color="getSourceColor(record.source)">
|
||||
{{ getSourceText(record.source) }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template v-if="column.key === 'device'">
|
||||
<span>
|
||||
<template v-if="record.device === 'desktop'">
|
||||
<laptop-outlined /> 桌面端
|
||||
</template>
|
||||
<template v-else-if="record.device === 'mobile'">
|
||||
<mobile-outlined /> 移动端
|
||||
</template>
|
||||
<template v-else-if="record.device === 'tablet'">
|
||||
<tablet-outlined /> 平板设备
|
||||
</template>
|
||||
<template v-else>
|
||||
<question-circle-outlined /> 未知
|
||||
</template>
|
||||
</span>
|
||||
</template>
|
||||
<template v-if="column.key === 'action'">
|
||||
<a-button type="link" @click="handleViewVisitDetail(record)">详情</a-button>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
</a-card>
|
||||
|
||||
<!-- 访问详情对话框 -->
|
||||
<a-modal
|
||||
v-model:visible="visitDetailVisible"
|
||||
title="访问详情"
|
||||
width="700px"
|
||||
:footer="null"
|
||||
>
|
||||
<template v-if="currentVisit">
|
||||
<a-descriptions bordered :column="1">
|
||||
<a-descriptions-item label="访问ID">{{ currentVisit.id }}</a-descriptions-item>
|
||||
<a-descriptions-item label="IP地址">{{ currentVisit.ipAddress }}</a-descriptions-item>
|
||||
<a-descriptions-item label="访问时间">{{ currentVisit.visitTime }}</a-descriptions-item>
|
||||
<a-descriptions-item label="停留时间">{{ currentVisit.duration }}分钟</a-descriptions-item>
|
||||
<a-descriptions-item label="访问来源">
|
||||
<a-tag :color="getSourceColor(currentVisit.source)">
|
||||
{{ getSourceText(currentVisit.source) }}
|
||||
</a-tag>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="设备信息">{{ currentVisit.deviceInfo }}</a-descriptions-item>
|
||||
<a-descriptions-item label="浏览器">{{ currentVisit.browser }}</a-descriptions-item>
|
||||
<a-descriptions-item label="操作系统">{{ currentVisit.os }}</a-descriptions-item>
|
||||
<a-descriptions-item label="地域">{{ currentVisit.location }}</a-descriptions-item>
|
||||
<a-descriptions-item label="访问页面">{{ currentVisit.visitedPage }}</a-descriptions-item>
|
||||
<a-descriptions-item label="访问路径">
|
||||
<div class="chart-container" style="height: 200px">
|
||||
<!-- 这里可以集成图表库,如ECharts或AntV -->
|
||||
<div class="chart-placeholder">用户访问路径图表</div>
|
||||
</div>
|
||||
</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
</template>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue';
|
||||
import { message } from 'ant-design-vue';
|
||||
import {
|
||||
SearchOutlined,
|
||||
ReloadOutlined,
|
||||
DownloadOutlined,
|
||||
EyeOutlined,
|
||||
UserOutlined,
|
||||
ClockCircleOutlined,
|
||||
PercentageOutlined,
|
||||
ArrowUpOutlined,
|
||||
ArrowDownOutlined,
|
||||
LaptopOutlined,
|
||||
MobileOutlined,
|
||||
TabletOutlined,
|
||||
QuestionCircleOutlined
|
||||
} from '@ant-design/icons-vue';
|
||||
|
||||
// 搜索表单
|
||||
const searchForm = reactive<any>({
|
||||
timeRange: [],
|
||||
source: undefined,
|
||||
device: undefined,
|
||||
});
|
||||
|
||||
// 状态数据
|
||||
const loading = ref(false);
|
||||
const pagination = reactive({
|
||||
current: 1,
|
||||
pageSize: 10,
|
||||
total: 0,
|
||||
showSizeChanger: true,
|
||||
showTotal: (total: number) => `共 ${total} 条`,
|
||||
});
|
||||
|
||||
// 统计数据
|
||||
const statistics = reactive({
|
||||
totalVisits: 125678,
|
||||
visitsIncrease: 15.3,
|
||||
uniqueVisitors: 45890,
|
||||
visitorsIncrease: 12.8,
|
||||
avgDuration: 4.5,
|
||||
durationIncrease: 8.2,
|
||||
bounceRate: 35.7,
|
||||
bounceDecrease: 5.4,
|
||||
});
|
||||
|
||||
// 访问记录表格列定义
|
||||
const visitColumns = [
|
||||
{
|
||||
title: '访问ID',
|
||||
dataIndex: 'id',
|
||||
key: 'id',
|
||||
width: 80,
|
||||
},
|
||||
{
|
||||
title: 'IP地址',
|
||||
dataIndex: 'ipAddress',
|
||||
key: 'ipAddress',
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
title: '访问时间',
|
||||
dataIndex: 'visitTime',
|
||||
key: 'visitTime',
|
||||
width: 180,
|
||||
sorter: true,
|
||||
},
|
||||
{
|
||||
title: '停留时间(分钟)',
|
||||
dataIndex: 'duration',
|
||||
key: 'duration',
|
||||
width: 120,
|
||||
sorter: true,
|
||||
},
|
||||
{
|
||||
title: '访问来源',
|
||||
dataIndex: 'source',
|
||||
key: 'source',
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
title: '设备类型',
|
||||
dataIndex: 'device',
|
||||
key: 'device',
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
title: '地域',
|
||||
dataIndex: 'location',
|
||||
key: 'location',
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
title: '访问页面',
|
||||
dataIndex: 'visitedPage',
|
||||
key: 'visitedPage',
|
||||
ellipsis: true,
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 80,
|
||||
fixed: 'right',
|
||||
},
|
||||
];
|
||||
|
||||
// 访问记录数据
|
||||
const visitRecords = ref<any[]>([]);
|
||||
|
||||
// 访问详情相关
|
||||
const visitDetailVisible = ref(false);
|
||||
const currentVisit = ref<any>(null);
|
||||
|
||||
// 格式化数字
|
||||
const formatNumber = (num: number) => {
|
||||
return num >= 10000 ? (num / 10000).toFixed(1) + '万' : num.toString();
|
||||
};
|
||||
|
||||
// 获取来源颜色
|
||||
const getSourceColor = (source: string) => {
|
||||
const colorMap: Record<string, string> = {
|
||||
direct: 'blue',
|
||||
search: 'green',
|
||||
social: 'purple',
|
||||
referral: 'orange',
|
||||
};
|
||||
return colorMap[source] || 'default';
|
||||
};
|
||||
|
||||
// 获取来源文本
|
||||
const getSourceText = (source: string) => {
|
||||
const textMap: Record<string, string> = {
|
||||
direct: '直接访问',
|
||||
search: '搜索引擎',
|
||||
social: '社交媒体',
|
||||
referral: '外部链接',
|
||||
};
|
||||
return textMap[source] || '未知';
|
||||
};
|
||||
|
||||
// 表格变化处理
|
||||
const handleTableChange = (pag: any, _filters: any, sorter: any) => {
|
||||
pagination.current = pag.current;
|
||||
pagination.pageSize = pag.pageSize;
|
||||
|
||||
// 处理排序
|
||||
const sortField = sorter.field;
|
||||
const sortOrder = sorter.order;
|
||||
|
||||
// 重新加载数据
|
||||
loadVisitRecordsData(sortField, sortOrder);
|
||||
};
|
||||
|
||||
// 加载访问记录数据
|
||||
const loadVisitRecordsData = (sortField?: string, sortOrder?: string) => {
|
||||
loading.value = true;
|
||||
|
||||
// 构建查询参数
|
||||
const params: any = {
|
||||
page: pagination.current,
|
||||
pageSize: pagination.pageSize,
|
||||
sortField,
|
||||
sortOrder,
|
||||
};
|
||||
|
||||
// 添加搜索条件
|
||||
if (searchForm.timeRange && searchForm.timeRange.length === 2) {
|
||||
params.startTime = searchForm.timeRange[0]?.format('YYYY-MM-DD');
|
||||
params.endTime = searchForm.timeRange[1]?.format('YYYY-MM-DD');
|
||||
}
|
||||
|
||||
if (searchForm.source && searchForm.source !== 'all') {
|
||||
params.source = searchForm.source;
|
||||
}
|
||||
|
||||
if (searchForm.device && searchForm.device !== 'all') {
|
||||
params.device = searchForm.device;
|
||||
}
|
||||
|
||||
// 模拟API请求
|
||||
setTimeout(() => {
|
||||
// 模拟数据,实际项目中应该通过API获取
|
||||
const mockData = Array.from({ length: 50 }, (_, index) => ({
|
||||
id: `VIS${100000 + index}`,
|
||||
ipAddress: `192.168.${Math.floor(Math.random() * 255)}.${Math.floor(Math.random() * 255)}`,
|
||||
visitTime: new Date(Date.now() - Math.floor(Math.random() * 30) * 24 * 60 * 60 * 1000).toLocaleString(),
|
||||
duration: Math.floor(Math.random() * 30) + 1,
|
||||
source: ['direct', 'search', 'social', 'referral'][Math.floor(Math.random() * 4)],
|
||||
device: ['desktop', 'mobile', 'tablet'][Math.floor(Math.random() * 3)],
|
||||
location: ['北京', '上海', '广州', '深圳', '杭州', '成都'][Math.floor(Math.random() * 6)],
|
||||
visitedPage: ['/home', '/album', '/photo', '/user', '/about'][Math.floor(Math.random() * 5)],
|
||||
browser: ['Chrome', 'Firefox', 'Safari', 'Edge'][Math.floor(Math.random() * 4)],
|
||||
os: ['Windows', 'MacOS', 'iOS', 'Android', 'Linux'][Math.floor(Math.random() * 5)],
|
||||
deviceInfo: `${['iPhone', 'Samsung', 'Huawei', 'Xiaomi'][Math.floor(Math.random() * 4)]} ${Math.floor(Math.random() * 10) + 10}`,
|
||||
}));
|
||||
|
||||
// 过滤数据
|
||||
let filteredData = [...mockData];
|
||||
|
||||
// 根据搜索条件过滤
|
||||
if (params.source) {
|
||||
filteredData = filteredData.filter(item => item.source === params.source);
|
||||
}
|
||||
|
||||
if (params.device) {
|
||||
filteredData = filteredData.filter(item => item.device === params.device);
|
||||
}
|
||||
|
||||
// 排序
|
||||
if (sortField && sortOrder) {
|
||||
filteredData.sort((a, b) => {
|
||||
const factor = sortOrder === 'ascend' ? 1 : -1;
|
||||
if (sortField === 'duration') {
|
||||
return (a.duration - b.duration) * factor;
|
||||
} else if (sortField === 'visitTime') {
|
||||
return (new Date(a.visitTime).getTime() - new Date(b.visitTime).getTime()) * factor;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
|
||||
// 分页
|
||||
const start = (params.page - 1) * params.pageSize;
|
||||
const end = start + params.pageSize;
|
||||
const paginatedData = filteredData.slice(start, end);
|
||||
|
||||
visitRecords.value = paginatedData;
|
||||
pagination.total = filteredData.length;
|
||||
loading.value = false;
|
||||
}, 500);
|
||||
};
|
||||
|
||||
// 搜索处理
|
||||
const handleSearch = () => {
|
||||
pagination.current = 1; // 重置到第一页
|
||||
loadVisitRecordsData();
|
||||
};
|
||||
|
||||
// 重置搜索
|
||||
const resetSearch = () => {
|
||||
searchForm.timeRange = [];
|
||||
searchForm.source = undefined;
|
||||
searchForm.device = undefined;
|
||||
pagination.current = 1;
|
||||
loadVisitRecordsData();
|
||||
};
|
||||
|
||||
// 导出数据
|
||||
const handleExportData = () => {
|
||||
loading.value = true;
|
||||
|
||||
// 模拟导出过程
|
||||
setTimeout(() => {
|
||||
message.success('数据导出成功');
|
||||
loading.value = false;
|
||||
}, 1000);
|
||||
|
||||
// 实际项目中,这里应该调用后端API导出数据
|
||||
// 可以使用window.open或创建一个隐藏的form提交来触发文件下载
|
||||
};
|
||||
|
||||
// 查看访问详情
|
||||
const handleViewVisitDetail = (record: any) => {
|
||||
currentVisit.value = record;
|
||||
visitDetailVisible.value = true;
|
||||
};
|
||||
|
||||
// 初始化加载数据
|
||||
onMounted(() => {
|
||||
loadVisitRecordsData();
|
||||
});
|
||||
|
||||
// 添加样式
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.visit-statistics {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.table-search-wrapper {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.table-operations {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.stat-cards {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.card-icon {
|
||||
margin-right: 8px;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.card-icon.visits {
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
.card-icon.visitors {
|
||||
color: #52c41a;
|
||||
}
|
||||
|
||||
.card-icon.duration {
|
||||
color: #722ed1;
|
||||
}
|
||||
|
||||
.card-icon.bounce {
|
||||
color: #fa8c16;
|
||||
}
|
||||
|
||||
.card-content {
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.card-value {
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.card-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.trend {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.trend.up {
|
||||
color: #52c41a;
|
||||
}
|
||||
|
||||
.trend.down {
|
||||
color: #f5222d;
|
||||
}
|
||||
|
||||
.period {
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.chart-container {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background-color: #f5f5f5;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.chart-placeholder {
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
</style>
|
298
src/views/Preview/PreviewBlurDetect.vue
Normal file
298
src/views/Preview/PreviewBlurDetect.vue
Normal file
@@ -0,0 +1,298 @@
|
||||
<template>
|
||||
<div class="blur-detection-test">
|
||||
<a-card class="main-card">
|
||||
<template #title>
|
||||
<div class="card-title">
|
||||
<experiment-outlined />
|
||||
<span>图像模糊检测</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<a-row :gutter="[16, 16]">
|
||||
<a-col :span="24">
|
||||
<a-alert type="info" show-icon>
|
||||
<template #message>上传图片进行模糊度检测,系统将自动分析图片清晰度</template>
|
||||
<template #description>支持JPG、PNG等常见图片格式</template>
|
||||
</a-alert>
|
||||
</a-col>
|
||||
|
||||
<a-col :span="24">
|
||||
<a-form layout="inline" class="threshold-form">
|
||||
<a-form-item label="模糊度阈值">
|
||||
<a-input-number
|
||||
v-model:value="threshold"
|
||||
:min="50"
|
||||
:max="500"
|
||||
style="width: 120px"
|
||||
@change="handleThresholdChange"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item>
|
||||
<a-tooltip title="值越小越容易被判定为模糊图片">
|
||||
<info-circle-outlined />
|
||||
</a-tooltip>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<div class="upload-container">
|
||||
<a-upload
|
||||
v-model:file-list="fileList"
|
||||
list-type="picture-card"
|
||||
:before-upload="beforeUpload"
|
||||
@preview="handlePreview"
|
||||
@remove="handleRemove"
|
||||
>
|
||||
<div v-if="fileList.length < 5">
|
||||
<plus-outlined />
|
||||
<div style="margin-top: 8px">上传图片</div>
|
||||
</div>
|
||||
</a-upload>
|
||||
<a-modal :open="previewVisible" :title="previewTitle" :footer="null" @cancel="handleCancel">
|
||||
<img alt="预览图片" style="width: 100%" :src="previewImage" />
|
||||
</a-modal>
|
||||
</div>
|
||||
|
||||
<div v-if="detectionResults.length > 0" class="results-container">
|
||||
<a-divider>检测结果</a-divider>
|
||||
<a-table :dataSource="detectionResults" :columns="columns" :pagination="false" bordered>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'status'">
|
||||
<a-tag :color="record.isBlurry ? 'red' : 'green'">
|
||||
{{ record.isBlurry ? '模糊' : '清晰' }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template v-if="column.key === 'performance'">
|
||||
<a-statistic
|
||||
:value="record.processTime"
|
||||
:precision="2"
|
||||
suffix="ms"
|
||||
:value-style="{ fontSize: '14px', color: record.processTime > 200 ? '#cf1322' : '#3f8600' }"
|
||||
/>
|
||||
</template>
|
||||
<template v-if="column.key === 'blurValue'">
|
||||
<a-progress
|
||||
:percent="Math.min(100, Math.round(record.blurValue / 5))"
|
||||
:stroke-color="record.isBlurry ? '#ff4d4f' : '#52c41a'"
|
||||
size="small"
|
||||
/>
|
||||
<div>{{ record.blurValue }}</div>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</div>
|
||||
</a-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { PlusOutlined, ExperimentOutlined, InfoCircleOutlined } from '@ant-design/icons-vue';
|
||||
import { message } from 'ant-design-vue';
|
||||
import type { UploadProps } from 'ant-design-vue';
|
||||
import { initBlurDetect, detectBlurFromFile } from '@/utils/imageBlurDetect/blurDetect';
|
||||
|
||||
// 初始化模糊检测模块
|
||||
onMounted(async () => {
|
||||
try {
|
||||
await initBlurDetect();
|
||||
message.success('模糊检测模块初始化成功');
|
||||
} catch (error) {
|
||||
message.error('模糊检测模块初始化失败');
|
||||
console.error(error);
|
||||
}
|
||||
});
|
||||
|
||||
// 文件列表
|
||||
const fileList = ref<any[]>([]);
|
||||
|
||||
// 预览相关状态
|
||||
const previewVisible = ref<boolean>(false);
|
||||
const previewImage = ref<string>('');
|
||||
const previewTitle = ref<string>('');
|
||||
|
||||
// 阈值设置
|
||||
const threshold = ref<number>(200); // 默认阈值
|
||||
|
||||
// 检测结果
|
||||
interface DetectionResult {
|
||||
key: string;
|
||||
fileName: string;
|
||||
blurValue: number;
|
||||
isBlurry: boolean;
|
||||
threshold: number;
|
||||
processTime: number; // 处理时间(毫秒)
|
||||
fileSize: number; // 文件大小(字节)
|
||||
}
|
||||
|
||||
const detectionResults = ref<DetectionResult[]>([]);
|
||||
|
||||
// 表格列定义
|
||||
const columns = [
|
||||
{
|
||||
title: '文件名',
|
||||
dataIndex: 'fileName',
|
||||
key: 'fileName',
|
||||
width: '20%',
|
||||
},
|
||||
{
|
||||
title: '模糊度值',
|
||||
dataIndex: 'blurValue',
|
||||
key: 'blurValue',
|
||||
sorter: (a: DetectionResult, b: DetectionResult) => a.blurValue - b.blurValue,
|
||||
width: '20%',
|
||||
},
|
||||
{
|
||||
title: '文件大小',
|
||||
dataIndex: 'fileSize',
|
||||
key: 'fileSize',
|
||||
width: '15%',
|
||||
sorter: (a: DetectionResult, b: DetectionResult) => a.fileSize - b.fileSize,
|
||||
customRender: ({ text }: { text: number }) => {
|
||||
if (text < 1024) {
|
||||
return `${text} B`;
|
||||
} else if (text < 1024 * 1024) {
|
||||
return `${(text / 1024).toFixed(2)} KB`;
|
||||
} else {
|
||||
return `${(text / (1024 * 1024)).toFixed(2)} MB`;
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '阈值',
|
||||
dataIndex: 'threshold',
|
||||
key: 'threshold',
|
||||
width: '10%',
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
key: 'status',
|
||||
width: '15%',
|
||||
},
|
||||
{
|
||||
title: '处理时间',
|
||||
key: 'performance',
|
||||
width: '20%',
|
||||
}
|
||||
];
|
||||
|
||||
// 处理阈值变化
|
||||
const handleThresholdChange = (value: number) => {
|
||||
threshold.value = value;
|
||||
message.info(`阈值已更新为: ${value}`);
|
||||
};
|
||||
|
||||
// 上传前检查文件
|
||||
const beforeUpload: UploadProps['beforeUpload'] = async (file) => {
|
||||
const isImage = file.type.startsWith('image/');
|
||||
if (!isImage) {
|
||||
message.error('只能上传图片文件!');
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
message.loading('正在检测图片模糊度...');
|
||||
const startTime = performance.now();
|
||||
const result = await detectBlurFromFile(file, threshold.value);
|
||||
const endTime = performance.now();
|
||||
const processTime = endTime - startTime;
|
||||
|
||||
// 添加检测结果
|
||||
detectionResults.value.push({
|
||||
key: file.uid,
|
||||
fileName: file.name,
|
||||
blurValue: Math.round(result.blurValue),
|
||||
isBlurry: result.isBlurry,
|
||||
threshold: threshold.value,
|
||||
processTime: Math.round(processTime * 100) / 100,
|
||||
fileSize: file.size
|
||||
});
|
||||
|
||||
message.success(`检测完成: ${result.isBlurry ? '图片模糊' : '图片清晰'}`);
|
||||
return false; // 阻止默认上传行为,我们只是预览和检测
|
||||
} catch (error) {
|
||||
message.error('模糊检测失败');
|
||||
console.error(error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// 处理图片预览
|
||||
const handlePreview = async (file: any) => {
|
||||
if (!file.url && !file.preview) {
|
||||
file.preview = await getBase64(file.originFileObj);
|
||||
}
|
||||
previewImage.value = file.url || file.preview;
|
||||
previewVisible.value = true;
|
||||
previewTitle.value = file.name || file.url.substring(file.url.lastIndexOf('/') + 1);
|
||||
};
|
||||
|
||||
// 关闭预览
|
||||
const handleCancel = () => {
|
||||
previewVisible.value = false;
|
||||
};
|
||||
|
||||
// 将文件转换为Base64
|
||||
const getBase64 = (file: File): Promise<string> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.readAsDataURL(file);
|
||||
reader.onload = () => resolve(reader.result as string);
|
||||
reader.onerror = error => reject(error);
|
||||
});
|
||||
};
|
||||
|
||||
// 处理图片删除
|
||||
const handleRemove = (file: any) => {
|
||||
// 从检测结果中删除对应项
|
||||
const index = detectionResults.value.findIndex(item => item.key === file.uid);
|
||||
if (index !== -1) {
|
||||
detectionResults.value.splice(index, 1);
|
||||
message.success('已删除图片及其检测结果');
|
||||
}
|
||||
return true; // 允许删除操作继续
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.blur-detection-test {
|
||||
padding: 20px;
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
|
||||
.main-card {
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 18px;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.threshold-form {
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.upload-container {
|
||||
margin: 20px 0;
|
||||
background-color: #fafafa;
|
||||
padding: 20px;
|
||||
border-radius: 4px;
|
||||
border: 1px dashed #d9d9d9;
|
||||
}
|
||||
|
||||
.results-container {
|
||||
margin-top: 30px;
|
||||
|
||||
h2 {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
@@ -151,6 +151,7 @@ const updateQrcodeSize = () => {
|
||||
};
|
||||
|
||||
function generateQrCodeUrl(): string {
|
||||
console.log(import.meta.env.VITE_APP_WEB_URL + "/main/upscale/phone/app?user_id=" + user.user.uid + "&token=" + user.token.accessToken);
|
||||
return import.meta.env.VITE_APP_WEB_URL + "/main/upscale/phone/app?user_id=" + user.user.uid + "&token=" + user.token.accessToken;
|
||||
}
|
||||
|
||||
|
@@ -51,7 +51,6 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {defineProps} from 'vue';
|
||||
import bucket from "@/assets/svgs/bucket.svg";
|
||||
import time from "@/assets/svgs/time.svg";
|
||||
import location from "@/assets/svgs/location-album.svg";
|
||||
|
122
src/workers/image-analysis/image-analysis.worker.ts
Normal file
122
src/workers/image-analysis/image-analysis.worker.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
// image-analysis.worker.ts
|
||||
import * as tf from '@tensorflow/tfjs';
|
||||
import '@tensorflow/tfjs-backend-webgl';
|
||||
import {NSFWJS} from 'nsfwjs';
|
||||
import {initNSFWJs, predictNSFW} from '@/utils/tfjs/nsfw';
|
||||
import {animePredictImageData} from '@/utils/tfjs/anime_classifier_pro';
|
||||
import {predictLandscapeImageData} from '@/utils/tfjs/landscape_recognition';
|
||||
import {cocoSsdPredict} from '@/utils/tfjs/mobilenet';
|
||||
import {getCategoryByLabel} from '@/constant/coco_ssd_label_category';
|
||||
|
||||
// 初始化TensorFlow后端
|
||||
tf.setBackend('webgl').then();
|
||||
|
||||
// 定义消息接口
|
||||
interface ImageAnalysisRequest {
|
||||
imageData: ArrayBuffer;
|
||||
width: number;
|
||||
height: number;
|
||||
settings: {
|
||||
nsfw_detection: boolean;
|
||||
anime_detection: boolean;
|
||||
landscape_detection: boolean;
|
||||
target_detection: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
interface ImageAnalysisResponse {
|
||||
isNSFW?: boolean;
|
||||
isAnime?: boolean;
|
||||
landscape?: string | null;
|
||||
tagName?: string | null;
|
||||
topCategory?: string | null;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// 注意:不再需要将ArrayBuffer转换为张量的函数,因为我们直接使用ImageData对象
|
||||
|
||||
// 主要的处理函数
|
||||
async function processImage(data: ImageAnalysisRequest): Promise<ImageAnalysisResponse> {
|
||||
const {imageData, width, height, settings} = data;
|
||||
const result: ImageAnalysisResponse = {};
|
||||
|
||||
try {
|
||||
const imgData: ImageData = new ImageData(new Uint8ClampedArray(imageData), width, height);
|
||||
// 并行执行所有启用的检测
|
||||
const tasks: Promise<any>[] = [];
|
||||
|
||||
// NSFW检测
|
||||
if (settings.nsfw_detection) {
|
||||
tasks.push(
|
||||
(async () => {
|
||||
const nsfw: NSFWJS = await initNSFWJs();
|
||||
const isNSFW = await predictNSFW(nsfw, imgData);
|
||||
return {isNSFW};
|
||||
})()
|
||||
);
|
||||
}
|
||||
|
||||
// 动漫检测
|
||||
if (settings.anime_detection) {
|
||||
tasks.push(
|
||||
(async () => {
|
||||
const prediction = await animePredictImageData(imgData);
|
||||
const isAnime = prediction === 'Furry' || prediction === 'Anime';
|
||||
return {isAnime};
|
||||
})()
|
||||
);
|
||||
}
|
||||
|
||||
// 景观识别
|
||||
if (settings.landscape_detection) {
|
||||
tasks.push(
|
||||
(async () => {
|
||||
const landscape = await predictLandscapeImageData(imgData);
|
||||
return {landscape};
|
||||
})()
|
||||
);
|
||||
}
|
||||
|
||||
// 目标检测
|
||||
if (settings.target_detection) {
|
||||
tasks.push(
|
||||
(async () => {
|
||||
|
||||
// 执行目标检测
|
||||
const cocoResults = await cocoSsdPredict(imgData);
|
||||
if (cocoResults && cocoResults.length > 0) {
|
||||
// 多个结果时,按 score 排序,取置信度最高的结果
|
||||
const sortedResults = cocoResults.sort((a: any, b: any) => b.score - a.score);
|
||||
const topCategory = getCategoryByLabel(sortedResults[0].class);
|
||||
const tagName = sortedResults[0].class;
|
||||
return {tagName, topCategory};
|
||||
}
|
||||
})()
|
||||
);
|
||||
}
|
||||
|
||||
// 等待所有任务完成
|
||||
const results = await Promise.all(tasks);
|
||||
|
||||
// 合并结果
|
||||
results.forEach(taskResult => {
|
||||
Object.assign(result, taskResult);
|
||||
});
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('Worker处理图像时出错:', error);
|
||||
return {error: error instanceof Error ? error.message : String(error)};
|
||||
}
|
||||
}
|
||||
|
||||
// 监听消息
|
||||
self.onmessage = async function (e: MessageEvent) {
|
||||
const data = e.data as ImageAnalysisRequest;
|
||||
try {
|
||||
const result = await processImage(data);
|
||||
self.postMessage(result);
|
||||
} catch (error) {
|
||||
self.postMessage({error: error instanceof Error ? error.message : String(error)});
|
||||
}
|
||||
};
|
@@ -1,11 +0,0 @@
|
||||
self.onmessage = async function (_e: MessageEvent): Promise<void> {
|
||||
// const {data} = e;
|
||||
// const {width, height, uint8Array} = data;
|
||||
|
||||
// const prediction1 = await animePredictImage(width, height, uint8Array);
|
||||
// const prediction2 = await animePredictImagePro(width, height, uint8Array);
|
||||
// if (prediction1 === 'Anime' && (prediction2 === 'Furry' || prediction2 === 'Anime')) {
|
||||
// self.postMessage({isAnime: true});
|
||||
// return;
|
||||
// }
|
||||
};
|
@@ -8,7 +8,6 @@
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
|
||||
},
|
||||
"include": [
|
||||
"vite.config.ts"
|
||||
|
Reference in New Issue
Block a user