Compare commits

...

7 Commits

Author SHA1 Message Date
iven
431c42289d chore: gitignore 添加临时文件排除规则 2026-05-13 23:30:45 +08:00
iven
675d5a3405 feat(mp): 新增 navigate 工具函数 2026-05-13 23:29:56 +08:00
iven
df1d85bfde docs: T40 UI 审计报告 + wiki 更新 + Docker 配置
- T40 UI 审计计划和结果文档(docs/qa/)
- wiki 更新:miniprogram 设计系统合规审计记录 + index 关键数字更新
- 审计 V2 完整报告(docs/audits/v2/)
- 讨论记录文档(docs/discussions/)
- 设计规格和实施计划(docs/superpowers/)
- 角色测试计划和结果(docs/qa/role-test-*)
- Docker 生产部署配置
2026-05-13 23:29:42 +08:00
iven
212c08b7ae feat(health,ai): 后端服务优化 + 媒体文件处理
- erp-health: article/banner/consultation/media 服务层优化
- erp-ai: analysis/insight/prompt 服务增强
- erp-auth: auth/role/token 服务改进
- erp-workflow: executor 执行引擎修复
- erp-plugin: 服务层改进
- 新增媒体上传文件样例
2026-05-13 23:28:57 +08:00
iven
e4e5ef04d4 feat(web): Web 前端功能完善 — API 扩展 + 组件优化
- 新增 AI 透析分析 API + 药物提醒 API
- MediaPicker/ThemeSwitcher/usePaginatedData 优化
- 健康管理页面组件增强(Banner/Consultation/Doctor/MediaLibrary 等)
- PluginCRUDPage 导入优化
2026-05-13 23:28:22 +08:00
iven
616e0a1539 feat(mp): 小程序功能完善 — 服务层扩展 + 页面优化
- 新增 actionInbox 服务层(待办事项列表/线程查询)
- consultation 服务扩展(会话详情/发送消息)
- 多页面代码优化(profile/messages/health/article)
- 新增 navigate 工具函数
2026-05-13 23:26:38 +08:00
iven
93c77c5857 fix(mp): T40 UI 设计系统合规审计修复 — 60 页面全覆盖
- 新增 $white 语义变量 + --tk-font-display Token
- 44 处 #fff → $white,2 处 background: #fff → $card
- 14 处 border-radius 硬编码统一为 $r-xs/$r-lg/$r
- 3 处 TSX inline 颜色提取为 SCSS 类(exchange/orders/action-inbox)
- ErrorBoundary 重构:6 个 inline style → SCSS 类 + Design Token
- 2 处离调色板颜色修正(#0284C7→$tx2, #94A3B8→$tx3)
- 2 处静默 catch 块添加状态清理(article/health)
- 趋势页补 Loading/EmptyState;咨询页 GuestGuard 统一
- 4 处 #FFFFFF → $white(mixins/index/exchange/variables)
2026-05-13 23:26:00 +08:00
204 changed files with 11475 additions and 277 deletions

57
.dockerignore Normal file
View File

@@ -0,0 +1,57 @@
# Git
.git
.gitignore
# CI/CD
.github
.gitea
# Documentation
docs/
wiki/
*.md
!README.md
# IDE
.vscode/
.idea/
*.swp
*.swo
# Screenshots and temp files
screenshots/
tmp/
*.log
*.png
*.jpg
*.jpeg
*.txt
!config/*.toml
# Python
*.py
__pycache__/
# Test artifacts
plans/
.claude/
# Docker
docker/
# Build artifacts (rebuilt in container)
target/
**/node_modules/
**/dist/
# Environment files (use docker env)
.env
.env.local
.env.*.local
# OS
.DS_Store
Thumbs.db
# Large binary files
*.traineddata

11
.gitignore vendored
View File

@@ -63,4 +63,13 @@ plans/
chi_sim.traineddata
# Local settings
.claude/settings.local.json
.claude/settings.local.json
tools/
# Temp/debug files
_temp/
tmp/
screenshots/
server-log.txt
snapshot_*.txt
uploads/

99
Cargo.lock generated
View File

@@ -516,12 +516,24 @@ dependencies = [
"syn 1.0.109",
]
[[package]]
name = "bytemuck"
version = "1.25.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec"
[[package]]
name = "byteorder"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
name = "byteorder-lite"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
[[package]]
name = "bytes"
version = "1.11.1"
@@ -1537,6 +1549,7 @@ dependencies = [
"erp-core",
"hex",
"hmac",
"image",
"jsonwebtoken",
"num-traits",
"rand_core 0.6.4",
@@ -1688,6 +1701,7 @@ dependencies = [
"erp-workflow",
"futures",
"hex",
"hmac",
"metrics",
"metrics-exporter-prometheus",
"moka",
@@ -1784,6 +1798,15 @@ dependencies = [
"windows-sys 0.52.0",
]
[[package]]
name = "fdeflate"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c"
dependencies = [
"simd-adler32",
]
[[package]]
name = "find-msvc-tools"
version = "0.1.9"
@@ -2520,6 +2543,32 @@ dependencies = [
"version_check",
]
[[package]]
name = "image"
version = "0.25.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104"
dependencies = [
"bytemuck",
"byteorder-lite",
"image-webp",
"moxcms",
"num-traits",
"png",
"zune-core",
"zune-jpeg",
]
[[package]]
name = "image-webp"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3"
dependencies = [
"byteorder-lite",
"quick-error",
]
[[package]]
name = "indexmap"
version = "2.14.0"
@@ -2998,6 +3047,16 @@ dependencies = [
"uuid",
]
[[package]]
name = "moxcms"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b"
dependencies = [
"num-traits",
"pxfm",
]
[[package]]
name = "multer"
version = "3.1.0"
@@ -3488,6 +3547,19 @@ version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
[[package]]
name = "png"
version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61"
dependencies = [
"bitflags",
"crc32fast",
"fdeflate",
"flate2",
"miniz_oxide",
]
[[package]]
name = "polyval"
version = "0.6.2"
@@ -3654,6 +3726,12 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "pxfm"
version = "0.1.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f"
[[package]]
name = "quanta"
version = "0.12.6"
@@ -3669,6 +3747,12 @@ dependencies = [
"winapi",
]
[[package]]
name = "quick-error"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
[[package]]
name = "quote"
version = "1.0.45"
@@ -7058,3 +7142,18 @@ dependencies = [
"cc",
"pkg-config",
]
[[package]]
name = "zune-core"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9"
[[package]]
name = "zune-jpeg"
version = "0.5.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296"
dependencies = [
"zune-core",
]

112
Dockerfile Normal file
View File

@@ -0,0 +1,112 @@
# ==============================
# Stage 1: Build Rust backend
# ==============================
FROM rust:1.85-bookworm AS rust-builder
WORKDIR /app
# 先复制依赖文件以利用 Docker 缓存
COPY Cargo.toml Cargo.lock ./
COPY crates/erp-core/Cargo.toml crates/erp-core/Cargo.toml
COPY crates/erp-auth/Cargo.toml crates/erp-auth/Cargo.toml
COPY crates/erp-config/Cargo.toml crates/erp-config/Cargo.toml
COPY crates/erp-workflow/Cargo.toml crates/erp-workflow/Cargo.toml
COPY crates/erp-message/Cargo.toml crates/erp-message/Cargo.toml
COPY crates/erp-plugin/Cargo.toml crates/erp-plugin/Cargo.toml
COPY crates/erp-health/Cargo.toml crates/erp-health/Cargo.toml
COPY crates/erp-ai/Cargo.toml crates/erp-ai/Cargo.toml
COPY crates/erp-dialysis/Cargo.toml crates/erp-dialysis/Cargo.toml
COPY crates/erp-server/Cargo.toml crates/erp-server/Cargo.toml
COPY crates/erp-server/migration/Cargo.toml crates/erp-server/migration/Cargo.toml
COPY crates/erp-plugin-prototype/Cargo.toml crates/erp-plugin-prototype/Cargo.toml
COPY crates/erp-plugin-test-sample/Cargo.toml crates/erp-plugin-test-sample/Cargo.toml
COPY crates/erp-plugin-assessment/Cargo.toml crates/erp-plugin-assessment/Cargo.toml
COPY crates/erp-plugin-crm/Cargo.toml crates/erp-plugin-crm/Cargo.toml
COPY crates/erp-plugin-freelance/Cargo.toml crates/erp-plugin-freelance/Cargo.toml
COPY crates/erp-plugin-inventory/Cargo.toml crates/erp-plugin-inventory/Cargo.toml
COPY crates/erp-plugin-itops/Cargo.toml crates/erp-plugin-itops/Cargo.toml
# 创建空的 lib.rs/main.rs 占位以缓存依赖
RUN mkdir -p crates/erp-core/src && echo "" > crates/erp-core/src/lib.rs \
&& mkdir -p crates/erp-auth/src && echo "" > crates/erp-auth/src/lib.rs \
&& mkdir -p crates/erp-config/src && echo "" > crates/erp-config/src/lib.rs \
&& mkdir -p crates/erp-workflow/src && echo "" > crates/erp-workflow/src/lib.rs \
&& mkdir -p crates/erp-message/src && echo "" > crates/erp-message/src/lib.rs \
&& mkdir -p crates/erp-plugin/src && echo "" > crates/erp-plugin/src/lib.rs \
&& mkdir -p crates/erp-health/src && echo "" > crates/erp-health/src/lib.rs \
&& mkdir -p crates/erp-ai/src && echo "" > crates/erp-ai/src/lib.rs \
&& mkdir -p crates/erp-dialysis/src && echo "" > crates/erp-dialysis/src/lib.rs \
&& mkdir -p crates/erp-server/src && echo "fn main(){}" > crates/erp-server/src/main.rs \
&& mkdir -p crates/erp-server/migration/src && echo "" > crates/erp-server/migration/src/lib.rs \
&& for crate in erp-plugin-prototype erp-plugin-test-sample erp-plugin-assessment erp-plugin-crm erp-plugin-freelance erp-plugin-inventory erp-plugin-itops; do \
mkdir -p crates/$crate/src && echo "" > crates/$crate/src/lib.rs; \
done
# 构建依赖(仅当 Cargo.toml/Cargo.lock 变化时重新编译)
RUN cargo build --release -p erp-server 2>/dev/null || true
# 复制实际源码
COPY crates/ crates/
# 重新构建(增量编译,只编译业务代码)
RUN cargo build --release -p erp-server
# ==============================
# Stage 2: Build frontend
# ==============================
FROM node:20-alpine AS frontend-builder
WORKDIR /app
RUN corepack enable && corepack prepare pnpm@latest --activate
COPY apps/web/package.json apps/web/pnpm-lock.yaml ./apps/web/
RUN cd apps/web && pnpm install --frozen-lockfile
COPY apps/web/ ./apps/web/
RUN cd apps/web && pnpm build
# ==============================
# Stage 3: Production runtime
# ==============================
FROM debian:bookworm-slim AS runtime
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
# 复制 Rust 二进制
COPY --from=rust-builder /app/target/release/erp-server /app/erp-server
# 复制配置文件
COPY config/ /app/config/
# 复制前端构建产物
COPY --from=frontend-builder /app/apps/web/dist/ /app/static/
# 创建上传目录
RUN mkdir -p /app/uploads
# 非特权用户运行
RUN useradd -r -s /bin/false appuser \
&& chown -R appuser:appuser /app
USER appuser
# 环境变量(运行时通过 docker-compose 覆盖)
ENV ERP__SERVER__HOST=0.0.0.0
ENV ERP__SERVER__PORT=3000
ENV ERP__SERVER__METRICS_PORT=9090
ENV ERP__STORAGE__UPLOAD_DIR=/app/uploads
EXPOSE 3000 9090
VOLUME ["/app/uploads"]
HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=3 \
CMD curl -f http://localhost:3000/api/v1/health || exit 1
ENTRYPOINT ["/app/erp-server"]

View File

@@ -10,11 +10,27 @@ function App({ children }: PropsWithChildren<Record<string, unknown>>) {
const restoreAuth = useAuthStore((s) => s.restore);
const restoreUI = useUIStore((s) => s.restore);
// 首次 mount 时立即恢复认证状态(优先于 useDidShow
useEffect(() => {
restoreAuth();
restoreUI();
}, []);
useDidShow(() => {
restoreAuth();
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(() => {
const timer = setInterval(() => {
flushEvents();

View File

@@ -44,7 +44,7 @@
.sync-btn {
padding: 12rpx 28rpx;
background: $pri;
color: #fff;
color: $white;
border-radius: $r-pill;
font-size: var(--tk-font-micro);
}

View File

@@ -46,5 +46,5 @@
.empty-state-action-text {
font-size: var(--tk-font-body-lg);
color: #fff;
color: $white;
}

View File

@@ -0,0 +1,52 @@
@import '../../styles/variables.scss';
.error-boundary {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 60vh;
padding: 40px 24px;
}
.error-icon-wrap {
width: 64px;
height: 64px;
border-radius: 32px;
background: $pri-l;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 20px;
}
.error-icon-text {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: var(--tk-font-h1);
font-weight: 600;
color: $pri-d;
}
.error-title {
font-size: var(--tk-font-h2);
color: $tx;
margin-bottom: 12px;
font-weight: 600;
}
.error-desc {
font-size: var(--tk-font-body-lg);
color: $tx3;
margin-bottom: 32px;
}
.error-retry-btn {
background: $pri;
border-radius: $r-sm;
padding: 14px 48px;
}
.error-retry-text {
color: $white;
font-size: var(--tk-font-h1);
}

View File

@@ -1,5 +1,6 @@
import React, { Component } from 'react';
import { View, Text } from '@tarojs/components';
import './index.scss';
interface Props {
children: React.ReactNode;
@@ -30,17 +31,17 @@ export default class ErrorBoundary extends Component<Props, State> {
render() {
if (this.state.hasError) {
return (
<View style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', minHeight: '60vh', padding: '40px 24px' }}>
<View style={{ width: '64px', height: '64px', borderRadius: '32px', background: '#F0DDD4', display: 'flex', alignItems: 'center', justifyContent: 'center', marginBottom: '20px' }}>
<Text style={{ fontFamily: 'Georgia, serif', fontSize: '28px', fontWeight: 600, color: '#8B3E1F' }}>!</Text>
<View className='error-boundary'>
<View className='error-icon-wrap'>
<Text className='error-icon-text'>!</Text>
</View>
<Text style={{ fontSize: '32px', color: '#2D2A26', marginBottom: '12px', fontWeight: 600 }}></Text>
<Text style={{ fontSize: '24px', color: '#78716C', marginBottom: '32px' }}></Text>
<Text className='error-title'></Text>
<Text className='error-desc'></Text>
<View
className='error-retry-btn'
onClick={this.handleRetry}
style={{ background: '#C4623A', borderRadius: '12px', padding: '14px 48px' }}
>
<Text style={{ color: '#FFFFFF', fontSize: '28px' }}></Text>
<Text className='error-retry-text'></Text>
</View>
</View>
);

View File

@@ -9,7 +9,7 @@
}
.error-state-icon {
font-size: 80px; /* hero icon — kept as-is */
font-size: var(--tk-font-display);
margin-bottom: 24px;
}
@@ -28,5 +28,5 @@
.error-state-retry-text {
font-size: var(--tk-font-body-lg);
color: #fff;
color: $white;
}

View File

@@ -60,5 +60,5 @@
.guard-btn-text {
font-size: var(--tk-font-body-sm);
font-weight: 600;
color: #fff;
color: $white;
}

View File

@@ -90,16 +90,16 @@
.auto-badge-text {
display: inline-block;
padding: 4px 16px;
border-radius: 8px;
border-radius: $r-xs;
font-size: var(--tk-font-body);
font-weight: 500;
background: #f0e6ff;
color: #7c3aed;
background: $pri-l;
color: $pri;
}
.trend-tip-card {
background: #fffbeb;
border: 1px solid #fde68a;
background: $wrn-l;
border: 1px solid $wrn;
border-radius: $r;
padding: 20px 24px;
margin-bottom: 20px;
@@ -107,6 +107,6 @@
.trend-tip-text {
font-size: var(--tk-font-body);
color: #92400e;
color: $wrn;
line-height: 1.6;
}

View File

@@ -1,4 +1,5 @@
@import '../../../styles/variables.scss';
@import '../../../styles/mixins.scss';
.article-detail-page {
min-height: 100vh;
@@ -33,7 +34,7 @@
color: $pri;
background: $pri-l;
padding: 4px 12px;
border-radius: 12px;
border-radius: $r-sm;
}
.article-author {

View File

@@ -1,9 +1,10 @@
import React, { useState, useEffect } from 'react';
import { View, Text, RichText } from '@tarojs/components';
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 { useElderClass } from '../../../hooks/useElderClass';
import { useAuthStore } from '../../../stores/auth';
import './index.scss';
export default function ArticleDetail() {
@@ -25,7 +26,9 @@ export default function ArticleDetail() {
useEffect(() => {
if (!id) return;
setLoading(true);
getArticleDetail(id)
const user = useAuthStore.getState().user;
const fetcher = user ? getArticleDetail(id) : getPublicArticleDetail(id);
fetcher
.then((data) => setArticle(data))
.catch(() => Taro.showToast({ title: '加载失败', icon: 'none' }))
.finally(() => setLoading(false));

View File

@@ -1,4 +1,5 @@
@import '../../styles/variables.scss';
@import '../../styles/mixins.scss';
.article-page {
min-height: 100vh;
@@ -19,7 +20,7 @@
font-size: var(--tk-font-h1);
color: $tx2;
background: $card;
border-radius: 32px;
border-radius: $r-lg;
border: 2px solid transparent;
&--active {
@@ -87,7 +88,7 @@
color: $pri;
background: $pri-l;
padding: 2px 12px;
border-radius: 12px;
border-radius: $r-sm;
}
.article-card-date {

View File

@@ -21,7 +21,7 @@ export default function ArticleList() {
const data = await listCategories();
setCategories(data || []);
} catch {
// 静默
setCategories([]);
}
}, []);

View File

@@ -78,7 +78,7 @@
.msg-avatar {
width: 32px;
height: 32px;
border-radius: 16px;
border-radius: $r;
background: $pri-l;
@include flex-center;
flex-shrink: 0;
@@ -116,7 +116,7 @@
word-break: break-all;
.msg-bubble--self & {
color: #fff;
color: $white;
}
}
@@ -178,7 +178,7 @@
height: 40px;
background: $bg;
border: 1.5px solid $bd;
border-radius: 20px;
border-radius: $r-lg;
padding: 0 14px;
font-size: var(--tk-font-cap);
color: $tx;
@@ -187,7 +187,7 @@
.chat-send-btn {
width: 40px;
height: 40px;
border-radius: 20px;
border-radius: $r-lg;
background: $pri;
@include flex-center;
flex-shrink: 0;
@@ -200,7 +200,7 @@
.chat-send-btn__icon {
font-size: var(--tk-font-cap);
color: #fff;
color: $white;
font-weight: 600;
}

View File

@@ -35,7 +35,7 @@
.consultation-create-btn-text {
font-size: var(--tk-font-body-sm);
font-weight: 600;
color: #fff;
color: $white;
}
/* ─── 居中容器 ─── */
@@ -206,6 +206,6 @@
.session-badge-text {
font-size: var(--tk-font-micro);
color: #fff;
color: $white;
font-weight: 600;
}

View File

@@ -1,8 +1,10 @@
import { useState, useRef } from 'react';
import { View, Text } from '@tarojs/components';
import Taro, { useDidShow, usePullDownRefresh, useReachBottom } from '@tarojs/taro';
import { useAuthStore } from '@/stores/auth';
import { listConsultations, ConsultationSession } from '@/services/consultation';
import Loading from '../../components/Loading';
import GuestGuard from '../../components/GuestGuard';
import { useElderClass } from '../../hooks/useElderClass';
import './index.scss';
@@ -31,6 +33,7 @@ function formatTime(iso: string): string {
}
export default function Consultation() {
const user = useAuthStore((s) => s.user);
const [sessions, setSessions] = useState<ConsultationSession[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
@@ -67,6 +70,7 @@ export default function Consultation() {
useDidShow(() => {
Taro.setNavigationBarTitle({ title: '在线咨询' });
if (!user) return;
loadSessions(1, true);
});
@@ -88,6 +92,9 @@ export default function Consultation() {
return (
<View className={`consultation-page ${modeClass}`}>
{!user ? (
<GuestGuard title='请先登录' desc='登录后即可与医生在线交流' />
) : (
<View className='consultation-body'>
{/* 副标题 */}
<Text className='consultation-subtitle'></Text>
@@ -165,6 +172,7 @@ export default function Consultation() {
</View>
)}
</View>
)}
</View>
);
}

View File

@@ -66,8 +66,28 @@
color: $card;
font-size: var(--tk-font-micro);
padding: 2px 6px;
border-radius: 4px;
border-radius: $r-xs;
flex-shrink: 0;
&--ai {
background: $pri;
}
&--alert {
background: $dan;
}
&--followup {
background: $acc;
}
&--anomaly {
background: $wrn;
}
&--default {
background: $tx3;
}
}
.inbox-card-title {

View File

@@ -19,11 +19,11 @@ const TYPE_LABEL: Record<string, string> = {
data_anomaly: '异常',
};
const TYPE_COLOR: Record<string, string> = {
ai_suggestion: '#722ed1',
alert: '#f5222d',
followup: '#1890ff',
data_anomaly: '#fa8c16',
const TYPE_CLS: Record<string, string> = {
ai_suggestion: 'inbox-type-tag--ai',
alert: 'inbox-type-tag--alert',
followup: 'inbox-type-tag--followup',
data_anomaly: 'inbox-type-tag--anomaly',
};
const STATUS_TABS = [
@@ -151,10 +151,7 @@ export default function ActionInboxPage() {
>
<View className="inbox-card-header">
<Text
className="inbox-type-tag"
style={{
background: TYPE_COLOR[item.action_type] || '#999',
}}
className={`inbox-type-tag ${TYPE_CLS[item.action_type] || 'inbox-type-tag--default'}`}
>
{TYPE_LABEL[item.action_type] || '未知'}
</Text>

View File

@@ -107,7 +107,7 @@ export default function ConsultationDetail() {
success: async (res) => {
if (res.confirm) {
try {
await doctorApi.closeSession(sessionId);
await doctorApi.closeSession(sessionId, session?.version ?? 0);
Taro.showToast({ title: '已关闭', icon: 'success' });
loadData();
} catch {

View File

@@ -94,5 +94,5 @@
.submit-btn__text {
font-size: var(--tk-font-num);
font-weight: bold;
color: #fff;
color: $white;
}

View File

@@ -41,7 +41,7 @@
.record-header__status {
display: inline-block;
padding: 4px 12px;
border-radius: 8px;
border-radius: $r-xs;
font-size: var(--tk-font-body);
background: $bd-l;
color: $tx3;
@@ -119,7 +119,7 @@
background: $pri;
.action-btn__text {
color: #fff;
color: $white;
}
&:active {

View File

@@ -89,7 +89,7 @@
.type-tag {
display: inline-block;
padding: 4px 12px;
border-radius: 8px;
border-radius: $r-xs;
font-size: var(--tk-font-body);
font-weight: 600;
background: $pri-l;
@@ -109,7 +109,7 @@
.status-tag {
display: inline-block;
padding: 4px 12px;
border-radius: 8px;
border-radius: $r-xs;
font-size: var(--tk-font-body);
background: $bd-l;
color: $tx3;
@@ -189,6 +189,6 @@
.fab-text {
font-size: var(--tk-font-hero);
color: #fff;
color: $white;
font-weight: bold;
}

View File

@@ -43,7 +43,7 @@
height: 36px;
border-radius: 50%;
background: $dan;
color: #fff;
color: $white;
text-align: center;
line-height: 36px;
font-weight: bold;
@@ -132,8 +132,9 @@
}
&__quick-actions {
display: flex;
gap: 24px;
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 20px;
}
&__footer {
@@ -190,7 +191,7 @@
line-height: 32px;
text-align: center;
background: $dan;
color: #fff;
color: $white;
font-size: var(--tk-font-body-sm);
font-weight: 700;
border-radius: $r-pill;

View File

@@ -138,3 +138,13 @@
color: $tx2;
}
}
.load-more-hint-wrap {
text-align: center;
padding: 20px;
}
.load-more-hint {
font-size: var(--tk-font-h2);
color: $tx3;
}

View File

@@ -124,7 +124,7 @@ export default function PatientList() {
<View
key={tag.id}
className={`tag-chip ${activeTag === tag.id ? 'active' : ''}`}
style={activeTag === tag.id && tag.color ? `background: ${tag.color}; color: #fff` : ''}
style={activeTag === tag.id && tag.color ? `background: ${tag.color}; color: white` : ''}
onClick={() => handleTagFilter(tag.id)}
>
<Text>{tag.name}</Text>
@@ -177,8 +177,8 @@ export default function PatientList() {
)}
{!loading && patients.length >= total && total > 0 && (
<View style={{ textAlign: 'center', padding: '20px' }}>
<Text style={{ fontSize: '24px', color: '#78716C' }}></Text>
<View className='load-more-hint-wrap'>
<Text className='load-more-hint'></Text>
</View>
)}
{loading && patients.length > 0 && <Loading />}

View File

@@ -89,5 +89,5 @@
.submit-btn__text {
font-size: var(--tk-font-num);
font-weight: bold;
color: #fff;
color: $white;
}

View File

@@ -40,7 +40,7 @@
.rx-header__status {
display: inline-block;
padding: 4px 12px;
border-radius: 8px;
border-radius: $r-xs;
font-size: var(--tk-font-body);
background: $bd-l;
color: $tx3;

View File

@@ -95,7 +95,7 @@
.status-tag {
display: inline-block;
padding: 4px 12px;
border-radius: 8px;
border-radius: $r-xs;
font-size: var(--tk-font-body);
background: $bd-l;
color: $tx3;
@@ -171,6 +171,6 @@
.fab-text {
font-size: var(--tk-font-hero);
color: #fff;
color: $white;
font-weight: bold;
}

View File

@@ -122,7 +122,7 @@
.submit-btn-text {
font-size: var(--tk-font-num);
color: #fff;
color: $white;
font-weight: 600;
}

View File

@@ -30,7 +30,7 @@
.vital-tab {
flex: 1;
height: 40px;
border-radius: 12px;
border-radius: $r-sm;
background: $surface-alt;
@include flex-center;
position: relative;
@@ -44,7 +44,7 @@
box-shadow: 0 2px 8px rgba(196, 98, 58, 0.25);
.vital-tab-text {
color: #fff;
color: $white;
}
}
}
@@ -89,7 +89,7 @@
height: 56px;
background: $bg;
border: 2px solid $bd;
border-radius: 12px;
border-radius: $r-sm;
padding: 0 16px;
font-family: 'Georgia', 'Times New Roman', serif;
font-size: var(--tk-font-body-lg);
@@ -121,7 +121,7 @@
.period-btn {
flex: 1;
height: 48px;
border-radius: 12px;
border-radius: $r-sm;
background: $surface-alt;
@include flex-center;
@@ -129,7 +129,7 @@
background: $pri;
.period-btn-text {
color: #fff;
color: $white;
}
}
@@ -148,7 +148,7 @@
.save-btn {
width: 100%;
height: 52px;
border-radius: 14px;
border-radius: $r-sm;
background: $pri;
@include flex-center;
margin-top: 20px;
@@ -162,7 +162,7 @@
.save-btn-text {
font-size: var(--tk-font-body-sm);
font-weight: 600;
color: #fff;
color: $white;
}
/* ─── 趋势图 ─── */
@@ -199,7 +199,7 @@
align-items: flex-end;
height: 120px;
background: $bg;
border-radius: 12px;
border-radius: $r-sm;
padding: 12px 8px;
gap: 0;
position: relative;
@@ -234,7 +234,7 @@
.trend-bar {
width: 28px;
border-radius: 6px 6px 0 0;
border-radius: $r-xs $r-xs 0 0;
min-height: 8px;
opacity: 0.8;
@@ -275,7 +275,7 @@
.device-icon {
width: 44px;
height: 44px;
border-radius: 12px;
border-radius: $r-sm;
background: $pri-l;
@include flex-center;
flex-shrink: 0;
@@ -368,6 +368,18 @@
height: 8px;
border-radius: 50%;
flex-shrink: 0;
&.ai-risk-high {
background: $dan;
}
&.ai-risk-medium {
background: $wrn;
}
&.ai-risk-low {
background: $acc;
}
}
.ai-suggestion-text {

View File

@@ -81,7 +81,7 @@ export default function Health() {
const items = await listPendingSuggestions();
setAiSuggestions(items.slice(0, 3));
} catch {
// 静默
setAiSuggestions([]);
}
};
@@ -236,13 +236,13 @@ export default function Health() {
<Text className='ai-card-count'>{aiSuggestions.length} </Text>
</View>
{aiSuggestions.map((s) => {
const riskColor = s.risk_level === 'high' ? '#ef4444' : s.risk_level === 'medium' ? '#f59e0b' : '#22c55e';
const riskCls = s.risk_level === 'high' ? 'ai-risk-high' : s.risk_level === 'medium' ? 'ai-risk-medium' : 'ai-risk-low';
const typeLabel = s.suggestion_type === 'followup' ? '随访' : s.suggestion_type === 'appointment' ? '预约' : '预警';
const params = s.params as Record<string, unknown> | null;
const reason = (params?.reason as string) || (params?.message as string) || typeLabel;
return (
<View key={s.id} className='ai-suggestion-item'>
<View className='ai-risk-dot' style={{ background: riskColor }} />
<View className={`ai-risk-dot ${riskCls}`} />
<Text className='ai-suggestion-text'>{reason.slice(0, 40)}</Text>
</View>
);

View File

@@ -42,7 +42,7 @@
position: relative;
width: 44px;
height: 44px;
border-radius: 22px;
border-radius: $r-pill;
background: $pri-l;
@include flex-center;
flex-shrink: 0;
@@ -63,7 +63,7 @@
right: 6px;
width: 8px;
height: 8px;
border-radius: 4px;
border-radius: $r-xs;
background: $dan;
}
@@ -212,7 +212,7 @@
border-radius: $r;
padding: 18px;
margin-bottom: 16px;
color: #fff;
color: $white;
}
.reminder-header {
@@ -225,13 +225,13 @@
.reminder-title {
font-size: var(--tk-font-cap);
font-weight: 600;
color: #fff;
color: $white;
}
.reminder-count {
font-size: var(--tk-font-micro);
opacity: 0.7;
color: #fff;
color: $white;
}
.reminder-item {
@@ -252,17 +252,17 @@
.reminder-tag {
font-size: var(--tk-font-micro);
padding: 2px 6px;
border-radius: 4px;
border-radius: $r-xs;
background: rgba(255, 255, 255, 0.2);
font-weight: 500;
color: #fff;
color: $white;
flex-shrink: 0;
}
.reminder-text {
font-size: var(--tk-font-cap);
flex: 1;
color: #fff;
color: $white;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@@ -270,7 +270,7 @@
.reminder-arrow {
opacity: 0.5;
color: #fff;
color: $white;
flex-shrink: 0;
}
@@ -284,7 +284,7 @@
.action-btn {
flex: 1;
height: 52px;
border-radius: 14px;
border-radius: $r-sm;
@include flex-center;
&:active {
@@ -294,7 +294,7 @@
.action-primary {
background: $pri;
color: #fff;
color: $white;
box-shadow: 0 2px 8px rgba(196, 98, 58, 0.25);
}
@@ -376,7 +376,7 @@
font-family: 'Georgia', 'Times New Roman', serif;
font-size: var(--tk-font-h1);
font-weight: 700;
color: #FFFFFF;
color: $white;
display: block;
margin-bottom: 8px;
}
@@ -495,5 +495,5 @@
.guest-login-btn-text {
font-size: var(--tk-font-h2);
font-weight: 600;
color: #fff;
color: $white;
}

View File

@@ -69,13 +69,12 @@ function GuestHome({ modeClass }: { modeClass: string }) {
]);
if (bannerData.status === 'fulfilled' && bannerData.value?.length > 0) {
const baseUrl = process.env.TARO_APP_API_URL || 'http://localhost:3000/api/v1';
const fileBase = baseUrl.replace(/\/api\/v1$/, '');
const apiBase = process.env.TARO_APP_API_URL || 'http://localhost:3000/api/v1';
const withLocal = await Promise.all(
bannerData.value.map(async (b) => {
if (!b.image_url) return b;
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 });
if (res.tempFilePath) {
return { ...b, local_path: res.tempFilePath };
@@ -94,6 +93,7 @@ function GuestHome({ modeClass }: { modeClass: string }) {
}
} catch {
setBanners(FALLBACK_SLIDES);
Taro.showToast({ title: '内容加载失败', icon: 'none' });
}
};
@@ -135,7 +135,11 @@ function GuestHome({ modeClass }: { modeClass: string }) {
{articles.length > 0 ? (
<View className='guest-articles'>
{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 && (
<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 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 indicators = [!!summary.blood_pressure, !!summary.heart_rate, !!summary.blood_sugar, !!summary.weight];

View File

@@ -35,7 +35,7 @@
.login-logo-mark {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: var(--tk-font-hero);
color: #fff;
color: $white;
font-weight: bold;
line-height: 1;
}
@@ -76,7 +76,7 @@
width: 100%;
height: $btn-primary-h;
background: $pri;
color: #fff;
color: $white;
font-size: var(--tk-font-body-lg);
font-weight: 600;
border-radius: $r;
@@ -123,7 +123,7 @@
.agreement-check-mark {
font-size: var(--tk-font-body-sm);
color: #fff;
color: $white;
font-weight: bold;
line-height: 1;
}

View File

@@ -51,7 +51,7 @@
.checkin-btn-text {
font-size: var(--tk-font-h2);
color: #fff;
color: $white;
font-weight: 600;
}
@@ -61,9 +61,9 @@
.points-balance {
@include serif-number;
font-size: 72px; /* kept as-is: special display value */
font-size: var(--tk-font-display);
font-weight: bold;
color: #fff;
color: $white;
display: block;
margin-bottom: 8px;
letter-spacing: 2px;
@@ -258,6 +258,6 @@
.empty-action-text {
font-size: var(--tk-font-body-lg);
color: #fff;
color: $white;
font-weight: 600;
}

View File

@@ -63,7 +63,7 @@
right: 12px;
min-width: 16px;
height: 16px;
border-radius: 8px;
border-radius: $r-xs;
background: $dan;
@include flex-center;
padding: 0 4px;
@@ -71,7 +71,7 @@
.msg-segment-badge-text {
font-size: var(--tk-font-micro);
color: #fff;
color: $white;
font-weight: 600;
}
@@ -121,7 +121,7 @@
.consult-avatar {
width: 44px;
height: 44px;
border-radius: 22px;
border-radius: $r-pill;
background: $surface-alt;
@include flex-center;
flex-shrink: 0;
@@ -183,7 +183,7 @@
.consult-badge {
min-width: 18px;
height: 18px;
border-radius: 9px;
border-radius: $r-pill;
background: $dan;
@include flex-center;
padding: 0 4px;
@@ -192,7 +192,7 @@
.consult-badge-text {
font-size: var(--tk-font-micro);
color: #fff;
color: $white;
font-weight: 600;
}
@@ -225,6 +225,23 @@
font-weight: 700;
}
.notify-type-appointment,
.notify-type-points {
background: $pri-l;
color: $pri;
}
.notify-type-alert {
background: $wrn-l;
color: $wrn;
}
.notify-type-followup,
.notify-type-report {
background: $acc-l;
color: $acc;
}
.notify-body {
flex: 1;
min-width: 0;
@@ -263,7 +280,7 @@
.notify-dot {
width: 8px;
height: 8px;
border-radius: 4px;
border-radius: $r-xs;
background: $pri;
flex-shrink: 0;
margin-top: 6px;

View File

@@ -20,12 +20,12 @@ interface NotificationItem {
read?: boolean;
}
const NOTIFY_ICONS: Record<string, { icon: string; bg: string; color: string }> = {
appointment: { icon: '约', bg: '#F0DDD4', color: '#C4623A' },
alert: { icon: '警', bg: '#FFF3E0', color: '#C4873A' },
followup: { icon: '随', bg: '#E8F0E8', color: '#5B7A5E' },
points: { icon: '分', bg: '#F0DDD4', color: '#C4623A' },
report: { icon: '报', bg: '#E8F0E8', color: '#5B7A5E' },
const NOTIFY_ICONS: Record<string, { icon: string; cls: string }> = {
appointment: { icon: '约', cls: 'notify-type-appointment' },
alert: { icon: '警', cls: 'notify-type-alert' },
followup: { icon: '随', cls: 'notify-type-followup' },
points: { icon: '分', cls: 'notify-type-points' },
report: { icon: '报', cls: 'notify-type-report' },
};
export default function Messages() {
@@ -68,6 +68,7 @@ export default function Messages() {
if (isRefresh) {
if (tab === 'consultation') setSessions([]);
else setNotifications([]);
Taro.showToast({ title: '加载失败,下拉重试', icon: 'none' });
}
} finally {
setLoading(false);
@@ -202,8 +203,8 @@ export default function Messages() {
const isUnread = !n.read;
return (
<View key={n.id} className={`notify-card ${isUnread ? '' : 'notify-card-muted'}`}>
<View className='notify-icon' style={`background:${cfg.bg};`}>
<Text className='notify-icon-char' style={`color:${cfg.color};`}>{cfg.icon}</Text>
<View className={`notify-icon ${cfg.cls}`}>
<Text className={`notify-icon-char ${cfg.cls}`}>{cfg.icon}</Text>
</View>
<View className='notify-body'>
<View className='notify-row'>

View File

@@ -245,7 +245,7 @@
}
.dm-field-warning-low {
color: #0284C7;
color: $tx2;
}
/* ── submit ── */

View File

@@ -3,6 +3,8 @@ import { View, Text } from '@tarojs/components';
import { useRouter } from '@tarojs/taro';
import { useHealthStore } from '@/stores/health';
import TrendChart from '@/components/TrendChart';
import Loading from '@/components/Loading';
import EmptyState from '@/components/EmptyState';
import { useElderClass } from '../../../hooks/useElderClass';
import './index.scss';
@@ -28,10 +30,15 @@ export default function Trend() {
const indicator = router.params.indicator || 'heart_rate';
const [range, setRange] = useState('7d');
const [points, setPoints] = useState<{ date: string; value: number }[]>([]);
const [loading, setLoading] = useState(true);
const { getTrend } = useHealthStore();
useEffect(() => {
getTrend(indicator, range).then(setPoints);
setLoading(true);
getTrend(indicator, range)
.then(setPoints)
.catch(() => setPoints([]))
.finally(() => setLoading(false));
}, [indicator, range]);
const meta = INDICATOR_META[indicator] || { label: indicator, unit: '' };
@@ -68,17 +75,27 @@ export default function Trend() {
</View>
{/* ECharts 折线图 */}
<View className='trend-chart-card'>
<TrendChart
data={points}
referenceMin={meta.refMin}
referenceMax={meta.refMax}
unit={meta.unit}
/>
</View>
{loading ? (
<View className='trend-chart-card'>
<Loading />
</View>
) : points.length === 0 ? (
<View className='trend-chart-card'>
<EmptyState text='暂无趋势数据' />
</View>
) : (
<View className='trend-chart-card'>
<TrendChart
data={points}
referenceMin={meta.refMin}
referenceMax={meta.refMax}
unit={meta.unit}
/>
</View>
)}
{/* 参考区间 */}
{meta.refMin !== undefined && meta.refMax !== undefined && (
{!loading && points.length > 0 && meta.refMin !== undefined && meta.refMax !== undefined && (
<View className='trend-ref-card'>
<Text className='trend-ref-label'></Text>
<Text className='trend-ref-value'>

View File

@@ -25,13 +25,25 @@
@include flex-center;
margin-right: 24px;
flex-shrink: 0;
&--physical {
background: $acc;
}
&--service {
background: $pri;
}
&--privilege {
background: $pri-d;
}
}
.product-icon-char {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: var(--tk-font-hero);
font-weight: bold;
color: #FFFFFF;
color: $white;
}
.product-meta {

View File

@@ -23,10 +23,10 @@ const TYPE_LABEL: Record<string, string> = {
privilege: '权益卡',
};
const TYPE_COLOR: Record<string, string> = {
physical: '#5B7A5E',
service: '#C4623A',
privilege: '#8B3E1F',
const TYPE_CLASS: Record<string, string> = {
physical: 'product-icon-wrap--physical',
service: 'product-icon-wrap--service',
privilege: 'product-icon-wrap--privilege',
};
export default function ExchangeConfirm() {
@@ -130,16 +130,13 @@ export default function ExchangeConfirm() {
const productType = product?.product_type || 'physical';
const initial = TYPE_INITIAL[productType] || '礼';
const typeLabel = TYPE_LABEL[productType] || '商品';
const typeColor = TYPE_COLOR[productType] || '#C4623A';
const iconCls = TYPE_CLASS[productType] || 'product-icon-wrap--service';
return (
<View className={`exchange-page ${modeClass}`}>
{/* 商品预览卡片 */}
<View className='product-card'>
<View
className='product-icon-wrap'
style={{ backgroundColor: typeColor }}
>
<View className={`product-icon-wrap ${iconCls}`}>
<Text className='product-icon-char'>{initial}</Text>
</View>
<View className='product-meta'>

View File

@@ -79,11 +79,26 @@
}
.order-status-tag {
@include tag(transparent, $tx3);
padding: 4px 16px;
border-radius: $r-pill;
margin-left: 12px;
flex-shrink: 0;
&--pending {
@include tag($wrn-l, $wrn);
}
&--verified {
@include tag($acc-l, $acc);
}
&--cancelled {
@include tag($dan-l, $dan);
}
&--expired {
@include tag($bd-l, $tx3);
}
}
.order-status-text {

View File

@@ -15,11 +15,11 @@ const STATUS_TABS = [
{ key: 'expired', label: '已过期' },
];
const STATUS_CONFIG: Record<string, { label: string; tagBg: string; tagColor: string }> = {
pending: { label: '待核销', tagBg: '#FFF3E0', tagColor: '#C4873A' },
verified: { label: '已核销', tagBg: '#E8F0E8', tagColor: '#5B7A5E' },
cancelled: { label: '已取消', tagBg: '#FDEAEA', tagColor: '#B54A4A' },
expired: { label: '已过期', tagBg: '#F0EBE5', tagColor: '#A8A29E' },
const STATUS_CONFIG: Record<string, { label: string; cls: string }> = {
pending: { label: '待核销', cls: 'order-status-tag--pending' },
verified: { label: '已核销', cls: 'order-status-tag--verified' },
cancelled: { label: '已取消', cls: 'order-status-tag--cancelled' },
expired: { label: '已过期', cls: 'order-status-tag--expired' },
};
export default function MallOrders() {
@@ -102,7 +102,7 @@ export default function MallOrders() {
};
const getStatusConfig = (status: string) => {
return STATUS_CONFIG[status] || { label: status, tagBg: '#F0EBE5', tagColor: '#A8A29E' };
return STATUS_CONFIG[status] || { label: status, cls: 'order-status-tag--expired' };
};
const formatDate = (dateStr: string) => {
@@ -144,8 +144,7 @@ export default function MallOrders() {
<View className='order-header'>
<Text className='order-product'> {order.product_id.slice(0, 8)}</Text>
<View
className='order-status-tag'
style={{ background: statusCfg.tagBg, color: statusCfg.tagColor }}
className={`order-status-tag ${statusCfg.cls}`}
>
<Text className='order-status-text'>{statusCfg.label}</Text>
</View>

View File

@@ -35,7 +35,7 @@
.status-tag {
display: inline-block;
padding: 4px 12px;
border-radius: 8px;
border-radius: $r-xs;
font-size: var(--tk-font-body);
font-weight: 500;
background: $bd-l;

View File

@@ -35,7 +35,7 @@
.status-tag {
display: inline-block;
padding: 4px 12px;
border-radius: 8px;
border-radius: $r-xs;
font-size: var(--tk-font-body);
font-weight: 500;
background: $bd-l;

View File

@@ -81,7 +81,7 @@
width: 26px;
height: 26px;
border-radius: 13px;
background: #fff;
background: $card;
position: absolute;
top: 2px;
left: 2px;

View File

@@ -96,7 +96,7 @@
.submit-text {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: var(--tk-font-num);
color: #fff;
color: $white;
font-weight: bold;
letter-spacing: 2px;
}

View File

@@ -75,7 +75,7 @@
}
.family-current-tag {
@include tag($pri, #fff);
@include tag($pri, $white);
font-size: var(--tk-font-body-sm);
padding: 2px 10px;
}
@@ -124,7 +124,7 @@
.family-add-text {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: var(--tk-font-num);
color: #fff;
color: $white;
font-weight: bold;
letter-spacing: 2px;
}

View File

@@ -8,6 +8,16 @@
padding-bottom: 160px;
}
.medication-loading {
padding: 40px 0;
text-align: center;
}
.medication-loading-text {
color: $tx3;
font-size: var(--tk-font-h1);
}
.page-title {
@include section-title;
padding-left: 4px;
@@ -99,7 +109,7 @@
width: 36px;
height: 36px;
border-radius: 50%;
background: #fff;
background: $card;
position: absolute;
top: 4px;
transition: left 0.3s;
@@ -217,7 +227,7 @@
.form-confirm-text {
font-size: var(--tk-font-body-lg);
color: #fff;
color: $white;
font-weight: bold;
}
@@ -235,7 +245,7 @@
.add-text {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: var(--tk-font-num);
color: #fff;
color: $white;
font-weight: bold;
letter-spacing: 2px;
}

View File

@@ -100,8 +100,8 @@ export default function MedicationReminder() {
return (
<View className={`medication-page ${modeClass}`}>
<Text className='page-title'></Text>
<View style={{ padding: '40px 0', textAlign: 'center' }}>
<Text style={{ color: '#94A3B8', fontSize: '28px' }}>...</Text>
<View className='medication-loading'>
<Text className='medication-loading-text'>...</Text>
</View>
</View>
);

View File

@@ -23,7 +23,7 @@
.profile-avatar {
width: 60px;
height: 60px;
border-radius: 30px;
border-radius: $r-pill;
background: linear-gradient(135deg, $pri-l 0%, $pri 100%);
@include flex-center;
flex-shrink: 0;
@@ -33,7 +33,7 @@
font-family: Georgia, 'Times New Roman', serif;
font-size: var(--tk-font-body-lg);
font-weight: 700;
color: #fff;
color: $white;
}
.profile-user-info {
@@ -128,7 +128,8 @@
display: flex;
align-items: center;
gap: 14px;
padding: 14px 16px;
padding: 16px;
min-height: 48px;
position: relative;
&:active {

View File

@@ -81,6 +81,7 @@ export default function Profile() {
const mode = useUIStore((s) => s.mode);
const modeClass = mode === 'elder' ? 'elder-mode' : '';
const isGuest = !user;
const groups = isGuest ? GUEST_GROUPS : LOGGED_IN_GROUPS;
useDidShow(() => {
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 (
<View className={`profile-page ${modeClass}`}>
@@ -125,10 +127,10 @@ export default function Profile() {
<>
<View className='profile-user-card'>
<View className='profile-avatar'>
<Text className='profile-avatar-char'>{(user?.display_name || '访').charAt(0)}</Text>
<Text className='profile-avatar-char'>{displayInitial}</Text>
</View>
<View className='profile-user-info'>
<Text className='profile-name'>{user?.display_name || '访客'}</Text>
<Text className='profile-name'>{displayName}</Text>
<Text className='profile-phone'>
{user?.phone ? `${user.phone.slice(0, 3)}****${user.phone.slice(-4)}` : ''}
</Text>

View File

@@ -114,7 +114,7 @@
font-size: var(--tk-font-h2);
font-weight: bold;
padding: 4px 12px;
border-radius: 16px;
border-radius: $r;
&.normal {
color: $tx3;

View File

@@ -60,6 +60,11 @@ export async function getArticleDetail(id: string) {
return api.get<Article>(`/health/articles/${id}`);
}
/** 公开文章详情(无需认证) */
export async function getPublicArticleDetail(id: string) {
return api.get<Article>(`/public/articles/${id}`);
}
export async function listCategories() {
return api.get<ArticleCategory[]>('/health/article-categories');
}

View File

@@ -1,4 +1,4 @@
import { api } from './request';
import { api, requestWithTimeout } from './request';
export interface ConsultationSession {
id: string;
@@ -60,3 +60,12 @@ export async function sendMessage(sessionId: string, content: string, contentTyp
export async function markSessionRead(sessionId: string) {
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);
}

View 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');
}

View File

@@ -1,4 +1,4 @@
import { api } from '../request';
import { api, requestWithTimeout } from '../request';
// ── Consultation (doctor view) ─────────────────────
@@ -14,6 +14,7 @@ export interface ConsultationSession {
last_message_at: string | null;
unread_count_doctor?: number;
created_at: string;
version: number;
}
export interface ConsultationMessage {
@@ -60,8 +61,17 @@ export async function markSessionRead(sessionId: string) {
return api.put<void>(`/health/consultation-sessions/${sessionId}/read`);
}
export async function closeSession(sessionId: string) {
return api.put<void>(`/health/consultation-sessions/${sessionId}/close`);
export async function closeSession(sessionId: string, version: number) {
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 {

View File

@@ -55,7 +55,7 @@
height: $btn-primary-h;
border-radius: $r;
background: $pri;
color: #FFFFFF;
color: $white;
font-size: var(--tk-font-body-lg);
font-weight: 600;
border: none;

View File

@@ -7,7 +7,8 @@
// 正常模式 Token
// ═══════════════════════════════════════
page {
// ─── 字号10 级,覆盖 92.5% 场景)───
// ─── 字号11 级,覆盖 92.5% 场景)───
--tk-font-display: 72px; // 大型装饰数值(积分余额、空状态图标)
--tk-font-hero: 48px; // 装饰图标、空状态字符
--tk-font-h1: 26px; // 页面/区块标题
--tk-font-h2: 24px; // 副标题、日期、菜单组
@@ -31,6 +32,7 @@ page {
// 标题 ×1.15 / 正文 ×1.35 / 辅助 ×1.55
// ═══════════════════════════════════════
.elder-mode {
--tk-font-display: 80px;
--tk-font-hero: 56px;
--tk-font-h1: 30px;
--tk-font-h2: 28px;

View File

@@ -10,6 +10,7 @@ $acc: #5B7A5E; // 鼠尾草绿 (success)
$acc-l: #E8F0E8; // 成功浅
$bg: #F5F0EB; // 主背景 (warm cream)
$card: #FFFFFF; // 卡片白
$white: #FFFFFF; // 纯白(文字/图标在彩色底上)
$surface-alt: #EDE8E2; // 辅助底
$tx: #2D2A26; // 主文字 (warm black)
$tx2: #5A554F; // 次文字 (warm gray) — AA 正文对比度 ~5.5:1

View File

@@ -0,0 +1,12 @@
import Taro from '@tarojs/taro';
const LOGIN_PAGE = '/pages/login/index';
export function navigateToLogin() {
Taro.navigateTo({
url: LOGIN_PAGE,
fail: () => {
Taro.reLaunch({ url: LOGIN_PAGE });
},
});
}

View File

@@ -0,0 +1,23 @@
import client from '../client';
export interface DialysisRiskRequest {
patient_id: string;
dialysis_session_id?: string;
}
export interface DialysisRiskAssessment {
id: string;
patient_id: string;
risk_level: string;
risk_factors: string[];
recommendations: string[];
kdigo_stage?: string;
created_at: string;
}
export const dialysisRiskApi = {
assess: async (data: DialysisRiskRequest) => {
const resp = await client.post('/ai/dialysis/risk-assessment', data);
return resp.data.data as DialysisRiskAssessment;
},
};

View File

@@ -27,6 +27,10 @@ export const suggestionApi = {
const resp = await client.post(`/ai/suggestions/${id}/approve`, { action });
return resp.data.data as { id: string; status: string };
},
execute: async (id: string) => {
const resp = await client.post(`/ai/suggestions/${id}/execute`);
return resp.data.data as { id: string; status: string };
},
getComparison: async (id: string) => {
const resp = await client.get(`/ai/suggestions/${id}/comparison`);
return resp.data.data as ComparisonReport;

View File

@@ -9,6 +9,42 @@ export interface TypeDistribution {
count: number;
}
export interface ProviderInfo {
id: string;
name: string;
provider_type: string;
is_active: boolean;
model_name?: string;
}
export interface ProviderHealth {
provider_id: string;
status: string;
latency_ms?: number;
last_checked_at?: string;
}
export interface QuotaSummary {
provider_id: string;
quota_limit: number;
quota_used: number;
quota_remaining: number;
period: string;
}
export interface BudgetStatus {
total_budget: number;
spent: number;
remaining: number;
period: string;
}
export interface CostEstimate {
analysis_type: string;
estimated_cost: number;
currency: string;
}
export const usageApi = {
overview: async () => {
const resp = await client.get('/ai/usage/overview');
@@ -18,4 +54,24 @@ export const usageApi = {
const resp = await client.get('/ai/usage/by-type');
return resp.data.data as TypeDistribution[];
},
listProviders: async () => {
const resp = await client.get('/ai/providers');
return resp.data.data as ProviderInfo[];
},
getProvidersHealth: async () => {
const resp = await client.get('/ai/providers/health');
return resp.data.data as ProviderHealth[];
},
getQuotaSummary: async () => {
const resp = await client.get('/ai/quota/summary');
return resp.data.data as QuotaSummary[];
},
getBudgetStatus: async () => {
const resp = await client.get('/ai/budget/status');
return resp.data.data as BudgetStatus;
},
getCostEstimate: async (params: { analysis_type: string }) => {
const resp = await client.get('/ai/cost/estimate', { params });
return resp.data.data as CostEstimate;
},
};

View File

@@ -86,3 +86,32 @@ export const alertRuleApi = {
deactivate: (id: string, version: number) =>
client.put(`/health/alert-rules/${id}/deactivate`, { version }).then((r) => r.data.data as AlertRule),
};
// --- Critical Alerts API ---
export interface CriticalAlert {
id: string;
patient_id: string;
patient_name?: string;
alert_type: string;
severity: string;
title: string;
detail?: Record<string, unknown>;
status: string;
acknowledged_by?: string;
acknowledged_at?: string;
notes?: string;
created_at: string;
version: number;
}
export const criticalAlertApi = {
list: (params?: { page?: number; page_size?: number }) =>
client.get('/health/critical-alerts', { params }).then((r) => r.data.data as PaginatedResponse<CriticalAlert>),
get: (id: string) =>
client.get(`/health/critical-alerts/${id}`).then((r) => r.data.data as CriticalAlert),
acknowledge: (id: string, req: { notes?: string }) =>
client.post(`/health/critical-alerts/${id}/acknowledge`, req).then((r) => r.data),
};

View File

@@ -149,11 +149,11 @@ export const articleApi = {
return data.data;
},
delete: async (id: string) => {
delete: async (id: string, version: number) => {
const { data } = await client.delete<{
success: boolean;
data: null;
}>(`/health/articles/${id}`);
}>(`/health/articles/${id}`, { data: { version } });
return data.data;
},
@@ -196,6 +196,14 @@ export const articleApi = {
}>(`/health/articles/${id}/view`);
return data.data;
},
listRevisions: async (id: string, params?: { page?: number; page_size?: number }) => {
const { data } = await client.get<{
success: boolean;
data: PaginatedResponse<Record<string, unknown>>;
}>(`/health/articles/${id}/revisions`, { params });
return data.data;
},
};
// --- Category API ---

View File

@@ -102,4 +102,36 @@ export const consultationApi = {
}>('/health/consultation-messages', req);
return data.data;
},
pollMessages: async (
sessionId: string,
afterId?: string,
) => {
const { data } = await client.get<{
success: boolean;
data: Message[];
}>(`/health/consultation-sessions/${sessionId}/messages/poll`, {
params: { after_id: afterId, timeout: 25 },
timeout: 30000,
});
return data.data;
},
markSessionRead: async (id: string) => {
await client.put(`/health/consultation-sessions/${id}/read`);
},
exportSessions: async (params?: {
status?: string;
patient_id?: string;
doctor_id?: string;
page?: number;
page_size?: number;
}) => {
const { data } = await client.get<{
success: boolean;
data: PaginatedResponse<Session>;
}>('/health/consultation-sessions/export', { params });
return data.data;
},
};

View File

@@ -105,4 +105,12 @@ export const dialysisApi = {
}>(`/health/dialysis-records/${id}/review`, req);
return data.data;
},
completeRecord: async (id: string, version: number) => {
const { data } = await client.put<{
success: boolean;
data: DialysisRecord;
}>(`/health/dialysis-records/${id}/complete`, { version });
return data.data;
},
};

View File

@@ -77,7 +77,7 @@ export const doctorApi = {
return data.data;
},
delete: async (id: string) => {
await client.delete(`/health/doctors/${id}`);
delete: async (id: string, version: number) => {
await client.delete(`/health/doctors/${id}`, { data: { version } });
},
};

View File

@@ -79,7 +79,7 @@ export const familyProxyApi = {
const { data } = await client.post<{
success: boolean;
data: FamilyMember;
}>(`/health/patients/${patientId}/family-members/${familyMemberId}/grant-access?version=${version}`, req);
}>(`/health/patients/${patientId}/family-members/${familyMemberId}/grant-access`, { ...req, version });
return data.data;
},
@@ -87,7 +87,7 @@ export const familyProxyApi = {
const { data } = await client.put<{
success: boolean;
data: FamilyMember;
}>(`/health/patients/${patientId}/family-members/${familyMemberId}/revoke-access?version=${version}`);
}>(`/health/patients/${patientId}/family-members/${familyMemberId}/revoke-access`, { version });
return data.data;
},
@@ -95,7 +95,7 @@ export const familyProxyApi = {
const { data } = await client.get<{
success: boolean;
data: FamilyPatientSummary[];
}>('/health/family/my-patients');
}>('/health/family/patients');
return data.data;
},

View File

@@ -249,6 +249,14 @@ export const healthDataApi = {
return data.data;
},
generateTrend: async (patientId: string, req: { indicator: string; start_date?: string; end_date?: string }) => {
const { data } = await client.post<{
success: boolean;
data: TrendData;
}>(`/health/patients/${patientId}/trends/generate`, req);
return data.data;
},
getIndicatorTimeseries: async (patientId: string, indicator: string) => {
const { data } = await client.get<{
success: boolean;

View File

@@ -0,0 +1,75 @@
import client from '../client';
import type { PaginatedResponse } from '../types';
// --- Types ---
export interface MedicationReminder {
id: string;
patient_id: string;
medication_name: string;
dosage?: string;
frequency: string;
time_slots: string[];
start_date?: string;
end_date?: string;
is_active: boolean;
notes?: string;
created_at: string;
updated_at: string;
version: number;
}
export interface CreateMedicationReminderReq {
patient_id: string;
medication_name: string;
dosage?: string;
frequency?: string;
time_slots?: string[];
start_date?: string;
end_date?: string;
is_active?: boolean;
notes?: string;
}
export interface UpdateMedicationReminderReq {
medication_name?: string;
dosage?: string;
frequency?: string;
time_slots?: string[];
start_date?: string;
end_date?: string;
is_active?: boolean;
notes?: string;
}
// --- API ---
export const medicationReminderApi = {
list: async (patientId: string, params?: { page?: number; page_size?: number }) => {
const { data } = await client.get<{
success: boolean;
data: PaginatedResponse<MedicationReminder>;
}>(`/health/patients/${patientId}/medication-reminders`, { params });
return data.data;
},
create: async (req: CreateMedicationReminderReq) => {
const { data } = await client.post<{
success: boolean;
data: MedicationReminder;
}>('/health/medication-reminders', req);
return data.data;
},
update: async (id: string, req: UpdateMedicationReminderReq & { version: number }) => {
const { data } = await client.put<{
success: boolean;
data: MedicationReminder;
}>(`/health/medication-reminders/${id}`, req);
return data.data;
},
delete: async (id: string, version: number) => {
await client.delete(`/health/medication-reminders/${id}`, { data: { version } });
},
};

View File

@@ -294,7 +294,7 @@ export const pointsApi = {
const { data } = await client.put<{
success: boolean;
data: PointsRule;
}>(`/health/admin/points/rules/${id}`, { data: req, version: req.version });
}>(`/health/admin/points/rules/${id}`, req);
return data.data;
},
@@ -325,7 +325,7 @@ export const pointsApi = {
const { data } = await client.put<{
success: boolean;
data: PointsProduct;
}>(`/health/admin/points/products/${id}`, { data: req, version: req.version });
}>(`/health/admin/points/products/${id}`, req);
return data.data;
},

View File

@@ -73,7 +73,7 @@ export default function MediaPicker({ open, onClose, onSelect, accept = 'image/*
onCancel={onClose}
footer={null}
width={720}
destroyOnClose
destroyOnHidden
>
<div style={{ display: 'flex', gap: 8, marginBottom: 16 }}>
<Input

View File

@@ -55,7 +55,7 @@ export default function ThemeSwitcher() {
);
return (
<Dropdown dropdownRender={() => content} trigger={['click']} placement="bottomRight">
<Dropdown popupRender={() => content} trigger={['click']} placement="bottomRight">
<div className="erp-header-btn" title="切换主题">
<BgColorsOutlined style={{ fontSize: 16 }} />
</div>

View File

@@ -59,9 +59,12 @@ export function usePaginatedData<T, F = string>(
const filtersRef = useRef(filters);
filtersRef.current = filters;
const stateRef = useRef(state);
stateRef.current = state;
const refresh = useCallback(
async (p?: number) => {
const targetPage = p ?? state.page;
const targetPage = p ?? stateRef.current.page;
setState((s) => ({ ...s, loading: true }));
try {
const result = await (fetchFnRef.current as any)(
@@ -75,7 +78,7 @@ export function usePaginatedData<T, F = string>(
setState((s) => ({ ...s, loading: false }));
}
},
[pageSize, state.page],
[pageSize],
);
useEffect(() => {

View File

@@ -27,7 +27,7 @@ export default function ImportModal({ open, pluginId, entityName, onClose, onSuc
footer={importResult ? (
<Button onClick={handleClose}></Button>
) : null}
destroyOnClose
destroyOnHidden
>
{importResult ? (
<div>

View File

@@ -440,7 +440,7 @@ export default function PluginCRUDPageInner({
open={modalOpen}
onCancel={() => { setModalOpen(false); setEditRecord(null); setFormValues({}); }}
onOk={() => form.submit()}
destroyOnClose
destroyOnHidden
>
<Form form={form} layout="vertical" onFinish={handleSubmit} onValuesChange={(_, allValues) => setFormValues(allValues)}>
{fields.map((field) => {

View File

@@ -230,7 +230,7 @@ export default function AiPromptList() {
onCancel={() => setModalOpen(false)}
onOk={() => form.submit()}
width={600}
destroyOnClose
destroyOnHidden
>
<Form form={form} onFinish={handleCreate} layout="vertical" style={{ marginTop: 16 }}>
<Form.Item

View File

@@ -105,9 +105,9 @@ export default function ArticleManageList() {
// ---- 操作 ----
const handleDelete = async (id: string) => {
const handleDelete = async (id: string, version: number) => {
try {
await articleApi.delete(id);
await articleApi.delete(id, version);
message.success('文章已删除');
refresh();
} catch {
@@ -231,7 +231,7 @@ export default function ArticleManageList() {
<AuthButton code="health.articles.manage">
<Popconfirm
title="确定删除此文章?"
onConfirm={() => handleDelete(record.id)}
onConfirm={() => handleDelete(record.id, record.version)}
>
<Button size="small" type="text" icon={<DeleteOutlined />} danger />
</Popconfirm>

View File

@@ -441,7 +441,7 @@ export default function BannerManage() {
confirmLoading={submitting}
okText={editingRecord ? '保存' : '创建'}
width={600}
destroyOnClose
destroyOnHidden
>
<Form
form={form}

View File

@@ -18,7 +18,6 @@ import { AuthButton } from "../../components/AuthButton";
import { EntityName } from "../../components/EntityName";
const PAGE_SIZE = 30;
const POLL_INTERVAL = 10_000;
function formatTime(value: string): string {
return new Date(value).toLocaleString("zh-CN", {
@@ -64,7 +63,7 @@ export default function ConsultationDetail() {
const chatEndRef = useRef<HTMLDivElement>(null);
const shouldScrollRef = useRef(true);
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
const isDark = useThemeMode();
@@ -114,39 +113,32 @@ export default function ConsultationDetail() {
fetchMessages(1, false);
}, [fetchSession, fetchMessages]);
// Poll new messages while session is active
// Long-poll new messages while session is active
useEffect(() => {
if (!session || session.status === "closed") return;
const stopPolling = () => {
if (pollRef.current) {
clearInterval(pollRef.current);
pollRef.current = null;
let cancelled = false;
const longPoll = async () => {
while (!cancelled) {
try {
const realMsgs = messages.filter((m) => !m.id.startsWith("temp_"));
const lastId =
realMsgs.length > 0 ? realMsgs[realMsgs.length - 1].id : undefined;
const newMsgs = await consultationApi.pollMessages(sessionId, lastId);
if (!cancelled && newMsgs.length > 0) {
setMessages((prev) => [...prev, ...newMsgs]);
shouldScrollRef.current = true;
}
} catch {
// timeout or network error, retry
}
}
};
stopPolling();
pollRef.current = setInterval(async () => {
if (!sessionId) return;
try {
const realMsgs = messages.filter((m) => !m.id.startsWith("temp_"));
const lastId =
realMsgs.length > 0 ? realMsgs[realMsgs.length - 1].id : undefined;
const result = await consultationApi.listMessages(sessionId, {
page: 1,
page_size: 50,
after_id: lastId,
});
if (result.data.length > 0) {
setMessages((prev) => [...prev, ...result.data]);
shouldScrollRef.current = true;
}
} catch {
// silent
}
}, POLL_INTERVAL);
longPoll();
return stopPolling;
return () => { cancelled = true; };
}, [session?.status, sessionId, messages.length]);
// Auto-scroll to bottom on new messages

View File

@@ -334,7 +334,7 @@ export default function ConsultationList() {
confirmLoading={createLoading}
okText="创建"
cancelText="取消"
destroyOnClose
destroyOnHidden
>
<Form form={createForm} layout="vertical" autoComplete="off">
<Form.Item

View File

@@ -297,7 +297,7 @@ export default function DialysisManageList() {
}}
onOk={() => form.submit()}
confirmLoading={submitting}
destroyOnClose
destroyOnHidden
width={640}
>
<Form form={form} layout="vertical" onFinish={handleSubmit}>

View File

@@ -373,7 +373,7 @@ export default function DoctorList() {
form.resetFields();
}}
onOk={() => form.submit()}
destroyOnClose
destroyOnHidden
width={560}
>
<Form form={form} layout="vertical" onFinish={handleSubmit}>

View File

@@ -366,7 +366,7 @@ export default function DoctorSchedule() {
form.resetFields();
}}
onOk={() => form.submit()}
destroyOnClose
destroyOnHidden
width={520}
>
<Form form={form} layout="vertical" onFinish={handleSubmit}>

View File

@@ -403,7 +403,7 @@ export default function FollowUpTaskList() {
confirmLoading={createLoading}
okText="创建"
cancelText="取消"
destroyOnClose
destroyOnHidden
>
<Form form={createForm} layout="vertical" autoComplete="off">
<Form.Item
@@ -501,7 +501,7 @@ export default function FollowUpTaskList() {
confirmLoading={assignLoading}
okText="确认"
cancelText="取消"
destroyOnClose
destroyOnHidden
>
<Form form={assignForm} layout="vertical" autoComplete="off">
<Form.Item

View File

@@ -253,7 +253,7 @@ export default function FollowUpTemplateList() {
onOk={handleSubmit}
onCancel={() => setModalOpen(false)}
width={720}
destroyOnClose
destroyOnHidden
>
<Form form={form} layout="vertical">
<Form.Item name="name" label="模板名称" rules={[{ required: true }]}>

View File

@@ -264,7 +264,7 @@ export default function MediaLibrary() {
</div>
{/* 上传弹窗 */}
<Modal title="上传文件" open={uploadOpen} onCancel={() => setUploadOpen(false)} footer={null} destroyOnClose>
<Modal title="上传文件" open={uploadOpen} onCancel={() => setUploadOpen(false)} footer={null} destroyOnHidden>
<Form form={uploadForm} layout="vertical" style={{ marginTop: 16 }}>
<Form.Item name="folder_id" label="目标文件夹">
<TreeSelect allowClear placeholder="根目录" treeDefaultExpandAll fieldNames={{ label: 'name', value: 'id', children: 'children' }} treeData={buildTree(folders)} style={{ width: '100%' }} />
@@ -279,7 +279,7 @@ export default function MediaLibrary() {
</Modal>
{/* 编辑弹窗 */}
<Modal title="编辑文件信息" open={editOpen} onCancel={() => setEditOpen(false)} onOk={() => editForm.submit()} confirmLoading={submitting} destroyOnClose>
<Modal title="编辑文件信息" open={editOpen} onCancel={() => setEditOpen(false)} onOk={() => editForm.submit()} confirmLoading={submitting} destroyOnHidden>
<Form form={editForm} onFinish={handleEditSubmit} layout="vertical" style={{ marginTop: 16 }}>
<Form.Item name="filename" label="文件名" rules={[{ required: true, message: '请输入文件名' }]}><Input placeholder="文件名" /></Form.Item>
<Form.Item name="alt_text" label="替代文本"><Input.TextArea rows={2} placeholder="描述图片内容(用于无障碍访问)" /></Form.Item>
@@ -288,7 +288,7 @@ export default function MediaLibrary() {
</Modal>
{/* 移动弹窗 */}
<Modal title="移动到文件夹" open={moveOpen} onCancel={() => setMoveOpen(false)} footer={null} destroyOnClose>
<Modal title="移动到文件夹" open={moveOpen} onCancel={() => setMoveOpen(false)} footer={null} destroyOnHidden>
<div style={{ marginTop: 16 }}>
<Typography.Paragraph type="secondary"></Typography.Paragraph>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
@@ -299,7 +299,7 @@ export default function MediaLibrary() {
</Modal>
{/* 文件夹创建/重命名弹窗 */}
<Modal title={editingFolder ? '重命名文件夹' : '新建文件夹'} open={folderOpen} onCancel={() => setFolderOpen(false)} onOk={() => folderForm.submit()} confirmLoading={submitting} destroyOnClose>
<Modal title={editingFolder ? '重命名文件夹' : '新建文件夹'} open={folderOpen} onCancel={() => setFolderOpen(false)} onOk={() => folderForm.submit()} confirmLoading={submitting} destroyOnHidden>
<Form form={folderForm} onFinish={handleFolderSubmit} layout="vertical" style={{ marginTop: 16 }}>
<Form.Item name="name" label="文件夹名称" rules={[{ required: true, message: '请输入文件夹名称' }]}><Input placeholder="输入名称" maxLength={50} /></Form.Item>
</Form>

View File

@@ -345,7 +345,7 @@ export default function OfflineEventList() {
form.resetFields();
}}
onOk={() => form.submit()}
destroyOnClose
destroyOnHidden
width={620}
>
<Form form={form} layout="vertical" onFinish={handleSubmit}>

View File

@@ -264,7 +264,7 @@ export default function PointsOrderList() {
}}
onOk={() => verifyForm.submit()}
confirmLoading={verifying}
destroyOnClose
destroyOnHidden
width={440}
>
<Form form={verifyForm} layout="vertical" onFinish={handleVerify}>

View File

@@ -342,7 +342,7 @@ export default function PointsRuleList() {
form.resetFields();
}}
onOk={() => form.submit()}
destroyOnClose
destroyOnHidden
width={560}
>
<Form form={form} layout="vertical" onFinish={handleSubmit}>

View File

@@ -199,7 +199,7 @@ export function DailyMonitoringTab({ patientId }: Props) {
}}
onOk={() => form.submit()}
confirmLoading={submitting}
destroyOnClose
destroyOnHidden
width={560}
>
<Form form={form} layout="vertical" onFinish={handleSubmit}>

View File

@@ -167,7 +167,7 @@ export function HealthRecordsTab({ patientId }: Props) {
onCancel={() => { setModalOpen(false); setEditingRecord(null); }}
onOk={() => form.submit()}
confirmLoading={submitting}
destroyOnClose
destroyOnHidden
width={520}
>
<Form form={form} layout="vertical" onFinish={handleSubmit}>

View File

@@ -1,6 +1,6 @@
import { useCallback, useState, useMemo } from 'react';
import { Table, Tag, Button, Modal, Form, Input, DatePicker, message, Popconfirm, Space, Card } from 'antd';
import { PlusOutlined, EditOutlined, DeleteOutlined, AuditOutlined, ThunderboltOutlined } from '@ant-design/icons';
import { PlusOutlined, EditOutlined, DeleteOutlined, AuditOutlined, ThunderboltOutlined, FileTextOutlined } from '@ant-design/icons';
import { dayjs } from '../../../utils/dayjs';
import type { Dayjs } from 'dayjs';
import { healthDataApi } from '../../../api/health/healthData';
@@ -30,6 +30,8 @@ export function LabReportsTab({ patientId }: Props) {
const [reviewSubmitting, setReviewSubmitting] = useState(false);
const [analyzingReportId, setAnalyzingReportId] = useState<string | null>(null);
const [analysisContent, setAnalysisContent] = useState('');
const [summaryReportId, setSummaryReportId] = useState<string | null>(null);
const [summaryContent, setSummaryContent] = useState('');
const handleAiAnalysis = async (reportId: string) => {
setAnalyzingReportId(reportId);
@@ -44,6 +46,19 @@ export function LabReportsTab({ patientId }: Props) {
});
};
const handleReportSummary = async (reportId: string) => {
setSummaryReportId(reportId);
setSummaryContent('');
await startAnalysis('report-summary', { report_id: reportId }, {
onChunk: (content) => setSummaryContent(prev => prev + content),
onError: (msg) => { message.error(msg); setSummaryReportId(null); },
onDone: () => {
message.success('报告摘要生成完成');
setSummaryReportId(null);
},
});
};
const fetcher = useCallback(
async (page: number, pageSize: number) => {
return healthDataApi.listLabReports(patientId, { page, page_size: pageSize });
@@ -163,6 +178,11 @@ export function LabReportsTab({ patientId }: Props) {
AI
</Button>
</AuthButton>
<AuthButton code="ai.analysis.manage">
<Button type="link" size="small" icon={<FileTextOutlined />} loading={summaryReportId === record.id} onClick={(e) => { e.stopPropagation(); handleReportSummary(record.id); }}>
</Button>
</AuthButton>
{record.status === 'pending' && (
<Button type="link" size="small" icon={<AuditOutlined />} onClick={() => openReviewModal(record)}>
@@ -207,13 +227,18 @@ export function LabReportsTab({ patientId }: Props) {
<div style={{ whiteSpace: 'pre-wrap' }}>{analysisContent}</div>
</Card>
)}
{summaryContent && (
<Card title="报告摘要" style={{ marginTop: 16 }} size="small">
<div style={{ whiteSpace: 'pre-wrap' }}>{summaryContent}</div>
</Card>
)}
<Modal
title={editingRecord ? '编辑化验报告' : '添加化验报告'}
open={modalOpen}
onCancel={() => { setModalOpen(false); setEditingRecord(null); }}
onOk={() => form.submit()}
confirmLoading={submitting}
destroyOnClose
destroyOnHidden
width={520}
>
<Form form={form} layout="vertical" onFinish={handleSubmit}>
@@ -235,7 +260,7 @@ export function LabReportsTab({ patientId }: Props) {
onCancel={() => { setReviewOpen(false); setReviewRecord(null); }}
onOk={handleReview}
confirmLoading={reviewSubmitting}
destroyOnClose
destroyOnHidden
width={480}
>
{reviewRecord && (

View File

@@ -289,7 +289,7 @@ export function VitalSignsTab({ patientId }: Props) {
}}
onOk={() => form.submit()}
confirmLoading={submitting}
destroyOnClose
destroyOnHidden
width={600}
>
<Form form={form} layout="vertical" onFinish={handleSubmit}>

View File

@@ -189,7 +189,7 @@ export default function ProcessDefinitions() {
onCancel={() => setDesignerOpen(false)}
footer={null}
width={1200}
destroyOnClose
destroyOnHidden
>
<Suspense fallback={<div style={{ textAlign: 'center', padding: 40 }}><Spin /></div>}>
<ProcessDesigner

Some files were not shown because too many files have changed in this diff Show More