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

17
auto-imports.d.ts vendored
View File

@@ -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<typeof import('pinia')['acceptHMRUpdate']>
readonly asyncComputed: UnwrapRef<typeof import('@vueuse/core')['asyncComputed']>
readonly autoResetRef: UnwrapRef<typeof import('@vueuse/core')['autoResetRef']>
readonly clearExpiredCaches: UnwrapRef<typeof import('./src/composables/useStorageCache')['clearExpiredCaches']>
readonly computed: UnwrapRef<typeof import('vue')['computed']>
readonly computedAsync: UnwrapRef<typeof import('@vueuse/core')['computedAsync']>
readonly computedEager: UnwrapRef<typeof import('@vueuse/core')['computedEager']>
@@ -357,6 +368,7 @@ declare module 'vue' {
readonly effectScope: UnwrapRef<typeof import('vue')['effectScope']>
readonly extendRef: UnwrapRef<typeof import('@vueuse/core')['extendRef']>
readonly getActivePinia: UnwrapRef<typeof import('pinia')['getActivePinia']>
readonly getCacheRemainingTime: UnwrapRef<typeof import('./src/composables/useStorageCache')['getCacheRemainingTime']>
readonly getCurrentInstance: UnwrapRef<typeof import('vue')['getCurrentInstance']>
readonly getCurrentScope: UnwrapRef<typeof import('vue')['getCurrentScope']>
readonly getCurrentWatcher: UnwrapRef<typeof import('vue')['getCurrentWatcher']>
@@ -364,6 +376,7 @@ declare module 'vue' {
readonly ignorableWatch: UnwrapRef<typeof import('@vueuse/core')['ignorableWatch']>
readonly inject: UnwrapRef<typeof import('vue')['inject']>
readonly injectLocal: UnwrapRef<typeof import('@vueuse/core')['injectLocal']>
readonly isCacheExpired: UnwrapRef<typeof import('./src/composables/useStorageCache')['isCacheExpired']>
readonly isDefined: UnwrapRef<typeof import('@vueuse/core')['isDefined']>
readonly isProxy: UnwrapRef<typeof import('vue')['isProxy']>
readonly isReactive: UnwrapRef<typeof import('vue')['isReactive']>
@@ -416,6 +429,7 @@ declare module 'vue' {
readonly refManualReset: UnwrapRef<typeof import('@vueuse/core')['refManualReset']>
readonly refThrottled: UnwrapRef<typeof import('@vueuse/core')['refThrottled']>
readonly refWithControl: UnwrapRef<typeof import('@vueuse/core')['refWithControl']>
readonly refreshCacheExpire: UnwrapRef<typeof import('./src/composables/useStorageCache')['refreshCacheExpire']>
readonly resolveComponent: UnwrapRef<typeof import('vue')['resolveComponent']>
readonly resolveRef: UnwrapRef<typeof import('@vueuse/core')['resolveRef']>
readonly setActivePinia: UnwrapRef<typeof import('pinia')['setActivePinia']>
@@ -520,6 +534,7 @@ declare module 'vue' {
readonly useInterval: UnwrapRef<typeof import('@vueuse/core')['useInterval']>
readonly useIntervalFn: UnwrapRef<typeof import('@vueuse/core')['useIntervalFn']>
readonly useKeyModifier: UnwrapRef<typeof import('@vueuse/core')['useKeyModifier']>
readonly useLanguage: UnwrapRef<typeof import('./src/composables/useLanguage')['useLanguage']>
readonly useLastChanged: UnwrapRef<typeof import('@vueuse/core')['useLastChanged']>
readonly useLink: UnwrapRef<typeof import('vue-router')['useLink']>
readonly useLocalStorage: UnwrapRef<typeof import('@vueuse/core')['useLocalStorage']>
@@ -559,6 +574,7 @@ declare module 'vue' {
readonly usePrevious: UnwrapRef<typeof import('@vueuse/core')['usePrevious']>
readonly useRafFn: UnwrapRef<typeof import('@vueuse/core')['useRafFn']>
readonly useRefHistory: UnwrapRef<typeof import('@vueuse/core')['useRefHistory']>
readonly useResetRef: UnwrapRef<typeof import('./src/composables/useResetRef')['useResetRef']>
readonly useResizeObserver: UnwrapRef<typeof import('@vueuse/core')['useResizeObserver']>
readonly useRoute: UnwrapRef<typeof import('vue-router')['useRoute']>
readonly useRouter: UnwrapRef<typeof import('vue-router')['useRouter']>
@@ -577,6 +593,7 @@ declare module 'vue' {
readonly useStepper: UnwrapRef<typeof import('@vueuse/core')['useStepper']>
readonly useStorage: UnwrapRef<typeof import('@vueuse/core')['useStorage']>
readonly useStorageAsync: UnwrapRef<typeof import('@vueuse/core')['useStorageAsync']>
readonly useStorageCache: UnwrapRef<typeof import('./src/composables/useStorageCache')['useStorageCache']>
readonly useStyleTag: UnwrapRef<typeof import('@vueuse/core')['useStyleTag']>
readonly useSupported: UnwrapRef<typeof import('@vueuse/core')['useSupported']>
readonly useSwipe: UnwrapRef<typeof import('@vueuse/core')['useSwipe']>

6
components.d.ts vendored
View File

@@ -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']

14
pnpm-lock.yaml generated
View File

@@ -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))

View File

@@ -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

View File

@@ -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)

BIN
src/assets/images/empty.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 481 KiB

View File

Before

Width:  |  Height:  |  Size: 173 KiB

After

Width:  |  Height:  |  Size: 173 KiB

25
src/components/empty.vue Normal file
View File

@@ -0,0 +1,25 @@
<script lang='ts' setup>
import empty from "@/assets/images/empty.png?url";
withDefaults(defineProps<{
image?: string;
title?: string;
}>(), {
image: empty,
title: "暂无数据",
});
</script>
<template>
<div class="flex flex-col-center space-y-2 my-4">
<slot name="icon">
<img :src="empty" class="w-22 h-22 object-contain">
</slot>
<div class="text-sm text-text-400">
{{ title }}
</div>
<slot name="extra" />
</div>
</template>
<style lang='css' scoped></style>

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

View File

@@ -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);

10
src/views/home/index.ts Normal file
View File

@@ -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];

View File

@@ -1,149 +1,71 @@
<script lang='ts' setup>
import {
calendarOutline,
chatbubblesOutline,
chevronForwardOutline,
eyeOutline,
megaphoneOutline,
newspaperOutline,
peopleOutline,
rocketOutline,
timeOutline,
} from "ionicons/icons";
import { onMounted, onUnmounted, ref } from "vue";
import type { Treaty } from "@elysiajs/eden";
import type { InfiniteScrollCustomEvent } from "@ionic/vue";
import type { Action } from "./";
import type { TreatyQuery } from "@/api/types";
import { chevronForwardOutline, eyeOutline, timeOutline } from "ionicons/icons";
import { client, safeClient } from "@/api";
import { actions } from "./";
// 公告数据
const announcements = ref([
{ id: 1, title: "关于深化改革的重要通知", time: "2026-01-16" },
{ id: 2, title: "平台升级维护公告", time: "2026-01-15" },
{ id: 3, title: "新年贺词:砥砺前行,共创辉煌", time: "2026-01-01" },
]);
// 新闻数据(模拟数据)
const newsList = ref([
{
id: 1,
title: "深化改革进入新阶段",
subtitle: "全面推进现代化建设,开创新局面",
time: "2026-01-16 10:30",
views: 1520,
image: "https://picsum.photos/seed/news1/400/250",
},
{
id: 2,
title: "团队协作再创佳绩",
subtitle: "凝心聚力,共筑梦想,携手共进新时代",
time: "2026-01-15 16:20",
views: 2340,
image: "https://picsum.photos/seed/news2/400/250",
},
{
id: 3,
title: "战略布局取得重大突破",
subtitle: "科学谋划,精准施策,推动高质量发展",
time: "2026-01-14 09:15",
views: 1890,
image: "https://picsum.photos/seed/news3/400/250",
},
{
id: 4,
title: "深化改革进入新阶段",
subtitle: "全面推进现代化建设,开创新局面",
time: "2026-01-16 10:30",
views: 1520,
image: "https://picsum.photos/seed/news1/400/250",
},
{
id: 5,
title: "团队协作再创佳绩",
subtitle: "凝心聚力,共筑梦想,携手共进新时代",
time: "2026-01-15 16:20",
views: 2340,
image: "https://picsum.photos/seed/news2/400/250",
},
]);
type NewsItem = Treaty.Data<typeof client.api.news.get>["data"][number];
type NewsQuery = TreatyQuery<typeof client.api.news.get>;
const router = useRouter();
// 快捷入口
const quickActions = ref([
{ id: 1, name: "签到", icon: calendarOutline, color: "#2373c3" },
{ id: 2, name: "团队中心", icon: peopleOutline, color: "#2373c3" },
{ id: 3, name: "邀请好友", icon: rocketOutline, color: "#2373c3" },
{ id: 4, name: "在线客服", icon: chatbubblesOutline, color: "#2373c3" },
]);
const [query] = useResetRef<NewsQuery>({
offset: 0,
limit: 10,
});
const data = ref<NewsItem[]>([]);
const isFinished = ref(false);
function handleQuickAction(action: any) {
async function fetchNews() {
const { data: responseData } = await safeClient(client.api.news.get({ query: { ...query.value } }));
data.value.push(...(responseData.value?.data || []));
isFinished.value = responseData.value?.pagination.hasNextPage === false;
}
async function handleInfinite(event: InfiniteScrollCustomEvent) {
if (isFinished.value) {
event.target.complete();
event.target.disabled = true;
return;
}
query.value.offset! += query.value.limit!;
await fetchNews();
setTimeout(() => {
event.target.complete();
}, 500);
}
function handleQuickAction(action: Action) {
switch (action.id) {
case 1:
case "signup":
router.push("/signup");
break;
case 2:
case "team":
console.log("团队中心");
break;
case 3:
case "invite":
router.push("/invite");
break;
case "support":
window.open("https://chat.riwsan.com", "_blank");
break;
}
}
function handleAnnouncementClick(announcement: any) {
console.log("查看公告:", announcement.title);
// TODO: 跳转到公告详情
}
function handleNewsClick(news: any) {
function handleNewsClick(news: NewsItem) {
console.log("查看新闻:", news.title);
// TODO: 跳转到新闻详情
}
// 走马灯相关
const currentAnnouncementIndex = ref(0);
let announcementTimer: number | null = null;
function startAnnouncementCarousel() {
announcementTimer = setInterval(() => {
currentAnnouncementIndex.value = (currentAnnouncementIndex.value + 1) % announcements.value.length;
}, 3000); // 每3秒切换
}
function stopAnnouncementCarousel() {
if (announcementTimer) {
clearInterval(announcementTimer);
announcementTimer = null;
}
}
onMounted(() => {
startAnnouncementCarousel();
});
onUnmounted(() => {
stopAnnouncementCarousel();
});
</script>
<template>
<ion-page>
<ion-content :fullscreen="true" class="home-page">
<!-- <ion-header class="ion-no-border header">
<ion-toolbar class="ion-toolbar">
<div slot="start" class="flex items-center px-3 py-3">
<img src="@/assets/images/guohui.png" alt="国徽" class="inline-block h-10 mr-2">
<div class="font-semibold text-lg">
国务院深化改革战略推进委员会
</div>
</div>
</ion-toolbar>
</ion-header> -->
<img src="@/assets/images/home-banner.jpg" class="h-60 w-full object-cover" alt="首页横幅">
<div class="ion-padding-horizontal">
<!-- 快捷入口区域 -->
<section class="my-5 grid grid-cols-4 gap-4">
<!-- <div class="grid grid-cols-4 gap-4 bg-white/95 p-5 rounded-2xl shadow-lg"> -->
<div
v-for="action in quickActions"
v-for="action in actions"
:key="action.id"
class="flex flex-col items-center gap-2 cursor-pointer transition-transform active:scale-95"
@click="handleQuickAction(action)"
@@ -156,7 +78,6 @@ onUnmounted(() => {
</div>
<span class="text-xs text-[#333] font-medium text-center">{{ action.name }}</span>
</div>
<!-- </div> -->
</section>
<!-- 新闻列表区域 -->
@@ -168,41 +89,42 @@ onUnmounted(() => {
新闻动态
</div>
</div>
<ion-button fill="clear" size="small" class="text-sm text-white h-8">
<ion-button fill="clear" size="small" class="text-sm h-8" @click="router.push('/layout/service')">
更多
<ion-icon slot="end" :icon="chevronForwardOutline" />
</ion-button>
</div>
<div class="flex flex-col gap-4">
<empty v-if="data.length === 0" class="my-10" />
<div v-else class="flex flex-col gap-4">
<div
v-for="news in newsList"
:key="news.id"
v-for="item in data"
:key="item.id"
class="bg-white rounded-2xl overflow-hidden shadow-sm cursor-pointer transition-all active:translate-y-0.5 active:shadow-sm flex"
@click="handleNewsClick(news)"
@click="handleNewsClick(item)"
>
<div class="relative w-28 h-28 flex-shrink-0 overflow-hidden">
<img :src="news.image" :alt="news.title" class="w-full h-full object-cover">
<div class="news-badge absolute top-2 left-2 bg-linear-to-br from-[#78d0ff] to-[#1879aa] text-white px-2 py-0.5 rounded-lg text-xs font-semibold shadow-lg">
<div class="relative w-28 h-28 shrink-0 overflow-hidden">
<img v-if="item.thumbnailId" :src="item.thumbnail" :alt="item.title" class="w-full h-full object-cover">
<div class="news-badge absolute top-2 left-2 bg-linear-to-br from-[#c41e3a] to-[#8b1a2e] text-white px-2 py-0.5 rounded-lg text-xs font-semibold shadow-lg">
热点
</div>
</div>
<div class="flex-1 p-4 flex flex-col justify-between">
<div>
<div class="text-base font-bold text-[#1a1a1a] mb-1 leading-snug line-clamp-2">
{{ news.title }}
{{ item.title }}
</div>
<p class="text-sm text-[#666] leading-relaxed line-clamp-2">
{{ news.subtitle }}
{{ item.summary }}
</p>
</div>
<div class="flex items-center gap-4 text-xs text-[#999] mt-2">
<span class="flex items-center gap-1">
<ion-icon :icon="timeOutline" class="text-sm" />
{{ news.time }}
{{ item.createdAt }}
</span>
<span class="flex items-center gap-1">
<ion-icon :icon="eyeOutline" class="text-sm" />
{{ news.views }}
{{ item.viewCount }}
</span>
</div>
</div>
@@ -210,23 +132,15 @@ onUnmounted(() => {
</div>
</section>
</div>
<ion-infinite-scroll threshold="100px" disabled @ion-infinite="handleInfinite">
<ion-infinite-scroll-content loading-spinner="bubbles" loading-text="加载更多..." />
</ion-infinite-scroll>
</ion-content>
</ion-page>
</template>
<style lang='css' scoped>
.ion-toolbar {
/* --background: #c32120; */
/* --color: #fff; */
}
.home-pg {
background: linear-gradient(180deg, #c32120 0%, #c32120 55%, #fef5f1 100%);
}
/* .home-page {
--background: linear-gradient(180deg, #c41e3a 0%, #e8756d 15%, #f5d5c8 35%, #fef5f1 50%, #fef5f1 65%, #f5d5c8 85%);
} */
/* 中国风图案 */
.chinese-pattern {
position: absolute;

View File

@@ -1,48 +1,52 @@
<script lang='ts' setup>
import {
chevronForwardOutline,
eyeOutline,
newspaperOutline,
timeOutline,
} from "ionicons/icons";
import type { Treaty } from "@elysiajs/eden";
import type { InfiniteScrollCustomEvent } from "@ionic/vue";
import type { TreatyQuery } from "@/api/types";
import { eyeOutline, timeOutline } from "ionicons/icons";
import { client, safeClient } from "@/api";
const newsList = ref([
{
id: 1,
title: "深化改革进入新阶段",
subtitle: "全面推进现代化建设,开创新局面",
time: "2026-01-16 10:30",
views: 1520,
image: "https://picsum.photos/seed/news1/400/250",
},
{
id: 2,
title: "团队协作再创佳绩",
subtitle: "凝心聚力,共筑梦想,携手共进新时代",
time: "2026-01-15 16:20",
views: 2340,
image: "https://picsum.photos/seed/news2/400/250",
},
{
id: 3,
title: "战略布局取得重大突破",
subtitle: "科学谋划,精准施策,推动高质量发展",
time: "2026-01-14 09:15",
views: 1890,
image: "https://picsum.photos/seed/news3/400/250",
},
]);
type NewsItem = Treaty.Data<typeof client.api.news.get>["data"][number];
type NewsQuery = TreatyQuery<typeof client.api.news.get>;
const [query] = useResetRef<NewsQuery>({
offset: 0,
limit: 10,
});
const data = ref<NewsItem[]>([]);
const isFinished = ref(false);
async function fetchNews() {
const { data: responseData } = await safeClient(client.api.news.get({ query: { ...query.value } }));
data.value.push(...(responseData.value?.data || []));
isFinished.value = responseData.value?.pagination.hasNextPage === false;
}
async function handleInfinite(event: InfiniteScrollCustomEvent) {
if (isFinished.value) {
event.target.complete();
event.target.disabled = true;
return;
}
query.value.offset! += query.value.limit!;
await fetchNews();
setTimeout(() => {
event.target.complete();
}, 500);
}
function handleNewsClick(news: any) {
console.log("查看新闻:", news.title);
// TODO: 跳转到新闻详情
}
onMounted(() => {
fetchNews();
});
</script>
<template>
<ion-page>
<ion-content>
<img src="@/assets/images/service-banner1.jpg" class="h-50 w-full object-cover" alt="服务页横幅">
<img src="@/assets/images/service-banner.jpg" class="h-50 w-full object-cover" alt="服务页横幅">
<!-- 新闻列表区域 -->
<section class="mb-5 -mt-5 ion-padding-horizontal">
@@ -54,40 +58,45 @@ function handleNewsClick(news: any) {
</div>
</div>
</div>
<div class="flex flex-col gap-4">
<empty v-if="data.length === 0" class="my-10" />
<div v-else class="flex flex-col gap-4">
<div
v-for="news in newsList"
:key="news.id"
v-for="item in data"
:key="item.id"
class="bg-white rounded-2xl overflow-hidden shadow-sm cursor-pointer transition-all active:translate-y-0.5 active:shadow-sm"
@click="handleNewsClick(news)"
@click="handleNewsClick(item)"
>
<div class="relative w-full h-45 overflow-hidden">
<img :src="news.image" :alt="news.title" class="w-full h-full object-cover">
<img v-if="item.thumbnailId" :src="item.thumbnail" :alt="item.title" class="w-full h-full object-cover">
<div class="news-badge absolute top-3 left-3 bg-linear-to-br from-[#78d0ff] to-[#1879aa] text-white px-3 py-1 rounded-xl text-xs font-semibold shadow-lg">
热点
</div>
</div>
<div class="p-4">
<h4 class="text-base font-bold text-[#1a1a1a] mb-2 leading-snug">
{{ news.title }}
{{ item.title }}
</h4>
<p class="text-sm text-[#666] mb-3 leading-relaxed line-clamp-2">
{{ news.subtitle }}
{{ item.summary }}
</p>
<div class="flex items-center gap-4 text-xs text-[#999]">
<span class="flex items-center gap-1">
<ion-icon :icon="timeOutline" class="text-sm" />
{{ news.time }}
{{ item.createdAt }}
</span>
<span class="flex items-center gap-1">
<ion-icon :icon="eyeOutline" class="text-sm" />
{{ news.views }}
{{ item.viewCount }}
</span>
</div>
</div>
</div>
</div>
</section>
<ion-infinite-scroll threshold="100px" disabled @ion-infinite="handleInfinite">
<ion-infinite-scroll-content loading-spinner="bubbles" loading-text="加载更多..." />
</ion-infinite-scroll>
</ion-content>
</ion-page>
</template>

View File

@@ -1,11 +1,16 @@
<script lang='ts' setup>
import {
calendarOutline,
checkmarkCircleOutline,
flameOutline,
ribbonOutline,
trophyOutline,
} from "ionicons/icons";
import { toastController } from "@ionic/vue";
import dayjs from "dayjs";
import { checkmarkCircleOutline } from "ionicons/icons";
import { client, safeClient } from "@/api";
const [start, end] = [dayjs().startOf("week"), dayjs().endOf("week")];
const { data } = await safeClient(client.api.checkIns.get({
query: {
startDate: start.toISOString(),
endDate: end.toISOString(),
},
}));
// 签到信息
const signupInfo = ref({
@@ -25,9 +30,13 @@ const recentSignup = ref([
{ day: "周日", date: "01-19", signed: false },
]);
function handleSignup() {
console.log("立即签到");
// TODO: 实现签到功能
async function handleSignup() {
await safeClient(client.api.checkIns.post());
toastController.create({
message: "签到成功!",
duration: 2000,
color: "success",
}).then(toast => toast.present());
}
</script>