add image blur detection and background management pages

This commit is contained in:
2025-03-12 16:12:19 +08:00
parent a20ea6c76b
commit 9e6dd55087
60 changed files with 6647 additions and 650 deletions

View File

@@ -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
View File

@@ -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
View File

@@ -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']
}

View File

@@ -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": {

View File

@@ -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',
});
};

View 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
View 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

View File

@@ -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"

View File

@@ -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;

View File

@@ -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>

View File

@@ -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%;
}

View File

@@ -1,3 +0,0 @@
import VueCompareImage from './VueCompareImage.vue';
export {VueCompareImage};

View File

@@ -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;
};

View File

@@ -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');

View File

@@ -0,0 +1,11 @@
export default [
{
path: '/preview/blur-detect',
name: 'PreviewBlurDetect',
component: () => import('@/views/Preview/PreviewBlurDetect.vue'),
meta: {
requiresAuth: false,
title: 'PreviewBlurDetect',
}
},
];

View 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: '存储管理',
}
},
]
}
];

View File

@@ -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({

View File

@@ -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(),
};
}

View File

@@ -342,7 +342,7 @@ export const useCommentStore = defineStore(
persistedState: {
persist: true,
storage: localForage,
key: 'comment',
key: 'STORE-COMMENT',
includePaths: ["emojiList", "commentList", "replyVisibility", "commentMap"]
}
}

View File

@@ -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",

View File

@@ -15,7 +15,7 @@ export const langStore = defineStore(
persistedState: {
persist: true,
storage: localStorage,
key: 'lang',
key: 'STORE-LANGUAGE',
includePaths: ['lang']
}
}

View File

@@ -17,7 +17,7 @@ export const useMenuStore = defineStore(
persistedState: {
persist: true,
storage: localStorage,
key: 'menu',
key: 'STORE-MENU',
includePaths: ['currentMenu', 'userCenterMenu', 'accountSettingMenu']
}
}

View File

@@ -57,7 +57,7 @@ export const useSearchStore = defineStore(
persistedState: {
persist: true,
storage: localStorage,
key: 'search',
key: 'STORE-SEARCH',
includePaths: ['searchOption', 'options']
}
}

View File

@@ -31,7 +31,7 @@ export const useShareStore = defineStore(
persistedState: {
persist: true,
storage: sessionStorage,
key: 'share',
key: 'STORE-SHARE',
includePaths: ['sharePassword']
}
}

View 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']
}
}
);

View File

@@ -40,7 +40,7 @@ export const useThemeStore = defineStore(
persistedState: {
persist: true,
storage: localStorage,
key: 'theme',
key: 'STORE-THEME',
includePaths: ['themeName', 'darkMode']
}
}

View File

@@ -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"]
}
}

View File

@@ -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: [],
}
}

View File

@@ -170,7 +170,7 @@ export const useAuthStore = defineStore(
persistedState: {
persist: true,
storage: localStorage,
key: 'user',
key: 'STORE-USER',
includePaths: ['user', 'token', "clientId"]
}
}

View 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;
});
}

View 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);
});
}

View 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

File diff suppressed because one or more lines are too long

Binary file not shown.

View File

@@ -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];
}

View File

@@ -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:

View File

@@ -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 [];
}

View 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 &copy; 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>支持JPGPNG等常见图片格式</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>

View File

@@ -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;
}

View File

@@ -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";

View 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)});
}
};

View File

@@ -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;
// }
};

View File

@@ -8,7 +8,6 @@
"allowSyntheticDefaultImports": true,
"strict": true,
"noEmit": true,
},
"include": [
"vite.config.ts"