Ver código fonte

feat: replace family activities with health news section

- Updated the patient-family index page to display health news instead of family activities.
- Introduced a new API call to fetch news articles and display them in a card format.
- Implemented image handling for news articles, including downloading protected images with authorization.
- Enhanced the news detail page to include author and publish time metadata.
- Added a skeleton loading state for the news detail view while fetching data.
- Created a utility function for downloading files with authorization headers.
mcbaiyun 4 semanas atrás
pai
commit
ae816d514a

+ 169 - 0
src/api/news.ts

@@ -0,0 +1,169 @@
+import request from './request'
+import { safeJsonParse } from '@/utils/jsonBig'
+
+// 资讯实体
+export interface News {
+  id: string
+  title: string
+  summary?: string
+  content?: string
+  authorId?: string
+  authorName?: string
+  publishTime?: string
+  coverImage?: string
+}
+
+// 分页查询参数
+export interface NewsQueryParams {
+  page?: number
+  size?: number
+  keyword?: string
+}
+
+export interface CreateNewsRequest {
+  title: string
+  content: string
+  summary?: string
+}
+
+export interface UpdateNewsRequest {
+  id: string | number
+  title: string
+  content: string
+  summary?: string
+}
+
+export interface NewsPageResponse {
+  records: News[]
+  total: number
+  size: number
+  current: number
+  pages: number
+}
+
+/**
+ * 创建资讯
+ */
+export async function createNews(payload: CreateNewsRequest) {
+  const res: any = await request({
+    url: 'https://wx.baiyun.work/news',
+    method: 'POST',
+    header: { 'Content-Type': 'application/json' },
+    data: payload
+  })
+  try {
+    const parsed = res?.data as any
+    if (parsed && parsed.code === 200 && parsed.data) {
+      parsed.data = { ...parsed.data, id: String(parsed.data.id) }
+      res.data = parsed
+    }
+  } catch (e) {
+    // ignore
+  }
+  return res
+}
+
+/**
+ * 更新资讯
+ */
+export async function updateNews(id: string | number, payload: UpdateNewsRequest) {
+  const res: any = await request({
+    url: `https://wx.baiyun.work/news/${encodeURIComponent(String(id))}`,
+    method: 'PUT',
+    header: { 'Content-Type': 'application/json' },
+    data: payload
+  })
+  try {
+    const parsed = res?.data as any
+    if (parsed && parsed.code === 200 && parsed.data) {
+      parsed.data = { ...parsed.data, id: String(parsed.data.id) }
+      res.data = parsed
+    }
+  } catch (e) {
+    // ignore
+  }
+  return res
+}
+
+/**
+ * 删除资讯
+ */
+export async function deleteNews(id: string | number) {
+  const res: any = await request({
+    url: `https://wx.baiyun.work/news/${encodeURIComponent(String(id))}`,
+    method: 'DELETE'
+  })
+  return res
+}
+
+/**
+ * 获取资讯详情
+ */
+export async function getNewsById(id: string | number) {
+  const res: any = await request({
+    url: `https://wx.baiyun.work/news/${encodeURIComponent(String(id))}`,
+    method: 'GET'
+  })
+  try {
+    const parsed = res?.data as any
+    if (parsed && parsed.code === 200 && parsed.data) {
+      parsed.data = { ...parsed.data, id: String(parsed.data.id) }
+      res.data = parsed
+    }
+  } catch (e) {
+    // ignore
+  }
+  return res
+}
+
+/**
+ * 获取资讯列表(分页)
+ */
+export async function getNewsList(query: NewsQueryParams) {
+  // server uses GET /news/list?page=...&size=...&keyword=...
+  const params = [] as string[]
+  if (query.page != null) params.push(`page=${encodeURIComponent(String(query.page))}`)
+  if (query.size != null) params.push(`size=${encodeURIComponent(String(query.size))}`)
+  if (query.keyword) params.push(`keyword=${encodeURIComponent(String(query.keyword))}`)
+  const q = params.length ? `?${params.join('&')}` : ''
+
+  const res: any = await request({
+    url: `https://wx.baiyun.work/news/list${q}`,
+    method: 'GET'
+  })
+  // ensure ids are strings
+  try {
+    const parsed = res?.data as any
+    if (parsed && parsed.code === 200 && parsed.data && Array.isArray(parsed.data.records)) {
+      parsed.data.records = parsed.data.records.map((r: any) => ({ ...r, id: String(r.id), authorId: r.authorId ? String(r.authorId) : undefined }))
+      res.data = parsed
+    }
+  } catch (e) {
+    // ignore
+  }
+  return res
+}
+
+/**
+ * 上传资讯图片(封装 uni.uploadFile)
+ */
+export function uploadNewsImage(filePath: string) {
+  return new Promise<any>((resolve, reject) => {
+    const token = uni.getStorageSync('token')
+    uni.uploadFile({
+      url: 'https://wx.baiyun.work/news/upload-image',
+      filePath,
+      name: 'file',
+      header: { Authorization: `Bearer ${token}` },
+      success: (uploadRes: any) => {
+        try {
+          const parsed = safeJsonParse(uploadRes.data || '{}')
+          resolve(parsed)
+        } catch (e) {
+          resolve({ data: uploadRes.data })
+        }
+      },
+      fail: (err: any) => reject(err)
+    })
+  })
+}

+ 112 - 14
src/pages/doctor/manage/news-edit.vue

@@ -42,6 +42,7 @@
 <script setup lang="ts">
 import { ref, onMounted, nextTick, computed } from 'vue'
 import CustomNav from '@/components/custom-nav.vue'
+import { createNews, updateNews, uploadNewsImage, getNewsById } from '@/api/news'
 
 const query: Record<string, string> = (((globalThis as any).getCurrentPages ? (globalThis as any).getCurrentPages().slice(-1)[0]?.options : {}) as Record<string, string>) || {}
 
@@ -92,12 +93,14 @@ const onFocus = (e: any) => {
   }
 }
 
+const isUploadingImage = ref(false)
 const chooseImage = () => {
   uni.chooseImage({ count: 1, success: async (res: any) => {
     if (res.tempFilePaths && res.tempFilePaths[0]) {
       const imgPath = res.tempFilePaths[0]
-      // 插入 markdown 图片语法:使用临时路径,后续应上传返回相对路径替换
-      const md = `![](${imgPath})`
+      // 插入 markdown 临时占位符
+      const tempToken = `__TEMP_IMAGE_${Date.now()}_${Math.random().toString(36).substring(2,8)}__`
+      const md = `![](${tempToken})`
       const before = content.value.slice(0, cursorPos.value)
       const after = content.value.slice(cursorPos.value)
       content.value = before + md + after
@@ -114,11 +117,53 @@ const chooseImage = () => {
       }
       // 插入图片后调整高度
       adjustTextareaHeight()
+
+      // 上传图片并替换占位符
+      isUploadingImage.value = true
+      uni.showLoading({ title: '图片上传中...', mask: true })
+      try {
+        const resp: any = await uploadNewsImage(imgPath)
+        // resp may be R<string> or R<{url/path/fileUrl: ...}>
+        const rdata = resp?.data ?? resp
+        const relative = typeof rdata === 'string' ? rdata : (rdata?.url || rdata?.path || rdata?.fileUrl || rdata?.file || '')
+        // use relative path in markdown like: news-images/1988172854356631553/1764778894438.jpg
+        let finalUrl = imgPath
+        if (relative) {
+          // If server returned an absolute URL (contains http), try to extract the relative news-images path
+          if (/^https?:\/\//i.test(relative)) {
+            const m = relative.match(/\/news\/image\/(.+)$/i)
+            if (m && m[1]) {
+              finalUrl = m[1].replace(/^\/*/, '')
+              // ensure prefixed with news-images/
+              if (!/^news-images\//i.test(finalUrl)) finalUrl = `news-images/${finalUrl}`
+            } else {
+              // try fallback: look for /news-images/ in path
+              const idx = relative.indexOf('/news-images/')
+              if (idx >= 0) finalUrl = relative.substring(idx + 1) // drop leading /
+              else finalUrl = relative
+            }
+          } else {
+            // normalize relative: if server returns 'news-images/...' keep it, otherwise prefix
+            finalUrl = /^news-images\//i.test(relative) ? relative : `news-images/${relative}`
+          }
+        }
+        // replace token placeholder with finalUrl
+        content.value = content.value.replace(tempToken, finalUrl)
+        uni.showToast({ title: '图片上传成功', icon: 'success' })
+      } catch (err) {
+        console.error('upload image failed', err)
+        // 失败时保留本地图片(避免数据丢失),提示用户
+        uni.showToast({ title: '图片上传失败,已保留本地路径', icon: 'none' })
+      } finally {
+        isUploadingImage.value = false
+        uni.hideLoading()
+      }
     }
   }})
 }
 
-const save = () => {
+const saving = ref(false)
+const save = async () => {
   if (!title.value.trim()) {
     uni.showToast({ title: '请输入标题', icon: 'none' })
     return
@@ -127,26 +172,79 @@ const save = () => {
     uni.showToast({ title: '请输入内容', icon: 'none' })
     return
   }
-  // 简单模拟保存:实际应调用后台 API
-  uni.showToast({ title: '保存成功(模拟)', icon: 'success' })
-  setTimeout(() => {
-    uni.navigateBack()
-  }, 600)
+  if (saving.value) return
+  saving.value = true
+  try {
+    if (query.id) {
+      // 编辑
+      const payload = {
+        id: query.id,
+        title: title.value,
+        content: content.value,
+        summary: summary.value
+      }
+      const res: any = await updateNews(query.id, payload)
+      const r = res?.data ?? res
+      if (r && r.code === 200) {
+        uni.showToast({ title: '更新成功', icon: 'success' })
+        setTimeout(() => uni.navigateBack(), 600)
+      } else {
+        throw new Error(r?.message || '更新失败')
+      }
+    } else {
+      // 新建
+      const payload = {
+        title: title.value,
+        content: content.value,
+        summary: summary.value
+      }
+      const res: any = await createNews(payload)
+      const r = res?.data ?? res
+      if (r && r.code === 200) {
+        uni.showToast({ title: '创建成功', icon: 'success' })
+        setTimeout(() => uni.navigateBack(), 600)
+      } else {
+        throw new Error(r?.message || '创建失败')
+      }
+    }
+  } catch (err: any) {
+    console.error('save news failed', err)
+    uni.showToast({ title: err?.message || '保存失败', icon: 'none' })
+  } finally {
+    saving.value = false
+  }
 }
 
 const cancel = () => {
   uni.navigateBack()
 }
 
-onMounted(() => {
+onMounted(async () => {
   // 如果是编辑,读取 id 并可调用接口拉取详情(这里模拟)
   const id = query.id || ''
   if (id) {
-    // TODO: 用接口拉取真实数据,当前使用模拟
-    title.value = '示例资讯标题 - 编辑'
-    summary.value = '这是示例概要,用于编辑场景显示。'
-    content.value = '# 示例内容\n\n示例 Markdown 内容...'
-    cover.value = ''
+    // 编辑:使用接口拉取真实数据
+    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
+        title.value = d.title || ''
+        summary.value = d.summary || ''
+        content.value = d.content || ''
+        cover.value = d.coverImage || ''
+      } else {
+        uni.showToast({ title: r?.message || '获取资讯失败', icon: 'none' })
+      }
+    } catch (err) {
+      console.error('get news failed', err)
+      uni.showToast({ title: '获取资讯失败', icon: 'none' })
+    }
   }
   // 初始调整编辑框高度以适配可能存在的内容
   nextTick(() => {

+ 143 - 46
src/pages/doctor/manage/news.vue

@@ -16,6 +16,7 @@
         <view class="skeleton-cover" />
         <view class="skeleton-meta">
           <view class="skeleton-title" />
+          <view class="skeleton-author" />
           <view class="skeleton-summary" />
         </view>
       </view>
@@ -23,7 +24,7 @@
     <view class="news-card" v-for="item in filteredList" :key="item.id">
       <view class="news-content">
         <view v-if="item.coverImage" class="news-image-container">
-          <image class="news-image" :src="item.coverImage" mode="aspectFill" />
+          <image class="news-image" :src="(isProtectedUrl(resolveImageUrl(item.coverImage)) ? (downloadedCovers[resolveImageUrl(item.coverImage)] || defaultCover) : (resolveImageUrl(item.coverImage) || defaultCover))" mode="aspectFill" @load="onCoverImageLoad" @error="onCoverImageError(item.coverImage, $event)" />
         </view>
         <view v-else class="news-placeholder">
               <image class="placeholder-icon" src="/static/icons/remixicon/image-line.svg" />
@@ -31,6 +32,7 @@
         <view class="meta">
           <text class="title">{{ item.title }}</text>
           <text class="summary">{{ item.summary }}</text>
+          <text class="author">作者: {{ item.authorName }}</text>
           <text class="time">发布时间: {{ item.publishTime }}</text>
         </view>
       </view>
@@ -42,7 +44,7 @@
       </view>
     </view>
 
-    <view class="empty-state" v-if="filteredList.length === 0">
+    <view class="empty-state" v-if="!isLoading && filteredList.length === 0">
       <view class="news-placeholder">
           <image class="placeholder-icon" src="/static/icons/remixicon/image-line.svg" />
         </view>
@@ -60,9 +62,11 @@
 </template>
 
 <script setup lang="ts">
-import { ref, computed } from 'vue'
+import { ref, computed, watch, onUnmounted } from 'vue'
+import { downloadWithAuth } from '@/utils/downloadWithAuth'
 import { onShow, onPullDownRefresh } from '@dcloudio/uni-app'
 import CustomNav from '@/components/custom-nav.vue'
+import { getNewsList, deleteNews } from '@/api/news'
 
 interface NewsItem {
   id: string
@@ -71,26 +75,28 @@ interface NewsItem {
   content?: string
   coverImage?: string
   publishTime?: string
+  authorName?: string
 }
 
-const newsList = ref<NewsItem[]>([
-    {
-    id: '1',
-    title: '如何管理血压:日常监测要点',
-    summary: '本文介绍了家庭血压监测的常见问题及注意事项。',
-    publishTime: '2025-01-01 10:00:00'
-  },
-  {
-    id: '2',
-    title: '糖尿病饮食小贴士',
-    summary: '5条简单饮食建议,助你在日常生活中控制血糖。',
-    publishTime: '2025-01-05 14:30:00'
-  }
-])
+const newsList = ref<NewsItem[]>([])
 const searchKey = ref('')
 const defaultCover = '/static/icons/remixicon/image-line.svg'
+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)) {
+    const fixed = `${HOST}/news/image/${src.replace(/^\/*/, '')}`
+    
+    return fixed
+  }
+  return src
+}
 const pageSize = ref(10)
 const currentPage = ref(1)
+const totalPages = ref(1)
 const hasMore = ref(false)
 const isLoading = ref(false)
 
@@ -99,11 +105,40 @@ const onCreateNews = () => {
   uni.navigateTo({ url: `/pages/doctor/manage/news-edit` })
 }
 
-const fetchNews = async () => {
-  // 模拟网络请求,真实场景使用 API 请求
+const fetchNews = async (page = 1, size = pageSize.value, keyword?: string) => {
   isLoading.value = true
-  await new Promise(resolve => setTimeout(resolve, 350))
-  isLoading.value = false
+  try {
+    const params = {
+      page: page,
+      size: size,
+      keyword: keyword || (searchKey.value?.trim() || undefined)
+    }
+    const result: any = await getNewsList(params)
+    if (result?.data?.code === 200) {
+      const pageData = result.data.data
+      // 将分页数据加载进来
+      if (page === 1) {
+        newsList.value = pageData.records || []
+      } else {
+        newsList.value = newsList.value.concat(pageData.records || [])
+      }
+      currentPage.value = pageData.current || page
+      pageSize.value = pageData.size || size
+      totalPages.value = pageData.pages || 1
+      hasMore.value = (currentPage.value < totalPages.value)
+    } else if (result?.data?.code === 401) {
+      // handle unauthorized
+      uni.removeStorageSync('token')
+      uni.reLaunch({ url: '/pages/public/login/index' })
+    } else {
+      uni.showToast({ title: result?.data?.message || '获取资讯失败', icon: 'none' })
+    }
+  } catch (err) {
+    console.error('fetch news error', err)
+    uni.showToast({ title: '获取资讯失败', icon: 'none' })
+  } finally {
+    isLoading.value = false
+  }
 }
 
 const viewNews = (item: NewsItem) => {
@@ -122,50 +157,99 @@ const removeNews = (item: NewsItem) => {
     content: `确定删除资讯《${item.title}》吗?`,
     success: (res) => {
       if (res.confirm) {
-        const idx = newsList.value.findIndex(i => i.id === item.id)
-        if (idx >= 0) {
-          newsList.value.splice(idx, 1)
-          uni.showToast({ title: '删除成功', icon: 'success' })
-        }
+        // 调用后端删除
+        (async () => {
+          try {
+            const result: any = await deleteNews(item.id)
+            if (result?.data?.code === 200) {
+              uni.showToast({ title: '删除成功', icon: 'success' })
+              // 刷新当前页
+              fetchNews(currentPage.value)
+            } else {
+              uni.showToast({ title: result?.data?.message || '删除失败', icon: 'none' })
+            }
+          } catch (err) {
+            console.error('delete news error', err)
+            uni.showToast({ title: '删除失败', icon: 'none' })
+          }
+        })()
       }
     }
   })
 }
 
 const onSearch = () => {
-  // 简单的本地过滤,后续可替换为后端搜索API
   currentPage.value = 1
+  fetchNews(1, pageSize.value, searchKey.value)
 }
 
-const filteredList = computed(() => {
-  const key = searchKey.value.trim().toLowerCase()
-  let list = newsList.value
-  if (key) {
-    list = list.filter(i => i.title.toLowerCase().includes(key) || (i.summary || '').toLowerCase().includes(key))
-  }
-  // 分页处理:简单模拟
-  const start = 0
-  const end = currentPage.value * pageSize.value
-  hasMore.value = list.length > end
-  return list.slice(start, end)
-})
+const filteredList = computed(() => newsList.value)
 
 const loadMore = () => {
-  currentPage.value += 1
+  if (!hasMore.value) return
+  fetchNews(currentPage.value + 1, pageSize.value, searchKey.value)
 }
 
+// watch(newsList, (newVal: NewsItem[]) => {})
+
+const onCoverImageLoad = (e: any) => {
+  // cover image loaded
+}
+
+const onCoverImageError = async (src?: string, e?: any) => {
+  console.error('[news-list] cover image load error', src, e)
+  if (!src) return
+  const url = resolveImageUrl(src)
+  if (isProtectedUrl(url) && !downloadedCovers.value[url]) {
+    try {
+      await downloadCoverWithAuth(url)
+    } catch (err) {
+      console.error('[news-list] fallback download cover error', err)
+    }
+  }
+}
+
+// 保护的图片(news/image)需要带 token 下载,这里使用 uni.downloadFile 附带 header 获取本地 tempFilePath
+const downloadedCovers = ref<Record<string, string>>({})
+const isProtectedUrl = (url?: string) => {
+  if (!url) return false
+  return url.startsWith(`${HOST}/news/image/`) || /\/news\/image\//.test(url)
+}
+const downloadCoverWithAuth = async (url: string) => {
+  const tmp = await downloadWithAuth(url)
+  if (tmp) {
+    downloadedCovers.value = { ...downloadedCovers.value, [url]: tmp }
+    return tmp
+  }
+  return ''
+}
+
+watch(newsList, (list) => {
+  if (!list || !Array.isArray(list)) return
+  for (const item of list) {
+    const url = resolveImageUrl(item.coverImage)
+    if (url && isProtectedUrl(url) && !downloadedCovers.value[url]) {
+      downloadCoverWithAuth(url)
+    }
+  }
+}, { immediate: true })
+
+onUnmounted(() => {
+  downloadedCovers.value = {}
+})
+
 onShow(() => {
   const token = uni.getStorageSync('token')
   if (!token) {
     uni.reLaunch({ url: '/pages/public/login/index' })
   }
-  fetchNews()
+  fetchNews(1, pageSize.value)
 })
 
 onPullDownRefresh(() => {
   // 下拉刷新
   currentPage.value = 1
-  fetchNews().then(() => uni.stopPullDownRefresh())
+  fetchNews(1, pageSize.value).then(() => uni.stopPullDownRefresh())
 })
 </script>
 
@@ -253,10 +337,11 @@ onPullDownRefresh(() => {
   display: flex;
   padding: 20rpx;
   gap: 20rpx;
+  align-items: center;
 }
 .news-image-container {
-  width: 180rpx;
-  height: 130rpx;
+  width: 220rpx;
+  height: 150rpx;
   border-radius: 10rpx;
   margin-right: 20rpx;
   overflow: hidden;
@@ -267,8 +352,8 @@ onPullDownRefresh(() => {
   object-fit: cover;
 }
 .news-placeholder {
-  width: 180rpx;
-  height: 130rpx;
+  width: 220rpx;
+  height: 150rpx;
   background-color: #f0f0f0;
   border-radius: 10rpx;
   margin-right: 20rpx;
@@ -310,6 +395,11 @@ onPullDownRefresh(() => {
   margin-top: 12rpx;
 }
 
+.author {
+  font-size: 24rpx;
+  margin-top: 12rpx;
+}
+
 .action-buttons {
   display: flex;
   gap: 12rpx;
@@ -404,6 +494,13 @@ onPullDownRefresh(() => {
   margin-bottom: 8rpx;
   border-radius: 6rpx;
 }
+.skeleton-author {
+  width: 40%;
+  height: 16rpx;
+  background: linear-gradient(90deg, #eee, #f6f6f6);
+  margin-bottom: 8rpx;
+  border-radius: 6rpx;
+}
 .skeleton-summary {
   width: 80%;
   height: 16rpx;

+ 173 - 168
src/pages/patient-family/index/index.vue

@@ -43,22 +43,22 @@
       </view>
 
 
-      <view class="family-activity-card">
+      <view class="health-news-card">
         <view class="card-header">
-          <text class="card-title">家人动态</text>
+          <text class="card-title">健康资讯</text>
         </view>
-        <view class="activity-card-content">
-          <view class="activity-item" v-for="(activity, index) in familyActivities" :key="index">
-            <view class="activity-avatar">
-              <image :src="activity.familyAvatar" class="avatar-img" mode="aspectFill" />
+        <view class="card-content">
+          <view class="news-item" v-for="(news, index) in newsList" :key="index" @click="onNewsClick(news)">
+            <view v-if="news.coverImage" class="news-image-container">
+              <image class="news-image" :src="getImageSrc(news.coverImage)" mode="aspectFill" />
             </view>
-            <view class="activity-text">
-              <text class="activity-desc">{{ activity.desc }}</text>
-              <text class="activity-time">{{ activity.time }}</text>
+            <view v-else class="news-placeholder">
+              <image class="placeholder-icon" src="/static/icons/remixicon/image-line.svg" />
+            </view>
+            <view class="news-text">
+              <text class="news-title">{{ news.title }}</text>
+              <text class="news-desc">{{ news.summary }}</text>
             </view>
-          </view>
-          <view v-if="familyActivities.length === 0" class="no-activity">
-            <text>暂无家人动态</text>
           </view>
         </view>
       </view>
@@ -69,7 +69,7 @@
 </template>
 
 <script setup lang="ts">
-import { ref, computed } from 'vue'
+import { ref, computed, watch, onUnmounted } from 'vue'
 import { onShow } from '@dcloudio/uni-app'
 import CustomNav from '@/components/custom-nav.vue'
 import { fetchUserInfo as fetchUserInfoApi } from '@/api/user'
@@ -77,8 +77,9 @@ import TabBar from '@/components/tab-bar.vue'
 import { handleQrScanResult } from '@/utils/qr'
 import request from '@/api/request'
 import { avatarCache } from '@/utils/avatarCache'
-import { queryBoundFamiliesActivities } from '@/api/userActivity'
 import { listUserBindingsByBoundUser, type UserBindingResponse, type UserBindingPageResponse } from '@/api/userBinding'
+import { downloadWithAuth } from '@/utils/downloadWithAuth'
+import { getNewsList, News } from '@/api/news'
 
 const user = ref<{ avatar?: string; nickname?: string }>({})
 
@@ -104,11 +105,35 @@ const todayReminders = ref({
   abnormalCount: 0
 })
 
-const familyActivities = ref<Array<{
-  desc: string
-  time: string
-  familyAvatar: string
-}>>([])
+const newsList = ref<News[]>([])
+
+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 defaultImage = '/static/icons/remixicon/image-line.svg'
+
+// 如果 news.image 是受保护图片,尝试下载并替换为临时本地路径
+const downloadedNewsImages = ref<Record<string, string>>({})
+const isProtectedUrl = (url?: string) => {
+  if (!url) return false
+  return url.startsWith(`${HOST}/news/image/`) || /\/news\/image\//.test(url)
+}
+
+const getImageSrc = (src?: string) => {
+  if (!src) return ''
+  const resolved = resolveImageUrl(src)
+  if (isProtectedUrl(resolved)) {
+    return downloadedNewsImages.value[resolved] || defaultImage
+  }
+  return resolved
+}
 
 const loadUser = () => {
   try {
@@ -194,154 +219,6 @@ const fetchTodayReminders = async () => {
   }
 }
 
-const fetchFamilyActivities = async () => {
-  try {
-    const token = uni.getStorageSync('token')
-    if (!token) return
-    
-    // 调用真实接口获取家人动态
-    const response = await queryBoundFamiliesActivities({
-      pageNum: 1,
-      pageSize: 10
-    })
-    
-    const resp = response.data as any
-    if (resp && resp.code === 200 && resp.data) {
-      // 转换数据格式,异步获取头像
-      const activitiesPromises = resp.data.records.map(async (activity: any) => ({
-        desc: formatActivityDescription(activity),
-        time: formatTime(activity.createTime),
-        familyAvatar: await getFamilyAvatar(activity.userId)
-      }))
-      
-      // 等待所有头像获取完成
-      const activities = await Promise.all(activitiesPromises)
-      familyActivities.value = activities
-    } else {
-      familyActivities.value = []
-    }
-  } catch (err) {
-    console.error('Fetch family activities error:', err)
-    // 如果接口调用失败,显示空数据
-    familyActivities.value = []
-  }
-}
-
-// 格式化时间显示
-const formatTime = (createTime: string) => {
-  try {
-    const now = new Date()
-    const create = new Date(createTime)
-    const diff = now.getTime() - create.getTime()
-    
-    const minutes = Math.floor(diff / (1000 * 60))
-    const hours = Math.floor(diff / (1000 * 60 * 60))
-    const days = Math.floor(diff / (1000 * 60 * 60 * 24))
-    
-    if (minutes < 1) return '刚刚'
-    if (minutes < 60) return `${minutes}分钟前`
-    if (hours < 24) return `${hours}小时前`
-    if (days < 7) return `${days}天前`
-    
-    return create.toLocaleDateString('zh-CN')
-  } catch (e) {
-    return createTime
-  }
-}
-
-// 获取家人头像
-const getFamilyAvatar = async (userId: string): Promise<string> => {
-  try {
-    const token = uni.getStorageSync('token')
-    if (!token) return defaultAvatarUrl
-    
-    // 检查是否有缓存的头像
-    if (avatarCache.has(userId)) {
-      return avatarCache.get(userId)!
-    }
-    
-    // 尝试下载用户头像
-    const downloadRes = await uni.downloadFile({
-      url: `https://wx.baiyun.work/user/avatar/${userId}`,
-      header: {
-        Authorization: `Bearer ${token}`
-      }
-    })
-    
-    if (downloadRes.statusCode === 200 && downloadRes.tempFilePath) {
-      // 缓存头像路径
-      avatarCache.set(userId, downloadRes.tempFilePath)
-      return downloadRes.tempFilePath
-    }
-  } catch (e) {
-    console.error('Download family avatar error:', e)
-  }
-  
-  // 如果获取失败,使用默认头像
-  return defaultAvatarUrl
-}
-
-// 格式化活动描述
-const formatActivityDescription = (activity: any) => {
-  // 如果已经有友好的描述,直接返回
-  if (activity.friendlyDescription) {
-    return activity.friendlyDescription
-  }
-  
-  // 根据 activityDescription 内容进一步细化描述
-  const description = activity.activityDescription || ''
-  let baseDescription = ''
-  
-  // 根据活动类型生成基础描述
-  switch (activity.activityType) {
-    case 'BLOOD_GLUCOSE_UPLOAD':
-      baseDescription = '上传了血糖数据'
-      break
-    case 'BLOOD_GLUCOSE_UPDATE':
-      baseDescription = '更新了血糖数据'
-      break
-    case 'BLOOD_PRESSURE_UPLOAD':
-      baseDescription = '上传了血压数据'
-      break
-    case 'HEART_RATE_UPLOAD':
-      baseDescription = '上传了心率数据'
-      break
-    case 'PHYSICAL_DATA_UPLOAD':
-      baseDescription = '上传了体格数据'
-      break
-    case 'HEALTH_RECORD_CREATE':
-      // 根据描述内容判断是创建还是更新
-      if (description.includes('save') || description.includes('update')) {
-        baseDescription = '更新了健康档案'
-      } else {
-        baseDescription = '创建了健康档案'
-      }
-      break
-    case 'HEALTH_RECORD_UPDATE':
-      baseDescription = '更新了健康档案'
-      break
-    case 'MEDICATION_CREATE':
-      baseDescription = '添加了用药记录'
-      break
-    case 'MEDICATION_UPDATE':
-      baseDescription = '更新了用药记录'
-      break
-    case 'USER_BINDING_CREATE':
-      baseDescription = '绑定了新家人'
-      break
-    case 'USER_BINDING_DELETE':
-      baseDescription = '解除了家人绑定'
-      break
-    default:
-      // 如果没有匹配的类型,尝试使用 activityDescription 或返回默认值
-      baseDescription = description && !description.includes('Controller') 
-        ? description 
-        : '执行了操作'
-  }
-  
-  return baseDescription
-}
-
 // 如果在微信小程序端且未登录,自动跳转到登录页
 onShow(() => {
   const token = uni.getStorageSync('token')
@@ -351,7 +228,7 @@ onShow(() => {
   } else {
     fetchUserInfo()
     fetchTodayReminders()
-    fetchFamilyActivities()
+    fetchNews()
   }
 })
 
@@ -453,6 +330,46 @@ async function checkAndNavigateToHealthData() {
 function onQrClick() {
   uni.navigateTo({ url: '/pages/public/profile/qr/index' })
 }
+
+watch(newsList, (list) => {
+  if (!list || !Array.isArray(list)) return
+  for (const item of list) {
+    const url = resolveImageUrl(item.coverImage)
+    if (url && isProtectedUrl(url) && !downloadedNewsImages.value[url]) {
+      downloadWithAuth(url).then(tmp => {
+        if (tmp) {
+          downloadedNewsImages.value = { ...downloadedNewsImages.value, [url]: tmp }
+        }
+      }).catch(err => {
+        console.error('[index] download news image failed', url, err)
+      })
+    }
+  }
+}, { immediate: true })
+
+onUnmounted(() => {
+  downloadedNewsImages.value = {}
+})
+
+const fetchNews = async () => {
+  try {
+    const result: any = await getNewsList({ page: 1, size: 5 })
+    if (result?.data?.code === 200) {
+      newsList.value = result.data.data.records || []
+    } else {
+      uni.showToast({ title: '获取资讯失败', icon: 'none' })
+    }
+  } catch (err) {
+    console.error('fetch news error', err)
+    uni.showToast({ title: '获取资讯失败', icon: 'none' })
+  }
+}
+
+const onNewsClick = (news: News) => {
+  uni.navigateTo({
+    url: `/pages/public/news-detail?id=${news.id}`
+  })
+}
 </script>
 
 <style>
@@ -734,4 +651,92 @@ function onQrClick() {
   color: #999;
   font-size: 28rpx;
 }
+
+.health-news-card {
+  background-color: #fff;
+  border-radius: 20rpx;
+  margin: 20rpx;
+  box-shadow: 0 2rpx 10rpx rgba(0, 0, 0, 0.1);
+  overflow: hidden;
+}
+
+.card-header {
+  padding: 20rpx;
+  border-bottom: 1rpx solid #eee;
+}
+
+.card-title {
+  font-size: 36rpx;
+  font-weight: bold;
+  color: #333;
+}
+
+.card-content {
+  padding: 20rpx;
+  display: flex;
+  flex-direction: column;
+}
+
+.news-item {
+  display: flex;
+  align-items: center;
+  margin-bottom: 20rpx;
+  padding-bottom: 20rpx;
+  border-bottom: 1rpx solid #eee;
+}
+
+.news-item:last-child {
+  margin-bottom: 0;
+  padding-bottom: 0;
+  border-bottom: none;
+}
+
+.news-image-container {
+  width: 180rpx;
+  height: 130rpx;
+  border-radius: 10rpx;
+  margin-right: 20rpx;
+  overflow: hidden;
+}
+
+.news-image {
+  width: 100%;
+  height: 100%;
+  object-fit: cover;
+}
+
+.news-placeholder {
+  width: 180rpx;
+  height: 130rpx;
+  background-color: #f0f0f0;
+  border-radius: 10rpx;
+  margin-right: 20rpx;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+
+.placeholder-icon {
+  width: 40rpx;
+  height: 40rpx;
+  opacity: 0.5;
+}
+
+.news-text {
+  flex: 1;
+}
+
+.news-title {
+  font-size: 32rpx;
+  font-weight: bold;
+  color: #333;
+  display: block;
+  margin-bottom: 10rpx;
+}
+
+.news-desc {
+  font-size: 28rpx;
+  color: #666;
+  line-height: 1.4;
+}
 </style>

+ 71 - 20
src/pages/patient/index/index.vue

@@ -70,15 +70,15 @@
         </view>
         <view class="card-content">
           <view class="news-item" v-for="(news, index) in newsList" :key="index" @click="onNewsClick(news)">
-            <view v-if="news.image" class="news-image-container">
-              <image class="news-image" :src="news.image" mode="aspectFill" />
+            <view v-if="news.coverImage" class="news-image-container">
+              <image class="news-image" :src="getImageSrc(news.coverImage)" mode="aspectFill" />
             </view>
             <view v-else class="news-placeholder">
               <image class="placeholder-icon" src="/static/icons/remixicon/image-line.svg" />
             </view>
             <view class="news-text">
               <text class="news-title">{{ news.title }}</text>
-              <text class="news-desc">{{ news.desc }}</text>
+              <text class="news-desc">{{ news.summary }}</text>
             </view>
           </view>
         </view>
@@ -110,13 +110,15 @@
 </template>
 
 <script setup lang="ts">
-import { ref, computed, onMounted } from 'vue'
+import { ref, computed, onMounted, watch, onUnmounted } from 'vue'
 import { onShow } from '@dcloudio/uni-app'
 import CustomNav from '@/components/custom-nav.vue'
 import TabBar from '@/components/tab-bar.vue'
 import { handleQrScanResult } from '@/utils/qr'
 import { fetchUserInfo as fetchUserInfoApi, downloadAvatar as downloadAvatarApi } from '@/api/user'
+import { downloadWithAuth } from '@/utils/downloadWithAuth'
 import { avatarCache } from '@/utils/avatarCache'
+import { getNewsList, News } from '@/api/news'
 
 const user = ref<{ avatar?: string; nickname?: string; age?: number }>({})
 
@@ -137,21 +139,55 @@ const avatarSrc = computed(() => {
   return defaultAvatarUrl
 })
 
-const newsList = ref([
-  {
-    title: '健康饮食指南',
-    desc: '了解均衡饮食的重要性,掌握健康饮食的基本原则。',
-    image: '/static/carousel/BHFIIABBCDJII-5kCEkD6zh9.jpg'
-  },
-  {
-    title: '运动与健康',
-    desc: '定期运动对身体的好处,以及如何制定适合自己的运动计划。'
-  },
-  {
-    title: '心理健康维护',
-    desc: '保持良好的心理状态,应对日常生活中的压力和挑战。'
+const newsList = ref<News[]>([])
+
+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 defaultImage = '/static/icons/remixicon/image-line.svg'
+
+// 如果 news.image 是受保护图片,尝试下载并替换为临时本地路径
+const downloadedNewsImages = ref<Record<string, string>>({})
+const isProtectedUrl = (url?: string) => {
+  if (!url) return false
+  return url.startsWith(`${HOST}/news/image/`) || /\/news\/image\//.test(url)
+}
+
+const getImageSrc = (src?: string) => {
+  if (!src) return ''
+  const resolved = resolveImageUrl(src)
+  if (isProtectedUrl(resolved)) {
+    return downloadedNewsImages.value[resolved] || defaultImage
+  }
+  return resolved
+}
+
+watch(newsList, (list) => {
+  if (!list || !Array.isArray(list)) return
+  for (const item of list) {
+    const url = resolveImageUrl(item.coverImage)
+    if (url && isProtectedUrl(url) && !downloadedNewsImages.value[url]) {
+      downloadWithAuth(url).then(tmp => {
+        if (tmp) {
+          downloadedNewsImages.value = { ...downloadedNewsImages.value, [url]: tmp }
+        }
+      }).catch(err => {
+        console.error('[index] download news image failed', url, err)
+      })
+    }
   }
-])
+}, { immediate: true })
+
+onUnmounted(() => {
+  downloadedNewsImages.value = {}
+})
 // const checkinTasks = ref<Array<{
 //   time: string
 //   type: string
@@ -175,6 +211,20 @@ const loadUser = () => {
   }
 }
 
+const fetchNews = async () => {
+  try {
+    const result: any = await getNewsList({ page: 1, size: 5 })
+    if (result?.data?.code === 200) {
+      newsList.value = result.data.data.records || []
+    } else {
+      uni.showToast({ title: '获取资讯失败', icon: 'none' })
+    }
+  } catch (err) {
+    console.error('fetch news error', err)
+    uni.showToast({ title: '获取资讯失败', icon: 'none' })
+  }
+}
+
 const fetchUserInfo = async () => {
   try {
     const token = uni.getStorageSync('token')
@@ -365,6 +415,7 @@ onShow(() => {
     uni.reLaunch({ url: '/pages/public/login/index' })
   } else {
     fetchUserInfo()
+    fetchNews()
     // 生成打卡任务
     // generateCheckinTasks()
     // 清理过期记录
@@ -408,9 +459,9 @@ function onQrClick() {
   uni.navigateTo({ url: '/pages/public/profile/qr/index' })
 }
 
-const onNewsClick = (news: any) => {
+const onNewsClick = (news: News) => {
   uni.navigateTo({
-    url: `/pages/public/news-detail`
+    url: `/pages/public/news-detail?id=${news.id}`
   })
 }
 </script>

+ 398 - 45
src/pages/public/news-detail.vue

@@ -1,9 +1,32 @@
 <template>
   <CustomNav title="图文资讯" leftType="back" />
   <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">
         <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 class="news-content">
         <view class="news-desc">
@@ -18,7 +41,7 @@
               </view>
             </view>
             <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>
@@ -27,8 +50,10 @@
 </template>
 
 <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 { getNewsById } from '@/api/news'
 
 // 处理内联markdown
 const processInline = (text: string) => {
@@ -36,18 +61,145 @@ const processInline = (text: string) => {
 }
 
 // 简单的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) => {
+  
+  // 将所有 markdown 或 HTML 中的 `news-images/...` 相对路径替换成绝对可访问的图片URL,
+  // 避免在页面中使用相对路径导致平台错误地解析为当前页面或其他路径。
+  if (markdown && typeof markdown === 'string') {
+    // 1) Markdown 图片语法: ![alt](news-images/xxx)
+    markdown = markdown.replace(/!\[([^\]]*)\]\((\/?news-images\/[^)\s\)]+)\)/ig,
+      (_m, alt, src) => {
+        const fixed = `${HOST}/news/image/${src.replace(/^\/*/, '')}`
+        
+        return `![${alt}](${fixed})`
+      })
+    // 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 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) {
     const trimmed = block.trim();
+    
     if (!trimmed) continue;
     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('## ')) {
-      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('### ')) {
-      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('- ')) {
       const lines = trimmed.split('\n');
       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)) });
     } else if (trimmed.match(/!\[.*\]\(.*\)/)) {
       const imgMatch = trimmed.match(/!\[([^\]]*)\]\(([^)]+)\)/);
+      
       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 {
-      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 {
-  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 || '图文资讯')
 
-![健康饮食图片](/static/carousel/BHFIIABBCDJII-5kCEkD6zh9.jpg)
-
-### 什么是均衡饮食?
-
-均衡饮食是指摄入各种营养素的比例适当,既能满足机体需要,又不会造成营养过剩或缺乏。
+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>
 
@@ -140,6 +410,22 @@ const parsedMarkdown = computed(() => {
   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 {
   padding-inline: 40rpx;
 }
@@ -202,7 +488,9 @@ const parsedMarkdown = computed(() => {
 }
 
 .img {
+  display: block;
   width: 100%;
+  height: auto;
   margin: 10rpx 0;
   border-radius: 8rpx;
 }
@@ -218,4 +506,69 @@ const parsedMarkdown = computed(() => {
   font-size: 32rpx;
   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>

+ 15 - 0
src/utils/downloadWithAuth.ts

@@ -0,0 +1,15 @@
+export async function downloadWithAuth(url: string) {
+  if (!url) return ''
+  const token = uni.getStorageSync('token')
+  if (!token) return ''
+  try {
+    const res: any = await uni.downloadFile({ url, header: { Authorization: `Bearer ${token}` } })
+    if (res && res.statusCode === 200 && res.tempFilePath) {
+      return res.tempFilePath
+    }
+    return ''
+  } catch (err) {
+    console.error('[downloadWithAuth] failed', err)
+    return ''
+  }
+}