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:
2026-01-18 01:00:03 +07:00
parent 51719cd229
commit 4dd2a49c70
18 changed files with 542 additions and 208 deletions

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

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

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