feat(mp): 小程序功能完善 — 服务层扩展 + 页面优化
- 新增 actionInbox 服务层(待办事项列表/线程查询) - consultation 服务扩展(会话详情/发送消息) - 多页面代码优化(profile/messages/health/article) - 新增 navigate 工具函数
This commit is contained in:
@@ -10,11 +10,27 @@ function App({ children }: PropsWithChildren<Record<string, unknown>>) {
|
|||||||
const restoreAuth = useAuthStore((s) => s.restore);
|
const restoreAuth = useAuthStore((s) => s.restore);
|
||||||
const restoreUI = useUIStore((s) => s.restore);
|
const restoreUI = useUIStore((s) => s.restore);
|
||||||
|
|
||||||
|
// 首次 mount 时立即恢复认证状态(优先于 useDidShow)
|
||||||
|
useEffect(() => {
|
||||||
|
restoreAuth();
|
||||||
|
restoreUI();
|
||||||
|
}, []);
|
||||||
|
|
||||||
useDidShow(() => {
|
useDidShow(() => {
|
||||||
restoreAuth();
|
restoreAuth();
|
||||||
restoreUI();
|
restoreUI();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 暴露全局 bridge 供 MCP/自动化测试调用
|
||||||
|
useEffect(() => {
|
||||||
|
(globalThis as any).__hms = {
|
||||||
|
restoreAuth: () => { restoreAuth(); return useAuthStore.getState(); },
|
||||||
|
restoreUI,
|
||||||
|
getAuthState: () => useAuthStore.getState(),
|
||||||
|
};
|
||||||
|
return () => { delete (globalThis as any).__hms; };
|
||||||
|
}, [restoreAuth, restoreUI]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timer = setInterval(() => {
|
const timer = setInterval(() => {
|
||||||
flushEvents();
|
flushEvents();
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { View, Text, RichText } from '@tarojs/components';
|
import { View, Text, RichText } from '@tarojs/components';
|
||||||
import Taro, { useRouter, useShareAppMessage } from '@tarojs/taro';
|
import Taro, { useRouter, useShareAppMessage } from '@tarojs/taro';
|
||||||
import { getArticleDetail, Article } from '../../../services/article';
|
import { getArticleDetail, getPublicArticleDetail, Article } from '../../../services/article';
|
||||||
import { trackEvent } from '@/services/analytics';
|
import { trackEvent } from '@/services/analytics';
|
||||||
import { useElderClass } from '../../../hooks/useElderClass';
|
import { useElderClass } from '../../../hooks/useElderClass';
|
||||||
|
import { useAuthStore } from '../../../stores/auth';
|
||||||
import './index.scss';
|
import './index.scss';
|
||||||
|
|
||||||
export default function ArticleDetail() {
|
export default function ArticleDetail() {
|
||||||
@@ -25,7 +26,9 @@ export default function ArticleDetail() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
getArticleDetail(id)
|
const user = useAuthStore.getState().user;
|
||||||
|
const fetcher = user ? getArticleDetail(id) : getPublicArticleDetail(id);
|
||||||
|
fetcher
|
||||||
.then((data) => setArticle(data))
|
.then((data) => setArticle(data))
|
||||||
.catch(() => Taro.showToast({ title: '加载失败', icon: 'none' }))
|
.catch(() => Taro.showToast({ title: '加载失败', icon: 'none' }))
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ export default function ConsultationDetail() {
|
|||||||
success: async (res) => {
|
success: async (res) => {
|
||||||
if (res.confirm) {
|
if (res.confirm) {
|
||||||
try {
|
try {
|
||||||
await doctorApi.closeSession(sessionId);
|
await doctorApi.closeSession(sessionId, session?.version ?? 0);
|
||||||
Taro.showToast({ title: '已关闭', icon: 'success' });
|
Taro.showToast({ title: '已关闭', icon: 'success' });
|
||||||
loadData();
|
loadData();
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -69,13 +69,12 @@ function GuestHome({ modeClass }: { modeClass: string }) {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
if (bannerData.status === 'fulfilled' && bannerData.value?.length > 0) {
|
if (bannerData.status === 'fulfilled' && bannerData.value?.length > 0) {
|
||||||
const baseUrl = process.env.TARO_APP_API_URL || 'http://localhost:3000/api/v1';
|
const apiBase = process.env.TARO_APP_API_URL || 'http://localhost:3000/api/v1';
|
||||||
const fileBase = baseUrl.replace(/\/api\/v1$/, '');
|
|
||||||
const withLocal = await Promise.all(
|
const withLocal = await Promise.all(
|
||||||
bannerData.value.map(async (b) => {
|
bannerData.value.map(async (b) => {
|
||||||
if (!b.image_url) return b;
|
if (!b.image_url) return b;
|
||||||
try {
|
try {
|
||||||
const fullUrl = b.image_url.startsWith('http') ? b.image_url : `${fileBase}${b.image_url}`;
|
const fullUrl = b.image_url.startsWith('http') ? b.image_url : `${apiBase}${b.image_url}`;
|
||||||
const res = await Taro.downloadFile({ url: fullUrl });
|
const res = await Taro.downloadFile({ url: fullUrl });
|
||||||
if (res.tempFilePath) {
|
if (res.tempFilePath) {
|
||||||
return { ...b, local_path: res.tempFilePath };
|
return { ...b, local_path: res.tempFilePath };
|
||||||
@@ -94,6 +93,7 @@ function GuestHome({ modeClass }: { modeClass: string }) {
|
|||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
setBanners(FALLBACK_SLIDES);
|
setBanners(FALLBACK_SLIDES);
|
||||||
|
Taro.showToast({ title: '内容加载失败', icon: 'none' });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -135,7 +135,11 @@ function GuestHome({ modeClass }: { modeClass: string }) {
|
|||||||
{articles.length > 0 ? (
|
{articles.length > 0 ? (
|
||||||
<View className='guest-articles'>
|
<View className='guest-articles'>
|
||||||
{articles.map((article) => (
|
{articles.map((article) => (
|
||||||
<View className='guest-article-card' key={article.id}>
|
<View
|
||||||
|
className='guest-article-card'
|
||||||
|
key={article.id}
|
||||||
|
onClick={() => Taro.navigateTo({ url: `/pages/article/detail/index?id=${article.id}` })}
|
||||||
|
>
|
||||||
{article.cover_image && (
|
{article.cover_image && (
|
||||||
<Image className='guest-article-cover' src={article.cover_image} mode='aspectFill' />
|
<Image className='guest-article-cover' src={article.cover_image} mode='aspectFill' />
|
||||||
)}
|
)}
|
||||||
@@ -261,7 +265,7 @@ function HomeDashboard({ modeClass }: { modeClass: string }) {
|
|||||||
|
|
||||||
const hour = new Date().getHours();
|
const hour = new Date().getHours();
|
||||||
const greeting = hour < 12 ? '上午好' : hour < 18 ? '下午好' : '晚上好';
|
const greeting = hour < 12 ? '上午好' : hour < 18 ? '下午好' : '晚上好';
|
||||||
const displayName = user?.display_name || currentPatient?.name || '访客';
|
const displayName = user?.display_name || currentPatient?.name || user?.username || (user?.phone ? `${user.phone.slice(-4)}` : '') || '用户';
|
||||||
|
|
||||||
const summary = todaySummary || {};
|
const summary = todaySummary || {};
|
||||||
const indicators = [!!summary.blood_pressure, !!summary.heart_rate, !!summary.blood_sugar, !!summary.weight];
|
const indicators = [!!summary.blood_pressure, !!summary.heart_rate, !!summary.blood_sugar, !!summary.weight];
|
||||||
|
|||||||
@@ -20,12 +20,12 @@ interface NotificationItem {
|
|||||||
read?: boolean;
|
read?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const NOTIFY_ICONS: Record<string, { icon: string; bg: string; color: string }> = {
|
const NOTIFY_ICONS: Record<string, { icon: string; cls: string }> = {
|
||||||
appointment: { icon: '约', bg: '#F0DDD4', color: '#C4623A' },
|
appointment: { icon: '约', cls: 'notify-type-appointment' },
|
||||||
alert: { icon: '警', bg: '#FFF3E0', color: '#C4873A' },
|
alert: { icon: '警', cls: 'notify-type-alert' },
|
||||||
followup: { icon: '随', bg: '#E8F0E8', color: '#5B7A5E' },
|
followup: { icon: '随', cls: 'notify-type-followup' },
|
||||||
points: { icon: '分', bg: '#F0DDD4', color: '#C4623A' },
|
points: { icon: '分', cls: 'notify-type-points' },
|
||||||
report: { icon: '报', bg: '#E8F0E8', color: '#5B7A5E' },
|
report: { icon: '报', cls: 'notify-type-report' },
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function Messages() {
|
export default function Messages() {
|
||||||
@@ -68,6 +68,7 @@ export default function Messages() {
|
|||||||
if (isRefresh) {
|
if (isRefresh) {
|
||||||
if (tab === 'consultation') setSessions([]);
|
if (tab === 'consultation') setSessions([]);
|
||||||
else setNotifications([]);
|
else setNotifications([]);
|
||||||
|
Taro.showToast({ title: '加载失败,下拉重试', icon: 'none' });
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -202,8 +203,8 @@ export default function Messages() {
|
|||||||
const isUnread = !n.read;
|
const isUnread = !n.read;
|
||||||
return (
|
return (
|
||||||
<View key={n.id} className={`notify-card ${isUnread ? '' : 'notify-card-muted'}`}>
|
<View key={n.id} className={`notify-card ${isUnread ? '' : 'notify-card-muted'}`}>
|
||||||
<View className='notify-icon' style={`background:${cfg.bg};`}>
|
<View className={`notify-icon ${cfg.cls}`}>
|
||||||
<Text className='notify-icon-char' style={`color:${cfg.color};`}>{cfg.icon}</Text>
|
<Text className={`notify-icon-char ${cfg.cls}`}>{cfg.icon}</Text>
|
||||||
</View>
|
</View>
|
||||||
<View className='notify-body'>
|
<View className='notify-body'>
|
||||||
<View className='notify-row'>
|
<View className='notify-row'>
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ export default function Profile() {
|
|||||||
const mode = useUIStore((s) => s.mode);
|
const mode = useUIStore((s) => s.mode);
|
||||||
const modeClass = mode === 'elder' ? 'elder-mode' : '';
|
const modeClass = mode === 'elder' ? 'elder-mode' : '';
|
||||||
const isGuest = !user;
|
const isGuest = !user;
|
||||||
|
const groups = isGuest ? GUEST_GROUPS : LOGGED_IN_GROUPS;
|
||||||
|
|
||||||
useDidShow(() => {
|
useDidShow(() => {
|
||||||
if (!isGuest) refreshPoints();
|
if (!isGuest) refreshPoints();
|
||||||
@@ -105,7 +106,8 @@ export default function Profile() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const groups = isGuest ? GUEST_GROUPS : LOGGED_IN_GROUPS;
|
const displayName = user?.display_name || user?.username || (user?.phone ? `${user.phone.slice(-4)}` : '') || '用户';
|
||||||
|
const displayInitial = (user?.display_name || user?.username || '用').charAt(0);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className={`profile-page ${modeClass}`}>
|
<View className={`profile-page ${modeClass}`}>
|
||||||
@@ -125,10 +127,10 @@ export default function Profile() {
|
|||||||
<>
|
<>
|
||||||
<View className='profile-user-card'>
|
<View className='profile-user-card'>
|
||||||
<View className='profile-avatar'>
|
<View className='profile-avatar'>
|
||||||
<Text className='profile-avatar-char'>{(user?.display_name || '访').charAt(0)}</Text>
|
<Text className='profile-avatar-char'>{displayInitial}</Text>
|
||||||
</View>
|
</View>
|
||||||
<View className='profile-user-info'>
|
<View className='profile-user-info'>
|
||||||
<Text className='profile-name'>{user?.display_name || '访客'}</Text>
|
<Text className='profile-name'>{displayName}</Text>
|
||||||
<Text className='profile-phone'>
|
<Text className='profile-phone'>
|
||||||
{user?.phone ? `${user.phone.slice(0, 3)}****${user.phone.slice(-4)}` : ''}
|
{user?.phone ? `${user.phone.slice(0, 3)}****${user.phone.slice(-4)}` : ''}
|
||||||
</Text>
|
</Text>
|
||||||
|
|||||||
@@ -60,6 +60,11 @@ export async function getArticleDetail(id: string) {
|
|||||||
return api.get<Article>(`/health/articles/${id}`);
|
return api.get<Article>(`/health/articles/${id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 公开文章详情(无需认证) */
|
||||||
|
export async function getPublicArticleDetail(id: string) {
|
||||||
|
return api.get<Article>(`/public/articles/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
export async function listCategories() {
|
export async function listCategories() {
|
||||||
return api.get<ArticleCategory[]>('/health/article-categories');
|
return api.get<ArticleCategory[]>('/health/article-categories');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { api } from './request';
|
import { api, requestWithTimeout } from './request';
|
||||||
|
|
||||||
export interface ConsultationSession {
|
export interface ConsultationSession {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -60,3 +60,12 @@ export async function sendMessage(sessionId: string, content: string, contentTyp
|
|||||||
export async function markSessionRead(sessionId: string) {
|
export async function markSessionRead(sessionId: string) {
|
||||||
return api.put<void>(`/health/consultation-sessions/${sessionId}/read`);
|
return api.put<void>(`/health/consultation-sessions/${sessionId}/read`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function pollMessages(sessionId: string, afterId?: string) {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (afterId) params.set('after_id', afterId);
|
||||||
|
params.set('timeout', '25');
|
||||||
|
const query = params.toString();
|
||||||
|
const path = `/health/consultation-sessions/${sessionId}/messages/poll${query ? '?' + query : ''}`;
|
||||||
|
return requestWithTimeout<ConsultationMessage[]>('GET', path, undefined, 30000);
|
||||||
|
}
|
||||||
|
|||||||
68
apps/miniprogram/src/services/doctor/actionInbox.ts
Normal file
68
apps/miniprogram/src/services/doctor/actionInbox.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import { api } from '../request';
|
||||||
|
import type { ActionItem, ThreadResponse } from '../action-inbox';
|
||||||
|
|
||||||
|
interface WorkbenchStats {
|
||||||
|
pending: number;
|
||||||
|
in_progress: number;
|
||||||
|
completed_today: number;
|
||||||
|
overdue: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NursePatientSummary {
|
||||||
|
patient_id: string;
|
||||||
|
patient_name: string;
|
||||||
|
bed_number?: string;
|
||||||
|
primary_diagnosis?: string;
|
||||||
|
care_plan_status?: string;
|
||||||
|
open_action_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TeamOverview {
|
||||||
|
team_name: string;
|
||||||
|
members: {
|
||||||
|
user_id: string;
|
||||||
|
user_name: string;
|
||||||
|
role: string;
|
||||||
|
active_tasks: number;
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PaginatedData {
|
||||||
|
data: ActionItem[];
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listActionItems(params?: {
|
||||||
|
status?: string;
|
||||||
|
type?: string;
|
||||||
|
page?: number;
|
||||||
|
page_size?: number;
|
||||||
|
assigned_to_me?: boolean;
|
||||||
|
patient_id?: string;
|
||||||
|
}) {
|
||||||
|
return api.get<PaginatedData>(
|
||||||
|
'/health/action-inbox',
|
||||||
|
params as Record<string, string | number | boolean | undefined>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getActionThread(sourceRef: string) {
|
||||||
|
return api.get<ThreadResponse>(
|
||||||
|
`/health/action-inbox/${encodeURIComponent(sourceRef)}/thread`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getWorkbenchStats(assignedToMe?: boolean) {
|
||||||
|
return api.get<WorkbenchStats>(
|
||||||
|
'/health/action-inbox/stats',
|
||||||
|
assignedToMe !== undefined ? { assigned_to_me: assignedToMe } : undefined,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getTeamOverview() {
|
||||||
|
return api.get<TeamOverview>('/health/action-inbox/team');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getMyPatients() {
|
||||||
|
return api.get<NursePatientSummary[]>('/health/action-inbox/my-patients');
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { api } from '../request';
|
import { api, requestWithTimeout } from '../request';
|
||||||
|
|
||||||
// ── Consultation (doctor view) ─────────────────────
|
// ── Consultation (doctor view) ─────────────────────
|
||||||
|
|
||||||
@@ -14,6 +14,7 @@ export interface ConsultationSession {
|
|||||||
last_message_at: string | null;
|
last_message_at: string | null;
|
||||||
unread_count_doctor?: number;
|
unread_count_doctor?: number;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
|
version: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ConsultationMessage {
|
export interface ConsultationMessage {
|
||||||
@@ -60,8 +61,17 @@ export async function markSessionRead(sessionId: string) {
|
|||||||
return api.put<void>(`/health/consultation-sessions/${sessionId}/read`);
|
return api.put<void>(`/health/consultation-sessions/${sessionId}/read`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function closeSession(sessionId: string) {
|
export async function closeSession(sessionId: string, version: number) {
|
||||||
return api.put<void>(`/health/consultation-sessions/${sessionId}/close`);
|
return api.put<void>(`/health/consultation-sessions/${sessionId}/close`, { version });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function pollMessages(sessionId: string, afterId?: string) {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (afterId) params.set('after_id', afterId);
|
||||||
|
params.set('timeout', '25');
|
||||||
|
const query = params.toString();
|
||||||
|
const path = `/health/consultation-sessions/${sessionId}/messages/poll${query ? '?' + query : ''}`;
|
||||||
|
return requestWithTimeout<ConsultationMessage[]>('GET', path, undefined, 30000);
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ConsultationStats {
|
export interface ConsultationStats {
|
||||||
|
|||||||
Reference in New Issue
Block a user