feat: 添加新闻模块,更新路由和组件,优化新闻列表和详情页
This commit is contained in:
1
auto-imports.d.ts
vendored
1
auto-imports.d.ts
vendored
@@ -451,7 +451,6 @@ declare module 'vue' {
|
|||||||
readonly shallowReactive: UnwrapRef<typeof import('vue')['shallowReactive']>
|
readonly shallowReactive: UnwrapRef<typeof import('vue')['shallowReactive']>
|
||||||
readonly shallowReadonly: UnwrapRef<typeof import('vue')['shallowReadonly']>
|
readonly shallowReadonly: UnwrapRef<typeof import('vue')['shallowReadonly']>
|
||||||
readonly shallowRef: UnwrapRef<typeof import('vue')['shallowRef']>
|
readonly shallowRef: UnwrapRef<typeof import('vue')['shallowRef']>
|
||||||
readonly showSuccessToast: UnwrapRef<typeof import('vant/es')['showSuccessToast']>
|
|
||||||
readonly showToast: UnwrapRef<typeof import('vant/es')['showToast']>
|
readonly showToast: UnwrapRef<typeof import('vant/es')['showToast']>
|
||||||
readonly storeToRefs: UnwrapRef<typeof import('pinia')['storeToRefs']>
|
readonly storeToRefs: UnwrapRef<typeof import('pinia')['storeToRefs']>
|
||||||
readonly syncRef: UnwrapRef<typeof import('@vueuse/core')['syncRef']>
|
readonly syncRef: UnwrapRef<typeof import('@vueuse/core')['syncRef']>
|
||||||
|
|||||||
4
components.d.ts
vendored
4
components.d.ts
vendored
@@ -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']
|
||||||
|
Divider: typeof import('./src/components/divider.vue')['default']
|
||||||
Empty: typeof import('./src/components/empty.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']
|
||||||
@@ -39,6 +40,7 @@ declare module 'vue' {
|
|||||||
IonTabBar: typeof import('@ionic/vue')['IonTabBar']
|
IonTabBar: typeof import('@ionic/vue')['IonTabBar']
|
||||||
IonTabButton: typeof import('@ionic/vue')['IonTabButton']
|
IonTabButton: typeof import('@ionic/vue')['IonTabButton']
|
||||||
IonTabs: typeof import('@ionic/vue')['IonTabs']
|
IonTabs: typeof import('@ionic/vue')['IonTabs']
|
||||||
|
IonText: typeof import('@ionic/vue')['IonText']
|
||||||
IonTextarea: typeof import('@ionic/vue')['IonTextarea']
|
IonTextarea: typeof import('@ionic/vue')['IonTextarea']
|
||||||
IonTitle: typeof import('@ionic/vue')['IonTitle']
|
IonTitle: typeof import('@ionic/vue')['IonTitle']
|
||||||
IonToggle: typeof import('@ionic/vue')['IonToggle']
|
IonToggle: typeof import('@ionic/vue')['IonToggle']
|
||||||
@@ -52,6 +54,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 Divider: typeof import('./src/components/divider.vue')['default']
|
||||||
const Empty: typeof import('./src/components/empty.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']
|
||||||
@@ -78,6 +81,7 @@ declare global {
|
|||||||
const IonTabBar: typeof import('@ionic/vue')['IonTabBar']
|
const IonTabBar: typeof import('@ionic/vue')['IonTabBar']
|
||||||
const IonTabButton: typeof import('@ionic/vue')['IonTabButton']
|
const IonTabButton: typeof import('@ionic/vue')['IonTabButton']
|
||||||
const IonTabs: typeof import('@ionic/vue')['IonTabs']
|
const IonTabs: typeof import('@ionic/vue')['IonTabs']
|
||||||
|
const IonText: typeof import('@ionic/vue')['IonText']
|
||||||
const IonTextarea: typeof import('@ionic/vue')['IonTextarea']
|
const IonTextarea: typeof import('@ionic/vue')['IonTextarea']
|
||||||
const IonTitle: typeof import('@ionic/vue')['IonTitle']
|
const IonTitle: typeof import('@ionic/vue')['IonTitle']
|
||||||
const IonToggle: typeof import('@ionic/vue')['IonToggle']
|
const IonToggle: typeof import('@ionic/vue')['IonToggle']
|
||||||
|
|||||||
31
src/components/divider.vue
Normal file
31
src/components/divider.vue
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<script lang='ts' setup>
|
||||||
|
defineProps<{ text?: string }>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="divider ion-margin-vertical" v-bind="$attrs">
|
||||||
|
<span v-if="text">{{ text }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.divider {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
text-align: center;
|
||||||
|
margin: 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.divider::before,
|
||||||
|
.divider::after {
|
||||||
|
content: "";
|
||||||
|
flex: 1;
|
||||||
|
border-bottom: 1px solid var(--ion-text-color-step-900);
|
||||||
|
}
|
||||||
|
|
||||||
|
.divider span {
|
||||||
|
padding: 0 10px;
|
||||||
|
color: var(--ion-color-medium);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -16,7 +16,7 @@ const { t } = useI18n();
|
|||||||
</div>
|
</div>
|
||||||
</ion-tab-button>
|
</ion-tab-button>
|
||||||
|
|
||||||
<ion-tab-button tab="service" href="/layout/service">
|
<ion-tab-button tab="news" href="/layout/news">
|
||||||
<div class="flex-col-center gap-1">
|
<div class="flex-col-center gap-1">
|
||||||
<ion-icon aria-hidden="true" :icon="radio" class="icon" />
|
<ion-icon aria-hidden="true" :icon="radio" class="icon" />
|
||||||
<ion-label>思想引领</ion-label>
|
<ion-label>思想引领</ion-label>
|
||||||
|
|||||||
@@ -23,8 +23,8 @@ const routes: Array<RouteRecordRaw> = [
|
|||||||
meta: { requiresAuth: true },
|
meta: { requiresAuth: true },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "service",
|
path: "news",
|
||||||
component: () => import("@/views/service/index.vue"),
|
component: () => import("@/views/news/index.vue"),
|
||||||
meta: { requiresAuth: true },
|
meta: { requiresAuth: true },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -39,6 +39,12 @@ const routes: Array<RouteRecordRaw> = [
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "/news/:id",
|
||||||
|
props: true,
|
||||||
|
component: () => import("@/views/news/detail.vue"),
|
||||||
|
meta: { requiresAuth: true },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: "/check_in",
|
path: "/check_in",
|
||||||
component: () => import("@/views/check_in/index.vue"),
|
component: () => import("@/views/check_in/index.vue"),
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ function handleQuickAction(action: Action) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleNewsClick(news: NewsItem) {
|
function handleNewsClick(news: NewsItem) {
|
||||||
console.log("查看新闻:", news.title);
|
router.push(`/news/${news.id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
@@ -206,7 +206,7 @@ onMounted(() => {
|
|||||||
class="bg-white rounded-2xl overflow-hidden card cursor-pointer transition-all active:translate-y-0.5 active:shadow-sm flex"
|
class="bg-white rounded-2xl overflow-hidden card cursor-pointer transition-all active:translate-y-0.5 active:shadow-sm flex"
|
||||||
@click="handleNewsClick(item)"
|
@click="handleNewsClick(item)"
|
||||||
>
|
>
|
||||||
<div class="relative w-28 h-28 shrink-0 overflow-hidden">
|
<div class="relative w-28 h-auto shrink-0 overflow-hidden">
|
||||||
<img v-if="item.thumbnailId" :src="item.thumbnailId" :alt="item.title" class="w-full h-full object-cover">
|
<img v-if="item.thumbnailId" :src="item.thumbnailId" :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 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">
|
||||||
热点
|
热点
|
||||||
@@ -214,10 +214,10 @@ onMounted(() => {
|
|||||||
</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-1 wrap-break-word">
|
||||||
{{ item.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 wrap-break-word">
|
||||||
{{ item.summary }}
|
{{ item.summary }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
187
src/views/news/detail.vue
Normal file
187
src/views/news/detail.vue
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
<script lang='ts' setup>
|
||||||
|
import { Icon } from "@iconify/vue";
|
||||||
|
import { downloadOutline, eyeOutline, timeOutline } from "ionicons/icons";
|
||||||
|
import markdownit from "markdown-it";
|
||||||
|
import { client, safeClient } from "@/api";
|
||||||
|
|
||||||
|
const props = defineProps<{ id: string }>();
|
||||||
|
const md = markdownit();
|
||||||
|
|
||||||
|
const { data, error } = safeClient(client.api.news({ id: props.id }).get());
|
||||||
|
|
||||||
|
function formatViewCount(count: number): string {
|
||||||
|
if (count >= 10000) {
|
||||||
|
return `${(count / 10000).toFixed(1)}w`;
|
||||||
|
}
|
||||||
|
if (count >= 1000) {
|
||||||
|
return `${(count / 1000).toFixed(1)}k`;
|
||||||
|
}
|
||||||
|
return String(count);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDownloadAttachment(url: string, filename: string) {
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = url;
|
||||||
|
link.download = filename;
|
||||||
|
link.target = "_blank";
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ion-page>
|
||||||
|
<ion-header class="ion-no-border">
|
||||||
|
<ion-toolbar class="ion-toolbar">
|
||||||
|
<ion-buttons slot="start">
|
||||||
|
<back-button />
|
||||||
|
</ion-buttons>
|
||||||
|
<ion-title>详情</ion-title>
|
||||||
|
</ion-toolbar>
|
||||||
|
</ion-header>
|
||||||
|
<ion-content :fullscreen="true" class="ion-padding">
|
||||||
|
<!-- 加载状态 -->
|
||||||
|
<div v-if="!data && !error" class="flex items-center justify-center h-full">
|
||||||
|
<ion-spinner name="crescent" color="primary" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 错误状态 -->
|
||||||
|
<div v-else-if="error" class="flex flex-col items-center justify-center h-full gap-4">
|
||||||
|
<Icon icon="mdi:alert-circle-outline" class="text-5xl text-(--ion-color-danger)" />
|
||||||
|
<ion-text color="medium">
|
||||||
|
加载失败,请稍后重试。
|
||||||
|
</ion-text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 新闻内容 -->
|
||||||
|
<div v-else-if="data" class="max-w-800px mx-auto pb-8">
|
||||||
|
<!-- 标题 -->
|
||||||
|
<div class="text-2xl sm:text-xl font-bold leading-tight text-(--ion-text-color) mb-4 wrap-break-word">
|
||||||
|
{{ data.title }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 元信息 -->
|
||||||
|
<div class="flex items-center gap-4 sm:gap-3 flex-wrap mb-4">
|
||||||
|
<div class="flex items-center gap-1.5 text-xs text-(--ion-color-medium)">
|
||||||
|
<ion-icon :icon="timeOutline" class="text-base" />
|
||||||
|
<span>{{ useDateFormat(data.publishedAt || data.createdAt, 'YYYY-MM-DD HH:mm') }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-1.5 text-xs text-(--ion-color-medium)">
|
||||||
|
<ion-icon :icon="eyeOutline" class="text-base" />
|
||||||
|
<span>{{ formatViewCount(data.viewCount || 0) }} 次浏览</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<divider class="my-4" />
|
||||||
|
|
||||||
|
<!-- 摘要 -->
|
||||||
|
<div v-if="data.summary" class="border-2 border-dashed border-text-900 rounded-xl p-4 ">
|
||||||
|
<div class="text-sm font-semibold text-(--ion-color-primary) mb-2">
|
||||||
|
摘要
|
||||||
|
</div>
|
||||||
|
<p class="text-base leading-relaxed text-(--ion-color-step-600) m-0">
|
||||||
|
{{ data.summary }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 缩略图 -->
|
||||||
|
<div v-if="data.thumbnailId" class="my-6 rounded-xl overflow-hidden shadow-sm dark:shadow-md">
|
||||||
|
<img :src="data.thumbnailId" :alt="data.title" class="w-full h-auto block">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 正文内容 -->
|
||||||
|
<div class="news-content text-base leading-relaxed text-(--ion-text-color) my-6">
|
||||||
|
<div v-html="md.render(data.content)" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 附件列表 -->
|
||||||
|
<div v-if="data.attachments && data.attachments.length > 0" class="mt-8 pt-6 border-t border-(--ion-color-light)">
|
||||||
|
<div class="flex items-center gap-2 text-base font-semibold text-(--ion-text-color) mb-4">
|
||||||
|
<Icon icon="mdi:paperclip" class="text-lg" />
|
||||||
|
<span>附件 ({{ data.attachments.length }})</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-3">
|
||||||
|
<div
|
||||||
|
v-for="(attachment, index) in data.attachments"
|
||||||
|
:key="index"
|
||||||
|
class="flex items-center justify-between p-3 px-4 bg-(--ion-color-light) dark:bg-(--ion-color-step-100) rounded-lg cursor-pointer transition-all hover:bg-(--ion-color-light-shade) dark:hover:bg-(--ion-color-step-150) hover:translate-x-1 active:scale-98"
|
||||||
|
@click="handleDownloadAttachment(attachment.url, attachment.name)"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-3 flex-1 min-w-0">
|
||||||
|
<Icon icon="mdi:file-document-outline" class="text-2xl text-(--ion-color-primary) shrink-0" />
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<div class="text-sm font-medium text-(--ion-text-color) truncate">
|
||||||
|
{{ attachment.name }}
|
||||||
|
</div>
|
||||||
|
<div v-if="attachment.size" class="text-xs text-(--ion-color-medium) mt-0.5">
|
||||||
|
{{ (attachment.size / 1024).toFixed(2) }} KB
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ion-button fill="clear" size="small" @click.stop="handleDownloadAttachment(attachment.url, attachment.name)">
|
||||||
|
<ion-icon slot="icon-only" :icon="downloadOutline" />
|
||||||
|
</ion-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ion-content>
|
||||||
|
</ion-page>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang='css' scoped>
|
||||||
|
@reference "tailwindcss";
|
||||||
|
|
||||||
|
/* 正文内容富文本样式 */
|
||||||
|
.news-content :deep(p) {
|
||||||
|
@apply my-4 whitespace-pre-wrap wrap-break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.news-content :deep(img) {
|
||||||
|
@apply max-w-full h-auto rounded-lg my-4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.news-content :deep(h1),
|
||||||
|
.news-content :deep(h2),
|
||||||
|
.news-content :deep(h3) {
|
||||||
|
@apply font-semibold my-6 mb-4 leading-tight;
|
||||||
|
}
|
||||||
|
|
||||||
|
.news-content :deep(h1) {
|
||||||
|
@apply text-xl;
|
||||||
|
}
|
||||||
|
|
||||||
|
.news-content :deep(h2) {
|
||||||
|
@apply text-lg;
|
||||||
|
}
|
||||||
|
|
||||||
|
.news-content :deep(h3) {
|
||||||
|
@apply text-base;
|
||||||
|
}
|
||||||
|
|
||||||
|
.news-content :deep(ul),
|
||||||
|
.news-content :deep(ol) {
|
||||||
|
@apply pl-6 my-4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.news-content :deep(li) {
|
||||||
|
@apply my-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.news-content :deep(blockquote) {
|
||||||
|
@apply border-l-3 border-(--ion-color-medium) pl-4 my-4 text-(--ion-color-medium) italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.news-content :deep(code) {
|
||||||
|
@apply bg-(--ion-color-light) px-1.5 py-0.5 rounded text-sm font-mono;
|
||||||
|
}
|
||||||
|
|
||||||
|
.news-content :deep(pre) {
|
||||||
|
@apply bg-(--ion-color-light) p-4 rounded-lg overflow-x-auto my-4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.news-content :deep(pre code) {
|
||||||
|
@apply bg-transparent p-0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -14,6 +14,7 @@ const [query] = useResetRef<NewsQuery>({
|
|||||||
});
|
});
|
||||||
const data = ref<NewsItem[]>([]);
|
const data = ref<NewsItem[]>([]);
|
||||||
const isFinished = ref(false);
|
const isFinished = ref(false);
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
async function fetchNews() {
|
async function fetchNews() {
|
||||||
const { data: responseData } = await safeClient(client.api.news.get({ query: { ...query.value } }));
|
const { data: responseData } = await safeClient(client.api.news.get({ query: { ...query.value } }));
|
||||||
@@ -34,8 +35,7 @@ async function handleInfinite(event: InfiniteScrollCustomEvent) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleNewsClick(news: any) {
|
function handleNewsClick(news: any) {
|
||||||
console.log("查看新闻:", news.title);
|
router.push(`/news/${news.id}`);
|
||||||
// TODO: 跳转到新闻详情
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
Reference in New Issue
Block a user