💄 restructure and beautify some page

This commit is contained in:
2025-03-28 18:29:48 +08:00
parent 8be4aca6db
commit c80fa21139
34 changed files with 5709 additions and 815 deletions

26
components.d.ts vendored
View File

@@ -14,14 +14,15 @@ declare module 'vue' {
ABadge: typeof import('ant-design-vue/es')['Badge']
AButton: typeof import('ant-design-vue/es')['Button']
ACard: typeof import('ant-design-vue/es')['Card']
ACardMeta: typeof import('ant-design-vue/es')['CardMeta']
ACascader: typeof import('ant-design-vue/es')['Cascader']
AccountSetting: typeof import('./src/views/User/AccountSetting/AccountSetting.vue')['default']
AccountSettingBackup: typeof import('./src/views/User/AccountSetting/components/AccountSettingBackup/AccountSettingBackup.vue')['default']
AccountSettingHome: typeof import('./src/views/User/AccountSetting/components/AccountSettingHome/AccountSettingHome.vue')['default']
AccountSettingInfo: typeof import('./src/views/User/AccountSetting/components/AccountSettingInfo/AccountSettingInfo.vue')['default']
AccountSettingLog: typeof import('./src/views/User/AccountSetting/components/AccountSettingLog/AccountSettingLog.vue')['default']
AccountSettingSidebar: typeof import('./src/views/User/AccountSetting/components/AccountSettingSidebar/AccountSettingSidebar.vue')['default']
AccountSettingStorage: typeof import('./src/views/User/AccountSetting/components/AccountSettingStorage/AccountSettingStorage.vue')['default']
AccountSettingTask: typeof import('./src/views/User/AccountSetting/components/AccountSettingTask/AccountSettingTask.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']
@@ -67,7 +68,6 @@ declare module 'vue' {
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']
@@ -78,9 +78,10 @@ declare module 'vue' {
ATabs: typeof import('ant-design-vue/es')['Tabs']
ATag: typeof import('ant-design-vue/es')['Tag']
ATextarea: typeof import('ant-design-vue/es')['Textarea']
ATimeline: typeof import('ant-design-vue/es')['Timeline']
ATimelineItem: typeof import('ant-design-vue/es')['TimelineItem']
ATimePicker: typeof import('ant-design-vue/es')['TimePicker']
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']
@@ -94,6 +95,7 @@ declare module 'vue' {
CheckCard: typeof import('./src/components/CheckCard/CheckCard.vue')['default']
CheckCircleOutlined: typeof import('@ant-design/icons-vue')['CheckCircleOutlined']
CheckOutlined: typeof import('@ant-design/icons-vue')['CheckOutlined']
ClockCircleOutlined: typeof import('@ant-design/icons-vue')['ClockCircleOutlined']
CloseCircleOutlined: typeof import('@ant-design/icons-vue')['CloseCircleOutlined']
CloseOutlined: typeof import('@ant-design/icons-vue')['CloseOutlined']
Clouds: typeof import('./src/components/Clouds/Clouds.vue')['default']
@@ -106,31 +108,30 @@ declare module 'vue' {
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']
DeleteConfirmModal: typeof import('./src/views/User/AccountSetting/components/AccountSettingTask/components/DeleteConfirmModal.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']
EmailModal: typeof import('./src/views/User/AccountSetting/components/AccountSettingHome/EmailModal.vue')['default']
ExclamationCircleOutlined: typeof import('@ant-design/icons-vue')['ExclamationCircleOutlined']
EyeInvisibleOutlined: typeof import('@ant-design/icons-vue')['EyeInvisibleOutlined']
EyeOutlined: typeof import('@ant-design/icons-vue')['EyeOutlined']
FileImageOutlined: typeof import('@ant-design/icons-vue')['FileImageOutlined']
FilerobotImageEditor: typeof import('./src/components/FilerobotImageEditor/FilerobotImageEditor.vue')['default']
Folder: typeof import('./src/components/Folder/Folder.vue')['default']
FolderOutlined: typeof import('@ant-design/icons-vue')['FolderOutlined']
ForgetPage: typeof import('./src/views/Forget/ForgetPage.vue')['default']
GradientText: typeof import('./src/components/MyUI/GradientText/GradientText.vue')['default']
HeatmapMax: typeof import('./src/components/HeatmapMax/HeatmapMax.vue')['default']
HeatmapPro: typeof import('./src/components/HeatmapPro/HeatmapPro.vue')['default']
ImageBed: typeof import('./src/views/ImageBed/index.vue')['default']
ImageEnhancer: typeof import('./src/components/ImageEnhancer/ImageEnhancer.vue')['default']
ImageList: typeof import('./src/views/Photograph/PrivacySpace/ImageList.vue')['default']
ImageShare: typeof import('./src/views/Share/ImageShare/ImageShare.vue')['default']
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']
InfoCircleOutlined: typeof import('@ant-design/icons-vue')['InfoCircleOutlined']
LandingPage: typeof import('./src/views/Landing/LandingPage.vue')['default']
LeftOutlined: typeof import('@ant-design/icons-vue')['LeftOutlined']
LinkOutlined: typeof import('@ant-design/icons-vue')['LinkOutlined']
@@ -147,7 +148,6 @@ declare module 'vue' {
LogoutOutlined: typeof import('@ant-design/icons-vue')['LogoutOutlined']
MainPage: typeof import('./src/views/Main/MainPage.vue')['default']
MessageReport: typeof import('./src/components/CommentReply/src/MessageReport/MessageReport.vue')['default']
MinusOutlined: typeof import('@ant-design/icons-vue')['MinusOutlined']
NotFound: typeof import('./src/views/404/NotFound.vue')['default']
OrderedListOutlined: typeof import('@ant-design/icons-vue')['OrderedListOutlined']
PageError403: typeof import('./src/views/Admin/Error/PageError403.vue')['default']
@@ -171,6 +171,7 @@ declare module 'vue' {
Popover: typeof import('./src/components/MyUI/Popover/Popover.vue')['default']
PreviewBlurDetect: typeof import('./src/views/Preview/PreviewBlurDetect/PreviewBlurDetect.vue')['default']
PreviewOCR: typeof import('./src/views/Preview/PreviewOCR/PreviewOCR.vue')['default']
PreviewQRCode: typeof import('./src/views/Preview/PreviewQRCode/PreviewQRCode.vue')['default']
PrivacyImageList: typeof import('./src/views/Photograph/PrivacySpace/PrivacyImageList.vue')['default']
PrivacySpace: typeof import('./src/views/Photograph/PrivacySpace/PrivacySpace.vue')['default']
QrcodeOutlined: typeof import('@ant-design/icons-vue')['QrcodeOutlined']
@@ -200,12 +201,14 @@ declare module 'vue' {
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']
TaskCard: typeof import('./src/views/User/AccountSetting/components/AccountSettingTask/components/TaskCard.vue')['default']
TaskForm: typeof import('./src/views/User/AccountSetting/components/AccountSettingTask/components/TaskForm.vue')['default']
TaskSchedule: typeof import('./src/components/TaskSchedule/TaskSchedule.vue')['default']
TaskTypeSelector: typeof import('./src/views/User/AccountSetting/components/AccountSettingTask/components/TaskTypeSelector.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']
@@ -223,7 +226,6 @@ declare module 'vue' {
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,6 +12,8 @@ server {
proxy_set_header Connection "upgrade";
access_log /var/log/nginx/host.access.log main;
error_log /var/log/nginx/error.log error;
client_max_body_size 120M;
client_body_buffer_size 10m;
location / {
@@ -39,6 +41,8 @@ server {
proxy_send_timeout 3600s; # 设置为1小时
send_timeout 3600s; # 设置为1小时
keepalive_timeout 3600s; # 设置为1小时
client_max_body_size 120M;
client_body_buffer_size 10m;
}
error_page 500 502 503 504 /50x.html;

View File

@@ -33,8 +33,8 @@
"@types/crypto-js": "^4.2.2",
"@types/file-saver": "^2.0.7",
"@types/json-stringify-safe": "^5.0.3",
"@types/leaflet": "^1.9.16",
"@types/node": "^22.13.11",
"@types/leaflet": "^1.9.17",
"@types/node": "^22.13.14",
"@types/nprogress": "^0.2.3",
"@vladmandic/face-api": "^1.7.15",
"@vuepic/vue-datepicker": "^11.0.2",
@@ -66,12 +66,12 @@
"nsfwjs": "^4.2.1",
"opencv-qr": "^0.7.0",
"pinia": "^3.0.1",
"pinia-plugin-persistedstate-2": "^2.0.29",
"pinia-plugin-persistedstate-2": "^2.0.30",
"qr-scanner-wechat": "^0.1.3",
"rimraf": "^6.0.1",
"seedrandom": "^3.0.5",
"swiper": "^11.2.6",
"unplugin-auto-import": "^19.1.1",
"unplugin-auto-import": "^19.1.2",
"upscaler": "^1.0.0-beta.19",
"vite-plugin-compression": "^0.5.1",
"vite-plugin-html": "^3.2.2",
@@ -91,16 +91,16 @@
"globals": "^16.0.0",
"sass": "^1.86.0",
"typescript": "^5.8.2",
"typescript-eslint": "^8.27.0",
"typescript-eslint": "^8.28.0",
"unplugin-vue-components": "^28.4.1",
"vite": "^6.2.2",
"vite": "^6.2.3",
"vite-plugin-bundle-obfuscator": "1.4.2",
"vite-plugin-chunk-split": "^0.5.0",
"vue-tsc": "2.2.8"
},
"overrides": {
"vite-plugin-chunk-split": {
"vite": "^6.2.0"
"vite": "^6.2.3"
}
}
}

View File

@@ -2,17 +2,14 @@ import {service} from "@/utils/alova/service.ts";
/**
* 从一个存储桶备份到另一个存储桶
* @param sourceProvider 源存储商
* @param sourceBucket 源存储
* @param targetProvider 目标存储商
* @param targetBucket 目标存储桶
* @param sourceStorage 源存储商
* @param targetStorage 目标存储
*/
export const backupStorageApi = (sourceProvider: string, sourceBucket: string, targetProvider: string, targetBucket: string) => {
export const backupStorageApi = (sourceStorage: string, targetStorage: string) => {
return service.Post('/api/auth/storage/backup', {
source_provider: sourceProvider,
source_bucket: sourceBucket,
target_provider: targetProvider,
target_bucket: targetBucket,
source_storage: sourceStorage,
target_storage: targetStorage,
}, {
meta: {
ignoreToken: false,
@@ -52,4 +49,4 @@ export const cancelBackupTaskApi = (taskId: string) => {
},
name: "cancel-backup-task",
});
};
};

View File

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 6h18"/>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
<path d="M10 11v6"/>
<path d="M14 11v6"/>
</svg>

After

Width:  |  Height:  |  Size: 326 B

View File

@@ -0,0 +1 @@
<svg t="1743043408733" class="icon" viewBox="0 0 1037 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="5882" width="200" height="200"><path d="M562.201852 861.528553h248.388751a54.824544 54.824544 0 0 0 0-109.649089H562.201852a54.824544 54.824544 0 0 0 0 109.649089zM303.62551 454.142734a147.808385 147.808385 0 0 0 147.572834-147.572833c0.058888-39.39594-15.310829-76.495256-43.16476-104.349187s-64.953246-43.341423-104.408074-43.341422A147.867272 147.867272 0 0 0 156.052677 308.572086a147.808385 147.808385 0 0 0 147.572833 145.570648zM265.58399 308.042096l0.058888-1.648859a38.159296 38.159296 0 0 1 37.982632-37.923745 38.100408 38.100408 0 0 1 0 76.083041 38.159296 38.159296 0 0 1-38.04152-36.510437z" fill="#4DA1FF" p-id="5883"></path><path d="M912.230951 10.010926H115.066766A102.170338 102.170338 0 0 0 13.249756 111.827937v797.340848a101.934786 101.934786 0 0 0 101.81701 101.758123h797.340848a100.698143 100.698143 0 0 0 71.902007-29.915004c19.197424-19.256311 29.73834-44.872506 29.679453-72.019783V111.827937A101.993674 101.993674 0 0 0 912.230951 10.010926z m-7.832078 109.590201v781.73558H122.839956V119.601127h781.558917z" fill="#4DA1FF" p-id="5884"></path></svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/>
<path d="M7.5 4.21l4.5 2.6 4.5-2.6"/>
<path d="M7.5 19.79V14.6L3 12"/>
<path d="M21 12l-4.5 2.6v5.19"/>
<path d="M3.27 6.96L12 12.01l8.73-5.05"/>
<path d="M12 22.08V12"/>
</svg>

After

Width:  |  Height:  |  Size: 482 B

View File

@@ -0,0 +1,539 @@
<template>
<div ref="containerRef" class="heatmap-container">
<!-- 标题和颜色图例 -->
<div class="header">
<h3 class="month-title">{{ currentMonthTitle }}</h3>
<div class="legend">
<span class="legend-text"></span>
<div v-for="(color, index) in colorLevels" :key="index" class="legend-item">
<a-tooltip :title="color.description">
<div class="legend-block" :style="{ backgroundColor: color.color }" />
</a-tooltip>
</div>
<span class="legend-text"></span>
</div>
</div>
<!-- 主图表区域 -->
<div class="chart-wrapper">
<!-- 贡献图主体 -->
<div class="chart-body">
<!-- 星期坐标轴 -->
<div class="weekday-axis">
<div v-for="(weekday, index) in weekdays" :key="index" class="weekday-label">{{ weekday }}</div>
</div>
<!-- 贡献格子 -->
<div class="weeks-container" :style="{ gap: `${cellGap}px` }">
<div
v-for="(week, wi) in visibleWeeks"
:key="wi"
class="week-column"
:style="{ gap: `${cellGap}px` }"
>
<a-tooltip
v-for="(day, di) in week"
:key="di"
:mouseEnterDelay="0.3"
:title="`${formatTooltip(day.date)}: ${day.count} 次上传`"
>
<div
class="day-cell"
:style="{
width: `${cellSize}px`,
height: `${cellSize}px`,
backgroundColor: getColor(day.count)
}"
/>
</a-tooltip>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount, nextTick, watch } from 'vue';
import {
format,
startOfWeek,
addDays,
eachDayOfInterval,
getMonth,
parseISO,
getYear,
subDays
} from 'date-fns';
import type { PropType } from 'vue';
import { zhCN } from 'date-fns/locale';
interface Contribution {
date: string;
count: number;
}
interface ColorLevel {
color: string;
min: number;
max: number;
description: string;
}
interface MonthLabel {
name: string;
position: number;
}
const props = defineProps({
contributions: {
type: Array as PropType<Contribution[]>,
required: true
},
colorLevels: {
type: Array as PropType<ColorLevel[]>,
default: () => [
{
color: '#ebedf0',
min: 0,
max: 0,
description: '没有上传'
},
{
color: '#9be9a8',
min: 1,
max: 3,
description: '1-3 上传'
},
{
color: '#40c463',
min: 4,
max: 5,
description: '4-5 上传'
},
{
color: '#30a14e',
min: 6,
max: 7,
description: '6-7 上传'
},
{
color: '#216e39',
min: 8,
max: Infinity,
description: '8+ 上传'
}
]
},
// 新增:可配置显示的周数
weeksToShow: {
type: Number,
default: 52 // 默认显示52周一年
},
// 新增:可配置星期标签
weekdayLabels: {
type: Array as PropType<string[]>,
default: () => ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
}
});
const containerRef = ref<HTMLElement | null>(null);
const cellSize = ref(12); // 初始单元格大小
const cellGap = ref(3); // 初始单元格间距
// 使用可配置的星期标签并确保正确显示7天
const weekdays = computed(() => {
// 如果用户提供了自定义标签,使用用户提供的标签
// 否则使用默认的7天标签
return props.weekdayLabels;
});
const visibleWeeks = ref<Array<Array<{ date: Date; count: number }>>>([]);
// 响应式变量,用于控制图表尺寸
const chartWidth = ref(0);
const chartHeight = ref(0);
// 当前月份标题
const currentMonthTitle = computed(() => {
const today = new Date();
return format(today, 'yyyy年MM月', { locale: zhCN });
});
// 更新尺寸函数
const updateSize = () => {
if (!containerRef.value) return;
const container = containerRef.value;
const containerWidth = container.offsetWidth;
const containerHeight = container.offsetHeight;
// 保留边距空间,考虑到星期标签的空间
const availableWidth = containerWidth - 70; // 左右边距,增加左侧星期标签的空间
const availableHeight = containerHeight - 110; // 上下边距,考虑标题和底部空间
// 更新图表尺寸
chartWidth.value = availableWidth;
chartHeight.value = availableHeight;
// 计算单元格尺寸,确保合理的显示效果
// 对于单月视图周数通常为4-6周
const weeksCount = visibleWeeks.value.length || 6; // 默认按6周计算
const daysCount = 7; // 一周7天
// 计算单元格的最大可能尺寸
const maxCellWidth = Math.max(1, (availableWidth - 50) / weeksCount); // 水平方向确保至少为1
const maxCellHeight = Math.max(1, availableHeight / daysCount); // 垂直方向确保至少为1
// 取较小值确保单元格是正方形,但为单月视图增加单元格大小
const maxCellSize = Math.min(maxCellWidth, maxCellHeight);
// 设置单元格大小和间距,单月视图可以使用更大的单元格
cellSize.value = Math.max(8, Math.min(18, maxCellSize - 2)); // 减去2给间距留空间最小值改为8最大值增加到18
cellGap.value = Math.max(2, Math.min(4, cellSize.value * 0.15)); // 最小间距改为2
// 强制下一帧更新DOM
nextTick(() => {
// 确保星期标签正确分布
const weekdayAxis = container.querySelector('.weekday-axis');
if (weekdayAxis) {
const weekdayLabels = weekdayAxis.querySelectorAll('.weekday-label');
const totalLabels = weekdayLabels.length;
if (totalLabels > 0) {
// 计算每个标签应该的位置
weekdayLabels.forEach((label, index) => {
const element = label as HTMLElement;
// 平均分布标签
element.style.height = `${100 / totalLabels}%`;
element.style.display = 'flex';
element.style.alignItems = 'center';
});
}
}
});
};
// 日期有效性检查
const isValidDate = (date: Date) => {
return date instanceof Date && !isNaN(date.getTime());
};
// 生成当前月份的日期网格
const generateDateGrid = () => {
const today = new Date();
// 获取当前月份的第一天
const currentMonth = today.getMonth();
const currentYear = today.getFullYear();
const firstDayOfMonth = new Date(currentYear, currentMonth, 1);
// 获取当前月份的最后一天
const lastDayOfMonth = new Date(currentYear, currentMonth + 1, 0);
// 获取第一天所在周的周一
const startDate = startOfWeek(firstDayOfMonth, { weekStartsOn: 1 }); // 从周一开始
// 获取最后一天(或今天,取较早的日期)
const endDate = today > lastDayOfMonth ? lastDayOfMonth : today;
const weeksArray: Date[][] = [];
let currentWeekStart = startDate;
while (currentWeekStart <= endDate) {
const weekEnd = addDays(currentWeekStart, 6);
const weekDays = eachDayOfInterval({
start: currentWeekStart,
end: weekEnd > endDate ? endDate : weekEnd
}).filter(date => isValidDate(date));
if (weekDays.length > 0) {
weeksArray.push(weekDays);
}
currentWeekStart = addDays(currentWeekStart, 7);
}
return weeksArray;
};
// 处理贡献数据,只显示当前月份的数据
const processContributions = () => {
// 获取当前月份
const today = new Date();
const currentMonth = today.getMonth();
const currentYear = today.getFullYear();
const contributionMap = new Map(
props.contributions
.filter(c => {
try {
const date = parseISO(c.date);
// 只保留当前月份的数据
return isValidDate(date) &&
date.getMonth() === currentMonth &&
date.getFullYear() === currentYear;
} catch {
return false;
}
})
.map(c => [format(parseISO(c.date), 'yyyy-MM-dd'), c.count])
);
return generateDateGrid()
.map(week =>
week.map(date => ({
date,
count: contributionMap.get(format(date, 'yyyy-MM-dd')) || 0
}))
)
.filter(week => week.length > 0);
};
// 不再需要月份标签,因为只显示当前月份
const monthLabels = computed(() => []);
// 颜色匹配逻辑
const getColor = (count: number) => {
return (
props.colorLevels.find(l => count >= l.min && count <= l.max)?.color ||
'#ebedf0'
);
};
// 格式化工具提示
const formatTooltip = (date: Date) => {
return format(date, 'yyyy年MM月dd日 EEEE', { locale: zhCN });
};
// 监听贡献数据变化
watch(
() => props.contributions,
() => {
visibleWeeks.value = processContributions();
nextTick(() => {
updateSize();
});
},
{ deep: true }
);
// 初始化
onMounted(() => {
// 先处理数据
visibleWeeks.value = processContributions();
// 立即执行一次更新尺寸,确保初始渲染正确
nextTick(() => {
updateSize();
// 添加窗口大小变化监听
window.addEventListener('resize', updateSize);
});
// 使用ResizeObserver监听容器大小变化
const observer = new ResizeObserver(() => {
updateSize();
});
if (containerRef.value) {
observer.observe(containerRef.value);
// 同时监听父容器的大小变化
const parentElement = containerRef.value.parentElement;
if (parentElement) {
observer.observe(parentElement);
}
}
// 清理函数
onBeforeUnmount(() => {
if (observer) observer.disconnect();
window.removeEventListener('resize', updateSize);
});
});
</script>
<style scoped>
.heatmap-container {
position: relative;
width: 100%;
height: 100%;
min-width: 300px;
min-height: 150px;
padding: 20px 15px;
box-sizing: border-box;
overflow: hidden;
display: flex;
flex-direction: column;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px;
}
.month-title {
margin: 0;
font-size: 16px;
font-weight: 600;
color: #24292e;
}
.legend {
display: flex;
align-items: center;
gap: 4px;
background: rgba(255, 255, 255, 0.9);
padding: 4px 8px;
border-radius: 4px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
z-index: 2;
font-size: 12px;
}
.legend-text {
color: #999999;
font-size: 12px;
}
.legend-item {
display: flex;
}
.legend-item + .legend-item {
margin-left: 2px;
}
.legend-block {
width: 14px;
height: 14px;
border-radius: 2px;
}
.chart-wrapper {
position: relative;
margin-top: 40px;
margin-left: 40px;
width: calc(100% - 40px);
height: calc(100% - 40px);
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.month-axis {
position: absolute;
top: -25px;
left: 0;
right: 0;
height: 20px;
white-space: nowrap;
}
.month-label {
position: absolute;
font-size: 11px;
color: #586069;
transform: translateX(-50%);
pointer-events: none;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
}
.chart-body {
display: flex;
flex: 1;
}
.weekday-axis {
position: absolute;
left: -35px;
top: 0;
bottom: 0;
width: 30px;
display: flex;
flex-direction: column;
height: 100%;
}
.weekday-label {
font-size: 10px;
color: #586069;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
height: calc(100% / 7); /* 确保7个标签均匀分布 */
display: flex;
align-items: center;
}
.weeks-container {
display: flex;
flex-wrap: nowrap;
overflow-x: auto;
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* IE and Edge */
padding-right: 20px;
width: 100%;
height: 100%;
}
.weeks-container::-webkit-scrollbar {
display: none; /* Chrome, Safari, Opera */
}
.week-column {
display: flex;
flex-direction: column;
}
.day-cell {
border-radius: 2px;
flex-shrink: 0;
cursor: pointer;
transition: transform 0.1s ease;
}
.day-cell:hover {
transform: scale(1.15);
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.2);
z-index: 1;
}
/* 响应式设计 */
@media (max-width: 768px) {
.heatmap-container {
padding: 15px 10px;
}
.legend {
top: 5px;
right: 5px;
padding: 3px 6px;
gap: 2px;
}
.legend-block {
width: 12px;
height: 12px;
}
.legend-text {
font-size: 10px;
}
.chart-wrapper {
margin-top: 35px;
margin-left: 30px;
}
.weekday-axis {
left: -25px;
width: 20px;
}
.weekday-label {
font-size: 9px;
}
}
</style>

View File

@@ -63,7 +63,7 @@
</template>
<script setup lang="ts">
import {ref, computed, onMounted, onBeforeUnmount} from 'vue';
import {ref, computed, onMounted, onBeforeUnmount, nextTick} from 'vue';
import {
format,
startOfWeek,
@@ -73,7 +73,6 @@ import {
parseISO,
getYear
} from 'date-fns';
import {debounce} from 'lodash-es';
import type {PropType} from 'vue';
import {zhCN} from 'date-fns/locale';
@@ -137,16 +136,17 @@ const props = defineProps({
});
const containerRef = ref<HTMLElement>();
// 设置固定的初始值,避免从小到大的变化
const cellSize = ref(12);
const cellGap = ref(3);
const weekdays = ['Mon', 'Wed', 'Fri'];
const visibleWeeks = ref<Array<Array<{ date: Date; count: number }>>>([]);
// 新增响应式变量
// 响应式变量,用于控制图表宽度
const chartMaxWidth = ref(0);
// 修改后的updateSize函数
const updateSize = debounce(() => {
// 优化updateSize函数移除debounce避免延迟导致的动画效果
const updateSize = () => {
if (!containerRef.value) return;
const container = containerRef.value;
@@ -156,15 +156,16 @@ const updateSize = debounce(() => {
// 动态计算最大宽度
chartMaxWidth.value = containerWidth - 40;
// 重新计算单元格尺寸
// 计算单元格尺寸,确保合理的显示效果
const maxCellSize = Math.min(
(containerWidth - 40) / 54, // 更精确的计算
containerHeight / 7 - cellGap.value
containerHeight / 7 - 3 // 使用固定的间距值,避免循环依赖
);
// 设置单元格大小和间距
cellSize.value = Math.max(8, Math.min(14, maxCellSize));
cellGap.value = Math.max(2, cellSize.value * 0.15);
}, 150);
cellGap.value = Math.max(2, Math.min(4, cellSize.value * 0.15));
};
// 日期有效性检查
const isValidDate = (date: Date) => {
@@ -260,13 +261,20 @@ const formatTooltip = (date: Date) => {
// 初始化
onMounted(() => {
// 先处理数据,再更新尺寸,避免不必要的重新计算
visibleWeeks.value = processContributions();
updateSize();
const observer = new ResizeObserver(debounce(() => {
// 立即执行一次更新尺寸,确保初始渲染正确
nextTick(() => {
updateSize();
visibleWeeks.value = processContributions();
}, 200));
});
// 使用ResizeObserver监听容器大小变化
const observer = new ResizeObserver(() => {
// 直接调用updateSize不使用debounce避免动画效果
updateSize();
// 不需要重新处理贡献数据,因为数据不会因为大小变化而改变
});
if (containerRef.value) observer.observe(containerRef.value);
onBeforeUnmount(() => observer.disconnect());
@@ -279,13 +287,15 @@ onMounted(() => {
width: 100%;
height: 100%;
min-width: 300px;
padding: 20px 15px; /* 调整左右padding */
padding: 20px 15px;
box-sizing: border-box;
overflow-x: auto; /* 允许横向滚动 */
/* 防止内容溢出 */
overflow-y: hidden;
}
.legend {
position: absolute; /* 粘性定位 */
position: absolute;
top: 10px;
right: 10px;
display: flex;
@@ -295,16 +305,15 @@ onMounted(() => {
padding: 4px 8px;
border-radius: 4px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
margin-bottom: 15px; /* 增加底部间距 */
z-index: 2; /* 确保在图上层级 */
}
.legend-item {
display: flex;
}
& + & {
margin-left: 4px; /* 增加色块间距 */
}
.legend-item + .legend-item {
margin-left: 4px; /* 增加色块间距 */
}
.legend-block {
@@ -317,7 +326,8 @@ onMounted(() => {
position: relative;
margin-top: 45px; /* 增加顶部间距 */
margin-left: 40px;
min-width: 520px; /* 最小宽度保证布局 */
/* 移除固定最小宽度,使用更灵活的方式 */
width: fit-content;
}
.month-axis {
@@ -382,7 +392,6 @@ onMounted(() => {
.chart-wrapper {
margin-top: 35px;
margin-left: 30px;
min-width: 480px;
}
}
@@ -393,7 +402,7 @@ onMounted(() => {
.day-cell {
border-radius: 15%;
transition: all 0.2s ease;
/* 移除过渡效果,避免加载时的动画 */
flex-shrink: 0;
cursor: pointer;
}
@@ -402,5 +411,7 @@ onMounted(() => {
transform: scale(1.15);
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.2);
z-index: 1;
/* 只在悬停时添加过渡效果 */
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
</style>

View File

@@ -13,14 +13,23 @@
placeholder="选择存储桶">
</ACascader>
</template>
<ATooltip title="选择存储桶" color="orange">
<AButton type="text" shape="circle" size="large" class="header-menu-item-btn">
<template #icon>
<AAvatar size="default" shape="circle"
:src="ProviderIcon[uploadStore.storageSelected?.[0]]? ProviderIcon[uploadStore.storageSelected?.[0]] : wenhao"/>
</template>
</AButton>
</ATooltip>
<ABadge dot
color="green"
:offset="[-9, 12]">
<ATooltip title="选择存储桶" color="orange">
<AButton type="text" shape="circle" size="large" class="header-menu-item-btn"
:class="{'breathing': !uploadStore.storageSelected?.length}">
<template #icon>
<AAvatar size="default" shape="circle"
:src="ProviderIcon[uploadStore.storageSelected?.[0]] ? ProviderIcon[uploadStore.storageSelected?.[0]] : wenhao"/>
</template>
</AButton>
</ATooltip>
<template #count>
<ExclamationCircleOutlined style="color: red" v-if="!uploadStore.storageSelected?.length"/>
</template>
</ABadge>
</APopover>
</div>
@@ -53,6 +62,12 @@
<span class="tool-box-card-title">OCR文字识别</span>
</AFlex>
</ACard>
<ACard hoverable class="tool-box-card" @click="router.push('/preview/qrcode')">
<AFlex :vertical="false" align="center" justify="flex-start" gap="small">
<AAvatar size="large" shape="square" :src="qr"/>
<span class="tool-box-card-title">二维码识别</span>
</AFlex>
</ACard>
</div>
</template>
<AButton type="text" shape="circle" size="large" class="header-menu-item-btn">
@@ -211,7 +226,9 @@ import {ProviderIcon} from "@/constant/provider_map.ts";
import toolBox from "@/assets/svgs/tool-box.svg";
import blur from "@/assets/svgs/blur.svg";
import scanIcon from "@/assets/svgs/scan.svg";
import qr from "@/assets/svgs/qr.svg";
import imgBed from "@/assets/svgs/img_bed.svg";
const router = useRouter();
@@ -422,6 +439,20 @@ onMounted(() => {
}
}
}
.breathing {
animation: breathing 2s ease-in-out infinite;
}
@keyframes breathing {
0% {
transform: scale(1);
}
50% {
transform: scale(1.1);
}
100% {
transform: scale(1);
}
}
</style>

View File

@@ -17,4 +17,13 @@ export default [
title: 'PreviewOcr',
}
},
{
path: '/preview/qrcode',
name: 'PreviewQRCode',
component: () => import('@/views/Preview/PreviewQRCode/PreviewQRCode.vue'),
meta: {
requiresAuth: false,
title: 'PreviewQRCode',
}
},
];

View File

@@ -95,6 +95,24 @@ export default [
requiresAuth: true,
title: '图像备份'
},
},
{
path: '/main/user/setting/task',
name: 'AccountSettingTask',
component: () => import('@/views/User/AccountSetting/components/AccountSettingTask/AccountSettingTask.vue'),
meta: {
requiresAuth: true,
title: '定时任务'
},
},
{
path: '/main/user/setting/log',
name: 'AccountSettingLog',
component: () => import('@/views/User/AccountSetting/components/AccountSettingLog/AccountSettingLog.vue'),
meta: {
requiresAuth: true,
title: '执行记录'
},
}
],
}

View File

@@ -2,29 +2,29 @@
<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"
: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-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" />
<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"
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>
@@ -33,7 +33,7 @@
</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>
@@ -41,20 +41,20 @@
<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
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>
@@ -62,22 +62,22 @@
<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-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-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>
@@ -88,8 +88,8 @@
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue';
import { message } from 'ant-design-vue';
import {ref, reactive, onMounted} from 'vue';
import {message} from 'ant-design-vue';
// 预定义的主题色
const predefinedColors = [
@@ -121,14 +121,14 @@ const settingsForm = reactive({
// 表单验证规则
const settingsRules = {
systemName: [
{ required: true, message: '请输入系统名称', trigger: 'blur' },
{ max: 50, message: '系统名称不能超过50个字符', trigger: 'blur' },
{required: true, message: '请输入系统名称', trigger: 'blur'},
{max: 50, message: '系统名称不能超过50个字符', trigger: 'blur'},
],
copyright: [
{ max: 100, message: '版权信息不能超过100个字符', trigger: 'blur' },
{max: 100, message: '版权信息不能超过100个字符', trigger: 'blur'},
],
icp: [
{ max: 50, message: '备案信息不能超过50个字符', trigger: 'blur' },
{max: 50, message: '备案信息不能超过50个字符', trigger: 'blur'},
],
};
@@ -170,7 +170,7 @@ const removeLogo = () => {
};
// 将文件转换为Base64
const getBase64 = (img: File, callback: Function) => {
const getBase64 = (img: File, callback: () => void) => {
const reader = new FileReader();
reader.addEventListener('load', () => callback(reader.result));
reader.readAsDataURL(img);
@@ -211,12 +211,12 @@ onMounted(() => {
.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;
@@ -224,18 +224,18 @@ onMounted(() => {
}
}
}
.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;
@@ -243,16 +243,16 @@ onMounted(() => {
cursor: pointer;
transition: all 0.3s;
border: 2px solid transparent;
&.active {
border-color: #000;
transform: scale(1.1);
}
&:hover {
transform: scale(1.1);
}
}
}
}
</style>
</style>

View File

@@ -331,8 +331,8 @@ const handlePermissionModalOk = () => {
const newId: any = Math.max(...permissionList.value.map(item => item.id)) + 1;
const statusText = permissionForm.status === 'active' ? '启用' : '禁用';
const now = new Date().toLocaleString();
permissionForm.id = newId;
permissionList.value.push({
newId,
...permissionForm,
statusText,
createTime: now

View File

@@ -5,12 +5,12 @@
<a-button type="primary" @click="handleAddRole">新增角色</a-button>
</template>
<a-table
:columns="columns"
:data-source="roleList"
:loading="loading"
:pagination="pagination"
@change="handleTableChange"
rowKey="id"
:columns="columns"
:data-source="roleList"
:loading="loading"
:pagination="pagination"
@change="handleTableChange"
rowKey="id"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'status'">
@@ -23,10 +23,10 @@
<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)"
title="确定要删除此角色吗?"
ok-text="确定"
cancel-text="取消"
@confirm="handleDeleteRole(record)"
>
<a-button type="link" danger>删除</a-button>
</a-popconfirm>
@@ -38,27 +38,27 @@
<!-- 角色表单对话框 -->
<a-modal
v-model:visible="roleModalVisible"
:title="modalTitle"
:confirm-loading="modalLoading"
@ok="handleRoleModalOk"
@cancel="handleRoleModalCancel"
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 }"
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-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-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-textarea v-model:value="roleForm.description" placeholder="请输入角色描述"/>
</a-form-item>
<a-form-item label="状态" name="status">
<a-radio-group v-model:value="roleForm.status">
@@ -71,24 +71,24 @@
<!-- 权限设置对话框 -->
<a-modal
v-model:visible="permissionModalVisible"
title="权限设置"
:confirm-loading="permissionModalLoading"
@ok="handlePermissionModalOk"
@cancel="handlePermissionModalCancel"
width="800px"
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="{
:columns="permissionColumns"
:data-source="permissionList"
:row-selection="{
selectedRowKeys: selectedPermissions,
onChange: onPermissionSelectionChange
}"
rowKey="id"
size="small"
rowKey="id"
size="small"
></a-table>
</template>
</a-modal>
@@ -96,8 +96,8 @@
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue';
import { message } from 'ant-design-vue';
import {ref, reactive, onMounted} from 'vue';
import {message} from 'ant-design-vue';
// 表格列定义
const columns = [
@@ -182,9 +182,9 @@ const roleForm = reactive({
});
const roleRules = {
name: [{ required: true, message: '请输入角色名称', trigger: 'blur' }],
code: [{ required: true, message: '请输入角色编码', trigger: 'blur' }],
status: [{ required: true, message: '请选择状态', trigger: 'change' }],
name: [{required: true, message: '请输入角色名称', trigger: 'blur'}],
code: [{required: true, message: '请输入角色编码', trigger: 'blur'}],
status: [{required: true, message: '请选择状态', trigger: 'change'}],
};
// 权限设置相关
@@ -275,11 +275,11 @@ const handleRoleModalOk = () => {
}
} else {
// 新增角色
const id = Math.max(...roleList.value.map(item => item.id)) + 1;
const id: any = Math.max(...roleList.value.map(item => item.id)) + 1;
const statusText = roleForm.status === 'active' ? '启用' : '禁用';
const now = new Date().toLocaleString();
roleForm.id = id;
roleList.value.push({
id,
...roleForm,
statusText,
createTime: now,

View File

@@ -239,7 +239,7 @@ import {
} from '@ant-design/icons-vue';
// 搜索表单
const searchForm = reactive({
const searchForm = reactive<any>({
timeRange: [],
userType: undefined,
registerSource: undefined,

View File

@@ -5,7 +5,7 @@
<div class="user-list-header-right">
<AButton type="primary" @click="handleAddUser">
<template #icon>
<PlusOutlined />
<PlusOutlined/>
</template>
新增用户
</AButton>
@@ -16,10 +16,10 @@
<div class="table-search-wrapper">
<AForm layout="inline" :model="searchForm">
<AFormItem label="用户名">
<AInput v-model:value="searchForm.username" placeholder="请输入用户名" allowClear />
<AInput v-model:value="searchForm.username" placeholder="请输入用户名" allowClear/>
</AFormItem>
<AFormItem label="手机号">
<AInput v-model:value="searchForm.phone" placeholder="请输入手机号" allowClear />
<AInput v-model:value="searchForm.phone" placeholder="请输入手机号" allowClear/>
</AFormItem>
<AFormItem label="状态">
<ASelect v-model:value="searchForm.status" placeholder="请选择状态" style="width: 120px" allowClear>
@@ -38,13 +38,13 @@
<AFormItem>
<AButton type="primary" @click="handleSearch">
<template #icon>
<SearchOutlined />
<SearchOutlined/>
</template>
搜索
</AButton>
<AButton style="margin-left: 8px" @click="resetSearch">
<template #icon>
<ReloadOutlined />
<ReloadOutlined/>
</template>
重置
</AButton>
@@ -53,12 +53,12 @@
</div>
<ATable
:columns="columns"
:data-source="userList"
:loading="loading"
:pagination="pagination"
@change="handleTableChange"
rowKey="id"
:columns="columns"
:data-source="userList"
:loading="loading"
:pagination="pagination"
@change="handleTableChange"
rowKey="id"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'status'">
@@ -71,10 +71,10 @@
<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)"
title="确定要删除此用户吗?"
ok-text="确定"
cancel-text="取消"
@confirm="handleDeleteUser(record)"
>
<AButton type="link" danger size="small">删除</AButton>
</APopconfirm>
@@ -86,21 +86,22 @@
<!-- 用户编辑对话框 -->
<AModal
v-model:visible="userModalVisible"
:title="modalTitle"
@ok="handleUserModalOk"
@cancel="handleUserModalCancel"
:confirmLoading="modalLoading"
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 }">
<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="请输入用户名" />
<AInput v-model:value="userForm.username" placeholder="请输入用户名"/>
</AFormItem>
<AFormItem label="手机号" name="phone">
<AInput v-model:value="userForm.phone" placeholder="请输入手机号" />
<AInput v-model:value="userForm.phone" placeholder="请输入手机号"/>
</AFormItem>
<AFormItem label="邮箱" name="email">
<AInput v-model:value="userForm.email" placeholder="请输入邮箱" />
<AInput v-model:value="userForm.email" placeholder="请输入邮箱"/>
</AFormItem>
<AFormItem label="角色" name="role">
<ASelect v-model:value="userForm.role" placeholder="请选择角色">
@@ -122,8 +123,8 @@
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue';
import { message } from 'ant-design-vue';
import {ref, reactive, onMounted} from 'vue';
import {message} from 'ant-design-vue';
import {
PlusOutlined,
SearchOutlined,
@@ -272,22 +273,22 @@ const userForm = reactive({
// 表单验证规则
const userFormRules = {
username: [
{ required: true, message: '请输入用户名', trigger: 'blur' },
{ min: 3, max: 20, message: '用户名长度应为3-20个字符', trigger: 'blur' }
{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' }
{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' }
{required: true, message: '请输入邮箱', trigger: 'blur'},
{type: 'email', message: '请输入正确的邮箱格式', trigger: 'blur'}
],
role: [
{ required: true, message: '请选择角色', trigger: 'change' }
{required: true, message: '请选择角色', trigger: 'change'}
],
status: [
{ required: true, message: '请选择状态', trigger: 'change' }
{required: true, message: '请选择状态', trigger: 'change'}
]
};
@@ -383,12 +384,12 @@ const handleUserModalOk = () => {
}
} else {
// 新增用户
const id = Math.max(...userList.value.map(item => item.id)) + 1;
const id: any = 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();
userForm.id = id;
userList.value.push({
id,
...userForm,
roleText,
statusText,

View File

@@ -437,7 +437,7 @@ onMounted(() => {
.image-bed-upload-container {
width: 100%;
padding: 10px;
//padding: 10px;
}
.image-bed-upload-content {

View File

@@ -528,7 +528,7 @@ const handleRecognize = async () => {
};
</script>
<style scoped>
<style scoped lang="scss">
.ocr-detection {
padding: 20px;
}
@@ -656,7 +656,6 @@ const handleRecognize = async () => {
}
.result-list {
max-height: 350px;
overflow-y: auto;
}

View File

@@ -0,0 +1,434 @@
<template>
<div class="qrcode-detection">
<a-card class="main-card">
<template #title>
<div class="card-title">
<scan-outlined/>
<span>二维码识别</span>
<a-tag color="blue" class="version-tag">增强版</a-tag>
</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-row>
<div class="upload-container">
<a-upload
v-model:file-list="fileList"
list-type="picture-card"
:before-upload="beforeUpload"
:multiple="false"
@remove="handleRemove"
>
<div v-if="!fileList.length">
<plus-outlined/>
<div style="margin-top: 8px">上传图片</div>
</div>
</a-upload>
</div>
<div class="preview-container" v-if="imageUrl">
<a-row :gutter="[16, 16]">
<a-col :span="12">
<div class="canvas-wrapper">
<!-- 图片预览区域 -->
<div class="image-preview-controls">
<div class="zoom-controls">
<a-button type="text" @click="zoomOut" :disabled="zoomLevel <= 10">
<template #icon><minus-outlined /></template>
</a-button>
<span class="zoom-level">{{ zoomLevel }}%</span>
<a-button type="text" @click="zoomIn" :disabled="zoomLevel >= 200">
<template #icon><plus-outlined /></template>
</a-button>
</div>
<a-tooltip title="使用鼠标滚轮缩放图片,按住鼠标拖动移动图片">
<info-circle-outlined />
</a-tooltip>
</div>
<div
class="canvas-container"
ref="canvasContainerRef"
@wheel="handleWheel"
@mousedown="startDrag"
@mousemove="onDrag"
@mouseup="stopDrag"
@mouseleave="stopDrag"
>
<canvas ref="canvasRef" :style="`transform: scale(${zoomLevel / 100}) translate(${dragOffset.x}px, ${dragOffset.y}px); transform-origin: top left;`"></canvas>
</div>
</div>
</a-col>
<a-col :span="12">
<a-card title="识别结果" :bordered="false" class="result-card">
<template #extra>
<a-button type="primary" :loading="recognizing" @click="handleRecognize">
{{ recognizing ? '识别中...' : '开始识别' }}
</a-button>
</template>
<div class="result-content">
<a-empty v-if="!qrCodeResult" description="暂无识别结果"/>
<div v-else class="qrcode-result">
<div class="result-header">
<div class="result-stats">
<a-tag color="blue">二维码类型: {{ qrCodeType }}</a-tag>
</div>
<div class="result-actions">
<a-button type="link" size="small" @click="copyQRCodeResult">
<template #icon><copy-outlined /></template>
复制内容
</a-button>
</div>
</div>
<div class="result-detail">
<a-typography-paragraph>
<template v-if="isUrl(qrCodeResult)">
<a-alert type="success" show-icon>
<template #message>识别到网址</template>
<template #description>
<a :href="qrCodeResult" target="_blank">{{ qrCodeResult }}</a>
</template>
</a-alert>
</template>
<template v-else>
<a-alert type="info" show-icon>
<template #message>识别到文本内容</template>
<template #description>
<div class="qrcode-text">{{ qrCodeResult }}</div>
</template>
</a-alert>
</template>
</a-typography-paragraph>
</div>
</div>
</div>
</a-card>
</a-col>
</a-row>
</div>
</a-card>
</div>
</template>
<script setup lang="ts">
import {
PlusOutlined,
ScanOutlined,
MinusOutlined,
CopyOutlined,
InfoCircleOutlined
} from '@ant-design/icons-vue';
import { message } from 'ant-design-vue';
import type { UploadProps } from 'ant-design-vue';
import { ready, scan } from 'qr-scanner-wechat';
// 状态变量
const fileList = ref<any[]>([]);
const imageUrl = ref<string>('');
const recognizing = ref<boolean>(false);
const qrCodeResult = ref<string>('');
const qrCodeType = ref<string>('未知');
const canvasRef = ref<HTMLCanvasElement | null>(null);
const canvasContainerRef = ref<HTMLDivElement | null>(null);
const zoomLevel = ref<number>(100); // 默认缩放级别为100%
// 拖动相关状态
const isDragging = ref<boolean>(false);
const dragOffset = ref<{x: number, y: number}>({x: 0, y: 0});
const dragStart = ref<{x: number, y: number}>({x: 0, y: 0});
// 初始化二维码扫描模块
onMounted(async () => {
try {
await ready();
message.success('二维码识别模块初始化成功');
} catch (error) {
message.error('二维码识别模块初始化失败');
console.error(error);
}
});
// 判断是否为URL
const isUrl = (str: string): boolean => {
try {
new URL(str);
return true;
} catch {
return false;
}
};
// 复制二维码结果
const copyQRCodeResult = () => {
if (qrCodeResult.value) {
navigator.clipboard.writeText(qrCodeResult.value)
.then(() => {
message.success('复制成功');
})
.catch(() => {
message.error('复制失败');
});
}
};
// 图片上传前的处理
const beforeUpload: UploadProps['beforeUpload'] = async (file) => {
const isImage = file.type.startsWith('image/');
if (!isImage) {
message.error('只能上传图片文件!');
return false;
}
// 清除之前的结果
qrCodeResult.value = '';
qrCodeType.value = '未知';
// 创建图片URL
imageUrl.value = URL.createObjectURL(file);
// 在Canvas上绘制图片
const img = new Image();
img.onload = () => {
if (canvasRef.value) {
const canvas = canvasRef.value;
canvas.width = img.width;
canvas.height = img.height;
const ctx = canvas.getContext('2d');
if (ctx) {
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(img, 0, 0, img.width, img.height);
}
}
};
img.src = imageUrl.value;
return false; // 阻止默认上传行为
};
// 移除图片
const handleRemove = () => {
imageUrl.value = '';
qrCodeResult.value = '';
qrCodeType.value = '未知';
if (canvasRef.value) {
const ctx = canvasRef.value.getContext('2d');
if (ctx) {
ctx.clearRect(0, 0, canvasRef.value.width, canvasRef.value.height);
}
}
// 重置拖动和缩放
zoomLevel.value = 100;
dragOffset.value = { x: 0, y: 0 };
};
// 识别二维码
const handleRecognize = async () => {
if (!imageUrl.value) {
message.warning('请先上传图片');
return;
}
recognizing.value = true;
try {
const img = new Image();
img.src = imageUrl.value;
// 等待图片加载完成
await new Promise((resolve) => {
if (img.complete) {
resolve(true);
} else {
img.onload = () => resolve(true);
}
});
// 使用qr-scanner-wechat进行识别
const result:any = await scan(img);
if (result.text) {
qrCodeResult.value = result.text;
qrCodeType.value = result.type || '标准二维码';
message.success('二维码识别成功');
} else {
message.warning('未检测到二维码');
}
} catch (error) {
console.error('二维码识别失败:', error);
message.error('二维码识别失败');
} finally {
recognizing.value = false;
}
};
// 缩放相关函数
const zoomIn = () => {
zoomLevel.value = Math.min(zoomLevel.value + 10, 200);
};
const zoomOut = () => {
zoomLevel.value = Math.max(zoomLevel.value - 10, 10);
};
const handleWheel = (e: WheelEvent) => {
e.preventDefault();
if (e.deltaY < 0) {
zoomIn();
} else {
zoomOut();
}
};
// 拖动相关函数
const startDrag = (e: MouseEvent) => {
isDragging.value = true;
dragStart.value = {
x: e.clientX - dragOffset.value.x,
y: e.clientY - dragOffset.value.y
};
};
const onDrag = (e: MouseEvent) => {
if (isDragging.value) {
dragOffset.value = {
x: e.clientX - dragStart.value.x,
y: e.clientY - dragStart.value.y
};
}
};
const stopDrag = () => {
isDragging.value = false;
};
</script>
<style scoped lang="scss">
.qrcode-detection {
padding: 20px;
max-width: 1200px;
margin: 0 auto;
.main-card {
border-radius: 12px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
overflow: hidden;
.card-title {
display: flex;
align-items: center;
gap: 10px;
font-size: 18px;
font-weight: 600;
.version-tag {
margin-left: 10px;
font-size: 12px;
font-weight: normal;
}
}
}
.upload-container {
margin: 20px 0;
display: flex;
justify-content: center;
}
.preview-container {
margin-top: 20px;
.canvas-wrapper {
border: 1px solid #eee;
border-radius: 8px;
overflow: hidden;
height: 400px;
position: relative;
.image-preview-controls {
position: absolute;
top: 10px;
right: 10px;
z-index: 10;
display: flex;
align-items: center;
gap: 10px;
background: rgba(255, 255, 255, 0.8);
padding: 5px 10px;
border-radius: 20px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
.zoom-controls {
display: flex;
align-items: center;
gap: 5px;
.zoom-level {
min-width: 50px;
text-align: center;
font-size: 14px;
}
}
}
.canvas-container {
width: 100%;
height: 100%;
overflow: hidden;
position: relative;
background: #f5f5f5;
display: flex;
justify-content: center;
align-items: center;
canvas {
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
}
}
.result-card {
min-height: 400px;
display: flex;
flex-direction: column;
.result-content {
flex: 1;
overflow-y: auto;
padding: 10px 0;
.qrcode-result {
.result-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px;
}
.result-detail {
margin-top: 15px;
.qrcode-text {
word-break: break-all;
white-space: pre-wrap;
font-size: 14px;
line-height: 1.6;
padding: 10px;
background: #f9f9f9;
border-radius: 4px;
max-height: 200px;
overflow-y: auto;
}
}
}
}
}
}
}
</style>

View File

@@ -19,14 +19,14 @@
<AForm layout="vertical">
<AFormItem label="存储商" name="sourceProvider">
<ASelect
v-model:value="sourceStorage.provider"
placeholder="请选择源存储商"
@change="handleSourceProviderChange"
v-model:value="sourceStorage.id"
placeholder="请选择源存储商"
@change="handleSourceProviderChange"
>
<ASelectOption
v-for="item in storageList"
:key="item.provider + '-' + item.bucket"
:value="item.provider + '-' + item.bucket"
v-for="item in storageList"
:key="item.id"
:value="item.id"
>
<AFlex align="center" gap="small">
<AAvatar :size="20" shape="circle" :src="ProviderIcon[item.provider]" />
@@ -41,17 +41,39 @@
<AAvatar :size="40" shape="circle" :src="ProviderIcon[selectedSourceStorage.provider]" />
<div class="storage-info">
<div class="storage-name">{{ selectedSourceStorage.bucket }}</div>
<ATag :color="ProviderColorMap[selectedSourceStorage.provider]">{{ ProviderNameMap[selectedSourceStorage.provider] }}</ATag>
<ATag :color="ProviderColorMap[selectedSourceStorage.provider]">
{{ ProviderNameMap[selectedSourceStorage.provider] }}
</ATag>
</div>
</div>
<div class="storage-card-content">
<div class="storage-detail">
<AAvatar size="small" shape="square" :src="bucket" />
<span>{{ selectedSourceStorage.capacity }}GB</span>
<div class="detail-text">
<div class="detail-label">存储容量</div>
<div class="detail-value">{{ selectedSourceStorage.capacity }}GB</div>
</div>
</div>
<div class="storage-detail">
<AAvatar size="small" shape="circle" :src="location" />
<span>{{ AliRegionMap[selectedSourceStorage.region] || selectedSourceStorage.region }}</span>
<div class="detail-text">
<div class="detail-label">存储区域</div>
<div class="detail-value">{{ AliRegionMap[selectedSourceStorage.region] || selectedSourceStorage.region }}</div>
</div>
</div>
<div class="storage-detail">
<AAvatar size="small" shape="square" :src="endpointIcon" />
<div class="detail-text">
<div class="detail-label">访问端点</div>
<div class="detail-value">{{ selectedSourceStorage.endpoint }}</div>
</div>
</div>
<div class="storage-detail">
<AAvatar size="small" shape="square" :src="dateIcon" />
<div class="detail-text">
<div class="detail-label">创建时间</div>
<div class="detail-value">{{ formatDate(selectedSourceStorage.created_at) }}</div>
</div>
</div>
</div>
</div>
@@ -71,15 +93,15 @@
<AForm layout="vertical">
<AFormItem label="存储商" name="targetProvider">
<ASelect
v-model:value="targetStorage.provider"
placeholder="请选择目标存储商"
@change="handleTargetProviderChange"
:disabled="!sourceStorage.provider"
v-model:value="targetStorage.id"
placeholder="请选择目标存储商"
@change="handleTargetProviderChange"
:disabled="!sourceStorage.id"
>
<ASelectOption
v-for="item in availableTargetStorages"
:key="item.provider + '-' + item.bucket"
:value="item.provider + '-' + item.bucket"
v-for="item in availableTargetStorages"
:key="item.id"
:value="item.id"
>
<AFlex align="center" gap="small">
<AAvatar :size="20" shape="circle" :src="ProviderIcon[item.provider]" />
@@ -94,17 +116,39 @@
<AAvatar :size="40" shape="circle" :src="ProviderIcon[selectedTargetStorage.provider]" />
<div class="storage-info">
<div class="storage-name">{{ selectedTargetStorage.bucket }}</div>
<ATag :color="ProviderColorMap[selectedTargetStorage.provider]">{{ ProviderNameMap[selectedTargetStorage.provider] }}</ATag>
<ATag :color="ProviderColorMap[selectedTargetStorage.provider]">
{{ ProviderNameMap[selectedTargetStorage.provider] }}
</ATag>
</div>
</div>
<div class="storage-card-content">
<div class="storage-detail">
<AAvatar size="small" shape="square" :src="bucket" />
<span>{{ selectedTargetStorage.capacity }}GB</span>
<div class="detail-text">
<div class="detail-label">存储容量</div>
<div class="detail-value">{{ selectedTargetStorage.capacity }}GB</div>
</div>
</div>
<div class="storage-detail">
<AAvatar size="small" shape="circle" :src="location" />
<span>{{ AliRegionMap[selectedTargetStorage.region] || selectedTargetStorage.region }}</span>
<div class="detail-text">
<div class="detail-label">存储区域</div>
<div class="detail-value">{{ AliRegionMap[selectedTargetStorage.region] || selectedTargetStorage.region }}</div>
</div>
</div>
<div class="storage-detail">
<AAvatar size="small" shape="square" :src="endpointIcon" />
<div class="detail-text">
<div class="detail-label">访问端点</div>
<div class="detail-value">{{ selectedTargetStorage.endpoint }}</div>
</div>
</div>
<div class="storage-detail">
<AAvatar size="small" shape="square" :src="dateIcon" />
<div class="detail-text">
<div class="detail-label">创建时间</div>
<div class="detail-value">{{ formatDate(selectedTargetStorage.created_at) }}</div>
</div>
</div>
</div>
</div>
@@ -114,11 +158,11 @@
<div class="backup-action">
<AButton
type="primary"
size="large"
:disabled="!isBackupReady"
@click="startBackup"
:loading="backupInProgress"
type="primary"
size="large"
:disabled="!isBackupReady"
@click="startBackup"
:loading="backupInProgress"
>
开始备份
</AButton>
@@ -154,8 +198,8 @@ import targetIcon from "@/assets/svgs/target-storage.svg";
const router = useRouter();
const storageList = ref<any[]>([]);
const sourceStorage = ref({ provider: '', bucket: '' });
const targetStorage = ref({ provider: '', bucket: '' });
const sourceStorage = ref({ id: null });
const targetStorage = ref({ id: null });
const selectedSourceStorage = ref<any>(null);
const selectedTargetStorage = ref<any>(null);
const backupModalVisible = ref(false);
@@ -164,14 +208,25 @@ const backupStatus = ref('准备开始备份...');
const backupInProgress = ref(false);
const backupTaskId = ref('');
import dateIcon from "@/assets/svgs/time.svg";
import endpointIcon from "@/assets/svgs/endpoint.svg";
const formatDate = (dateString) => {
return new Date(dateString).toLocaleDateString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit'
});
};
// 获取存储列表
async function getStorageList() {
const res: any = await listUserStorageConfigApi();
if (res && res.code === 200) {
storageList.value = res.data.records;
// 重置选择
sourceStorage.value = { provider: '', bucket: '' };
targetStorage.value = { provider: '', bucket: '' };
sourceStorage.value = { id: null };
targetStorage.value = { id: null };
selectedSourceStorage.value = null;
selectedTargetStorage.value = null;
}
@@ -179,12 +234,8 @@ async function getStorageList() {
// 计算可用的目标存储(排除已选择的源存储)
const availableTargetStorages = computed(() => {
if (!sourceStorage.value.provider) return [];
return storageList.value.filter(item => {
const sourceKey = sourceStorage.value.provider;
const itemKey = item.provider + '-' + item.bucket;
return sourceKey !== itemKey;
});
if (!sourceStorage.value.id) return [];
return storageList.value.filter(item => item.id !== sourceStorage.value.id);
});
// 判断是否可以开始备份
@@ -199,22 +250,17 @@ function handleSourceProviderChange(value) {
return;
}
const [provider, bucket] = value.split('-');
const selected = storageList.value.find(item =>
item.provider === provider && item.bucket === bucket
);
const selected = storageList.value.find(item => item.id === value);
if (selected) {
selectedSourceStorage.value = selected;
// 如果目标存储与源存储相同,则清空目标存储
if (targetStorage.value.provider) {
const [targetProvider, targetBucket] = targetStorage.value.provider.split('-');
if (targetProvider === provider && targetBucket === bucket) {
targetStorage.value.provider = '';
selectedTargetStorage.value = null;
}
if (targetStorage.value.id === value) {
targetStorage.value.id = null;
selectedTargetStorage.value = null;
}
} else {
selectedSourceStorage.value = null;
}
}
@@ -225,13 +271,12 @@ function handleTargetProviderChange(value) {
return;
}
const [provider, bucket] = value.split('-');
const selected = storageList.value.find(item =>
item.provider === provider && item.bucket === bucket
);
const selected = storageList.value.find(item => item.id === value);
if (selected) {
selectedTargetStorage.value = selected;
} else {
selectedTargetStorage.value = null;
}
}
@@ -245,17 +290,9 @@ async function startBackup() {
backupStatus.value = '正在准备备份...';
try {
// 调用备份API
const sourceProviderInfo = selectedSourceStorage.value.provider;
const sourceBucketInfo = selectedSourceStorage.value.bucket;
const targetProviderInfo = selectedTargetStorage.value.provider;
const targetBucketInfo = selectedTargetStorage.value.bucket;
const res: any = await backupStorageApi(
sourceProviderInfo,
sourceBucketInfo,
targetProviderInfo,
targetBucketInfo
selectedSourceStorage.value.id,
selectedTargetStorage.value.id
);
if (res && res.code === 200) {
@@ -343,10 +380,11 @@ onMounted(() => {
</script>
<style scoped lang="scss">
/* 样式保持不变 */
.account-setting-backup {
width: 100%;
height: 100%;
overflow: auto;
//height: 100%;
//overflow: auto;
.account-setting-backup-header {
display: flex;
@@ -459,13 +497,34 @@ onMounted(() => {
}
.storage-card-content {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
margin-top: 12px;
.storage-detail {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 8px;
color: #666;
font-size: 14px;
padding: 8px;
background: rgba(245, 245, 245, 0.5);
border-radius: 8px;
.detail-text {
display: flex;
flex-direction: column;
.detail-label {
font-size: 12px;
color: #999;
}
.detail-value {
font-size: 14px;
font-weight: 500;
color: #333;
}
}
}
}
}

View File

@@ -537,7 +537,7 @@ const handleThirdPartySuccess = (_type: string) => {
justify-content: flex-start;
align-items: flex-start;
gap: 16px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
//box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
border: 1px solid #f0f0f0;
transition: all 0.3s ease;

View File

@@ -63,7 +63,7 @@
@finish="handleUpdatePhone"
>
<AFormItem label="当前手机">
<AInput :value="currentPhone" disabled />
<AInput :value="userStore.user.phone" disabled />
</AFormItem>
<AFormItem
@@ -120,13 +120,6 @@ import { message } from 'ant-design-vue';
import { sendMessage } from '@/api/user';
import useStore from "@/store";
defineProps({
currentPhone: {
type: String,
default: ''
}
});
const emit = defineEmits(['success']);
// 获取用户store

View File

@@ -7,6 +7,7 @@
:width="500"
:maskClosable="false"
@cancel="handleCancel"
:footer="null"
>
<div class="third-party-container">
<div class="third-party-item">
@@ -22,18 +23,18 @@
</div>
</div>
<div class="third-party-action">
<AButton
type="primary"
size="small"
<AButton
type="primary"
size="small"
:disabled="thirdPartyStatus.wechat"
@click="handleBindThirdParty('wechat')"
>
{{ thirdPartyStatus.wechat ? '已绑定' : '绑定' }}
</AButton>
<AButton
v-if="thirdPartyStatus.wechat"
type="link"
danger
<AButton
v-if="thirdPartyStatus.wechat"
type="link"
danger
size="small"
@click="handleUnbindThirdParty('wechat')"
>
@@ -55,18 +56,18 @@
</div>
</div>
<div class="third-party-action">
<AButton
type="primary"
size="small"
<AButton
type="primary"
size="small"
:disabled="thirdPartyStatus.qq"
@click="handleBindThirdParty('qq')"
>
{{ thirdPartyStatus.qq ? '已绑定' : '绑定' }}
</AButton>
<AButton
v-if="thirdPartyStatus.qq"
type="link"
danger
<AButton
v-if="thirdPartyStatus.qq"
type="link"
danger
size="small"
@click="handleUnbindThirdParty('qq')"
>
@@ -88,18 +89,18 @@
</div>
</div>
<div class="third-party-action">
<AButton
type="primary"
size="small"
<AButton
type="primary"
size="small"
:disabled="thirdPartyStatus.github"
@click="handleBindThirdParty('github')"
>
{{ thirdPartyStatus.github ? '已绑定' : '绑定' }}
</AButton>
<AButton
v-if="thirdPartyStatus.github"
type="link"
danger
<AButton
v-if="thirdPartyStatus.github"
type="link"
danger
size="small"
@click="handleUnbindThirdParty('github')"
>
@@ -121,18 +122,18 @@
</div>
</div>
<div class="third-party-action">
<AButton
type="primary"
size="small"
<AButton
type="primary"
size="small"
:disabled="thirdPartyStatus.gitee"
@click="handleBindThirdParty('gitee')"
>
{{ thirdPartyStatus.gitee ? '已绑定' : '绑定' }}
</AButton>
<AButton
v-if="thirdPartyStatus.gitee"
type="link"
danger
<AButton
v-if="thirdPartyStatus.gitee"
type="link"
danger
size="small"
@click="handleUnbindThirdParty('gitee')"
>
@@ -141,10 +142,6 @@
</div>
</div>
</div>
<template #footer>
<AButton key="back" @click="handleCancel">关闭</AButton>
</template>
</AModal>
<!-- 解绑确认模态窗口 -->
@@ -242,7 +239,7 @@ const handleBindThirdParty = (type: string) => {
// 解绑第三方账号(显示确认窗口)
const handleUnbindThirdParty = (type: string) => {
currentUnbindType.value = type;
// 设置平台名称
switch (type) {
case 'wechat':
@@ -261,7 +258,7 @@ const handleUnbindThirdParty = (type: string) => {
currentUnbindPlatform.value = '';
break;
}
// 显示确认窗口
confirmUnbindVisible.value = true;
};
@@ -276,13 +273,13 @@ const cancelUnbind = () => {
// 确认解绑
const confirmUnbind = async () => {
if (!currentUnbindType.value) return;
try {
submitLoading.value = true;
// 这里需要实现解绑第三方账号的API调用暂时模拟成功
setTimeout(() => {
message.success(`${currentUnbindPlatform.value}账号解绑成功`);
// 更新状态
switch (currentUnbindType.value) {
case 'wechat':
@@ -300,7 +297,7 @@ const confirmUnbind = async () => {
default:
break;
}
emit('success', `unbind_${currentUnbindType.value}`);
cancelUnbind();
submitLoading.value = false;
@@ -362,4 +359,4 @@ const confirmUnbind = async () => {
display: flex;
gap: 8px;
}
</style>
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,126 @@
// 执行记录页面样式
.account-setting-log {
width: 100%;
.account-setting-log-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
span {
font-size: 20px;
font-weight: bold;
}
}
.account-setting-log-body {
.log-filter-section {
background-color: white;
padding: 16px;
border-radius: 8px;
margin-bottom: 20px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
}
.log-card {
margin-bottom: 20px;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
.storage-info {
display: flex;
align-items: center;
}
.content-tags {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.time-info {
font-size: 12px;
line-height: 1.5;
}
.error-message {
color: #ff4d4f;
}
.status-dot {
display: inline-block;
width: 6px;
height: 6px;
border-radius: 50%;
background-color: #d9d9d9;
margin-right: 4px;
&.active {
background-color: currentColor;
}
}
.task-detail-info {
.detail-item {
margin-bottom: 8px;
display: flex;
align-items: center;
.label {
color: rgba(0, 0, 0, 0.65);
margin-right: 8px;
min-width: 80px;
}
}
}
}
}
.log-detail {
.section-title {
font-size: 16px;
font-weight: bold;
margin: 24px 0 16px;
padding-bottom: 8px;
border-bottom: 1px solid #f0f0f0;
}
.files-list, .task-logs {
margin-top: 24px;
}
.log-item {
margin-bottom: 8px;
.log-time {
font-size: 12px;
color: rgba(0, 0, 0, 0.45);
margin-bottom: 4px;
}
.log-message {
padding: 8px 12px;
background-color: #f5f5f5;
border-radius: 4px;
&.log-info {
background-color: #e6f7ff;
}
&.log-success {
background-color: #f6ffed;
}
&.log-warning {
background-color: #fffbe6;
}
&.log-error {
background-color: #fff2f0;
}
}
}
}
}

View File

@@ -35,6 +35,18 @@
</template>
<span class="ant-menu-item-title">图像备份</span>
</AMenuItem>
<AMenuItem title="定时任务" key="task" :style="menuCSSStyle">
<template #icon>
<AAvatar shape="square" size="small" :src="time"/>
</template>
<span class="ant-menu-item-title">定时任务</span>
</AMenuItem>
<AMenuItem title="执行记录" key="log" :style="menuCSSStyle">
<template #icon>
<AAvatar shape="square" size="small" :src="logIcon"/>
</template>
<span class="ant-menu-item-title">执行记录</span>
</AMenuItem>
</AMenu>
</div>
@@ -46,6 +58,8 @@ import home from "@/assets/svgs/home.svg";
import peopleAlbum from "@/assets/svgs/people-album.svg";
import storage from "@/assets/svgs/storage.svg";
import backup from "@/assets/svgs/source-storage.svg";
import time from "@/assets/svgs/time.svg";
import logIcon from "@/assets/svgs/data_analysis.svg";
const menuStore = useStore().menu;
const menuCSSStyle: any = reactive({

View File

@@ -0,0 +1,846 @@
<template>
<div class="account-setting-task">
<div class="account-setting-task-header">
<span>定时任务</span>
<AButton type="primary" @click="showAddTaskModal">
<template #icon>
<PlusOutlined/>
</template>
新增任务
</AButton>
</div>
<div class="account-setting-task-body">
<!-- 任务列表 -->
<AEmpty v-if="taskList.length === 0" description="暂无定时任务"/>
<div v-else class="task-list">
<div v-for="(task, index) in taskList" :key="index" class="task-card" :class="getTaskTypeClass(task.type)">
<div class="task-card-header">
<div class="task-card-title">
<AAvatar shape="square" size="small" :src="getTaskIcon(task.type)" class="task-icon"/>
<span>{{ getTaskTypeName(task.type) }}</span>
<ATag :color="getTaskTypeColor(task.type)" class="task-type-tag">{{ getTaskTypeShortName(task.type) }}</ATag>
</div>
<div class="task-card-actions">
<AButton type="text" size="small" @click="editTask(index)" class="action-btn edit-btn">
<template #icon>
<EditOutlined/>
</template>
编辑
</AButton>
<AButton type="text" size="small" @click="deleteTask(index)" class="action-btn delete-btn">
<template #icon>
<DeleteOutlined/>
</template>
删除
</AButton>
<ASwitch v-model:checked="task.enabled" @change="(checked) => toggleTaskStatus(index, checked)" class="task-switch"/>
</div>
</div>
<div class="task-card-content" :class="{ 'disabled': !task.enabled }">
<div class="task-info">
<div class="task-info-group">
<div class="task-info-item">
<span class="label">执行频率</span>
<span class="value">
<ATag :color="getFrequencyColor(task.frequency)" class="info-tag">{{ getFrequencyText(task.frequency) }}</ATag>
</span>
</div>
<div class="task-info-item">
<span class="label">执行时间</span>
<span class="value">
<ATag color="blue" class="info-tag time-tag">{{ formatTime(task.time) }}</ATag>
</span>
</div>
</div>
<!-- 备份任务特有信息 -->
<template v-if="task.type === 'backup'">
<div class="task-info-group backup-info">
<div class="group-title">备份配置</div>
<div class="task-info-item">
<span class="label">备份存储</span>
<span class="value">
<AFlex align="center" gap="small" class="storage-info">
<AAvatar v-if="getStorageById(task.storageId)" :size="20" shape="circle"
:src="ProviderIcon[getStorageById(task.storageId)?.provider]"/>
<span>{{
getStorageById(task.storageId) ? `${ProviderNameMap[getStorageById(task.storageId)?.provider]} - ${getStorageById(task.storageId)?.bucket}` : '未设置'
}}</span>
</AFlex>
</span>
</div>
<div class="task-info-item">
<span class="label">备份内容</span>
<span class="value content-tags">
<template v-if="task.content && task.content.length">
<ATag v-for="item in task.content" :key="item" color="cyan" class="content-tag">{{ getContentItemName(item) }}</ATag>
</template>
<template v-else></template>
</span>
</div>
</div>
</template>
<!-- 清理任务特有信息 -->
<template v-if="task.type === 'cleanup'">
<div class="task-info-group cleanup-info">
<div class="group-title">清理配置</div>
<div class="task-info-item">
<span class="label">清理策略</span>
<span class="value">
<ATag :color="getCleanupStrategyColor(task.strategy)" class="info-tag">{{ getCleanupStrategyText(task.strategy) }}</ATag>
</span>
</div>
<div v-if="task.strategy !== 'duplicates'" class="task-info-item">
<span class="label">未使用阈值</span>
<span class="value">
<ATag color="orange" class="info-tag">{{ task.unusedThreshold }}</ATag>
</span>
</div>
</div>
</template>
<!-- 回收站任务特有信息 -->
<template v-if="task.type === 'recycle'">
<div class="task-info-group recycle-info">
<div class="group-title">回收站配置</div>
<div class="task-info-item">
<span class="label">保留策略</span>
<span class="value">
<ATag :color="getRetentionPolicyColor(task.retentionPolicy)" class="info-tag">{{ getRetentionPolicyText(task.retentionPolicy) }}</ATag>
</span>
</div>
<div v-if="task.retentionPolicy === 'time'" class="task-info-item">
<span class="label">保留时间</span>
<span class="value">
<ATag color="purple" class="info-tag">{{ task.retentionDays }}</ATag>
</span>
</div>
<div class="task-info-item">
<span class="label">清空前通知</span>
<span class="value">
<ATag :color="task.notifyBeforeEmpty ? 'success' : 'default'" class="info-tag">{{ task.notifyBeforeEmpty ? '是' : '否' }}</ATag>
</span>
</div>
</div>
</template>
</div>
<div class="task-status">
<ATag :color="task.enabled ? 'success' : 'default'" class="status-tag">
<template #icon>
<span class="status-dot" :class="{'active': task.enabled}"></span>
</template>
{{ task.enabled ? '已启用' : '已禁用' }}
</ATag>
<div class="task-last-run" v-if="task.lastRun">
<ATag color="#87878a" bordered class="last-run-tag">
<template #icon>
<ClockCircleOutlined />
</template>
{{ formatDate(task.lastRun) }}
</ATag>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 添加任务模态框 -->
<AModal
v-model:open="addTaskModalVisible"
:title="isEditing ? '编辑任务' : '新增任务'"
@ok="handleAddTaskOk"
@cancel="handleAddTaskCancel"
:okText="isEditing ? '保存' : '添加'"
cancelText="取消"
:width="650"
class="task-modal"
:maskClosable="false"
:destroyOnClose="true"
>
<!-- 第一步选择任务类型 -->
<div v-if="!selectedTaskType && !isEditing" class="task-type-selection">
<p class="selection-tip">请选择要创建的任务类型</p>
<div class="task-type-options">
<CheckCard
class="task-type-card"
value="backup"
v-model="tempSelectedType"
selectionMode="single"
iconPosition="top-right"
borderRadius="16px"
:showHoverCircle="true"
:iconSize="28"
>
<div class="task-type-content">
<AAvatar shape="square" :size="40" :src="backupIcon"/>
<span>定时备份</span>
<p>定期备份您的照片和相册数据</p>
</div>
</CheckCard>
<CheckCard
class="task-type-card"
value="cleanup"
v-model="tempSelectedType"
selectionMode="single"
iconPosition="top-right"
borderRadius="16px"
margin="12px"
:showHoverCircle="true"
:iconSize="28"
>
<div class="task-type-content">
<AAvatar shape="square" :size="40" :src="cleanupIcon"/>
<span>定时清理</span>
<p>定期清理重复或未使用的文件</p>
</div>
</CheckCard>
<CheckCard
class="task-type-card"
value="recycle"
v-model="tempSelectedType"
selectionMode="single"
iconPosition="top-right"
borderRadius="16px"
:showHoverCircle="true"
:iconSize="28"
>
<div class="task-type-content">
<AAvatar shape="square" :size="40" :src="recycleIcon"/>
<span>回收站管理</span>
<p>定期清空回收站中的文件</p>
</div>
</CheckCard>
</div>
<div class="task-type-actions">
<AButton type="primary" :disabled="!tempSelectedType" @click="confirmTaskType">下一步</AButton>
</div>
</div>
<!-- 第二步填写任务详情 -->
<div v-else>
<!-- 备份任务表单 -->
<AForm v-if="selectedTaskType === 'backup'" layout="vertical">
<AFormItem label="备份频率" name="backupFrequency">
<ASelect
v-model:value="currentTask.frequency"
placeholder="请选择备份频率"
>
<ASelectOption value="daily">每天</ASelectOption>
<ASelectOption value="weekly">每周</ASelectOption>
<ASelectOption value="monthly">每月</ASelectOption>
</ASelect>
</AFormItem>
<AFormItem label="备份时间" name="backupTime">
<ATimePicker
v-model:value="currentTask.time"
format="HH:mm"
placeholder="请选择备份时间"
style="width: 100%"
/>
</AFormItem>
<AFormItem label="备份存储" name="backupStorage">
<ASelect
v-model:value="currentTask.storageId"
placeholder="请选择备份存储"
>
<ASelectOption
v-for="item in storageList"
:key="item.id"
:value="item.id"
>
<AFlex align="center" gap="small">
<AAvatar :size="20" shape="circle" :src="ProviderIcon[item.provider]"/>
<span>{{ ProviderNameMap[item.provider] }} - {{ item.bucket }}</span>
</AFlex>
</ASelectOption>
</ASelect>
</AFormItem>
<AFormItem label="备份内容" name="backupContent">
<ACheckboxGroup
v-model:value="currentTask.content"
>
<ACheckbox value="photos">照片</ACheckbox>
<ACheckbox value="albums">相册</ACheckbox>
<ACheckbox value="settings">设置</ACheckbox>
</ACheckboxGroup>
</AFormItem>
</AForm>
<!-- 清理任务表单 -->
<AForm v-if="selectedTaskType === 'cleanup'" layout="vertical">
<AFormItem label="清理频率" name="cleanupFrequency">
<ASelect
v-model:value="currentTask.frequency"
placeholder="请选择清理频率"
>
<ASelectOption value="daily">每天</ASelectOption>
<ASelectOption value="weekly">每周</ASelectOption>
<ASelectOption value="monthly">每月</ASelectOption>
</ASelect>
</AFormItem>
<AFormItem label="清理时间" name="cleanupTime">
<ATimePicker
v-model:value="currentTask.time"
format="HH:mm"
placeholder="请选择清理时间"
style="width: 100%"
/>
</AFormItem>
<AFormItem label="清理策略" name="cleanupStrategy">
<ARadioGroup
v-model:value="currentTask.strategy"
>
<ARadio value="duplicates">仅清理重复文件</ARadio>
<ARadio value="unused">清理长期未使用文件</ARadio>
<ARadio value="both">两者都清理</ARadio>
</ARadioGroup>
</AFormItem>
<AFormItem
label="未使用时间阈值(天)"
name="unusedThreshold"
v-if="currentTask.strategy !== 'duplicates'"
>
<AInputNumber
v-model:value="currentTask.unusedThreshold"
:min="1"
:max="365"
style="width: 100%"
/>
</AFormItem>
</AForm>
<!-- 回收站管理表单 -->
<AForm v-if="selectedTaskType === 'recycle'" layout="vertical">
<AFormItem label="自动清空频率" name="recycleFrequency">
<ASelect
v-model:value="currentTask.frequency"
placeholder="请选择清空频率"
>
<ASelectOption value="weekly">每周</ASelectOption>
<ASelectOption value="biweekly">每两周</ASelectOption>
<ASelectOption value="monthly">每月</ASelectOption>
</ASelect>
</AFormItem>
<AFormItem label="执行时间" name="recycleTime">
<ATimePicker
v-model:value="currentTask.time"
format="HH:mm"
placeholder="请选择执行时间"
style="width: 100%"
/>
</AFormItem>
<AFormItem label="保留策略" name="retentionPolicy">
<ARadioGroup
v-model:value="currentTask.retentionPolicy"
>
<ARadio value="all">清空所有文件</ARadio>
<ARadio value="time">按时间清空</ARadio>
</ARadioGroup>
</AFormItem>
<AFormItem
label="保留时间(天)"
name="retentionDays"
v-if="currentTask.retentionPolicy === 'time'"
>
<AInputNumber
v-model:value="currentTask.retentionDays"
:min="1"
:max="90"
style="width: 100%"
/>
</AFormItem>
<AFormItem label="清空前通知" name="notifyBeforeEmpty">
<ASwitch
v-model:checked="currentTask.notifyBeforeEmpty"
/>
</AFormItem>
</AForm>
</div>
</AModal>
<!-- 删除确认对话框 -->
<AModal
v-model:open="deleteConfirmVisible"
title="确认删除"
@ok="confirmDelete"
@cancel="cancelDelete"
okText="删除"
cancelText="取消"
:okButtonProps="{ danger: true, size: 'middle' }"
class="delete-confirm-modal"
:maskClosable="false"
:closable="true"
>
<template #icon>
<ExclamationCircleOutlined style="color: #ff4d4f; font-size: 22px;" />
</template>
<div class="delete-confirm-content">
<p>确定要删除这个定时任务吗</p>
<p class="warning-text">此操作不可恢复请谨慎操作</p>
</div>
</AModal>
</div>
</template>
<script setup lang="ts">
import {message} from 'ant-design-vue';
import {PlusOutlined, EditOutlined, DeleteOutlined, ClockCircleOutlined, ExclamationCircleOutlined} from '@ant-design/icons-vue';
import {listUserStorageConfigApi} from "@/api/storage";
import backupIcon from "@/assets/svgs/source-storage.svg";
import cleanupIcon from "@/assets/svgs/cleanup.svg";
import recycleIcon from "@/assets/svgs/recycle.svg";
import {ProviderIcon, ProviderNameMap} from "@/constant/provider_map";
import dayjs from 'dayjs';
import CheckCard from '@/components/CheckCard/CheckCard.vue';
// 存储列表
const storageList = ref<any[]>([]);
// 任务列表
const taskList = ref<any[]>([]);
// 模态框相关
const addTaskModalVisible = ref(false);
const selectedTaskType = ref<any>();
const tempSelectedType = ref([]);
const isEditing = ref(false);
const editingIndex = ref(-1);
// 删除确认
const deleteConfirmVisible = ref(false);
const deletingIndex = ref(-1);
// 当前编辑的任务
const currentTask = reactive<any>({
type: '',
enabled: true,
frequency: '',
time: null,
// 备份任务特有
storageId: '',
content: [],
// 清理任务特有
strategy: 'duplicates',
unusedThreshold: 30,
// 回收站任务特有
retentionPolicy: 'time',
retentionDays: 30,
notifyBeforeEmpty: true,
});
// 获取存储列表
const getStorageList = async () => {
try {
const res: any = await listUserStorageConfigApi();
if (res.code === 200) {
storageList.value = res.data.records;
}
} catch (error) {
console.error('获取存储列表失败', error);
message.error('获取存储列表失败');
}
};
// 获取任务列表
const getTaskList = async () => {
try {
// 这里应该调用获取任务列表的API
// 模拟数据
taskList.value = [
{
type: 'backup',
enabled: true,
frequency: 'daily',
time: dayjs('2023-01-01 03:00:00'),
storageId: '1',
content: ['photos', 'albums'],
lastRun: '2023-05-15 03:00:00'
},
{
type: 'cleanup',
enabled: false,
frequency: 'weekly',
time: dayjs('2023-01-01 04:00:00'),
strategy: 'both',
unusedThreshold: 60,
lastRun: '2023-05-10 04:00:00'
}
];
message.success('获取任务列表成功');
} catch (error) {
console.error('获取任务列表失败', error);
message.error('获取任务列表失败');
}
};
// 刷新任务列表
const refreshTasks = () => {
getTaskList();
getStorageList();
};
// 显示添加任务模态框
const showAddTaskModal = () => {
isEditing.value = false;
selectedTaskType.value = '';
tempSelectedType.value = [];
resetCurrentTask();
addTaskModalVisible.value = true;
};
// 确认任务类型
const confirmTaskType = () => {
selectedTaskType.value = tempSelectedType.value[0];
currentTask.type = tempSelectedType.value;
// 设置默认值
if (selectedTaskType.value === 'backup') {
currentTask.frequency = 'daily';
currentTask.content = ['photos'];
} else if (selectedTaskType.value === 'cleanup') {
currentTask.frequency = 'weekly';
currentTask.strategy = 'duplicates';
} else if (selectedTaskType.value === 'recycle') {
currentTask.frequency = 'monthly';
currentTask.retentionPolicy = 'time';
}
};
// 重置当前任务
const resetCurrentTask = () => {
Object.assign(currentTask, {
type: '',
enabled: true,
frequency: '',
time: null,
storageId: '',
content: [],
strategy: 'duplicates',
unusedThreshold: 30,
retentionPolicy: 'time',
retentionDays: 30,
notifyBeforeEmpty: true,
});
};
// 处理添加任务确认
const handleAddTaskOk = () => {
// 验证表单
if (!currentTask.time) {
message.warning('请选择执行时间');
return;
}
if (currentTask.type === 'backup') {
if (!currentTask.storageId) {
message.warning('请选择备份存储');
return;
}
if (currentTask.content.length === 0) {
message.warning('请选择备份内容');
return;
}
}
// 如果是编辑模式
if (isEditing.value) {
taskList.value[editingIndex.value] = {...currentTask};
message.success('任务更新成功');
} else {
// 添加新任务
taskList.value.push({...currentTask});
message.success('任务添加成功');
}
// 关闭模态框
addTaskModalVisible.value = false;
};
// 处理添加任务取消
const handleAddTaskCancel = () => {
addTaskModalVisible.value = false;
};
// 编辑任务
const editTask = (index: number) => {
try {
isEditing.value = true;
editingIndex.value = index;
const task = taskList.value[index];
selectedTaskType.value = task.type;
// 重置当前任务对象
resetCurrentTask();
// 复制基本属性
Object.keys(task).forEach(key => {
if (key === 'time' && task[key]) {
// 确保时间字段正确处理
currentTask.time = dayjs(task[key]);
} else {
currentTask[key] = task[key];
}
});
// 打开模态框
addTaskModalVisible.value = true;
} catch (error) {
console.error('编辑任务出错', error);
message.error('编辑任务出错');
}
};
// 删除任务
const deleteTask = (index: number) => {
try {
deletingIndex.value = index;
deleteConfirmVisible.value = true;
} catch (error) {
console.error('删除任务出错', error);
message.error('删除任务出错');
}
};
// 确认删除
const confirmDelete = () => {
if (deletingIndex.value > -1) {
taskList.value.splice(deletingIndex.value, 1);
message.success('任务删除成功');
}
deleteConfirmVisible.value = false;
};
// 取消删除
const cancelDelete = () => {
deleteConfirmVisible.value = false;
};
// 切换任务状态
const toggleTaskStatus = (index: number, status: boolean) => {
try {
// 更新任务状态
taskList.value[index].enabled = status;
// 这里可以添加API调用来更新后端数据
// 例如: updateTaskStatusApi(taskList.value[index].id, status);
// 显示成功消息
message.success(`任务已${status ? '启用' : '禁用'}`);
} catch (error) {
console.error('切换任务状态出错', error);
message.error('切换任务状态出错');
}
};
// 获取任务图标
const getTaskIcon = (type: string) => {
switch (type) {
case 'backup':
return backupIcon;
case 'cleanup':
return cleanupIcon;
case 'recycle':
return recycleIcon;
default:
return '';
}
};
// 获取任务类型名称
const getTaskTypeName = (type: string) => {
switch (type) {
case 'backup':
return '定时备份';
case 'cleanup':
return '定时清理';
case 'recycle':
return '回收站管理';
default:
return '未知任务';
}
};
// 获取任务类型样式类
const getTaskTypeClass = (type: string) => {
switch (type) {
case 'backup':
return 'backup-task';
case 'cleanup':
return 'cleanup-task';
case 'recycle':
return 'recycle-task';
default:
return '';
}
};
// 获取任务类型颜色
const getTaskTypeColor = (type: string) => {
switch (type) {
case 'backup':
return 'blue';
case 'cleanup':
return 'green';
case 'recycle':
return 'orange';
default:
return 'default';
}
};
// 获取任务类型简称
const getTaskTypeShortName = (type: string) => {
switch (type) {
case 'backup':
return '备份';
case 'cleanup':
return '清理';
case 'recycle':
return '回收';
default:
return '未知';
}
};
// 获取频率颜色
const getFrequencyColor = (frequency: string) => {
switch (frequency) {
case 'daily':
return 'magenta';
case 'weekly':
return 'purple';
case 'biweekly':
return 'geekblue';
case 'monthly':
return 'cyan';
default:
return 'default';
}
};
// 获取清理策略颜色
const getCleanupStrategyColor = (strategy: string) => {
switch (strategy) {
case 'duplicates':
return 'green';
case 'unused':
return 'orange';
case 'both':
return 'volcano';
default:
return 'default';
}
};
// 获取保留策略颜色
const getRetentionPolicyColor = (policy: string) => {
switch (policy) {
case 'all':
return 'red';
case 'time':
return 'blue';
default:
return 'default';
}
};
// 获取内容项名称
const getContentItemName = (item: string) => {
const contentMap: Record<string, string> = {
photos: '照片',
albums: '相册',
settings: '设置'
};
return contentMap[item] || item;
};
// 获取频率文本
const getFrequencyText = (frequency: string) => {
switch (frequency) {
case 'daily':
return '每天';
case 'weekly':
return '每周';
case 'biweekly':
return '每两周';
case 'monthly':
return '每月';
default:
return '未知';
}
};
// 格式化时间
const formatTime = (time: any) => {
try {
if (!time) return '未设置';
return dayjs(time).format('HH:mm');
} catch (error) {
console.error('格式化时间出错', error);
return '时间格式错误';
}
};
// 格式化日期
const formatDate = (date: string) => {
try {
if (!date) return '未执行';
return dayjs(date).format('YYYY-MM-DD HH:mm');
} catch (error) {
console.error('格式化日期出错', error);
return '日期格式错误';
}
};
// 获取清理策略文本
const getCleanupStrategyText = (strategy: string) => {
switch (strategy) {
case 'duplicates':
return '仅清理重复文件';
case 'unused':
return '清理长期未使用文件';
case 'both':
return '清理重复和未使用文件';
default:
return '未知';
}
};
// 获取保留策略文本
const getRetentionPolicyText = (policy: string) => {
switch (policy) {
case 'all':
return '清空所有文件';
case 'time':
return '按时间清空';
default:
return '未知';
}
};
// 根据ID获取存储信息
const getStorageById = (id: string) => {
return storageList.value.find(item => item.id === id);
};
onMounted(() => {
getStorageList();
getTaskList();
});
</script>
<style scoped lang="scss" src="./index.scss">
</style>

View File

@@ -0,0 +1,721 @@
.account-setting-task {
width: calc(100% - 72px);
background-color: var(--white-color, #fff);
border-radius: 16px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.08);
transition: all 0.3s ease;
&:hover {
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.12);
}
.account-setting-task-header {
padding: 0 36px;
width: 100%;
height: 70px;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid #eaeaea;
background: linear-gradient(135deg, #f8f9fa 0%, #e9f2ff 100%);
border-radius: 16px 16px 0 0;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
span {
font-size: 22px;
font-weight: 600;
color: #222222;
position: relative;
padding-left: 16px;
letter-spacing: 0.5px;
&::before {
content: '';
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
width: 5px;
height: 24px;
background: linear-gradient(to bottom, #1890ff, #36cfc9);
border-radius: 3px;
}
}
}
.account-setting-task-body {
padding: 36px;
width: 100%;
display: flex;
flex-direction: column;
gap: 24px;
background-color: var(--white-color, #fff);
border-radius: 0 0 16px 16px;
.task-list {
display: flex;
flex-direction: column;
gap: 20px;
}
.task-card {
background-color: #fafafa;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.08);
transition: all 0.3s ease;
border: 1px solid #f0f0f0;
position: relative;
margin-bottom: 8px;
&:hover {
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.12);
transform: translateY(-4px);
}
&.backup-task {
border-left: 4px solid #1890ff;
&::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 4px;
background: linear-gradient(90deg, #1890ff, #36cfc9);
}
}
&.cleanup-task {
border-left: 4px solid #52c41a;
&::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 4px;
background: linear-gradient(90deg, #52c41a, #b7eb8f);
}
}
&.recycle-task {
border-left: 4px solid #fa8c16;
&::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 4px;
background: linear-gradient(90deg, #fa8c16, #ffd666);
}
}
.task-card-header {
padding: 16px 20px;
display: flex;
justify-content: space-between;
align-items: center;
background-color: #fff;
border-bottom: 1px solid #f0f0f0;
.task-card-title {
display: flex;
align-items: center;
gap: 10px;
.task-icon {
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1);
border-radius: 4px;
}
span {
font-size: 16px;
font-weight: 600;
color: #333;
}
.task-type-tag {
margin-left: 4px;
font-size: 12px;
border-radius: 4px;
}
}
.task-card-actions {
display: flex;
align-items: center;
gap: 12px;
.action-btn {
border-radius: 4px;
transition: all 0.2s;
padding: 0 8px;
&.edit-btn:hover {
color: #1890ff;
background-color: #e6f7ff;
}
&.delete-btn:hover {
color: #ff4d4f;
background-color: #fff1f0;
}
}
.task-switch {
margin-left: 4px;
}
}
}
.task-card-content {
padding: 20px;
background-color: #fff;
display: flex;
justify-content: space-between;
&.disabled {
opacity: 0.7;
background-color: #fafafa;
position: relative;
&::after {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.02);
pointer-events: none;
}
}
.task-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 16px;
.task-info-group {
display: flex;
flex-direction: column;
gap: 12px;
padding-bottom: 12px;
position: relative;
&:not(:last-child) {
border-bottom: 1px dashed #f0f0f0;
margin-bottom: 4px;
}
.group-title {
font-size: 14px;
font-weight: 500;
color: #666;
margin-bottom: 4px;
position: relative;
padding-left: 12px;
&::before {
content: '';
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
width: 4px;
height: 14px;
border-radius: 2px;
}
}
&.backup-info .group-title::before {
background: linear-gradient(to bottom, #1890ff, #36cfc9);
}
&.cleanup-info .group-title::before {
background: linear-gradient(to bottom, #52c41a, #b7eb8f);
}
&.recycle-info .group-title::before {
background: linear-gradient(to bottom, #fa8c16, #ffd666);
}
}
.task-info-item {
display: flex;
align-items: flex-start;
gap: 8px;
.label {
font-weight: 500;
color: #666;
min-width: 80px;
}
.value {
color: #333;
flex: 1;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
.info-tag {
margin: 0;
border-radius: 4px;
}
.time-tag {
font-family: monospace;
}
.content-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
.content-tag {
margin: 0;
border-radius: 4px;
}
}
.storage-info {
background-color: #f5f5f5;
padding: 4px 8px;
border-radius: 4px;
}
}
}
}
.task-status {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 12px;
margin-left: 16px;
.status-tag {
padding: 4px 8px;
border-radius: 4px;
font-weight: 500;
.status-dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
background-color: #d9d9d9;
margin-right: 6px;
&.active {
background-color: #52c41a;
box-shadow: 0 0 6px rgba(82, 196, 26, 0.5);
animation: pulse 1.5s infinite;
}
}
}
.task-last-run {
.last-run-tag {
font-size: 12px;
padding: 2px 8px;
border-radius: 4px;
}
}
}
}
}
}
// 任务类型选择样式
.task-type-selection {
padding: 32px 20px;
background: linear-gradient(135deg, #ffffff 0%, #f0f2f5 100%);
border-radius: 16px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.06);
.selection-tip {
font-size: 22px;
font-weight: 600;
margin-bottom: 32px;
color: #1a1a1a;
text-align: center;
letter-spacing: 0.5px;
animation: fadeInDown 0.6s ease-out;
}
.task-type-options {
display: flex;
gap: 24px;
margin-bottom: 40px;
animation: fadeInUp 0.6s ease-out;
.task-type-card {
flex: 1;
height: 100%;
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
.task-type-content {
padding: 32px 24px;
display: flex;
flex-direction: column;
align-items: center;
gap: 20px;
width: 100%;
position: relative;
overflow: hidden;
&::before {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 6px;
opacity: 0;
transition: all 0.4s ease;
}
&:nth-child(1)::before {
background: linear-gradient(90deg, #1890ff, #36cfc9);
}
&:nth-child(2)::before {
background: linear-gradient(90deg, #52c41a, #b7eb8f);
}
&:nth-child(3)::before {
background: linear-gradient(90deg, #fa8c16, #ffd666);
}
}
&:hover .task-type-content::before {
opacity: 1;
}
&:hover {
transform: translateY(-4px);
}
&.selected .task-type-content::before {
opacity: 1;
}
}
.check-card.selected {
box-shadow: 0 12px 28px rgba(24, 144, 255, 0.2);
background: linear-gradient(145deg, #e6f7ff 0%, #ffffff 100%);
}
.task-type-option {
flex: 1;
padding: 32px 24px;
border-radius: 16px;
background: linear-gradient(145deg, #ffffff 0%, #f8f9fa 100%);
border: 2px solid transparent;
display: flex;
flex-direction: column;
align-items: center;
gap: 20px;
cursor: pointer;
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
overflow: hidden;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
&::before {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 6px;
opacity: 0;
transition: all 0.4s ease;
}
&::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 0;
height: 0;
background: radial-gradient(circle, rgba(24, 144, 255, 0.2) 0%, rgba(24, 144, 255, 0) 60%);
border-radius: 50%;
transform: translate(-50%, -50%);
transition: all 0.6s cubic-bezier(0.4, 0, 0.2, 1);
}
&:hover {
transform: translateY(-4px);
box-shadow: 0 8px 24px rgba(24, 144, 255, 0.15);
background: linear-gradient(145deg, #ffffff 0%, #f0f7ff 100%);
&::before {
opacity: 1;
}
span {
color: #1890ff;
transform: scale(1.05);
}
p {
color: #4096ff;
}
}
&:active::after {
width: 200%;
height: 200%;
opacity: 0;
transition: 0s;
}
&.selected {
border-color: #1890ff;
background: linear-gradient(145deg, #e6f7ff 0%, #ffffff 100%);
box-shadow: 0 12px 28px rgba(24, 144, 255, 0.2);
transform: translateY(-4px) scale(1.02);
animation: pulse 2s infinite;
&::before {
opacity: 1;
background: linear-gradient(90deg, #1890ff, #36cfc9);
}
span {
color: #1890ff;
font-weight: 600;
}
p {
color: #4096ff;
}
}
span {
font-size: 16px;
font-weight: 600;
color: #333;
transition: all 0.3s ease;
}
p {
color: #666;
transition: all 0.3s ease;
font-size: 14px;
margin-top: 8px;
text-align: center;
}
.task-type-tag {
margin-left: 4px;
font-size: 12px;
border-radius: 4px;
}
@keyframes fadeInDown {
from {
opacity: 0;
transform: translateY(-20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes pulse {
0% {
box-shadow: 0 12px 28px rgba(24, 144, 255, 0.2);
}
50% {
box-shadow: 0 12px 28px rgba(24, 144, 255, 0.4);
}
100% {
box-shadow: 0 12px 28px rgba(24, 144, 255, 0.2);
}
}
}
.task-type-actions {
margin-top: 16px;
text-align: center;
.ant-btn {
height: 44px;
padding: 0 32px;
font-size: 16px;
font-weight: 500;
border-radius: 22px;
background: linear-gradient(90deg, #1890ff, #36cfc9);
border: none;
box-shadow: 0 4px 12px rgba(24, 144, 255, 0.3);
transition: all 0.3s ease;
&:hover {
transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(24, 144, 255, 0.4);
}
&:disabled {
background: #f5f5f5;
color: #bfbfbf;
box-shadow: none;
}
}
}
&:nth-child(1)::before {
background: linear-gradient(90deg, #1890ff, #36cfc9);
}
&:nth-child(2)::before {
background: linear-gradient(90deg, #52c41a, #b7eb8f);
}
&:nth-child(3)::before {
background: linear-gradient(90deg, #fa8c16, #ffd666);
}
span {
font-size: 18px;
font-weight: 600;
color: #333;
}
p {
font-size: 14px;
color: #666;
text-align: center;
margin: 0;
}
}
}
.task-type-actions {
display: flex;
justify-content: center;
margin-top: 32px;
animation: fadeInUp 0.8s ease-out;
.ant-btn {
min-width: 120px;
height: 40px;
font-size: 16px;
border-radius: 8px;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
background: linear-gradient(135deg, #1890ff 0%, #36cfc9 100%);
border: none;
color: white;
box-shadow: 0 4px 12px rgba(24, 144, 255, 0.3);
&:hover {
transform: translateY(-2px);
box-shadow: 0 8px 16px rgba(24, 144, 255, 0.4);
background: linear-gradient(135deg, #40a9ff 0%, #5cdbd3 100%);
}
&:active {
transform: translateY(0);
box-shadow: 0 4px 8px rgba(24, 144, 255, 0.2);
}
&:disabled {
background: linear-gradient(135deg, #bfbfbf 0%, #d9d9d9 100%);
box-shadow: none;
cursor: not-allowed;
opacity: 0.7;
}
}
}
}
// 删除确认对话框样式
.delete-confirm-modal {
.delete-confirm-content {
padding: 12px 0;
p {
margin-bottom: 8px;
font-size: 15px;
}
.warning-text {
color: #ff4d4f;
font-size: 13px;
}
}
}
// 任务模态框样式
.task-modal {
:deep(.ant-modal-content) {
border-radius: 12px;
overflow: hidden;
}
:deep(.ant-modal-header) {
padding: 16px 24px;
border-bottom: 1px solid #f0f0f0;
background: linear-gradient(135deg, #f8f9fa 0%, #e9f2ff 100%);
}
:deep(.ant-modal-title) {
font-size: 18px;
font-weight: 600;
color: #222;
}
:deep(.ant-modal-body) {
padding: 24px;
}
:deep(.ant-modal-footer) {
border-top: 1px solid #f0f0f0;
padding: 12px 24px;
}
:deep(.ant-form-item-label) {
font-weight: 500;
}
:deep(.ant-select), :deep(.ant-input-number), :deep(.ant-picker) {
width: 100%;
border-radius: 6px;
}
:deep(.ant-radio-wrapper), :deep(.ant-checkbox-wrapper) {
margin-bottom: 8px;
}
}
@keyframes pulse {
0% {
box-shadow: 0 0 0 0 rgba(82, 196, 26, 0.5);
}
70% {
box-shadow: 0 0 0 6px rgba(82, 196, 26, 0);
}
100% {
box-shadow: 0 0 0 0 rgba(82, 196, 26, 0);
}
}

View File

@@ -1,49 +1,74 @@
<template>
<div class="personal-center">
<div class="personal-center-header">
<Header :logo-color="'#fff'" style="background: transparent;box-shadow: none;backdrop-filter: none;"/>
<div class="personal-center-header-info">
<div class="personal-center-header-info-container">
<div class="personal-center-header-info-container-avatar">
<AAvatar :size="80" :text-size="1.5" :round="true" :src="userStore.user.avatar"/>
</div>
<div class="personal-center-header-info-container-description">
<div class="personal-center-header-info-container-description-name">
<span>{{ userStore.user.nickname }}</span>
<img src="/level_icon/icon/lv1.png" class="personal-level" alt="level">
<Header :logo-color="'#333'" class="fixed-header" />
<div class="personal-center-container">
<!-- 左侧边栏 - 用户信息和导航 -->
<div class="personal-center-sidebar">
<div class="user-profile">
<div class="user-avatar">
<AAvatar :size="100" :text-size="1.8" :round="true" :src="userStore.user.avatar"/>
<div class="user-level">
<img src="/level_icon/icon/lv1.png" alt="level">
</div>
<div class="personal-center-header-info-container-description-introduce">
<span>描述信息</span>
</div>
<div class="user-info">
<h2 class="user-name">{{ userStore.user.nickname }}</h2>
<p class="user-description">描述信息</p>
</div>
<div class="user-stats">
<div class="stat-item">
<div class="stat-value">128</div>
<div class="stat-label">照片</div>
</div>
<div class="stat-item">
<div class="stat-value">56</div>
<div class="stat-label">分享</div>
</div>
<div class="stat-item">
<div class="stat-value">24</div>
<div class="stat-label">收藏</div>
</div>
</div>
</div>
<AMenu
class="sidebar-menu"
:selectedKeys="[menuStore.userCenterMenu]"
mode="inline"
:selectable="true"
:multiple="false"
@select="handleClick"
>
<AMenuItem key="home" title="主页">
<template #icon>
<AAvatar shape="square" size="small" :src="home"/>
</template>
<span class="menu-item-title">主页</span>
</AMenuItem>
<AMenuItem key="dynamic" title="动态">
<template #icon>
<AAvatar shape="square" size="small" :src="dynamic"/>
</template>
<span class="menu-item-title">动态</span>
</AMenuItem>
<AMenuItem key="setting" title="设置">
<template #icon>
<AAvatar shape="square" size="small" :src="setting"/>
</template>
<span class="menu-item-title">设置</span>
</AMenuItem>
</AMenu>
</div>
</div>
<div class="personal-center-content">
<AMenu :selectedKeys="[menuStore.userCenterMenu]" mode="horizontal" :selectable="true" :multiple="false"
@select="handleClick">
<AMenuItem key="home" :style="menuCSSStyle" title="主页">
<template #icon>
<AAvatar shape="square" size="small" :src="home"/>
</template>
<span class="ant-menu-item-title">主页</span>
</AMenuItem>
<AMenuItem key="dynamic" :style="menuCSSStyle" title="动态">
<template #icon>
<AAvatar shape="square" size="small" :src="dynamic"/>
</template>
<span class="ant-menu-item-title">动态</span>
</AMenuItem>
<AMenuItem key="setting" :style="menuCSSStyle" title="设置">
<template #icon>
<AAvatar shape="square" size="small" :src="setting"/>
</template>
<span class="ant-menu-item-title">设置</span>
</AMenuItem>
</AMenu>
<div class="personal-center-content-container">
<router-view>
</router-view>
<!-- 右侧内容区域 -->
<div class="personal-center-content">
<div class="content-header">
<h1>{{ getPageTitle() }}</h1>
</div>
<div class="content-container">
<router-view></router-view>
</div>
</div>
</div>
</div>
@@ -55,106 +80,227 @@ import home from "@/assets/svgs/home.svg";
import dynamic from "@/assets/svgs/dynamic.svg";
import setting from "@/assets/svgs/setting.svg";
const userStore = useStore().user;
const menuStore = useStore().menu;
const router = useRouter();
const menuCSSStyle: any = reactive({
display: 'flex',
alignItems: 'center',
});
// 页面标题映射
const pageTitles = {
'home': '个人主页',
'dynamic': '我的动态',
'setting': '账户设置'
};
// 获取当前页面标题
function getPageTitle() {
return pageTitles[menuStore.userCenterMenu] || '个人中心';
}
function handleClick({key}) {
menuStore.userCenterMenu = key;
router.push(`/main/user/center/${key}`);
}
</script>
<style scoped lang="scss">
.personal-center {
//background-color: #eaeef6;
//min-height: 100vh;
background-color: #f5f7fa;
.personal-center-header {
.fixed-header {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 200px;
background-image: linear-gradient(to bottom, rgba(0, 0, 0, 0.6), rgba(0, 0, 0, 0.2)), url("@/assets/images/bg_1.png");
background-size: cover;
background-position: center;
position: relative;
z-index: 1000;
}
.personal-center-header-info {
width: 100%;
height: 130px;
display: flex;
flex-direction: row;
.personal-center-container {
display: flex;
width: calc(100vw - 50px);
margin: 0 auto;
padding: 20px;
gap: 30px;
margin-top: 70px; /* 为固定的Header腾出空间 */
position: relative; /* 为绝对定位的子元素提供参考 */
overflow: visible; /* 允许内容溢出,以便右侧内容可以滚动 */
}
.personal-center-header-info-container {
width: 95%;
height: 110px;
margin: 0 auto;
display: flex;
flex-direction: row;
align-items: flex-end;
justify-content: flex-start;
gap: 20px;
/* 左侧边栏样式 */
.personal-center-sidebar {
width: 300px;
flex-shrink: 0;
background-color: #fff;
border-radius: 12px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
overflow: hidden;
transition: all 0.3s ease;
position: sticky;
top: 90px; /* 为固定的Header腾出空间比margin-top多一点以确保有足够空间 */
height: calc(100vh - 110px); /* 设置固定高度减去header和上下margin的高度 */
align-self: flex-start; /* 确保在flex容器中靠上对齐 */
.personal-center-header-info-container-avatar {
height: 80px;
width: 80px;
border: 2px solid #fff;
.user-profile {
padding: 30px 20px;
text-align: center;
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
color: white;
position: relative;
.user-avatar {
position: relative;
width: 100px;
height: 100px;
margin: 0 auto 15px;
.user-level {
position: absolute;
bottom: 0;
right: 0;
background: white;
border-radius: 50%;
}
padding: 3px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
.personal-center-header-info-container-description {
height: 70px;
width: 80%;
display: flex;
flex-direction: column;
justify-content: space-between;
align-items: flex-start;
.personal-center-header-info-container-description-name {
font-size: 24px;
font-weight: 700;
color: #fff;
text-shadow: 0px 1px 2px rgba(0, 0, 0, .4);
display: flex;
flex-direction: row;
align-items: center;
gap: 20px;
.personal-level {
width: auto;
height: 20px;
}
}
.personal-center-header-info-container-description-introduce {
font-size: 16px;
color: #fff;
background-color: rgba(255, 255, 255, .2);
box-shadow: 0 0 0 1px rgba(255, 255, 255, .5)
img {
width: 24px;
height: 24px;
}
}
}
.user-info {
margin-bottom: 20px;
.user-name {
font-size: 20px;
font-weight: 600;
margin-bottom: 5px;
}
.user-description {
font-size: 14px;
opacity: 0.8;
}
}
.user-stats {
display: flex;
justify-content: space-around;
padding-top: 15px;
border-top: 1px solid rgba(255, 255, 255, 0.2);
.stat-item {
text-align: center;
.stat-value {
font-size: 18px;
font-weight: 600;
}
.stat-label {
font-size: 12px;
opacity: 0.8;
}
}
}
}
.sidebar-menu {
border-right: none;
:deep(.ant-menu-item) {
height: 50px;
line-height: 50px;
margin: 4px 0;
&:hover {
background-color: rgba(99, 102, 241, 0.05);
}
&.ant-menu-item-selected {
background-color: rgba(99, 102, 241, 0.1);
color: #6366f1;
border-right: 3px solid #6366f1;
font-weight: 500;
}
}
.menu-item-title {
font-size: 15px;
margin-left: 10px;
}
}
}
/* 右侧内容区域样式 */
.personal-center-content {
flex: 1;
background-color: #fff;
border-radius: 12px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
overflow: auto; /* 允许内容区域独立滚动 */
display: flex;
flex-direction: column;
/* 移除最大高度限制,允许内容无限滚动 */
.ant-menu-item-title {
font-size: 16px;
font-weight: bold;
.content-header {
padding: 20px 30px;
border-bottom: 1px solid #eee;
h1 {
font-size: 22px;
font-weight: 600;
color: #333;
margin: 0;
}
}
.personal-center-content-container {
width: calc(100% - 40px);
height: calc(100vh - 290px);
padding: 20px;
.content-container {
flex: 1;
padding: 20px 30px;
overflow: auto;
}
}
}
/* 响应式设计 */
@media (max-width: 1024px) {
.personal-center-container {
flex-direction: column;
padding: 10px;
overflow: auto; /* 在小屏幕上允许整个容器滚动 */
}
.personal-center-sidebar {
width: 100%;
margin-bottom: 20px;
position: relative; /* 在小屏幕上取消sticky定位 */
top: 0;
height: auto; /* 在小屏幕上高度自适应 */
}
.sidebar-menu {
display: flex;
overflow-x: auto;
:deep(.ant-menu-item) {
flex: 1;
text-align: center;
}
}
}
@media (max-width: 768px) {
.user-profile {
padding: 20px 15px;
}
.content-header {
padding: 15px 20px;
}
.content-container {
padding: 15px 20px;
}
}
}
</style>

View File

@@ -1,6 +1,38 @@
<template>
<div class="user-center-dynamic" ref="chartRef">
<div class="user-center-dynamic">
<div class="section-header">
</div>
<div class="chart-container" ref="chartRef">
<!-- 图表将在这里渲染 -->
</div>
<div class="stats-summary">
<div class="summary-item">
<div class="summary-icon visits"></div>
<div class="summary-content">
<div class="summary-value">{{ totalVisits }}</div>
<div class="summary-label">总访问量</div>
</div>
</div>
<div class="summary-item">
<div class="summary-icon visitors"></div>
<div class="summary-content">
<div class="summary-value">{{ totalVisitors }}</div>
<div class="summary-label">总访客数</div>
</div>
</div>
<div class="summary-item">
<div class="summary-icon publishes"></div>
<div class="summary-content">
<div class="summary-value">{{ totalPublishes }}</div>
<div class="summary-label">总发布数</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
@@ -19,63 +51,122 @@ const dates = ref<string[]>([]);
const visitCounts = ref<number[]>([]);
const visitorCounts = ref<number[]>([]);
const publishCounts = ref<number[]>([]);
// 移除时间范围选择
let chartInstance: echarts.ECharts | null = null;
// 计算总访问量、总访客数和总发布数
const totalVisits = computed(() => {
return visitCounts.value.reduce((sum, count) => sum + count, 0);
});
const totalVisitors = computed(() => {
return visitorCounts.value.reduce((sum, count) => sum + count, 0);
});
const totalPublishes = computed(() => {
return publishCounts.value.reduce((sum, count) => sum + count, 0);
});
async function getData() {
const res: any = await getShareStatisticsInfoApi();
if (res && res.code === 200) {
const data: DataItem[] = res.data.records;
dates.value = data.map((item: DataItem) => item.date);
visitCounts.value = data.map((item: DataItem) => item.visit_count);
visitorCounts.value = data.map((item: DataItem) => item.visitor_count);
publishCounts.value = data.map((item: DataItem) => item.publish_count);
try {
const res: any = await getShareStatisticsInfoApi();
if (res && res.code === 200) {
const data: DataItem[] = res.data.records;
dates.value = data.map((item: DataItem) => item.date);
visitCounts.value = data.map((item: DataItem) => item.visit_count);
visitorCounts.value = data.map((item: DataItem) => item.visitor_count);
publishCounts.value = data.map((item: DataItem) => item.publish_count);
// 数据加载完成后初始化或更新图表
initChart();
}
} catch (error) {
console.error('获取分享统计数据失败:', error);
}
}
onMounted(async () => {
await nextTick();
const chartInstance = echarts.init(chartRef.value);
// 初始化图表
function initChart() {
if (!chartRef.value) return;
// 如果图表实例已存在,销毁它
if (chartInstance) {
chartInstance.dispose();
}
// 创建新的图表实例
chartInstance = echarts.init(chartRef.value);
// 使用防抖函数处理resize事件
const resizeHandler = () => {
if (chartInstance) {
nextTick(() => {
chartInstance.resize();
});
}
};
// 监听窗口大小变化,调整图表大小
window.addEventListener('resize', resizeHandler);
// 设置图表配置
updateChartOption();
}
// 更新图表配置
function updateChartOption() {
if (!chartInstance) return;
const colorList = ["#6366f1", "#22c55e", "#f97316"];
const colorList = ["#9E87FF", "#73DDFF", "#fe9a8b", "#F56948", "#9E87FF"];
const option = {
backgroundColor: "#fff",
title: {
text: "最近七天分享统计",
fontSize: 12,
fontWeight: 400,
text: "分享统计概览",
textStyle: {
fontSize: 16,
fontWeight: 600,
color: '#333'
},
left: "center",
top: "5%",
},
legend: {
icon: "circle",
top: "5%",
right: "5%",
itemWidth: 6,
bottom: "0%",
itemWidth: 8,
itemHeight: 8,
itemGap: 20,
color: "#556677",
textStyle: {
color: "#333",
fontSize: 12
}
},
tooltip: {
trigger: "axis",
axisPointer: {
label: {
show: true,
backgroundColor: "#fff",
color: "#556677",
borderColor: "rgba(0,0,0,0)",
shadowColor: "rgba(0,0,0,0)",
shadowOffsetY: 0,
},
lineStyle: {
width: 0,
},
},
backgroundColor: "#fff",
color: "#5c6c7c",
padding: [10, 10],
extraCssText: "box-shadow: 1px 0 2px 0 rgba(163,163,163,0.5)",
borderRadius: 4,
shadowColor: 'rgba(0, 0, 0, 0.1)',
shadowBlur: 10,
textStyle: {
color: "#333",
},
padding: [10, 15],
axisPointer: {
type: 'line',
lineStyle: {
color: '#6366f1',
width: 1,
type: 'dashed'
}
}
},
grid: {
top: "15%",
left: "3%",
right: "3%",
bottom: "12%",
containLabel: true
},
xAxis: [
{
@@ -83,7 +174,7 @@ onMounted(async () => {
data: dates.value,
axisLine: {
lineStyle: {
color: "#DCE2E8",
color: "#e0e0e0",
},
},
axisTick: {
@@ -91,68 +182,10 @@ onMounted(async () => {
},
axisLabel: {
interval: 0,
color: "#556677",
// 默认x轴字体大小
color: "#666",
fontSize: 12,
// margin:文字到x轴的距离
margin: 15,
},
axisPointer: {
label: {
// padding: [11, 5, 7],
padding: [0, 0, 10, 0],
/*
除了padding[0]建议必须是0之外其他三项可随意设置
和CSSpadding相同[上,右,下,左]
如果需要下边线超出文字设左右padding即可左右padding最好相同
padding[2]的10:
10 = 文字距下边线的距离 + 下边线的宽度
UI图中文字距下边线距离为7 下边线宽度为2
则padding: [0, 0, 9, 0]
*/
// 这里的margin和axisLabel的margin要一致!
margin: 15,
// 移入时的字体大小
fontSize: 12,
backgroundColor: {
type: "linear",
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [
{
offset: 0,
color: "#fff", // 0% 处的颜色
},
{
// offset: 0.9,
offset: 0.86,
/*
0.86 = (文字 + 文字距下边线的距离)/(文字 + 文字距下边线的距离 + 下边线的宽度)
*/
color: "#fff", // 0% 处的颜色
},
{
offset: 0.86,
color: "#33c0cd", // 0% 处的颜色
},
{
offset: 1,
color: "#33c0cd", // 100% 处的颜色
},
],
global: false, // 缺省为 false
},
},
rotate: dates.value.length > 10 ? 45 : 0
},
boundaryGap: false,
},
@@ -160,42 +193,33 @@ onMounted(async () => {
yAxis: [
{
type: "value",
name: "数量",
nameTextStyle: {
color: "#666",
fontSize: 12,
padding: [0, 0, 0, 5]
},
axisTick: {
show: false,
},
axisLine: {
show: true,
lineStyle: {
color: "#DCE2E8",
color: "#e0e0e0",
},
},
axisLabel: {
color: "#556677",
color: "#666",
fontSize: 12
},
splitLine: {
show: false,
},
},
{
type: "value",
position: "right",
axisTick: {
show: false,
},
axisLabel: {
color: "#556677",
formatter: "{value}",
},
axisLine: {
show: true,
lineStyle: {
color: "#DCE2E8",
},
color: "#f5f5f5",
type: "dashed"
}
},
splitLine: {
show: false,
},
},
}
],
series: [
{
@@ -263,7 +287,7 @@ onMounted(async () => {
type: "line",
data: publishCounts.value,
symbolSize: 1,
yAxisIndex: 1,
yAxisIndex: 0,
symbol: "circle",
smooth: true,
showSymbol: false,
@@ -272,7 +296,7 @@ onMounted(async () => {
color: new echarts.graphic.LinearGradient(0, 0, 1, 0, [
{
offset: 0,
color: "#fe9a",
color: "#fe9a8b",
},
{
offset: 1,
@@ -291,8 +315,7 @@ onMounted(async () => {
],
};
chartInstance.setOption(option);
});
}
onBeforeMount(() => {
getData();
@@ -308,12 +331,115 @@ onBeforeUnmount(() => {
});
</script>
<style scoped lang="scss">
.user-center-dynamic {
width: calc(100vw - 40px);
height: calc(100vh - 290px);
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
h3 {
font-size: 18px;
font-weight: 600;
color: #333;
margin: 0;
}
}
.chart-container {
flex: 1;
width: 100%;
min-height: 350px;
margin-bottom: 20px;
}
.stats-summary {
display: flex;
justify-content: space-between;
gap: 20px;
margin-top: 10px;
.summary-item {
flex: 1;
background-color: #f9fafb;
border-radius: 10px;
padding: 15px;
display: flex;
align-items: center;
gap: 15px;
transition: all 0.3s ease;
&:hover {
transform: translateY(-3px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
}
.summary-icon {
width: 40px;
height: 40px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
position: relative;
&::before {
font-family: "Font Awesome 5 Free";
font-weight: 900;
font-size: 18px;
color: white;
}
&.visits {
background-color: #6366f1;
&::before { content: '\f201'; } /* chart line icon */
}
&.visitors {
background-color: #22c55e;
&::before { content: '\f007'; } /* user icon */
}
&.publishes {
background-color: #f97316;
&::before { content: '\f093'; } /* upload icon */
}
}
.summary-content {
flex: 1;
.summary-value {
font-size: 20px;
font-weight: 700;
color: #333;
margin-bottom: 4px;
}
.summary-label {
font-size: 13px;
color: #666;
}
}
}
}
@media (max-width: 768px) {
.stats-summary {
flex-direction: column;
gap: 10px;
}
.chart-container {
min-height: 250px;
}
}
}
</style>

View File

@@ -1,206 +1,613 @@
<template>
<div class="user-center-home">
<div class="user-center-home-left">
<div class="user-center-home-left-top" v-if="chartData">
<div class="user-center-home-left-top-card"
style="background: linear-gradient(102.74deg, rgb(66, 230, 171) -7.03%, rgb(103, 235, 187) 97.7%);">
<div class="user-center-home-left-top-card-top">
<div class="user-center-home-left-top-card-top-avatar">
<AAvatar :size="60" shape="square" :src="imageIcon"/>
</div>
<div class="user-center-home-left-top-card-top-name">
<span style="font-size: 2.8vh;color: rgba(255, 255, 255, 0.6); font-weight: bold;">图片总数</span>
<span style="font-size: 3.8vh;font-weight: bold;color: #ffffff">{{ chartData.image_count }}</span>
</div>
</div>
<div class="user-center-home-left-top-card-bottom">
<span style="font-size: 2.3vh;color: rgba(255, 255, 255, 0.6); font-weight: bold;">今日上传</span>
<span style="font-size: 3vh;font-weight: bold;color: #ffffff">+{{ chartData.today_upload_count }}</span>
</div>
<!-- 数据卡片区域 -->
<div class="stats-cards" v-if="chartData">
<div class="stats-card" data-type="images">
<div class="stats-card-icon">
<AAvatar :size="50" shape="square" :src="imageIcon"/>
</div>
<div class="user-center-home-left-top-card"
style="background: linear-gradient(101.63deg, rgb(82, 138, 250) -12.83%, rgb(122, 167, 255) 100%);">
<div class="user-center-home-left-top-card-top">
<div class="user-center-home-left-top-card-top-avatar">
<AAvatar :size="60" shape="square" :src="shareIcon"/>
</div>
<div class="user-center-home-left-top-card-top-name">
<span style="font-size: 2.8vh;color: rgba(255, 255, 255, 0.6); font-weight: bold;">分享总数</span>
<span style="font-size: 3.8vh;font-weight: bold;color: white">{{ chartData.share_count }}</span>
</div>
</div>
<div class="user-center-home-left-top-card-bottom">
<span style="font-size: 2.3vh;color: rgba(255, 255, 255, 0.6); font-weight: bold;">今日上传</span>
<span style="font-size: 2.8vh;font-weight: bold;color: white">+{{ chartData.today_share_count }}</span>
</div>
</div>
<div class="user-center-home-left-top-card"
style="background: linear-gradient(102.99deg, rgb(126, 92, 255) 3.18%, rgb(162, 139, 255) 102.52%);">
<div class="user-center-home-left-top-card-top">
<div class="user-center-home-left-top-card-top-avatar">
<AAvatar :size="60" shape="square" :src="fileSize"/>
</div>
<div class="user-center-home-left-top-card-top-name">
<span style="font-size: 2.8vh;color: rgba(255, 255, 255, 0.6); font-weight: bold;">文件总量</span>
<span
style="font-size: 3.8vh;font-weight: bold;color: white">{{
bytesToSize(chartData.file_size_count)
}}</span>
</div>
</div>
<div class="user-center-home-left-top-card-bottom">
<span style="font-size: 2.3vh;color: rgba(255, 255, 255, 0.6);font-weight: bold;">今日上传</span>
<span
style="font-size: 2.8vh;font-weight: bold;color: white">+{{
bytesToSize(chartData.today_file_size_count)
}}</span>
<div class="stats-card-content">
<div class="stats-card-title">图片总数</div>
<div class="stats-card-value">{{ chartData.image_count }}</div>
<div class="stats-card-footer">
<span class="stats-card-label">今日上传</span>
<span class="stats-card-change">+{{ chartData.today_upload_count }}</span>
</div>
</div>
</div>
<div class="user-center-home-left-bottom" v-if="chartData">
<span style="font-size: 16px; font-weight: bold; margin-left: 20px;">文件上传热力图</span>
<HeatmapPro :contributions="chartData.heatmap"/>
<div class="stats-card" data-type="shares">
<div class="stats-card-icon">
<AAvatar :size="50" shape="square" :src="shareIcon"/>
</div>
<div class="stats-card-content">
<div class="stats-card-title">分享总数</div>
<div class="stats-card-value">{{ chartData.share_count }}</div>
<div class="stats-card-footer">
<span class="stats-card-label">今日分享</span>
<span class="stats-card-change">+{{ chartData.today_share_count }}</span>
</div>
</div>
</div>
<div class="stats-card" data-type="storage">
<div class="stats-card-icon">
<AAvatar :size="50" shape="square" :src="fileSize"/>
</div>
<div class="stats-card-content">
<div class="stats-card-title">文件总量</div>
<div class="stats-card-value">{{ bytesToSize(chartData.file_size_count) }}</div>
<div class="stats-card-footer">
<span class="stats-card-label">今日上传</span>
<span class="stats-card-change">+{{ bytesToSize(chartData.today_file_size_count) }}</span>
</div>
</div>
</div>
</div>
<div class="user-center-home-right">
<ACard class="user-center-home-right-card" :hoverable="false">
</ACard>
<!-- 主要内容区域 -->
<div class="content-grid">
<!-- 月度上传图表区域 -->
<div class="monthly-chart-section" v-if="chartData">
<div class="section-header">
<h3>月度上传数量统计</h3>
</div>
<div class="section-body" ref="chartRef">
<!-- ECharts将在这里渲染 -->
</div>
</div>
<!-- 最近活动区域 -->
<div class="recent-activity-section">
<div class="section-header">
<h3>最近活动</h3>
<div class="section-actions">
<AButton type="link">查看全部</AButton>
</div>
</div>
<div class="section-body">
<div class="activity-list">
<div class="activity-item" v-for="(item, index) in recentActivities" :key="index">
<div class="activity-icon" :class="item.type"></div>
<div class="activity-content">
<div class="activity-title">{{ item.title }}</div>
<div class="activity-time">{{ item.time }}</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import HeatmapPro from "@/components/HeatmapPro/HeatmapPro.vue";
import imageIcon from "@/assets/svgs/image-icon.svg";
import shareIcon from "@/assets/svgs/share-icon.svg";
import fileSize from "@/assets/svgs/file-size.svg";
import {getUserUploadInfoApi} from "@/api/storage";
import bytesToSize from "@/utils/imageUtils/bytesToSize";
import * as echarts from 'echarts';
const chartData = ref<any>();
// 模拟最近活动数据
const recentActivities = ref([
{
type: 'upload',
title: '上传了5张照片到「旅行相册」',
time: '今天 14:30'
},
{
type: 'share',
title: '分享了相册「家庭聚会」',
time: '昨天 18:45'
},
{
type: 'like',
title: '收藏了「风景摄影」',
time: '3天前'
},
{
type: 'comment',
title: '评论了照片「日落」',
time: '上周'
},
{
type: 'system',
title: '系统自动备份了您的照片',
time: '2周前'
}
]);
const chartRef = ref<HTMLElement | null>(null);
let chartInstance: echarts.ECharts | null = null;
// 处理热力图数据,转换为月度上传数量
const processMonthlyData = (heatmapData: any[]) => {
if (!heatmapData || !Array.isArray(heatmapData)) return { months: [], counts: [] };
// 创建一个对象来存储每个月的上传数量
const monthlyUploads: Record<string, number> = {};
// 遍历热力图数据,按月份汇总上传数量
heatmapData.forEach(item => {
if (item && item.date) {
// 从日期中提取年月格式为YYYY-MM
const date = new Date(item.date);
const yearMonth = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`;
// 累加该月份的上传数量
if (!monthlyUploads[yearMonth]) {
monthlyUploads[yearMonth] = 0;
}
monthlyUploads[yearMonth] += item.count || 0;
}
});
// 获取所有月份并排序
const sortedMonths = Object.keys(monthlyUploads).sort();
// 提取排序后的月份和对应的上传数量
const months = sortedMonths.map(month => {
// 将YYYY-MM格式转换为更友好的显示格式如"2023年1月"
const [year, month2] = month.split('-');
return `${year}${parseInt(month2)}`;
});
const counts = sortedMonths.map(month => monthlyUploads[month]);
return { months, counts };
};
// 初始化图表
const initChart = () => {
if (!chartRef.value) return;
// 如果图表实例已存在,销毁它
if (chartInstance) {
chartInstance.dispose();
}
// 创建新的图表实例
chartInstance = echarts.init(chartRef.value);
// 使用防抖函数处理resize事件
const resizeHandler = () => {
if (chartInstance) {
nextTick(() => {
chartInstance.resize();
});
}
};
// 监听窗口大小变化,调整图表大小
window.addEventListener('resize', resizeHandler);
// 更新图表配置
updateChartOption();
};
// 更新图表配置
const updateChartOption = () => {
if (!chartInstance || !chartData.value || !chartData.value.heatmap) return;
// 处理数据,获取月度上传数量
const { months, counts } = processMonthlyData(chartData.value.heatmap);
// 设置图表配置
const option = {
backgroundColor: "#fff",
title: {
text: "月度上传数量统计",
textStyle: {
fontSize: 16,
fontWeight: 600,
color: '#333'
},
left: "center",
top: "5%",
},
tooltip: {
trigger: "axis",
backgroundColor: "#fff",
borderRadius: 4,
shadowColor: 'rgba(0, 0, 0, 0.1)',
shadowBlur: 10,
textStyle: {
color: "#333",
},
padding: [10, 15],
formatter: '{b}: {c} 次上传'
},
grid: {
top: "15%",
left: "3%",
right: "3%",
bottom: "12%",
containLabel: true
},
xAxis: [
{
type: "category",
data: months,
axisLine: {
lineStyle: {
color: "#e0e0e0",
},
},
axisTick: {
show: false,
},
axisLabel: {
interval: 0,
color: "#666",
fontSize: 12,
margin: 15,
rotate: months.length > 6 ? 45 : 0
},
boundaryGap: true,
},
],
yAxis: [
{
type: "value",
name: "上传数量",
nameTextStyle: {
color: "#666",
fontSize: 12,
padding: [0, 0, 0, 5]
},
axisTick: {
show: false,
},
axisLine: {
show: true,
lineStyle: {
color: "#e0e0e0",
},
},
axisLabel: {
color: "#666",
fontSize: 12
},
splitLine: {
show: true,
lineStyle: {
color: "#f5f5f5",
type: "dashed"
}
},
}
],
series: [
{
name: "上传数量",
type: "bar",
barWidth: '40%',
data: counts,
itemStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: '#42e6ab' },
{ offset: 1, color: '#67ebbb' }
])
},
emphasis: {
itemStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: '#3ad598' },
{ offset: 1, color: '#5ae9a9' }
])
}
}
}
]
};
// 应用配置
chartInstance.setOption(option);
};
async function getData() {
const res: any = await getUserUploadInfoApi();
if (res && res.code === 200) {
chartData.value = res.data;
// 数据加载完成后初始化图表
nextTick(() => {
initChart();
});
}
}
onMounted(() => {
getData();
});
// 组件卸载时清理图表实例和事件监听
onBeforeUnmount(() => {
if (chartInstance) {
chartInstance.dispose();
chartInstance = null;
}
window.removeEventListener('resize', () => {});
});
</script>
<style scoped lang="scss">
.user-center-home {
width: 100%;
height: 100%;
display: flex;
flex-direction: row;
justify-content: space-between;
gap: 40px;
flex-direction: column;
gap: 24px;
.user-center-home-left {
width: 60%;
height: 100%;
/* 数据卡片区域 */
.stats-cards {
display: flex;
flex-direction: column;
justify-content: flex-start;
gap: 20px;
width: 100%;
.user-center-home-left-top {
width: 100%;
height: 25vh;
.stats-card {
flex: 1;
background-color: white;
border-radius: 12px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
padding: 20px;
display: flex;
flex-direction: row;
align-items: flex-start;
justify-content: space-between;
overflow: hidden;
position: relative;
transition: all 0.3s ease;
&:hover {
transform: translateY(-5px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
}
.user-center-home-left-top-card {
width: 28%;
height: 90%;
background-color: #fff;
border-radius: 1.8vh;
cursor: pointer;
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: flex-start;
padding-inline: 10px;
padding-block: 10px;
gap: 10px;
border: 1px solid #eee;
&::before {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
opacity: 0.8;
z-index: 0;
}
&:hover {
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
&[data-type="images"]::before {
background: linear-gradient(135deg, rgba(66, 230, 171, 0.1) 0%, rgba(103, 235, 187, 0.1) 100%);
}
&[data-type="shares"]::before {
background: linear-gradient(135deg, rgba(82, 138, 250, 0.1) 0%, rgba(122, 167, 255, 0.1) 100%);
}
&[data-type="storage"]::before {
background: linear-gradient(135deg, rgba(126, 92, 255, 0.1) 0%, rgba(162, 139, 255, 0.1) 100%);
}
.stats-card-icon {
width: 50px;
height: 50px;
margin-right: 16px;
z-index: 1;
&::after {
content: '';
position: absolute;
width: 80px;
height: 80px;
border-radius: 50%;
left: -15px;
top: -15px;
z-index: -1;
opacity: 0.1;
}
}
&[data-type="images"] .stats-card-icon::after {
background-color: #42e6ab;
}
&[data-type="shares"] .stats-card-icon::after {
background-color: #528afa;
}
&[data-type="storage"] .stats-card-icon::after {
background-color: #7e5cff;
}
.stats-card-content {
flex: 1;
z-index: 1;
.stats-card-title {
font-size: 14px;
color: #666;
margin-bottom: 8px;
}
.user-center-home-left-top-card-top {
width: 100%;
height: 60%;
display: flex;
flex-direction: row;
align-items: flex-start;
justify-content: flex-start;
gap: 10px;
.user-center-home-left-top-card-top-avatar {
width: 60px;
height: 60px;
}
.user-center-home-left-top-card-top-name {
width: 100%;
height: 60px;
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: space-between;
}
.stats-card-value {
font-size: 28px;
font-weight: 700;
color: #333;
margin-bottom: 16px;
}
.user-center-home-left-top-card-bottom {
width: 100%;
height: 40%;
.stats-card-footer {
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-start;
gap: 10px;
gap: 8px;
.stats-card-label {
font-size: 13px;
color: #888;
}
.stats-card-change {
font-size: 14px;
font-weight: 600;
color: #42e6ab;
}
}
}
}
}
.user-center-home-left-bottom {
width: 100%;
height: 58%;
/* 内容网格区域 */
.content-grid {
display: grid;
grid-template-columns: 2fr 1fr;
gap: 24px;
width: 100%;
flex: 1;
/* 通用区块样式 */
.monthly-chart-section,
.recent-activity-section {
background-color: white;
border-radius: 12px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
padding: 20px;
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: flex-start;
background-color: #fff;
border-radius: 1.8vh;
border: 1px solid #eee;
padding-block: 20px;
height: 100%;
transition: all 0.3s ease;
&:hover {
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.08);
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
padding-bottom: 12px;
border-bottom: 1px solid #f0f0f0;
h3 {
font-size: 16px;
font-weight: 600;
color: #333;
margin: 0;
}
}
.section-body {
flex: 1;
}
}
/* 月度上传图表区域特定样式 */
.monthly-chart-section {
overflow: hidden;
.section-body {
height: 300px; /* 设置固定高度以确保图表正确渲染 */
}
}
/* 最近活动区域特定样式 */
.recent-activity-section {
.activity-list {
display: flex;
flex-direction: column;
gap: 16px;
.activity-item {
display: flex;
align-items: center;
padding: 12px 0;
border-bottom: 1px solid #f5f5f5;
&:last-child {
border-bottom: none;
}
.activity-icon {
width: 36px;
height: 36px;
border-radius: 50%;
margin-right: 12px;
display: flex;
align-items: center;
justify-content: center;
position: relative;
&::before {
font-family: "Font Awesome 5 Free";
font-weight: 900;
font-size: 16px;
color: white;
}
&.upload {
background-color: #42e6ab;
&::before { content: '\f093'; } /* upload icon */
}
&.share {
background-color: #528afa;
&::before { content: '\f064'; } /* share icon */
}
&.like {
background-color: #ff6b6b;
&::before { content: '\f004'; } /* heart icon */
}
&.comment {
background-color: #ffc107;
&::before { content: '\f075'; } /* comment icon */
}
&.system {
background-color: #6c757d;
&::before { content: '\f013'; } /* gear icon */
}
}
.activity-content {
flex: 1;
.activity-title {
font-size: 14px;
color: #333;
margin-bottom: 4px;
}
.activity-time {
font-size: 12px;
color: #999;
}
}
}
}
}
}
.user-center-home-right {
width: 39%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
/* 响应式设计 */
@media (max-width: 1024px) {
.stats-cards {
flex-direction: column;
.stats-card {
width: 100%;
}
}
.user-center-home-right-card {
width: 100%;
height: 100%;
background-color: #fff;
//margin-top: 20px;
.content-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 768px) {
gap: 16px;
.stats-cards {
gap: 16px;
}
.content-grid {
gap: 16px;
}
}
}

View File

@@ -5,121 +5,375 @@
<p class="setting-description">您可以在这里管理您的账户设置和偏好</p>
</div>
<div class="setting-section">
<div class="section-title">账户安全</div>
<div class="user-center-setting-content">
<div class="user-center-setting-item">
<div class="setting-item-left">
<img src="@/assets/svgs/account_security.svg" alt="AI识别" class="setting-icon" />
<div class="setting-text">
<span class="user-center-setting-item-title">开启AI识别</span>
<span class="setting-description">允许系统使用AI技术识别您的照片内容</span>
<div class="settings-container">
<!-- 左侧导航 -->
<div class="settings-nav">
<div
class="nav-item"
:class="{ active: activeSection === 'account' }"
@click="activeSection = 'account'"
>
<div class="nav-icon account"></div>
<span>账户安全</span>
</div>
<div
class="nav-item"
:class="{ active: activeSection === 'privacy' }"
@click="activeSection = 'privacy'"
>
<div class="nav-icon privacy"></div>
<span>隐私设置</span>
</div>
<div
class="nav-item"
:class="{ active: activeSection === 'notification' }"
@click="activeSection = 'notification'"
>
<div class="nav-icon notification"></div>
<span>通知设置</span>
</div>
<div
class="nav-item"
:class="{ active: activeSection === 'appearance' }"
@click="activeSection = 'appearance'"
>
<div class="nav-icon appearance"></div>
<span>外观设置</span>
</div>
</div>
<!-- 右侧内容区 -->
<div class="settings-content">
<!-- 账户安全设置 -->
<div v-if="activeSection === 'account'" class="settings-section">
<div class="section-title">账户安全</div>
<div class="settings-list">
<div class="setting-item">
<div class="setting-item-left">
<div class="setting-icon account-ai"></div>
<div class="setting-text">
<span class="setting-item-title">开启AI识别</span>
<span class="setting-item-desc">允许系统使用AI技术识别您的照片内容</span>
</div>
</div>
<ASwitch v-model:checked="userStore.settings.enableAI">
<template #checkedChildren>
<check-outlined />
</template>
<template #unCheckedChildren>
<close-outlined />
</template>
</ASwitch>
</div>
<div class="setting-item">
<div class="setting-item-left">
<div class="setting-icon account-mobile"></div>
<div class="setting-text">
<span class="setting-item-title">开启手机上传</span>
<span class="setting-item-desc">允许从移动设备上传照片到您的相册</span>
</div>
</div>
<ASwitch v-model:checked="userStore.settings.enableMobileUpload">
<template #checkedChildren>
<check-outlined />
</template>
<template #unCheckedChildren>
<close-outlined />
</template>
</ASwitch>
</div>
<div class="setting-item">
<div class="setting-item-left">
<div class="setting-icon account-password"></div>
<div class="setting-text">
<span class="setting-item-title">修改密码</span>
<span class="setting-item-desc">定期更改密码可以提高账户安全性</span>
</div>
</div>
<AButton type="primary" size="small">修改</AButton>
</div>
</div>
<ASwitch v-model:checked="userStore.settings.enableAI">
<template #checkedChildren>
<check-outlined />
</template>
<template #unCheckedChildren>
<close-outlined />
</template>
</ASwitch>
</div>
<div class="user-center-setting-item">
<div class="setting-item-left">
<img src="@/assets/svgs/login_security.svg" alt="手机上传" class="setting-icon" />
<div class="setting-text">
<span class="user-center-setting-item-title">开启手机上传</span>
<span class="setting-description">允许从移动设备上传照片到您的相册</span>
<!-- 隐私设置 -->
<div v-if="activeSection === 'privacy'" class="settings-section">
<div class="section-title">隐私设置</div>
<div class="settings-list">
<div class="setting-item">
<div class="setting-item-left">
<div class="setting-icon privacy-profile"></div>
<div class="setting-text">
<span class="setting-item-title">公开个人资料</span>
<span class="setting-item-desc">允许其他用户查看您的个人资料信息</span>
</div>
</div>
<ASwitch v-model:checked="userStore.settings.publicProfile">
<template #checkedChildren>
<check-outlined />
</template>
<template #unCheckedChildren>
<close-outlined />
</template>
</ASwitch>
</div>
<div class="setting-item">
<div class="setting-item-left">
<div class="setting-icon privacy-comment"></div>
<div class="setting-text">
<span class="setting-item-title">开启评论功能</span>
<span class="setting-item-desc">允许其他用户在您的照片下方评论</span>
</div>
</div>
<ASwitch v-model:checked="userStore.settings.enableComment">
<template #checkedChildren>
<check-outlined />
</template>
<template #unCheckedChildren>
<close-outlined />
</template>
</ASwitch>
</div>
<div class="setting-item">
<div class="setting-item-left">
<div class="setting-icon privacy-search"></div>
<div class="setting-text">
<span class="setting-item-title">保存搜索记录</span>
<span class="setting-item-desc">保存您的搜索历史以提供更好的推荐</span>
</div>
</div>
<ASwitch v-model:checked="userStore.settings.saveSearchHistory">
<template #checkedChildren>
<check-outlined />
</template>
<template #unCheckedChildren>
<close-outlined />
</template>
</ASwitch>
</div>
</div>
</div>
<!-- 通知设置 -->
<div v-if="activeSection === 'notification'" class="settings-section">
<div class="section-title">通知设置</div>
<div class="settings-list">
<div class="setting-item">
<div class="setting-item-left">
<div class="setting-icon notification-email"></div>
<div class="setting-text">
<span class="setting-item-title">邮件通知</span>
<span class="setting-item-desc">接收重要活动和更新的邮件通知</span>
</div>
</div>
<ASwitch v-model:checked="notificationSettings.email">
<template #checkedChildren>
<check-outlined />
</template>
<template #unCheckedChildren>
<close-outlined />
</template>
</ASwitch>
</div>
<div class="setting-item">
<div class="setting-item-left">
<div class="setting-icon notification-comment"></div>
<div class="setting-text">
<span class="setting-item-title">评论通知</span>
<span class="setting-item-desc">当有人评论您的照片时接收通知</span>
</div>
</div>
<ASwitch v-model:checked="notificationSettings.comment">
<template #checkedChildren>
<check-outlined />
</template>
<template #unCheckedChildren>
<close-outlined />
</template>
</ASwitch>
</div>
<div class="setting-item">
<div class="setting-item-left">
<div class="setting-icon notification-system"></div>
<div class="setting-text">
<span class="setting-item-title">系统通知</span>
<span class="setting-item-desc">接收系统更新和维护信息</span>
</div>
</div>
<ASwitch v-model:checked="notificationSettings.system">
<template #checkedChildren>
<check-outlined />
</template>
<template #unCheckedChildren>
<close-outlined />
</template>
</ASwitch>
</div>
</div>
</div>
<!-- 外观设置 -->
<div v-if="activeSection === 'appearance'" class="settings-section">
<div class="section-title">外观设置</div>
<div class="settings-list">
<div class="setting-item">
<div class="setting-item-left">
<div class="setting-icon appearance-theme"></div>
<div class="setting-text">
<span class="setting-item-title">主题模式</span>
<span class="setting-item-desc">选择您喜欢的界面主题</span>
</div>
</div>
<ARadioGroup v-model:value="appearanceSettings.theme">
<ARadioButton value="light">浅色</ARadioButton>
<ARadioButton value="dark">深色</ARadioButton>
<ARadioButton value="system">跟随系统</ARadioButton>
</ARadioGroup>
</div>
<div class="setting-item">
<div class="setting-item-left">
<div class="setting-icon appearance-layout"></div>
<div class="setting-text">
<span class="setting-item-title">布局密度</span>
<span class="setting-item-desc">调整界面元素的紧凑程度</span>
</div>
</div>
<ASelect v-model:value="appearanceSettings.density" style="width: 120px">
<ASelectOption value="compact">紧凑</ASelectOption>
<ASelectOption value="default">默认</ASelectOption>
<ASelectOption value="comfortable">宽松</ASelectOption>
</ASelect>
</div>
</div>
<ASwitch v-model:checked="userStore.settings.enableMobileUpload">
<template #checkedChildren>
<check-outlined />
</template>
<template #unCheckedChildren>
<close-outlined />
</template>
</ASwitch>
</div>
</div>
</div>
<div class="setting-section">
<div class="section-title">隐私设置</div>
<div class="user-center-setting-content">
<div class="user-center-setting-item">
<div class="setting-item-left">
<img src="@/assets/svgs/privacy.svg" alt="个人资料" class="setting-icon" />
<div class="setting-text">
<span class="user-center-setting-item-title">公开个人资料</span>
<span class="setting-description">允许其他用户查看您的个人资料信息</span>
</div>
</div>
<ASwitch v-model:checked="userStore.settings.publicProfile">
<template #checkedChildren>
<check-outlined />
</template>
<template #unCheckedChildren>
<close-outlined />
</template>
</ASwitch>
</div>
<div class="user-center-setting-item">
<div class="setting-item-left">
<img src="@/assets/svgs/community.svg" alt="评论" class="setting-icon" />
<div class="setting-text">
<span class="user-center-setting-item-title">开启评论功能</span>
<span class="setting-description">允许其他用户在您的照片下方评论</span>
</div>
</div>
<ASwitch v-model:checked="userStore.settings.enableComment">
<template #checkedChildren>
<check-outlined />
</template>
<template #unCheckedChildren>
<close-outlined />
</template>
</ASwitch>
</div>
<div class="user-center-setting-item">
<div class="setting-item-left">
<img src="@/assets/svgs/search.svg" alt="搜索记录" class="setting-icon" />
<div class="setting-text">
<span class="user-center-setting-item-title">保存搜索记录</span>
<span class="setting-description">保存您的搜索历史以提供更好的推荐</span>
</div>
</div>
<ASwitch v-model:checked="userStore.settings.saveSearchHistory">
<template #checkedChildren>
<check-outlined />
</template>
<template #unCheckedChildren>
<close-outlined />
</template>
</ASwitch>
</div>
</div>
<div class="settings-actions">
<AButton type="primary" @click="saveSettings">保存设置</AButton>
<AButton @click="resetSettings">重置</AButton>
</div>
</div>
</template>
<script setup lang="ts">
import { CheckOutlined, CloseOutlined } from '@ant-design/icons-vue';
import useStore from "@/store";
import { ref } from 'vue';
import { message as AMessage } from 'ant-design-vue';
const userStore = useStore().user;
const activeSection = ref('account');
// 通知设置(模拟数据)
const notificationSettings = ref({
email: true,
comment: true,
system: false
});
// const saveSettings = () => {
// // 保存设置到后端
// };
// 外观设置(模拟数据)
const appearanceSettings = ref({
theme: 'light',
density: 'default'
});
// 保存设置
const saveSettings = () => {
// 这里可以添加保存到后端的逻辑
// 模拟保存成功
AMessage.success('设置已保存');
};
// 重置设置
const resetSettings = () => {
// 重置为默认值
notificationSettings.value = {
email: true,
comment: true,
system: false
};
appearanceSettings.value = {
theme: 'light',
density: 'default'
};
// 模拟重置成功
AMessage.success('设置已重置');
};
</script>
<style scoped lang="scss">
/* 图标样式 */
.setting-icon {
&.account-ai {
background-image: url('@/assets/svgs/ai-icon.svg');
}
&.account-mobile {
background-image: url('@/assets/svgs/qr-phone.svg');
}
&.account-password {
background-image: url('@/assets/svgs/lock.svg');
}
&.privacy-profile {
background-image: url('@/assets/svgs/personal-center.svg');
}
&.privacy-comment {
background-image: url('@/assets/svgs/community.svg');
}
&.privacy-search {
background-image: url('@/assets/svgs/search.svg');
}
&.notification-email {
background-image: url('@/assets/svgs/email_security.svg');
}
&.notification-comment {
background-image: url('@/assets/svgs/community.svg');
}
&.notification-system {
background-image: url('@/assets/svgs/setting.svg');
}
&.appearance-theme {
background-image: url('@/assets/svgs/thing-album.svg');
}
&.appearance-layout {
background-image: url('@/assets/svgs/thing-album.svg');
}
}
.nav-icon {
&.account {
background-image: url('@/assets/svgs/account_security.svg');
}
&.privacy {
background-image: url('@/assets/svgs/privacy.svg');
}
&.notification {
background-image: url('@/assets/svgs/notice.svg');
}
&.appearance {
background-image: url('@/assets/svgs/thing-album.svg');
}
}
.user-center-setting {
width: 100%;
height: 100%;
@@ -145,10 +399,66 @@ const userStore = useStore().user;
}
}
.setting-section {
width: calc(100% - 80px);
.settings-container {
display: flex;
width: 100%;
gap: 30px;
.settings-nav {
width: 200px;
flex-shrink: 0;
background-color: #f9fafb;
border-radius: 12px;
overflow: hidden;
.nav-item {
display: flex;
align-items: center;
padding: 15px;
cursor: pointer;
transition: all 0.3s ease;
border-left: 3px solid transparent;
&:hover {
background-color: rgba(99, 102, 241, 0.05);
}
&.active {
background-color: rgba(99, 102, 241, 0.1);
border-left-color: #6366f1;
color: #6366f1;
}
.nav-icon {
width: 24px;
height: 24px;
margin-right: 12px;
background-size: contain;
background-position: center;
background-repeat: no-repeat;
opacity: 0.7;
}
&.active .nav-icon {
opacity: 1;
}
span {
font-size: 14px;
font-weight: 500;
}
}
}
.settings-content {
flex: 1;
}
}
.settings-section {
width: calc(100% - 50px);
margin-bottom: 30px;
background-color: var(--white-color, #fff);
background-color: #fff;
border-radius: 12px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
padding: 20px;
@@ -162,87 +472,115 @@ const userStore = useStore().user;
font-size: 18px;
font-weight: 600;
margin-bottom: 20px;
color: var(--text-color, #333);
color: #333;
padding-bottom: 10px;
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
}
}
.user-center-setting-content {
width: 100%;
display: flex;
flex-direction: column;
gap: 16px;
.user-center-setting-item {
width: 100%;
min-height: 60px;
.settings-list {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
padding: 10px 0;
border-bottom: 1px solid rgba(0, 0, 0, 0.03);
flex-direction: column;
gap: 16px;
&:last-child {
border-bottom: none;
}
.setting-item-left {
.setting-item {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
padding: 12px 0;
border-bottom: 1px solid rgba(0, 0, 0, 0.03);
.setting-icon {
width: 28px;
height: 28px;
&:last-child {
border-bottom: none;
}
.setting-text {
.setting-item-left {
display: flex;
flex-direction: column;
align-items: center;
gap: 15px;
.user-center-setting-item-title {
display: inline-block;
font-size: 16px;
font-weight: 500;
line-height: 22px;
color: var(--text-color, #333);
margin-bottom: 4px;
.setting-icon {
width: 36px;
height: 36px;
border-radius: 8px;
background-color: rgba(99, 102, 241, 0.1);
display: flex;
align-items: center;
justify-content: center;
color: #6366f1;
}
.setting-description {
font-size: 12px;
color: #999;
line-height: 1.5;
.setting-text {
display: flex;
flex-direction: column;
.setting-item-title {
font-size: 15px;
font-weight: 500;
color: #333;
margin-bottom: 4px;
}
.setting-item-desc {
font-size: 13px;
color: #666;
}
}
}
}
:deep(.ant-switch) {
background-color: rgba(0, 0, 0, 0.25);
&.ant-switch-checked {
background-color: var(--blue, rgba(96,165,250,.9));
}
}
}
}
.settings-actions {
display: flex;
justify-content: flex-end;
gap: 12px;
margin-top: 20px;
width: 100%;
}
@media (max-width: 768px) {
padding: 10px;
.setting-section {
.settings-container {
flex-direction: column;
.settings-nav {
width: 100%;
display: flex;
overflow-x: auto;
margin-bottom: 20px;
.nav-item {
flex: 1;
justify-content: center;
padding: 12px 8px;
border-left: none;
border-bottom: 3px solid transparent;
&.active {
border-left-color: transparent;
border-bottom-color: #6366f1;
}
.nav-icon {
margin-right: 8px;
}
}
}
}
.settings-section {
padding: 15px;
}
.user-center-setting-item {
.setting-item {
flex-direction: column;
align-items: flex-start;
gap: 10px;
.setting-item-left {
width: 100%;
margin-bottom: 10px;
}
}
}

View File

@@ -64,6 +64,9 @@ export default defineConfig(({mode}: { mode: string }): object => {
force: false,
needsInterop: [".vite"],
},
worker: {
format: 'es'
},
plugins: [
vue(),
vitePluginBundleObfuscator(defaultObfuscatorConfig),
@@ -177,6 +180,7 @@ export default defineConfig(({mode}: { mode: string }): object => {
watch: null, // 设置为 {} 则会启用 rollup 的监听器
rollupOptions: { // 自定义底层的 Rollup 打包配置
output: {
format: 'es',
chunkFileNames: 'js/[name]-[hash].js', // 引入文件名的名称
entryFileNames: 'js/[name]-[hash].js', // 包的入口文件名称
assetFileNames: '[ext]/[name]-[hash].[ext]',// 资源文件像 字体,图片等