Compare commits
2 Commits
b320641d9c
...
8111471e93
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8111471e93 | ||
|
|
181bfb1f3e |
Binary file not shown.
Binary file not shown.
Binary file not shown.
93
app/assets/fonts/OFL.txt
Normal file
93
app/assets/fonts/OFL.txt
Normal file
@@ -0,0 +1,93 @@
|
||||
Copyright 2011 The Quicksand Project Authors (https://github.com/andrew-paglinawan/QuicksandFamily), with Reserved Font Name “Quicksand”.
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -5,7 +5,7 @@ import 'package:flutter/animation.dart';
|
||||
class DesignTokens {
|
||||
DesignTokens._();
|
||||
|
||||
// ===== 间距 =====
|
||||
// ===== 间距(4px 基准,9 级)=====
|
||||
static const double spacing4 = 4;
|
||||
static const double spacing8 = 8;
|
||||
static const double spacing12 = 12;
|
||||
@@ -13,8 +13,16 @@ class DesignTokens {
|
||||
static const double spacing20 = 20;
|
||||
static const double spacing24 = 24;
|
||||
static const double spacing32 = 32;
|
||||
static const double spacing40 = 40;
|
||||
static const double spacing48 = 48;
|
||||
|
||||
// ===== 安全区 & 布局常量(对齐 spec §1)=====
|
||||
static const double safeTop = 54; // iPhone Dynamic Island
|
||||
static const double safeBottom = 34; // Home Indicator
|
||||
static const double tabHeight = 56; // 底部 Tab 栏
|
||||
static const double touchMin = 44; // WCAG 最小触控目标
|
||||
static const double containerMax = 390; // 移动端容器宽度
|
||||
|
||||
// ===== 动画时长 =====
|
||||
static const Duration animFast = Duration(milliseconds: 150);
|
||||
static const Duration animNormal = Duration(milliseconds: 300);
|
||||
|
||||
@@ -21,6 +21,7 @@ import '../../features/home/views/home_page.dart';
|
||||
import '../../features/calendar/views/calendar_page.dart';
|
||||
import '../../features/mood/views/mood_page.dart';
|
||||
import '../../features/search/views/search_page.dart';
|
||||
import '../../features/discover/views/discover_page.dart';
|
||||
import '../../features/calendar/views/weekly_page.dart';
|
||||
import '../../features/calendar/views/monthly_page.dart';
|
||||
import '../../features/profile/views/profile_page.dart';
|
||||
@@ -151,18 +152,13 @@ GoRouter createAppRouter(AuthBloc authBloc) {
|
||||
name: 'calendar',
|
||||
builder: (context, state) => const CalendarPage(),
|
||||
),
|
||||
// 发现页(搜索页 — 标签+心情筛选日记)
|
||||
// 发现页 — 灵感、话题、达人日记(spec §3.12)
|
||||
GoRoute(
|
||||
path: '/discover',
|
||||
name: 'discover',
|
||||
builder: (context, state) {
|
||||
final journalRepo = context.read<JournalRepository>();
|
||||
return BlocProvider(
|
||||
create: (_) => SearchBloc(journalRepository: journalRepo),
|
||||
child: const SearchPage(),
|
||||
);
|
||||
},
|
||||
builder: (context, state) => const DiscoverPage(),
|
||||
),
|
||||
// 个人中心
|
||||
GoRoute(
|
||||
path: '/profile',
|
||||
name: 'profile',
|
||||
@@ -171,6 +167,20 @@ GoRouter createAppRouter(AuthBloc authBloc) {
|
||||
],
|
||||
),
|
||||
|
||||
// 搜索页 — 全屏无 Tab(spec §3.13)
|
||||
GoRoute(
|
||||
path: '/search',
|
||||
name: 'search',
|
||||
parentNavigatorKey: _rootNavigatorKey,
|
||||
builder: (context, state) {
|
||||
final journalRepo = context.read<JournalRepository>();
|
||||
return BlocProvider(
|
||||
create: (_) => SearchBloc(journalRepository: journalRepo),
|
||||
child: const SearchPage(),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
// 全屏页面(无底部导航)
|
||||
GoRoute(
|
||||
path: '/editor',
|
||||
|
||||
@@ -164,19 +164,21 @@ class AppColors {
|
||||
static const Color shadowDark = Color(0xFF000000);
|
||||
|
||||
// ===== 心情颜色映射 =====
|
||||
// 对齐 spec §2.8 mood-selector: happy/calm/sad/angry/thinking
|
||||
// 对齐 spec §3.6 calendar mood-dot 颜色(开心=secondary, 平静=tertiary, 难过=#5B7DB1)
|
||||
|
||||
/// 心情 → 颜色
|
||||
/// 心情 → 颜色(主色,用于心情选择器圆圈/标签)
|
||||
static const Map<String, Color> moodColors = {
|
||||
'happy': Color(0xFFFFD93D), // 😊 开心 — 暖黄
|
||||
'calm': Color(0xFF81B29A), // 😌 平静 — 鼠尾草绿
|
||||
'sad': Color(0xFF7B9CC4), // 😢 难过 — 灰蓝
|
||||
'angry': Color(0xFFE07A5F), // 😠 生气 — 珊瑚
|
||||
'thinking': Color(0xFFB8A9C9),// 🤔 思考 — 淡紫
|
||||
'happy': secondary, // 😊 开心 — 鼠尾草绿 #81B29A
|
||||
'calm': tertiary, // 😌 平静 — 暖金 #F2CC8F
|
||||
'sad': Color(0xFF5B7DB1), // 😢 难过 — 灰蓝
|
||||
'angry': accent, // 😠 生气 — 珊瑚 #E07A5F
|
||||
'thinking': metaLight, // 🤔 思考 — 灰棕 #8B7E74(替代原先的淡紫,spec 无淡紫)
|
||||
};
|
||||
|
||||
/// 心情 → 日历背景色
|
||||
/// 心情 → 日历单元格背景色
|
||||
static const Map<String, Color> moodCellColors = {
|
||||
'happy': secondarySoftLight, // #D4E8DC
|
||||
'happy': secondarySoftLight, // #D4E8DC
|
||||
'love': roseSoftLight, // #F0DADA
|
||||
'calm': tertiarySoftLight, // #FBE8C8
|
||||
'sad': Color(0xFFD4DDE8), // 灰蓝
|
||||
|
||||
@@ -1,16 +1,12 @@
|
||||
// 暖记圆角系统
|
||||
// 对齐 Open Design 原型稿 tokens.css: xs(8) / sm(10) / md(16) / lg(22) / xl(28) / pill
|
||||
// 对齐 Open Design 原型稿 tokens.css: sm(10) / md(16) / lg(22) / xl(28) / pill
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class AppRadius {
|
||||
AppRadius._();
|
||||
|
||||
/// 极小圆角 8px — 小型元素
|
||||
static const double xs = 8;
|
||||
static BorderRadius get xsBorder => BorderRadius.circular(xs);
|
||||
|
||||
/// 小圆角 10px — 按钮、输入框
|
||||
/// 小圆角 10px — 按钮、输入框、小元素
|
||||
static const double sm = 10;
|
||||
static BorderRadius get smBorder => BorderRadius.circular(sm);
|
||||
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
// 暖记阴影系统 — soft / medium / float
|
||||
// 对齐 spec §1 阴影 token:
|
||||
// --elev-soft: 0 2px 12px rgba(45,36,32,0.06)
|
||||
// --elev-medium: 0 4px 20px rgba(45,36,32,0.08)
|
||||
// --elev-float: 0 8px 32px rgba(45,36,32,0.12)
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
@@ -12,9 +16,9 @@ class AppShadows {
|
||||
BoxShadow(
|
||||
color: isDark
|
||||
? Colors.black.withValues(alpha: 0.3)
|
||||
: const Color(0xFF2D2420).withValues(alpha: 0.08),
|
||||
: const Color(0xFF2D2420).withValues(alpha: 0.06),
|
||||
offset: const Offset(0, 2),
|
||||
blurRadius: 8,
|
||||
blurRadius: 12,
|
||||
),
|
||||
];
|
||||
}
|
||||
@@ -26,9 +30,9 @@ class AppShadows {
|
||||
BoxShadow(
|
||||
color: isDark
|
||||
? Colors.black.withValues(alpha: 0.4)
|
||||
: const Color(0xFF2D2420).withValues(alpha: 0.12),
|
||||
: const Color(0xFF2D2420).withValues(alpha: 0.08),
|
||||
offset: const Offset(0, 4),
|
||||
blurRadius: 16,
|
||||
blurRadius: 20,
|
||||
),
|
||||
];
|
||||
}
|
||||
@@ -40,9 +44,9 @@ class AppShadows {
|
||||
BoxShadow(
|
||||
color: isDark
|
||||
? Colors.black.withValues(alpha: 0.5)
|
||||
: const Color(0xFF2D2420).withValues(alpha: 0.16),
|
||||
: const Color(0xFF2D2420).withValues(alpha: 0.12),
|
||||
offset: const Offset(0, 8),
|
||||
blurRadius: 24,
|
||||
blurRadius: 32,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -157,7 +157,7 @@ class _AchievementProgressCard extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
ClipRRect(
|
||||
borderRadius: AppRadius.xsBorder,
|
||||
borderRadius: AppRadius.smBorder,
|
||||
child: LinearProgressIndicator(
|
||||
value: progress,
|
||||
minHeight: 10,
|
||||
|
||||
@@ -182,13 +182,10 @@ class _LoginPageState extends State<LoginPage> with SingleTickerProviderStateMix
|
||||
padding: const EdgeInsets.only(bottom: DesignTokens.spacing16),
|
||||
child: TextFormField(
|
||||
controller: _displayNameController,
|
||||
decoration: InputDecoration(
|
||||
decoration: const InputDecoration(
|
||||
labelText: '昵称',
|
||||
hintText: '你想被叫什么名字?',
|
||||
prefixIcon: const Icon(Icons.face_rounded),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: AppRadius.mdBorder,
|
||||
),
|
||||
prefixIcon: Icon(Icons.face_rounded),
|
||||
),
|
||||
textInputAction: TextInputAction.next,
|
||||
),
|
||||
@@ -202,9 +199,6 @@ class _LoginPageState extends State<LoginPage> with SingleTickerProviderStateMix
|
||||
labelText: '账号',
|
||||
hintText: _isRegister ? '设置一个账号名' : '输入你的账号',
|
||||
prefixIcon: const Icon(Icons.person_rounded),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: AppRadius.mdBorder,
|
||||
),
|
||||
),
|
||||
textInputAction: TextInputAction.next,
|
||||
validator: (value) {
|
||||
@@ -237,9 +231,6 @@ class _LoginPageState extends State<LoginPage> with SingleTickerProviderStateMix
|
||||
});
|
||||
},
|
||||
),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: AppRadius.mdBorder,
|
||||
),
|
||||
),
|
||||
textInputAction: TextInputAction.done,
|
||||
onFieldSubmitted: (_) => _submit(),
|
||||
@@ -269,7 +260,7 @@ class _LoginPageState extends State<LoginPage> with SingleTickerProviderStateMix
|
||||
onPressed: isLoading ? null : _submit,
|
||||
style: FilledButton.styleFrom(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: AppRadius.mdBorder,
|
||||
borderRadius: AppRadius.pillBorder,
|
||||
),
|
||||
),
|
||||
child: isLoading
|
||||
|
||||
@@ -608,7 +608,7 @@ class _DayCell extends StatelessWidget {
|
||||
: isToday
|
||||
? colorScheme.primaryContainer
|
||||
: null,
|
||||
borderRadius: AppRadius.xsBorder,
|
||||
borderRadius: AppRadius.smBorder,
|
||||
border: isToday && !isSelected
|
||||
? Border.all(color: colorScheme.primary, width: 2)
|
||||
: null,
|
||||
|
||||
@@ -351,7 +351,7 @@ class _DiaryWallCard extends StatelessWidget {
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.primaryContainer.withValues(alpha: 0.3),
|
||||
borderRadius: AppRadius.xsBorder,
|
||||
borderRadius: AppRadius.smBorder,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
|
||||
485
app/lib/features/discover/views/discover_page.dart
Normal file
485
app/lib/features/discover/views/discover_page.dart
Normal file
@@ -0,0 +1,485 @@
|
||||
// 发现页 — 严格对齐 spec §3.12 discover.html
|
||||
//
|
||||
// 视觉层级(从上到下):
|
||||
// 1. 搜索框 (pill 形状)
|
||||
// 2. 每日推荐卡片 inspiration-card (accent→tertiary 渐变)
|
||||
// 3. 热门话题 hot-topics (横向滚动 chips)
|
||||
// 4. 精选模板 featured-templates (2 列网格)
|
||||
// 5. 达人日记 expert-diaries (纵向列表)
|
||||
//
|
||||
// 注意:本页是发现/灵感浏览,区别于 /search(主动搜索)
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../../core/constants/design_tokens.dart';
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
import '../../../core/theme/app_radius.dart';
|
||||
import '../../../core/theme/app_shadows.dart';
|
||||
import '../../../core/theme/app_typography.dart';
|
||||
|
||||
class DiscoverPage extends StatelessWidget {
|
||||
const DiscoverPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final isDark = theme.brightness == Brightness.dark;
|
||||
final bg = isDark ? AppColors.bgDark : AppColors.bgLight;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: bg,
|
||||
body: SafeArea(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: DesignTokens.spacing20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SizedBox(height: DesignTokens.spacing12),
|
||||
_SearchBar(onTap: () => context.push('/search')),
|
||||
const SizedBox(height: DesignTokens.spacing20),
|
||||
const _InspirationCard(
|
||||
title: '今日推荐:图书馆的午后时光',
|
||||
author: '小暖 · 5月31日',
|
||||
emoji: '📚',
|
||||
),
|
||||
const SizedBox(height: DesignTokens.spacing24),
|
||||
_SectionTitle(title: '热门话题'),
|
||||
const SizedBox(height: DesignTokens.spacing12),
|
||||
const _HotTopicsChips(),
|
||||
const SizedBox(height: DesignTokens.spacing24),
|
||||
_SectionTitle(title: '精选模板'),
|
||||
const SizedBox(height: DesignTokens.spacing12),
|
||||
const _FeaturedTemplatesGrid(),
|
||||
const SizedBox(height: DesignTokens.spacing24),
|
||||
_SectionTitle(title: '达人日记'),
|
||||
const SizedBox(height: DesignTokens.spacing12),
|
||||
const _ExpertDiariesList(),
|
||||
const SizedBox(height: DesignTokens.spacing24),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 1. 搜索框(点击跳转 /search)
|
||||
class _SearchBar extends StatelessWidget {
|
||||
const _SearchBar({required this.onTap});
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: AppRadius.pillBorder,
|
||||
child: Container(
|
||||
height: 48,
|
||||
padding: const EdgeInsets.symmetric(horizontal: DesignTokens.spacing16),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surface,
|
||||
borderRadius: AppRadius.pillBorder,
|
||||
border: Border.all(color: theme.colorScheme.outlineVariant),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.search_rounded, size: 20, color: theme.colorScheme.onSurfaceVariant),
|
||||
const SizedBox(width: DesignTokens.spacing12),
|
||||
Text(
|
||||
'搜索日记、模板、话题...',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 2. 每日推荐卡片(渐变背景)
|
||||
class _InspirationCard extends StatelessWidget {
|
||||
const _InspirationCard({
|
||||
required this.title,
|
||||
required this.author,
|
||||
required this.emoji,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final String author;
|
||||
final String emoji;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(DesignTokens.spacing20),
|
||||
decoration: BoxDecoration(
|
||||
gradient: const LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [AppColors.accent, AppColors.tertiary],
|
||||
),
|
||||
borderRadius: AppRadius.lgBorder,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: AppColors.accent.withValues(alpha: 0.2),
|
||||
offset: const Offset(0, 4),
|
||||
blurRadius: 14,
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
// 装饰圆
|
||||
Positioned(
|
||||
right: -20,
|
||||
top: -20,
|
||||
child: Container(
|
||||
width: 120,
|
||||
height: 120,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: Colors.white.withValues(alpha: 0.12),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
left: -10,
|
||||
bottom: -20,
|
||||
child: Container(
|
||||
width: 80,
|
||||
height: 80,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: Colors.white.withValues(alpha: 0.08),
|
||||
),
|
||||
),
|
||||
),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'今日推荐',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.white.withValues(alpha: 0.85),
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: DesignTokens.spacing12),
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 80,
|
||||
height: 80,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.2),
|
||||
borderRadius: AppRadius.mdBorder,
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Text(emoji, style: const TextStyle(fontSize: 36)),
|
||||
),
|
||||
const SizedBox(width: DesignTokens.spacing16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Colors.white,
|
||||
height: 1.25,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
author,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.white.withValues(alpha: 0.75),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SectionTitle extends StatelessWidget {
|
||||
const _SectionTitle({required this.title});
|
||||
final String title;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
fontFamily: AppTypography.displayFont,
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: theme.colorScheme.onSurface,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 3. 热门话题(横向滚动 chips)
|
||||
class _HotTopicsChips extends StatelessWidget {
|
||||
const _HotTopicsChips();
|
||||
|
||||
static const _topics = [
|
||||
'#期末备考', '#读书笔记', '#旅行手账', '#美食日记',
|
||||
'#校园生活', '#自我成长', '#心情日记', '#手写摘抄',
|
||||
];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return SizedBox(
|
||||
height: 44,
|
||||
child: ListView.separated(
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: _topics.length,
|
||||
separatorBuilder: (_, __) => const SizedBox(width: DesignTokens.spacing8),
|
||||
itemBuilder: (context, index) {
|
||||
final isHot = index < 3;
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: isHot ? theme.colorScheme.primary : theme.colorScheme.surface,
|
||||
borderRadius: AppRadius.pillBorder,
|
||||
border: isHot ? null : Border.all(color: theme.colorScheme.outlineVariant),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
_topics[index],
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: isHot ? theme.colorScheme.onPrimary : theme.colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 4. 精选模板(2 列网格)
|
||||
class _FeaturedTemplatesGrid extends StatelessWidget {
|
||||
const _FeaturedTemplatesGrid();
|
||||
|
||||
static const _templates = [
|
||||
('📖', '每日心情日记', '2.3k 人使用', AppColors.secondarySoftLight),
|
||||
('🎓', '期末复习计划', '1.8k 人使用', AppColors.tertiarySoftLight),
|
||||
('🌿', '植物观察日记', '956 人使用', AppColors.roseSoftLight),
|
||||
('✈️', '旅行手账本', '742 人使用', AppColors.secondarySoftLight),
|
||||
];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GridView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 2,
|
||||
mainAxisSpacing: DesignTokens.spacing12,
|
||||
crossAxisSpacing: DesignTokens.spacing12,
|
||||
childAspectRatio: 0.85,
|
||||
),
|
||||
itemCount: _templates.length,
|
||||
itemBuilder: (context, index) {
|
||||
final t = _templates[index];
|
||||
return _TemplateCard(emoji: t.$1, name: t.$2, usage: t.$3, bg: t.$4);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TemplateCard extends StatelessWidget {
|
||||
const _TemplateCard({
|
||||
required this.emoji,
|
||||
required this.name,
|
||||
required this.usage,
|
||||
required this.bg,
|
||||
});
|
||||
|
||||
final String emoji;
|
||||
final String name;
|
||||
final String usage;
|
||||
final Color bg;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Material(
|
||||
color: theme.colorScheme.surface,
|
||||
borderRadius: AppRadius.mdBorder,
|
||||
child: InkWell(
|
||||
onTap: () => context.push('/templates'),
|
||||
borderRadius: AppRadius.mdBorder,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: AppRadius.mdBorder,
|
||||
border: Border.all(color: theme.colorScheme.outlineVariant),
|
||||
),
|
||||
padding: const EdgeInsets.all(DesignTokens.spacing12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
height: 96,
|
||||
decoration: BoxDecoration(
|
||||
color: bg,
|
||||
borderRadius: AppRadius.smBorder,
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Text(emoji, style: const TextStyle(fontSize: 32)),
|
||||
),
|
||||
const SizedBox(height: DesignTokens.spacing8),
|
||||
Text(
|
||||
name,
|
||||
style: TextStyle(
|
||||
fontFamily: AppTypography.displayFont,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: theme.colorScheme.onSurface,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
usage,
|
||||
style: TextStyle(fontSize: 11, color: theme.colorScheme.onSurfaceVariant),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 5. 达人日记(纵向列表)
|
||||
class _ExpertDiariesList extends StatelessWidget {
|
||||
const _ExpertDiariesList();
|
||||
|
||||
static const _experts = [
|
||||
('🌸', '小桃子', '春日漫步手账', '记录春天的每一朵花开...', '342 赞'),
|
||||
('☕', '咖啡少年', '咖啡馆日记', '今天尝试了一家新店...', '218 赞'),
|
||||
('📝', '学习达人', '考研倒计时30天', '坚持就是胜利...', '556 赞'),
|
||||
];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Column(
|
||||
children: _experts.map((e) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: DesignTokens.spacing12),
|
||||
padding: const EdgeInsets.all(DesignTokens.spacing16),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surface,
|
||||
borderRadius: AppRadius.mdBorder,
|
||||
border: Border.all(color: theme.colorScheme.outlineVariant),
|
||||
boxShadow: AppShadows.soft(context),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surfaceWarmLight,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Text(e.$1, style: const TextStyle(fontSize: 20)),
|
||||
),
|
||||
const SizedBox(width: DesignTokens.spacing12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
e.$2,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: DesignTokens.spacing8),
|
||||
Text(
|
||||
'·',
|
||||
style: TextStyle(color: theme.colorScheme.onSurfaceVariant),
|
||||
),
|
||||
const SizedBox(width: DesignTokens.spacing8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
e.$3,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontFamily: AppTypography.displayFont,
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: theme.colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
e.$4,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: DesignTokens.spacing8),
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.favorite_rounded, size: 14, color: AppColors.rose),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
e.$5,
|
||||
style: TextStyle(fontSize: 11, color: theme.colorScheme.onSurfaceVariant),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -48,11 +48,19 @@ final class HomeLoaded extends HomeState {
|
||||
/// 连续写日记天数(从日记列表推算)
|
||||
final int streakDays;
|
||||
|
||||
/// 本月日记数(spec §3.4 quick-stats)
|
||||
final int monthCount;
|
||||
|
||||
/// 总日记数(spec §3.4 quick-stats)
|
||||
final int totalCount;
|
||||
|
||||
const HomeLoaded({
|
||||
this.recentJournals = const [],
|
||||
this.hasTodayEntry = false,
|
||||
this.topMood,
|
||||
this.streakDays = 0,
|
||||
this.monthCount = 0,
|
||||
this.totalCount = 0,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -103,11 +111,18 @@ class HomeBloc extends Bloc<HomeEvent, HomeState> {
|
||||
// 推算连续天数
|
||||
final streakDays = _calculateStreak(journals);
|
||||
|
||||
// 本月日记数 & 总数(spec §3.4 quick-stats)
|
||||
final monthCount = journals.where((j) =>
|
||||
j.date.year == today.year && j.date.month == today.month).length;
|
||||
final totalCount = journals.length;
|
||||
|
||||
emit(HomeLoaded(
|
||||
recentJournals: journals,
|
||||
hasTodayEntry: hasTodayEntry,
|
||||
topMood: topMood,
|
||||
streakDays: streakDays,
|
||||
monthCount: monthCount,
|
||||
totalCount: totalCount,
|
||||
));
|
||||
} catch (e) {
|
||||
emit(const HomeLoaded()); // 空状态而非错误,离线友好
|
||||
|
||||
@@ -1,16 +1,31 @@
|
||||
// 首页 — 日记流 + 心情概览
|
||||
// 首页·日记流 — 严格对齐 spec §3.4 home-daily.html
|
||||
//
|
||||
// 视觉层级(从上到下):
|
||||
// 1. 问候语 + 日期(右上角搜索按钮)
|
||||
// 2. 连续记录徽章 streak-badge (pill)
|
||||
// 3. 心情选择器(5 选 1,bg=#FFFFFF surface 卡片)
|
||||
// 4. "今天的日记" 渐变卡片 + 浮动写按钮
|
||||
// 5. 三栏统计(本月日记/连续天数/总日记数)
|
||||
// 6. 最近记录标题 + 查看全部
|
||||
// 7. 日记卡片列表
|
||||
//
|
||||
// 颜色规范(spec §7.1):
|
||||
// - 页面背景用 var(--bg) #FFF8F0(不是纯白)
|
||||
// - Card 用 var(--surface) #FFFFFF(与页面背景形成层次)
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:nuanji_app/core/theme/app_colors.dart';
|
||||
import 'package:nuanji_app/core/theme/app_radius.dart';
|
||||
import 'package:nuanji_app/core/theme/app_typography.dart';
|
||||
import 'package:nuanji_app/data/models/journal_entry.dart';
|
||||
import 'package:nuanji_app/data/repositories/journal_repository.dart';
|
||||
|
||||
import '../../../core/constants/design_tokens.dart';
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
import '../../../core/theme/app_radius.dart';
|
||||
import '../../../core/theme/app_shadows.dart';
|
||||
import '../../../core/theme/app_typography.dart';
|
||||
import '../../../data/models/journal_entry.dart';
|
||||
import '../../../data/repositories/journal_repository.dart';
|
||||
import '../bloc/home_bloc.dart';
|
||||
|
||||
/// 首页 — 展示最近日记流和心情概览
|
||||
class HomePage extends StatelessWidget {
|
||||
const HomePage({super.key});
|
||||
|
||||
@@ -31,278 +46,691 @@ class _HomeView extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
final isDark = theme.brightness == Brightness.dark;
|
||||
final bg = isDark ? AppColors.bgDark : AppColors.bgLight;
|
||||
|
||||
return BlocBuilder<HomeBloc, HomeState>(
|
||||
builder: (context, state) {
|
||||
final loaded = state is HomeLoaded ? state : const HomeLoaded();
|
||||
final isLoading = state is HomeLoading;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(
|
||||
'暖记',
|
||||
style: theme.textTheme.headlineSmall?.copyWith(
|
||||
fontFamily: AppTypography.handwrittenFont,
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
// 搜索按钮(设计稿要求右上角圆形搜索按钮)
|
||||
IconButton(
|
||||
onPressed: () => context.push('/discover'),
|
||||
icon: const Icon(Icons.search_rounded),
|
||||
tooltip: '搜索',
|
||||
),
|
||||
IconButton(
|
||||
onPressed: () => context.go('/stickers'),
|
||||
icon: const Icon(Icons.emoji_emotions_outlined),
|
||||
tooltip: '贴纸库',
|
||||
),
|
||||
IconButton(
|
||||
onPressed: () => context.go('/templates'),
|
||||
icon: const Icon(Icons.dashboard_customize_outlined),
|
||||
tooltip: '模板',
|
||||
),
|
||||
],
|
||||
),
|
||||
body: state is HomeLoading
|
||||
backgroundColor: bg,
|
||||
body: isLoading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: state is HomeLoaded
|
||||
? _buildContent(context, state)
|
||||
: _buildContent(context, const HomeLoaded()),
|
||||
: RefreshIndicator(
|
||||
onRefresh: () async {
|
||||
context.read<HomeBloc>().add(const HomeRefresh());
|
||||
},
|
||||
child: _buildContent(context, loaded),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildContent(BuildContext context, HomeLoaded state) {
|
||||
return RefreshIndicator(
|
||||
onRefresh: () async {
|
||||
context.read<HomeBloc>().add(const HomeRefresh());
|
||||
},
|
||||
child: SingleChildScrollView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 心情快速选择卡片
|
||||
_QuickMoodCard(
|
||||
hasTodayEntry: state.hasTodayEntry,
|
||||
topMood: state.topMood,
|
||||
streakDays: state.streakDays,
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
final now = DateTime.now();
|
||||
final greeting = _greeting(now.hour);
|
||||
final dateText = _formatDate(now);
|
||||
|
||||
// 最近日记
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'最近日记',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => context.go('/calendar'),
|
||||
child: const Text('查看全部'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
return SingleChildScrollView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
DesignTokens.spacing20,
|
||||
MediaQuery.of(context).padding.top + DesignTokens.spacing8,
|
||||
DesignTokens.spacing20,
|
||||
DesignTokens.spacing24,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_GreetingHeader(
|
||||
greeting: greeting,
|
||||
username: '小暖',
|
||||
dateText: dateText,
|
||||
onSearchTap: () => context.push('/search'),
|
||||
),
|
||||
const SizedBox(height: DesignTokens.spacing16),
|
||||
|
||||
state.recentJournals.isEmpty
|
||||
? const _EmptyJournalState()
|
||||
: _JournalList(journals: state.recentJournals),
|
||||
if (state.streakDays > 0) ...[
|
||||
_StreakBadge(days: state.streakDays),
|
||||
const SizedBox(height: DesignTokens.spacing16),
|
||||
],
|
||||
),
|
||||
|
||||
_MoodSelectorCard(
|
||||
topMood: state.topMood,
|
||||
weather: const _Weather(icon: '☀', label: '晴 26°'),
|
||||
onMoodTap: (_) => context.push('/editor'),
|
||||
),
|
||||
const SizedBox(height: DesignTokens.spacing20),
|
||||
|
||||
_TodayCard(
|
||||
hasTodayEntry: state.hasTodayEntry,
|
||||
onTap: () => context.push('/editor'),
|
||||
),
|
||||
const SizedBox(height: DesignTokens.spacing20),
|
||||
|
||||
_QuickStats(
|
||||
monthCount: state.monthCount,
|
||||
streakDays: state.streakDays,
|
||||
totalCount: state.totalCount,
|
||||
),
|
||||
const SizedBox(height: DesignTokens.spacing24),
|
||||
|
||||
_SectionHeader(
|
||||
title: '最近记录',
|
||||
onSeeAll: () => context.go('/calendar'),
|
||||
),
|
||||
const SizedBox(height: DesignTokens.spacing12),
|
||||
|
||||
state.recentJournals.isEmpty
|
||||
? const _EmptyJournalState()
|
||||
: _JournalList(journals: state.recentJournals),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _greeting(int hour) {
|
||||
if (hour < 6) return '夜深了';
|
||||
if (hour < 11) return '早上好';
|
||||
if (hour < 14) return '中午好';
|
||||
if (hour < 18) return '下午好';
|
||||
if (hour < 22) return '晚上好';
|
||||
return '夜深了';
|
||||
}
|
||||
|
||||
String _formatDate(DateTime now) {
|
||||
const weekdays = ['周一', '周二', '周三', '周四', '周五', '周六', '周日'];
|
||||
final w = weekdays[now.weekday - 1];
|
||||
return '${now.year}年${now.month}月${now.day}日 · $w';
|
||||
}
|
||||
}
|
||||
|
||||
/// 心情快速选择卡片
|
||||
class _QuickMoodCard extends StatelessWidget {
|
||||
const _QuickMoodCard({
|
||||
required this.hasTodayEntry,
|
||||
this.topMood,
|
||||
this.streakDays = 0,
|
||||
/// 1. 顶部问候 + 日期 + 搜索按钮
|
||||
class _GreetingHeader extends StatelessWidget {
|
||||
const _GreetingHeader({
|
||||
required this.greeting,
|
||||
required this.username,
|
||||
required this.dateText,
|
||||
required this.onSearchTap,
|
||||
});
|
||||
|
||||
final bool hasTodayEntry;
|
||||
final Mood? topMood;
|
||||
final int streakDays;
|
||||
final String greeting;
|
||||
final String username;
|
||||
final String dateText;
|
||||
final VoidCallback onSearchTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
final moods = [
|
||||
('😊', '开心', Mood.happy),
|
||||
('😌', '平静', Mood.calm),
|
||||
('😢', '难过', Mood.sad),
|
||||
('😠', '生气', Mood.angry),
|
||||
('🤔', '思考', Mood.thinking),
|
||||
];
|
||||
final fg = colorScheme.onSurface;
|
||||
final muted = colorScheme.onSurfaceVariant;
|
||||
|
||||
return Card(
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(borderRadius: AppRadius.lgBorder),
|
||||
color: colorScheme.primaryContainer.withValues(alpha: 0.3),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text('今天心情如何?', style: theme.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600)),
|
||||
const Spacer(),
|
||||
if (streakDays > 0)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.tertiary.withValues(alpha: 0.2),
|
||||
borderRadius: AppRadius.xsBorder,
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
dateText,
|
||||
style: TextStyle(fontSize: 13, color: muted, fontWeight: FontWeight.w500),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text.rich(
|
||||
TextSpan(
|
||||
style: TextStyle(
|
||||
fontFamily: AppTypography.displayFont,
|
||||
fontSize: 30,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: fg,
|
||||
height: 1.2,
|
||||
),
|
||||
children: [
|
||||
TextSpan(text: '$greeting,'),
|
||||
TextSpan(
|
||||
text: username,
|
||||
style: TextStyle(color: colorScheme.primary),
|
||||
),
|
||||
child: Text('🔥 连续 $streakDays 天', style: theme.textTheme.labelSmall),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
InkWell(
|
||||
onTap: onSearchTap,
|
||||
customBorder: const CircleBorder(),
|
||||
child: Container(
|
||||
width: DesignTokens.touchMin,
|
||||
height: DesignTokens.touchMin,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surface,
|
||||
shape: BoxShape.circle,
|
||||
boxShadow: AppShadows.soft(context),
|
||||
),
|
||||
child: Icon(Icons.search_rounded, size: 20, color: fg),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 2. 连续记录徽章 (pill, tertiary-soft 背景)
|
||||
class _StreakBadge extends StatelessWidget {
|
||||
const _StreakBadge({required this.days});
|
||||
final int days;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.tertiarySoftLight,
|
||||
borderRadius: AppRadius.pillBorder,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.local_fire_department_rounded,
|
||||
size: 14,
|
||||
color: Color(0xFFB8860B),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'连续记录 $days 天',
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Color(0xFFB8860B),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 3. 心情选择器卡片 — bg=#FFFFFF, radius=16
|
||||
class _MoodSelectorCard extends StatelessWidget {
|
||||
const _MoodSelectorCard({
|
||||
required this.topMood,
|
||||
required this.weather,
|
||||
required this.onMoodTap,
|
||||
});
|
||||
|
||||
final Mood? topMood;
|
||||
final _Weather weather;
|
||||
final ValueChanged<Mood> onMoodTap;
|
||||
|
||||
static const _moods = [
|
||||
('😊', '开心', Mood.happy),
|
||||
('😐', '平静', Mood.calm),
|
||||
('😢', '难过', Mood.sad),
|
||||
('😡', '生气', Mood.angry),
|
||||
('🤔', '思考', Mood.thinking),
|
||||
];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: DesignTokens.spacing20,
|
||||
vertical: DesignTokens.spacing16,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surface,
|
||||
borderRadius: AppRadius.mdBorder,
|
||||
boxShadow: AppShadows.soft(context),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
'今天心情如何?',
|
||||
style: TextStyle(
|
||||
fontFamily: AppTypography.handwrittenFont,
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: theme.colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
Text(
|
||||
'${weather.icon} ${weather.label}',
|
||||
style: TextStyle(fontSize: 13, color: theme.colorScheme.onSurfaceVariant),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: DesignTokens.spacing12),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: _moods.map((m) {
|
||||
final isTop = topMood == m.$3;
|
||||
return InkWell(
|
||||
onTap: () => onMoodTap(m.$3),
|
||||
customBorder: const CircleBorder(),
|
||||
child: _MoodOption(
|
||||
emoji: m.$1,
|
||||
label: m.$2,
|
||||
selected: isTop,
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MoodOption extends StatelessWidget {
|
||||
const _MoodOption({
|
||||
required this.emoji,
|
||||
required this.label,
|
||||
required this.selected,
|
||||
});
|
||||
|
||||
final String emoji;
|
||||
final String label;
|
||||
final bool selected;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
AnimatedContainer(
|
||||
duration: DesignTokens.animFast,
|
||||
width: DesignTokens.touchMin,
|
||||
height: DesignTokens.touchMin,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: selected ? AppColors.surfaceWarmLight : Colors.transparent,
|
||||
borderRadius: AppRadius.mdBorder,
|
||||
),
|
||||
child: Text(emoji, style: const TextStyle(fontSize: 28)),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: selected ? FontWeight.w600 : FontWeight.w500,
|
||||
color: selected ? theme.colorScheme.primary : theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Weather {
|
||||
const _Weather({required this.icon, required this.label});
|
||||
final String icon;
|
||||
final String label;
|
||||
}
|
||||
|
||||
/// 4. "今天的日记" 渐变卡片 + 浮动写按钮
|
||||
class _TodayCard extends StatelessWidget {
|
||||
const _TodayCard({required this.hasTodayEntry, required this.onTap});
|
||||
final bool hasTodayEntry;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: AppRadius.lgBorder,
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(DesignTokens.spacing24),
|
||||
decoration: BoxDecoration(
|
||||
gradient: const LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [AppColors.accent, AppColors.tertiary],
|
||||
),
|
||||
borderRadius: AppRadius.lgBorder,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: AppColors.accent.withValues(alpha: 0.25),
|
||||
offset: const Offset(0, 4),
|
||||
blurRadius: 14,
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned(
|
||||
right: -30,
|
||||
top: -30,
|
||||
child: Container(
|
||||
width: 120,
|
||||
height: 120,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: Colors.white.withValues(alpha: 0.12),
|
||||
),
|
||||
if (hasTodayEntry)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.secondary.withValues(alpha: 0.2),
|
||||
borderRadius: AppRadius.xsBorder,
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
left: -20,
|
||||
bottom: -20,
|
||||
child: Container(
|
||||
width: 80,
|
||||
height: 80,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: Colors.white.withValues(alpha: 0.08),
|
||||
),
|
||||
),
|
||||
),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'今天的日记',
|
||||
style: TextStyle(
|
||||
fontFamily: AppTypography.handwrittenFont,
|
||||
fontSize: 13,
|
||||
color: Colors.white.withValues(alpha: 0.85),
|
||||
),
|
||||
child: Text('✅ 今日已写', style: theme.textTheme.labelSmall),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||
children: moods.map((mood) {
|
||||
final isTop = topMood == mood.$3;
|
||||
return GestureDetector(
|
||||
onTap: () => context.push('/editor'),
|
||||
child: Column(
|
||||
children: [
|
||||
Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: (AppColors.moodColors[mood.$3.value] ?? colorScheme.primary)
|
||||
.withValues(alpha: isTop ? 0.3 : 0.15),
|
||||
border: isTop ? Border.all(color: AppColors.accent, width: 2) : null,
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Text(mood.$1, style: const TextStyle(fontSize: 22)),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(mood.$2, style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: colorScheme.onSurface.withValues(alpha: 0.6),
|
||||
)),
|
||||
],
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
hasTodayEntry ? '继续今天的记录' : '写点什么吧...',
|
||||
style: const TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.bgLight,
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
'记录一个温暖的瞬间...',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: Colors.white.withValues(alpha: 0.75),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Positioned(
|
||||
right: DesignTokens.spacing20,
|
||||
bottom: DesignTokens.spacing20,
|
||||
child: Material(
|
||||
color: Colors.white,
|
||||
shape: const CircleBorder(),
|
||||
elevation: 4,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
customBorder: const CircleBorder(),
|
||||
child: SizedBox(
|
||||
width: 48,
|
||||
height: 48,
|
||||
child: Icon(Icons.add_rounded, size: 22, color: AppColors.accent),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 日记列表
|
||||
/// 5. 三栏统计
|
||||
class _QuickStats extends StatelessWidget {
|
||||
const _QuickStats({
|
||||
required this.monthCount,
|
||||
required this.streakDays,
|
||||
required this.totalCount,
|
||||
});
|
||||
|
||||
final int monthCount;
|
||||
final int streakDays;
|
||||
final int totalCount;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(child: _stat(context, '$monthCount', '本月日记', AppColors.accent)),
|
||||
const SizedBox(width: DesignTokens.spacing12),
|
||||
Expanded(child: _stat(context, '$streakDays', '连续天数', AppColors.secondary)),
|
||||
const SizedBox(width: DesignTokens.spacing12),
|
||||
Expanded(child: _stat(context, '$totalCount', '总日记数', AppColors.fgLight)),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _stat(BuildContext context, String num, String label, Color color) {
|
||||
final theme = Theme.of(context);
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(DesignTokens.spacing16),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surface,
|
||||
borderRadius: AppRadius.mdBorder,
|
||||
boxShadow: AppShadows.soft(context),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Text(
|
||||
num,
|
||||
style: TextStyle(
|
||||
fontFamily: AppTypography.displayFont,
|
||||
fontSize: 30,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: color,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(fontSize: 11, color: theme.colorScheme.onSurfaceVariant),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 6. 区块标题
|
||||
class _SectionHeader extends StatelessWidget {
|
||||
const _SectionHeader({required this.title, required this.onSeeAll});
|
||||
|
||||
final String title;
|
||||
final VoidCallback onSeeAll;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
fontFamily: AppTypography.displayFont,
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: theme.colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: onSeeAll,
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: theme.colorScheme.primary,
|
||||
minimumSize: const Size(44, 32),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
),
|
||||
child: const Text('查看全部', style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500)),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 7. 日记列表
|
||||
class _JournalList extends StatelessWidget {
|
||||
const _JournalList({required this.journals});
|
||||
final List<JournalEntry> journals;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
|
||||
return Column(
|
||||
children: journals.map((journal) {
|
||||
final moodColor = AppColors.moodColors[journal.mood.value] ?? colorScheme.primary;
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: AppRadius.mdBorder,
|
||||
side: BorderSide(color: colorScheme.outlineVariant),
|
||||
),
|
||||
child: InkWell(
|
||||
onTap: () => context.push('/editor?id=${journal.id}'),
|
||||
borderRadius: AppRadius.mdBorder,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: moodColor.withValues(alpha: 0.2),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Text(_moodEmoji(journal.mood), style: const TextStyle(fontSize: 20)),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(journal.title, style: theme.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600),
|
||||
maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
const SizedBox(height: 4),
|
||||
Text('${journal.date.month}月${journal.date.day}日',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurface.withValues(alpha: 0.5)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Icon(Icons.chevron_right, color: colorScheme.onSurface.withValues(alpha: 0.3)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
children: journals
|
||||
.map((j) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: DesignTokens.spacing12),
|
||||
child: _JournalCard(journal: j),
|
||||
))
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _JournalCard extends StatelessWidget {
|
||||
const _JournalCard({required this.journal});
|
||||
final JournalEntry journal;
|
||||
|
||||
String _moodEmoji(Mood m) => switch (m) {
|
||||
Mood.happy => '😊',
|
||||
Mood.calm => '😐',
|
||||
Mood.sad => '😢',
|
||||
Mood.angry => '😡',
|
||||
Mood.thinking => '🤔',
|
||||
};
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final moodColor = AppColors.moodColors[journal.mood.value] ?? AppColors.accent;
|
||||
final excerpt = journal.tags.isEmpty ? '点击查看详情' : journal.tags.take(3).map((t) => '#$t').join(' ');
|
||||
|
||||
return Material(
|
||||
color: theme.colorScheme.surface,
|
||||
borderRadius: AppRadius.mdBorder,
|
||||
child: InkWell(
|
||||
onTap: () => context.push('/editor?id=${journal.id}'),
|
||||
borderRadius: AppRadius.mdBorder,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(DesignTokens.spacing16),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: AppRadius.mdBorder,
|
||||
border: Border.all(color: theme.colorScheme.outlineVariant),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 72,
|
||||
height: 72,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surfaceWarmLight,
|
||||
borderRadius: AppRadius.smBorder,
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Text(_moodEmoji(journal.mood), style: const TextStyle(fontSize: 32)),
|
||||
),
|
||||
const SizedBox(width: DesignTokens.spacing16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'${journal.date.month}月${journal.date.day}日',
|
||||
style: TextStyle(fontSize: 11, color: theme.colorScheme.onSurfaceVariant),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
journal.title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontFamily: AppTypography.displayFont,
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: theme.colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
excerpt,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(fontSize: 13, color: theme.colorScheme.onSurfaceVariant, height: 1.5),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Container(
|
||||
width: 28,
|
||||
height: 28,
|
||||
decoration: BoxDecoration(
|
||||
color: moodColor.withValues(alpha: 0.15),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Text(_moodEmoji(journal.mood), style: const TextStyle(fontSize: 14)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _moodEmoji(Mood mood) => switch (mood) {
|
||||
Mood.happy => '😊', Mood.calm => '😌', Mood.sad => '😢',
|
||||
Mood.angry => '😠', Mood.thinking => '🤔',
|
||||
};
|
||||
}
|
||||
|
||||
/// 空日记状态
|
||||
class _EmptyJournalState extends StatelessWidget {
|
||||
const _EmptyJournalState();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 48),
|
||||
padding: const EdgeInsets.symmetric(vertical: DesignTokens.spacing40),
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(Icons.edit_note_rounded, size: 64, color: colorScheme.onSurface.withValues(alpha: 0.2)),
|
||||
const SizedBox(height: 16),
|
||||
Text('开始你的第一篇手账日记吧!',
|
||||
style: theme.textTheme.bodyLarge?.copyWith(color: colorScheme.onSurface.withValues(alpha: 0.5))),
|
||||
const SizedBox(height: 24),
|
||||
Icon(
|
||||
Icons.auto_stories_rounded,
|
||||
size: 64,
|
||||
color: theme.colorScheme.onSurface.withValues(alpha: 0.2),
|
||||
),
|
||||
const SizedBox(height: DesignTokens.spacing16),
|
||||
Text(
|
||||
'开始你的第一篇手账日记吧!',
|
||||
style: theme.textTheme.bodyLarge?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: DesignTokens.spacing24),
|
||||
FilledButton.icon(
|
||||
onPressed: () => context.push('/editor'),
|
||||
icon: const Icon(Icons.add_rounded),
|
||||
label: const Text('写日记'),
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: AppColors.accent,
|
||||
foregroundColor: AppColors.bgLight,
|
||||
shape: RoundedRectangleBorder(borderRadius: AppRadius.pillBorder),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -83,31 +83,7 @@ class _ResponsiveScaffoldState extends State<ResponsiveScaffold> {
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 导航项定义(4 项,中间由 FAB 占位)=====
|
||||
|
||||
const _navItems = [
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.home_outlined),
|
||||
selectedIcon: Icon(Icons.home),
|
||||
label: '首页',
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.calendar_month_outlined),
|
||||
selectedIcon: Icon(Icons.calendar_month),
|
||||
label: '日历',
|
||||
),
|
||||
// 索引 2: 中心 FAB 占位(写日记)
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.explore_outlined),
|
||||
selectedIcon: Icon(Icons.explore),
|
||||
label: '发现',
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.person_outline),
|
||||
selectedIcon: Icon(Icons.person),
|
||||
label: '我的',
|
||||
),
|
||||
];
|
||||
// ===== 导航项定义(平板/桌面 NavigationRail)=====
|
||||
|
||||
const _railItems = [
|
||||
NavigationRailDestination(
|
||||
@@ -132,7 +108,8 @@ const _railItems = [
|
||||
),
|
||||
];
|
||||
|
||||
// ===== 中心写日记 FAB 按钮 =====
|
||||
// ===== 中心写日记 FAB 按钮(spec §2.2 凸起按钮)=====
|
||||
// 尺寸 48x48, 圆形, accent 色, shadow-accent
|
||||
|
||||
class _CenterFabButton extends StatelessWidget {
|
||||
const _CenterFabButton({required this.onPressed});
|
||||
@@ -141,19 +118,33 @@ class _CenterFabButton extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FloatingActionButton(
|
||||
heroTag: 'center_write',
|
||||
onPressed: onPressed,
|
||||
backgroundColor: AppColors.accent,
|
||||
foregroundColor: const Color(0xFFFFF8F0),
|
||||
elevation: 4,
|
||||
return Material(
|
||||
color: AppColors.accent,
|
||||
shape: const CircleBorder(),
|
||||
child: const Icon(Icons.edit_rounded, size: 28),
|
||||
elevation: 4,
|
||||
shadowColor: AppColors.accent.withValues(alpha: 0.4),
|
||||
child: InkWell(
|
||||
onTap: onPressed,
|
||||
customBorder: const CircleBorder(),
|
||||
child: SizedBox(
|
||||
width: 48,
|
||||
height: 48,
|
||||
child: Center(
|
||||
child: Icon(Icons.add_rounded, size: 24, color: AppColors.bgLight),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 手机布局 — 底部 TabBar + 中心凸起 FAB =====
|
||||
// ===== 手机布局 — 底部 TabBar (90px) + 中心凸起 FAB =====
|
||||
//
|
||||
// spec §2.2 规定:
|
||||
// - Tab 栏总高 = 56px (tab-height) + 34px (safe-bottom) = 90px
|
||||
// - 中心"写日记"按钮 margin-top:-16px 凸出 Tab 栏顶部
|
||||
// - 圆形 48x48, accent 色, shadow-accent
|
||||
// - Tab 项图标 24x24, 文字 11px/500
|
||||
|
||||
class _MobileLayout extends StatelessWidget {
|
||||
const _MobileLayout({
|
||||
@@ -177,85 +168,121 @@ class _MobileLayout extends StatelessWidget {
|
||||
? AppBar(title: Text(appBarTitle!))
|
||||
: null,
|
||||
body: body,
|
||||
floatingActionButton: onCenterButtonPressed != null
|
||||
? _CenterFabButton(onPressed: onCenterButtonPressed!)
|
||||
: null,
|
||||
floatingActionButtonLocation: FloatingActionButtonLocation.centerDocked,
|
||||
extendBody: true, // 允许内容延伸到 Tab 栏下面(圆角透明效果)
|
||||
bottomNavigationBar: _BottomNavBar(
|
||||
selectedIndex: selectedIndex,
|
||||
onDestinationSelected: onDestinationSelected,
|
||||
onCenterButtonPressed: onCenterButtonPressed,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 自定义底部导航栏 — 支持中心凹槽
|
||||
/// 自定义底部导航栏 — 90px 总高 + 中心凸起 FAB
|
||||
class _BottomNavBar extends StatelessWidget {
|
||||
const _BottomNavBar({
|
||||
required this.selectedIndex,
|
||||
required this.onDestinationSelected,
|
||||
this.onCenterButtonPressed,
|
||||
});
|
||||
|
||||
final int selectedIndex;
|
||||
final ValueChanged<int> onDestinationSelected;
|
||||
final VoidCallback? onCenterButtonPressed;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
final bottomPadding = MediaQuery.of(context).padding.bottom;
|
||||
// spec §2.2: 总高 = 56 (tab-height) + bottomPadding (safe-bottom, 通常 34)
|
||||
|
||||
return BottomAppBar(
|
||||
shape: const CircularNotchedRectangle(),
|
||||
padding: EdgeInsets.zero,
|
||||
height: 64,
|
||||
color: colorScheme.surface,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||
children: [
|
||||
// 首页
|
||||
_NavItem(
|
||||
icon: Icons.home_outlined,
|
||||
activeIcon: Icons.home,
|
||||
label: '首页',
|
||||
isSelected: selectedIndex == 0,
|
||||
color: colorScheme.primary,
|
||||
inactiveColor: colorScheme.onSurfaceVariant,
|
||||
onTap: () => onDestinationSelected(0),
|
||||
),
|
||||
// 日历
|
||||
_NavItem(
|
||||
icon: Icons.calendar_month_outlined,
|
||||
activeIcon: Icons.calendar_month,
|
||||
label: '日历',
|
||||
isSelected: selectedIndex == 1,
|
||||
color: colorScheme.primary,
|
||||
inactiveColor: colorScheme.onSurfaceVariant,
|
||||
onTap: () => onDestinationSelected(1),
|
||||
),
|
||||
// 中间留给 FAB 凹槽 — 占位
|
||||
const SizedBox(width: 48),
|
||||
// 发现
|
||||
_NavItem(
|
||||
icon: Icons.explore_outlined,
|
||||
activeIcon: Icons.explore,
|
||||
label: '发现',
|
||||
isSelected: selectedIndex == 2,
|
||||
color: colorScheme.primary,
|
||||
inactiveColor: colorScheme.onSurfaceVariant,
|
||||
onTap: () => onDestinationSelected(2),
|
||||
),
|
||||
// 我的
|
||||
_NavItem(
|
||||
icon: Icons.person_outline,
|
||||
activeIcon: Icons.person,
|
||||
label: '我的',
|
||||
isSelected: selectedIndex == 3,
|
||||
color: colorScheme.primary,
|
||||
inactiveColor: colorScheme.onSurfaceVariant,
|
||||
onTap: () => onDestinationSelected(3),
|
||||
),
|
||||
],
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
child: SizedBox(
|
||||
height: 56 + bottomPadding,
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
alignment: Alignment.topCenter,
|
||||
children: [
|
||||
// Tab 栏主体
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surface,
|
||||
border: Border(
|
||||
top: BorderSide(color: colorScheme.outlineVariant, width: 1),
|
||||
),
|
||||
),
|
||||
padding: EdgeInsets.only(bottom: bottomPadding),
|
||||
child: Row(
|
||||
children: [
|
||||
// 首页
|
||||
Expanded(
|
||||
child: _NavItem(
|
||||
icon: Icons.home_outlined,
|
||||
activeIcon: Icons.home,
|
||||
label: '首页',
|
||||
isSelected: selectedIndex == 0,
|
||||
color: colorScheme.primary,
|
||||
inactiveColor: colorScheme.onSurfaceVariant,
|
||||
onTap: () => onDestinationSelected(0),
|
||||
),
|
||||
),
|
||||
// 日历
|
||||
Expanded(
|
||||
child: _NavItem(
|
||||
icon: Icons.calendar_month_outlined,
|
||||
activeIcon: Icons.calendar_month,
|
||||
label: '日历',
|
||||
isSelected: selectedIndex == 1,
|
||||
color: colorScheme.primary,
|
||||
inactiveColor: colorScheme.onSurfaceVariant,
|
||||
onTap: () => onDestinationSelected(1),
|
||||
),
|
||||
),
|
||||
// 中间 FAB 占位
|
||||
const SizedBox(width: 64),
|
||||
// 发现
|
||||
Expanded(
|
||||
child: _NavItem(
|
||||
icon: Icons.explore_outlined,
|
||||
activeIcon: Icons.explore,
|
||||
label: '发现',
|
||||
isSelected: selectedIndex == 2,
|
||||
color: colorScheme.primary,
|
||||
inactiveColor: colorScheme.onSurfaceVariant,
|
||||
onTap: () => onDestinationSelected(2),
|
||||
),
|
||||
),
|
||||
// 我的
|
||||
Expanded(
|
||||
child: _NavItem(
|
||||
icon: Icons.person_outline,
|
||||
activeIcon: Icons.person,
|
||||
label: '我的',
|
||||
isSelected: selectedIndex == 3,
|
||||
color: colorScheme.primary,
|
||||
inactiveColor: colorScheme.onSurfaceVariant,
|
||||
onTap: () => onDestinationSelected(3),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
// 中心凸起写按钮 — margin-top:-16 凸出 Tab 栏顶部
|
||||
if (onCenterButtonPressed != null)
|
||||
Positioned(
|
||||
top: -16,
|
||||
child: _CenterFabButton(onPressed: onCenterButtonPressed!),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
28
apps/web/.gitignore
vendored
Normal file
28
apps/web/.gitignore
vendored
Normal file
@@ -0,0 +1,28 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
node_modules/
|
||||
dist/
|
||||
test-results/
|
||||
playwright-report/
|
||||
73
apps/web/README.md
Normal file
73
apps/web/README.md
Normal file
@@ -0,0 +1,73 @@
|
||||
# React + TypeScript + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
|
||||
|
||||
## React Compiler
|
||||
|
||||
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||
|
||||
```js
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
|
||||
// Remove tseslint.configs.recommended and replace with this
|
||||
tseslint.configs.recommendedTypeChecked,
|
||||
// Alternatively, use this for stricter rules
|
||||
tseslint.configs.strictTypeChecked,
|
||||
// Optionally, add this for stylistic rules
|
||||
tseslint.configs.stylisticTypeChecked,
|
||||
|
||||
// Other configs...
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
|
||||
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||
|
||||
```js
|
||||
// eslint.config.js
|
||||
import reactX from 'eslint-plugin-react-x'
|
||||
import reactDom from 'eslint-plugin-react-dom'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
// Enable lint rules for React
|
||||
reactX.configs['recommended-typescript'],
|
||||
// Enable lint rules for React DOM
|
||||
reactDom.configs.recommended,
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
23
apps/web/eslint.config.js
Normal file
23
apps/web/eslint.config.js
Normal file
@@ -0,0 +1,23 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
reactHooks.configs.flat.recommended,
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
},
|
||||
])
|
||||
19
apps/web/index.html
Normal file
19
apps/web/index.html
Normal file
@@ -0,0 +1,19 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="description" content="ERP 平台底座 — 模块化 SaaS 企业资源管理系统,提供身份权限、工作流引擎、消息中心、系统配置等核心基础设施" />
|
||||
<meta name="theme-color" content="#4F46E5" />
|
||||
<meta name="format-detection" content="telephone=no" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@400;500;600;700&family=Noto+Serif+SC:wght@400;600;700&display=swap" rel="stylesheet" />
|
||||
<title>HMS 健康管理平台</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
8482
apps/web/package-lock.json
generated
Normal file
8482
apps/web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
63
apps/web/package.json
Normal file
63
apps/web/package.json
Normal file
@@ -0,0 +1,63 @@
|
||||
{
|
||||
"name": "web",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e:ui": "playwright test --ui"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ant-design/charts": "^2.6.7",
|
||||
"@ant-design/icons": "^6.1.1",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@wangeditor/editor": "^5.1.23",
|
||||
"@wangeditor/editor-for-react": "^1.0.6",
|
||||
"@xyflow/react": "^12.10.2",
|
||||
"antd": "^6.3.5",
|
||||
"axios": "^1.15.0",
|
||||
"dayjs": "^1.11.20",
|
||||
"dompurify": "^3.4.5",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-router-dom": "^7.14.0",
|
||||
"snabbdom": "^3.6.3",
|
||||
"zustand": "^5.0.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.4",
|
||||
"@playwright/test": "^1.52.0",
|
||||
"@tailwindcss/vite": "^4.2.2",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/dompurify": "^3.2.0",
|
||||
"@types/node": "^24.12.2",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"@vitest/coverage-v8": "^4.1.5",
|
||||
"eslint": "^9.39.4",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^17.4.0",
|
||||
"jsdom": "^29.0.2",
|
||||
"msw": "^2.13.6",
|
||||
"tailwindcss": "^4.2.2",
|
||||
"typescript": "~6.0.2",
|
||||
"typescript-eslint": "^8.58.0",
|
||||
"vite": "^8.0.4",
|
||||
"vitest": "^4.1.5"
|
||||
},
|
||||
"msw": {
|
||||
"workerDirectory": [
|
||||
"public"
|
||||
]
|
||||
}
|
||||
}
|
||||
32
apps/web/playwright.config.ts
Normal file
32
apps/web/playwright.config.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
testMatch: ['smoke/**/*.spec.ts', 'flows/**/*.spec.ts'],
|
||||
timeout: 60_000,
|
||||
retries: 1,
|
||||
fullyParallel: false,
|
||||
workers: 1,
|
||||
forbidOnly: !!process.env.CI,
|
||||
reporter: [['html', { open: 'never' }], ['list']],
|
||||
use: {
|
||||
baseURL: process.env.E2E_BASE_URL || 'http://localhost:5174',
|
||||
headless: true,
|
||||
screenshot: 'only-on-failure',
|
||||
trace: 'on-first-retry',
|
||||
video: 'retain-on-failure',
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
},
|
||||
],
|
||||
globalSetup: './e2e/check-readiness',
|
||||
webServer: {
|
||||
command: 'pnpm dev',
|
||||
port: 5174,
|
||||
reuseExistingServer: true,
|
||||
timeout: 30_000,
|
||||
},
|
||||
});
|
||||
5980
apps/web/pnpm-lock.yaml
generated
Normal file
5980
apps/web/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
BIN
apps/web/public/crm.wasm
Normal file
BIN
apps/web/public/crm.wasm
Normal file
Binary file not shown.
1
apps/web/public/favicon.svg
Normal file
1
apps/web/public/favicon.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.3 KiB |
24
apps/web/public/icons.svg
Normal file
24
apps/web/public/icons.svg
Normal file
@@ -0,0 +1,24 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||
</symbol>
|
||||
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||
</symbol>
|
||||
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||
</symbol>
|
||||
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||
</symbol>
|
||||
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.9 KiB |
BIN
apps/web/public/inventory.wasm
Normal file
BIN
apps/web/public/inventory.wasm
Normal file
Binary file not shown.
349
apps/web/public/mockServiceWorker.js
Normal file
349
apps/web/public/mockServiceWorker.js
Normal file
@@ -0,0 +1,349 @@
|
||||
/* eslint-disable */
|
||||
/* tslint:disable */
|
||||
|
||||
/**
|
||||
* Mock Service Worker.
|
||||
* @see https://github.com/mswjs/msw
|
||||
* - Please do NOT modify this file.
|
||||
*/
|
||||
|
||||
const PACKAGE_VERSION = '2.13.6'
|
||||
const INTEGRITY_CHECKSUM = '4db4a41e972cec1b64cc569c66952d82'
|
||||
const IS_MOCKED_RESPONSE = Symbol('isMockedResponse')
|
||||
const activeClientIds = new Set()
|
||||
|
||||
addEventListener('install', function () {
|
||||
self.skipWaiting()
|
||||
})
|
||||
|
||||
addEventListener('activate', function (event) {
|
||||
event.waitUntil(self.clients.claim())
|
||||
})
|
||||
|
||||
addEventListener('message', async function (event) {
|
||||
const clientId = Reflect.get(event.source || {}, 'id')
|
||||
|
||||
if (!clientId || !self.clients) {
|
||||
return
|
||||
}
|
||||
|
||||
const client = await self.clients.get(clientId)
|
||||
|
||||
if (!client) {
|
||||
return
|
||||
}
|
||||
|
||||
const allClients = await self.clients.matchAll({
|
||||
type: 'window',
|
||||
})
|
||||
|
||||
switch (event.data) {
|
||||
case 'KEEPALIVE_REQUEST': {
|
||||
sendToClient(client, {
|
||||
type: 'KEEPALIVE_RESPONSE',
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case 'INTEGRITY_CHECK_REQUEST': {
|
||||
sendToClient(client, {
|
||||
type: 'INTEGRITY_CHECK_RESPONSE',
|
||||
payload: {
|
||||
packageVersion: PACKAGE_VERSION,
|
||||
checksum: INTEGRITY_CHECKSUM,
|
||||
},
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case 'MOCK_ACTIVATE': {
|
||||
activeClientIds.add(clientId)
|
||||
|
||||
sendToClient(client, {
|
||||
type: 'MOCKING_ENABLED',
|
||||
payload: {
|
||||
client: {
|
||||
id: client.id,
|
||||
frameType: client.frameType,
|
||||
},
|
||||
},
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case 'CLIENT_CLOSED': {
|
||||
activeClientIds.delete(clientId)
|
||||
|
||||
const remainingClients = allClients.filter((client) => {
|
||||
return client.id !== clientId
|
||||
})
|
||||
|
||||
// Unregister itself when there are no more clients
|
||||
if (remainingClients.length === 0) {
|
||||
self.registration.unregister()
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
addEventListener('fetch', function (event) {
|
||||
const requestInterceptedAt = Date.now()
|
||||
|
||||
// Bypass navigation requests.
|
||||
if (event.request.mode === 'navigate') {
|
||||
return
|
||||
}
|
||||
|
||||
// Opening the DevTools triggers the "only-if-cached" request
|
||||
// that cannot be handled by the worker. Bypass such requests.
|
||||
if (
|
||||
event.request.cache === 'only-if-cached' &&
|
||||
event.request.mode !== 'same-origin'
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
// Bypass all requests when there are no active clients.
|
||||
// Prevents the self-unregistered worked from handling requests
|
||||
// after it's been terminated (still remains active until the next reload).
|
||||
if (activeClientIds.size === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const requestId = crypto.randomUUID()
|
||||
event.respondWith(handleRequest(event, requestId, requestInterceptedAt))
|
||||
})
|
||||
|
||||
/**
|
||||
* @param {FetchEvent} event
|
||||
* @param {string} requestId
|
||||
* @param {number} requestInterceptedAt
|
||||
*/
|
||||
async function handleRequest(event, requestId, requestInterceptedAt) {
|
||||
const client = await resolveMainClient(event)
|
||||
const requestCloneForEvents = event.request.clone()
|
||||
const response = await getResponse(
|
||||
event,
|
||||
client,
|
||||
requestId,
|
||||
requestInterceptedAt,
|
||||
)
|
||||
|
||||
// Send back the response clone for the "response:*" life-cycle events.
|
||||
// Ensure MSW is active and ready to handle the message, otherwise
|
||||
// this message will pend indefinitely.
|
||||
if (client && activeClientIds.has(client.id)) {
|
||||
const serializedRequest = await serializeRequest(requestCloneForEvents)
|
||||
|
||||
// Clone the response so both the client and the library could consume it.
|
||||
const responseClone = response.clone()
|
||||
|
||||
sendToClient(
|
||||
client,
|
||||
{
|
||||
type: 'RESPONSE',
|
||||
payload: {
|
||||
isMockedResponse: IS_MOCKED_RESPONSE in response,
|
||||
request: {
|
||||
id: requestId,
|
||||
...serializedRequest,
|
||||
},
|
||||
response: {
|
||||
type: responseClone.type,
|
||||
status: responseClone.status,
|
||||
statusText: responseClone.statusText,
|
||||
headers: Object.fromEntries(responseClone.headers.entries()),
|
||||
body: responseClone.body,
|
||||
},
|
||||
},
|
||||
},
|
||||
responseClone.body ? [serializedRequest.body, responseClone.body] : [],
|
||||
)
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the main client for the given event.
|
||||
* Client that issues a request doesn't necessarily equal the client
|
||||
* that registered the worker. It's with the latter the worker should
|
||||
* communicate with during the response resolving phase.
|
||||
* @param {FetchEvent} event
|
||||
* @returns {Promise<Client | undefined>}
|
||||
*/
|
||||
async function resolveMainClient(event) {
|
||||
const client = await self.clients.get(event.clientId)
|
||||
|
||||
if (activeClientIds.has(event.clientId)) {
|
||||
return client
|
||||
}
|
||||
|
||||
if (client?.frameType === 'top-level') {
|
||||
return client
|
||||
}
|
||||
|
||||
const allClients = await self.clients.matchAll({
|
||||
type: 'window',
|
||||
})
|
||||
|
||||
return allClients
|
||||
.filter((client) => {
|
||||
// Get only those clients that are currently visible.
|
||||
return client.visibilityState === 'visible'
|
||||
})
|
||||
.find((client) => {
|
||||
// Find the client ID that's recorded in the
|
||||
// set of clients that have registered the worker.
|
||||
return activeClientIds.has(client.id)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {FetchEvent} event
|
||||
* @param {Client | undefined} client
|
||||
* @param {string} requestId
|
||||
* @param {number} requestInterceptedAt
|
||||
* @returns {Promise<Response>}
|
||||
*/
|
||||
async function getResponse(event, client, requestId, requestInterceptedAt) {
|
||||
// Clone the request because it might've been already used
|
||||
// (i.e. its body has been read and sent to the client).
|
||||
const requestClone = event.request.clone()
|
||||
|
||||
function passthrough() {
|
||||
// Cast the request headers to a new Headers instance
|
||||
// so the headers can be manipulated with.
|
||||
const headers = new Headers(requestClone.headers)
|
||||
|
||||
// Remove the "accept" header value that marked this request as passthrough.
|
||||
// This prevents request alteration and also keeps it compliant with the
|
||||
// user-defined CORS policies.
|
||||
const acceptHeader = headers.get('accept')
|
||||
if (acceptHeader) {
|
||||
const values = acceptHeader.split(',').map((value) => value.trim())
|
||||
const filteredValues = values.filter(
|
||||
(value) => value !== 'msw/passthrough',
|
||||
)
|
||||
|
||||
if (filteredValues.length > 0) {
|
||||
headers.set('accept', filteredValues.join(', '))
|
||||
} else {
|
||||
headers.delete('accept')
|
||||
}
|
||||
}
|
||||
|
||||
return fetch(requestClone, { headers })
|
||||
}
|
||||
|
||||
// Bypass mocking when the client is not active.
|
||||
if (!client) {
|
||||
return passthrough()
|
||||
}
|
||||
|
||||
// Bypass initial page load requests (i.e. static assets).
|
||||
// The absence of the immediate/parent client in the map of the active clients
|
||||
// means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet
|
||||
// and is not ready to handle requests.
|
||||
if (!activeClientIds.has(client.id)) {
|
||||
return passthrough()
|
||||
}
|
||||
|
||||
// Notify the client that a request has been intercepted.
|
||||
const serializedRequest = await serializeRequest(event.request)
|
||||
const clientMessage = await sendToClient(
|
||||
client,
|
||||
{
|
||||
type: 'REQUEST',
|
||||
payload: {
|
||||
id: requestId,
|
||||
interceptedAt: requestInterceptedAt,
|
||||
...serializedRequest,
|
||||
},
|
||||
},
|
||||
[serializedRequest.body],
|
||||
)
|
||||
|
||||
switch (clientMessage.type) {
|
||||
case 'MOCK_RESPONSE': {
|
||||
return respondWithMock(clientMessage.data)
|
||||
}
|
||||
|
||||
case 'PASSTHROUGH': {
|
||||
return passthrough()
|
||||
}
|
||||
}
|
||||
|
||||
return passthrough()
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Client} client
|
||||
* @param {any} message
|
||||
* @param {Array<Transferable>} transferrables
|
||||
* @returns {Promise<any>}
|
||||
*/
|
||||
function sendToClient(client, message, transferrables = []) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const channel = new MessageChannel()
|
||||
|
||||
channel.port1.onmessage = (event) => {
|
||||
if (event.data && event.data.error) {
|
||||
return reject(event.data.error)
|
||||
}
|
||||
|
||||
resolve(event.data)
|
||||
}
|
||||
|
||||
client.postMessage(message, [
|
||||
channel.port2,
|
||||
...transferrables.filter(Boolean),
|
||||
])
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Response} response
|
||||
* @returns {Response}
|
||||
*/
|
||||
function respondWithMock(response) {
|
||||
// Setting response status code to 0 is a no-op.
|
||||
// However, when responding with a "Response.error()", the produced Response
|
||||
// instance will have status code set to 0. Since it's not possible to create
|
||||
// a Response instance with status code 0, handle that use-case separately.
|
||||
if (response.status === 0) {
|
||||
return Response.error()
|
||||
}
|
||||
|
||||
const mockedResponse = new Response(response.body, response)
|
||||
|
||||
Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, {
|
||||
value: true,
|
||||
enumerable: true,
|
||||
})
|
||||
|
||||
return mockedResponse
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Request} request
|
||||
*/
|
||||
async function serializeRequest(request) {
|
||||
return {
|
||||
url: request.url,
|
||||
mode: request.mode,
|
||||
method: request.method,
|
||||
headers: Object.fromEntries(request.headers.entries()),
|
||||
cache: request.cache,
|
||||
credentials: request.credentials,
|
||||
destination: request.destination,
|
||||
integrity: request.integrity,
|
||||
redirect: request.redirect,
|
||||
referrer: request.referrer,
|
||||
referrerPolicy: request.referrerPolicy,
|
||||
body: await request.arrayBuffer(),
|
||||
keepalive: request.keepalive,
|
||||
}
|
||||
}
|
||||
2
apps/web/public/robots.txt
Normal file
2
apps/web/public/robots.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
User-agent: *
|
||||
Allow: /
|
||||
371
apps/web/src/App.tsx
Normal file
371
apps/web/src/App.tsx
Normal file
@@ -0,0 +1,371 @@
|
||||
import { useEffect, lazy, Suspense, useMemo } from 'react';
|
||||
import { HashRouter, Routes, Route, Navigate, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { ConfigProvider, theme as antdTheme, Spin, Result } from 'antd';
|
||||
import zhCN from 'antd/locale/zh_CN';
|
||||
import MainLayout from './layouts/MainLayout';
|
||||
import Login from './pages/Login';
|
||||
import { ErrorBoundary } from './components/ErrorBoundary';
|
||||
import { useAuthStore } from './stores/auth';
|
||||
import { useAppStore } from './stores/app';
|
||||
import type { ThemeName } from './stores/app';
|
||||
import { ROUTE_PERMISSIONS, FROZEN_ROUTES, validateRouteCoverage } from './routeConfig';
|
||||
|
||||
const Home = lazy(() => import('./pages/Home'));
|
||||
const Users = lazy(() => import('./pages/Users'));
|
||||
const Roles = lazy(() => import('./pages/Roles'));
|
||||
const Organizations = lazy(() => import('./pages/Organizations'));
|
||||
const Workflow = lazy(() => import('./pages/Workflow'));
|
||||
const Messages = lazy(() => import('./pages/Messages'));
|
||||
const Settings = lazy(() => import('./pages/Settings'));
|
||||
const PluginAdmin = lazy(() => import('./pages/PluginAdmin'));
|
||||
const PluginMarket = lazy(() => import('./pages/PluginMarket'));
|
||||
const PluginCRUDPage = lazy(() => import('./pages/PluginCRUDPage'));
|
||||
const PluginTabsPage = lazy(() => import('./pages/PluginTabsPage').then((m) => ({ default: m.PluginTabsPage })));
|
||||
const PluginTreePage = lazy(() => import('./pages/PluginTreePage').then((m) => ({ default: m.PluginTreePage })));
|
||||
const PluginGraphPage = lazy(() => import('./pages/PluginGraphPage').then((m) => ({ default: m.PluginGraphPage })));
|
||||
const PluginDashboardPage = lazy(() => import('./pages/PluginDashboardPage').then((m) => ({ default: m.PluginDashboardPage })));
|
||||
const PluginKanbanPage = lazy(() => import('./pages/PluginKanbanPage'));
|
||||
|
||||
// 健康管理模块
|
||||
const PatientList = lazy(() => import('./pages/health/PatientList'));
|
||||
const PatientDetail = lazy(() => import('./pages/health/PatientDetail'));
|
||||
const PatientTagManage = lazy(() => import('./pages/health/PatientTagManage'));
|
||||
const DoctorList = lazy(() => import('./pages/health/DoctorList'));
|
||||
const AppointmentList = lazy(() => import('./pages/health/AppointmentList'));
|
||||
const DoctorSchedule = lazy(() => import('./pages/health/DoctorSchedule'));
|
||||
const FollowUpTaskList = lazy(() => import('./pages/health/FollowUpTaskList'));
|
||||
const FollowUpRecordList = lazy(() => import('./pages/health/FollowUpRecordList'));
|
||||
const ConsultationList = lazy(() => import('./pages/health/ConsultationList'));
|
||||
const ConsultationDetail = lazy(() => import('./pages/health/ConsultationDetail'));
|
||||
const PointsRuleList = lazy(() => import('./pages/health/PointsRuleList'));
|
||||
const PointsProductList = lazy(() => import('./pages/health/PointsProductList'));
|
||||
const PointsOrderList = lazy(() => import('./pages/health/PointsOrderList'));
|
||||
const OfflineEventList = lazy(() => import('./pages/health/OfflineEventList'));
|
||||
const StatisticsDashboard = lazy(() => import('./pages/health/StatisticsDashboard'));
|
||||
const AiPromptList = lazy(() => import('./pages/health/AiPromptList'));
|
||||
const AiAnalysisList = lazy(() => import('./pages/health/AiAnalysisList'));
|
||||
const AiUsageDashboard = lazy(() => import('./pages/health/AiUsageDashboard'));
|
||||
const AiConfigPage = lazy(() => import('./pages/health/AiConfigPage'));
|
||||
const KnowledgeV2Page = lazy(() => import('./pages/ai/KnowledgeV2Page'));
|
||||
const AiChatPage = lazy(() => import('./pages/ai/ChatPage'));
|
||||
const AlertList = lazy(() => import('./pages/health/AlertList'));
|
||||
const AlertDashboard = lazy(() => import('./pages/health/AlertDashboard'));
|
||||
const AlertRuleList = lazy(() => import('./pages/health/AlertRuleList'));
|
||||
const DeviceManage = lazy(() => import('./pages/health/DeviceManage'));
|
||||
const RealtimeMonitor = lazy(() => import('./pages/health/RealtimeMonitor'));
|
||||
const OAuthClientList = lazy(() => import('./pages/health/OAuthClientList'));
|
||||
const DialysisManageList = lazy(() => import('./pages/health/DialysisManageList'));
|
||||
const ActionInbox = lazy(() => import('./pages/health/ActionInbox'));
|
||||
const FollowUpTemplateList = lazy(() => import('./pages/health/FollowUpTemplateList'));
|
||||
const CarePlanList = lazy(() => import('./pages/health/CarePlanList'));
|
||||
const CarePlanDetail = lazy(() => import('./pages/health/CarePlanDetail'));
|
||||
const ShiftList = lazy(() => import('./pages/health/ShiftList'));
|
||||
const ShiftDetail = lazy(() => import('./pages/health/ShiftDetail'));
|
||||
const MedicationRecordList = lazy(() => import('./pages/health/MedicationRecordList'));
|
||||
const BleGatewayList = lazy(() => import('./pages/health/BleGatewayList'));
|
||||
const BleGatewayDetail = lazy(() => import('./pages/health/BleGatewayDetail'));
|
||||
const CriticalValueThresholdList = lazy(() => import('./pages/health/CriticalValueThresholdList'));
|
||||
const FamilyProxyPage = lazy(() => import('./pages/health/FamilyProxyPage'));
|
||||
|
||||
// 内容管理
|
||||
const ArticleManageList = lazy(() => import('./pages/health/ArticleManageList'));
|
||||
const ArticleEditor = lazy(() => import('./pages/health/articleEditor/ArticleEditor'));
|
||||
const ArticleCategoryManage = lazy(() => import('./pages/health/ArticleCategoryManage'));
|
||||
const ArticleTagManage = lazy(() => import('./pages/health/ArticleTagManage'));
|
||||
const BannerManage = lazy(() => import('./pages/health/BannerManage'));
|
||||
const MediaLibrary = lazy(() => import('./pages/health/MediaLibrary'));
|
||||
|
||||
function FrozenRoute() {
|
||||
return <Result status="info" title="功能暂未开放" subTitle="该功能正在优化中,敬请期待" />;
|
||||
}
|
||||
|
||||
function ForbiddenPage() {
|
||||
const navigate = useNavigate();
|
||||
return (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '60vh' }}>
|
||||
<Result
|
||||
status="403"
|
||||
title="权限不足"
|
||||
subTitle="您没有访问此页面的权限,请联系管理员"
|
||||
extra={<button onClick={() => navigate('/')} style={{ cursor: 'pointer', color: 'var(--ant-color-primary)', background: 'none', border: 'none', fontSize: 14 }}>返回首页</button>}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PrivateRoute({ children }: { children: React.ReactNode }) {
|
||||
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
|
||||
const permissions = useAuthStore((s) => s.permissions);
|
||||
const location = useLocation();
|
||||
|
||||
if (!isAuthenticated) return <Navigate to="/login" replace />;
|
||||
|
||||
const path = location.pathname;
|
||||
|
||||
// 冻结路由检查
|
||||
if (FROZEN_ROUTES.some((frozen) => path.startsWith(frozen))) {
|
||||
return <FrozenRoute />;
|
||||
}
|
||||
|
||||
// 首页/工作台始终放行
|
||||
if (path === '/' || path === '') return <>{children}</>;
|
||||
|
||||
const matchedPrefix = Object.keys(ROUTE_PERMISSIONS).find(
|
||||
(prefix) => path === prefix || path.startsWith(prefix + '/'),
|
||||
);
|
||||
if (matchedPrefix) {
|
||||
const required = ROUTE_PERMISSIONS[matchedPrefix];
|
||||
const hasAccess = required.some((r) => permissions.includes(r));
|
||||
if (!hasAccess) return <ForbiddenPage />;
|
||||
} else {
|
||||
return <ForbiddenPage />;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
const baseToken = {
|
||||
borderRadius: 10,
|
||||
borderRadiusLG: 12,
|
||||
borderRadiusSM: 6,
|
||||
fontFamily: "'Noto Sans SC', -apple-system, system-ui, 'Segoe UI', Roboto, 'PingFang SC', 'Microsoft YaHei', Helvetica, Arial, sans-serif",
|
||||
fontSize: 14,
|
||||
fontSizeHeading4: 20,
|
||||
controlHeight: 40,
|
||||
controlHeightLG: 44,
|
||||
controlHeightSM: 32,
|
||||
boxShadow: 'none',
|
||||
boxShadowSecondary: '0 1px 3px rgba(0,0,0,0.06), 0 1px 2px rgba(0,0,0,0.04)',
|
||||
};
|
||||
|
||||
const baseComponents = {
|
||||
Button: { primaryShadow: 'none', fontWeight: 500 },
|
||||
Card: { paddingLG: 20 },
|
||||
Menu: { itemBorderRadius: 10, itemMarginInline: 8, itemHeight: 40 },
|
||||
Modal: { borderRadiusLG: 16 },
|
||||
Tag: { borderRadiusSM: 6 },
|
||||
};
|
||||
|
||||
const themeConfigs: Record<ThemeName, { token: Record<string, unknown>; components: Record<string, Record<string, unknown>> }> = {
|
||||
blue: {
|
||||
token: {
|
||||
...baseToken,
|
||||
colorPrimary: '#2563eb',
|
||||
colorSuccess: '#059669',
|
||||
colorWarning: '#d97706',
|
||||
colorError: '#dc2626',
|
||||
colorInfo: '#0284c7',
|
||||
colorBgLayout: '#f8fafc',
|
||||
colorBgContainer: '#ffffff',
|
||||
colorBgElevated: '#ffffff',
|
||||
colorBorder: '#e2e8f0',
|
||||
colorBorderSecondary: '#f1f5f9',
|
||||
},
|
||||
components: {
|
||||
...baseComponents,
|
||||
Table: { headerBg: '#f1f5f9', headerColor: '#475569', rowHoverBg: '#f1f5f9', fontSize: 14 },
|
||||
},
|
||||
},
|
||||
warm: {
|
||||
token: {
|
||||
...baseToken,
|
||||
borderRadius: 12,
|
||||
borderRadiusLG: 14,
|
||||
borderRadiusSM: 8,
|
||||
colorPrimary: '#C4623A',
|
||||
colorSuccess: '#5B7A5E',
|
||||
colorWarning: '#C4873A',
|
||||
colorError: '#B54A4A',
|
||||
colorInfo: '#8B7A5E',
|
||||
colorBgLayout: '#F5F0EB',
|
||||
colorBgContainer: '#ffffff',
|
||||
colorBgElevated: '#ffffff',
|
||||
colorBorder: '#E8E2DC',
|
||||
colorBorderSecondary: '#F0EBE5',
|
||||
},
|
||||
components: {
|
||||
...baseComponents,
|
||||
Table: { headerBg: '#EDE8E2', headerColor: '#7A756E', rowHoverBg: '#F5F0EB', fontSize: 14 },
|
||||
},
|
||||
},
|
||||
dark: {
|
||||
token: {
|
||||
...baseToken,
|
||||
colorPrimary: '#60A5FA',
|
||||
colorSuccess: '#34D399',
|
||||
colorWarning: '#FBBF24',
|
||||
colorError: '#F87171',
|
||||
colorInfo: '#38BDF8',
|
||||
colorBgLayout: '#0F172A',
|
||||
colorBgContainer: '#1E293B',
|
||||
colorBgElevated: '#334155',
|
||||
colorBorder: '#334155',
|
||||
colorBorderSecondary: 'rgba(255,255,255,0.06)',
|
||||
boxShadow: 'none',
|
||||
boxShadowSecondary: '0 2px 8px rgba(0,0,0,0.3), 0 1px 3px rgba(0,0,0,0.2)',
|
||||
},
|
||||
components: {
|
||||
...baseComponents,
|
||||
Table: { headerBg: '#1E293B', headerColor: '#94A3B8', rowHoverBg: '#1E293B', fontSize: 14 },
|
||||
},
|
||||
},
|
||||
emerald: {
|
||||
token: {
|
||||
...baseToken,
|
||||
borderRadius: 10,
|
||||
borderRadiusLG: 14,
|
||||
borderRadiusSM: 8,
|
||||
colorPrimary: '#5B7A5E',
|
||||
colorSuccess: '#3D7A42',
|
||||
colorWarning: '#B8863A',
|
||||
colorError: '#A54A4A',
|
||||
colorInfo: '#4A7A8B',
|
||||
colorBgLayout: '#F4F7F4',
|
||||
colorBgContainer: '#ffffff',
|
||||
colorBgElevated: '#ffffff',
|
||||
colorBorder: '#D5DED5',
|
||||
colorBorderSecondary: '#E5ECE5',
|
||||
},
|
||||
components: {
|
||||
...baseComponents,
|
||||
Table: { headerBg: '#EDF2ED', headerColor: '#5A6E5A', rowHoverBg: '#F4F7F4', fontSize: 14 },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default function App() {
|
||||
const loadFromStorage = useAuthStore((s) => s.loadFromStorage);
|
||||
const themeName = useAppStore((s) => s.theme);
|
||||
|
||||
useEffect(() => {
|
||||
loadFromStorage();
|
||||
}, [loadFromStorage]);
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.setAttribute('data-theme', themeName);
|
||||
}, [themeName]);
|
||||
|
||||
// DEV mode: validate all routes have permission declarations
|
||||
useEffect(() => {
|
||||
validateRouteCoverage([
|
||||
"/users", "/roles", "/organizations", "/workflow", "/messages", "/settings",
|
||||
"/plugins/admin", "/plugins/market",
|
||||
"/health/statistics", "/health/patients", "/health/tags", "/health/doctors",
|
||||
"/health/appointments", "/health/schedules", "/health/follow-up-tasks",
|
||||
"/health/follow-up-records", "/health/consultations",
|
||||
"/health/points-rules", "/health/points-products", "/health/points-orders",
|
||||
"/health/offline-events", "/health/ai-prompts", "/health/ai-analysis",
|
||||
"/health/ai-usage", "/health/ai-config", "/health/ai-knowledge", "/health/alerts", "/health/alert-dashboard",
|
||||
"/ai/chat",
|
||||
"/health/alert-rules", "/health/devices", "/health/realtime-monitor",
|
||||
"/health/oauth-clients", "/health/dialysis", "/health/action-inbox",
|
||||
"/health/follow-up-templates", "/health/care-plans", "/health/shifts",
|
||||
"/health/medications", "/health/ble-gateways",
|
||||
"/health/critical-value-thresholds", "/health/diagnoses",
|
||||
"/health/family-proxy", "/health/consents",
|
||||
"/health/articles", "/health/article-categories", "/health/article-tags",
|
||||
"/health/banners", "/health/media-library",
|
||||
"/health/medication-records",
|
||||
]);
|
||||
}, []);
|
||||
|
||||
const isDark = themeName === 'dark';
|
||||
const antTheme = useMemo(() => themeConfigs[themeName] ?? themeConfigs.blue, [themeName]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<a href="#root" className="erp-skip-link">跳转到主要内容</a>
|
||||
<ConfigProvider
|
||||
locale={zhCN}
|
||||
theme={{
|
||||
...antTheme,
|
||||
algorithm: isDark ? antdTheme.darkAlgorithm : antdTheme.defaultAlgorithm,
|
||||
}}
|
||||
>
|
||||
<HashRouter>
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route
|
||||
path="/*"
|
||||
element={
|
||||
<PrivateRoute>
|
||||
<MainLayout>
|
||||
<ErrorBoundary>
|
||||
<Suspense fallback={<div style={{ display: 'flex', justifyContent: 'center', padding: 100 }}><Spin size="large" /></div>}>
|
||||
<Routes>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/users" element={<Users />} />
|
||||
<Route path="/roles" element={<Roles />} />
|
||||
<Route path="/organizations" element={<Organizations />} />
|
||||
<Route path="/workflow" element={<Workflow />} />
|
||||
<Route path="/messages" element={<Messages />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
<Route path="/plugins/admin" element={<PluginAdmin />} />
|
||||
<Route path="/plugins/market" element={<PluginMarket />} />
|
||||
<Route path="/plugins/:pluginId/tabs/:pageLabel" element={<PluginTabsPage />} />
|
||||
<Route path="/plugins/:pluginId/tree/:entityName" element={<PluginTreePage />} />
|
||||
<Route path="/plugins/:pluginId/graph/:entityName" element={<PluginGraphPage />} />
|
||||
<Route path="/plugins/:pluginId/dashboard" element={<PluginDashboardPage />} />
|
||||
<Route path="/plugins/:pluginId/kanban/:entityName" element={<PluginKanbanPage />} />
|
||||
<Route path="/plugins/:pluginId/:entityName" element={<PluginCRUDPage />} />
|
||||
{/* 健康管理 */}
|
||||
<Route path="/health/statistics" element={<StatisticsDashboard />} />
|
||||
<Route path="/health/patients" element={<PatientList />} />
|
||||
<Route path="/health/patients/:id" element={<PatientDetail />} />
|
||||
<Route path="/health/tags" element={<PatientTagManage />} />
|
||||
<Route path="/health/doctors" element={<DoctorList />} />
|
||||
<Route path="/health/appointments" element={<AppointmentList />} />
|
||||
<Route path="/health/schedules" element={<DoctorSchedule />} />
|
||||
<Route path="/health/follow-up-tasks" element={<FollowUpTaskList />} />
|
||||
<Route path="/health/follow-up-records" element={<FollowUpRecordList />} />
|
||||
<Route path="/health/consultations" element={<ConsultationList />} />
|
||||
<Route path="/health/consultations/:id" element={<ConsultationDetail />} />
|
||||
<Route path="/health/points-rules" element={<PointsRuleList />} />
|
||||
<Route path="/health/points-products" element={<PointsProductList />} />
|
||||
<Route path="/health/points-orders" element={<PointsOrderList />} />
|
||||
<Route path="/health/offline-events" element={<OfflineEventList />} />
|
||||
<Route path="/health/ai-prompts" element={<AiPromptList />} />
|
||||
<Route path="/health/ai-analysis" element={<AiAnalysisList />} />
|
||||
<Route path="/health/ai-usage" element={<AiUsageDashboard />} />
|
||||
<Route path="/health/ai-config" element={<AiConfigPage />} />
|
||||
<Route path="/health/ai-knowledge" element={<KnowledgeV2Page />} />
|
||||
<Route path="/ai/chat" element={<AiChatPage />} />
|
||||
<Route path="/health/alerts" element={<AlertList />} />
|
||||
<Route path="/health/alert-dashboard" element={<AlertDashboard />} />
|
||||
<Route path="/health/alert-rules" element={<AlertRuleList />} />
|
||||
<Route path="/health/devices" element={<DeviceManage />} />
|
||||
<Route path="/health/realtime-monitor" element={<RealtimeMonitor />} />
|
||||
<Route path="/health/oauth-clients" element={<OAuthClientList />} />
|
||||
<Route path="/health/dialysis" element={<DialysisManageList />} />
|
||||
<Route path="/health/action-inbox" element={<ActionInbox />} />
|
||||
<Route path="/health/follow-up-templates" element={<FollowUpTemplateList />} />
|
||||
<Route path="/health/care-plans" element={<CarePlanList />} />
|
||||
<Route path="/health/care-plans/:id" element={<CarePlanDetail />} />
|
||||
<Route path="/health/shifts" element={<ShiftList />} />
|
||||
<Route path="/health/shifts/:id" element={<ShiftDetail />} />
|
||||
<Route path="/health/medications" element={<MedicationRecordList />} />
|
||||
<Route path="/health/ble-gateways" element={<BleGatewayList />} />
|
||||
<Route path="/health/ble-gateways/:id" element={<BleGatewayDetail />} />
|
||||
<Route path="/health/critical-value-thresholds" element={<CriticalValueThresholdList />} />
|
||||
<Route path="/health/family-proxy" element={<FamilyProxyPage />} />
|
||||
{/* 内容管理 */}
|
||||
<Route path="/health/articles" element={<ArticleManageList />} />
|
||||
<Route path="/health/articles/new" element={<ArticleEditor />} />
|
||||
<Route path="/health/articles/:id/edit" element={<ArticleEditor />} />
|
||||
<Route path="/health/article-categories" element={<ArticleCategoryManage />} />
|
||||
<Route path="/health/article-tags" element={<ArticleTagManage />} />
|
||||
<Route path="/health/banners" element={<BannerManage />} />
|
||||
<Route path="/health/media-library" element={<MediaLibrary />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
</MainLayout>
|
||||
</PrivateRoute>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</HashRouter>
|
||||
</ConfigProvider>
|
||||
</>
|
||||
);
|
||||
}
|
||||
127
apps/web/src/api/ai/analysis.test.ts
Normal file
127
apps/web/src/api/ai/analysis.test.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* AI 模块 API 契约测试(analysis + prompts + suggestions + usage)
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
const mockGet = vi.fn()
|
||||
const mockPost = vi.fn()
|
||||
const mockPut = vi.fn()
|
||||
const mockDelete = vi.fn()
|
||||
|
||||
vi.mock('../client', () => ({
|
||||
default: {
|
||||
get: (...args: unknown[]) => mockGet(...args),
|
||||
post: (...args: unknown[]) => mockPost(...args),
|
||||
put: (...args: unknown[]) => mockPut(...args),
|
||||
delete: (...args: unknown[]) => mockDelete(...args),
|
||||
},
|
||||
}))
|
||||
|
||||
import { analysisApi } from './analysis'
|
||||
import { promptApi } from './prompts'
|
||||
import { suggestionApi } from './suggestions'
|
||||
import { usageApi } from './usage'
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('analysisApi', () => {
|
||||
const fakeRes = { data: { data: {} } }
|
||||
|
||||
it('list 应调用 GET /ai/analysis/history 并传递查询参数', async () => {
|
||||
mockGet.mockResolvedValue(fakeRes)
|
||||
await analysisApi.list({ patient_id: 'p-001', analysis_type: 'lab-report', page: 1, page_size: 10 })
|
||||
|
||||
expect(mockGet).toHaveBeenCalledWith('/ai/analysis/history', {
|
||||
params: { patient_id: 'p-001', analysis_type: 'lab-report', page: 1, page_size: 10 },
|
||||
})
|
||||
})
|
||||
|
||||
it('get 应调用 GET /ai/analysis/:id', async () => {
|
||||
mockGet.mockResolvedValue(fakeRes)
|
||||
await analysisApi.get('ana-001')
|
||||
|
||||
expect(mockGet).toHaveBeenCalledWith('/ai/analysis/ana-001')
|
||||
})
|
||||
})
|
||||
|
||||
describe('promptApi', () => {
|
||||
const fakeRes = { data: { data: {} } }
|
||||
|
||||
it('list 应调用 GET /ai/prompts 并传递查询参数', async () => {
|
||||
mockGet.mockResolvedValue(fakeRes)
|
||||
await promptApi.list({ category: 'analysis', page: 1, page_size: 10 })
|
||||
|
||||
expect(mockGet).toHaveBeenCalledWith('/ai/prompts', {
|
||||
params: { category: 'analysis', page: 1, page_size: 10 },
|
||||
})
|
||||
})
|
||||
|
||||
it('create 应调用 POST /ai/prompts 并传递请求体', async () => {
|
||||
mockPost.mockResolvedValue(fakeRes)
|
||||
const req = { name: '化验解读', system_prompt: '你是专业医生', user_prompt_template: '解读: {report}', model_config: {}, category: 'analysis' }
|
||||
await promptApi.create(req)
|
||||
|
||||
expect(mockPost).toHaveBeenCalledWith('/ai/prompts', req)
|
||||
})
|
||||
|
||||
it('activate 应调用 POST /ai/prompts/:id/activate', async () => {
|
||||
mockPost.mockResolvedValue(fakeRes)
|
||||
await promptApi.activate('prompt-001')
|
||||
|
||||
expect(mockPost).toHaveBeenCalledWith('/ai/prompts/prompt-001/activate')
|
||||
})
|
||||
|
||||
it('rollback 应调用 POST /ai/prompts/:id/rollback', async () => {
|
||||
mockPost.mockResolvedValue(fakeRes)
|
||||
await promptApi.rollback('prompt-001')
|
||||
|
||||
expect(mockPost).toHaveBeenCalledWith('/ai/prompts/prompt-001/rollback')
|
||||
})
|
||||
})
|
||||
|
||||
describe('suggestionApi', () => {
|
||||
const fakeRes = { data: { data: {} } }
|
||||
|
||||
it('list 应调用 GET /ai/suggestions 并传递查询参数', async () => {
|
||||
mockGet.mockResolvedValue(fakeRes)
|
||||
await suggestionApi.list({ analysis_id: 'ana-001', status: 'pending' })
|
||||
|
||||
expect(mockGet).toHaveBeenCalledWith('/ai/suggestions', {
|
||||
params: { analysis_id: 'ana-001', status: 'pending' },
|
||||
})
|
||||
})
|
||||
|
||||
it('approve 应调用 POST /ai/suggestions/:id/approve 并传递 action', async () => {
|
||||
mockPost.mockResolvedValue(fakeRes)
|
||||
await suggestionApi.approve('sug-001', 'approve')
|
||||
|
||||
expect(mockPost).toHaveBeenCalledWith('/ai/suggestions/sug-001/approve', { action: 'approve' })
|
||||
})
|
||||
|
||||
it('getComparison 应调用 GET /ai/suggestions/:id/comparison', async () => {
|
||||
mockGet.mockResolvedValue(fakeRes)
|
||||
await suggestionApi.getComparison('sug-001')
|
||||
|
||||
expect(mockGet).toHaveBeenCalledWith('/ai/suggestions/sug-001/comparison')
|
||||
})
|
||||
})
|
||||
|
||||
describe('usageApi', () => {
|
||||
const fakeRes = { data: { data: {} } }
|
||||
|
||||
it('overview 应调用 GET /ai/usage/overview', async () => {
|
||||
mockGet.mockResolvedValue(fakeRes)
|
||||
await usageApi.overview()
|
||||
|
||||
expect(mockGet).toHaveBeenCalledWith('/ai/usage/overview')
|
||||
})
|
||||
|
||||
it('byType 应调用 GET /ai/usage/by-type', async () => {
|
||||
mockGet.mockResolvedValue(fakeRes)
|
||||
await usageApi.byType()
|
||||
|
||||
expect(mockGet).toHaveBeenCalledWith('/ai/usage/by-type')
|
||||
})
|
||||
})
|
||||
47
apps/web/src/api/ai/analysis.ts
Normal file
47
apps/web/src/api/ai/analysis.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import client from '../client';
|
||||
import type { PaginatedResponse } from '../types';
|
||||
|
||||
export interface AnalysisItem {
|
||||
id: string;
|
||||
patient_id: string;
|
||||
patient_name?: string;
|
||||
analysis_type: string;
|
||||
source_ref: string;
|
||||
model_used: string;
|
||||
status: string;
|
||||
result_content: string | null;
|
||||
result_metadata: Record<string, unknown> | null;
|
||||
error_message: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface HealthSummaryResponse {
|
||||
patient_id: string;
|
||||
risk_level: 'low' | 'medium' | 'high' | 'critical';
|
||||
active_insights_count: number;
|
||||
recent_analyses_count: number;
|
||||
latest_insight_title: string | null;
|
||||
latest_analysis_type: string | null;
|
||||
summary_items: Array<{
|
||||
category: string;
|
||||
title: string;
|
||||
severity: string | null;
|
||||
created_at: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export const analysisApi = {
|
||||
list: async (params?: { patient_id?: string; analysis_type?: string; page?: number; page_size?: number }) => {
|
||||
const resp = await client.get('/ai/analysis/history', { params });
|
||||
return resp.data.data as PaginatedResponse<AnalysisItem>;
|
||||
},
|
||||
get: async (id: string) => {
|
||||
const resp = await client.get(`/ai/analysis/${id}`);
|
||||
return resp.data.data as AnalysisItem;
|
||||
},
|
||||
getHealthSummary: async (patientId: string) => {
|
||||
const resp = await client.get('/ai/health-summary', { params: { patient_id: patientId } });
|
||||
return resp.data.data as HealthSummaryResponse;
|
||||
},
|
||||
};
|
||||
98
apps/web/src/api/ai/analysisSse.ts
Normal file
98
apps/web/src/api/ai/analysisSse.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
export type AnalysisType = 'lab-report' | 'trends' | 'checkup-plan' | 'report-summary' | 'follow-up-summary';
|
||||
|
||||
interface AnalyzeBody {
|
||||
report_id?: string;
|
||||
patient_id?: string;
|
||||
metrics?: string[];
|
||||
source_id?: string;
|
||||
}
|
||||
|
||||
const ENDPOINT_MAP: Record<AnalysisType, string> = {
|
||||
'lab-report': '/ai/analyze/lab-report',
|
||||
'trends': '/ai/analyze/trends',
|
||||
'checkup-plan': '/ai/analyze/checkup-plan',
|
||||
'report-summary': '/ai/analyze/report-summary',
|
||||
'follow-up-summary': '/ai/analyze/follow-up-summary',
|
||||
};
|
||||
|
||||
export interface SseCallbacks {
|
||||
onChunk: (content: string, index: number) => void;
|
||||
onError: (message: string) => void;
|
||||
onDone: (analysisId: string) => void;
|
||||
}
|
||||
|
||||
export async function startAnalysis(
|
||||
type: AnalysisType,
|
||||
body: AnalyzeBody,
|
||||
callbacks: SseCallbacks,
|
||||
): Promise<AbortController> {
|
||||
const controller = new AbortController();
|
||||
const endpoint = ENDPOINT_MAP[type];
|
||||
|
||||
const token = localStorage.getItem('hms-token');
|
||||
const resp = await fetch(`/api/v1${endpoint}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({ message: '分析请求失败' }));
|
||||
callbacks.onError(err?.message || `HTTP ${resp.status}`);
|
||||
return controller;
|
||||
}
|
||||
|
||||
const reader = resp.body?.getReader();
|
||||
if (!reader) {
|
||||
callbacks.onError('无法读取响应流');
|
||||
return controller;
|
||||
}
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
let chunkIndex = 0;
|
||||
let buffer = '';
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || '';
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('data: ')) {
|
||||
const data = line.slice(6);
|
||||
if (data === '[DONE]') {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const event = JSON.parse(data);
|
||||
if (event.type === 'chunk' && event.content) {
|
||||
callbacks.onChunk(event.content, chunkIndex++);
|
||||
} else if (event.type === 'done' && event.analysis_id) {
|
||||
callbacks.onDone(event.analysis_id);
|
||||
} else if (event.type === 'error') {
|
||||
callbacks.onError(event.message || '分析出错');
|
||||
}
|
||||
} catch {
|
||||
// 非 JSON 行,跳过
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (!controller.signal.aborted) {
|
||||
callbacks.onError(err instanceof Error ? err.message : '连接中断');
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
return controller;
|
||||
}
|
||||
118
apps/web/src/api/ai/chat.ts
Normal file
118
apps/web/src/api/ai/chat.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import client from '../client';
|
||||
|
||||
export interface ChatHistoryItem {
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
}
|
||||
|
||||
export type DisplayHint =
|
||||
| {
|
||||
type: 'vital_card';
|
||||
indicator_type: string;
|
||||
values: [string, number][];
|
||||
unit: string;
|
||||
}
|
||||
| {
|
||||
type: 'lab_report_card';
|
||||
report_date: string;
|
||||
abnormal_count: number;
|
||||
}
|
||||
| {
|
||||
type: 'action_confirm';
|
||||
action_type: string;
|
||||
summary: string;
|
||||
confirm_payload: unknown;
|
||||
}
|
||||
| {
|
||||
type: 'risk_alert';
|
||||
level: string;
|
||||
message: string;
|
||||
}
|
||||
| {
|
||||
type: 'trend_chart';
|
||||
metrics: string[];
|
||||
period: string;
|
||||
summary: string;
|
||||
}
|
||||
| {
|
||||
type: 'insight_card';
|
||||
title: string;
|
||||
severity: string;
|
||||
items: string[];
|
||||
}
|
||||
| {
|
||||
type: 'patient_profile';
|
||||
chronic_conditions: string[];
|
||||
medication_count: number;
|
||||
}
|
||||
| { type: 'text' };
|
||||
|
||||
export interface ChatResponse {
|
||||
reply: string;
|
||||
message_id: string;
|
||||
iterations: number;
|
||||
display_hints?: DisplayHint[];
|
||||
}
|
||||
|
||||
export interface ChatSession {
|
||||
id: string;
|
||||
title: string | null;
|
||||
patient_id: string | null;
|
||||
status: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export const aiChatApi = {
|
||||
sendMessage: async (
|
||||
message: string,
|
||||
history: ChatHistoryItem[],
|
||||
patientId?: string,
|
||||
sessionId?: string
|
||||
): Promise<ChatResponse> => {
|
||||
const resp = await client.post('/ai/chat', {
|
||||
message,
|
||||
history,
|
||||
...(patientId ? { patient_id: patientId } : {}),
|
||||
...(sessionId ? { session_id: sessionId } : {}),
|
||||
});
|
||||
return resp.data.data as ChatResponse;
|
||||
},
|
||||
|
||||
createSession: async (
|
||||
patientId?: string,
|
||||
title?: string
|
||||
): Promise<ChatSession> => {
|
||||
const resp = await client.post('/ai/chat/sessions', {
|
||||
...(patientId ? { patient_id: patientId } : {}),
|
||||
...(title ? { title } : {}),
|
||||
});
|
||||
return resp.data.data as ChatSession;
|
||||
},
|
||||
|
||||
listSessions: async (): Promise<ChatSession[]> => {
|
||||
const resp = await client.get('/ai/chat/sessions');
|
||||
return resp.data.data as ChatSession[];
|
||||
},
|
||||
|
||||
renameSession: async (
|
||||
sessionId: string,
|
||||
title: string
|
||||
): Promise<void> => {
|
||||
await client.put(`/ai/chat/sessions/${sessionId}/rename`, { title });
|
||||
},
|
||||
|
||||
closeSession: async (sessionId: string): Promise<void> => {
|
||||
await client.post(`/ai/chat/sessions/${sessionId}/close`);
|
||||
},
|
||||
|
||||
getSessionMessages: async (sessionId: string): Promise<Array<{
|
||||
id: string;
|
||||
role: string;
|
||||
content: string | null;
|
||||
created_at: string;
|
||||
}>> => {
|
||||
const resp = await client.get(`/ai/chat/sessions/${sessionId}/messages`);
|
||||
return resp.data.data;
|
||||
},
|
||||
};
|
||||
45
apps/web/src/api/ai/config.ts
Normal file
45
apps/web/src/api/ai/config.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import client from '../client';
|
||||
|
||||
export interface AiAgentConfig {
|
||||
model: string;
|
||||
temperature: number;
|
||||
max_tokens: number;
|
||||
max_iterations: number;
|
||||
system_prompt: string;
|
||||
}
|
||||
|
||||
export interface AiAnalysisDefaults {
|
||||
model: string;
|
||||
temperature: number;
|
||||
max_tokens: number;
|
||||
}
|
||||
|
||||
export interface AiProviderConfig {
|
||||
provider_type: string;
|
||||
enabled: boolean;
|
||||
base_url: string;
|
||||
api_key: string;
|
||||
model: string;
|
||||
}
|
||||
|
||||
export interface AiConfig {
|
||||
agent: AiAgentConfig;
|
||||
analysis_defaults: AiAnalysisDefaults;
|
||||
default_provider: string;
|
||||
providers: Record<string, AiProviderConfig>;
|
||||
}
|
||||
|
||||
export const aiConfigApi = {
|
||||
get: async () => {
|
||||
const resp = await client.get('/ai/config');
|
||||
return resp.data.data as AiConfig;
|
||||
},
|
||||
getDefaults: async () => {
|
||||
const resp = await client.get('/ai/config/defaults');
|
||||
return resp.data.data as AiConfig;
|
||||
},
|
||||
update: async (config: AiConfig) => {
|
||||
const resp = await client.put('/ai/config', { config });
|
||||
return resp.data.data as AiConfig;
|
||||
},
|
||||
};
|
||||
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;
|
||||
},
|
||||
};
|
||||
188
apps/web/src/api/ai/knowledgeV2.ts
Normal file
188
apps/web/src/api/ai/knowledgeV2.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
import client from '../client';
|
||||
|
||||
// === Types ===
|
||||
|
||||
export interface KnowledgeBase {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
name: string;
|
||||
kb_type: string;
|
||||
description: string | null;
|
||||
icon: string | null;
|
||||
chunk_strategy: Record<string, unknown>;
|
||||
intent_keywords: Record<string, unknown>;
|
||||
embedding_model: string | null;
|
||||
is_enabled: boolean;
|
||||
document_count: number;
|
||||
chunk_count: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface KnowledgeDocument {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
knowledge_base_id: string;
|
||||
title: string;
|
||||
doc_type: string;
|
||||
source_type: string;
|
||||
source_url: string | null;
|
||||
file_name: string | null;
|
||||
file_size: number | null;
|
||||
file_mime_type: string | null;
|
||||
content: string | null;
|
||||
status: string;
|
||||
chunk_count: number;
|
||||
embedded_count: number;
|
||||
error_message: string | null;
|
||||
processing_started_at: string | null;
|
||||
processing_completed_at: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface SearchHit {
|
||||
chunk_id: string;
|
||||
document_id: string;
|
||||
chunk_index: number;
|
||||
content: string;
|
||||
doc_title: string;
|
||||
similarity: number;
|
||||
metadata: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface CreateKnowledgeBaseReq {
|
||||
name: string;
|
||||
kb_type: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
chunk_strategy?: Record<string, unknown>;
|
||||
intent_keywords?: Record<string, unknown>;
|
||||
embedding_model?: string;
|
||||
is_enabled?: boolean;
|
||||
}
|
||||
|
||||
export interface UpdateKnowledgeBaseReq {
|
||||
name?: string;
|
||||
kb_type?: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
chunk_strategy?: Record<string, unknown>;
|
||||
intent_keywords?: Record<string, unknown>;
|
||||
embedding_model?: string;
|
||||
is_enabled?: boolean;
|
||||
}
|
||||
|
||||
export interface CreateDocumentReq {
|
||||
kb_id: string;
|
||||
title: string;
|
||||
doc_type?: string;
|
||||
source_type?: string;
|
||||
source_url?: string;
|
||||
content?: string;
|
||||
}
|
||||
|
||||
// === API ===
|
||||
|
||||
export const knowledgeV2Api = {
|
||||
// Knowledge Bases
|
||||
listKnowledgeBases: async (params?: {
|
||||
kb_type?: string;
|
||||
is_enabled?: boolean;
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
}) => {
|
||||
const resp = await client.get('/ai/knowledge-bases', { params });
|
||||
return resp.data.data as {
|
||||
data: KnowledgeBase[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
};
|
||||
},
|
||||
|
||||
getKnowledgeBase: async (id: string) => {
|
||||
const resp = await client.get(`/ai/knowledge-bases/${id}`);
|
||||
return resp.data.data as KnowledgeBase;
|
||||
},
|
||||
|
||||
createKnowledgeBase: async (data: CreateKnowledgeBaseReq) => {
|
||||
const resp = await client.post('/ai/knowledge-bases', data);
|
||||
return resp.data.data as { id: string };
|
||||
},
|
||||
|
||||
updateKnowledgeBase: async (id: string, data: UpdateKnowledgeBaseReq) => {
|
||||
const resp = await client.put(`/ai/knowledge-bases/${id}`, data);
|
||||
return resp.data.data as { id: string };
|
||||
},
|
||||
|
||||
deleteKnowledgeBase: async (id: string) => {
|
||||
const resp = await client.delete(`/ai/knowledge-bases/${id}`);
|
||||
return resp.data.data as { id: string };
|
||||
},
|
||||
|
||||
// Documents
|
||||
listDocuments: async (
|
||||
kbId: string,
|
||||
params?: { status?: string; page?: number; page_size?: number },
|
||||
) => {
|
||||
const resp = await client.get(
|
||||
`/ai/knowledge-bases/${kbId}/documents`,
|
||||
{ params },
|
||||
);
|
||||
return resp.data.data as {
|
||||
data: KnowledgeDocument[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
};
|
||||
},
|
||||
|
||||
getDocument: async (id: string) => {
|
||||
const resp = await client.get(`/ai/documents/${id}`);
|
||||
return resp.data.data as KnowledgeDocument;
|
||||
},
|
||||
|
||||
createManualDocument: async (data: CreateDocumentReq) => {
|
||||
const resp = await client.post('/ai/documents/manual', data);
|
||||
return resp.data.data as { id: string };
|
||||
},
|
||||
|
||||
uploadDocument: async (
|
||||
kbId: string,
|
||||
file: File,
|
||||
title?: string,
|
||||
) => {
|
||||
const formData = new FormData();
|
||||
formData.append('kb_id', kbId);
|
||||
formData.append('file', file);
|
||||
if (title) {
|
||||
formData.append('title', title);
|
||||
}
|
||||
const resp = await client.post('/ai/documents/upload', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
});
|
||||
return resp.data.data as { id: string };
|
||||
},
|
||||
|
||||
deleteDocument: async (kbId: string, id: string) => {
|
||||
const resp = await client.delete(
|
||||
`/ai/knowledge-bases/${kbId}/documents/${id}`,
|
||||
);
|
||||
return resp.data.data as { id: string };
|
||||
},
|
||||
|
||||
// Hit Test
|
||||
hitTest: async (kbId: string, query: string, topK?: number) => {
|
||||
const resp = await client.post('/ai/documents/hit-test', {
|
||||
kb_id: kbId,
|
||||
query,
|
||||
top_k: topK,
|
||||
});
|
||||
return resp.data.data as {
|
||||
query: string;
|
||||
total: number;
|
||||
hits: SearchHit[];
|
||||
};
|
||||
},
|
||||
};
|
||||
54
apps/web/src/api/ai/prompts.ts
Normal file
54
apps/web/src/api/ai/prompts.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import client from '../client';
|
||||
import type { PaginatedResponse } from '../types';
|
||||
|
||||
export interface PromptItem {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
system_prompt: string;
|
||||
user_prompt_template: string;
|
||||
model_config: Record<string, unknown>;
|
||||
version: number;
|
||||
is_active: boolean;
|
||||
category: string;
|
||||
analysis_type: string;
|
||||
tags: Record<string, unknown> | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface CreatePromptReq {
|
||||
name: string;
|
||||
description?: string;
|
||||
system_prompt: string;
|
||||
user_prompt_template: string;
|
||||
model_config: Record<string, unknown>;
|
||||
category: string;
|
||||
analysis_type: string;
|
||||
}
|
||||
|
||||
export const promptApi = {
|
||||
list: async (params?: { category?: string; analysis_type?: string; page?: number; page_size?: number }) => {
|
||||
const resp = await client.get('/ai/prompts', { params });
|
||||
return resp.data.data as PaginatedResponse<PromptItem>;
|
||||
},
|
||||
create: async (data: CreatePromptReq) => {
|
||||
const resp = await client.post('/ai/prompts', data);
|
||||
return resp.data.data as PromptItem;
|
||||
},
|
||||
activate: async (id: string) => {
|
||||
const resp = await client.post(`/ai/prompts/${id}/activate`);
|
||||
return resp.data.data as PromptItem;
|
||||
},
|
||||
deactivate: async (id: string) => {
|
||||
const resp = await client.post(`/ai/prompts/${id}/deactivate`);
|
||||
return resp.data.data as PromptItem;
|
||||
},
|
||||
rollback: async (id: string) => {
|
||||
const resp = await client.post(`/ai/prompts/${id}/rollback`);
|
||||
return resp.data.data as PromptItem;
|
||||
},
|
||||
delete: async (id: string) => {
|
||||
await client.delete(`/ai/prompts/${id}`);
|
||||
},
|
||||
};
|
||||
38
apps/web/src/api/ai/suggestions.ts
Normal file
38
apps/web/src/api/ai/suggestions.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import client from '../client';
|
||||
|
||||
export interface SuggestionItem {
|
||||
id: string;
|
||||
analysis_id: string;
|
||||
suggestion_type: string;
|
||||
risk_level: string;
|
||||
params: Record<string, unknown> | null;
|
||||
status: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface ComparisonReport {
|
||||
suggestion_id: string;
|
||||
baseline: Record<string, unknown> | null;
|
||||
current: Record<string, unknown> | null;
|
||||
comparison_available: boolean;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export const suggestionApi = {
|
||||
list: async (params?: { analysis_id?: string; status?: string }) => {
|
||||
const resp = await client.get('/ai/suggestions', { params });
|
||||
return resp.data.data as { data: SuggestionItem[]; total: number };
|
||||
},
|
||||
approve: async (id: string, action: 'approve' | 'reject') => {
|
||||
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;
|
||||
},
|
||||
};
|
||||
107
apps/web/src/api/ai/usage.ts
Normal file
107
apps/web/src/api/ai/usage.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import client from '../client';
|
||||
|
||||
export interface UsageOverview {
|
||||
total_count: number;
|
||||
}
|
||||
|
||||
export interface TypeDistribution {
|
||||
analysis_type: string;
|
||||
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 interface DailyUsageRow {
|
||||
date: string;
|
||||
feature: string;
|
||||
provider: string;
|
||||
model: string;
|
||||
total_calls: number;
|
||||
total_input_tokens: number;
|
||||
total_output_tokens: number;
|
||||
total_cost_cents: number;
|
||||
}
|
||||
|
||||
export interface FeatureFlag {
|
||||
feature: string;
|
||||
is_enabled: boolean;
|
||||
}
|
||||
|
||||
export const usageApi = {
|
||||
overview: async () => {
|
||||
const resp = await client.get('/ai/usage/overview');
|
||||
return resp.data.data as UsageOverview;
|
||||
},
|
||||
byType: async () => {
|
||||
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;
|
||||
},
|
||||
getDailyUsage: async (startDate: string, endDate: string) => {
|
||||
const resp = await client.get('/ai/admin/daily-usage', {
|
||||
params: { start_date: startDate, end_date: endDate },
|
||||
});
|
||||
return resp.data.data as DailyUsageRow[];
|
||||
},
|
||||
getFeatureFlags: async () => {
|
||||
const resp = await client.get('/ai/admin/flags');
|
||||
return resp.data.data as FeatureFlag[];
|
||||
},
|
||||
updateFeatureFlag: async (feature: string, enabled: boolean) => {
|
||||
const resp = await client.post('/ai/admin/flags', { feature, enabled });
|
||||
return resp.data.data as { feature: string; enabled: boolean };
|
||||
},
|
||||
};
|
||||
51
apps/web/src/api/auditLogs.test.ts
Normal file
51
apps/web/src/api/auditLogs.test.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* auditLogs API 契约测试
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
const mockGet = vi.fn()
|
||||
const mockPost = vi.fn()
|
||||
const mockPut = vi.fn()
|
||||
const mockDelete = vi.fn()
|
||||
|
||||
vi.mock('./client', () => ({
|
||||
default: {
|
||||
get: (...args: unknown[]) => mockGet(...args),
|
||||
post: (...args: unknown[]) => mockPost(...args),
|
||||
put: (...args: unknown[]) => mockPut(...args),
|
||||
delete: (...args: unknown[]) => mockDelete(...args),
|
||||
},
|
||||
}))
|
||||
|
||||
import * as auditLogsApi from './auditLogs'
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('auditLogs API', () => {
|
||||
const fakeRes = { data: { success: true, data: {} } }
|
||||
|
||||
it('listAuditLogs 应调用 GET /audit-logs 并传递查询参数', async () => {
|
||||
mockGet.mockResolvedValue(fakeRes)
|
||||
await auditLogsApi.listAuditLogs({ resource_type: 'user', user_id: 'u-001', page: 1, page_size: 10 })
|
||||
|
||||
expect(mockGet).toHaveBeenCalledWith('/audit-logs', {
|
||||
params: expect.objectContaining({
|
||||
resource_type: 'user',
|
||||
user_id: 'u-001',
|
||||
page: 1,
|
||||
page_size: 10,
|
||||
}),
|
||||
})
|
||||
})
|
||||
|
||||
it('listAuditLogs 默认应传 page=1 page_size=20', async () => {
|
||||
mockGet.mockResolvedValue(fakeRes)
|
||||
await auditLogsApi.listAuditLogs()
|
||||
|
||||
expect(mockGet).toHaveBeenCalledWith('/audit-logs', {
|
||||
params: expect.objectContaining({ page: 1, page_size: 20 }),
|
||||
})
|
||||
})
|
||||
})
|
||||
31
apps/web/src/api/auditLogs.ts
Normal file
31
apps/web/src/api/auditLogs.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import client from './client';
|
||||
import type { PaginatedResponse } from './types';
|
||||
|
||||
export interface AuditLogItem {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
action: string;
|
||||
resource_type: string;
|
||||
resource_id: string;
|
||||
user_id: string;
|
||||
old_value?: string;
|
||||
new_value?: string;
|
||||
ip_address?: string;
|
||||
user_agent?: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface AuditLogQuery {
|
||||
resource_type?: string;
|
||||
user_id?: string;
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
}
|
||||
|
||||
export async function listAuditLogs(query: AuditLogQuery = {}) {
|
||||
const { data } = await client.get<{ success: boolean; data: PaginatedResponse<AuditLogItem> }>(
|
||||
'/audit-logs',
|
||||
{ params: { page: query.page ?? 1, page_size: query.page_size ?? 20, ...query } },
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
55
apps/web/src/api/auth.test.ts
Normal file
55
apps/web/src/api/auth.test.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* auth API 契约测试
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
const mockGet = vi.fn()
|
||||
const mockPost = vi.fn()
|
||||
const mockPut = vi.fn()
|
||||
const mockDelete = vi.fn()
|
||||
|
||||
vi.mock('./client', () => ({
|
||||
default: {
|
||||
get: (...args: unknown[]) => mockGet(...args),
|
||||
post: (...args: unknown[]) => mockPost(...args),
|
||||
put: (...args: unknown[]) => mockPut(...args),
|
||||
delete: (...args: unknown[]) => mockDelete(...args),
|
||||
},
|
||||
}))
|
||||
|
||||
import * as authApi from './auth'
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('auth API', () => {
|
||||
const fakeRes = { data: { success: true, data: {} } }
|
||||
|
||||
it('login 应调用 POST /auth/login 并传递用户名密码', async () => {
|
||||
mockPost.mockResolvedValue(fakeRes)
|
||||
await authApi.login({ username: 'admin', password: '123456' })
|
||||
|
||||
expect(mockPost).toHaveBeenCalledWith('/auth/login', {
|
||||
username: 'admin',
|
||||
password: '123456',
|
||||
})
|
||||
})
|
||||
|
||||
it('logout 应调用 POST /auth/logout', async () => {
|
||||
mockPost.mockResolvedValue(undefined)
|
||||
await authApi.logout()
|
||||
|
||||
expect(mockPost).toHaveBeenCalledWith('/auth/logout')
|
||||
})
|
||||
|
||||
it('changePassword 应调用 POST /auth/change-password', async () => {
|
||||
mockPost.mockResolvedValue(undefined)
|
||||
await authApi.changePassword('oldPass', 'newPass')
|
||||
|
||||
expect(mockPost).toHaveBeenCalledWith('/auth/change-password', {
|
||||
current_password: 'oldPass',
|
||||
new_password: 'newPass',
|
||||
})
|
||||
})
|
||||
})
|
||||
55
apps/web/src/api/auth.ts
Normal file
55
apps/web/src/api/auth.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import client from './client';
|
||||
|
||||
export interface LoginRequest {
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface UserInfo {
|
||||
id: string;
|
||||
username: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
display_name?: string;
|
||||
avatar_url?: string;
|
||||
status: string;
|
||||
roles: RoleInfo[];
|
||||
version: number;
|
||||
}
|
||||
|
||||
export interface RoleInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
code: string;
|
||||
description?: string;
|
||||
is_system: boolean;
|
||||
}
|
||||
|
||||
export interface LoginResponse {
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
expires_in: number;
|
||||
user: UserInfo;
|
||||
}
|
||||
|
||||
export async function login(req: LoginRequest): Promise<LoginResponse> {
|
||||
const { data } = await client.post<{ success: boolean; data: LoginResponse }>(
|
||||
'/auth/login',
|
||||
req
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function logout(): Promise<void> {
|
||||
await client.post('/auth/logout');
|
||||
}
|
||||
|
||||
export async function changePassword(
|
||||
currentPassword: string,
|
||||
newPassword: string
|
||||
): Promise<void> {
|
||||
await client.post('/auth/change-password', {
|
||||
current_password: currentPassword,
|
||||
new_password: newPassword,
|
||||
});
|
||||
}
|
||||
261
apps/web/src/api/client.ts
Normal file
261
apps/web/src/api/client.ts
Normal file
@@ -0,0 +1,261 @@
|
||||
import axios from "axios";
|
||||
import { message as antMessage } from "antd";
|
||||
|
||||
// 请求缓存:短时间内相同请求复用结果
|
||||
interface CacheEntry {
|
||||
data: unknown;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
const requestCache = new Map<string, CacheEntry>();
|
||||
const CACHE_TTL = 5000; // 5 秒缓存
|
||||
|
||||
function getCacheKey(config: {
|
||||
url?: string;
|
||||
params?: unknown;
|
||||
method?: string;
|
||||
}): string {
|
||||
return `${config.method || "get"}:${config.url || ""}:${JSON.stringify(config.params || {})}`;
|
||||
}
|
||||
|
||||
const defaultAdapter = axios.getAdapter(axios.defaults.adapter);
|
||||
|
||||
const client = axios.create({
|
||||
baseURL: "/api/v1",
|
||||
timeout: 10000,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
adapter: (config) => {
|
||||
// GET 请求检查缓存
|
||||
if (config.method === "get" && config.url) {
|
||||
const key = getCacheKey(config);
|
||||
const entry = requestCache.get(key);
|
||||
if (entry && Date.now() - entry.timestamp < CACHE_TTL) {
|
||||
return Promise.resolve({
|
||||
data: entry.data,
|
||||
status: 200,
|
||||
statusText: "OK (cached)",
|
||||
headers: new axios.AxiosHeaders(),
|
||||
config,
|
||||
});
|
||||
}
|
||||
}
|
||||
return defaultAdapter(config);
|
||||
},
|
||||
});
|
||||
|
||||
// Decode JWT payload without external library
|
||||
function decodeJwtPayload(
|
||||
token: string,
|
||||
): { exp?: number; sub?: string } | null {
|
||||
try {
|
||||
const parts = token.split(".");
|
||||
if (parts.length !== 3) return null;
|
||||
const payload = JSON.parse(
|
||||
atob(parts[1].replace(/-/g, "+").replace(/_/g, "/")),
|
||||
);
|
||||
return payload;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if token is expired or about to expire (within 30s buffer)
|
||||
function isTokenExpiringSoon(token: string): boolean {
|
||||
const payload = decodeJwtPayload(token);
|
||||
if (!payload?.exp) return true;
|
||||
return Date.now() / 1000 > payload.exp - 30;
|
||||
}
|
||||
|
||||
// Request interceptor: attach access token + proactive refresh
|
||||
client.interceptors.request.use(async (config) => {
|
||||
const token = localStorage.getItem("access_token");
|
||||
if (token) {
|
||||
// If token is about to expire, proactively refresh before sending the request
|
||||
if (isTokenExpiringSoon(token)) {
|
||||
const refreshToken = localStorage.getItem("refresh_token");
|
||||
if (refreshToken && !isRefreshing) {
|
||||
isRefreshing = true;
|
||||
try {
|
||||
const { data } = await axios.post("/api/v1/auth/refresh", {
|
||||
refresh_token: refreshToken,
|
||||
});
|
||||
const newAccess = data.data.access_token;
|
||||
const newRefresh = data.data.refresh_token;
|
||||
|
||||
// 验证新 token 的用户身份一致
|
||||
const currentUserSub = decodeJwtPayload(token)?.sub;
|
||||
const newTokenSub = decodeJwtPayload(newAccess)?.sub;
|
||||
if (currentUserSub && newTokenSub && currentUserSub !== newTokenSub) {
|
||||
localStorage.removeItem("access_token");
|
||||
localStorage.removeItem("refresh_token");
|
||||
localStorage.removeItem("user");
|
||||
window.location.hash = "/login";
|
||||
return Promise.reject(new Error("身份验证失败,请重新登录"));
|
||||
}
|
||||
|
||||
localStorage.setItem("access_token", newAccess);
|
||||
localStorage.setItem("refresh_token", newRefresh);
|
||||
processQueue(null, newAccess);
|
||||
config.headers.Authorization = `Bearer ${newAccess}`;
|
||||
return config;
|
||||
} catch {
|
||||
processQueue(new Error("refresh failed"), null);
|
||||
// Continue with old token, let 401 handler deal with it
|
||||
} finally {
|
||||
isRefreshing = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
return config;
|
||||
});
|
||||
|
||||
// 响应拦截器:缓存 GET 响应 + 自动刷新 token
|
||||
client.interceptors.response.use(
|
||||
(response) => {
|
||||
// 缓存 GET 响应
|
||||
if (response.config.method === "get" && response.config.url) {
|
||||
const key = getCacheKey(response.config);
|
||||
requestCache.set(key, { data: response.data, timestamp: Date.now() });
|
||||
}
|
||||
return response;
|
||||
},
|
||||
async (error) => {
|
||||
const originalRequest = error.config;
|
||||
if (
|
||||
error.response?.status === 401 &&
|
||||
!originalRequest._retry &&
|
||||
!originalRequest.url?.includes("/auth/login")
|
||||
) {
|
||||
if (isRefreshing) {
|
||||
return new Promise((resolve, reject) => {
|
||||
failedQueue.push({ resolve, reject });
|
||||
}).then((token) => {
|
||||
originalRequest.headers.Authorization = `Bearer ${token}`;
|
||||
return client(originalRequest);
|
||||
});
|
||||
}
|
||||
|
||||
originalRequest._retry = true;
|
||||
isRefreshing = true;
|
||||
|
||||
try {
|
||||
const refreshToken = localStorage.getItem("refresh_token");
|
||||
if (!refreshToken) throw new Error("No refresh token");
|
||||
|
||||
const { data } = await axios.post("/api/v1/auth/refresh", {
|
||||
refresh_token: refreshToken,
|
||||
});
|
||||
|
||||
const newAccessToken = data.data.access_token;
|
||||
const newRefreshToken = data.data.refresh_token;
|
||||
|
||||
// 验证新 token 的用户身份与当前用户一致,防止并发场景下身份切换
|
||||
const currentToken = localStorage.getItem("access_token");
|
||||
const currentUserSub = currentToken
|
||||
? decodeJwtPayload(currentToken)?.sub
|
||||
: null;
|
||||
const newTokenSub = decodeJwtPayload(newAccessToken)?.sub;
|
||||
if (currentUserSub && newTokenSub && currentUserSub !== newTokenSub) {
|
||||
// 身份不一致,强制登出
|
||||
localStorage.removeItem("access_token");
|
||||
localStorage.removeItem("refresh_token");
|
||||
localStorage.removeItem("user");
|
||||
window.location.hash = "/login";
|
||||
return Promise.reject(new Error("身份验证失败,请重新登录"));
|
||||
}
|
||||
|
||||
localStorage.setItem("access_token", newAccessToken);
|
||||
localStorage.setItem("refresh_token", newRefreshToken);
|
||||
|
||||
processQueue(null, newAccessToken);
|
||||
|
||||
originalRequest.headers.Authorization = `Bearer ${newAccessToken}`;
|
||||
return client(originalRequest);
|
||||
} catch (refreshError) {
|
||||
processQueue(refreshError, null);
|
||||
localStorage.removeItem("access_token");
|
||||
localStorage.removeItem("refresh_token");
|
||||
window.location.hash = "/login";
|
||||
return Promise.reject(refreshError);
|
||||
} finally {
|
||||
isRefreshing = false;
|
||||
}
|
||||
}
|
||||
|
||||
return Promise.reject(error);
|
||||
},
|
||||
);
|
||||
|
||||
// 全局错误提示(仅对未被组件处理的错误显示)
|
||||
let globalErrorTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
function showGlobalError(msg: string) {
|
||||
// 防止短时间内弹出大量相同提示
|
||||
if (globalErrorTimer) return;
|
||||
antMessage.error(msg, 3);
|
||||
globalErrorTimer = setTimeout(() => {
|
||||
globalErrorTimer = null;
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
// 全局错误拦截 — 在响应拦截器之后、组件 catch 之前执行
|
||||
// 组件可通过 axios config 中设置 skipGlobalError: true 来抑制全局提示
|
||||
declare module "axios" {
|
||||
interface AxiosRequestConfig {
|
||||
skipGlobalError?: boolean;
|
||||
}
|
||||
interface InternalAxiosRequestConfig {
|
||||
skipGlobalError?: boolean;
|
||||
}
|
||||
}
|
||||
|
||||
client.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
if (error.config?.skipGlobalError) {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
if (!error.response) {
|
||||
showGlobalError("网络连接异常,请检查网络");
|
||||
} else if (error.response.status === 403) {
|
||||
// 403 通常是权限不足,不全局提示 — 组件层通过 AuthButton 已隐藏操作入口
|
||||
} else if (error.response.status === 404) {
|
||||
// 404 通常由组件自行处理(如跳转),不全局提示
|
||||
} else if (error.response.status >= 500) {
|
||||
showGlobalError("服务器异常,请稍后重试");
|
||||
}
|
||||
return Promise.reject(error);
|
||||
},
|
||||
);
|
||||
|
||||
let isRefreshing = false;
|
||||
let failedQueue: Array<{
|
||||
resolve: (token: string) => void;
|
||||
reject: (error: unknown) => void;
|
||||
}> = [];
|
||||
|
||||
function processQueue(error: unknown, token: string | null) {
|
||||
failedQueue.forEach(({ resolve, reject }) => {
|
||||
if (token) resolve(token);
|
||||
else reject(error);
|
||||
});
|
||||
failedQueue = [];
|
||||
}
|
||||
|
||||
// 清除缓存(登录/登出时调用)
|
||||
export function clearApiCache() {
|
||||
requestCache.clear();
|
||||
}
|
||||
|
||||
// 通用错误处理:提取后端错误消息并展示
|
||||
export function handleApiError(err: unknown, fallback = "操作失败"): string {
|
||||
const msg =
|
||||
(err as { response?: { data?: { message?: string } } })?.response?.data
|
||||
?.message || fallback;
|
||||
antMessage.error(msg);
|
||||
return msg;
|
||||
}
|
||||
|
||||
export default client;
|
||||
197
apps/web/src/api/config-modules.test.ts
Normal file
197
apps/web/src/api/config-modules.test.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
/**
|
||||
* config-modules API 契约测试(menus + settings + languages + numberingRules + themes)
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
const mockGet = vi.fn()
|
||||
const mockPost = vi.fn()
|
||||
const mockPut = vi.fn()
|
||||
const mockDelete = vi.fn()
|
||||
|
||||
vi.mock('./client', () => ({
|
||||
default: {
|
||||
get: (...args: unknown[]) => mockGet(...args),
|
||||
post: (...args: unknown[]) => mockPost(...args),
|
||||
put: (...args: unknown[]) => mockPut(...args),
|
||||
delete: (...args: unknown[]) => mockDelete(...args),
|
||||
},
|
||||
}))
|
||||
|
||||
import * as menusApi from './menus'
|
||||
import * as settingsApi from './settings'
|
||||
import * as languagesApi from './languages'
|
||||
import * as numberingApi from './numberingRules'
|
||||
import * as themesApi from './themes'
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// ============================================================
|
||||
// menus
|
||||
// ============================================================
|
||||
describe('menus API', () => {
|
||||
const fakeRes = { data: { success: true, data: {} } }
|
||||
|
||||
it('getMenus 应调用 GET /config/menus', async () => {
|
||||
mockGet.mockResolvedValue(fakeRes)
|
||||
await menusApi.getMenus()
|
||||
|
||||
expect(mockGet).toHaveBeenCalledWith('/config/menus')
|
||||
})
|
||||
|
||||
it('getMenusForUser 应调用 GET /menus/user', async () => {
|
||||
mockGet.mockResolvedValue(fakeRes)
|
||||
await menusApi.getMenusForUser()
|
||||
|
||||
expect(mockGet).toHaveBeenCalledWith('/menus/user')
|
||||
})
|
||||
|
||||
it('batchSaveMenus 应调用 PUT /config/menus 并传递 menus 数组', async () => {
|
||||
mockPut.mockResolvedValue(undefined)
|
||||
const menus = [{ title: '仪表盘', path: '/dashboard' }]
|
||||
await menusApi.batchSaveMenus(menus)
|
||||
|
||||
expect(mockPut).toHaveBeenCalledWith('/config/menus', { menus })
|
||||
})
|
||||
|
||||
it('createMenu 应调用 POST /config/menus', async () => {
|
||||
mockPost.mockResolvedValue(fakeRes)
|
||||
const req = { title: '新菜单', path: '/new', sort_order: 10 }
|
||||
await menusApi.createMenu(req)
|
||||
|
||||
expect(mockPost).toHaveBeenCalledWith('/config/menus', req)
|
||||
})
|
||||
|
||||
it('deleteMenu 应调用 DELETE /config/menus/:id 并在 body 传递 version', async () => {
|
||||
mockDelete.mockResolvedValue(undefined)
|
||||
await menusApi.deleteMenu('menu-001', 3)
|
||||
|
||||
expect(mockDelete).toHaveBeenCalledWith('/config/menus/menu-001', { data: { version: 3 } })
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================
|
||||
// settings
|
||||
// ============================================================
|
||||
describe('settings API', () => {
|
||||
const fakeRes = { data: { success: true, data: {} } }
|
||||
|
||||
it('getSetting 应调用 GET /config/settings/:key 并传递 scope 参数', async () => {
|
||||
mockGet.mockResolvedValue(fakeRes)
|
||||
await settingsApi.getSetting('site.name', 'global', 'org-001')
|
||||
|
||||
expect(mockGet).toHaveBeenCalledWith('/config/settings/site.name', {
|
||||
params: { scope: 'global', scope_id: 'org-001' },
|
||||
})
|
||||
})
|
||||
|
||||
it('updateSetting 应调用 PUT /config/settings/:key', async () => {
|
||||
mockPut.mockResolvedValue(fakeRes)
|
||||
await settingsApi.updateSetting('site.name', '新名称', 1)
|
||||
|
||||
expect(mockPut).toHaveBeenCalledWith('/config/settings/site.name', {
|
||||
setting_value: '新名称',
|
||||
version: 1,
|
||||
})
|
||||
})
|
||||
|
||||
it('deleteSetting 应调用 DELETE /config/settings/:key', async () => {
|
||||
mockDelete.mockResolvedValue(undefined)
|
||||
await settingsApi.deleteSetting('site.name', 2)
|
||||
|
||||
expect(mockDelete).toHaveBeenCalledWith('/config/settings/site.name', { data: { version: 2 } })
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================
|
||||
// languages
|
||||
// ============================================================
|
||||
describe('languages API', () => {
|
||||
const fakeRes = { data: { success: true, data: {} } }
|
||||
|
||||
it('listLanguages 应调用 GET /config/languages', async () => {
|
||||
mockGet.mockResolvedValue(fakeRes)
|
||||
await languagesApi.listLanguages()
|
||||
|
||||
expect(mockGet).toHaveBeenCalledWith('/config/languages')
|
||||
})
|
||||
|
||||
it('updateLanguage 应调用 PUT /config/languages/:code', async () => {
|
||||
mockPut.mockResolvedValue(fakeRes)
|
||||
await languagesApi.updateLanguage('zh-CN', { is_active: true, name: '简体中文' })
|
||||
|
||||
expect(mockPut).toHaveBeenCalledWith('/config/languages/zh-CN', {
|
||||
is_active: true,
|
||||
name: '简体中文',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================
|
||||
// numberingRules
|
||||
// ============================================================
|
||||
describe('numberingRules API', () => {
|
||||
const fakeRes = { data: { success: true, data: {} } }
|
||||
|
||||
it('listNumberingRules 应调用 GET /config/numbering-rules', async () => {
|
||||
mockGet.mockResolvedValue(fakeRes)
|
||||
await numberingApi.listNumberingRules(1, 10)
|
||||
|
||||
expect(mockGet).toHaveBeenCalledWith('/config/numbering-rules', {
|
||||
params: { page: 1, page_size: 10 },
|
||||
})
|
||||
})
|
||||
|
||||
it('createNumberingRule 应调用 POST /config/numbering-rules', async () => {
|
||||
mockPost.mockResolvedValue(fakeRes)
|
||||
const req = { name: '患者编号', code: 'patient', prefix: 'P', seq_length: 6 }
|
||||
await numberingApi.createNumberingRule(req)
|
||||
|
||||
expect(mockPost).toHaveBeenCalledWith('/config/numbering-rules', req)
|
||||
})
|
||||
|
||||
it('updateNumberingRule 应调用 PUT /config/numbering-rules/:id', async () => {
|
||||
mockPut.mockResolvedValue(fakeRes)
|
||||
const req = { prefix: 'HMS', version: 1 }
|
||||
await numberingApi.updateNumberingRule('nr-001', req)
|
||||
|
||||
expect(mockPut).toHaveBeenCalledWith('/config/numbering-rules/nr-001', req)
|
||||
})
|
||||
|
||||
it('generateNumber 应调用 POST /config/numbering-rules/:id/generate', async () => {
|
||||
mockPost.mockResolvedValue(fakeRes)
|
||||
await numberingApi.generateNumber('nr-001')
|
||||
|
||||
expect(mockPost).toHaveBeenCalledWith('/config/numbering-rules/nr-001/generate')
|
||||
})
|
||||
|
||||
it('deleteNumberingRule 应调用 DELETE /config/numbering-rules/:id', async () => {
|
||||
mockDelete.mockResolvedValue(undefined)
|
||||
await numberingApi.deleteNumberingRule('nr-001', 1)
|
||||
|
||||
expect(mockDelete).toHaveBeenCalledWith('/config/numbering-rules/nr-001', { data: { version: 1 } })
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================
|
||||
// themes
|
||||
// ============================================================
|
||||
describe('themes API', () => {
|
||||
const fakeRes = { data: { success: true, data: {} } }
|
||||
|
||||
it('getTheme 应调用 GET /config/themes', async () => {
|
||||
mockGet.mockResolvedValue(fakeRes)
|
||||
await themesApi.getTheme()
|
||||
|
||||
expect(mockGet).toHaveBeenCalledWith('/config/themes')
|
||||
})
|
||||
|
||||
it('updateTheme 应调用 PUT /config/themes', async () => {
|
||||
mockPut.mockResolvedValue(fakeRes)
|
||||
const theme = { primary_color: '#1890ff', brand_name: 'HMS' }
|
||||
await themesApi.updateTheme(theme)
|
||||
|
||||
expect(mockPut).toHaveBeenCalledWith('/config/themes', theme)
|
||||
})
|
||||
})
|
||||
66
apps/web/src/api/copilot.ts
Normal file
66
apps/web/src/api/copilot.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import client from './client';
|
||||
import type { PaginatedResponse } from './types';
|
||||
|
||||
// --- Types ---
|
||||
|
||||
export type InsightType = 'risk_score' | 'anomaly' | 'follow_up_hint' | 'consult_hint';
|
||||
export type InsightSource = 'rule' | 'llm' | 'hybrid';
|
||||
export type RiskLevel = 'low' | 'medium' | 'high' | 'critical';
|
||||
|
||||
export interface MatchedRule {
|
||||
rule_id: string;
|
||||
name: string;
|
||||
score: number;
|
||||
severity: string;
|
||||
suggestion?: string;
|
||||
}
|
||||
|
||||
export interface RiskScore {
|
||||
score: number;
|
||||
level: RiskLevel;
|
||||
matched_rules: MatchedRule[];
|
||||
}
|
||||
|
||||
export interface CopilotInsight {
|
||||
id: string;
|
||||
patient_id: string;
|
||||
insight_type: InsightType;
|
||||
source: InsightSource;
|
||||
severity: 'info' | 'warning' | 'critical';
|
||||
title: string;
|
||||
content: Record<string, unknown>;
|
||||
rule_matches?: MatchedRule[];
|
||||
llm_supplement?: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
// --- API Functions ---
|
||||
|
||||
export async function getPatientRisk(patientId: string) {
|
||||
const { data } = await client.get<{ success: boolean; data: RiskScore }>(`/copilot/patients/${patientId}/risk`);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function listInsights(params: {
|
||||
patient_id?: string;
|
||||
insight_type?: string;
|
||||
severity?: string;
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
}) {
|
||||
const { data } = await client.get<{ success: boolean; data: PaginatedResponse<CopilotInsight> }>('/copilot/insights', { params });
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export function dismissInsight(id: string) {
|
||||
return client.post(`/copilot/insights/${id}/dismiss`);
|
||||
}
|
||||
|
||||
export function listAlerts(params?: { severity?: string; page?: number; page_size?: number }) {
|
||||
return listInsights({ insight_type: 'anomaly', ...params });
|
||||
}
|
||||
|
||||
export async function listRules(params?: { page?: number; page_size?: number }) {
|
||||
const { data } = await client.get<{ success: boolean; data: PaginatedResponse<Record<string, unknown>> }>('/copilot/rules', { params });
|
||||
return data.data;
|
||||
}
|
||||
96
apps/web/src/api/dictionaries.test.ts
Normal file
96
apps/web/src/api/dictionaries.test.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* dictionaries API 契约测试
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
const mockGet = vi.fn()
|
||||
const mockPost = vi.fn()
|
||||
const mockPut = vi.fn()
|
||||
const mockDelete = vi.fn()
|
||||
|
||||
vi.mock('./client', () => ({
|
||||
default: {
|
||||
get: (...args: unknown[]) => mockGet(...args),
|
||||
post: (...args: unknown[]) => mockPost(...args),
|
||||
put: (...args: unknown[]) => mockPut(...args),
|
||||
delete: (...args: unknown[]) => mockDelete(...args),
|
||||
},
|
||||
}))
|
||||
|
||||
import * as dictApi from './dictionaries'
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('dictionaries API', () => {
|
||||
const fakeRes = { data: { success: true, data: {} } }
|
||||
|
||||
it('listDictionaries 应调用 GET /config/dictionaries 并传递分页参数', async () => {
|
||||
mockGet.mockResolvedValue(fakeRes)
|
||||
await dictApi.listDictionaries(1, 10)
|
||||
|
||||
expect(mockGet).toHaveBeenCalledWith('/config/dictionaries', {
|
||||
params: { page: 1, page_size: 10 },
|
||||
})
|
||||
})
|
||||
|
||||
it('createDictionary 应调用 POST /config/dictionaries', async () => {
|
||||
mockPost.mockResolvedValue(fakeRes)
|
||||
const req = { name: '性别', code: 'gender', description: '性别字典' }
|
||||
await dictApi.createDictionary(req)
|
||||
|
||||
expect(mockPost).toHaveBeenCalledWith('/config/dictionaries', req)
|
||||
})
|
||||
|
||||
it('updateDictionary 应调用 PUT /config/dictionaries/:id', async () => {
|
||||
mockPut.mockResolvedValue(fakeRes)
|
||||
const req = { name: '性别(更新)', version: 1 }
|
||||
await dictApi.updateDictionary('dict-001', req)
|
||||
|
||||
expect(mockPut).toHaveBeenCalledWith('/config/dictionaries/dict-001', req)
|
||||
})
|
||||
|
||||
it('deleteDictionary 应调用 DELETE /config/dictionaries/:id 并在 body 传递 version', async () => {
|
||||
mockDelete.mockResolvedValue(undefined)
|
||||
await dictApi.deleteDictionary('dict-001', 2)
|
||||
|
||||
expect(mockDelete).toHaveBeenCalledWith('/config/dictionaries/dict-001', {
|
||||
data: { version: 2 },
|
||||
})
|
||||
})
|
||||
|
||||
it('listItemsByCode 应调用 GET /config/dictionaries/items 并传递 code 参数', async () => {
|
||||
mockGet.mockResolvedValue(fakeRes)
|
||||
await dictApi.listItemsByCode('gender')
|
||||
|
||||
expect(mockGet).toHaveBeenCalledWith('/config/dictionaries/items', {
|
||||
params: { code: 'gender' },
|
||||
})
|
||||
})
|
||||
|
||||
it('createDictionaryItem 应调用 POST /config/dictionaries/:id/items', async () => {
|
||||
mockPost.mockResolvedValue(fakeRes)
|
||||
const req = { label: '男', value: 'male', sort_order: 1 }
|
||||
await dictApi.createDictionaryItem('dict-001', req)
|
||||
|
||||
expect(mockPost).toHaveBeenCalledWith('/config/dictionaries/dict-001/items', req)
|
||||
})
|
||||
|
||||
it('updateDictionaryItem 应调用 PUT /config/dictionaries/:dictId/items/:itemId', async () => {
|
||||
mockPut.mockResolvedValue(fakeRes)
|
||||
const req = { label: '女', version: 1 }
|
||||
await dictApi.updateDictionaryItem('dict-001', 'item-001', req)
|
||||
|
||||
expect(mockPut).toHaveBeenCalledWith('/config/dictionaries/dict-001/items/item-001', req)
|
||||
})
|
||||
|
||||
it('deleteDictionaryItem 应调用 DELETE /config/dictionaries/:dictId/items/:itemId', async () => {
|
||||
mockDelete.mockResolvedValue(undefined)
|
||||
await dictApi.deleteDictionaryItem('dict-001', 'item-001', 1)
|
||||
|
||||
expect(mockDelete).toHaveBeenCalledWith('/config/dictionaries/dict-001/items/item-001', {
|
||||
data: { version: 1 },
|
||||
})
|
||||
})
|
||||
})
|
||||
111
apps/web/src/api/dictionaries.ts
Normal file
111
apps/web/src/api/dictionaries.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import client from './client';
|
||||
import type { PaginatedResponse } from './types';
|
||||
|
||||
export interface DictionaryItemInfo {
|
||||
id: string;
|
||||
dictionary_id: string;
|
||||
label: string;
|
||||
value: string;
|
||||
sort_order: number;
|
||||
color?: string;
|
||||
version: number;
|
||||
}
|
||||
|
||||
export interface DictionaryInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
code: string;
|
||||
description?: string;
|
||||
items: DictionaryItemInfo[];
|
||||
version: number;
|
||||
}
|
||||
|
||||
export interface CreateDictionaryRequest {
|
||||
name: string;
|
||||
code: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface UpdateDictionaryRequest {
|
||||
name?: string;
|
||||
description?: string;
|
||||
version: number;
|
||||
}
|
||||
|
||||
export async function listDictionaries(page = 1, pageSize = 20) {
|
||||
const { data } = await client.get<{ success: boolean; data: PaginatedResponse<DictionaryInfo> }>(
|
||||
'/config/dictionaries',
|
||||
{ params: { page, page_size: pageSize } },
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function createDictionary(req: CreateDictionaryRequest) {
|
||||
const { data } = await client.post<{ success: boolean; data: DictionaryInfo }>(
|
||||
'/config/dictionaries',
|
||||
req,
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function updateDictionary(id: string, req: UpdateDictionaryRequest) {
|
||||
const { data } = await client.put<{ success: boolean; data: DictionaryInfo }>(
|
||||
`/config/dictionaries/${id}`,
|
||||
req,
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function deleteDictionary(id: string, version: number) {
|
||||
await client.delete(`/config/dictionaries/${id}`, { data: { version } });
|
||||
}
|
||||
|
||||
export async function listItemsByCode(code: string) {
|
||||
const { data } = await client.get<{ success: boolean; data: DictionaryItemInfo[] }>(
|
||||
'/config/dictionaries/items',
|
||||
{ params: { code } },
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export interface CreateDictionaryItemRequest {
|
||||
label: string;
|
||||
value: string;
|
||||
sort_order?: number;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export interface UpdateDictionaryItemRequest {
|
||||
label?: string;
|
||||
value?: string;
|
||||
sort_order?: number;
|
||||
color?: string;
|
||||
version: number;
|
||||
}
|
||||
|
||||
export async function createDictionaryItem(
|
||||
dictionaryId: string,
|
||||
req: CreateDictionaryItemRequest,
|
||||
) {
|
||||
const { data } = await client.post<{ success: boolean; data: DictionaryItemInfo }>(
|
||||
`/config/dictionaries/${dictionaryId}/items`,
|
||||
req,
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function updateDictionaryItem(
|
||||
dictionaryId: string,
|
||||
itemId: string,
|
||||
req: UpdateDictionaryItemRequest,
|
||||
) {
|
||||
const { data } = await client.put<{ success: boolean; data: DictionaryItemInfo }>(
|
||||
`/config/dictionaries/${dictionaryId}/items/${itemId}`,
|
||||
req,
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function deleteDictionaryItem(dictionaryId: string, itemId: string, version: number) {
|
||||
await client.delete(`/config/dictionaries/${dictionaryId}/items/${itemId}`, { data: { version } });
|
||||
}
|
||||
128
apps/web/src/api/health/actionInbox.ts
Normal file
128
apps/web/src/api/health/actionInbox.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import client from '../client';
|
||||
import type { PaginatedResponse } from '../types';
|
||||
|
||||
export type ActionType = 'ai_suggestion' | 'alert' | 'followup' | 'data_anomaly';
|
||||
export type ActionPriority = 'urgent' | 'high' | 'medium' | 'low';
|
||||
export type ActionStatus = 'pending' | 'in_progress' | 'completed' | 'dismissed';
|
||||
|
||||
export interface ActionItem {
|
||||
id: string;
|
||||
action_type: ActionType;
|
||||
priority: ActionPriority;
|
||||
status: ActionStatus;
|
||||
title: string;
|
||||
summary: string;
|
||||
patient_id: string;
|
||||
patient_name: string;
|
||||
source_ref: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface ThreadEvent {
|
||||
step: string;
|
||||
label: string;
|
||||
status: ActionStatus;
|
||||
detail?: string;
|
||||
timestamp?: string;
|
||||
operator?: string;
|
||||
link_to?: string;
|
||||
}
|
||||
|
||||
export interface ActionDefinition {
|
||||
key: string;
|
||||
label: string;
|
||||
variant: 'primary' | 'danger' | 'default';
|
||||
api_endpoint?: string;
|
||||
}
|
||||
|
||||
export interface ThreadResponse {
|
||||
action_item: ActionItem;
|
||||
thread: ThreadEvent[];
|
||||
available_actions: ActionDefinition[];
|
||||
}
|
||||
|
||||
export interface WorkbenchStats {
|
||||
total_pending: number;
|
||||
ai_suggestion_pending: number;
|
||||
urgent_alerts: number;
|
||||
followup_due: number;
|
||||
completion_rate: number | null;
|
||||
}
|
||||
|
||||
export interface NursePatientSummary {
|
||||
patient_id: string;
|
||||
patient_name: string;
|
||||
pending_actions: number;
|
||||
highest_priority: ActionPriority;
|
||||
}
|
||||
|
||||
export interface TeamMemberOverview {
|
||||
user_id: string;
|
||||
name: string;
|
||||
title: string;
|
||||
pending_count: number;
|
||||
completed_count: number;
|
||||
overdue_count: number;
|
||||
completion_rate: number;
|
||||
}
|
||||
|
||||
export interface TeamOverview {
|
||||
members: TeamMemberOverview[];
|
||||
risk_distribution: {
|
||||
high: number;
|
||||
medium: number;
|
||||
low: number;
|
||||
};
|
||||
total_pending: number;
|
||||
total_completed: number;
|
||||
}
|
||||
|
||||
export const actionInboxApi = {
|
||||
list: async (params?: {
|
||||
status?: string;
|
||||
type?: string;
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
assigned_to_me?: boolean;
|
||||
patient_id?: string;
|
||||
}) => {
|
||||
const { data } = await client.get<{
|
||||
success: boolean;
|
||||
data: PaginatedResponse<ActionItem>;
|
||||
}>('/health/action-inbox', { params });
|
||||
return data.data;
|
||||
},
|
||||
|
||||
getThread: async (sourceRef: string) => {
|
||||
const { data } = await client.get<{
|
||||
success: boolean;
|
||||
data: ThreadResponse;
|
||||
}>(`/health/action-inbox/${encodeURIComponent(sourceRef)}/thread`);
|
||||
return data.data;
|
||||
},
|
||||
|
||||
stats: async (params?: { assigned_to_me?: boolean }) => {
|
||||
const { data } = await client.get<{
|
||||
success: boolean;
|
||||
data: WorkbenchStats;
|
||||
}>('/health/action-inbox/stats', { params });
|
||||
return data.data;
|
||||
},
|
||||
|
||||
myPatients: async () => {
|
||||
const { data } = await client.get<{
|
||||
success: boolean;
|
||||
data: NursePatientSummary[];
|
||||
}>('/health/action-inbox/my-patients');
|
||||
return data.data;
|
||||
},
|
||||
|
||||
team: async () => {
|
||||
const { data } = await client.get<{
|
||||
success: boolean;
|
||||
data: TeamOverview;
|
||||
}>('/health/action-inbox/team');
|
||||
return data.data;
|
||||
},
|
||||
};
|
||||
100
apps/web/src/api/health/alerts.test.ts
Normal file
100
apps/web/src/api/health/alerts.test.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* alerts API 契约测试
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
const mockGet = vi.fn()
|
||||
const mockPost = vi.fn()
|
||||
const mockPut = vi.fn()
|
||||
const mockDelete = vi.fn()
|
||||
|
||||
vi.mock('../client', () => ({
|
||||
default: {
|
||||
get: (...args: unknown[]) => mockGet(...args),
|
||||
post: (...args: unknown[]) => mockPost(...args),
|
||||
put: (...args: unknown[]) => mockPut(...args),
|
||||
delete: (...args: unknown[]) => mockDelete(...args),
|
||||
},
|
||||
}))
|
||||
|
||||
import { alertApi, alertRuleApi } from './alerts'
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('alertApi', () => {
|
||||
const fakeRes = { data: { data: {} } }
|
||||
|
||||
it('list 应调用 GET /health/alerts 并传递查询参数', async () => {
|
||||
mockGet.mockResolvedValue(fakeRes)
|
||||
await alertApi.list({ patient_id: 'p-001', status: 'active', page: 1, page_size: 20 })
|
||||
|
||||
expect(mockGet).toHaveBeenCalledWith('/health/alerts', {
|
||||
params: { patient_id: 'p-001', status: 'active', page: 1, page_size: 20 },
|
||||
})
|
||||
})
|
||||
|
||||
it('acknowledge 应调用 PUT /health/alerts/:id/acknowledge 并传递 version', async () => {
|
||||
mockPut.mockResolvedValue(fakeRes)
|
||||
await alertApi.acknowledge('a-001', 2)
|
||||
|
||||
expect(mockPut).toHaveBeenCalledWith('/health/alerts/a-001/acknowledge', { version: 2 })
|
||||
})
|
||||
|
||||
it('dismiss 应调用 PUT /health/alerts/:id/dismiss', async () => {
|
||||
mockPut.mockResolvedValue(fakeRes)
|
||||
await alertApi.dismiss('a-001', 1)
|
||||
|
||||
expect(mockPut).toHaveBeenCalledWith('/health/alerts/a-001/dismiss', { version: 1 })
|
||||
})
|
||||
|
||||
it('resolve 应调用 PUT /health/alerts/:id/resolve', async () => {
|
||||
mockPut.mockResolvedValue(fakeRes)
|
||||
await alertApi.resolve('a-001', 3)
|
||||
|
||||
expect(mockPut).toHaveBeenCalledWith('/health/alerts/a-001/resolve', { version: 3 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('alertRuleApi', () => {
|
||||
const fakeRes = { data: { data: {} } }
|
||||
|
||||
it('list 应调用 GET /health/alert-rules 并传递查询参数', async () => {
|
||||
mockGet.mockResolvedValue(fakeRes)
|
||||
await alertRuleApi.list({ device_type: 'blood_pressure', page: 1, page_size: 10 })
|
||||
|
||||
expect(mockGet).toHaveBeenCalledWith('/health/alert-rules', {
|
||||
params: { device_type: 'blood_pressure', page: 1, page_size: 10 },
|
||||
})
|
||||
})
|
||||
|
||||
it('create 应调用 POST /health/alert-rules 并传递请求体', async () => {
|
||||
mockPost.mockResolvedValue(fakeRes)
|
||||
const req = {
|
||||
name: '血压偏高告警',
|
||||
device_type: 'blood_pressure',
|
||||
condition_type: 'threshold',
|
||||
condition_params: { field: 'systolic', operator: '>', value: 140 },
|
||||
severity: 'high',
|
||||
}
|
||||
await alertRuleApi.create(req)
|
||||
|
||||
expect(mockPost).toHaveBeenCalledWith('/health/alert-rules', req)
|
||||
})
|
||||
|
||||
it('update 应调用 PUT /health/alert-rules/:id', async () => {
|
||||
mockPut.mockResolvedValue(fakeRes)
|
||||
const req = { severity: 'critical', version: 1 }
|
||||
await alertRuleApi.update('rule-001', req)
|
||||
|
||||
expect(mockPut).toHaveBeenCalledWith('/health/alert-rules/rule-001', req)
|
||||
})
|
||||
|
||||
it('deactivate 应调用 PUT /health/alert-rules/:id/deactivate', async () => {
|
||||
mockPut.mockResolvedValue(fakeRes)
|
||||
await alertRuleApi.deactivate('rule-001', 2)
|
||||
|
||||
expect(mockPut).toHaveBeenCalledWith('/health/alert-rules/rule-001/deactivate', { version: 2 })
|
||||
})
|
||||
})
|
||||
118
apps/web/src/api/health/alerts.ts
Normal file
118
apps/web/src/api/health/alerts.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import client from '../client';
|
||||
import type { PaginatedResponse } from '../types';
|
||||
|
||||
// --- Types ---
|
||||
export interface Alert {
|
||||
id: string;
|
||||
patient_id: string;
|
||||
patient_name?: string;
|
||||
rule_id: string;
|
||||
severity: string;
|
||||
title: string;
|
||||
detail?: Record<string, unknown>;
|
||||
status: string;
|
||||
acknowledged_by?: string;
|
||||
acknowledged_by_name?: string;
|
||||
acknowledged_at?: string;
|
||||
resolved_at?: string;
|
||||
created_at: string;
|
||||
version: number;
|
||||
}
|
||||
|
||||
export interface AlertRule {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
device_type: string;
|
||||
condition_type: string;
|
||||
condition_params: Record<string, unknown>;
|
||||
severity: string;
|
||||
is_active: boolean;
|
||||
apply_tags?: Record<string, unknown>;
|
||||
notify_roles: unknown[];
|
||||
cooldown_minutes: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
version: number;
|
||||
}
|
||||
|
||||
export interface CreateAlertRuleReq {
|
||||
name: string;
|
||||
description?: string;
|
||||
device_type: string;
|
||||
condition_type: string;
|
||||
condition_params: Record<string, unknown>;
|
||||
severity?: string;
|
||||
apply_tags?: Record<string, unknown>;
|
||||
notify_roles?: unknown[];
|
||||
cooldown_minutes?: number;
|
||||
}
|
||||
|
||||
export interface UpdateAlertRuleReq {
|
||||
name?: string;
|
||||
description?: string;
|
||||
condition_params?: Record<string, unknown>;
|
||||
severity?: string;
|
||||
apply_tags?: Record<string, unknown>;
|
||||
notify_roles?: unknown[];
|
||||
cooldown_minutes?: number;
|
||||
version: number;
|
||||
}
|
||||
|
||||
// --- API ---
|
||||
export const alertApi = {
|
||||
list: (params?: { patient_id?: string; doctor_id?: string; status?: string; page?: number; page_size?: number }) =>
|
||||
client.get('/health/alerts', { params }).then((r) => r.data.data as PaginatedResponse<Alert>),
|
||||
|
||||
acknowledge: (id: string, version: number) =>
|
||||
client.put(`/health/alerts/${id}/acknowledge`, { version }).then((r) => r.data.data as Alert),
|
||||
|
||||
dismiss: (id: string, version: number) =>
|
||||
client.put(`/health/alerts/${id}/dismiss`, { version }).then((r) => r.data.data as Alert),
|
||||
|
||||
resolve: (id: string, version: number) =>
|
||||
client.put(`/health/alerts/${id}/resolve`, { version }).then((r) => r.data.data as Alert),
|
||||
};
|
||||
|
||||
export const alertRuleApi = {
|
||||
list: (params?: { device_type?: string; page?: number; page_size?: number }) =>
|
||||
client.get('/health/alert-rules', { params }).then((r) => r.data.data as PaginatedResponse<AlertRule>),
|
||||
|
||||
create: (data: CreateAlertRuleReq) =>
|
||||
client.post('/health/alert-rules', data).then((r) => r.data.data as AlertRule),
|
||||
|
||||
update: (id: string, data: UpdateAlertRuleReq) =>
|
||||
client.put(`/health/alert-rules/${id}`, data).then((r) => r.data.data as AlertRule),
|
||||
|
||||
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),
|
||||
};
|
||||
172
apps/web/src/api/health/api.test.ts
Normal file
172
apps/web/src/api/health/api.test.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* 健康模块新增 API 函数的契约测试
|
||||
*
|
||||
* 验证 dialysisApi / pointsAdminApi / healthDataApi 的日常监测与报告审核函数
|
||||
* 是否调用了正确的 HTTP 方法、URL 路径和参数。
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
// --- Mock axios client ---
|
||||
// 三个被测文件都 import client from '../client',相对路径一致
|
||||
const mockGet = vi.fn()
|
||||
const mockPost = vi.fn()
|
||||
const mockPut = vi.fn()
|
||||
const mockDelete = vi.fn()
|
||||
|
||||
vi.mock('../client', () => ({
|
||||
default: {
|
||||
get: (...args: unknown[]) => mockGet(...args),
|
||||
post: (...args: unknown[]) => mockPost(...args),
|
||||
put: (...args: unknown[]) => mockPut(...args),
|
||||
delete: (...args: unknown[]) => mockDelete(...args),
|
||||
},
|
||||
}))
|
||||
|
||||
// 在 mock 生效后导入被测模块
|
||||
import { dialysisApi } from './dialysis'
|
||||
import { pointsAdminApi } from './points'
|
||||
import { healthDataApi } from './healthData'
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// ============================================================
|
||||
// dialysisApi
|
||||
// ============================================================
|
||||
describe('dialysisApi', () => {
|
||||
const fakeResponse = { data: { success: true, data: {} } }
|
||||
|
||||
it('listRecords 应调用 GET /health/patients/:id/dialysis-records 并传递分页参数', async () => {
|
||||
mockGet.mockResolvedValue(fakeResponse)
|
||||
await dialysisApi.listRecords('p-001', { page: 2, page_size: 20 })
|
||||
|
||||
expect(mockGet).toHaveBeenCalledTimes(1)
|
||||
expect(mockGet).toHaveBeenCalledWith(
|
||||
'/health/patients/p-001/dialysis-records',
|
||||
{ params: { page: 2, page_size: 20 } },
|
||||
)
|
||||
})
|
||||
|
||||
it('getRecord 应调用 GET /health/dialysis-records/:id', async () => {
|
||||
mockGet.mockResolvedValue(fakeResponse)
|
||||
await dialysisApi.getRecord('rec-123')
|
||||
|
||||
expect(mockGet).toHaveBeenCalledWith('/health/dialysis-records/rec-123')
|
||||
})
|
||||
|
||||
it('createRecord 应调用 POST /health/dialysis-records 并传递请求体', async () => {
|
||||
mockPost.mockResolvedValue(fakeResponse)
|
||||
const req = { patient_id: 'p-001', dialysis_date: '2026-04-30', dialysis_type: 'hemodialysis' }
|
||||
await dialysisApi.createRecord(req)
|
||||
|
||||
expect(mockPost).toHaveBeenCalledWith('/health/dialysis-records', req)
|
||||
})
|
||||
|
||||
it('updateRecord 应调用 PUT /health/dialysis-records/:id 并传递请求体', async () => {
|
||||
mockPut.mockResolvedValue(fakeResponse)
|
||||
const req = { dry_weight: 65.0, version: 3 }
|
||||
await dialysisApi.updateRecord('rec-123', req)
|
||||
|
||||
expect(mockPut).toHaveBeenCalledWith('/health/dialysis-records/rec-123', req)
|
||||
})
|
||||
|
||||
it('deleteRecord 应调用 DELETE /health/dialysis-records/:id 并在 body 中传递 version', async () => {
|
||||
mockDelete.mockResolvedValue(undefined)
|
||||
await dialysisApi.deleteRecord('rec-123', 3)
|
||||
|
||||
expect(mockDelete).toHaveBeenCalledWith('/health/dialysis-records/rec-123', {
|
||||
data: { version: 3 },
|
||||
})
|
||||
})
|
||||
|
||||
it('reviewRecord 应调用 PUT /health/dialysis-records/:id/review', async () => {
|
||||
mockPut.mockResolvedValue(fakeResponse)
|
||||
const req = { version: 2, doctor_notes: '指标正常' }
|
||||
await dialysisApi.reviewRecord('rec-456', req)
|
||||
|
||||
expect(mockPut).toHaveBeenCalledWith('/health/dialysis-records/rec-456/review', req)
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================
|
||||
// pointsAdminApi
|
||||
// ============================================================
|
||||
describe('pointsAdminApi', () => {
|
||||
const fakeResponse = { data: { success: true, data: {} } }
|
||||
|
||||
it('getPatientAccount 应调用 GET /health/admin/points/patients/:id/account', async () => {
|
||||
mockGet.mockResolvedValue(fakeResponse)
|
||||
await pointsAdminApi.getPatientAccount('p-001')
|
||||
|
||||
expect(mockGet).toHaveBeenCalledWith('/health/admin/points/patients/p-001/account')
|
||||
})
|
||||
|
||||
it('listPatientTransactions 应调用 GET 并传递分页参数', async () => {
|
||||
mockGet.mockResolvedValue(fakeResponse)
|
||||
await pointsAdminApi.listPatientTransactions('p-001', { page: 1, page_size: 10 })
|
||||
|
||||
expect(mockGet).toHaveBeenCalledWith(
|
||||
'/health/admin/points/patients/p-001/transactions',
|
||||
{ params: { page: 1, page_size: 10 } },
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================
|
||||
// healthDataApi — 日常监测 + 报告审核
|
||||
// ============================================================
|
||||
describe('healthDataApi 日常监测', () => {
|
||||
const fakeResponse = { data: { success: true, data: {} } }
|
||||
|
||||
it('listDailyMonitoring 应调用 GET /health/patients/:id/daily-monitoring 并传递分页参数', async () => {
|
||||
mockGet.mockResolvedValue(fakeResponse)
|
||||
await healthDataApi.listDailyMonitoring('p-001', { page: 1, page_size: 15 })
|
||||
|
||||
expect(mockGet).toHaveBeenCalledWith(
|
||||
'/health/patients/p-001/daily-monitoring',
|
||||
{ params: { page: 1, page_size: 15 } },
|
||||
)
|
||||
})
|
||||
|
||||
it('createDailyMonitoring 应调用 POST /health/daily-monitoring 并传递请求体', async () => {
|
||||
mockPost.mockResolvedValue(fakeResponse)
|
||||
const req = {
|
||||
patient_id: 'p-001',
|
||||
record_date: '2026-04-30',
|
||||
weight: 70.5,
|
||||
blood_sugar: 5.2,
|
||||
}
|
||||
await healthDataApi.createDailyMonitoring(req)
|
||||
|
||||
expect(mockPost).toHaveBeenCalledWith('/health/daily-monitoring', req)
|
||||
})
|
||||
|
||||
it('updateDailyMonitoring 应调用 PUT /health/daily-monitoring/:id 并传递请求体', async () => {
|
||||
mockPut.mockResolvedValue(fakeResponse)
|
||||
const req = { weight: 71.0, version: 1 }
|
||||
await healthDataApi.updateDailyMonitoring('dm-123', req)
|
||||
|
||||
expect(mockPut).toHaveBeenCalledWith('/health/daily-monitoring/dm-123', req)
|
||||
})
|
||||
|
||||
it('deleteDailyMonitoring 应调用 DELETE /health/daily-monitoring/:id 并在 body 中传递 version', async () => {
|
||||
mockDelete.mockResolvedValue(undefined)
|
||||
await healthDataApi.deleteDailyMonitoring('dm-123', 2)
|
||||
|
||||
expect(mockDelete).toHaveBeenCalledWith('/health/daily-monitoring/dm-123', {
|
||||
data: { version: 2 },
|
||||
})
|
||||
})
|
||||
|
||||
it('reviewLabReport 应调用 PUT /health/patients/:pid/lab-reports/:rid/review', async () => {
|
||||
mockPut.mockResolvedValue(fakeResponse)
|
||||
const req = { version: 1, doctor_notes: '指标略有异常,建议复查' }
|
||||
await healthDataApi.reviewLabReport('p-001', 'lr-456', req)
|
||||
|
||||
expect(mockPut).toHaveBeenCalledWith(
|
||||
'/health/patients/p-001/lab-reports/lr-456/review',
|
||||
req,
|
||||
)
|
||||
})
|
||||
})
|
||||
106
apps/web/src/api/health/appointments.test.ts
Normal file
106
apps/web/src/api/health/appointments.test.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* appointments API 契约测试
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
const mockGet = vi.fn()
|
||||
const mockPost = vi.fn()
|
||||
const mockPut = vi.fn()
|
||||
const mockDelete = vi.fn()
|
||||
|
||||
vi.mock('../client', () => ({
|
||||
default: {
|
||||
get: (...args: unknown[]) => mockGet(...args),
|
||||
post: (...args: unknown[]) => mockPost(...args),
|
||||
put: (...args: unknown[]) => mockPut(...args),
|
||||
delete: (...args: unknown[]) => mockDelete(...args),
|
||||
},
|
||||
}))
|
||||
|
||||
import { appointmentApi } from './appointments'
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('appointmentApi', () => {
|
||||
const fakeRes = { data: { success: true, data: {} } }
|
||||
|
||||
it('list 应调用 GET /health/appointments 并传递查询参数', async () => {
|
||||
mockGet.mockResolvedValue(fakeRes)
|
||||
await appointmentApi.list({ page: 1, page_size: 20, status: 'confirmed', doctor_id: 'd-001' })
|
||||
|
||||
expect(mockGet).toHaveBeenCalledWith('/health/appointments', {
|
||||
params: { page: 1, page_size: 20, status: 'confirmed', doctor_id: 'd-001' },
|
||||
})
|
||||
})
|
||||
|
||||
it('get 应调用 GET /health/appointments/:id', async () => {
|
||||
mockGet.mockResolvedValue(fakeRes)
|
||||
await appointmentApi.get('appt-001')
|
||||
|
||||
expect(mockGet).toHaveBeenCalledWith('/health/appointments/appt-001')
|
||||
})
|
||||
|
||||
it('create 应调用 POST /health/appointments 并传递请求体', async () => {
|
||||
mockPost.mockResolvedValue(fakeRes)
|
||||
const req = {
|
||||
patient_id: 'p-001',
|
||||
doctor_id: 'd-001',
|
||||
appointment_date: '2026-05-10',
|
||||
start_time: '09:00',
|
||||
end_time: '09:30',
|
||||
}
|
||||
await appointmentApi.create(req)
|
||||
|
||||
expect(mockPost).toHaveBeenCalledWith('/health/appointments', req)
|
||||
})
|
||||
|
||||
it('updateStatus 应调用 PUT /health/appointments/:id/status', async () => {
|
||||
mockPut.mockResolvedValue(fakeRes)
|
||||
const req = { status: 'cancelled', cancel_reason: '时间冲突', version: 2 }
|
||||
await appointmentApi.updateStatus('appt-001', req)
|
||||
|
||||
expect(mockPut).toHaveBeenCalledWith('/health/appointments/appt-001/status', req)
|
||||
})
|
||||
|
||||
it('listSchedules 应调用 GET /health/doctor-schedules 并传递查询参数', async () => {
|
||||
mockGet.mockResolvedValue(fakeRes)
|
||||
await appointmentApi.listSchedules({ doctor_id: 'd-001', date: '2026-05-10' })
|
||||
|
||||
expect(mockGet).toHaveBeenCalledWith('/health/doctor-schedules', {
|
||||
params: { doctor_id: 'd-001', date: '2026-05-10' },
|
||||
})
|
||||
})
|
||||
|
||||
it('createSchedule 应调用 POST /health/doctor-schedules', async () => {
|
||||
mockPost.mockResolvedValue(fakeRes)
|
||||
const req = {
|
||||
doctor_id: 'd-001',
|
||||
schedule_date: '2026-05-10',
|
||||
start_time: '08:00',
|
||||
end_time: '12:00',
|
||||
max_appointments: 10,
|
||||
}
|
||||
await appointmentApi.createSchedule(req)
|
||||
|
||||
expect(mockPost).toHaveBeenCalledWith('/health/doctor-schedules', req)
|
||||
})
|
||||
|
||||
it('updateSchedule 应调用 PUT /health/doctor-schedules/:id', async () => {
|
||||
mockPut.mockResolvedValue(fakeRes)
|
||||
const req = { max_appointments: 15, version: 1 }
|
||||
await appointmentApi.updateSchedule('sch-001', req)
|
||||
|
||||
expect(mockPut).toHaveBeenCalledWith('/health/doctor-schedules/sch-001', req)
|
||||
})
|
||||
|
||||
it('calendar 应调用 GET /health/doctor-schedules/calendar', async () => {
|
||||
mockGet.mockResolvedValue(fakeRes)
|
||||
await appointmentApi.calendar({ start_date: '2026-05-01', end_date: '2026-05-31', doctor_id: 'd-001' })
|
||||
|
||||
expect(mockGet).toHaveBeenCalledWith('/health/doctor-schedules/calendar', {
|
||||
params: { start_date: '2026-05-01', end_date: '2026-05-31', doctor_id: 'd-001' },
|
||||
})
|
||||
})
|
||||
})
|
||||
164
apps/web/src/api/health/appointments.ts
Normal file
164
apps/web/src/api/health/appointments.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
import client from '../client';
|
||||
import type { PaginatedResponse } from '../types';
|
||||
|
||||
// --- Types ---
|
||||
export interface Appointment {
|
||||
id: string;
|
||||
patient_id: string;
|
||||
doctor_id?: string;
|
||||
appointment_type: string;
|
||||
appointment_date: string;
|
||||
start_time: string;
|
||||
end_time: string;
|
||||
status: string;
|
||||
cancel_reason?: string;
|
||||
notes?: string;
|
||||
patient_name?: string;
|
||||
doctor_name?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
version: number;
|
||||
}
|
||||
|
||||
export interface CreateAppointmentReq {
|
||||
patient_id: string;
|
||||
doctor_id?: string;
|
||||
appointment_type?: string;
|
||||
appointment_date: string;
|
||||
start_time: string;
|
||||
end_time: string;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface UpdateAppointmentStatusReq {
|
||||
status: string;
|
||||
cancel_reason?: string;
|
||||
}
|
||||
|
||||
export interface Schedule {
|
||||
id: string;
|
||||
doctor_id: string;
|
||||
schedule_date: string;
|
||||
period_type: string;
|
||||
start_time: string;
|
||||
end_time: string;
|
||||
max_appointments: number;
|
||||
current_appointments: number;
|
||||
status: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
version: number;
|
||||
}
|
||||
|
||||
export interface CreateScheduleReq {
|
||||
doctor_id: string;
|
||||
schedule_date: string;
|
||||
period_type?: string;
|
||||
start_time: string;
|
||||
end_time: string;
|
||||
max_appointments: number;
|
||||
}
|
||||
|
||||
export interface UpdateScheduleReq {
|
||||
start_time?: string;
|
||||
end_time?: string;
|
||||
max_appointments?: number;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
export interface CalendarDay {
|
||||
date: string;
|
||||
schedules: Schedule[];
|
||||
}
|
||||
|
||||
// --- API ---
|
||||
export const appointmentApi = {
|
||||
list: async (params: {
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
status?: string;
|
||||
patient_id?: string;
|
||||
doctor_id?: string;
|
||||
date?: string;
|
||||
search?: string;
|
||||
appointment_type?: string;
|
||||
}) => {
|
||||
const { data } = await client.get<{
|
||||
success: boolean;
|
||||
data: PaginatedResponse<Appointment>;
|
||||
}>('/health/appointments', { params });
|
||||
return data.data;
|
||||
},
|
||||
|
||||
get: async (id: string) => {
|
||||
const { data } = await client.get<{
|
||||
success: boolean;
|
||||
data: Appointment;
|
||||
}>(`/health/appointments/${id}`);
|
||||
return data.data;
|
||||
},
|
||||
|
||||
create: async (req: CreateAppointmentReq) => {
|
||||
const { data } = await client.post<{
|
||||
success: boolean;
|
||||
data: Appointment;
|
||||
}>('/health/appointments', req);
|
||||
return data.data;
|
||||
},
|
||||
|
||||
updateStatus: async (
|
||||
id: string,
|
||||
req: UpdateAppointmentStatusReq & { version: number },
|
||||
) => {
|
||||
const { data } = await client.put<{
|
||||
success: boolean;
|
||||
data: Appointment;
|
||||
}>(`/health/appointments/${id}/status`, req);
|
||||
return data.data;
|
||||
},
|
||||
|
||||
// Schedules
|
||||
listSchedules: async (params: {
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
doctor_id?: string;
|
||||
date?: string;
|
||||
}) => {
|
||||
const { data } = await client.get<{
|
||||
success: boolean;
|
||||
data: PaginatedResponse<Schedule>;
|
||||
}>('/health/doctor-schedules', { params });
|
||||
return data.data;
|
||||
},
|
||||
|
||||
createSchedule: async (req: CreateScheduleReq) => {
|
||||
const { data } = await client.post<{
|
||||
success: boolean;
|
||||
data: Schedule;
|
||||
}>('/health/doctor-schedules', req);
|
||||
return data.data;
|
||||
},
|
||||
|
||||
updateSchedule: async (
|
||||
id: string,
|
||||
req: UpdateScheduleReq & { version: number },
|
||||
) => {
|
||||
const { data } = await client.put<{
|
||||
success: boolean;
|
||||
data: Schedule;
|
||||
}>(`/health/doctor-schedules/${id}`, req);
|
||||
return data.data;
|
||||
},
|
||||
|
||||
calendar: async (params: {
|
||||
start_date: string;
|
||||
end_date: string;
|
||||
doctor_id?: string;
|
||||
}) => {
|
||||
const { data } = await client.get<{
|
||||
success: boolean;
|
||||
data: CalendarDay[];
|
||||
}>('/health/doctor-schedules/calendar', { params });
|
||||
return data.data;
|
||||
},
|
||||
};
|
||||
173
apps/web/src/api/health/articles.test.ts
Normal file
173
apps/web/src/api/health/articles.test.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* articles API 契约测试
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
const mockGet = vi.fn()
|
||||
const mockPost = vi.fn()
|
||||
const mockPut = vi.fn()
|
||||
const mockDelete = vi.fn()
|
||||
|
||||
vi.mock('../client', () => ({
|
||||
default: {
|
||||
get: (...args: unknown[]) => mockGet(...args),
|
||||
post: (...args: unknown[]) => mockPost(...args),
|
||||
put: (...args: unknown[]) => mockPut(...args),
|
||||
delete: (...args: unknown[]) => mockDelete(...args),
|
||||
},
|
||||
}))
|
||||
|
||||
import { articleApi, articleCategoryApi, articleTagApi } from './articles'
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('articleApi', () => {
|
||||
const fakeRes = { data: { success: true, data: {} } }
|
||||
|
||||
it('list 应调用 GET /health/articles 并传递查询参数', async () => {
|
||||
mockGet.mockResolvedValue(fakeRes)
|
||||
await articleApi.list({ page: 1, page_size: 10, status: 'published', category_id: 'cat-001' })
|
||||
|
||||
expect(mockGet).toHaveBeenCalledWith('/health/articles', {
|
||||
params: { page: 1, page_size: 10, status: 'published', category_id: 'cat-001' },
|
||||
})
|
||||
})
|
||||
|
||||
it('get 应调用 GET /health/articles/:id', async () => {
|
||||
mockGet.mockResolvedValue(fakeRes)
|
||||
await articleApi.get('art-001')
|
||||
|
||||
expect(mockGet).toHaveBeenCalledWith('/health/articles/art-001')
|
||||
})
|
||||
|
||||
it('create 应调用 POST /health/articles', async () => {
|
||||
mockPost.mockResolvedValue(fakeRes)
|
||||
const req = { title: '健康饮食指南', content: '正文内容', content_type: 'markdown' as const }
|
||||
await articleApi.create(req)
|
||||
|
||||
expect(mockPost).toHaveBeenCalledWith('/health/articles', req)
|
||||
})
|
||||
|
||||
it('update 应调用 PUT /health/articles/:id 并传递请求体', async () => {
|
||||
mockPut.mockResolvedValue(fakeRes)
|
||||
const req = { title: '健康饮食指南(修订)', version: 1 }
|
||||
await articleApi.update('art-001', req)
|
||||
|
||||
expect(mockPut).toHaveBeenCalledWith('/health/articles/art-001', req)
|
||||
})
|
||||
|
||||
it('delete 应调用 DELETE /health/articles/:id', async () => {
|
||||
mockDelete.mockResolvedValue({ data: { success: true, data: null } })
|
||||
await articleApi.delete('art-001', 1)
|
||||
|
||||
expect(mockDelete).toHaveBeenCalledWith('/health/articles/art-001')
|
||||
})
|
||||
|
||||
it('submit 应调用 POST /health/articles/:id/submit 并传递 version', async () => {
|
||||
mockPost.mockResolvedValue(fakeRes)
|
||||
await articleApi.submit('art-001', 2)
|
||||
|
||||
expect(mockPost).toHaveBeenCalledWith('/health/articles/art-001/submit', { version: 2 })
|
||||
})
|
||||
|
||||
it('approve 应调用 POST /health/articles/:id/approve', async () => {
|
||||
mockPost.mockResolvedValue(fakeRes)
|
||||
await articleApi.approve('art-001', 2)
|
||||
|
||||
expect(mockPost).toHaveBeenCalledWith('/health/articles/art-001/approve', { version: 2 })
|
||||
})
|
||||
|
||||
it('reject 应调用 POST /health/articles/:id/reject 并传递 review_note', async () => {
|
||||
mockPost.mockResolvedValue(fakeRes)
|
||||
await articleApi.reject('art-001', 2, '内容需要修改')
|
||||
|
||||
expect(mockPost).toHaveBeenCalledWith('/health/articles/art-001/reject', {
|
||||
version: 2,
|
||||
review_note: '内容需要修改',
|
||||
})
|
||||
})
|
||||
|
||||
it('unpublish 应调用 POST /health/articles/:id/unpublish', async () => {
|
||||
mockPost.mockResolvedValue(fakeRes)
|
||||
await articleApi.unpublish('art-001', 3)
|
||||
|
||||
expect(mockPost).toHaveBeenCalledWith('/health/articles/art-001/unpublish', { version: 3 })
|
||||
})
|
||||
|
||||
it('view 应调用 POST /health/articles/:id/view', async () => {
|
||||
mockPost.mockResolvedValue(fakeRes)
|
||||
await articleApi.view('art-001')
|
||||
|
||||
expect(mockPost).toHaveBeenCalledWith('/health/articles/art-001/view')
|
||||
})
|
||||
})
|
||||
|
||||
describe('articleCategoryApi', () => {
|
||||
const fakeRes = { data: { success: true, data: {} } }
|
||||
|
||||
it('list 应调用 GET /health/article-categories', async () => {
|
||||
mockGet.mockResolvedValue(fakeRes)
|
||||
await articleCategoryApi.list()
|
||||
|
||||
expect(mockGet).toHaveBeenCalledWith('/health/article-categories')
|
||||
})
|
||||
|
||||
it('create 应调用 POST /health/article-categories', async () => {
|
||||
mockPost.mockResolvedValue(fakeRes)
|
||||
const req = { name: '营养健康', sort_order: 1 }
|
||||
await articleCategoryApi.create(req)
|
||||
|
||||
expect(mockPost).toHaveBeenCalledWith('/health/article-categories', req)
|
||||
})
|
||||
|
||||
it('update 应调用 PUT /health/article-categories/:id', async () => {
|
||||
mockPut.mockResolvedValue(fakeRes)
|
||||
const req = { name: '营养健康(更新)' }
|
||||
await articleCategoryApi.update('cat-001', req)
|
||||
|
||||
expect(mockPut).toHaveBeenCalledWith('/health/article-categories/cat-001', req)
|
||||
})
|
||||
|
||||
it('delete 应调用 DELETE /health/article-categories/:id', async () => {
|
||||
mockDelete.mockResolvedValue({ data: { success: true, data: null } })
|
||||
await articleCategoryApi.delete('cat-001')
|
||||
|
||||
expect(mockDelete).toHaveBeenCalledWith('/health/article-categories/cat-001')
|
||||
})
|
||||
})
|
||||
|
||||
describe('articleTagApi', () => {
|
||||
const fakeRes = { data: { success: true, data: {} } }
|
||||
|
||||
it('list 应调用 GET /health/article-tags', async () => {
|
||||
mockGet.mockResolvedValue(fakeRes)
|
||||
await articleTagApi.list()
|
||||
|
||||
expect(mockGet).toHaveBeenCalledWith('/health/article-tags')
|
||||
})
|
||||
|
||||
it('create 应调用 POST /health/article-tags', async () => {
|
||||
mockPost.mockResolvedValue(fakeRes)
|
||||
const req = { name: '高血压', color: '#ff0000' }
|
||||
await articleTagApi.create(req)
|
||||
|
||||
expect(mockPost).toHaveBeenCalledWith('/health/article-tags', req)
|
||||
})
|
||||
|
||||
it('update 应调用 PUT /health/article-tags/:id', async () => {
|
||||
mockPut.mockResolvedValue(fakeRes)
|
||||
const req = { name: '高血压管理', version: 1 }
|
||||
await articleTagApi.update('tag-001', req)
|
||||
|
||||
expect(mockPut).toHaveBeenCalledWith('/health/article-tags/tag-001', req)
|
||||
})
|
||||
|
||||
it('delete 应调用 DELETE /health/article-tags/:id 并在 body 传递 version', async () => {
|
||||
mockDelete.mockResolvedValue({ data: { success: true, data: null } })
|
||||
await articleTagApi.delete('tag-001', 2)
|
||||
|
||||
expect(mockDelete).toHaveBeenCalledWith('/health/article-tags/tag-001', { data: { version: 2 } })
|
||||
})
|
||||
})
|
||||
283
apps/web/src/api/health/articles.ts
Normal file
283
apps/web/src/api/health/articles.ts
Normal file
@@ -0,0 +1,283 @@
|
||||
import client from '../client';
|
||||
import type { PaginatedResponse } from '../types';
|
||||
|
||||
// --- Article Types ---
|
||||
|
||||
export type ArticleStatus = 'draft' | 'pending_review' | 'published' | 'rejected';
|
||||
export type ArticleContentType = 'rich_text' | 'markdown';
|
||||
|
||||
export interface ArticleListItem {
|
||||
id: string;
|
||||
title: string;
|
||||
summary?: string;
|
||||
cover_image?: string;
|
||||
content_type: ArticleContentType;
|
||||
status: ArticleStatus;
|
||||
slug?: string;
|
||||
category_id?: string;
|
||||
category_name?: string;
|
||||
tags?: ArticleTagItem[];
|
||||
author?: string;
|
||||
reviewed_by?: string;
|
||||
reviewed_at?: string;
|
||||
review_note?: string;
|
||||
view_count: number;
|
||||
sort_order: number;
|
||||
is_public: boolean;
|
||||
published_at?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
version: number;
|
||||
}
|
||||
|
||||
export interface Article extends ArticleListItem {
|
||||
content?: string;
|
||||
}
|
||||
|
||||
export interface CreateArticleReq {
|
||||
title: string;
|
||||
summary?: string;
|
||||
content?: string;
|
||||
content_type?: ArticleContentType;
|
||||
cover_image?: string;
|
||||
slug?: string;
|
||||
category_id?: string;
|
||||
tag_ids?: string[];
|
||||
sort_order?: number;
|
||||
is_public?: boolean;
|
||||
}
|
||||
|
||||
export interface UpdateArticleReq {
|
||||
title?: string;
|
||||
summary?: string;
|
||||
content?: string;
|
||||
content_type?: ArticleContentType;
|
||||
cover_image?: string;
|
||||
slug?: string;
|
||||
category_id?: string;
|
||||
tag_ids?: string[];
|
||||
sort_order?: number;
|
||||
is_public?: boolean;
|
||||
version: number;
|
||||
}
|
||||
|
||||
export interface ArticleListParams {
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
status?: ArticleStatus;
|
||||
category_id?: string;
|
||||
tag_id?: string;
|
||||
keyword?: string;
|
||||
}
|
||||
|
||||
// --- Category Types ---
|
||||
|
||||
export interface ArticleCategory {
|
||||
id: string;
|
||||
name: string;
|
||||
slug?: string;
|
||||
parent_id?: string;
|
||||
parent_name?: string;
|
||||
sort_order: number;
|
||||
description?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
version: number;
|
||||
}
|
||||
|
||||
export interface CreateCategoryReq {
|
||||
name: string;
|
||||
slug?: string;
|
||||
parent_id?: string;
|
||||
sort_order?: number;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface UpdateCategoryReq {
|
||||
name?: string;
|
||||
slug?: string;
|
||||
parent_id?: string;
|
||||
sort_order?: number;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
// --- Tag Types ---
|
||||
|
||||
export interface ArticleTagItem {
|
||||
id: string;
|
||||
name: string;
|
||||
slug?: string;
|
||||
color?: string;
|
||||
created_at: string;
|
||||
version?: number;
|
||||
}
|
||||
|
||||
export interface CreateTagReq {
|
||||
name: string;
|
||||
slug?: string;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
// --- Article API ---
|
||||
|
||||
export const articleApi = {
|
||||
list: async (params: ArticleListParams) => {
|
||||
const { data } = await client.get<{
|
||||
success: boolean;
|
||||
data: PaginatedResponse<ArticleListItem>;
|
||||
}>('/health/articles', { params });
|
||||
return data.data;
|
||||
},
|
||||
|
||||
get: async (id: string) => {
|
||||
const { data } = await client.get<{
|
||||
success: boolean;
|
||||
data: Article;
|
||||
}>(`/health/articles/${id}`);
|
||||
return data.data;
|
||||
},
|
||||
|
||||
create: async (req: CreateArticleReq) => {
|
||||
const { data } = await client.post<{
|
||||
success: boolean;
|
||||
data: Article;
|
||||
}>('/health/articles', req);
|
||||
return data.data;
|
||||
},
|
||||
|
||||
update: async (id: string, req: UpdateArticleReq) => {
|
||||
const { data } = await client.put<{
|
||||
success: boolean;
|
||||
data: Article;
|
||||
}>(`/health/articles/${id}`, req);
|
||||
return data.data;
|
||||
},
|
||||
|
||||
delete: async (id: string, version: number) => {
|
||||
const { data } = await client.delete<{
|
||||
success: boolean;
|
||||
data: null;
|
||||
}>(`/health/articles/${id}`, { data: { version } });
|
||||
return data.data;
|
||||
},
|
||||
|
||||
submit: async (id: string, version: number) => {
|
||||
const { data } = await client.post<{
|
||||
success: boolean;
|
||||
data: Article;
|
||||
}>(`/health/articles/${id}/submit`, { version });
|
||||
return data.data;
|
||||
},
|
||||
|
||||
approve: async (id: string, version: number) => {
|
||||
const { data } = await client.post<{
|
||||
success: boolean;
|
||||
data: Article;
|
||||
}>(`/health/articles/${id}/approve`, { version });
|
||||
return data.data;
|
||||
},
|
||||
|
||||
reject: async (id: string, version: number, review_note: string) => {
|
||||
const { data } = await client.post<{
|
||||
success: boolean;
|
||||
data: Article;
|
||||
}>(`/health/articles/${id}/reject`, { version, review_note });
|
||||
return data.data;
|
||||
},
|
||||
|
||||
unpublish: async (id: string, version: number) => {
|
||||
const { data } = await client.post<{
|
||||
success: boolean;
|
||||
data: Article;
|
||||
}>(`/health/articles/${id}/unpublish`, { version });
|
||||
return data.data;
|
||||
},
|
||||
|
||||
view: async (id: string) => {
|
||||
const { data } = await client.post<{
|
||||
success: boolean;
|
||||
data: Article;
|
||||
}>(`/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 ---
|
||||
|
||||
export const articleCategoryApi = {
|
||||
list: async () => {
|
||||
const { data } = await client.get<{
|
||||
success: boolean;
|
||||
data: ArticleCategory[];
|
||||
}>('/health/article-categories');
|
||||
return data.data;
|
||||
},
|
||||
|
||||
create: async (req: CreateCategoryReq) => {
|
||||
const { data } = await client.post<{
|
||||
success: boolean;
|
||||
data: ArticleCategory;
|
||||
}>('/health/article-categories', req);
|
||||
return data.data;
|
||||
},
|
||||
|
||||
update: async (id: string, req: UpdateCategoryReq) => {
|
||||
const { data } = await client.put<{
|
||||
success: boolean;
|
||||
data: ArticleCategory;
|
||||
}>(`/health/article-categories/${id}`, req);
|
||||
return data.data;
|
||||
},
|
||||
|
||||
delete: async (id: string, version: number) => {
|
||||
const { data } = await client.delete<{
|
||||
success: boolean;
|
||||
data: null;
|
||||
}>(`/health/article-categories/${id}`, { data: { version } });
|
||||
return data.data;
|
||||
},
|
||||
};
|
||||
|
||||
// --- Tag API ---
|
||||
|
||||
export const articleTagApi = {
|
||||
list: async () => {
|
||||
const { data } = await client.get<{
|
||||
success: boolean;
|
||||
data: ArticleTagItem[];
|
||||
}>('/health/article-tags');
|
||||
return data.data;
|
||||
},
|
||||
|
||||
create: async (req: CreateTagReq) => {
|
||||
const { data } = await client.post<{
|
||||
success: boolean;
|
||||
data: ArticleTagItem;
|
||||
}>('/health/article-tags', req);
|
||||
return data.data;
|
||||
},
|
||||
|
||||
update: async (id: string, req: { name: string; version: number }) => {
|
||||
const { data } = await client.put<{
|
||||
success: boolean;
|
||||
data: ArticleTagItem;
|
||||
}>(`/health/article-tags/${id}`, req);
|
||||
return data.data;
|
||||
},
|
||||
|
||||
delete: async (id: string, version: number) => {
|
||||
const { data } = await client.delete<{
|
||||
success: boolean;
|
||||
data: null;
|
||||
}>(`/health/article-tags/${id}`, { data: { version } });
|
||||
return data.data;
|
||||
},
|
||||
};
|
||||
116
apps/web/src/api/health/banners.ts
Normal file
116
apps/web/src/api/health/banners.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import client from '../client';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 轮播图类型
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface BannerItem {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
media_item_id: string;
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
link_type?: string;
|
||||
link_target?: string;
|
||||
sort_order: number;
|
||||
status: string;
|
||||
start_time?: string;
|
||||
end_time?: string;
|
||||
image_url?: string;
|
||||
thumbnail_url?: string;
|
||||
media_deleted: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
created_by?: string;
|
||||
updated_by?: string;
|
||||
version: number;
|
||||
}
|
||||
|
||||
export interface CreateBannerReq {
|
||||
media_item_id: string;
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
link_type?: string;
|
||||
link_target?: string;
|
||||
sort_order?: number;
|
||||
status?: string;
|
||||
start_time?: string;
|
||||
end_time?: string;
|
||||
}
|
||||
|
||||
export interface UpdateBannerReq {
|
||||
media_item_id?: string;
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
link_type?: string;
|
||||
link_target?: string;
|
||||
sort_order?: number;
|
||||
status?: string;
|
||||
start_time?: string;
|
||||
end_time?: string;
|
||||
version: number;
|
||||
}
|
||||
|
||||
export interface SortBannerReq {
|
||||
items: Array<{ id: string; sort_order: number }>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 轮播图 API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const bannerApi = {
|
||||
/** 获取轮播图列表(可按状态筛选) */
|
||||
list: async (status?: string) => {
|
||||
const { data } = await client.get<{
|
||||
success: boolean;
|
||||
data: BannerItem[];
|
||||
}>('/health/banners', { params: status ? { status } : undefined });
|
||||
return data.data;
|
||||
},
|
||||
|
||||
/** 获取单个轮播图 */
|
||||
get: async (id: string) => {
|
||||
const { data } = await client.get<{
|
||||
success: boolean;
|
||||
data: BannerItem;
|
||||
}>(`/health/banners/${id}`);
|
||||
return data.data;
|
||||
},
|
||||
|
||||
/** 创建轮播图 */
|
||||
create: async (req: CreateBannerReq) => {
|
||||
const { data } = await client.post<{
|
||||
success: boolean;
|
||||
data: BannerItem;
|
||||
}>('/health/banners', req);
|
||||
return data.data;
|
||||
},
|
||||
|
||||
/** 更新轮播图 */
|
||||
update: async (id: string, req: UpdateBannerReq) => {
|
||||
const { data } = await client.put<{
|
||||
success: boolean;
|
||||
data: BannerItem;
|
||||
}>(`/health/banners/${id}`, req);
|
||||
return data.data;
|
||||
},
|
||||
|
||||
/** 删除轮播图 */
|
||||
delete: async (id: string, version: number) => {
|
||||
const { data } = await client.delete<{
|
||||
success: boolean;
|
||||
data: null;
|
||||
}>(`/health/banners/${id}`, { data: { version } });
|
||||
return data.data;
|
||||
},
|
||||
|
||||
/** 轮播图排序 */
|
||||
sort: async (req: SortBannerReq) => {
|
||||
const { data } = await client.put<{
|
||||
success: boolean;
|
||||
data: null;
|
||||
}>('/health/banners/sort', req);
|
||||
return data.data;
|
||||
},
|
||||
};
|
||||
170
apps/web/src/api/health/bleGateways.ts
Normal file
170
apps/web/src/api/health/bleGateways.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
import client from '../client';
|
||||
import type { PaginatedResponse } from '../types';
|
||||
|
||||
// --- Types ---
|
||||
|
||||
export interface BleGateway {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
gateway_id: string;
|
||||
name: string;
|
||||
status: string;
|
||||
firmware_version?: string;
|
||||
ip_address?: string;
|
||||
last_heartbeat_at?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
version: number;
|
||||
api_key?: string;
|
||||
patient_count?: number;
|
||||
}
|
||||
|
||||
export interface GatewayBinding {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
gateway_id: string;
|
||||
patient_id: string;
|
||||
peripheral_mac?: string;
|
||||
device_type?: string;
|
||||
status: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
version: number;
|
||||
}
|
||||
|
||||
export interface CreateBleGatewayReq {
|
||||
gateway_id: string;
|
||||
name: string;
|
||||
firmware_version?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface UpdateBleGatewayReq {
|
||||
name?: string;
|
||||
status?: string;
|
||||
firmware_version?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ListBleGatewaysParams {
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
export interface CreateBindingReq {
|
||||
patient_id: string;
|
||||
peripheral_mac?: string;
|
||||
device_type?: string;
|
||||
}
|
||||
|
||||
export interface BatchBindReq {
|
||||
bindings: CreateBindingReq[];
|
||||
}
|
||||
|
||||
// --- Constants ---
|
||||
|
||||
export const GATEWAY_STATUS_OPTIONS = [
|
||||
{ label: '在线', value: 'online' },
|
||||
{ label: '离线', value: 'offline' },
|
||||
{ label: '未激活', value: 'inactive' },
|
||||
{ label: '已禁用', value: 'disabled' },
|
||||
];
|
||||
|
||||
export const GATEWAY_STATUS_COLOR: Record<string, string> = {
|
||||
online: 'green',
|
||||
offline: 'red',
|
||||
inactive: 'default',
|
||||
disabled: 'error',
|
||||
};
|
||||
|
||||
export const GATEWAY_STATUS_LABEL: Record<string, string> = Object.fromEntries(
|
||||
GATEWAY_STATUS_OPTIONS.map((o) => [o.value, o.label]),
|
||||
);
|
||||
|
||||
export const BINDING_STATUS_COLOR: Record<string, string> = {
|
||||
active: 'green',
|
||||
inactive: 'default',
|
||||
unbound: 'error',
|
||||
};
|
||||
|
||||
// --- API ---
|
||||
|
||||
export const bleGatewayApi = {
|
||||
// --- Gateways ---
|
||||
|
||||
list: async (params?: ListBleGatewaysParams) => {
|
||||
const { data } = await client.get<{
|
||||
success: boolean;
|
||||
data: PaginatedResponse<BleGateway>;
|
||||
}>('/health/ble-gateways', { params });
|
||||
return data.data;
|
||||
},
|
||||
|
||||
get: async (gatewayId: string) => {
|
||||
const { data } = await client.get<{
|
||||
success: boolean;
|
||||
data: BleGateway;
|
||||
}>(`/health/ble-gateways/${gatewayId}`);
|
||||
return data.data;
|
||||
},
|
||||
|
||||
create: async (req: CreateBleGatewayReq) => {
|
||||
const { data } = await client.post<{
|
||||
success: boolean;
|
||||
data: BleGateway;
|
||||
}>('/health/ble-gateways', req);
|
||||
return data.data;
|
||||
},
|
||||
|
||||
update: async (gatewayId: string, req: UpdateBleGatewayReq & { version: number }) => {
|
||||
const { data } = await client.put<{
|
||||
success: boolean;
|
||||
data: BleGateway;
|
||||
}>(`/health/ble-gateways/${gatewayId}`, req);
|
||||
return data.data;
|
||||
},
|
||||
|
||||
delete: async (gatewayId: string, version: number) => {
|
||||
await client.delete(`/health/ble-gateways/${gatewayId}`, { data: { version } });
|
||||
},
|
||||
|
||||
regenerateKey: async (gatewayId: string) => {
|
||||
const { data } = await client.post<{
|
||||
success: boolean;
|
||||
data: BleGateway;
|
||||
}>(`/health/ble-gateways/${gatewayId}/regenerate-key`);
|
||||
return data.data;
|
||||
},
|
||||
|
||||
// --- Bindings ---
|
||||
|
||||
listBindings: async (gatewayId: string, params?: { page?: number; page_size?: number }) => {
|
||||
const { data } = await client.get<{
|
||||
success: boolean;
|
||||
data: PaginatedResponse<GatewayBinding>;
|
||||
}>(`/health/ble-gateways/${gatewayId}/bindings`, { params });
|
||||
return data.data;
|
||||
},
|
||||
|
||||
bindPatient: async (gatewayId: string, req: CreateBindingReq) => {
|
||||
const { data } = await client.post<{
|
||||
success: boolean;
|
||||
data: GatewayBinding;
|
||||
}>(`/health/ble-gateways/${gatewayId}/bindings`, req);
|
||||
return data.data;
|
||||
},
|
||||
|
||||
batchBind: async (gatewayId: string, req: BatchBindReq) => {
|
||||
const { data } = await client.post<{
|
||||
success: boolean;
|
||||
data: GatewayBinding[];
|
||||
}>(`/health/ble-gateways/${gatewayId}/bindings/batch`, req);
|
||||
return data.data;
|
||||
},
|
||||
|
||||
unbindPatient: async (gatewayId: string, bindingId: string, version: number) => {
|
||||
await client.delete(`/health/ble-gateways/${gatewayId}/bindings/${bindingId}`, { data: { version } });
|
||||
},
|
||||
};
|
||||
245
apps/web/src/api/health/carePlans.ts
Normal file
245
apps/web/src/api/health/carePlans.ts
Normal file
@@ -0,0 +1,245 @@
|
||||
import client from '../client';
|
||||
import type { PaginatedResponse } from '../types';
|
||||
|
||||
// --- Types ---
|
||||
|
||||
export interface CarePlan {
|
||||
id: string;
|
||||
patient_id: string;
|
||||
plan_type: string;
|
||||
status: string;
|
||||
title: string;
|
||||
goals?: Record<string, unknown>;
|
||||
start_date?: string;
|
||||
end_date?: string;
|
||||
notes?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
version: number;
|
||||
}
|
||||
|
||||
export interface CarePlanItem {
|
||||
id: string;
|
||||
plan_id: string;
|
||||
item_type: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
status: string;
|
||||
schedule?: string;
|
||||
sort_order?: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
version: number;
|
||||
}
|
||||
|
||||
export interface CarePlanOutcome {
|
||||
id: string;
|
||||
plan_id: string;
|
||||
item_id?: string;
|
||||
metric: string;
|
||||
baseline_value: string;
|
||||
target_value: string;
|
||||
current_value?: string;
|
||||
measured_at?: string;
|
||||
notes?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
version: number;
|
||||
}
|
||||
|
||||
export interface CreateCarePlanReq {
|
||||
patient_id: string;
|
||||
plan_type: string;
|
||||
title: string;
|
||||
goals?: Record<string, unknown>;
|
||||
start_date?: string;
|
||||
end_date?: string;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface UpdateCarePlanReq {
|
||||
plan_type?: string;
|
||||
title?: string;
|
||||
status?: string;
|
||||
goals?: Record<string, unknown>;
|
||||
start_date?: string;
|
||||
end_date?: string;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface CreateCarePlanItemReq {
|
||||
item_type: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
schedule?: string;
|
||||
sort_order?: number;
|
||||
}
|
||||
|
||||
export interface UpdateCarePlanItemReq {
|
||||
item_type?: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
status?: string;
|
||||
schedule?: string;
|
||||
sort_order?: number;
|
||||
}
|
||||
|
||||
export interface CreateCarePlanOutcomeReq {
|
||||
item_id?: string;
|
||||
metric: string;
|
||||
baseline_value: string;
|
||||
target_value: string;
|
||||
current_value?: string;
|
||||
measured_at?: string;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface UpdateCarePlanOutcomeReq {
|
||||
item_id?: string;
|
||||
metric?: string;
|
||||
baseline_value?: string;
|
||||
target_value?: string;
|
||||
current_value?: string;
|
||||
measured_at?: string;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface ListCarePlansParams {
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
patient_id?: string;
|
||||
plan_type?: string;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
// --- Constants ---
|
||||
|
||||
export const PLAN_TYPE_OPTIONS = [
|
||||
{ label: '血液透析', value: 'hemodialysis' },
|
||||
{ label: '腹膜透析', value: 'peritoneal' },
|
||||
{ label: '慢性病管理', value: 'chronic_disease' },
|
||||
{ label: '康复计划', value: 'rehabilitation' },
|
||||
];
|
||||
|
||||
export const PLAN_STATUS_OPTIONS = [
|
||||
{ label: '草稿', value: 'draft' },
|
||||
{ label: '进行中', value: 'active' },
|
||||
{ label: '已完成', value: 'completed' },
|
||||
{ label: '已取消', value: 'cancelled' },
|
||||
];
|
||||
|
||||
export const ITEM_TYPE_OPTIONS = [
|
||||
{ label: '药物干预', value: 'medication' },
|
||||
{ label: '饮食管理', value: 'diet' },
|
||||
{ label: '运动计划', value: 'exercise' },
|
||||
{ label: '监测项目', value: 'monitoring' },
|
||||
{ label: '教育指导', value: 'education' },
|
||||
{ label: '其他', value: 'other' },
|
||||
];
|
||||
|
||||
export const PLAN_STATUS_COLOR: Record<string, string> = {
|
||||
draft: 'default',
|
||||
active: 'processing',
|
||||
completed: 'success',
|
||||
cancelled: 'error',
|
||||
};
|
||||
|
||||
// --- API ---
|
||||
|
||||
export const carePlanApi = {
|
||||
list: async (params: ListCarePlansParams) => {
|
||||
const { data } = await client.get<{
|
||||
success: boolean;
|
||||
data: PaginatedResponse<CarePlan>;
|
||||
}>('/health/care-plans', { params });
|
||||
return data.data;
|
||||
},
|
||||
|
||||
get: async (id: string) => {
|
||||
const { data } = await client.get<{
|
||||
success: boolean;
|
||||
data: CarePlan;
|
||||
}>(`/health/care-plans/${id}`);
|
||||
return data.data;
|
||||
},
|
||||
|
||||
create: async (req: CreateCarePlanReq) => {
|
||||
const { data } = await client.post<{
|
||||
success: boolean;
|
||||
data: CarePlan;
|
||||
}>('/health/care-plans', req);
|
||||
return data.data;
|
||||
},
|
||||
|
||||
update: async (id: string, req: UpdateCarePlanReq & { version: number }) => {
|
||||
const { data } = await client.put<{
|
||||
success: boolean;
|
||||
data: CarePlan;
|
||||
}>(`/health/care-plans/${id}`, req);
|
||||
return data.data;
|
||||
},
|
||||
|
||||
delete: async (id: string, version: number) => {
|
||||
await client.delete(`/health/care-plans/${id}`, { data: { version } });
|
||||
},
|
||||
|
||||
// --- Items ---
|
||||
|
||||
listItems: async (planId: string, params?: { page?: number; page_size?: number }) => {
|
||||
const { data } = await client.get<{
|
||||
success: boolean;
|
||||
data: PaginatedResponse<CarePlanItem>;
|
||||
}>(`/health/care-plans/${planId}/items`, { params });
|
||||
return data.data;
|
||||
},
|
||||
|
||||
createItem: async (planId: string, req: CreateCarePlanItemReq) => {
|
||||
const { data } = await client.post<{
|
||||
success: boolean;
|
||||
data: CarePlanItem;
|
||||
}>(`/health/care-plans/${planId}/items`, req);
|
||||
return data.data;
|
||||
},
|
||||
|
||||
updateItem: async (planId: string, itemId: string, req: UpdateCarePlanItemReq & { version: number }) => {
|
||||
const { data } = await client.put<{
|
||||
success: boolean;
|
||||
data: CarePlanItem;
|
||||
}>(`/health/care-plans/${planId}/items/${itemId}`, req);
|
||||
return data.data;
|
||||
},
|
||||
|
||||
deleteItem: async (planId: string, itemId: string, version: number) => {
|
||||
await client.delete(`/health/care-plans/${planId}/items/${itemId}`, { data: { version } });
|
||||
},
|
||||
|
||||
// --- Outcomes ---
|
||||
|
||||
listOutcomes: async (planId: string, params?: { page?: number; page_size?: number }) => {
|
||||
const { data } = await client.get<{
|
||||
success: boolean;
|
||||
data: PaginatedResponse<CarePlanOutcome>;
|
||||
}>(`/health/care-plans/${planId}/outcomes`, { params });
|
||||
return data.data;
|
||||
},
|
||||
|
||||
createOutcome: async (planId: string, req: CreateCarePlanOutcomeReq) => {
|
||||
const { data } = await client.post<{
|
||||
success: boolean;
|
||||
data: CarePlanOutcome;
|
||||
}>(`/health/care-plans/${planId}/outcomes`, req);
|
||||
return data.data;
|
||||
},
|
||||
|
||||
updateOutcome: async (planId: string, outcomeId: string, req: UpdateCarePlanOutcomeReq & { version: number }) => {
|
||||
const { data } = await client.put<{
|
||||
success: boolean;
|
||||
data: CarePlanOutcome;
|
||||
}>(`/health/care-plans/${planId}/outcomes/${outcomeId}`, req);
|
||||
return data.data;
|
||||
},
|
||||
|
||||
deleteOutcome: async (planId: string, outcomeId: string, version: number) => {
|
||||
await client.delete(`/health/care-plans/${planId}/outcomes/${outcomeId}`, { data: { version } });
|
||||
},
|
||||
};
|
||||
92
apps/web/src/api/health/consents.ts
Normal file
92
apps/web/src/api/health/consents.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import client from '../client';
|
||||
import type { PaginatedResponse } from '../types';
|
||||
|
||||
// --- Types ---
|
||||
|
||||
export interface Consent {
|
||||
id: string;
|
||||
patient_id: string;
|
||||
consent_type: string;
|
||||
consent_scope: string;
|
||||
status: string;
|
||||
granted_at?: string;
|
||||
revoked_at?: string;
|
||||
expiry_date?: string;
|
||||
consent_method?: string;
|
||||
witness_name?: string;
|
||||
notes?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
version: number;
|
||||
}
|
||||
|
||||
export interface CreateConsentReq {
|
||||
patient_id: string;
|
||||
consent_type: string;
|
||||
consent_scope: string;
|
||||
expiry_date?: string;
|
||||
consent_method?: string;
|
||||
witness_name?: string;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface RevokeConsentReq {
|
||||
notes?: string;
|
||||
version: number;
|
||||
}
|
||||
|
||||
// --- Constants ---
|
||||
|
||||
export const CONSENT_TYPE_OPTIONS = [
|
||||
{ label: '治疗同意', value: 'treatment' },
|
||||
{ label: '数据共享', value: 'data_sharing' },
|
||||
{ label: '隐私政策', value: 'privacy' },
|
||||
{ label: '研究参与', value: 'research' },
|
||||
];
|
||||
|
||||
export const CONSENT_SCOPE_OPTIONS = [
|
||||
{ label: '全部', value: 'all' },
|
||||
{ label: '健康数据', value: 'health_data' },
|
||||
{ label: '基本信息', value: 'basic_info' },
|
||||
{ label: '体检报告', value: 'examination' },
|
||||
];
|
||||
|
||||
export const CONSENT_STATUS_COLOR: Record<string, string> = {
|
||||
active: 'green',
|
||||
revoked: 'red',
|
||||
expired: 'default',
|
||||
};
|
||||
|
||||
export const CONSENT_STATUS_LABEL: Record<string, string> = {
|
||||
active: '生效中',
|
||||
revoked: '已撤销',
|
||||
expired: '已过期',
|
||||
};
|
||||
|
||||
// --- API ---
|
||||
|
||||
export const consentApi = {
|
||||
list: async (patientId: string, params?: { page?: number; page_size?: number }) => {
|
||||
const { data } = await client.get<{
|
||||
success: boolean;
|
||||
data: PaginatedResponse<Consent>;
|
||||
}>(`/health/patients/${patientId}/consents`, { params });
|
||||
return data.data;
|
||||
},
|
||||
|
||||
grant: async (req: CreateConsentReq) => {
|
||||
const { data } = await client.post<{
|
||||
success: boolean;
|
||||
data: Consent;
|
||||
}>('/health/consents', req);
|
||||
return data.data;
|
||||
},
|
||||
|
||||
revoke: async (consentId: string, req: RevokeConsentReq) => {
|
||||
const { data } = await client.put<{
|
||||
success: boolean;
|
||||
data: Consent;
|
||||
}>(`/health/consents/${consentId}/revoke`, req);
|
||||
return data.data;
|
||||
},
|
||||
};
|
||||
98
apps/web/src/api/health/consultations.test.ts
Normal file
98
apps/web/src/api/health/consultations.test.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* consultations API 契约测试
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
const mockGet = vi.fn()
|
||||
const mockPost = vi.fn()
|
||||
const mockPut = vi.fn()
|
||||
const mockDelete = vi.fn()
|
||||
|
||||
vi.mock('../client', () => ({
|
||||
default: {
|
||||
get: (...args: unknown[]) => mockGet(...args),
|
||||
post: (...args: unknown[]) => mockPost(...args),
|
||||
put: (...args: unknown[]) => mockPut(...args),
|
||||
delete: (...args: unknown[]) => mockDelete(...args),
|
||||
},
|
||||
}))
|
||||
|
||||
import { consultationApi } from './consultations'
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('consultationApi', () => {
|
||||
const fakeRes = { data: { success: true, data: {} } }
|
||||
|
||||
it('listSessions 应调用 GET /health/consultation-sessions 并传递查询参数', async () => {
|
||||
mockGet.mockResolvedValue(fakeRes)
|
||||
await consultationApi.listSessions({ page: 1, page_size: 20, status: 'active', patient_id: 'p-001' })
|
||||
|
||||
expect(mockGet).toHaveBeenCalledWith('/health/consultation-sessions', {
|
||||
params: { page: 1, page_size: 20, status: 'active', patient_id: 'p-001' },
|
||||
})
|
||||
})
|
||||
|
||||
it('createSession 应调用 POST /health/consultation-sessions', async () => {
|
||||
mockPost.mockResolvedValue(fakeRes)
|
||||
const req = { patient_id: 'p-001', doctor_id: 'd-001', consultation_type: 'online' }
|
||||
await consultationApi.createSession(req)
|
||||
|
||||
expect(mockPost).toHaveBeenCalledWith('/health/consultation-sessions', req)
|
||||
})
|
||||
|
||||
it('getSession 应调用 GET /health/consultation-sessions/:id', async () => {
|
||||
mockGet.mockResolvedValue(fakeRes)
|
||||
await consultationApi.getSession('sess-001')
|
||||
|
||||
expect(mockGet).toHaveBeenCalledWith('/health/consultation-sessions/sess-001')
|
||||
})
|
||||
|
||||
it('closeSession 应调用 PUT /health/consultation-sessions/:id/close', async () => {
|
||||
mockPut.mockResolvedValue(fakeRes)
|
||||
await consultationApi.closeSession('sess-001', { version: 1 })
|
||||
|
||||
expect(mockPut).toHaveBeenCalledWith('/health/consultation-sessions/sess-001/close', { version: 1 })
|
||||
})
|
||||
|
||||
it('listMessages 应调用 GET /health/consultation-sessions/:id/messages 并传递分页参数', async () => {
|
||||
mockGet.mockResolvedValue(fakeRes)
|
||||
await consultationApi.listMessages('sess-001', { page: 2, page_size: 50, after_id: 'msg-100' })
|
||||
|
||||
expect(mockGet).toHaveBeenCalledWith('/health/consultation-sessions/sess-001/messages', {
|
||||
params: { page: 2, page_size: 50, after_id: 'msg-100' },
|
||||
})
|
||||
})
|
||||
|
||||
it('createMessage 应调用 POST /health/consultation-messages', async () => {
|
||||
mockPost.mockResolvedValue(fakeRes)
|
||||
const req = { session_id: 'sess-001', content_type: 'text', content: '你好' }
|
||||
await consultationApi.createMessage(req)
|
||||
|
||||
expect(mockPost).toHaveBeenCalledWith('/health/consultation-messages', req)
|
||||
})
|
||||
|
||||
it('createFollowUpFromSession 应调用 POST /health/consultation-sessions/:id/follow-up', async () => {
|
||||
mockPost.mockResolvedValue(fakeRes)
|
||||
const req = { follow_up_type: 'phone', planned_date: '2026-06-01' }
|
||||
await consultationApi.createFollowUpFromSession('sess-001', req)
|
||||
|
||||
expect(mockPost).toHaveBeenCalledWith('/health/consultation-sessions/sess-001/follow-up', req)
|
||||
})
|
||||
|
||||
it('triggerAiAnalysisFromSession 应调用 POST /health/consultation-sessions/:id/ai-analysis', async () => {
|
||||
mockPost.mockResolvedValue(fakeRes)
|
||||
await consultationApi.triggerAiAnalysisFromSession('sess-001')
|
||||
|
||||
expect(mockPost).toHaveBeenCalledWith('/health/consultation-sessions/sess-001/ai-analysis', {})
|
||||
})
|
||||
|
||||
it('triggerAiAnalysisFromSession 传入 analysis_type 时应携带参数', async () => {
|
||||
mockPost.mockResolvedValue(fakeRes)
|
||||
await consultationApi.triggerAiAnalysisFromSession('sess-001', { analysis_type: 'trend' })
|
||||
|
||||
expect(mockPost).toHaveBeenCalledWith('/health/consultation-sessions/sess-001/ai-analysis', { analysis_type: 'trend' })
|
||||
})
|
||||
})
|
||||
185
apps/web/src/api/health/consultations.ts
Normal file
185
apps/web/src/api/health/consultations.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
import client from '../client';
|
||||
import type { PaginatedResponse } from '../types';
|
||||
|
||||
// --- Types ---
|
||||
export interface Session {
|
||||
id: string;
|
||||
patient_id: string;
|
||||
doctor_id?: string;
|
||||
patient_name?: string;
|
||||
doctor_name?: string;
|
||||
consultation_type: string;
|
||||
status: string;
|
||||
last_message_at?: string;
|
||||
unread_count_patient: number;
|
||||
unread_count_doctor: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
version: number;
|
||||
}
|
||||
|
||||
export interface CreateSessionReq {
|
||||
patient_id: string;
|
||||
doctor_id?: string;
|
||||
consultation_type?: string;
|
||||
}
|
||||
|
||||
export interface Message {
|
||||
id: string;
|
||||
session_id: string;
|
||||
sender_id: string;
|
||||
sender_role: string;
|
||||
content_type: string;
|
||||
content: string;
|
||||
is_read: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface CreateMessageReq {
|
||||
session_id: string;
|
||||
content_type?: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
// --- 咨询联动请求类型 ---
|
||||
export interface CreateFollowUpFromConsultationReq {
|
||||
follow_up_type: string;
|
||||
planned_date: string;
|
||||
assigned_to?: string;
|
||||
content_template?: string;
|
||||
}
|
||||
|
||||
export interface FollowUpFromConsultationResp {
|
||||
task_id: string;
|
||||
session_id: string;
|
||||
patient_id: string;
|
||||
}
|
||||
|
||||
export interface TriggerAiAnalysisReq {
|
||||
analysis_type?: string;
|
||||
}
|
||||
|
||||
export interface AiAnalysisTriggeredResp {
|
||||
session_id: string;
|
||||
patient_id: string;
|
||||
analysis_type: string;
|
||||
}
|
||||
|
||||
// --- API ---
|
||||
export const consultationApi = {
|
||||
listSessions: async (params: {
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
status?: string;
|
||||
patient_id?: string;
|
||||
doctor_id?: string;
|
||||
}) => {
|
||||
const { data } = await client.get<{
|
||||
success: boolean;
|
||||
data: PaginatedResponse<Session>;
|
||||
}>('/health/consultation-sessions', { params });
|
||||
return data.data;
|
||||
},
|
||||
|
||||
createSession: async (req: CreateSessionReq) => {
|
||||
const { data } = await client.post<{
|
||||
success: boolean;
|
||||
data: Session;
|
||||
}>('/health/consultation-sessions', req);
|
||||
return data.data;
|
||||
},
|
||||
|
||||
getSession: async (id: string) => {
|
||||
const { data } = await client.get<{
|
||||
success: boolean;
|
||||
data: Session;
|
||||
}>(`/health/consultation-sessions/${id}`);
|
||||
return data.data;
|
||||
},
|
||||
|
||||
closeSession: async (
|
||||
id: string,
|
||||
req: { version: number },
|
||||
) => {
|
||||
const { data } = await client.put<{
|
||||
success: boolean;
|
||||
data: Session;
|
||||
}>(`/health/consultation-sessions/${id}/close`, req);
|
||||
return data.data;
|
||||
},
|
||||
|
||||
listMessages: async (
|
||||
sessionId: string,
|
||||
params: { page?: number; page_size?: number; after_id?: string },
|
||||
) => {
|
||||
const { data } = await client.get<{
|
||||
success: boolean;
|
||||
data: PaginatedResponse<Message>;
|
||||
}>(`/health/consultation-sessions/${sessionId}/messages`, { params });
|
||||
return data.data;
|
||||
},
|
||||
|
||||
createMessage: async (req: CreateMessageReq) => {
|
||||
const { data } = await client.post<{
|
||||
success: boolean;
|
||||
data: Message;
|
||||
}>('/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;
|
||||
},
|
||||
|
||||
/** 从咨询会话创建随访任务 */
|
||||
createFollowUpFromSession: async (
|
||||
sessionId: string,
|
||||
req: CreateFollowUpFromConsultationReq,
|
||||
) => {
|
||||
const { data } = await client.post<{
|
||||
success: boolean;
|
||||
data: FollowUpFromConsultationResp;
|
||||
}>(`/health/consultation-sessions/${sessionId}/follow-up`, req);
|
||||
return data.data;
|
||||
},
|
||||
|
||||
/** 从咨询会话触发 AI 分析 */
|
||||
triggerAiAnalysisFromSession: async (
|
||||
sessionId: string,
|
||||
req?: TriggerAiAnalysisReq,
|
||||
) => {
|
||||
const { data } = await client.post<{
|
||||
success: boolean;
|
||||
data: AiAnalysisTriggeredResp;
|
||||
}>(`/health/consultation-sessions/${sessionId}/ai-analysis`, req ?? {});
|
||||
return data.data;
|
||||
},
|
||||
};
|
||||
110
apps/web/src/api/health/criticalValueThresholds.ts
Normal file
110
apps/web/src/api/health/criticalValueThresholds.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import client from '../client';
|
||||
|
||||
// --- Types ---
|
||||
|
||||
export interface CriticalValueThreshold {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
indicator: string;
|
||||
direction: string;
|
||||
threshold_value: number;
|
||||
level: string;
|
||||
department?: string;
|
||||
age_min?: number;
|
||||
age_max?: number;
|
||||
is_active: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
version: number;
|
||||
}
|
||||
|
||||
export interface CreateThresholdReq {
|
||||
indicator: string;
|
||||
direction: string;
|
||||
threshold_value: number;
|
||||
level?: string;
|
||||
department?: string;
|
||||
age_min?: number;
|
||||
age_max?: number;
|
||||
}
|
||||
|
||||
export interface UpdateThresholdReq {
|
||||
threshold_value: number;
|
||||
level?: string;
|
||||
department?: string;
|
||||
age_min?: number;
|
||||
age_max?: number;
|
||||
version: number;
|
||||
}
|
||||
|
||||
// --- Constants ---
|
||||
|
||||
export const INDICATOR_OPTIONS = [
|
||||
{ label: '收缩压', value: 'systolic_bp' },
|
||||
{ label: '舒张压', value: 'diastolic_bp' },
|
||||
{ label: '心率', value: 'heart_rate' },
|
||||
{ label: '血糖', value: 'blood_sugar' },
|
||||
{ label: '空腹血糖', value: 'blood_sugar_fasting' },
|
||||
{ label: '餐后血糖', value: 'blood_sugar_postprandial' },
|
||||
{ label: '血氧', value: 'blood_oxygen' },
|
||||
{ label: '体温', value: 'temperature' },
|
||||
];
|
||||
|
||||
export const DIRECTION_OPTIONS = [
|
||||
{ label: '偏高', value: 'high' },
|
||||
{ label: '偏低', value: 'low' },
|
||||
];
|
||||
|
||||
export const LEVEL_OPTIONS = [
|
||||
{ label: '危急', value: 'critical' },
|
||||
{ label: '警告', value: 'warning' },
|
||||
];
|
||||
|
||||
export const LEVEL_COLOR: Record<string, string> = {
|
||||
critical: 'red',
|
||||
warning: 'orange',
|
||||
};
|
||||
|
||||
export const INDICATOR_LABEL: Record<string, string> = Object.fromEntries(
|
||||
INDICATOR_OPTIONS.map((o) => [o.value, o.label]),
|
||||
);
|
||||
|
||||
export const DIRECTION_LABEL: Record<string, string> = Object.fromEntries(
|
||||
DIRECTION_OPTIONS.map((o) => [o.value, o.label]),
|
||||
);
|
||||
|
||||
export const LEVEL_LABEL: Record<string, string> = Object.fromEntries(
|
||||
LEVEL_OPTIONS.map((o) => [o.value, o.label]),
|
||||
);
|
||||
|
||||
// --- API ---
|
||||
|
||||
export const criticalValueThresholdApi = {
|
||||
list: async () => {
|
||||
const { data } = await client.get<{
|
||||
success: boolean;
|
||||
data: CriticalValueThreshold[];
|
||||
}>('/health/critical-value-thresholds');
|
||||
return data.data;
|
||||
},
|
||||
|
||||
create: async (req: CreateThresholdReq) => {
|
||||
const { data } = await client.post<{
|
||||
success: boolean;
|
||||
data: CriticalValueThreshold;
|
||||
}>('/health/critical-value-thresholds', req);
|
||||
return data.data;
|
||||
},
|
||||
|
||||
update: async (id: string, req: UpdateThresholdReq) => {
|
||||
const { data } = await client.put<{
|
||||
success: boolean;
|
||||
data: CriticalValueThreshold;
|
||||
}>(`/health/critical-value-thresholds/${id}`, req);
|
||||
return data.data;
|
||||
},
|
||||
|
||||
delete: async (id: string) => {
|
||||
await client.delete(`/health/critical-value-thresholds/${id}`);
|
||||
},
|
||||
};
|
||||
105
apps/web/src/api/health/dashboard.test.ts
Normal file
105
apps/web/src/api/health/dashboard.test.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* dashboard + actionInbox API 契约测试
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
const mockGet = vi.fn()
|
||||
const mockPost = vi.fn()
|
||||
const mockPut = vi.fn()
|
||||
const mockDelete = vi.fn()
|
||||
|
||||
vi.mock('../client', () => ({
|
||||
default: {
|
||||
get: (...args: unknown[]) => mockGet(...args),
|
||||
post: (...args: unknown[]) => mockPost(...args),
|
||||
put: (...args: unknown[]) => mockPut(...args),
|
||||
delete: (...args: unknown[]) => mockDelete(...args),
|
||||
},
|
||||
}))
|
||||
|
||||
import { dashboardApi } from './dashboard'
|
||||
import { actionInboxApi } from './actionInbox'
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('dashboardApi', () => {
|
||||
const fakeRes = { data: { data: {} } }
|
||||
|
||||
it('getSystemHealth 应调用 GET /health/admin/system-health', async () => {
|
||||
mockGet.mockResolvedValue(fakeRes)
|
||||
await dashboardApi.getSystemHealth()
|
||||
|
||||
expect(mockGet).toHaveBeenCalledWith('/health/admin/system-health')
|
||||
})
|
||||
|
||||
it('getUserActivity 应调用 GET /health/admin/user-activity', async () => {
|
||||
mockGet.mockResolvedValue(fakeRes)
|
||||
await dashboardApi.getUserActivity()
|
||||
|
||||
expect(mockGet).toHaveBeenCalledWith('/health/admin/user-activity')
|
||||
})
|
||||
|
||||
it('getModuleStatus 应调用 GET /health/admin/modules', async () => {
|
||||
mockGet.mockResolvedValue(fakeRes)
|
||||
await dashboardApi.getModuleStatus()
|
||||
|
||||
expect(mockGet).toHaveBeenCalledWith('/health/admin/modules')
|
||||
})
|
||||
|
||||
it('getPointsRecentActivity 应调用 GET /health/points/recent-activity', async () => {
|
||||
mockGet.mockResolvedValue(fakeRes)
|
||||
await dashboardApi.getPointsRecentActivity()
|
||||
|
||||
expect(mockGet).toHaveBeenCalledWith('/health/points/recent-activity')
|
||||
})
|
||||
|
||||
it('getArticleStats 应调用 GET /health/articles/stats', async () => {
|
||||
mockGet.mockResolvedValue(fakeRes)
|
||||
await dashboardApi.getArticleStats()
|
||||
|
||||
expect(mockGet).toHaveBeenCalledWith('/health/articles/stats')
|
||||
})
|
||||
})
|
||||
|
||||
describe('actionInboxApi', () => {
|
||||
const fakeRes = { data: { success: true, data: {} } }
|
||||
|
||||
it('list 应调用 GET /health/action-inbox 并传递查询参数', async () => {
|
||||
mockGet.mockResolvedValue(fakeRes)
|
||||
await actionInboxApi.list({ status: 'pending', type: 'alert', page: 1, page_size: 20 })
|
||||
|
||||
expect(mockGet).toHaveBeenCalledWith('/health/action-inbox', {
|
||||
params: { status: 'pending', type: 'alert', page: 1, page_size: 20 },
|
||||
})
|
||||
})
|
||||
|
||||
it('getThread 应调用 GET /health/action-inbox/:sourceRef/thread', async () => {
|
||||
mockGet.mockResolvedValue(fakeRes)
|
||||
await actionInboxApi.getThread('ref-001')
|
||||
|
||||
expect(mockGet).toHaveBeenCalledWith('/health/action-inbox/ref-001/thread')
|
||||
})
|
||||
|
||||
it('getThread 应对特殊字符 URL 编码', async () => {
|
||||
mockGet.mockResolvedValue(fakeRes)
|
||||
await actionInboxApi.getThread('ref/with:special')
|
||||
|
||||
expect(mockGet).toHaveBeenCalledWith('/health/action-inbox/ref%2Fwith%3Aspecial/thread')
|
||||
})
|
||||
|
||||
it('stats 应调用 GET /health/action-inbox/stats', async () => {
|
||||
mockGet.mockResolvedValue(fakeRes)
|
||||
await actionInboxApi.stats()
|
||||
|
||||
expect(mockGet).toHaveBeenCalledWith('/health/action-inbox/stats')
|
||||
})
|
||||
|
||||
it('team 应调用 GET /health/action-inbox/team', async () => {
|
||||
mockGet.mockResolvedValue(fakeRes)
|
||||
await actionInboxApi.team()
|
||||
|
||||
expect(mockGet).toHaveBeenCalledWith('/health/action-inbox/team')
|
||||
})
|
||||
})
|
||||
69
apps/web/src/api/health/dashboard.ts
Normal file
69
apps/web/src/api/health/dashboard.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import client from '../client';
|
||||
|
||||
export interface ServiceHealthStatus {
|
||||
name: string;
|
||||
status: string;
|
||||
message: string;
|
||||
response_ms: number | null;
|
||||
}
|
||||
|
||||
export interface SystemHealthResp {
|
||||
services: ServiceHealthStatus[];
|
||||
checked_at: string;
|
||||
}
|
||||
|
||||
export interface RoleCount {
|
||||
role: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface UserActivityResp {
|
||||
daily_active: number;
|
||||
weekly_active: number;
|
||||
monthly_active: number;
|
||||
total_registered: number;
|
||||
by_role: RoleCount[];
|
||||
}
|
||||
|
||||
export interface ModuleStatusResp {
|
||||
name: string;
|
||||
display_name: string;
|
||||
description: string;
|
||||
active: boolean;
|
||||
entity_count: number | null;
|
||||
route_count: number | null;
|
||||
}
|
||||
|
||||
export interface PointsActivityItem {
|
||||
id: string;
|
||||
user_name: string;
|
||||
detail: string;
|
||||
amount: string;
|
||||
type: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface ArticleStatsResp {
|
||||
published: number;
|
||||
draft: number;
|
||||
pending_review: number;
|
||||
rejected: number;
|
||||
total_views: number;
|
||||
}
|
||||
|
||||
export const dashboardApi = {
|
||||
getSystemHealth: () =>
|
||||
client.get('/health/admin/system-health').then((r) => r.data.data as SystemHealthResp),
|
||||
|
||||
getUserActivity: () =>
|
||||
client.get('/health/admin/user-activity').then((r) => r.data.data as UserActivityResp),
|
||||
|
||||
getModuleStatus: () =>
|
||||
client.get('/health/admin/modules').then((r) => r.data.data as ModuleStatusResp[]),
|
||||
|
||||
getPointsRecentActivity: () =>
|
||||
client.get('/health/points/recent-activity').then((r) => r.data.data as PointsActivityItem[]),
|
||||
|
||||
getArticleStats: () =>
|
||||
client.get('/health/articles/stats').then((r) => r.data.data as ArticleStatsResp),
|
||||
};
|
||||
82
apps/web/src/api/health/deviceReadings.test.ts
Normal file
82
apps/web/src/api/health/deviceReadings.test.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* deviceReadings + devices API 契约测试
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
const mockGet = vi.fn()
|
||||
const mockPost = vi.fn()
|
||||
const mockPut = vi.fn()
|
||||
const mockDelete = vi.fn()
|
||||
|
||||
vi.mock('../client', () => ({
|
||||
default: {
|
||||
get: (...args: unknown[]) => mockGet(...args),
|
||||
post: (...args: unknown[]) => mockPost(...args),
|
||||
put: (...args: unknown[]) => mockPut(...args),
|
||||
delete: (...args: unknown[]) => mockDelete(...args),
|
||||
},
|
||||
}))
|
||||
|
||||
import { deviceReadingApi } from './deviceReadings'
|
||||
import { deviceApi } from './devices'
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('deviceReadingApi', () => {
|
||||
const fakeRes = { data: { data: {} } }
|
||||
|
||||
it('batchCreate 应调用 POST /health/patients/:id/device-readings/batch', async () => {
|
||||
mockPost.mockResolvedValue(fakeRes)
|
||||
const data = {
|
||||
device_id: 'dev-001',
|
||||
readings: [
|
||||
{ device_type: 'blood_pressure', values: { systolic: 130, diastolic: 85 }, measured_at: '2026-05-03T08:00:00Z' },
|
||||
],
|
||||
}
|
||||
await deviceReadingApi.batchCreate('p-001', data)
|
||||
|
||||
expect(mockPost).toHaveBeenCalledWith('/health/patients/p-001/device-readings/batch', data)
|
||||
})
|
||||
|
||||
it('query 应调用 GET /health/patients/:id/device-readings 并剥离 patient_id', async () => {
|
||||
mockGet.mockResolvedValue(fakeRes)
|
||||
await deviceReadingApi.query({ patient_id: 'p-001', device_type: 'blood_pressure', hours: 24 })
|
||||
|
||||
expect(mockGet).toHaveBeenCalledWith('/health/patients/p-001/device-readings', {
|
||||
params: { device_type: 'blood_pressure', hours: 24 },
|
||||
})
|
||||
})
|
||||
|
||||
it('queryHourly 应调用 GET /health/patients/:id/device-readings/hourly', async () => {
|
||||
mockGet.mockResolvedValue(fakeRes)
|
||||
await deviceReadingApi.queryHourly({ patient_id: 'p-001', device_type: 'blood_pressure', days: 7 })
|
||||
|
||||
expect(mockGet).toHaveBeenCalledWith('/health/patients/p-001/device-readings/hourly', {
|
||||
params: { device_type: 'blood_pressure', days: 7 },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('deviceApi', () => {
|
||||
const fakeRes = { data: { data: {} } }
|
||||
|
||||
it('listDevices 应调用 GET /health/devices 并传递查询参数', async () => {
|
||||
mockGet.mockResolvedValue(fakeRes)
|
||||
await deviceApi.listDevices({ patient_id: 'p-001', device_type: 'blood_pressure', page: 1, page_size: 10 })
|
||||
|
||||
expect(mockGet).toHaveBeenCalledWith('/health/devices', {
|
||||
params: { patient_id: 'p-001', device_type: 'blood_pressure', page: 1, page_size: 10 },
|
||||
})
|
||||
})
|
||||
|
||||
it('unbindDevice 应调用 DELETE /health/devices/:id 并在 body 传递 version', async () => {
|
||||
mockDelete.mockResolvedValue(fakeRes)
|
||||
await deviceApi.unbindDevice('dev-001', 2)
|
||||
|
||||
expect(mockDelete).toHaveBeenCalledWith('/health/devices/dev-001', {
|
||||
data: { version: 2 },
|
||||
})
|
||||
})
|
||||
})
|
||||
72
apps/web/src/api/health/deviceReadings.ts
Normal file
72
apps/web/src/api/health/deviceReadings.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import client from '../client';
|
||||
import type { PaginatedResponse } from '../types';
|
||||
|
||||
// --- Types ---
|
||||
export interface DeviceReading {
|
||||
id: string;
|
||||
device_id?: string;
|
||||
device_type: string;
|
||||
device_model?: string;
|
||||
raw_value: Record<string, unknown>;
|
||||
measured_at: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface HourlyReading {
|
||||
id: string;
|
||||
device_type: string;
|
||||
hour_start: string;
|
||||
min_val?: number;
|
||||
max_val?: number;
|
||||
avg_val: number;
|
||||
sample_count: number;
|
||||
}
|
||||
|
||||
export interface DailyReading {
|
||||
id: string;
|
||||
device_type: string;
|
||||
date_bucket: string;
|
||||
min_val?: number;
|
||||
max_val?: number;
|
||||
avg_val: number;
|
||||
sample_count: number;
|
||||
percentile_95?: number;
|
||||
}
|
||||
|
||||
export interface BatchReadingRequest {
|
||||
device_id: string;
|
||||
device_model?: string;
|
||||
readings: {
|
||||
device_type: string;
|
||||
values: Record<string, unknown>;
|
||||
measured_at: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface BatchResult {
|
||||
accepted: number;
|
||||
duplicates: number;
|
||||
earliest?: string;
|
||||
latest?: string;
|
||||
}
|
||||
|
||||
// --- API ---
|
||||
export const deviceReadingApi = {
|
||||
batchCreate: (patientId: string, data: BatchReadingRequest) =>
|
||||
client.post(`/health/patients/${patientId}/device-readings/batch`, data).then((r) => r.data.data as BatchResult),
|
||||
|
||||
query: (params: { patient_id: string; device_type?: string; hours?: number; page?: number; page_size?: number }) => {
|
||||
const { patient_id, ...query } = params;
|
||||
return client.get(`/health/patients/${patient_id}/device-readings`, { params: query }).then((r) => r.data.data as PaginatedResponse<DeviceReading>);
|
||||
},
|
||||
|
||||
queryHourly: (params: { patient_id: string; device_type: string; days?: number; page?: number; page_size?: number }) => {
|
||||
const { patient_id, ...query } = params;
|
||||
return client.get(`/health/patients/${patient_id}/device-readings/hourly`, { params: query }).then((r) => r.data.data as PaginatedResponse<HourlyReading>);
|
||||
},
|
||||
|
||||
queryDaily: (params: { patient_id: string; device_type?: string; from_date?: string; to_date?: string; page?: number; page_size?: number }) => {
|
||||
const { patient_id, ...query } = params;
|
||||
return client.get(`/health/vital-signs/daily`, { params: { ...query, patient_id } }).then((r) => r.data.data as PaginatedResponse<DailyReading>);
|
||||
},
|
||||
};
|
||||
37
apps/web/src/api/health/devices.ts
Normal file
37
apps/web/src/api/health/devices.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import client from '../client';
|
||||
import type { PaginatedResponse } from '../types';
|
||||
|
||||
// --- Types ---
|
||||
export interface DeviceItem {
|
||||
id: string;
|
||||
patient_id: string;
|
||||
device_id: string;
|
||||
device_model: string;
|
||||
device_type: string;
|
||||
status?: string;
|
||||
firmware_version?: string;
|
||||
manufacturer?: string;
|
||||
connection_type?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
bound_at: string;
|
||||
last_sync_at: string;
|
||||
version: number;
|
||||
}
|
||||
|
||||
// --- API ---
|
||||
export const deviceApi = {
|
||||
listDevices: (params?: {
|
||||
patient_id?: string;
|
||||
device_type?: string;
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
}) =>
|
||||
client
|
||||
.get('/health/devices', { params })
|
||||
.then((r) => r.data.data as PaginatedResponse<DeviceItem>),
|
||||
|
||||
unbindDevice: (id: string, version: number) =>
|
||||
client
|
||||
.delete(`/health/devices/${id}`, { data: { version } })
|
||||
.then((r) => r.data.data as DeviceItem),
|
||||
};
|
||||
108
apps/web/src/api/health/diagnoses.ts
Normal file
108
apps/web/src/api/health/diagnoses.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import client from '../client';
|
||||
import type { PaginatedResponse } from '../types';
|
||||
|
||||
// --- Types ---
|
||||
|
||||
export interface Diagnosis {
|
||||
id: string;
|
||||
patient_id: string;
|
||||
health_record_id?: string;
|
||||
icd_code: string;
|
||||
diagnosis_name: string;
|
||||
diagnosis_type: string;
|
||||
diagnosed_date: string;
|
||||
status: string;
|
||||
diagnosed_by?: string;
|
||||
notes?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
version: number;
|
||||
}
|
||||
|
||||
export interface CreateDiagnosisReq {
|
||||
icd_code: string;
|
||||
diagnosis_name: string;
|
||||
diagnosis_type?: string;
|
||||
diagnosed_date: string;
|
||||
status?: string;
|
||||
health_record_id?: string;
|
||||
diagnosed_by?: string;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface UpdateDiagnosisReq {
|
||||
icd_code?: string;
|
||||
diagnosis_name?: string;
|
||||
diagnosis_type?: string;
|
||||
diagnosed_date?: string;
|
||||
status?: string;
|
||||
health_record_id?: string;
|
||||
diagnosed_by?: string;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
// --- Constants ---
|
||||
|
||||
export const DIAGNOSIS_TYPE_OPTIONS = [
|
||||
{ label: '主要诊断', value: 'primary' },
|
||||
{ label: '次要诊断', value: 'secondary' },
|
||||
{ label: '合并症', value: 'comorbid' },
|
||||
];
|
||||
|
||||
export const DIAGNOSIS_STATUS_OPTIONS = [
|
||||
{ label: '活跃', value: 'active' },
|
||||
{ label: '已缓解', value: 'resolved' },
|
||||
{ label: '慢性', value: 'chronic' },
|
||||
];
|
||||
|
||||
export const DIAGNOSIS_TYPE_COLOR: Record<string, string> = {
|
||||
primary: 'red',
|
||||
secondary: 'blue',
|
||||
comorbid: 'orange',
|
||||
};
|
||||
|
||||
export const DIAGNOSIS_STATUS_COLOR: Record<string, string> = {
|
||||
active: 'green',
|
||||
resolved: 'default',
|
||||
chronic: 'orange',
|
||||
};
|
||||
|
||||
export const DIAGNOSIS_TYPE_LABEL: Record<string, string> = Object.fromEntries(
|
||||
DIAGNOSIS_TYPE_OPTIONS.map((o) => [o.value, o.label]),
|
||||
);
|
||||
|
||||
export const DIAGNOSIS_STATUS_LABEL: Record<string, string> = Object.fromEntries(
|
||||
DIAGNOSIS_STATUS_OPTIONS.map((o) => [o.value, o.label]),
|
||||
);
|
||||
|
||||
// --- API ---
|
||||
|
||||
export const diagnosisApi = {
|
||||
list: async (patientId: string, params?: { page?: number; page_size?: number }) => {
|
||||
const { data } = await client.get<{
|
||||
success: boolean;
|
||||
data: PaginatedResponse<Diagnosis>;
|
||||
}>(`/health/patients/${patientId}/diagnoses`, { params });
|
||||
return data.data;
|
||||
},
|
||||
|
||||
create: async (patientId: string, req: CreateDiagnosisReq) => {
|
||||
const { data } = await client.post<{
|
||||
success: boolean;
|
||||
data: Diagnosis;
|
||||
}>(`/health/patients/${patientId}/diagnoses`, req);
|
||||
return data.data;
|
||||
},
|
||||
|
||||
update: async (diagnosisId: string, req: UpdateDiagnosisReq & { version: number }) => {
|
||||
const { data } = await client.put<{
|
||||
success: boolean;
|
||||
data: Diagnosis;
|
||||
}>(`/health/diagnoses/${diagnosisId}`, req);
|
||||
return data.data;
|
||||
},
|
||||
|
||||
delete: async (diagnosisId: string, version: number) => {
|
||||
await client.delete(`/health/diagnoses/${diagnosisId}`, { data: { version } });
|
||||
},
|
||||
};
|
||||
116
apps/web/src/api/health/dialysis.ts
Normal file
116
apps/web/src/api/health/dialysis.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import client from '../client';
|
||||
import type { PaginatedResponse } from '../types';
|
||||
|
||||
// --- Types ---
|
||||
|
||||
export interface DialysisRecord {
|
||||
id: string;
|
||||
patient_id: string;
|
||||
dialysis_date: string;
|
||||
start_time?: string;
|
||||
end_time?: string;
|
||||
dry_weight?: number;
|
||||
pre_weight?: number;
|
||||
post_weight?: number;
|
||||
pre_bp_systolic?: number;
|
||||
pre_bp_diastolic?: number;
|
||||
post_bp_systolic?: number;
|
||||
post_bp_diastolic?: number;
|
||||
pre_heart_rate?: number;
|
||||
post_heart_rate?: number;
|
||||
ultrafiltration_volume?: number;
|
||||
dialysis_duration?: number;
|
||||
blood_flow_rate?: number;
|
||||
dialysis_type: string;
|
||||
symptoms?: Record<string, unknown>;
|
||||
complication_notes?: string;
|
||||
status: string;
|
||||
reviewed_by?: string;
|
||||
reviewed_at?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
version: number;
|
||||
}
|
||||
|
||||
export interface CreateDialysisRecordReq {
|
||||
patient_id: string;
|
||||
dialysis_date: string;
|
||||
start_time?: string;
|
||||
end_time?: string;
|
||||
dry_weight?: number;
|
||||
pre_weight?: number;
|
||||
post_weight?: number;
|
||||
pre_bp_systolic?: number;
|
||||
pre_bp_diastolic?: number;
|
||||
post_bp_systolic?: number;
|
||||
post_bp_diastolic?: number;
|
||||
pre_heart_rate?: number;
|
||||
post_heart_rate?: number;
|
||||
ultrafiltration_volume?: number;
|
||||
dialysis_duration?: number;
|
||||
blood_flow_rate?: number;
|
||||
dialysis_type?: string;
|
||||
complication_notes?: string;
|
||||
}
|
||||
|
||||
// --- API ---
|
||||
|
||||
export const dialysisApi = {
|
||||
listRecords: async (
|
||||
patientId: string,
|
||||
params: { page?: number; page_size?: number },
|
||||
) => {
|
||||
const { data } = await client.get<{
|
||||
success: boolean;
|
||||
data: PaginatedResponse<DialysisRecord>;
|
||||
}>(`/health/patients/${patientId}/dialysis-records`, { params });
|
||||
return data.data;
|
||||
},
|
||||
|
||||
getRecord: async (id: string) => {
|
||||
const { data } = await client.get<{
|
||||
success: boolean;
|
||||
data: DialysisRecord;
|
||||
}>(`/health/dialysis-records/${id}`);
|
||||
return data.data;
|
||||
},
|
||||
|
||||
createRecord: async (req: CreateDialysisRecordReq) => {
|
||||
const { data } = await client.post<{
|
||||
success: boolean;
|
||||
data: DialysisRecord;
|
||||
}>('/health/dialysis-records', req);
|
||||
return data.data;
|
||||
},
|
||||
|
||||
updateRecord: async (
|
||||
id: string,
|
||||
req: Partial<CreateDialysisRecordReq> & { version: number },
|
||||
) => {
|
||||
const { data } = await client.put<{
|
||||
success: boolean;
|
||||
data: DialysisRecord;
|
||||
}>(`/health/dialysis-records/${id}`, req);
|
||||
return data.data;
|
||||
},
|
||||
|
||||
deleteRecord: async (id: string, version: number) => {
|
||||
await client.delete(`/health/dialysis-records/${id}`, { data: { version } });
|
||||
},
|
||||
|
||||
reviewRecord: async (id: string, req: { version: number; doctor_notes?: string }) => {
|
||||
const { data } = await client.put<{
|
||||
success: boolean;
|
||||
data: Record<string, unknown>;
|
||||
}>(`/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;
|
||||
},
|
||||
};
|
||||
67
apps/web/src/api/health/doctors.test.ts
Normal file
67
apps/web/src/api/health/doctors.test.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* doctors API 契约测试
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
const mockGet = vi.fn()
|
||||
const mockPost = vi.fn()
|
||||
const mockPut = vi.fn()
|
||||
const mockDelete = vi.fn()
|
||||
|
||||
vi.mock('../client', () => ({
|
||||
default: {
|
||||
get: (...args: unknown[]) => mockGet(...args),
|
||||
post: (...args: unknown[]) => mockPost(...args),
|
||||
put: (...args: unknown[]) => mockPut(...args),
|
||||
delete: (...args: unknown[]) => mockDelete(...args),
|
||||
},
|
||||
}))
|
||||
|
||||
import { doctorApi } from './doctors'
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('doctorApi', () => {
|
||||
const fakeRes = { data: { success: true, data: {} } }
|
||||
|
||||
it('list 应调用 GET /health/doctors 并传递查询参数', async () => {
|
||||
mockGet.mockResolvedValue(fakeRes)
|
||||
await doctorApi.list({ page: 1, page_size: 10, search: '王', department: '内科' })
|
||||
|
||||
expect(mockGet).toHaveBeenCalledWith('/health/doctors', {
|
||||
params: { page: 1, page_size: 10, search: '王', department: '内科' },
|
||||
})
|
||||
})
|
||||
|
||||
it('get 应调用 GET /health/doctors/:id', async () => {
|
||||
mockGet.mockResolvedValue(fakeRes)
|
||||
await doctorApi.get('d-001')
|
||||
|
||||
expect(mockGet).toHaveBeenCalledWith('/health/doctors/d-001')
|
||||
})
|
||||
|
||||
it('create 应调用 POST /health/doctors 并传递请求体', async () => {
|
||||
mockPost.mockResolvedValue(fakeRes)
|
||||
const req = { name: '王医生', department: '内科', title: '主任医师' }
|
||||
await doctorApi.create(req)
|
||||
|
||||
expect(mockPost).toHaveBeenCalledWith('/health/doctors', req)
|
||||
})
|
||||
|
||||
it('update 应调用 PUT /health/doctors/:id 并传递请求体含 version', async () => {
|
||||
mockPut.mockResolvedValue(fakeRes)
|
||||
const req = { title: '副主任医师', version: 1 }
|
||||
await doctorApi.update('d-001', req)
|
||||
|
||||
expect(mockPut).toHaveBeenCalledWith('/health/doctors/d-001', req)
|
||||
})
|
||||
|
||||
it('delete 应调用 DELETE /health/doctors/:id', async () => {
|
||||
mockDelete.mockResolvedValue(undefined)
|
||||
await doctorApi.delete('d-001', 1)
|
||||
|
||||
expect(mockDelete).toHaveBeenCalledWith('/health/doctors/d-001')
|
||||
})
|
||||
})
|
||||
83
apps/web/src/api/health/doctors.ts
Normal file
83
apps/web/src/api/health/doctors.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import client from '../client';
|
||||
import type { PaginatedResponse } from '../types';
|
||||
|
||||
// --- Types ---
|
||||
export interface Doctor {
|
||||
id: string;
|
||||
user_id?: string;
|
||||
name: string;
|
||||
department?: string;
|
||||
title?: string;
|
||||
specialty?: string;
|
||||
license_number?: string;
|
||||
bio?: string;
|
||||
online_status: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
version: number;
|
||||
}
|
||||
|
||||
export interface CreateDoctorReq {
|
||||
user_id?: string;
|
||||
name: string;
|
||||
department?: string;
|
||||
title?: string;
|
||||
specialty?: string;
|
||||
license_number?: string;
|
||||
bio?: string;
|
||||
}
|
||||
|
||||
export interface UpdateDoctorReq {
|
||||
name?: string;
|
||||
department?: string;
|
||||
title?: string;
|
||||
specialty?: string;
|
||||
license_number?: string;
|
||||
bio?: string;
|
||||
online_status?: string;
|
||||
}
|
||||
|
||||
// --- API ---
|
||||
export const doctorApi = {
|
||||
list: async (params: {
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
search?: string;
|
||||
department?: string;
|
||||
title?: string;
|
||||
}) => {
|
||||
const { data } = await client.get<{
|
||||
success: boolean;
|
||||
data: PaginatedResponse<Doctor>;
|
||||
}>('/health/doctors', { params });
|
||||
return data.data;
|
||||
},
|
||||
|
||||
get: async (id: string) => {
|
||||
const { data } = await client.get<{
|
||||
success: boolean;
|
||||
data: Doctor;
|
||||
}>(`/health/doctors/${id}`);
|
||||
return data.data;
|
||||
},
|
||||
|
||||
create: async (req: CreateDoctorReq) => {
|
||||
const { data } = await client.post<{
|
||||
success: boolean;
|
||||
data: Doctor;
|
||||
}>('/health/doctors', req);
|
||||
return data.data;
|
||||
},
|
||||
|
||||
update: async (id: string, req: UpdateDoctorReq & { version: number }) => {
|
||||
const { data } = await client.put<{
|
||||
success: boolean;
|
||||
data: Doctor;
|
||||
}>(`/health/doctors/${id}`, req);
|
||||
return data.data;
|
||||
},
|
||||
|
||||
delete: async (id: string, version: number) => {
|
||||
await client.delete(`/health/doctors/${id}`, { data: { version } });
|
||||
},
|
||||
};
|
||||
109
apps/web/src/api/health/familyProxy.ts
Normal file
109
apps/web/src/api/health/familyProxy.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import client from '../client';
|
||||
|
||||
// --- Types ---
|
||||
|
||||
export interface FamilyMember {
|
||||
id: string;
|
||||
patient_id: string;
|
||||
name: string;
|
||||
relationship: string;
|
||||
phone?: string;
|
||||
birth_date?: string;
|
||||
notes?: string;
|
||||
user_id?: string;
|
||||
consent_status: string;
|
||||
access_level: string;
|
||||
consented_at?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
version: number;
|
||||
}
|
||||
|
||||
export interface FamilyPatientSummary {
|
||||
family_member_id: string;
|
||||
patient_id: string;
|
||||
patient_name: string;
|
||||
relationship: string;
|
||||
consent_status: string;
|
||||
access_level: string;
|
||||
consented_at?: string;
|
||||
}
|
||||
|
||||
export interface FamilyHealthSummary {
|
||||
patient_id: string;
|
||||
patient_name: string;
|
||||
latest_vital_signs?: Record<string, unknown>;
|
||||
active_care_plan?: Record<string, unknown>;
|
||||
recent_alerts_count: number;
|
||||
next_appointment?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface GrantAccessReq {
|
||||
access_level: string;
|
||||
}
|
||||
|
||||
// --- Constants ---
|
||||
|
||||
export const CONSENT_STATUS_OPTIONS = [
|
||||
{ label: '已同意', value: 'granted' },
|
||||
{ label: '待确认', value: 'pending' },
|
||||
{ label: '已撤销', value: 'revoked' },
|
||||
{ label: '已过期', value: 'expired' },
|
||||
];
|
||||
|
||||
export const ACCESS_LEVEL_OPTIONS = [
|
||||
{ label: '完全访问', value: 'full' },
|
||||
{ label: '只读', value: 'read_only' },
|
||||
{ label: '摘要', value: 'summary' },
|
||||
];
|
||||
|
||||
export const CONSENT_STATUS_COLOR: Record<string, string> = {
|
||||
granted: 'green',
|
||||
pending: 'orange',
|
||||
revoked: 'red',
|
||||
expired: 'default',
|
||||
};
|
||||
|
||||
export const ACCESS_LEVEL_LABEL: Record<string, string> = Object.fromEntries(
|
||||
ACCESS_LEVEL_OPTIONS.map((o) => [o.value, o.label]),
|
||||
);
|
||||
|
||||
export const CONSENT_STATUS_LABEL: Record<string, string> = Object.fromEntries(
|
||||
CONSENT_STATUS_OPTIONS.map((o) => [o.value, o.label]),
|
||||
);
|
||||
|
||||
// --- API ---
|
||||
|
||||
export const familyProxyApi = {
|
||||
grantAccess: async (patientId: string, familyMemberId: string, req: GrantAccessReq, version: number) => {
|
||||
const { data } = await client.post<{
|
||||
success: boolean;
|
||||
data: FamilyMember;
|
||||
}>(`/health/patients/${patientId}/family-members/${familyMemberId}/grant-access`, { ...req, version });
|
||||
return data.data;
|
||||
},
|
||||
|
||||
revokeAccess: async (patientId: string, familyMemberId: string, version: number) => {
|
||||
const { data } = await client.put<{
|
||||
success: boolean;
|
||||
data: FamilyMember;
|
||||
}>(`/health/patients/${patientId}/family-members/${familyMemberId}/revoke-access`, { version });
|
||||
return data.data;
|
||||
},
|
||||
|
||||
listMyPatients: async () => {
|
||||
const { data } = await client.get<{
|
||||
success: boolean;
|
||||
data: FamilyPatientSummary[];
|
||||
}>('/health/family/patients');
|
||||
return data.data;
|
||||
},
|
||||
|
||||
getHealthSummary: async (patientId: string) => {
|
||||
const { data } = await client.get<{
|
||||
success: boolean;
|
||||
data: FamilyHealthSummary;
|
||||
}>(`/health/family/patients/${patientId}/health-summary`);
|
||||
return data.data;
|
||||
},
|
||||
};
|
||||
97
apps/web/src/api/health/followUp.test.ts
Normal file
97
apps/web/src/api/health/followUp.test.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* followUp API 契约测试
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
const mockGet = vi.fn()
|
||||
const mockPost = vi.fn()
|
||||
const mockPut = vi.fn()
|
||||
const mockDelete = vi.fn()
|
||||
|
||||
vi.mock('../client', () => ({
|
||||
default: {
|
||||
get: (...args: unknown[]) => mockGet(...args),
|
||||
post: (...args: unknown[]) => mockPost(...args),
|
||||
put: (...args: unknown[]) => mockPut(...args),
|
||||
delete: (...args: unknown[]) => mockDelete(...args),
|
||||
},
|
||||
}))
|
||||
|
||||
import { followUpApi } from './followUp'
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('followUpApi - Tasks', () => {
|
||||
const fakeRes = { data: { success: true, data: {} } }
|
||||
|
||||
it('listTasks 应调用 GET /health/follow-up-tasks 并传递查询参数', async () => {
|
||||
mockGet.mockResolvedValue(fakeRes)
|
||||
await followUpApi.listTasks({ page: 1, page_size: 20, patient_id: 'p-001', status: 'pending' })
|
||||
|
||||
expect(mockGet).toHaveBeenCalledWith('/health/follow-up-tasks', {
|
||||
params: { page: 1, page_size: 20, patient_id: 'p-001', status: 'pending' },
|
||||
})
|
||||
})
|
||||
|
||||
it('getTask 应调用 GET /health/follow-up-tasks/:id', async () => {
|
||||
mockGet.mockResolvedValue(fakeRes)
|
||||
await followUpApi.getTask('task-001')
|
||||
|
||||
expect(mockGet).toHaveBeenCalledWith('/health/follow-up-tasks/task-001')
|
||||
})
|
||||
|
||||
it('createTask 应调用 POST /health/follow-up-tasks', async () => {
|
||||
mockPost.mockResolvedValue(fakeRes)
|
||||
const req = { patient_id: 'p-001', follow_up_type: 'phone', planned_date: '2026-05-10' }
|
||||
await followUpApi.createTask(req)
|
||||
|
||||
expect(mockPost).toHaveBeenCalledWith('/health/follow-up-tasks', req)
|
||||
})
|
||||
|
||||
it('updateTask 应调用 PUT /health/follow-up-tasks/:id 并传递 version', async () => {
|
||||
mockPut.mockResolvedValue(fakeRes)
|
||||
const req = { status: 'completed', version: 1 }
|
||||
await followUpApi.updateTask('task-001', req)
|
||||
|
||||
expect(mockPut).toHaveBeenCalledWith('/health/follow-up-tasks/task-001', req)
|
||||
})
|
||||
|
||||
it('deleteTask 应调用 DELETE /health/follow-up-tasks/:id 并在 body 传递 version', async () => {
|
||||
mockDelete.mockResolvedValue(undefined)
|
||||
await followUpApi.deleteTask('task-001', 2)
|
||||
|
||||
expect(mockDelete).toHaveBeenCalledWith('/health/follow-up-tasks/task-001', {
|
||||
data: { version: 2 },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('followUpApi - Records', () => {
|
||||
const fakeRes = { data: { success: true, data: {} } }
|
||||
|
||||
it('listRecords 应调用 GET /health/follow-up-records 并传递查询参数', async () => {
|
||||
mockGet.mockResolvedValue(fakeRes)
|
||||
await followUpApi.listRecords({ page: 1, page_size: 10, task_id: 'task-001' })
|
||||
|
||||
expect(mockGet).toHaveBeenCalledWith('/health/follow-up-records', {
|
||||
params: { page: 1, page_size: 10, task_id: 'task-001' },
|
||||
})
|
||||
})
|
||||
|
||||
it('createRecord 应调用 POST /health/follow-up-tasks/:taskId/records 并注入 task_id', async () => {
|
||||
mockPost.mockResolvedValue(fakeRes)
|
||||
const req = {
|
||||
executed_date: '2026-05-10',
|
||||
result: '已完成',
|
||||
patient_condition: '良好',
|
||||
}
|
||||
await followUpApi.createRecord('task-001', req)
|
||||
|
||||
expect(mockPost).toHaveBeenCalledWith('/health/follow-up-tasks/task-001/records', {
|
||||
...req,
|
||||
task_id: 'task-001',
|
||||
})
|
||||
})
|
||||
})
|
||||
133
apps/web/src/api/health/followUp.ts
Normal file
133
apps/web/src/api/health/followUp.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import client from '../client';
|
||||
import type { PaginatedResponse } from '../types';
|
||||
|
||||
// --- Types ---
|
||||
export interface FollowUpTask {
|
||||
id: string;
|
||||
patient_id: string;
|
||||
assigned_to?: string;
|
||||
patient_name?: string;
|
||||
assigned_to_name?: string;
|
||||
follow_up_type: string;
|
||||
planned_date: string;
|
||||
status: string;
|
||||
content_template?: string;
|
||||
related_appointment_id?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
version: number;
|
||||
}
|
||||
|
||||
export interface CreateFollowUpTaskReq {
|
||||
patient_id: string;
|
||||
assigned_to?: string;
|
||||
follow_up_type: string;
|
||||
planned_date: string;
|
||||
content_template?: string;
|
||||
related_appointment_id?: string;
|
||||
}
|
||||
|
||||
export interface UpdateFollowUpTaskReq {
|
||||
assigned_to?: string;
|
||||
follow_up_type?: string;
|
||||
planned_date?: string;
|
||||
content_template?: string;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
export interface FollowUpRecord {
|
||||
id: string;
|
||||
task_id: string;
|
||||
executed_by?: string;
|
||||
executed_date: string;
|
||||
result?: string;
|
||||
patient_condition?: string;
|
||||
medical_advice?: string;
|
||||
next_follow_up_date?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
version: number;
|
||||
}
|
||||
|
||||
export interface CreateFollowUpRecordReq {
|
||||
task_id: string;
|
||||
executed_by?: string;
|
||||
executed_date: string;
|
||||
result?: string;
|
||||
patient_condition?: string;
|
||||
medical_advice?: string;
|
||||
next_follow_up_date?: string;
|
||||
}
|
||||
|
||||
// --- API ---
|
||||
export const followUpApi = {
|
||||
// Tasks
|
||||
listTasks: async (params: {
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
patient_id?: string;
|
||||
assigned_to?: string;
|
||||
status?: string;
|
||||
}) => {
|
||||
const { data } = await client.get<{
|
||||
success: boolean;
|
||||
data: PaginatedResponse<FollowUpTask>;
|
||||
}>('/health/follow-up-tasks', { params });
|
||||
return data.data;
|
||||
},
|
||||
|
||||
getTask: async (id: string) => {
|
||||
const { data } = await client.get<{
|
||||
success: boolean;
|
||||
data: FollowUpTask;
|
||||
}>(`/health/follow-up-tasks/${id}`);
|
||||
return data.data;
|
||||
},
|
||||
|
||||
createTask: async (req: CreateFollowUpTaskReq) => {
|
||||
const { data } = await client.post<{
|
||||
success: boolean;
|
||||
data: FollowUpTask;
|
||||
}>('/health/follow-up-tasks', req);
|
||||
return data.data;
|
||||
},
|
||||
|
||||
updateTask: async (
|
||||
id: string,
|
||||
req: UpdateFollowUpTaskReq & { version: number },
|
||||
) => {
|
||||
const { data } = await client.put<{
|
||||
success: boolean;
|
||||
data: FollowUpTask;
|
||||
}>(`/health/follow-up-tasks/${id}`, req);
|
||||
return data.data;
|
||||
},
|
||||
|
||||
deleteTask: async (id: string, version: number) => {
|
||||
await client.delete(`/health/follow-up-tasks/${id}`, {
|
||||
data: { version },
|
||||
});
|
||||
},
|
||||
|
||||
// Records
|
||||
listRecords: async (params: {
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
task_id?: string;
|
||||
patient_id?: string;
|
||||
}) => {
|
||||
const { data } = await client.get<{
|
||||
success: boolean;
|
||||
data: PaginatedResponse<FollowUpRecord>;
|
||||
}>('/health/follow-up-records', { params });
|
||||
return data.data;
|
||||
},
|
||||
|
||||
createRecord: async (taskId: string, req: Omit<CreateFollowUpRecordReq, 'task_id'>) => {
|
||||
const { data } = await client.post<{
|
||||
success: boolean;
|
||||
data: FollowUpRecord;
|
||||
}>(`/health/follow-up-tasks/${taskId}/records`, { ...req, task_id: taskId });
|
||||
return data.data;
|
||||
},
|
||||
};
|
||||
75
apps/web/src/api/health/followUpTemplates.test.ts
Normal file
75
apps/web/src/api/health/followUpTemplates.test.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* followUpTemplates API 契约测试
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
const mockGet = vi.fn()
|
||||
const mockPost = vi.fn()
|
||||
const mockPut = vi.fn()
|
||||
const mockDelete = vi.fn()
|
||||
|
||||
vi.mock('../client', () => ({
|
||||
default: {
|
||||
get: (...args: unknown[]) => mockGet(...args),
|
||||
post: (...args: unknown[]) => mockPost(...args),
|
||||
put: (...args: unknown[]) => mockPut(...args),
|
||||
delete: (...args: unknown[]) => mockDelete(...args),
|
||||
},
|
||||
}))
|
||||
|
||||
import { followUpTemplateApi } from './followUpTemplates'
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('followUpTemplateApi', () => {
|
||||
const fakeRes = { data: { success: true, data: {} } }
|
||||
|
||||
it('list 应调用 GET /health/follow-up-templates 并传递查询参数', async () => {
|
||||
mockGet.mockResolvedValue(fakeRes)
|
||||
await followUpTemplateApi.list({ page: 1, page_size: 10, follow_up_type: 'phone', status: 'active' })
|
||||
|
||||
expect(mockGet).toHaveBeenCalledWith('/health/follow-up-templates', {
|
||||
params: { page: 1, page_size: 10, follow_up_type: 'phone', status: 'active' },
|
||||
})
|
||||
})
|
||||
|
||||
it('get 应调用 GET /health/follow-up-templates/:id', async () => {
|
||||
mockGet.mockResolvedValue(fakeRes)
|
||||
await followUpTemplateApi.get('tpl-001')
|
||||
|
||||
expect(mockGet).toHaveBeenCalledWith('/health/follow-up-templates/tpl-001')
|
||||
})
|
||||
|
||||
it('create 应调用 POST /health/follow-up-templates 并传递请求体', async () => {
|
||||
mockPost.mockResolvedValue(fakeRes)
|
||||
const req = {
|
||||
name: '电话随访模板',
|
||||
follow_up_type: 'phone',
|
||||
fields: [
|
||||
{ label: '患者状态', field_key: 'patient_status', field_type: 'select', required: true, options: '良好,一般,较差' },
|
||||
],
|
||||
}
|
||||
await followUpTemplateApi.create(req)
|
||||
|
||||
expect(mockPost).toHaveBeenCalledWith('/health/follow-up-templates', req)
|
||||
})
|
||||
|
||||
it('update 应调用 PUT /health/follow-up-templates/:id 并传递请求体', async () => {
|
||||
mockPut.mockResolvedValue(fakeRes)
|
||||
const req = { name: '更新后模板', status: 'active', version: 1 }
|
||||
await followUpTemplateApi.update('tpl-001', req)
|
||||
|
||||
expect(mockPut).toHaveBeenCalledWith('/health/follow-up-templates/tpl-001', req)
|
||||
})
|
||||
|
||||
it('delete 应调用 DELETE /health/follow-up-templates/:id 并在 body 传递 version', async () => {
|
||||
mockDelete.mockResolvedValue(undefined)
|
||||
await followUpTemplateApi.delete('tpl-001', 2)
|
||||
|
||||
expect(mockDelete).toHaveBeenCalledWith('/health/follow-up-templates/tpl-001', {
|
||||
data: { version: 2 },
|
||||
})
|
||||
})
|
||||
})
|
||||
119
apps/web/src/api/health/followUpTemplates.ts
Normal file
119
apps/web/src/api/health/followUpTemplates.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import client from '../client';
|
||||
import type { PaginatedResponse } from '../types';
|
||||
|
||||
export type FollowUpType = 'phone' | 'outpatient' | 'home_visit' | 'online' | 'wechat';
|
||||
export type TemplateStatus = 'active' | 'draft' | 'archived';
|
||||
|
||||
export interface TemplateField {
|
||||
id: string;
|
||||
template_id: string;
|
||||
label: string;
|
||||
field_key: string;
|
||||
field_type: string;
|
||||
required: boolean;
|
||||
options?: string;
|
||||
placeholder?: string;
|
||||
validation?: string;
|
||||
sort_order: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
version: number;
|
||||
}
|
||||
|
||||
export interface TemplateFieldReq {
|
||||
label: string;
|
||||
field_key: string;
|
||||
field_type: string;
|
||||
required?: boolean;
|
||||
options?: string;
|
||||
placeholder?: string;
|
||||
validation?: string;
|
||||
sort_order?: number;
|
||||
}
|
||||
|
||||
export interface FollowUpTemplate {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
follow_up_type: string;
|
||||
applicable_scope?: string;
|
||||
status: string;
|
||||
fields: TemplateField[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
version: number;
|
||||
}
|
||||
|
||||
export interface FollowUpTemplateListItem {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
follow_up_type: string;
|
||||
status: string;
|
||||
field_count: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
version: number;
|
||||
}
|
||||
|
||||
export interface CreateTemplateReq {
|
||||
name: string;
|
||||
description?: string;
|
||||
follow_up_type: string;
|
||||
applicable_scope?: string;
|
||||
fields: TemplateFieldReq[];
|
||||
}
|
||||
|
||||
export interface UpdateTemplateReq {
|
||||
name?: string;
|
||||
description?: string;
|
||||
follow_up_type?: string;
|
||||
applicable_scope?: string;
|
||||
status?: string;
|
||||
fields?: TemplateFieldReq[];
|
||||
}
|
||||
|
||||
export const followUpTemplateApi = {
|
||||
list: async (params?: {
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
follow_up_type?: string;
|
||||
status?: string;
|
||||
}) => {
|
||||
const { data } = await client.get<{
|
||||
success: boolean;
|
||||
data: PaginatedResponse<FollowUpTemplateListItem>;
|
||||
}>('/health/follow-up-templates', { params });
|
||||
return data.data;
|
||||
},
|
||||
|
||||
get: async (id: string) => {
|
||||
const { data } = await client.get<{
|
||||
success: boolean;
|
||||
data: FollowUpTemplate;
|
||||
}>(`/health/follow-up-templates/${id}`);
|
||||
return data.data;
|
||||
},
|
||||
|
||||
create: async (req: CreateTemplateReq) => {
|
||||
const { data } = await client.post<{
|
||||
success: boolean;
|
||||
data: FollowUpTemplate;
|
||||
}>('/health/follow-up-templates', req);
|
||||
return data.data;
|
||||
},
|
||||
|
||||
update: async (id: string, req: UpdateTemplateReq & { version: number }) => {
|
||||
const { data } = await client.put<{
|
||||
success: boolean;
|
||||
data: FollowUpTemplate;
|
||||
}>(`/health/follow-up-templates/${id}`, req);
|
||||
return data.data;
|
||||
},
|
||||
|
||||
delete: async (id: string, version: number) => {
|
||||
await client.delete(`/health/follow-up-templates/${id}`, {
|
||||
data: { version },
|
||||
});
|
||||
},
|
||||
};
|
||||
135
apps/web/src/api/health/healthData.test.ts
Normal file
135
apps/web/src/api/health/healthData.test.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* healthData API 契约测试(体征/化验报告/健康记录/趋势/日常监测)
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
const mockGet = vi.fn()
|
||||
const mockPost = vi.fn()
|
||||
const mockPut = vi.fn()
|
||||
const mockDelete = vi.fn()
|
||||
|
||||
vi.mock('../client', () => ({
|
||||
default: {
|
||||
get: (...args: unknown[]) => mockGet(...args),
|
||||
post: (...args: unknown[]) => mockPost(...args),
|
||||
put: (...args: unknown[]) => mockPut(...args),
|
||||
delete: (...args: unknown[]) => mockDelete(...args),
|
||||
},
|
||||
}))
|
||||
|
||||
import { healthDataApi } from './healthData'
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('healthDataApi - Vital Signs', () => {
|
||||
const fakeRes = { data: { success: true, data: {} } }
|
||||
|
||||
it('listVitalSigns 应调用 GET /health/patients/:id/vital-signs 并传递分页', async () => {
|
||||
mockGet.mockResolvedValue(fakeRes)
|
||||
await healthDataApi.listVitalSigns('p-001', { page: 1, page_size: 10 })
|
||||
|
||||
expect(mockGet).toHaveBeenCalledWith('/health/patients/p-001/vital-signs', {
|
||||
params: { page: 1, page_size: 10 },
|
||||
})
|
||||
})
|
||||
|
||||
it('createVitalSigns 应调用 POST /health/patients/:id/vital-signs', async () => {
|
||||
mockPost.mockResolvedValue(fakeRes)
|
||||
const req = { record_date: '2026-05-03', systolic_bp_morning: 120, diastolic_bp_morning: 80 }
|
||||
await healthDataApi.createVitalSigns('p-001', req)
|
||||
|
||||
expect(mockPost).toHaveBeenCalledWith('/health/patients/p-001/vital-signs', req)
|
||||
})
|
||||
|
||||
it('updateVitalSigns 应调用 PUT /health/patients/:pid/vital-signs/:id', async () => {
|
||||
mockPut.mockResolvedValue(fakeRes)
|
||||
const req = { systolic_bp_morning: 125, version: 1 }
|
||||
await healthDataApi.updateVitalSigns('p-001', 'vs-001', req)
|
||||
|
||||
expect(mockPut).toHaveBeenCalledWith('/health/patients/p-001/vital-signs/vs-001', req)
|
||||
})
|
||||
|
||||
it('deleteVitalSigns 应调用 DELETE /health/patients/:pid/vital-signs/:id', async () => {
|
||||
mockDelete.mockResolvedValue(undefined)
|
||||
await healthDataApi.deleteVitalSigns('p-001', 'vs-001')
|
||||
|
||||
expect(mockDelete).toHaveBeenCalledWith('/health/patients/p-001/vital-signs/vs-001')
|
||||
})
|
||||
})
|
||||
|
||||
describe('healthDataApi - Lab Reports', () => {
|
||||
const fakeRes = { data: { success: true, data: {} } }
|
||||
|
||||
it('listLabReports 应调用 GET /health/patients/:id/lab-reports', async () => {
|
||||
mockGet.mockResolvedValue(fakeRes)
|
||||
await healthDataApi.listLabReports('p-001', { page: 1, page_size: 10 })
|
||||
|
||||
expect(mockGet).toHaveBeenCalledWith('/health/patients/p-001/lab-reports', {
|
||||
params: { page: 1, page_size: 10 },
|
||||
})
|
||||
})
|
||||
|
||||
it('createLabReport 应调用 POST /health/patients/:id/lab-reports', async () => {
|
||||
mockPost.mockResolvedValue(fakeRes)
|
||||
const req = { report_date: '2026-05-03', report_type: 'blood_test' }
|
||||
await healthDataApi.createLabReport('p-001', req)
|
||||
|
||||
expect(mockPost).toHaveBeenCalledWith('/health/patients/p-001/lab-reports', req)
|
||||
})
|
||||
|
||||
it('reviewLabReport 应调用 PUT /health/patients/:pid/lab-reports/:rid/review', async () => {
|
||||
mockPut.mockResolvedValue(fakeRes)
|
||||
const req = { version: 1, doctor_notes: '指标正常' }
|
||||
await healthDataApi.reviewLabReport('p-001', 'lr-001', req)
|
||||
|
||||
expect(mockPut).toHaveBeenCalledWith('/health/patients/p-001/lab-reports/lr-001/review', req)
|
||||
})
|
||||
|
||||
it('deleteLabReport 应调用 DELETE /health/patients/:pid/lab-reports/:id', async () => {
|
||||
mockDelete.mockResolvedValue(undefined)
|
||||
await healthDataApi.deleteLabReport('p-001', 'lr-001')
|
||||
|
||||
expect(mockDelete).toHaveBeenCalledWith('/health/patients/p-001/lab-reports/lr-001')
|
||||
})
|
||||
})
|
||||
|
||||
describe('healthDataApi - Health Records', () => {
|
||||
const fakeRes = { data: { success: true, data: {} } }
|
||||
|
||||
it('listHealthRecords 应调用 GET /health/patients/:id/health-records', async () => {
|
||||
mockGet.mockResolvedValue(fakeRes)
|
||||
await healthDataApi.listHealthRecords('p-001', { page: 1, page_size: 10 })
|
||||
|
||||
expect(mockGet).toHaveBeenCalledWith('/health/patients/p-001/health-records', {
|
||||
params: { page: 1, page_size: 10 },
|
||||
})
|
||||
})
|
||||
|
||||
it('createHealthRecord 应调用 POST /health/patients/:id/health-records', async () => {
|
||||
mockPost.mockResolvedValue(fakeRes)
|
||||
const req = { record_type: 'checkup', record_date: '2026-05-03', content: '体检结果正常' }
|
||||
await healthDataApi.createHealthRecord('p-001', req)
|
||||
|
||||
expect(mockPost).toHaveBeenCalledWith('/health/patients/p-001/health-records', req)
|
||||
})
|
||||
})
|
||||
|
||||
describe('healthDataApi - Trends', () => {
|
||||
const fakeRes = { data: { success: true, data: {} } }
|
||||
|
||||
it('listTrends 应调用 GET /health/patients/:id/trends', async () => {
|
||||
mockGet.mockResolvedValue(fakeRes)
|
||||
await healthDataApi.listTrends('p-001')
|
||||
|
||||
expect(mockGet).toHaveBeenCalledWith('/health/patients/p-001/trends')
|
||||
})
|
||||
|
||||
it('getIndicatorTimeseries 应调用 GET /health/patients/:id/trends/:indicator 并编码', async () => {
|
||||
mockGet.mockResolvedValue(fakeRes)
|
||||
await healthDataApi.getIndicatorTimeseries('p-001', 'blood_pressure/systolic')
|
||||
|
||||
expect(mockGet).toHaveBeenCalledWith('/health/patients/p-001/trends/blood_pressure%2Fsystolic')
|
||||
})
|
||||
})
|
||||
304
apps/web/src/api/health/healthData.ts
Normal file
304
apps/web/src/api/health/healthData.ts
Normal file
@@ -0,0 +1,304 @@
|
||||
import client from '../client';
|
||||
import type { PaginatedResponse } from '../types';
|
||||
|
||||
// --- Types ---
|
||||
export interface VitalSigns {
|
||||
id: string;
|
||||
patient_id: string;
|
||||
record_date: string;
|
||||
systolic_bp_morning?: number;
|
||||
diastolic_bp_morning?: number;
|
||||
systolic_bp_evening?: number;
|
||||
diastolic_bp_evening?: number;
|
||||
heart_rate?: number;
|
||||
weight?: number;
|
||||
blood_sugar?: number;
|
||||
water_intake_ml?: number;
|
||||
urine_output_ml?: number;
|
||||
notes?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
version: number;
|
||||
}
|
||||
|
||||
export interface CreateVitalSignsReq {
|
||||
record_date: string;
|
||||
systolic_bp_morning?: number;
|
||||
diastolic_bp_morning?: number;
|
||||
systolic_bp_evening?: number;
|
||||
diastolic_bp_evening?: number;
|
||||
heart_rate?: number;
|
||||
weight?: number;
|
||||
blood_sugar?: number;
|
||||
water_intake_ml?: number;
|
||||
urine_output_ml?: number;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface LabReport {
|
||||
id: string;
|
||||
patient_id: string;
|
||||
report_date: string;
|
||||
report_type: string;
|
||||
items?: unknown;
|
||||
image_urls?: string[];
|
||||
doctor_notes?: string;
|
||||
source?: string;
|
||||
status: string;
|
||||
reviewed_by?: string;
|
||||
reviewed_at?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
version: number;
|
||||
}
|
||||
|
||||
export interface CreateLabReportReq {
|
||||
report_date: string;
|
||||
report_type: string;
|
||||
items?: unknown;
|
||||
image_urls?: string[];
|
||||
doctor_notes?: string;
|
||||
}
|
||||
|
||||
export interface HealthRecord {
|
||||
id: string;
|
||||
patient_id: string;
|
||||
record_type: string;
|
||||
record_date: string;
|
||||
overall_assessment?: string;
|
||||
report_file_url?: string;
|
||||
source?: string;
|
||||
notes?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
version: number;
|
||||
}
|
||||
|
||||
export interface CreateHealthRecordReq {
|
||||
record_type: string;
|
||||
record_date: string;
|
||||
overall_assessment?: string;
|
||||
report_file_url?: string;
|
||||
}
|
||||
|
||||
export interface DailyMonitoring {
|
||||
id: string;
|
||||
patient_id: string;
|
||||
record_date: string;
|
||||
morning_bp_systolic?: number;
|
||||
morning_bp_diastolic?: number;
|
||||
evening_bp_systolic?: number;
|
||||
evening_bp_diastolic?: number;
|
||||
weight?: number;
|
||||
blood_sugar?: number;
|
||||
fluid_intake?: number;
|
||||
urine_output?: number;
|
||||
notes?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
version: number;
|
||||
}
|
||||
|
||||
export interface CreateDailyMonitoringReq {
|
||||
patient_id: string;
|
||||
record_date: string;
|
||||
morning_bp_systolic?: number;
|
||||
morning_bp_diastolic?: number;
|
||||
evening_bp_systolic?: number;
|
||||
evening_bp_diastolic?: number;
|
||||
weight?: number;
|
||||
blood_sugar?: number;
|
||||
fluid_intake?: number;
|
||||
urine_output?: number;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface TrendData {
|
||||
id: string;
|
||||
patient_id: string;
|
||||
indicator: string;
|
||||
trend_data: { date: string; value: number }[];
|
||||
generated_at: string;
|
||||
}
|
||||
|
||||
// --- API ---
|
||||
export const healthDataApi = {
|
||||
// Vital Signs
|
||||
listVitalSigns: async (
|
||||
patientId: string,
|
||||
params: { page?: number; page_size?: number },
|
||||
) => {
|
||||
const { data } = await client.get<{
|
||||
success: boolean;
|
||||
data: PaginatedResponse<VitalSigns>;
|
||||
}>(`/health/patients/${patientId}/vital-signs`, { params });
|
||||
return data.data;
|
||||
},
|
||||
|
||||
createVitalSigns: async (patientId: string, req: CreateVitalSignsReq) => {
|
||||
const { data } = await client.post<{
|
||||
success: boolean;
|
||||
data: VitalSigns;
|
||||
}>(`/health/patients/${patientId}/vital-signs`, req);
|
||||
return data.data;
|
||||
},
|
||||
|
||||
updateVitalSigns: async (
|
||||
patientId: string,
|
||||
id: string,
|
||||
req: Partial<CreateVitalSignsReq> & { version: number },
|
||||
) => {
|
||||
const { data } = await client.put<{
|
||||
success: boolean;
|
||||
data: VitalSigns;
|
||||
}>(`/health/patients/${patientId}/vital-signs/${id}`, req);
|
||||
return data.data;
|
||||
},
|
||||
|
||||
deleteVitalSigns: async (patientId: string, id: string) => {
|
||||
await client.delete(`/health/patients/${patientId}/vital-signs/${id}`);
|
||||
},
|
||||
|
||||
// Lab Reports
|
||||
listLabReports: async (
|
||||
patientId: string,
|
||||
params: { page?: number; page_size?: number },
|
||||
) => {
|
||||
const { data } = await client.get<{
|
||||
success: boolean;
|
||||
data: PaginatedResponse<LabReport>;
|
||||
}>(`/health/patients/${patientId}/lab-reports`, { params });
|
||||
return data.data;
|
||||
},
|
||||
|
||||
createLabReport: async (patientId: string, req: CreateLabReportReq) => {
|
||||
const { data } = await client.post<{
|
||||
success: boolean;
|
||||
data: LabReport;
|
||||
}>(`/health/patients/${patientId}/lab-reports`, req);
|
||||
return data.data;
|
||||
},
|
||||
|
||||
updateLabReport: async (
|
||||
patientId: string,
|
||||
id: string,
|
||||
req: Partial<CreateLabReportReq> & { version: number },
|
||||
) => {
|
||||
const { data } = await client.put<{
|
||||
success: boolean;
|
||||
data: LabReport;
|
||||
}>(`/health/patients/${patientId}/lab-reports/${id}`, req);
|
||||
return data.data;
|
||||
},
|
||||
|
||||
deleteLabReport: async (patientId: string, id: string) => {
|
||||
await client.delete(`/health/patients/${patientId}/lab-reports/${id}`);
|
||||
},
|
||||
|
||||
reviewLabReport: async (patientId: string, reportId: string, req: { version: number; doctor_notes?: string }) => {
|
||||
const { data } = await client.put<{
|
||||
success: boolean;
|
||||
data: Record<string, unknown>;
|
||||
}>(`/health/patients/${patientId}/lab-reports/${reportId}/review`, req);
|
||||
return data.data;
|
||||
},
|
||||
|
||||
// Health Records
|
||||
listHealthRecords: async (
|
||||
patientId: string,
|
||||
params: { page?: number; page_size?: number },
|
||||
) => {
|
||||
const { data } = await client.get<{
|
||||
success: boolean;
|
||||
data: PaginatedResponse<HealthRecord>;
|
||||
}>(`/health/patients/${patientId}/health-records`, { params });
|
||||
return data.data;
|
||||
},
|
||||
|
||||
createHealthRecord: async (
|
||||
patientId: string,
|
||||
req: CreateHealthRecordReq,
|
||||
) => {
|
||||
const { data } = await client.post<{
|
||||
success: boolean;
|
||||
data: HealthRecord;
|
||||
}>(`/health/patients/${patientId}/health-records`, req);
|
||||
return data.data;
|
||||
},
|
||||
|
||||
updateHealthRecord: async (
|
||||
patientId: string,
|
||||
id: string,
|
||||
req: Partial<CreateHealthRecordReq> & { version: number },
|
||||
) => {
|
||||
const { data } = await client.put<{
|
||||
success: boolean;
|
||||
data: HealthRecord;
|
||||
}>(`/health/patients/${patientId}/health-records/${id}`, req);
|
||||
return data.data;
|
||||
},
|
||||
|
||||
deleteHealthRecord: async (patientId: string, id: string) => {
|
||||
await client.delete(`/health/patients/${patientId}/health-records/${id}`);
|
||||
},
|
||||
|
||||
// Trends
|
||||
listTrends: async (patientId: string) => {
|
||||
const { data } = await client.get<{
|
||||
success: boolean;
|
||||
data: TrendData[];
|
||||
}>(`/health/patients/${patientId}/trends`);
|
||||
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;
|
||||
data: { date: string; value: number }[];
|
||||
}>(`/health/patients/${patientId}/trends/${encodeURIComponent(indicator)}`);
|
||||
return data.data;
|
||||
},
|
||||
|
||||
// Daily Monitoring
|
||||
listDailyMonitoring: async (
|
||||
patientId: string,
|
||||
params: { page?: number; page_size?: number },
|
||||
) => {
|
||||
const { data } = await client.get<{
|
||||
success: boolean;
|
||||
data: PaginatedResponse<DailyMonitoring>;
|
||||
}>(`/health/patients/${patientId}/daily-monitoring`, { params });
|
||||
return data.data;
|
||||
},
|
||||
|
||||
createDailyMonitoring: async (req: CreateDailyMonitoringReq) => {
|
||||
const { data } = await client.post<{
|
||||
success: boolean;
|
||||
data: DailyMonitoring;
|
||||
}>('/health/daily-monitoring', req);
|
||||
return data.data;
|
||||
},
|
||||
|
||||
updateDailyMonitoring: async (
|
||||
id: string,
|
||||
req: Partial<CreateDailyMonitoringReq> & { version: number },
|
||||
) => {
|
||||
const { data } = await client.put<{
|
||||
success: boolean;
|
||||
data: DailyMonitoring;
|
||||
}>(`/health/daily-monitoring/${id}`, req);
|
||||
return data.data;
|
||||
},
|
||||
|
||||
deleteDailyMonitoring: async (id: string, version: number) => {
|
||||
await client.delete(`/health/daily-monitoring/${id}`, { data: { version } });
|
||||
},
|
||||
};
|
||||
208
apps/web/src/api/health/media.ts
Normal file
208
apps/web/src/api/health/media.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
import client from '../client';
|
||||
import type { PaginatedResponse } from '../types';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 媒体文件类型
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface MediaItem {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
folder_id?: string;
|
||||
filename: string;
|
||||
storage_path: string;
|
||||
thumbnail_path?: string;
|
||||
content_type: string;
|
||||
file_size: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
alt_text?: string;
|
||||
is_public: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
created_by?: string;
|
||||
updated_by?: string;
|
||||
version: number;
|
||||
}
|
||||
|
||||
export interface MediaListParams {
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
folder_id?: string;
|
||||
content_type?: string;
|
||||
keyword?: string;
|
||||
is_public?: boolean;
|
||||
}
|
||||
|
||||
export interface UpdateMediaReq {
|
||||
filename?: string;
|
||||
alt_text?: string;
|
||||
is_public?: boolean;
|
||||
folder_id?: string;
|
||||
version: number;
|
||||
}
|
||||
|
||||
export interface MoveMediaReq {
|
||||
folder_id?: string;
|
||||
version: number;
|
||||
}
|
||||
|
||||
export interface CropReq {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
version: number;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 文件夹类型
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface FolderItem {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
name: string;
|
||||
parent_id?: string;
|
||||
sort_order: number;
|
||||
children: FolderItem[];
|
||||
item_count: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
version: number;
|
||||
}
|
||||
|
||||
export interface CreateFolderReq {
|
||||
name: string;
|
||||
parent_id?: string;
|
||||
sort_order?: number;
|
||||
}
|
||||
|
||||
export interface UpdateFolderReq {
|
||||
name?: string;
|
||||
parent_id?: string;
|
||||
sort_order?: number;
|
||||
version: number;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 媒体文件 API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const mediaApi = {
|
||||
/** 分页查询媒体文件列表 */
|
||||
list: async (params: MediaListParams) => {
|
||||
const { data } = await client.get<{
|
||||
success: boolean;
|
||||
data: PaginatedResponse<MediaItem>;
|
||||
}>('/health/media', { params });
|
||||
return data.data;
|
||||
},
|
||||
|
||||
/** 上传媒体文件(multipart/form-data) */
|
||||
upload: async (formData: FormData) => {
|
||||
const { data } = await client.post<{
|
||||
success: boolean;
|
||||
data: MediaItem;
|
||||
}>('/health/media/upload', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
});
|
||||
return data.data;
|
||||
},
|
||||
|
||||
/** 获取单个媒体文件详情 */
|
||||
get: async (id: string) => {
|
||||
const { data } = await client.get<{
|
||||
success: boolean;
|
||||
data: MediaItem;
|
||||
}>(`/health/media/${id}`);
|
||||
return data.data;
|
||||
},
|
||||
|
||||
/** 更新媒体文件信息 */
|
||||
update: async (id: string, req: UpdateMediaReq) => {
|
||||
const { data } = await client.put<{
|
||||
success: boolean;
|
||||
data: MediaItem;
|
||||
}>(`/health/media/${id}`, req);
|
||||
return data.data;
|
||||
},
|
||||
|
||||
/** 删除媒体文件 */
|
||||
delete: async (id: string, version: number) => {
|
||||
const { data } = await client.delete<{
|
||||
success: boolean;
|
||||
data: null;
|
||||
}>(`/health/media/${id}`, { data: { version } });
|
||||
return data.data;
|
||||
},
|
||||
|
||||
/** 移动媒体文件到指定文件夹 */
|
||||
move: async (id: string, req: MoveMediaReq) => {
|
||||
const { data } = await client.post<{
|
||||
success: boolean;
|
||||
data: MediaItem;
|
||||
}>(`/health/media/${id}/move`, req);
|
||||
return data.data;
|
||||
},
|
||||
|
||||
/** 批量删除媒体文件 */
|
||||
batchDelete: async (ids: string[]) => {
|
||||
const { data } = await client.post<{
|
||||
success: boolean;
|
||||
data: null;
|
||||
}>('/health/media/batch-delete', { ids });
|
||||
return data.data;
|
||||
},
|
||||
|
||||
/** 裁剪媒体文件 */
|
||||
crop: async (id: string, req: CropReq) => {
|
||||
const { data } = await client.post<{
|
||||
success: boolean;
|
||||
data: MediaItem;
|
||||
}>(`/health/media/${id}/crop`, req);
|
||||
return data.data;
|
||||
},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 文件夹 API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const mediaFolderApi = {
|
||||
/** 获取文件夹树形结构 */
|
||||
tree: async () => {
|
||||
const { data } = await client.get<{
|
||||
success: boolean;
|
||||
data: FolderItem[];
|
||||
}>('/health/media-folders');
|
||||
return data.data;
|
||||
},
|
||||
|
||||
/** 创建文件夹 */
|
||||
create: async (req: CreateFolderReq) => {
|
||||
const { data } = await client.post<{
|
||||
success: boolean;
|
||||
data: FolderItem;
|
||||
}>('/health/media-folders', req);
|
||||
return data.data;
|
||||
},
|
||||
|
||||
/** 更新文件夹 */
|
||||
update: async (id: string, req: UpdateFolderReq) => {
|
||||
const { data } = await client.put<{
|
||||
success: boolean;
|
||||
data: FolderItem;
|
||||
}>(`/health/media-folders/${id}`, req);
|
||||
return data.data;
|
||||
},
|
||||
|
||||
/** 删除文件夹 */
|
||||
delete: async (id: string, version: number) => {
|
||||
const { data } = await client.delete<{
|
||||
success: boolean;
|
||||
data: null;
|
||||
}>(`/health/media-folders/${id}`, { data: { version } });
|
||||
return data.data;
|
||||
},
|
||||
};
|
||||
111
apps/web/src/api/health/medicationRecords.ts
Normal file
111
apps/web/src/api/health/medicationRecords.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import client from '../client';
|
||||
import type { PaginatedResponse } from '../types';
|
||||
|
||||
// --- Types ---
|
||||
|
||||
export interface MedicationRecord {
|
||||
id: string;
|
||||
patient_id: string;
|
||||
medication_name: string;
|
||||
generic_name?: string;
|
||||
dosage?: string;
|
||||
unit?: string;
|
||||
frequency?: string;
|
||||
route?: string;
|
||||
start_date?: string;
|
||||
end_date?: string;
|
||||
is_current: boolean;
|
||||
prescribed_by?: string;
|
||||
notes?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
version: number;
|
||||
}
|
||||
|
||||
export interface CreateMedicationRecordReq {
|
||||
patient_id: string;
|
||||
medication_name: string;
|
||||
generic_name?: string;
|
||||
dosage?: string;
|
||||
unit?: string;
|
||||
frequency?: string;
|
||||
route?: string;
|
||||
start_date?: string;
|
||||
end_date?: string;
|
||||
is_current?: boolean;
|
||||
prescribed_by?: string;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface UpdateMedicationRecordReq {
|
||||
medication_name?: string;
|
||||
generic_name?: string;
|
||||
dosage?: string;
|
||||
unit?: string;
|
||||
frequency?: string;
|
||||
route?: string;
|
||||
start_date?: string;
|
||||
end_date?: string;
|
||||
is_current?: boolean;
|
||||
prescribed_by?: string;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
// --- Constants ---
|
||||
|
||||
export const FREQUENCY_OPTIONS = [
|
||||
{ label: '每日一次', value: 'QD' },
|
||||
{ label: '每日两次', value: 'BID' },
|
||||
{ label: '每日三次', value: 'TID' },
|
||||
{ label: '每晚一次', value: 'QN' },
|
||||
{ label: '每周一次', value: 'QW' },
|
||||
{ label: '必要时', value: 'PRN' },
|
||||
];
|
||||
|
||||
export const ROUTE_OPTIONS = [
|
||||
{ label: '口服', value: 'oral' },
|
||||
{ label: '静脉注射', value: 'iv' },
|
||||
{ label: '皮下注射', value: 'sc' },
|
||||
{ label: '外用', value: 'topical' },
|
||||
{ label: '吸入', value: 'inhalation' },
|
||||
];
|
||||
|
||||
// --- API ---
|
||||
|
||||
export const medicationRecordApi = {
|
||||
list: async (patientId: string, params?: { page?: number; page_size?: number }) => {
|
||||
const { data } = await client.get<{
|
||||
success: boolean;
|
||||
data: PaginatedResponse<MedicationRecord>;
|
||||
}>(`/health/patients/${patientId}/medications`, { params });
|
||||
return data.data;
|
||||
},
|
||||
|
||||
get: async (id: string) => {
|
||||
const { data } = await client.get<{
|
||||
success: boolean;
|
||||
data: MedicationRecord;
|
||||
}>(`/health/medications/${id}`);
|
||||
return data.data;
|
||||
},
|
||||
|
||||
create: async (req: CreateMedicationRecordReq) => {
|
||||
const { data } = await client.post<{
|
||||
success: boolean;
|
||||
data: MedicationRecord;
|
||||
}>('/health/medications', req);
|
||||
return data.data;
|
||||
},
|
||||
|
||||
update: async (id: string, req: UpdateMedicationRecordReq & { version: number }) => {
|
||||
const { data } = await client.put<{
|
||||
success: boolean;
|
||||
data: MedicationRecord;
|
||||
}>(`/health/medications/${id}`, req);
|
||||
return data.data;
|
||||
},
|
||||
|
||||
delete: async (id: string, version: number) => {
|
||||
await client.delete(`/health/medications/${id}`, { data: { version } });
|
||||
},
|
||||
};
|
||||
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;
|
||||
reminder_times: unknown;
|
||||
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;
|
||||
reminder_times?: unknown;
|
||||
start_date?: string;
|
||||
end_date?: string;
|
||||
is_active?: boolean;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface UpdateMedicationReminderReq {
|
||||
medication_name?: string;
|
||||
dosage?: string;
|
||||
frequency?: string;
|
||||
reminder_times?: unknown;
|
||||
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 } });
|
||||
},
|
||||
};
|
||||
73
apps/web/src/api/health/oauthClients.ts
Normal file
73
apps/web/src/api/health/oauthClients.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import client from '../client';
|
||||
|
||||
// --- Types ---
|
||||
export interface OAuthClient {
|
||||
id: string;
|
||||
client_id: string;
|
||||
client_name: string;
|
||||
scopes: string[];
|
||||
rate_limit_per_minute: number;
|
||||
is_active: boolean;
|
||||
token_lifetime_seconds: number;
|
||||
created_at: string;
|
||||
version: number;
|
||||
}
|
||||
|
||||
export interface OAuthClientDetail extends OAuthClient {
|
||||
tenant_id: string;
|
||||
client_secret: string;
|
||||
allowed_patient_ids?: string[];
|
||||
}
|
||||
|
||||
export interface CreateOAuthClientReq {
|
||||
client_name: string;
|
||||
scopes: string[];
|
||||
allowed_patient_ids?: string[];
|
||||
rate_limit_per_minute?: number;
|
||||
token_lifetime_seconds?: number;
|
||||
}
|
||||
|
||||
export interface UpdateOAuthClientReq {
|
||||
client_name?: string;
|
||||
scopes?: string[];
|
||||
allowed_patient_ids?: string[] | null;
|
||||
rate_limit_per_minute?: number;
|
||||
is_active?: boolean;
|
||||
token_lifetime_seconds?: number;
|
||||
version: number;
|
||||
}
|
||||
|
||||
export interface RegenerateSecretResp {
|
||||
client_id: string;
|
||||
client_secret: string;
|
||||
}
|
||||
|
||||
// --- FHIR Scope ---
|
||||
export const FHIR_SCOPE_OPTIONS = [
|
||||
{ value: 'Patient.read', label: 'Patient.read — 读取患者' },
|
||||
{ value: 'Observation.read', label: 'Observation.read — 读取体征' },
|
||||
{ value: 'Device.read', label: 'Device.read — 读取设备' },
|
||||
{ value: 'DiagnosticReport.read', label: 'DiagnosticReport.read — 读取诊断报告' },
|
||||
{ value: 'Encounter.read', label: 'Encounter.read — 读取就诊记录' },
|
||||
{ value: 'Practitioner.read', label: 'Practitioner.read — 读取医护' },
|
||||
{ value: 'Appointment.read', label: 'Appointment.read — 读取预约' },
|
||||
{ value: 'Task.read', label: 'Task.read — 读取随访任务' },
|
||||
];
|
||||
|
||||
// --- API ---
|
||||
export const oauthClientApi = {
|
||||
list: () =>
|
||||
client.get('/health/oauth/clients').then((r) => r.data.data as OAuthClient[]),
|
||||
|
||||
create: (data: CreateOAuthClientReq) =>
|
||||
client.post('/health/oauth/clients', data).then((r) => r.data.data as OAuthClientDetail),
|
||||
|
||||
update: (id: string, data: UpdateOAuthClientReq) =>
|
||||
client.put(`/health/oauth/clients/${id}`, data).then((r) => r.data.data as OAuthClient),
|
||||
|
||||
delete: (id: string) =>
|
||||
client.delete(`/health/oauth/clients/${id}`).then((r) => r.data),
|
||||
|
||||
regenerateSecret: (id: string) =>
|
||||
client.post(`/health/oauth/clients/${id}/regenerate-secret`).then((r) => r.data.data as RegenerateSecretResp),
|
||||
};
|
||||
126
apps/web/src/api/health/patients.test.ts
Normal file
126
apps/web/src/api/health/patients.test.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* patients API 契约测试
|
||||
*
|
||||
* 验证 patientApi 各函数调用正确的 HTTP 方法、URL 路径和参数序列化。
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
const mockGet = vi.fn()
|
||||
const mockPost = vi.fn()
|
||||
const mockPut = vi.fn()
|
||||
const mockDelete = vi.fn()
|
||||
|
||||
vi.mock('../client', () => ({
|
||||
default: {
|
||||
get: (...args: unknown[]) => mockGet(...args),
|
||||
post: (...args: unknown[]) => mockPost(...args),
|
||||
put: (...args: unknown[]) => mockPut(...args),
|
||||
delete: (...args: unknown[]) => mockDelete(...args),
|
||||
},
|
||||
}))
|
||||
|
||||
import { patientApi } from './patients'
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('patientApi', () => {
|
||||
const fakeRes = { data: { success: true, data: {} } }
|
||||
|
||||
it('list 应调用 GET /health/patients 并传递查询参数', async () => {
|
||||
mockGet.mockResolvedValue(fakeRes)
|
||||
await patientApi.list({ page: 1, page_size: 20, search: '张三', status: 'active' })
|
||||
|
||||
expect(mockGet).toHaveBeenCalledWith('/health/patients', {
|
||||
params: { page: 1, page_size: 20, search: '张三', status: 'active' },
|
||||
})
|
||||
})
|
||||
|
||||
it('list 应支持 tag_id 过滤参数', async () => {
|
||||
mockGet.mockResolvedValue(fakeRes)
|
||||
await patientApi.list({ tag_id: 'tag-001' })
|
||||
|
||||
expect(mockGet).toHaveBeenCalledWith('/health/patients', {
|
||||
params: { tag_id: 'tag-001' },
|
||||
})
|
||||
})
|
||||
|
||||
it('get 应调用 GET /health/patients/:id', async () => {
|
||||
mockGet.mockResolvedValue(fakeRes)
|
||||
await patientApi.get('p-001')
|
||||
|
||||
expect(mockGet).toHaveBeenCalledWith('/health/patients/p-001')
|
||||
})
|
||||
|
||||
it('create 应调用 POST /health/patients 并传递请求体', async () => {
|
||||
mockPost.mockResolvedValue(fakeRes)
|
||||
const req = { name: '李四', gender: 'male', birth_date: '1990-01-01' }
|
||||
await patientApi.create(req)
|
||||
|
||||
expect(mockPost).toHaveBeenCalledWith('/health/patients', req)
|
||||
})
|
||||
|
||||
it('update 应调用 PUT /health/patients/:id 并传递请求体含 version', async () => {
|
||||
mockPut.mockResolvedValue(fakeRes)
|
||||
const req = { name: '李四改', version: 2 }
|
||||
await patientApi.update('p-001', req)
|
||||
|
||||
expect(mockPut).toHaveBeenCalledWith('/health/patients/p-001', req)
|
||||
})
|
||||
|
||||
it('delete 应调用 DELETE /health/patients/:id 并在 body 中传递 version', async () => {
|
||||
mockDelete.mockResolvedValue(undefined)
|
||||
await patientApi.delete('p-001', 3)
|
||||
|
||||
expect(mockDelete).toHaveBeenCalledWith('/health/patients/p-001', {
|
||||
data: { version: 3 },
|
||||
})
|
||||
})
|
||||
|
||||
it('manageTags 应调用 POST /health/patients/:id/tags 并传递 tag_ids', async () => {
|
||||
mockPost.mockResolvedValue(undefined)
|
||||
await patientApi.manageTags('p-001', ['tag-1', 'tag-2'])
|
||||
|
||||
expect(mockPost).toHaveBeenCalledWith('/health/patients/p-001/tags', {
|
||||
tag_ids: ['tag-1', 'tag-2'],
|
||||
})
|
||||
})
|
||||
|
||||
it('listFamilyMembers 应调用 GET /health/patients/:id/family-members', async () => {
|
||||
mockGet.mockResolvedValue(fakeRes)
|
||||
await patientApi.listFamilyMembers('p-001')
|
||||
|
||||
expect(mockGet).toHaveBeenCalledWith('/health/patients/p-001/family-members')
|
||||
})
|
||||
|
||||
it('createFamilyMember 应调用 POST /health/patients/:id/family-members', async () => {
|
||||
mockPost.mockResolvedValue(fakeRes)
|
||||
const req = { name: '家属A', relationship: 'spouse', phone: '13800138000' }
|
||||
await patientApi.createFamilyMember('p-001', req)
|
||||
|
||||
expect(mockPost).toHaveBeenCalledWith('/health/patients/p-001/family-members', req)
|
||||
})
|
||||
|
||||
it('updateFamilyMember 应调用 PUT /health/patients/:pid/family-members/:mid', async () => {
|
||||
mockPut.mockResolvedValue(fakeRes)
|
||||
const req = { name: '家属A改', version: 1 }
|
||||
await patientApi.updateFamilyMember('p-001', 'fm-001', req)
|
||||
|
||||
expect(mockPut).toHaveBeenCalledWith('/health/patients/p-001/family-members/fm-001', req)
|
||||
})
|
||||
|
||||
it('deleteFamilyMember 应调用 DELETE /health/patients/:pid/family-members/:mid', async () => {
|
||||
mockDelete.mockResolvedValue(undefined)
|
||||
await patientApi.deleteFamilyMember('p-001', 'fm-001')
|
||||
|
||||
expect(mockDelete).toHaveBeenCalledWith('/health/patients/p-001/family-members/fm-001')
|
||||
})
|
||||
|
||||
it('listTags 应调用 GET /health/patient-tags', async () => {
|
||||
mockGet.mockResolvedValue(fakeRes)
|
||||
await patientApi.listTags()
|
||||
|
||||
expect(mockGet).toHaveBeenCalledWith('/health/patient-tags')
|
||||
})
|
||||
})
|
||||
175
apps/web/src/api/health/patients.ts
Normal file
175
apps/web/src/api/health/patients.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
import client from '../client';
|
||||
import type { PaginatedResponse } from '../types';
|
||||
|
||||
// --- Types ---
|
||||
export interface PatientListItem {
|
||||
id: string;
|
||||
name: string;
|
||||
gender?: string;
|
||||
birth_date?: string;
|
||||
blood_type?: string;
|
||||
status: string;
|
||||
verification_status: string;
|
||||
source?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
version: number;
|
||||
}
|
||||
|
||||
export interface PatientDetail {
|
||||
id: string;
|
||||
user_id?: string;
|
||||
name: string;
|
||||
gender?: string;
|
||||
birth_date?: string;
|
||||
blood_type?: string;
|
||||
id_number?: string;
|
||||
allergy_history?: string;
|
||||
medical_history_summary?: string;
|
||||
emergency_contact_name?: string;
|
||||
emergency_contact_phone?: string;
|
||||
status: string;
|
||||
verification_status: string;
|
||||
source?: string;
|
||||
notes?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
version: number;
|
||||
}
|
||||
|
||||
export interface CreatePatientReq {
|
||||
name: string;
|
||||
gender?: string;
|
||||
birth_date?: string;
|
||||
blood_type?: string;
|
||||
id_number?: string;
|
||||
allergy_history?: string;
|
||||
medical_history_summary?: string;
|
||||
emergency_contact_name?: string;
|
||||
emergency_contact_phone?: string;
|
||||
source?: string;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface UpdatePatientReq extends Partial<CreatePatientReq> {
|
||||
status?: string;
|
||||
verification_status?: string;
|
||||
}
|
||||
|
||||
export interface FamilyMember {
|
||||
id: string;
|
||||
name: string;
|
||||
relationship: string;
|
||||
phone?: string;
|
||||
id_number?: string;
|
||||
notes?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
version: number;
|
||||
}
|
||||
|
||||
export interface CreateFamilyMemberReq {
|
||||
name: string;
|
||||
relationship: string;
|
||||
phone?: string;
|
||||
id_number?: string;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface TagItem {
|
||||
id: string;
|
||||
name: string;
|
||||
color: string | null;
|
||||
description: string | null;
|
||||
}
|
||||
|
||||
// --- API ---
|
||||
export const patientApi = {
|
||||
list: async (params: {
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
search?: string;
|
||||
status?: string;
|
||||
tag_id?: string;
|
||||
}) => {
|
||||
const { data } = await client.get<{
|
||||
success: boolean;
|
||||
data: PaginatedResponse<PatientListItem>;
|
||||
}>('/health/patients', { params });
|
||||
return data.data;
|
||||
},
|
||||
|
||||
get: async (id: string) => {
|
||||
const { data } = await client.get<{
|
||||
success: boolean;
|
||||
data: PatientDetail;
|
||||
}>(`/health/patients/${id}`);
|
||||
return data.data;
|
||||
},
|
||||
|
||||
create: async (req: CreatePatientReq) => {
|
||||
const { data } = await client.post<{
|
||||
success: boolean;
|
||||
data: PatientDetail;
|
||||
}>('/health/patients', req);
|
||||
return data.data;
|
||||
},
|
||||
|
||||
update: async (id: string, req: UpdatePatientReq & { version: number }) => {
|
||||
const { data } = await client.put<{
|
||||
success: boolean;
|
||||
data: PatientDetail;
|
||||
}>(`/health/patients/${id}`, req);
|
||||
return data.data;
|
||||
},
|
||||
|
||||
delete: async (id: string, version: number) => {
|
||||
await client.delete(`/health/patients/${id}`, { data: { version } });
|
||||
},
|
||||
|
||||
manageTags: async (id: string, tagIds: string[]) => {
|
||||
await client.post(`/health/patients/${id}/tags`, { tag_ids: tagIds });
|
||||
},
|
||||
|
||||
listFamilyMembers: async (id: string) => {
|
||||
const { data } = await client.get<{
|
||||
success: boolean;
|
||||
data: FamilyMember[];
|
||||
}>(`/health/patients/${id}/family-members`);
|
||||
return data.data;
|
||||
},
|
||||
|
||||
createFamilyMember: async (id: string, req: CreateFamilyMemberReq) => {
|
||||
const { data } = await client.post<{
|
||||
success: boolean;
|
||||
data: FamilyMember;
|
||||
}>(`/health/patients/${id}/family-members`, req);
|
||||
return data.data;
|
||||
},
|
||||
|
||||
updateFamilyMember: async (
|
||||
patientId: string,
|
||||
memberId: string,
|
||||
req: Partial<CreateFamilyMemberReq> & { version: number },
|
||||
) => {
|
||||
const { data } = await client.put<{
|
||||
success: boolean;
|
||||
data: FamilyMember;
|
||||
}>(`/health/patients/${patientId}/family-members/${memberId}`, req);
|
||||
return data.data;
|
||||
},
|
||||
|
||||
deleteFamilyMember: async (patientId: string, memberId: string) => {
|
||||
await client.delete(
|
||||
`/health/patients/${patientId}/family-members/${memberId}`,
|
||||
);
|
||||
},
|
||||
|
||||
listTags: async () => {
|
||||
const { data } = await client.get<{
|
||||
success: boolean;
|
||||
data: TagItem[];
|
||||
}>('/health/patient-tags');
|
||||
return data.data;
|
||||
},
|
||||
};
|
||||
230
apps/web/src/api/health/points.test.ts
Normal file
230
apps/web/src/api/health/points.test.ts
Normal file
@@ -0,0 +1,230 @@
|
||||
/**
|
||||
* points API 契约测试(完整覆盖 pointsApi + pointsAdminApi)
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
const mockGet = vi.fn()
|
||||
const mockPost = vi.fn()
|
||||
const mockPut = vi.fn()
|
||||
const mockDelete = vi.fn()
|
||||
|
||||
vi.mock('../client', () => ({
|
||||
default: {
|
||||
get: (...args: unknown[]) => mockGet(...args),
|
||||
post: (...args: unknown[]) => mockPost(...args),
|
||||
put: (...args: unknown[]) => mockPut(...args),
|
||||
delete: (...args: unknown[]) => mockDelete(...args),
|
||||
},
|
||||
}))
|
||||
|
||||
import { pointsApi, pointsAdminApi } from './points'
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('pointsAdminApi', () => {
|
||||
const fakeRes = { data: { success: true, data: {} } }
|
||||
|
||||
it('getPatientAccount 应调用 GET /health/admin/points/patients/:id/account', async () => {
|
||||
mockGet.mockResolvedValue(fakeRes)
|
||||
await pointsAdminApi.getPatientAccount('p-001')
|
||||
|
||||
expect(mockGet).toHaveBeenCalledWith('/health/admin/points/patients/p-001/account')
|
||||
})
|
||||
|
||||
it('listPatientTransactions 应调用 GET 并传递分页参数', async () => {
|
||||
mockGet.mockResolvedValue(fakeRes)
|
||||
await pointsAdminApi.listPatientTransactions('p-001', { page: 2, page_size: 15 })
|
||||
|
||||
expect(mockGet).toHaveBeenCalledWith('/health/admin/points/patients/p-001/transactions', {
|
||||
params: { page: 2, page_size: 15 },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('pointsApi - Rules', () => {
|
||||
const fakeRes = { data: { success: true, data: {} } }
|
||||
|
||||
it('listRules 应调用 GET /health/admin/points/rules', async () => {
|
||||
mockGet.mockResolvedValue(fakeRes)
|
||||
await pointsApi.listRules()
|
||||
|
||||
expect(mockGet).toHaveBeenCalledWith('/health/admin/points/rules')
|
||||
})
|
||||
|
||||
it('createRule 应调用 POST /health/admin/points/rules', async () => {
|
||||
mockPost.mockResolvedValue(fakeRes)
|
||||
const req = { event_type: 'daily_checkin', name: '每日签到', points_value: 10, daily_cap: 1 }
|
||||
await pointsApi.createRule(req)
|
||||
|
||||
expect(mockPost).toHaveBeenCalledWith('/health/admin/points/rules', req)
|
||||
})
|
||||
|
||||
it('updateRule 应调用 PUT /health/admin/points/rules/:id', async () => {
|
||||
mockPut.mockResolvedValue(fakeRes)
|
||||
const req = { points_value: 20, version: 1 }
|
||||
await pointsApi.updateRule('rule-001', req)
|
||||
|
||||
expect(mockPut).toHaveBeenCalledWith('/health/admin/points/rules/rule-001', {
|
||||
data: req,
|
||||
version: req.version,
|
||||
})
|
||||
})
|
||||
|
||||
it('deleteRule 应调用 DELETE /health/admin/points/rules/:id', async () => {
|
||||
mockDelete.mockResolvedValue(undefined)
|
||||
await pointsApi.deleteRule('rule-001', 2)
|
||||
|
||||
expect(mockDelete).toHaveBeenCalledWith('/health/admin/points/rules/rule-001', {
|
||||
data: { version: 2 },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('pointsApi - Products', () => {
|
||||
const fakeRes = { data: { success: true, data: {} } }
|
||||
|
||||
it('listProducts 应调用 GET /health/points/products', async () => {
|
||||
mockGet.mockResolvedValue(fakeRes)
|
||||
await pointsApi.listProducts()
|
||||
|
||||
expect(mockGet).toHaveBeenCalledWith('/health/points/products', { params: undefined })
|
||||
})
|
||||
|
||||
it('createProduct 应调用 POST /health/admin/points/products', async () => {
|
||||
mockPost.mockResolvedValue(fakeRes)
|
||||
const req = { name: '体检优惠券', product_type: 'service', points_cost: 500, stock: 100 }
|
||||
await pointsApi.createProduct(req)
|
||||
|
||||
expect(mockPost).toHaveBeenCalledWith('/health/admin/points/products', req)
|
||||
})
|
||||
|
||||
it('updateProduct 应调用 PUT /health/admin/points/products/:id', async () => {
|
||||
mockPut.mockResolvedValue(fakeRes)
|
||||
const req = { points_cost: 600, version: 1 }
|
||||
await pointsApi.updateProduct('prod-001', req)
|
||||
|
||||
expect(mockPut).toHaveBeenCalledWith('/health/admin/points/products/prod-001', {
|
||||
data: req,
|
||||
version: req.version,
|
||||
})
|
||||
})
|
||||
|
||||
it('deleteProduct 应调用 DELETE /health/admin/points/products/:id', async () => {
|
||||
mockDelete.mockResolvedValue(undefined)
|
||||
await pointsApi.deleteProduct('prod-001', 1)
|
||||
|
||||
expect(mockDelete).toHaveBeenCalledWith('/health/admin/points/products/prod-001', {
|
||||
data: { version: 1 },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('pointsApi - Orders', () => {
|
||||
const fakeRes = { data: { success: true, data: {} } }
|
||||
|
||||
it('listOrders 应调用 GET /health/admin/points/orders', async () => {
|
||||
mockGet.mockResolvedValue(fakeRes)
|
||||
await pointsApi.listOrders()
|
||||
|
||||
expect(mockGet).toHaveBeenCalledWith('/health/admin/points/orders', { params: undefined })
|
||||
})
|
||||
|
||||
it('verifyOrder 应调用 POST /health/points/verify', async () => {
|
||||
mockPost.mockResolvedValue(fakeRes)
|
||||
const req = { qr_code: 'QR-123456' }
|
||||
await pointsApi.verifyOrder(req)
|
||||
|
||||
expect(mockPost).toHaveBeenCalledWith('/health/points/verify', req)
|
||||
})
|
||||
})
|
||||
|
||||
describe('pointsApi - Offline Events', () => {
|
||||
const fakeRes = { data: { success: true, data: {} } }
|
||||
|
||||
it('listOfflineEvents 应调用 GET /health/admin/offline-events', async () => {
|
||||
mockGet.mockResolvedValue(fakeRes)
|
||||
await pointsApi.listOfflineEvents()
|
||||
|
||||
expect(mockGet).toHaveBeenCalledWith('/health/admin/offline-events', { params: undefined })
|
||||
})
|
||||
|
||||
it('createOfflineEvent 应调用 POST /health/admin/offline-events', async () => {
|
||||
mockPost.mockResolvedValue(fakeRes)
|
||||
const req = { title: '健康讲座', event_date: '2026-05-20', points_reward: 50 }
|
||||
await pointsApi.createOfflineEvent(req)
|
||||
|
||||
expect(mockPost).toHaveBeenCalledWith('/health/admin/offline-events', req)
|
||||
})
|
||||
|
||||
it('updateOfflineEvent 应调用 PUT /health/admin/offline-events/:id', async () => {
|
||||
mockPut.mockResolvedValue(fakeRes)
|
||||
const req = { title: '健康讲座(更新)', version: 1 }
|
||||
await pointsApi.updateOfflineEvent('evt-001', req)
|
||||
|
||||
expect(mockPut).toHaveBeenCalledWith('/health/admin/offline-events/evt-001', req)
|
||||
})
|
||||
|
||||
it('deleteOfflineEvent 应调用 DELETE /health/admin/offline-events/:id', async () => {
|
||||
mockDelete.mockResolvedValue(undefined)
|
||||
await pointsApi.deleteOfflineEvent('evt-001', 1)
|
||||
|
||||
expect(mockDelete).toHaveBeenCalledWith('/health/admin/offline-events/evt-001', {
|
||||
data: { version: 1 },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('pointsApi - Statistics', () => {
|
||||
const fakeRes = { data: { success: true, data: {} } }
|
||||
|
||||
it('getStatistics 应调用 GET /health/admin/points/statistics', async () => {
|
||||
mockGet.mockResolvedValue(fakeRes)
|
||||
await pointsApi.getStatistics()
|
||||
|
||||
expect(mockGet).toHaveBeenCalledWith('/health/admin/points/statistics')
|
||||
})
|
||||
|
||||
it('getPatientStats 应调用 GET /health/admin/statistics/patients', async () => {
|
||||
mockGet.mockResolvedValue(fakeRes)
|
||||
await pointsApi.getPatientStats()
|
||||
|
||||
expect(mockGet).toHaveBeenCalledWith('/health/admin/statistics/patients')
|
||||
})
|
||||
|
||||
it('getConsultationStats 应调用 GET /health/admin/statistics/consultations', async () => {
|
||||
mockGet.mockResolvedValue(fakeRes)
|
||||
await pointsApi.getConsultationStats()
|
||||
|
||||
expect(mockGet).toHaveBeenCalledWith('/health/admin/statistics/consultations')
|
||||
})
|
||||
|
||||
it('getFollowUpStats 应调用 GET /health/admin/statistics/follow-ups', async () => {
|
||||
mockGet.mockResolvedValue(fakeRes)
|
||||
await pointsApi.getFollowUpStats()
|
||||
|
||||
expect(mockGet).toHaveBeenCalledWith('/health/admin/statistics/follow-ups')
|
||||
})
|
||||
|
||||
it('getHealthDataStats 应调用 GET /health/admin/statistics/health-data', async () => {
|
||||
mockGet.mockResolvedValue(fakeRes)
|
||||
await pointsApi.getHealthDataStats()
|
||||
|
||||
expect(mockGet).toHaveBeenCalledWith('/health/admin/statistics/health-data')
|
||||
})
|
||||
|
||||
it('getDialysisStats 应调用 GET /health/admin/statistics/dialysis', async () => {
|
||||
mockGet.mockResolvedValue(fakeRes)
|
||||
await pointsApi.getDialysisStats()
|
||||
|
||||
expect(mockGet).toHaveBeenCalledWith('/health/admin/statistics/dialysis')
|
||||
})
|
||||
|
||||
it('getPersonalStats 应调用 GET /health/admin/statistics/personal-stats', async () => {
|
||||
mockGet.mockResolvedValue(fakeRes)
|
||||
await pointsApi.getPersonalStats()
|
||||
|
||||
expect(mockGet).toHaveBeenCalledWith('/health/admin/statistics/personal-stats')
|
||||
})
|
||||
})
|
||||
446
apps/web/src/api/health/points.ts
Normal file
446
apps/web/src/api/health/points.ts
Normal file
@@ -0,0 +1,446 @@
|
||||
import client from '../client';
|
||||
import type { PaginatedResponse } from '../types';
|
||||
|
||||
// --- Types ---
|
||||
|
||||
export interface PointsRule {
|
||||
id: string;
|
||||
event_type: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
points_value: number;
|
||||
daily_cap: number;
|
||||
streak_7d_bonus: number;
|
||||
streak_14d_bonus: number;
|
||||
streak_30d_bonus: number;
|
||||
is_active: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
version: number;
|
||||
}
|
||||
|
||||
export interface CreatePointsRuleReq {
|
||||
event_type: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
points_value: number;
|
||||
daily_cap?: number;
|
||||
streak_7d_bonus?: number;
|
||||
streak_14d_bonus?: number;
|
||||
streak_30d_bonus?: number;
|
||||
}
|
||||
|
||||
export interface PointsProduct {
|
||||
id: string;
|
||||
name: string;
|
||||
product_type: string; // physical / service / privilege
|
||||
points_cost: number;
|
||||
stock: number;
|
||||
image_url: string | null;
|
||||
description: string | null;
|
||||
is_active: boolean;
|
||||
sort_order: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
version: number;
|
||||
}
|
||||
|
||||
export interface CreatePointsProductReq {
|
||||
name: string;
|
||||
product_type: string;
|
||||
points_cost: number;
|
||||
stock: number;
|
||||
description?: string;
|
||||
image_url?: string;
|
||||
sort_order?: number;
|
||||
}
|
||||
|
||||
export interface PointsOrder {
|
||||
id: string;
|
||||
patient_id: string;
|
||||
product_id: string;
|
||||
product_name: string | null;
|
||||
points_cost: number;
|
||||
status: string; // pending / verified / cancelled / expired
|
||||
qr_code: string;
|
||||
verified_by: string | null;
|
||||
verified_at: string | null;
|
||||
expires_at: string | null;
|
||||
notes: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
version: number;
|
||||
}
|
||||
|
||||
export interface VerifyOrderReq {
|
||||
qr_code: string;
|
||||
}
|
||||
|
||||
export interface OfflineEvent {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
event_date: string;
|
||||
start_time: string | null;
|
||||
end_time: string | null;
|
||||
location: string | null;
|
||||
points_reward: number;
|
||||
max_participants: number;
|
||||
current_participants: number;
|
||||
status: string; // draft / published / ongoing / completed / cancelled
|
||||
image_url: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
version: number;
|
||||
}
|
||||
|
||||
export interface CreateOfflineEventReq {
|
||||
title: string;
|
||||
description?: string;
|
||||
event_date: string;
|
||||
start_time?: string;
|
||||
end_time?: string;
|
||||
location?: string;
|
||||
points_reward?: number;
|
||||
max_participants?: number;
|
||||
status?: string;
|
||||
image_url?: string;
|
||||
}
|
||||
|
||||
export interface PointsStatistics {
|
||||
total_issued: number;
|
||||
total_spent: number;
|
||||
total_expired: number;
|
||||
active_accounts: number;
|
||||
top_earners: Array<{
|
||||
account_id: string;
|
||||
patient_id: string;
|
||||
patient_name: string;
|
||||
total_earned: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface PatientStatistics {
|
||||
total_patients: number;
|
||||
new_this_month: number;
|
||||
new_this_week: number;
|
||||
active_this_month: number;
|
||||
}
|
||||
|
||||
export interface ConsultationStatistics {
|
||||
total_sessions: number;
|
||||
pending_reply: number;
|
||||
avg_response_time_minutes: number | null;
|
||||
this_month: number;
|
||||
}
|
||||
|
||||
export interface FollowUpStatistics {
|
||||
total_tasks: number;
|
||||
completed: number;
|
||||
pending: number;
|
||||
overdue: number;
|
||||
completion_rate: number;
|
||||
}
|
||||
|
||||
export interface PersonalStats {
|
||||
my_patients: number;
|
||||
new_patients_this_month: number;
|
||||
follow_up_rate: number;
|
||||
consultations_this_month: number;
|
||||
pending_consultations: number;
|
||||
vital_signs_report_rate: number;
|
||||
today_appointments: number;
|
||||
overdue_follow_ups: number;
|
||||
today_follow_ups: number;
|
||||
abnormal_vital_signs: number;
|
||||
vital_signs_reported: number;
|
||||
vital_signs_total: number;
|
||||
pending_lab_reviews: number;
|
||||
yesterday_my_patients?: number;
|
||||
yesterday_today_appointments?: number;
|
||||
yesterday_consultations_this_month?: number;
|
||||
yesterday_follow_up_rate?: number;
|
||||
yesterday_today_follow_ups?: number;
|
||||
yesterday_overdue_follow_ups?: number;
|
||||
}
|
||||
|
||||
export interface OverviewStatistics {
|
||||
patients: PatientStatistics;
|
||||
consultations: ConsultationStatistics;
|
||||
follow_ups: FollowUpStatistics;
|
||||
points: PointsStatistics;
|
||||
}
|
||||
|
||||
// --- Health Data Statistics Types ---
|
||||
|
||||
export interface NameValue {
|
||||
name: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
export interface DialysisStatistics {
|
||||
total_records: number;
|
||||
this_month: number;
|
||||
type_distribution: NameValue[];
|
||||
complication_rate: number;
|
||||
avg_ultrafiltration: number | null;
|
||||
avg_duration: number | null;
|
||||
pending_review: number;
|
||||
}
|
||||
|
||||
export interface LabReportStatistics {
|
||||
total_reports: number;
|
||||
this_month: number;
|
||||
type_distribution: NameValue[];
|
||||
abnormal_items: number;
|
||||
pending_review: number;
|
||||
reviewed: number;
|
||||
}
|
||||
|
||||
export interface AppointmentStatistics {
|
||||
total_appointments: number;
|
||||
this_month: number;
|
||||
status_distribution: NameValue[];
|
||||
type_distribution: NameValue[];
|
||||
cancel_rate: number;
|
||||
}
|
||||
|
||||
export interface DailyReportRate {
|
||||
date: string;
|
||||
reported: number;
|
||||
total: number;
|
||||
rate: number;
|
||||
}
|
||||
|
||||
export interface VitalSignsReportRate {
|
||||
total_patients: number;
|
||||
reported_patients: number;
|
||||
report_rate: number;
|
||||
total_records: number;
|
||||
daily_trend: DailyReportRate[];
|
||||
}
|
||||
|
||||
export interface HealthDataStats {
|
||||
lab_reports: LabReportStatistics;
|
||||
appointments: AppointmentStatistics;
|
||||
vital_signs_report_rate: VitalSignsReportRate;
|
||||
}
|
||||
|
||||
// --- API ---
|
||||
|
||||
export interface PointsAccountDetail {
|
||||
id: string;
|
||||
patient_id: string;
|
||||
balance: number;
|
||||
total_earned: number;
|
||||
total_spent: number;
|
||||
total_expired: number;
|
||||
}
|
||||
|
||||
export interface PointsTransactionDetail {
|
||||
id: string;
|
||||
account_id: string;
|
||||
transaction_type: string;
|
||||
amount: number;
|
||||
remaining_amount: number;
|
||||
status: string;
|
||||
expires_at: string | null;
|
||||
balance_after: number;
|
||||
description: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export const pointsAdminApi = {
|
||||
getPatientAccount: async (patientId: string) => {
|
||||
const { data } = await client.get<{
|
||||
success: boolean;
|
||||
data: PointsAccountDetail;
|
||||
}>(`/health/admin/points/patients/${patientId}/account`);
|
||||
return data.data;
|
||||
},
|
||||
|
||||
listPatientTransactions: async (
|
||||
patientId: string,
|
||||
params: { page?: number; page_size?: number },
|
||||
) => {
|
||||
const { data } = await client.get<{
|
||||
success: boolean;
|
||||
data: PaginatedResponse<PointsTransactionDetail>;
|
||||
}>(`/health/admin/points/patients/${patientId}/transactions`, { params });
|
||||
return data.data;
|
||||
},
|
||||
};
|
||||
|
||||
// --- API (original) ---
|
||||
|
||||
export const pointsApi = {
|
||||
// Rules
|
||||
listRules: async () => {
|
||||
const { data } = await client.get<{
|
||||
success: boolean;
|
||||
data: PointsRule[];
|
||||
}>('/health/admin/points/rules');
|
||||
return data.data;
|
||||
},
|
||||
|
||||
createRule: async (req: CreatePointsRuleReq) => {
|
||||
const { data } = await client.post<{
|
||||
success: boolean;
|
||||
data: PointsRule;
|
||||
}>('/health/admin/points/rules', req);
|
||||
return data.data;
|
||||
},
|
||||
|
||||
updateRule: async (id: string, req: Partial<CreatePointsRuleReq> & { is_active?: boolean; version: number }) => {
|
||||
const { data } = await client.put<{
|
||||
success: boolean;
|
||||
data: PointsRule;
|
||||
}>(`/health/admin/points/rules/${id}`, req);
|
||||
return data.data;
|
||||
},
|
||||
|
||||
deleteRule: async (id: string, version: number) => {
|
||||
await client.delete(`/health/admin/points/rules/${id}`, {
|
||||
data: { version },
|
||||
});
|
||||
},
|
||||
|
||||
// Products
|
||||
listProducts: async (params?: Record<string, unknown>) => {
|
||||
const { data } = await client.get<{
|
||||
success: boolean;
|
||||
data: PaginatedResponse<PointsProduct>;
|
||||
}>('/health/points/products', { params });
|
||||
return data.data;
|
||||
},
|
||||
|
||||
createProduct: async (req: CreatePointsProductReq) => {
|
||||
const { data } = await client.post<{
|
||||
success: boolean;
|
||||
data: PointsProduct;
|
||||
}>('/health/admin/points/products', req);
|
||||
return data.data;
|
||||
},
|
||||
|
||||
updateProduct: async (id: string, req: Partial<CreatePointsProductReq> & { is_active?: boolean; version: number }) => {
|
||||
const { version, ...fields } = req;
|
||||
const { data } = await client.put<{
|
||||
success: boolean;
|
||||
data: PointsProduct;
|
||||
}>(`/health/admin/points/products/${id}`, { data: fields, version });
|
||||
return data.data;
|
||||
},
|
||||
|
||||
deleteProduct: async (id: string, version: number) => {
|
||||
await client.delete(`/health/admin/points/products/${id}`, {
|
||||
data: { version },
|
||||
});
|
||||
},
|
||||
|
||||
// Orders
|
||||
listOrders: async (params?: Record<string, unknown>) => {
|
||||
const { data } = await client.get<{
|
||||
success: boolean;
|
||||
data: PaginatedResponse<PointsOrder>;
|
||||
}>('/health/admin/points/orders', { params });
|
||||
return data.data;
|
||||
},
|
||||
|
||||
verifyOrder: async (req: VerifyOrderReq) => {
|
||||
const { data } = await client.post<{
|
||||
success: boolean;
|
||||
data: PointsOrder;
|
||||
}>('/health/points/verify', req);
|
||||
return data.data;
|
||||
},
|
||||
|
||||
// Offline Events
|
||||
listOfflineEvents: async (params?: Record<string, unknown>) => {
|
||||
const { data } = await client.get<{
|
||||
success: boolean;
|
||||
data: PaginatedResponse<OfflineEvent>;
|
||||
}>('/health/admin/offline-events', { params });
|
||||
return data.data;
|
||||
},
|
||||
|
||||
createOfflineEvent: async (req: CreateOfflineEventReq) => {
|
||||
const { data } = await client.post<{
|
||||
success: boolean;
|
||||
data: OfflineEvent;
|
||||
}>('/health/admin/offline-events', req);
|
||||
return data.data;
|
||||
},
|
||||
|
||||
updateOfflineEvent: async (id: string, req: Partial<CreateOfflineEventReq> & { version: number }) => {
|
||||
const { data } = await client.put<{
|
||||
success: boolean;
|
||||
data: OfflineEvent;
|
||||
}>(`/health/admin/offline-events/${id}`, req);
|
||||
return data.data;
|
||||
},
|
||||
|
||||
deleteOfflineEvent: async (id: string, version: number) => {
|
||||
await client.delete(`/health/admin/offline-events/${id}`, {
|
||||
data: { version },
|
||||
});
|
||||
},
|
||||
|
||||
// Points Statistics
|
||||
getStatistics: async (opts?: { silent?: boolean }) => {
|
||||
const { data } = await client.get<{
|
||||
success: boolean;
|
||||
data: PointsStatistics;
|
||||
}>('/health/admin/points/statistics', { skipGlobalError: opts?.silent });
|
||||
return data.data;
|
||||
},
|
||||
|
||||
// --- Dashboard Statistics ---
|
||||
|
||||
getPatientStats: async (opts?: { silent?: boolean }): Promise<PatientStatistics> => {
|
||||
const { data } = await client.get<{
|
||||
success: boolean;
|
||||
data: PatientStatistics;
|
||||
}>('/health/admin/statistics/patients', { skipGlobalError: opts?.silent });
|
||||
return data.data;
|
||||
},
|
||||
|
||||
getConsultationStats: async (opts?: { silent?: boolean }): Promise<ConsultationStatistics> => {
|
||||
const { data } = await client.get<{
|
||||
success: boolean;
|
||||
data: ConsultationStatistics;
|
||||
}>('/health/admin/statistics/consultations', { skipGlobalError: opts?.silent });
|
||||
return data.data;
|
||||
},
|
||||
|
||||
getFollowUpStats: async (opts?: { silent?: boolean }): Promise<FollowUpStatistics> => {
|
||||
const { data } = await client.get<{
|
||||
success: boolean;
|
||||
data: FollowUpStatistics;
|
||||
}>('/health/admin/statistics/follow-ups', { skipGlobalError: opts?.silent });
|
||||
return data.data;
|
||||
},
|
||||
|
||||
getHealthDataStats: async (opts?: { silent?: boolean }): Promise<HealthDataStats> => {
|
||||
const { data } = await client.get<{
|
||||
success: boolean;
|
||||
data: HealthDataStats;
|
||||
}>('/health/admin/statistics/health-data', { skipGlobalError: opts?.silent });
|
||||
return data.data;
|
||||
},
|
||||
|
||||
getDialysisStats: async (opts?: { silent?: boolean }): Promise<DialysisStatistics> => {
|
||||
const { data } = await client.get<{
|
||||
success: boolean;
|
||||
data: DialysisStatistics;
|
||||
}>('/health/admin/statistics/dialysis', { skipGlobalError: opts?.silent });
|
||||
return data.data;
|
||||
},
|
||||
|
||||
getPersonalStats: async (opts?: { silent?: boolean }): Promise<PersonalStats> => {
|
||||
const { data } = await client.get<{
|
||||
success: boolean;
|
||||
data: PersonalStats;
|
||||
}>('/health/admin/statistics/personal-stats', { skipGlobalError: opts?.silent });
|
||||
return data.data;
|
||||
},
|
||||
};
|
||||
247
apps/web/src/api/health/shifts.ts
Normal file
247
apps/web/src/api/health/shifts.ts
Normal file
@@ -0,0 +1,247 @@
|
||||
import client from '../client';
|
||||
import type { PaginatedResponse } from '../types';
|
||||
|
||||
// --- Types ---
|
||||
|
||||
export interface Shift {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
shift_date: string;
|
||||
period: string;
|
||||
nurse_id?: string;
|
||||
status: string;
|
||||
notes?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
version: number;
|
||||
patient_count?: number;
|
||||
critical_count?: number;
|
||||
attention_count?: number;
|
||||
}
|
||||
|
||||
export interface PatientAssignment {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
shift_id: string;
|
||||
patient_id: string;
|
||||
care_level: string;
|
||||
notes?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
version: number;
|
||||
patient_name?: string;
|
||||
}
|
||||
|
||||
export interface HandoffLog {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
from_shift_id: string;
|
||||
to_shift_id: string;
|
||||
patient_id: string;
|
||||
notes?: string;
|
||||
pending_items?: Record<string, unknown>;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
version: number;
|
||||
patient_name?: string;
|
||||
}
|
||||
|
||||
export interface CreateShiftReq {
|
||||
shift_date: string;
|
||||
period: string;
|
||||
nurse_id?: string;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface UpdateShiftReq {
|
||||
shift_date?: string;
|
||||
period?: string;
|
||||
nurse_id?: string;
|
||||
status?: string;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface ListShiftsParams {
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
shift_date?: string;
|
||||
period?: string;
|
||||
nurse_id?: string;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
export interface CreatePatientAssignmentReq {
|
||||
patient_id: string;
|
||||
care_level?: string;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface BatchAssignReq {
|
||||
patient_ids: string[];
|
||||
care_level?: string;
|
||||
}
|
||||
|
||||
export interface UpdatePatientAssignmentReq {
|
||||
care_level?: string;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface CreateHandoffReq {
|
||||
from_shift_id: string;
|
||||
to_shift_id: string;
|
||||
patient_id: string;
|
||||
notes?: string;
|
||||
pending_items?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ListHandoffParams {
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
from_shift_id?: string;
|
||||
to_shift_id?: string;
|
||||
}
|
||||
|
||||
// --- Constants ---
|
||||
|
||||
export const PERIOD_OPTIONS = [
|
||||
{ label: '上午班', value: 'morning' },
|
||||
{ label: '下午班', value: 'afternoon' },
|
||||
{ label: '晚班', value: 'evening' },
|
||||
{ label: '夜班', value: 'night' },
|
||||
];
|
||||
|
||||
export const SHIFT_STATUS_OPTIONS = [
|
||||
{ label: '待开始', value: 'scheduled' },
|
||||
{ label: '进行中', value: 'in_progress' },
|
||||
{ label: '已完成', value: 'completed' },
|
||||
{ label: '已取消', value: 'cancelled' },
|
||||
];
|
||||
|
||||
export const CARE_LEVEL_OPTIONS = [
|
||||
{ label: '稳定', value: 'stable' },
|
||||
{ label: '需关注', value: 'attention' },
|
||||
{ label: '危重', value: 'critical' },
|
||||
];
|
||||
|
||||
export const PERIOD_LABEL: Record<string, string> = Object.fromEntries(
|
||||
PERIOD_OPTIONS.map((o) => [o.value, o.label]),
|
||||
);
|
||||
|
||||
export const SHIFT_STATUS_LABEL: Record<string, string> = Object.fromEntries(
|
||||
SHIFT_STATUS_OPTIONS.map((o) => [o.value, o.label]),
|
||||
);
|
||||
|
||||
export const SHIFT_STATUS_COLOR: Record<string, string> = {
|
||||
scheduled: 'default',
|
||||
in_progress: 'processing',
|
||||
completed: 'success',
|
||||
cancelled: 'error',
|
||||
};
|
||||
|
||||
export const CARE_LEVEL_LABEL: Record<string, string> = Object.fromEntries(
|
||||
CARE_LEVEL_OPTIONS.map((o) => [o.value, o.label]),
|
||||
);
|
||||
|
||||
export const CARE_LEVEL_COLOR: Record<string, string> = {
|
||||
stable: 'green',
|
||||
attention: 'orange',
|
||||
critical: 'red',
|
||||
};
|
||||
|
||||
// --- API ---
|
||||
|
||||
export const shiftApi = {
|
||||
// --- Shifts ---
|
||||
|
||||
list: async (params: ListShiftsParams) => {
|
||||
const { data } = await client.get<{
|
||||
success: boolean;
|
||||
data: PaginatedResponse<Shift>;
|
||||
}>('/health/shifts', { params });
|
||||
return data.data;
|
||||
},
|
||||
|
||||
get: async (shiftId: string) => {
|
||||
const { data } = await client.get<{
|
||||
success: boolean;
|
||||
data: Shift;
|
||||
}>(`/health/shifts/${shiftId}`);
|
||||
return data.data;
|
||||
},
|
||||
|
||||
create: async (req: CreateShiftReq) => {
|
||||
const { data } = await client.post<{
|
||||
success: boolean;
|
||||
data: Shift;
|
||||
}>('/health/shifts', req);
|
||||
return data.data;
|
||||
},
|
||||
|
||||
update: async (shiftId: string, req: UpdateShiftReq & { version: number }) => {
|
||||
const { data } = await client.put<{
|
||||
success: boolean;
|
||||
data: Shift;
|
||||
}>(`/health/shifts/${shiftId}`, req);
|
||||
return data.data;
|
||||
},
|
||||
|
||||
delete: async (shiftId: string, version: number) => {
|
||||
await client.delete(`/health/shifts/${shiftId}`, { data: { version } });
|
||||
},
|
||||
|
||||
// --- Assignments ---
|
||||
|
||||
listAssignments: async (shiftId: string, params?: { page?: number; page_size?: number }) => {
|
||||
const { data } = await client.get<{
|
||||
success: boolean;
|
||||
data: PaginatedResponse<PatientAssignment>;
|
||||
}>(`/health/shifts/${shiftId}/assignments`, { params });
|
||||
return data.data;
|
||||
},
|
||||
|
||||
createAssignment: async (shiftId: string, req: CreatePatientAssignmentReq) => {
|
||||
const { data } = await client.post<{
|
||||
success: boolean;
|
||||
data: PatientAssignment;
|
||||
}>(`/health/shifts/${shiftId}/assignments`, req);
|
||||
return data.data;
|
||||
},
|
||||
|
||||
batchAssign: async (shiftId: string, req: BatchAssignReq) => {
|
||||
const { data } = await client.post<{
|
||||
success: boolean;
|
||||
data: PatientAssignment[];
|
||||
}>(`/health/shifts/${shiftId}/assignments/batch`, req);
|
||||
return data.data;
|
||||
},
|
||||
|
||||
updateAssignment: async (shiftId: string, assignmentId: string, req: UpdatePatientAssignmentReq & { version: number }) => {
|
||||
const { data } = await client.put<{
|
||||
success: boolean;
|
||||
data: PatientAssignment;
|
||||
}>(`/health/shifts/${shiftId}/assignments/${assignmentId}`, req);
|
||||
return data.data;
|
||||
},
|
||||
|
||||
deleteAssignment: async (shiftId: string, assignmentId: string, version: number) => {
|
||||
await client.delete(`/health/shifts/${shiftId}/assignments/${assignmentId}`, { data: { version } });
|
||||
},
|
||||
|
||||
// --- Handoff Logs ---
|
||||
|
||||
listHandoffs: async (params?: ListHandoffParams) => {
|
||||
const { data } = await client.get<{
|
||||
success: boolean;
|
||||
data: PaginatedResponse<HandoffLog>;
|
||||
}>('/health/handoff-logs', { params });
|
||||
return data.data;
|
||||
},
|
||||
|
||||
createHandoff: async (req: CreateHandoffReq) => {
|
||||
const { data } = await client.post<{
|
||||
success: boolean;
|
||||
data: HandoffLog;
|
||||
}>('/health/handoff-logs', req);
|
||||
return data.data;
|
||||
},
|
||||
};
|
||||
34
apps/web/src/api/languages.ts
Normal file
34
apps/web/src/api/languages.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import client from './client';
|
||||
|
||||
// --- Types ---
|
||||
|
||||
export interface LanguageInfo {
|
||||
code: string;
|
||||
name: string;
|
||||
is_active: boolean;
|
||||
}
|
||||
|
||||
export interface UpdateLanguageRequest {
|
||||
is_active: boolean;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
// --- API Functions ---
|
||||
|
||||
export async function listLanguages(): Promise<LanguageInfo[]> {
|
||||
const { data } = await client.get<{ success: boolean; data: LanguageInfo[] }>(
|
||||
'/config/languages',
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function updateLanguage(
|
||||
code: string,
|
||||
req: UpdateLanguageRequest,
|
||||
): Promise<LanguageInfo> {
|
||||
const { data } = await client.put<{ success: boolean; data: LanguageInfo }>(
|
||||
`/config/languages/${code}`,
|
||||
req,
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
63
apps/web/src/api/menus.ts
Normal file
63
apps/web/src/api/menus.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import client from './client';
|
||||
|
||||
export interface MenuInfo {
|
||||
id: string;
|
||||
parent_id?: string;
|
||||
title: string;
|
||||
path?: string;
|
||||
icon?: string;
|
||||
sort_order: number;
|
||||
visible: boolean;
|
||||
menu_type: string;
|
||||
permission?: string;
|
||||
children?: MenuInfo[];
|
||||
version: number;
|
||||
}
|
||||
|
||||
export interface MenuItemReq {
|
||||
id?: string;
|
||||
parent_id?: string;
|
||||
title: string;
|
||||
path?: string;
|
||||
icon?: string;
|
||||
sort_order?: number;
|
||||
visible?: boolean;
|
||||
menu_type?: string;
|
||||
permission?: string;
|
||||
role_ids?: string[];
|
||||
version?: number;
|
||||
}
|
||||
|
||||
export async function getMenus() {
|
||||
const { data } = await client.get<{ success: boolean; data: MenuInfo[] }>('/config/menus');
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function getMenusForUser() {
|
||||
const { data } = await client.get<{ success: boolean; data: MenuInfo[] }>('/menus/user');
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function batchSaveMenus(menus: MenuItemReq[]) {
|
||||
await client.put('/config/menus', { menus });
|
||||
}
|
||||
|
||||
export async function createMenu(req: MenuItemReq) {
|
||||
const { data } = await client.post<{ success: boolean; data: MenuInfo }>(
|
||||
'/config/menus',
|
||||
req,
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function updateMenu(id: string, req: MenuItemReq) {
|
||||
const { data } = await client.put<{ success: boolean; data: MenuInfo }>(
|
||||
`/config/menus/${id}`,
|
||||
req,
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function deleteMenu(id: string, version: number) {
|
||||
await client.delete(`/config/menus/${id}`, { data: { version } });
|
||||
}
|
||||
40
apps/web/src/api/messageTemplates.ts
Normal file
40
apps/web/src/api/messageTemplates.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import client from './client';
|
||||
import type { PaginatedResponse } from './types';
|
||||
|
||||
export interface MessageTemplateInfo {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
name: string;
|
||||
code: string;
|
||||
channel: string;
|
||||
title_template: string;
|
||||
body_template: string;
|
||||
language: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface CreateTemplateRequest {
|
||||
name: string;
|
||||
code: string;
|
||||
channel?: string;
|
||||
title_template: string;
|
||||
body_template: string;
|
||||
language?: string;
|
||||
}
|
||||
|
||||
export async function listTemplates(page = 1, pageSize = 20) {
|
||||
const { data } = await client.get<{ success: boolean; data: PaginatedResponse<MessageTemplateInfo> }>(
|
||||
'/message-templates',
|
||||
{ params: { page, page_size: pageSize } },
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function createTemplate(req: CreateTemplateRequest) {
|
||||
const { data } = await client.post<{ success: boolean; data: MessageTemplateInfo }>(
|
||||
'/message-templates',
|
||||
req,
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
100
apps/web/src/api/messages.test.ts
Normal file
100
apps/web/src/api/messages.test.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* messages + messageTemplates API 契约测试
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
const mockGet = vi.fn()
|
||||
const mockPost = vi.fn()
|
||||
const mockPut = vi.fn()
|
||||
const mockDelete = vi.fn()
|
||||
|
||||
vi.mock('./client', () => ({
|
||||
default: {
|
||||
get: (...args: unknown[]) => mockGet(...args),
|
||||
post: (...args: unknown[]) => mockPost(...args),
|
||||
put: (...args: unknown[]) => mockPut(...args),
|
||||
delete: (...args: unknown[]) => mockDelete(...args),
|
||||
},
|
||||
}))
|
||||
|
||||
import * as messagesApi from './messages'
|
||||
import * as templateApi from './messageTemplates'
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('messages API', () => {
|
||||
const fakeRes = { data: { success: true, data: {} } }
|
||||
|
||||
it('listMessages 应调用 GET /messages 并传递查询参数', async () => {
|
||||
mockGet.mockResolvedValue(fakeRes)
|
||||
await messagesApi.listMessages({ page: 2, page_size: 10, is_read: false, priority: 'high' })
|
||||
|
||||
expect(mockGet).toHaveBeenCalledWith('/messages', {
|
||||
params: expect.objectContaining({
|
||||
page: 2,
|
||||
page_size: 10,
|
||||
is_read: false,
|
||||
priority: 'high',
|
||||
}),
|
||||
})
|
||||
})
|
||||
|
||||
it('getUnreadCount 应调用 GET /messages/unread-count', async () => {
|
||||
mockGet.mockResolvedValue(fakeRes)
|
||||
await messagesApi.getUnreadCount()
|
||||
|
||||
expect(mockGet).toHaveBeenCalledWith('/messages/unread-count')
|
||||
})
|
||||
|
||||
it('markRead 应调用 PUT /messages/:id/read', async () => {
|
||||
mockPut.mockResolvedValue({ data: { success: true } })
|
||||
await messagesApi.markRead('msg-001')
|
||||
|
||||
expect(mockPut).toHaveBeenCalledWith('/messages/msg-001/read')
|
||||
})
|
||||
|
||||
it('markAllRead 应调用 PUT /messages/read-all', async () => {
|
||||
mockPut.mockResolvedValue({ data: { success: true } })
|
||||
await messagesApi.markAllRead()
|
||||
|
||||
expect(mockPut).toHaveBeenCalledWith('/messages/read-all')
|
||||
})
|
||||
|
||||
it('deleteMessage 应调用 DELETE /messages/:id', async () => {
|
||||
mockDelete.mockResolvedValue({ data: { success: true } })
|
||||
await messagesApi.deleteMessage('msg-001')
|
||||
|
||||
expect(mockDelete).toHaveBeenCalledWith('/messages/msg-001')
|
||||
})
|
||||
|
||||
it('sendMessage 应调用 POST /messages 并传递请求体', async () => {
|
||||
mockPost.mockResolvedValue(fakeRes)
|
||||
const req = { title: '通知', body: '内容', recipient_id: 'u-001' }
|
||||
await messagesApi.sendMessage(req)
|
||||
|
||||
expect(mockPost).toHaveBeenCalledWith('/messages', req)
|
||||
})
|
||||
})
|
||||
|
||||
describe('messageTemplates API', () => {
|
||||
const fakeRes = { data: { success: true, data: {} } }
|
||||
|
||||
it('listTemplates 应调用 GET /message-templates 并传递分页参数', async () => {
|
||||
mockGet.mockResolvedValue(fakeRes)
|
||||
await templateApi.listTemplates(1, 10)
|
||||
|
||||
expect(mockGet).toHaveBeenCalledWith('/message-templates', {
|
||||
params: { page: 1, page_size: 10 },
|
||||
})
|
||||
})
|
||||
|
||||
it('createTemplate 应调用 POST /message-templates', async () => {
|
||||
mockPost.mockResolvedValue(fakeRes)
|
||||
const req = { name: '预约提醒', code: 'appointment_reminder', title_template: '预约提醒', body_template: '您有预约' }
|
||||
await templateApi.createTemplate(req)
|
||||
|
||||
expect(mockPost).toHaveBeenCalledWith('/message-templates', req)
|
||||
})
|
||||
})
|
||||
98
apps/web/src/api/messages.ts
Normal file
98
apps/web/src/api/messages.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import client from './client';
|
||||
import type { PaginatedResponse } from './types';
|
||||
|
||||
export interface MessageInfo {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
template_id?: string;
|
||||
sender_id?: string;
|
||||
sender_type: string;
|
||||
recipient_id: string;
|
||||
recipient_type: string;
|
||||
title: string;
|
||||
body: string;
|
||||
priority: string;
|
||||
business_type?: string;
|
||||
business_id?: string;
|
||||
is_read: boolean;
|
||||
read_at?: string;
|
||||
is_archived: boolean;
|
||||
status: string;
|
||||
sent_at?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface SendMessageRequest {
|
||||
title: string;
|
||||
body: string;
|
||||
recipient_id: string;
|
||||
recipient_type?: string;
|
||||
priority?: string;
|
||||
template_id?: string;
|
||||
business_type?: string;
|
||||
business_id?: string;
|
||||
}
|
||||
|
||||
export interface MessageQuery {
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
is_read?: boolean;
|
||||
priority?: string;
|
||||
business_type?: string;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
export async function listMessages(query: MessageQuery = {}) {
|
||||
const { data } = await client.get<{ success: boolean; data: PaginatedResponse<MessageInfo> }>(
|
||||
'/messages',
|
||||
{ params: { page: query.page ?? 1, page_size: query.page_size ?? 20, ...query } },
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function getUnreadCount() {
|
||||
const { data } = await client.get<{ success: boolean; data: { count: number } }>(
|
||||
'/messages/unread-count',
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function markRead(id: string) {
|
||||
const { data } = await client.put<{ success: boolean }>(
|
||||
`/messages/${id}/read`,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function markAllRead() {
|
||||
const { data } = await client.put<{ success: boolean }>(
|
||||
'/messages/read-all',
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function deleteMessage(id: string) {
|
||||
const { data } = await client.delete<{ success: boolean }>(
|
||||
`/messages/${id}`,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function sendMessage(req: SendMessageRequest) {
|
||||
const { data } = await client.post<{ success: boolean; data: MessageInfo }>(
|
||||
'/messages',
|
||||
req,
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export interface SubscriptionUpdateReq {
|
||||
dnd_enabled: boolean;
|
||||
dnd_start?: string;
|
||||
dnd_end?: string;
|
||||
}
|
||||
|
||||
export async function updateSubscription(req: SubscriptionUpdateReq) {
|
||||
await client.put('/message-subscriptions', req);
|
||||
}
|
||||
73
apps/web/src/api/numberingRules.ts
Normal file
73
apps/web/src/api/numberingRules.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import client from './client';
|
||||
import type { PaginatedResponse } from './types';
|
||||
|
||||
export interface NumberingRuleInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
code: string;
|
||||
prefix: string;
|
||||
date_format?: string;
|
||||
seq_length: number;
|
||||
seq_start: number;
|
||||
seq_current: number;
|
||||
separator: string;
|
||||
reset_cycle: string;
|
||||
last_reset_date?: string;
|
||||
version: number;
|
||||
}
|
||||
|
||||
export interface CreateNumberingRuleRequest {
|
||||
name: string;
|
||||
code: string;
|
||||
prefix?: string;
|
||||
date_format?: string;
|
||||
seq_length?: number;
|
||||
seq_start?: number;
|
||||
separator?: string;
|
||||
reset_cycle?: string;
|
||||
}
|
||||
|
||||
export interface UpdateNumberingRuleRequest {
|
||||
name?: string;
|
||||
prefix?: string;
|
||||
date_format?: string;
|
||||
seq_length?: number;
|
||||
separator?: string;
|
||||
reset_cycle?: string;
|
||||
version: number;
|
||||
}
|
||||
|
||||
export async function listNumberingRules(page = 1, pageSize = 20) {
|
||||
const { data } = await client.get<{ success: boolean; data: PaginatedResponse<NumberingRuleInfo> }>(
|
||||
'/config/numbering-rules',
|
||||
{ params: { page, page_size: pageSize } },
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function createNumberingRule(req: CreateNumberingRuleRequest) {
|
||||
const { data } = await client.post<{ success: boolean; data: NumberingRuleInfo }>(
|
||||
'/config/numbering-rules',
|
||||
req,
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function updateNumberingRule(id: string, req: UpdateNumberingRuleRequest) {
|
||||
const { data } = await client.put<{ success: boolean; data: NumberingRuleInfo }>(
|
||||
`/config/numbering-rules/${id}`,
|
||||
req,
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function generateNumber(id: string) {
|
||||
const { data } = await client.post<{ success: boolean; data: { number: string } }>(
|
||||
`/config/numbering-rules/${id}/generate`,
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function deleteNumberingRule(id: string, version: number) {
|
||||
await client.delete(`/config/numbering-rules/${id}`, { data: { version } });
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user