feat: 添加系统设置页面,集成应用更新和缓存管理功能,优化用户体验

This commit is contained in:
2025-12-20 01:32:33 +07:00
parent 521585035e
commit 2703e6d007
8 changed files with 403 additions and 4 deletions

4
auto-imports.d.ts vendored
View File

@@ -134,6 +134,7 @@ declare global {
const until: typeof import('@vueuse/core').until const until: typeof import('@vueuse/core').until
const useActiveElement: typeof import('@vueuse/core').useActiveElement const useActiveElement: typeof import('@vueuse/core').useActiveElement
const useAnimate: typeof import('@vueuse/core').useAnimate const useAnimate: typeof import('@vueuse/core').useAnimate
const useAppUpdate: typeof import('./src/composables/useAppUpdate').useAppUpdate
const useArrayDifference: typeof import('@vueuse/core').useArrayDifference const useArrayDifference: typeof import('@vueuse/core').useArrayDifference
const useArrayEvery: typeof import('@vueuse/core').useArrayEvery const useArrayEvery: typeof import('@vueuse/core').useArrayEvery
const useArrayFilter: typeof import('@vueuse/core').useArrayFilter const useArrayFilter: typeof import('@vueuse/core').useArrayFilter
@@ -156,6 +157,7 @@ declare global {
const useBreakpoints: typeof import('@vueuse/core').useBreakpoints const useBreakpoints: typeof import('@vueuse/core').useBreakpoints
const useBroadcastChannel: typeof import('@vueuse/core').useBroadcastChannel const useBroadcastChannel: typeof import('@vueuse/core').useBroadcastChannel
const useBrowserLocation: typeof import('@vueuse/core').useBrowserLocation const useBrowserLocation: typeof import('@vueuse/core').useBrowserLocation
const useCacheSize: typeof import('./src/composables/useCacheSize').useCacheSize
const useCached: typeof import('@vueuse/core').useCached const useCached: typeof import('@vueuse/core').useCached
const useClipboard: typeof import('@vueuse/core').useClipboard const useClipboard: typeof import('@vueuse/core').useClipboard
const useClipboardItems: typeof import('@vueuse/core').useClipboardItems const useClipboardItems: typeof import('@vueuse/core').useClipboardItems
@@ -476,6 +478,7 @@ declare module 'vue' {
readonly until: UnwrapRef<typeof import('@vueuse/core')['until']> readonly until: UnwrapRef<typeof import('@vueuse/core')['until']>
readonly useActiveElement: UnwrapRef<typeof import('@vueuse/core')['useActiveElement']> readonly useActiveElement: UnwrapRef<typeof import('@vueuse/core')['useActiveElement']>
readonly useAnimate: UnwrapRef<typeof import('@vueuse/core')['useAnimate']> readonly useAnimate: UnwrapRef<typeof import('@vueuse/core')['useAnimate']>
readonly useAppUpdate: UnwrapRef<typeof import('./src/composables/useAppUpdate')['useAppUpdate']>
readonly useArrayDifference: UnwrapRef<typeof import('@vueuse/core')['useArrayDifference']> readonly useArrayDifference: UnwrapRef<typeof import('@vueuse/core')['useArrayDifference']>
readonly useArrayEvery: UnwrapRef<typeof import('@vueuse/core')['useArrayEvery']> readonly useArrayEvery: UnwrapRef<typeof import('@vueuse/core')['useArrayEvery']>
readonly useArrayFilter: UnwrapRef<typeof import('@vueuse/core')['useArrayFilter']> readonly useArrayFilter: UnwrapRef<typeof import('@vueuse/core')['useArrayFilter']>
@@ -498,6 +501,7 @@ declare module 'vue' {
readonly useBreakpoints: UnwrapRef<typeof import('@vueuse/core')['useBreakpoints']> readonly useBreakpoints: UnwrapRef<typeof import('@vueuse/core')['useBreakpoints']>
readonly useBroadcastChannel: UnwrapRef<typeof import('@vueuse/core')['useBroadcastChannel']> readonly useBroadcastChannel: UnwrapRef<typeof import('@vueuse/core')['useBroadcastChannel']>
readonly useBrowserLocation: UnwrapRef<typeof import('@vueuse/core')['useBrowserLocation']> readonly useBrowserLocation: UnwrapRef<typeof import('@vueuse/core')['useBrowserLocation']>
readonly useCacheSize: UnwrapRef<typeof import('./src/composables/useCacheSize')['useCacheSize']>
readonly useCached: UnwrapRef<typeof import('@vueuse/core')['useCached']> readonly useCached: UnwrapRef<typeof import('@vueuse/core')['useCached']>
readonly useClipboard: UnwrapRef<typeof import('@vueuse/core')['useClipboard']> readonly useClipboard: UnwrapRef<typeof import('@vueuse/core')['useClipboard']>
readonly useClipboardItems: UnwrapRef<typeof import('@vueuse/core')['useClipboardItems']> readonly useClipboardItems: UnwrapRef<typeof import('@vueuse/core')['useClipboardItems']>

2
components.d.ts vendored
View File

@@ -40,6 +40,7 @@ declare module 'vue' {
IonItem: typeof import('@ionic/vue')['IonItem'] IonItem: typeof import('@ionic/vue')['IonItem']
IonLabel: typeof import('@ionic/vue')['IonLabel'] IonLabel: typeof import('@ionic/vue')['IonLabel']
IonList: typeof import('@ionic/vue')['IonList'] IonList: typeof import('@ionic/vue')['IonList']
IonListHeader: typeof import('@ionic/vue')['IonListHeader']
IonModal: typeof import('@ionic/vue')['IonModal'] IonModal: typeof import('@ionic/vue')['IonModal']
IonNote: typeof import('@ionic/vue')['IonNote'] IonNote: typeof import('@ionic/vue')['IonNote']
IonPage: typeof import('@ionic/vue')['IonPage'] IonPage: typeof import('@ionic/vue')['IonPage']
@@ -106,6 +107,7 @@ declare global {
const IonItem: typeof import('@ionic/vue')['IonItem'] const IonItem: typeof import('@ionic/vue')['IonItem']
const IonLabel: typeof import('@ionic/vue')['IonLabel'] const IonLabel: typeof import('@ionic/vue')['IonLabel']
const IonList: typeof import('@ionic/vue')['IonList'] const IonList: typeof import('@ionic/vue')['IonList']
const IonListHeader: typeof import('@ionic/vue')['IonListHeader']
const IonModal: typeof import('@ionic/vue')['IonModal'] const IonModal: typeof import('@ionic/vue')['IonModal']
const IonNote: typeof import('@ionic/vue')['IonNote'] const IonNote: typeof import('@ionic/vue')['IonNote']
const IonPage: typeof import('@ionic/vue')['IonPage'] const IonPage: typeof import('@ionic/vue')['IonPage']

View File

@@ -0,0 +1,103 @@
/**
* 应用更新检查组合式函数
*/
export function useAppUpdate() {
const isChecking = ref(false);
const hasUpdate = ref(false);
const latestVersion = ref("");
const currentVersion = ref("1.0.0"); // 从 package.json 或环境变量读取
/**
* 检查是否有新版本
*/
async function checkForUpdate(): Promise<{
hasUpdate: boolean;
currentVersion: string;
latestVersion?: string;
}> {
isChecking.value = true;
try {
// 方案1: 从服务器检查版本(需要后端 API
// const response = await fetch('/api/version');
// const { version } = await response.json();
// latestVersion.value = version;
// 方案2: 检查 Service Worker 更新PWA 应用)
if ("serviceWorker" in navigator) {
const registration = await navigator.serviceWorker.getRegistration();
if (registration) {
await registration.update();
const hasNewWorker = registration.waiting !== null || registration.installing !== null;
hasUpdate.value = hasNewWorker;
if (hasNewWorker) {
return {
hasUpdate: true,
currentVersion: currentVersion.value,
latestVersion: "新版本可用",
};
}
}
}
// 模拟检查(实际应用中需要替换为真实的 API 调用)
await new Promise(resolve => setTimeout(resolve, 1000));
// 暂时返回无更新
return {
hasUpdate: false,
currentVersion: currentVersion.value,
};
}
catch (error) {
console.error("检查更新失败:", error);
throw error;
}
finally {
isChecking.value = false;
}
}
/**
* 应用更新(重新加载应用)
*/
async function applyUpdate(): Promise<void> {
if ("serviceWorker" in navigator) {
const registration = await navigator.serviceWorker.getRegistration();
if (registration?.waiting) {
// 通知 Service Worker 跳过等待,立即激活
registration.waiting.postMessage({ type: "SKIP_WAITING" });
// 等待 Service Worker 激活后重新加载页面
navigator.serviceWorker.addEventListener("controllerchange", () => {
window.location.reload();
});
return;
}
}
// 如果没有 Service Worker直接重新加载
window.location.reload();
}
/**
* 强制刷新应用(清除缓存后重新加载)
*/
async function forceReload(): Promise<void> {
if ("caches" in window) {
const cacheNames = await caches.keys();
await Promise.all(cacheNames.map(name => caches.delete(name)));
}
window.location.reload();
}
return {
isChecking,
hasUpdate,
currentVersion,
latestVersion,
checkForUpdate,
applyUpdate,
forceReload,
};
}

View File

@@ -0,0 +1,122 @@
/**
* 获取应用缓存大小的组合式函数
*/
export function useCacheSize() {
const cacheSize = ref<string>("计算中...");
const isLoading = ref(false);
/**
* 计算当前应用使用的缓存大小
*/
async function calculateCacheSize() {
isLoading.value = true;
try {
if ("storage" in navigator && "estimate" in navigator.storage) {
// 使用 Storage API 获取精确的存储使用量
const estimate = await navigator.storage.estimate();
const usageInMB = ((estimate.usage || 0) / (1024 * 1024)).toFixed(2);
cacheSize.value = `${usageInMB} MB`;
}
else {
// 降级方案:计算 localStorage 大小
let totalSize = 0;
for (const key in localStorage) {
if (Object.prototype.hasOwnProperty.call(localStorage, key)) {
totalSize += localStorage[key].length + key.length;
}
}
const sizeInKB = (totalSize / 1024).toFixed(2);
cacheSize.value = `${sizeInKB} KB (仅 localStorage)`;
}
}
catch (error) {
console.error("计算缓存大小失败:", error);
cacheSize.value = "未知";
}
finally {
isLoading.value = false;
}
}
/**
* 获取缓存大小的原始字节数
*/
async function getCacheSizeInBytes(): Promise<number> {
try {
if ("storage" in navigator && "estimate" in navigator.storage) {
const estimate = await navigator.storage.estimate();
return estimate.usage || 0;
}
else {
let totalSize = 0;
for (const key in localStorage) {
if (Object.prototype.hasOwnProperty.call(localStorage, key)) {
totalSize += localStorage[key].length + key.length;
}
}
return totalSize;
}
}
catch (error) {
console.error("获取缓存大小失败:", error);
return 0;
}
}
/**
* 清除应用缓存
*/
async function clearCache(): Promise<void> {
isLoading.value = true;
try {
// 清除 localStorage
localStorage.clear();
// 清除 sessionStorage
sessionStorage.clear();
// 清除 Cache API 缓存
if ("caches" in window) {
const cacheNames = await caches.keys();
await Promise.all(
cacheNames.map(cacheName => caches.delete(cacheName)),
);
}
// 清除 IndexedDB如果需要
if ("indexedDB" in window) {
const databases = await indexedDB.databases();
await Promise.all(
databases.map((db) => {
if (db.name) {
return new Promise<void>((resolve, reject) => {
const request = indexedDB.deleteDatabase(db.name!);
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
}
return Promise.resolve();
}),
);
}
// 重新计算缓存大小
await calculateCacheSize();
}
catch (error) {
console.error("清除缓存失败:", error);
throw error;
}
finally {
isLoading.value = false;
}
}
return {
cacheSize,
isLoading,
calculateCacheSize,
getCacheSizeInBytes,
clearCache,
};
}

View File

@@ -53,6 +53,10 @@ const routes: Array<RouteRecordRaw> = [
path: "/user/settings", path: "/user/settings",
component: () => import("@/views/user/settings.vue"), component: () => import("@/views/user/settings.vue"),
}, },
{
path: "/system-settings",
component: () => import("@/views/system-settings/index.vue"),
},
{ {
path: "/issue/issuing-apply", path: "/issue/issuing-apply",
props: ({ query, params }) => ({ query, params }), props: ({ query, params }) => ({ query, params }),

View File

@@ -0,0 +1,164 @@
<script lang="ts" setup>
import { alertController, toastController } from "@ionic/vue";
import { checkbox, close, information, languageOutline, refresh } from "ionicons/icons";
const { cacheSize, calculateCacheSize, clearCache } = useCacheSize();
const { isChecking, checkForUpdate } = useAppUpdate();
function handleClearCache() {
clearCache();
calculateCacheSize();
toastController.create({
message: "缓存已清除",
duration: 2000,
icon: checkbox,
position: "bottom",
color: "success",
}).then(toast => toast.present());
}
async function handleCheckUpdate() {
try {
const result = await checkForUpdate();
if (result.hasUpdate) {
const alert = await alertController.create({
header: "发现新版本",
message: `当前版本: ${result.currentVersion}\n最新版本: ${result.latestVersion || "新版本"}`,
buttons: [
{
text: "取消",
role: "cancel",
},
{
text: "立即更新",
handler: () => {
window.location.reload();
},
},
],
});
await alert.present();
}
else {
const toast = await toastController.create({
message: "已是最新版本",
duration: 2000,
icon: checkbox,
position: "bottom",
color: "success",
});
await toast.present();
}
}
catch (error) {
const toast = await toastController.create({
message: "检查更新失败",
duration: 2000,
position: "bottom",
color: "danger",
});
await toast.present();
}
}
onMounted(() => {
calculateCacheSize();
});
</script>
<template>
<ion-page>
<ion-header>
<ion-toolbar class="ui-toolbar">
<ion-back-button slot="start" />
</ion-toolbar>
</ion-header>
<ion-content :fullscreen="true" class="ion-padding">
<ion-list lines="full">
<ion-list-header>
<ion-label>设置</ion-label>
</ion-list-header>
<ion-item button>
<div class="flex justify-between w-full items-center">
<div class="flex-center space-x-2">
<div class="icon">
<ion-icon :icon="languageOutline" class="text-lg" />
</div>
<div class="text-sm font-semibold">
语言
</div>
</div>
<div class="end">
简体中文
</div>
</div>
</ion-item>
<ion-item button>
<div class="flex justify-between w-full items-center">
<div class="flex-center space-x-2">
<div class="icon">
<ion-icon :icon="information" class="text-lg" />
</div>
<div class="text-sm font-semibold">
关于我们
</div>
</div>
</div>
</ion-item>
<ion-item button @click="handleClearCache">
<div class="flex justify-between w-full items-center">
<div class="flex-center space-x-2">
<div class="icon">
<ion-icon :icon="close" class="text-lg" />
</div>
<div class="text-sm font-semibold">
清除缓存
</div>
</div>
<div class="end">
{{ cacheSize }}
</div>
</div>
</ion-item>
<ion-item button @click="handleCheckUpdate">
<div class="flex justify-between w-full items-center">
<div class="flex-center space-x-2">
<div class="icon">
<ion-icon :icon="refresh" class="text-lg" :class="{ 'animate-spin': isChecking }" />
</div>
<div class="text-sm font-semibold">
检查更新
</div>
</div>
</div>
</ion-item>
</ion-list>
</ion-content>
</ion-page>
</template>
<style lang='css' scoped>
@reference "tailwindcss";
ion-item {
--padding-start: 0;
--padding-end: 0;
--padding-top: 6px;
--padding-bottom: 6px;
border-radius: 0.25rem;
}
.end {
@apply text-sm text-gray-500;
}
.icon {
@apply bg-[#1c1c1c] text-white;
}
.icon {
@apply rounded-lg p-2 w-7 h-7 flex items-center justify-center;
}
</style>

View File

@@ -6,14 +6,14 @@ const { user } = useAuth();
<template> <template>
<div class="user-info-container"> <div class="user-info-container">
<div class="user-info"> <div class="user-info" @click="$router.push('/user/settings')">
<ui-avatar class="size-18" /> <ui-avatar class="size-18" />
<div> <div>
<div class="user-name"> <div class="user-name">
{{ user?.email }} {{ user?.email }}
</div> </div>
<div class="user-uid mt-2 text-sm text-text-100"> <div class="user-uid mt-1 text-xs text-text-100">
UID: 95223143 <ion-icon :icon="copyOutline" /> UID: 95223143
</div> </div>
</div> </div>
</div> </div>

View File

@@ -31,7 +31,7 @@ async function handleRefresh(event: RefresherCustomEvent) {
<ion-button fill="clear"> <ion-button fill="clear">
<ion-icon slot="icon-only" :icon="notificationsOutline" /> <ion-icon slot="icon-only" :icon="notificationsOutline" />
</ion-button> </ion-button>
<ion-button fill="clear" router-link="/user/settings"> <ion-button fill="clear" router-link="/system-settings">
<ion-icon slot="icon-only" :icon="settingsOutline" /> <ion-icon slot="icon-only" :icon="settingsOutline" />
</ion-button> </ion-button>
</div> </div>