feat: add Empty component and language management
- Introduced a new Empty component for displaying no data states. - Added language management functionality with support for loading saved languages. - Updated App.vue to load saved language on mount. - Modified components.d.ts to include new components and global variables. - Updated pnpm-lock.yaml and pnpm-workspace.yaml to use the latest version of @capp/eden. - Refactored home and service views to utilize the new data fetching logic with infinite scroll. - Removed unused images and added new service banner. - Enhanced signup functionality to include toast notifications on successful sign-in.
This commit is contained in:
77
src/composables/useLanguage.ts
Normal file
77
src/composables/useLanguage.ts
Normal file
@@ -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<Locale>("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<string>(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,
|
||||
};
|
||||
}
|
||||
14
src/composables/useResetRef.ts
Normal file
14
src/composables/useResetRef.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import type { MaybeRef } from "vue";
|
||||
import cloneDeepWith from "lodash-es/cloneDeepWith";
|
||||
import { isRef, ref } from "vue";
|
||||
|
||||
export function useResetRef<T>(value: MaybeRef<T>) {
|
||||
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;
|
||||
}
|
||||
248
src/composables/useStorageCache.ts
Normal file
248
src/composables/useStorageCache.ts
Normal file
@@ -0,0 +1,248 @@
|
||||
import type { RemovableRef, StorageLike } from "@vueuse/core";
|
||||
|
||||
interface CacheData<T> {
|
||||
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<T>(
|
||||
key: string,
|
||||
initialValue: T,
|
||||
options: UseStorageCacheOptions = {},
|
||||
): RemovableRef<T> {
|
||||
const {
|
||||
ttl,
|
||||
storage = localStorage,
|
||||
autoRemoveOnExpire = true,
|
||||
} = options;
|
||||
|
||||
// 尝试从 storage 读取缓存数据
|
||||
const rawData = storage.getItem(key);
|
||||
let cachedData: CacheData<T> | null = null;
|
||||
|
||||
if (rawData) {
|
||||
try {
|
||||
cachedData = JSON.parse(rawData) as CacheData<T>;
|
||||
|
||||
// 检查是否过期
|
||||
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<T>(
|
||||
key,
|
||||
defaultValue,
|
||||
storage,
|
||||
{
|
||||
serializer: {
|
||||
read: (raw: string) => {
|
||||
try {
|
||||
const data = JSON.parse(raw) as CacheData<T>;
|
||||
// 再次检查过期时间(防止并发问题)
|
||||
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<T> = {
|
||||
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<any>,
|
||||
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<any>;
|
||||
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<any>;
|
||||
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<any>;
|
||||
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<any>;
|
||||
|
||||
// 检查是否过期
|
||||
if (data.expireTime && Date.now() > data.expireTime) {
|
||||
storage.removeItem(key);
|
||||
removedCount++;
|
||||
}
|
||||
}
|
||||
catch {
|
||||
// 忽略解析错误
|
||||
}
|
||||
});
|
||||
|
||||
return removedCount;
|
||||
}
|
||||
Reference in New Issue
Block a user