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:
17
auto-imports.d.ts
vendored
17
auto-imports.d.ts
vendored
@@ -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
6
components.d.ts
vendored
@@ -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
14
pnpm-lock.yaml
generated
@@ -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))
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
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 |
|
Before Width: | Height: | Size: 173 KiB After Width: | Height: | Size: 173 KiB |
25
src/components/empty.vue
Normal file
25
src/components/empty.vue
Normal 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>
|
||||
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;
|
||||
}
|
||||
@@ -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
10
src/views/home/index.ts
Normal 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];
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user