diff --git a/auto-imports.d.ts b/auto-imports.d.ts index 75ff4a9..8195e98 100644 --- a/auto-imports.d.ts +++ b/auto-imports.d.ts @@ -10,6 +10,7 @@ declare global { const acceptHMRUpdate: typeof import('pinia').acceptHMRUpdate const asyncComputed: typeof import('@vueuse/core').asyncComputed const autoResetRef: typeof import('@vueuse/core').autoResetRef + const clearExpiredCaches: typeof import('./src/composables/useStorageCache').clearExpiredCaches const computed: typeof import('vue').computed const computedAsync: typeof import('@vueuse/core').computedAsync const computedEager: typeof import('@vueuse/core').computedEager @@ -39,6 +40,7 @@ declare global { const effectScope: typeof import('vue').effectScope const extendRef: typeof import('@vueuse/core').extendRef const getActivePinia: typeof import('pinia').getActivePinia + const getCacheRemainingTime: typeof import('./src/composables/useStorageCache').getCacheRemainingTime const getCurrentInstance: typeof import('vue').getCurrentInstance const getCurrentScope: typeof import('vue').getCurrentScope const getCurrentWatcher: typeof import('vue').getCurrentWatcher @@ -46,6 +48,7 @@ declare global { const ignorableWatch: typeof import('@vueuse/core').ignorableWatch const inject: typeof import('vue').inject const injectLocal: typeof import('@vueuse/core').injectLocal + const isCacheExpired: typeof import('./src/composables/useStorageCache').isCacheExpired const isDefined: typeof import('@vueuse/core').isDefined const isProxy: typeof import('vue').isProxy const isReactive: typeof import('vue').isReactive @@ -98,6 +101,7 @@ declare global { const refManualReset: typeof import('@vueuse/core').refManualReset const refThrottled: typeof import('@vueuse/core').refThrottled const refWithControl: typeof import('@vueuse/core').refWithControl + const refreshCacheExpire: typeof import('./src/composables/useStorageCache').refreshCacheExpire const resolveComponent: typeof import('vue').resolveComponent const resolveRef: typeof import('@vueuse/core').resolveRef const setActivePinia: typeof import('pinia').setActivePinia @@ -202,6 +206,7 @@ declare global { const useInterval: typeof import('@vueuse/core').useInterval const useIntervalFn: typeof import('@vueuse/core').useIntervalFn const useKeyModifier: typeof import('@vueuse/core').useKeyModifier + const useLanguage: typeof import('./src/composables/useLanguage').useLanguage const useLastChanged: typeof import('@vueuse/core').useLastChanged const useLink: typeof import('vue-router').useLink const useLocalStorage: typeof import('@vueuse/core').useLocalStorage @@ -241,6 +246,7 @@ declare global { const usePrevious: typeof import('@vueuse/core').usePrevious const useRafFn: typeof import('@vueuse/core').useRafFn const useRefHistory: typeof import('@vueuse/core').useRefHistory + const useResetRef: typeof import('./src/composables/useResetRef').useResetRef const useResizeObserver: typeof import('@vueuse/core').useResizeObserver const useRoute: typeof import('vue-router').useRoute const useRouter: typeof import('vue-router').useRouter @@ -259,6 +265,7 @@ declare global { const useStepper: typeof import('@vueuse/core').useStepper const useStorage: typeof import('@vueuse/core').useStorage const useStorageAsync: typeof import('@vueuse/core').useStorageAsync + const useStorageCache: typeof import('./src/composables/useStorageCache').useStorageCache const useStyleTag: typeof import('@vueuse/core').useStyleTag const useSupported: typeof import('@vueuse/core').useSupported const useSwipe: typeof import('@vueuse/core').useSwipe @@ -318,6 +325,9 @@ declare global { // @ts-ignore export type { Component, Slot, Slots, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, ShallowRef, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue' import('vue') + // @ts-ignore + export type { Language } from './src/composables/useLanguage' + import('./src/composables/useLanguage') } // for vue template auto import @@ -329,6 +339,7 @@ declare module 'vue' { readonly acceptHMRUpdate: UnwrapRef readonly asyncComputed: UnwrapRef readonly autoResetRef: UnwrapRef + readonly clearExpiredCaches: UnwrapRef readonly computed: UnwrapRef readonly computedAsync: UnwrapRef readonly computedEager: UnwrapRef @@ -357,6 +368,7 @@ declare module 'vue' { readonly effectScope: UnwrapRef readonly extendRef: UnwrapRef readonly getActivePinia: UnwrapRef + readonly getCacheRemainingTime: UnwrapRef readonly getCurrentInstance: UnwrapRef readonly getCurrentScope: UnwrapRef readonly getCurrentWatcher: UnwrapRef @@ -364,6 +376,7 @@ declare module 'vue' { readonly ignorableWatch: UnwrapRef readonly inject: UnwrapRef readonly injectLocal: UnwrapRef + readonly isCacheExpired: UnwrapRef readonly isDefined: UnwrapRef readonly isProxy: UnwrapRef readonly isReactive: UnwrapRef @@ -416,6 +429,7 @@ declare module 'vue' { readonly refManualReset: UnwrapRef readonly refThrottled: UnwrapRef readonly refWithControl: UnwrapRef + readonly refreshCacheExpire: UnwrapRef readonly resolveComponent: UnwrapRef readonly resolveRef: UnwrapRef readonly setActivePinia: UnwrapRef @@ -520,6 +534,7 @@ declare module 'vue' { readonly useInterval: UnwrapRef readonly useIntervalFn: UnwrapRef readonly useKeyModifier: UnwrapRef + readonly useLanguage: UnwrapRef readonly useLastChanged: UnwrapRef readonly useLink: UnwrapRef readonly useLocalStorage: UnwrapRef @@ -559,6 +574,7 @@ declare module 'vue' { readonly usePrevious: UnwrapRef readonly useRafFn: UnwrapRef readonly useRefHistory: UnwrapRef + readonly useResetRef: UnwrapRef readonly useResizeObserver: UnwrapRef readonly useRoute: UnwrapRef readonly useRouter: UnwrapRef @@ -577,6 +593,7 @@ declare module 'vue' { readonly useStepper: UnwrapRef readonly useStorage: UnwrapRef readonly useStorageAsync: UnwrapRef + readonly useStorageCache: UnwrapRef readonly useStyleTag: UnwrapRef readonly useSupported: UnwrapRef readonly useSwipe: UnwrapRef diff --git a/components.d.ts b/components.d.ts index aee3da4..67e6db8 100644 --- a/components.d.ts +++ b/components.d.ts @@ -13,6 +13,7 @@ export {} declare module 'vue' { export interface GlobalComponents { BackButton: typeof import('./src/components/back-button.vue')['default'] + Empty: typeof import('./src/components/empty.vue')['default'] IonApp: typeof import('@ionic/vue')['IonApp'] IonAvatar: typeof import('@ionic/vue')['IonAvatar'] IonButton: typeof import('@ionic/vue')['IonButton'] @@ -20,6 +21,8 @@ declare module 'vue' { IonContent: typeof import('@ionic/vue')['IonContent'] IonHeader: typeof import('@ionic/vue')['IonHeader'] IonIcon: typeof import('@ionic/vue')['IonIcon'] + IonInfiniteScroll: typeof import('@ionic/vue')['IonInfiniteScroll'] + IonInfiniteScrollContent: typeof import('@ionic/vue')['IonInfiniteScrollContent'] IonInput: typeof import('@ionic/vue')['IonInput'] IonItem: typeof import('@ionic/vue')['IonItem'] IonLabel: typeof import('@ionic/vue')['IonLabel'] @@ -40,6 +43,7 @@ declare module 'vue' { // For TSX support declare global { const BackButton: typeof import('./src/components/back-button.vue')['default'] + const Empty: typeof import('./src/components/empty.vue')['default'] const IonApp: typeof import('@ionic/vue')['IonApp'] const IonAvatar: typeof import('@ionic/vue')['IonAvatar'] const IonButton: typeof import('@ionic/vue')['IonButton'] @@ -47,6 +51,8 @@ declare global { const IonContent: typeof import('@ionic/vue')['IonContent'] const IonHeader: typeof import('@ionic/vue')['IonHeader'] const IonIcon: typeof import('@ionic/vue')['IonIcon'] + const IonInfiniteScroll: typeof import('@ionic/vue')['IonInfiniteScroll'] + const IonInfiniteScrollContent: typeof import('@ionic/vue')['IonInfiniteScrollContent'] const IonInput: typeof import('@ionic/vue')['IonInput'] const IonItem: typeof import('@ionic/vue')['IonItem'] const IonLabel: typeof import('@ionic/vue')['IonLabel'] diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c286bc9..1b1b7e6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -52,8 +52,8 @@ catalogs: specifier: 8.0.0 version: 8.0.0 '@capp/eden': - specifier: http://192.168.1.2:9538/api/capp-eden-0.0.2.tgz - version: 0.0.2 + specifier: http://192.168.1.2:9538/api/capp-eden-0.0.4.tgz + version: 0.0.4 '@cloudflare/workers-types': specifier: ^4.20260113.0 version: 4.20260116.0 @@ -298,7 +298,7 @@ importers: version: 8.0.0(@capacitor/core@8.0.0) '@capp/eden': specifier: 'catalog:' - version: http://192.168.1.2:9538/api/capp-eden-0.0.2.tgz(@elysiajs/eden@1.4.6(elysia@1.4.22(@sinclair/typebox@0.34.47)(exact-mirror@0.2.6(@sinclair/typebox@0.34.47))(file-type@21.3.0)(openapi-types@12.1.3)(typescript@5.9.3))) + version: http://192.168.1.2:9538/api/capp-eden-0.0.4.tgz(@elysiajs/eden@1.4.6(elysia@1.4.22(@sinclair/typebox@0.34.47)(exact-mirror@0.2.6(@sinclair/typebox@0.34.47))(file-type@21.3.0)(openapi-types@12.1.3)(typescript@5.9.3))) '@elysiajs/eden': specifier: 'catalog:' version: 1.4.6(elysia@1.4.22(@sinclair/typebox@0.34.47)(exact-mirror@0.2.6(@sinclair/typebox@0.34.47))(file-type@21.3.0)(openapi-types@12.1.3)(typescript@5.9.3)) @@ -1182,9 +1182,9 @@ packages: '@capacitor/synapse@1.0.4': resolution: {integrity: sha512-/C1FUo8/OkKuAT4nCIu/34ny9siNHr9qtFezu4kxm6GY1wNFxrCFWjfYx5C1tUhVGz3fxBABegupkpjXvjCHrw==} - '@capp/eden@http://192.168.1.2:9538/api/capp-eden-0.0.2.tgz': - resolution: {tarball: http://192.168.1.2:9538/api/capp-eden-0.0.2.tgz} - version: 0.0.2 + '@capp/eden@http://192.168.1.2:9538/api/capp-eden-0.0.4.tgz': + resolution: {tarball: http://192.168.1.2:9538/api/capp-eden-0.0.4.tgz} + version: 0.0.4 peerDependencies: '@elysiajs/eden': ^1.4.6 @@ -6903,7 +6903,7 @@ snapshots: '@capacitor/synapse@1.0.4': {} - '@capp/eden@http://192.168.1.2:9538/api/capp-eden-0.0.2.tgz(@elysiajs/eden@1.4.6(elysia@1.4.22(@sinclair/typebox@0.34.47)(exact-mirror@0.2.6(@sinclair/typebox@0.34.47))(file-type@21.3.0)(openapi-types@12.1.3)(typescript@5.9.3)))': + '@capp/eden@http://192.168.1.2:9538/api/capp-eden-0.0.4.tgz(@elysiajs/eden@1.4.6(elysia@1.4.22(@sinclair/typebox@0.34.47)(exact-mirror@0.2.6(@sinclair/typebox@0.34.47))(file-type@21.3.0)(openapi-types@12.1.3)(typescript@5.9.3)))': dependencies: '@elysiajs/eden': 1.4.6(elysia@1.4.22(@sinclair/typebox@0.34.47)(exact-mirror@0.2.6(@sinclair/typebox@0.34.47))(file-type@21.3.0)(openapi-types@12.1.3)(typescript@5.9.3)) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 8313ecf..7c4e369 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -18,7 +18,7 @@ catalog: '@capacitor/keyboard': 8.0.0 '@capacitor/share': ^8.0.0 '@capacitor/status-bar': 8.0.0 - '@capp/eden': http://192.168.1.2:9538/api/capp-eden-0.0.2.tgz + '@capp/eden': http://192.168.1.2:9538/api/capp-eden-0.0.4.tgz '@cloudflare/workers-types': ^4.20260113.0 '@elysiajs/eden': ^1.4.6 '@faker-js/faker': ^10.2.0 diff --git a/src/App.vue b/src/App.vue index 9cab956..1aa521f 100644 --- a/src/App.vue +++ b/src/App.vue @@ -3,6 +3,11 @@ import { App as CapacitorApp } from "@capacitor/app"; const userStore = useUserStore(); const { isAuthenticated } = storeToRefs(userStore); +const { loadSavedLanguage } = useLanguage(); + +onBeforeMount(() => { + loadSavedLanguage(); +}); onMounted(() => { if (!isAuthenticated.value) diff --git a/src/assets/images/empty.png b/src/assets/images/empty.png new file mode 100644 index 0000000..242b6b9 Binary files /dev/null and b/src/assets/images/empty.png differ diff --git a/src/assets/images/guohui.png b/src/assets/images/guohui.png deleted file mode 100644 index 58ebdff..0000000 Binary files a/src/assets/images/guohui.png and /dev/null differ diff --git a/src/assets/images/home-bg.png b/src/assets/images/home-bg.png deleted file mode 100644 index b4d6a5d..0000000 Binary files a/src/assets/images/home-bg.png and /dev/null differ diff --git a/src/assets/images/service-banner1.jpg b/src/assets/images/service-banner.jpg similarity index 100% rename from src/assets/images/service-banner1.jpg rename to src/assets/images/service-banner.jpg diff --git a/src/components/empty.vue b/src/components/empty.vue new file mode 100644 index 0000000..2154953 --- /dev/null +++ b/src/components/empty.vue @@ -0,0 +1,25 @@ + + + + + diff --git a/src/composables/useLanguage.ts b/src/composables/useLanguage.ts new file mode 100644 index 0000000..fe27f1f --- /dev/null +++ b/src/composables/useLanguage.ts @@ -0,0 +1,77 @@ +import type { Locale } from "vue-i18n"; +import { client, safeClient } from "@/api"; + +export interface Language { + code: Locale; + name: string; + nativeName: string; +} + +/** + * 语言管理组合式函数 + */ +export function useLanguage() { + const { locale, availableLocales, mergeLocaleMessage } = useI18n(); + const language = useStorage("app-language", locale.value); + + // 可用的语言列表 + const languages: Language[] = [ + { + code: "zh-CN", + name: "Chinese (Simplified)", + nativeName: "简体中文", + }, + ]; + + // 当前语言 + const currentLanguage = computed(() => { + return languages.find(lang => lang.code === locale.value) || languages[0]; + }); + + /** + * 切换语言 + */ + function setLanguage(langCode: Locale) { + locale.value = langCode; + language.value = langCode; + loadRemoteLanguage(); + } + + function loadRemoteLanguage() { + const storageKey = `remote-lang-${language.value}`; + const remoteLangJson = useStorageCache(storageKey, "", { ttl: 24 * 60 * 60 }); // 缓存 1 天 + if (remoteLangJson.value) { + try { + const messages = JSON.parse(remoteLangJson.value); + mergeLocaleMessage(locale.value, messages); + return; + } + catch (e) { + console.error("Failed to parse remote language JSON:", e); + } + } + safeClient(client.api.error_messages({ lang: language.value }).get()).then((res) => { + clearExpiredCaches([storageKey]); + remoteLangJson.value = JSON.stringify(res.data.value); + mergeLocaleMessage(locale.value, res.data.value); + }); + } + + /** + * 从 localStorage 加载保存的语言 + */ + function loadSavedLanguage() { + if (language.value && availableLocales.includes(language.value)) { + locale.value = language.value; + loadRemoteLanguage(); + } + } + + return { + languages, + currentLanguage, + locale, + setLanguage, + loadSavedLanguage, + }; +} diff --git a/src/composables/useResetRef.ts b/src/composables/useResetRef.ts new file mode 100644 index 0000000..ad4ed2b --- /dev/null +++ b/src/composables/useResetRef.ts @@ -0,0 +1,14 @@ +import type { MaybeRef } from "vue"; +import cloneDeepWith from "lodash-es/cloneDeepWith"; +import { isRef, ref } from "vue"; + +export function useResetRef(value: MaybeRef) { + const _valueDefine = cloneDeepWith(value as any); + const _value = isRef(value) ? value : ref(value); + + function reset(value?: T) { + _value.value = value ? cloneDeepWith(value) : cloneDeepWith(_valueDefine); + } + + return [_value, reset] as const; +} diff --git a/src/composables/useStorageCache.ts b/src/composables/useStorageCache.ts new file mode 100644 index 0000000..ae0a514 --- /dev/null +++ b/src/composables/useStorageCache.ts @@ -0,0 +1,248 @@ +import type { RemovableRef, StorageLike } from "@vueuse/core"; + +interface CacheData { + value: T; + expireTime?: number; // 过期时间戳(毫秒) +} + +interface UseStorageCacheOptions { + /** + * 缓存过期时间(秒) + * @default undefined 永不过期 + */ + ttl?: number; + /** + * 存储介质 + * @default localStorage + */ + storage?: StorageLike; + /** + * 是否在过期后自动删除 + * @default true + */ + autoRemoveOnExpire?: boolean; +} + +/** + * 使用支持过期时间的 Storage + * @param key 存储键 + * @param initialValue 初始值 + * @param options 配置项 + * @returns 响应式存储引用 + * + * @example + * ```ts + * // 创建一个 5 分钟后过期的缓存 + * const token = useStorageCache('user-token', '', { ttl: 300 }) + * + * // 设置值 + * token.value = 'new-token' + * + * // 检查是否过期 + * if (isExpired(token)) { + * console.log('Token 已过期') + * } + * + * // 刷新过期时间 + * refreshExpire(token, 600) // 延长到 10 分钟 + * ``` + */ +export function useStorageCache( + key: string, + initialValue: T, + options: UseStorageCacheOptions = {}, +): RemovableRef { + const { + ttl, + storage = localStorage, + autoRemoveOnExpire = true, + } = options; + + // 尝试从 storage 读取缓存数据 + const rawData = storage.getItem(key); + let cachedData: CacheData | null = null; + + if (rawData) { + try { + cachedData = JSON.parse(rawData) as CacheData; + + // 检查是否过期 + if (cachedData.expireTime && Date.now() > cachedData.expireTime) { + // 已过期 + if (autoRemoveOnExpire) { + storage.removeItem(key); + } + cachedData = null; + } + } + catch (error) { + console.error(`解析缓存数据失败 [${key}]:`, error); + cachedData = null; + } + } + + // 计算初始值 + const defaultValue = cachedData ? cachedData.value : initialValue; + + // 使用 useStorage 创建响应式引用 + const storageRef = useStorage( + key, + defaultValue, + storage, + { + serializer: { + read: (raw: string) => { + try { + const data = JSON.parse(raw) as CacheData; + // 再次检查过期时间(防止并发问题) + if (data.expireTime && Date.now() > data.expireTime) { + if (autoRemoveOnExpire) { + storage.removeItem(key); + } + return initialValue; + } + return data.value; + } + catch { + return initialValue; + } + }, + write: (value: T) => { + const cacheData: CacheData = { + value, + }; + + // 如果设置了 ttl,计算过期时间 + if (ttl && ttl > 0) { + cacheData.expireTime = Date.now() + ttl * 1000; + } + + return JSON.stringify(cacheData); + }, + }, + }, + ); + + return storageRef; +} + +/** + * 检查缓存是否过期 + * @param key 存储键或存储引用 + * @param storage 存储介质 + * @returns 是否过期 + */ +export function isCacheExpired( + key: string | RemovableRef, + storage: StorageLike = localStorage, +): boolean { + const cacheKey = typeof key === "string" ? key : key.value; + + try { + const rawData = storage.getItem(cacheKey); + if (!rawData) { + return true; + } + + const data = JSON.parse(rawData) as CacheData; + if (!data.expireTime) { + return false; // 没有设置过期时间,永不过期 + } + + return Date.now() > data.expireTime; + } + catch { + return true; + } +} + +/** + * 刷新缓存过期时间 + * @param key 存储键 + * @param ttl 新的过期时间(秒) + * @param storage 存储介质 + */ +export function refreshCacheExpire( + key: string, + ttl: number, + storage: StorageLike = localStorage, +): void { + try { + const rawData = storage.getItem(key); + if (!rawData) { + return; + } + + const data = JSON.parse(rawData) as CacheData; + data.expireTime = Date.now() + ttl * 1000; + + storage.setItem(key, JSON.stringify(data)); + } + catch (error) { + console.error(`刷新缓存过期时间失败 [${key}]:`, error); + } +} + +/** + * 获取缓存剩余有效时间(秒) + * @param key 存储键 + * @param storage 存储介质 + * @returns 剩余时间(秒),如果已过期返回 0,如果永不过期返回 Infinity + */ +export function getCacheRemainingTime( + key: string, + storage: StorageLike = localStorage, +): number { + try { + const rawData = storage.getItem(key); + if (!rawData) { + return 0; + } + + const data = JSON.parse(rawData) as CacheData; + if (!data.expireTime) { + return Infinity; // 永不过期 + } + + const remaining = Math.max(0, data.expireTime - Date.now()); + return Math.floor(remaining / 1000); + } + catch { + return 0; + } +} + +/** + * 清除指定键的过期缓存 + * @param keys 要检查的键列表 + * @param storage 存储介质 + * @returns 被删除的键数量 + */ +export function clearExpiredCaches( + keys: string[], + storage: StorageLike = localStorage, +): number { + let removedCount = 0; + + keys.forEach((key) => { + try { + const rawData = storage.getItem(key); + if (!rawData) { + return; + } + + const data = JSON.parse(rawData) as CacheData; + + // 检查是否过期 + if (data.expireTime && Date.now() > data.expireTime) { + storage.removeItem(key); + removedCount++; + } + } + catch { + // 忽略解析错误 + } + }); + + return removedCount; +} diff --git a/src/theme/ionic.css b/src/theme/ionic.css index 4871a1c..1662e65 100644 --- a/src/theme/ionic.css +++ b/src/theme/ionic.css @@ -1,4 +1,4 @@ -/* :root { +:root { --ion-color-primary: #c31d39; --ion-color-primary-rgb: 195,29,57; --ion-color-primary-contrast: #ffffff; @@ -62,8 +62,8 @@ --ion-color-dark-shade: #292929; --ion-color-dark-tint: #444444; -} */ - +} +/* :root { --ion-color-primary: #2065c3; --ion-color-primary-rgb: 32,101,195; @@ -128,7 +128,7 @@ --ion-color-dark-shade: #292929; --ion-color-dark-tint: #444444; -} +} */ .ion-toolbar { --background: var(--ion-color-primary-contrast); diff --git a/src/views/home/index.ts b/src/views/home/index.ts new file mode 100644 index 0000000..96dfc34 --- /dev/null +++ b/src/views/home/index.ts @@ -0,0 +1,10 @@ +import { calendarOutline, chatbubblesOutline, peopleOutline, rocketOutline } from "ionicons/icons"; + +export const actions = [ + { id: "signup", name: "签到", icon: calendarOutline, color: "#c32120" }, + { id: "team", name: "团队中心", icon: peopleOutline, color: "#c32120" }, + { id: "invite", name: "邀请好友", icon: rocketOutline, color: "#c32120" }, + { id: "support", name: "在线客服", icon: chatbubblesOutline, color: "#c32120" }, +] as const; + +export type Action = (typeof actions)[number]; diff --git a/src/views/home/index.vue b/src/views/home/index.vue index 070b8ca..fff379e 100644 --- a/src/views/home/index.vue +++ b/src/views/home/index.vue @@ -1,149 +1,71 @@