Compare commits
7 Commits
02082ccc61
...
431c42289d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
431c42289d | ||
|
|
675d5a3405 | ||
|
|
df1d85bfde | ||
|
|
212c08b7ae | ||
|
|
e4e5ef04d4 | ||
|
|
616e0a1539 | ||
|
|
93c77c5857 |
57
.dockerignore
Normal file
57
.dockerignore
Normal 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
11
.gitignore
vendored
@@ -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
99
Cargo.lock
generated
@@ -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
112
Dockerfile
Normal 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"]
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -46,5 +46,5 @@
|
||||
|
||||
.empty-state-action-text {
|
||||
font-size: var(--tk-font-body-lg);
|
||||
color: #fff;
|
||||
color: $white;
|
||||
}
|
||||
|
||||
52
apps/miniprogram/src/components/ErrorBoundary/index.scss
Normal file
52
apps/miniprogram/src/components/ErrorBoundary/index.scss
Normal 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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -60,5 +60,5 @@
|
||||
.guard-btn-text {
|
||||
font-size: var(--tk-font-body-sm);
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
color: $white;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -21,7 +21,7 @@ export default function ArticleList() {
|
||||
const data = await listCategories();
|
||||
setCategories(data || []);
|
||||
} catch {
|
||||
// 静默
|
||||
setCategories([]);
|
||||
}
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -94,5 +94,5 @@
|
||||
.submit-btn__text {
|
||||
font-size: var(--tk-font-num);
|
||||
font-weight: bold;
|
||||
color: #fff;
|
||||
color: $white;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 />}
|
||||
|
||||
@@ -89,5 +89,5 @@
|
||||
.submit-btn__text {
|
||||
font-size: var(--tk-font-num);
|
||||
font-weight: bold;
|
||||
color: #fff;
|
||||
color: $white;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -122,7 +122,7 @@
|
||||
|
||||
.submit-btn-text {
|
||||
font-size: var(--tk-font-num);
|
||||
color: #fff;
|
||||
color: $white;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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'>
|
||||
|
||||
@@ -245,7 +245,7 @@
|
||||
}
|
||||
|
||||
.dm-field-warning-low {
|
||||
color: #0284C7;
|
||||
color: $tx2;
|
||||
}
|
||||
|
||||
/* ── submit ── */
|
||||
|
||||
@@ -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'>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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'>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -81,7 +81,7 @@
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 13px;
|
||||
background: #fff;
|
||||
background: $card;
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
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) ─────────────────────
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
12
apps/miniprogram/src/utils/navigate.ts
Normal file
12
apps/miniprogram/src/utils/navigate.ts
Normal 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 });
|
||||
},
|
||||
});
|
||||
}
|
||||
23
apps/web/src/api/ai/dialysis.ts
Normal file
23
apps/web/src/api/ai/dialysis.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
|
||||
@@ -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 ---
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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 } });
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
75
apps/web/src/api/health/medicationReminders.ts
Normal file
75
apps/web/src/api/health/medicationReminders.ts
Normal 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 } });
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -27,7 +27,7 @@ export default function ImportModal({ open, pluginId, entityName, onClose, onSuc
|
||||
footer={importResult ? (
|
||||
<Button onClick={handleClose}>关闭</Button>
|
||||
) : null}
|
||||
destroyOnClose
|
||||
destroyOnHidden
|
||||
>
|
||||
{importResult ? (
|
||||
<div>
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -441,7 +441,7 @@ export default function BannerManage() {
|
||||
confirmLoading={submitting}
|
||||
okText={editingRecord ? '保存' : '创建'}
|
||||
width={600}
|
||||
destroyOnClose
|
||||
destroyOnHidden
|
||||
>
|
||||
<Form
|
||||
form={form}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -334,7 +334,7 @@ export default function ConsultationList() {
|
||||
confirmLoading={createLoading}
|
||||
okText="创建"
|
||||
cancelText="取消"
|
||||
destroyOnClose
|
||||
destroyOnHidden
|
||||
>
|
||||
<Form form={createForm} layout="vertical" autoComplete="off">
|
||||
<Form.Item
|
||||
|
||||
@@ -297,7 +297,7 @@ export default function DialysisManageList() {
|
||||
}}
|
||||
onOk={() => form.submit()}
|
||||
confirmLoading={submitting}
|
||||
destroyOnClose
|
||||
destroyOnHidden
|
||||
width={640}
|
||||
>
|
||||
<Form form={form} layout="vertical" onFinish={handleSubmit}>
|
||||
|
||||
@@ -373,7 +373,7 @@ export default function DoctorList() {
|
||||
form.resetFields();
|
||||
}}
|
||||
onOk={() => form.submit()}
|
||||
destroyOnClose
|
||||
destroyOnHidden
|
||||
width={560}
|
||||
>
|
||||
<Form form={form} layout="vertical" onFinish={handleSubmit}>
|
||||
|
||||
@@ -366,7 +366,7 @@ export default function DoctorSchedule() {
|
||||
form.resetFields();
|
||||
}}
|
||||
onOk={() => form.submit()}
|
||||
destroyOnClose
|
||||
destroyOnHidden
|
||||
width={520}
|
||||
>
|
||||
<Form form={form} layout="vertical" onFinish={handleSubmit}>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }]}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -345,7 +345,7 @@ export default function OfflineEventList() {
|
||||
form.resetFields();
|
||||
}}
|
||||
onOk={() => form.submit()}
|
||||
destroyOnClose
|
||||
destroyOnHidden
|
||||
width={620}
|
||||
>
|
||||
<Form form={form} layout="vertical" onFinish={handleSubmit}>
|
||||
|
||||
@@ -264,7 +264,7 @@ export default function PointsOrderList() {
|
||||
}}
|
||||
onOk={() => verifyForm.submit()}
|
||||
confirmLoading={verifying}
|
||||
destroyOnClose
|
||||
destroyOnHidden
|
||||
width={440}
|
||||
>
|
||||
<Form form={verifyForm} layout="vertical" onFinish={handleVerify}>
|
||||
|
||||
@@ -342,7 +342,7 @@ export default function PointsRuleList() {
|
||||
form.resetFields();
|
||||
}}
|
||||
onOk={() => form.submit()}
|
||||
destroyOnClose
|
||||
destroyOnHidden
|
||||
width={560}
|
||||
>
|
||||
<Form form={form} layout="vertical" onFinish={handleSubmit}>
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user