|
@@ -1,9 +1,32 @@
|
|
|
<template>
|
|
<template>
|
|
|
<CustomNav title="图文资讯" leftType="back" />
|
|
<CustomNav title="图文资讯" leftType="back" />
|
|
|
<view class="page-container">
|
|
<view class="page-container">
|
|
|
- <view class="news-detail-card">
|
|
|
|
|
|
|
+ <view v-if="isLoading" class="skeleton">
|
|
|
|
|
+ <view class="skeleton-header">
|
|
|
|
|
+ <view class="skeleton-title"></view>
|
|
|
|
|
+ <view class="skeleton-meta">
|
|
|
|
|
+ <view class="skeleton-meta-item"></view>
|
|
|
|
|
+ <view class="skeleton-meta-item"></view>
|
|
|
|
|
+ </view>
|
|
|
|
|
+ </view>
|
|
|
|
|
+ <view class="skeleton-content">
|
|
|
|
|
+ <view class="skeleton-line"></view>
|
|
|
|
|
+ <view class="skeleton-line short"></view>
|
|
|
|
|
+ <view class="skeleton-line"></view>
|
|
|
|
|
+ <view class="skeleton-line short"></view>
|
|
|
|
|
+ <view class="skeleton-line"></view>
|
|
|
|
|
+ <view class="skeleton-line short"></view>
|
|
|
|
|
+ <view class="skeleton-line"></view>
|
|
|
|
|
+ <view class="skeleton-line short"></view>
|
|
|
|
|
+ </view>
|
|
|
|
|
+ </view>
|
|
|
|
|
+ <view v-else class="news-detail-card">
|
|
|
<view class="news-header">
|
|
<view class="news-header">
|
|
|
<text class="news-title">{{ newsTitle }}</text>
|
|
<text class="news-title">{{ newsTitle }}</text>
|
|
|
|
|
+ <view class="news-meta">
|
|
|
|
|
+ <text class="news-author">作者:{{ news.authorName }}</text>
|
|
|
|
|
+ <text class="news-time">日期:{{ news.publishTime ? news.publishTime.split(' ')[0] : '' }}</text>
|
|
|
|
|
+ </view>
|
|
|
</view>
|
|
</view>
|
|
|
<view class="news-content">
|
|
<view class="news-content">
|
|
|
<view class="news-desc">
|
|
<view class="news-desc">
|
|
@@ -18,7 +41,7 @@
|
|
|
</view>
|
|
</view>
|
|
|
</view>
|
|
</view>
|
|
|
<text v-if="item.type === 'blockquote'">{{ item.content }}</text>
|
|
<text v-if="item.type === 'blockquote'">{{ item.content }}</text>
|
|
|
- <image v-if="item.type === 'img'" :src="item.src" :alt="item.alt" />
|
|
|
|
|
|
|
+ <image v-if="item.type === 'img'" class="img" mode="widthFix" :src="getImageSrc(item.src)" :alt="item.alt" @load="onImageLoad" @error="onImageError(item.src, $event)" />
|
|
|
</view>
|
|
</view>
|
|
|
</view>
|
|
</view>
|
|
|
</view>
|
|
</view>
|
|
@@ -27,8 +50,10 @@
|
|
|
</template>
|
|
</template>
|
|
|
|
|
|
|
|
<script setup lang="ts">
|
|
<script setup lang="ts">
|
|
|
-import { ref, computed } from 'vue'
|
|
|
|
|
|
|
+import { ref, computed, onMounted, watch, onUnmounted } from 'vue'
|
|
|
|
|
+import { downloadWithAuth } from '@/utils/downloadWithAuth'
|
|
|
import CustomNav from '@/components/custom-nav.vue'
|
|
import CustomNav from '@/components/custom-nav.vue'
|
|
|
|
|
+import { getNewsById } from '@/api/news'
|
|
|
|
|
|
|
|
// 处理内联markdown
|
|
// 处理内联markdown
|
|
|
const processInline = (text: string) => {
|
|
const processInline = (text: string) => {
|
|
@@ -36,18 +61,145 @@ const processInline = (text: string) => {
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
// 简单的markdown解析函数,返回组件数组
|
|
// 简单的markdown解析函数,返回组件数组
|
|
|
|
|
+// 对于相对路径形式的新闻图片(如 news-images/...),以 host + /news/image/{relative} 的方式转换为可访问 URL
|
|
|
|
|
+const HOST = 'https://wx.baiyun.work'
|
|
|
|
|
+const resolveImageUrl = (src?: string) => {
|
|
|
|
|
+ if (!src) return ''
|
|
|
|
|
+ if (/^https?:\/\//i.test(src)) return src
|
|
|
|
|
+ if (/^\/?news-images\//i.test(src)) {
|
|
|
|
|
+ return `${HOST}/news/image/${src.replace(/^\/*/, '')}`
|
|
|
|
|
+ }
|
|
|
|
|
+ return src
|
|
|
|
|
+}
|
|
|
const markdownToComponents = (markdown: string) => {
|
|
const markdownToComponents = (markdown: string) => {
|
|
|
|
|
+
|
|
|
|
|
+ // 将所有 markdown 或 HTML 中的 `news-images/...` 相对路径替换成绝对可访问的图片URL,
|
|
|
|
|
+ // 避免在页面中使用相对路径导致平台错误地解析为当前页面或其他路径。
|
|
|
|
|
+ if (markdown && typeof markdown === 'string') {
|
|
|
|
|
+ // 1) Markdown 图片语法: 
|
|
|
|
|
+ markdown = markdown.replace(/!\[([^\]]*)\]\((\/?news-images\/[^)\s\)]+)\)/ig,
|
|
|
|
|
+ (_m, alt, src) => {
|
|
|
|
|
+ const fixed = `${HOST}/news/image/${src.replace(/^\/*/, '')}`
|
|
|
|
|
+
|
|
|
|
|
+ return ``
|
|
|
|
|
+ })
|
|
|
|
|
+ // 2) HTML <img src="news-images/...">
|
|
|
|
|
+ markdown = markdown.replace(/<img([^>]*?)src=(['"]?)(\/?news-images\/[^'" >]+)\2([^>]*)>/ig,
|
|
|
|
|
+ (_m, before, q, src, after) => {
|
|
|
|
|
+ const fixed = `${HOST}/news/image/${src.replace(/^\/*/, '')}`
|
|
|
|
|
+ return `<img${before}src="${fixed}"${after}>`
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
const blocks = markdown.split(/\n\n+/);
|
|
const blocks = markdown.split(/\n\n+/);
|
|
|
const components = [];
|
|
const components = [];
|
|
|
|
|
+ const parseLine = (line: string) => {
|
|
|
|
|
+ const trimmedLine = line.trim()
|
|
|
|
|
+ if (!trimmedLine) return
|
|
|
|
|
+ if (trimmedLine.startsWith('- ')) {
|
|
|
|
|
+ const lines = trimmedLine.split('\n')
|
|
|
|
|
+ const items = lines.map(line => processInline(line.startsWith('- ') ? line.substring(2) : line))
|
|
|
|
|
+ components.push({ type: 'ul', content: items })
|
|
|
|
|
+ } else if (/^\d+\./.test(trimmedLine)) {
|
|
|
|
|
+ // numbered list
|
|
|
|
|
+ const lines = trimmedLine.split('\n')
|
|
|
|
|
+ const items = lines.map(line => line.replace(/^\d+\./, '').trim()).filter(Boolean)
|
|
|
|
|
+ components.push({ type: 'ul', content: items })
|
|
|
|
|
+ } else if (trimmedLine.startsWith('> ')) {
|
|
|
|
|
+ components.push({ type: 'blockquote', content: processInline(trimmedLine.substring(2)) })
|
|
|
|
|
+ } else if (trimmedLine.match(/!\[.*\]\(.*\)/)) {
|
|
|
|
|
+ const imgMatch = trimmedLine.match(/!\[([^\]]*)\]\(([^)]+)\)/)
|
|
|
|
|
+
|
|
|
|
|
+ if (imgMatch) {
|
|
|
|
|
+ let src = imgMatch[2]
|
|
|
|
|
+ if (/^\/?news-images\//i.test(src) && !/^https?:\/\//i.test(src)) {
|
|
|
|
|
+ src = `${HOST}/news/image/${src.replace(/^\//, '')}`
|
|
|
|
|
+ }
|
|
|
|
|
+ components.push({ type: 'img', src, alt: imgMatch[1] })
|
|
|
|
|
+
|
|
|
|
|
+ }
|
|
|
|
|
+ } else if (trimmedLine.match(/<img[^>]*src=['\"][^'\"]+['\"][^>]*>/i)) {
|
|
|
|
|
+ const htmlImgMatch = trimmedLine.match(/<img[^>]*src=['\"]([^'\"]+)['\"][^>]*>/i)
|
|
|
|
|
+ if (htmlImgMatch) {
|
|
|
|
|
+ let src = htmlImgMatch[1]
|
|
|
|
|
+ if (/^\/?news-images\//i.test(src) && !/^https?:\/\//i.test(src)) {
|
|
|
|
|
+ src = `${HOST}/news/image/${src.replace(/^\//, '')}`
|
|
|
|
|
+ }
|
|
|
|
|
+ components.push({ type: 'img', src, alt: '' })
|
|
|
|
|
+
|
|
|
|
|
+ }
|
|
|
|
|
+ } else {
|
|
|
|
|
+ // handle inline images inside paragraph
|
|
|
|
|
+ const inlineImg = trimmedLine.match(/!\[([^\]]*)\]\(([^)]+)\)/)
|
|
|
|
|
+ if (inlineImg) {
|
|
|
|
|
+
|
|
|
|
|
+ let src = inlineImg[2]
|
|
|
|
|
+ if (/^\/?news-images\//i.test(src) && !/^https?:\/\//i.test(src)) {
|
|
|
|
|
+ src = `${HOST}/news/image/${src.replace(/^\//, '')}`
|
|
|
|
|
+ }
|
|
|
|
|
+ components.push({ type: 'img', src, alt: inlineImg[1] })
|
|
|
|
|
+ const paragraphContent = trimmedLine.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '').trim()
|
|
|
|
|
+ if (paragraphContent) components.push({ type: 'p', content: processInline(paragraphContent) })
|
|
|
|
|
+ } else {
|
|
|
|
|
+ components.push({ type: 'p', content: processInline(trimmedLine) })
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const parseLines = (lines: string[]) => {
|
|
|
|
|
+ let i = 0
|
|
|
|
|
+ while (i < lines.length) {
|
|
|
|
|
+ const ln = (lines[i] || '').trim()
|
|
|
|
|
+ if (!ln) { i++; continue }
|
|
|
|
|
+ // grouped unordered list
|
|
|
|
|
+ if (ln.startsWith('- ')) {
|
|
|
|
|
+ const items: string[] = []
|
|
|
|
|
+ while (i < lines.length && (lines[i] || '').trim().startsWith('- ')) {
|
|
|
|
|
+ const itemLine = (lines[i] || '').trim()
|
|
|
|
|
+ items.push(processInline(itemLine.substring(2).trim()))
|
|
|
|
|
+ i++
|
|
|
|
|
+ }
|
|
|
|
|
+ components.push({ type: 'ul', content: items })
|
|
|
|
|
+ } else if (/^\d+\./.test(ln)) {
|
|
|
|
|
+ // grouped numbered list
|
|
|
|
|
+ const items: string[] = []
|
|
|
|
|
+ while (i < lines.length && /^\d+\./.test((lines[i] || '').trim())) {
|
|
|
|
|
+ const itemLine = (lines[i] || '').trim()
|
|
|
|
|
+ items.push(processInline(itemLine.replace(/^\d+\./, '').trim()))
|
|
|
|
|
+ i++
|
|
|
|
|
+ }
|
|
|
|
|
+ components.push({ type: 'ul', content: items })
|
|
|
|
|
+ } else {
|
|
|
|
|
+ parseLine(ln)
|
|
|
|
|
+ i++
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
for (const block of blocks) {
|
|
for (const block of blocks) {
|
|
|
const trimmed = block.trim();
|
|
const trimmed = block.trim();
|
|
|
|
|
+
|
|
|
if (!trimmed) continue;
|
|
if (!trimmed) continue;
|
|
|
if (trimmed.startsWith('# ')) {
|
|
if (trimmed.startsWith('# ')) {
|
|
|
- components.push({ type: 'h1', content: processInline(trimmed.substring(2)) });
|
|
|
|
|
|
|
+ const lines = trimmed.split(/\n+/)
|
|
|
|
|
+ const headerLine = lines.shift() || ''
|
|
|
|
|
+ components.push({ type: 'h1', content: processInline(headerLine.replace(/^#\s+/, '').trim()) });
|
|
|
|
|
+ if (lines.length) {
|
|
|
|
|
+ parseLines(lines)
|
|
|
|
|
+ }
|
|
|
} else if (trimmed.startsWith('## ')) {
|
|
} else if (trimmed.startsWith('## ')) {
|
|
|
- components.push({ type: 'h2', content: processInline(trimmed.substring(3)) });
|
|
|
|
|
|
|
+ const lines = trimmed.split(/\n+/)
|
|
|
|
|
+ const headerLine = lines.shift() || ''
|
|
|
|
|
+ components.push({ type: 'h2', content: processInline(headerLine.replace(/^##\s+/, '').trim()) });
|
|
|
|
|
+ if (lines.length) {
|
|
|
|
|
+ parseLines(lines)
|
|
|
|
|
+ }
|
|
|
} else if (trimmed.startsWith('### ')) {
|
|
} else if (trimmed.startsWith('### ')) {
|
|
|
- components.push({ type: 'h3', content: processInline(trimmed.substring(4)) });
|
|
|
|
|
|
|
+ const lines = trimmed.split(/\n+/)
|
|
|
|
|
+ const headerLine = lines.shift() || ''
|
|
|
|
|
+ components.push({ type: 'h3', content: processInline(headerLine.replace(/^###\s+/, '').trim()) });
|
|
|
|
|
+ if (lines.length) {
|
|
|
|
|
+ parseLines(lines)
|
|
|
|
|
+ }
|
|
|
} else if (trimmed.startsWith('- ')) {
|
|
} else if (trimmed.startsWith('- ')) {
|
|
|
const lines = trimmed.split('\n');
|
|
const lines = trimmed.split('\n');
|
|
|
const items = lines.map(line => processInline(line.startsWith('- ') ? line.substring(2) : line));
|
|
const items = lines.map(line => processInline(line.startsWith('- ') ? line.substring(2) : line));
|
|
@@ -56,66 +208,184 @@ const markdownToComponents = (markdown: string) => {
|
|
|
components.push({ type: 'blockquote', content: processInline(trimmed.substring(2)) });
|
|
components.push({ type: 'blockquote', content: processInline(trimmed.substring(2)) });
|
|
|
} else if (trimmed.match(/!\[.*\]\(.*\)/)) {
|
|
} else if (trimmed.match(/!\[.*\]\(.*\)/)) {
|
|
|
const imgMatch = trimmed.match(/!\[([^\]]*)\]\(([^)]+)\)/);
|
|
const imgMatch = trimmed.match(/!\[([^\]]*)\]\(([^)]+)\)/);
|
|
|
|
|
+
|
|
|
if (imgMatch) {
|
|
if (imgMatch) {
|
|
|
- components.push({ type: 'img', src: imgMatch[2], alt: imgMatch[1] });
|
|
|
|
|
|
|
+ let src = imgMatch[2];
|
|
|
|
|
+ // 如果是相对 news-images 路径,前缀 HOST + /news/image/
|
|
|
|
|
+ if (/^\/?news-images\//i.test(src) && !/^https?:\/\//i.test(src)) {
|
|
|
|
|
+ // 避免路径以 / 开头导致双斜杠
|
|
|
|
|
+ src = `${HOST}/news/image/${src.replace(/^\//, '')}`
|
|
|
|
|
+ }
|
|
|
|
|
+ components.push({ type: 'img', src, alt: imgMatch[1] });
|
|
|
|
|
+
|
|
|
|
|
+ }
|
|
|
|
|
+ } else if (trimmed.match(/<img[^>]*src=['\"][^'\"]+['\"][^>]*>/i)) {
|
|
|
|
|
+ // 支持 HTML <img src="..."> 标签
|
|
|
|
|
+ const htmlImgMatch = trimmed.match(/<img[^>]*src=['\"]([^'\"]+)['\"][^>]*>/i)
|
|
|
|
|
+ if (htmlImgMatch) {
|
|
|
|
|
+
|
|
|
|
|
+ let src = htmlImgMatch[1]
|
|
|
|
|
+ if (/^\/?news-images\//i.test(src) && !/^https?:\/\//i.test(src)) {
|
|
|
|
|
+ src = `${HOST}/news/image/${src.replace(/^\//, '')}`
|
|
|
|
|
+ }
|
|
|
|
|
+ // alt 可能存在,但此处不严格解析 alt
|
|
|
|
|
+ components.push({ type: 'img', src, alt: '' })
|
|
|
|
|
+
|
|
|
}
|
|
}
|
|
|
} else {
|
|
} else {
|
|
|
- components.push({ type: 'p', content: processInline(trimmed) });
|
|
|
|
|
|
|
+ // detect inline images inside this paragraph
|
|
|
|
|
+ const inlineImg = trimmed.match(/!\[([^\]]*)\]\(([^)]+)\)/)
|
|
|
|
|
+ if (inlineImg) {
|
|
|
|
|
+
|
|
|
|
|
+ const src = inlineImg[2]
|
|
|
|
|
+ components.push({ type: 'img', src, alt: inlineImg[1] })
|
|
|
|
|
+ // remove inline image from paragraph content
|
|
|
|
|
+ const paragraphContent = trimmed.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '').trim()
|
|
|
|
|
+ if (paragraphContent) components.push({ type: 'p', content: processInline(paragraphContent) })
|
|
|
|
|
+ } else {
|
|
|
|
|
+ components.push({ type: 'p', content: processInline(trimmed) });
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
- return components;
|
|
|
|
|
|
|
+ // 清理段落中残余的 markdown image 语法(防止某些特殊格式未被 parseLine 清理)
|
|
|
|
|
+ for (const comp of components) {
|
|
|
|
|
+ if (comp.type === 'p' && typeof comp.content === 'string') {
|
|
|
|
|
+ comp.content = comp.content.replace(/!\[[^\]]*\]\([^\)]+\)/g, '').trim()
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ // 过滤空段落
|
|
|
|
|
+ const filtered = components.filter(c => !(c.type === 'p' && (typeof c.content !== 'string' || c.content.trim() === '')))
|
|
|
|
|
+
|
|
|
|
|
+ return filtered;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
interface NewsItem {
|
|
interface NewsItem {
|
|
|
- desc: string
|
|
|
|
|
|
|
+ title?: string
|
|
|
|
|
+ content?: string
|
|
|
|
|
+ authorName?: string
|
|
|
|
|
+ publishTime?: string
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-const news = ref<NewsItem>({
|
|
|
|
|
- desc: `# 健康饮食指南
|
|
|
|
|
-
|
|
|
|
|
-## 均衡饮食的重要性
|
|
|
|
|
|
|
+const news = ref<NewsItem>({ title: '', content: '' })
|
|
|
|
|
+const isLoading = ref(true)
|
|
|
|
|
|
|
|
-了解均衡饮食的重要性,掌握健康饮食的基本原则。
|
|
|
|
|
|
|
+// 使用后端 title,如果没有则回退为默认文字
|
|
|
|
|
+const newsTitle = computed(() => news.value.title || '图文资讯')
|
|
|
|
|
|
|
|
-
|
|
|
|
|
-
|
|
|
|
|
-### 什么是均衡饮食?
|
|
|
|
|
-
|
|
|
|
|
-均衡饮食是指摄入各种营养素的比例适当,既能满足机体需要,又不会造成营养过剩或缺乏。
|
|
|
|
|
|
|
+const parsedMarkdown = computed(() => {
|
|
|
|
|
+ let desc = news.value.content || ''
|
|
|
|
|
+ const match = desc.match(/^# (.+)$/m)
|
|
|
|
|
+ // 如果内容里第一行是 # 标题 且和后端 title 相同,则删掉,以避免重复
|
|
|
|
|
+ if (match && news.value.title && match[1] === news.value.title) {
|
|
|
|
|
+ desc = desc.replace(/^# .+\n/, '').trimStart()
|
|
|
|
|
+ }
|
|
|
|
|
+ const comps = markdownToComponents(desc)
|
|
|
|
|
+
|
|
|
|
|
+ return comps
|
|
|
|
|
+})
|
|
|
|
|
|
|
|
-## 合理的饮食结构
|
|
|
|
|
|
|
+const defaultImage = '/static/icons/remixicon/image-line.svg'
|
|
|
|
|
+
|
|
|
|
|
+// 下载带鉴权的图片到本地临时路径(用于小程序等平台,避免 image 标签无法附带 Authorization header 导致 401)
|
|
|
|
|
+const downloadedImages = ref<Record<string, string>>({})
|
|
|
|
|
+const isProtectedUrl = (url?: string) => {
|
|
|
|
|
+ if (!url) return false
|
|
|
|
|
+ try {
|
|
|
|
|
+ const u = url
|
|
|
|
|
+ return u.startsWith(`${HOST}/news/image/`) || /\/news\/image\//.test(u)
|
|
|
|
|
+ } catch (e) {
|
|
|
|
|
+ return false
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
|
|
|
-合理的饮食结构应包括以下五大类食物:
|
|
|
|
|
|
|
+const downloadImageWithAuth = async (url: string) => {
|
|
|
|
|
+ const tmp = await downloadWithAuth(url)
|
|
|
|
|
+
|
|
|
|
|
+ return tmp
|
|
|
|
|
+}
|
|
|
|
|
|
|
|
-- **谷物**:碳水化合物的主要来源,提供能量
|
|
|
|
|
-- **蔬菜和水果**:富含维生素、矿物质和纤维素,有助于预防慢性疾病
|
|
|
|
|
-- **肉类**:提供优质蛋白质和铁等营养素
|
|
|
|
|
-- **奶制品**:钙的重要来源
|
|
|
|
|
-- **油脂类**:提供必需脂肪酸
|
|
|
|
|
|
|
+// 监听 parsedMarkdown,自动下载受保护图片的临时文件并缓存
|
|
|
|
|
+watch(parsedMarkdown, (comps) => {
|
|
|
|
|
+ if (!comps || !Array.isArray(comps)) return
|
|
|
|
|
+ for (const c of comps) {
|
|
|
|
|
+ if (c.type === 'img' && c.src && isProtectedUrl(c.src)) {
|
|
|
|
|
+ // 如果尚未下载或者之前下载失败,尝试下载
|
|
|
|
|
+ if (!downloadedImages.value[c.src]) {
|
|
|
|
|
+ downloadImageWithAuth(c.src).then(tmp => {
|
|
|
|
|
+ if (tmp) {
|
|
|
|
|
+ downloadedImages.value = { ...downloadedImages.value, [c.src]: tmp }
|
|
|
|
|
+ }
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+}, { immediate: true })
|
|
|
|
|
|
|
|
-### 饮食建议
|
|
|
|
|
|
|
+onUnmounted(() => {
|
|
|
|
|
+ // 清理下载缓存,释放临时文件引用
|
|
|
|
|
+ downloadedImages.value = {}
|
|
|
|
|
+})
|
|
|
|
|
|
|
|
-1. 每天摄入足够的蔬菜和水果
|
|
|
|
|
-2. 控制糖和盐的摄入量
|
|
|
|
|
-3. 选择全谷物食品
|
|
|
|
|
-4. 适量饮水,保持身体水分平衡
|
|
|
|
|
|
|
+// 统一计算 image src(如果为受保护的 URL,优先使用已下载的本地路径)
|
|
|
|
|
+const getImageSrc = (src?: string) => {
|
|
|
|
|
+ if (!src) return ''
|
|
|
|
|
+ if (isProtectedUrl(src)) {
|
|
|
|
|
+ // 已下载则返回临时本地路径,否则返回空字符串(避免直接用远程 URL 引发 401)
|
|
|
|
|
+ return downloadedImages.value[src] || defaultImage
|
|
|
|
|
+ }
|
|
|
|
|
+ return resolveImageUrl(src)
|
|
|
|
|
+}
|
|
|
|
|
|
|
|
-> **温馨提示**:良好的饮食习惯是健康的基础,建议定期咨询专业营养师制定个性化饮食方案。
|
|
|
|
|
-`
|
|
|
|
|
-})
|
|
|
|
|
|
|
+const onImageLoad = (e: any) => {
|
|
|
|
|
+
|
|
|
|
|
+}
|
|
|
|
|
|
|
|
-const newsTitle = computed(() => {
|
|
|
|
|
- const match = news.value.desc.match(/^# (.+)$/m)
|
|
|
|
|
- return match ? match[1] : '图文资讯'
|
|
|
|
|
-})
|
|
|
|
|
|
|
+const onImageError = async (src?: string, e?: any) => {
|
|
|
|
|
+ console.error('[news-detail] image load error src=', src, 'event=', e)
|
|
|
|
|
+ // 尝试降级处理:若是受保护的图片,尝试使用带 Authorization header 的 downloadFile 下载到本地再显示
|
|
|
|
|
+ try {
|
|
|
|
|
+ if (src && isProtectedUrl(src) && !downloadedImages.value[src]) {
|
|
|
|
|
+ const tmp = await downloadImageWithAuth(src)
|
|
|
|
|
+ if (tmp) {
|
|
|
|
|
+ downloadedImages.value = { ...downloadedImages.value, [src]: tmp }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch (err) {
|
|
|
|
|
+ console.error('[news-detail] fallback download onImageError failed', err)
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
|
|
|
-const parsedMarkdown = computed(() => {
|
|
|
|
|
- let desc = news.value.desc
|
|
|
|
|
- const match = desc.match(/^# (.+)$/m)
|
|
|
|
|
- if (match) {
|
|
|
|
|
- desc = desc.replace(/^# .+\n/, '').trimStart()
|
|
|
|
|
|
|
+onMounted(async () => {
|
|
|
|
|
+ const query: Record<string, string> = (((globalThis as any).getCurrentPages ? (globalThis as any).getCurrentPages().slice(-1)[0]?.options : {}) as Record<string, string>) || {}
|
|
|
|
|
+ const id = query.id || ''
|
|
|
|
|
+ if (!id) {
|
|
|
|
|
+ isLoading.value = false
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ try {
|
|
|
|
|
+ const resp: any = await getNewsById(id)
|
|
|
|
|
+ if (resp?.statusCode === 401) {
|
|
|
|
|
+ uni.removeStorageSync('token')
|
|
|
|
|
+ uni.reLaunch({ url: '/pages/public/login/index' })
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ const r = resp?.data ?? resp
|
|
|
|
|
+ if (r && r.code === 200 && r.data) {
|
|
|
|
|
+ const d = r.data
|
|
|
|
|
+
|
|
|
|
|
+ news.value.title = d.title || ''
|
|
|
|
|
+ news.value.content = d.content || ''
|
|
|
|
|
+ news.value.authorName = d.authorName || ''
|
|
|
|
|
+ news.value.publishTime = d.publishTime || ''
|
|
|
|
|
+ } else {
|
|
|
|
|
+ uni.showToast({ title: r?.message || '获取资讯失败', icon: 'none' })
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch (err) {
|
|
|
|
|
+ console.error('get news failed', err)
|
|
|
|
|
+ uni.showToast({ title: '获取资讯失败', icon: 'none' })
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ isLoading.value = false
|
|
|
}
|
|
}
|
|
|
- return markdownToComponents(desc)
|
|
|
|
|
})
|
|
})
|
|
|
</script>
|
|
</script>
|
|
|
|
|
|
|
@@ -140,6 +410,22 @@ const parsedMarkdown = computed(() => {
|
|
|
border-bottom: 1rpx solid #eee;
|
|
border-bottom: 1rpx solid #eee;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+.news-meta {
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ justify-content: space-between;
|
|
|
|
|
+ margin-top: 20rpx;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.news-author {
|
|
|
|
|
+ font-size: 28rpx;
|
|
|
|
|
+ color: #666;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.news-time {
|
|
|
|
|
+ font-size: 28rpx;
|
|
|
|
|
+ color: #666;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
.news-content {
|
|
.news-content {
|
|
|
padding-inline: 40rpx;
|
|
padding-inline: 40rpx;
|
|
|
}
|
|
}
|
|
@@ -202,7 +488,9 @@ const parsedMarkdown = computed(() => {
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
.img {
|
|
.img {
|
|
|
|
|
+ display: block;
|
|
|
width: 100%;
|
|
width: 100%;
|
|
|
|
|
+ height: auto;
|
|
|
margin: 10rpx 0;
|
|
margin: 10rpx 0;
|
|
|
border-radius: 8rpx;
|
|
border-radius: 8rpx;
|
|
|
}
|
|
}
|
|
@@ -218,4 +506,69 @@ const parsedMarkdown = computed(() => {
|
|
|
font-size: 32rpx;
|
|
font-size: 32rpx;
|
|
|
color: #666;
|
|
color: #666;
|
|
|
}
|
|
}
|
|
|
|
|
+
|
|
|
|
|
+/* 骨架屏样式 */
|
|
|
|
|
+.skeleton {
|
|
|
|
|
+ background-color: #fff;
|
|
|
|
|
+ margin: 20rpx;
|
|
|
|
|
+ border-radius: 20rpx;
|
|
|
|
|
+ box-shadow: 0 2rpx 10rpx rgba(0, 0, 0, 0.1);
|
|
|
|
|
+ overflow: hidden;
|
|
|
|
|
+ padding: 40rpx;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.skeleton-header {
|
|
|
|
|
+ border-bottom: 1rpx solid #eee;
|
|
|
|
|
+ padding-bottom: 40rpx;
|
|
|
|
|
+ margin-bottom: 40rpx;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.skeleton-meta {
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ justify-content: space-between;
|
|
|
|
|
+ margin-top: 20rpx;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.skeleton-meta-item {
|
|
|
|
|
+ height: 28rpx;
|
|
|
|
|
+ background-color: #e0e0e0;
|
|
|
|
|
+ border-radius: 4rpx;
|
|
|
|
|
+ animation: skeleton-loading 1.5s ease-in-out infinite;
|
|
|
|
|
+ width: 120rpx;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.skeleton-title {
|
|
|
|
|
+ height: 56rpx;
|
|
|
|
|
+ background-color: #e0e0e0;
|
|
|
|
|
+ border-radius: 4rpx;
|
|
|
|
|
+ animation: skeleton-loading 1.5s ease-in-out infinite;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.skeleton-content {
|
|
|
|
|
+ padding-inline: 0;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.skeleton-line {
|
|
|
|
|
+ height: 32rpx;
|
|
|
|
|
+ background-color: #e0e0e0;
|
|
|
|
|
+ border-radius: 4rpx;
|
|
|
|
|
+ margin-bottom: 20rpx;
|
|
|
|
|
+ animation: skeleton-loading 1.5s ease-in-out infinite;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.skeleton-line.short {
|
|
|
|
|
+ width: 60%;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+@keyframes skeleton-loading {
|
|
|
|
|
+ 0% {
|
|
|
|
|
+ opacity: 1;
|
|
|
|
|
+ }
|
|
|
|
|
+ 50% {
|
|
|
|
|
+ opacity: 0.5;
|
|
|
|
|
+ }
|
|
|
|
|
+ 100% {
|
|
|
|
|
+ opacity: 1;
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
</style>
|
|
</style>
|