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