فهرست منبع

feat(doctor): 新增健康资讯管理页面及编辑功能,优化资讯详情展示

mcbaiyun 4 هفته پیش
والد
کامیت
5436aae183

+ 13 - 1
src/pages.json

@@ -115,7 +115,7 @@
 			}
 		},
 		{
-			"path": "pages/patient/index/news-detail",
+			"path": "pages/public/news-detail",
 			"style": {
 				"navigationBarTitleText": "图文资讯"
 			}
@@ -192,6 +192,18 @@
 				"navigationBarTitleText": "药品信息管理"
 			}
 		},
+        {
+            "path": "pages/doctor/manage/news",
+            "style": {
+                "navigationBarTitleText": "健康资讯管理"
+            }
+        },
+		{
+			"path": "pages/doctor/manage/news-edit",
+			"style": {
+				"navigationBarTitleText": "编辑资讯"
+			}
+		},
 		{
 			"path": "pages/patient/index/family",
 			"style": {

+ 8 - 0
src/pages/doctor/manage/index.vue

@@ -8,6 +8,11 @@
           <text class="menu-text">药品信息管理</text>
           <uni-icons class="menu-arrow" type="arrowright" size="20" color="#c0c0c0" />
         </view>
+        <view class="menu-item" @click="onItemClick('健康资讯管理')">
+          <image src="/static/icons/remixicon/article-line.svg" class="menu-icon" mode="widthFix" />
+          <text class="menu-text">健康资讯管理</text>
+          <uni-icons class="menu-arrow" type="arrowright" size="20" color="#c0c0c0" />
+        </view>
       </view>
     </view>
   </view>
@@ -32,6 +37,9 @@ function onItemClick(type: string) {
     case '药品信息管理':
       uni.navigateTo({ url: '/pages/doctor/manage/medicine' })
       break
+    case '健康资讯管理':
+      uni.navigateTo({ url: '/pages/doctor/manage/news' })
+      break
     default:
       uni.showToast({ title: `${type} 功能正在开发中`, icon: 'none' })
   }

+ 206 - 0
src/pages/doctor/manage/news-edit.vue

@@ -0,0 +1,206 @@
+<template>
+  <CustomNav title="编辑资讯" leftType="back" />
+  <view class="page-container">
+    <view class="form-card">
+      <view class="form-item">
+        <text class="label">标题</text>
+        <input v-model="title" placeholder="输入标题(不超过30字)" maxlength="30" />
+      </view>
+
+      <view class="form-item">
+        <text class="label">概要</text>
+        <input v-model="summary" placeholder="输入概要(不超过50字)" maxlength="50" @input="onSummaryInput" />
+        <text class="hint">{{ summary.length }} / 50</text>
+      </view>
+
+      <!-- 封面不单独存储,封面由 Markdown 中第一张图片决定 -->
+
+      <view class="form-item">
+        <text class="label">内容(Markdown)</text>
+        <textarea
+          ref="mdarea"
+          v-model="content"
+          class="markdown-area"
+          :style="textareaStyle"
+          placeholder="在此输入 Markdown 内容"
+          @input="onInput"
+          @focus="onFocus"
+        ></textarea>
+      </view>
+
+      <view class="actions">
+        <button class="btn primary" @click="save">保存</button>
+        <button class="btn secondary" @click="cancel">取消</button>
+      </view>
+    </view>
+  </view>
+  <view class="fab-insert" @click="chooseImage" role="button" aria-label="插入图片">
+    <image class="fab-icon" src="/static/icons/remixicon/image-line.svg" mode="widthFix" />
+  </view>
+</template>
+
+<script setup lang="ts">
+import { ref, onMounted, nextTick, computed } from 'vue'
+import CustomNav from '@/components/custom-nav.vue'
+
+const query: Record<string, string> = (((globalThis as any).getCurrentPages ? (globalThis as any).getCurrentPages().slice(-1)[0]?.options : {}) as Record<string, string>) || {}
+
+const title = ref('')
+const summary = ref('')
+const content = ref('')
+const cover = ref('')
+
+const onSummaryInput = (e: any) => {
+  if (summary.value.length > 50) {
+    summary.value = summary.value.substring(0, 50)
+    uni.showToast({ title: '概要不能超过50字', icon: 'none' })
+  }
+}
+
+
+const mdarea = ref<any>(null)
+const cursorPos = ref(0)
+// 文本区域高度(rpx 单位字符串),JS 内部控制
+const textAreaHeight = ref('420rpx')
+const textareaStyle = computed(() => ({ height: textAreaHeight.value }))
+
+const onInput = (e: any) => {
+  // uni-app textarea 的 input 事件通常包含 detail.value 和 detail.cursor
+  if (e && e.detail) {
+    content.value = e.detail.value
+    cursorPos.value = typeof e.detail.cursor === 'number' ? e.detail.cursor : content.value.length
+  }
+  // 每次输入后尝试调整高度
+  adjustTextareaHeight()
+}
+
+// 根据内容估算高度并设置到 textAreaHeight(单位 rpx)
+const adjustTextareaHeight = () => {
+  try {
+    const text = content.value || ''
+    const lines = (text.match(/\n/g) || []).length + 1
+    const estimated = Math.max(300, Math.min(1200, lines * 44))
+    textAreaHeight.value = estimated + 'rpx'
+  } catch (err) {
+    // ignore
+  }
+}
+
+const onFocus = (e: any) => {
+  if (e && e.detail) {
+    cursorPos.value = typeof e.detail.cursor === 'number' ? e.detail.cursor : cursorPos.value
+  }
+}
+
+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})`
+      const before = content.value.slice(0, cursorPos.value)
+      const after = content.value.slice(cursorPos.value)
+      content.value = before + md + after
+      // 更新光标位置到插入位置之后
+      cursorPos.value = (before + md).length
+      // 尝试聚焦并将光标置于新位置(不同平台行为可能不同)
+      await nextTick()
+      try {
+        if (mdarea.value && typeof mdarea.value.focus === 'function') {
+          mdarea.value.focus()
+        }
+      } catch (err) {
+        // ignore
+      }
+      // 插入图片后调整高度
+      adjustTextareaHeight()
+    }
+  }})
+}
+
+const save = () => {
+  if (!title.value.trim()) {
+    uni.showToast({ title: '请输入标题', icon: 'none' })
+    return
+  }
+  if (!content.value.trim()) {
+    uni.showToast({ title: '请输入内容', icon: 'none' })
+    return
+  }
+  // 简单模拟保存:实际应调用后台 API
+  uni.showToast({ title: '保存成功(模拟)', icon: 'success' })
+  setTimeout(() => {
+    uni.navigateBack()
+  }, 600)
+}
+
+const cancel = () => {
+  uni.navigateBack()
+}
+
+onMounted(() => {
+  // 如果是编辑,读取 id 并可调用接口拉取详情(这里模拟)
+  const id = query.id || ''
+  if (id) {
+    // TODO: 用接口拉取真实数据,当前使用模拟
+    title.value = '示例资讯标题 - 编辑'
+    summary.value = '这是示例概要,用于编辑场景显示。'
+    content.value = '# 示例内容\n\n示例 Markdown 内容...'
+    cover.value = ''
+  }
+  // 初始调整编辑框高度以适配可能存在的内容
+  nextTick(() => {
+    adjustTextareaHeight()
+  })
+})
+</script>
+
+<style scoped>
+.page-container { padding-top: calc(var(--status-bar-height) + 44px); min-height: 100vh; background: #f5f5f5; padding-bottom: 60rpx }
+.form-card { background: #fff; margin: 20rpx; border-radius: 12rpx; padding: 28rpx }
+.form-item { margin-bottom: 20rpx }
+.label { display: block; font-size: 28rpx; color: #222; margin-bottom: 10rpx }
+input, textarea { width: 100%; box-sizing: border-box; padding: 12rpx; border-radius: 8rpx; border: 1rpx solid #eee; font-size: 28rpx }
+.markdown-area { min-height: 420rpx; resize: none }
+/* 增大标题和概要输入框高度以提升可编辑空间 */
+.form-item input { height: 88rpx; line-height: 88rpx; padding-top: 0; padding-bottom: 0 }
+.cover-row { display:flex; align-items:center; gap:12rpx }
+.cover-preview { width: 160rpx; height: 110rpx; object-fit:cover; border-radius:8rpx }
+.choose-btn { padding: 10rpx 14rpx; border-radius:8rpx; background:#2d8cf0; color:#fff; border:none }
+.hint { font-size: 24rpx; color: #999; margin-top: 6rpx }
+.actions { display:flex; gap:12rpx; justify-content:flex-end; margin-top: 10rpx }
+
+/* Button styles: use consistent primary/secondary appearance */
+/* Flatter buttons: reduce vertical padding and corner radius */
+.btn { padding: 10rpx 30rpx; border-radius: 8rpx; background: #fff; border: 1rpx solid #e6e6e6; font-size: 28rpx; color: #333; box-shadow: none; display: inline-flex; align-items: center; justify-content: center; }
+.btn:active { transform: translateY(1rpx); }
+
+.btn.primary { background: linear-gradient(180deg,#6fb7ff,#2d8cf0); color:#fff; border:none; box-shadow: 0 6rpx 18rpx rgba(45,140,240,0.10); }
+/* Secondary (cancel) button - lighter */
+.btn.secondary { background: #f0f0f0; color: #666; border: none; }
+.btn.secondary:hover { transform: translateY(-1rpx); }
+.btn.primary:hover { transform: translateY(-2rpx); box-shadow: 0 14rpx 40rpx rgba(45,140,240,0.14); }
+.btn.secondary:hover { transform: translateY(-2rpx); }
+
+/* 悬浮插入图片按钮 */
+.fab-insert {
+  position: fixed;
+  right: 28rpx;
+  bottom: 120rpx;
+  width: 110rpx;
+  height: 110rpx;
+  border-radius: 999px;
+  background: linear-gradient(180deg, #4a90e2, #2d8cf0);
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  color: #fff;
+  font-size: 0; /* 使用图标,不显示文字 */
+  z-index: 1400;
+  box-shadow: 0 8rpx 20rpx rgba(0,0,0,0.18);
+  border: none;
+}
+.fab-insert:active { transform: translateY(2rpx) }
+
+.fab-icon { width: 56rpx; height: 56rpx; display: block; -webkit-filter: invert(1) brightness(2); filter: invert(1) brightness(2); }
+</style>

+ 433 - 0
src/pages/doctor/manage/news.vue

@@ -0,0 +1,433 @@
+<template>
+  <CustomNav title="健康资讯管理" leftType="back" />
+  <view class="page-container">
+      <view class="action-bar">
+        <view class="search-wrap">
+          <input class="search-input" placeholder="搜索标题或概要" v-model="searchKey" @confirm="onSearch" />
+          <view class="search-btn" @click="onSearch">
+            <uni-icons type="search" size="22" color="#fff" />
+          </view>
+        </view>
+
+      </view>
+
+    <view v-if="isLoading" class="skeleton-list">
+      <view v-for="i in 3" :key="i" class="skeleton-card">
+        <view class="skeleton-cover" />
+        <view class="skeleton-meta">
+          <view class="skeleton-title" />
+          <view class="skeleton-summary" />
+        </view>
+      </view>
+    </view>
+    <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" />
+        </view>
+        <view v-else class="news-placeholder">
+              <image class="placeholder-icon" src="/static/icons/remixicon/image-line.svg" />
+            </view>
+        <view class="meta">
+          <text class="title">{{ item.title }}</text>
+          <text class="summary">{{ item.summary }}</text>
+          <text class="time">发布时间: {{ item.publishTime }}</text>
+        </view>
+      </view>
+
+      <view class="action-buttons">
+        <button class="action-btn primary" @click="viewNews(item)">查看</button>
+        <button class="action-btn secondary" @click="editNews(item)">编辑</button>
+        <button class="action-btn danger" @click="removeNews(item)">删除</button>
+      </view>
+    </view>
+
+    <view class="empty-state" v-if="filteredList.length === 0">
+      <view class="news-placeholder">
+          <image class="placeholder-icon" src="/static/icons/remixicon/image-line.svg" />
+        </view>
+      <text class="empty-text">暂无资讯</text>
+      <button class="create-cta" @click="onCreateNews">现在创建</button>
+    </view>
+    <view class="loadmore" v-if="hasMore">
+      <button class="load-btn" @click="loadMore">加载更多</button>
+    </view>
+  </view>
+  <!-- 悬浮新建按钮(样式参考 physical.vue) -->
+  <view class="fab" @click="onCreateNews" role="button" aria-label="新建资讯">
+    <view class="fab-inner">+</view>
+  </view>
+</template>
+
+<script setup lang="ts">
+import { ref, computed } from 'vue'
+import { onShow, onPullDownRefresh } from '@dcloudio/uni-app'
+import CustomNav from '@/components/custom-nav.vue'
+
+interface NewsItem {
+  id: string
+  title: string
+  summary?: string
+  content?: string
+  coverImage?: string
+  publishTime?: 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 searchKey = ref('')
+const defaultCover = '/static/icons/remixicon/image-line.svg'
+const pageSize = ref(10)
+const currentPage = ref(1)
+const hasMore = ref(false)
+const isLoading = ref(false)
+
+const onCreateNews = () => {
+  // 跳转到编辑/新建页(新建时不带 id)
+  uni.navigateTo({ url: `/pages/doctor/manage/news-edit` })
+}
+
+const fetchNews = async () => {
+  // 模拟网络请求,真实场景使用 API 请求
+  isLoading.value = true
+  await new Promise(resolve => setTimeout(resolve, 350))
+  isLoading.value = false
+}
+
+const viewNews = (item: NewsItem) => {
+  // 跳转到详情页(已迁移到 `pages/public/news-detail`)
+  uni.navigateTo({ url: `/pages/public/news-detail?id=${item.id}` })
+}
+
+const editNews = (item: NewsItem) => {
+  // 带 id 跳转到编辑页
+  uni.navigateTo({ url: `/pages/doctor/manage/news-edit?id=${item.id}` })
+}
+
+const removeNews = (item: NewsItem) => {
+  uni.showModal({
+    title: '确认删除',
+    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' })
+        }
+      }
+    }
+  })
+}
+
+const onSearch = () => {
+  // 简单的本地过滤,后续可替换为后端搜索API
+  currentPage.value = 1
+}
+
+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 loadMore = () => {
+  currentPage.value += 1
+}
+
+onShow(() => {
+  const token = uni.getStorageSync('token')
+  if (!token) {
+    uni.reLaunch({ url: '/pages/public/login/index' })
+  }
+  fetchNews()
+})
+
+onPullDownRefresh(() => {
+  // 下拉刷新
+  currentPage.value = 1
+  fetchNews().then(() => uni.stopPullDownRefresh())
+})
+</script>
+
+<style scoped>
+.page-container {
+  min-height: 100vh;
+  background-color: #f5f5f5;
+  padding-top: calc(var(--status-bar-height) + 44px);
+  padding-bottom: 40rpx;
+}
+.action-bar {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  gap: 18rpx;
+  padding: 28rpx 20rpx;
+}
+.create-btn {
+  background-color: #2d8cf0;
+  color: #fff;
+  border-radius: 10rpx;
+  padding: 18rpx 26rpx;
+  font-size: 28rpx;
+  min-width: 140rpx;
+}
+
+.search-wrap {
+  display: flex;
+  align-items: center;
+  gap: 12rpx;
+  flex: 1; /* 自动撑满剩余空间 */
+}
+
+/* 悬浮按钮 */
+.fab {
+  position: fixed;
+  right: 28rpx;
+  bottom: 160rpx; /* 类似 physical.vue 的位置 */
+  width: 110rpx;
+  height: 110rpx;
+  border-radius: 999px;
+  background: linear-gradient(180deg, #4a90e2, #2d8cf0); /* 蓝色渐变 */
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  box-shadow: 0 6rpx 18rpx rgba(0, 0, 0, 0.2);
+  z-index: 1200;
+  border: none;
+}
+.fab:active { transform: translateY(2rpx); }
+.fab:focus { outline: none; }
+.fab-inner { color: #fff; font-size: 56rpx; line-height: 56rpx }
+.search-input {
+  flex: 1;
+  height: 64rpx;
+  border-radius: 12rpx;
+  padding: 0 20rpx;
+  border: 1rpx solid #e6e6e6;
+  font-size: 28rpx;
+  background: #fff;
+}
+.search-btn {
+  width: 64rpx;
+  height: 64rpx;
+  border-radius: 12rpx;
+  background-color: #4a90e2;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+
+.news-card {
+  background-color: #fff;
+  margin: 18rpx 20rpx;
+  border-radius: 12rpx;
+  overflow: hidden;
+  box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.06);
+  transition: transform 0.12s ease, box-shadow 0.12s ease;
+}
+.news-card:hover {
+  transform: translateY(-4rpx);
+  box-shadow: 0 10rpx 30rpx rgba(0, 0, 0, 0.12);
+}
+.news-content {
+  display: flex;
+  padding: 20rpx;
+  gap: 20rpx;
+}
+.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;
+}
+/* empty-placeholder removed to keep strict parity with patient/index styles */
+.meta {
+  flex: 1;
+  display: flex;
+  flex-direction: column;
+  justify-content: space-between;
+}
+.title {
+  font-size: 34rpx;
+  line-height: 1.4;
+  color: #222;
+  font-weight: 600;
+}
+.summary {
+  font-size: 28rpx;
+  margin-top: 8rpx;
+  color: #666;
+  line-height: 1.6;
+  display: -webkit-box;
+  -webkit-box-orient: vertical;
+  -webkit-line-clamp: 2; /* limit to 2 lines */
+  line-clamp: 2;
+  overflow: hidden;
+}
+.time {
+  font-size: 24rpx;
+  margin-top: 12rpx;
+}
+
+.action-buttons {
+  display: flex;
+  gap: 12rpx;
+  padding: 12rpx 18rpx;
+  border-top: 1rpx solid #f5f5f5;
+  justify-content: flex-end;
+  flex-wrap: wrap;
+}
+.action-btn {
+  padding: 10rpx 22rpx;
+  border-radius: 20rpx;
+  min-width: 110rpx;
+  height: 64rpx;
+  line-height: 44rpx;
+  background: #fff;
+  color: #333;
+  font-size: 28rpx;
+  border: 1rpx solid #e6e6e6;
+  box-shadow: none;
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  transition: transform 0.12s ease, box-shadow 0.12s ease;
+}
+.action-btn:active { transform: translateY(2rpx); }
+.action-btn:hover { box-shadow: 0 6rpx 12rpx rgba(0,0,0,0.06); }
+.primary {
+  background: linear-gradient(180deg,#6fb7ff,#2d8cf0);
+  color: #fff;
+  border: none;
+  box-shadow: 0 10rpx 28rpx rgba(45,140,240,0.12);
+}
+.danger {
+  background: linear-gradient(180deg,#ff8a8a,#ff6b6b);
+  color: #fff;
+  border: none;
+  box-shadow: 0 10rpx 28rpx rgba(255,107,107,0.12);
+}
+
+/* 次要按钮样式:比主按钮弱一点,但比纯白更协调 */
+.secondary {
+  background: linear-gradient(180deg,#cfeeff,#9fd7ff); /* 更明显的浅蓝填充 */
+  color: #063a7a; /* 深蓝文字,保持可读性 */
+  border: none;
+  box-shadow: 0 8rpx 20rpx rgba(45,140,240,0.08);
+}
+.secondary:hover {
+  transform: translateY(-2rpx);
+  box-shadow: 0 12rpx 28rpx rgba(45,140,240,0.12);
+}
+
+.empty-state {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  padding: 100rpx 40rpx;
+}
+.empty-icon {
+  width: 160rpx;
+  height: 160rpx;
+}
+.empty-text {
+  font-size: 36rpx;
+}
+
+.skeleton-list {
+  padding: 18rpx 18rpx;
+}
+.skeleton-card {
+  background: #fff;
+  display: flex;
+  gap: 18rpx;
+  padding: 16rpx;
+  border-radius: 12rpx;
+  margin-bottom: 16rpx;
+  align-items: center;
+}
+.skeleton-cover {
+  width: 160rpx;
+  height: 96rpx;
+  border-radius: 8rpx;
+  background: linear-gradient(90deg, #eee, #f6f6f6);
+}
+.skeleton-meta {
+  flex: 1;
+}
+.skeleton-title {
+  width: 60%;
+  height: 22rpx;
+  background: linear-gradient(90deg, #eee, #f6f6f6);
+  margin-bottom: 8rpx;
+  border-radius: 6rpx;
+}
+.skeleton-summary {
+  width: 80%;
+  height: 16rpx;
+  background: linear-gradient(90deg, #eee, #f6f6f6);
+  border-radius: 6rpx;
+}
+
+.create-cta {
+  margin-top: 18rpx;
+  background-color: #2d8cf0;
+  color: #fff;
+  padding: 16rpx 28rpx;
+  border-radius: 12rpx;
+}
+
+.loadmore {
+  padding: 24rpx 40rpx;
+  display: flex;
+  justify-content: center;
+}
+.load-btn {
+  background-color: #fff;
+  border: 1rpx solid #e6e6e6;
+  padding: 12rpx 30rpx;
+  border-radius: 12rpx;
+}
+</style>

+ 1 - 1
src/pages/patient/index/index.vue

@@ -410,7 +410,7 @@ function onQrClick() {
 
 const onNewsClick = (news: any) => {
   uni.navigateTo({
-    url: `/pages/patient/index/news-detail`
+    url: `/pages/public/news-detail`
   })
 }
 </script>

+ 1 - 1
src/pages/patient/index/news-detail.vue → src/pages/public/news-detail.vue

@@ -218,4 +218,4 @@ const parsedMarkdown = computed(() => {
   font-size: 32rpx;
   color: #666;
 }
-</style>
+</style>