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 acceptHMRUpdate: typeof import('pinia').acceptHMRUpdate
const asyncComputed: typeof import('@vueuse/core').asyncComputed const asyncComputed: typeof import('@vueuse/core').asyncComputed
const autoResetRef: typeof import('@vueuse/core').autoResetRef const autoResetRef: typeof import('@vueuse/core').autoResetRef
const clearExpiredCaches: typeof import('./src/composables/useStorageCache').clearExpiredCaches
const computed: typeof import('vue').computed const computed: typeof import('vue').computed
const computedAsync: typeof import('@vueuse/core').computedAsync const computedAsync: typeof import('@vueuse/core').computedAsync
const computedEager: typeof import('@vueuse/core').computedEager const computedEager: typeof import('@vueuse/core').computedEager
@@ -39,6 +40,7 @@ declare global {
const effectScope: typeof import('vue').effectScope const effectScope: typeof import('vue').effectScope
const extendRef: typeof import('@vueuse/core').extendRef const extendRef: typeof import('@vueuse/core').extendRef
const getActivePinia: typeof import('pinia').getActivePinia const getActivePinia: typeof import('pinia').getActivePinia
const getCacheRemainingTime: typeof import('./src/composables/useStorageCache').getCacheRemainingTime
const getCurrentInstance: typeof import('vue').getCurrentInstance const getCurrentInstance: typeof import('vue').getCurrentInstance
const getCurrentScope: typeof import('vue').getCurrentScope const getCurrentScope: typeof import('vue').getCurrentScope
const getCurrentWatcher: typeof import('vue').getCurrentWatcher const getCurrentWatcher: typeof import('vue').getCurrentWatcher
@@ -46,6 +48,7 @@ declare global {
const ignorableWatch: typeof import('@vueuse/core').ignorableWatch const ignorableWatch: typeof import('@vueuse/core').ignorableWatch
const inject: typeof import('vue').inject const inject: typeof import('vue').inject
const injectLocal: typeof import('@vueuse/core').injectLocal const injectLocal: typeof import('@vueuse/core').injectLocal
const isCacheExpired: typeof import('./src/composables/useStorageCache').isCacheExpired
const isDefined: typeof import('@vueuse/core').isDefined const isDefined: typeof import('@vueuse/core').isDefined
const isProxy: typeof import('vue').isProxy const isProxy: typeof import('vue').isProxy
const isReactive: typeof import('vue').isReactive const isReactive: typeof import('vue').isReactive
@@ -98,6 +101,7 @@ declare global {
const refManualReset: typeof import('@vueuse/core').refManualReset const refManualReset: typeof import('@vueuse/core').refManualReset
const refThrottled: typeof import('@vueuse/core').refThrottled const refThrottled: typeof import('@vueuse/core').refThrottled
const refWithControl: typeof import('@vueuse/core').refWithControl const refWithControl: typeof import('@vueuse/core').refWithControl
const refreshCacheExpire: typeof import('./src/composables/useStorageCache').refreshCacheExpire
const resolveComponent: typeof import('vue').resolveComponent const resolveComponent: typeof import('vue').resolveComponent
const resolveRef: typeof import('@vueuse/core').resolveRef const resolveRef: typeof import('@vueuse/core').resolveRef
const setActivePinia: typeof import('pinia').setActivePinia const setActivePinia: typeof import('pinia').setActivePinia
@@ -202,6 +206,7 @@ declare global {
const useInterval: typeof import('@vueuse/core').useInterval const useInterval: typeof import('@vueuse/core').useInterval
const useIntervalFn: typeof import('@vueuse/core').useIntervalFn const useIntervalFn: typeof import('@vueuse/core').useIntervalFn
const useKeyModifier: typeof import('@vueuse/core').useKeyModifier const useKeyModifier: typeof import('@vueuse/core').useKeyModifier
const useLanguage: typeof import('./src/composables/useLanguage').useLanguage
const useLastChanged: typeof import('@vueuse/core').useLastChanged const useLastChanged: typeof import('@vueuse/core').useLastChanged
const useLink: typeof import('vue-router').useLink const useLink: typeof import('vue-router').useLink
const useLocalStorage: typeof import('@vueuse/core').useLocalStorage const useLocalStorage: typeof import('@vueuse/core').useLocalStorage
@@ -241,6 +246,7 @@ declare global {
const usePrevious: typeof import('@vueuse/core').usePrevious const usePrevious: typeof import('@vueuse/core').usePrevious
const useRafFn: typeof import('@vueuse/core').useRafFn const useRafFn: typeof import('@vueuse/core').useRafFn
const useRefHistory: typeof import('@vueuse/core').useRefHistory const useRefHistory: typeof import('@vueuse/core').useRefHistory
const useResetRef: typeof import('./src/composables/useResetRef').useResetRef
const useResizeObserver: typeof import('@vueuse/core').useResizeObserver const useResizeObserver: typeof import('@vueuse/core').useResizeObserver
const useRoute: typeof import('vue-router').useRoute const useRoute: typeof import('vue-router').useRoute
const useRouter: typeof import('vue-router').useRouter const useRouter: typeof import('vue-router').useRouter
@@ -259,6 +265,7 @@ declare global {
const useStepper: typeof import('@vueuse/core').useStepper const useStepper: typeof import('@vueuse/core').useStepper
const useStorage: typeof import('@vueuse/core').useStorage const useStorage: typeof import('@vueuse/core').useStorage
const useStorageAsync: typeof import('@vueuse/core').useStorageAsync const useStorageAsync: typeof import('@vueuse/core').useStorageAsync
const useStorageCache: typeof import('./src/composables/useStorageCache').useStorageCache
const useStyleTag: typeof import('@vueuse/core').useStyleTag const useStyleTag: typeof import('@vueuse/core').useStyleTag
const useSupported: typeof import('@vueuse/core').useSupported const useSupported: typeof import('@vueuse/core').useSupported
const useSwipe: typeof import('@vueuse/core').useSwipe const useSwipe: typeof import('@vueuse/core').useSwipe
@@ -318,6 +325,9 @@ declare global {
// @ts-ignore // @ts-ignore
export type { Component, Slot, Slots, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, ShallowRef, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue' export type { Component, Slot, Slots, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, ShallowRef, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue'
import('vue') import('vue')
// @ts-ignore
export type { Language } from './src/composables/useLanguage'
import('./src/composables/useLanguage')
} }
// for vue template auto import // for vue template auto import
@@ -329,6 +339,7 @@ declare module 'vue' {
readonly acceptHMRUpdate: UnwrapRef<typeof import('pinia')['acceptHMRUpdate']> readonly acceptHMRUpdate: UnwrapRef<typeof import('pinia')['acceptHMRUpdate']>
readonly asyncComputed: UnwrapRef<typeof import('@vueuse/core')['asyncComputed']> readonly asyncComputed: UnwrapRef<typeof import('@vueuse/core')['asyncComputed']>
readonly autoResetRef: UnwrapRef<typeof import('@vueuse/core')['autoResetRef']> 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 computed: UnwrapRef<typeof import('vue')['computed']>
readonly computedAsync: UnwrapRef<typeof import('@vueuse/core')['computedAsync']> readonly computedAsync: UnwrapRef<typeof import('@vueuse/core')['computedAsync']>
readonly computedEager: UnwrapRef<typeof import('@vueuse/core')['computedEager']> readonly computedEager: UnwrapRef<typeof import('@vueuse/core')['computedEager']>
@@ -357,6 +368,7 @@ declare module 'vue' {
readonly effectScope: UnwrapRef<typeof import('vue')['effectScope']> readonly effectScope: UnwrapRef<typeof import('vue')['effectScope']>
readonly extendRef: UnwrapRef<typeof import('@vueuse/core')['extendRef']> readonly extendRef: UnwrapRef<typeof import('@vueuse/core')['extendRef']>
readonly getActivePinia: UnwrapRef<typeof import('pinia')['getActivePinia']> readonly getActivePinia: UnwrapRef<typeof import('pinia')['getActivePinia']>
readonly getCacheRemainingTime: UnwrapRef<typeof import('./src/composables/useStorageCache')['getCacheRemainingTime']>
readonly getCurrentInstance: UnwrapRef<typeof import('vue')['getCurrentInstance']> readonly getCurrentInstance: UnwrapRef<typeof import('vue')['getCurrentInstance']>
readonly getCurrentScope: UnwrapRef<typeof import('vue')['getCurrentScope']> readonly getCurrentScope: UnwrapRef<typeof import('vue')['getCurrentScope']>
readonly getCurrentWatcher: UnwrapRef<typeof import('vue')['getCurrentWatcher']> readonly getCurrentWatcher: UnwrapRef<typeof import('vue')['getCurrentWatcher']>
@@ -364,6 +376,7 @@ declare module 'vue' {
readonly ignorableWatch: UnwrapRef<typeof import('@vueuse/core')['ignorableWatch']> readonly ignorableWatch: UnwrapRef<typeof import('@vueuse/core')['ignorableWatch']>
readonly inject: UnwrapRef<typeof import('vue')['inject']> readonly inject: UnwrapRef<typeof import('vue')['inject']>
readonly injectLocal: UnwrapRef<typeof import('@vueuse/core')['injectLocal']> 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 isDefined: UnwrapRef<typeof import('@vueuse/core')['isDefined']>
readonly isProxy: UnwrapRef<typeof import('vue')['isProxy']> readonly isProxy: UnwrapRef<typeof import('vue')['isProxy']>
readonly isReactive: UnwrapRef<typeof import('vue')['isReactive']> readonly isReactive: UnwrapRef<typeof import('vue')['isReactive']>
@@ -416,6 +429,7 @@ declare module 'vue' {
readonly refManualReset: UnwrapRef<typeof import('@vueuse/core')['refManualReset']> readonly refManualReset: UnwrapRef<typeof import('@vueuse/core')['refManualReset']>
readonly refThrottled: UnwrapRef<typeof import('@vueuse/core')['refThrottled']> readonly refThrottled: UnwrapRef<typeof import('@vueuse/core')['refThrottled']>
readonly refWithControl: UnwrapRef<typeof import('@vueuse/core')['refWithControl']> 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 resolveComponent: UnwrapRef<typeof import('vue')['resolveComponent']>
readonly resolveRef: UnwrapRef<typeof import('@vueuse/core')['resolveRef']> readonly resolveRef: UnwrapRef<typeof import('@vueuse/core')['resolveRef']>
readonly setActivePinia: UnwrapRef<typeof import('pinia')['setActivePinia']> readonly setActivePinia: UnwrapRef<typeof import('pinia')['setActivePinia']>
@@ -520,6 +534,7 @@ declare module 'vue' {
readonly useInterval: UnwrapRef<typeof import('@vueuse/core')['useInterval']> readonly useInterval: UnwrapRef<typeof import('@vueuse/core')['useInterval']>
readonly useIntervalFn: UnwrapRef<typeof import('@vueuse/core')['useIntervalFn']> readonly useIntervalFn: UnwrapRef<typeof import('@vueuse/core')['useIntervalFn']>
readonly useKeyModifier: UnwrapRef<typeof import('@vueuse/core')['useKeyModifier']> 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 useLastChanged: UnwrapRef<typeof import('@vueuse/core')['useLastChanged']>
readonly useLink: UnwrapRef<typeof import('vue-router')['useLink']> readonly useLink: UnwrapRef<typeof import('vue-router')['useLink']>
readonly useLocalStorage: UnwrapRef<typeof import('@vueuse/core')['useLocalStorage']> readonly useLocalStorage: UnwrapRef<typeof import('@vueuse/core')['useLocalStorage']>
@@ -559,6 +574,7 @@ declare module 'vue' {
readonly usePrevious: UnwrapRef<typeof import('@vueuse/core')['usePrevious']> readonly usePrevious: UnwrapRef<typeof import('@vueuse/core')['usePrevious']>
readonly useRafFn: UnwrapRef<typeof import('@vueuse/core')['useRafFn']> readonly useRafFn: UnwrapRef<typeof import('@vueuse/core')['useRafFn']>
readonly useRefHistory: UnwrapRef<typeof import('@vueuse/core')['useRefHistory']> 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 useResizeObserver: UnwrapRef<typeof import('@vueuse/core')['useResizeObserver']>
readonly useRoute: UnwrapRef<typeof import('vue-router')['useRoute']> readonly useRoute: UnwrapRef<typeof import('vue-router')['useRoute']>
readonly useRouter: UnwrapRef<typeof import('vue-router')['useRouter']> readonly useRouter: UnwrapRef<typeof import('vue-router')['useRouter']>
@@ -577,6 +593,7 @@ declare module 'vue' {
readonly useStepper: UnwrapRef<typeof import('@vueuse/core')['useStepper']> readonly useStepper: UnwrapRef<typeof import('@vueuse/core')['useStepper']>
readonly useStorage: UnwrapRef<typeof import('@vueuse/core')['useStorage']> readonly useStorage: UnwrapRef<typeof import('@vueuse/core')['useStorage']>
readonly useStorageAsync: UnwrapRef<typeof import('@vueuse/core')['useStorageAsync']> 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 useStyleTag: UnwrapRef<typeof import('@vueuse/core')['useStyleTag']>
readonly useSupported: UnwrapRef<typeof import('@vueuse/core')['useSupported']> readonly useSupported: UnwrapRef<typeof import('@vueuse/core')['useSupported']>
readonly useSwipe: UnwrapRef<typeof import('@vueuse/core')['useSwipe']> readonly useSwipe: UnwrapRef<typeof import('@vueuse/core')['useSwipe']>

6
components.d.ts vendored
View File

@@ -13,6 +13,7 @@ export {}
declare module 'vue' { declare module 'vue' {
export interface GlobalComponents { export interface GlobalComponents {
BackButton: typeof import('./src/components/back-button.vue')['default'] BackButton: typeof import('./src/components/back-button.vue')['default']
Empty: typeof import('./src/components/empty.vue')['default']
IonApp: typeof import('@ionic/vue')['IonApp'] IonApp: typeof import('@ionic/vue')['IonApp']
IonAvatar: typeof import('@ionic/vue')['IonAvatar'] IonAvatar: typeof import('@ionic/vue')['IonAvatar']
IonButton: typeof import('@ionic/vue')['IonButton'] IonButton: typeof import('@ionic/vue')['IonButton']
@@ -20,6 +21,8 @@ declare module 'vue' {
IonContent: typeof import('@ionic/vue')['IonContent'] IonContent: typeof import('@ionic/vue')['IonContent']
IonHeader: typeof import('@ionic/vue')['IonHeader'] IonHeader: typeof import('@ionic/vue')['IonHeader']
IonIcon: typeof import('@ionic/vue')['IonIcon'] IonIcon: typeof import('@ionic/vue')['IonIcon']
IonInfiniteScroll: typeof import('@ionic/vue')['IonInfiniteScroll']
IonInfiniteScrollContent: typeof import('@ionic/vue')['IonInfiniteScrollContent']
IonInput: typeof import('@ionic/vue')['IonInput'] IonInput: typeof import('@ionic/vue')['IonInput']
IonItem: typeof import('@ionic/vue')['IonItem'] IonItem: typeof import('@ionic/vue')['IonItem']
IonLabel: typeof import('@ionic/vue')['IonLabel'] IonLabel: typeof import('@ionic/vue')['IonLabel']
@@ -40,6 +43,7 @@ declare module 'vue' {
// For TSX support // For TSX support
declare global { declare global {
const BackButton: typeof import('./src/components/back-button.vue')['default'] 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 IonApp: typeof import('@ionic/vue')['IonApp']
const IonAvatar: typeof import('@ionic/vue')['IonAvatar'] const IonAvatar: typeof import('@ionic/vue')['IonAvatar']
const IonButton: typeof import('@ionic/vue')['IonButton'] const IonButton: typeof import('@ionic/vue')['IonButton']
@@ -47,6 +51,8 @@ declare global {
const IonContent: typeof import('@ionic/vue')['IonContent'] const IonContent: typeof import('@ionic/vue')['IonContent']
const IonHeader: typeof import('@ionic/vue')['IonHeader'] const IonHeader: typeof import('@ionic/vue')['IonHeader']
const IonIcon: typeof import('@ionic/vue')['IonIcon'] 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 IonInput: typeof import('@ionic/vue')['IonInput']
const IonItem: typeof import('@ionic/vue')['IonItem'] const IonItem: typeof import('@ionic/vue')['IonItem']
const IonLabel: typeof import('@ionic/vue')['IonLabel'] const IonLabel: typeof import('@ionic/vue')['IonLabel']

14
pnpm-lock.yaml generated
View File

@@ -52,8 +52,8 @@ catalogs:
specifier: 8.0.0 specifier: 8.0.0
version: 8.0.0 version: 8.0.0
'@capp/eden': '@capp/eden':
specifier: http://192.168.1.2:9538/api/capp-eden-0.0.2.tgz specifier: http://192.168.1.2:9538/api/capp-eden-0.0.4.tgz
version: 0.0.2 version: 0.0.4
'@cloudflare/workers-types': '@cloudflare/workers-types':
specifier: ^4.20260113.0 specifier: ^4.20260113.0
version: 4.20260116.0 version: 4.20260116.0
@@ -298,7 +298,7 @@ importers:
version: 8.0.0(@capacitor/core@8.0.0) version: 8.0.0(@capacitor/core@8.0.0)
'@capp/eden': '@capp/eden':
specifier: 'catalog:' 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': '@elysiajs/eden':
specifier: 'catalog:' 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)) 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': '@capacitor/synapse@1.0.4':
resolution: {integrity: sha512-/C1FUo8/OkKuAT4nCIu/34ny9siNHr9qtFezu4kxm6GY1wNFxrCFWjfYx5C1tUhVGz3fxBABegupkpjXvjCHrw==} resolution: {integrity: sha512-/C1FUo8/OkKuAT4nCIu/34ny9siNHr9qtFezu4kxm6GY1wNFxrCFWjfYx5C1tUhVGz3fxBABegupkpjXvjCHrw==}
'@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':
resolution: {tarball: 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.4.tgz}
version: 0.0.2 version: 0.0.4
peerDependencies: peerDependencies:
'@elysiajs/eden': ^1.4.6 '@elysiajs/eden': ^1.4.6
@@ -6903,7 +6903,7 @@ snapshots:
'@capacitor/synapse@1.0.4': {} '@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: 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)) '@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/keyboard': 8.0.0
'@capacitor/share': ^8.0.0 '@capacitor/share': ^8.0.0
'@capacitor/status-bar': 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 '@cloudflare/workers-types': ^4.20260113.0
'@elysiajs/eden': ^1.4.6 '@elysiajs/eden': ^1.4.6
'@faker-js/faker': ^10.2.0 '@faker-js/faker': ^10.2.0

View File

@@ -3,6 +3,11 @@ import { App as CapacitorApp } from "@capacitor/app";
const userStore = useUserStore(); const userStore = useUserStore();
const { isAuthenticated } = storeToRefs(userStore); const { isAuthenticated } = storeToRefs(userStore);
const { loadSavedLanguage } = useLanguage();
onBeforeMount(() => {
loadSavedLanguage();
});
onMounted(() => { onMounted(() => {
if (!isAuthenticated.value) 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: #c31d39;
--ion-color-primary-rgb: 195,29,57; --ion-color-primary-rgb: 195,29,57;
--ion-color-primary-contrast: #ffffff; --ion-color-primary-contrast: #ffffff;
@@ -62,8 +62,8 @@
--ion-color-dark-shade: #292929; --ion-color-dark-shade: #292929;
--ion-color-dark-tint: #444444; --ion-color-dark-tint: #444444;
} */ }
/*
:root { :root {
--ion-color-primary: #2065c3; --ion-color-primary: #2065c3;
--ion-color-primary-rgb: 32,101,195; --ion-color-primary-rgb: 32,101,195;
@@ -128,7 +128,7 @@
--ion-color-dark-shade: #292929; --ion-color-dark-shade: #292929;
--ion-color-dark-tint: #444444; --ion-color-dark-tint: #444444;
} } */
.ion-toolbar { .ion-toolbar {
--background: var(--ion-color-primary-contrast); --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> <script lang='ts' setup>
import { import type { Treaty } from "@elysiajs/eden";
calendarOutline, import type { InfiniteScrollCustomEvent } from "@ionic/vue";
chatbubblesOutline, import type { Action } from "./";
chevronForwardOutline, import type { TreatyQuery } from "@/api/types";
eyeOutline, import { chevronForwardOutline, eyeOutline, timeOutline } from "ionicons/icons";
megaphoneOutline, import { client, safeClient } from "@/api";
newspaperOutline, import { actions } from "./";
peopleOutline,
rocketOutline,
timeOutline,
} from "ionicons/icons";
import { onMounted, onUnmounted, ref } from "vue";
// 公告数据 type NewsItem = Treaty.Data<typeof client.api.news.get>["data"][number];
const announcements = ref([ type NewsQuery = TreatyQuery<typeof client.api.news.get>;
{ 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",
},
]);
const router = useRouter(); const router = useRouter();
// 快捷入口 const [query] = useResetRef<NewsQuery>({
const quickActions = ref([ offset: 0,
{ id: 1, name: "签到", icon: calendarOutline, color: "#2373c3" }, limit: 10,
{ id: 2, name: "团队中心", icon: peopleOutline, color: "#2373c3" }, });
{ id: 3, name: "邀请好友", icon: rocketOutline, color: "#2373c3" }, const data = ref<NewsItem[]>([]);
{ id: 4, name: "在线客服", icon: chatbubblesOutline, color: "#2373c3" }, 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) { switch (action.id) {
case 1: case "signup":
router.push("/signup"); router.push("/signup");
break; break;
case 2: case "team":
console.log("团队中心"); console.log("团队中心");
break; break;
case 3: case "invite":
router.push("/invite"); router.push("/invite");
break; break;
case "support":
window.open("https://chat.riwsan.com", "_blank");
break;
} }
} }
function handleAnnouncementClick(announcement: any) { function handleNewsClick(news: NewsItem) {
console.log("查看公告:", announcement.title);
// TODO: 跳转到公告详情
}
function handleNewsClick(news: any) {
console.log("查看新闻:", news.title); 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> </script>
<template> <template>
<ion-page> <ion-page>
<ion-content :fullscreen="true" class="home-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="首页横幅"> <img src="@/assets/images/home-banner.jpg" class="h-60 w-full object-cover" alt="首页横幅">
<div class="ion-padding-horizontal"> <div class="ion-padding-horizontal">
<!-- 快捷入口区域 -->
<section class="my-5 grid grid-cols-4 gap-4"> <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 <div
v-for="action in quickActions" v-for="action in actions"
:key="action.id" :key="action.id"
class="flex flex-col items-center gap-2 cursor-pointer transition-transform active:scale-95" class="flex flex-col items-center gap-2 cursor-pointer transition-transform active:scale-95"
@click="handleQuickAction(action)" @click="handleQuickAction(action)"
@@ -156,7 +78,6 @@ onUnmounted(() => {
</div> </div>
<span class="text-xs text-[#333] font-medium text-center">{{ action.name }}</span> <span class="text-xs text-[#333] font-medium text-center">{{ action.name }}</span>
</div> </div>
<!-- </div> -->
</section> </section>
<!-- 新闻列表区域 --> <!-- 新闻列表区域 -->
@@ -168,41 +89,42 @@ onUnmounted(() => {
新闻动态 新闻动态
</div> </div>
</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-icon slot="end" :icon="chevronForwardOutline" />
</ion-button> </ion-button>
</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 <div
v-for="news in newsList" v-for="item in data"
:key="news.id" :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" 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"> <div class="relative w-28 h-28 shrink-0 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-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="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> </div>
<div class="flex-1 p-4 flex flex-col justify-between"> <div class="flex-1 p-4 flex flex-col justify-between">
<div> <div>
<div class="text-base font-bold text-[#1a1a1a] mb-1 leading-snug line-clamp-2"> <div class="text-base font-bold text-[#1a1a1a] mb-1 leading-snug line-clamp-2">
{{ news.title }} {{ item.title }}
</div> </div>
<p class="text-sm text-[#666] leading-relaxed line-clamp-2"> <p class="text-sm text-[#666] leading-relaxed line-clamp-2">
{{ news.subtitle }} {{ item.summary }}
</p> </p>
</div> </div>
<div class="flex items-center gap-4 text-xs text-[#999] mt-2"> <div class="flex items-center gap-4 text-xs text-[#999] mt-2">
<span class="flex items-center gap-1"> <span class="flex items-center gap-1">
<ion-icon :icon="timeOutline" class="text-sm" /> <ion-icon :icon="timeOutline" class="text-sm" />
{{ news.time }} {{ item.createdAt }}
</span> </span>
<span class="flex items-center gap-1"> <span class="flex items-center gap-1">
<ion-icon :icon="eyeOutline" class="text-sm" /> <ion-icon :icon="eyeOutline" class="text-sm" />
{{ news.views }} {{ item.viewCount }}
</span> </span>
</div> </div>
</div> </div>
@@ -210,23 +132,15 @@ onUnmounted(() => {
</div> </div>
</section> </section>
</div> </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-content>
</ion-page> </ion-page>
</template> </template>
<style lang='css' scoped> <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 { .chinese-pattern {
position: absolute; position: absolute;

View File

@@ -1,48 +1,52 @@
<script lang='ts' setup> <script lang='ts' setup>
import { import type { Treaty } from "@elysiajs/eden";
chevronForwardOutline, import type { InfiniteScrollCustomEvent } from "@ionic/vue";
eyeOutline, import type { TreatyQuery } from "@/api/types";
newspaperOutline, import { eyeOutline, timeOutline } from "ionicons/icons";
timeOutline, import { client, safeClient } from "@/api";
} from "ionicons/icons";
const newsList = ref([ type NewsItem = Treaty.Data<typeof client.api.news.get>["data"][number];
{ type NewsQuery = TreatyQuery<typeof client.api.news.get>;
id: 1,
title: "深化改革进入新阶段", const [query] = useResetRef<NewsQuery>({
subtitle: "全面推进现代化建设,开创新局面", offset: 0,
time: "2026-01-16 10:30", limit: 10,
views: 1520, });
image: "https://picsum.photos/seed/news1/400/250", const data = ref<NewsItem[]>([]);
}, const isFinished = ref(false);
{
id: 2, async function fetchNews() {
title: "团队协作再创佳绩", const { data: responseData } = await safeClient(client.api.news.get({ query: { ...query.value } }));
subtitle: "凝心聚力,共筑梦想,携手共进新时代", data.value.push(...(responseData.value?.data || []));
time: "2026-01-15 16:20", isFinished.value = responseData.value?.pagination.hasNextPage === false;
views: 2340, }
image: "https://picsum.photos/seed/news2/400/250", async function handleInfinite(event: InfiniteScrollCustomEvent) {
}, if (isFinished.value) {
{ event.target.complete();
id: 3, event.target.disabled = true;
title: "战略布局取得重大突破", return;
subtitle: "科学谋划,精准施策,推动高质量发展", }
time: "2026-01-14 09:15", query.value.offset! += query.value.limit!;
views: 1890, await fetchNews();
image: "https://picsum.photos/seed/news3/400/250", setTimeout(() => {
}, event.target.complete();
]); }, 500);
}
function handleNewsClick(news: any) { function handleNewsClick(news: any) {
console.log("查看新闻:", news.title); console.log("查看新闻:", news.title);
// TODO: 跳转到新闻详情 // TODO: 跳转到新闻详情
} }
onMounted(() => {
fetchNews();
});
</script> </script>
<template> <template>
<ion-page> <ion-page>
<ion-content> <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"> <section class="mb-5 -mt-5 ion-padding-horizontal">
@@ -54,40 +58,45 @@ function handleNewsClick(news: any) {
</div> </div>
</div> </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 <div
v-for="news in newsList" v-for="item in data"
:key="news.id" :key="item.id"
class="bg-white rounded-2xl overflow-hidden shadow-sm cursor-pointer transition-all active:translate-y-0.5 active:shadow-sm" 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"> <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 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> </div>
<div class="p-4"> <div class="p-4">
<h4 class="text-base font-bold text-[#1a1a1a] mb-2 leading-snug"> <h4 class="text-base font-bold text-[#1a1a1a] mb-2 leading-snug">
{{ news.title }} {{ item.title }}
</h4> </h4>
<p class="text-sm text-[#666] mb-3 leading-relaxed line-clamp-2"> <p class="text-sm text-[#666] mb-3 leading-relaxed line-clamp-2">
{{ news.subtitle }} {{ item.summary }}
</p> </p>
<div class="flex items-center gap-4 text-xs text-[#999]"> <div class="flex items-center gap-4 text-xs text-[#999]">
<span class="flex items-center gap-1"> <span class="flex items-center gap-1">
<ion-icon :icon="timeOutline" class="text-sm" /> <ion-icon :icon="timeOutline" class="text-sm" />
{{ news.time }} {{ item.createdAt }}
</span> </span>
<span class="flex items-center gap-1"> <span class="flex items-center gap-1">
<ion-icon :icon="eyeOutline" class="text-sm" /> <ion-icon :icon="eyeOutline" class="text-sm" />
{{ news.views }} {{ item.viewCount }}
</span> </span>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</section> </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-content>
</ion-page> </ion-page>
</template> </template>

View File

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