chore(hmos): 导入生产环境鸿蒙工程
- 以 D:\SmartSenior 生产工程为源,纳入 AppScope、entry、cjagent、hvigor 及根配置 - 移除 build-profile.json5 的 signingConfigs(p12/p7b/cer 路径与加密口令)及 products.signingConfig 引用 - 不纳入本地与开发产物:local.properties、.idea、.hvigor、.codegenie、demo、backup_a2a_native、SESSION_LOG.md、build_err.txt、build/、oh_modules - .gitignore 追加 .codegenie 与密钥文件通配(*.p12/*.p7b/*.cer/*.jks/*.keystore/*.pem)
This commit is contained in:
@@ -0,0 +1,244 @@
|
||||
// 智慧养老(数智护老)客户端主页面
|
||||
// 结构:HdsNavigation + HdsTabs,五个页签(工作台/预警/业务/更多/我的)
|
||||
// 业务内容分别在 pages/workbench、pages/alerts、pages/business、pages/profile 中维护
|
||||
import { curves } from '@kit.ArkUI';
|
||||
import { hdsMaterial } from '@hms.hds.hdsMaterial';
|
||||
import { HdsTabs, HdsTabsAttribute, HdsTabsController, HdsNavigation, HdsNavigationTitleMode, ScrollEffectType } from '@kit.UIDesignKit';
|
||||
import { router } from '@kit.ArkUI';
|
||||
import { Workbench } from './workbench/Workbench';
|
||||
import { AlertsTab } from './alerts/AlertsTab';
|
||||
import { BusinessTab } from './business/BusinessTab';
|
||||
import { ProfileTab } from './profile/ProfileTab';
|
||||
import { AuthStore } from '../utils/AuthStore';
|
||||
import { parseRouteTarget } from '../common/RouteTarget';
|
||||
|
||||
@Entry
|
||||
@Component
|
||||
struct Index {
|
||||
@State message: string = '数智护老';
|
||||
@State subTitle: string = 'SmartSenior';
|
||||
@State currentIndex: number = 0;
|
||||
|
||||
private hdsTabsController: HdsTabsController = new HdsTabsController();
|
||||
|
||||
aboutToAppear(): void {
|
||||
// 老人角色使用独立的老人端首页
|
||||
AuthStore.getState().then((state) => {
|
||||
if (state.role === 'elderly') {
|
||||
router.replaceUrl({ url: 'pages/elderly/ElderlyHome' });
|
||||
}
|
||||
// 冷启动带目标页(卡片/快捷方式/小艺/通知点击跳转):进首页后再跳目标页
|
||||
const pending = AppStorage.get<string>('pendingCardTarget') ?? '';
|
||||
if (pending.length > 0) {
|
||||
AppStorage.setOrCreate<string>('pendingCardTarget', '');
|
||||
const rt = parseRouteTarget(pending);
|
||||
setTimeout(() => {
|
||||
router.pushUrl({ url: rt.url, params: rt.params }).catch(() => {});
|
||||
}, 500);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Builder
|
||||
TabBarItem(icon: string, text: string, index: number) {
|
||||
Column({ space: 4 }) {
|
||||
// 处理"更多"页签的特殊图标
|
||||
if (index === 3) {
|
||||
// 使用自定义的SymbolSpan图标
|
||||
Stack() {
|
||||
// 未选中状态的图标
|
||||
Text() {
|
||||
SymbolSpan($r("sys.symbol.list_bullet"))
|
||||
}
|
||||
.fontSize(24)
|
||||
.fontColor('#888888')
|
||||
.opacity((this.currentIndex !== index) ? 1 : 0)
|
||||
.animation({
|
||||
duration: 200,
|
||||
curve: curves.springMotion(0.4, 0.8)
|
||||
})
|
||||
|
||||
// 选中状态的图标
|
||||
Text() {
|
||||
SymbolSpan($r("sys.symbol.list_bullet"))
|
||||
}
|
||||
.fontSize(24)
|
||||
.fontColor('#007AFF')
|
||||
.opacity((this.currentIndex === index) ? 1 : 0)
|
||||
.animation({
|
||||
duration: 200,
|
||||
curve: curves.springMotion(0.4, 0.8)
|
||||
})
|
||||
}
|
||||
} else {
|
||||
// 其他页签保持原有逻辑
|
||||
Stack() {
|
||||
// 未选中状态的图标
|
||||
Text(icon)
|
||||
.fontSize(24)
|
||||
.fontColor('#888888')
|
||||
.opacity((this.currentIndex !== index) ? 1 : 0)
|
||||
.animation({
|
||||
duration: 200,
|
||||
curve: curves.springMotion(0.4, 0.8)
|
||||
})
|
||||
|
||||
// 选中状态的图标
|
||||
Text(icon)
|
||||
.fontSize(24)
|
||||
.fontColor('#007AFF')
|
||||
.opacity((this.currentIndex === index) ? 1 : 0)
|
||||
.animation({
|
||||
duration: 200,
|
||||
curve: curves.springMotion(0.4, 0.8)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 文字部分
|
||||
Stack() {
|
||||
// 未选中状态的文字
|
||||
Text(text)
|
||||
.fontSize(12)
|
||||
.fontColor('#888888')
|
||||
.opacity((this.currentIndex !== index) ? 1 : 0)
|
||||
.animation({
|
||||
duration: 200,
|
||||
curve: curves.springMotion(0.4, 0.8)
|
||||
})
|
||||
|
||||
// 选中状态的文字
|
||||
Text(text)
|
||||
.fontSize(12)
|
||||
.fontColor('#007AFF')
|
||||
.opacity((this.currentIndex === index) ? 1 : 0)
|
||||
.animation({
|
||||
duration: 200,
|
||||
curve: curves.springMotion(0.4, 0.8)
|
||||
})
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
.justifyContent(FlexAlign.Center)
|
||||
.alignItems(HorizontalAlign.Center)
|
||||
.onClick(() => {
|
||||
// 直接更新当前选中的索引
|
||||
this.currentIndex = index;
|
||||
// 更新HdsTabs控制器
|
||||
this.hdsTabsController.changeIndex(index);
|
||||
})
|
||||
}
|
||||
|
||||
@Builder
|
||||
TabContentBuilder(index: number) {
|
||||
TabContent() {
|
||||
Stack() {
|
||||
if (index === 0) {
|
||||
Workbench()
|
||||
}
|
||||
if (index === 1) {
|
||||
AlertsTab()
|
||||
}
|
||||
if (index === 2) {
|
||||
BusinessTab()
|
||||
}
|
||||
if (index === 3) {
|
||||
Column() {
|
||||
Text('更多功能开发中')
|
||||
.fontSize(16)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
.justifyContent(FlexAlign.Center)
|
||||
.alignItems(HorizontalAlign.Center)
|
||||
}
|
||||
if (index === 4) {
|
||||
ProfileTab()
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
// 避让顶部 Hds 标题栏(MINI 模式 + 状态栏)
|
||||
.padding({ top: 92 })
|
||||
}
|
||||
.tabBar(this.TabBarItem(
|
||||
index === 0 ? '' :
|
||||
index === 1 ? '' :
|
||||
index === 2 ? '' :
|
||||
index === 3 ? '' : '',
|
||||
index === 0 ? '工作台' :
|
||||
index === 1 ? '预警' :
|
||||
index === 2 ? '业务' :
|
||||
index === 3 ? '更多' : '我的',
|
||||
index
|
||||
))
|
||||
}
|
||||
|
||||
build() {
|
||||
Column() {
|
||||
// 主应用界面
|
||||
Stack() {
|
||||
HdsNavigation() {
|
||||
HdsTabs({ controller: this.hdsTabsController }) {
|
||||
ForEach([0, 1, 2, 3, 4], (index: number) => {
|
||||
this.TabContentBuilder(index)
|
||||
})
|
||||
}
|
||||
.barOverlap(true)
|
||||
.barPosition(BarPosition.End)
|
||||
.vertical(false)
|
||||
.scrollable(false)
|
||||
.animationDuration(200)
|
||||
.onChange((index: number) => {
|
||||
this.currentIndex = index;
|
||||
})
|
||||
.barFloatingStyle({
|
||||
barWidth: { smallWidth: 300, mediumWidth: 440, largeWidth: 560 },
|
||||
barBottomMargin: 28,
|
||||
gradientMask: { maskColor: '#00000000', maskHeight: 92 },
|
||||
systemMaterialEffect: {
|
||||
materialType: hdsMaterial.MaterialType.IMMERSIVE,
|
||||
materialLevel: hdsMaterial.MaterialLevel.EXQUISITE
|
||||
}
|
||||
})
|
||||
}
|
||||
.mode(NavigationMode.Stack)
|
||||
.titleBar({
|
||||
content: {
|
||||
title: {
|
||||
mainTitle: this.message,
|
||||
subTitle: this.subTitle,
|
||||
},
|
||||
// 右上角菜单按钮暂时隐藏
|
||||
// menu: this.menus,
|
||||
},
|
||||
style: {
|
||||
scrollEffectOpts: {
|
||||
enableScrollEffect: false,
|
||||
scrollEffectType: ScrollEffectType.GRADIENT_BLUR,
|
||||
},
|
||||
systemMaterialEffect: {
|
||||
materialType: hdsMaterial.MaterialType.ADAPTIVE,
|
||||
materialLevel: hdsMaterial.MaterialLevel.EXQUISITE
|
||||
}
|
||||
}
|
||||
})
|
||||
.hideBackButton(true)
|
||||
.titleMode(HdsNavigationTitleMode.MINI)
|
||||
.ignoreLayoutSafeArea([LayoutSafeAreaType.SYSTEM], [LayoutSafeAreaEdge.TOP, LayoutSafeAreaEdge.BOTTOM])
|
||||
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
|
||||
.backgroundColor($r('app.color.background'))
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
.backgroundColor($r('app.color.background'))
|
||||
.alignContent(Alignment.Top)
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
.backgroundColor($r('app.color.background'))
|
||||
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
// 登录页(ArkTS 严格类型,无 any)—— 账号密码 + 邮箱验证码 + 家属注册
|
||||
import { router, promptAction, uiMaterial } from '@kit.ArkUI';
|
||||
import { preferences } from '@kit.ArkData';
|
||||
import { common } from '@kit.AbilityKit';
|
||||
import { BusinessError, deviceInfo } from '@kit.BasicServicesKit';
|
||||
import {
|
||||
LoginResponse,
|
||||
PREFS_NAME,
|
||||
PREFS_AGREED,
|
||||
ROLE_NAME_MAP,
|
||||
normalizeError,
|
||||
} from '../common/ApiConstants';
|
||||
import { api, RequestError } from '../utils/Request';
|
||||
import { AuthStore } from '../utils/AuthStore';
|
||||
import { Constants } from '../common/Constants';
|
||||
import { PushManager } from '../utils/PushManager';
|
||||
import { AlertSocket } from '../utils/AlertSocket';
|
||||
import { requestLocationPermission } from '../utils/PermissionUtil';
|
||||
import { loadFontScale, resetFontScaleRuntime } from '../utils/FontScale';
|
||||
import { parseRouteTarget } from '../common/RouteTarget';
|
||||
|
||||
interface HomeRoute {
|
||||
route: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
// 登录请求体
|
||||
interface LoginPayload {
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
function getHomeRoute(role: string, familyVerified: boolean): HomeRoute {
|
||||
if (role === 'elderly') {
|
||||
return { route: 'pages/Index', name: '老人端' };
|
||||
}
|
||||
if (role === 'family' && !familyVerified) {
|
||||
return { route: 'pages/Index', name: '家属端' };
|
||||
}
|
||||
return { route: 'pages/Index', name: ROLE_NAME_MAP[role] ?? '工作台' };
|
||||
}
|
||||
|
||||
@Entry
|
||||
@Component
|
||||
struct LoginPage {
|
||||
@State username: string = '';
|
||||
@State password: string = '';
|
||||
@State loading: boolean = false;
|
||||
@State showPassword: boolean = false;
|
||||
@State showAgreement: boolean = false;
|
||||
@StorageProp('needPrivacyCheck') @Watch('onNeedPrivacyChanged') needPrivacyCheck: boolean = false;
|
||||
|
||||
// 应用初始化(EntryAbility)判定未同意 → 登录页一渲染就弹
|
||||
onNeedPrivacyChanged(): void {
|
||||
if (this.needPrivacyCheck) {
|
||||
this.showAgreement = true;
|
||||
}
|
||||
}
|
||||
|
||||
async aboutToAppear(): Promise<void> {
|
||||
// 兜底:AppStorage 标志未就绪时直接读偏好,确保首次启动必弹
|
||||
const context = getContext(this) as common.UIAbilityContext;
|
||||
const prefs: preferences.Preferences = await preferences.getPreferences(context, PREFS_NAME);
|
||||
const agreed = await prefs.get(PREFS_AGREED, 0);
|
||||
if (agreed !== 1) {
|
||||
this.showAgreement = true;
|
||||
}
|
||||
// 登录持久化:已登录用户直接进入主页
|
||||
const loggedIn = await AuthStore.isLoggedIn();
|
||||
if (loggedIn) {
|
||||
router.replaceUrl({ url: 'pages/Index' });
|
||||
}
|
||||
}
|
||||
|
||||
private async agree(): Promise<void> {
|
||||
const context = getContext(this) as common.UIAbilityContext;
|
||||
const prefs: preferences.Preferences = await preferences.getPreferences(context, PREFS_NAME);
|
||||
await prefs.put(PREFS_AGREED, 1);
|
||||
await prefs.flush();
|
||||
AppStorage.setOrCreate<boolean>('needPrivacyCheck', false);
|
||||
this.showAgreement = false;
|
||||
// 隐私政策同意后一次性完成授权:先通知,再定位(系统授权弹窗依次排队)
|
||||
try {
|
||||
await PushManager.init(context);
|
||||
} catch (err) {
|
||||
console.error('[Login] PushManager init failed:', normalizeError(err as BusinessError | Error));
|
||||
}
|
||||
await requestLocationPermission(context);
|
||||
}
|
||||
|
||||
private async handleLogin(): Promise<void> {
|
||||
if (this.username.trim().length === 0) {
|
||||
promptAction.showToast({ message: '请输入账号' });
|
||||
return;
|
||||
}
|
||||
if (this.password.length === 0) {
|
||||
promptAction.showToast({ message: '请输入密码' });
|
||||
return;
|
||||
}
|
||||
if (this.loading) {
|
||||
return;
|
||||
}
|
||||
this.loading = true;
|
||||
try {
|
||||
const payload: LoginPayload = {
|
||||
username: this.username.trim(),
|
||||
password: this.password,
|
||||
};
|
||||
const res = await api.post<LoginResponse>('/auth/login/', payload, { anonymous: true });
|
||||
await AuthStore.setAuth({
|
||||
token: res.access,
|
||||
refreshToken: res.refresh,
|
||||
role: res.user.role,
|
||||
userId: res.user.id,
|
||||
userName: res.user.name,
|
||||
familyVerified: res.user.family_verified,
|
||||
});
|
||||
// Push Kit:把推送令牌绑定到当前登录用户
|
||||
PushManager.uploadTokenAfterLogin().catch(() => {});
|
||||
// 字体设置:老人/家属登录恢复持久化档位,其他角色强制跟随系统
|
||||
if (res.user.role === 'elderly' || res.user.role === 'family') {
|
||||
loadFontScale(getContext(this) as common.UIAbilityContext).catch(() => {});
|
||||
} else {
|
||||
resetFontScaleRuntime();
|
||||
}
|
||||
// 预警实时推送:建立 WebSocket 连接
|
||||
AlertSocket.connect().catch(() => {});
|
||||
const home = getHomeRoute(res.user.role, res.user.family_verified);
|
||||
router.replaceUrl({ url: home.route });
|
||||
// 冷启动带目标页(卡片/快捷方式/小艺/通知点击跳转):进入首页后再跳目标页
|
||||
const pending = AppStorage.get<string>('pendingCardTarget') ?? '';
|
||||
if (pending.length > 0) {
|
||||
AppStorage.setOrCreate<string>('pendingCardTarget', '');
|
||||
const rt = parseRouteTarget(pending);
|
||||
setTimeout(() => {
|
||||
router.pushUrl({ url: rt.url, params: rt.params }).catch(() => {});
|
||||
}, 500);
|
||||
}
|
||||
} catch (err) {
|
||||
// 后端不可用时,写死测试账号作为离线兜底(正常联调不会走到这里)
|
||||
const username = this.username.trim();
|
||||
if (username === '0001' && this.password === '0001') {
|
||||
await AuthStore.setAuth({
|
||||
token: 'local-0001',
|
||||
refreshToken: '',
|
||||
role: 'admin',
|
||||
userId: 1,
|
||||
userName: '测试用户',
|
||||
familyVerified: false,
|
||||
});
|
||||
router.replaceUrl({ url: 'pages/Index' });
|
||||
return;
|
||||
}
|
||||
if (username === '0002' && this.password === '0002') {
|
||||
await AuthStore.setAuth({
|
||||
token: 'local-0002',
|
||||
refreshToken: '',
|
||||
role: 'elderly',
|
||||
userId: 2,
|
||||
userName: '测试老人',
|
||||
familyVerified: false,
|
||||
});
|
||||
router.replaceUrl({ url: 'pages/Index' });
|
||||
return;
|
||||
}
|
||||
const message = err instanceof RequestError ? err.message : normalizeError(err as BusinessError | Error);
|
||||
promptAction.showToast({ message: message });
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 隐私政策弹窗的沉浸式系统材质:uiMaterial 从 API 26 起才提供,
|
||||
// 低版本设备上该命名空间不存在,直接访问 uiMaterial.ImmersiveStyle 会抛 TypeError 导致应用退出
|
||||
private buildAgreementMaterial(): SystemUiMaterial | undefined {
|
||||
if (deviceInfo.sdkApiVersion < 26) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
return new uiMaterial.ImmersiveMaterial({
|
||||
style: uiMaterial.ImmersiveStyle.ULTRA_THIN,
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn('[Login] immersive material unavailable:', normalizeError(err as BusinessError | Error));
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
build() {
|
||||
Stack() {
|
||||
// 背景(延伸到状态栏与导航栏,沉浸式)
|
||||
Column()
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
.backgroundColor($r('app.color.background'))
|
||||
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
|
||||
|
||||
Column({ space: 20 }) {
|
||||
Blank()
|
||||
.height(56) // 状态栏安全间距
|
||||
|
||||
// Logo
|
||||
Image($r('app.media.logo1'))
|
||||
.width(88)
|
||||
.height(88)
|
||||
.borderRadius(20)
|
||||
.shadow({ radius: 10, color: '#33007AFF', offsetX: 0, offsetY: 5 })
|
||||
|
||||
Text('数智护老')
|
||||
.fontSize(28)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
|
||||
Text('请输入账号信息以继续使用智慧照护端')
|
||||
.fontSize(14)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
|
||||
// 账号输入
|
||||
Column({ space: 14 }) {
|
||||
TextInput({ placeholder: '请输入账号 / 手机号 / 邮箱', text: this.username })
|
||||
.placeholderColor($r('app.color.inactive_tab'))
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.height(50)
|
||||
.padding({ left: 16, right: 16 })
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.borderRadius(12)
|
||||
.onChange((value: string) => {
|
||||
this.username = value;
|
||||
})
|
||||
|
||||
// 密码输入
|
||||
Row() {
|
||||
TextInput({ placeholder: '请输入登录密码', text: this.password })
|
||||
.placeholderColor($r('app.color.inactive_tab'))
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.type(InputType.Password)
|
||||
.height(50)
|
||||
.layoutWeight(1)
|
||||
.padding({ left: 16, right: 16 })
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.borderRadius(12)
|
||||
.onChange((value: string) => {
|
||||
this.password = value;
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
|
||||
// 登录按钮
|
||||
Button(this.loading ? '登录中...' : '登录')
|
||||
.width('100%')
|
||||
.height(50)
|
||||
.fontSize(16)
|
||||
.fontColor('#FFFFFF')
|
||||
.backgroundColor($r('app.color.accent_blue'))
|
||||
.borderRadius(12)
|
||||
.enabled(!this.loading)
|
||||
.onClick(() => {
|
||||
this.handleLogin();
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ left: 24, right: 24 })
|
||||
}
|
||||
.width('100%')
|
||||
.justifyContent(FlexAlign.Center)
|
||||
.alignItems(HorizontalAlign.Center)
|
||||
.padding({ left: 24, right: 24, bottom: 60 })
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
|
||||
// 隐私政策弹窗(与 Index 一致的透明沉浸式样式,应用打开时弹出)
|
||||
.bindSheet($$this.showAgreement, this.AgreementSheetBuilder(), {
|
||||
preferType: SheetType.CENTER,
|
||||
detents: [SheetSize.MEDIUM],
|
||||
dragBar: true,
|
||||
showClose: false,
|
||||
title: { title: '用户协议与隐私政策' },
|
||||
backgroundColor: Color.Transparent,
|
||||
systemMaterial: this.buildAgreementMaterial(),
|
||||
onWillDismiss: (action: DismissSheetAction) => {
|
||||
// 未同意前禁止下拉/点遮罩关闭
|
||||
if (this.showAgreement) {
|
||||
promptAction.showToast({ message: '请先阅读并同意隐私政策' });
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// 隐私政策半模态内容
|
||||
@Builder
|
||||
AgreementSheetBuilder() {
|
||||
Column() {
|
||||
Scroll() {
|
||||
Column() {
|
||||
Text('欢迎使用前请仔细阅读以下政策全文:')
|
||||
.fontSize(14)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
.margin({ bottom: 12 })
|
||||
ForEach(Constants.PRIVACY_DETAIL, (item: string) => {
|
||||
Text(item)
|
||||
.fontSize(14)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
.textAlign(TextAlign.Start)
|
||||
.margin({ bottom: 8 })
|
||||
}, (item: string) => item.substring(0, 12))
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ left: 24, right: 24 })
|
||||
}
|
||||
.layoutWeight(1)
|
||||
.scrollBar(BarState.Auto)
|
||||
.align(Alignment.Top)
|
||||
|
||||
Row({ space: 12 }) {
|
||||
Button('不同意并退出')
|
||||
.layoutWeight(1)
|
||||
.height(44)
|
||||
.fontColor('#FFFFFF')
|
||||
.backgroundColor($r('app.color.accent_red'))
|
||||
.borderRadius(10)
|
||||
.onClick(() => {
|
||||
this.handleDisagree();
|
||||
})
|
||||
Button('同意并继续')
|
||||
.layoutWeight(1)
|
||||
.height(44)
|
||||
.fontColor('#FFFFFF')
|
||||
.backgroundColor($r('app.color.accent_blue'))
|
||||
.borderRadius(10)
|
||||
.onClick(() => {
|
||||
this.agree();
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ left: 24, right: 24, top: 8, bottom: 24 })
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
}
|
||||
|
||||
private async handleDisagree(): Promise<void> {
|
||||
this.showAgreement = false;
|
||||
// 按原交互:不同意则退出应用
|
||||
const context = getContext(this) as common.UIAbilityContext;
|
||||
context.terminateSelf();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
// 管理员 · 意见反馈工单管理(ArkTS 严格类型,无 any)
|
||||
// 接口:GET /feedback/records/、PATCH /feedback/records/{id}/
|
||||
import { promptAction } from '@kit.ArkUI';
|
||||
import { HdsNavigation, HdsNavigationTitleMode, ScrollEffectType } from '@kit.UIDesignKit';
|
||||
import { hdsMaterial } from '@hms.hds.hdsMaterial';
|
||||
import { BusinessError } from '@kit.BasicServicesKit';
|
||||
import { Page, normalizeError } from '../../common/ApiConstants';
|
||||
import { api, RequestError } from '../../utils/Request';
|
||||
|
||||
interface FeedbackItem {
|
||||
id: number;
|
||||
content: string;
|
||||
contact: string;
|
||||
status: string;
|
||||
status_label: string;
|
||||
user_name: string;
|
||||
user_alias: string;
|
||||
user_role: string;
|
||||
created_at: string;
|
||||
resolved_at: string;
|
||||
}
|
||||
|
||||
function roleLabel(role: string): string {
|
||||
if (role === 'family') {
|
||||
return '家属';
|
||||
}
|
||||
if (role === 'elderly') {
|
||||
return '长者';
|
||||
}
|
||||
if (role === 'nurse') {
|
||||
return '护理员';
|
||||
}
|
||||
if (role === 'staff_nurse') {
|
||||
return '护士';
|
||||
}
|
||||
if (role === 'admin') {
|
||||
return '管理员';
|
||||
}
|
||||
return role || '--';
|
||||
}
|
||||
|
||||
function formatTime(iso: string): string {
|
||||
if (!iso || iso.length === 0) {
|
||||
return '';
|
||||
}
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) {
|
||||
return iso;
|
||||
}
|
||||
const mm = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const dd = String(d.getDate()).padStart(2, '0');
|
||||
const hh = String(d.getHours()).padStart(2, '0');
|
||||
const mi = String(d.getMinutes()).padStart(2, '0');
|
||||
return `${mm}/${dd} ${hh}:${mi}`;
|
||||
}
|
||||
|
||||
@Entry
|
||||
@Component
|
||||
struct AdminFeedback {
|
||||
@State items: Array<FeedbackItem> = [];
|
||||
@State loading: boolean = true;
|
||||
@State page: number = 1;
|
||||
@State count: number = 0;
|
||||
@State statusFilter: string = 'all';
|
||||
@State processingId: number = 0;
|
||||
private pageSize: number = 10;
|
||||
private scroller: Scroller = new Scroller();
|
||||
|
||||
aboutToAppear(): void {
|
||||
this.reload();
|
||||
}
|
||||
|
||||
private reload(): void {
|
||||
this.page = 1;
|
||||
this.items = [];
|
||||
this.fetch(true);
|
||||
}
|
||||
|
||||
private async fetch(isReset: boolean): Promise<void> {
|
||||
this.loading = true;
|
||||
try {
|
||||
let res: Page<FeedbackItem>;
|
||||
if (this.statusFilter !== 'all') {
|
||||
res = await api.get<Page<FeedbackItem>>('/feedback/records/', {
|
||||
page: this.page,
|
||||
page_size: this.pageSize,
|
||||
status: this.statusFilter,
|
||||
});
|
||||
} else {
|
||||
res = await api.get<Page<FeedbackItem>>('/feedback/records/', {
|
||||
page: this.page,
|
||||
page_size: this.pageSize,
|
||||
});
|
||||
}
|
||||
this.count = res.count;
|
||||
if (isReset) {
|
||||
this.items = res.results;
|
||||
} else {
|
||||
this.items = this.items.concat(res.results);
|
||||
}
|
||||
if (res.results.length > 0) {
|
||||
this.page += 1;
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof RequestError ? err.message : normalizeError(err as BusinessError | Error);
|
||||
promptAction.showToast({ message: message });
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private switchStatus(mode: string): void {
|
||||
if (this.statusFilter === mode) {
|
||||
return;
|
||||
}
|
||||
this.statusFilter = mode;
|
||||
this.reload();
|
||||
}
|
||||
|
||||
private async toggleStatus(item: FeedbackItem): Promise<void> {
|
||||
if (this.processingId === item.id) {
|
||||
return;
|
||||
}
|
||||
this.processingId = item.id;
|
||||
try {
|
||||
const next = item.status === 'resolved' ? 'pending' : 'resolved';
|
||||
const updated = await api.patch<FeedbackItem>(`/feedback/records/${item.id}/`, { status: next });
|
||||
this.items = this.items.map((it: FeedbackItem): FeedbackItem => {
|
||||
if (it.id === item.id) {
|
||||
return {
|
||||
id: it.id,
|
||||
content: it.content,
|
||||
contact: it.contact,
|
||||
status: updated.status,
|
||||
status_label: updated.status === 'resolved' ? '已处理' : '待处理',
|
||||
user_name: it.user_name,
|
||||
user_alias: it.user_alias,
|
||||
user_role: it.user_role,
|
||||
created_at: it.created_at,
|
||||
resolved_at: it.resolved_at,
|
||||
};
|
||||
}
|
||||
return it;
|
||||
});
|
||||
promptAction.showToast({ message: updated.status === 'resolved' ? '已标记为已处理' : '已设为待处理' });
|
||||
} catch (err) {
|
||||
const message = err instanceof RequestError ? err.message : normalizeError(err as BusinessError | Error);
|
||||
promptAction.showToast({ message: message });
|
||||
} finally {
|
||||
this.processingId = 0;
|
||||
}
|
||||
}
|
||||
|
||||
@Builder
|
||||
ItemCard(item: FeedbackItem) {
|
||||
Column({ space: 8 }) {
|
||||
Row({ space: 8 }) {
|
||||
Text(item.user_name || item.user_alias || '用户')
|
||||
.fontSize(15)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
Text(roleLabel(item.user_role))
|
||||
.fontSize(11)
|
||||
.fontColor('#007AFF')
|
||||
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
|
||||
.backgroundColor('#1A007AFF')
|
||||
.borderRadius(6)
|
||||
Blank()
|
||||
Text(item.status === 'resolved' ? '已处理' : '待处理')
|
||||
.fontSize(12)
|
||||
.fontColor(item.status === 'resolved' ? $r('app.color.accent_green') : '#FF9500')
|
||||
if (item.status !== 'resolved') {
|
||||
Text('处理')
|
||||
.fontSize(13)
|
||||
.fontColor('#FFFFFF')
|
||||
.padding({ left: 10, right: 10, top: 4, bottom: 4 })
|
||||
.backgroundColor('#007AFF')
|
||||
.borderRadius(8)
|
||||
.onClick(() => {
|
||||
this.toggleStatus(item);
|
||||
})
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(VerticalAlign.Center)
|
||||
|
||||
Text(item.content)
|
||||
.fontSize(14)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.width('100%')
|
||||
.maxLines(3)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
|
||||
Row({ space: 8 }) {
|
||||
Text(formatTime(item.created_at))
|
||||
.fontSize(12)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
if (item.contact && item.contact.length > 0) {
|
||||
Text(`联系方式:${item.contact}`)
|
||||
.fontSize(12)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
}
|
||||
.width('100%')
|
||||
.padding(14)
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.borderRadius(14)
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
}
|
||||
|
||||
build() {
|
||||
HdsNavigation() {
|
||||
Column() {
|
||||
if (this.loading) {
|
||||
Column() {
|
||||
Text('正在加载')
|
||||
.fontSize(15)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ top: 60 })
|
||||
} else if (this.items.length === 0) {
|
||||
Column({ space: 8 }) {
|
||||
Text('暂无反馈')
|
||||
.fontSize(16)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
Text('还没有意见反馈工单')
|
||||
.fontSize(13)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ top: 160 })
|
||||
.alignItems(HorizontalAlign.Center)
|
||||
} else {
|
||||
Scroll(this.scroller) {
|
||||
Column({ space: 12 }) {
|
||||
Row({ space: 8 }) {
|
||||
Text('全部')
|
||||
.fontSize(14)
|
||||
.fontColor(this.statusFilter === 'all' ? '#FFFFFF' : $r('app.color.text_secondary'))
|
||||
.padding({ left: 14, right: 14, top: 6, bottom: 6 })
|
||||
.backgroundColor(this.statusFilter === 'all' ? $r('app.color.accent_blue') : $r('app.color.background_card'))
|
||||
.borderRadius(14)
|
||||
.onClick(() => {
|
||||
this.switchStatus('all');
|
||||
})
|
||||
Text('待处理')
|
||||
.fontSize(14)
|
||||
.fontColor(this.statusFilter === 'pending' ? '#FFFFFF' : $r('app.color.text_secondary'))
|
||||
.padding({ left: 14, right: 14, top: 6, bottom: 6 })
|
||||
.backgroundColor(this.statusFilter === 'pending' ? $r('app.color.accent_blue') : $r('app.color.background_card'))
|
||||
.borderRadius(14)
|
||||
.onClick(() => {
|
||||
this.switchStatus('pending');
|
||||
})
|
||||
Text('已处理')
|
||||
.fontSize(14)
|
||||
.fontColor(this.statusFilter === 'resolved' ? '#FFFFFF' : $r('app.color.text_secondary'))
|
||||
.padding({ left: 14, right: 14, top: 6, bottom: 6 })
|
||||
.backgroundColor(this.statusFilter === 'resolved' ? $r('app.color.accent_blue') : $r('app.color.background_card'))
|
||||
.borderRadius(14)
|
||||
.onClick(() => {
|
||||
this.switchStatus('resolved');
|
||||
})
|
||||
Blank()
|
||||
Text(`共 ${this.count} 条`)
|
||||
.fontSize(13)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(VerticalAlign.Center)
|
||||
|
||||
ForEach(this.items, (item: FeedbackItem) => {
|
||||
this.ItemCard(item)
|
||||
}, (item: FeedbackItem) => String(item.id))
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ left: 16, right: 16, top: 120, bottom: 24 })
|
||||
}
|
||||
.width('100%')
|
||||
.layoutWeight(1)
|
||||
.scrollBar(BarState.Auto)
|
||||
.edgeEffect(EdgeEffect.Spring)
|
||||
.onReachEnd(() => {
|
||||
if (!this.loading && this.items.length < this.count) {
|
||||
this.fetch(false);
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
}
|
||||
.mode(NavigationMode.Stack)
|
||||
.titleBar({
|
||||
content: {
|
||||
title: {
|
||||
mainTitle: '意见反馈',
|
||||
},
|
||||
},
|
||||
style: {
|
||||
scrollEffectOpts: {
|
||||
enableScrollEffect: false,
|
||||
scrollEffectType: ScrollEffectType.GRADIENT_BLUR,
|
||||
},
|
||||
systemMaterialEffect: {
|
||||
materialType: hdsMaterial.MaterialType.ADAPTIVE,
|
||||
materialLevel: hdsMaterial.MaterialLevel.EXQUISITE,
|
||||
},
|
||||
},
|
||||
avoidLayoutSafeArea: false,
|
||||
enableComponentSafeArea: false,
|
||||
})
|
||||
.bindToScrollable([this.scroller])
|
||||
.hideBackButton(true)
|
||||
.titleMode(HdsNavigationTitleMode.MINI)
|
||||
.ignoreLayoutSafeArea([LayoutSafeAreaType.SYSTEM], [LayoutSafeAreaEdge.TOP, LayoutSafeAreaEdge.BOTTOM])
|
||||
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
|
||||
.backgroundColor($r('app.color.background'))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
// 管理员 · 护理员详情(ArkTS 严格类型,无 any)
|
||||
// 接口:GET /users/{id}/、GET /stats/supervisor/nurse-detail/{id}/
|
||||
import { router, promptAction } from '@kit.ArkUI';
|
||||
import { HdsNavigation, HdsNavigationTitleMode, ScrollEffectType } from '@kit.UIDesignKit';
|
||||
import { hdsMaterial } from '@hms.hds.hdsMaterial';
|
||||
import { BusinessError } from '@kit.BasicServicesKit';
|
||||
import { normalizeError } from '../../common/ApiConstants';
|
||||
import { api, RequestError } from '../../utils/Request';
|
||||
|
||||
interface NurseProfile {
|
||||
id: number;
|
||||
name: string;
|
||||
phone: string;
|
||||
group: string;
|
||||
role: string;
|
||||
duty_status: string;
|
||||
account_status: string;
|
||||
date_joined: string;
|
||||
}
|
||||
|
||||
interface Summary {
|
||||
record_count: number;
|
||||
elderly_served: number;
|
||||
}
|
||||
|
||||
interface WindowProgress {
|
||||
status: string; // completed / current / upcoming / overdue
|
||||
}
|
||||
|
||||
interface ServiceItem {
|
||||
service_name: string;
|
||||
category_name: string;
|
||||
status: string;
|
||||
remaining: number;
|
||||
window_progress?: Array<WindowProgress>;
|
||||
}
|
||||
|
||||
interface ServiceProgress {
|
||||
elderly_id: number;
|
||||
elderly_name: string;
|
||||
room: string;
|
||||
bed: string;
|
||||
items: Array<ServiceItem>;
|
||||
}
|
||||
|
||||
interface NurseDetail {
|
||||
date: string;
|
||||
nurse_id: number;
|
||||
nurse_name: string;
|
||||
duty_status: string;
|
||||
group: string;
|
||||
summary: Summary;
|
||||
overall_status: string;
|
||||
overall_completion_rate: number;
|
||||
service_progress: Array<ServiceProgress>;
|
||||
}
|
||||
|
||||
function statusLabel(item: ServiceItem): string {
|
||||
if (item.status === 'completed') {
|
||||
return '完成';
|
||||
}
|
||||
if (item.status === 'partial') {
|
||||
return '部分';
|
||||
}
|
||||
if (item.status === 'missed') {
|
||||
// 后端把"完成数=0"都标 missed,需按时间窗区分:
|
||||
// 还有进行中/未到的窗口 → 未开始;全部窗口已过 → 遗漏
|
||||
const wp: Array<WindowProgress> = item.window_progress ?? [];
|
||||
const hasOpen = wp.some((w: WindowProgress): boolean => w.status === 'current' || w.status === 'upcoming');
|
||||
return hasOpen ? '未开始' : '遗漏';
|
||||
}
|
||||
return '遗漏';
|
||||
}
|
||||
|
||||
function statusColor(item: ServiceItem): string {
|
||||
if (item.status === 'completed') {
|
||||
return '#34C759';
|
||||
}
|
||||
if (item.status === 'partial') {
|
||||
return '#FF9500';
|
||||
}
|
||||
if (item.status === 'missed') {
|
||||
const wp: Array<WindowProgress> = item.window_progress ?? [];
|
||||
const hasOpen = wp.some((w: WindowProgress): boolean => w.status === 'current' || w.status === 'upcoming');
|
||||
return hasOpen ? '#8E8E93' : '#FF3B30';
|
||||
}
|
||||
return '#FF3B30';
|
||||
}
|
||||
|
||||
function dutyLabel(status: string): string {
|
||||
if (status === 'on_duty') {
|
||||
return '在岗';
|
||||
}
|
||||
if (status === 'rest') {
|
||||
return '休息';
|
||||
}
|
||||
if (status === 'offboard') {
|
||||
return '离职';
|
||||
}
|
||||
return '--';
|
||||
}
|
||||
|
||||
function percentText(rate: number): string {
|
||||
if (rate === undefined || rate === null) {
|
||||
return '--';
|
||||
}
|
||||
return `${Math.round(rate * 100)}%`;
|
||||
}
|
||||
|
||||
@Entry
|
||||
@Component
|
||||
struct AdminNurseDetail {
|
||||
@State nurseId: number = 0;
|
||||
@State profile: NurseProfile | null = null;
|
||||
@State detail: NurseDetail | null = null;
|
||||
@State loading: boolean = true;
|
||||
private scroller: Scroller = new Scroller();
|
||||
|
||||
aboutToAppear(): void {
|
||||
const params = router.getParams() as Record<string, string | number>;
|
||||
if (params) {
|
||||
const id = params['id'];
|
||||
if (id !== undefined) {
|
||||
this.nurseId = Number(id);
|
||||
}
|
||||
}
|
||||
this.load();
|
||||
}
|
||||
|
||||
private async load(): Promise<void> {
|
||||
this.loading = true;
|
||||
try {
|
||||
const results: Array<NurseProfile | NurseDetail | null> = await Promise.all([
|
||||
api.get<NurseProfile>(`/auth/users/${this.nurseId}/`).catch((): NurseProfile | null => null),
|
||||
api.get<NurseDetail>(`/stats/supervisor/nurse-detail/${this.nurseId}/`).catch((): NurseDetail | null => null),
|
||||
]);
|
||||
this.profile = results[0] as NurseProfile | null;
|
||||
this.detail = results[1] as NurseDetail | null;
|
||||
} catch (err) {
|
||||
const message = err instanceof RequestError ? err.message : normalizeError(err as BusinessError | Error);
|
||||
promptAction.showToast({ message: message });
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
@Builder
|
||||
InfoRow(label: string, value: string) {
|
||||
Row({ space: 12 }) {
|
||||
Text(label)
|
||||
.fontSize(14)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
.width(72)
|
||||
Text(value)
|
||||
.fontSize(14)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.layoutWeight(1)
|
||||
}
|
||||
.width('100%')
|
||||
}
|
||||
|
||||
@Builder
|
||||
StatTile(value: string, label: string, color: string) {
|
||||
Column({ space: 4 }) {
|
||||
Text(value)
|
||||
.fontSize(22)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor(color)
|
||||
Text(label)
|
||||
.fontSize(12)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
}
|
||||
.layoutWeight(1)
|
||||
.justifyContent(FlexAlign.Center)
|
||||
.alignItems(HorizontalAlign.Center)
|
||||
}
|
||||
|
||||
@Builder
|
||||
ElderlyProgress(p: ServiceProgress) {
|
||||
Column({ space: 6 }) {
|
||||
Row({ space: 8 }) {
|
||||
Text(p.elderly_name)
|
||||
.fontSize(15)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
if (p.room && p.room.length > 0) {
|
||||
Text(`${p.room}${p.bed ? '-' + p.bed : ''}`)
|
||||
.fontSize(12)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
}
|
||||
Blank()
|
||||
Text(`${p.items.length} 项服务`)
|
||||
.fontSize(12)
|
||||
.fontColor($r('app.color.inactive_tab'))
|
||||
}
|
||||
.width('100%')
|
||||
|
||||
ForEach(p.items, (item: ServiceItem) => {
|
||||
Row({ space: 8 }) {
|
||||
Text(item.service_name)
|
||||
.fontSize(13)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.layoutWeight(1)
|
||||
.maxLines(1)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
Text(statusLabel(item))
|
||||
.fontSize(12)
|
||||
.fontColor(statusColor(item))
|
||||
}
|
||||
.width('100%')
|
||||
}, (item: ServiceItem) => `${p.elderly_id}-${item.service_name}`)
|
||||
}
|
||||
.width('100%')
|
||||
.padding(12)
|
||||
.backgroundColor($r('app.color.background'))
|
||||
.borderRadius(10)
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
}
|
||||
|
||||
build() {
|
||||
HdsNavigation() {
|
||||
Column() {
|
||||
if (this.loading) {
|
||||
Text('加载中...')
|
||||
.fontSize(15)
|
||||
.fontColor($r('app.color.inactive_tab'))
|
||||
.margin({ top: 60 })
|
||||
} else {
|
||||
Scroll(this.scroller) {
|
||||
Column({ space: 12 }) {
|
||||
if (this.profile) {
|
||||
Column({ space: 12 }) {
|
||||
this.InfoRow('姓名', this.profile.name || '--')
|
||||
this.InfoRow('手机', this.profile.phone || '--')
|
||||
this.InfoRow('所属组', this.profile.group || '--')
|
||||
this.InfoRow('值班状态', dutyLabel(this.profile.duty_status))
|
||||
this.InfoRow('账号状态', this.profile.account_status || '--')
|
||||
}
|
||||
.width('100%')
|
||||
.padding(16)
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.borderRadius(16)
|
||||
}
|
||||
|
||||
if (this.detail) {
|
||||
// 今日统计
|
||||
Column({ space: 12 }) {
|
||||
Row() {
|
||||
Text('今日工作')
|
||||
.fontSize(16)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
Blank()
|
||||
Text(`完成率 ${percentText(this.detail.overall_completion_rate)}`)
|
||||
.fontSize(13)
|
||||
.fontColor($r('app.color.accent_green'))
|
||||
}
|
||||
.width('100%')
|
||||
|
||||
Row() {
|
||||
this.StatTile(String(this.detail.summary.record_count), '完成记录', '#007AFF')
|
||||
this.StatTile(String(this.detail.summary.elderly_served), '服务长者', '#34C759')
|
||||
this.StatTile(percentText(this.detail.overall_completion_rate), '整体完成率', '#007AFF')
|
||||
}
|
||||
.width('100%')
|
||||
}
|
||||
.width('100%')
|
||||
.padding(16)
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.borderRadius(16)
|
||||
|
||||
// 服务进度
|
||||
if (this.detail.service_progress.length > 0) {
|
||||
Column({ space: 8 }) {
|
||||
Text('今日服务进度')
|
||||
.fontSize(16)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.width('100%')
|
||||
ForEach(this.detail.service_progress, (p: ServiceProgress) => {
|
||||
Column() {
|
||||
this.ElderlyProgress(p)
|
||||
}
|
||||
.width('100%')
|
||||
}, (p: ServiceProgress) => String(p.elderly_id))
|
||||
}
|
||||
.width('100%')
|
||||
.padding(16)
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.borderRadius(16)
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
}
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ left: 16, right: 16, bottom: 24 })
|
||||
}
|
||||
.width('100%')
|
||||
.layoutWeight(1)
|
||||
.scrollBar(BarState.Auto)
|
||||
.edgeEffect(EdgeEffect.Spring)
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
}
|
||||
.mode(NavigationMode.Stack)
|
||||
.titleBar({
|
||||
content: {
|
||||
title: {
|
||||
mainTitle: '护理员详情',
|
||||
},
|
||||
},
|
||||
style: {
|
||||
scrollEffectOpts: {
|
||||
enableScrollEffect: false,
|
||||
scrollEffectType: ScrollEffectType.GRADIENT_BLUR,
|
||||
},
|
||||
systemMaterialEffect: {
|
||||
materialType: hdsMaterial.MaterialType.ADAPTIVE,
|
||||
materialLevel: hdsMaterial.MaterialLevel.EXQUISITE,
|
||||
},
|
||||
},
|
||||
avoidLayoutSafeArea: false,
|
||||
enableComponentSafeArea: false,
|
||||
})
|
||||
.bindToScrollable([this.scroller])
|
||||
.hideBackButton(false)
|
||||
.titleMode(HdsNavigationTitleMode.MINI)
|
||||
.ignoreLayoutSafeArea([LayoutSafeAreaType.SYSTEM], [LayoutSafeAreaEdge.TOP, LayoutSafeAreaEdge.BOTTOM])
|
||||
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
|
||||
.backgroundColor($r('app.color.background'))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
// 管理员 · 护理员列表(ArkTS 严格类型,无 any)
|
||||
// 接口:GET /users/?role=nurse
|
||||
import { router, promptAction } from '@kit.ArkUI';
|
||||
import { HdsNavigation, HdsNavigationTitleMode, ScrollEffectType } from '@kit.UIDesignKit';
|
||||
import { hdsMaterial } from '@hms.hds.hdsMaterial';
|
||||
import { BusinessError } from '@kit.BasicServicesKit';
|
||||
import { normalizeError } from '../../common/ApiConstants';
|
||||
import { api, RequestError } from '../../utils/Request';
|
||||
|
||||
interface NurseUser {
|
||||
id: number;
|
||||
username: string;
|
||||
name: string;
|
||||
phone: string;
|
||||
group: string;
|
||||
duty_status: string;
|
||||
account_status: string;
|
||||
}
|
||||
|
||||
function dutyLabel(status: string): string {
|
||||
if (status === 'on_duty') {
|
||||
return '在岗';
|
||||
}
|
||||
if (status === 'rest') {
|
||||
return '休息';
|
||||
}
|
||||
if (status === 'offboard') {
|
||||
return '离职';
|
||||
}
|
||||
return '--';
|
||||
}
|
||||
|
||||
function dutyColor(status: string): string {
|
||||
if (status === 'on_duty') {
|
||||
return '#34C759';
|
||||
}
|
||||
if (status === 'rest') {
|
||||
return '#FF9500';
|
||||
}
|
||||
return '#8E8E93';
|
||||
}
|
||||
|
||||
@Entry
|
||||
@Component
|
||||
struct AdminNurseList {
|
||||
@State nurses: Array<NurseUser> = [];
|
||||
@State loading: boolean = true;
|
||||
private scroller: Scroller = new Scroller();
|
||||
|
||||
aboutToAppear(): void {
|
||||
this.load();
|
||||
}
|
||||
|
||||
private async load(): Promise<void> {
|
||||
this.loading = true;
|
||||
try {
|
||||
const res = await api.get<Array<NurseUser>>('/auth/users/', { role: 'nurse', page_size: 50 });
|
||||
this.nurses = res;
|
||||
} catch (err) {
|
||||
const message = err instanceof RequestError ? err.message : normalizeError(err as BusinessError | Error);
|
||||
promptAction.showToast({ message: message });
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private openDetail(nurse: NurseUser): void {
|
||||
router.pushUrl({
|
||||
url: 'pages/admin/AdminNurseDetail',
|
||||
params: { id: nurse.id, name: nurse.name },
|
||||
}).catch((err: BusinessError) => {
|
||||
promptAction.showToast({ message: `打开失败:${normalizeError(err)}` });
|
||||
});
|
||||
}
|
||||
|
||||
@Builder
|
||||
NurseCard(nurse: NurseUser) {
|
||||
Row({ space: 12 }) {
|
||||
Column() {
|
||||
Text(nurse.name.substring(0, 1))
|
||||
.fontSize(18)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor('#FFFFFF')
|
||||
}
|
||||
.width(48)
|
||||
.height(48)
|
||||
.justifyContent(FlexAlign.Center)
|
||||
.alignItems(HorizontalAlign.Center)
|
||||
.backgroundColor($r('app.color.accent_blue'))
|
||||
.borderRadius(14)
|
||||
|
||||
Column({ space: 6 }) {
|
||||
Row({ space: 8 }) {
|
||||
Text(nurse.name)
|
||||
.fontSize(16)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
Text(dutyLabel(nurse.duty_status))
|
||||
.fontSize(11)
|
||||
.fontColor(dutyColor(nurse.duty_status))
|
||||
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
|
||||
.backgroundColor('#14000000')
|
||||
.borderRadius(6)
|
||||
}
|
||||
.width('100%')
|
||||
|
||||
Text(`${nurse.phone || '--'}${nurse.group ? ' · ' + nurse.group : ''}`)
|
||||
.fontSize(13)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
.width('100%')
|
||||
}
|
||||
.layoutWeight(1)
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
|
||||
Text('›')
|
||||
.fontSize(20)
|
||||
.fontColor($r('app.color.inactive_tab'))
|
||||
}
|
||||
.width('100%')
|
||||
.padding(14)
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.borderRadius(14)
|
||||
.onClick(() => {
|
||||
this.openDetail(nurse);
|
||||
})
|
||||
}
|
||||
|
||||
build() {
|
||||
HdsNavigation() {
|
||||
Column() {
|
||||
if (this.loading) {
|
||||
Text('加载中...')
|
||||
.fontSize(15)
|
||||
.fontColor($r('app.color.inactive_tab'))
|
||||
.margin({ top: 60 })
|
||||
} else if (this.nurses.length === 0) {
|
||||
Text('暂无护理员')
|
||||
.fontSize(15)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
.margin({ top: 160 })
|
||||
} else {
|
||||
Scroll(this.scroller) {
|
||||
Column({ space: 10 }) {
|
||||
ForEach(this.nurses, (nurse: NurseUser) => {
|
||||
Column() {
|
||||
this.NurseCard(nurse)
|
||||
}
|
||||
.width('100%')
|
||||
}, (nurse: NurseUser) => String(nurse.id))
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ left: 16, right: 16, top: 120, bottom: 24 })
|
||||
}
|
||||
.width('100%')
|
||||
.layoutWeight(1)
|
||||
.scrollBar(BarState.Auto)
|
||||
.edgeEffect(EdgeEffect.Spring)
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
}
|
||||
.mode(NavigationMode.Stack)
|
||||
.titleBar({
|
||||
content: {
|
||||
title: {
|
||||
mainTitle: '护理员',
|
||||
},
|
||||
},
|
||||
style: {
|
||||
scrollEffectOpts: {
|
||||
enableScrollEffect: false,
|
||||
scrollEffectType: ScrollEffectType.GRADIENT_BLUR,
|
||||
},
|
||||
systemMaterialEffect: {
|
||||
materialType: hdsMaterial.MaterialType.ADAPTIVE,
|
||||
materialLevel: hdsMaterial.MaterialLevel.EXQUISITE,
|
||||
},
|
||||
},
|
||||
avoidLayoutSafeArea: false,
|
||||
enableComponentSafeArea: false,
|
||||
})
|
||||
.bindToScrollable([this.scroller])
|
||||
.hideBackButton(false)
|
||||
.titleMode(HdsNavigationTitleMode.MINI)
|
||||
.ignoreLayoutSafeArea([LayoutSafeAreaType.SYSTEM], [LayoutSafeAreaEdge.TOP, LayoutSafeAreaEdge.BOTTOM])
|
||||
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
|
||||
.backgroundColor($r('app.color.background'))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,346 @@
|
||||
// 管理员 · 护理记录列表(ArkTS 严格类型,无 any)
|
||||
// 接口:GET /tasks/nursing-records/,支持 全部/今日 筛选 + 滚动分页
|
||||
import { router, promptAction } from '@kit.ArkUI';
|
||||
import { HdsNavigation, HdsNavigationTitleMode, ScrollEffectType } from '@kit.UIDesignKit';
|
||||
import { hdsMaterial } from '@hms.hds.hdsMaterial';
|
||||
import { BusinessError } from '@kit.BasicServicesKit';
|
||||
import { Page, normalizeError } from '../../common/ApiConstants';
|
||||
import { api, RequestError } from '../../utils/Request';
|
||||
|
||||
interface VitalPart {
|
||||
text?: string;
|
||||
numeric?: string;
|
||||
trend?: string;
|
||||
systolic?: string;
|
||||
diastolic?: string;
|
||||
}
|
||||
|
||||
interface TextPart {
|
||||
text?: string;
|
||||
trend?: string;
|
||||
}
|
||||
|
||||
interface ExtractedJson {
|
||||
temperature?: VitalPart;
|
||||
blood_pressure?: VitalPart;
|
||||
weight?: VitalPart;
|
||||
medication?: TextPart;
|
||||
diet?: TextPart;
|
||||
mood?: TextPart;
|
||||
}
|
||||
|
||||
interface NursingRecordItem {
|
||||
id: number;
|
||||
elderly: number | null;
|
||||
nurse: number | null;
|
||||
elderly_name: string;
|
||||
nurse_name: string;
|
||||
images: Array<string>;
|
||||
raw_text: string;
|
||||
extracted_json: object;
|
||||
work_item: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
function todayYMD(): string {
|
||||
const now = new Date();
|
||||
const mm = String(now.getMonth() + 1).padStart(2, '0');
|
||||
const dd = String(now.getDate()).padStart(2, '0');
|
||||
return `${now.getFullYear()}-${mm}-${dd}`;
|
||||
}
|
||||
|
||||
function formatRecordTime(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) {
|
||||
return iso;
|
||||
}
|
||||
const mm = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const dd = String(d.getDate()).padStart(2, '0');
|
||||
const hh = String(d.getHours()).padStart(2, '0');
|
||||
const mi = String(d.getMinutes()).padStart(2, '0');
|
||||
return `${mm}/${dd} ${hh}:${mi}`;
|
||||
}
|
||||
|
||||
function recordSummary(record: NursingRecordItem): string {
|
||||
const e = record.extracted_json as ExtractedJson;
|
||||
const parts: Array<string> = [];
|
||||
if (e.temperature && e.temperature.text && e.temperature.text.length > 0) {
|
||||
parts.push(`体温 ${e.temperature.text}`);
|
||||
}
|
||||
if (e.blood_pressure && e.blood_pressure.text && e.blood_pressure.text.length > 0) {
|
||||
parts.push(`血压 ${e.blood_pressure.text}`);
|
||||
}
|
||||
if (e.weight && e.weight.text && e.weight.text.length > 0) {
|
||||
parts.push(`体重 ${e.weight.text}`);
|
||||
}
|
||||
if (e.medication && e.medication.text && e.medication.text.length > 0) {
|
||||
parts.push(`服药 ${e.medication.text}`);
|
||||
}
|
||||
if (e.diet && e.diet.text && e.diet.text.length > 0) {
|
||||
parts.push(`进食 ${e.diet.text}`);
|
||||
}
|
||||
if (e.mood && e.mood.text && e.mood.text.length > 0) {
|
||||
parts.push(`心理 ${e.mood.text}`);
|
||||
}
|
||||
return parts.join(' · ');
|
||||
}
|
||||
|
||||
@Entry
|
||||
@Component
|
||||
struct AdminNursingRecords {
|
||||
@State records: Array<NursingRecordItem> = [];
|
||||
@State loading: boolean = true;
|
||||
@State loadingMore: boolean = false;
|
||||
@State page: number = 1;
|
||||
@State count: number = 0;
|
||||
@State dateFilter: string = 'all';
|
||||
private pageSize: number = 10;
|
||||
private scroller: Scroller = new Scroller();
|
||||
|
||||
aboutToAppear(): void {
|
||||
this.reload();
|
||||
}
|
||||
|
||||
private reload(): void {
|
||||
this.page = 1;
|
||||
this.records = [];
|
||||
this.fetch(true);
|
||||
}
|
||||
|
||||
private async fetch(isReset: boolean): Promise<void> {
|
||||
if (isReset) {
|
||||
this.loading = true;
|
||||
} else {
|
||||
this.loadingMore = true;
|
||||
}
|
||||
try {
|
||||
let res: Page<NursingRecordItem>;
|
||||
if (this.dateFilter === 'today') {
|
||||
res = await api.get<Page<NursingRecordItem>>('/tasks/nursing-records/', {
|
||||
page: this.page,
|
||||
page_size: this.pageSize,
|
||||
ordering: '-created_at',
|
||||
date: todayYMD(),
|
||||
});
|
||||
} else {
|
||||
res = await api.get<Page<NursingRecordItem>>('/tasks/nursing-records/', {
|
||||
page: this.page,
|
||||
page_size: this.pageSize,
|
||||
ordering: '-created_at',
|
||||
});
|
||||
}
|
||||
this.count = res.count;
|
||||
if (isReset) {
|
||||
this.records = res.results;
|
||||
} else {
|
||||
this.records = this.records.concat(res.results);
|
||||
}
|
||||
if (res.results.length > 0) {
|
||||
this.page += 1;
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof RequestError ? err.message : normalizeError(err as BusinessError | Error);
|
||||
promptAction.showToast({ message: message });
|
||||
} finally {
|
||||
this.loading = false;
|
||||
this.loadingMore = false;
|
||||
}
|
||||
}
|
||||
|
||||
private switchFilter(mode: string): void {
|
||||
if (this.dateFilter === mode) {
|
||||
return;
|
||||
}
|
||||
this.dateFilter = mode;
|
||||
this.reload();
|
||||
}
|
||||
|
||||
private openDetail(record: NursingRecordItem): void {
|
||||
router.pushUrl({
|
||||
url: 'pages/admin/AdminRecordDetail',
|
||||
params: { id: record.id },
|
||||
}).catch((err: BusinessError) => {
|
||||
promptAction.showToast({ message: `打开失败:${normalizeError(err)}` });
|
||||
});
|
||||
}
|
||||
|
||||
@Builder
|
||||
RecordCard(record: NursingRecordItem) {
|
||||
Row({ space: 12 }) {
|
||||
Column() {
|
||||
Text(record.elderly_name.length > 0 ? record.elderly_name.substring(0, 1) : '护')
|
||||
.fontSize(18)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor('#FFFFFF')
|
||||
}
|
||||
.width(48)
|
||||
.height(48)
|
||||
.justifyContent(FlexAlign.Center)
|
||||
.alignItems(HorizontalAlign.Center)
|
||||
.backgroundColor($r('app.color.accent_blue'))
|
||||
.borderRadius(14)
|
||||
|
||||
Column({ space: 5 }) {
|
||||
Row({ space: 8 }) {
|
||||
Text(record.elderly_name)
|
||||
.fontSize(16)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
Blank()
|
||||
Text(formatRecordTime(record.created_at))
|
||||
.fontSize(12)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
}
|
||||
.width('100%')
|
||||
|
||||
if (record.raw_text && record.raw_text.length > 0) {
|
||||
Text(record.raw_text)
|
||||
.fontSize(13)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
.maxLines(2)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
.width('100%')
|
||||
}
|
||||
|
||||
if (recordSummary(record).length > 0) {
|
||||
Text(recordSummary(record))
|
||||
.fontSize(12)
|
||||
.fontColor($r('app.color.accent_blue'))
|
||||
.maxLines(2)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
.width('100%')
|
||||
}
|
||||
|
||||
Row({ space: 8 }) {
|
||||
Text(record.nurse_name || '--')
|
||||
.fontSize(12)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
if (record.images.length > 0) {
|
||||
Text(`图片 ${record.images.length}`)
|
||||
.fontSize(12)
|
||||
.fontColor($r('app.color.accent_green'))
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
}
|
||||
.layoutWeight(1)
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
}
|
||||
.width('100%')
|
||||
.padding(14)
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.borderRadius(14)
|
||||
.onClick(() => {
|
||||
this.openDetail(record);
|
||||
})
|
||||
}
|
||||
|
||||
build() {
|
||||
HdsNavigation() {
|
||||
Column() {
|
||||
if (this.loading) {
|
||||
Column() {
|
||||
Text('正在加载')
|
||||
.fontSize(15)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ top: 60 })
|
||||
} else if (this.records.length === 0) {
|
||||
Column({ space: 8 }) {
|
||||
Text('暂无护理记录')
|
||||
.fontSize(16)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
Text(this.dateFilter === 'today' ? '今日还没有护理记录' : '还没有护理记录')
|
||||
.fontSize(13)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ top: 160 })
|
||||
.alignItems(HorizontalAlign.Center)
|
||||
} else {
|
||||
Scroll(this.scroller) {
|
||||
Column({ space: 12 }) {
|
||||
Row({ space: 8 }) {
|
||||
Text('全部')
|
||||
.fontSize(14)
|
||||
.fontColor(this.dateFilter === 'all' ? '#FFFFFF' : $r('app.color.text_secondary'))
|
||||
.padding({ left: 14, right: 14, top: 6, bottom: 6 })
|
||||
.backgroundColor(this.dateFilter === 'all' ? $r('app.color.accent_blue') : $r('app.color.background_card'))
|
||||
.borderRadius(14)
|
||||
.onClick(() => {
|
||||
this.switchFilter('all');
|
||||
})
|
||||
Text('今日')
|
||||
.fontSize(14)
|
||||
.fontColor(this.dateFilter === 'today' ? '#FFFFFF' : $r('app.color.text_secondary'))
|
||||
.padding({ left: 14, right: 14, top: 6, bottom: 6 })
|
||||
.backgroundColor(this.dateFilter === 'today' ? $r('app.color.accent_blue') : $r('app.color.background_card'))
|
||||
.borderRadius(14)
|
||||
.onClick(() => {
|
||||
this.switchFilter('today');
|
||||
})
|
||||
Blank()
|
||||
Text(`共 ${this.count} 条`)
|
||||
.fontSize(13)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(VerticalAlign.Center)
|
||||
|
||||
ForEach(this.records, (record: NursingRecordItem) => {
|
||||
this.RecordCard(record)
|
||||
}, (record: NursingRecordItem) => String(record.id))
|
||||
|
||||
if (this.loadingMore) {
|
||||
Text('加载中...')
|
||||
.fontSize(13)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
.padding(12)
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ left: 16, right: 16, top: 120, bottom: 24 })
|
||||
}
|
||||
.width('100%')
|
||||
.layoutWeight(1)
|
||||
.scrollBar(BarState.Auto)
|
||||
.edgeEffect(EdgeEffect.Spring)
|
||||
.onReachEnd(() => {
|
||||
if (!this.loading && !this.loadingMore && this.records.length < this.count) {
|
||||
this.fetch(false);
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
}
|
||||
.mode(NavigationMode.Stack)
|
||||
.titleBar({
|
||||
content: {
|
||||
title: {
|
||||
mainTitle: '护理记录',
|
||||
},
|
||||
},
|
||||
style: {
|
||||
scrollEffectOpts: {
|
||||
enableScrollEffect: false,
|
||||
scrollEffectType: ScrollEffectType.GRADIENT_BLUR,
|
||||
},
|
||||
systemMaterialEffect: {
|
||||
materialType: hdsMaterial.MaterialType.ADAPTIVE,
|
||||
materialLevel: hdsMaterial.MaterialLevel.EXQUISITE,
|
||||
},
|
||||
},
|
||||
avoidLayoutSafeArea: false,
|
||||
enableComponentSafeArea: false,
|
||||
})
|
||||
.bindToScrollable([this.scroller])
|
||||
.hideBackButton(true)
|
||||
.titleMode(HdsNavigationTitleMode.MINI)
|
||||
.ignoreLayoutSafeArea([LayoutSafeAreaType.SYSTEM], [LayoutSafeAreaEdge.TOP, LayoutSafeAreaEdge.BOTTOM])
|
||||
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
|
||||
.backgroundColor($r('app.color.background'))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,603 @@
|
||||
// 管理员 · 信息概览(ArkTS 严格类型,无 any)
|
||||
// 接口:GET /dashboard/summary/
|
||||
import { router } from '@kit.ArkUI';
|
||||
import { promptAction } from '@kit.ArkUI';
|
||||
import { HdsNavigation, HdsNavigationTitleMode, ScrollEffectType } from '@kit.UIDesignKit';
|
||||
import { hdsMaterial } from '@hms.hds.hdsMaterial';
|
||||
import { BusinessError } from '@kit.BasicServicesKit';
|
||||
import { normalizeError } from '../../common/ApiConstants';
|
||||
import { api, RequestError } from '../../utils/Request';
|
||||
|
||||
interface HealthDistribution {
|
||||
total: number;
|
||||
good: number;
|
||||
attention: number;
|
||||
bedridden: number;
|
||||
good_percent: string;
|
||||
attention_percent: string;
|
||||
bedridden_percent: string;
|
||||
}
|
||||
|
||||
interface UnresolvedAlert {
|
||||
id: number;
|
||||
title: string;
|
||||
description: string;
|
||||
level: string;
|
||||
elderly_name: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface RecentRecord {
|
||||
id: number;
|
||||
elderly_name: string;
|
||||
nurse_name: string;
|
||||
raw_text: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface DailyTrend {
|
||||
date: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
interface MealSummary {
|
||||
category: string;
|
||||
reserved_count: number;
|
||||
menu_items: Array<string>;
|
||||
}
|
||||
|
||||
interface MealsOverview {
|
||||
total_reserved_count: number;
|
||||
meals: Array<MealSummary>;
|
||||
}
|
||||
|
||||
interface CareOverview {
|
||||
on_duty_count: number;
|
||||
today_record_count: number;
|
||||
daily_trend: Array<DailyTrend>;
|
||||
}
|
||||
|
||||
interface OperationOverview {
|
||||
care: CareOverview;
|
||||
meals: MealsOverview;
|
||||
}
|
||||
|
||||
interface DashboardSummary {
|
||||
summary_date: string;
|
||||
overview_elderly: number;
|
||||
focused_elderly: number;
|
||||
on_duty_nurses: number;
|
||||
active_accounts: number;
|
||||
today_alert_count: number;
|
||||
pending_alert_count: number;
|
||||
today_record_count: number;
|
||||
health_distribution: HealthDistribution;
|
||||
unresolved_alerts: Array<UnresolvedAlert>;
|
||||
recent_records: Array<RecentRecord>;
|
||||
operation_overview: OperationOverview;
|
||||
}
|
||||
|
||||
function levelColor(level: string): string {
|
||||
if (level === 'emergency') {
|
||||
return '#FF3B30';
|
||||
}
|
||||
if (level === 'warning') {
|
||||
return '#FF9500';
|
||||
}
|
||||
return '#007AFF';
|
||||
}
|
||||
|
||||
function levelLabel(level: string): string {
|
||||
if (level === 'emergency') {
|
||||
return '紧急';
|
||||
}
|
||||
if (level === 'warning') {
|
||||
return '注意';
|
||||
}
|
||||
return '正常';
|
||||
}
|
||||
|
||||
function mealLabel(category: string): string {
|
||||
if (category === 'breakfast') {
|
||||
return '早餐';
|
||||
}
|
||||
if (category === 'lunch') {
|
||||
return '午餐';
|
||||
}
|
||||
return '晚餐';
|
||||
}
|
||||
|
||||
function formatDateTime(iso: string): string {
|
||||
if (!iso || iso.length === 0) {
|
||||
return '--';
|
||||
}
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) {
|
||||
return '--';
|
||||
}
|
||||
const mm = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const dd = String(d.getDate()).padStart(2, '0');
|
||||
const hh = String(d.getHours()).padStart(2, '0');
|
||||
const mi = String(d.getMinutes()).padStart(2, '0');
|
||||
return `${mm}/${dd} ${hh}:${mi}`;
|
||||
}
|
||||
|
||||
@Entry
|
||||
@Component
|
||||
struct AdminOverview {
|
||||
@State data: DashboardSummary | null = null;
|
||||
@State loading: boolean = true;
|
||||
|
||||
private scroller: Scroller = new Scroller();
|
||||
|
||||
aboutToAppear(): void {
|
||||
this.load();
|
||||
}
|
||||
|
||||
private async load(): Promise<void> {
|
||||
this.loading = true;
|
||||
try {
|
||||
const res = await api.get<DashboardSummary>('/dashboard/summary/');
|
||||
this.data = res;
|
||||
} catch (err) {
|
||||
const message = err instanceof RequestError ? err.message : normalizeError(err as BusinessError | Error);
|
||||
promptAction.showToast({ message: message });
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
@Builder
|
||||
StatTile(value: number, unit: string, label: string, color: string) {
|
||||
Column({ space: 4 }) {
|
||||
Row({ space: 2 }) {
|
||||
Text(String(value))
|
||||
.fontSize(24)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor(color)
|
||||
Text(unit)
|
||||
.fontSize(12)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
.margin({ top: 6 })
|
||||
}
|
||||
Text(label)
|
||||
.fontSize(12)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
}
|
||||
.layoutWeight(1)
|
||||
.justifyContent(FlexAlign.Center)
|
||||
.alignItems(HorizontalAlign.Center)
|
||||
}
|
||||
|
||||
@Builder
|
||||
BarRow(label: string, count: number, percent: string, color: string) {
|
||||
Row({ space: 10 }) {
|
||||
Text(label)
|
||||
.fontSize(14)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.width(56)
|
||||
Progress({ value: count, total: Math.max(this.totalSafe(), 1) })
|
||||
.color(color)
|
||||
.style({ strokeWidth: 8 })
|
||||
.layoutWeight(1)
|
||||
Text(percent)
|
||||
.fontSize(13)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
.width(44)
|
||||
.textAlign(TextAlign.End)
|
||||
}
|
||||
.width('100%')
|
||||
}
|
||||
|
||||
private totalSafe(): number {
|
||||
const d = this.data;
|
||||
if (d !== null && d.health_distribution !== null) {
|
||||
return d.health_distribution.total;
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
@Builder
|
||||
AlertCard(alert: UnresolvedAlert) {
|
||||
Row({ space: 8 }) {
|
||||
Text(levelLabel(alert.level))
|
||||
.fontSize(11)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor(levelColor(alert.level))
|
||||
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
|
||||
.backgroundColor(alert.level === 'emergency' ? '#14FF3B30' : (alert.level === 'warning' ? '#14FF9500' : '#14007AFF'))
|
||||
.borderRadius(4)
|
||||
Column({ space: 4 }) {
|
||||
Text(alert.title)
|
||||
.fontSize(14)
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.maxLines(1)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
.width('100%')
|
||||
Text(`${alert.elderly_name} · ${formatDateTime(alert.created_at)}`)
|
||||
.fontSize(12)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
.width('100%')
|
||||
}
|
||||
.layoutWeight(1)
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
}
|
||||
.width('100%')
|
||||
}
|
||||
|
||||
@Builder
|
||||
RecordCard(record: RecentRecord) {
|
||||
Column({ space: 4 }) {
|
||||
Row() {
|
||||
Text(record.elderly_name)
|
||||
.fontSize(14)
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
Blank()
|
||||
Text(formatDateTime(record.created_at))
|
||||
.fontSize(11)
|
||||
.fontColor($r('app.color.inactive_tab'))
|
||||
}
|
||||
.width('100%')
|
||||
Text(record.raw_text)
|
||||
.fontSize(13)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
.maxLines(2)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
.width('100%')
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
}
|
||||
|
||||
private healthValues(): Array<number> {
|
||||
const d = this.data;
|
||||
if (d !== null && d.health_distribution !== null) {
|
||||
return [d.health_distribution.good, d.health_distribution.attention, d.health_distribution.bedridden];
|
||||
}
|
||||
return [0, 0, 0];
|
||||
}
|
||||
|
||||
private trendMax(): number {
|
||||
const d = this.data;
|
||||
if (d !== null && d.operation_overview !== null && d.operation_overview.care !== null) {
|
||||
let max = 1;
|
||||
d.operation_overview.care.daily_trend.forEach((t: DailyTrend) => {
|
||||
if (t.count > max) {
|
||||
max = t.count;
|
||||
}
|
||||
});
|
||||
return max;
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
private barHeight(count: number): number {
|
||||
return Math.round(60 * count / this.trendMax());
|
||||
}
|
||||
|
||||
private shortDate(dateStr: string): string {
|
||||
if (!dateStr || dateStr.length < 5) {
|
||||
return dateStr;
|
||||
}
|
||||
return dateStr.substring(5);
|
||||
}
|
||||
|
||||
@Builder
|
||||
HealthRingCard() {
|
||||
Column({ space: 12 }) {
|
||||
Row() {
|
||||
Text('健康分布')
|
||||
.fontSize(16)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
Blank()
|
||||
Text(`${this.data?.health_distribution.total ?? 0} 人`)
|
||||
.fontSize(12)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
}
|
||||
.width('100%')
|
||||
|
||||
Stack() {
|
||||
DataPanel({
|
||||
values: this.healthValues(),
|
||||
max: Math.max(this.data?.health_distribution.total ?? 1, 1),
|
||||
type: DataPanelType.Circle,
|
||||
})
|
||||
.width(168)
|
||||
.height(168)
|
||||
.strokeWidth(24)
|
||||
.valueColors(['#34C759', '#FF9500', '#FF3B30'])
|
||||
Column({ space: 2 }) {
|
||||
Text(String(this.data?.health_distribution.total ?? 0))
|
||||
.fontSize(28)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
Text('在院对象')
|
||||
.fontSize(11)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
}
|
||||
.alignItems(HorizontalAlign.Center)
|
||||
}
|
||||
.width('100%')
|
||||
.height(180)
|
||||
.alignContent(Alignment.Center)
|
||||
|
||||
Row({ space: 8 }) {
|
||||
Circle().width(10).height(10).fill('#34C759')
|
||||
Text('良好')
|
||||
.fontSize(13)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.layoutWeight(1)
|
||||
Text(`${this.data?.health_distribution.good ?? 0} 人 · ${this.data?.health_distribution.good_percent ?? '0%'}`)
|
||||
.fontSize(12)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
}
|
||||
.width('100%')
|
||||
|
||||
Row({ space: 8 }) {
|
||||
Circle().width(10).height(10).fill('#FF9500')
|
||||
Text('需关注')
|
||||
.fontSize(13)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.layoutWeight(1)
|
||||
Text(`${this.data?.health_distribution.attention ?? 0} 人 · ${this.data?.health_distribution.attention_percent ?? '0%'}`)
|
||||
.fontSize(12)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
}
|
||||
.width('100%')
|
||||
|
||||
Row({ space: 8 }) {
|
||||
Circle().width(10).height(10).fill('#FF3B30')
|
||||
Text('卧床')
|
||||
.fontSize(13)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.layoutWeight(1)
|
||||
Text(`${this.data?.health_distribution.bedridden ?? 0} 人 · ${this.data?.health_distribution.bedridden_percent ?? '0%'}`)
|
||||
.fontSize(12)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
}
|
||||
.width('100%')
|
||||
}
|
||||
.width('100%')
|
||||
.padding(16)
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.borderRadius(16)
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
}
|
||||
|
||||
@Builder
|
||||
TrendChartCard() {
|
||||
Column({ space: 10 }) {
|
||||
Row() {
|
||||
Text('近7日护理记录')
|
||||
.fontSize(16)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
Blank()
|
||||
Text('每日条数')
|
||||
.fontSize(12)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
}
|
||||
.width('100%')
|
||||
|
||||
Row({ space: 8 }) {
|
||||
ForEach(this.data?.operation_overview.care.daily_trend ?? [], (t: DailyTrend) => {
|
||||
Column({ space: 4 }) {
|
||||
Text(String(t.count))
|
||||
.fontSize(9)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
Column()
|
||||
.width(22)
|
||||
.height(this.barHeight(t.count))
|
||||
.backgroundColor('#007AFF')
|
||||
.borderRadius(4)
|
||||
Text(this.shortDate(t.date))
|
||||
.fontSize(9)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
}
|
||||
.justifyContent(FlexAlign.End)
|
||||
}, (t: DailyTrend) => t.date)
|
||||
}
|
||||
.width('100%')
|
||||
.height(120)
|
||||
.alignItems(VerticalAlign.Bottom)
|
||||
.justifyContent(FlexAlign.SpaceAround)
|
||||
}
|
||||
.width('100%')
|
||||
.padding(16)
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.borderRadius(16)
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
}
|
||||
|
||||
build() {
|
||||
HdsNavigation() {
|
||||
Column() {
|
||||
Scroll(this.scroller) {
|
||||
Column({ space: 12 }) {
|
||||
if (this.loading) {
|
||||
Text('加载中...')
|
||||
.fontSize(15)
|
||||
.fontColor($r('app.color.inactive_tab'))
|
||||
.margin({ top: 40 })
|
||||
} else if (this.data) {
|
||||
// 顶部统计
|
||||
Row() {
|
||||
this.StatTile(this.data.overview_elderly, '位', '在院对象', '#007AFF')
|
||||
this.StatTile(this.data.on_duty_nurses, '名', '在岗护理', '#34C759')
|
||||
this.StatTile(this.data.today_record_count, '条', '今日记录', '#FF9500')
|
||||
}
|
||||
.width('100%')
|
||||
.height(96)
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.borderRadius(16)
|
||||
.padding(8)
|
||||
|
||||
Row() {
|
||||
this.StatTile(this.data.today_alert_count, '条', '今日预警', '#FF3B30')
|
||||
this.StatTile(this.data.pending_alert_count, '条', '待处理', '#FF3B30')
|
||||
this.StatTile(this.data.focused_elderly, '位', '重点关注', '#AF52DE')
|
||||
}
|
||||
.width('100%')
|
||||
.height(96)
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.borderRadius(16)
|
||||
.padding(8)
|
||||
|
||||
// 健康分布(环形图)
|
||||
this.HealthRingCard()
|
||||
|
||||
// 运营概览 · 护理
|
||||
Column({ space: 10 }) {
|
||||
Text('运营概览')
|
||||
.fontSize(16)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.width('100%')
|
||||
Row({ space: 12 }) {
|
||||
Text(`在岗护理 ${this.data.operation_overview.care.on_duty_count} 名`)
|
||||
.fontSize(14)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
Text(`今日记录 ${this.data.operation_overview.care.today_record_count} 条`)
|
||||
.fontSize(14)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
}
|
||||
.width('100%')
|
||||
}
|
||||
.width('100%')
|
||||
.padding(16)
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.borderRadius(16)
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
|
||||
// 近7日护理记录趋势
|
||||
this.TrendChartCard()
|
||||
|
||||
// 订餐
|
||||
Column({ space: 10 }) {
|
||||
Text(`今日订餐 ${this.data.operation_overview.meals.total_reserved_count} 份`)
|
||||
.fontSize(16)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.width('100%')
|
||||
ForEach(this.data.operation_overview.meals.meals, (m: MealSummary) => {
|
||||
Row({ space: 8 }) {
|
||||
Text(mealLabel(m.category))
|
||||
.fontSize(14)
|
||||
.fontColor($r('app.color.accent_blue'))
|
||||
Text(`${m.reserved_count} 份`)
|
||||
.fontSize(14)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
Text(m.menu_items.join('、'))
|
||||
.fontSize(12)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
.layoutWeight(1)
|
||||
.maxLines(1)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
}
|
||||
.width('100%')
|
||||
}, (m: MealSummary) => m.category)
|
||||
}
|
||||
.width('100%')
|
||||
.padding(16)
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.borderRadius(16)
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
|
||||
// 未处理预警
|
||||
Column({ space: 8 }) {
|
||||
Text(`未处理预警 (${this.data.unresolved_alerts.length})`)
|
||||
.fontSize(16)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.width('100%')
|
||||
if (this.data.unresolved_alerts.length === 0) {
|
||||
Text('暂无未处理预警')
|
||||
.fontSize(13)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
.width('100%')
|
||||
} else {
|
||||
ForEach(this.data.unresolved_alerts, (alert: UnresolvedAlert) => {
|
||||
Column() {
|
||||
this.AlertCard(alert)
|
||||
}
|
||||
.width('100%')
|
||||
}, (alert: UnresolvedAlert) => String(alert.id))
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.padding(16)
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.borderRadius(16)
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
|
||||
// 最近护理记录
|
||||
Column({ space: 8 }) {
|
||||
Text('最近护理记录')
|
||||
.fontSize(16)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.width('100%')
|
||||
if (this.data.recent_records.length === 0) {
|
||||
Text('暂无护理记录')
|
||||
.fontSize(13)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
.width('100%')
|
||||
} else {
|
||||
ForEach(this.data.recent_records, (record: RecentRecord) => {
|
||||
Column() {
|
||||
this.RecordCard(record)
|
||||
}
|
||||
.width('100%')
|
||||
}, (record: RecentRecord) => String(record.id))
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.padding(16)
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.borderRadius(16)
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
.margin({ bottom: 10 })
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ left: 16, right: 16, top: 92, bottom: 30 })
|
||||
}
|
||||
.width('100%')
|
||||
.layoutWeight(1)
|
||||
.scrollBar(BarState.Auto)
|
||||
.edgeEffect(EdgeEffect.Spring)
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
}
|
||||
.mode(NavigationMode.Stack)
|
||||
.titleBar({
|
||||
content: {
|
||||
title: {
|
||||
mainTitle: '信息概览',
|
||||
},
|
||||
},
|
||||
style: {
|
||||
scrollEffectOpts: {
|
||||
enableScrollEffect: false,
|
||||
scrollEffectType: ScrollEffectType.GRADIENT_BLUR,
|
||||
},
|
||||
systemMaterialEffect: {
|
||||
materialType: hdsMaterial.MaterialType.ADAPTIVE,
|
||||
materialLevel: hdsMaterial.MaterialLevel.EXQUISITE,
|
||||
},
|
||||
},
|
||||
avoidLayoutSafeArea: false,
|
||||
enableComponentSafeArea: false,
|
||||
})
|
||||
.bindToScrollable([this.scroller])
|
||||
.hideBackButton(false)
|
||||
.titleMode(HdsNavigationTitleMode.MINI)
|
||||
.ignoreLayoutSafeArea([LayoutSafeAreaType.SYSTEM], [LayoutSafeAreaEdge.TOP, LayoutSafeAreaEdge.BOTTOM])
|
||||
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
|
||||
.backgroundColor($r('app.color.background'))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
// 管理员 · 智能查询(ArkTS 严格类型,无 any)
|
||||
// 接口:POST /tasks/admin-query/ {query} → {answer, summaries, matched_record}
|
||||
import { router } from '@kit.ArkUI';
|
||||
import { promptAction } from '@kit.ArkUI';
|
||||
import { HdsNavigation, HdsNavigationTitleMode, ScrollEffectType } from '@kit.UIDesignKit';
|
||||
import { hdsMaterial } from '@hms.hds.hdsMaterial';
|
||||
import { BusinessError } from '@kit.BasicServicesKit';
|
||||
import { normalizeError } from '../../common/ApiConstants';
|
||||
import { api, RequestError } from '../../utils/Request';
|
||||
|
||||
interface ChatMessage {
|
||||
id: number;
|
||||
role: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
interface SummaryItem {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface AdminQueryResult {
|
||||
answer: string;
|
||||
summaries: Array<SummaryItem>;
|
||||
}
|
||||
|
||||
interface QueryPayload {
|
||||
query: string;
|
||||
}
|
||||
|
||||
let messageSeq = 1;
|
||||
|
||||
@Entry
|
||||
@Component
|
||||
struct AdminQuery {
|
||||
@State inputText: string = '';
|
||||
@State messages: Array<ChatMessage> = [];
|
||||
@State summaries: Array<SummaryItem> = [];
|
||||
@State sending: boolean = false;
|
||||
|
||||
private scroller: Scroller = new Scroller();
|
||||
|
||||
private async send(): Promise<void> {
|
||||
const text = this.inputText.trim();
|
||||
if (text.length === 0 || this.sending) {
|
||||
return;
|
||||
}
|
||||
const userMsg: ChatMessage = { id: messageSeq++, role: 'user', content: text };
|
||||
this.messages = this.messages.concat([userMsg]);
|
||||
this.inputText = '';
|
||||
this.sending = true;
|
||||
setTimeout(() => {
|
||||
this.scroller.scrollEdge(Edge.Bottom);
|
||||
}, 50);
|
||||
try {
|
||||
const payload: QueryPayload = { query: text };
|
||||
const res = await api.post<AdminQueryResult>('/tasks/admin-query/', payload);
|
||||
this.summaries = res.summaries;
|
||||
const answer = res.answer && res.answer.length > 0 ? res.answer : '未查询到相关信息。';
|
||||
this.messages = this.messages.concat([{ id: messageSeq++, role: 'assistant', content: answer }]);
|
||||
this.scroller.scrollEdge(Edge.Bottom);
|
||||
} catch (err) {
|
||||
const message = err instanceof RequestError ? err.message : normalizeError(err as BusinessError | Error);
|
||||
this.messages = this.messages.concat([{ id: messageSeq++, role: 'assistant', content: message }]);
|
||||
this.scroller.scrollEdge(Edge.Bottom);
|
||||
} finally {
|
||||
this.sending = false;
|
||||
}
|
||||
}
|
||||
|
||||
@Builder
|
||||
SummaryRow(item: SummaryItem) {
|
||||
Row({ space: 10 }) {
|
||||
Text(item.label)
|
||||
.fontSize(14)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
.width(60)
|
||||
Text(item.value)
|
||||
.fontSize(14)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.layoutWeight(1)
|
||||
}
|
||||
.width('100%')
|
||||
}
|
||||
|
||||
@Builder
|
||||
MessageView(msg: ChatMessage) {
|
||||
Row() {
|
||||
if (msg.role === 'user') {
|
||||
Blank()
|
||||
Text(msg.content)
|
||||
.fontSize(16)
|
||||
.fontColor('#FFFFFF')
|
||||
.constraintSize({ maxWidth: '72%' })
|
||||
.padding(14)
|
||||
.backgroundColor($r('app.color.accent_blue'))
|
||||
.borderRadius(14)
|
||||
} else {
|
||||
Text(msg.content)
|
||||
.fontSize(16)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.constraintSize({ maxWidth: '88%' })
|
||||
.padding(14)
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.borderRadius(14)
|
||||
Blank()
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ left: 16, right: 16, bottom: 12 })
|
||||
}
|
||||
|
||||
build() {
|
||||
HdsNavigation() {
|
||||
Column() {
|
||||
Scroll(this.scroller) {
|
||||
Column({ space: 6 }) {
|
||||
if (this.messages.length === 0) {
|
||||
Text('您好,我是管理员智能查询助手 🌟\n可以用文字或语音查询护理记录与健康数据,例如:\n“查询张三今天的体温”')
|
||||
.fontSize(16)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
.textAlign(TextAlign.Center)
|
||||
.width('100%')
|
||||
.padding({ top: 60 })
|
||||
}
|
||||
ForEach(this.messages, (msg: ChatMessage) => {
|
||||
this.MessageView(msg)
|
||||
}, (msg: ChatMessage) => String(msg.id))
|
||||
|
||||
if (this.summaries.length > 0) {
|
||||
Column({ space: 8 }) {
|
||||
ForEach(this.summaries, (item: SummaryItem) => {
|
||||
this.SummaryRow(item)
|
||||
}, (item: SummaryItem) => item.label)
|
||||
}
|
||||
.width('92%')
|
||||
.padding(14)
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.borderRadius(12)
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ top: 92, bottom: 20 })
|
||||
}
|
||||
.scrollBar(BarState.Auto)
|
||||
.edgeEffect(EdgeEffect.Spring)
|
||||
.layoutWeight(1)
|
||||
.align(Alignment.Top)
|
||||
|
||||
Row({ space: 10 }) {
|
||||
TextInput({ placeholder: '输入查询内容...', text: this.inputText })
|
||||
.placeholderColor($r('app.color.inactive_tab'))
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.fontSize(16)
|
||||
.height(52)
|
||||
.layoutWeight(1)
|
||||
.padding({ left: 16, right: 16 })
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.borderRadius(14)
|
||||
.onChange((value: string) => {
|
||||
this.inputText = value;
|
||||
})
|
||||
.onSubmit((enterKey: EnterKeyType) => {
|
||||
this.send();
|
||||
})
|
||||
Button(this.sending ? '…' : '查询')
|
||||
.height(52)
|
||||
.padding({ left: 18, right: 18 })
|
||||
.constraintSize({ minWidth: 80 })
|
||||
.fontSize(16)
|
||||
.fontColor('#FFFFFF')
|
||||
.backgroundColor($r('app.color.accent_blue'))
|
||||
.borderRadius(14)
|
||||
.enabled(!this.sending)
|
||||
.onClick(() => {
|
||||
this.send();
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ left: 16, right: 16, bottom: 20 })
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
}
|
||||
.mode(NavigationMode.Stack)
|
||||
.titleBar({
|
||||
content: {
|
||||
title: {
|
||||
mainTitle: '智能查询',
|
||||
},
|
||||
},
|
||||
style: {
|
||||
scrollEffectOpts: {
|
||||
enableScrollEffect: false,
|
||||
scrollEffectType: ScrollEffectType.GRADIENT_BLUR,
|
||||
},
|
||||
systemMaterialEffect: {
|
||||
materialType: hdsMaterial.MaterialType.ADAPTIVE,
|
||||
materialLevel: hdsMaterial.MaterialLevel.EXQUISITE,
|
||||
},
|
||||
},
|
||||
avoidLayoutSafeArea: false,
|
||||
enableComponentSafeArea: false,
|
||||
})
|
||||
.bindToScrollable([this.scroller])
|
||||
.hideBackButton(false)
|
||||
.titleMode(HdsNavigationTitleMode.MINI)
|
||||
.ignoreLayoutSafeArea([LayoutSafeAreaType.SYSTEM], [LayoutSafeAreaEdge.TOP, LayoutSafeAreaEdge.BOTTOM])
|
||||
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
|
||||
.backgroundColor($r('app.color.background'))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
// 管理员 · 护理记录详情(ArkTS 严格类型,无 any)
|
||||
// 接口:GET /tasks/nursing-records/{id}/
|
||||
import { router, promptAction } from '@kit.ArkUI';
|
||||
import { HdsNavigation, HdsNavigationTitleMode, ScrollEffectType } from '@kit.UIDesignKit';
|
||||
import { hdsMaterial } from '@hms.hds.hdsMaterial';
|
||||
import { BusinessError } from '@kit.BasicServicesKit';
|
||||
import { normalizeError } from '../../common/ApiConstants';
|
||||
import { api, RequestError } from '../../utils/Request';
|
||||
|
||||
interface VitalPart {
|
||||
text?: string;
|
||||
numeric?: string;
|
||||
trend?: string;
|
||||
systolic?: string;
|
||||
diastolic?: string;
|
||||
}
|
||||
|
||||
interface TextPart {
|
||||
text?: string;
|
||||
trend?: string;
|
||||
}
|
||||
|
||||
interface ExtractedJson {
|
||||
record_time?: string;
|
||||
elderly_name?: string;
|
||||
elderly_id_card?: string;
|
||||
temperature?: VitalPart;
|
||||
blood_pressure?: VitalPart;
|
||||
weight?: VitalPart;
|
||||
medication?: TextPart;
|
||||
diet?: TextPart;
|
||||
mood?: TextPart;
|
||||
other?: TextPart;
|
||||
}
|
||||
|
||||
interface NursingRecordDetail {
|
||||
id: number;
|
||||
elderly: number | null;
|
||||
elderly_name: string;
|
||||
nurse: number | null;
|
||||
nurse_name: string;
|
||||
raw_text: string;
|
||||
images: Array<string>;
|
||||
extracted_json: object;
|
||||
work_item: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
function formatFullTime(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) {
|
||||
return iso;
|
||||
}
|
||||
const mm = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const dd = String(d.getDate()).padStart(2, '0');
|
||||
const hh = String(d.getHours()).padStart(2, '0');
|
||||
const mi = String(d.getMinutes()).padStart(2, '0');
|
||||
return `${d.getFullYear()}-${mm}-${dd} ${hh}:${mi}`;
|
||||
}
|
||||
|
||||
interface VitalRow {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
function buildVitalRows(record: NursingRecordDetail): Array<VitalRow> {
|
||||
const e = record.extracted_json as ExtractedJson;
|
||||
const rows: Array<VitalRow> = [];
|
||||
const add = (label: string, value: string): void => {
|
||||
if (value && value.length > 0) {
|
||||
rows.push({ label: label, value: value });
|
||||
}
|
||||
};
|
||||
add('记录时间', e.record_time || '');
|
||||
add('体温', e.temperature ? e.temperature.text || e.temperature.numeric || '' : '');
|
||||
add('血压', e.blood_pressure ? e.blood_pressure.text || '' : '');
|
||||
add('体重', e.weight ? e.weight.text || e.weight.numeric || '' : '');
|
||||
add('服药', e.medication ? e.medication.text || '' : '');
|
||||
add('进食', e.diet ? e.diet.text || '' : '');
|
||||
add('情绪', e.mood ? e.mood.text || '' : '');
|
||||
add('其他', e.other ? e.other.text || '' : '');
|
||||
return rows;
|
||||
}
|
||||
|
||||
@Entry
|
||||
@Component
|
||||
struct AdminRecordDetail {
|
||||
@State detail: NursingRecordDetail | null = null;
|
||||
@State loading: boolean = true;
|
||||
private recordId: number = 0;
|
||||
|
||||
aboutToAppear(): void {
|
||||
const params = router.getParams() as Record<string, Object>;
|
||||
this.recordId = Number(params['id'] ?? 0);
|
||||
this.load();
|
||||
}
|
||||
|
||||
private async load(): Promise<void> {
|
||||
this.loading = true;
|
||||
try {
|
||||
const res = await api.get<NursingRecordDetail>(`/tasks/nursing-records/${this.recordId}/`);
|
||||
this.detail = res;
|
||||
} catch (err) {
|
||||
const message = err instanceof RequestError ? err.message : normalizeError(err as BusinessError | Error);
|
||||
promptAction.showToast({ message: message });
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
@Builder
|
||||
FieldRow(row: VitalRow) {
|
||||
Row({ space: 12 }) {
|
||||
Text(row.label)
|
||||
.fontSize(14)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
.width(72)
|
||||
Text(row.value)
|
||||
.fontSize(14)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.layoutWeight(1)
|
||||
.maxLines(3)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ top: 6, bottom: 6 })
|
||||
}
|
||||
|
||||
build() {
|
||||
HdsNavigation() {
|
||||
if (this.loading) {
|
||||
Column() {
|
||||
Text('正在加载')
|
||||
.fontSize(15)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
.justifyContent(FlexAlign.Center)
|
||||
} else if (this.detail === null) {
|
||||
Column() {
|
||||
Text('记录不存在或已删除')
|
||||
.fontSize(15)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
.justifyContent(FlexAlign.Center)
|
||||
} else {
|
||||
Scroll() {
|
||||
Column({ space: 12 }) {
|
||||
// 老人头卡
|
||||
Row({ space: 12 }) {
|
||||
Column() {
|
||||
Text(this.detail.elderly_name.length > 0 ? this.detail.elderly_name.substring(0, 1) : '护')
|
||||
.fontSize(20)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor('#FFFFFF')
|
||||
}
|
||||
.width(52)
|
||||
.height(52)
|
||||
.justifyContent(FlexAlign.Center)
|
||||
.alignItems(HorizontalAlign.Center)
|
||||
.backgroundColor($r('app.color.accent_blue'))
|
||||
.borderRadius(16)
|
||||
|
||||
Column({ space: 5 }) {
|
||||
Text(this.detail.elderly_name)
|
||||
.fontSize(17)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
Text(`${this.detail.nurse_name || '--'} · ${formatFullTime(this.detail.created_at)}`)
|
||||
.fontSize(13)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
}
|
||||
.layoutWeight(1)
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
}
|
||||
.width('100%')
|
||||
.padding(16)
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.borderRadius(16)
|
||||
.alignItems(VerticalAlign.Center)
|
||||
|
||||
// 体征与评估
|
||||
if (buildVitalRows(this.detail).length > 0) {
|
||||
Column({ space: 2 }) {
|
||||
Text('体征与评估')
|
||||
.fontSize(16)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.width('100%')
|
||||
ForEach(buildVitalRows(this.detail), (row: VitalRow) => {
|
||||
this.FieldRow(row)
|
||||
}, (row: VitalRow) => `${row.label}-${row.value}`)
|
||||
}
|
||||
.width('100%')
|
||||
.padding(16)
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.borderRadius(16)
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
}
|
||||
|
||||
// 护理描述
|
||||
Column({ space: 8 }) {
|
||||
Text('护理描述')
|
||||
.fontSize(16)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.width('100%')
|
||||
Text(this.detail.raw_text || '无')
|
||||
.fontSize(14)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.width('100%')
|
||||
}
|
||||
.width('100%')
|
||||
.padding(16)
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.borderRadius(16)
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ left: 16, right: 16, top: 120, bottom: 24 })
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
.scrollBar(BarState.Auto)
|
||||
}
|
||||
}
|
||||
.mode(NavigationMode.Stack)
|
||||
.titleBar({
|
||||
content: {
|
||||
title: {
|
||||
mainTitle: '护理记录详情',
|
||||
},
|
||||
},
|
||||
style: {
|
||||
scrollEffectOpts: {
|
||||
enableScrollEffect: false,
|
||||
scrollEffectType: ScrollEffectType.GRADIENT_BLUR,
|
||||
},
|
||||
systemMaterialEffect: {
|
||||
materialType: hdsMaterial.MaterialType.ADAPTIVE,
|
||||
materialLevel: hdsMaterial.MaterialLevel.EXQUISITE,
|
||||
},
|
||||
},
|
||||
avoidLayoutSafeArea: false,
|
||||
enableComponentSafeArea: false,
|
||||
})
|
||||
.hideBackButton(false)
|
||||
.titleMode(HdsNavigationTitleMode.MINI)
|
||||
.ignoreLayoutSafeArea([LayoutSafeAreaType.SYSTEM], [LayoutSafeAreaEdge.TOP, LayoutSafeAreaEdge.BOTTOM])
|
||||
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
|
||||
.backgroundColor($r('app.color.background'))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,452 @@
|
||||
// 管理员 · 销售详情(ArkTS 严格类型,无 any)
|
||||
// 接口:GET /market/cycles/overview/
|
||||
// GET /market/leads/
|
||||
// GET /market/contracts/
|
||||
import { promptAction } from '@kit.ArkUI';
|
||||
import { HdsNavigation, HdsNavigationTitleMode, ScrollEffectType } from '@kit.UIDesignKit';
|
||||
import { hdsMaterial } from '@hms.hds.hdsMaterial';
|
||||
import { BusinessError } from '@kit.BasicServicesKit';
|
||||
import { Page, normalizeError } from '../../common/ApiConstants';
|
||||
import { api, RequestError } from '../../utils/Request';
|
||||
import { router } from '@kit.ArkUI';
|
||||
|
||||
interface CycleOverview {
|
||||
month: string;
|
||||
total_count: number;
|
||||
assigned_count: number;
|
||||
unassigned_count: number;
|
||||
pending_count: number;
|
||||
in_progress_count: number;
|
||||
won_count: number;
|
||||
lost_count: number;
|
||||
active_sales_count: number;
|
||||
sales_breakdown: Array<SalesBreakdown>;
|
||||
}
|
||||
|
||||
interface SalesBreakdown {
|
||||
sales_id: number;
|
||||
sales_name: string;
|
||||
assigned_count: number;
|
||||
pending_count: number;
|
||||
in_progress_count: number;
|
||||
won_count: number;
|
||||
lost_count: number;
|
||||
won_amount: number;
|
||||
}
|
||||
|
||||
interface SalesLead {
|
||||
id: number;
|
||||
name: string;
|
||||
gender: string;
|
||||
age: number | null;
|
||||
contact_method: string;
|
||||
status: string;
|
||||
deal_amount: number | null;
|
||||
current_sales_name: string;
|
||||
current_cycle_month: string;
|
||||
latest_note: string;
|
||||
last_follow_up_at: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface Contract {
|
||||
id: number;
|
||||
lead_name: string;
|
||||
customer_name: string;
|
||||
sales_name: string;
|
||||
contract_amount: number | null;
|
||||
contract_code: string;
|
||||
contract_date_range: string;
|
||||
room_number: string;
|
||||
signed_date: string;
|
||||
occupancy_status: string;
|
||||
care_level_label: string;
|
||||
admitted_elderly_name: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
function statusLabel(status: string): string {
|
||||
if (status === 'won') {
|
||||
return '成交';
|
||||
}
|
||||
if (status === 'in_progress') {
|
||||
return '跟进中';
|
||||
}
|
||||
if (status === 'lost') {
|
||||
return '失败';
|
||||
}
|
||||
return '待跟进';
|
||||
}
|
||||
|
||||
function statusColor(status: string): string {
|
||||
if (status === 'won') {
|
||||
return '#34C759';
|
||||
}
|
||||
if (status === 'in_progress') {
|
||||
return '#007AFF';
|
||||
}
|
||||
if (status === 'lost') {
|
||||
return '#8E8E93';
|
||||
}
|
||||
return '#FF9500';
|
||||
}
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
if (!iso || iso.length === 0) {
|
||||
return '--';
|
||||
}
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) {
|
||||
return '--';
|
||||
}
|
||||
const mm = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const dd = String(d.getDate()).padStart(2, '0');
|
||||
return `${mm}/${dd}`;
|
||||
}
|
||||
|
||||
function monthLabel(month: string): string {
|
||||
if (!month || month.length === 0) {
|
||||
return '';
|
||||
}
|
||||
const parts = month.split('-');
|
||||
if (parts.length !== 2) {
|
||||
return month;
|
||||
}
|
||||
return `${parts[0]}年${parts[1]}月`;
|
||||
}
|
||||
|
||||
function amountText(amount: number | null): string {
|
||||
if (amount === null || amount === undefined) {
|
||||
return '--';
|
||||
}
|
||||
return `¥${amount}`;
|
||||
}
|
||||
|
||||
@Entry
|
||||
@Component
|
||||
struct AdminSalesDetail {
|
||||
@State cycle: CycleOverview | null = null;
|
||||
@State leads: Array<SalesLead> = [];
|
||||
@State contracts: Array<Contract> = [];
|
||||
@State loading: boolean = true;
|
||||
private scroller: Scroller = new Scroller();
|
||||
|
||||
aboutToAppear(): void {
|
||||
this.load();
|
||||
}
|
||||
|
||||
private async load(): Promise<void> {
|
||||
this.loading = true;
|
||||
try {
|
||||
const results: Array<CycleOverview | Page<SalesLead> | Page<Contract> | null> = await Promise.all([
|
||||
api.get<CycleOverview>('/market/cycles/overview/').catch((): CycleOverview | null => null),
|
||||
api.get<Page<SalesLead>>('/market/leads/', { page_size: 30, ordering: '-created_at' }).catch((): Page<SalesLead> | null => null),
|
||||
api.get<Page<Contract>>('/market/contracts/', { page_size: 30 }).catch((): Page<Contract> | null => null),
|
||||
]);
|
||||
this.cycle = results[0] as CycleOverview | null;
|
||||
const leadRes = results[1] as Page<SalesLead> | null;
|
||||
const contractRes = results[2] as Page<Contract> | null;
|
||||
this.leads = leadRes ? leadRes.results : [];
|
||||
this.contracts = contractRes ? contractRes.results : [];
|
||||
} catch (err) {
|
||||
const message = err instanceof RequestError ? err.message : normalizeError(err as BusinessError | Error);
|
||||
promptAction.showToast({ message: message });
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
@Builder
|
||||
StatTile(value: number, label: string, color: string) {
|
||||
Column({ space: 4 }) {
|
||||
Text(String(value))
|
||||
.fontSize(22)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor(color)
|
||||
Text(label)
|
||||
.fontSize(12)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
}
|
||||
.layoutWeight(1)
|
||||
.justifyContent(FlexAlign.Center)
|
||||
.alignItems(HorizontalAlign.Center)
|
||||
}
|
||||
|
||||
@Builder
|
||||
LeadCard(lead: SalesLead) {
|
||||
Column({ space: 6 }) {
|
||||
Row({ space: 8 }) {
|
||||
Text(lead.name)
|
||||
.fontSize(16)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
if (lead.age !== null && lead.age !== undefined) {
|
||||
Text(`${lead.age}岁`)
|
||||
.fontSize(13)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
}
|
||||
Blank()
|
||||
Text(statusLabel(lead.status))
|
||||
.fontSize(12)
|
||||
.fontColor(statusColor(lead.status))
|
||||
.padding({ left: 8, right: 8, top: 2, bottom: 2 })
|
||||
.backgroundColor('#14000000')
|
||||
.borderRadius(6)
|
||||
}
|
||||
.width('100%')
|
||||
|
||||
if (lead.contact_method && lead.contact_method.length > 0) {
|
||||
Text(`📞 ${lead.contact_method}`)
|
||||
.fontSize(13)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
.width('100%')
|
||||
}
|
||||
|
||||
Row({ space: 12 }) {
|
||||
if (lead.current_sales_name && lead.current_sales_name.length > 0) {
|
||||
Text(`负责:${lead.current_sales_name}`)
|
||||
.fontSize(12)
|
||||
.fontColor($r('app.color.accent_blue'))
|
||||
}
|
||||
Text(amountText(lead.deal_amount))
|
||||
.fontSize(13)
|
||||
.fontColor('#FF9500')
|
||||
Blank()
|
||||
Text(formatDate(lead.created_at))
|
||||
.fontSize(12)
|
||||
.fontColor($r('app.color.inactive_tab'))
|
||||
}
|
||||
.width('100%')
|
||||
}
|
||||
.width('100%')
|
||||
.padding(14)
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.borderRadius(12)
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
}
|
||||
|
||||
@Builder
|
||||
ContractCard(c: Contract) {
|
||||
Column({ space: 6 }) {
|
||||
Row({ space: 8 }) {
|
||||
Text(c.customer_name)
|
||||
.fontSize(16)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
Blank()
|
||||
Text(amountText(c.contract_amount))
|
||||
.fontSize(14)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor('#FF9500')
|
||||
}
|
||||
.width('100%')
|
||||
|
||||
Row({ space: 12 }) {
|
||||
if (c.contract_code && c.contract_code.length > 0) {
|
||||
Text(c.contract_code)
|
||||
.fontSize(12)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
}
|
||||
if (c.contract_date_range && c.contract_date_range.length > 0) {
|
||||
Text(c.contract_date_range)
|
||||
.fontSize(12)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
}
|
||||
Blank()
|
||||
}
|
||||
.width('100%')
|
||||
|
||||
Row({ space: 12 }) {
|
||||
if (c.sales_name && c.sales_name.length > 0) {
|
||||
Text(`销售:${c.sales_name}`)
|
||||
.fontSize(12)
|
||||
.fontColor($r('app.color.accent_blue'))
|
||||
}
|
||||
if (c.room_number && c.room_number.length > 0) {
|
||||
Text(`房号:${c.room_number}`)
|
||||
.fontSize(12)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
}
|
||||
.width('100%')
|
||||
.padding(14)
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.borderRadius(12)
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
}
|
||||
|
||||
build() {
|
||||
HdsNavigation() {
|
||||
Column() {
|
||||
if (this.loading) {
|
||||
Text('加载中...')
|
||||
.fontSize(15)
|
||||
.fontColor($r('app.color.inactive_tab'))
|
||||
.margin({ top: 60 })
|
||||
} else {
|
||||
Scroll(this.scroller) {
|
||||
Column({ space: 12 }) {
|
||||
if (this.cycle) {
|
||||
// 周期概览
|
||||
Column({ space: 12 }) {
|
||||
Row() {
|
||||
Text(monthLabel(this.cycle.month))
|
||||
.fontSize(17)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
Blank()
|
||||
Text(`活跃销售 ${this.cycle.active_sales_count} 名`)
|
||||
.fontSize(12)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
}
|
||||
.width('100%')
|
||||
|
||||
Row() {
|
||||
this.StatTile(this.cycle.total_count, '总线索', '#007AFF')
|
||||
this.StatTile(this.cycle.won_count, '成交', '#34C759')
|
||||
this.StatTile(this.cycle.in_progress_count, '跟进中', '#FF9500')
|
||||
this.StatTile(this.cycle.lost_count, '失败', '#8E8E93')
|
||||
}
|
||||
.width('100%')
|
||||
|
||||
if (this.cycle.sales_breakdown.length > 0) {
|
||||
Column({ space: 8 }) {
|
||||
ForEach(this.cycle.sales_breakdown, (s: SalesBreakdown) => {
|
||||
Row({ space: 10 }) {
|
||||
Text(s.sales_name)
|
||||
.fontSize(13)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.width(72)
|
||||
Text(`${s.assigned_count}条`)
|
||||
.fontSize(12)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
Text(`成交 ${s.won_count}`)
|
||||
.fontSize(12)
|
||||
.fontColor($r('app.color.accent_green'))
|
||||
Text(`¥${s.won_amount}`)
|
||||
.fontSize(12)
|
||||
.fontColor('#FF9500')
|
||||
Blank()
|
||||
}
|
||||
.width('100%')
|
||||
}, (s: SalesBreakdown) => String(s.sales_id))
|
||||
}
|
||||
.width('100%')
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.padding(16)
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.borderRadius(16)
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
}
|
||||
|
||||
// 线索列表
|
||||
Column({ space: 8 }) {
|
||||
Row() {
|
||||
Text('销售线索')
|
||||
.fontSize(16)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
Blank()
|
||||
Text(`共 ${this.leads.length} 条`)
|
||||
.fontSize(12)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
}
|
||||
.width('100%')
|
||||
if (this.leads.length === 0) {
|
||||
Text('暂无销售线索')
|
||||
.fontSize(13)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
.width('100%')
|
||||
.padding(16)
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.borderRadius(12)
|
||||
} else {
|
||||
ForEach(this.leads, (lead: SalesLead) => {
|
||||
Column() {
|
||||
this.LeadCard(lead)
|
||||
}
|
||||
.width('100%')
|
||||
}, (lead: SalesLead) => String(lead.id))
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
|
||||
// 合同列表
|
||||
Column({ space: 8 }) {
|
||||
Row() {
|
||||
Text('签约合同')
|
||||
.fontSize(16)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
Blank()
|
||||
Text(`共 ${this.contracts.length} 份`)
|
||||
.fontSize(12)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
}
|
||||
.width('100%')
|
||||
if (this.contracts.length === 0) {
|
||||
Text('暂无合同')
|
||||
.fontSize(13)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
.width('100%')
|
||||
.padding(16)
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.borderRadius(12)
|
||||
} else {
|
||||
ForEach(this.contracts, (c: Contract) => {
|
||||
Column() {
|
||||
this.ContractCard(c)
|
||||
}
|
||||
.width('100%')
|
||||
}, (c: Contract) => String(c.id))
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
.margin({ bottom: 20 })
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ left: 16, right: 16, bottom: 24 })
|
||||
}
|
||||
.width('100%')
|
||||
.layoutWeight(1)
|
||||
.scrollBar(BarState.Auto)
|
||||
.edgeEffect(EdgeEffect.Spring)
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
}
|
||||
.mode(NavigationMode.Stack)
|
||||
.titleBar({
|
||||
content: {
|
||||
title: {
|
||||
mainTitle: '销售详情',
|
||||
},
|
||||
},
|
||||
style: {
|
||||
scrollEffectOpts: {
|
||||
enableScrollEffect: false,
|
||||
scrollEffectType: ScrollEffectType.GRADIENT_BLUR,
|
||||
},
|
||||
systemMaterialEffect: {
|
||||
materialType: hdsMaterial.MaterialType.ADAPTIVE,
|
||||
materialLevel: hdsMaterial.MaterialLevel.EXQUISITE,
|
||||
},
|
||||
},
|
||||
avoidLayoutSafeArea: false,
|
||||
enableComponentSafeArea: false,
|
||||
})
|
||||
.bindToScrollable([this.scroller])
|
||||
.hideBackButton(false)
|
||||
.titleMode(HdsNavigationTitleMode.MINI)
|
||||
.ignoreLayoutSafeArea([LayoutSafeAreaType.SYSTEM], [LayoutSafeAreaEdge.TOP, LayoutSafeAreaEdge.BOTTOM])
|
||||
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
|
||||
.backgroundColor($r('app.color.background'))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
// 管理员 · 排班管理(ArkTS 严格类型,无 any)
|
||||
// 接口:GET /shifts/floor-duty-assignments/?duty_date=
|
||||
// GET /shifts/caregiver-schedules/?month=
|
||||
// GET /shifts/caregiver-duty-assignments/?schedule_version=
|
||||
import { router } from '@kit.ArkUI';
|
||||
import { promptAction } from '@kit.ArkUI';
|
||||
import { HdsNavigation, HdsNavigationTitleMode, ScrollEffectType } from '@kit.UIDesignKit';
|
||||
import { hdsMaterial } from '@hms.hds.hdsMaterial';
|
||||
import { BusinessError } from '@kit.BasicServicesKit';
|
||||
import { Page, normalizeError } from '../../common/ApiConstants';
|
||||
import { api, RequestError } from '../../utils/Request';
|
||||
|
||||
interface FloorDuty {
|
||||
id: number;
|
||||
duty_date: string;
|
||||
floor: number;
|
||||
shift_name: string;
|
||||
shift_time: string;
|
||||
staff_nurse_name: string;
|
||||
}
|
||||
|
||||
interface CaregiverAssignment {
|
||||
id: number;
|
||||
duty_date: string;
|
||||
caregiver_name: string;
|
||||
source_name: string;
|
||||
target_floor: number | null;
|
||||
target_room: string;
|
||||
scope_type: string;
|
||||
shift_code: string;
|
||||
is_day_duty: boolean;
|
||||
}
|
||||
|
||||
interface ScheduleVersion {
|
||||
id: number;
|
||||
target_month: string;
|
||||
version: number;
|
||||
status: string;
|
||||
assignments: Array<CaregiverAssignment>;
|
||||
}
|
||||
|
||||
function todayStr(): string {
|
||||
const now = new Date();
|
||||
const mm = String(now.getMonth() + 1).padStart(2, '0');
|
||||
const dd = String(now.getDate()).padStart(2, '0');
|
||||
return `${now.getFullYear()}-${mm}-${dd}`;
|
||||
}
|
||||
|
||||
function monthStr(): string {
|
||||
const now = new Date();
|
||||
const mm = String(now.getMonth() + 1).padStart(2, '0');
|
||||
return `${now.getFullYear()}-${mm}`;
|
||||
}
|
||||
|
||||
function weekdayLabel(): string {
|
||||
const weekdays: Array<string> = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'];
|
||||
return weekdays[new Date().getDay()];
|
||||
}
|
||||
|
||||
function scopeLabel(scope: string): string {
|
||||
if (scope === 'floor') {
|
||||
return '楼层';
|
||||
}
|
||||
if (scope === 'private_room') {
|
||||
return '专护';
|
||||
}
|
||||
return '辅助';
|
||||
}
|
||||
|
||||
@Entry
|
||||
@Component
|
||||
struct AdminSchedule {
|
||||
@State floorDuties: Array<FloorDuty> = [];
|
||||
@State caregiverAssignments: Array<CaregiverAssignment> = [];
|
||||
@State loading: boolean = true;
|
||||
|
||||
private scroller: Scroller = new Scroller();
|
||||
|
||||
aboutToAppear(): void {
|
||||
this.load();
|
||||
}
|
||||
|
||||
private async load(): Promise<void> {
|
||||
this.loading = true;
|
||||
try {
|
||||
const responses: Array<Page<FloorDuty> | Page<ScheduleVersion> | null> = await Promise.all([
|
||||
api.get<Page<FloorDuty>>('/shifts/floor-duty-assignments/', { duty_date: todayStr(), page_size: 100 })
|
||||
.catch((): Page<FloorDuty> | null => null),
|
||||
api.get<Page<ScheduleVersion>>('/shifts/caregiver-schedules/', { month: monthStr(), page_size: 20 })
|
||||
.catch((): Page<ScheduleVersion> | null => null),
|
||||
]);
|
||||
const floorRes = responses[0] as Page<FloorDuty> | null;
|
||||
this.floorDuties = floorRes ? floorRes.results : [];
|
||||
|
||||
const versionRes = responses[1] as Page<ScheduleVersion> | null;
|
||||
if (versionRes) {
|
||||
const versions: Array<ScheduleVersion> = versionRes.results;
|
||||
// 取已发布的版本;如无发布取第一个
|
||||
const published = versions.find((v: ScheduleVersion) => v.status === 'published');
|
||||
const target = published ?? (versions.length > 0 ? versions[0] : null);
|
||||
if (target && target.assignments) {
|
||||
this.caregiverAssignments = target.assignments.filter(
|
||||
(a: CaregiverAssignment) => a.duty_date === todayStr());
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof RequestError ? err.message : normalizeError(err as BusinessError | Error);
|
||||
promptAction.showToast({ message: message });
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
@Builder
|
||||
FloorCard(duty: FloorDuty) {
|
||||
Row({ space: 12 }) {
|
||||
Column() {
|
||||
Text(String(duty.floor))
|
||||
.fontSize(20)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor('#FFFFFF')
|
||||
}
|
||||
.width(48)
|
||||
.height(48)
|
||||
.justifyContent(FlexAlign.Center)
|
||||
.alignItems(HorizontalAlign.Center)
|
||||
.backgroundColor($r('app.color.accent_blue'))
|
||||
.borderRadius(14)
|
||||
|
||||
Column({ space: 4 }) {
|
||||
Text(`${duty.floor}楼 · ${duty.shift_name}`)
|
||||
.fontSize(16)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
Text(duty.shift_time)
|
||||
.fontSize(13)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
}
|
||||
.layoutWeight(1)
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
|
||||
Text(duty.staff_nurse_name || '--')
|
||||
.fontSize(15)
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor($r('app.color.accent_green'))
|
||||
}
|
||||
.width('100%')
|
||||
.padding(14)
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.borderRadius(14)
|
||||
}
|
||||
|
||||
@Builder
|
||||
CaregiverCard(a: CaregiverAssignment) {
|
||||
Row({ space: 12 }) {
|
||||
Text(a.caregiver_name || a.source_name || '未校对')
|
||||
.fontSize(16)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
Text(a.is_day_duty ? '日值' : scopeLabel(a.scope_type))
|
||||
.fontSize(11)
|
||||
.fontColor('#FFFFFF')
|
||||
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
|
||||
.backgroundColor(a.is_day_duty ? '#AF52DE' : '#FF9500')
|
||||
.borderRadius(4)
|
||||
Blank()
|
||||
Text(a.target_floor ? `${a.target_floor}楼` : (a.target_room ? a.target_room : '--'))
|
||||
.fontSize(14)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
Text(a.shift_code)
|
||||
.fontSize(13)
|
||||
.fontColor($r('app.color.accent_blue'))
|
||||
}
|
||||
.width('100%')
|
||||
.padding(14)
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.borderRadius(12)
|
||||
}
|
||||
|
||||
build() {
|
||||
HdsNavigation() {
|
||||
Column() {
|
||||
Scroll(this.scroller) {
|
||||
Column({ space: 12 }) {
|
||||
if (this.loading) {
|
||||
Text('加载中...')
|
||||
.fontSize(15)
|
||||
.fontColor($r('app.color.inactive_tab'))
|
||||
.margin({ top: 40 })
|
||||
} else {
|
||||
// 楼层护士值班
|
||||
Column({ space: 8 }) {
|
||||
Text('今日楼层护士值班')
|
||||
.fontSize(16)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.width('100%')
|
||||
if (this.floorDuties.length === 0) {
|
||||
Text('今日暂无楼层值班排班')
|
||||
.fontSize(13)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
.width('100%')
|
||||
.padding(16)
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.borderRadius(12)
|
||||
} else {
|
||||
ForEach(this.floorDuties, (duty: FloorDuty) => {
|
||||
Column() {
|
||||
this.FloorCard(duty)
|
||||
}
|
||||
.width('100%')
|
||||
}, (duty: FloorDuty) => String(duty.id))
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
|
||||
// 护理员今日值班
|
||||
Column({ space: 8 }) {
|
||||
Text('护理员今日值班')
|
||||
.fontSize(16)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.width('100%')
|
||||
if (this.caregiverAssignments.length === 0) {
|
||||
Text('当前月份暂无护理员排班数据')
|
||||
.fontSize(13)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
.width('100%')
|
||||
.padding(16)
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.borderRadius(12)
|
||||
} else {
|
||||
ForEach(this.caregiverAssignments, (a: CaregiverAssignment) => {
|
||||
Column() {
|
||||
this.CaregiverCard(a)
|
||||
}
|
||||
.width('100%')
|
||||
}, (a: CaregiverAssignment) => String(a.id))
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
.margin({ bottom: 20 })
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ left: 16, right: 16, top: 120, bottom: 30 })
|
||||
}
|
||||
.width('100%')
|
||||
.layoutWeight(1)
|
||||
.scrollBar(BarState.Auto)
|
||||
.edgeEffect(EdgeEffect.Spring)
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
}
|
||||
.mode(NavigationMode.Stack)
|
||||
.titleBar({
|
||||
content: {
|
||||
title: {
|
||||
mainTitle: '排班管理',
|
||||
},
|
||||
},
|
||||
style: {
|
||||
scrollEffectOpts: {
|
||||
enableScrollEffect: false,
|
||||
scrollEffectType: ScrollEffectType.GRADIENT_BLUR,
|
||||
},
|
||||
systemMaterialEffect: {
|
||||
materialType: hdsMaterial.MaterialType.ADAPTIVE,
|
||||
materialLevel: hdsMaterial.MaterialLevel.EXQUISITE,
|
||||
},
|
||||
},
|
||||
avoidLayoutSafeArea: false,
|
||||
enableComponentSafeArea: false,
|
||||
})
|
||||
.bindToScrollable([this.scroller])
|
||||
.hideBackButton(false)
|
||||
.titleMode(HdsNavigationTitleMode.MINI)
|
||||
.ignoreLayoutSafeArea([LayoutSafeAreaType.SYSTEM], [LayoutSafeAreaEdge.TOP, LayoutSafeAreaEdge.BOTTOM])
|
||||
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
|
||||
.backgroundColor($r('app.color.background'))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,419 @@
|
||||
// 管理员 · 设备监测/睡眠(ArkTS 严格类型,无 any)
|
||||
// 接口:GET /devices/types/sleep_monitor/overview/、/devices/types/body_sensing/overview/
|
||||
import { promptAction } from '@kit.ArkUI';
|
||||
import { HdsNavigation, HdsNavigationTitleMode, ScrollEffectType } from '@kit.UIDesignKit';
|
||||
import { hdsMaterial } from '@hms.hds.hdsMaterial';
|
||||
import { BusinessError } from '@kit.BasicServicesKit';
|
||||
import { normalizeError } from '../../common/ApiConstants';
|
||||
import { api, RequestError } from '../../utils/Request';
|
||||
|
||||
interface TypeInfo {
|
||||
code: string;
|
||||
name: string;
|
||||
device_count: number;
|
||||
bound_elderly_count: number;
|
||||
unresolved_alert_count: number;
|
||||
}
|
||||
|
||||
interface ElderlyBrief {
|
||||
name: string;
|
||||
gender: string;
|
||||
age: number;
|
||||
care_level: string;
|
||||
}
|
||||
|
||||
interface DeviceAlert {
|
||||
key: string;
|
||||
severity: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
interface SleepInfo {
|
||||
status: string;
|
||||
deep_minutes: number;
|
||||
light_minutes: number;
|
||||
awake_minutes: number;
|
||||
start_at: string;
|
||||
wake_at: string;
|
||||
report_date: string;
|
||||
}
|
||||
|
||||
interface Vitals {
|
||||
heart_rate: number | null;
|
||||
respiratory_rate: number | null;
|
||||
body_temperature: number | null;
|
||||
}
|
||||
|
||||
interface SleepResident {
|
||||
id: number;
|
||||
device_code: string;
|
||||
floor: string;
|
||||
room: string;
|
||||
bed: string;
|
||||
elderly: ElderlyBrief;
|
||||
presence: boolean;
|
||||
body_movement: boolean;
|
||||
sleep: SleepInfo;
|
||||
vitals: Vitals;
|
||||
alerts: Array<DeviceAlert>;
|
||||
}
|
||||
|
||||
interface SleepOverview {
|
||||
type: TypeInfo;
|
||||
pending_alert_count: number;
|
||||
monitoring_residents: Array<SleepResident>;
|
||||
}
|
||||
|
||||
interface BodySensing {
|
||||
online: boolean;
|
||||
motion_label: string;
|
||||
fall_status: boolean;
|
||||
long_unattended: boolean;
|
||||
people_count: number;
|
||||
}
|
||||
|
||||
interface BodyResident {
|
||||
id: number;
|
||||
device_code: string;
|
||||
floor: string;
|
||||
room: string;
|
||||
bed: string;
|
||||
elderly: ElderlyBrief;
|
||||
presence: boolean;
|
||||
body_sensing: BodySensing;
|
||||
alerts: Array<DeviceAlert>;
|
||||
}
|
||||
|
||||
interface BodyOverview {
|
||||
type: TypeInfo;
|
||||
pending_alert_count: number;
|
||||
monitoring_residents: Array<BodyResident>;
|
||||
}
|
||||
|
||||
function roomLabel(r: SleepResident | BodyResident): string {
|
||||
if (r.floor && r.floor.length > 0) {
|
||||
return `${r.floor}${r.room ? '/' + r.room : ''}${r.bed ? '/' + r.bed : ''}`;
|
||||
}
|
||||
return r.room || r.bed || '--';
|
||||
}
|
||||
|
||||
function vitalText(v: number | null): string {
|
||||
return v === null || v === undefined ? '--' : String(v);
|
||||
}
|
||||
|
||||
@Entry
|
||||
@Component
|
||||
struct DeviceMonitoring {
|
||||
@State sleep: SleepOverview | null = null;
|
||||
@State body: BodyOverview | null = null;
|
||||
@State loading: boolean = true;
|
||||
private scroller: Scroller = new Scroller();
|
||||
|
||||
aboutToAppear(): void {
|
||||
this.load();
|
||||
}
|
||||
|
||||
private async load(): Promise<void> {
|
||||
this.loading = true;
|
||||
try {
|
||||
const results: Array<SleepOverview | BodyOverview | null> = await Promise.all([
|
||||
api.get<SleepOverview>('/devices/types/sleep_monitor/overview/').catch((): SleepOverview | null => null),
|
||||
api.get<BodyOverview>('/devices/types/body_sensing/overview/').catch((): BodyOverview | null => null),
|
||||
]);
|
||||
this.sleep = results[0] as SleepOverview | null;
|
||||
this.body = results[1] as BodyOverview | null;
|
||||
} catch (err) {
|
||||
const message = err instanceof RequestError ? err.message : normalizeError(err as BusinessError | Error);
|
||||
promptAction.showToast({ message: message });
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
@Builder
|
||||
SleepCard(r: SleepResident) {
|
||||
Column({ space: 8 }) {
|
||||
Row({ space: 10 }) {
|
||||
Column() {
|
||||
Text((r.elderly.name || '?').substring(0, 1))
|
||||
.fontSize(18)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor('#FFFFFF')
|
||||
}
|
||||
.width(44)
|
||||
.height(44)
|
||||
.justifyContent(FlexAlign.Center)
|
||||
.alignItems(HorizontalAlign.Center)
|
||||
.backgroundColor($r('app.color.accent_blue'))
|
||||
.borderRadius(12)
|
||||
|
||||
Column({ space: 4 }) {
|
||||
Row({ space: 8 }) {
|
||||
Text(r.elderly.name)
|
||||
.fontSize(16)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
Text(roomLabel(r))
|
||||
.fontSize(12)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
Blank()
|
||||
}
|
||||
.width('100%')
|
||||
Text(r.sleep.status || '--')
|
||||
.fontSize(13)
|
||||
.fontColor(r.presence ? $r('app.color.accent_blue') : $r('app.color.accent_green'))
|
||||
.width('100%')
|
||||
}
|
||||
.layoutWeight(1)
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
}
|
||||
.width('100%')
|
||||
|
||||
if (r.sleep) {
|
||||
Row({ space: 10 }) {
|
||||
Text(`深睡 ${r.sleep.deep_minutes}分`)
|
||||
.fontSize(12)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
Text(`浅睡 ${r.sleep.light_minutes}分`)
|
||||
.fontSize(12)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
Text(`清醒 ${r.sleep.awake_minutes}分`)
|
||||
.fontSize(12)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
Blank()
|
||||
Text(`${r.sleep.start_at}~${r.sleep.wake_at}`)
|
||||
.fontSize(12)
|
||||
.fontColor($r('app.color.inactive_tab'))
|
||||
}
|
||||
.width('100%')
|
||||
}
|
||||
|
||||
if (r.vitals) {
|
||||
Row({ space: 10 }) {
|
||||
Text(`心率 ${vitalText(r.vitals.heart_rate)}`)
|
||||
.fontSize(12)
|
||||
.fontColor($r('app.color.accent_red'))
|
||||
Text(`呼吸 ${vitalText(r.vitals.respiratory_rate)}`)
|
||||
.fontSize(12)
|
||||
.fontColor($r('app.color.accent_blue'))
|
||||
Text(`体温 ${vitalText(r.vitals.body_temperature)}`)
|
||||
.fontSize(12)
|
||||
.fontColor('#FF9500')
|
||||
Blank()
|
||||
}
|
||||
.width('100%')
|
||||
}
|
||||
|
||||
if (r.alerts && r.alerts.length > 0) {
|
||||
ForEach(r.alerts, (a: DeviceAlert) => {
|
||||
Text(`⚠ ${a.description || a.key}`)
|
||||
.fontSize(12)
|
||||
.fontColor('#FF3B30')
|
||||
.width('100%')
|
||||
}, (a: DeviceAlert) => `${r.id}-${a.key}`)
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.padding(14)
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.borderRadius(14)
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
}
|
||||
|
||||
@Builder
|
||||
BodyCard(r: BodyResident) {
|
||||
Column({ space: 8 }) {
|
||||
Row({ space: 10 }) {
|
||||
Column() {
|
||||
Text((r.elderly.name || '?').substring(0, 1))
|
||||
.fontSize(18)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor('#FFFFFF')
|
||||
}
|
||||
.width(44)
|
||||
.height(44)
|
||||
.justifyContent(FlexAlign.Center)
|
||||
.alignItems(HorizontalAlign.Center)
|
||||
.backgroundColor('#7C3AED')
|
||||
.borderRadius(12)
|
||||
|
||||
Column({ space: 4 }) {
|
||||
Row({ space: 8 }) {
|
||||
Text(r.elderly.name)
|
||||
.fontSize(16)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
Text(roomLabel(r))
|
||||
.fontSize(12)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
Blank()
|
||||
}
|
||||
.width('100%')
|
||||
Text(r.body_sensing && r.body_sensing.motion_label ? r.body_sensing.motion_label : '--')
|
||||
.fontSize(13)
|
||||
.fontColor(r.presence ? $r('app.color.accent_blue') : $r('app.color.inactive_tab'))
|
||||
.width('100%')
|
||||
}
|
||||
.layoutWeight(1)
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
}
|
||||
.width('100%')
|
||||
|
||||
if (r.body_sensing) {
|
||||
Row({ space: 10 }) {
|
||||
Text(r.body_sensing.online ? '在线' : '离线')
|
||||
.fontSize(12)
|
||||
.fontColor(r.body_sensing.online ? $r('app.color.accent_green') : $r('app.color.inactive_tab'))
|
||||
if (r.body_sensing.fall_status) {
|
||||
Text('跌倒!')
|
||||
.fontSize(12)
|
||||
.fontColor('#FF3B30')
|
||||
}
|
||||
if (r.body_sensing.long_unattended) {
|
||||
Text('长期无人')
|
||||
.fontSize(12)
|
||||
.fontColor('#FF9500')
|
||||
}
|
||||
Blank()
|
||||
}
|
||||
.width('100%')
|
||||
}
|
||||
|
||||
if (r.alerts && r.alerts.length > 0) {
|
||||
ForEach(r.alerts, (a: DeviceAlert) => {
|
||||
Text(`⚠ ${a.description || a.key}`)
|
||||
.fontSize(12)
|
||||
.fontColor('#FF3B30')
|
||||
.width('100%')
|
||||
}, (a: DeviceAlert) => `${r.id}-${a.key}`)
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.padding(14)
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.borderRadius(14)
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
}
|
||||
|
||||
build() {
|
||||
HdsNavigation() {
|
||||
Column() {
|
||||
if (this.loading) {
|
||||
Text('加载中...')
|
||||
.fontSize(15)
|
||||
.fontColor($r('app.color.inactive_tab'))
|
||||
.margin({ top: 60 })
|
||||
} else {
|
||||
Scroll(this.scroller) {
|
||||
Column({ space: 14 }) {
|
||||
// 睡眠监测
|
||||
Column({ space: 10 }) {
|
||||
Row() {
|
||||
Text('睡眠监测')
|
||||
.fontSize(17)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
Blank()
|
||||
if (this.sleep) {
|
||||
Text(`设备 ${this.sleep.type.device_count} · 绑定 ${this.sleep.type.bound_elderly_count}`)
|
||||
.fontSize(12)
|
||||
.fontColor($r('app.color.inactive_tab'))
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
if (!this.sleep) {
|
||||
Text('暂无睡眠设备数据')
|
||||
.fontSize(13)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
.width('100%')
|
||||
.padding(16)
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.borderRadius(12)
|
||||
} else {
|
||||
ForEach(this.sleep.monitoring_residents, (r: SleepResident) => {
|
||||
Column() {
|
||||
this.SleepCard(r)
|
||||
}
|
||||
.width('100%')
|
||||
}, (r: SleepResident) => String(r.id))
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
|
||||
// 体感监测
|
||||
Column({ space: 10 }) {
|
||||
Row() {
|
||||
Text('体感监测')
|
||||
.fontSize(17)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
Blank()
|
||||
if (this.body) {
|
||||
Text(`设备 ${this.body.type.device_count} · 绑定 ${this.body.type.bound_elderly_count}`)
|
||||
.fontSize(12)
|
||||
.fontColor($r('app.color.inactive_tab'))
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
if (!this.body) {
|
||||
Text('暂无体感设备数据')
|
||||
.fontSize(13)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
.width('100%')
|
||||
.padding(16)
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.borderRadius(12)
|
||||
} else {
|
||||
ForEach(this.body.monitoring_residents, (r: BodyResident) => {
|
||||
Column() {
|
||||
this.BodyCard(r)
|
||||
}
|
||||
.width('100%')
|
||||
}, (r: BodyResident) => String(r.id))
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
.margin({ bottom: 20 })
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ left: 16, right: 16, top: 92, bottom: 30 })
|
||||
}
|
||||
.width('100%')
|
||||
.layoutWeight(1)
|
||||
.scrollBar(BarState.Auto)
|
||||
.edgeEffect(EdgeEffect.Spring)
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
}
|
||||
.mode(NavigationMode.Stack)
|
||||
.titleBar({
|
||||
content: {
|
||||
title: {
|
||||
mainTitle: '设备监测',
|
||||
},
|
||||
},
|
||||
style: {
|
||||
scrollEffectOpts: {
|
||||
enableScrollEffect: false,
|
||||
scrollEffectType: ScrollEffectType.GRADIENT_BLUR,
|
||||
},
|
||||
systemMaterialEffect: {
|
||||
materialType: hdsMaterial.MaterialType.ADAPTIVE,
|
||||
materialLevel: hdsMaterial.MaterialLevel.EXQUISITE,
|
||||
},
|
||||
},
|
||||
avoidLayoutSafeArea: false,
|
||||
enableComponentSafeArea: false,
|
||||
})
|
||||
.bindToScrollable([this.scroller])
|
||||
.hideBackButton(false)
|
||||
.titleMode(HdsNavigationTitleMode.MINI)
|
||||
.ignoreLayoutSafeArea([LayoutSafeAreaType.SYSTEM], [LayoutSafeAreaEdge.TOP, LayoutSafeAreaEdge.BOTTOM])
|
||||
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
|
||||
.backgroundColor($r('app.color.background'))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
// 预警页签(ArkTS 严格类型,无 any)
|
||||
// 数据来源:/alerts/
|
||||
import { BusinessError } from '@kit.BasicServicesKit';
|
||||
import { Page, normalizeError } from '../../common/ApiConstants';
|
||||
import { api } from '../../utils/Request';
|
||||
import { AlertSocket } from '../../utils/AlertSocket';
|
||||
import { FontScaleLevel, FONT_SCALE_MAP } from '../../utils/FontScale';
|
||||
|
||||
interface AlertItem {
|
||||
id: number;
|
||||
title: string;
|
||||
description: string;
|
||||
level: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
function formatDateTime(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) {
|
||||
return '--';
|
||||
}
|
||||
const mm = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const dd = String(d.getDate()).padStart(2, '0');
|
||||
const hh = String(d.getHours()).padStart(2, '0');
|
||||
const mi = String(d.getMinutes()).padStart(2, '0');
|
||||
return `${mm}/${dd} ${hh}:${mi}`;
|
||||
}
|
||||
|
||||
function levelColor(level: string): string {
|
||||
const lv = (level || '').toLowerCase();
|
||||
if (lv.indexOf('critical') >= 0 || lv.indexOf('emergency') >= 0 || lv.indexOf('严重') >= 0) {
|
||||
return ('app.color.accent_red');
|
||||
}
|
||||
if (lv.indexOf('warning') >= 0 || lv.indexOf('警告') >= 0) {
|
||||
return '#FF9500';
|
||||
}
|
||||
return ('app.color.accent_blue');
|
||||
}
|
||||
|
||||
@Component
|
||||
export struct AlertsTab {
|
||||
@State alerts: Array<AlertItem> = [];
|
||||
@State loading: boolean = true;
|
||||
@State refreshing: boolean = false;
|
||||
@StorageProp('fontScale') fontScaleLevel: FontScaleLevel = 'system';
|
||||
|
||||
private scaleFor(): number {
|
||||
return FONT_SCALE_MAP[this.fontScaleLevel] ?? 1.0;
|
||||
}
|
||||
|
||||
aboutToAppear(): void {
|
||||
this.loadAlerts();
|
||||
// 注册预警实时推送:收到推送刷新列表
|
||||
AlertSocket.setListener(() => {
|
||||
this.loadAlerts();
|
||||
});
|
||||
}
|
||||
|
||||
aboutToDisappear(): void {
|
||||
AlertSocket.setListener(null);
|
||||
}
|
||||
|
||||
private async loadAlerts(): Promise<void> {
|
||||
this.loading = true;
|
||||
try {
|
||||
const res = await api.get<Page<AlertItem>>('/alerts/', {
|
||||
page_size: 50,
|
||||
ordering: '-created_at',
|
||||
});
|
||||
this.alerts = res.results;
|
||||
} catch (err) {
|
||||
// 静默降级:显示空态
|
||||
console.error('[AlertsTab] load failed:', normalizeError(err as BusinessError | Error));
|
||||
} finally {
|
||||
this.loading = false;
|
||||
this.refreshing = false;
|
||||
}
|
||||
}
|
||||
|
||||
@Builder
|
||||
AlertCard(item: AlertItem) {
|
||||
Column({ space: 8 }) {
|
||||
Row({ space: 8 }) {
|
||||
Circle()
|
||||
.width(8)
|
||||
.height(8)
|
||||
.fill(levelColor(item.level))
|
||||
Text(item.title)
|
||||
.fontSize(Math.round(15 * this.scaleFor()))
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.layoutWeight(1)
|
||||
.maxLines(1)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
Text(formatDateTime(item.created_at))
|
||||
.fontSize(Math.round(12 * this.scaleFor()))
|
||||
.fontColor($r('app.color.inactive_tab'))
|
||||
}
|
||||
.width('100%')
|
||||
|
||||
Text(item.description && item.description.length > 0 ? item.description : '(无详情)')
|
||||
.fontSize(Math.round(13 * this.scaleFor()))
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
.maxLines(2)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
.width('100%')
|
||||
}
|
||||
.width('100%')
|
||||
.padding(16)
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.borderRadius(14)
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
}
|
||||
|
||||
build() {
|
||||
Refresh({ refreshing: $$this.refreshing }) {
|
||||
Scroll() {
|
||||
Column({ space: 10 }) {
|
||||
Row() {
|
||||
Text('预警消息')
|
||||
.fontSize(Math.round(18 * this.scaleFor()))
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
Blank()
|
||||
Text('刷新')
|
||||
.fontSize(Math.round(13 * this.scaleFor()))
|
||||
.fontColor($r('app.color.accent_blue'))
|
||||
.onClick(() => {
|
||||
this.loadAlerts();
|
||||
})
|
||||
}
|
||||
.width('92%')
|
||||
.margin({ top: 20, bottom: 4 })
|
||||
|
||||
if (this.loading) {
|
||||
Text('加载中...')
|
||||
.fontSize(Math.round(13 * this.scaleFor()))
|
||||
.fontColor($r('app.color.inactive_tab'))
|
||||
.margin({ top: 40 })
|
||||
} else if (this.alerts.length === 0) {
|
||||
Column({ space: 8 }) {
|
||||
Text('🔔')
|
||||
.fontSize(Math.round(32 * this.scaleFor()))
|
||||
Text('暂无预警消息')
|
||||
.fontSize(Math.round(14 * this.scaleFor()))
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
}
|
||||
.width('100%')
|
||||
.margin({ top: 80 })
|
||||
.alignItems(HorizontalAlign.Center)
|
||||
} else {
|
||||
ForEach(this.alerts, (item: AlertItem) => {
|
||||
this.AlertCard(item)
|
||||
}, (item: AlertItem) => String(item.id))
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(HorizontalAlign.Center)
|
||||
.padding({ bottom: 120 })
|
||||
}
|
||||
.scrollBar(BarState.Auto)
|
||||
.edgeEffect(EdgeEffect.Spring)
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
.onRefreshing(() => {
|
||||
this.loadAlerts();
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
// 业务中心页签(按角色自适应入口列表,ArkTS 严格类型,无 any)
|
||||
import { router } from '@kit.ArkUI';
|
||||
import { promptAction } from '@kit.ArkUI';
|
||||
import { uiMaterial } from '@kit.ArkUI';
|
||||
import { BusinessError } from '@kit.BasicServicesKit';
|
||||
import { UnifiedRole, normalizeError } from '../../common/ApiConstants';
|
||||
import { AuthStore } from '../../utils/AuthStore';
|
||||
import { FontScaleLevel, FONT_SCALE_MAP } from '../../utils/FontScale';
|
||||
|
||||
interface BusinessEntry {
|
||||
key: string;
|
||||
title: string;
|
||||
desc: string;
|
||||
route: string; // 空字符串表示未实现
|
||||
}
|
||||
|
||||
interface BusinessSection {
|
||||
title: string;
|
||||
entries: Array<BusinessEntry>;
|
||||
}
|
||||
|
||||
@Component
|
||||
export struct BusinessTab {
|
||||
@State role: UnifiedRole | null = null;
|
||||
@State sections: Array<BusinessSection> = [];
|
||||
@StorageProp('fontScale') fontScaleLevel: FontScaleLevel = 'system';
|
||||
|
||||
private scaleFor(): number {
|
||||
return FONT_SCALE_MAP[this.fontScaleLevel] ?? 1.0;
|
||||
}
|
||||
|
||||
aboutToAppear(): void {
|
||||
this.loadRole();
|
||||
}
|
||||
|
||||
private async loadRole(): Promise<void> {
|
||||
const state = await AuthStore.getState();
|
||||
this.role = state.role;
|
||||
this.applyRoleConfig();
|
||||
}
|
||||
|
||||
private applyRoleConfig(): void {
|
||||
const role = this.role;
|
||||
if (role === 'nurse' || role === 'staff_nurse') {
|
||||
this.sections = [
|
||||
{
|
||||
title: '护理工作',
|
||||
entries: [
|
||||
{ key: 'record', title: '护理记录录入', desc: '提交文字/图片护理记录', route: 'pages/nurse/RecordEntry' },
|
||||
{ key: 'history', title: '护理记录列表', desc: '查看与搜索历史记录', route: 'pages/nurse/History' },
|
||||
{ key: 'work-center', title: '护理中心', desc: '当前楼层与护理要务', route: 'pages/nurse/WorkCenter' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '老人服务',
|
||||
entries: [
|
||||
{ key: 'elderly', title: '老人列表', desc: '负责长者的档案信息', route: 'pages/elderly/ElderlyList' },
|
||||
{ key: 'skin', title: '皮肤检查', desc: '压疮风险巡检记录', route: 'pages/nurse/RecordEntry' },
|
||||
{ key: 'medications', title: '用药管理', desc: '长者用药提醒与记录', route: 'pages/nurse/MedicationToDo' },
|
||||
],
|
||||
},
|
||||
];
|
||||
} else if (role === 'family') {
|
||||
this.sections = [
|
||||
{
|
||||
title: '看护服务',
|
||||
entries: [
|
||||
{ key: 'records', title: '护理记录', desc: '查看长者护理记录', route: 'pages/family/Records' },
|
||||
{ key: 'elderly', title: '长者档案', desc: '查看长者档案信息', route: 'pages/family/FamilyElderly' },
|
||||
{ key: 'history', title: '长者历史', desc: '健康与护理历史', route: 'pages/family/FamilyElderlyHistory' },
|
||||
{ key: 'assistant', title: '智能助手', desc: '随时咨询家人状况', route: 'pages/family/FamilyAssistant' },
|
||||
{ key: 'verify', title: '安全验证', desc: '验证家属身份', route: 'pages/family/FamilyVerify' },
|
||||
],
|
||||
},
|
||||
];
|
||||
} else if (role === 'fire_control') {
|
||||
this.sections = [
|
||||
{
|
||||
title: '消防工作',
|
||||
entries: [
|
||||
{ key: 'patrol', title: '消防巡更', desc: '扫码打卡与巡更记录', route: 'pages/fire-control/FirePatrolHome' },
|
||||
],
|
||||
},
|
||||
];
|
||||
} else if (role === 'sales') {
|
||||
this.sections = [
|
||||
{
|
||||
title: '销售工作',
|
||||
entries: [
|
||||
{ key: 'sales', title: '销售工作台', desc: '线索、跟进与合同', route: 'pages/sales/SalesHome' },
|
||||
],
|
||||
},
|
||||
];
|
||||
} else {
|
||||
// 管理员/默认
|
||||
this.sections = [
|
||||
{
|
||||
title: '记录管理',
|
||||
entries: [
|
||||
{ key: 'today', title: '今日记录', desc: '查看今日全部护理记录', route: 'pages/nurse/History' },
|
||||
{ key: 'records', title: '记录概览', desc: '搜索与筛选护理记录', route: 'pages/nurse/History' },
|
||||
{ key: 'alerts', title: '预警概览', desc: '查看预警处理情况', route: 'pages/nurse/Alerts' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '运营管理',
|
||||
entries: [
|
||||
{ key: 'meal', title: '订餐查询', desc: '今日菜单与订餐记录', route: 'pages/meals/MealOrders' },
|
||||
{ key: 'schedule', title: '排班管理', desc: '查看和调整护理排班', route: 'pages/admin/AdminSchedule' },
|
||||
{ key: 'feedback', title: '意见反馈', desc: '收集家属及员工建议', route: 'pages/profile/Feedback' },
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
private onEntryClick(entry: BusinessEntry): void {
|
||||
if (entry.route.length === 0) {
|
||||
promptAction.showToast({ message: '功能开发中' });
|
||||
return;
|
||||
}
|
||||
router.pushUrl({ url: entry.route }).catch((err: BusinessError) => {
|
||||
promptAction.showToast({ message: `页面打开失败:${normalizeError(err)}` });
|
||||
});
|
||||
}
|
||||
|
||||
@Builder
|
||||
EntryRow(entry: BusinessEntry) {
|
||||
Row({ space: 12 }) {
|
||||
Column({ space: 4 }) {
|
||||
Text(entry.title)
|
||||
.fontSize(Math.round(16 * this.scaleFor()))
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
Text(entry.desc)
|
||||
.fontSize(Math.round(12 * this.scaleFor()))
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
}
|
||||
.layoutWeight(1)
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
|
||||
Text('›')
|
||||
.fontSize(Math.round(22 * this.scaleFor()))
|
||||
.fontColor($r('app.color.inactive_tab'))
|
||||
}
|
||||
.width('100%')
|
||||
.padding(16)
|
||||
.backgroundColor(Color.Transparent)
|
||||
.backgroundBlurStyle(BlurStyle.Thin)
|
||||
.borderRadius(14)
|
||||
.onClick(() => {
|
||||
this.onEntryClick(entry);
|
||||
})
|
||||
}
|
||||
|
||||
build() {
|
||||
Scroll() {
|
||||
Column({ space: 18 }) {
|
||||
ForEach(this.sections, (section: BusinessSection) => {
|
||||
Column({ space: 10 }) {
|
||||
Text(section.title)
|
||||
.fontSize(Math.round(16 * this.scaleFor()))
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.width('100%')
|
||||
|
||||
Column({ space: 8 }) {
|
||||
ForEach(section.entries, (entry: BusinessEntry) => {
|
||||
this.EntryRow(entry)
|
||||
}, (entry: BusinessEntry) => entry.key)
|
||||
}
|
||||
.width('100%')
|
||||
}
|
||||
.width('92%')
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
}, (section: BusinessSection) => section.title)
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(HorizontalAlign.Center)
|
||||
.padding({ top: 20, bottom: 120 })
|
||||
}
|
||||
.scrollBar(BarState.Auto)
|
||||
.edgeEffect(EdgeEffect.Spring)
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
// 老人端 · 活动安排(ArkTS 严格类型,无 any)
|
||||
// 接口:GET /activities/weeks/<week_start>/
|
||||
import { router, promptAction } from '@kit.ArkUI';
|
||||
import { InsightIntentPage, common } from '@kit.AbilityKit';
|
||||
import { BusinessError } from '@kit.BasicServicesKit';
|
||||
import { HdsNavigation, HdsNavigationTitleMode, ScrollEffectType } from '@kit.UIDesignKit';
|
||||
import { hdsMaterial } from '@hms.hds.hdsMaterial';
|
||||
import { normalizeError } from '../../common/ApiConstants';
|
||||
import { api } from '../../utils/Request';
|
||||
import { addCalendarEvent } from '../../utils/CalendarHelper';
|
||||
|
||||
interface ActivityItem {
|
||||
id: number;
|
||||
activity_name: string;
|
||||
activity_description: string;
|
||||
activity_date: string;
|
||||
start_time: string;
|
||||
end_time: string;
|
||||
location: string;
|
||||
participants: number;
|
||||
}
|
||||
|
||||
interface ActivityWeek {
|
||||
week_start: string;
|
||||
status: string;
|
||||
items: Array<ActivityItem>;
|
||||
}
|
||||
|
||||
function weekStart(date: Date): string {
|
||||
const d = new Date(date);
|
||||
const day = d.getDay() === 0 ? 7 : d.getDay();
|
||||
d.setDate(d.getDate() - day + 1);
|
||||
const mm = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const dd = String(d.getDate()).padStart(2, '0');
|
||||
return `${d.getFullYear()}-${mm}-${dd}`;
|
||||
}
|
||||
|
||||
function formatDateLabel(iso: string): string {
|
||||
const d = new Date(iso + 'T00:00:00');
|
||||
if (Number.isNaN(d.getTime())) {
|
||||
return iso;
|
||||
}
|
||||
const weekdays: Array<string> = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'];
|
||||
return `${d.getMonth() + 1}/${d.getDate()} ${weekdays[d.getDay()]}`;
|
||||
}
|
||||
|
||||
function formatTime(t: string): string {
|
||||
return t && t.length > 0 ? t : '--';
|
||||
}
|
||||
|
||||
function formatYMD(d: Date): string {
|
||||
const mm = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const dd = String(d.getDate()).padStart(2, '0');
|
||||
return `${d.getFullYear()}-${mm}-${dd}`;
|
||||
}
|
||||
|
||||
@InsightIntentPage({
|
||||
intentName: 'ViewActivities',
|
||||
domain: 'ElderlyCare',
|
||||
intentVersion: '1.0.0',
|
||||
displayName: '查看院内活动',
|
||||
displayDescription: '打开智慧护老的活动页,查看本周和下周的院内活动安排',
|
||||
uiAbility: 'EntryAbility',
|
||||
pagePath: 'ets/pages/elderly/Activities',
|
||||
keywords: ['活动', '健身', '八段锦', '娱乐', '课程'],
|
||||
llmDescription: '查看养老院本周及未来的院内活动安排与报名信息',
|
||||
})
|
||||
@Entry
|
||||
@Component
|
||||
struct Activities {
|
||||
@State items: Array<ActivityItem> = [];
|
||||
@State loading: boolean = true;
|
||||
@State weekStartText: string = weekStart(new Date());
|
||||
private scroller: Scroller = new Scroller();
|
||||
|
||||
aboutToAppear(): void {
|
||||
this.loadActivities();
|
||||
}
|
||||
|
||||
// 从本周起向后查找最近一个已发布且有活动的周,避免本周未发布时误显示空周
|
||||
private async loadActivities(): Promise<void> {
|
||||
this.loading = true;
|
||||
try {
|
||||
const base = new Date(this.weekStartText + 'T00:00:00');
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const ws = new Date(base);
|
||||
ws.setDate(ws.getDate() + i * 7);
|
||||
const week = formatYMD(ws);
|
||||
const res = await api.get<ActivityWeek>(`/activities/weeks/${week}/`);
|
||||
if (res.items && res.items.length > 0) {
|
||||
this.items = res.items;
|
||||
this.weekStartText = week;
|
||||
break;
|
||||
}
|
||||
this.items = [];
|
||||
}
|
||||
} catch (err) {
|
||||
promptAction.showToast({ message: normalizeError(err as BusinessError | Error) });
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
@Builder
|
||||
ActivityCard(item: ActivityItem) {
|
||||
Column({ space: 8 }) {
|
||||
Row() {
|
||||
Text(item.activity_name)
|
||||
.fontSize(18)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.layoutWeight(1)
|
||||
.maxLines(1)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
Text(formatDateLabel(item.activity_date))
|
||||
.fontSize(13)
|
||||
.fontColor($r('app.color.accent_blue'))
|
||||
}
|
||||
.width('100%')
|
||||
|
||||
if (item.activity_description && item.activity_description.length > 0) {
|
||||
Text(item.activity_description)
|
||||
.fontSize(14)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
.maxLines(2)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
.width('100%')
|
||||
}
|
||||
|
||||
Row({ space: 12 }) {
|
||||
Text(`🕐 ${formatTime(item.start_time)} - ${formatTime(item.end_time)}`)
|
||||
.fontSize(13)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
if (item.location && item.location.length > 0) {
|
||||
Text(`📍 ${item.location}`)
|
||||
.fontSize(13)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
}
|
||||
Blank()
|
||||
Text('+ 日历')
|
||||
.fontSize(13)
|
||||
.fontColor($r('app.color.accent_blue'))
|
||||
.padding({ left: 10, right: 10, top: 6, bottom: 6 })
|
||||
.borderRadius(14)
|
||||
.backgroundColor($r('app.color.background'))
|
||||
.onClick(() => {
|
||||
this.addEventToCalendar(item);
|
||||
})
|
||||
.accessibilityLevel('yes')
|
||||
.accessibilityText(`将${item.activity_name}加入系统日历`)
|
||||
}
|
||||
.width('100%')
|
||||
}
|
||||
.width('100%')
|
||||
.padding(16)
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.borderRadius(14)
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
}
|
||||
|
||||
// 解析活动日期与时间 → 写入系统日历
|
||||
private async addEventToCalendar(item: ActivityItem): Promise<void> {
|
||||
try {
|
||||
// 后端时间为 HH:mm:ss,兼容 HH:mm;避免拼出非法 ISO 串
|
||||
const startT = item.start_time.length >= 8 ? item.start_time.substring(0, 5) : item.start_time;
|
||||
const endT = item.end_time.length >= 8 ? item.end_time.substring(0, 5) : item.end_time;
|
||||
const startTimeStr = `${item.activity_date}T${startT.length > 0 ? startT : '09:00'}:00`;
|
||||
const endTimeStr = `${item.activity_date}T${endT.length > 0 ? endT : '10:00'}:00`;
|
||||
const start = new Date(startTimeStr).getTime();
|
||||
const end = new Date(endTimeStr).getTime();
|
||||
if (Number.isNaN(start) || Number.isNaN(end)) {
|
||||
promptAction.showToast({ message: '活动时间无法解析' });
|
||||
return;
|
||||
}
|
||||
const context = this.getUIContext().getHostContext() as common.UIAbilityContext;
|
||||
const errText = await addCalendarEvent(context, `活动:${item.activity_name}`, start, end,
|
||||
item.location ?? '', item.activity_description ?? '');
|
||||
if (errText.length === 0) {
|
||||
promptAction.showToast({ message: '已加入系统日历' });
|
||||
} else {
|
||||
promptAction.showToast({ message: errText });
|
||||
}
|
||||
} catch (err) {
|
||||
promptAction.showToast({ message: `写入日历失败:${normalizeError(err as BusinessError | Error)}` });
|
||||
}
|
||||
}
|
||||
|
||||
build() {
|
||||
HdsNavigation() {
|
||||
Column() {
|
||||
if (this.loading) {
|
||||
Text('加载中...')
|
||||
.fontSize(15)
|
||||
.fontColor($r('app.color.inactive_tab'))
|
||||
.margin({ top: 80 })
|
||||
} else if (this.items.length === 0) {
|
||||
Column({ space: 8 }) {
|
||||
Text('🎉')
|
||||
.fontSize(40)
|
||||
Text('本周暂无活动安排')
|
||||
.fontSize(16)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
}
|
||||
.width('100%')
|
||||
.margin({ top: 100 })
|
||||
.alignItems(HorizontalAlign.Center)
|
||||
} else {
|
||||
Scroll(this.scroller) {
|
||||
Column({ space: 10 }) {
|
||||
Text(`活动安排(${this.weekStartText} 起)`)
|
||||
.fontSize(13)
|
||||
.fontColor($r('app.color.inactive_tab'))
|
||||
.width('100%')
|
||||
ForEach(this.items, (item: ActivityItem) => {
|
||||
this.ActivityCard(item)
|
||||
}, (item: ActivityItem) => String(item.id))
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ left: 16, right: 16, top: 92, bottom: 20 })
|
||||
}
|
||||
.scrollBar(BarState.Auto)
|
||||
.edgeEffect(EdgeEffect.Spring)
|
||||
.layoutWeight(1)
|
||||
.width('100%')
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
}
|
||||
.mode(NavigationMode.Stack)
|
||||
.titleBar({
|
||||
content: {
|
||||
title: {
|
||||
mainTitle: '活动安排',
|
||||
},
|
||||
},
|
||||
style: {
|
||||
scrollEffectOpts: {
|
||||
enableScrollEffect: false,
|
||||
scrollEffectType: ScrollEffectType.GRADIENT_BLUR,
|
||||
},
|
||||
systemMaterialEffect: {
|
||||
materialType: hdsMaterial.MaterialType.ADAPTIVE,
|
||||
materialLevel: hdsMaterial.MaterialLevel.EXQUISITE,
|
||||
},
|
||||
},
|
||||
avoidLayoutSafeArea: false,
|
||||
enableComponentSafeArea: false,
|
||||
})
|
||||
.bindToScrollable([this.scroller])
|
||||
.hideBackButton(false)
|
||||
.titleMode(HdsNavigationTitleMode.MINI)
|
||||
.ignoreLayoutSafeArea([LayoutSafeAreaType.SYSTEM], [LayoutSafeAreaEdge.TOP, LayoutSafeAreaEdge.BOTTOM])
|
||||
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
|
||||
.backgroundColor($r('app.color.background'))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
// 老人助手(ArkTS 严格类型,无 any)
|
||||
// 数据来源:/elderly-portal/assistant/reply/
|
||||
import { promptAction } from '@kit.ArkUI';
|
||||
import { BusinessError } from '@kit.BasicServicesKit';
|
||||
import { normalizeError } from '../../common/ApiConstants';
|
||||
import { api, RequestError } from '../../utils/Request';
|
||||
|
||||
interface ChatMessage {
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
}
|
||||
|
||||
interface ReplyResult {
|
||||
reply: string;
|
||||
auto_reserved: boolean;
|
||||
reservations: Array<Record<string, string | number | boolean>>;
|
||||
}
|
||||
|
||||
// 助手消息请求体
|
||||
interface AssistantPayload {
|
||||
message: string;
|
||||
}
|
||||
|
||||
interface ReservationItem {
|
||||
id: string;
|
||||
meal_name: string;
|
||||
time: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
@Entry
|
||||
@Component
|
||||
struct Assistant {
|
||||
@State inputText: string = '';
|
||||
@State messages: ChatMessage[] = [];
|
||||
@State sending: boolean = false;
|
||||
|
||||
private scroller: Scroller = new Scroller();
|
||||
|
||||
private async send(): Promise<void> {
|
||||
const text = this.inputText.trim();
|
||||
if (text.length === 0 || this.sending) {
|
||||
return;
|
||||
}
|
||||
const nextMessages = this.messages.concat([{ role: 'user', content: text }]);
|
||||
this.messages = nextMessages;
|
||||
this.inputText = '';
|
||||
this.sending = true;
|
||||
try {
|
||||
const payload: AssistantPayload = { message: text };
|
||||
const res = await api.post<ReplyResult>('/elderly-portal/assistant/reply/', payload);
|
||||
const reply = res.reply && res.reply.length > 0 ? res.reply : '暂时无法回答,请稍后再试。';
|
||||
this.messages = this.messages.concat([{ role: 'assistant', content: reply }]);
|
||||
} catch (err) {
|
||||
const message = err instanceof RequestError ? err.message : normalizeError(err as BusinessError | Error);
|
||||
this.messages = this.messages.concat([{ role: 'assistant', content: message }]);
|
||||
} finally {
|
||||
this.sending = false;
|
||||
}
|
||||
}
|
||||
|
||||
@Builder
|
||||
MessageView(msg: ChatMessage) {
|
||||
Row() {
|
||||
if (msg.role === 'user') {
|
||||
Blank()
|
||||
Column() {
|
||||
Text(msg.content)
|
||||
.fontSize(15)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
}
|
||||
.width('75%')
|
||||
.padding(12)
|
||||
.backgroundColor($r('app.color.accent_blue'))
|
||||
.borderRadius(12)
|
||||
} else {
|
||||
Column() {
|
||||
Text(msg.content)
|
||||
.fontSize(15)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
}
|
||||
.width('75%')
|
||||
.padding(12)
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.borderRadius(12)
|
||||
Blank()
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ left: 16, right: 16, bottom: 10 })
|
||||
}
|
||||
|
||||
build() {
|
||||
Column() {
|
||||
Text('老人助手')
|
||||
.fontSize(20)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.width('100%')
|
||||
.padding({ left: 20, right: 20, top: 20, bottom: 8 })
|
||||
|
||||
Scroll(this.scroller) {
|
||||
Column({ space: 6 }) {
|
||||
ForEach(this.messages, (msg: ChatMessage) => {
|
||||
this.MessageView(msg)
|
||||
}, (msg: ChatMessage) => `${msg.role}-${this.messages.indexOf(msg)}`)
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ bottom: 20 })
|
||||
}
|
||||
.scrollBar(BarState.Auto)
|
||||
.edgeEffect(EdgeEffect.Spring)
|
||||
.layoutWeight(1)
|
||||
.align(Alignment.Top)
|
||||
|
||||
Row({ space: 10 }) {
|
||||
TextArea({ placeholder: '输入想说的话,例如:我想吃清淡点的晚餐', text: this.inputText })
|
||||
.placeholderColor($r('app.color.inactive_tab'))
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.height(46)
|
||||
.layoutWeight(1)
|
||||
.padding({ left: 14, right: 14 })
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.borderRadius(12)
|
||||
.onChange((value: string) => {
|
||||
this.inputText = value;
|
||||
})
|
||||
Button(this.sending ? '…' : '发送')
|
||||
.width(64)
|
||||
.height(46)
|
||||
.fontColor('#FFFFFF')
|
||||
.backgroundColor($r('app.color.accent_blue'))
|
||||
.borderRadius(12)
|
||||
.enabled(!this.sending)
|
||||
.onClick(() => {
|
||||
this.send();
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ left: 16, right: 16, bottom: 20 })
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
.backgroundColor($r('app.color.background'))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,383 @@
|
||||
// 新增对象(录入老人档案)(ArkTS 严格类型,无 any)
|
||||
// 接口:POST /elderly/
|
||||
import { router } from '@kit.ArkUI';
|
||||
import { promptAction } from '@kit.ArkUI';
|
||||
import { BusinessError } from '@kit.BasicServicesKit';
|
||||
import { HdsNavigation, HdsNavigationTitleMode, ScrollEffectType } from '@kit.UIDesignKit';
|
||||
import { hdsMaterial } from '@hms.hds.hdsMaterial';
|
||||
import { normalizeError } from '../../common/ApiConstants';
|
||||
import { api, RequestError } from '../../utils/Request';
|
||||
import { common } from '@kit.AbilityKit';
|
||||
import { takePhoto, pickOneImage, recognizeTextFromUri, extractIdCardFields } from '../../utils/OcrHelper';
|
||||
|
||||
// 新增老人请求体
|
||||
interface ElderlyCreatePayload {
|
||||
name: string;
|
||||
gender: string;
|
||||
birth_date: string;
|
||||
id_card: string;
|
||||
phone: string;
|
||||
room: string;
|
||||
bed: string;
|
||||
floor: string;
|
||||
care_level: string;
|
||||
medical_history: string;
|
||||
allergies: string;
|
||||
medications: string;
|
||||
}
|
||||
|
||||
const CARE_LEVELS: Array<string> = ['self_care', 'semi_care', 'full_care'];
|
||||
const GENDERS: Array<string> = ['male', 'female'];
|
||||
|
||||
function careLevelLabel(value: string): string {
|
||||
if (value === 'self_care') {
|
||||
return '自理';
|
||||
}
|
||||
if (value === 'semi_care') {
|
||||
return '半护理';
|
||||
}
|
||||
if (value === 'full_care') {
|
||||
return '全护理';
|
||||
}
|
||||
return '自理';
|
||||
}
|
||||
|
||||
@Entry
|
||||
@Component
|
||||
struct ElderlyAdd {
|
||||
@State name: string = '';
|
||||
@State gender: string = 'male';
|
||||
@State birthDate: string = '';
|
||||
@State idCard: string = '';
|
||||
@State phone: string = '';
|
||||
@State room: string = '';
|
||||
@State bed: string = '';
|
||||
@State floor: string = '';
|
||||
@State careLevel: string = 'self_care';
|
||||
@State medicalHistory: string = '';
|
||||
@State allergies: string = '';
|
||||
@State medications: string = '';
|
||||
@State submitting: boolean = false;
|
||||
@State ocrRunning: boolean = false;
|
||||
private scroller: Scroller = new Scroller();
|
||||
|
||||
// 拍身份证识别:端侧 OCR 提取证件号/性别/出生日期/姓名,自动填表(可核对修改)
|
||||
private async runIdCardOcr(useCamera: boolean): Promise<void> {
|
||||
if (this.ocrRunning) {
|
||||
return;
|
||||
}
|
||||
const context = this.getUIContext().getHostContext() as common.UIAbilityContext;
|
||||
const uri = useCamera ? await takePhoto(context) : await pickOneImage(context);
|
||||
if (uri === null) {
|
||||
return;
|
||||
}
|
||||
this.ocrRunning = true;
|
||||
promptAction.showToast({ message: '识别中...' });
|
||||
try {
|
||||
const text = await recognizeTextFromUri(uri);
|
||||
const fields = extractIdCardFields(text);
|
||||
if (fields.idNumber.length === 0) {
|
||||
promptAction.showToast({ message: '未识别到身份证号,请光线充足后重拍' });
|
||||
return;
|
||||
}
|
||||
this.idCard = fields.idNumber;
|
||||
if (fields.gender.length > 0) {
|
||||
this.gender = fields.gender;
|
||||
}
|
||||
if (fields.birthDate.length > 0) {
|
||||
this.birthDate = fields.birthDate;
|
||||
}
|
||||
if (this.name.trim().length === 0 && fields.name.length > 0) {
|
||||
this.name = fields.name;
|
||||
}
|
||||
promptAction.showToast({ message: '识别完成,请核对后提交' });
|
||||
} catch (err) {
|
||||
console.error('[ElderlyAdd] id card ocr failed:', normalizeError(err as BusinessError | Error));
|
||||
promptAction.showToast({ message: '识别失败,请重试或手动输入' });
|
||||
} finally {
|
||||
this.ocrRunning = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 跨端 OCR 菜单(bindMenu 自定义项)
|
||||
@Builder
|
||||
OcrMenu() {
|
||||
Column() {
|
||||
Text('📷 拍照识别')
|
||||
.width('100%')
|
||||
.padding(12)
|
||||
.fontSize(15)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.onClick(() => {
|
||||
this.runIdCardOcr(true);
|
||||
})
|
||||
Divider().color($r('app.color.background'))
|
||||
Text('🖼️ 从相册识别')
|
||||
.width('100%')
|
||||
.padding(12)
|
||||
.fontSize(15)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.onClick(() => {
|
||||
this.runIdCardOcr(false);
|
||||
})
|
||||
}
|
||||
.width(170)
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.borderRadius(12)
|
||||
}
|
||||
|
||||
private async submit(): Promise<void> {
|
||||
if (this.name.trim().length === 0) {
|
||||
promptAction.showToast({ message: '请输入老人姓名' });
|
||||
return;
|
||||
}
|
||||
if (this.idCard.trim().length === 0) {
|
||||
promptAction.showToast({ message: '请输入身份证号' });
|
||||
return;
|
||||
}
|
||||
if (this.submitting) {
|
||||
return;
|
||||
}
|
||||
this.submitting = true;
|
||||
try {
|
||||
const body: ElderlyCreatePayload = {
|
||||
name: this.name.trim(),
|
||||
gender: this.gender,
|
||||
birth_date: this.birthDate.trim(),
|
||||
id_card: this.idCard.trim(),
|
||||
phone: this.phone.trim(),
|
||||
room: this.room.trim(),
|
||||
bed: this.bed.trim(),
|
||||
floor: this.floor.trim(),
|
||||
care_level: this.careLevel,
|
||||
medical_history: this.medicalHistory.trim(),
|
||||
allergies: this.allergies.trim(),
|
||||
medications: this.medications.trim(),
|
||||
};
|
||||
await api.post('/elderly/', body);
|
||||
promptAction.showToast({ message: '老人档案已创建' });
|
||||
router.back();
|
||||
} catch (err) {
|
||||
const message = err instanceof RequestError ? err.message : normalizeError(err as BusinessError | Error);
|
||||
promptAction.showToast({ message: message });
|
||||
} finally {
|
||||
this.submitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
@Builder
|
||||
Field(label: string, placeholder: string, value: string, onChange: (v: string) => void) {
|
||||
Column({ space: 6 }) {
|
||||
Text(label)
|
||||
.fontSize(14)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.width('100%')
|
||||
TextInput({ placeholder: placeholder, text: value })
|
||||
.placeholderColor($r('app.color.inactive_tab'))
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.height(44)
|
||||
.padding({ left: 12, right: 12 })
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.borderRadius(10)
|
||||
.onChange((v: string) => {
|
||||
onChange(v);
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
}
|
||||
|
||||
build() {
|
||||
HdsNavigation() {
|
||||
Column() {
|
||||
Scroll(this.scroller) {
|
||||
Column({ space: 14 }) {
|
||||
// 基本信息
|
||||
Column({ space: 12 }) {
|
||||
Text('基本信息')
|
||||
.fontSize(17)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.width('100%')
|
||||
|
||||
this.Field('姓名 *', '请输入老人姓名', this.name, (v: string) => {
|
||||
this.name = v;
|
||||
})
|
||||
|
||||
// 性别
|
||||
Column({ space: 6 }) {
|
||||
Text('性别')
|
||||
.fontSize(14)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.width('100%')
|
||||
Row({ space: 10 }) {
|
||||
ForEach(GENDERS, (g: string) => {
|
||||
Text(g === 'male' ? '男' : '女')
|
||||
.fontSize(14)
|
||||
.fontColor(this.gender === g ? '#FFFFFF' : $r('app.color.text_primary'))
|
||||
.padding({ left: 20, right: 20, top: 10, bottom: 10 })
|
||||
.backgroundColor(this.gender === g ? $r('app.color.accent_blue') : $r('app.color.background_card'))
|
||||
.borderRadius(10)
|
||||
.onClick(() => {
|
||||
this.gender = g;
|
||||
})
|
||||
}, (g: string) => g)
|
||||
}
|
||||
.width('100%')
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
|
||||
this.Field('出生日期', '如 1950-01-01', this.birthDate, (v: string) => {
|
||||
this.birthDate = v;
|
||||
})
|
||||
this.Field('身份证号 *', '请输入身份证号', this.idCard, (v: string) => {
|
||||
this.idCard = v;
|
||||
})
|
||||
// 身份证拍照识别(端侧 OCR,可选)
|
||||
Button(this.ocrRunning ? '识别中...' : '📷 拍身份证自动填入')
|
||||
.fontSize(14)
|
||||
.height(40)
|
||||
.fontColor('#FFFFFF')
|
||||
.backgroundColor($r('app.color.accent_blue'))
|
||||
.borderRadius(10)
|
||||
.enabled(!this.ocrRunning)
|
||||
.bindMenu(this.OcrMenu)
|
||||
.accessibilityLevel('yes')
|
||||
.accessibilityText('拍身份证自动填入')
|
||||
.accessibilityDescription('拍照或从相册选择身份证照片,自动识别证件号、性别、出生日期')
|
||||
this.Field('联系电话', '选填', this.phone, (v: string) => {
|
||||
this.phone = v;
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
.padding(16)
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.borderRadius(14)
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
|
||||
// 位置与护理
|
||||
Column({ space: 12 }) {
|
||||
Text('位置与护理')
|
||||
.fontSize(17)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.width('100%')
|
||||
|
||||
this.Field('房间', '如 801', this.room, (v: string) => {
|
||||
this.room = v;
|
||||
})
|
||||
this.Field('床位', '如 12', this.bed, (v: string) => {
|
||||
this.bed = v;
|
||||
})
|
||||
this.Field('楼层', '如 8F', this.floor, (v: string) => {
|
||||
this.floor = v;
|
||||
})
|
||||
|
||||
// 护理等级
|
||||
Column({ space: 6 }) {
|
||||
Text('护理等级')
|
||||
.fontSize(14)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.width('100%')
|
||||
Row({ space: 10 }) {
|
||||
ForEach(CARE_LEVELS, (level: string) => {
|
||||
Text(careLevelLabel(level))
|
||||
.fontSize(14)
|
||||
.fontColor(this.careLevel === level ? '#FFFFFF' : $r('app.color.text_primary'))
|
||||
.padding({ left: 16, right: 16, top: 10, bottom: 10 })
|
||||
.backgroundColor(this.careLevel === level ? $r('app.color.accent_blue') : $r('app.color.background_card'))
|
||||
.borderRadius(10)
|
||||
.onClick(() => {
|
||||
this.careLevel = level;
|
||||
})
|
||||
}, (level: string) => level)
|
||||
}
|
||||
.width('100%')
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
}
|
||||
.width('100%')
|
||||
.padding(16)
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.borderRadius(14)
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
|
||||
// 健康信息
|
||||
Column({ space: 12 }) {
|
||||
Text('健康信息')
|
||||
.fontSize(17)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.width('100%')
|
||||
|
||||
this.Field('病史', '选填', this.medicalHistory, (v: string) => {
|
||||
this.medicalHistory = v;
|
||||
})
|
||||
this.Field('过敏史', '选填', this.allergies, (v: string) => {
|
||||
this.allergies = v;
|
||||
})
|
||||
this.Field('用药情况', '选填', this.medications, (v: string) => {
|
||||
this.medications = v;
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
.padding(16)
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.borderRadius(14)
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
|
||||
Button(this.submitting ? '提交中...' : '保存老人档案')
|
||||
.width('100%')
|
||||
.height(50)
|
||||
.fontSize(16)
|
||||
.fontColor('#FFFFFF')
|
||||
.backgroundColor($r('app.color.accent_blue'))
|
||||
.borderRadius(12)
|
||||
.enabled(!this.submitting)
|
||||
.onClick(() => {
|
||||
this.submit();
|
||||
})
|
||||
}
|
||||
.width('92%')
|
||||
.padding({ top: 120, bottom: 30 })
|
||||
.alignItems(HorizontalAlign.Center)
|
||||
}
|
||||
.scrollBar(BarState.Auto)
|
||||
.edgeEffect(EdgeEffect.Spring)
|
||||
.layoutWeight(1)
|
||||
.width('100%')
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
}
|
||||
.mode(NavigationMode.Stack)
|
||||
.titleBar({
|
||||
content: {
|
||||
title: {
|
||||
mainTitle: '新增对象',
|
||||
},
|
||||
},
|
||||
style: {
|
||||
scrollEffectOpts: {
|
||||
enableScrollEffect: false,
|
||||
scrollEffectType: ScrollEffectType.GRADIENT_BLUR,
|
||||
},
|
||||
systemMaterialEffect: {
|
||||
materialType: hdsMaterial.MaterialType.ADAPTIVE,
|
||||
materialLevel: hdsMaterial.MaterialLevel.EXQUISITE,
|
||||
},
|
||||
},
|
||||
avoidLayoutSafeArea: false,
|
||||
enableComponentSafeArea: false,
|
||||
})
|
||||
.bindToScrollable([this.scroller])
|
||||
.hideBackButton(false)
|
||||
.titleMode(HdsNavigationTitleMode.MINI)
|
||||
.ignoreLayoutSafeArea([LayoutSafeAreaType.SYSTEM], [LayoutSafeAreaEdge.TOP, LayoutSafeAreaEdge.BOTTOM])
|
||||
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
|
||||
.backgroundColor($r('app.color.background'))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
// 老人端 · 聊天助手面板(ArkTS 严格类型,无 any)
|
||||
// 数据来源:/elderly-portal/assistant/reply/
|
||||
// 历史对话记录本地持久化(preferences)
|
||||
import { promptAction } from '@kit.ArkUI';
|
||||
import { preferences } from '@kit.ArkData';
|
||||
import { common } from '@kit.AbilityKit';
|
||||
import { BusinessError } from '@kit.BasicServicesKit';
|
||||
import { normalizeError } from '../../common/ApiConstants';
|
||||
import { api, RequestError } from '../../utils/Request';
|
||||
import { FontScaleLevel, FONT_SCALE_MAP } from '../../utils/FontScale';
|
||||
|
||||
const CHAT_PREFS_NAME = 'ElderlyChat';
|
||||
const CHAT_MESSAGES_KEY = 'messages';
|
||||
|
||||
interface ChatMessage {
|
||||
id: number; // 唯一消息 id
|
||||
role: string;
|
||||
content: string;
|
||||
displayLen: number; // 已显示字符数(打字机效果),-1 表示全文
|
||||
}
|
||||
|
||||
// 自增 id 计数器
|
||||
let chatMessageSeq = 1;
|
||||
|
||||
interface AssistantReservation {
|
||||
slot_id: string;
|
||||
slot_name: string;
|
||||
ordered: boolean;
|
||||
service_date: string;
|
||||
}
|
||||
|
||||
interface ReplyResult {
|
||||
reply: string;
|
||||
auto_reserved: boolean;
|
||||
reservations: Array<AssistantReservation>;
|
||||
}
|
||||
|
||||
// 助手消息请求体
|
||||
interface AssistantPayload {
|
||||
message: string;
|
||||
}
|
||||
|
||||
@Component
|
||||
export struct ElderlyAssistantPanel {
|
||||
@State inputText: string = '';
|
||||
@State messages: Array<ChatMessage> = [];
|
||||
@State sending: boolean = false;
|
||||
@State typingKey: string = '';
|
||||
@StorageProp('fontScale') fontScaleLevel: FontScaleLevel = 'system';
|
||||
|
||||
// 缩放倍数(读 @StorageProp,变化触发重渲)
|
||||
private scaleFor(): number {
|
||||
return FONT_SCALE_MAP[this.fontScaleLevel] ?? 1.0;
|
||||
}
|
||||
|
||||
private scroller: Scroller = new Scroller();
|
||||
private typingTimer: number = -1;
|
||||
|
||||
aboutToAppear(): void {
|
||||
this.loadHistory().then(() => {
|
||||
// 打开默认滚动到最新消息
|
||||
setTimeout(() => {
|
||||
this.scrollToBottom();
|
||||
}, 100);
|
||||
});
|
||||
}
|
||||
|
||||
// 读取本地历史对话
|
||||
private async loadHistory(): Promise<void> {
|
||||
try {
|
||||
const context = getContext(this) as common.UIAbilityContext;
|
||||
const prefs = await preferences.getPreferences(context, CHAT_PREFS_NAME);
|
||||
const value = await prefs.get(CHAT_MESSAGES_KEY, '');
|
||||
if (typeof value === 'string' && value.length > 0) {
|
||||
const parsed = JSON.parse(value) as Array<ChatMessage>;
|
||||
if (parsed && parsed.length > 0) {
|
||||
// 历史消息补 displayLen(全文显示)
|
||||
const restored = parsed.map((m: ChatMessage): ChatMessage => ({
|
||||
id: chatMessageSeq++,
|
||||
role: m.role,
|
||||
content: m.content,
|
||||
displayLen: m.displayLen !== undefined ? m.displayLen : -1,
|
||||
}));
|
||||
this.messages = restored;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[ElderlyChat] load history failed:', normalizeError(err as BusinessError | Error));
|
||||
}
|
||||
}
|
||||
|
||||
// 保存历史对话(限制最近 100 条;保存时全部以全文 displayLen=-1 存储)
|
||||
private async saveHistory(): Promise<void> {
|
||||
try {
|
||||
const context = getContext(this) as common.UIAbilityContext;
|
||||
const prefs = await preferences.getPreferences(context, CHAT_PREFS_NAME);
|
||||
const toSave = this.messages.length > 100 ? this.messages.slice(this.messages.length - 100) : this.messages;
|
||||
const fullText = toSave.map((m: ChatMessage): ChatMessage => ({
|
||||
id: m.id,
|
||||
role: m.role,
|
||||
content: m.content,
|
||||
displayLen: -1,
|
||||
}));
|
||||
await prefs.put(CHAT_MESSAGES_KEY, JSON.stringify(fullText));
|
||||
await prefs.flush();
|
||||
} catch (err) {
|
||||
console.warn('[ElderlyChat] save history failed:', normalizeError(err as BusinessError | Error));
|
||||
}
|
||||
}
|
||||
|
||||
// 滚动到底部聚焦最新消息
|
||||
private scrollToBottom(): void {
|
||||
this.scroller.scrollEdge(Edge.Bottom);
|
||||
}
|
||||
|
||||
// 启动打字机效果:逐字显示回复
|
||||
private startTyping(id: number, fullText: string): void {
|
||||
this.clearTyping();
|
||||
this.typingKey = String(id);
|
||||
this.messages = this.messages.map((m: ChatMessage): ChatMessage => {
|
||||
if (m.id === id) {
|
||||
return { id: m.id, role: m.role, content: m.content, displayLen: 0 };
|
||||
}
|
||||
return m;
|
||||
});
|
||||
let count = 0;
|
||||
this.typingTimer = setInterval(() => {
|
||||
count += 2;
|
||||
if (count >= fullText.length) {
|
||||
this.clearTyping();
|
||||
}
|
||||
this.messages = this.messages.map((m: ChatMessage): ChatMessage => {
|
||||
if (m.id === id) {
|
||||
const len = Math.min(count, fullText.length);
|
||||
return { id: m.id, role: m.role, content: m.content, displayLen: len };
|
||||
}
|
||||
return m;
|
||||
});
|
||||
this.scrollToBottom();
|
||||
}, 30);
|
||||
}
|
||||
|
||||
private clearTyping(): void {
|
||||
if (this.typingTimer !== -1) {
|
||||
clearInterval(this.typingTimer);
|
||||
this.typingTimer = -1;
|
||||
}
|
||||
this.typingKey = '';
|
||||
}
|
||||
|
||||
private async send(): Promise<void> {
|
||||
const text = this.inputText.trim();
|
||||
if (text.length === 0 || this.sending) {
|
||||
return;
|
||||
}
|
||||
const userMsg: ChatMessage = { id: chatMessageSeq++, role: 'user', content: text, displayLen: -1 };
|
||||
this.messages = this.messages.concat([userMsg]);
|
||||
this.inputText = '';
|
||||
this.sending = true;
|
||||
// 发送后聚焦新消息
|
||||
setTimeout(() => {
|
||||
this.scrollToBottom();
|
||||
}, 50);
|
||||
try {
|
||||
const payload: AssistantPayload = { message: text };
|
||||
const res = await api.post<ReplyResult>('/elderly-portal/assistant/reply/', payload);
|
||||
let reply = res.reply && res.reply.length > 0 ? res.reply : '我会继续陪您聊天。';
|
||||
if (res.auto_reserved && res.reservations.length > 0) {
|
||||
reply += '\n已为您自动预订:' + res.reservations.map((r: AssistantReservation) => r.slot_name).join('、');
|
||||
}
|
||||
const botMsg: ChatMessage = { id: chatMessageSeq++, role: 'assistant', content: reply, displayLen: 0 };
|
||||
this.messages = this.messages.concat([botMsg]);
|
||||
// 打字机逐字显示回复
|
||||
this.startTyping(botMsg.id, reply);
|
||||
} catch (err) {
|
||||
const message = err instanceof RequestError ? err.message : normalizeError(err as BusinessError | Error);
|
||||
const botMsg: ChatMessage = { id: chatMessageSeq++, role: 'assistant', content: message, displayLen: -1 };
|
||||
this.messages = this.messages.concat([botMsg]);
|
||||
this.scrollToBottom();
|
||||
} finally {
|
||||
this.sending = false;
|
||||
// 保存历史对话
|
||||
this.saveHistory();
|
||||
}
|
||||
}
|
||||
|
||||
// 按打字机进度截取显示文本
|
||||
private displayText(msg: ChatMessage): string {
|
||||
if (msg.displayLen < 0) {
|
||||
return msg.content;
|
||||
}
|
||||
return msg.content.substring(0, msg.displayLen);
|
||||
}
|
||||
|
||||
// 按 id 从 @State messages 读取(保证打字机/更新重渲)
|
||||
private messageById(id: number): ChatMessage {
|
||||
const found = this.messages.find((m: ChatMessage) => m.id === id);
|
||||
if (found) {
|
||||
return found;
|
||||
}
|
||||
return { id: id, role: 'assistant', content: '', displayLen: -1 };
|
||||
}
|
||||
|
||||
@Builder
|
||||
MessageView(id: number) {
|
||||
Row() {
|
||||
if (this.messageById(id).role === 'user') {
|
||||
Blank()
|
||||
Text(this.displayText(this.messageById(id)))
|
||||
.fontSize(Math.round(18 * this.scaleFor()))
|
||||
.fontColor('#FFFFFF')
|
||||
.constraintSize({ maxWidth: '70%' })
|
||||
.padding(14)
|
||||
.backgroundColor($r('app.color.accent_blue'))
|
||||
.borderRadius(14)
|
||||
} else {
|
||||
Text(this.displayText(this.messageById(id)))
|
||||
.fontSize(Math.round(18 * this.scaleFor()))
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.constraintSize({ maxWidth: '70%' })
|
||||
.padding(14)
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.borderRadius(14)
|
||||
Blank()
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ left: 16, right: 16, bottom: 12 })
|
||||
}
|
||||
|
||||
build() {
|
||||
Column() {
|
||||
Scroll(this.scroller) {
|
||||
Column({ space: 6 }) {
|
||||
if (this.messages.length === 0) {
|
||||
Text('您好,我是您的智能助手 🌟\n可以直接对我说,例如:\n“帮我预订明天的午餐”')
|
||||
.fontSize(Math.round(18 * this.scaleFor()))
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
.textAlign(TextAlign.Center)
|
||||
.width('100%')
|
||||
.padding({ top: 60 })
|
||||
}
|
||||
ForEach(this.messages, (msg: ChatMessage) => {
|
||||
this.MessageView(msg.id)
|
||||
}, (msg: ChatMessage) => String(msg.id))
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ bottom: 20 })
|
||||
}
|
||||
.scrollBar(BarState.Auto)
|
||||
.edgeEffect(EdgeEffect.Spring)
|
||||
.layoutWeight(1)
|
||||
.align(Alignment.Top)
|
||||
|
||||
Row({ space: 10 }) {
|
||||
TextInput({ placeholder: '输入想说的话...', text: this.inputText })
|
||||
.placeholderColor($r('app.color.inactive_tab'))
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.fontSize(Math.round(18 * this.scaleFor()))
|
||||
.height(52)
|
||||
.layoutWeight(1)
|
||||
.padding({ left: 16, right: 16 })
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.borderRadius(14)
|
||||
.onChange((value: string) => {
|
||||
this.inputText = value;
|
||||
})
|
||||
.onSubmit((enterKey: EnterKeyType) => {
|
||||
// 回车直接发送
|
||||
this.send();
|
||||
})
|
||||
Button(this.sending ? '…' : '发送')
|
||||
.height(52)
|
||||
.padding({ left: 18, right: 18 })
|
||||
.constraintSize({ minWidth: 80 })
|
||||
.fontSize(Math.round(18 * this.scaleFor()))
|
||||
.fontColor('#FFFFFF')
|
||||
.backgroundColor($r('app.color.accent_blue'))
|
||||
.borderRadius(14)
|
||||
.enabled(!this.sending)
|
||||
.onClick(() => {
|
||||
this.send();
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ left: 16, right: 16, bottom: 12 })
|
||||
|
||||
// AI 声明
|
||||
Text('内容由 AI 生成,仅供参考,如有疑问请咨询护理人员')
|
||||
.fontSize(11)
|
||||
.fontColor($r('app.color.inactive_tab'))
|
||||
.textAlign(TextAlign.Center)
|
||||
.width('100%')
|
||||
.padding({ bottom: 8 })
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,353 @@
|
||||
// 老人详情页(ArkTS 严格类型,无 any)
|
||||
// 数据来源:GET /elderly/{id}/
|
||||
import { router } from '@kit.ArkUI';
|
||||
import { promptAction } from '@kit.ArkUI';
|
||||
import { BusinessError } from '@kit.BasicServicesKit';
|
||||
import { HdsNavigation, HdsNavigationTitleMode, ScrollEffectType } from '@kit.UIDesignKit';
|
||||
import { hdsMaterial } from '@hms.hds.hdsMaterial';
|
||||
import { normalizeError } from '../../common/ApiConstants';
|
||||
import { api } from '../../utils/Request';
|
||||
|
||||
interface ElderlyDetailData {
|
||||
id: number;
|
||||
name: string | null;
|
||||
gender: string | null;
|
||||
age: number | null;
|
||||
birth_date: string | null;
|
||||
plain_id_card: string | null;
|
||||
room_bed: string | null;
|
||||
floor_room_bed: string | null;
|
||||
care_level: string | null;
|
||||
care_level_label: string | null;
|
||||
care_status: string | null;
|
||||
overview_status: string | null;
|
||||
risks: Array<string> | null;
|
||||
medications: string | null;
|
||||
medical_history: string | null;
|
||||
allergies: string | null;
|
||||
current_caregiver_name: string | null;
|
||||
primary_family_name: string | null;
|
||||
primary_family_relation: string | null;
|
||||
primary_family_phone: string | null;
|
||||
emergency_contact: string | null;
|
||||
emergency_phone: string | null;
|
||||
phone: string | null;
|
||||
}
|
||||
|
||||
function genderLabel(gender: string | null): string {
|
||||
if (gender === 'male') {
|
||||
return '男';
|
||||
}
|
||||
if (gender === 'female') {
|
||||
return '女';
|
||||
}
|
||||
return '未录入';
|
||||
}
|
||||
|
||||
function displayEmpty(value: string | null | undefined): string {
|
||||
if (value === null || value === undefined) {
|
||||
return '—';
|
||||
}
|
||||
const text = String(value).trim();
|
||||
return text.length > 0 ? text : '—';
|
||||
}
|
||||
|
||||
@Entry
|
||||
@Component
|
||||
struct ElderlyDetail {
|
||||
@State detail: ElderlyDetailData | null = null;
|
||||
@State loading: boolean = true;
|
||||
private elderlyId: string = '';
|
||||
private scroller: Scroller = new Scroller();
|
||||
|
||||
aboutToAppear(): void {
|
||||
const params = router.getParams() as Record<string, string> | undefined;
|
||||
if (params) {
|
||||
this.elderlyId = params['id'] ?? '';
|
||||
}
|
||||
this.loadDetail();
|
||||
}
|
||||
|
||||
private async loadDetail(): Promise<void> {
|
||||
if (this.elderlyId.length === 0) {
|
||||
this.loading = false;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
this.detail = await api.get<ElderlyDetailData>(`/elderly/${this.elderlyId}/`);
|
||||
} catch (err) {
|
||||
promptAction.showToast({ message: normalizeError(err as BusinessError | Error) });
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
@Builder
|
||||
InfoRow(label: string, value: string) {
|
||||
Row({ space: 12 }) {
|
||||
Text(label)
|
||||
.fontSize(15)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
.width(84)
|
||||
Text(displayEmpty(value))
|
||||
.fontSize(15)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.layoutWeight(1)
|
||||
.textAlign(TextAlign.Start)
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ top: 10, bottom: 10 })
|
||||
}
|
||||
|
||||
@Builder
|
||||
RiskTag(tag: string) {
|
||||
Text(tag)
|
||||
.fontSize(13)
|
||||
.fontColor($r('app.color.accent_red'))
|
||||
.padding({ left: 10, right: 10, top: 6, bottom: 6 })
|
||||
.backgroundColor('#22FF4D4D')
|
||||
.borderRadius(8)
|
||||
.margin({ right: 8, bottom: 8 })
|
||||
}
|
||||
|
||||
|
||||
build() {
|
||||
HdsNavigation() {
|
||||
Column() {
|
||||
if (this.loading) {
|
||||
Text('加载中...')
|
||||
.fontSize(15)
|
||||
.fontColor($r('app.color.inactive_tab'))
|
||||
.margin({ top: 60 })
|
||||
} else if (!this.detail) {
|
||||
Text('未找到该老人信息')
|
||||
.fontSize(15)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
.margin({ top: 60 })
|
||||
} else {
|
||||
Scroll(this.scroller) {
|
||||
Column({ space: 14 }) {
|
||||
// 基本信息卡
|
||||
Column({ space: 6 }) {
|
||||
Row({ space: 8 }) {
|
||||
Text(this.detail.name ?? '')
|
||||
.fontSize(24)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
if (this.detail.overview_status && this.detail.overview_status.length > 0) {
|
||||
Text(this.detail.overview_status)
|
||||
.fontSize(13)
|
||||
.fontColor('#FFFFFF')
|
||||
.padding({ left: 8, right: 8, top: 3, bottom: 3 })
|
||||
.backgroundColor($r('app.color.accent_green'))
|
||||
.borderRadius(8)
|
||||
}
|
||||
}
|
||||
Text(`${genderLabel(this.detail.gender)} | 年龄:${this.detail.age ?? '未录入'} | ID: ${this.detail.id}`)
|
||||
.fontSize(14)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
if (this.detail.plain_id_card && this.detail.plain_id_card.length > 0) {
|
||||
Text(this.detail.plain_id_card)
|
||||
.fontSize(14)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.padding(18)
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.borderRadius(16)
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
|
||||
// 护理等级 + 房间
|
||||
Column({ space: 6 }) {
|
||||
Row() {
|
||||
Text('护理等级')
|
||||
.fontSize(14)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
Blank()
|
||||
Text(displayEmpty(this.detail.care_level_label))
|
||||
.fontSize(16)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor($r('app.color.accent_blue'))
|
||||
}
|
||||
.width('100%')
|
||||
Divider().color($r('app.color.inactive_tab'))
|
||||
Text(displayEmpty(this.detail.floor_room_bed))
|
||||
.fontSize(15)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.width('100%')
|
||||
}
|
||||
.width('100%')
|
||||
.padding(18)
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.borderRadius(16)
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
|
||||
// 风险标签
|
||||
if (this.detail.risks && this.detail.risks.length > 0) {
|
||||
Column({ space: 8 }) {
|
||||
Text('风险关注')
|
||||
.fontSize(16)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.width('100%')
|
||||
Flex({ wrap: FlexWrap.Wrap }) {
|
||||
ForEach(this.detail.risks, (risk: string) => {
|
||||
if (risk && risk.length > 0) {
|
||||
this.RiskTag(risk)
|
||||
}
|
||||
}, (risk: string) => risk)
|
||||
}
|
||||
.width('100%')
|
||||
}
|
||||
.width('100%')
|
||||
.padding(16)
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.borderRadius(16)
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
}
|
||||
|
||||
// 状态卡
|
||||
Column({ space: 6 }) {
|
||||
Text('护理状态')
|
||||
.fontSize(16)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.width('100%')
|
||||
Row() {
|
||||
Text('状态')
|
||||
.fontSize(15)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
Blank()
|
||||
Text(displayEmpty(this.detail.care_status))
|
||||
.fontSize(15)
|
||||
.fontColor($r('app.color.accent_green'))
|
||||
}
|
||||
.width('100%')
|
||||
Row() {
|
||||
Text('用药')
|
||||
.fontSize(15)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
Blank()
|
||||
Text(displayEmpty(this.detail.medications))
|
||||
.fontSize(15)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
}
|
||||
.width('100%')
|
||||
Row() {
|
||||
Text('备注')
|
||||
.fontSize(15)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
Blank()
|
||||
Text(displayEmpty(this.detail.medical_history))
|
||||
.fontSize(15)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
}
|
||||
.width('100%')
|
||||
}
|
||||
.width('100%')
|
||||
.padding(16)
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.borderRadius(16)
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
|
||||
// 护理员
|
||||
Column({ space: 6 }) {
|
||||
Text('护理员')
|
||||
.fontSize(16)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.width('100%')
|
||||
Text(displayEmpty(this.detail.current_caregiver_name))
|
||||
.fontSize(15)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
.width('100%')
|
||||
}
|
||||
.width('100%')
|
||||
.padding(16)
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.borderRadius(16)
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
|
||||
// 联系人
|
||||
Column({ space: 10 }) {
|
||||
Text('联系人')
|
||||
.fontSize(16)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.width('100%')
|
||||
if (this.detail.primary_family_name && this.detail.primary_family_name.trim().length > 0) {
|
||||
Row() {
|
||||
Text(this.detail.primary_family_name)
|
||||
.fontSize(16)
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
if (this.detail.primary_family_relation && this.detail.primary_family_relation.trim().length > 0) {
|
||||
Text(this.detail.primary_family_relation)
|
||||
.fontSize(13)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
.margin({ left: 8 })
|
||||
}
|
||||
Blank()
|
||||
if (this.detail.primary_family_phone && this.detail.primary_family_phone.trim().length > 0) {
|
||||
Text(this.detail.primary_family_phone)
|
||||
.fontSize(14)
|
||||
.fontColor($r('app.color.accent_blue'))
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
} else {
|
||||
Text('暂无配置')
|
||||
.fontSize(15)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
.width('100%')
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.padding(16)
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.borderRadius(16)
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
.margin({ bottom: 30 })
|
||||
}
|
||||
.width('92%')
|
||||
.alignItems(HorizontalAlign.Center)
|
||||
.padding({ top: 60 })
|
||||
}
|
||||
.scrollBar(BarState.Auto)
|
||||
.edgeEffect(EdgeEffect.Spring)
|
||||
.layoutWeight(1)
|
||||
.width('100%')
|
||||
}
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
.mode(NavigationMode.Stack)
|
||||
.titleBar({
|
||||
content: {
|
||||
title: {
|
||||
mainTitle: '关注对象详情',
|
||||
},
|
||||
},
|
||||
style: {
|
||||
scrollEffectOpts: {
|
||||
enableScrollEffect: false,
|
||||
scrollEffectType: ScrollEffectType.GRADIENT_BLUR,
|
||||
},
|
||||
systemMaterialEffect: {
|
||||
materialType: hdsMaterial.MaterialType.ADAPTIVE,
|
||||
materialLevel: hdsMaterial.MaterialLevel.EXQUISITE,
|
||||
},
|
||||
},
|
||||
avoidLayoutSafeArea: false,
|
||||
enableComponentSafeArea: false,
|
||||
})
|
||||
.bindToScrollable([this.scroller])
|
||||
.hideBackButton(false)
|
||||
.titleMode(HdsNavigationTitleMode.MINI)
|
||||
.ignoreLayoutSafeArea([LayoutSafeAreaType.SYSTEM], [LayoutSafeAreaEdge.TOP, LayoutSafeAreaEdge.BOTTOM])
|
||||
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
|
||||
.backgroundColor($r('app.color.background'))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
// 长者端 · 快速拨号(ArkTS 严格类型,无 any)
|
||||
// 拨号:call.makeCall 跳转拨号界面,由用户确认拨打
|
||||
// 家属列表:GET /elderly/bindings/(长者角色只能看到绑定自己的记录,含家属姓名/电话)
|
||||
import { promptAction } from '@kit.ArkUI';
|
||||
import { InsightIntentPage } from '@kit.AbilityKit';
|
||||
import { HdsNavigation, HdsNavigationTitleMode, ScrollEffectType } from '@kit.UIDesignKit';
|
||||
import { hdsMaterial } from '@hms.hds.hdsMaterial';
|
||||
import { call } from '@kit.TelephonyKit';
|
||||
import { BusinessError } from '@kit.BasicServicesKit';
|
||||
import { Page } from '../../common/ApiConstants';
|
||||
import { api } from '../../utils/Request';
|
||||
|
||||
interface DialContact {
|
||||
name: string;
|
||||
phone: string;
|
||||
desc: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
interface ApiFamilyBinding {
|
||||
id: number;
|
||||
user_name: string;
|
||||
user_phone: string;
|
||||
}
|
||||
|
||||
const CONTACTS: Array<DialContact> = [
|
||||
{ name: '服务中心', phone: '020-12345678', desc: '院内服务台,任何需求都可以找', color: '#007AFF' },
|
||||
{ name: '急救电话', phone: '120', desc: '突发身体不适时拨打', color: '#FF3B30' },
|
||||
{ name: '火警电话', phone: '119', desc: '发现火情时拨打', color: '#FF9500' },
|
||||
{ name: '报警电话', phone: '110', desc: '需要警察帮助时拨打', color: '#8E8E93' },
|
||||
];
|
||||
|
||||
@InsightIntentPage({
|
||||
intentName: 'CallFamily',
|
||||
domain: 'ElderlyCare',
|
||||
intentVersion: '1.0.0',
|
||||
displayName: '呼叫家人',
|
||||
displayDescription: '打开智慧护老的拨号页,一键拨打家人或护理员电话',
|
||||
uiAbility: 'EntryAbility',
|
||||
pagePath: 'ets/pages/elderly/ElderlyDial',
|
||||
keywords: ['打电话', '呼叫', '家人', '护理员', '电话', '求助'],
|
||||
llmDescription: '打开一键拨号页,拨打家人、护理员或养老院前台电话',
|
||||
})
|
||||
@Entry
|
||||
@Component
|
||||
struct ElderlyDial {
|
||||
@State familyContacts: Array<DialContact> = [];
|
||||
@State familyLoading: boolean = true;
|
||||
|
||||
aboutToAppear(): void {
|
||||
this.loadFamily();
|
||||
}
|
||||
|
||||
// 拉取绑定自己的家属(无电话的不展示)
|
||||
private async loadFamily(): Promise<void> {
|
||||
this.familyLoading = true;
|
||||
try {
|
||||
const res = await api.get<Page<ApiFamilyBinding>>('/elderly/bindings/', { page_size: 50 });
|
||||
const contacts: Array<DialContact> = [];
|
||||
res.results.forEach((b: ApiFamilyBinding): void => {
|
||||
const phone = typeof b.user_phone === 'string' ? b.user_phone.trim() : '';
|
||||
const name = typeof b.user_name === 'string' ? b.user_name.trim() : '';
|
||||
if (phone.length > 0) {
|
||||
contacts.push({
|
||||
name: name.length > 0 ? name : '家属',
|
||||
phone: phone,
|
||||
desc: '您的家属,有事随时联系',
|
||||
color: '#34C759',
|
||||
});
|
||||
}
|
||||
});
|
||||
this.familyContacts = contacts;
|
||||
} catch (err) {
|
||||
console.error('[ElderlyDial] load family bindings failed:', (err as Error).message);
|
||||
} finally {
|
||||
this.familyLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private dial(phone: string): void {
|
||||
call.makeCall(phone).catch(() => {
|
||||
promptAction.showToast({ message: '拨号失败,请手动拨打' });
|
||||
});
|
||||
}
|
||||
|
||||
@Builder
|
||||
ContactCard(contact: DialContact) {
|
||||
Row({ space: 14 }) {
|
||||
Column() {
|
||||
Text(contact.name.substring(0, 1))
|
||||
.fontSize(24)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor('#FFFFFF')
|
||||
}
|
||||
.width(56)
|
||||
.height(56)
|
||||
.justifyContent(FlexAlign.Center)
|
||||
.alignItems(HorizontalAlign.Center)
|
||||
.backgroundColor(contact.color)
|
||||
.borderRadius(28)
|
||||
|
||||
Column({ space: 4 }) {
|
||||
Text(contact.name)
|
||||
.fontSize(20)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
Text(contact.desc)
|
||||
.fontSize(13)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
Text(contact.phone)
|
||||
.fontSize(16)
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor('#007AFF')
|
||||
}
|
||||
.layoutWeight(1)
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
|
||||
Text('拨打')
|
||||
.fontSize(16)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor('#FFFFFF')
|
||||
.padding({ left: 18, right: 18, top: 10, bottom: 10 })
|
||||
.backgroundColor('#34C759')
|
||||
.borderRadius(22)
|
||||
}
|
||||
.width('100%')
|
||||
.padding(16)
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.borderRadius(18)
|
||||
.alignItems(VerticalAlign.Center)
|
||||
.onClick(() => {
|
||||
this.dial(contact.phone);
|
||||
})
|
||||
}
|
||||
|
||||
build() {
|
||||
HdsNavigation() {
|
||||
Scroll() {
|
||||
Column({ space: 14 }) {
|
||||
Text('点击卡片即可拨打电话')
|
||||
.fontSize(14)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
.width('100%')
|
||||
|
||||
// 家属(动态:绑定我的家属账号)
|
||||
if (this.familyContacts.length > 0) {
|
||||
Text('我的家属')
|
||||
.fontSize(15)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.width('100%')
|
||||
}
|
||||
ForEach(this.familyContacts, (contact: DialContact) => {
|
||||
this.ContactCard(contact)
|
||||
}, (contact: DialContact) => contact.phone)
|
||||
|
||||
// 常用电话
|
||||
Text('常用电话')
|
||||
.fontSize(15)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.width('100%')
|
||||
ForEach(CONTACTS, (contact: DialContact) => {
|
||||
this.ContactCard(contact)
|
||||
}, (contact: DialContact) => contact.phone)
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ left: 16, right: 16, top: 120, bottom: 30 })
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
.scrollBar(BarState.Auto)
|
||||
.edgeEffect(EdgeEffect.Spring)
|
||||
}
|
||||
.mode(NavigationMode.Stack)
|
||||
.titleBar({
|
||||
content: {
|
||||
title: {
|
||||
mainTitle: '快速拨号',
|
||||
},
|
||||
},
|
||||
style: {
|
||||
scrollEffectOpts: {
|
||||
enableScrollEffect: false,
|
||||
scrollEffectType: ScrollEffectType.GRADIENT_BLUR,
|
||||
},
|
||||
systemMaterialEffect: {
|
||||
materialType: hdsMaterial.MaterialType.ADAPTIVE,
|
||||
materialLevel: hdsMaterial.MaterialLevel.EXQUISITE,
|
||||
},
|
||||
},
|
||||
avoidLayoutSafeArea: false,
|
||||
enableComponentSafeArea: false,
|
||||
})
|
||||
.hideBackButton(false)
|
||||
.titleMode(HdsNavigationTitleMode.MINI)
|
||||
.ignoreLayoutSafeArea([LayoutSafeAreaType.SYSTEM], [LayoutSafeAreaEdge.TOP, LayoutSafeAreaEdge.BOTTOM])
|
||||
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
|
||||
.backgroundColor($r('app.color.background'))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
// 老人端 · 历史订餐(ArkTS 严格类型,无 any)
|
||||
// 数据来源:/elderly-portal/reservations/history/
|
||||
import { promptAction } from '@kit.ArkUI';
|
||||
import { HdsNavigation, HdsNavigationTitleMode, ScrollEffectType } from '@kit.UIDesignKit';
|
||||
import { hdsMaterial } from '@hms.hds.hdsMaterial';
|
||||
import { BusinessError } from '@kit.BasicServicesKit';
|
||||
import { normalizeError } from '../../common/ApiConstants';
|
||||
import { api } from '../../utils/Request';
|
||||
import { FontScaleLevel, FONT_SCALE_MAP } from '../../utils/FontScale';
|
||||
|
||||
interface MealHistoryItem {
|
||||
slot_id: string;
|
||||
service_date: string;
|
||||
category: string;
|
||||
menu: string;
|
||||
ordered: boolean;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
interface MealHistoryResponse {
|
||||
reservations: Array<MealHistoryItem>;
|
||||
}
|
||||
|
||||
@Component
|
||||
export struct ElderlyHistoryPanel {
|
||||
@StorageProp('fontScale') fontScaleLevel: FontScaleLevel = 'system';
|
||||
@State items: Array<MealHistoryItem> = [];
|
||||
@State loading: boolean = true;
|
||||
|
||||
private scroller: Scroller = new Scroller();
|
||||
|
||||
private scaleFor(): number {
|
||||
return FONT_SCALE_MAP[this.fontScaleLevel] ?? 1.0;
|
||||
}
|
||||
|
||||
aboutToAppear(): void {
|
||||
this.loadHistory();
|
||||
}
|
||||
|
||||
private async loadHistory(): Promise<void> {
|
||||
this.loading = true;
|
||||
try {
|
||||
const res = await api.get<MealHistoryResponse>('/elderly-portal/reservations/history/');
|
||||
this.items = res.reservations;
|
||||
} catch (err) {
|
||||
promptAction.showToast({ message: normalizeError(err as BusinessError | Error) });
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
@Builder
|
||||
HistoryCard(item: MealHistoryItem) {
|
||||
Column({ space: 8 }) {
|
||||
Row() {
|
||||
Text(item.category)
|
||||
.fontSize(Math.round(18 * this.scaleFor()))
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor($r('app.color.accent_blue'))
|
||||
Blank()
|
||||
Text(item.service_date)
|
||||
.fontSize(Math.round(14 * this.scaleFor()))
|
||||
.fontColor($r('app.color.inactive_tab'))
|
||||
}
|
||||
.width('100%')
|
||||
|
||||
if (item.menu && item.menu.length > 0) {
|
||||
Text(item.menu)
|
||||
.fontSize(Math.round(16 * this.scaleFor()))
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.width('100%')
|
||||
}
|
||||
|
||||
Text(item.ordered ? '已预订' : '已取消')
|
||||
.fontSize(Math.round(14 * this.scaleFor()))
|
||||
.fontColor('#FFFFFF')
|
||||
.padding({ left: 10, right: 10, top: 4, bottom: 4 })
|
||||
.backgroundColor(item.ordered ? $r('app.color.accent_green') : $r('app.color.inactive_tab'))
|
||||
.borderRadius(8)
|
||||
}
|
||||
.width('100%')
|
||||
.padding(18)
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.borderRadius(16)
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
}
|
||||
|
||||
build() {
|
||||
HdsNavigation() {
|
||||
Column() {
|
||||
if (this.loading) {
|
||||
Text('加载中...')
|
||||
.fontSize(Math.round(15 * this.scaleFor()))
|
||||
.fontColor($r('app.color.inactive_tab'))
|
||||
.margin({ top: 80 })
|
||||
} else if (this.items.length === 0) {
|
||||
Column({ space: 8 }) {
|
||||
Text('🍽️')
|
||||
.fontSize(40)
|
||||
Text('暂无订餐历史')
|
||||
.fontSize(Math.round(15 * this.scaleFor()))
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
}
|
||||
.width('100%')
|
||||
.margin({ top: 100 })
|
||||
.alignItems(HorizontalAlign.Center)
|
||||
} else {
|
||||
Scroll(this.scroller) {
|
||||
Column({ space: 10 }) {
|
||||
ForEach(this.items, (item: MealHistoryItem) => {
|
||||
this.HistoryCard(item)
|
||||
}, (item: MealHistoryItem) => `${item.slot_id}-${item.service_date}`)
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ left: 16, right: 16, top: 92, bottom: 24 })
|
||||
}
|
||||
.width('100%')
|
||||
.layoutWeight(1)
|
||||
.scrollBar(BarState.Auto)
|
||||
.edgeEffect(EdgeEffect.Spring)
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
}
|
||||
.mode(NavigationMode.Stack)
|
||||
.titleBar({
|
||||
content: {
|
||||
title: {
|
||||
mainTitle: '历史订餐',
|
||||
},
|
||||
},
|
||||
style: {
|
||||
scrollEffectOpts: {
|
||||
enableScrollEffect: false,
|
||||
scrollEffectType: ScrollEffectType.GRADIENT_BLUR,
|
||||
},
|
||||
systemMaterialEffect: {
|
||||
materialType: hdsMaterial.MaterialType.ADAPTIVE,
|
||||
materialLevel: hdsMaterial.MaterialLevel.EXQUISITE,
|
||||
},
|
||||
},
|
||||
avoidLayoutSafeArea: false,
|
||||
enableComponentSafeArea: false,
|
||||
})
|
||||
.bindToScrollable([this.scroller])
|
||||
.hideBackButton(false)
|
||||
.titleMode(HdsNavigationTitleMode.MINI)
|
||||
.ignoreLayoutSafeArea([LayoutSafeAreaType.SYSTEM], [LayoutSafeAreaEdge.TOP, LayoutSafeAreaEdge.BOTTOM])
|
||||
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
|
||||
.backgroundColor($r('app.color.background'))
|
||||
}
|
||||
}
|
||||
|
||||
@Entry
|
||||
@Component
|
||||
struct ElderlyHistory {
|
||||
build() {
|
||||
Column() {
|
||||
ElderlyHistoryPanel()
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
.backgroundColor($r('app.color.background'))
|
||||
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,205 @@
|
||||
// 关注对象(老人列表)页(ArkTS 严格类型,无 any)
|
||||
// 数据来源:GET /elderly/?page_size=
|
||||
import { router } from '@kit.ArkUI';
|
||||
import { promptAction } from '@kit.ArkUI';
|
||||
import { BusinessError } from '@kit.BasicServicesKit';
|
||||
import { HdsNavigation, HdsNavigationTitleMode, ScrollEffectType } from '@kit.UIDesignKit';
|
||||
import { hdsMaterial } from '@hms.hds.hdsMaterial';
|
||||
import { Page, normalizeError } from '../../common/ApiConstants';
|
||||
import { api } from '../../utils/Request';
|
||||
|
||||
interface ElderlyItem {
|
||||
id: number;
|
||||
name: string;
|
||||
gender: string;
|
||||
age: number | null;
|
||||
room_bed: string;
|
||||
floor_room_bed: string;
|
||||
care_level_label: string;
|
||||
overview_status: string;
|
||||
}
|
||||
|
||||
// 详情页路由参数
|
||||
interface DetailParams {
|
||||
id: string;
|
||||
}
|
||||
|
||||
function genderLabel(gender: string): string {
|
||||
if (gender === 'male') {
|
||||
return '男';
|
||||
}
|
||||
if (gender === 'female') {
|
||||
return '女';
|
||||
}
|
||||
return '未录入';
|
||||
}
|
||||
|
||||
@Entry
|
||||
@Component
|
||||
struct ElderlyList {
|
||||
@State elderly: Array<ElderlyItem> = [];
|
||||
@State loading: boolean = true;
|
||||
private scroller: Scroller = new Scroller();
|
||||
|
||||
aboutToAppear(): void {
|
||||
this.loadList();
|
||||
}
|
||||
|
||||
private async loadList(): Promise<void> {
|
||||
this.loading = true;
|
||||
try {
|
||||
const res = await api.get<Page<ElderlyItem>>('/elderly/', { page_size: 100 });
|
||||
this.elderly = res.results;
|
||||
} catch (err) {
|
||||
promptAction.showToast({ message: normalizeError(err as BusinessError | Error) });
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private openDetail(item: ElderlyItem): void {
|
||||
const params: DetailParams = { id: String(item.id) };
|
||||
router.pushUrl({
|
||||
url: 'pages/elderly/ElderlyDetail',
|
||||
params: params,
|
||||
}).catch((err: BusinessError) => {
|
||||
promptAction.showToast({ message: `页面打开失败:${normalizeError(err)}` });
|
||||
});
|
||||
}
|
||||
|
||||
@Builder
|
||||
ElderlyCard(item: ElderlyItem) {
|
||||
Row({ space: 12 }) {
|
||||
// 首字头像
|
||||
Column() {
|
||||
Text(item.name.length > 0 ? item.name.substring(0, 1) : '老')
|
||||
.fontSize(20)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor('#FFFFFF')
|
||||
}
|
||||
.width(52)
|
||||
.height(52)
|
||||
.justifyContent(FlexAlign.Center)
|
||||
.backgroundColor($r('app.color.accent_blue'))
|
||||
.borderRadius(26)
|
||||
|
||||
Column({ space: 6 }) {
|
||||
Row({ space: 8 }) {
|
||||
Text(item.name)
|
||||
.fontSize(18)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
if (item.overview_status && item.overview_status.length > 0) {
|
||||
Text(item.overview_status)
|
||||
.fontSize(12)
|
||||
.fontColor('#FFFFFF')
|
||||
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
|
||||
.backgroundColor($r('app.color.accent_green'))
|
||||
.borderRadius(6)
|
||||
}
|
||||
Blank()
|
||||
Text('›')
|
||||
.fontSize(20)
|
||||
.fontColor($r('app.color.inactive_tab'))
|
||||
}
|
||||
.width('100%')
|
||||
|
||||
Row({ space: 8 }) {
|
||||
Text(`${genderLabel(item.gender)}`)
|
||||
.fontSize(13)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
Text(`年龄:${item.age ?? '未录入'}`)
|
||||
.fontSize(13)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
Text(item.care_level_label && item.care_level_label.length > 0 ? item.care_level_label : '')
|
||||
.fontSize(13)
|
||||
.fontColor($r('app.color.accent_blue'))
|
||||
}
|
||||
.width('100%')
|
||||
|
||||
if (item.floor_room_bed && item.floor_room_bed.length > 0) {
|
||||
Text(item.floor_room_bed)
|
||||
.fontSize(13)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
.width('100%')
|
||||
}
|
||||
}
|
||||
.layoutWeight(1)
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
}
|
||||
.width('100%')
|
||||
.padding(14)
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.borderRadius(16)
|
||||
.alignItems(VerticalAlign.Center)
|
||||
.onClick(() => {
|
||||
this.openDetail(item);
|
||||
})
|
||||
}
|
||||
|
||||
build() {
|
||||
HdsNavigation() {
|
||||
Column() {
|
||||
if (this.loading) {
|
||||
Text('加载中...')
|
||||
.fontSize(15)
|
||||
.fontColor($r('app.color.inactive_tab'))
|
||||
.margin({ top: 60 })
|
||||
} else if (this.elderly.length === 0) {
|
||||
Column({ space: 8 }) {
|
||||
Text('👴')
|
||||
.fontSize(36)
|
||||
Text('暂无关注对象')
|
||||
.fontSize(16)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
}
|
||||
.width('100%')
|
||||
.margin({ top: 80 })
|
||||
.alignItems(HorizontalAlign.Center)
|
||||
} else {
|
||||
Scroll(this.scroller) {
|
||||
Column({ space: 12 }) {
|
||||
ForEach(this.elderly, (item: ElderlyItem) => {
|
||||
this.ElderlyCard(item)
|
||||
}, (item: ElderlyItem) => String(item.id))
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ left: 16, right: 16, bottom: 20 })
|
||||
}
|
||||
.scrollBar(BarState.Auto)
|
||||
.edgeEffect(EdgeEffect.Spring)
|
||||
.layoutWeight(1)
|
||||
.width('100%')
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
}
|
||||
.mode(NavigationMode.Stack)
|
||||
.titleBar({
|
||||
content: {
|
||||
title: {
|
||||
mainTitle: '关注对象',
|
||||
},
|
||||
},
|
||||
style: {
|
||||
scrollEffectOpts: {
|
||||
enableScrollEffect: false,
|
||||
scrollEffectType: ScrollEffectType.GRADIENT_BLUR,
|
||||
},
|
||||
systemMaterialEffect: {
|
||||
materialType: hdsMaterial.MaterialType.ADAPTIVE,
|
||||
materialLevel: hdsMaterial.MaterialLevel.EXQUISITE,
|
||||
},
|
||||
},
|
||||
avoidLayoutSafeArea: false,
|
||||
enableComponentSafeArea: false,
|
||||
})
|
||||
.bindToScrollable([this.scroller])
|
||||
.hideBackButton(false)
|
||||
.titleMode(HdsNavigationTitleMode.MINI)
|
||||
.ignoreLayoutSafeArea([LayoutSafeAreaType.SYSTEM], [LayoutSafeAreaEdge.TOP, LayoutSafeAreaEdge.BOTTOM])
|
||||
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
|
||||
.backgroundColor($r('app.color.background'))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
// 老人端 · 餐食详情(ArkTS 严格类型,无 any)
|
||||
// 数据来源:GET /elderly-portal/meals/{id}/?service_date=
|
||||
// 操作:POST /elderly-portal/meals/{id}/reservation/
|
||||
import { router } from '@kit.ArkUI';
|
||||
import { promptAction } from '@kit.ArkUI';
|
||||
import { BusinessError } from '@kit.BasicServicesKit';
|
||||
import { HdsNavigation, HdsNavigationTitleMode, ScrollEffectType } from '@kit.UIDesignKit';
|
||||
import { hdsMaterial } from '@hms.hds.hdsMaterial';
|
||||
import { normalizeError } from '../../common/ApiConstants';
|
||||
import { api, RequestError } from '../../utils/Request';
|
||||
|
||||
interface ElderlyMeal {
|
||||
id: string;
|
||||
name: string;
|
||||
time: string;
|
||||
menu: string;
|
||||
description: string;
|
||||
ordered: boolean;
|
||||
reservable?: boolean;
|
||||
reservation_hint?: string;
|
||||
type: string;
|
||||
service_date?: string;
|
||||
}
|
||||
|
||||
interface ReservationPayload {
|
||||
ordered: boolean;
|
||||
service_date?: string;
|
||||
}
|
||||
|
||||
function todayText(): string {
|
||||
const d = new Date();
|
||||
const mm = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const dd = String(d.getDate()).padStart(2, '0');
|
||||
return `${d.getFullYear()}-${mm}-${dd}`;
|
||||
}
|
||||
|
||||
// 校验日期是否为 YYYY-MM-DD 格式
|
||||
function isValidDate(date: string): boolean {
|
||||
const pattern = /^\d{4}-\d{2}-\d{2}$/;
|
||||
return pattern.test(date);
|
||||
}
|
||||
|
||||
@Entry
|
||||
@Component
|
||||
struct ElderlyMealDetail {
|
||||
@State meal: ElderlyMeal | null = null;
|
||||
@State loading: boolean = true;
|
||||
@State toggling: boolean = false;
|
||||
private mealId: string = '';
|
||||
private serviceDate: string = '';
|
||||
private scroller: Scroller = new Scroller();
|
||||
|
||||
aboutToAppear(): void {
|
||||
const params = router.getParams() as Record<string, string> | undefined;
|
||||
if (params) {
|
||||
this.mealId = params['mealId'] ?? '';
|
||||
this.serviceDate = params['serviceDate'] ?? '';
|
||||
}
|
||||
this.loadMeal();
|
||||
}
|
||||
|
||||
private async loadMeal(): Promise<void> {
|
||||
if (this.mealId.length === 0) {
|
||||
this.loading = false;
|
||||
return;
|
||||
}
|
||||
// 标准化 service_date 为 YYYY-MM-DD(截取前 10 位,避免带时间/空格)
|
||||
const normalized = this.serviceDate.length >= 10 ? this.serviceDate.substring(0, 10) : this.serviceDate;
|
||||
const finalDate = isValidDate(normalized) ? normalized : todayText();
|
||||
try {
|
||||
const url = `/elderly-portal/meals/${this.mealId}/?service_date=${finalDate}`;
|
||||
console.info(`[MealDetail] request url=${url} rawServiceDate=${this.serviceDate} normalized=${normalized}`);
|
||||
this.meal = await api.get<ElderlyMeal>(url);
|
||||
} catch (err) {
|
||||
promptAction.showToast({ message: normalizeError(err as BusinessError | Error) });
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async toggleOrder(): Promise<void> {
|
||||
const meal = this.meal;
|
||||
if (!meal || this.toggling) {
|
||||
return;
|
||||
}
|
||||
if (meal.reservable === false) {
|
||||
promptAction.showToast({ message: meal.reservation_hint || '当前餐次不可操作' });
|
||||
return;
|
||||
}
|
||||
this.toggling = true;
|
||||
try {
|
||||
const payload: ReservationPayload = { ordered: !meal.ordered };
|
||||
if (this.serviceDate.length > 0) {
|
||||
payload.service_date = this.serviceDate;
|
||||
}
|
||||
const updated = await api.post<ElderlyMeal>(`/elderly-portal/meals/${meal.id}/reservation/`, payload);
|
||||
this.meal = updated;
|
||||
promptAction.showToast({ message: updated.ordered ? '预订成功' : '已取消预订' });
|
||||
} catch (err) {
|
||||
const message = err instanceof RequestError ? err.message : normalizeError(err as BusinessError | Error);
|
||||
promptAction.showToast({ message: message });
|
||||
} finally {
|
||||
this.toggling = false;
|
||||
}
|
||||
}
|
||||
|
||||
private statusLabel(): string {
|
||||
const meal = this.meal;
|
||||
if (!meal) {
|
||||
return '';
|
||||
}
|
||||
if (meal.ordered) {
|
||||
return '已预订';
|
||||
}
|
||||
if (meal.reservable === false) {
|
||||
return '不可预订';
|
||||
}
|
||||
return '未预订';
|
||||
}
|
||||
|
||||
@Builder
|
||||
InfoRow(label: string, value: string) {
|
||||
Row({ space: 12 }) {
|
||||
Text(label)
|
||||
.fontSize(18)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
.width(90)
|
||||
Text(value && value.length > 0 ? value : '--')
|
||||
.fontSize(18)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.layoutWeight(1)
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(VerticalAlign.Top)
|
||||
}
|
||||
|
||||
build() {
|
||||
HdsNavigation() {
|
||||
Column() {
|
||||
if (this.loading) {
|
||||
Text('加载中...')
|
||||
.fontSize(16)
|
||||
.fontColor($r('app.color.inactive_tab'))
|
||||
.margin({ top: 60 })
|
||||
} else if (!this.meal) {
|
||||
Text('未找到该餐次信息')
|
||||
.fontSize(16)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
.margin({ top: 60 })
|
||||
} else {
|
||||
Scroll(this.scroller) {
|
||||
Column({ space: 16 }) {
|
||||
Text(this.meal.name)
|
||||
.fontSize(30)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.width('100%')
|
||||
|
||||
Column({ space: 14 }) {
|
||||
this.InfoRow('用餐时间', this.meal.time)
|
||||
this.InfoRow('服务日期', this.meal.service_date ?? this.serviceDate)
|
||||
this.InfoRow('菜单', this.meal.menu)
|
||||
this.InfoRow('说明', this.meal.description)
|
||||
}
|
||||
.width('100%')
|
||||
.padding(20)
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.borderRadius(16)
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
|
||||
if (this.meal.reservation_hint && this.meal.reservation_hint.length > 0) {
|
||||
Text(this.meal.reservation_hint)
|
||||
.fontSize(15)
|
||||
.fontColor('#FF9500')
|
||||
.width('100%')
|
||||
}
|
||||
|
||||
Row({ space: 10 }) {
|
||||
Text(this.statusLabel())
|
||||
.fontSize(18)
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor(this.meal.ordered ? $r('app.color.accent_green') : $r('app.color.text_secondary'))
|
||||
Blank()
|
||||
Button(this.toggling ? '处理中...' : (this.meal.ordered ? '取消预订' : '立即预订'))
|
||||
.height(52)
|
||||
.fontSize(20)
|
||||
.fontColor('#FFFFFF')
|
||||
.backgroundColor(this.meal.ordered ? $r('app.color.accent_red') : $r('app.color.accent_green'))
|
||||
.borderRadius(14)
|
||||
.enabled(!this.toggling)
|
||||
.onClick(() => {
|
||||
this.toggleOrder();
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
.padding(20)
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.borderRadius(16)
|
||||
}
|
||||
.width('92%')
|
||||
.padding({ top: 60, bottom: 30 })
|
||||
.alignItems(HorizontalAlign.Center)
|
||||
}
|
||||
.scrollBar(BarState.Auto)
|
||||
.edgeEffect(EdgeEffect.Spring)
|
||||
.layoutWeight(1)
|
||||
.width('100%')
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
}
|
||||
.mode(NavigationMode.Stack)
|
||||
.titleBar({
|
||||
content: {
|
||||
title: {
|
||||
mainTitle: '餐食详情',
|
||||
},
|
||||
},
|
||||
style: {
|
||||
scrollEffectOpts: {
|
||||
enableScrollEffect: false,
|
||||
scrollEffectType: ScrollEffectType.GRADIENT_BLUR,
|
||||
},
|
||||
systemMaterialEffect: {
|
||||
materialType: hdsMaterial.MaterialType.ADAPTIVE,
|
||||
materialLevel: hdsMaterial.MaterialLevel.EXQUISITE,
|
||||
},
|
||||
},
|
||||
avoidLayoutSafeArea: false,
|
||||
enableComponentSafeArea: false,
|
||||
})
|
||||
.bindToScrollable([this.scroller])
|
||||
.hideBackButton(false)
|
||||
.titleMode(HdsNavigationTitleMode.MINI)
|
||||
.ignoreLayoutSafeArea([LayoutSafeAreaType.SYSTEM], [LayoutSafeAreaEdge.TOP, LayoutSafeAreaEdge.BOTTOM])
|
||||
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
|
||||
.backgroundColor($r('app.color.background'))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,546 @@
|
||||
// 老人端 · 餐饮服务(定制餐点单)(ArkTS 严格类型,无 any)
|
||||
// 接口(与 Web 管理端/小程序一致):
|
||||
// GET /meals/custom-menu/?service_date=&category=
|
||||
// GET /meals/custom-orders/
|
||||
// POST /meals/custom-orders/
|
||||
// POST /meals/custom-orders/{id}/cancel/
|
||||
import { router } from '@kit.ArkUI';
|
||||
import { InsightIntentPage } from '@kit.AbilityKit';
|
||||
import { promptAction } from '@kit.ArkUI';
|
||||
import { HdsNavigation, HdsNavigationTitleMode, ScrollEffectType } from '@kit.UIDesignKit';
|
||||
import { hdsMaterial } from '@hms.hds.hdsMaterial';
|
||||
import { BusinessError } from '@kit.BasicServicesKit';
|
||||
import { normalizeError } from '../../common/ApiConstants';
|
||||
import { api, EmptyBody, RequestError } from '../../utils/Request';
|
||||
import { FontScaleLevel, FONT_SCALE_MAP } from '../../utils/FontScale';
|
||||
|
||||
type MealCategory = string; // '早餐' | '午餐' | '晚餐'
|
||||
|
||||
interface StandardMenuItem {
|
||||
id: number;
|
||||
name: string;
|
||||
dish_type: string;
|
||||
}
|
||||
|
||||
interface CustomMenuItem {
|
||||
id: number;
|
||||
source: string; // 'menu' | 'recommendation'
|
||||
name: string;
|
||||
price: string;
|
||||
dish_type: string;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
interface CustomMenuResponse {
|
||||
standard_menu: Array<StandardMenuItem>;
|
||||
custom_menu: Array<CustomMenuItem>;
|
||||
}
|
||||
|
||||
interface OrderItemBrief {
|
||||
dish_name: string;
|
||||
quantity: number;
|
||||
}
|
||||
|
||||
interface CustomOrder {
|
||||
id: number;
|
||||
service_date: string;
|
||||
category: string;
|
||||
status: string;
|
||||
items: Array<OrderItemBrief>;
|
||||
}
|
||||
|
||||
interface CustomOrdersResponse {
|
||||
orders: Array<CustomOrder>;
|
||||
}
|
||||
|
||||
function customOrderStatusLabel(status: string): string {
|
||||
if (status === 'active') {
|
||||
return '有效';
|
||||
}
|
||||
if (status === 'cancelled') {
|
||||
return '已取消';
|
||||
}
|
||||
return status;
|
||||
}
|
||||
|
||||
function customOrderStatusColor(status: string): string {
|
||||
if (status === 'active') {
|
||||
return '#34C759';
|
||||
}
|
||||
if (status === 'cancelled') {
|
||||
return '#8E8E93';
|
||||
}
|
||||
return '#8E8E93';
|
||||
}
|
||||
|
||||
|
||||
interface OrderItemPayload {
|
||||
menu_item_id?: number;
|
||||
recommendation_id?: number;
|
||||
quantity: number;
|
||||
}
|
||||
|
||||
interface CreateOrderPayload {
|
||||
service_date: string;
|
||||
category: string;
|
||||
items: Array<OrderItemPayload>;
|
||||
}
|
||||
|
||||
interface DishKeyItem {
|
||||
item: CustomMenuItem;
|
||||
key: string;
|
||||
}
|
||||
|
||||
const CATEGORIES: Array<string> = ['早餐', '午餐', '晚餐'];
|
||||
|
||||
function tomorrowText(): string {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() + 1);
|
||||
const mm = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const dd = String(d.getDate()).padStart(2, '0');
|
||||
return `${d.getFullYear()}-${mm}-${dd}`;
|
||||
}
|
||||
|
||||
function dishKey(item: CustomMenuItem): string {
|
||||
return `${item.source}:${item.id}`;
|
||||
}
|
||||
|
||||
function categoryIcon(category: string): string {
|
||||
if (category === '早餐') {
|
||||
return '🌅';
|
||||
}
|
||||
if (category === '午餐') {
|
||||
return '🍚';
|
||||
}
|
||||
return '🌙';
|
||||
}
|
||||
|
||||
@InsightIntentPage({
|
||||
intentName: 'ViewMealOrder',
|
||||
domain: 'ElderlyCare',
|
||||
intentVersion: '1.0.0',
|
||||
displayName: '查看我的订餐',
|
||||
displayDescription: '打开智慧护老的订餐页,查看今日和明日的菜单并预订',
|
||||
uiAbility: 'EntryAbility',
|
||||
pagePath: 'ets/pages/elderly/ElderlyMealOrder',
|
||||
keywords: ['订餐', '点餐', '吃饭', '菜单', '午餐', '晚餐', '早餐'],
|
||||
llmDescription: '查看养老院长者的今日订餐与菜单,并可预订或取消餐食',
|
||||
})
|
||||
@Entry
|
||||
@Component
|
||||
struct ElderlyMealOrder {
|
||||
@StorageProp('fontScale') fontScaleLevel: FontScaleLevel = 'system';
|
||||
@State serviceDate: string = tomorrowText();
|
||||
@State category: string = '午餐';
|
||||
@State customMenu: Array<CustomMenuItem> = [];
|
||||
@State standardMenu: Array<StandardMenuItem> = [];
|
||||
@State selectedKeys: Array<string> = [];
|
||||
@State orders: Array<CustomOrder> = [];
|
||||
@State loading: boolean = true;
|
||||
@State submitting: boolean = false;
|
||||
|
||||
private scroller: Scroller = new Scroller();
|
||||
|
||||
private scaleFor(): number {
|
||||
return FONT_SCALE_MAP[this.fontScaleLevel] ?? 1.0;
|
||||
}
|
||||
|
||||
aboutToAppear(): void {
|
||||
this.load();
|
||||
}
|
||||
|
||||
private async load(): Promise<void> {
|
||||
this.loading = true;
|
||||
try {
|
||||
const responses: Array<CustomMenuResponse | CustomOrdersResponse | null> = await Promise.all([
|
||||
api.get<CustomMenuResponse>('/meals/custom-menu/', {
|
||||
service_date: this.serviceDate,
|
||||
category: this.category,
|
||||
}).catch((): CustomMenuResponse | null => null),
|
||||
api.get<CustomOrdersResponse>('/meals/custom-orders/').catch((): CustomOrdersResponse | null => null),
|
||||
]);
|
||||
const menuRes = responses[0] as CustomMenuResponse | null;
|
||||
const orderRes = responses[1] as CustomOrdersResponse | null;
|
||||
this.standardMenu = menuRes ? menuRes.standard_menu : [];
|
||||
this.customMenu = menuRes ? menuRes.custom_menu : [];
|
||||
this.orders = orderRes ? orderRes.orders : [];
|
||||
} catch (err) {
|
||||
promptAction.showToast({ message: normalizeError(err as BusinessError | Error) });
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private personalRecommendations(): Array<CustomMenuItem> {
|
||||
return this.customMenu.filter((item: CustomMenuItem) => item.source === 'recommendation');
|
||||
}
|
||||
|
||||
private selectableDishes(): Array<CustomMenuItem> {
|
||||
return this.customMenu.filter((item: CustomMenuItem) => item.source === 'menu');
|
||||
}
|
||||
|
||||
private isSelected(item: CustomMenuItem): boolean {
|
||||
return this.selectedKeys.indexOf(dishKey(item)) >= 0;
|
||||
}
|
||||
|
||||
private toggleDish(item: CustomMenuItem): void {
|
||||
const key = dishKey(item);
|
||||
if (this.selectedKeys.indexOf(key) >= 0) {
|
||||
this.selectedKeys = this.selectedKeys.filter((value: string) => value !== key);
|
||||
} else {
|
||||
this.selectedKeys = this.selectedKeys.concat([key]);
|
||||
}
|
||||
}
|
||||
|
||||
private submitBtnText(): string {
|
||||
if (this.submitting) {
|
||||
return '提交中...';
|
||||
}
|
||||
if (this.selectedKeys.length === 0) {
|
||||
return '请先选择菜品';
|
||||
}
|
||||
if (this.scaleFor() >= 1.5) {
|
||||
return `提交定制餐(${this.selectedKeys.length})`;
|
||||
}
|
||||
return `提交定制餐(已选${this.selectedKeys.length} 项)`;
|
||||
}
|
||||
|
||||
private selectedDishes(): Array<CustomMenuItem> {
|
||||
return this.customMenu.filter((item: CustomMenuItem) => this.selectedKeys.indexOf(dishKey(item)) >= 0);
|
||||
}
|
||||
|
||||
private async createOrder(): Promise<void> {
|
||||
const selected = this.selectedDishes();
|
||||
if (selected.length === 0 || this.submitting) {
|
||||
return;
|
||||
}
|
||||
this.submitting = true;
|
||||
try {
|
||||
const items: Array<OrderItemPayload> = selected.map((item: CustomMenuItem): OrderItemPayload => {
|
||||
const payload: OrderItemPayload = { quantity: 1 };
|
||||
if (item.source === 'recommendation') {
|
||||
payload.recommendation_id = item.id;
|
||||
} else {
|
||||
payload.menu_item_id = item.id;
|
||||
}
|
||||
return payload;
|
||||
});
|
||||
const body: CreateOrderPayload = {
|
||||
service_date: this.serviceDate,
|
||||
category: this.category,
|
||||
items: items,
|
||||
};
|
||||
await api.post('/meals/custom-orders/', body);
|
||||
this.selectedKeys = [];
|
||||
promptAction.showToast({ message: '已提交定制餐' });
|
||||
await this.load();
|
||||
} catch (err) {
|
||||
const message = err instanceof RequestError ? err.message : normalizeError(err as BusinessError | Error);
|
||||
promptAction.showToast({ message: message });
|
||||
} finally {
|
||||
this.submitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async cancelOrder(orderId: number): Promise<void> {
|
||||
if (this.submitting) {
|
||||
return;
|
||||
}
|
||||
this.submitting = true;
|
||||
try {
|
||||
await api.post(`/meals/custom-orders/${orderId}/cancel/`, new EmptyBody());
|
||||
promptAction.showToast({ message: '已取消定制餐' });
|
||||
await this.load();
|
||||
} catch (err) {
|
||||
const message = err instanceof RequestError ? err.message : normalizeError(err as BusinessError | Error);
|
||||
promptAction.showToast({ message: message });
|
||||
} finally {
|
||||
this.submitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
@Builder
|
||||
CategoryTab(category: string) {
|
||||
Column({ space: 4 }) {
|
||||
Text(categoryIcon(category))
|
||||
.fontSize(Math.round(22 * this.scaleFor()))
|
||||
Text(category)
|
||||
.fontSize(Math.round(16 * this.scaleFor()))
|
||||
.fontWeight(this.category === category ? FontWeight.Bold : FontWeight.Normal)
|
||||
.fontColor(this.category === category ? $r('app.color.accent_blue') : $r('app.color.text_secondary'))
|
||||
}
|
||||
.layoutWeight(1)
|
||||
.padding({ top: 10, bottom: 10 })
|
||||
.backgroundColor(this.category === category ? '#14007AFF' : Color.Transparent)
|
||||
.borderRadius(12)
|
||||
.onClick(() => {
|
||||
if (this.category !== category) {
|
||||
this.category = category;
|
||||
this.selectedKeys = [];
|
||||
this.load();
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@Builder
|
||||
DishCard(item: CustomMenuItem) {
|
||||
Row({ space: 10 }) {
|
||||
Column({ space: 4 }) {
|
||||
Text(item.name)
|
||||
.fontSize(Math.round(17 * this.scaleFor()))
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.maxLines(1)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
Row({ space: 6 }) {
|
||||
if (item.price && item.price.length > 0) {
|
||||
Text(`¥${item.price}`)
|
||||
.fontSize(Math.round(14 * this.scaleFor()))
|
||||
.fontColor('#FF9500')
|
||||
}
|
||||
if (item.source === 'recommendation' && item.reason && item.reason.length > 0) {
|
||||
Text(item.reason)
|
||||
.fontSize(Math.round(12 * this.scaleFor()))
|
||||
.fontColor($r('app.color.accent_blue'))
|
||||
.maxLines(1)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
.layoutWeight(1)
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
}
|
||||
.layoutWeight(1)
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
|
||||
Text(this.isSelected(item) ? '✓' : '+')
|
||||
.fontSize(Math.round(18 * this.scaleFor()))
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor('#FFFFFF')
|
||||
.width(32)
|
||||
.height(32)
|
||||
.textAlign(TextAlign.Center)
|
||||
.backgroundColor(this.isSelected(item) ? $r('app.color.accent_green') : $r('app.color.inactive_tab'))
|
||||
.borderRadius(16)
|
||||
}
|
||||
.width('100%')
|
||||
.padding(14)
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.borderRadius(14)
|
||||
.border({
|
||||
width: 2,
|
||||
color: this.isSelected(item) ? $r('app.color.accent_green') : Color.Transparent
|
||||
})
|
||||
.onClick(() => {
|
||||
this.toggleDish(item);
|
||||
})
|
||||
}
|
||||
|
||||
@Builder
|
||||
OrderCard(order: CustomOrder) {
|
||||
Column({ space: 8 }) {
|
||||
Row() {
|
||||
Text(`${order.category} · ${order.service_date}`)
|
||||
.fontSize(Math.round(16 * this.scaleFor()))
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
Blank()
|
||||
Text(customOrderStatusLabel(order.status))
|
||||
.fontSize(Math.round(13 * this.scaleFor()))
|
||||
.fontColor(customOrderStatusColor(order.status))
|
||||
}
|
||||
.width('100%')
|
||||
|
||||
Text(order.items.map((item: OrderItemBrief) => `${item.dish_name}×${item.quantity}`).join('、'))
|
||||
.fontSize(Math.round(14 * this.scaleFor()))
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
.width('100%')
|
||||
|
||||
if (order.status === 'active') {
|
||||
Button('取消定制餐')
|
||||
.height(32)
|
||||
.fontSize(Math.round(13 * this.scaleFor()))
|
||||
.fontColor('#FFFFFF')
|
||||
.backgroundColor($r('app.color.accent_red'))
|
||||
.borderRadius(10)
|
||||
.onClick(() => {
|
||||
this.cancelOrder(order.id);
|
||||
})
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.padding(14)
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.borderRadius(14)
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
}
|
||||
|
||||
build() {
|
||||
HdsNavigation() {
|
||||
Column() {
|
||||
Scroll(this.scroller) {
|
||||
Column({ space: 10 }) {
|
||||
// 日期 + 餐次切换
|
||||
Column({ space: 10 }) {
|
||||
Text(`服务日期${this.serviceDate}(明日)`)
|
||||
.fontSize(Math.round(15 * this.scaleFor()))
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
.width('100%')
|
||||
|
||||
Row({ space: 8 }) {
|
||||
ForEach(CATEGORIES, (category: string) => {
|
||||
this.CategoryTab(category)
|
||||
}, (category: string) => category)
|
||||
}
|
||||
.width('100%')
|
||||
}
|
||||
.width('100%')
|
||||
.padding(14)
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.borderRadius(16)
|
||||
|
||||
if (this.loading) {
|
||||
Text('加载中...')
|
||||
.fontSize(Math.round(15 * this.scaleFor()))
|
||||
.fontColor($r('app.color.inactive_tab'))
|
||||
.width('100%')
|
||||
.padding({ top: 40, bottom: 40 })
|
||||
.textAlign(TextAlign.Center)
|
||||
} else {
|
||||
// 个人推荐
|
||||
if (this.personalRecommendations().length > 0) {
|
||||
Text('为您推荐')
|
||||
.fontSize(Math.round(19 * this.scaleFor()))
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.width('100%')
|
||||
ForEach(this.personalRecommendations(), (item: CustomMenuItem) => {
|
||||
this.DishCard(item)
|
||||
}, (item: CustomMenuItem) => dishKey(item))
|
||||
}
|
||||
|
||||
// 标准菜单(只读)
|
||||
if (this.standardMenu.length > 0) {
|
||||
Text('标准菜单')
|
||||
.fontSize(Math.round(19 * this.scaleFor()))
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.width('100%')
|
||||
ForEach(this.standardMenu, (item: StandardMenuItem) => {
|
||||
Row({ space: 10 }) {
|
||||
Text(item.name)
|
||||
.fontSize(Math.round(16 * this.scaleFor()))
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.layoutWeight(1)
|
||||
.maxLines(1)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
if (item.dish_type && item.dish_type.length > 0) {
|
||||
Text(item.dish_type)
|
||||
.fontSize(Math.round(12 * this.scaleFor()))
|
||||
.fontColor($r('app.color.inactive_tab'))
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.padding(14)
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.borderRadius(12)
|
||||
}, (item: StandardMenuItem) => String(item.id))
|
||||
}
|
||||
|
||||
// 可选菜品(可定制加菜)
|
||||
Text('可选定制菜')
|
||||
.fontSize(Math.round(19 * this.scaleFor()))
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.width('100%')
|
||||
|
||||
if (this.selectableDishes().length === 0) {
|
||||
Text('暂无可选定制菜')
|
||||
.fontSize(Math.round(15 * this.scaleFor()))
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
.width('100%')
|
||||
.padding(16)
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.borderRadius(14)
|
||||
.textAlign(TextAlign.Center)
|
||||
} else {
|
||||
ForEach(this.selectableDishes(), (item: CustomMenuItem) => {
|
||||
this.DishCard(item)
|
||||
}, (item: CustomMenuItem) => dishKey(item))
|
||||
}
|
||||
|
||||
// 提交
|
||||
Button(this.submitBtnText())
|
||||
.width('100%')
|
||||
.height(52)
|
||||
.fontSize(Math.round(18 * this.scaleFor()))
|
||||
.fontColor('#FFFFFF')
|
||||
.backgroundColor(this.selectedKeys.length > 0 ? $r('app.color.accent_green') : $r('app.color.inactive_tab'))
|
||||
.borderRadius(14)
|
||||
.enabled(this.selectedKeys.length > 0 && !this.submitting)
|
||||
.onClick(() => {
|
||||
this.createOrder();
|
||||
})
|
||||
|
||||
// 我的定制餐
|
||||
Text('我的定制餐')
|
||||
.fontSize(Math.round(19 * this.scaleFor()))
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.width('100%')
|
||||
|
||||
if (this.orders.length === 0) {
|
||||
Text('暂无定制餐订单')
|
||||
.fontSize(Math.round(15 * this.scaleFor()))
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
.width('100%')
|
||||
.padding(16)
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.borderRadius(14)
|
||||
.textAlign(TextAlign.Center)
|
||||
} else {
|
||||
ForEach(this.orders, (order: CustomOrder) => {
|
||||
this.OrderCard(order)
|
||||
}, (order: CustomOrder) => String(order.id))
|
||||
}
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ left: 16, right: 16, top: 92, bottom: 24 })
|
||||
}
|
||||
.width('100%')
|
||||
.layoutWeight(1)
|
||||
.scrollBar(BarState.Auto)
|
||||
.edgeEffect(EdgeEffect.Spring)
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
}
|
||||
.mode(NavigationMode.Stack)
|
||||
.titleBar({
|
||||
content: {
|
||||
title: {
|
||||
mainTitle: '餐饮服务',
|
||||
},
|
||||
},
|
||||
style: {
|
||||
scrollEffectOpts: {
|
||||
enableScrollEffect: false,
|
||||
scrollEffectType: ScrollEffectType.GRADIENT_BLUR,
|
||||
},
|
||||
systemMaterialEffect: {
|
||||
materialType: hdsMaterial.MaterialType.ADAPTIVE,
|
||||
materialLevel: hdsMaterial.MaterialLevel.EXQUISITE,
|
||||
},
|
||||
},
|
||||
avoidLayoutSafeArea: false,
|
||||
enableComponentSafeArea: false,
|
||||
})
|
||||
.bindToScrollable([this.scroller])
|
||||
.hideBackButton(false)
|
||||
.titleMode(HdsNavigationTitleMode.MINI)
|
||||
.ignoreLayoutSafeArea([LayoutSafeAreaType.SYSTEM], [LayoutSafeAreaEdge.TOP, LayoutSafeAreaEdge.BOTTOM])
|
||||
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
|
||||
.backgroundColor($r('app.color.background'))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
// 老人端 · 我的面板(ArkTS 严格类型,无 any)
|
||||
// 对齐小程序 ElderlyProfilePanel:账号卡 + 菜单 + 退出登录
|
||||
import { router } from '@kit.ArkUI';
|
||||
import { promptAction } from '@kit.ArkUI';
|
||||
import { common } from '@kit.AbilityKit';
|
||||
import { BusinessError } from '@kit.BasicServicesKit';
|
||||
import { AuthStore } from '../../utils/AuthStore';
|
||||
import { normalizeError } from '../../common/ApiConstants';
|
||||
import { api, RequestError } from '../../utils/Request';
|
||||
import { FontScaleLevel, FontScaleOption, FONT_SCALE_LABELS, FONT_SCALE_MAP, loadFontScale, setFontScale } from '../../utils/FontScale';
|
||||
|
||||
interface ProfileDashboard {
|
||||
elderly_id: number | null;
|
||||
elderly_name: string;
|
||||
service_date: string;
|
||||
}
|
||||
|
||||
interface ProfileMenu {
|
||||
key: string;
|
||||
title: string;
|
||||
desc: string;
|
||||
route: string; // 空字符串表示未实现
|
||||
}
|
||||
|
||||
@Component
|
||||
export struct ElderlyProfilePanel {
|
||||
@State userName: string = '';
|
||||
@State userId: number = 0;
|
||||
@State elderlyName: string = '';
|
||||
@State fontScale: FontScaleLevel = 'normal';
|
||||
@StorageProp('fontScale') fontScaleLevel: FontScaleLevel = 'system';
|
||||
|
||||
private scaleFor(): number {
|
||||
return FONT_SCALE_MAP[this.fontScaleLevel] ?? 1.0;
|
||||
}
|
||||
|
||||
menus: Array<ProfileMenu> = [
|
||||
{ key: 'meal', title: '餐饮服务', desc: '查看并选择定制餐', route: 'pages/elderly/ElderlyMealOrder' },
|
||||
{ key: 'activities', title: '活动安排', desc: '查看今天和本周的社工活动', route: 'pages/elderly/Activities' },
|
||||
{ key: 'notifications', title: '消息通知', desc: '查看护理记录和订餐状态消息', route: 'pages/notifications/Notifications' },
|
||||
{ key: 'history', title: '历史订餐', desc: '查看过往和近期的订餐记录', route: 'pages/elderly/ElderlyHistory' },
|
||||
|
||||
];
|
||||
|
||||
aboutToAppear(): void {
|
||||
this.loadAuth();
|
||||
this.loadDashboard();
|
||||
loadFontScale(getContext(this) as common.UIAbilityContext).then((level: FontScaleLevel) => {
|
||||
this.fontScale = level;
|
||||
});
|
||||
}
|
||||
|
||||
private async loadDashboard(): Promise<void> {
|
||||
try {
|
||||
const res = await api.get<ProfileDashboard>('/elderly-portal/dashboard/');
|
||||
this.elderlyName = res.elderly_name || '';
|
||||
} catch (err) {
|
||||
console.warn('[ElderlyProfile] load dashboard failed:', normalizeError(err as BusinessError | Error));
|
||||
}
|
||||
}
|
||||
|
||||
private changeFontScale(level: FontScaleLevel): void {
|
||||
this.fontScale = level;
|
||||
setFontScale(getContext(this) as common.UIAbilityContext, level);
|
||||
promptAction.showToast({ message: `字体已调整为${FONT_SCALE_LABELS.find((item) => item.level === level)?.label ?? ''}` });
|
||||
}
|
||||
|
||||
private async loadAuth(): Promise<void> {
|
||||
const state = await AuthStore.getState();
|
||||
this.userName = state.userName;
|
||||
this.userId = state.userId;
|
||||
}
|
||||
|
||||
private onMenuClick(menu: ProfileMenu): void {
|
||||
if (menu.route.length === 0) {
|
||||
promptAction.showToast({ message: '功能开发中' });
|
||||
return;
|
||||
}
|
||||
router.pushUrl({ url: menu.route }).catch(() => {});
|
||||
}
|
||||
|
||||
private async logout(): Promise<void> {
|
||||
await AuthStore.logout();
|
||||
promptAction.showToast({ message: '已退出登录' });
|
||||
router.replaceUrl({ url: 'pages/Login' }).catch(() => {});
|
||||
}
|
||||
|
||||
@Builder
|
||||
MenuRow(menu: ProfileMenu) {
|
||||
Row({ space: 12 }) {
|
||||
Column({ space: 6 }) {
|
||||
Text(menu.title)
|
||||
.fontSize(Math.round(17 * this.scaleFor()))
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
Text(menu.desc)
|
||||
.fontSize(Math.round(15 * this.scaleFor()))
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
}
|
||||
.layoutWeight(1)
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
|
||||
Text('›')
|
||||
.fontSize(Math.round(22 * this.scaleFor()))
|
||||
.fontColor($r('app.color.inactive_tab'))
|
||||
}
|
||||
.width('100%')
|
||||
.padding(14)
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.borderRadius(14)
|
||||
.onClick(() => {
|
||||
this.onMenuClick(menu);
|
||||
})
|
||||
}
|
||||
|
||||
build() {
|
||||
Scroll() {
|
||||
Column({ space: 12 }) {
|
||||
// 用户信息卡
|
||||
Column({ space: 8 }) {
|
||||
Stack() {
|
||||
Circle()
|
||||
.width(64)
|
||||
.height(64)
|
||||
.fill($r('app.color.accent_blue'))
|
||||
Text((this.elderlyName || this.userName || '客').substring(0, 1))
|
||||
.fontSize(Math.round(26 * this.scaleFor()))
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor('#FFFFFF')
|
||||
}
|
||||
.width(64)
|
||||
.height(64)
|
||||
|
||||
Text(this.elderlyName || this.userName || '用户')
|
||||
.fontSize(Math.round(22 * this.scaleFor()))
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
|
||||
Text(this.elderlyName.length > 0 ? '长者账号' : '账号')
|
||||
.fontSize(Math.round(13 * this.scaleFor()))
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
}
|
||||
.width('92%')
|
||||
.padding({ top: 16, bottom: 14 })
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.borderRadius(16)
|
||||
.margin({ top: 16 })
|
||||
|
||||
// 账号编号
|
||||
Row() {
|
||||
Text('账号编号')
|
||||
.fontSize(Math.round(15 * this.scaleFor()))
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
Blank()
|
||||
Text(this.userId > 0 ? String(this.userId) : '--')
|
||||
.fontSize(Math.round(15 * this.scaleFor()))
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
}
|
||||
.width('92%')
|
||||
.padding(14)
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.borderRadius(14)
|
||||
|
||||
// 字体大小
|
||||
Column({ space: 8 }) {
|
||||
Text('字体大小')
|
||||
.fontSize(Math.round(15 * this.scaleFor()))
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.width('100%')
|
||||
Flex({ wrap: FlexWrap.Wrap, alignItems: ItemAlign.Center }) {
|
||||
ForEach(FONT_SCALE_LABELS, (item: FontScaleOption) => {
|
||||
Text(item.label)
|
||||
.fontSize(Math.round(15 * this.scaleFor()))
|
||||
.fontColor(this.fontScale === item.level ? '#FFFFFF' : $r('app.color.text_primary'))
|
||||
.padding({ left: 18, right: 18, top: 10, bottom: 10 })
|
||||
.backgroundColor(this.fontScale === item.level ? $r('app.color.accent_blue') : $r('app.color.background_card'))
|
||||
.borderRadius(10)
|
||||
.margin({ right: 10, bottom: 10 })
|
||||
.onClick(() => {
|
||||
this.changeFontScale(item.level);
|
||||
})
|
||||
}, (item: FontScaleOption) => item.level)
|
||||
}
|
||||
.width('100%')
|
||||
}
|
||||
.width('92%')
|
||||
.padding(14)
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.borderRadius(14)
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
|
||||
// 菜单
|
||||
Column({ space: 8 }) {
|
||||
ForEach(this.menus, (menu: ProfileMenu) => {
|
||||
this.MenuRow(menu)
|
||||
}, (menu: ProfileMenu) => menu.key)
|
||||
}
|
||||
.width('92%')
|
||||
|
||||
// 退出登录
|
||||
Text('退出登录')
|
||||
.fontSize(Math.round(17 * this.scaleFor()))
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor($r('app.color.accent_red'))
|
||||
.textAlign(TextAlign.Center)
|
||||
.width('92%')
|
||||
.padding(14)
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.borderRadius(14)
|
||||
// 避让底部悬浮 Tab
|
||||
.margin({ bottom: 130 })
|
||||
|
||||
.onClick(() => {
|
||||
this.logout();
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(HorizontalAlign.Center)
|
||||
}
|
||||
.scrollBar(BarState.Auto)
|
||||
.edgeEffect(EdgeEffect.Spring)
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
// 老人端 · 膳食推荐(ArkTS 严格类型,无 any)
|
||||
// 接口:GET /elderly-portal/recommendations/
|
||||
import { router } from '@kit.ArkUI';
|
||||
import { promptAction } from '@kit.ArkUI';
|
||||
import { HdsNavigation, HdsNavigationTitleMode, ScrollEffectType } from '@kit.UIDesignKit';
|
||||
import { hdsMaterial } from '@hms.hds.hdsMaterial';
|
||||
import { BusinessError } from '@kit.BasicServicesKit';
|
||||
import { normalizeError } from '../../common/ApiConstants';
|
||||
import { api, RequestError } from '../../utils/Request';
|
||||
import { FontScaleLevel, FONT_SCALE_MAP } from '../../utils/FontScale';
|
||||
|
||||
interface Recommendation {
|
||||
id: number;
|
||||
service_date: string;
|
||||
meal_period: string;
|
||||
dish_name: string;
|
||||
price: string;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
interface RecommendationsResponse {
|
||||
elderly_id: number | null;
|
||||
recommendations: Array<Recommendation>;
|
||||
}
|
||||
|
||||
function periodIcon(period: string): string {
|
||||
if (period === '早餐') {
|
||||
return '🌅';
|
||||
}
|
||||
if (period === '午餐') {
|
||||
return '🍚';
|
||||
}
|
||||
return '🌙';
|
||||
}
|
||||
|
||||
function periodColor(period: string): string {
|
||||
if (period === '早餐') {
|
||||
return '#FF9500';
|
||||
}
|
||||
if (period === '午餐') {
|
||||
return '#007AFF';
|
||||
}
|
||||
return '#34C759';
|
||||
}
|
||||
|
||||
function formatDateLabel(dateStr: string): string {
|
||||
if (!dateStr || dateStr.length === 0) {
|
||||
return '';
|
||||
}
|
||||
const parts: Array<string> = dateStr.split('-');
|
||||
if (parts.length !== 3) {
|
||||
return dateStr;
|
||||
}
|
||||
const month = parseInt(parts[1], 10);
|
||||
const day = parseInt(parts[2], 10);
|
||||
return `${month}月${day}日`;
|
||||
}
|
||||
|
||||
function isToday(dateStr: string): boolean {
|
||||
if (!dateStr || dateStr.length === 0) {
|
||||
return false;
|
||||
}
|
||||
const now = new Date();
|
||||
const mm = String(now.getMonth() + 1).padStart(2, '0');
|
||||
const dd = String(now.getDate()).padStart(2, '0');
|
||||
return dateStr === `${now.getFullYear()}-${mm}-${dd}`;
|
||||
}
|
||||
|
||||
@Entry
|
||||
@Component
|
||||
struct ElderlyRecommendations {
|
||||
@StorageProp('fontScale') fontScaleLevel: FontScaleLevel = 'system';
|
||||
@State recommendations: Array<Recommendation> = [];
|
||||
@State loading: boolean = true;
|
||||
|
||||
private scroller: Scroller = new Scroller();
|
||||
|
||||
private scaleFor(): number {
|
||||
return FONT_SCALE_MAP[this.fontScaleLevel] ?? 1.0;
|
||||
}
|
||||
|
||||
aboutToAppear(): void {
|
||||
this.load();
|
||||
}
|
||||
|
||||
private async load(): Promise<void> {
|
||||
this.loading = true;
|
||||
try {
|
||||
const res = await api.get<RecommendationsResponse>('/elderly-portal/recommendations/');
|
||||
this.recommendations = res.recommendations;
|
||||
} catch (err) {
|
||||
const message = err instanceof RequestError ? err.message : normalizeError(err as BusinessError | Error);
|
||||
promptAction.showToast({ message: message });
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
@Builder
|
||||
RecommendCard(item: Recommendation) {
|
||||
Column({ space: 8 }) {
|
||||
Row({ space: 10 }) {
|
||||
Text(periodIcon(item.meal_period))
|
||||
.fontSize(Math.round(24 * this.scaleFor()))
|
||||
Text(`${item.meal_period} · ${isToday(item.service_date) ? '今日' : formatDateLabel(item.service_date)}`)
|
||||
.fontSize(Math.round(17 * this.scaleFor()))
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
Blank()
|
||||
Text(`¥${item.price}`)
|
||||
.fontSize(Math.round(16 * this.scaleFor()))
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor('#FF9500')
|
||||
}
|
||||
.width('100%')
|
||||
|
||||
Text(item.dish_name)
|
||||
.fontSize(Math.round(20 * this.scaleFor()))
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor(periodColor(item.meal_period))
|
||||
.width('100%')
|
||||
|
||||
if (item.reason && item.reason.length > 0) {
|
||||
Text(item.reason)
|
||||
.fontSize(Math.round(15 * this.scaleFor()))
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
.lineHeight(22)
|
||||
.width('100%')
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.padding(16)
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.borderRadius(18)
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
}
|
||||
|
||||
build() {
|
||||
HdsNavigation() {
|
||||
Column() {
|
||||
Scroll(this.scroller) {
|
||||
Column({ space: 12 }) {
|
||||
if (this.loading) {
|
||||
Text('加载中...')
|
||||
.fontSize(Math.round(16 * this.scaleFor()))
|
||||
.fontColor($r('app.color.inactive_tab'))
|
||||
.margin({ top: 40 })
|
||||
} else if (this.recommendations.length === 0) {
|
||||
Column({ space: 8 }) {
|
||||
Text('暂无膳食推荐')
|
||||
.fontSize(Math.round(20 * this.scaleFor()))
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
Text('护理团队会根据您的健康状况推送个性化餐食建议')
|
||||
.fontSize(Math.round(15 * this.scaleFor()))
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
.textAlign(TextAlign.Center)
|
||||
}
|
||||
.width('100%')
|
||||
.padding(24)
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.borderRadius(16)
|
||||
.alignItems(HorizontalAlign.Center)
|
||||
} else {
|
||||
ForEach(this.recommendations, (item: Recommendation) => {
|
||||
Column() {
|
||||
this.RecommendCard(item)
|
||||
}
|
||||
.width('100%')
|
||||
}, (item: Recommendation) => String(item.id))
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ left: 16, right: 16, top: 92, bottom: 30 })
|
||||
}
|
||||
.width('100%')
|
||||
.layoutWeight(1)
|
||||
.scrollBar(BarState.Auto)
|
||||
.edgeEffect(EdgeEffect.Spring)
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
}
|
||||
.mode(NavigationMode.Stack)
|
||||
.titleBar({
|
||||
content: {
|
||||
title: {
|
||||
mainTitle: '膳食推荐',
|
||||
},
|
||||
},
|
||||
style: {
|
||||
scrollEffectOpts: {
|
||||
enableScrollEffect: false,
|
||||
scrollEffectType: ScrollEffectType.GRADIENT_BLUR,
|
||||
},
|
||||
systemMaterialEffect: {
|
||||
materialType: hdsMaterial.MaterialType.ADAPTIVE,
|
||||
materialLevel: hdsMaterial.MaterialLevel.EXQUISITE,
|
||||
},
|
||||
},
|
||||
avoidLayoutSafeArea: false,
|
||||
enableComponentSafeArea: false,
|
||||
})
|
||||
.bindToScrollable([this.scroller])
|
||||
.hideBackButton(false)
|
||||
.titleMode(HdsNavigationTitleMode.MINI)
|
||||
.ignoreLayoutSafeArea([LayoutSafeAreaType.SYSTEM], [LayoutSafeAreaEdge.TOP, LayoutSafeAreaEdge.BOTTOM])
|
||||
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
|
||||
.backgroundColor($r('app.color.background'))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
// 长者端 · 天气(ArkTS 严格类型,无 any)
|
||||
// 数据:Open-Meteo(utils/OpenMeteo.ts),广州 7 天预报
|
||||
import { router } from '@kit.ArkUI';
|
||||
import { InsightIntentPage } from '@kit.AbilityKit';
|
||||
import { HdsNavigation, HdsNavigationTitleMode, ScrollEffectType } from '@kit.UIDesignKit';
|
||||
import { hdsMaterial } from '@hms.hds.hdsMaterial';
|
||||
import { fetchWeather, weatherCodeLabel, todayYMD, WeatherData, WeatherDay } from '../../utils/OpenMeteo';
|
||||
|
||||
function dateLabel(dateStr: string): string {
|
||||
const today = todayYMD();
|
||||
if (dateStr === today) {
|
||||
return '今天';
|
||||
}
|
||||
if (dateStr.length < 5) {
|
||||
return dateStr;
|
||||
}
|
||||
const md = dateStr.substring(5);
|
||||
return md.replace('-', '/');
|
||||
}
|
||||
|
||||
// 天气图标(与 weatherCodeLabel 同一套 code 区间)
|
||||
function weatherEmoji(code: number): string {
|
||||
if (code === 0 || code === 1) {
|
||||
return '☀️';
|
||||
}
|
||||
if (code === 2) {
|
||||
return '⛅';
|
||||
}
|
||||
if (code === 3) {
|
||||
return '☁️';
|
||||
}
|
||||
if (code === 45 || code === 48) {
|
||||
return '🌫️';
|
||||
}
|
||||
if (code >= 51 && code <= 57) {
|
||||
return '🌦️';
|
||||
}
|
||||
if (code >= 71 && code <= 77 || code === 85 || code === 86) {
|
||||
return '❄️';
|
||||
}
|
||||
if (code >= 95) {
|
||||
return '⛈️';
|
||||
}
|
||||
return '🌧️';
|
||||
}
|
||||
|
||||
// 温度图表常量:0~40° 映射到 160px
|
||||
const CHART_MIN_T = 0;
|
||||
const CHART_MAX_T = 40;
|
||||
const CHART_HEIGHT = 160;
|
||||
const CHART_PX_PER_DEGREE = CHART_HEIGHT / (CHART_MAX_T - CHART_MIN_T);
|
||||
|
||||
@InsightIntentPage({
|
||||
intentName: 'ViewWeather',
|
||||
domain: 'ElderlyCare',
|
||||
intentVersion: '1.0.0',
|
||||
displayName: '查看天气',
|
||||
displayDescription: '打开智慧护老的天气页,查看养老院所在地七天天气',
|
||||
uiAbility: 'EntryAbility',
|
||||
pagePath: 'ets/pages/elderly/ElderlyWeather',
|
||||
keywords: ['天气', '下雨', '气温', '出门', '降温'],
|
||||
llmDescription: '查看养老院所在地的今日与未来七天天气预报',
|
||||
})
|
||||
@Entry
|
||||
@Component
|
||||
struct ElderlyWeather {
|
||||
@State weather: WeatherData | null = null;
|
||||
@State loading: boolean = true;
|
||||
@State errorMsg: string = '';
|
||||
|
||||
aboutToAppear(): void {
|
||||
// 与首页同源:优先用首页传入的定位坐标(-1 表示未定位,回退广州);无参数直接进入也回退
|
||||
const params = router.getParams() as Record<string, Object> | null | undefined;
|
||||
let lat = -1;
|
||||
let lon = -1;
|
||||
if (params !== null && params !== undefined) {
|
||||
lat = Number(params['lat'] ?? -1);
|
||||
lon = Number(params['lon'] ?? -1);
|
||||
}
|
||||
if (lat > 0 && lon > 0) {
|
||||
this.load(lat, lon);
|
||||
} else {
|
||||
this.load(undefined, undefined);
|
||||
}
|
||||
}
|
||||
|
||||
private async load(lat?: number, lon?: number): Promise<void> {
|
||||
this.loading = true;
|
||||
this.errorMsg = '';
|
||||
try {
|
||||
const res = await fetchWeather(lat, lon);
|
||||
this.weather = res;
|
||||
} catch (err) {
|
||||
this.errorMsg = (err as Error).message;
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 当前温度映射到 0-40° 仪表区间
|
||||
private gaugeValue(): number {
|
||||
if (this.weather === null) {
|
||||
return 0;
|
||||
}
|
||||
const t = Math.round(this.weather.current.temperature);
|
||||
return Math.max(0, Math.min(40, t));
|
||||
}
|
||||
|
||||
private todayRainText(): string {
|
||||
const list = this.weather?.daily ?? [];
|
||||
if (list.length > 0) {
|
||||
return `今日降雨概率 ${list[0].rainPct}%`;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
build() {
|
||||
HdsNavigation() {
|
||||
Scroll() {
|
||||
Column({ space: 24 }) {
|
||||
if (this.loading) {
|
||||
Text('正在获取天气...')
|
||||
.fontSize(16)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
.padding({ top: 60 })
|
||||
} else if (this.errorMsg.length > 0) {
|
||||
Column({ space: 10 }) {
|
||||
Text('天气获取失败')
|
||||
.fontSize(18)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
Text(this.errorMsg)
|
||||
.fontSize(13)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ top: 60 })
|
||||
.alignItems(HorizontalAlign.Center)
|
||||
} else {
|
||||
// 今日温度仪表(官方 Gauge 样式)
|
||||
Column({ space: 8 }) {
|
||||
Row() {
|
||||
Gauge({ value: this.gaugeValue(), min: 0, max: 40 }) {
|
||||
Column() {
|
||||
Text(this.weather !== null ? `${this.weather.current.temperature.toFixed(1)}°C` : '--')
|
||||
.fontSize(34)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.width('100%')
|
||||
.textAlign(TextAlign.Center)
|
||||
.margin({ top: '35%' })
|
||||
.maxLines(1)
|
||||
Text(this.weather !== null ? `${weatherCodeLabel(this.weather.current.code)} · 广州今天` : '--')
|
||||
.fontSize(14)
|
||||
.fontColor($r('sys.color.ohos_id_color_text_secondary'))
|
||||
.width('100%')
|
||||
.textAlign(TextAlign.Center)
|
||||
Text(this.todayRainText())
|
||||
.fontSize(13)
|
||||
.fontColor($r('sys.color.ohos_id_color_text_secondary'))
|
||||
.width('100%')
|
||||
.textAlign(TextAlign.Center)
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
.alignItems(HorizontalAlign.Center)
|
||||
}
|
||||
.startAngle(210)
|
||||
.endAngle(150)
|
||||
.colors([
|
||||
[new LinearGradient([{ color: '#61cfbe', offset: 0 }, { color: '#46b1e3', offset: 1 }]), 1],
|
||||
[new LinearGradient([{ color: '#c1e4be', offset: 0 }, { color: '#64bb5c', offset: 1 }]), 1],
|
||||
[new LinearGradient([{ color: '#fceb99', offset: 0 }, { color: '#f7ce00', offset: 1 }]), 1],
|
||||
[new LinearGradient([{ color: '#f8c5a6', offset: 0 }, { color: '#ed6f21', offset: 1 }]), 1]
|
||||
])
|
||||
.width(240)
|
||||
.height(240)
|
||||
.strokeWidth(18)
|
||||
.trackShadow({ radius: 7, offsetX: 7, offsetY: 7 })
|
||||
}
|
||||
.width('100%')
|
||||
.justifyContent(FlexAlign.Center)
|
||||
}
|
||||
.width('100%')
|
||||
.padding(16)
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.borderRadius(20)
|
||||
.alignItems(HorizontalAlign.Center)
|
||||
|
||||
// 未来 7 天温度范围图表(竖向柱状:柱底=最低温,柱顶=最高温)
|
||||
Text('未来 7 天')
|
||||
.fontSize(18)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.width('100%')
|
||||
|
||||
Column({ space: 12 }) {
|
||||
Row({ space: 8 }) {
|
||||
// 纵轴刻度
|
||||
Column() {
|
||||
Text('40°')
|
||||
.fontSize(12)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
Text('30°')
|
||||
.fontSize(12)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
Text('20°')
|
||||
.fontSize(12)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
Text('10°')
|
||||
.fontSize(12)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
Text('0°')
|
||||
.fontSize(12)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
}
|
||||
.height(CHART_HEIGHT)
|
||||
.justifyContent(FlexAlign.SpaceBetween)
|
||||
.alignItems(HorizontalAlign.End)
|
||||
|
||||
// 每日一列:图标 + 双柱(蓝=最低温/橙=最高温,柱高=温度) + 日期 + 降雨
|
||||
ForEach(this.weather?.daily ?? [], (day: WeatherDay) => {
|
||||
Column({ space: 4 }) {
|
||||
Text(weatherEmoji(day.code))
|
||||
.fontSize(20)
|
||||
Row({ space: 6 }) {
|
||||
Column({ space: 3 }) {
|
||||
Text(`${Math.round(Math.max(day.minT, CHART_MIN_T))}°`)
|
||||
.fontSize(11)
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor('#46b1e3')
|
||||
Column()
|
||||
.width(14)
|
||||
.height(Math.min(Math.max(12, Math.round((Math.max(day.minT, CHART_MIN_T)) * CHART_PX_PER_DEGREE)), CHART_HEIGHT - 24))
|
||||
.backgroundColor('#46b1e3')
|
||||
.borderRadius(7)
|
||||
}
|
||||
.alignItems(HorizontalAlign.Center)
|
||||
Column({ space: 3 }) {
|
||||
Text(`${Math.round(Math.min(day.maxT, CHART_MAX_T))}°`)
|
||||
.fontSize(11)
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor('#ed6f21')
|
||||
Column()
|
||||
.width(14)
|
||||
.height(Math.min(Math.max(12, Math.round((Math.min(day.maxT, CHART_MAX_T)) * CHART_PX_PER_DEGREE)), CHART_HEIGHT - 24))
|
||||
.backgroundColor('#ed6f21')
|
||||
.borderRadius(7)
|
||||
}
|
||||
.alignItems(HorizontalAlign.Center)
|
||||
}
|
||||
.height(CHART_HEIGHT)
|
||||
.width('100%')
|
||||
.alignItems(VerticalAlign.Bottom)
|
||||
.justifyContent(FlexAlign.Center)
|
||||
|
||||
Text(dateLabel(day.date))
|
||||
.fontSize(13)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.maxLines(1)
|
||||
Text(`雨${day.rainPct}%`)
|
||||
.fontSize(11)
|
||||
.fontColor(day.rainPct >= 60 ? '#007AFF' : $r('app.color.text_secondary'))
|
||||
.maxLines(1)
|
||||
}
|
||||
.layoutWeight(1)
|
||||
.alignItems(HorizontalAlign.Center)
|
||||
.accessibilityLevel('yes')
|
||||
.accessibilityText(`${dateLabel(day.date)},${weatherCodeLabel(day.code)},最低${Math.round(day.minT)}度,最高${Math.round(day.maxT)}度,降雨概率${day.rainPct}%`)
|
||||
}, (day: WeatherDay) => day.date)
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(VerticalAlign.Bottom)
|
||||
|
||||
Row({ space: 6 }) {
|
||||
Column().width(14).height(14).borderRadius(7).backgroundColor('#46b1e3')
|
||||
Text('最低温')
|
||||
.fontSize(12)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
Column().width(14).height(14).borderRadius(7).backgroundColor('#ed6f21')
|
||||
Text('最高温')
|
||||
.fontSize(12)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
}
|
||||
.width('100%')
|
||||
.justifyContent(FlexAlign.Center)
|
||||
}
|
||||
.width('100%')
|
||||
.padding(16)
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.borderRadius(20)
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ left: 16, right: 16, top: 120, bottom: 30 })
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
.scrollBar(BarState.Auto)
|
||||
.edgeEffect(EdgeEffect.Spring)
|
||||
}
|
||||
.mode(NavigationMode.Stack)
|
||||
.titleBar({
|
||||
content: {
|
||||
title: {
|
||||
mainTitle: '天气',
|
||||
},
|
||||
},
|
||||
style: {
|
||||
scrollEffectOpts: {
|
||||
enableScrollEffect: false,
|
||||
scrollEffectType: ScrollEffectType.GRADIENT_BLUR,
|
||||
},
|
||||
systemMaterialEffect: {
|
||||
materialType: hdsMaterial.MaterialType.ADAPTIVE,
|
||||
materialLevel: hdsMaterial.MaterialLevel.EXQUISITE,
|
||||
},
|
||||
},
|
||||
avoidLayoutSafeArea: false,
|
||||
enableComponentSafeArea: false,
|
||||
})
|
||||
.hideBackButton(false)
|
||||
.titleMode(HdsNavigationTitleMode.MINI)
|
||||
.ignoreLayoutSafeArea([LayoutSafeAreaType.SYSTEM], [LayoutSafeAreaEdge.TOP, LayoutSafeAreaEdge.BOTTOM])
|
||||
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
|
||||
.backgroundColor($r('app.color.background'))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
// 家属 · 预警详情(ArkTS 严格类型,无 any)
|
||||
// 接口:GET /alerts/{id}/
|
||||
import { router, promptAction } from '@kit.ArkUI';
|
||||
import { HdsNavigation, HdsNavigationTitleMode, ScrollEffectType } from '@kit.UIDesignKit';
|
||||
import { hdsMaterial } from '@hms.hds.hdsMaterial';
|
||||
import { BusinessError } from '@kit.BasicServicesKit';
|
||||
import { normalizeError } from '../../common/ApiConstants';
|
||||
import { api, RequestError } from '../../utils/Request';
|
||||
import { FontScaleLevel, FONT_SCALE_MAP } from '../../utils/FontScale';
|
||||
|
||||
interface AlertProcess {
|
||||
id: number;
|
||||
nurse_name: string;
|
||||
description: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface AlertDetail {
|
||||
id: number;
|
||||
elderly_name: string;
|
||||
level: string;
|
||||
metric: string;
|
||||
actual_value: string;
|
||||
title: string;
|
||||
description: string;
|
||||
status_display: string;
|
||||
processing_by_name: string;
|
||||
resolved_at: string;
|
||||
created_at: string;
|
||||
processes: Array<AlertProcess>;
|
||||
}
|
||||
|
||||
function levelLabel(level: string): string {
|
||||
if (level === 'emergency') {
|
||||
return '紧急';
|
||||
}
|
||||
if (level === 'warning') {
|
||||
return '注意';
|
||||
}
|
||||
return '正常';
|
||||
}
|
||||
|
||||
function levelColor(level: string): string {
|
||||
if (level === 'emergency') {
|
||||
return '#FF3B30';
|
||||
}
|
||||
if (level === 'warning') {
|
||||
return '#FF9500';
|
||||
}
|
||||
return '#007AFF';
|
||||
}
|
||||
|
||||
function formatDateTime(iso: string): string {
|
||||
if (!iso || iso.length === 0) {
|
||||
return '--';
|
||||
}
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) {
|
||||
return '--';
|
||||
}
|
||||
const mm = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const dd = String(d.getDate()).padStart(2, '0');
|
||||
const hh = String(d.getHours()).padStart(2, '0');
|
||||
const mi = String(d.getMinutes()).padStart(2, '0');
|
||||
return `${mm}/${dd} ${hh}:${mi}`;
|
||||
}
|
||||
|
||||
@Entry
|
||||
@Component
|
||||
struct FamilyAlertDetail {
|
||||
@State alert: AlertDetail | null = null;
|
||||
@State loading: boolean = true;
|
||||
@StorageProp('fontScale') fontScaleLevel: FontScaleLevel = 'system';
|
||||
|
||||
private scaleFor(): number {
|
||||
return FONT_SCALE_MAP[this.fontScaleLevel] ?? 1.0;
|
||||
}
|
||||
|
||||
private scroller: Scroller = new Scroller();
|
||||
|
||||
aboutToAppear(): void {
|
||||
const params = router.getParams() as Record<string, string | number>;
|
||||
const id = params ? params['id'] : null;
|
||||
if (id === null || id === undefined) {
|
||||
router.back();
|
||||
return;
|
||||
}
|
||||
this.load(Number(id));
|
||||
}
|
||||
|
||||
private async load(id: number): Promise<void> {
|
||||
this.loading = true;
|
||||
try {
|
||||
const res = await api.get<AlertDetail>(`/alerts/${id}/`);
|
||||
this.alert = res;
|
||||
} catch (err) {
|
||||
const message = err instanceof RequestError ? err.message : normalizeError(err as BusinessError | Error);
|
||||
promptAction.showToast({ message: message });
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
@Builder
|
||||
InfoRow(label: string, value: string) {
|
||||
Row({ space: 12 }) {
|
||||
Text(label)
|
||||
.fontSize(Math.round(14 * this.scaleFor()))
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
.width(72)
|
||||
Text(value)
|
||||
.fontSize(Math.round(14 * this.scaleFor()))
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.layoutWeight(1)
|
||||
}
|
||||
.width('100%')
|
||||
}
|
||||
|
||||
@Builder
|
||||
ProcessCard(process: AlertProcess) {
|
||||
Column({ space: 6 }) {
|
||||
Row() {
|
||||
Text(process.nurse_name)
|
||||
.fontSize(Math.round(14 * this.scaleFor()))
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
Blank()
|
||||
Text(formatDateTime(process.created_at))
|
||||
.fontSize(Math.round(12 * this.scaleFor()))
|
||||
.fontColor($r('app.color.inactive_tab'))
|
||||
}
|
||||
.width('100%')
|
||||
Text(process.description)
|
||||
.fontSize(Math.round(14 * this.scaleFor()))
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
.width('100%')
|
||||
}
|
||||
.width('100%')
|
||||
.padding(14)
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.borderRadius(12)
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
}
|
||||
|
||||
build() {
|
||||
HdsNavigation() {
|
||||
Column() {
|
||||
Scroll(this.scroller) {
|
||||
Column({ space: 12 }) {
|
||||
if (this.loading) {
|
||||
Text('加载中...')
|
||||
.fontSize(15)
|
||||
.fontColor($r('app.color.inactive_tab'))
|
||||
.margin({ top: 40 })
|
||||
} else if (this.alert) {
|
||||
Column({ space: 10 }) {
|
||||
Row({ space: 8 }) {
|
||||
Text(levelLabel(this.alert.level))
|
||||
.fontSize(Math.round(13 * this.scaleFor()))
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor(levelColor(this.alert.level))
|
||||
.padding({ left: 10, right: 10, top: 3, bottom: 3 })
|
||||
.backgroundColor(this.alert.level === 'emergency' ? '#14FF3B30' : (this.alert.level === 'warning' ? '#14FF9500' : '#14007AFF'))
|
||||
.borderRadius(6)
|
||||
Text(this.alert.status_display)
|
||||
.fontSize(Math.round(13 * this.scaleFor()))
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
}
|
||||
.width('100%')
|
||||
|
||||
Text(this.alert.title)
|
||||
.fontSize(Math.round(20 * this.scaleFor()))
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.width('100%')
|
||||
|
||||
if (this.alert.description && this.alert.description.length > 0) {
|
||||
Text(this.alert.description)
|
||||
.fontSize(Math.round(15 * this.scaleFor()))
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
.lineHeight(Math.round(22 * this.scaleFor()))
|
||||
.width('100%')
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.padding(16)
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.borderRadius(14)
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
|
||||
Column({ space: 12 }) {
|
||||
this.InfoRow('长者', this.alert.elderly_name)
|
||||
if (this.alert.metric && this.alert.metric.length > 0) {
|
||||
this.InfoRow('指标', this.alert.metric)
|
||||
}
|
||||
if (this.alert.actual_value && this.alert.actual_value.length > 0) {
|
||||
this.InfoRow('实际值', this.alert.actual_value)
|
||||
}
|
||||
this.InfoRow('触发时间', formatDateTime(this.alert.created_at))
|
||||
if (this.alert.resolved_at && this.alert.resolved_at.length > 0) {
|
||||
this.InfoRow('处理时间', formatDateTime(this.alert.resolved_at))
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.padding(16)
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.borderRadius(14)
|
||||
|
||||
if (this.alert.processes && this.alert.processes.length > 0) {
|
||||
Column({ space: 8 }) {
|
||||
Text('处理记录')
|
||||
.fontSize(Math.round(16 * this.scaleFor()))
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.width('100%')
|
||||
ForEach(this.alert.processes, (process: AlertProcess) => {
|
||||
Column() {
|
||||
this.ProcessCard(process)
|
||||
}
|
||||
.width('100%')
|
||||
}, (process: AlertProcess) => String(process.id))
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
}
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ left: 16, right: 16, top: 92, bottom: 30 })
|
||||
}
|
||||
.width('100%')
|
||||
.layoutWeight(1)
|
||||
.scrollBar(BarState.Auto)
|
||||
.edgeEffect(EdgeEffect.Spring)
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
}
|
||||
.mode(NavigationMode.Stack)
|
||||
.titleBar({
|
||||
content: {
|
||||
title: {
|
||||
mainTitle: '预警详情',
|
||||
},
|
||||
},
|
||||
style: {
|
||||
scrollEffectOpts: {
|
||||
enableScrollEffect: false,
|
||||
scrollEffectType: ScrollEffectType.GRADIENT_BLUR,
|
||||
},
|
||||
systemMaterialEffect: {
|
||||
materialType: hdsMaterial.MaterialType.ADAPTIVE,
|
||||
materialLevel: hdsMaterial.MaterialLevel.EXQUISITE,
|
||||
},
|
||||
},
|
||||
avoidLayoutSafeArea: false,
|
||||
enableComponentSafeArea: false,
|
||||
})
|
||||
.bindToScrollable([this.scroller])
|
||||
.hideBackButton(false)
|
||||
.titleMode(HdsNavigationTitleMode.MINI)
|
||||
.ignoreLayoutSafeArea([LayoutSafeAreaType.SYSTEM], [LayoutSafeAreaEdge.TOP, LayoutSafeAreaEdge.BOTTOM])
|
||||
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
|
||||
.backgroundColor($r('app.color.background'))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
// 家属 · 健康预警列表(ArkTS 严格类型,无 any)
|
||||
// 接口:GET /alerts/
|
||||
import { router, promptAction } from '@kit.ArkUI';
|
||||
import { HdsNavigation, HdsNavigationTitleMode, ScrollEffectType } from '@kit.UIDesignKit';
|
||||
import { hdsMaterial } from '@hms.hds.hdsMaterial';
|
||||
import { BusinessError } from '@kit.BasicServicesKit';
|
||||
import { Page, normalizeError } from '../../common/ApiConstants';
|
||||
import { api, RequestError } from '../../utils/Request';
|
||||
import { FontScaleLevel, FONT_SCALE_MAP } from '../../utils/FontScale';
|
||||
|
||||
interface FamilyAlert {
|
||||
id: number;
|
||||
elderly_name: string;
|
||||
level: string;
|
||||
metric: string;
|
||||
actual_value: string;
|
||||
title: string;
|
||||
description: string;
|
||||
status_display: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
function levelColor(level: string): string {
|
||||
if (level === 'emergency') {
|
||||
return '#FF3B30';
|
||||
}
|
||||
if (level === 'warning') {
|
||||
return '#FF9500';
|
||||
}
|
||||
return '#007AFF';
|
||||
}
|
||||
|
||||
function levelLabel(level: string): string {
|
||||
if (level === 'emergency') {
|
||||
return '紧急';
|
||||
}
|
||||
if (level === 'warning') {
|
||||
return '注意';
|
||||
}
|
||||
return '正常';
|
||||
}
|
||||
|
||||
function formatDateTime(iso: string): string {
|
||||
if (!iso || iso.length === 0) {
|
||||
return '--';
|
||||
}
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) {
|
||||
return '--';
|
||||
}
|
||||
const mm = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const dd = String(d.getDate()).padStart(2, '0');
|
||||
const hh = String(d.getHours()).padStart(2, '0');
|
||||
const mi = String(d.getMinutes()).padStart(2, '0');
|
||||
return `${mm}/${dd} ${hh}:${mi}`;
|
||||
}
|
||||
|
||||
@Entry
|
||||
@Component
|
||||
struct FamilyAlerts {
|
||||
@State alerts: Array<FamilyAlert> = [];
|
||||
@State loading: boolean = true;
|
||||
@State refreshing: boolean = false;
|
||||
@StorageProp('fontScale') fontScaleLevel: FontScaleLevel = 'system';
|
||||
|
||||
private scaleFor(): number {
|
||||
return FONT_SCALE_MAP[this.fontScaleLevel] ?? 1.0;
|
||||
}
|
||||
|
||||
private scroller: Scroller = new Scroller();
|
||||
|
||||
aboutToAppear(): void {
|
||||
this.load();
|
||||
}
|
||||
|
||||
private async load(): Promise<void> {
|
||||
this.loading = true;
|
||||
try {
|
||||
const res = await api.get<Page<FamilyAlert>>('/alerts/', {
|
||||
page_size: 50,
|
||||
ordering: '-created_at',
|
||||
});
|
||||
this.alerts = res.results;
|
||||
} catch (err) {
|
||||
const message = err instanceof RequestError ? err.message : normalizeError(err as BusinessError | Error);
|
||||
promptAction.showToast({ message: message });
|
||||
} finally {
|
||||
this.loading = false;
|
||||
this.refreshing = false;
|
||||
}
|
||||
}
|
||||
|
||||
private openDetail(alert: FamilyAlert): void {
|
||||
router.pushUrl({ url: 'pages/family/FamilyAlertDetail', params: { id: alert.id } })
|
||||
.catch((err: BusinessError) => {
|
||||
promptAction.showToast({ message: `打开失败:${normalizeError(err)}` });
|
||||
});
|
||||
}
|
||||
|
||||
@Builder
|
||||
AlertCard(alert: FamilyAlert) {
|
||||
Row({ space: 10 }) {
|
||||
Column({ space: 6 }) {
|
||||
Row({ space: 8 }) {
|
||||
Text(levelLabel(alert.level))
|
||||
.fontSize(Math.round(12 * this.scaleFor()))
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor(levelColor(alert.level))
|
||||
.padding({ left: 8, right: 8, top: 2, bottom: 2 })
|
||||
.backgroundColor(alert.level === 'emergency' ? '#14FF3B30' : (alert.level === 'warning' ? '#14FF9500' : '#14007AFF'))
|
||||
.borderRadius(6)
|
||||
Text(alert.title)
|
||||
.fontSize(Math.round(16 * this.scaleFor()))
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.layoutWeight(1)
|
||||
.maxLines(1)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
}
|
||||
.width('100%')
|
||||
|
||||
if (alert.description && alert.description.length > 0) {
|
||||
Text(alert.description)
|
||||
.fontSize(Math.round(13 * this.scaleFor()))
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
.maxLines(2)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
.width('100%')
|
||||
}
|
||||
|
||||
Row({ space: 8 }) {
|
||||
Text(alert.elderly_name)
|
||||
.fontSize(Math.round(12 * this.scaleFor()))
|
||||
.fontColor($r('app.color.accent_blue'))
|
||||
Blank()
|
||||
Text(formatDateTime(alert.created_at))
|
||||
.fontSize(Math.round(12 * this.scaleFor()))
|
||||
.fontColor($r('app.color.inactive_tab'))
|
||||
}
|
||||
.width('100%')
|
||||
}
|
||||
.layoutWeight(1)
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
Text('›')
|
||||
.fontSize(20)
|
||||
.fontColor($r('app.color.inactive_tab'))
|
||||
}
|
||||
.width('100%')
|
||||
.padding(14)
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.borderRadius(14)
|
||||
.onClick(() => {
|
||||
this.openDetail(alert);
|
||||
})
|
||||
}
|
||||
|
||||
build() {
|
||||
HdsNavigation() {
|
||||
Column() {
|
||||
Refresh({ refreshing: $$this.refreshing }) {
|
||||
Scroll(this.scroller) {
|
||||
Column({ space: 12 }) {
|
||||
if (this.loading) {
|
||||
Text('加载中...')
|
||||
.fontSize(15)
|
||||
.fontColor($r('app.color.inactive_tab'))
|
||||
.margin({ top: 40 })
|
||||
} else if (this.alerts.length === 0) {
|
||||
Text('暂无预警')
|
||||
.fontSize(15)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
.width('100%')
|
||||
.padding(24)
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.borderRadius(14)
|
||||
.textAlign(TextAlign.Center)
|
||||
} else {
|
||||
ForEach(this.alerts, (alert: FamilyAlert) => {
|
||||
Column() {
|
||||
this.AlertCard(alert)
|
||||
}
|
||||
.width('100%')
|
||||
}, (alert: FamilyAlert) => String(alert.id))
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ left: 16, right: 16, top: 92, bottom: 30 })
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
.scrollBar(BarState.Auto)
|
||||
.edgeEffect(EdgeEffect.Spring)
|
||||
}
|
||||
.width('100%')
|
||||
.layoutWeight(1)
|
||||
.onRefreshing(() => {
|
||||
this.load();
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
}
|
||||
.mode(NavigationMode.Stack)
|
||||
.titleBar({
|
||||
content: {
|
||||
title: {
|
||||
mainTitle: '健康预警',
|
||||
},
|
||||
},
|
||||
style: {
|
||||
scrollEffectOpts: {
|
||||
enableScrollEffect: false,
|
||||
scrollEffectType: ScrollEffectType.GRADIENT_BLUR,
|
||||
},
|
||||
systemMaterialEffect: {
|
||||
materialType: hdsMaterial.MaterialType.ADAPTIVE,
|
||||
materialLevel: hdsMaterial.MaterialLevel.EXQUISITE,
|
||||
},
|
||||
},
|
||||
avoidLayoutSafeArea: false,
|
||||
enableComponentSafeArea: false,
|
||||
})
|
||||
.bindToScrollable([this.scroller])
|
||||
.hideBackButton(false)
|
||||
.titleMode(HdsNavigationTitleMode.MINI)
|
||||
.ignoreLayoutSafeArea([LayoutSafeAreaType.SYSTEM], [LayoutSafeAreaEdge.TOP, LayoutSafeAreaEdge.BOTTOM])
|
||||
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
|
||||
.backgroundColor($r('app.color.background'))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
// 家属 · 智能助手(ArkTS 严格类型,无 any)
|
||||
// 接口:POST /robots/family-assistant/query/ {message} → {reply}
|
||||
import { router, promptAction } from '@kit.ArkUI';
|
||||
import { HdsNavigation, HdsNavigationTitleMode, ScrollEffectType } from '@kit.UIDesignKit';
|
||||
import { hdsMaterial } from '@hms.hds.hdsMaterial';
|
||||
import { BusinessError } from '@kit.BasicServicesKit';
|
||||
import { normalizeError } from '../../common/ApiConstants';
|
||||
import { api, RequestError } from '../../utils/Request';
|
||||
import { FontScaleLevel, FONT_SCALE_MAP } from '../../utils/FontScale';
|
||||
|
||||
interface ChatMessage {
|
||||
id: number;
|
||||
role: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
interface AssistantReply {
|
||||
reply: string;
|
||||
}
|
||||
|
||||
let messageSeq = 1;
|
||||
|
||||
@Entry
|
||||
@Component
|
||||
struct FamilyAssistant {
|
||||
@State inputText: string = '';
|
||||
@State messages: Array<ChatMessage> = [];
|
||||
@State sending: boolean = false;
|
||||
@StorageProp('fontScale') fontScaleLevel: FontScaleLevel = 'system';
|
||||
|
||||
private scaleFor(): number {
|
||||
return FONT_SCALE_MAP[this.fontScaleLevel] ?? 1.0;
|
||||
}
|
||||
|
||||
private scroller: Scroller = new Scroller();
|
||||
|
||||
private async send(): Promise<void> {
|
||||
const text = this.inputText.trim();
|
||||
if (text.length === 0 || this.sending) {
|
||||
return;
|
||||
}
|
||||
const userMsg: ChatMessage = { id: messageSeq++, role: 'user', content: text };
|
||||
this.messages = this.messages.concat([userMsg]);
|
||||
this.inputText = '';
|
||||
this.sending = true;
|
||||
setTimeout(() => {
|
||||
this.scroller.scrollEdge(Edge.Bottom);
|
||||
}, 50);
|
||||
try {
|
||||
const res = await api.post<AssistantReply>('/robots/family-assistant/query/', { message: text });
|
||||
const reply = res.reply && res.reply.length > 0 ? res.reply : '好的,我会继续为您查询。';
|
||||
this.messages = this.messages.concat([{ id: messageSeq++, role: 'assistant', content: reply }]);
|
||||
this.scroller.scrollEdge(Edge.Bottom);
|
||||
} catch (err) {
|
||||
const message = err instanceof RequestError ? err.message : normalizeError(err as BusinessError | Error);
|
||||
this.messages = this.messages.concat([{ id: messageSeq++, role: 'assistant', content: message }]);
|
||||
this.scroller.scrollEdge(Edge.Bottom);
|
||||
} finally {
|
||||
this.sending = false;
|
||||
}
|
||||
}
|
||||
|
||||
@Builder
|
||||
MessageView(msg: ChatMessage) {
|
||||
Row() {
|
||||
if (msg.role === 'user') {
|
||||
Blank()
|
||||
Text(msg.content)
|
||||
.fontSize(Math.round(17 * this.scaleFor()))
|
||||
.fontColor('#FFFFFF')
|
||||
.constraintSize({ maxWidth: '72%' })
|
||||
.padding(14)
|
||||
.backgroundColor($r('app.color.accent_blue'))
|
||||
.borderRadius(14)
|
||||
} else {
|
||||
Text(msg.content)
|
||||
.fontSize(Math.round(17 * this.scaleFor()))
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.constraintSize({ maxWidth: '72%' })
|
||||
.padding(14)
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.borderRadius(14)
|
||||
Blank()
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ left: 16, right: 16, bottom: 12 })
|
||||
}
|
||||
|
||||
build() {
|
||||
HdsNavigation() {
|
||||
Column() {
|
||||
Scroll(this.scroller) {
|
||||
Column({ space: 6 }) {
|
||||
if (this.messages.length === 0) {
|
||||
Text('您好,我是家属智能助手 🌟\n可以问询您家人的健康与照护情况,例如:\n“我妈今天状态怎么样?”')
|
||||
.fontSize(Math.round(17 * this.scaleFor()))
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
.textAlign(TextAlign.Center)
|
||||
.width('100%')
|
||||
.padding({ top: 60 })
|
||||
}
|
||||
ForEach(this.messages, (msg: ChatMessage) => {
|
||||
this.MessageView(msg)
|
||||
}, (msg: ChatMessage) => String(msg.id))
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ top: 92, bottom: 20 })
|
||||
}
|
||||
.scrollBar(BarState.Auto)
|
||||
.edgeEffect(EdgeEffect.Spring)
|
||||
.layoutWeight(1)
|
||||
.align(Alignment.Top)
|
||||
|
||||
Row({ space: 10 }) {
|
||||
TextInput({ placeholder: '输入您的问题...', text: this.inputText })
|
||||
.placeholderColor($r('app.color.inactive_tab'))
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.fontSize(Math.round(17 * this.scaleFor()))
|
||||
.height(52)
|
||||
.layoutWeight(1)
|
||||
.padding({ left: 16, right: 16 })
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.borderRadius(14)
|
||||
.onChange((value: string) => {
|
||||
this.inputText = value;
|
||||
})
|
||||
.onSubmit((enterKey: EnterKeyType) => {
|
||||
this.send();
|
||||
})
|
||||
Button(this.sending ? '…' : '发送')
|
||||
.height(52)
|
||||
.padding({ left: 18, right: 18 })
|
||||
.constraintSize({ minWidth: 80 })
|
||||
.fontSize(Math.round(17 * this.scaleFor()))
|
||||
.fontColor('#FFFFFF')
|
||||
.backgroundColor($r('app.color.accent_blue'))
|
||||
.borderRadius(14)
|
||||
.enabled(!this.sending)
|
||||
.onClick(() => {
|
||||
this.send();
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ left: 16, right: 16, bottom: 20 })
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
}
|
||||
.mode(NavigationMode.Stack)
|
||||
.titleBar({
|
||||
content: {
|
||||
title: {
|
||||
mainTitle: '智能助手',
|
||||
},
|
||||
},
|
||||
style: {
|
||||
scrollEffectOpts: {
|
||||
enableScrollEffect: false,
|
||||
scrollEffectType: ScrollEffectType.GRADIENT_BLUR,
|
||||
},
|
||||
systemMaterialEffect: {
|
||||
materialType: hdsMaterial.MaterialType.ADAPTIVE,
|
||||
materialLevel: hdsMaterial.MaterialLevel.EXQUISITE,
|
||||
},
|
||||
},
|
||||
avoidLayoutSafeArea: false,
|
||||
enableComponentSafeArea: false,
|
||||
})
|
||||
.bindToScrollable([this.scroller])
|
||||
.hideBackButton(false)
|
||||
.titleMode(HdsNavigationTitleMode.MINI)
|
||||
.ignoreLayoutSafeArea([LayoutSafeAreaType.SYSTEM], [LayoutSafeAreaEdge.TOP, LayoutSafeAreaEdge.BOTTOM])
|
||||
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
|
||||
.backgroundColor($r('app.color.background'))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
// 家属绑定验证(ArkTS 严格类型,无 any)
|
||||
// 接口:POST /elderly/add/ {elderly_name, elderly_id_card, relation}
|
||||
import { router } from '@kit.ArkUI';
|
||||
import { promptAction } from '@kit.ArkUI';
|
||||
import { BusinessError } from '@kit.BasicServicesKit';
|
||||
import { HdsNavigation, HdsNavigationTitleMode, ScrollEffectType } from '@kit.UIDesignKit';
|
||||
import { hdsMaterial } from '@hms.hds.hdsMaterial';
|
||||
import { normalizeError } from '../../common/ApiConstants';
|
||||
import { api, RequestError } from '../../utils/Request';
|
||||
import { FontScaleLevel, FONT_SCALE_MAP } from '../../utils/FontScale';
|
||||
|
||||
// 家属绑定请求体
|
||||
interface FamilyBindPayload {
|
||||
elderly_name: string;
|
||||
elderly_id_card: string;
|
||||
relation?: string;
|
||||
}
|
||||
|
||||
@Entry
|
||||
@Component
|
||||
struct FamilyBind {
|
||||
@State elderlyName: string = '';
|
||||
@State idCard: string = '';
|
||||
@State relation: string = '';
|
||||
@State submitting: boolean = false;
|
||||
@StorageProp('fontScale') fontScaleLevel: FontScaleLevel = 'system';
|
||||
|
||||
private scaleFor(): number {
|
||||
return FONT_SCALE_MAP[this.fontScaleLevel] ?? 1.0;
|
||||
}
|
||||
|
||||
private scroller: Scroller = new Scroller();
|
||||
|
||||
private async submit(): Promise<void> {
|
||||
if (this.elderlyName.trim().length === 0) {
|
||||
promptAction.showToast({ message: '请输入长者姓名' });
|
||||
return;
|
||||
}
|
||||
if (this.idCard.trim().length === 0) {
|
||||
promptAction.showToast({ message: '请输入长者身份证号' });
|
||||
return;
|
||||
}
|
||||
if (this.submitting) {
|
||||
return;
|
||||
}
|
||||
this.submitting = true;
|
||||
try {
|
||||
const body: FamilyBindPayload = {
|
||||
elderly_name: this.elderlyName.trim(),
|
||||
elderly_id_card: this.idCard.trim().toUpperCase(),
|
||||
};
|
||||
if (this.relation.trim().length > 0) {
|
||||
body.relation = this.relation.trim();
|
||||
}
|
||||
await api.post('/elderly/add/', body);
|
||||
promptAction.showToast({ message: '绑定成功' });
|
||||
router.back();
|
||||
} catch (err) {
|
||||
const message = err instanceof RequestError ? err.message : normalizeError(err as BusinessError | Error);
|
||||
promptAction.showToast({ message: message });
|
||||
} finally {
|
||||
this.submitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
@Builder
|
||||
Field(label: string, placeholder: string, value: string, onChange: (v: string) => void, type?: number) {
|
||||
Column({ space: 6 }) {
|
||||
Text(label)
|
||||
.fontSize(Math.round(14 * this.scaleFor()))
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.width('100%')
|
||||
TextInput({ placeholder: placeholder, text: value })
|
||||
.placeholderColor($r('app.color.inactive_tab'))
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.type(type !== undefined ? type : InputType.Normal)
|
||||
.height(44)
|
||||
.padding({ left: 12, right: 12 })
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.borderRadius(10)
|
||||
.onChange((v: string) => {
|
||||
onChange(v);
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
}
|
||||
|
||||
build() {
|
||||
HdsNavigation() {
|
||||
Column() {
|
||||
Scroll(this.scroller) {
|
||||
Column({ space: 14 }) {
|
||||
Column({ space: 8 }) {
|
||||
Text('👨👩👧 添加我的长者')
|
||||
.fontSize(Math.round(20 * this.scaleFor()))
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
Text('输入长者的姓名与身份证号,核验后即可查看长者的护理记录与健康信息')
|
||||
.fontSize(Math.round(14 * this.scaleFor()))
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
|
||||
Column({ space: 12 }) {
|
||||
this.Field('长者姓名 *', '请输入长者姓名', this.elderlyName, (v: string) => {
|
||||
this.elderlyName = v;
|
||||
})
|
||||
this.Field('身份证号 *', '请输入 18 位身份证号', this.idCard, (v: string) => {
|
||||
this.idCard = v;
|
||||
})
|
||||
this.Field('与长者关系', '如:家属 / 子女', this.relation, (v: string) => {
|
||||
this.relation = v;
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
.padding(16)
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.borderRadius(14)
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
|
||||
Button(this.submitting ? '提交中...' : '确认绑定')
|
||||
.width('100%')
|
||||
.height(50)
|
||||
.fontSize(Math.round(16 * this.scaleFor()))
|
||||
.fontColor('#FFFFFF')
|
||||
.backgroundColor($r('app.color.accent_blue'))
|
||||
.borderRadius(12)
|
||||
.enabled(!this.submitting)
|
||||
.onClick(() => {
|
||||
this.submit();
|
||||
})
|
||||
}
|
||||
.width('92%')
|
||||
.padding({ top: 60, bottom: 30 })
|
||||
.alignItems(HorizontalAlign.Center)
|
||||
}
|
||||
.scrollBar(BarState.Auto)
|
||||
.edgeEffect(EdgeEffect.Spring)
|
||||
.layoutWeight(1)
|
||||
.width('100%')
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
}
|
||||
.mode(NavigationMode.Stack)
|
||||
.titleBar({
|
||||
content: {
|
||||
title: {
|
||||
mainTitle: '绑定长者',
|
||||
},
|
||||
},
|
||||
style: {
|
||||
scrollEffectOpts: {
|
||||
enableScrollEffect: false,
|
||||
scrollEffectType: ScrollEffectType.GRADIENT_BLUR,
|
||||
},
|
||||
systemMaterialEffect: {
|
||||
materialType: hdsMaterial.MaterialType.ADAPTIVE,
|
||||
materialLevel: hdsMaterial.MaterialLevel.EXQUISITE,
|
||||
},
|
||||
},
|
||||
avoidLayoutSafeArea: false,
|
||||
enableComponentSafeArea: false,
|
||||
})
|
||||
.bindToScrollable([this.scroller])
|
||||
.hideBackButton(false)
|
||||
.titleMode(HdsNavigationTitleMode.MINI)
|
||||
.ignoreLayoutSafeArea([LayoutSafeAreaType.SYSTEM], [LayoutSafeAreaEdge.TOP, LayoutSafeAreaEdge.BOTTOM])
|
||||
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
|
||||
.backgroundColor($r('app.color.background'))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
// 家属 · 长者档案列表(ArkTS 严格类型,无 any)
|
||||
// 接口:GET /elderly/
|
||||
import { router, promptAction } from '@kit.ArkUI';
|
||||
import { HdsNavigation, HdsNavigationTitleMode, ScrollEffectType } from '@kit.UIDesignKit';
|
||||
import { hdsMaterial } from '@hms.hds.hdsMaterial';
|
||||
import { BusinessError } from '@kit.BasicServicesKit';
|
||||
import { Page, normalizeError } from '../../common/ApiConstants';
|
||||
import { api, RequestError } from '../../utils/Request';
|
||||
import { FontScaleLevel, FONT_SCALE_MAP } from '../../utils/FontScale';
|
||||
|
||||
interface FamilyElderlyItem {
|
||||
id: number;
|
||||
name: string;
|
||||
gender: string;
|
||||
age: number;
|
||||
room_bed: string;
|
||||
care_level_label: string;
|
||||
overview_status: string;
|
||||
risks: Array<string>;
|
||||
}
|
||||
|
||||
function genderLabel(gender: string): string {
|
||||
if (gender === 'male') {
|
||||
return '男';
|
||||
}
|
||||
if (gender === 'female') {
|
||||
return '女';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
@Entry
|
||||
@Component
|
||||
struct FamilyElderly {
|
||||
@StorageProp('fontScale') fontScaleLevel: FontScaleLevel = 'system';
|
||||
|
||||
private scaleFor(): number {
|
||||
return FONT_SCALE_MAP[this.fontScaleLevel] ?? 1.0;
|
||||
}
|
||||
@State elderly: Array<FamilyElderlyItem> = [];
|
||||
@State loading: boolean = true;
|
||||
|
||||
private scroller: Scroller = new Scroller();
|
||||
|
||||
aboutToAppear(): void {
|
||||
this.load();
|
||||
}
|
||||
|
||||
private async load(): Promise<void> {
|
||||
this.loading = true;
|
||||
try {
|
||||
const res = await api.get<Page<FamilyElderlyItem>>('/elderly/', { page_size: 50 });
|
||||
this.elderly = res.results;
|
||||
} catch (err) {
|
||||
const message = err instanceof RequestError ? err.message : normalizeError(err as BusinessError | Error);
|
||||
promptAction.showToast({ message: message });
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private openDetail(item: FamilyElderlyItem): void {
|
||||
router.pushUrl({ url: 'pages/family/FamilyElderlyDetail', params: { id: item.id } })
|
||||
.catch((err: BusinessError) => {
|
||||
promptAction.showToast({ message: `打开失败:${normalizeError(err)}` });
|
||||
});
|
||||
}
|
||||
|
||||
@Builder
|
||||
ElderlyCard(item: FamilyElderlyItem) {
|
||||
Row({ space: 12 }) {
|
||||
Column() {
|
||||
Text(item.name.substring(0, 1))
|
||||
.fontSize(Math.round(20 * this.scaleFor()))
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor('#FFFFFF')
|
||||
}
|
||||
.width(52)
|
||||
.height(52)
|
||||
.justifyContent(FlexAlign.Center)
|
||||
.alignItems(HorizontalAlign.Center)
|
||||
.backgroundColor($r('app.color.accent_blue'))
|
||||
.borderRadius(16)
|
||||
|
||||
Column({ space: 6 }) {
|
||||
Row({ space: 8 }) {
|
||||
Text(item.name)
|
||||
.fontSize(Math.round(17 * this.scaleFor()))
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
Text(`${item.age}岁 · ${genderLabel(item.gender)}`)
|
||||
.fontSize(Math.round(13 * this.scaleFor()))
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
Blank()
|
||||
Text(item.overview_status)
|
||||
.fontSize(Math.round(12 * this.scaleFor()))
|
||||
.fontColor($r('app.color.accent_green'))
|
||||
}
|
||||
.width('100%')
|
||||
|
||||
Text(item.care_level_label)
|
||||
.fontSize(Math.round(13 * this.scaleFor()))
|
||||
.fontColor($r('app.color.accent_blue'))
|
||||
.width('100%')
|
||||
|
||||
Row({ space: 8 }) {
|
||||
if (item.room_bed && item.room_bed.length > 0) {
|
||||
Text(item.room_bed)
|
||||
.fontSize(Math.round(12 * this.scaleFor()))
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
}
|
||||
if (item.risks && item.risks.length > 0) {
|
||||
Text(item.risks.join('、'))
|
||||
.fontSize(Math.round(12 * this.scaleFor()))
|
||||
.fontColor('#FF9500')
|
||||
.maxLines(1)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
.layoutWeight(1)
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
}
|
||||
.layoutWeight(1)
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
|
||||
Text('›')
|
||||
.fontSize(Math.round(20 * this.scaleFor()))
|
||||
.fontColor($r('app.color.inactive_tab'))
|
||||
}
|
||||
.width('100%')
|
||||
.padding(14)
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.borderRadius(14)
|
||||
.onClick(() => {
|
||||
this.openDetail(item);
|
||||
})
|
||||
}
|
||||
|
||||
build() {
|
||||
HdsNavigation() {
|
||||
Column() {
|
||||
Scroll(this.scroller) {
|
||||
Column({ space: 12 }) {
|
||||
if (this.loading) {
|
||||
Text('加载中...')
|
||||
.fontSize(Math.round(15 * this.scaleFor()))
|
||||
.fontColor($r('app.color.inactive_tab'))
|
||||
.margin({ top: 40 })
|
||||
} else if (this.elderly.length === 0) {
|
||||
Text('暂无绑定的长者')
|
||||
.fontSize(Math.round(15 * this.scaleFor()))
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
.width('100%')
|
||||
.padding(24)
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.borderRadius(14)
|
||||
.textAlign(TextAlign.Center)
|
||||
} else {
|
||||
ForEach(this.elderly, (item: FamilyElderlyItem) => {
|
||||
Column() {
|
||||
this.ElderlyCard(item)
|
||||
}
|
||||
.width('100%')
|
||||
}, (item: FamilyElderlyItem) => String(item.id))
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ left: 16, right: 16, top: 92, bottom: 30 })
|
||||
}
|
||||
.width('100%')
|
||||
.layoutWeight(1)
|
||||
.scrollBar(BarState.Auto)
|
||||
.edgeEffect(EdgeEffect.Spring)
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
}
|
||||
.mode(NavigationMode.Stack)
|
||||
.titleBar({
|
||||
content: {
|
||||
title: {
|
||||
mainTitle: '长者档案',
|
||||
},
|
||||
},
|
||||
style: {
|
||||
scrollEffectOpts: {
|
||||
enableScrollEffect: false,
|
||||
scrollEffectType: ScrollEffectType.GRADIENT_BLUR,
|
||||
},
|
||||
systemMaterialEffect: {
|
||||
materialType: hdsMaterial.MaterialType.ADAPTIVE,
|
||||
materialLevel: hdsMaterial.MaterialLevel.EXQUISITE,
|
||||
},
|
||||
},
|
||||
avoidLayoutSafeArea: false,
|
||||
enableComponentSafeArea: false,
|
||||
})
|
||||
.bindToScrollable([this.scroller])
|
||||
.hideBackButton(false)
|
||||
.titleMode(HdsNavigationTitleMode.MINI)
|
||||
.ignoreLayoutSafeArea([LayoutSafeAreaType.SYSTEM], [LayoutSafeAreaEdge.TOP, LayoutSafeAreaEdge.BOTTOM])
|
||||
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
|
||||
.backgroundColor($r('app.color.background'))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
// 家属 · 长者档案详情(ArkTS 严格类型,无 any)
|
||||
// 接口:GET /elderly/{id}/
|
||||
import { router, promptAction } from '@kit.ArkUI';
|
||||
import { HdsNavigation, HdsNavigationTitleMode, ScrollEffectType } from '@kit.UIDesignKit';
|
||||
import { hdsMaterial } from '@hms.hds.hdsMaterial';
|
||||
import { BusinessError } from '@kit.BasicServicesKit';
|
||||
import { normalizeError } from '../../common/ApiConstants';
|
||||
import { api, RequestError } from '../../utils/Request';
|
||||
import { KnockShareSession, KnockSharePayload, HealthReportData } from '../../utils/KnockShare';
|
||||
import { FontScaleLevel, FONT_SCALE_MAP } from '../../utils/FontScale';
|
||||
|
||||
interface ElderlyDetail {
|
||||
id: number;
|
||||
name: string;
|
||||
gender: string;
|
||||
age: number;
|
||||
floor_room_bed: string;
|
||||
room_bed: string;
|
||||
care_level_label: string;
|
||||
overview_status: string;
|
||||
medical_history: string;
|
||||
allergies: string;
|
||||
medications: Array<string>;
|
||||
emergency_contact: string;
|
||||
emergency_phone: string;
|
||||
health_score: number | null;
|
||||
risks: Array<string>;
|
||||
current_caregiver_name: string;
|
||||
primary_family_name: string;
|
||||
primary_family_relation: string;
|
||||
is_active: boolean;
|
||||
}
|
||||
|
||||
interface HealthReportRow {
|
||||
id: number;
|
||||
title: string;
|
||||
period_start: string;
|
||||
period_end: string;
|
||||
summary: string;
|
||||
llm_analysis: string;
|
||||
is_generated: boolean;
|
||||
}
|
||||
|
||||
interface HealthReportListResp {
|
||||
results?: Array<HealthReportRow>;
|
||||
}
|
||||
|
||||
function genderLabel(gender: string): string {
|
||||
if (gender === 'male') {
|
||||
return '男';
|
||||
}
|
||||
if (gender === 'female') {
|
||||
return '女';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
@Entry
|
||||
@Component
|
||||
struct FamilyElderlyDetail {
|
||||
@State detail: ElderlyDetail | null = null;
|
||||
@State loading: boolean = true;
|
||||
@State latestReport: HealthReportRow | null = null;
|
||||
@StorageProp('fontScale') fontScaleLevel: FontScaleLevel = 'system';
|
||||
|
||||
private scaleFor(): number {
|
||||
return FONT_SCALE_MAP[this.fontScaleLevel] ?? 1.0;
|
||||
}
|
||||
|
||||
private scroller: Scroller = new Scroller();
|
||||
private knockSession: KnockShareSession = new KnockShareSession();
|
||||
|
||||
aboutToAppear(): void {
|
||||
const params = router.getParams() as Record<string, string | number>;
|
||||
const id = params ? params['id'] : null;
|
||||
if (id === null || id === undefined) {
|
||||
router.back();
|
||||
return;
|
||||
}
|
||||
this.load(Number(id));
|
||||
this.registerKnockShare();
|
||||
}
|
||||
|
||||
aboutToDisappear(): void {
|
||||
this.knockSession.unregister();
|
||||
}
|
||||
|
||||
// 碰一碰分享:两台设备轻触即把长者健康报告传给对方
|
||||
private registerKnockShare(): void {
|
||||
this.knockSession.register((): KnockSharePayload | null => {
|
||||
const d = this.detail;
|
||||
if (d === null) {
|
||||
return null;
|
||||
}
|
||||
const report: HealthReportData = {
|
||||
name: d.name,
|
||||
gender: genderLabel(d.gender),
|
||||
age: d.age,
|
||||
room: d.floor_room_bed || d.room_bed || '',
|
||||
care_level: d.care_level_label,
|
||||
health_score: d.health_score,
|
||||
status: d.overview_status,
|
||||
risks: d.risks ?? [],
|
||||
medical_history: d.medical_history,
|
||||
allergies: d.allergies,
|
||||
medications: d.medications ?? [],
|
||||
caregiver: d.current_caregiver_name,
|
||||
emergency_contact: (d.emergency_contact || '') + (d.emergency_phone ? ` ${d.emergency_phone}` : ''),
|
||||
shared_at: new Date().toLocaleString('zh-CN', { hour12: false }),
|
||||
report_title: this.latestReport !== null ? this.latestReport.title : '',
|
||||
report_period: this.latestReport !== null ? `${this.latestReport.period_start} ~ ${this.latestReport.period_end}` : '',
|
||||
report_summary: this.latestReport !== null ? this.latestReport.summary : '',
|
||||
report_ai: this.latestReport !== null ? this.latestReport.llm_analysis : ''
|
||||
};
|
||||
const desc = this.latestReport !== null
|
||||
? `${this.latestReport.title} · 含 AI 分析`
|
||||
: `${d.age}岁 · ${d.care_level_label} · 健康评分 ${d.health_score !== null ? d.health_score : '--'}`;
|
||||
return {
|
||||
title: `数智护老健康报告 · ${d.name}`,
|
||||
description: desc,
|
||||
url: `smartsenior://report?data=${encodeURIComponent(JSON.stringify(report))}`
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private knockShareHint(): void {
|
||||
promptAction.showToast({ message: '请与对方设备轻触,即可分享健康报告', duration: 2500 });
|
||||
}
|
||||
|
||||
private async load(id: number): Promise<void> {
|
||||
this.loading = true;
|
||||
try {
|
||||
const res = await api.get<ElderlyDetail>(`/elderly/${id}/`);
|
||||
this.detail = res;
|
||||
} catch (err) {
|
||||
const message = err instanceof RequestError ? err.message : normalizeError(err as BusinessError | Error);
|
||||
promptAction.showToast({ message: message });
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
// 最新一期 AI 健康报告:碰一碰分享内容带上 AI 分析
|
||||
try {
|
||||
const rep = await api.get<HealthReportListResp | Array<HealthReportRow>>(`/reports/?elderly=${id}`);
|
||||
const rows: Array<HealthReportRow> = Array.isArray(rep) ? rep : (rep.results ?? []);
|
||||
this.latestReport = rows.length > 0 ? rows[0] : null;
|
||||
} catch (err) {
|
||||
console.warn('[FamilyElderlyDetail] reports load failed:', (err as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
private openHistory(id: number): void {
|
||||
router.pushUrl({ url: 'pages/family/FamilyElderlyHistory', params: { id: id } })
|
||||
.catch((err: BusinessError) => {
|
||||
promptAction.showToast({ message: `打开失败:${normalizeError(err)}` });
|
||||
});
|
||||
}
|
||||
|
||||
private openHealthReport(id: number): void {
|
||||
router.pushUrl({ url: 'pages/family/FamilyHealthReport', params: { id: id } })
|
||||
.catch((err: BusinessError) => {
|
||||
promptAction.showToast({ message: `打开失败:${normalizeError(err)}` });
|
||||
});
|
||||
}
|
||||
|
||||
@Builder
|
||||
InfoRow(label: string, value: string) {
|
||||
Row({ space: 12 }) {
|
||||
Text(label)
|
||||
.fontSize(Math.round(14 * this.scaleFor()))
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
.width(72)
|
||||
Text(value)
|
||||
.fontSize(Math.round(14 * this.scaleFor()))
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.layoutWeight(1)
|
||||
}
|
||||
.width('100%')
|
||||
}
|
||||
|
||||
build() {
|
||||
HdsNavigation() {
|
||||
Column() {
|
||||
Scroll(this.scroller) {
|
||||
Column({ space: 12 }) {
|
||||
if (this.loading) {
|
||||
Text('加载中...')
|
||||
.fontSize(Math.round(15 * this.scaleFor()))
|
||||
.fontColor($r('app.color.inactive_tab'))
|
||||
.margin({ top: 40 })
|
||||
} else if (this.detail) {
|
||||
// 头部
|
||||
Row({ space: 12 }) {
|
||||
Column() {
|
||||
Text(this.detail.name.substring(0, 1))
|
||||
.fontSize(Math.round(24 * this.scaleFor()))
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor('#FFFFFF')
|
||||
}
|
||||
.width(64)
|
||||
.height(64)
|
||||
.justifyContent(FlexAlign.Center)
|
||||
.alignItems(HorizontalAlign.Center)
|
||||
.backgroundColor($r('app.color.accent_blue'))
|
||||
.borderRadius(20)
|
||||
|
||||
Column({ space: 4 }) {
|
||||
Row({ space: 8 }) {
|
||||
Text(this.detail.name)
|
||||
.fontSize(Math.round(22 * this.scaleFor()))
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
Text(`${this.detail.age}岁 · ${genderLabel(this.detail.gender)}`)
|
||||
.fontSize(Math.round(14 * this.scaleFor()))
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
}
|
||||
.width('100%')
|
||||
Text(this.detail.care_level_label)
|
||||
.fontSize(Math.round(14 * this.scaleFor()))
|
||||
.fontColor($r('app.color.accent_blue'))
|
||||
.width('100%')
|
||||
}
|
||||
.layoutWeight(1)
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
}
|
||||
.width('100%')
|
||||
.padding(16)
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.borderRadius(14)
|
||||
|
||||
// 档案信息
|
||||
Column({ space: 12 }) {
|
||||
Text('基本信息')
|
||||
.fontSize(Math.round(16 * this.scaleFor()))
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.width('100%')
|
||||
this.InfoRow('床位', this.detail.floor_room_bed || this.detail.room_bed || '--')
|
||||
this.InfoRow('健康评分', this.detail.health_score !== null ? String(this.detail.health_score) : '--')
|
||||
this.InfoRow('主责护理员', this.detail.current_caregiver_name || '--')
|
||||
this.InfoRow('紧急联系人', (this.detail.emergency_contact || '') + (this.detail.emergency_phone ? ` ${this.detail.emergency_phone}` : '') || '--')
|
||||
if (this.detail.primary_family_name && this.detail.primary_family_name.length > 0) {
|
||||
this.InfoRow('绑定家属', `${this.detail.primary_family_name}${this.detail.primary_family_relation ? '(' + this.detail.primary_family_relation + ')' : ''}`)
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.padding(16)
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.borderRadius(14)
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
|
||||
// 健康信息
|
||||
Column({ space: 12 }) {
|
||||
Text('健康信息')
|
||||
.fontSize(Math.round(16 * this.scaleFor()))
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.width('100%')
|
||||
this.InfoRow('风险标签', this.detail.risks && this.detail.risks.length > 0 ? this.detail.risks.join('、') : '--')
|
||||
this.InfoRow('病史', this.detail.medical_history || '--')
|
||||
this.InfoRow('过敏', this.detail.allergies || '--')
|
||||
this.InfoRow('常用药物', this.detail.medications && this.detail.medications.length > 0 ? this.detail.medications.join('、') : '--')
|
||||
}
|
||||
.width('100%')
|
||||
.padding(16)
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.borderRadius(14)
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
|
||||
Button('AI 健康报告')
|
||||
.width('100%')
|
||||
.height(48)
|
||||
.fontColor($r('app.color.accent_blue'))
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.borderRadius(12)
|
||||
.border({ width: 1, color: $r('app.color.accent_blue') })
|
||||
.onClick(() => {
|
||||
this.openHealthReport(this.detail!.id);
|
||||
})
|
||||
|
||||
Button('碰一碰分享健康报告')
|
||||
.width('100%')
|
||||
.height(48)
|
||||
.fontColor($r('app.color.accent_blue'))
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.borderRadius(12)
|
||||
.border({ width: 1, color: $r('app.color.accent_blue') })
|
||||
.onClick(() => {
|
||||
this.knockShareHint();
|
||||
})
|
||||
|
||||
Button('查看护理记录')
|
||||
.width('100%')
|
||||
.height(48)
|
||||
.fontColor('#FFFFFF')
|
||||
.backgroundColor($r('app.color.accent_blue'))
|
||||
.borderRadius(12)
|
||||
.onClick(() => {
|
||||
this.openHistory(this.detail!.id);
|
||||
})
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ left: 16, right: 16, top: 92, bottom: 30 })
|
||||
}
|
||||
.width('100%')
|
||||
.layoutWeight(1)
|
||||
.scrollBar(BarState.Auto)
|
||||
.edgeEffect(EdgeEffect.Spring)
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
}
|
||||
.mode(NavigationMode.Stack)
|
||||
.titleBar({
|
||||
content: {
|
||||
title: {
|
||||
mainTitle: '长者档案',
|
||||
},
|
||||
},
|
||||
style: {
|
||||
scrollEffectOpts: {
|
||||
enableScrollEffect: false,
|
||||
scrollEffectType: ScrollEffectType.GRADIENT_BLUR,
|
||||
},
|
||||
systemMaterialEffect: {
|
||||
materialType: hdsMaterial.MaterialType.ADAPTIVE,
|
||||
materialLevel: hdsMaterial.MaterialLevel.EXQUISITE,
|
||||
},
|
||||
},
|
||||
avoidLayoutSafeArea: false,
|
||||
enableComponentSafeArea: false,
|
||||
})
|
||||
.bindToScrollable([this.scroller])
|
||||
.hideBackButton(false)
|
||||
.titleMode(HdsNavigationTitleMode.MINI)
|
||||
.ignoreLayoutSafeArea([LayoutSafeAreaType.SYSTEM], [LayoutSafeAreaEdge.TOP, LayoutSafeAreaEdge.BOTTOM])
|
||||
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
|
||||
.backgroundColor($r('app.color.background'))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
// 家属 · 长者护理记录历史(ArkTS 严格类型,无 any)
|
||||
// 接口:GET /tasks/nursing-records/?elderly={id}
|
||||
import { router, promptAction } from '@kit.ArkUI';
|
||||
import { HdsNavigation, HdsNavigationTitleMode, ScrollEffectType } from '@kit.UIDesignKit';
|
||||
import { hdsMaterial } from '@hms.hds.hdsMaterial';
|
||||
import { BusinessError } from '@kit.BasicServicesKit';
|
||||
import { Page, normalizeError } from '../../common/ApiConstants';
|
||||
import { api, RequestError } from '../../utils/Request';
|
||||
import { FontScaleLevel, FONT_SCALE_MAP } from '../../utils/FontScale';
|
||||
|
||||
interface HistoryRecord {
|
||||
id: number;
|
||||
raw_text: string;
|
||||
nurse_name: string;
|
||||
elderly_name: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface ElderlyLite {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
function formatDateTime(iso: string): string {
|
||||
if (!iso || iso.length === 0) {
|
||||
return '--';
|
||||
}
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) {
|
||||
return '--';
|
||||
}
|
||||
const mm = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const dd = String(d.getDate()).padStart(2, '0');
|
||||
const hh = String(d.getHours()).padStart(2, '0');
|
||||
const mi = String(d.getMinutes()).padStart(2, '0');
|
||||
return `${mm}/${dd} ${hh}:${mi}`;
|
||||
}
|
||||
|
||||
@Entry
|
||||
@Component
|
||||
struct FamilyElderlyHistory {
|
||||
@StorageProp('fontScale') fontScaleLevel: FontScaleLevel = 'system';
|
||||
|
||||
private scaleFor(): number {
|
||||
return FONT_SCALE_MAP[this.fontScaleLevel] ?? 1.0;
|
||||
}
|
||||
@State elderlyId: number = 0;
|
||||
@State elderlyName: string = '';
|
||||
@State records: Array<HistoryRecord> = [];
|
||||
@State loading: boolean = true;
|
||||
|
||||
private scroller: Scroller = new Scroller();
|
||||
|
||||
aboutToAppear(): void {
|
||||
const params = router.getParams() as Record<string, string | number>;
|
||||
if (params) {
|
||||
const id = params['id'];
|
||||
const name = params['name'];
|
||||
if (id !== undefined) {
|
||||
this.elderlyId = Number(id);
|
||||
}
|
||||
if (typeof name === 'string') {
|
||||
this.elderlyName = name;
|
||||
}
|
||||
}
|
||||
if (this.elderlyId === 0) {
|
||||
this.resolveFirstElderly();
|
||||
} else {
|
||||
this.load();
|
||||
}
|
||||
}
|
||||
|
||||
// 未指定长者(如从"长者历史"入口进入)时自动取绑定长者的第一条
|
||||
private async resolveFirstElderly(): Promise<void> {
|
||||
try {
|
||||
const res = await api.get<Page<ElderlyLite>>('/elderly/', { page_size: 1 });
|
||||
if (res.results.length > 0) {
|
||||
const first: ElderlyLite = res.results[0];
|
||||
this.elderlyId = first.id;
|
||||
this.elderlyName = first.name;
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[FamilyElderlyHistory] resolve first elderly failed:', normalizeError(err as BusinessError | Error));
|
||||
}
|
||||
this.load();
|
||||
}
|
||||
|
||||
private async load(): Promise<void> {
|
||||
this.loading = true;
|
||||
try {
|
||||
const res = await api.get<Page<HistoryRecord>>('/tasks/nursing-records/', {
|
||||
elderly: this.elderlyId,
|
||||
page_size: 50,
|
||||
ordering: '-created_at',
|
||||
});
|
||||
this.records = res.results;
|
||||
} catch (err) {
|
||||
const message = err instanceof RequestError ? err.message : normalizeError(err as BusinessError | Error);
|
||||
promptAction.showToast({ message: message });
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
@Builder
|
||||
RecordCard(record: HistoryRecord) {
|
||||
Column({ space: 6 }) {
|
||||
Row() {
|
||||
Text(record.nurse_name || '护理员')
|
||||
.fontSize(Math.round(14 * this.scaleFor()))
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor($r('app.color.accent_blue'))
|
||||
Blank()
|
||||
Text(formatDateTime(record.created_at))
|
||||
.fontSize(Math.round(12 * this.scaleFor()))
|
||||
.fontColor($r('app.color.inactive_tab'))
|
||||
}
|
||||
.width('100%')
|
||||
Text(record.raw_text)
|
||||
.fontSize(Math.round(14 * this.scaleFor()))
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.lineHeight(Math.round(21 * this.scaleFor()))
|
||||
.width('100%')
|
||||
}
|
||||
.width('100%')
|
||||
.padding(14)
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.borderRadius(12)
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
}
|
||||
|
||||
build() {
|
||||
HdsNavigation() {
|
||||
Column() {
|
||||
Scroll(this.scroller) {
|
||||
Column({ space: 12 }) {
|
||||
if (this.loading) {
|
||||
Text('加载中...')
|
||||
.fontSize(Math.round(15 * this.scaleFor()))
|
||||
.fontColor($r('app.color.inactive_tab'))
|
||||
.margin({ top: 40 })
|
||||
} else if (this.records.length === 0) {
|
||||
Text('暂无护理记录')
|
||||
.fontSize(Math.round(15 * this.scaleFor()))
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
.width('100%')
|
||||
.padding(24)
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.borderRadius(14)
|
||||
.textAlign(TextAlign.Center)
|
||||
} else {
|
||||
ForEach(this.records, (record: HistoryRecord) => {
|
||||
Column() {
|
||||
this.RecordCard(record)
|
||||
}
|
||||
.width('100%')
|
||||
}, (record: HistoryRecord) => String(record.id))
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ left: 16, right: 16, top: 92, bottom: 30 })
|
||||
}
|
||||
.width('100%')
|
||||
.layoutWeight(1)
|
||||
.scrollBar(BarState.Auto)
|
||||
.edgeEffect(EdgeEffect.Spring)
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
}
|
||||
.mode(NavigationMode.Stack)
|
||||
.titleBar({
|
||||
content: {
|
||||
title: {
|
||||
mainTitle: '长者历史',
|
||||
},
|
||||
},
|
||||
style: {
|
||||
scrollEffectOpts: {
|
||||
enableScrollEffect: false,
|
||||
scrollEffectType: ScrollEffectType.GRADIENT_BLUR,
|
||||
},
|
||||
systemMaterialEffect: {
|
||||
materialType: hdsMaterial.MaterialType.ADAPTIVE,
|
||||
materialLevel: hdsMaterial.MaterialLevel.EXQUISITE,
|
||||
},
|
||||
},
|
||||
avoidLayoutSafeArea: false,
|
||||
enableComponentSafeArea: false,
|
||||
})
|
||||
.bindToScrollable([this.scroller])
|
||||
.hideBackButton(false)
|
||||
.titleMode(HdsNavigationTitleMode.MINI)
|
||||
.ignoreLayoutSafeArea([LayoutSafeAreaType.SYSTEM], [LayoutSafeAreaEdge.TOP, LayoutSafeAreaEdge.BOTTOM])
|
||||
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
|
||||
.backgroundColor($r('app.color.background'))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,422 @@
|
||||
// 家属 · AI 健康报告:页面直接预览健康风险报告 PDF,并交给端侧智能体解读
|
||||
// 链路:GET /reports/health-risk/pdf/ → 沙箱 → PdfView 预览 → 抽文本(文字层/端侧 OCR)→ 智能体同沙箱读取
|
||||
import { router, promptAction } from '@kit.ArkUI';
|
||||
import { HdsNavigation, HdsNavigationTitleMode, ScrollEffectType } from '@kit.UIDesignKit';
|
||||
import { hdsMaterial } from '@hms.hds.hdsMaterial';
|
||||
import { BusinessError, deviceInfo } from '@kit.BasicServicesKit';
|
||||
import { common } from '@kit.AbilityKit';
|
||||
import { PdfView, pdfService, pdfViewManager } from '@kit.PDFKit';
|
||||
import { AgentController, FunctionComponent, FunctionOptions, ButtonType } from '@kit.AgentFrameworkKit';
|
||||
import { normalizeError } from '../../common/ApiConstants';
|
||||
import { api, RequestError } from '../../utils/Request';
|
||||
import { FontScaleLevel, FONT_SCALE_MAP } from '../../utils/FontScale';
|
||||
import {
|
||||
downloadHealthRiskPdf,
|
||||
extractPdfText,
|
||||
fetchReportTextFromServer,
|
||||
cacheReportContextForAgent,
|
||||
clearReportContext,
|
||||
readReportContext,
|
||||
hasLocalReportPdf,
|
||||
reportPdfPath,
|
||||
loadPdfForPreview,
|
||||
releasePdfPreview,
|
||||
} from '../../utils/HealthReportPdf';
|
||||
|
||||
// 传给智能体的报告正文上限(queryText 过长会被截断或拒绝)
|
||||
const AGENT_INLINE_MAX_CHARS = 1200;
|
||||
|
||||
// 报告总结专用:小艺开放平台的"单Agent"(云侧 DeepSeek-V3),不关联端侧智能体扩展
|
||||
// 这样点按钮后由云侧模型直接依据 queryText 里的报告正文总结,不会被 A2AServer 抢答、也不依赖我们后端
|
||||
// (老人端首页仍用端 A2A 智能体 agent8ca28632414640028b76ee6bf69efa80,两者互不影响)
|
||||
const REPORT_SUMMARY_AGENT_ID = 'agent3f66fdeb52504b99a12288b691df2eab';
|
||||
|
||||
interface HealthReportRow {
|
||||
id: number;
|
||||
title: string;
|
||||
period_start: string;
|
||||
period_end: string;
|
||||
summary: string;
|
||||
llm_analysis: string;
|
||||
is_generated: boolean;
|
||||
elderly_name?: string;
|
||||
}
|
||||
|
||||
interface HealthReportListResp {
|
||||
results?: Array<HealthReportRow>;
|
||||
}
|
||||
|
||||
@Entry
|
||||
@Component
|
||||
struct FamilyHealthReport {
|
||||
// PDF 顶部占位高度:避免报告内容被 HDS 标题栏遮挡
|
||||
// 内容区从屏幕顶端算起,所以取 状态栏(~38) + HDS MINI 标题栏(~56) ≈ 94,留一点余量取 96
|
||||
private readonly PDF_TOP_BLANK: number = 96;
|
||||
@StorageProp('fontScale') fontScaleLevel: FontScaleLevel = 'system';
|
||||
private scaleFor(): number {
|
||||
return FONT_SCALE_MAP[this.fontScaleLevel] ?? 1.0;
|
||||
}
|
||||
|
||||
// 浮在 PDF 上的控件单独收窄字体缩放:PDF 是固定尺寸的,
|
||||
// 大字档(xlarge=1.9)会把状态文案和按钮撑到互相重叠、盖住报告
|
||||
private overlayScaleFor(): number {
|
||||
const scale = this.scaleFor();
|
||||
return scale > 1.2 ? 1.2 : scale;
|
||||
}
|
||||
|
||||
// 智能体按钮的提问:把报告正文直接带给小艺,让云侧模型(DeepSeek-V3)总结
|
||||
// FunctionOptions 只有 queryText 一个能带内容进去的字段,正文太长会被截断,这里限长
|
||||
private agentQueryText(): string {
|
||||
const cleaned = this.cleanReportForAgent(this.reportText);
|
||||
const body = cleaned.length > AGENT_INLINE_MAX_CHARS
|
||||
? cleaned.substring(0, AGENT_INLINE_MAX_CHARS)
|
||||
: cleaned;
|
||||
if (body.length === 0) {
|
||||
return '帮我总结这份报告';
|
||||
}
|
||||
return `请直接总结下面这份健康风险报告,面向家属用中文给出 3-5 条要点,只使用报告里出现的数据,不要编造:\n${body}`;
|
||||
}
|
||||
|
||||
// 去掉 OCR 带出来的页脚噪声:页码、"本报告由…自动生成"、重复的生成时间、免责声明
|
||||
// 这些既撑长对话气泡,也会干扰模型抓重点
|
||||
private cleanReportForAgent(text: string): string {
|
||||
const kept: string[] = [];
|
||||
for (const raw of text.split('\n')) {
|
||||
const line = raw.trim();
|
||||
if (line.length === 0) {
|
||||
continue;
|
||||
}
|
||||
if (line.indexOf('页/共') >= 0 || line.indexOf('页/ 共') >= 0) {
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith('本报告') || line.startsWith('生成时间') || line.startsWith('内容由')) {
|
||||
continue;
|
||||
}
|
||||
kept.push(line);
|
||||
}
|
||||
return kept.join('\n');
|
||||
}
|
||||
@State reports: Array<HealthReportRow> = [];
|
||||
@State selected: HealthReportRow | null = null;
|
||||
// 端侧智能体总结:报告上下文准备状态
|
||||
@State agentSupported: boolean = false;
|
||||
@State preparing: boolean = false;
|
||||
@State reportReady: boolean = false;
|
||||
@State prepMessage: string = '';
|
||||
// 已提取的报告正文:点击智能体按钮时随 queryText 一起带给小艺
|
||||
@State reportText: string = '';
|
||||
// 报告 PDF 预览状态
|
||||
@State pdfReady: boolean = false;
|
||||
@State pdfMessage: string = '正在加载报告...';
|
||||
// 标题栏(含返回按钮)沉浸光感材质等级:按设备支持能力选择
|
||||
@State customMaterialLevel: hdsMaterial.MaterialLevel = hdsMaterial.MaterialLevel.EXQUISITE;
|
||||
|
||||
private elderlyId: number = 0;
|
||||
private pdfController: pdfViewManager.PdfController = new pdfViewManager.PdfController();
|
||||
|
||||
aboutToAppear(): void {
|
||||
const params = router.getParams() as Record<string, string | number>;
|
||||
this.elderlyId = params ? Number(params['id']) : 0;
|
||||
if (!(this.elderlyId > 0)) {
|
||||
router.back();
|
||||
return;
|
||||
}
|
||||
// 设备不支持 IMMERSIVE 材质时降级为 SMOOTH,避免卡顿/发热
|
||||
const materialTypes: Array<hdsMaterial.MaterialType> = hdsMaterial.getSystemMaterialTypes();
|
||||
if (materialTypes.indexOf(hdsMaterial.MaterialType.IMMERSIVE) < 0) {
|
||||
this.customMaterialLevel = hdsMaterial.MaterialLevel.SMOOTH;
|
||||
}
|
||||
this.checkAgentSupport();
|
||||
this.load();
|
||||
this.prepareReport();
|
||||
}
|
||||
|
||||
aboutToDisappear(): void {
|
||||
releasePdfPreview(this.pdfController);
|
||||
}
|
||||
|
||||
// 检测智能体可用性(设备/华为账号),不可用则只显示普通按钮
|
||||
private checkAgentSupport(): void {
|
||||
const model = deviceInfo.productModel.toLowerCase();
|
||||
if (model.indexOf('emulator') >= 0 || model.indexOf('x86') >= 0) {
|
||||
this.agentSupported = false;
|
||||
return;
|
||||
}
|
||||
this.tryCheckAgentSupport(1);
|
||||
}
|
||||
|
||||
// isAgentSupport 偶发直接返回 false(智能体服务刚起来/未联网时),重试 3 次,任一次通过即可用
|
||||
private tryCheckAgentSupport(attempt: number): void {
|
||||
try {
|
||||
const context = this.getUIContext().getHostContext() as common.UIAbilityContext;
|
||||
new AgentController().isAgentSupport(context, REPORT_SUMMARY_AGENT_ID).then((supported: boolean) => {
|
||||
console.info(`[FamilyHealthReport] agent supported: ${supported} (attempt ${attempt})`);
|
||||
if (supported) {
|
||||
this.agentSupported = true;
|
||||
return;
|
||||
}
|
||||
this.scheduleAgentRetry(attempt);
|
||||
}).catch((err: BusinessError) => {
|
||||
console.error('[FamilyHealthReport] isAgentSupport failed:', normalizeError(err));
|
||||
this.scheduleAgentRetry(attempt);
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[FamilyHealthReport] isAgentSupport threw:', normalizeError(err as BusinessError | Error));
|
||||
this.scheduleAgentRetry(attempt);
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleAgentRetry(attempt: number): void {
|
||||
if (attempt >= 3) {
|
||||
this.agentSupported = false;
|
||||
return;
|
||||
}
|
||||
setTimeout(() => {
|
||||
this.tryCheckAgentSupport(attempt + 1);
|
||||
}, 1500);
|
||||
}
|
||||
|
||||
// 准备报告:下载 PDF → 页面预览;正文三级来源(后端正文 → PDF 文字层/端侧 OCR → 报告概要)
|
||||
// 不依赖报告列表接口:健康风险报告 PDF 是无参接口,列表为空也能准备
|
||||
private async prepareReport(): Promise<void> {
|
||||
if (this.preparing) {
|
||||
return;
|
||||
}
|
||||
this.preparing = true;
|
||||
this.reportReady = false;
|
||||
this.pdfReady = false;
|
||||
this.prepMessage = '正在准备报告...';
|
||||
this.pdfMessage = '正在加载报告...';
|
||||
try {
|
||||
const context = this.getUIContext().getHostContext() as common.UIAbilityContext;
|
||||
// 1. 页面直接预览 PDF(PDF Kit 重复 loadDocument 前必须先 release,见 loadPdfForPreview)
|
||||
// 沙箱已缓存过报告时不再阻塞在下载上:断网/后端不可用也能立刻预览并进入正文提取(OCR)
|
||||
let pdfPath = reportPdfPath(context);
|
||||
if (hasLocalReportPdf(context)) {
|
||||
console.info('[FamilyHealthReport] local pdf found, skip download');
|
||||
} else {
|
||||
try {
|
||||
pdfPath = await downloadHealthRiskPdf(context);
|
||||
} catch (err) {
|
||||
// ArkTS 不允许直接 throw unknown,包成 Error 再抛
|
||||
throw new Error(normalizeError(err as BusinessError | Error));
|
||||
}
|
||||
}
|
||||
this.pdfReady = await loadPdfForPreview(this.pdfController, pdfPath);
|
||||
this.pdfMessage = this.pdfReady ? '' : '报告预览加载失败';
|
||||
|
||||
// 2. 正文:优先后端正文接口,同时并行跑端侧提取
|
||||
// 端侧 OCR 实测两页只要 ~0.4s,并行后总耗时取决于较慢的一方;断网时只等正文接口超时,不再叠加
|
||||
const localTextPromise: Promise<string> = extractPdfText(pdfPath);
|
||||
let text = await fetchReportTextFromServer();
|
||||
let source = '后端正文';
|
||||
if (text.length === 0) {
|
||||
// 3. 退回端侧解析:文字层 / 图片型 PDF 走端侧 OCR(部分机型 OCR 服务不可用)
|
||||
text = await localTextPromise;
|
||||
source = '端侧识别';
|
||||
}
|
||||
if (text.length === 0) {
|
||||
// 4. 最后退回报告列表已有的周期总结 / AI 分析
|
||||
const current = this.selected;
|
||||
const parts: string[] = [];
|
||||
if (current !== null && current.summary.length > 0) {
|
||||
parts.push(`周期总结:${current.summary}`);
|
||||
}
|
||||
if (current !== null && current.llm_analysis.length > 0) {
|
||||
parts.push(`AI 分析:${current.llm_analysis}`);
|
||||
}
|
||||
text = parts.join('\n');
|
||||
source = '报告概要';
|
||||
}
|
||||
|
||||
if (text.length > 0) {
|
||||
const chars = cacheReportContextForAgent(text, '健康风险报告');
|
||||
this.reportText = text;
|
||||
this.reportReady = true;
|
||||
this.prepMessage = `报告已就绪(${source} ${chars} 字)`;
|
||||
} else if (readReportContext().length > 0) {
|
||||
// 取不到新正文时沿用上次内容(沙箱里预置的正文也能保住),避免智能体无数据可总结
|
||||
this.reportReady = true;
|
||||
this.prepMessage = '未取到新正文,沿用上次内容';
|
||||
} else {
|
||||
this.reportReady = false;
|
||||
this.prepMessage = '暂未取到报告正文,可稍后重试';
|
||||
clearReportContext();
|
||||
}
|
||||
} catch (err) {
|
||||
// 异常时同样不删已有正文,只有本来就空才清理
|
||||
if (readReportContext().length === 0) {
|
||||
clearReportContext();
|
||||
}
|
||||
this.reportReady = readReportContext().length > 0;
|
||||
this.prepMessage = this.reportReady ? '报告加载失败,沿用上次内容' : '报告加载失败,请稍后重试';
|
||||
this.pdfMessage = '报告加载失败';
|
||||
console.error('[FamilyHealthReport] prepare failed:', normalizeError(err as BusinessError | Error));
|
||||
} finally {
|
||||
this.preparing = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async load(): Promise<void> {
|
||||
try {
|
||||
const res = await api.get<HealthReportListResp | Array<HealthReportRow>>(`/reports/?elderly=${this.elderlyId}`);
|
||||
const rows: Array<HealthReportRow> = Array.isArray(res) ? res : (res.results ?? []);
|
||||
this.reports = rows;
|
||||
this.selected = rows.length > 0 ? rows[0] : null;
|
||||
} catch (err) {
|
||||
const message = err instanceof RequestError ? err.message : normalizeError(err as BusinessError | Error);
|
||||
promptAction.showToast({ message: message });
|
||||
}
|
||||
}
|
||||
|
||||
// 报告 PDF 全屏区域:铺满整屏(含状态栏下方),不需要预留标题栏/底部条的高度
|
||||
@Builder
|
||||
PdfPreviewCard() {
|
||||
if (this.pdfReady) {
|
||||
PdfView({
|
||||
controller: this.pdfController,
|
||||
pageLayout: pdfService.PageLayout.LAYOUT_SINGLE,
|
||||
isContinuous: true,
|
||||
showScroll: true,
|
||||
pageFit: pdfService.PageFit.FIT_WIDTH,
|
||||
})
|
||||
.width('100%')
|
||||
.layoutWeight(1)
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
} else {
|
||||
Column({ space: 12 }) {
|
||||
Text(this.preparing ? '报告加载中...' : (this.pdfMessage.length > 0 ? this.pdfMessage : '报告暂不可用'))
|
||||
.fontSize(Math.round(14 * this.scaleFor()))
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
Button(this.preparing ? '加载中...' : '重新加载报告')
|
||||
.fontSize(Math.round(14 * this.scaleFor()))
|
||||
.height(40)
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.fontColor($r('app.color.accent_blue'))
|
||||
.enabled(!this.preparing)
|
||||
.onClick(() => {
|
||||
this.prepareReport();
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
.layoutWeight(1)
|
||||
.justifyContent(FlexAlign.Center)
|
||||
.alignItems(HorizontalAlign.Center)
|
||||
}
|
||||
}
|
||||
|
||||
// 底部操作条:状态 + 智能体按钮(底部居中)——正文缓存到沙箱后,点击即由小艺带着"帮我总结这份报告"拉起智能体
|
||||
@Builder
|
||||
AgentSummaryCard() {
|
||||
Column({ space: 8 }) {
|
||||
Text(this.prepMessage.length > 0 ? this.prepMessage : '等待报告')
|
||||
.fontSize(Math.round(12 * this.overlayScaleFor()))
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
.textAlign(TextAlign.Center)
|
||||
.width('100%')
|
||||
// 不加底色容器:状态文案直接浮在 PDF 上
|
||||
.visibility(this.prepMessage.length > 0 ? Visibility.Visible : Visibility.None)
|
||||
|
||||
Row({ space: 8 }) {
|
||||
// 只要正文就绪就给出智能体入口:isAgentSupport 偶发误判,不能让探测结果把演示按钮藏掉
|
||||
// (真不支持时点按钮由 FunctionComponent 的 onError 提示)
|
||||
if (this.reportReady) {
|
||||
FunctionComponent({
|
||||
agentId: REPORT_SUMMARY_AGENT_ID,
|
||||
options: {
|
||||
title: 'AI 总结报告',
|
||||
queryText: this.agentQueryText(),
|
||||
buttonType: ButtonType.CAPSULE,
|
||||
controlSize: ControlSize.NORMAL,
|
||||
titleFontSize: 14,
|
||||
iconSize: 20,
|
||||
isShowShadow: true,
|
||||
} as FunctionOptions,
|
||||
onError: (err: BusinessError) => {
|
||||
promptAction.showToast({ message: `智能体启动失败:${err.message}` });
|
||||
},
|
||||
})
|
||||
}
|
||||
// 「重新准备报告」只在正文没就绪时出现,避免和智能体按钮在同一行挤在一起
|
||||
if (!this.reportReady) {
|
||||
Button(this.preparing ? '报告准备中...' : '重新准备报告')
|
||||
.fontSize(Math.round(14 * this.overlayScaleFor()))
|
||||
.height(40)
|
||||
.backgroundColor($r('app.color.background'))
|
||||
.fontColor($r('app.color.accent_blue'))
|
||||
.enabled(!this.preparing)
|
||||
.onClick(() => {
|
||||
this.prepareReport();
|
||||
})
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.justifyContent(FlexAlign.Center)
|
||||
|
||||
// 注意:不要再根据 agentSupported 提示"设备不支持"——isAgentSupport 会去云端校验,
|
||||
// 断网/账号校验失败时会误报 false,但实测点按钮照样能唤起智能体(小艺会连上本应用的端侧智能体)
|
||||
if (!this.reportReady) {
|
||||
// 智能体本身可用,只是报告正文还没取到——别让文案误导成"设备不支持"
|
||||
Text('报告正文未取到,点「重新准备报告」重试')
|
||||
.fontSize(Math.round(12 * this.overlayScaleFor()))
|
||||
.fontColor($r('app.color.inactive_tab'))
|
||||
.textAlign(TextAlign.Center)
|
||||
.width('100%')
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ left: 16, right: 16, top: 10, bottom: 18 })
|
||||
.alignItems(HorizontalAlign.Center)
|
||||
}
|
||||
|
||||
// 悬浮返回按钮已移除:HDS 顶栏保留,返回按钮由标题栏提供
|
||||
|
||||
build() {
|
||||
HdsNavigation() {
|
||||
// Stack(alignContent 底部):PDF 铺满,智能体按钮覆盖在 PDF 之上
|
||||
Stack({ alignContent: Alignment.Bottom }) {
|
||||
Column() {
|
||||
// 顶部占位:避免报告内容被 HDS 标题栏遮挡
|
||||
Blank()
|
||||
.height(this.PDF_TOP_BLANK)
|
||||
this.PdfPreviewCard()
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
|
||||
// 底部悬浮:状态文案 + 智能体按钮(居中),覆盖在 PDF 上
|
||||
this.AgentSummaryCard()
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
}
|
||||
.mode(NavigationMode.Stack)
|
||||
.titleBar({
|
||||
content: {
|
||||
title: {
|
||||
mainTitle: '健康报告',
|
||||
},
|
||||
},
|
||||
style: {
|
||||
// 常驻模糊:不跟随滚动变化,静止时顶栏就是毛玻璃
|
||||
scrollEffectOpts: {
|
||||
enableScrollEffect: false,
|
||||
scrollEffectType: ScrollEffectType.GRADIENT_BLUR,
|
||||
},
|
||||
// 官方沉浸光感写法:materialType 用 ADAPTIVE,等级由设备支持的材质能力决定
|
||||
// (IMMERSIVE 是"能力枚举",不是 titleBar 的 materialType;写成 IMMERSIVE 会导致不生效、整条透明)
|
||||
systemMaterialEffect: {
|
||||
materialType: hdsMaterial.MaterialType.ADAPTIVE,
|
||||
materialLevel: this.customMaterialLevel,
|
||||
},
|
||||
},
|
||||
avoidLayoutSafeArea: false,
|
||||
enableComponentSafeArea: false,
|
||||
})
|
||||
.titleMode(HdsNavigationTitleMode.MINI)
|
||||
// 顶部越界:状态栏与 HDS 顶栏覆盖在报告之上,靠沉浸光感材质做模糊(不是实心挡板)
|
||||
.ignoreLayoutSafeArea([LayoutSafeAreaType.SYSTEM], [LayoutSafeAreaEdge.TOP, LayoutSafeAreaEdge.BOTTOM])
|
||||
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
|
||||
.backgroundColor($r('app.color.background'))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
// 家属看护首页(ArkTS 严格类型,无 any)
|
||||
// 数据来源:/elderly/、/alerts/、/tasks/nursing-records/
|
||||
import { router, promptAction, uiMaterial } from '@kit.ArkUI';
|
||||
import { BusinessError } from '@kit.BasicServicesKit';
|
||||
import { Page, normalizeError } from '../../common/ApiConstants';
|
||||
import { api } from '../../utils/Request';
|
||||
import { AuthStore } from '../../utils/AuthStore';
|
||||
import { FontScaleLevel, FONT_SCALE_MAP } from '../../utils/FontScale';
|
||||
|
||||
interface ApiRecord {
|
||||
id: number;
|
||||
elderly_name: string;
|
||||
raw_text: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface ElderlyRecord {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface AlertRecord {
|
||||
id: number;
|
||||
title: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
function formatDateTime(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) {
|
||||
return '--';
|
||||
}
|
||||
const mm = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const dd = String(d.getDate()).padStart(2, '0');
|
||||
const hh = String(d.getHours()).padStart(2, '0');
|
||||
const mi = String(d.getMinutes()).padStart(2, '0');
|
||||
return `${mm}/${dd} ${hh}:${mi}`;
|
||||
}
|
||||
|
||||
interface QuickAction {
|
||||
key: string;
|
||||
title: string;
|
||||
desc: string;
|
||||
route: string;
|
||||
}
|
||||
|
||||
@Entry
|
||||
@Component
|
||||
struct FamilyHome {
|
||||
@State userName: string = '';
|
||||
@State boundCount: number = 0;
|
||||
@State recordCount: number = 0;
|
||||
@State alertCount: number = 0;
|
||||
@State latestRecordText: string = '';
|
||||
@State latestRecordTime: string = '';
|
||||
@State hasRecord: boolean = false;
|
||||
@State loading: boolean = true;
|
||||
@StorageProp('fontScale') fontScaleLevel: FontScaleLevel = 'system';
|
||||
|
||||
private scaleFor(): number {
|
||||
return FONT_SCALE_MAP[this.fontScaleLevel] ?? 1.0;
|
||||
}
|
||||
|
||||
private actions: QuickAction[] = [
|
||||
{ key: 'records', title: '护理记录', desc: '查看护理记录', route: 'pages/family/Records' },
|
||||
{ key: 'alerts', title: '预警', desc: '查看预警信息', route: 'pages/family/FamilyAlerts' },
|
||||
{ key: 'elderly', title: '长者档案', desc: '查看长者信息', route: 'pages/family/FamilyElderly' },
|
||||
{ key: 'assistant', title: '智能助手', desc: '随时咨询家人状况', route: 'pages/family/FamilyAssistant' },
|
||||
];
|
||||
|
||||
async aboutToAppear(): Promise<void> {
|
||||
const state = await AuthStore.getState();
|
||||
this.userName = state.userName;
|
||||
await this.loadData();
|
||||
}
|
||||
|
||||
private async loadData(): Promise<void> {
|
||||
this.loading = true;
|
||||
try {
|
||||
const results = await Promise.all([
|
||||
api.get<Page<ElderlyRecord>>('/elderly/', { page_size: 1 }).catch(() => null),
|
||||
api.get<Page<AlertRecord>>('/alerts/', { page_size: 1, ordering: '-created_at' }).catch(() => null),
|
||||
api.get<Page<ApiRecord>>('/tasks/nursing-records/', { page_size: 1, ordering: '-created_at' }).catch(() => null),
|
||||
]);
|
||||
const elderlyRes = results[0];
|
||||
const alertRes = results[1];
|
||||
const recordRes = results[2];
|
||||
if (elderlyRes) {
|
||||
this.boundCount = elderlyRes.count;
|
||||
}
|
||||
if (alertRes) {
|
||||
this.alertCount = alertRes.count;
|
||||
}
|
||||
if (recordRes) {
|
||||
this.recordCount = recordRes.count;
|
||||
if (recordRes.results.length > 0) {
|
||||
const r: ApiRecord = recordRes.results[0];
|
||||
this.latestRecordText = r.raw_text;
|
||||
this.latestRecordTime = formatDateTime(r.created_at);
|
||||
this.hasRecord = true;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
promptAction.showToast({ message: normalizeError(err as BusinessError | Error) });
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private go(route: string): void {
|
||||
router.pushUrl({ url: route }).catch((err: BusinessError) => {
|
||||
promptAction.showToast({ message: `页面开发中:${normalizeError(err)}` });
|
||||
});
|
||||
}
|
||||
|
||||
@Builder
|
||||
StatCard(value: number, label: string, color: string) {
|
||||
Column({ space: 4 }) {
|
||||
Text(String(value))
|
||||
.fontSize(Math.round(26 * this.scaleFor()))
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor(color)
|
||||
Text(label)
|
||||
.fontSize(Math.round(12 * this.scaleFor()))
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
}
|
||||
.layoutWeight(1)
|
||||
.justifyContent(FlexAlign.Center)
|
||||
.alignItems(HorizontalAlign.Center)
|
||||
}
|
||||
|
||||
@Builder
|
||||
QuickActionCard(action: QuickAction) {
|
||||
Column({ space: 6 }) {
|
||||
Text(action.title)
|
||||
.fontSize(Math.round(15 * this.scaleFor()))
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
Text(action.desc)
|
||||
.fontSize(Math.round(11 * this.scaleFor()))
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
}
|
||||
.width('30%')
|
||||
.height(60)
|
||||
.margin({ bottom: 10 })
|
||||
.justifyContent(FlexAlign.Center)
|
||||
.alignItems(HorizontalAlign.Center)
|
||||
.backgroundColor(Color.Transparent)
|
||||
.backgroundBlurStyle(BlurStyle.Thin)
|
||||
.borderRadius(12)
|
||||
.onClick(() => {
|
||||
this.go(action.route);
|
||||
})
|
||||
}
|
||||
|
||||
build() {
|
||||
Column() {
|
||||
Scroll() {
|
||||
Column({ space: 16 }) {
|
||||
Column({ space: 6 }) {
|
||||
Text(`${this.userName || '家人'},欢迎回来`)
|
||||
.fontSize(Math.round(24 * this.scaleFor()))
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
Text('随时关注长者健康状态')
|
||||
.fontSize(Math.round(13 * this.scaleFor()))
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ left: 20, right: 20, top: 40, bottom: 8 })
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
|
||||
Row() {
|
||||
this.StatCard(this.boundCount, '位长者', ('app.color.accent_blue'))
|
||||
this.StatCard(this.recordCount, '条记录', ('app.color.accent_green'))
|
||||
this.StatCard(this.alertCount, '条预警', ('app.color.accent_red'))
|
||||
}
|
||||
.width('92%')
|
||||
.height(96)
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.borderRadius(16)
|
||||
.padding(8)
|
||||
|
||||
Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.SpaceBetween }) {
|
||||
ForEach(this.actions, (action: QuickAction) => {
|
||||
this.QuickActionCard(action)
|
||||
}, (action: QuickAction) => action.key)
|
||||
}
|
||||
.width('92%')
|
||||
|
||||
Column({ space: 8 }) {
|
||||
Text('最新护理记录')
|
||||
.fontSize(Math.round(16 * this.scaleFor()))
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.width('100%')
|
||||
Column({ space: 6 }) {
|
||||
if (this.hasRecord) {
|
||||
Text(this.latestRecordText)
|
||||
.fontSize(Math.round(14 * this.scaleFor()))
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.maxLines(3)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
.width('100%')
|
||||
Text(this.latestRecordTime)
|
||||
.fontSize(Math.round(12 * this.scaleFor()))
|
||||
.fontColor($r('app.color.inactive_tab'))
|
||||
} else {
|
||||
Text('暂无护理记录')
|
||||
.fontSize(Math.round(14 * this.scaleFor()))
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.padding(16)
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.borderRadius(14)
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
}
|
||||
.width('92%')
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
.margin({ bottom: 30 })
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(HorizontalAlign.Center)
|
||||
}
|
||||
.scrollBar(BarState.Auto)
|
||||
.edgeEffect(EdgeEffect.Spring)
|
||||
.layoutWeight(1)
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
.backgroundColor($r('app.color.background'))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
// 家属 · 碰一碰分享接收页:渲染对方设备碰一碰传来的长者健康报告卡
|
||||
// 数据来源:EntryAbility.reportData 解析 smartsenior://report?data=<JSON> 写入 AppStorage.pendingReportData
|
||||
import { HealthReportData } from '../../utils/KnockShare';
|
||||
import { FontScaleLevel, FONT_SCALE_MAP } from '../../utils/FontScale';
|
||||
|
||||
@Entry
|
||||
@Component
|
||||
struct FamilyReportCard {
|
||||
@StorageProp('fontScale') fontScaleLevel: FontScaleLevel = 'system';
|
||||
|
||||
private scaleFor(): number {
|
||||
return FONT_SCALE_MAP[this.fontScaleLevel] ?? 1.0;
|
||||
}
|
||||
@StorageProp('pendingReportData') reportStr: string = '';
|
||||
@State report: HealthReportData | null = null;
|
||||
@State parseFailed: boolean = false;
|
||||
|
||||
aboutToAppear(): void {
|
||||
if (this.reportStr.length === 0) {
|
||||
this.parseFailed = true;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const raw = JSON.parse(this.reportStr) as HealthReportData;
|
||||
// 防御旧版发送端:报告字段可能整体缺失,统一补默认值避免 undefined 崩溃
|
||||
this.report = {
|
||||
name: raw.name ?? '',
|
||||
gender: raw.gender ?? '',
|
||||
age: raw.age ?? 0,
|
||||
room: raw.room ?? '',
|
||||
care_level: raw.care_level ?? '',
|
||||
health_score: raw.health_score ?? null,
|
||||
status: raw.status ?? '',
|
||||
risks: raw.risks ?? [],
|
||||
medical_history: raw.medical_history ?? '',
|
||||
allergies: raw.allergies ?? '',
|
||||
medications: raw.medications ?? [],
|
||||
caregiver: raw.caregiver ?? '',
|
||||
emergency_contact: raw.emergency_contact ?? '',
|
||||
report_title: raw.report_title ?? '',
|
||||
report_period: raw.report_period ?? '',
|
||||
report_summary: raw.report_summary ?? '',
|
||||
report_ai: raw.report_ai ?? '',
|
||||
shared_at: raw.shared_at ?? '',
|
||||
};
|
||||
} catch (err) {
|
||||
this.parseFailed = true;
|
||||
}
|
||||
}
|
||||
|
||||
@Builder
|
||||
InfoRow(label: string, value: string) {
|
||||
Row({ space: 12 }) {
|
||||
Text(label)
|
||||
.fontSize(Math.round(16 * this.scaleFor()))
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
.width(88)
|
||||
Text(value.length > 0 ? value : '--')
|
||||
.fontSize(Math.round(16 * this.scaleFor()))
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.layoutWeight(1)
|
||||
}
|
||||
.width('100%')
|
||||
}
|
||||
|
||||
build() {
|
||||
Scroll() {
|
||||
Column({ space: 16 }) {
|
||||
if (this.parseFailed) {
|
||||
Text('未收到健康报告数据')
|
||||
.fontSize(Math.round(18 * this.scaleFor()))
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
.margin({ top: 120 })
|
||||
Text('请与对方设备碰一碰后重新接收')
|
||||
.fontSize(Math.round(14 * this.scaleFor()))
|
||||
.fontColor($r('app.color.inactive_tab'))
|
||||
} else if (this.report !== null) {
|
||||
// 报告头
|
||||
Column({ space: 8 }) {
|
||||
Text('长者健康报告')
|
||||
.fontSize(Math.round(14 * this.scaleFor()))
|
||||
.fontColor('#FFFFFF')
|
||||
.opacity(0.9)
|
||||
Text(this.report.name)
|
||||
.fontSize(Math.round(32 * this.scaleFor()))
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor('#FFFFFF')
|
||||
Text(`${this.report.age}岁 · ${this.report.gender}${this.report.room.length > 0 ? ' · ' + this.report.room : ''}`)
|
||||
.fontSize(Math.round(15 * this.scaleFor()))
|
||||
.fontColor('#FFFFFF')
|
||||
.opacity(0.9)
|
||||
if (this.report.health_score !== null) {
|
||||
Text(`健康评分 ${this.report.health_score}`)
|
||||
.fontSize(Math.round(15 * this.scaleFor()))
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor('#FFFFFF')
|
||||
.padding({ left: 14, right: 14, top: 6, bottom: 6 })
|
||||
.backgroundColor('#33FFFFFF')
|
||||
.borderRadius(16)
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.padding(24)
|
||||
.borderRadius(18)
|
||||
.linearGradient({
|
||||
angle: 135,
|
||||
colors: [[$r('app.color.accent_blue'), 0], ['#5B8DEF', 1]]
|
||||
})
|
||||
.alignItems(HorizontalAlign.Center)
|
||||
|
||||
Text(this.report.care_level)
|
||||
.fontSize(Math.round(14 * this.scaleFor()))
|
||||
.fontColor($r('app.color.accent_blue'))
|
||||
.width('100%')
|
||||
|
||||
// 报告内容
|
||||
Column({ space: 14 }) {
|
||||
Text('健康信息')
|
||||
.fontSize(Math.round(17 * this.scaleFor()))
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.width('100%')
|
||||
this.InfoRow('健康状态', this.report.status)
|
||||
this.InfoRow('风险标签', this.report.risks.length > 0 ? this.report.risks.join('、') : '')
|
||||
this.InfoRow('病史', this.report.medical_history)
|
||||
this.InfoRow('过敏', this.report.allergies)
|
||||
this.InfoRow('常用药物', this.report.medications.length > 0 ? this.report.medications.join('、') : '')
|
||||
this.InfoRow('主责护理员', this.report.caregiver)
|
||||
this.InfoRow('紧急联系人', this.report.emergency_contact)
|
||||
}
|
||||
.width('100%')
|
||||
.padding(18)
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.borderRadius(14)
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
|
||||
if (this.report.report_title.length > 0) {
|
||||
// AI 健康报告(周期总结 + AI 分析)
|
||||
Column({ space: 10 }) {
|
||||
Row({ space: 6 }) {
|
||||
Text('AI 健康报告')
|
||||
.fontSize(Math.round(16 * this.scaleFor()))
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor($r('app.color.accent_blue'))
|
||||
Text(this.report.report_title)
|
||||
.fontSize(Math.round(13 * this.scaleFor()))
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
.layoutWeight(1)
|
||||
.maxLines(1)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
}
|
||||
.width('100%')
|
||||
if (this.report.report_period.length > 0) {
|
||||
Text(this.report.report_period)
|
||||
.fontSize(Math.round(12 * this.scaleFor()))
|
||||
.fontColor($r('app.color.inactive_tab'))
|
||||
.width('100%')
|
||||
}
|
||||
if (this.report.report_summary.length > 0) {
|
||||
Text(this.report.report_summary)
|
||||
.fontSize(Math.round(14 * this.scaleFor()))
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.width('100%')
|
||||
.lineHeight(Math.round(22 * this.scaleFor()))
|
||||
}
|
||||
if (this.report.report_ai.length > 0) {
|
||||
Divider().color($r('app.color.background'))
|
||||
Text('AI 分析')
|
||||
.fontSize(Math.round(13 * this.scaleFor()))
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor($r('app.color.accent_blue'))
|
||||
.width('100%')
|
||||
Text(this.report.report_ai)
|
||||
.fontSize(Math.round(14 * this.scaleFor()))
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.width('100%')
|
||||
.lineHeight(Math.round(22 * this.scaleFor()))
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.padding(18)
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.borderRadius(14)
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
}
|
||||
|
||||
Text(`分享时间:${this.report.shared_at}`)
|
||||
.fontSize(Math.round(12 * this.scaleFor()))
|
||||
.fontColor($r('app.color.inactive_tab'))
|
||||
.width('100%')
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ left: 16, right: 16, top: 72, bottom: 30 })
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
.scrollBar(BarState.Auto)
|
||||
.edgeEffect(EdgeEffect.Spring)
|
||||
.align(Alignment.Top)
|
||||
.backgroundColor($r('app.color.background'))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
// 家属 · 安全验证(ArkTS 严格类型,无 any)
|
||||
// 接口:POST /elderly/bindings/verify/
|
||||
import { promptAction } from '@kit.ArkUI';
|
||||
import { HdsNavigation, HdsNavigationTitleMode, ScrollEffectType } from '@kit.UIDesignKit';
|
||||
import { hdsMaterial } from '@hms.hds.hdsMaterial';
|
||||
import { BusinessError } from '@kit.BasicServicesKit';
|
||||
import { normalizeError } from '../../common/ApiConstants';
|
||||
import { api, EmptyBody, RequestError } from '../../utils/Request';
|
||||
import { FontScaleLevel, FONT_SCALE_MAP } from '../../utils/FontScale';
|
||||
|
||||
interface VerifyResult {
|
||||
verified: boolean;
|
||||
elderly_name: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
@Entry
|
||||
@Component
|
||||
struct FamilyVerify {
|
||||
@State result: VerifyResult | null = null;
|
||||
@State loading: boolean = false;
|
||||
@State verified: boolean = false;
|
||||
@State elderlyName: string = '';
|
||||
@StorageProp('fontScale') fontScaleLevel: FontScaleLevel = 'system';
|
||||
|
||||
private scaleFor(): number {
|
||||
return FONT_SCALE_MAP[this.fontScaleLevel] ?? 1.0;
|
||||
}
|
||||
|
||||
private scroller: Scroller = new Scroller();
|
||||
|
||||
aboutToAppear(): void {
|
||||
this.verify();
|
||||
}
|
||||
|
||||
private async verify(): Promise<void> {
|
||||
if (this.loading) {
|
||||
return;
|
||||
}
|
||||
this.loading = true;
|
||||
try {
|
||||
const res = await api.post<VerifyResult>('/elderly/bindings/verify/', new EmptyBody());
|
||||
this.result = res;
|
||||
this.verified = res.verified;
|
||||
this.elderlyName = res.elderly_name;
|
||||
} catch (err) {
|
||||
const message = err instanceof RequestError ? err.message : normalizeError(err as BusinessError | Error);
|
||||
this.verified = false;
|
||||
this.elderlyName = '';
|
||||
promptAction.showToast({ message: message });
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
build() {
|
||||
HdsNavigation() {
|
||||
Column() {
|
||||
Scroll(this.scroller) {
|
||||
Column({ space: 16 }) {
|
||||
// 状态图标
|
||||
Column({ space: 12 }) {
|
||||
Column() {
|
||||
Text(this.loading ? '⏳' : (this.verified ? '✅' : '❓'))
|
||||
.fontSize(Math.round(48 * this.scaleFor()))
|
||||
}
|
||||
.width(96)
|
||||
.height(96)
|
||||
.justifyContent(FlexAlign.Center)
|
||||
.alignItems(HorizontalAlign.Center)
|
||||
.backgroundColor(this.verified ? '#1400C853' : '#14FF9500')
|
||||
.borderRadius(24)
|
||||
|
||||
Text(this.loading ? '验证中...' : (this.verified ? '身份已验证' : '待验证'))
|
||||
.fontSize(Math.round(20 * this.scaleFor()))
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ top: 12 })
|
||||
.alignItems(HorizontalAlign.Center)
|
||||
|
||||
// 绑定长者信息
|
||||
Column({ space: 10 }) {
|
||||
Row({ space: 12 }) {
|
||||
Text('绑定长者')
|
||||
.fontSize(Math.round(14 * this.scaleFor()))
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
.width(72)
|
||||
Text(this.elderlyName.length > 0 ? this.elderlyName : '--')
|
||||
.fontSize(Math.round(15 * this.scaleFor()))
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.layoutWeight(1)
|
||||
}
|
||||
.width('100%')
|
||||
|
||||
Row({ space: 12 }) {
|
||||
Text('验证状态')
|
||||
.fontSize(Math.round(14 * this.scaleFor()))
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
.width(72)
|
||||
Text(this.verified ? '已验证' : '未验证')
|
||||
.fontSize(Math.round(15 * this.scaleFor()))
|
||||
.fontColor(this.verified ? $r('app.color.accent_green') : '#FF9500')
|
||||
.layoutWeight(1)
|
||||
}
|
||||
.width('100%')
|
||||
}
|
||||
.width('100%')
|
||||
.padding(16)
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.borderRadius(16)
|
||||
|
||||
Text('验证通过后,您可查看绑定长者的健康、护理与预警信息。')
|
||||
.fontSize(Math.round(13 * this.scaleFor()))
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
.lineHeight(Math.round(20 * this.scaleFor()))
|
||||
.width('100%')
|
||||
.padding({ left: 4, right: 4 })
|
||||
|
||||
if (!this.verified) {
|
||||
Text('当前账号尚未绑定长者,请先在"绑定长者"中完成绑定后再验证。')
|
||||
.fontSize(Math.round(13 * this.scaleFor()))
|
||||
.fontColor($r('app.color.inactive_tab'))
|
||||
.lineHeight(Math.round(20 * this.scaleFor()))
|
||||
.width('100%')
|
||||
.padding(14)
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.borderRadius(12)
|
||||
}
|
||||
|
||||
Button(this.loading ? '验证中...' : '重新验证')
|
||||
.width('100%')
|
||||
.height(48)
|
||||
.fontSize(Math.round(16 * this.scaleFor()))
|
||||
.fontColor('#FFFFFF')
|
||||
.backgroundColor($r('app.color.accent_blue'))
|
||||
.borderRadius(12)
|
||||
.enabled(!this.loading)
|
||||
.onClick(() => {
|
||||
this.verify();
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ left: 16, right: 16, top: 92, bottom: 30 })
|
||||
}
|
||||
.width('100%')
|
||||
.layoutWeight(1)
|
||||
.scrollBar(BarState.Auto)
|
||||
.edgeEffect(EdgeEffect.Spring)
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
}
|
||||
.mode(NavigationMode.Stack)
|
||||
.titleBar({
|
||||
content: {
|
||||
title: {
|
||||
mainTitle: '安全验证',
|
||||
},
|
||||
},
|
||||
style: {
|
||||
scrollEffectOpts: {
|
||||
enableScrollEffect: false,
|
||||
scrollEffectType: ScrollEffectType.GRADIENT_BLUR,
|
||||
},
|
||||
systemMaterialEffect: {
|
||||
materialType: hdsMaterial.MaterialType.ADAPTIVE,
|
||||
materialLevel: hdsMaterial.MaterialLevel.EXQUISITE,
|
||||
},
|
||||
},
|
||||
avoidLayoutSafeArea: false,
|
||||
enableComponentSafeArea: false,
|
||||
})
|
||||
.bindToScrollable([this.scroller])
|
||||
.hideBackButton(false)
|
||||
.titleMode(HdsNavigationTitleMode.MINI)
|
||||
.ignoreLayoutSafeArea([LayoutSafeAreaType.SYSTEM], [LayoutSafeAreaEdge.TOP, LayoutSafeAreaEdge.BOTTOM])
|
||||
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
|
||||
.backgroundColor($r('app.color.background'))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
// 家属端护理记录(ArkTS 严格类型,无 any)
|
||||
// 数据来源:/tasks/nursing-records/
|
||||
import { router, promptAction } from '@kit.ArkUI';
|
||||
import { HdsNavigation, HdsNavigationTitleMode, ScrollEffectType } from '@kit.UIDesignKit';
|
||||
import { hdsMaterial } from '@hms.hds.hdsMaterial';
|
||||
import { BusinessError } from '@kit.BasicServicesKit';
|
||||
import { Page, normalizeError } from '../../common/ApiConstants';
|
||||
import { api } from '../../utils/Request';
|
||||
import { ProtectedImage } from '../../components/ProtectedImage';
|
||||
import { FontScaleLevel, FONT_SCALE_MAP } from '../../utils/FontScale';
|
||||
|
||||
// 记录详情页路由参数
|
||||
interface RecordDetailParams {
|
||||
id: string;
|
||||
}
|
||||
|
||||
interface RecordItem {
|
||||
id: number;
|
||||
elderly_name: string;
|
||||
nurse_name: string | null;
|
||||
raw_text: string;
|
||||
images: Array<string> | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
function formatTime(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) {
|
||||
return '--';
|
||||
}
|
||||
const mm = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const dd = String(d.getDate()).padStart(2, '0');
|
||||
const hh = String(d.getHours()).padStart(2, '0');
|
||||
const mi = String(d.getMinutes()).padStart(2, '0');
|
||||
return `${mm}/${dd} ${hh}:${mi}`;
|
||||
}
|
||||
|
||||
@Entry
|
||||
@Component
|
||||
struct FamilyRecords {
|
||||
@State records: RecordItem[] = [];
|
||||
@State loading: boolean = true;
|
||||
@StorageProp('fontScale') fontScaleLevel: FontScaleLevel = 'system';
|
||||
|
||||
private scaleFor(): number {
|
||||
return FONT_SCALE_MAP[this.fontScaleLevel] ?? 1.0;
|
||||
}
|
||||
|
||||
private scroller: Scroller = new Scroller();
|
||||
|
||||
async aboutToAppear(): Promise<void> {
|
||||
await this.loadRecords();
|
||||
}
|
||||
|
||||
private async loadRecords(): Promise<void> {
|
||||
this.loading = true;
|
||||
try {
|
||||
const res = await api.get<Page<RecordItem>>('/tasks/nursing-records/', {
|
||||
page_size: 50,
|
||||
ordering: '-created_at',
|
||||
});
|
||||
this.records = res.results;
|
||||
} catch (err) {
|
||||
promptAction.showToast({ message: normalizeError(err as BusinessError | Error) });
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private openDetail(item: RecordItem): void {
|
||||
const params: RecordDetailParams = { id: String(item.id) };
|
||||
router.pushUrl({
|
||||
url: 'pages/nurse/RecordDetail',
|
||||
params: params,
|
||||
}).catch((err: BusinessError) => {
|
||||
promptAction.showToast({ message: `页面打开失败:${normalizeError(err)}` });
|
||||
});
|
||||
}
|
||||
|
||||
@Builder
|
||||
RecordItemView(item: RecordItem) {
|
||||
Column({ space: 8 }) {
|
||||
Row() {
|
||||
Text(item.elderly_name)
|
||||
.fontSize(Math.round(16 * this.scaleFor()))
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
Blank()
|
||||
Text(formatTime(item.created_at))
|
||||
.fontSize(Math.round(12 * this.scaleFor()))
|
||||
.fontColor($r('app.color.inactive_tab'))
|
||||
}
|
||||
.width('100%')
|
||||
|
||||
Text(item.raw_text && item.raw_text.length > 0 ? item.raw_text : '(无文字内容)')
|
||||
.fontSize(Math.round(13 * this.scaleFor()))
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
.maxLines(4)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
.width('100%')
|
||||
|
||||
if (item.images && item.images.length > 0) {
|
||||
Row({ space: 8 }) {
|
||||
ForEach(item.images.slice(0, 3), (img: string) => {
|
||||
Stack() {
|
||||
ProtectedImage({ mediaPath: img })
|
||||
}
|
||||
.width(64)
|
||||
.height(64)
|
||||
.borderRadius(8)
|
||||
.clip(true)
|
||||
}, (img: string) => img)
|
||||
if (item.images.length > 3) {
|
||||
Text(`+${item.images.length - 3}`)
|
||||
.fontSize(Math.round(12 * this.scaleFor()))
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.padding(16)
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.borderRadius(14)
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
}
|
||||
|
||||
build() {
|
||||
HdsNavigation() {
|
||||
Column() {
|
||||
Scroll(this.scroller) {
|
||||
Column({ space: 10 }) {
|
||||
ForEach(this.records, (item: RecordItem) => {
|
||||
Column() {
|
||||
this.RecordItemView(item)
|
||||
}
|
||||
.width('100%')
|
||||
.onClick(() => {
|
||||
this.openDetail(item);
|
||||
})
|
||||
}, (item: RecordItem) => String(item.id))
|
||||
if (this.loading) {
|
||||
Text('加载中...')
|
||||
.fontSize(Math.round(13 * this.scaleFor()))
|
||||
.fontColor($r('app.color.inactive_tab'))
|
||||
.padding(12)
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ left: 16, right: 16, top: 92, bottom: 24 })
|
||||
}
|
||||
.width('100%')
|
||||
.layoutWeight(1)
|
||||
.scrollBar(BarState.Auto)
|
||||
.edgeEffect(EdgeEffect.Spring)
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
}
|
||||
.mode(NavigationMode.Stack)
|
||||
.titleBar({
|
||||
content: {
|
||||
title: {
|
||||
mainTitle: '护理记录',
|
||||
},
|
||||
},
|
||||
style: {
|
||||
scrollEffectOpts: {
|
||||
enableScrollEffect: false,
|
||||
scrollEffectType: ScrollEffectType.GRADIENT_BLUR,
|
||||
},
|
||||
systemMaterialEffect: {
|
||||
materialType: hdsMaterial.MaterialType.ADAPTIVE,
|
||||
materialLevel: hdsMaterial.MaterialLevel.EXQUISITE,
|
||||
},
|
||||
},
|
||||
avoidLayoutSafeArea: false,
|
||||
enableComponentSafeArea: false,
|
||||
})
|
||||
.bindToScrollable([this.scroller])
|
||||
.hideBackButton(false)
|
||||
.titleMode(HdsNavigationTitleMode.MINI)
|
||||
.ignoreLayoutSafeArea([LayoutSafeAreaType.SYSTEM], [LayoutSafeAreaEdge.TOP, LayoutSafeAreaEdge.BOTTOM])
|
||||
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
|
||||
.backgroundColor($r('app.color.background'))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,681 @@
|
||||
// 消防 · 工作台与巡更(ArkTS 严格类型,无 any)
|
||||
// 接口:GET /fire-patrol/stats/、/fire-patrol/points/、/fire-patrol/my-records/
|
||||
// POST /fire-patrol/my-records/
|
||||
// 跨设备拍照:ServiceCollaboration Kit,平板调用附近同账号手机相机,照片回传后入上传列表
|
||||
import { promptAction, router } from '@kit.ArkUI';
|
||||
import { photoAccessHelper } from '@kit.MediaLibraryKit';
|
||||
import { HdsNavigation, HdsNavigationTitleMode, ScrollEffectType } from '@kit.UIDesignKit';
|
||||
import { hdsMaterial } from '@hms.hds.hdsMaterial';
|
||||
import { BusinessError } from '@kit.BasicServicesKit';
|
||||
import { CollaborationServiceStateDialog, createCollaborationServiceMenuItems, CollaborationServiceFilter } from '@kit.ServiceCollaborationKit';
|
||||
import { fileIo } from '@kit.CoreFileKit';
|
||||
import { common } from '@kit.AbilityKit';
|
||||
import { scanBarcode, scanCore } from '@kit.ScanKit';
|
||||
import { Page, normalizeError } from '../../common/ApiConstants';
|
||||
import { api, uploadMultipartFiles, RequestError } from '../../utils/Request';
|
||||
|
||||
const MAX_IMAGES = 9;
|
||||
|
||||
interface FireStats {
|
||||
total_records: number;
|
||||
today_records: number;
|
||||
photo_records: number;
|
||||
abnormal_records: number;
|
||||
point_count: number;
|
||||
active_point_count: number;
|
||||
}
|
||||
|
||||
interface FirePoint {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
location: string;
|
||||
manager: string;
|
||||
status: string;
|
||||
require_photo: boolean;
|
||||
}
|
||||
|
||||
interface FireRecord {
|
||||
id: string;
|
||||
patrolman: string;
|
||||
point: string;
|
||||
time: string;
|
||||
status: string;
|
||||
note: string;
|
||||
}
|
||||
|
||||
interface CheckinPayload {
|
||||
point: string;
|
||||
status: string;
|
||||
note: string;
|
||||
photos?: Array<CheckinPhoto>;
|
||||
}
|
||||
|
||||
// 扫码识别巡更点响应(POST /fire-patrol/mobile/resolve-point/)
|
||||
interface ResolvedPoint {
|
||||
pk: number;
|
||||
id: string;
|
||||
name: string;
|
||||
location: string;
|
||||
manager: string;
|
||||
status: string;
|
||||
requirePhoto: boolean;
|
||||
}
|
||||
|
||||
interface CheckinPhoto {
|
||||
id: string;
|
||||
label: string;
|
||||
url: string;
|
||||
originalName: string;
|
||||
}
|
||||
|
||||
interface UploadPhotosResponse {
|
||||
photos: Array<CheckinPhoto>;
|
||||
}
|
||||
|
||||
function todayStr(): string {
|
||||
const now = new Date();
|
||||
const mm = String(now.getMonth() + 1).padStart(2, '0');
|
||||
const dd = String(now.getDate()).padStart(2, '0');
|
||||
return `${now.getFullYear()}-${mm}-${dd}`;
|
||||
}
|
||||
|
||||
function formatTime(t: string): string {
|
||||
if (!t) {
|
||||
return '--';
|
||||
}
|
||||
const d = new Date(t);
|
||||
if (Number.isNaN(d.getTime())) {
|
||||
return t;
|
||||
}
|
||||
const hh = String(d.getHours()).padStart(2, '0');
|
||||
const mi = String(d.getMinutes()).padStart(2, '0');
|
||||
return `${hh}:${mi}`;
|
||||
}
|
||||
|
||||
@Entry
|
||||
@Component
|
||||
struct FirePatrolHome {
|
||||
@State stats: FireStats | null = null;
|
||||
@State points: Array<FirePoint> = [];
|
||||
@State records: Array<FireRecord> = [];
|
||||
@State loading: boolean = true;
|
||||
@State checkingId: string = '';
|
||||
@State showPointPicker: boolean = false;
|
||||
@State selectedPoint: string = '';
|
||||
@State note: string = '';
|
||||
@State selectedUris: Array<string> = [];
|
||||
@State uploadedPhotos: Array<CheckinPhoto> = [];
|
||||
@State uploading: boolean = false;
|
||||
@State resolving: boolean = false;
|
||||
@State resolvedInfo: string = '';
|
||||
private scroller: Scroller = new Scroller();
|
||||
|
||||
aboutToAppear(): void {
|
||||
this.load();
|
||||
}
|
||||
|
||||
private pickImages(): void {
|
||||
if (this.uploading) {
|
||||
return;
|
||||
}
|
||||
const remain = MAX_IMAGES - this.selectedUris.length;
|
||||
if (remain <= 0) {
|
||||
promptAction.showToast({ message: `最多选择 ${MAX_IMAGES} 张图片` });
|
||||
return;
|
||||
}
|
||||
const options = new photoAccessHelper.PhotoSelectOptions();
|
||||
options.MIMEType = photoAccessHelper.PhotoViewMIMETypes.IMAGE_TYPE;
|
||||
options.maxSelectNumber = remain;
|
||||
const picker = new photoAccessHelper.PhotoViewPicker();
|
||||
picker.select(options).then((result: photoAccessHelper.PhotoSelectResult) => {
|
||||
if (result.photoUris && result.photoUris.length > 0) {
|
||||
this.selectedUris = this.selectedUris.concat(result.photoUris);
|
||||
}
|
||||
}).catch((err: BusinessError) => {
|
||||
console.error('[FirePatrol] pick images failed:', err.message);
|
||||
});
|
||||
}
|
||||
|
||||
// 跨设备设备列表菜单(配合 bindMenu 使用):拍照 + 调用其他设备图库
|
||||
@Builder
|
||||
CollabMenu() {
|
||||
Menu() {
|
||||
createCollaborationServiceMenuItems([CollaborationServiceFilter.TAKE_PHOTO, CollaborationServiceFilter.IMAGE_PICKER], MAX_IMAGES);
|
||||
}
|
||||
}
|
||||
|
||||
// 接收远端设备回传的图片(ArrayBuffer),写入沙箱后并入上传列表
|
||||
private onCollabState(stateCode: number, bufferType: string, buffer: ArrayBuffer): void {
|
||||
if (stateCode !== 0 || bufferType !== 'general.image' || buffer.byteLength === 0) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const context = this.getUIContext().getHostContext() as common.UIAbilityContext;
|
||||
const path = `${context.cacheDir}/collab_${Date.now()}.jpg`;
|
||||
const file = fileIo.openSync(path, fileIo.OpenMode.READ_WRITE | fileIo.OpenMode.CREATE | fileIo.OpenMode.TRUNC);
|
||||
try {
|
||||
fileIo.writeSync(file.fd, buffer);
|
||||
} finally {
|
||||
fileIo.closeSync(file);
|
||||
}
|
||||
this.selectedUris = this.selectedUris.concat([`file://${path}`]);
|
||||
promptAction.showToast({ message: '已接收远端设备照片' });
|
||||
} catch (err) {
|
||||
console.error('[FirePatrol] save collab image failed:', normalizeError(err as BusinessError | Error));
|
||||
promptAction.showToast({ message: '远端照片保存失败' });
|
||||
}
|
||||
}
|
||||
|
||||
private async uploadPhotos(): Promise<Array<CheckinPhoto>> {
|
||||
if (this.selectedUris.length === 0) {
|
||||
return [];
|
||||
}
|
||||
this.uploading = true;
|
||||
try {
|
||||
const res = await uploadMultipartFiles<UploadPhotosResponse>(
|
||||
'/fire-patrol/upload-images/', this.selectedUris, 'images');
|
||||
this.uploadedPhotos = res.photos || [];
|
||||
return this.uploadedPhotos;
|
||||
} finally {
|
||||
this.uploading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async load(): Promise<void> {
|
||||
this.loading = true;
|
||||
try {
|
||||
const results: Array<FireStats | Page<FirePoint> | Page<FireRecord> | null> = await Promise.all([
|
||||
api.get<FireStats>('/fire-patrol/stats/').catch((): FireStats | null => null),
|
||||
api.get<Page<FirePoint>>('/fire-patrol/points/', { page_size: 100 }).catch((): Page<FirePoint> | null => null),
|
||||
api.get<Page<FireRecord>>('/fire-patrol/my-records/', { start: todayStr(), end: todayStr(), page_size: 50 }).catch((): Page<FireRecord> | null => null),
|
||||
]);
|
||||
this.stats = results[0] as FireStats | null;
|
||||
const pRes = results[1] as Page<FirePoint> | null;
|
||||
this.points = pRes ? pRes.results : [];
|
||||
const rRes = results[2] as Page<FireRecord> | null;
|
||||
this.records = rRes ? rRes.results : [];
|
||||
} catch (err) {
|
||||
const message = err instanceof RequestError ? err.message : normalizeError(err as BusinessError | Error);
|
||||
promptAction.showToast({ message: message });
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 扫码识别成功后打开打卡面板(唯一打卡入口:必须到场扫码)
|
||||
private openScannedCheckin(point: ResolvedPoint): void {
|
||||
this.selectedPoint = point.name || point.id;
|
||||
this.resolvedInfo = [
|
||||
point.location && point.location.length > 0 ? `位置:${point.location}` : '',
|
||||
point.manager && point.manager.length > 0 ? `负责人:${point.manager}` : '',
|
||||
].filter((s: string): boolean => s.length > 0).join(' · ');
|
||||
this.note = '';
|
||||
this.selectedUris = [];
|
||||
this.uploadedPhotos = [];
|
||||
this.showPointPicker = true;
|
||||
}
|
||||
|
||||
// 从扫码原文提取二维码载荷:兼容纯加密串与带 scene/qr_content 参数的 URL(对齐小程序 normalize 逻辑)
|
||||
private extractQrContent(raw: string): string {
|
||||
const decoded = raw.trim();
|
||||
if (decoded.length === 0) {
|
||||
return '';
|
||||
}
|
||||
const qIndex = decoded.indexOf('?');
|
||||
if (qIndex < 0) {
|
||||
return decoded;
|
||||
}
|
||||
const params = new Map<string, string>();
|
||||
decoded.substring(qIndex + 1).split('&').forEach((pair: string) => {
|
||||
const eq = pair.indexOf('=');
|
||||
if (eq > 0) {
|
||||
const key = decodeURIComponent(pair.substring(0, eq)).trim();
|
||||
let value = pair.substring(eq + 1);
|
||||
try {
|
||||
value = decodeURIComponent(value);
|
||||
} catch (err) {
|
||||
// 保留原值
|
||||
}
|
||||
if (key.length > 0 && !params.has(key)) {
|
||||
params.set(key, value.trim());
|
||||
}
|
||||
}
|
||||
});
|
||||
// q 参数可能内嵌一层 ?a=b&c=d
|
||||
const q = params.get('q') ?? '';
|
||||
if (q.indexOf('?') >= 0) {
|
||||
const nested = q.substring(q.indexOf('?') + 1);
|
||||
nested.split('&').forEach((pair: string) => {
|
||||
const eq = pair.indexOf('=');
|
||||
if (eq > 0) {
|
||||
const key = decodeURIComponent(pair.substring(0, eq)).trim();
|
||||
let value = pair.substring(eq + 1);
|
||||
try {
|
||||
value = decodeURIComponent(value);
|
||||
} catch (err) {
|
||||
// 保留原值
|
||||
}
|
||||
if (key.length > 0 && !params.has(key)) {
|
||||
params.set(key, value.trim());
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
const keys: Array<string> = ['scene', 'content', 'qr_content', 'qrContent', 'raw', 'text'];
|
||||
for (let i = 0; i < keys.length; i++) {
|
||||
const v = params.get(keys[i]) ?? '';
|
||||
if (v.length > 0) {
|
||||
return v;
|
||||
}
|
||||
}
|
||||
return decoded;
|
||||
}
|
||||
|
||||
// 调起系统扫码页识别巡更点二维码
|
||||
private scanPoint(): void {
|
||||
if (this.resolving) {
|
||||
return;
|
||||
}
|
||||
const context = this.getUIContext().getHostContext() as common.UIAbilityContext;
|
||||
const options: scanBarcode.ScanOptions = {
|
||||
scanTypes: [scanCore.ScanType.QR_CODE],
|
||||
enableAlbum: true,
|
||||
};
|
||||
scanBarcode.startScanForResult(context, options).then((result: scanBarcode.ScanResult) => {
|
||||
const raw = result.originalValue ?? '';
|
||||
const content = this.extractQrContent(raw);
|
||||
if (content.length === 0) {
|
||||
promptAction.showToast({ message: '未识别到二维码内容' });
|
||||
return;
|
||||
}
|
||||
this.resolvePoint(content);
|
||||
}).catch((err: BusinessError) => {
|
||||
// 用户取消扫码(1000500001)静默
|
||||
if (err.code !== 1000500001) {
|
||||
promptAction.showToast({ message: `扫码失败:${err.message}` });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 后端解密二维码并返回巡更点信息
|
||||
private async resolvePoint(content: string): Promise<void> {
|
||||
this.resolving = true;
|
||||
try {
|
||||
const res = await api.post<ResolvedPoint>('/fire-patrol/mobile/resolve-point/', { content: content });
|
||||
promptAction.showToast({ message: `巡更点已识别:${res.name || res.id}` });
|
||||
this.openScannedCheckin(res);
|
||||
} catch (err) {
|
||||
const message = err instanceof RequestError ? err.message : normalizeError(err as BusinessError | Error);
|
||||
promptAction.showToast({ message: message });
|
||||
} finally {
|
||||
this.resolving = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async submitCheckin(): Promise<void> {
|
||||
if (this.checkingId.length > 0) {
|
||||
return;
|
||||
}
|
||||
const target = this.selectedPoint;
|
||||
this.checkingId = target;
|
||||
try {
|
||||
const photos: Array<CheckinPhoto> = await this.uploadPhotos();
|
||||
const payload: CheckinPayload = {
|
||||
point: target,
|
||||
status: '正常',
|
||||
note: this.note.trim(),
|
||||
photos: photos,
|
||||
};
|
||||
await api.post('/fire-patrol/my-records/', payload);
|
||||
this.showPointPicker = false;
|
||||
promptAction.showToast({ message: '巡更打卡成功' });
|
||||
await this.load();
|
||||
} catch (err) {
|
||||
const message = err instanceof RequestError ? err.message : normalizeError(err as BusinessError | Error);
|
||||
promptAction.showToast({ message: message });
|
||||
} finally {
|
||||
this.checkingId = '';
|
||||
}
|
||||
}
|
||||
|
||||
@Builder
|
||||
StatTile(value: number, label: string, color: string) {
|
||||
Column({ space: 4 }) {
|
||||
Text(String(value))
|
||||
.fontSize(22)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor(color)
|
||||
Text(label)
|
||||
.fontSize(12)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
}
|
||||
.layoutWeight(1)
|
||||
.justifyContent(FlexAlign.Center)
|
||||
.alignItems(HorizontalAlign.Center)
|
||||
}
|
||||
|
||||
@Builder
|
||||
PointCard(p: FirePoint) {
|
||||
Row({ space: 12 }) {
|
||||
Column({ space: 4 }) {
|
||||
Text(p.name)
|
||||
.fontSize(15)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.width('100%')
|
||||
if (p.location && p.location.length > 0) {
|
||||
Text(p.location)
|
||||
.fontSize(12)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
.width('100%')
|
||||
}
|
||||
}
|
||||
.layoutWeight(1)
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
|
||||
Text(p.status === '正常' ? '使用中' : p.status)
|
||||
.fontSize(12)
|
||||
.fontColor(p.status === '正常' ? $r('app.color.accent_green') : $r('app.color.text_secondary'))
|
||||
}
|
||||
.width('100%')
|
||||
.padding(14)
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.borderRadius(12)
|
||||
}
|
||||
|
||||
@Builder
|
||||
RecordCard(r: FireRecord) {
|
||||
Row({ space: 10 }) {
|
||||
Column({ space: 4 }) {
|
||||
Text(r.point)
|
||||
.fontSize(15)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
Text(`${r.patrolman || ''} · ${formatTime(r.time)}`)
|
||||
.fontSize(12)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
}
|
||||
.layoutWeight(1)
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
Text(r.status)
|
||||
.fontSize(12)
|
||||
.fontColor(r.status === '正常' ? $r('app.color.accent_green') : '#FF9500')
|
||||
}
|
||||
.width('100%')
|
||||
.padding(14)
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.borderRadius(12)
|
||||
.onClick(() => {
|
||||
router.pushUrl({
|
||||
url: 'pages/fire-control/FireRecordDetail',
|
||||
params: { id: r.id },
|
||||
}).catch((err: BusinessError) => {
|
||||
promptAction.showToast({ message: `页面打开失败:${normalizeError(err)}` });
|
||||
});
|
||||
})
|
||||
.accessibilityLevel('yes')
|
||||
.accessibilityText(`巡检记录,${r.point},${r.status},点按查看详情`)
|
||||
}
|
||||
|
||||
@Builder
|
||||
CheckinSheet() {
|
||||
Column({ space: 12 }) {
|
||||
Text('巡更打卡')
|
||||
.fontSize(18)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
Row({ space: 10 }) {
|
||||
Text('巡更点')
|
||||
.fontSize(14)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
if (this.selectedPoint.length > 0) {
|
||||
Column({ space: 2 }) {
|
||||
Text(this.selectedPoint)
|
||||
.fontSize(15)
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
if (this.resolvedInfo.length > 0) {
|
||||
Text(this.resolvedInfo)
|
||||
.fontSize(12)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
}
|
||||
}
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
.layoutWeight(1)
|
||||
} else {
|
||||
Text('未选择')
|
||||
.fontSize(15)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
.layoutWeight(1)
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
TextInput({ placeholder: '备注(可选)', text: this.note })
|
||||
.placeholderColor($r('app.color.inactive_tab'))
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.fontSize(15)
|
||||
.height(46)
|
||||
.padding({ left: 14, right: 14 })
|
||||
.backgroundColor($r('app.color.background'))
|
||||
.borderRadius(10)
|
||||
.onChange((value: string) => {
|
||||
this.note = value;
|
||||
})
|
||||
|
||||
Column({ space: 8 }) {
|
||||
Row() {
|
||||
Text(`照片(${this.selectedUris.length}/${MAX_IMAGES})`)
|
||||
.fontSize(13)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
Blank()
|
||||
Button('跨设备拍照/选图')
|
||||
.fontSize(13)
|
||||
.height(30)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.backgroundColor(Color.Transparent)
|
||||
.borderRadius(8)
|
||||
.enabled(!this.uploading)
|
||||
.bindMenu(this.CollabMenu)
|
||||
.accessibilityLevel('yes')
|
||||
.accessibilityText('跨设备拍照')
|
||||
.accessibilityDescription('调用附近设备的相机拍照,照片自动加入上传列表')
|
||||
Button(this.uploading ? '上传中...' : '+ 添加照片')
|
||||
.fontSize(13)
|
||||
.height(30)
|
||||
.fontColor($r('app.color.accent_blue'))
|
||||
.backgroundColor(Color.Transparent)
|
||||
.borderRadius(8)
|
||||
.enabled(!this.uploading)
|
||||
.onClick(() => {
|
||||
this.pickImages();
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
if (this.selectedUris.length > 0) {
|
||||
Flex({ wrap: FlexWrap.Wrap }) {
|
||||
ForEach(this.selectedUris, (uri: string) => {
|
||||
Image(uri)
|
||||
.width(64)
|
||||
.height(64)
|
||||
.borderRadius(8)
|
||||
.margin({ right: 8, bottom: 8 })
|
||||
}, (uri: string) => uri)
|
||||
}
|
||||
.width('100%')
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
|
||||
Button(this.checkingId.length > 0 ? '打卡中...' : '确认打卡')
|
||||
.width('100%')
|
||||
.height(48)
|
||||
.fontSize(16)
|
||||
.fontColor('#FFFFFF')
|
||||
.backgroundColor($r('app.color.accent_green'))
|
||||
.borderRadius(12)
|
||||
.enabled(this.checkingId.length === 0)
|
||||
.onClick(() => {
|
||||
this.submitCheckin();
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
.padding(20)
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.borderRadius(16)
|
||||
}
|
||||
|
||||
build() {
|
||||
HdsNavigation() {
|
||||
Column() {
|
||||
// 跨设备互通状态弹窗(接收远端相机回传数据)
|
||||
CollaborationServiceStateDialog({
|
||||
onState: (stateCode: number, bufferType: string, buffer: ArrayBuffer): void => {
|
||||
this.onCollabState(stateCode, bufferType, buffer);
|
||||
}
|
||||
})
|
||||
if (this.loading) {
|
||||
Text('加载中...')
|
||||
.fontSize(15)
|
||||
.fontColor($r('app.color.inactive_tab'))
|
||||
.margin({ top: 60 })
|
||||
} else {
|
||||
Scroll(this.scroller) {
|
||||
Column({ space: 12 }) {
|
||||
if (this.stats) {
|
||||
Row() {
|
||||
this.StatTile(this.stats.today_records, '今日打卡', '#34C759')
|
||||
this.StatTile(this.stats.abnormal_records, '异常', '#FF3B30')
|
||||
this.StatTile(this.stats.active_point_count, '巡更点', '#007AFF')
|
||||
}
|
||||
.width('100%')
|
||||
.padding(16)
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.borderRadius(16)
|
||||
}
|
||||
|
||||
// 唯一打卡入口:必须到场扫描点位二维码
|
||||
Column({ space: 6 }) {
|
||||
Button(this.resolving ? '识别中...' : ' 现场扫码巡更')
|
||||
.width('100%')
|
||||
.height(52)
|
||||
.fontSize(17)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor('#FFFFFF')
|
||||
.backgroundColor($r('app.color.accent_green'))
|
||||
.borderRadius(14)
|
||||
.enabled(!this.resolving)
|
||||
.onClick(() => {
|
||||
this.scanPoint();
|
||||
})
|
||||
.accessibilityLevel('yes')
|
||||
.accessibilityText('现场扫码巡更打卡')
|
||||
}
|
||||
.width('100%')
|
||||
|
||||
Column({ space: 8 }) {
|
||||
Row() {
|
||||
Text('巡更点')
|
||||
.fontSize(16)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
Blank()
|
||||
Text(`共 ${this.points.length} 个`)
|
||||
.fontSize(12)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
}
|
||||
.width('100%')
|
||||
if (this.points.length === 0) {
|
||||
Text('暂无巡更点')
|
||||
.fontSize(13)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
.width('100%')
|
||||
.padding(16)
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.borderRadius(12)
|
||||
} else {
|
||||
ForEach(this.points, (p: FirePoint) => {
|
||||
Column() {
|
||||
this.PointCard(p)
|
||||
}
|
||||
.width('100%')
|
||||
}, (p: FirePoint) => p.id || p.code)
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
|
||||
Column({ space: 8 }) {
|
||||
Text('今日巡更记录')
|
||||
.fontSize(16)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.width('100%')
|
||||
if (this.records.length === 0) {
|
||||
Text('今日暂无打卡记录')
|
||||
.fontSize(13)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
.width('100%')
|
||||
.padding(16)
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.borderRadius(12)
|
||||
} else {
|
||||
ForEach(this.records, (r: FireRecord) => {
|
||||
Column() {
|
||||
this.RecordCard(r)
|
||||
}
|
||||
.width('100%')
|
||||
}, (r: FireRecord) => r.id)
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
.margin({ bottom: 20 })
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ left: 16, right: 16, top: 92, bottom: 30 })
|
||||
}
|
||||
.width('100%')
|
||||
.layoutWeight(1)
|
||||
.scrollBar(BarState.Auto)
|
||||
.edgeEffect(EdgeEffect.Spring)
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
}
|
||||
.mode(NavigationMode.Stack)
|
||||
.titleBar({
|
||||
content: {
|
||||
title: {
|
||||
mainTitle: '消防巡更',
|
||||
},
|
||||
},
|
||||
style: {
|
||||
scrollEffectOpts: {
|
||||
enableScrollEffect: false,
|
||||
scrollEffectType: ScrollEffectType.GRADIENT_BLUR,
|
||||
},
|
||||
systemMaterialEffect: {
|
||||
materialType: hdsMaterial.MaterialType.ADAPTIVE,
|
||||
materialLevel: hdsMaterial.MaterialLevel.EXQUISITE,
|
||||
},
|
||||
},
|
||||
avoidLayoutSafeArea: false,
|
||||
enableComponentSafeArea: false,
|
||||
})
|
||||
.bindToScrollable([this.scroller])
|
||||
.hideBackButton(false)
|
||||
.titleMode(HdsNavigationTitleMode.MINI)
|
||||
.ignoreLayoutSafeArea([LayoutSafeAreaType.SYSTEM], [LayoutSafeAreaEdge.TOP, LayoutSafeAreaEdge.BOTTOM])
|
||||
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
|
||||
.backgroundColor($r('app.color.background'))
|
||||
.bindSheet($$this.showPointPicker, this.CheckinSheet(), {
|
||||
height: 440,
|
||||
dragBar: true,
|
||||
showClose: true,
|
||||
backgroundColor: $r('app.color.background_card'),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
// 消防 · 巡检记录详情(ArkTS 严格类型,无 any)
|
||||
// 接口:GET /fire-patrol/my-records/{code}/
|
||||
import { router } from '@kit.ArkUI';
|
||||
import { HdsNavigation, HdsNavigationTitleMode, ScrollEffectType } from '@kit.UIDesignKit';
|
||||
import { hdsMaterial } from '@hms.hds.hdsMaterial';
|
||||
import { ProtectedImage } from '../../components/ProtectedImage';
|
||||
import { api, RequestError } from '../../utils/Request';
|
||||
|
||||
interface FireRecordInfo {
|
||||
id: string;
|
||||
patrolman: string;
|
||||
point: string;
|
||||
point_pk: number;
|
||||
time: string;
|
||||
status: string;
|
||||
hasPhoto: boolean;
|
||||
note: string;
|
||||
photos: Array<string>;
|
||||
recorder: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
function statusColor(status: string): ResourceColor {
|
||||
return status === '正常' ? $r('app.color.accent_green') : '#FF9500';
|
||||
}
|
||||
|
||||
@Entry
|
||||
@Component
|
||||
struct FireRecordDetail {
|
||||
@State record: FireRecordInfo | null = null;
|
||||
@State loading: boolean = true;
|
||||
@State errorMsg: string = '';
|
||||
@State recordId: string = '';
|
||||
private scroller: Scroller = new Scroller();
|
||||
|
||||
aboutToAppear(): void {
|
||||
const params = router.getParams() as Record<string, Object> | null | undefined;
|
||||
if (params !== null && params !== undefined) {
|
||||
const id = params['id'];
|
||||
this.recordId = typeof id === 'string' ? id : String(id ?? '');
|
||||
}
|
||||
if (this.recordId.length > 0) {
|
||||
this.load();
|
||||
} else {
|
||||
this.loading = false;
|
||||
this.errorMsg = '缺少记录参数';
|
||||
}
|
||||
}
|
||||
|
||||
// 后端字段可能缺失(undefined 会让 .length 崩溃),统一归一化
|
||||
private normalizeRecord(res: FireRecordInfo): FireRecordInfo {
|
||||
const s = (v: string | null | undefined): string => (typeof v === 'string' ? v : '');
|
||||
const photos: Array<string> = Array.isArray(res.photos) ? res.photos : [];
|
||||
return {
|
||||
id: s(res.id),
|
||||
patrolman: s(res.patrolman),
|
||||
point: s(res.point),
|
||||
point_pk: typeof res.point_pk === 'number' ? res.point_pk : 0,
|
||||
time: s(res.time),
|
||||
status: s(res.status),
|
||||
hasPhoto: res.hasPhoto === true,
|
||||
note: s(res.note),
|
||||
photos: photos,
|
||||
recorder: s(res.recorder),
|
||||
created_at: s(res.created_at),
|
||||
updated_at: s(res.updated_at),
|
||||
};
|
||||
}
|
||||
|
||||
private async load(): Promise<void> {
|
||||
this.loading = true;
|
||||
this.errorMsg = '';
|
||||
try {
|
||||
const res = await api.get<FireRecordInfo>(`/fire-patrol/my-records/${encodeURIComponent(this.recordId)}/`);
|
||||
this.record = this.normalizeRecord(res);
|
||||
} catch (err) {
|
||||
this.errorMsg = err instanceof RequestError ? err.message : `${err}`;
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
@Builder
|
||||
InfoRow(label: string, value: string) {
|
||||
Row({ space: 12 }) {
|
||||
Text(label)
|
||||
.fontSize(14)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
.width(76)
|
||||
Text((value ?? '').length > 0 ? value : '--')
|
||||
.fontSize(14)
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.layoutWeight(1)
|
||||
}
|
||||
.width('100%')
|
||||
}
|
||||
|
||||
build() {
|
||||
HdsNavigation() {
|
||||
Column() {
|
||||
Scroll(this.scroller) {
|
||||
Column({ space: 12 }) {
|
||||
if (this.loading) {
|
||||
Text('加载中...')
|
||||
.fontSize(14)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
.width('100%')
|
||||
.padding({ top: 40 })
|
||||
.textAlign(TextAlign.Center)
|
||||
} else if (this.errorMsg.length > 0) {
|
||||
Column({ space: 8 }) {
|
||||
Text('加载失败')
|
||||
.fontSize(16)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
Text(this.errorMsg)
|
||||
.fontSize(13)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ top: 40 })
|
||||
.alignItems(HorizontalAlign.Center)
|
||||
} else if (this.record !== null) {
|
||||
Column({ space: 10 }) {
|
||||
Row({ space: 8 }) {
|
||||
Text(this.record.point)
|
||||
.fontSize(20)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.layoutWeight(1)
|
||||
Text(this.record.status)
|
||||
.fontSize(13)
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor('#FFFFFF')
|
||||
.padding({ left: 12, right: 12, top: 5, bottom: 5 })
|
||||
.backgroundColor(statusColor(this.record.status))
|
||||
.borderRadius(10)
|
||||
}
|
||||
.width('100%')
|
||||
|
||||
this.InfoRow('巡检员', this.record.patrolman)
|
||||
this.InfoRow('打卡时间', this.record.time)
|
||||
this.InfoRow('登记人', this.record.recorder)
|
||||
this.InfoRow('创建时间', this.record.created_at)
|
||||
this.InfoRow('更新时间', this.record.updated_at)
|
||||
}
|
||||
.width('100%')
|
||||
.padding(16)
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.borderRadius(16)
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
.accessibilityLevel('yes')
|
||||
.accessibilityText(`巡检记录,${this.record.point},${this.record.status},巡检员${this.record.patrolman},时间${this.record.time}`)
|
||||
|
||||
// 备注
|
||||
Column({ space: 6 }) {
|
||||
Text('备注')
|
||||
.fontSize(15)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.width('100%')
|
||||
Text(this.record.note.length > 0 ? this.record.note : '无备注')
|
||||
.fontSize(14)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
.width('100%')
|
||||
}
|
||||
.width('100%')
|
||||
.padding(16)
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.borderRadius(16)
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
|
||||
// 现场照片
|
||||
if (this.record.photos.length > 0) {
|
||||
Column({ space: 8 }) {
|
||||
Text(`现场照片 (${this.record.photos.length})`)
|
||||
.fontSize(15)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.width('100%')
|
||||
Flex({ wrap: FlexWrap.Wrap }) {
|
||||
ForEach(this.record.photos, (img: string) => {
|
||||
ProtectedImage({ mediaPath: img })
|
||||
.width('32%')
|
||||
.aspectRatio(1)
|
||||
.borderRadius(10)
|
||||
.margin({ right: '2%', bottom: 8 })
|
||||
.clip(true)
|
||||
}, (img: string) => img)
|
||||
}
|
||||
.width('100%')
|
||||
}
|
||||
.width('100%')
|
||||
.padding(16)
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.borderRadius(16)
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
}
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ left: 16, right: 16, top: 92, bottom: 30 })
|
||||
}
|
||||
.width('100%')
|
||||
.layoutWeight(1)
|
||||
.scrollBar(BarState.Auto)
|
||||
.edgeEffect(EdgeEffect.Spring, { alwaysEnabled: true })
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
}
|
||||
.mode(NavigationMode.Stack)
|
||||
.titleBar({
|
||||
content: {
|
||||
title: {
|
||||
mainTitle: '巡检记录详情',
|
||||
},
|
||||
},
|
||||
style: {
|
||||
scrollEffectOpts: {
|
||||
enableScrollEffect: false,
|
||||
scrollEffectType: ScrollEffectType.GRADIENT_BLUR,
|
||||
},
|
||||
systemMaterialEffect: {
|
||||
materialType: hdsMaterial.MaterialType.ADAPTIVE,
|
||||
materialLevel: hdsMaterial.MaterialLevel.EXQUISITE,
|
||||
},
|
||||
},
|
||||
avoidLayoutSafeArea: false,
|
||||
enableComponentSafeArea: false,
|
||||
})
|
||||
.bindToScrollable([this.scroller])
|
||||
.hideBackButton(false)
|
||||
.titleMode(HdsNavigationTitleMode.MINI)
|
||||
.ignoreLayoutSafeArea([LayoutSafeAreaType.SYSTEM], [LayoutSafeAreaEdge.TOP, LayoutSafeAreaEdge.BOTTOM])
|
||||
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
|
||||
.backgroundColor($r('app.color.background'))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,518 @@
|
||||
// 订餐查询(管理端)(ArkTS 严格类型,无 any)
|
||||
// 接口:GET /meals/orders/list/?service_date=&category=&search=&page=
|
||||
import { promptAction } from '@kit.ArkUI';
|
||||
import { BusinessError } from '@kit.BasicServicesKit';
|
||||
import { HdsNavigation, HdsNavigationTitleMode, ScrollEffectType } from '@kit.UIDesignKit';
|
||||
import { hdsMaterial } from '@hms.hds.hdsMaterial';
|
||||
import { Page, normalizeError } from '../../common/ApiConstants';
|
||||
import { api } from '../../utils/Request';
|
||||
|
||||
interface OrderItem {
|
||||
id: number;
|
||||
elderly_name: string;
|
||||
room: string;
|
||||
bed: string;
|
||||
category: string;
|
||||
service_date: string;
|
||||
is_reserved: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
// 每日用餐统计
|
||||
interface MealCounts {
|
||||
total: number;
|
||||
breakfast: number;
|
||||
lunch: number;
|
||||
dinner: number;
|
||||
}
|
||||
|
||||
interface MealGroups {
|
||||
administrative: MealCounts;
|
||||
elderly_dining: MealCounts;
|
||||
floor_elderly: MealCounts;
|
||||
floor_caregiver: MealCounts;
|
||||
}
|
||||
|
||||
interface OrderSummary {
|
||||
summary_date: string;
|
||||
today_breakfast: Array<string>;
|
||||
today_lunch: Array<string>;
|
||||
today_dinner: Array<string>;
|
||||
today_count: number;
|
||||
week_count: number;
|
||||
month_count: number;
|
||||
breakfast_count: number;
|
||||
lunch_count: number;
|
||||
dinner_count: number;
|
||||
meal_groups: MealGroups;
|
||||
}
|
||||
|
||||
// 每日用餐报告(行政/长者/楼层统计来源于此,对齐 web)
|
||||
interface DailyReport {
|
||||
service_date: string;
|
||||
meal_groups: MealGroups;
|
||||
}
|
||||
|
||||
const CATEGORIES: Array<string> = ['全部', '早餐', '午餐', '晚餐'];
|
||||
|
||||
function today(): string {
|
||||
const d = new Date();
|
||||
const mm = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const dd = String(d.getDate()).padStart(2, '0');
|
||||
return `${d.getFullYear()}-${mm}-${dd}`;
|
||||
}
|
||||
|
||||
function formatSummaryTime(): string {
|
||||
const d = new Date();
|
||||
const hh = String(d.getHours()).padStart(2, '0');
|
||||
const mi = String(d.getMinutes()).padStart(2, '0');
|
||||
const ss = String(d.getSeconds()).padStart(2, '0');
|
||||
return `${hh}:${mi}:${ss}`;
|
||||
}
|
||||
|
||||
function categoryIcon(category: string): string {
|
||||
if (category === '早餐') {
|
||||
return '🌅';
|
||||
}
|
||||
if (category === '午餐') {
|
||||
return '🍚';
|
||||
}
|
||||
if (category === '晚餐') {
|
||||
return '🌙';
|
||||
}
|
||||
return '☀';
|
||||
}
|
||||
|
||||
@Entry
|
||||
@Component
|
||||
struct MealOrders {
|
||||
@State orders: Array<OrderItem> = [];
|
||||
@State loading: boolean = true;
|
||||
@State total: number = 0;
|
||||
@State page: number = 1;
|
||||
@State hasMore: boolean = true;
|
||||
@State category: string = '全部';
|
||||
@State serviceDate: string = today();
|
||||
@State searchText: string = '';
|
||||
@State summary: OrderSummary | null = null;
|
||||
@State reportGroups: MealGroups | null = null;
|
||||
@State summaryLoading: boolean = true;
|
||||
@State noPermission: boolean = false;
|
||||
private scroller: Scroller = new Scroller();
|
||||
|
||||
aboutToAppear(): void {
|
||||
this.loadSummary();
|
||||
this.loadPage(1);
|
||||
}
|
||||
|
||||
private async loadSummary(): Promise<void> {
|
||||
this.summaryLoading = true;
|
||||
try {
|
||||
// 同时拉每日用餐报告(行政/长者/楼层统计来源)与订餐统计
|
||||
const report = await api.get<DailyReport>('/meals/reports/daily/', { service_date: this.serviceDate })
|
||||
.catch((err: BusinessError | Error): DailyReport | null => {
|
||||
console.warn('[MealOrders] daily report error:', normalizeError(err));
|
||||
return null;
|
||||
});
|
||||
const summary = await api.get<OrderSummary>('/meals/orders/summary/', { service_date: this.serviceDate })
|
||||
.catch((err: BusinessError | Error): OrderSummary | null => {
|
||||
console.warn('[MealOrders] order summary error:', normalizeError(err));
|
||||
return null;
|
||||
});
|
||||
this.reportGroups = report ? report.meal_groups : null;
|
||||
this.summary = summary;
|
||||
// 两个接口都失败(如家属无权限 403):视为无权查看
|
||||
this.noPermission = (!report && !summary);
|
||||
// 调试:显示 daily 返回是否成功
|
||||
console.info(`[MealOrders] report=${report ? 'OK' : 'NULL'} administrative_total=${report ? report.meal_groups.administrative.total : 'N/A'}`);
|
||||
} catch (err) {
|
||||
this.noPermission = true;
|
||||
this.reportGroups = null;
|
||||
this.summary = null;
|
||||
console.warn('[MealOrders] load summary failed:', normalizeError(err as BusinessError | Error));
|
||||
} finally {
|
||||
this.summaryLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 选择日期(点击日期打开选择器)
|
||||
private pickDate(): void {
|
||||
const parts: Array<string> = this.serviceDate.split('-');
|
||||
const year = parts.length > 0 ? Number(parts[0]) : 2026;
|
||||
const month = parts.length > 1 ? Number(parts[1]) : 1;
|
||||
const day = parts.length > 2 ? Number(parts[2]) : 1;
|
||||
DatePickerDialog.show({
|
||||
start: new Date('2020-01-01'),
|
||||
end: new Date('2035-12-31'),
|
||||
selected: new Date(year, month - 1, day),
|
||||
onDateAccept: (value: Date) => {
|
||||
const mm = String(value.getMonth() + 1).padStart(2, '0');
|
||||
const dd = String(value.getDate()).padStart(2, '0');
|
||||
this.serviceDate = `${value.getFullYear()}-${mm}-${dd}`;
|
||||
this.loadSummary();
|
||||
this.loadPage(1);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private async loadPage(p: number): Promise<void> {
|
||||
if (p === 1) {
|
||||
this.loading = true;
|
||||
}
|
||||
try {
|
||||
// 内联 Record 字面量(.ets 中不允许 Record 局部变量赋值)
|
||||
const res = await api.get<Page<OrderItem>>('/meals/orders/list/', {
|
||||
page: p,
|
||||
page_size: 20,
|
||||
service_date: this.serviceDate,
|
||||
category: this.category === '全部' ? '' : this.category,
|
||||
search: this.searchText.trim(),
|
||||
});
|
||||
this.total = res.count;
|
||||
this.page = p;
|
||||
this.hasMore = (p - 1) * 20 + res.results.length < res.count;
|
||||
if (p === 1) {
|
||||
this.orders = res.results;
|
||||
} else {
|
||||
this.orders = this.orders.concat(res.results);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[MealOrders] load orders failed:', normalizeError(err as BusinessError | Error));
|
||||
if (this.orders.length === 0) {
|
||||
promptAction.showToast({ message: '订餐记录加载失败,请稍后重试' });
|
||||
}
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private loadMore(): void {
|
||||
if (!this.loading && this.hasMore) {
|
||||
this.loadPage(this.page + 1);
|
||||
}
|
||||
}
|
||||
|
||||
private groupByKey(groups: MealGroups, key: string): MealCounts | null {
|
||||
if (key === 'administrative') {
|
||||
return groups.administrative;
|
||||
}
|
||||
if (key === 'elderly_dining') {
|
||||
return groups.elderly_dining;
|
||||
}
|
||||
if (key === 'floor_elderly') {
|
||||
return groups.floor_elderly;
|
||||
}
|
||||
if (key === 'floor_caregiver') {
|
||||
return groups.floor_caregiver;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private selectCategory(category: string): void {
|
||||
if (this.category !== category) {
|
||||
this.category = category;
|
||||
this.loadPage(1);
|
||||
}
|
||||
}
|
||||
|
||||
private search(): void {
|
||||
this.loadPage(1);
|
||||
}
|
||||
|
||||
@Builder
|
||||
MealCountRow(key: string, title: string) {
|
||||
Column({ space: 6 }) {
|
||||
Row() {
|
||||
Text(title)
|
||||
.fontSize(15)
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
Blank()
|
||||
if (this.countOf(key).total > 0) {
|
||||
Text(`${this.countOf(key).total} 人次`)
|
||||
.fontSize(16)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor($r('app.color.accent_blue'))
|
||||
Row({ space: 10 }) {
|
||||
Text(`早 ${this.countOf(key).breakfast}`)
|
||||
.fontSize(13)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
Text(`午 ${this.countOf(key).lunch}`)
|
||||
.fontSize(13)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
Text(`晚 ${this.countOf(key).dinner}`)
|
||||
.fontSize(13)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
}
|
||||
.margin({ left: 12 })
|
||||
} else {
|
||||
Text('无数据')
|
||||
.fontSize(13)
|
||||
.fontColor($r('app.color.inactive_tab'))
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(VerticalAlign.Center)
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ top: 10, bottom: 10 })
|
||||
}
|
||||
|
||||
// 取分组统计(直接读 @State reportGroups,保证渲染响应)
|
||||
private countOf(key: string): MealCounts {
|
||||
const groups = this.reportGroups;
|
||||
if (!groups) {
|
||||
return { total: 0, breakfast: 0, lunch: 0, dinner: 0 };
|
||||
}
|
||||
const counts = this.groupByKey(groups, key);
|
||||
if (counts) {
|
||||
return counts;
|
||||
}
|
||||
return { total: 0, breakfast: 0, lunch: 0, dinner: 0 };
|
||||
}
|
||||
|
||||
@Builder
|
||||
SummarySection() {
|
||||
Column({ space: 10 }) {
|
||||
Row() {
|
||||
Text('每日用餐统计')
|
||||
.fontSize(18)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
Blank()
|
||||
Text(`更新于 ${formatSummaryTime()}`)
|
||||
.fontSize(12)
|
||||
.fontColor($r('app.color.inactive_tab'))
|
||||
}
|
||||
.width('100%')
|
||||
|
||||
Text(this.serviceDate)
|
||||
.fontSize(13)
|
||||
.fontColor($r('app.color.accent_blue'))
|
||||
.width('100%')
|
||||
|
||||
Column({ space: 0 }) {
|
||||
this.MealCountRow('administrative', '行政用餐')
|
||||
Divider().color($r('app.color.inactive_tab')).opacity(0.3)
|
||||
this.MealCountRow('elderly_dining', '长者饭堂')
|
||||
Divider().color($r('app.color.inactive_tab')).opacity(0.3)
|
||||
this.MealCountRow('floor_elderly', '楼层长者')
|
||||
Divider().color($r('app.color.inactive_tab')).opacity(0.3)
|
||||
this.MealCountRow('floor_caregiver', '楼层护理员')
|
||||
}
|
||||
.width('100%')
|
||||
}
|
||||
.width('100%')
|
||||
.padding(16)
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.borderRadius(16)
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
}
|
||||
|
||||
@Builder
|
||||
OrderCard(item: OrderItem) {
|
||||
Column({ space: 8 }) {
|
||||
Row() {
|
||||
Text(item.elderly_name)
|
||||
.fontSize(17)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
Text(` ${item.room}${item.bed ? `-${item.bed}` : ''}`)
|
||||
.fontSize(14)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
.margin({ left: 6 })
|
||||
Blank()
|
||||
Text(item.category)
|
||||
.fontSize(13)
|
||||
.fontColor($r('app.color.accent_blue'))
|
||||
.padding({ left: 8, right: 8, top: 3, bottom: 3 })
|
||||
.border({ width: 1, color: $r('app.color.accent_blue') })
|
||||
.borderRadius(8)
|
||||
}
|
||||
.width('100%')
|
||||
|
||||
Row() {
|
||||
Text(item.service_date)
|
||||
.fontSize(13)
|
||||
.fontColor($r('app.color.inactive_tab'))
|
||||
Blank()
|
||||
Text(item.is_reserved ? '已预订' : '未预订')
|
||||
.fontSize(13)
|
||||
.fontColor(item.is_reserved ? $r('app.color.accent_green') : $r('app.color.inactive_tab'))
|
||||
}
|
||||
.width('100%')
|
||||
}
|
||||
.width('100%')
|
||||
.padding(14)
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.borderRadius(14)
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
}
|
||||
|
||||
@Builder
|
||||
CategoryTab(category: string) {
|
||||
Column({ space: 2 }) {
|
||||
Text(categoryIcon(category))
|
||||
.fontSize(18)
|
||||
Text(category)
|
||||
.fontSize(14)
|
||||
.fontWeight(this.category === category ? FontWeight.Bold : FontWeight.Normal)
|
||||
.fontColor(this.category === category ? $r('app.color.accent_blue') : $r('app.color.text_secondary'))
|
||||
}
|
||||
.layoutWeight(1)
|
||||
.padding({ top: 8, bottom: 8 })
|
||||
.backgroundColor(this.category === category ? '#14007AFF' : Color.Transparent)
|
||||
.borderRadius(12)
|
||||
.onClick(() => {
|
||||
this.selectCategory(category);
|
||||
})
|
||||
}
|
||||
|
||||
build() {
|
||||
HdsNavigation() {
|
||||
Column() {
|
||||
if (this.noPermission) {
|
||||
Column({ space: 8 }) {
|
||||
Text('🔒')
|
||||
.fontSize(40)
|
||||
Text('仅管理员/护士可查看订餐统计')
|
||||
.fontSize(16)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
}
|
||||
.width('100%')
|
||||
.margin({ top: 120 })
|
||||
.alignItems(HorizontalAlign.Center)
|
||||
} else {
|
||||
// 筛选栏
|
||||
Column({ space: 10 }) {
|
||||
// 日期 + 搜索
|
||||
Row({ space: 10 }) {
|
||||
Text(this.serviceDate)
|
||||
.fontSize(14)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.padding({ left: 12, right: 12, top: 8, bottom: 8 })
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.borderRadius(10)
|
||||
.onClick(() => {
|
||||
this.pickDate();
|
||||
})
|
||||
TextInput({ placeholder: '搜索老人/房间', text: this.searchText })
|
||||
.placeholderColor($r('app.color.inactive_tab'))
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.height(36)
|
||||
.layoutWeight(1)
|
||||
.padding({ left: 12, right: 12 })
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.borderRadius(10)
|
||||
.onChange((value: string) => {
|
||||
this.searchText = value;
|
||||
})
|
||||
Button('搜索')
|
||||
.height(36)
|
||||
.fontSize(13)
|
||||
.fontColor('#FFFFFF')
|
||||
.backgroundColor($r('app.color.accent_blue'))
|
||||
.borderRadius(10)
|
||||
.onClick(() => {
|
||||
this.search();
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
|
||||
// 餐次切换
|
||||
Row({ space: 8 }) {
|
||||
ForEach(CATEGORIES, (category: string) => {
|
||||
this.CategoryTab(category)
|
||||
}, (category: string) => category)
|
||||
}
|
||||
.width('100%')
|
||||
}
|
||||
.width('92%')
|
||||
.padding({ top: 120, bottom: 8 })
|
||||
|
||||
if (this.loading) {
|
||||
Text('加载中...')
|
||||
.fontSize(15)
|
||||
.fontColor($r('app.color.inactive_tab'))
|
||||
.margin({ top: 60 })
|
||||
} else if (this.orders.length === 0 && !this.reportGroups) {
|
||||
Column({ space: 8 }) {
|
||||
Text('🍚')
|
||||
.fontSize(36)
|
||||
Text('暂无订餐记录')
|
||||
.fontSize(16)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
}
|
||||
.width('100%')
|
||||
.margin({ top: 80 })
|
||||
.alignItems(HorizontalAlign.Center)
|
||||
} else {
|
||||
Scroll(this.scroller) {
|
||||
Column({ space: 10 }) {
|
||||
// 每日用餐统计
|
||||
Column() {
|
||||
this.SummarySection()
|
||||
}
|
||||
.width('100%')
|
||||
.margin({ bottom: 4 })
|
||||
|
||||
Text(`共 ${this.total} 条订餐记录`)
|
||||
.fontSize(13)
|
||||
.fontColor($r('app.color.inactive_tab'))
|
||||
.width('100%')
|
||||
ForEach(this.orders, (item: OrderItem) => {
|
||||
this.OrderCard(item)
|
||||
}, (item: OrderItem) => String(item.id))
|
||||
if (this.hasMore) {
|
||||
Text('加载更多...')
|
||||
.fontSize(13)
|
||||
.fontColor($r('app.color.inactive_tab'))
|
||||
.width('100%')
|
||||
.textAlign(TextAlign.Center)
|
||||
.padding(10)
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ left: 16, right: 16, top: 120, bottom: 20 })
|
||||
}
|
||||
.scrollBar(BarState.Auto)
|
||||
.edgeEffect(EdgeEffect.Spring)
|
||||
.layoutWeight(1)
|
||||
.width('100%')
|
||||
.onReachEnd(() => {
|
||||
this.loadMore();
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
}
|
||||
.mode(NavigationMode.Stack)
|
||||
.titleBar({
|
||||
content: {
|
||||
title: {
|
||||
mainTitle: '订餐查询',
|
||||
},
|
||||
},
|
||||
style: {
|
||||
scrollEffectOpts: {
|
||||
enableScrollEffect: false,
|
||||
scrollEffectType: ScrollEffectType.GRADIENT_BLUR,
|
||||
},
|
||||
systemMaterialEffect: {
|
||||
materialType: hdsMaterial.MaterialType.ADAPTIVE,
|
||||
materialLevel: hdsMaterial.MaterialLevel.EXQUISITE,
|
||||
},
|
||||
},
|
||||
avoidLayoutSafeArea: false,
|
||||
enableComponentSafeArea: false,
|
||||
})
|
||||
.bindToScrollable([this.scroller])
|
||||
.hideBackButton(false)
|
||||
.titleMode(HdsNavigationTitleMode.MINI)
|
||||
.ignoreLayoutSafeArea([LayoutSafeAreaType.SYSTEM], [LayoutSafeAreaEdge.TOP, LayoutSafeAreaEdge.BOTTOM])
|
||||
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
|
||||
.backgroundColor($r('app.color.background'))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
// 消息通知页(ArkTS 严格类型,无 any)
|
||||
// 数据来源:/auth/notifications/(用户通知)+ /alerts/(预警),合并时间线
|
||||
import { promptAction } from '@kit.ArkUI';
|
||||
import { HdsNavigation, HdsNavigationTitleMode, ScrollEffectType } from '@kit.UIDesignKit';
|
||||
import { hdsMaterial } from '@hms.hds.hdsMaterial';
|
||||
import { BusinessError } from '@kit.BasicServicesKit';
|
||||
import { Page, normalizeError } from '../../common/ApiConstants';
|
||||
import { api, EmptyBody } from '../../utils/Request';
|
||||
import { FontScaleLevel, FONT_SCALE_MAP } from '../../utils/FontScale';
|
||||
import { AuthStore } from '../../utils/AuthStore';
|
||||
|
||||
interface UserNotice {
|
||||
id: number;
|
||||
title: string;
|
||||
content: string;
|
||||
is_read: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface AlertItem {
|
||||
id: number;
|
||||
title: string;
|
||||
description: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
// 合并后的信息流条目(source 区分类型)
|
||||
interface FeedItem {
|
||||
key: string;
|
||||
source: string; // 'user' | 'alert'
|
||||
id: number;
|
||||
title: string;
|
||||
content: string;
|
||||
isRead: boolean;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
function formatDateTime(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) {
|
||||
return '--';
|
||||
}
|
||||
const mm = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const dd = String(d.getDate()).padStart(2, '0');
|
||||
const hh = String(d.getHours()).padStart(2, '0');
|
||||
const mi = String(d.getMinutes()).padStart(2, '0');
|
||||
return `${mm}/${dd} ${hh}:${mi}`;
|
||||
}
|
||||
|
||||
@Entry
|
||||
@Component
|
||||
struct Notifications {
|
||||
@StorageProp('fontScale') fontScaleLevel: FontScaleLevel = 'system';
|
||||
@State feed: Array<FeedItem> = [];
|
||||
@State loading: boolean = true;
|
||||
@State markingAll: boolean = false;
|
||||
|
||||
private scroller: Scroller = new Scroller();
|
||||
|
||||
private scaleFor(): number {
|
||||
return FONT_SCALE_MAP[this.fontScaleLevel] ?? 1.0;
|
||||
}
|
||||
|
||||
aboutToAppear(): void {
|
||||
this.loadData();
|
||||
}
|
||||
|
||||
private async loadData(): Promise<void> {
|
||||
this.loading = true;
|
||||
try {
|
||||
// 护理预警仅限护理/管理角色:消防、销售不拉取 /alerts/
|
||||
const state = await AuthStore.getState();
|
||||
const role = state.role;
|
||||
const includeAlerts = role === null || role === 'admin' || role === 'nurse' || role === 'staff_nurse';
|
||||
const responses: Array<Page<UserNotice> | Page<AlertItem> | null> = await Promise.all([
|
||||
api.get<Page<UserNotice>>('/auth/notifications/', { page_size: 30 }).catch((): Page<UserNotice> | null => null),
|
||||
includeAlerts
|
||||
? api.get<Page<AlertItem>>('/alerts/', { page_size: 30, ordering: '-created_at' }).catch((): Page<AlertItem> | null => null)
|
||||
: Promise.resolve(null),
|
||||
]);
|
||||
const noticeRes = responses[0] as Page<UserNotice> | null;
|
||||
const alertRes = responses[1] as Page<AlertItem> | null;
|
||||
const items: Array<FeedItem> = [];
|
||||
if (noticeRes) {
|
||||
noticeRes.results.forEach((n: UserNotice) => {
|
||||
items.push({
|
||||
key: `user-${n.id}`,
|
||||
source: 'user',
|
||||
id: n.id,
|
||||
title: n.title,
|
||||
content: n.content,
|
||||
isRead: n.is_read,
|
||||
createdAt: n.created_at,
|
||||
});
|
||||
});
|
||||
}
|
||||
if (alertRes) {
|
||||
alertRes.results.forEach((a: AlertItem) => {
|
||||
items.push({
|
||||
key: `alert-${a.id}`,
|
||||
source: 'alert',
|
||||
id: a.id,
|
||||
title: a.title,
|
||||
content: a.description,
|
||||
isRead: true,
|
||||
createdAt: a.created_at,
|
||||
});
|
||||
});
|
||||
}
|
||||
items.sort((a: FeedItem, b: FeedItem): number => {
|
||||
return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
|
||||
});
|
||||
this.feed = items;
|
||||
} catch (err) {
|
||||
promptAction.showToast({ message: normalizeError(err as BusinessError | Error) });
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async markRead(item: FeedItem): Promise<void> {
|
||||
if (item.source !== 'user' || item.isRead) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await api.post(`/auth/notifications/${item.id}/mark-read/`, new EmptyBody());
|
||||
this.feed = this.feed.map((f: FeedItem): FeedItem => {
|
||||
if (f.key === item.key) {
|
||||
return { key: f.key, source: f.source, id: f.id, title: f.title, content: f.content, isRead: true, createdAt: f.createdAt } as FeedItem;
|
||||
}
|
||||
return f;
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[Notifications] mark read failed:', normalizeError(err as BusinessError | Error));
|
||||
}
|
||||
}
|
||||
|
||||
private async markAllRead(): Promise<void> {
|
||||
if (this.markingAll) {
|
||||
return;
|
||||
}
|
||||
this.markingAll = true;
|
||||
try {
|
||||
await api.post('/auth/notifications/mark-all-read/', new EmptyBody());
|
||||
this.feed = this.feed.map((f: FeedItem): FeedItem =>
|
||||
({ key: f.key, source: f.source, id: f.id, title: f.title, content: f.content, isRead: true, createdAt: f.createdAt }) as FeedItem);
|
||||
promptAction.showToast({ message: '已全部标记为已读' });
|
||||
} catch (err) {
|
||||
promptAction.showToast({ message: normalizeError(err as BusinessError | Error) });
|
||||
} finally {
|
||||
this.markingAll = false;
|
||||
}
|
||||
}
|
||||
|
||||
private hasUnread(): boolean {
|
||||
return this.feed.some((f: FeedItem) => f.source === 'user' && !f.isRead);
|
||||
}
|
||||
|
||||
@Builder
|
||||
FeedCard(key: string) {
|
||||
Column({ space: 8 }) {
|
||||
Row({ space: 8 }) {
|
||||
if (!this.feedItem(key).isRead) {
|
||||
Circle()
|
||||
.width(8)
|
||||
.height(8)
|
||||
.fill($r('app.color.accent_red'))
|
||||
}
|
||||
Text(this.feedItem(key).source === 'user' ? '通知' : '预警')
|
||||
.fontSize(Math.round(11 * this.scaleFor()))
|
||||
.fontColor(this.feedItem(key).source === 'user' ? $r('app.color.accent_blue') : '#FF9500')
|
||||
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
|
||||
.borderRadius(6)
|
||||
.border({ width: 1, color: this.feedItem(key).source === 'user' ? $r('app.color.accent_blue') : '#FF9500' })
|
||||
Blank()
|
||||
Text(formatDateTime(this.feedItem(key).createdAt))
|
||||
.fontSize(Math.round(12 * this.scaleFor()))
|
||||
.fontColor($r('app.color.inactive_tab'))
|
||||
}
|
||||
.width('100%')
|
||||
|
||||
Text(this.feedItem(key).title)
|
||||
.fontSize(Math.round(15 * this.scaleFor()))
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.maxLines(1)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
.width('100%')
|
||||
|
||||
if (this.feedItem(key).content && this.feedItem(key).content.length > 0) {
|
||||
Text(this.feedItem(key).content)
|
||||
.fontSize(Math.round(13 * this.scaleFor()))
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
.maxLines(2)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
.width('100%')
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.padding(16)
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.borderRadius(14)
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
.onClick(() => {
|
||||
this.markRead(this.feedItem(key));
|
||||
})
|
||||
}
|
||||
|
||||
// 按 key 从 @State feed 读取(保证 markAllRead 后重渲红点)
|
||||
private feedItem(key: string): FeedItem {
|
||||
const found = this.feed.find((f: FeedItem) => f.key === key);
|
||||
if (found) {
|
||||
return found;
|
||||
}
|
||||
return { key: key, source: 'user', id: 0, title: '', content: '', isRead: true, createdAt: '' };
|
||||
}
|
||||
|
||||
build() {
|
||||
HdsNavigation() {
|
||||
Column() {
|
||||
if (this.loading) {
|
||||
Text('加载中...')
|
||||
.fontSize(Math.round(14 * this.scaleFor()))
|
||||
.fontColor($r('app.color.inactive_tab'))
|
||||
.margin({ top: 60 })
|
||||
} else if (this.feed.length === 0) {
|
||||
Column({ space: 8 }) {
|
||||
Text('🔔')
|
||||
.fontSize(Math.round(32 * this.scaleFor()))
|
||||
Text('暂无消息通知')
|
||||
.fontSize(Math.round(15 * this.scaleFor()))
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
}
|
||||
.width('100%')
|
||||
.margin({ top: 80 })
|
||||
.alignItems(HorizontalAlign.Center)
|
||||
} else {
|
||||
Scroll(this.scroller) {
|
||||
Column({ space: 10 }) {
|
||||
if (this.hasUnread()) {
|
||||
Row() {
|
||||
Text(`共 ${this.feed.length} 条 · 有新通知`)
|
||||
.fontSize(Math.round(13 * this.scaleFor()))
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
Blank()
|
||||
Text(this.markingAll ? '处理中...' : '全部已读')
|
||||
.fontSize(Math.round(14 * this.scaleFor()))
|
||||
.fontColor($r('app.color.accent_blue'))
|
||||
.onClick(() => {
|
||||
this.markAllRead();
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ left: 4, right: 4, top: 8, bottom: 8 })
|
||||
}
|
||||
ForEach(this.feed, (item: FeedItem) => {
|
||||
this.FeedCard(item.key)
|
||||
}, (item: FeedItem) => item.key)
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ left: 16, right: 16, top: 108, bottom: 24 })
|
||||
}
|
||||
.width('100%')
|
||||
.layoutWeight(1)
|
||||
.scrollBar(BarState.Auto)
|
||||
.edgeEffect(EdgeEffect.Spring, { alwaysEnabled: true })
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
}
|
||||
.mode(NavigationMode.Stack)
|
||||
.titleBar({
|
||||
content: {
|
||||
title: {
|
||||
mainTitle: '消息通知',
|
||||
},
|
||||
},
|
||||
style: {
|
||||
scrollEffectOpts: {
|
||||
enableScrollEffect: false,
|
||||
scrollEffectType: ScrollEffectType.GRADIENT_BLUR,
|
||||
},
|
||||
systemMaterialEffect: {
|
||||
materialType: hdsMaterial.MaterialType.ADAPTIVE,
|
||||
materialLevel: hdsMaterial.MaterialLevel.EXQUISITE,
|
||||
},
|
||||
},
|
||||
avoidLayoutSafeArea: false,
|
||||
enableComponentSafeArea: false,
|
||||
})
|
||||
.bindToScrollable([this.scroller])
|
||||
.hideBackButton(false)
|
||||
.titleMode(HdsNavigationTitleMode.MINI)
|
||||
.ignoreLayoutSafeArea([LayoutSafeAreaType.SYSTEM], [LayoutSafeAreaEdge.TOP, LayoutSafeAreaEdge.BOTTOM])
|
||||
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
|
||||
.backgroundColor($r('app.color.background'))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
// 预警中心(ArkTS 严格类型,无 any)
|
||||
// 数据来源:/alerts/
|
||||
import { promptAction } from '@kit.ArkUI';
|
||||
import { HdsNavigation, HdsNavigationTitleMode, ScrollEffectType } from '@kit.UIDesignKit';
|
||||
import { hdsMaterial } from '@hms.hds.hdsMaterial';
|
||||
import { BusinessError } from '@kit.BasicServicesKit';
|
||||
import { Page, normalizeError } from '../../common/ApiConstants';
|
||||
import { api } from '../../utils/Request';
|
||||
|
||||
interface AlertItem {
|
||||
id: number;
|
||||
title: string;
|
||||
description: string;
|
||||
level: string;
|
||||
status: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
function formatTime(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) {
|
||||
return '--';
|
||||
}
|
||||
const mm = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const dd = String(d.getDate()).padStart(2, '0');
|
||||
const hh = String(d.getHours()).padStart(2, '0');
|
||||
const mi = String(d.getMinutes()).padStart(2, '0');
|
||||
return `${mm}/${dd} ${hh}:${mi}`;
|
||||
}
|
||||
|
||||
function levelColor(level: string): string {
|
||||
const lv = (level || '').toLowerCase();
|
||||
if (lv.indexOf('critical') >= 0 || lv.indexOf('严重') >= 0) {
|
||||
return ('app.color.accent_red');
|
||||
}
|
||||
if (lv.indexOf('warning') >= 0 || lv.indexOf('警告') >= 0) {
|
||||
return '#FF9500';
|
||||
}
|
||||
return ('app.color.accent_blue');
|
||||
}
|
||||
|
||||
@Entry
|
||||
@Component
|
||||
struct Alerts {
|
||||
@State alerts: AlertItem[] = [];
|
||||
@State loading: boolean = true;
|
||||
@State refreshing: boolean = false;
|
||||
|
||||
private scroller: Scroller = new Scroller();
|
||||
|
||||
async aboutToAppear(): Promise<void> {
|
||||
await this.loadAlerts();
|
||||
}
|
||||
|
||||
private async loadAlerts(): Promise<void> {
|
||||
this.loading = true;
|
||||
try {
|
||||
const res = await api.get<Page<AlertItem>>('/alerts/', {
|
||||
page_size: 50,
|
||||
ordering: '-created_at',
|
||||
});
|
||||
this.alerts = res.results;
|
||||
} catch (err) {
|
||||
promptAction.showToast({ message: normalizeError(err as BusinessError | Error) });
|
||||
} finally {
|
||||
this.loading = false;
|
||||
this.refreshing = false;
|
||||
}
|
||||
}
|
||||
|
||||
@Builder
|
||||
AlertItemView(item: AlertItem) {
|
||||
Column({ space: 8 }) {
|
||||
Row({ space: 8 }) {
|
||||
Circle()
|
||||
.width(8)
|
||||
.height(8)
|
||||
.fill(levelColor(item.level))
|
||||
Text(item.title)
|
||||
.fontSize(15)
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.layoutWeight(1)
|
||||
Text(formatTime(item.created_at))
|
||||
.fontSize(12)
|
||||
.fontColor($r('app.color.inactive_tab'))
|
||||
}
|
||||
.width('100%')
|
||||
|
||||
Text(item.description && item.description.length > 0 ? item.description : '(无详情)')
|
||||
.fontSize(13)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
.maxLines(3)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
.width('100%')
|
||||
}
|
||||
.width('100%')
|
||||
.padding(16)
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.borderRadius(14)
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
}
|
||||
|
||||
build() {
|
||||
HdsNavigation() {
|
||||
Column() {
|
||||
Refresh({ refreshing: $$this.refreshing }) {
|
||||
Scroll(this.scroller) {
|
||||
Column({ space: 10 }) {
|
||||
ForEach(this.alerts, (item: AlertItem) => {
|
||||
Column() {
|
||||
this.AlertItemView(item)
|
||||
}
|
||||
.width('100%')
|
||||
}, (item: AlertItem) => String(item.id))
|
||||
if (this.loading) {
|
||||
Text('加载中...')
|
||||
.fontSize(13)
|
||||
.fontColor($r('app.color.inactive_tab'))
|
||||
.padding(12)
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ left: 16, right: 16, top: 92, bottom: 24 })
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
.scrollBar(BarState.Auto)
|
||||
.edgeEffect(EdgeEffect.Spring)
|
||||
}
|
||||
.width('100%')
|
||||
.layoutWeight(1)
|
||||
.onRefreshing(() => {
|
||||
this.loadAlerts();
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
}
|
||||
.mode(NavigationMode.Stack)
|
||||
.titleBar({
|
||||
content: {
|
||||
title: {
|
||||
mainTitle: '预警中心',
|
||||
},
|
||||
},
|
||||
style: {
|
||||
scrollEffectOpts: {
|
||||
enableScrollEffect: false,
|
||||
scrollEffectType: ScrollEffectType.GRADIENT_BLUR,
|
||||
},
|
||||
systemMaterialEffect: {
|
||||
materialType: hdsMaterial.MaterialType.ADAPTIVE,
|
||||
materialLevel: hdsMaterial.MaterialLevel.EXQUISITE,
|
||||
},
|
||||
},
|
||||
avoidLayoutSafeArea: false,
|
||||
enableComponentSafeArea: false,
|
||||
})
|
||||
.bindToScrollable([this.scroller])
|
||||
.hideBackButton(false)
|
||||
.titleMode(HdsNavigationTitleMode.MINI)
|
||||
.ignoreLayoutSafeArea([LayoutSafeAreaType.SYSTEM], [LayoutSafeAreaEdge.TOP, LayoutSafeAreaEdge.BOTTOM])
|
||||
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
|
||||
.backgroundColor($r('app.color.background'))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
// 护理员 · 代取消订餐(内部饭堂,仅明日,ArkTS 严格类型)
|
||||
// 长者:GET /meals/elderly-cancellation-candidates/ ;状态:GET /meals/internal-meals/elderly/{id}/ ;取消:POST /meals/internal-meals/elderly/{id}/
|
||||
import { router, promptAction } from '@kit.ArkUI';
|
||||
import { HdsNavigation, HdsNavigationTitleMode, ScrollEffectType } from '@kit.UIDesignKit';
|
||||
import { hdsMaterial } from '@hms.hds.hdsMaterial';
|
||||
import { BusinessError } from '@kit.BasicServicesKit';
|
||||
import { Page, normalizeError } from '../../common/ApiConstants';
|
||||
import { api, RequestError } from '../../utils/Request';
|
||||
|
||||
interface Candidate {
|
||||
id: number;
|
||||
name: string;
|
||||
gender: string;
|
||||
age: number;
|
||||
room: string;
|
||||
bed: string;
|
||||
}
|
||||
|
||||
interface InternalMeal {
|
||||
category: string;
|
||||
reserved: boolean;
|
||||
}
|
||||
|
||||
interface InternalMealResponse {
|
||||
service_date: string;
|
||||
meals: Array<InternalMeal>;
|
||||
}
|
||||
|
||||
interface CancelBody {
|
||||
service_date: string;
|
||||
category: string;
|
||||
reserved: boolean;
|
||||
whole_grain_rice: boolean;
|
||||
}
|
||||
|
||||
function tomorrowYMD(): string {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() + 1);
|
||||
const mm = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const dd = String(d.getDate()).padStart(2, '0');
|
||||
return `${d.getFullYear()}-${mm}-${dd}`;
|
||||
}
|
||||
|
||||
@Entry
|
||||
@Component
|
||||
struct CancelMeal {
|
||||
@State list: Array<Candidate> = [];
|
||||
@State loading: boolean = true;
|
||||
@State selected: Candidate | null = null;
|
||||
@State meals: Array<InternalMeal> = [];
|
||||
@State mealsLoading: boolean = false;
|
||||
@State cancelingId: string = '';
|
||||
@State keyword: string = '';
|
||||
private scroller: Scroller = new Scroller();
|
||||
|
||||
aboutToAppear(): void {
|
||||
this.loadCandidates();
|
||||
}
|
||||
|
||||
private async loadCandidates(): Promise<void> {
|
||||
this.loading = true;
|
||||
try {
|
||||
const res = await api.get<Page<Candidate>>('/meals/elderly-cancellation-candidates/', { page_size: 100 });
|
||||
this.list = res.results;
|
||||
} catch (err) {
|
||||
const message = err instanceof RequestError ? err.message : normalizeError(err as BusinessError | Error);
|
||||
promptAction.showToast({ message: message });
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private filtered(): Array<Candidate> {
|
||||
const kw = this.keyword.trim();
|
||||
if (kw.length === 0) {
|
||||
return this.list;
|
||||
}
|
||||
return this.list.filter((c: Candidate): boolean => {
|
||||
return c.name.indexOf(kw) >= 0 || (c.room || '').indexOf(kw) >= 0 || (c.bed || '').indexOf(kw) >= 0;
|
||||
});
|
||||
}
|
||||
|
||||
private async select(candidate: Candidate): Promise<void> {
|
||||
this.selected = candidate;
|
||||
this.meals = [];
|
||||
this.mealsLoading = true;
|
||||
try {
|
||||
const date = tomorrowYMD();
|
||||
const res = await api.get<InternalMealResponse>(`/meals/internal-meals/elderly/${candidate.id}/`, {
|
||||
service_date: date,
|
||||
});
|
||||
this.meals = res.meals;
|
||||
} catch (err) {
|
||||
const message = err instanceof RequestError ? err.message : normalizeError(err as BusinessError | Error);
|
||||
promptAction.showToast({ message: message });
|
||||
} finally {
|
||||
this.mealsLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async cancelMeal(meal: InternalMeal): Promise<void> {
|
||||
if (this.cancelingId.length > 0) {
|
||||
return;
|
||||
}
|
||||
if (this.selected === null) {
|
||||
return;
|
||||
}
|
||||
this.cancelingId = meal.category;
|
||||
try {
|
||||
const body: CancelBody = {
|
||||
service_date: tomorrowYMD(),
|
||||
category: meal.category,
|
||||
reserved: false,
|
||||
whole_grain_rice: false,
|
||||
};
|
||||
await api.post(`/meals/internal-meals/elderly/${this.selected.id}/`, body);
|
||||
this.meals = this.meals.map((m: InternalMeal): InternalMeal => {
|
||||
if (m.category === meal.category) {
|
||||
return { category: m.category, reserved: false };
|
||||
}
|
||||
return m;
|
||||
});
|
||||
promptAction.showToast({ message: `已取消明日${meal.category}` });
|
||||
} catch (err) {
|
||||
const message = err instanceof RequestError ? err.message : normalizeError(err as BusinessError | Error);
|
||||
promptAction.showToast({ message: message });
|
||||
} finally {
|
||||
this.cancelingId = '';
|
||||
}
|
||||
}
|
||||
|
||||
@Builder
|
||||
CandidateRow(c: Candidate) {
|
||||
Row({ space: 10 }) {
|
||||
Text(c.name)
|
||||
.fontSize(15)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
Text(`${c.room || ''}${c.bed ? ' ' + c.bed : ''}`)
|
||||
.fontSize(12)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
Blank()
|
||||
Text(this.selected !== null && this.selected.id === c.id ? '已选' : '›')
|
||||
.fontSize(13)
|
||||
.fontColor(this.selected !== null && this.selected.id === c.id ? '#007AFF' : $r('app.color.inactive_tab'))
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ left: 14, right: 14, top: 12, bottom: 12 })
|
||||
.backgroundColor(this.selected !== null && this.selected.id === c.id ? '#1A007AFF' : $r('app.color.background_card'))
|
||||
.borderRadius(10)
|
||||
.onClick(() => {
|
||||
this.select(c);
|
||||
})
|
||||
}
|
||||
|
||||
@Builder
|
||||
MealRow(meal: InternalMeal) {
|
||||
Row({ space: 10 }) {
|
||||
Text(meal.category)
|
||||
.fontSize(15)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
Blank()
|
||||
if (meal.reserved) {
|
||||
Text('已预订')
|
||||
.fontSize(12)
|
||||
.fontColor($r('app.color.accent_green'))
|
||||
Text(this.cancelingId === meal.category ? '取消中...' : '取消')
|
||||
.fontSize(13)
|
||||
.fontColor('#FFFFFF')
|
||||
.padding({ left: 12, right: 12, top: 5, bottom: 5 })
|
||||
.backgroundColor(this.cancelingId === meal.category ? '#A9A9A9' : '#FF3B30')
|
||||
.borderRadius(8)
|
||||
.onClick(() => {
|
||||
this.cancelMeal(meal);
|
||||
})
|
||||
} else {
|
||||
Text('未预订')
|
||||
.fontSize(12)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ left: 14, right: 14, top: 12, bottom: 12 })
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.borderRadius(10)
|
||||
.alignItems(VerticalAlign.Center)
|
||||
}
|
||||
|
||||
build() {
|
||||
HdsNavigation() {
|
||||
Column() {
|
||||
Scroll(this.scroller) {
|
||||
Column({ space: 12 }) {
|
||||
// 长者搜索
|
||||
TextInput({ placeholder: '搜索姓名 / 房间 / 床位', text: this.keyword })
|
||||
.width('100%')
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.borderRadius(10)
|
||||
.onChange((value: string) => {
|
||||
this.keyword = value;
|
||||
})
|
||||
|
||||
// 长者列表
|
||||
if (this.loading) {
|
||||
Text('正在加载长者...')
|
||||
.fontSize(14)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
.padding(16)
|
||||
} else if (this.filtered().length === 0) {
|
||||
Text('暂无符合条件的长者')
|
||||
.fontSize(14)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
.padding(16)
|
||||
} else {
|
||||
ForEach(this.filtered(), (c: Candidate) => {
|
||||
this.CandidateRow(c)
|
||||
}, (c: Candidate) => String(c.id))
|
||||
}
|
||||
|
||||
// 明日订餐面板
|
||||
if (this.selected !== null) {
|
||||
Column({ space: 8 }) {
|
||||
Row() {
|
||||
Text(`明日订餐 · ${this.selected.name}`)
|
||||
.fontSize(16)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
Blank()
|
||||
Text(tomorrowYMD())
|
||||
.fontSize(12)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
}
|
||||
.width('100%')
|
||||
|
||||
if (this.mealsLoading) {
|
||||
Text('正在加载订餐状态...')
|
||||
.fontSize(13)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
.padding(12)
|
||||
} else if (this.meals.length === 0) {
|
||||
Text('明日暂无订餐数据')
|
||||
.fontSize(13)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
.padding(12)
|
||||
} else {
|
||||
ForEach(this.meals, (meal: InternalMeal) => {
|
||||
this.MealRow(meal)
|
||||
}, (meal: InternalMeal) => meal.category)
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.padding(16)
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.borderRadius(14)
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ left: 16, right: 16, top: 120, bottom: 24 })
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
.scrollBar(BarState.Auto)
|
||||
.edgeEffect(EdgeEffect.Spring)
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
}
|
||||
.mode(NavigationMode.Stack)
|
||||
.titleBar({
|
||||
content: {
|
||||
title: {
|
||||
mainTitle: '代取消订餐',
|
||||
},
|
||||
},
|
||||
style: {
|
||||
scrollEffectOpts: {
|
||||
enableScrollEffect: false,
|
||||
scrollEffectType: ScrollEffectType.GRADIENT_BLUR,
|
||||
},
|
||||
systemMaterialEffect: {
|
||||
materialType: hdsMaterial.MaterialType.ADAPTIVE,
|
||||
materialLevel: hdsMaterial.MaterialLevel.EXQUISITE,
|
||||
},
|
||||
},
|
||||
avoidLayoutSafeArea: false,
|
||||
enableComponentSafeArea: false,
|
||||
})
|
||||
.hideBackButton(false)
|
||||
.titleMode(HdsNavigationTitleMode.MINI)
|
||||
.ignoreLayoutSafeArea([LayoutSafeAreaType.SYSTEM], [LayoutSafeAreaEdge.TOP, LayoutSafeAreaEdge.BOTTOM])
|
||||
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
|
||||
.backgroundColor($r('app.color.background'))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
// 护理记录历史列表(ArkTS 严格类型,无 any)
|
||||
// 数据来源:/tasks/nursing-records/
|
||||
import { router, promptAction } from '@kit.ArkUI';
|
||||
import { HdsNavigation, HdsNavigationTitleMode, ScrollEffectType } from '@kit.UIDesignKit';
|
||||
import { hdsMaterial } from '@hms.hds.hdsMaterial';
|
||||
import { BusinessError } from '@kit.BasicServicesKit';
|
||||
import { Page, normalizeError } from '../../common/ApiConstants';
|
||||
import { api } from '../../utils/Request';
|
||||
import { ProtectedImage } from '../../components/ProtectedImage';
|
||||
|
||||
interface ApiRecord {
|
||||
id: number;
|
||||
elderly_name: string;
|
||||
nurse_name: string | null;
|
||||
raw_text: string;
|
||||
images: Array<string> | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
// 记录详情页路由参数
|
||||
interface RecordDetailParams {
|
||||
id: string;
|
||||
}
|
||||
|
||||
function formatTime(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) {
|
||||
return '--';
|
||||
}
|
||||
const mm = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const dd = String(d.getDate()).padStart(2, '0');
|
||||
const hh = String(d.getHours()).padStart(2, '0');
|
||||
const mi = String(d.getMinutes()).padStart(2, '0');
|
||||
return `${mm}/${dd} ${hh}:${mi}`;
|
||||
}
|
||||
|
||||
@Entry
|
||||
@Component
|
||||
struct History {
|
||||
@State records: ApiRecord[] = [];
|
||||
@State loading: boolean = true;
|
||||
@State total: number = 0;
|
||||
@State page: number = 1;
|
||||
@State hasMore: boolean = true;
|
||||
|
||||
private scroller: Scroller = new Scroller();
|
||||
|
||||
async aboutToAppear(): Promise<void> {
|
||||
await this.loadPage(1);
|
||||
}
|
||||
|
||||
async onPageShow(): Promise<void> {
|
||||
await this.loadPage(1);
|
||||
}
|
||||
|
||||
private async loadPage(p: number): Promise<void> {
|
||||
if (p === 1) {
|
||||
this.loading = true;
|
||||
}
|
||||
try {
|
||||
const res = await api.get<Page<ApiRecord>>('/tasks/nursing-records/', {
|
||||
page: p,
|
||||
page_size: 20,
|
||||
ordering: '-created_at',
|
||||
});
|
||||
this.total = res.count;
|
||||
this.page = p;
|
||||
this.hasMore = this.records.length + res.results.length < res.count;
|
||||
if (p === 1) {
|
||||
this.records = res.results;
|
||||
} else {
|
||||
this.records = this.records.concat(res.results);
|
||||
}
|
||||
} catch (err) {
|
||||
promptAction.showToast({ message: normalizeError(err as BusinessError | Error) });
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private loadMore(): void {
|
||||
if (!this.loading && this.hasMore) {
|
||||
this.loadPage(this.page + 1);
|
||||
}
|
||||
}
|
||||
|
||||
private openDetail(item: ApiRecord): void {
|
||||
const params: RecordDetailParams = { id: String(item.id) };
|
||||
router.pushUrl({
|
||||
url: 'pages/nurse/RecordDetail',
|
||||
params: params,
|
||||
}).catch((err: BusinessError) => {
|
||||
promptAction.showToast({ message: `页面打开失败:${normalizeError(err)}` });
|
||||
});
|
||||
}
|
||||
|
||||
@Builder
|
||||
RecordItem(item: ApiRecord) {
|
||||
Column({ space: 8 }) {
|
||||
Row() {
|
||||
Text(item.elderly_name)
|
||||
.fontSize(16)
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
Blank()
|
||||
Text(formatTime(item.created_at))
|
||||
.fontSize(12)
|
||||
.fontColor($r('app.color.inactive_tab'))
|
||||
}
|
||||
.width('100%')
|
||||
|
||||
Text(item.raw_text && item.raw_text.length > 0 ? item.raw_text : '(无文字内容)')
|
||||
.fontSize(13)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
.maxLines(2)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
.width('100%')
|
||||
|
||||
if (item.images && item.images.length > 0) {
|
||||
Row({ space: 8 }) {
|
||||
ForEach(item.images.slice(0, 3), (img: string) => {
|
||||
Stack() {
|
||||
ProtectedImage({ mediaPath: img })
|
||||
}
|
||||
.width(64)
|
||||
.height(64)
|
||||
.borderRadius(8)
|
||||
.clip(true)
|
||||
}, (img: string) => img)
|
||||
if (item.images.length > 3) {
|
||||
Text(`+${item.images.length - 3}`)
|
||||
.fontSize(12)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.padding(16)
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.borderRadius(14)
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
}
|
||||
|
||||
build() {
|
||||
HdsNavigation() {
|
||||
Column() {
|
||||
Scroll(this.scroller) {
|
||||
Column({ space: 10 }) {
|
||||
ForEach(this.records, (item: ApiRecord) => {
|
||||
Column() {
|
||||
this.RecordItem(item)
|
||||
}
|
||||
.width('100%')
|
||||
.onClick(() => {
|
||||
this.openDetail(item);
|
||||
})
|
||||
}, (item: ApiRecord) => String(item.id))
|
||||
if (this.loading) {
|
||||
Text('加载中...')
|
||||
.fontSize(13)
|
||||
.fontColor($r('app.color.inactive_tab'))
|
||||
.padding(12)
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ left: 16, right: 16, top: 92, bottom: 24 })
|
||||
}
|
||||
.width('100%')
|
||||
.layoutWeight(1)
|
||||
.scrollBar(BarState.Auto)
|
||||
.edgeEffect(EdgeEffect.Spring)
|
||||
.onReachEnd(() => {
|
||||
this.loadMore();
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
}
|
||||
.mode(NavigationMode.Stack)
|
||||
.titleBar({
|
||||
content: {
|
||||
title: {
|
||||
mainTitle: '历史记录',
|
||||
},
|
||||
},
|
||||
style: {
|
||||
scrollEffectOpts: {
|
||||
enableScrollEffect: false,
|
||||
scrollEffectType: ScrollEffectType.GRADIENT_BLUR,
|
||||
},
|
||||
systemMaterialEffect: {
|
||||
materialType: hdsMaterial.MaterialType.ADAPTIVE,
|
||||
materialLevel: hdsMaterial.MaterialLevel.EXQUISITE,
|
||||
},
|
||||
},
|
||||
avoidLayoutSafeArea: false,
|
||||
enableComponentSafeArea: false,
|
||||
})
|
||||
.bindToScrollable([this.scroller])
|
||||
.hideBackButton(false)
|
||||
.titleMode(HdsNavigationTitleMode.MINI)
|
||||
.ignoreLayoutSafeArea([LayoutSafeAreaType.SYSTEM], [LayoutSafeAreaEdge.TOP, LayoutSafeAreaEdge.BOTTOM])
|
||||
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
|
||||
.backgroundColor($r('app.color.background'))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,392 @@
|
||||
// 护理员 · 今日喂药待办(ArkTS 严格类型,无 any)
|
||||
// 接口:GET /care-plans/medication-administrations/today/
|
||||
// POST /care-plans/medication-administrations/{id}/execute/
|
||||
import { router } from '@kit.ArkUI';
|
||||
import { promptAction } from '@kit.ArkUI';
|
||||
import { HdsNavigation, HdsNavigationTitleMode, ScrollEffectType } from '@kit.UIDesignKit';
|
||||
import { hdsMaterial } from '@hms.hds.hdsMaterial';
|
||||
import { BusinessError } from '@kit.BasicServicesKit';
|
||||
import { normalizeError } from '../../common/ApiConstants';
|
||||
import { api, RequestError } from '../../utils/Request';
|
||||
|
||||
interface MedicationAdministration {
|
||||
id: number;
|
||||
elderly_name: string;
|
||||
elderly: number;
|
||||
medicine_name: string;
|
||||
dosage: string;
|
||||
route: string;
|
||||
meal_timing: string;
|
||||
prescribed_note: string;
|
||||
scheduled_time: string;
|
||||
scheduled_at: string;
|
||||
status: string;
|
||||
effective_status: string;
|
||||
actual_at: string | null;
|
||||
reason: string;
|
||||
administered_by_name: string;
|
||||
}
|
||||
|
||||
interface ExecutePayload {
|
||||
status: string;
|
||||
actual_at?: string;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
function mealTimingLabel(timing: string): string {
|
||||
if (timing === 'before_meal') {
|
||||
return '餐前';
|
||||
}
|
||||
if (timing === 'after_meal') {
|
||||
return '餐后';
|
||||
}
|
||||
return '不限';
|
||||
}
|
||||
|
||||
function statusColor(status: string): string {
|
||||
if (status === 'administered') {
|
||||
return '#34C759';
|
||||
}
|
||||
if (status === 'refused') {
|
||||
return '#FF3B30';
|
||||
}
|
||||
if (status === 'deferred') {
|
||||
return '#FF9500';
|
||||
}
|
||||
if (status === 'missed') {
|
||||
return '#8E8E93';
|
||||
}
|
||||
if (status === 'overdue') {
|
||||
return '#FF3B30';
|
||||
}
|
||||
if (status === 'processing') {
|
||||
return '#007AFF';
|
||||
}
|
||||
return '#8E8E93';
|
||||
}
|
||||
|
||||
function statusLabel(status: string): string {
|
||||
if (status === 'administered') {
|
||||
return '已给药';
|
||||
}
|
||||
if (status === 'refused') {
|
||||
return '已拒服';
|
||||
}
|
||||
if (status === 'deferred') {
|
||||
return '已暂缓';
|
||||
}
|
||||
if (status === 'missed') {
|
||||
return '已漏服';
|
||||
}
|
||||
if (status === 'overdue') {
|
||||
return '已逾期';
|
||||
}
|
||||
if (status === 'processing') {
|
||||
return '处理中';
|
||||
}
|
||||
return '待执行';
|
||||
}
|
||||
|
||||
function formatSlot(iso: string): string {
|
||||
if (!iso || iso.length === 0) {
|
||||
return '--';
|
||||
}
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) {
|
||||
return '--';
|
||||
}
|
||||
const hh = String(d.getHours()).padStart(2, '0');
|
||||
const mi = String(d.getMinutes()).padStart(2, '0');
|
||||
return `${hh}:${mi}`;
|
||||
}
|
||||
|
||||
function todayStr(): string {
|
||||
const now = new Date();
|
||||
const mm = String(now.getMonth() + 1).padStart(2, '0');
|
||||
const dd = String(now.getDate()).padStart(2, '0');
|
||||
return `${now.getFullYear()}-${mm}-${dd}`;
|
||||
}
|
||||
|
||||
@Entry
|
||||
@Component
|
||||
struct MedicationToDo {
|
||||
@State items: Array<MedicationAdministration> = [];
|
||||
@State loading: boolean = true;
|
||||
@State executingId: number = 0;
|
||||
// 异常原因输入
|
||||
@State showReasonSheet: boolean = false;
|
||||
@State reasonTargetId: number = 0;
|
||||
@State reasonTargetStatus: string = '';
|
||||
@State reasonText: string = '';
|
||||
|
||||
private scroller: Scroller = new Scroller();
|
||||
|
||||
aboutToAppear(): void {
|
||||
this.load();
|
||||
}
|
||||
|
||||
private async load(): Promise<void> {
|
||||
this.loading = true;
|
||||
try {
|
||||
const res = await api.get<Array<MedicationAdministration>>(
|
||||
'/care-plans/medication-administrations/today/');
|
||||
this.items = res;
|
||||
} catch (err) {
|
||||
const message = err instanceof RequestError ? err.message : normalizeError(err as BusinessError | Error);
|
||||
promptAction.showToast({ message: message });
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async execute(item: MedicationAdministration, status: string): Promise<void> {
|
||||
if (this.executingId !== 0) {
|
||||
return;
|
||||
}
|
||||
if (status !== 'administered') {
|
||||
this.reasonTargetId = item.id;
|
||||
this.reasonTargetStatus = status;
|
||||
this.reasonText = '';
|
||||
this.showReasonSheet = true;
|
||||
return;
|
||||
}
|
||||
this.executingId = item.id;
|
||||
try {
|
||||
const payload: ExecutePayload = { status: 'administered' };
|
||||
await api.post(`/care-plans/medication-administrations/${item.id}/execute/`, payload);
|
||||
promptAction.showToast({ message: `已记录:${item.medicine_name}` });
|
||||
await this.load();
|
||||
} catch (err) {
|
||||
const message = err instanceof RequestError ? err.message : normalizeError(err as BusinessError | Error);
|
||||
promptAction.showToast({ message: message });
|
||||
} finally {
|
||||
this.executingId = 0;
|
||||
}
|
||||
}
|
||||
|
||||
private async submitReason(): Promise<void> {
|
||||
if (this.reasonText.trim().length === 0) {
|
||||
promptAction.showToast({ message: '请填写原因' });
|
||||
return;
|
||||
}
|
||||
this.executingId = this.reasonTargetId;
|
||||
try {
|
||||
const payload: ExecutePayload = {
|
||||
status: this.reasonTargetStatus,
|
||||
reason: this.reasonText.trim(),
|
||||
};
|
||||
await api.post(`/care-plans/medication-administrations/${this.reasonTargetId}/execute/`, payload);
|
||||
this.showReasonSheet = false;
|
||||
promptAction.showToast({ message: '已记录异常' });
|
||||
await this.load();
|
||||
} catch (err) {
|
||||
const message = err instanceof RequestError ? err.message : normalizeError(err as BusinessError | Error);
|
||||
promptAction.showToast({ message: message });
|
||||
} finally {
|
||||
this.executingId = 0;
|
||||
}
|
||||
}
|
||||
|
||||
@Builder
|
||||
MedCard(item: MedicationAdministration) {
|
||||
Column({ space: 8 }) {
|
||||
Row({ space: 10 }) {
|
||||
Text(item.elderly_name)
|
||||
.fontSize(17)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
Blank()
|
||||
Text(statusLabel(item.effective_status))
|
||||
.fontSize(12)
|
||||
.fontColor(statusColor(item.effective_status))
|
||||
}
|
||||
.width('100%')
|
||||
|
||||
Row() {
|
||||
Text(item.medicine_name)
|
||||
.fontSize(16)
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor($r('app.color.accent_blue'))
|
||||
Text(item.dosage)
|
||||
.fontSize(14)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
.margin({ left: 8 })
|
||||
}
|
||||
.width('100%')
|
||||
|
||||
Row({ space: 12 }) {
|
||||
Text(`⏰ ${formatSlot(item.scheduled_at)}`)
|
||||
.fontSize(13)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
Text(item.route)
|
||||
.fontSize(13)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
Text(mealTimingLabel(item.meal_timing))
|
||||
.fontSize(13)
|
||||
.fontColor($r('app.color.accent_blue'))
|
||||
}
|
||||
.width('100%')
|
||||
|
||||
if (item.prescribed_note && item.prescribed_note.length > 0) {
|
||||
Text(item.prescribed_note)
|
||||
.fontSize(13)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
.width('100%')
|
||||
}
|
||||
|
||||
if (item.effective_status !== 'administered' &&
|
||||
item.effective_status !== 'refused' &&
|
||||
item.effective_status !== 'deferred' &&
|
||||
item.effective_status !== 'missed') {
|
||||
Row({ space: 10 }) {
|
||||
Button(this.executingId === item.id ? '处理中...' : '已给药')
|
||||
.height(36)
|
||||
.fontSize(14)
|
||||
.fontColor('#FFFFFF')
|
||||
.backgroundColor('#34C759')
|
||||
.borderRadius(10)
|
||||
.layoutWeight(1)
|
||||
.enabled(this.executingId === 0)
|
||||
.onClick(() => {
|
||||
this.execute(item, 'administered');
|
||||
})
|
||||
Button('拒服')
|
||||
.height(36)
|
||||
.fontSize(14)
|
||||
.fontColor('#FF3B30')
|
||||
.backgroundColor('#14FF3B30')
|
||||
.borderRadius(10)
|
||||
.layoutWeight(1)
|
||||
.enabled(this.executingId === 0)
|
||||
.onClick(() => {
|
||||
this.execute(item, 'refused');
|
||||
})
|
||||
Button('暂缓')
|
||||
.height(36)
|
||||
.fontSize(14)
|
||||
.fontColor('#FF9500')
|
||||
.backgroundColor('#14FF9500')
|
||||
.borderRadius(10)
|
||||
.layoutWeight(1)
|
||||
.enabled(this.executingId === 0)
|
||||
.onClick(() => {
|
||||
this.execute(item, 'deferred');
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.padding(14)
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.borderRadius(14)
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
}
|
||||
|
||||
@Builder
|
||||
ReasonSheet() {
|
||||
Column({ space: 12 }) {
|
||||
Text(`说明${statusLabel(this.reasonTargetStatus)}原因`)
|
||||
.fontSize(17)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
TextArea({ placeholder: '请输入原因(必填)', text: this.reasonText })
|
||||
.placeholderColor($r('app.color.inactive_tab'))
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.height(120)
|
||||
.padding(14)
|
||||
.backgroundColor($r('app.color.background'))
|
||||
.borderRadius(12)
|
||||
.onChange((value: string) => {
|
||||
this.reasonText = value;
|
||||
})
|
||||
Button(this.executingId === this.reasonTargetId ? '提交中...' : '确认提交')
|
||||
.width('100%')
|
||||
.height(48)
|
||||
.fontColor('#FFFFFF')
|
||||
.backgroundColor($r('app.color.accent_blue'))
|
||||
.borderRadius(12)
|
||||
.enabled(this.executingId === 0)
|
||||
.onClick(() => {
|
||||
this.submitReason();
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
.padding(20)
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.borderRadius(16)
|
||||
}
|
||||
|
||||
build() {
|
||||
HdsNavigation() {
|
||||
Column() {
|
||||
Scroll(this.scroller) {
|
||||
Column({ space: 12 }) {
|
||||
if (this.loading) {
|
||||
Text('加载中...')
|
||||
.fontSize(15)
|
||||
.fontColor($r('app.color.inactive_tab'))
|
||||
.margin({ top: 40 })
|
||||
} else if (this.items.length === 0) {
|
||||
Text('今日暂无喂药任务')
|
||||
.fontSize(15)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
.width('100%')
|
||||
.padding(24)
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.borderRadius(14)
|
||||
.textAlign(TextAlign.Center)
|
||||
} else {
|
||||
ForEach(this.items, (item: MedicationAdministration) => {
|
||||
Column() {
|
||||
this.MedCard(item)
|
||||
}
|
||||
.width('100%')
|
||||
}, (item: MedicationAdministration) => String(item.id))
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ left: 16, right: 16, top: 120, bottom: 30 })
|
||||
}
|
||||
.width('100%')
|
||||
.layoutWeight(1)
|
||||
.scrollBar(BarState.Auto)
|
||||
.edgeEffect(EdgeEffect.Spring)
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
}
|
||||
.mode(NavigationMode.Stack)
|
||||
.titleBar({
|
||||
content: {
|
||||
title: {
|
||||
mainTitle: '今日喂药待办',
|
||||
},
|
||||
},
|
||||
style: {
|
||||
scrollEffectOpts: {
|
||||
enableScrollEffect: false,
|
||||
scrollEffectType: ScrollEffectType.GRADIENT_BLUR,
|
||||
},
|
||||
systemMaterialEffect: {
|
||||
materialType: hdsMaterial.MaterialType.ADAPTIVE,
|
||||
materialLevel: hdsMaterial.MaterialLevel.EXQUISITE,
|
||||
},
|
||||
},
|
||||
avoidLayoutSafeArea: false,
|
||||
enableComponentSafeArea: false,
|
||||
})
|
||||
.bindToScrollable([this.scroller])
|
||||
.hideBackButton(false)
|
||||
.titleMode(HdsNavigationTitleMode.MINI)
|
||||
.ignoreLayoutSafeArea([LayoutSafeAreaType.SYSTEM], [LayoutSafeAreaEdge.TOP, LayoutSafeAreaEdge.BOTTOM])
|
||||
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
|
||||
.backgroundColor($r('app.color.background'))
|
||||
.bindSheet($$this.showReasonSheet, this.ReasonSheet(), {
|
||||
height: 300,
|
||||
dragBar: true,
|
||||
showClose: true,
|
||||
backgroundColor: $r('app.color.background_card'),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
// 护士 · 预警处理留证(ArkTS 严格类型,无 any)
|
||||
// 接口:GET /alerts/、GET /alerts/{id}/、POST /alerts/{id}/start_process/、POST /alerts/{id}/process/
|
||||
import { promptAction } from '@kit.ArkUI';
|
||||
import { HdsNavigation, HdsNavigationTitleMode, ScrollEffectType } from '@kit.UIDesignKit';
|
||||
import { hdsMaterial } from '@hms.hds.hdsMaterial';
|
||||
import { BusinessError } from '@kit.BasicServicesKit';
|
||||
import { Page, normalizeError } from '../../common/ApiConstants';
|
||||
import { api, EmptyBody, RequestError } from '../../utils/Request';
|
||||
|
||||
interface AlertItem {
|
||||
id: number;
|
||||
elderly_name: string;
|
||||
level: string;
|
||||
title: string;
|
||||
description: string;
|
||||
status: string;
|
||||
status_display: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
function levelColor(level: string): string {
|
||||
if (level === 'emergency') {
|
||||
return '#FF3B30';
|
||||
}
|
||||
if (level === 'warning') {
|
||||
return '#FF9500';
|
||||
}
|
||||
return '#007AFF';
|
||||
}
|
||||
|
||||
function levelLabel(level: string): string {
|
||||
if (level === 'emergency') {
|
||||
return '紧急';
|
||||
}
|
||||
if (level === 'warning') {
|
||||
return '注意';
|
||||
}
|
||||
return '信息';
|
||||
}
|
||||
|
||||
function formatDateTime(iso: string): string {
|
||||
if (!iso) {
|
||||
return '--';
|
||||
}
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) {
|
||||
return '--';
|
||||
}
|
||||
const mm = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const dd = String(d.getDate()).padStart(2, '0');
|
||||
const hh = String(d.getHours()).padStart(2, '0');
|
||||
const mi = String(d.getMinutes()).padStart(2, '0');
|
||||
return `${mm}/${dd} ${hh}:${mi}`;
|
||||
}
|
||||
|
||||
@Entry
|
||||
@Component
|
||||
struct NurseAlertProcess {
|
||||
@State alerts: Array<AlertItem> = [];
|
||||
@State loading: boolean = true;
|
||||
@State processing: boolean = false;
|
||||
@State targetAlert: AlertItem | null = null;
|
||||
@State showSheet: boolean = false;
|
||||
@State processNote: string = '';
|
||||
private scroller: Scroller = new Scroller();
|
||||
|
||||
aboutToAppear(): void {
|
||||
this.load();
|
||||
}
|
||||
|
||||
private async load(): Promise<void> {
|
||||
this.loading = true;
|
||||
try {
|
||||
const res = await api.get<Page<AlertItem>>('/alerts/', {
|
||||
status: 'pending',
|
||||
page_size: 50,
|
||||
ordering: '-created_at',
|
||||
});
|
||||
this.alerts = res.results;
|
||||
} catch (err) {
|
||||
const message = err instanceof RequestError ? err.message : normalizeError(err as BusinessError | Error);
|
||||
promptAction.showToast({ message: message });
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private openProcess(alert: AlertItem): void {
|
||||
this.targetAlert = alert;
|
||||
this.processNote = '';
|
||||
this.showSheet = true;
|
||||
}
|
||||
|
||||
private async submit(): Promise<void> {
|
||||
if (this.processing || this.targetAlert === null) {
|
||||
return;
|
||||
}
|
||||
if (this.processNote.trim().length === 0) {
|
||||
promptAction.showToast({ message: '请填写处理说明' });
|
||||
return;
|
||||
}
|
||||
this.processing = true;
|
||||
const id = this.targetAlert.id;
|
||||
try {
|
||||
await api.post(`/alerts/${id}/start_process/`, new EmptyBody()).catch((err: BusinessError | Error) => {
|
||||
console.warn('[NurseAlertProcess] start_process:', normalizeError(err));
|
||||
});
|
||||
await api.post(`/alerts/${id}/process/`, { description: this.processNote.trim(), images: [] });
|
||||
this.showSheet = false;
|
||||
promptAction.showToast({ message: '预警已处理并留证' });
|
||||
await this.load();
|
||||
} catch (err) {
|
||||
const message = err instanceof RequestError ? err.message : normalizeError(err as BusinessError | Error);
|
||||
promptAction.showToast({ message: message });
|
||||
} finally {
|
||||
this.processing = false;
|
||||
}
|
||||
}
|
||||
|
||||
@Builder
|
||||
AlertCard(alert: AlertItem) {
|
||||
Column({ space: 6 }) {
|
||||
Row({ space: 8 }) {
|
||||
Text(levelLabel(alert.level))
|
||||
.fontSize(11)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor(levelColor(alert.level))
|
||||
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
|
||||
.backgroundColor('#14000000')
|
||||
.borderRadius(4)
|
||||
Text(alert.title)
|
||||
.fontSize(15)
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.layoutWeight(1)
|
||||
.maxLines(1)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
}
|
||||
.width('100%')
|
||||
|
||||
if (alert.description && alert.description.length > 0) {
|
||||
Text(alert.description)
|
||||
.fontSize(13)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
.maxLines(2)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
.width('100%')
|
||||
}
|
||||
|
||||
Row({ space: 10 }) {
|
||||
Text(alert.elderly_name)
|
||||
.fontSize(12)
|
||||
.fontColor($r('app.color.accent_blue'))
|
||||
Blank()
|
||||
Text(formatDateTime(alert.created_at))
|
||||
.fontSize(12)
|
||||
.fontColor($r('app.color.inactive_tab'))
|
||||
Button('处理')
|
||||
.height(30)
|
||||
.fontSize(12)
|
||||
.fontColor('#FFFFFF')
|
||||
.backgroundColor($r('app.color.accent_green'))
|
||||
.borderRadius(8)
|
||||
.onClick(() => {
|
||||
this.openProcess(alert);
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
}
|
||||
.width('100%')
|
||||
.padding(14)
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.borderRadius(12)
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
}
|
||||
|
||||
@Builder
|
||||
ProcessSheet() {
|
||||
Column({ space: 12 }) {
|
||||
Text('处理预警')
|
||||
.fontSize(18)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
Text(this.targetAlert ? `${this.targetAlert.elderly_name} · ${this.targetAlert.title}` : '')
|
||||
.fontSize(14)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
.width('100%')
|
||||
TextArea({ placeholder: '填写处理说明(必填)', text: this.processNote })
|
||||
.placeholderColor($r('app.color.inactive_tab'))
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.height(120)
|
||||
.padding(14)
|
||||
.backgroundColor($r('app.color.background'))
|
||||
.borderRadius(10)
|
||||
.onChange((value: string) => {
|
||||
this.processNote = value;
|
||||
})
|
||||
Button(this.processing ? '提交中...' : '确认处理并留证')
|
||||
.width('100%')
|
||||
.height(48)
|
||||
.fontSize(16)
|
||||
.fontColor('#FFFFFF')
|
||||
.backgroundColor($r('app.color.accent_green'))
|
||||
.borderRadius(12)
|
||||
.enabled(!this.processing)
|
||||
.onClick(() => {
|
||||
this.submit();
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
.padding(20)
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.borderRadius(16)
|
||||
}
|
||||
|
||||
build() {
|
||||
HdsNavigation() {
|
||||
Column() {
|
||||
if (this.loading) {
|
||||
Text('加载中...')
|
||||
.fontSize(15)
|
||||
.fontColor($r('app.color.inactive_tab'))
|
||||
.margin({ top: 60 })
|
||||
} else if (this.alerts.length === 0) {
|
||||
Text('暂无待处理预警')
|
||||
.fontSize(15)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
.margin({ top: 160 })
|
||||
} else {
|
||||
Scroll(this.scroller) {
|
||||
Column({ space: 10 }) {
|
||||
ForEach(this.alerts, (alert: AlertItem) => {
|
||||
Column() {
|
||||
this.AlertCard(alert)
|
||||
}
|
||||
.width('100%')
|
||||
}, (alert: AlertItem) => String(alert.id))
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ left: 16, right: 16, top: 92, bottom: 30 })
|
||||
}
|
||||
.width('100%')
|
||||
.layoutWeight(1)
|
||||
.scrollBar(BarState.Auto)
|
||||
.edgeEffect(EdgeEffect.Spring)
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
}
|
||||
.mode(NavigationMode.Stack)
|
||||
.titleBar({
|
||||
content: {
|
||||
title: {
|
||||
mainTitle: '预警处理',
|
||||
},
|
||||
},
|
||||
style: {
|
||||
scrollEffectOpts: {
|
||||
enableScrollEffect: false,
|
||||
scrollEffectType: ScrollEffectType.GRADIENT_BLUR,
|
||||
},
|
||||
systemMaterialEffect: {
|
||||
materialType: hdsMaterial.MaterialType.ADAPTIVE,
|
||||
materialLevel: hdsMaterial.MaterialLevel.EXQUISITE,
|
||||
},
|
||||
},
|
||||
avoidLayoutSafeArea: false,
|
||||
enableComponentSafeArea: false,
|
||||
})
|
||||
.bindToScrollable([this.scroller])
|
||||
.hideBackButton(false)
|
||||
.titleMode(HdsNavigationTitleMode.MINI)
|
||||
.ignoreLayoutSafeArea([LayoutSafeAreaType.SYSTEM], [LayoutSafeAreaEdge.TOP, LayoutSafeAreaEdge.BOTTOM])
|
||||
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
|
||||
.backgroundColor($r('app.color.background'))
|
||||
.bindSheet($$this.showSheet, this.ProcessSheet(), {
|
||||
height: 340,
|
||||
dragBar: true,
|
||||
showClose: true,
|
||||
backgroundColor: $r('app.color.background_card'),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
// 护士 · 体检评估与护理方案(ArkTS 严格类型,无 any)
|
||||
// 接口:GET /care-plans/plans/、GET /care-plans/assessments/
|
||||
import { promptAction } from '@kit.ArkUI';
|
||||
import { HdsNavigation, HdsNavigationTitleMode, ScrollEffectType } from '@kit.UIDesignKit';
|
||||
import { hdsMaterial } from '@hms.hds.hdsMaterial';
|
||||
import { BusinessError } from '@kit.BasicServicesKit';
|
||||
import { Page, normalizeError } from '../../common/ApiConstants';
|
||||
import { api, RequestError } from '../../utils/Request';
|
||||
|
||||
interface CarePlan {
|
||||
id: number;
|
||||
elderly_name: string;
|
||||
version: number;
|
||||
status: string;
|
||||
generation_status: string;
|
||||
review_status: string;
|
||||
summary: string;
|
||||
suggested_care_level: string;
|
||||
suggested_care_level_reason: string;
|
||||
effective_from: string;
|
||||
}
|
||||
|
||||
interface Assessment {
|
||||
id: number;
|
||||
elderly_name: string;
|
||||
source: string;
|
||||
title: string;
|
||||
report_date: string;
|
||||
ocr_status: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
function planStatusLabel(status: string): string {
|
||||
if (status === 'draft') {
|
||||
return '草稿';
|
||||
}
|
||||
if (status === 'published') {
|
||||
return '已发布';
|
||||
}
|
||||
return '已取代';
|
||||
}
|
||||
|
||||
function planStatusColor(status: string): string {
|
||||
if (status === 'published') {
|
||||
return '#34C759';
|
||||
}
|
||||
if (status === 'draft') {
|
||||
return '#FF9500';
|
||||
}
|
||||
return '#8E8E93';
|
||||
}
|
||||
|
||||
function careLevelLabel(level: string): string {
|
||||
if (level === 'self_care') {
|
||||
return '自理';
|
||||
}
|
||||
if (level === 'semi_care') {
|
||||
return '半护理';
|
||||
}
|
||||
if (level === 'full_care') {
|
||||
return '全护理';
|
||||
}
|
||||
if (level === 'special_care') {
|
||||
return '特殊护理';
|
||||
}
|
||||
if (level === 'cognitive_care') {
|
||||
return '认知护理';
|
||||
}
|
||||
if (level === 'intensive_care') {
|
||||
return '专护';
|
||||
}
|
||||
return level || '--';
|
||||
}
|
||||
|
||||
@Entry
|
||||
@Component
|
||||
struct NurseCarePlan {
|
||||
@State plans: Array<CarePlan> = [];
|
||||
@State assessments: Array<Assessment> = [];
|
||||
@State loading: boolean = true;
|
||||
private scroller: Scroller = new Scroller();
|
||||
|
||||
aboutToAppear(): void {
|
||||
this.load();
|
||||
}
|
||||
|
||||
private async load(): Promise<void> {
|
||||
this.loading = true;
|
||||
try {
|
||||
const results: Array<Page<CarePlan> | Page<Assessment> | null> = await Promise.all([
|
||||
api.get<Page<CarePlan>>('/care-plans/plans/', { page_size: 50 }).catch((): Page<CarePlan> | null => null),
|
||||
api.get<Page<Assessment>>('/care-plans/assessments/', { page_size: 50 }).catch((): Page<Assessment> | null => null),
|
||||
]);
|
||||
const p = results[0] as Page<CarePlan> | null;
|
||||
const a = results[1] as Page<Assessment> | null;
|
||||
this.plans = p ? p.results : [];
|
||||
this.assessments = a ? a.results : [];
|
||||
} catch (err) {
|
||||
const message = err instanceof RequestError ? err.message : normalizeError(err as BusinessError | Error);
|
||||
promptAction.showToast({ message: message });
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
@Builder
|
||||
PlanCard(p: CarePlan) {
|
||||
Column({ space: 6 }) {
|
||||
Row({ space: 8 }) {
|
||||
Text(p.elderly_name)
|
||||
.fontSize(16)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
Text(`v${p.version}`)
|
||||
.fontSize(12)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
Blank()
|
||||
Text(planStatusLabel(p.status))
|
||||
.fontSize(12)
|
||||
.fontColor(planStatusColor(p.status))
|
||||
}
|
||||
.width('100%')
|
||||
|
||||
Text(careLevelLabel(p.suggested_care_level))
|
||||
.fontSize(13)
|
||||
.fontColor($r('app.color.accent_blue'))
|
||||
.width('100%')
|
||||
|
||||
if (p.summary && p.summary.length > 0) {
|
||||
Text(p.summary)
|
||||
.fontSize(13)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
.maxLines(2)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
.width('100%')
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.padding(14)
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.borderRadius(12)
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
}
|
||||
|
||||
@Builder
|
||||
AssessmentCard(a: Assessment) {
|
||||
Row({ space: 10 }) {
|
||||
Column({ space: 4 }) {
|
||||
Text(a.elderly_name)
|
||||
.fontSize(15)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
Text(`${a.source === 'upload' ? '上传报告' : '人工录入'} · ${a.report_date || '--'}`)
|
||||
.fontSize(12)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
}
|
||||
.layoutWeight(1)
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
Text(a.ocr_status === 'succeeded' ? '已解析' : (a.ocr_status === 'pending' ? '解析中' : a.ocr_status || '--'))
|
||||
.fontSize(12)
|
||||
.fontColor($r('app.color.inactive_tab'))
|
||||
}
|
||||
.width('100%')
|
||||
.padding(14)
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.borderRadius(12)
|
||||
}
|
||||
|
||||
build() {
|
||||
HdsNavigation() {
|
||||
Column() {
|
||||
if (this.loading) {
|
||||
Text('加载中...')
|
||||
.fontSize(15)
|
||||
.fontColor($r('app.color.inactive_tab'))
|
||||
.margin({ top: 60 })
|
||||
} else {
|
||||
Scroll(this.scroller) {
|
||||
Column({ space: 12 }) {
|
||||
Column({ space: 8 }) {
|
||||
Text(`护理方案(${this.plans.length})`)
|
||||
.fontSize(16)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.width('100%')
|
||||
if (this.plans.length === 0) {
|
||||
Text('暂无护理方案')
|
||||
.fontSize(13)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
.width('100%')
|
||||
.padding(16)
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.borderRadius(12)
|
||||
} else {
|
||||
ForEach(this.plans, (p: CarePlan) => {
|
||||
Column() {
|
||||
this.PlanCard(p)
|
||||
}
|
||||
.width('100%')
|
||||
}, (p: CarePlan) => String(p.id))
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
|
||||
Column({ space: 8 }) {
|
||||
Text(`体检评估(${this.assessments.length})`)
|
||||
.fontSize(16)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.width('100%')
|
||||
if (this.assessments.length === 0) {
|
||||
Text('暂无体检评估')
|
||||
.fontSize(13)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
.width('100%')
|
||||
.padding(16)
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.borderRadius(12)
|
||||
} else {
|
||||
ForEach(this.assessments, (a: Assessment) => {
|
||||
Column() {
|
||||
this.AssessmentCard(a)
|
||||
}
|
||||
.width('100%')
|
||||
}, (a: Assessment) => String(a.id))
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
.margin({ bottom: 20 })
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ left: 16, right: 16, top: 92, bottom: 30 })
|
||||
}
|
||||
.width('100%')
|
||||
.layoutWeight(1)
|
||||
.scrollBar(BarState.Auto)
|
||||
.edgeEffect(EdgeEffect.Spring)
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
}
|
||||
.mode(NavigationMode.Stack)
|
||||
.titleBar({
|
||||
content: {
|
||||
title: {
|
||||
mainTitle: '护理方案',
|
||||
},
|
||||
},
|
||||
style: {
|
||||
scrollEffectOpts: {
|
||||
enableScrollEffect: false,
|
||||
scrollEffectType: ScrollEffectType.GRADIENT_BLUR,
|
||||
},
|
||||
systemMaterialEffect: {
|
||||
materialType: hdsMaterial.MaterialType.ADAPTIVE,
|
||||
materialLevel: hdsMaterial.MaterialLevel.EXQUISITE,
|
||||
},
|
||||
},
|
||||
avoidLayoutSafeArea: false,
|
||||
enableComponentSafeArea: false,
|
||||
})
|
||||
.bindToScrollable([this.scroller])
|
||||
.hideBackButton(false)
|
||||
.titleMode(HdsNavigationTitleMode.MINI)
|
||||
.ignoreLayoutSafeArea([LayoutSafeAreaType.SYSTEM], [LayoutSafeAreaEdge.TOP, LayoutSafeAreaEdge.BOTTOM])
|
||||
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
|
||||
.backgroundColor($r('app.color.background'))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
// 护理员工作台(ArkTS 严格类型,无 any)
|
||||
// 数据来源:/tasks/work-center/、/alerts/、/auth/notifications/
|
||||
import { router, promptAction, uiMaterial } from '@kit.ArkUI';
|
||||
import { BusinessError } from '@kit.BasicServicesKit';
|
||||
import { normalizeError, Page } from '../../common/ApiConstants';
|
||||
import { api } from '../../utils/Request';
|
||||
import { AuthStore } from '../../utils/AuthStore';
|
||||
|
||||
interface CareSummary {
|
||||
current: number;
|
||||
overdue: number;
|
||||
upcoming: number;
|
||||
completed: number;
|
||||
}
|
||||
|
||||
interface ShiftLocation {
|
||||
floor: number | null;
|
||||
}
|
||||
|
||||
interface ShiftInfo {
|
||||
locations: ShiftLocation[];
|
||||
}
|
||||
|
||||
interface ElderlyBrief {
|
||||
id: number;
|
||||
}
|
||||
|
||||
interface CareCenterResponse {
|
||||
current_shift: ShiftInfo | null;
|
||||
summary: CareSummary;
|
||||
elderly?: ElderlyBrief[];
|
||||
}
|
||||
|
||||
interface ApiAlertItem {
|
||||
id: number;
|
||||
title: string;
|
||||
description: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface ApiNoticeItem {
|
||||
id: number;
|
||||
title: string;
|
||||
content: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
function formatDateTime(iso: string): string {
|
||||
if (!iso) {
|
||||
return '--';
|
||||
}
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) {
|
||||
return '--';
|
||||
}
|
||||
const mm = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const dd = String(d.getDate()).padStart(2, '0');
|
||||
const hh = String(d.getHours()).padStart(2, '0');
|
||||
const mi = String(d.getMinutes()).padStart(2, '0');
|
||||
return `${mm}/${dd} ${hh}:${mi}`;
|
||||
}
|
||||
|
||||
function greeting(): string {
|
||||
const hour = new Date().getHours();
|
||||
if (hour < 12) {
|
||||
return '早上好';
|
||||
}
|
||||
if (hour < 18) {
|
||||
return '下午好';
|
||||
}
|
||||
return '晚上好';
|
||||
}
|
||||
|
||||
interface QuickAction {
|
||||
key: string;
|
||||
title: string;
|
||||
desc: string;
|
||||
route: string;
|
||||
}
|
||||
|
||||
@Entry
|
||||
@Component
|
||||
struct NurseHome {
|
||||
@State userName: string = '';
|
||||
@State elderlyCount: number = 0;
|
||||
@State activeCareCount: number = 0;
|
||||
@State overdueCount: number = 0;
|
||||
@State latestAlert: string = '';
|
||||
@State latestAlertTime: string = '';
|
||||
@State hasAlert: boolean = false;
|
||||
@State loading: boolean = true;
|
||||
|
||||
private actions: QuickAction[] = [
|
||||
{ key: 'record', title: '事件录入', desc: '快速记录护理事件', route: 'pages/nurse/RecordEntry' },
|
||||
{ key: 'history', title: '历史记录', desc: '查看过往护理记录', route: 'pages/nurse/History' },
|
||||
{ key: 'alerts', title: '预警中心', desc: '查看实时预警', route: 'pages/nurse/Alerts' },
|
||||
{ key: 'medication', title: '喂药待办', desc: '今日喂药执行', route: 'pages/nurse/MedicationToDo' },
|
||||
{ key: 'patrol', title: '风险巡视', desc: '风险点逐项确认', route: 'pages/nurse/RiskPatrol' },
|
||||
{ key: 'workcenter', title: '护理中心', desc: '认领与完成工作项', route: 'pages/nurse/WorkCenter' },
|
||||
{ key: 'dashboard', title: '数据看板', desc: '运营数据一览', route: 'pages/admin/AdminOverview' },
|
||||
];
|
||||
|
||||
async aboutToAppear(): Promise<void> {
|
||||
const state = await AuthStore.getState();
|
||||
this.userName = state.userName;
|
||||
await this.loadData();
|
||||
}
|
||||
|
||||
async onPageShow(): Promise<void> {
|
||||
await this.loadData();
|
||||
}
|
||||
|
||||
private async loadData(): Promise<void> {
|
||||
this.loading = true;
|
||||
try {
|
||||
const results = await Promise.all([
|
||||
api.get<CareCenterResponse>('/tasks/work-center/', { view: 'all' }).catch(() => null),
|
||||
api.get<Page<ApiAlertItem>>('/alerts/', { page_size: 1, ordering: '-created_at' }).catch(() => null),
|
||||
api.get<Page<ApiNoticeItem>>('/auth/notifications/', { page_size: 1 }).catch(() => null),
|
||||
]);
|
||||
if (results[0]) {
|
||||
const careRes = results[0];
|
||||
this.elderlyCount = careRes.elderly ? careRes.elderly.length : 0;
|
||||
const summary = careRes.summary;
|
||||
this.activeCareCount = summary.current + summary.overdue + summary.upcoming;
|
||||
this.overdueCount = summary.overdue;
|
||||
}
|
||||
const alert = results[1] && results[1].results.length > 0 ? results[1].results[0] : null;
|
||||
const notice = results[2] && results[2].results.length > 0 ? results[2].results[0] : null;
|
||||
if (alert) {
|
||||
this.latestAlert = alert.title;
|
||||
this.latestAlertTime = formatDateTime(alert.created_at);
|
||||
this.hasAlert = true;
|
||||
} else if (notice) {
|
||||
this.latestAlert = notice.title;
|
||||
this.latestAlertTime = formatDateTime(notice.created_at);
|
||||
this.hasAlert = true;
|
||||
} else {
|
||||
this.latestAlert = '';
|
||||
this.latestAlertTime = '';
|
||||
this.hasAlert = false;
|
||||
}
|
||||
} catch (err) {
|
||||
promptAction.showToast({ message: normalizeError(err as BusinessError | Error) });
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private go(route: string): void {
|
||||
router.pushUrl({ url: route }).catch((err: BusinessError) => {
|
||||
promptAction.showToast({ message: `页面开发中:${normalizeError(err)}` });
|
||||
});
|
||||
}
|
||||
|
||||
@Builder
|
||||
StatCard(value: number, unit: string, label: string, color: string) {
|
||||
Column({ space: 4 }) {
|
||||
Row({ space: 2 }) {
|
||||
Text(String(value))
|
||||
.fontSize(26)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor(color)
|
||||
Text(unit)
|
||||
.fontSize(13)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
.margin({ top: 8 })
|
||||
}
|
||||
Text(label)
|
||||
.fontSize(12)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
}
|
||||
.layoutWeight(1)
|
||||
.justifyContent(FlexAlign.Center)
|
||||
.alignItems(HorizontalAlign.Center)
|
||||
}
|
||||
|
||||
@Builder
|
||||
QuickActionCard(action: QuickAction) {
|
||||
Column({ space: 6 }) {
|
||||
Text(action.title)
|
||||
.fontSize(15)
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
Text(action.desc)
|
||||
.fontSize(11)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
}
|
||||
.width('30%')
|
||||
.height(60)
|
||||
.margin({ bottom: 10 })
|
||||
.justifyContent(FlexAlign.Center)
|
||||
.alignItems(HorizontalAlign.Center)
|
||||
.backgroundColor(Color.Transparent)
|
||||
.backgroundBlurStyle(BlurStyle.Thin)
|
||||
.borderRadius(12)
|
||||
.onClick(() => {
|
||||
this.go(action.route);
|
||||
})
|
||||
}
|
||||
|
||||
build() {
|
||||
Column() {
|
||||
Scroll() {
|
||||
Column({ space: 16 }) {
|
||||
// 顶部问候
|
||||
Column({ space: 6 }) {
|
||||
Text(`${this.userName || '护理员'},${greeting()}`)
|
||||
.fontSize(24)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
Text('护理员 · 今日在岗')
|
||||
.fontSize(13)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ left: 20, right: 20, top: 40, bottom: 8 })
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
|
||||
// 数据看板
|
||||
Row() {
|
||||
this.StatCard(this.elderlyCount, '位', '负责长者', ('app.color.accent_blue'))
|
||||
this.StatCard(this.activeCareCount, '项', '待完成', ('app.color.accent_red'))
|
||||
this.StatCard(this.overdueCount, '项', '已经逾期', '#FF9500')
|
||||
}
|
||||
.width('92%')
|
||||
.height(96)
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.borderRadius(16)
|
||||
.padding(8)
|
||||
|
||||
// 快捷操作
|
||||
Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.SpaceBetween }) {
|
||||
ForEach(this.actions, (action: QuickAction) => {
|
||||
this.QuickActionCard(action)
|
||||
}, (action: QuickAction) => action.key)
|
||||
}
|
||||
.width('92%')
|
||||
|
||||
// 消息通知
|
||||
Column({ space: 8 }) {
|
||||
Row() {
|
||||
Text('消息通知')
|
||||
.fontSize(16)
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
Blank()
|
||||
Text('查看全部')
|
||||
.fontSize(13)
|
||||
.fontColor($r('app.color.accent_blue'))
|
||||
}
|
||||
.width('100%')
|
||||
|
||||
Column({ space: 8 }) {
|
||||
if (this.hasAlert) {
|
||||
Text(this.latestAlert)
|
||||
.fontSize(15)
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
Text(this.latestAlertTime)
|
||||
.fontSize(12)
|
||||
.fontColor($r('app.color.inactive_tab'))
|
||||
} else {
|
||||
Text('当前没有新的消息通知')
|
||||
.fontSize(14)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.padding(16)
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.borderRadius(14)
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
}
|
||||
.width('92%')
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
.margin({ bottom: 30 })
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(HorizontalAlign.Center)
|
||||
}
|
||||
.scrollBar(BarState.Auto)
|
||||
.edgeEffect(EdgeEffect.Spring)
|
||||
.layoutWeight(1)
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
.backgroundColor($r('app.color.background'))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
// 护理记录详情页(ArkTS 严格类型,无 any)
|
||||
// 接口:GET /tasks/nursing-records/{id}/
|
||||
import { router } from '@kit.ArkUI';
|
||||
import { promptAction } from '@kit.ArkUI';
|
||||
import { BusinessError } from '@kit.BasicServicesKit';
|
||||
import { HdsNavigation, HdsNavigationTitleMode, ScrollEffectType } from '@kit.UIDesignKit';
|
||||
import { hdsMaterial } from '@hms.hds.hdsMaterial';
|
||||
import { normalizeError } from '../../common/ApiConstants';
|
||||
import { api } from '../../utils/Request';
|
||||
import { ProtectedImage } from '../../components/ProtectedImage';
|
||||
|
||||
// 结构化块
|
||||
interface StructBlock {
|
||||
text?: string;
|
||||
numeric?: number | null;
|
||||
unit?: string;
|
||||
trend?: string | null;
|
||||
}
|
||||
|
||||
interface NursingRecordDetail {
|
||||
id: number;
|
||||
elderly: number;
|
||||
elderly_name: string;
|
||||
nurse_name: string | null;
|
||||
raw_text: string;
|
||||
images: Array<string> | null;
|
||||
created_at: string;
|
||||
extracted_json: Record<string, StructBlock> | null;
|
||||
}
|
||||
|
||||
function formatTime(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) {
|
||||
return iso || '--';
|
||||
}
|
||||
const mm = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const dd = String(d.getDate()).padStart(2, '0');
|
||||
const hh = String(d.getHours()).padStart(2, '0');
|
||||
const mi = String(d.getMinutes()).padStart(2, '0');
|
||||
return `${mm}/${dd} ${hh}:${mi}`;
|
||||
}
|
||||
|
||||
@Entry
|
||||
@Component
|
||||
struct RecordDetail {
|
||||
@State detail: NursingRecordDetail | null = null;
|
||||
@State loading: boolean = true;
|
||||
private recordId: string = '';
|
||||
private scroller: Scroller = new Scroller();
|
||||
|
||||
aboutToAppear(): void {
|
||||
const params = router.getParams() as Record<string, string> | undefined;
|
||||
if (params) {
|
||||
this.recordId = params['id'] ?? '';
|
||||
}
|
||||
this.loadDetail();
|
||||
}
|
||||
|
||||
private async loadDetail(): Promise<void> {
|
||||
if (this.recordId.length === 0) {
|
||||
this.loading = false;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
this.detail = await api.get<NursingRecordDetail>(`/tasks/nursing-records/${this.recordId}/`);
|
||||
} catch (err) {
|
||||
promptAction.showToast({ message: normalizeError(err as BusinessError | Error) });
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 提取结构化块文本
|
||||
private blockText(key: string, label: string): string {
|
||||
const extracted = this.detail?.extracted_json;
|
||||
if (!extracted) {
|
||||
return '';
|
||||
}
|
||||
const block = extracted[key];
|
||||
if (!block || !block.text) {
|
||||
return '';
|
||||
}
|
||||
return `${label}:${block.text}`;
|
||||
}
|
||||
|
||||
@Builder
|
||||
BlockRow(text: string) {
|
||||
if (text.length > 0) {
|
||||
Text(text)
|
||||
.fontSize(15)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.width('100%')
|
||||
}
|
||||
}
|
||||
|
||||
build() {
|
||||
HdsNavigation() {
|
||||
Column() {
|
||||
if (this.loading) {
|
||||
Text('加载中...')
|
||||
.fontSize(15)
|
||||
.fontColor($r('app.color.inactive_tab'))
|
||||
.margin({ top: 80 })
|
||||
} else if (!this.detail) {
|
||||
Text('未找到该记录')
|
||||
.fontSize(15)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
.margin({ top: 80 })
|
||||
} else {
|
||||
Scroll(this.scroller) {
|
||||
Column({ space: 14 }) {
|
||||
// 头部信息
|
||||
Column({ space: 6 }) {
|
||||
Row() {
|
||||
Text(this.detail.elderly_name)
|
||||
.fontSize(22)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
Blank()
|
||||
Text(formatTime(this.detail.created_at))
|
||||
.fontSize(13)
|
||||
.fontColor($r('app.color.inactive_tab'))
|
||||
}
|
||||
.width('100%')
|
||||
if (this.detail.nurse_name && this.detail.nurse_name.length > 0) {
|
||||
Text(`护理员:${this.detail.nurse_name}`)
|
||||
.fontSize(14)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
.width('100%')
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.padding(16)
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.borderRadius(16)
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
|
||||
// 原始文字
|
||||
if (this.detail.raw_text && this.detail.raw_text.length > 0) {
|
||||
Column({ space: 8 }) {
|
||||
Text('记录内容')
|
||||
.fontSize(16)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.width('100%')
|
||||
Text(this.detail.raw_text)
|
||||
.fontSize(15)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.width('100%')
|
||||
}
|
||||
.width('100%')
|
||||
.padding(16)
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.borderRadius(16)
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
}
|
||||
|
||||
// 图片
|
||||
if (this.detail.images && this.detail.images.length > 0) {
|
||||
Column({ space: 8 }) {
|
||||
Text('图片')
|
||||
.fontSize(16)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.width('100%')
|
||||
Flex({ wrap: FlexWrap.Wrap }) {
|
||||
ForEach(this.detail.images, (img: string) => {
|
||||
Stack() {
|
||||
ProtectedImage({ mediaPath: img })
|
||||
}
|
||||
.width(96)
|
||||
.height(96)
|
||||
.borderRadius(10)
|
||||
.clip(true)
|
||||
.margin({ right: 8, bottom: 8 })
|
||||
}, (img: string) => img)
|
||||
}
|
||||
.width('100%')
|
||||
}
|
||||
.width('100%')
|
||||
.padding(16)
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.borderRadius(16)
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
}
|
||||
|
||||
// 结构化解析
|
||||
if (this.detail.extracted_json) {
|
||||
Column({ space: 8 }) {
|
||||
Text('结构化解析')
|
||||
.fontSize(16)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.width('100%')
|
||||
this.BlockRow(this.blockText('temperature', '体温'))
|
||||
this.BlockRow(this.blockText('blood_pressure', '血压'))
|
||||
this.BlockRow(this.blockText('weight', '体重'))
|
||||
this.BlockRow(this.blockText('medication', '用药'))
|
||||
this.BlockRow(this.blockText('diet', '饮食'))
|
||||
this.BlockRow(this.blockText('mood', '情绪'))
|
||||
this.BlockRow(this.blockText('other', '其他'))
|
||||
}
|
||||
.width('100%')
|
||||
.padding(16)
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.borderRadius(16)
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
}
|
||||
}
|
||||
.width('92%')
|
||||
.padding({ top: 60, bottom: 30 })
|
||||
.alignItems(HorizontalAlign.Center)
|
||||
}
|
||||
.scrollBar(BarState.Auto)
|
||||
.edgeEffect(EdgeEffect.Spring)
|
||||
.layoutWeight(1)
|
||||
.width('100%')
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
}
|
||||
.mode(NavigationMode.Stack)
|
||||
.titleBar({
|
||||
content: {
|
||||
title: {
|
||||
mainTitle: '记录详情',
|
||||
},
|
||||
},
|
||||
style: {
|
||||
scrollEffectOpts: {
|
||||
enableScrollEffect: false,
|
||||
scrollEffectType: ScrollEffectType.GRADIENT_BLUR,
|
||||
},
|
||||
systemMaterialEffect: {
|
||||
materialType: hdsMaterial.MaterialType.ADAPTIVE,
|
||||
materialLevel: hdsMaterial.MaterialLevel.EXQUISITE,
|
||||
},
|
||||
},
|
||||
avoidLayoutSafeArea: false,
|
||||
enableComponentSafeArea: false,
|
||||
})
|
||||
.bindToScrollable([this.scroller])
|
||||
.hideBackButton(false)
|
||||
.titleMode(HdsNavigationTitleMode.MINI)
|
||||
.ignoreLayoutSafeArea([LayoutSafeAreaType.SYSTEM], [LayoutSafeAreaEdge.TOP, LayoutSafeAreaEdge.BOTTOM])
|
||||
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
|
||||
.backgroundColor($r('app.color.background'))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,668 @@
|
||||
// 护理事件录入(ArkTS 严格类型,无 any)
|
||||
// 图片:/tasks/upload-images/(multipart)→ 记录:/tasks/nursing-records/
|
||||
// 结构化解析:/tasks/parse/(raw_text → extracted_json,可编辑后随记录提交)
|
||||
import { router } from '@kit.ArkUI';
|
||||
import { promptAction } from '@kit.ArkUI';
|
||||
import { HdsNavigation, HdsNavigationTitleMode, ScrollEffectType } from '@kit.UIDesignKit';
|
||||
import { hdsMaterial } from '@hms.hds.hdsMaterial';
|
||||
import { photoAccessHelper } from '@kit.MediaLibraryKit';
|
||||
import { BusinessError } from '@kit.BasicServicesKit';
|
||||
import { Page, normalizeError } from '../../common/ApiConstants';
|
||||
import { api, uploadMultipartFiles, RequestError } from '../../utils/Request';
|
||||
import { AuthStore } from '../../utils/AuthStore';
|
||||
import { common } from '@kit.AbilityKit';
|
||||
import { takePhoto, pickOneImage, recognizeTextFromUri } from '../../utils/OcrHelper';
|
||||
|
||||
interface ElderlyOption {
|
||||
id: number;
|
||||
name: string;
|
||||
room: string | null;
|
||||
bed: string | null;
|
||||
}
|
||||
|
||||
interface UploadResponse {
|
||||
urls: Array<string>;
|
||||
}
|
||||
|
||||
// ── 结构化解析结果类型(对齐后端 normalize_structured_result) ──
|
||||
interface NumericBlock {
|
||||
text: string;
|
||||
numeric: number | null;
|
||||
unit: string;
|
||||
trend: string | null;
|
||||
}
|
||||
|
||||
interface BPBlock {
|
||||
text: string;
|
||||
systolic: number | null;
|
||||
diastolic: number | null;
|
||||
unit: string;
|
||||
trend: string | null;
|
||||
}
|
||||
|
||||
interface TextBlock {
|
||||
text: string;
|
||||
trend: string | null;
|
||||
}
|
||||
|
||||
interface OtherBlock {
|
||||
text: string;
|
||||
}
|
||||
|
||||
interface PressureUlcerBlock {
|
||||
body_position: string;
|
||||
skin_statuses: Array<string>;
|
||||
pressure_site: string;
|
||||
protective_aid: string;
|
||||
shift: string;
|
||||
recorder_role: string;
|
||||
wound_area: string;
|
||||
}
|
||||
|
||||
interface PatrolBlock {
|
||||
completed: boolean;
|
||||
count: number;
|
||||
note: string;
|
||||
}
|
||||
|
||||
interface StructuredResult {
|
||||
record_time: string;
|
||||
elderly_name: string;
|
||||
elderly_id_card: string;
|
||||
temperature: NumericBlock;
|
||||
blood_pressure: BPBlock;
|
||||
weight: NumericBlock;
|
||||
medication: TextBlock;
|
||||
diet: TextBlock;
|
||||
mood: TextBlock;
|
||||
other: OtherBlock;
|
||||
pressure_ulcer: PressureUlcerBlock;
|
||||
completed_services: Array<Record<string, string | number | boolean>>;
|
||||
patrol: PatrolBlock;
|
||||
}
|
||||
|
||||
interface ParseResponse {
|
||||
extracted_json: StructuredResult;
|
||||
}
|
||||
|
||||
// /tasks/parse/ 请求体
|
||||
interface ParsePayload {
|
||||
raw_text: string;
|
||||
elderly?: number;
|
||||
}
|
||||
|
||||
// /tasks/nursing-records/ 创建请求体
|
||||
interface RecordCreatePayload {
|
||||
elderly: number;
|
||||
raw_text: string;
|
||||
nurse: number;
|
||||
images: Array<string>;
|
||||
extracted_json?: StructuredResult;
|
||||
}
|
||||
|
||||
const MAX_IMAGES = 9;
|
||||
|
||||
@Entry
|
||||
@Component
|
||||
struct RecordEntry {
|
||||
@State elderlyOptions: Array<ElderlyOption> = [];
|
||||
@State selectedElderlyId: number = 0;
|
||||
@State recordText: string = '';
|
||||
@State ocrRunning: boolean = false;
|
||||
@State selectedUris: Array<string> = [];
|
||||
@State showElderlyPicker: boolean = false;
|
||||
@State submitting: boolean = false;
|
||||
@State parsing: boolean = false;
|
||||
@State loading: boolean = true;
|
||||
// 结构化解析结果(原始对象,用于提交)与可编辑字段(平铺 @State)
|
||||
@State parsedOriginal: StructuredResult | null = null;
|
||||
@State eTime: string = '';
|
||||
@State eTemp: string = '';
|
||||
@State eBP: string = '';
|
||||
@State eWeight: string = '';
|
||||
@State eMedication: string = '';
|
||||
@State eDiet: string = '';
|
||||
@State eMood: string = '';
|
||||
@State eOther: string = '';
|
||||
@State ePressureSite: string = '';
|
||||
private scroller: Scroller = new Scroller();
|
||||
|
||||
async aboutToAppear(): Promise<void> {
|
||||
await this.loadElderly();
|
||||
}
|
||||
|
||||
private async loadElderly(): Promise<void> {
|
||||
this.loading = true;
|
||||
try {
|
||||
const res = await api.get<Page<ElderlyOption>>('/elderly/', { page_size: 100 });
|
||||
this.elderlyOptions = res.results;
|
||||
if (this.elderlyOptions.length > 0) {
|
||||
this.selectedElderlyId = this.elderlyOptions[0].id;
|
||||
}
|
||||
} catch (err) {
|
||||
promptAction.showToast({ message: normalizeError(err as BusinessError | Error) });
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private selectedElderlyName(): string {
|
||||
const found = this.elderlyOptions.find((item) => item.id === this.selectedElderlyId);
|
||||
return found ? found.name : '请选择长者';
|
||||
}
|
||||
|
||||
private pickImages(): void {
|
||||
if (this.selectedUris.length >= MAX_IMAGES) {
|
||||
promptAction.showToast({ message: `最多选择 ${MAX_IMAGES} 张图片` });
|
||||
return;
|
||||
}
|
||||
const options = new photoAccessHelper.PhotoSelectOptions();
|
||||
options.MIMEType = photoAccessHelper.PhotoViewMIMETypes.IMAGE_TYPE;
|
||||
options.maxSelectNumber = MAX_IMAGES - this.selectedUris.length;
|
||||
const picker = new photoAccessHelper.PhotoViewPicker();
|
||||
picker.select(options).then((result: photoAccessHelper.PhotoSelectResult) => {
|
||||
if (result.photoUris && result.photoUris.length > 0) {
|
||||
this.selectedUris = this.selectedUris.concat(result.photoUris);
|
||||
}
|
||||
}).catch((err: BusinessError) => {
|
||||
console.error('[RecordEntry] pick images failed:', err.message);
|
||||
});
|
||||
}
|
||||
|
||||
// AI 结构化解析
|
||||
// 拍纸质护理单:端侧 OCR 识别文字填入内容框(可编辑后走 AI 结构化解析)
|
||||
private async runSheetOcr(useCamera: boolean): Promise<void> {
|
||||
if (this.ocrRunning) {
|
||||
return;
|
||||
}
|
||||
const context = this.getUIContext().getHostContext() as common.UIAbilityContext;
|
||||
const uri = useCamera ? await takePhoto(context) : await pickOneImage(context);
|
||||
if (uri === null) {
|
||||
return;
|
||||
}
|
||||
this.ocrRunning = true;
|
||||
try {
|
||||
const text = await recognizeTextFromUri(uri);
|
||||
if (text.trim().length === 0) {
|
||||
promptAction.showToast({ message: '未识别到文字,请光线充足后重拍' });
|
||||
return;
|
||||
}
|
||||
this.recordText = text.length > 800 ? text.substring(0, 800) : text;
|
||||
promptAction.showToast({ message: '已识别文字,请核对后点击 AI 解析' });
|
||||
} catch (err) {
|
||||
console.error('[RecordEntry] sheet ocr failed:', normalizeError(err as BusinessError | Error));
|
||||
promptAction.showToast({ message: '识别失败,请重试或手动输入' });
|
||||
} finally {
|
||||
this.ocrRunning = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 跨端 OCR 菜单(bindMenu 自定义项)
|
||||
@Builder
|
||||
OcrMenu() {
|
||||
Column() {
|
||||
Text('📷 拍照识别')
|
||||
.width('100%')
|
||||
.padding(12)
|
||||
.fontSize(15)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.onClick(() => {
|
||||
this.runSheetOcr(true);
|
||||
})
|
||||
Divider().color($r('app.color.background'))
|
||||
Text('🖼️ 从相册识别')
|
||||
.width('100%')
|
||||
.padding(12)
|
||||
.fontSize(15)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.onClick(() => {
|
||||
this.runSheetOcr(false);
|
||||
})
|
||||
}
|
||||
.width(190)
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.borderRadius(12)
|
||||
}
|
||||
|
||||
private async parseText(): Promise<void> {
|
||||
if (this.recordText.trim().length === 0) {
|
||||
promptAction.showToast({ message: '请先输入护理内容再解析' });
|
||||
return;
|
||||
}
|
||||
if (this.parsing) {
|
||||
return;
|
||||
}
|
||||
this.parsing = true;
|
||||
try {
|
||||
const payload: ParsePayload = { raw_text: this.recordText.trim() };
|
||||
if (this.selectedElderlyId > 0) {
|
||||
payload.elderly = this.selectedElderlyId;
|
||||
}
|
||||
const res = await api.post<ParseResponse>('/tasks/parse/', payload);
|
||||
this.parsedOriginal = res.extracted_json;
|
||||
// 平铺到可编辑字段
|
||||
this.eTime = res.extracted_json.record_time ?? '';
|
||||
this.eTemp = res.extracted_json.temperature?.text ?? '';
|
||||
this.eBP = res.extracted_json.blood_pressure?.text ?? '';
|
||||
this.eWeight = res.extracted_json.weight?.text ?? '';
|
||||
this.eMedication = res.extracted_json.medication?.text ?? '';
|
||||
this.eDiet = res.extracted_json.diet?.text ?? '';
|
||||
this.eMood = res.extracted_json.mood?.text ?? '';
|
||||
this.eOther = res.extracted_json.other?.text ?? '';
|
||||
this.ePressureSite = res.extracted_json.pressure_ulcer?.pressure_site ?? '';
|
||||
promptAction.showToast({ message: '解析完成,请核对结果' });
|
||||
} catch (err) {
|
||||
const message = err instanceof RequestError ? err.message : normalizeError(err as BusinessError | Error);
|
||||
promptAction.showToast({ message: message });
|
||||
} finally {
|
||||
this.parsing = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 用编辑后的平铺字段覆盖原始解析结果
|
||||
private buildExtracted(): StructuredResult | null {
|
||||
const original = this.parsedOriginal;
|
||||
if (!original) {
|
||||
return null;
|
||||
}
|
||||
const copy: StructuredResult = JSON.parse(JSON.stringify(original)) as StructuredResult;
|
||||
copy.record_time = this.eTime;
|
||||
if (copy.temperature) {
|
||||
copy.temperature.text = this.eTemp;
|
||||
}
|
||||
if (copy.blood_pressure) {
|
||||
copy.blood_pressure.text = this.eBP;
|
||||
}
|
||||
if (copy.weight) {
|
||||
copy.weight.text = this.eWeight;
|
||||
}
|
||||
if (copy.medication) {
|
||||
copy.medication.text = this.eMedication;
|
||||
}
|
||||
if (copy.diet) {
|
||||
copy.diet.text = this.eDiet;
|
||||
}
|
||||
if (copy.mood) {
|
||||
copy.mood.text = this.eMood;
|
||||
}
|
||||
if (copy.other) {
|
||||
copy.other.text = this.eOther;
|
||||
}
|
||||
if (copy.pressure_ulcer) {
|
||||
copy.pressure_ulcer.pressure_site = this.ePressureSite;
|
||||
}
|
||||
return copy;
|
||||
}
|
||||
|
||||
private async submit(): Promise<void> {
|
||||
if (this.selectedElderlyId === 0) {
|
||||
promptAction.showToast({ message: '请选择长者' });
|
||||
return;
|
||||
}
|
||||
if (this.recordText.trim().length === 0) {
|
||||
promptAction.showToast({ message: '请输入护理内容' });
|
||||
return;
|
||||
}
|
||||
if (this.submitting) {
|
||||
return;
|
||||
}
|
||||
this.submitting = true;
|
||||
try {
|
||||
// 1. 先上传图片(如有)
|
||||
let imagePaths: Array<string> = [];
|
||||
if (this.selectedUris.length > 0) {
|
||||
const uploadRes = await uploadMultipartFiles<UploadResponse>(
|
||||
'/tasks/upload-images/', this.selectedUris, 'images');
|
||||
imagePaths = uploadRes.urls;
|
||||
}
|
||||
// 2. 创建护理记录(带结构化结果,如有)
|
||||
const state = await AuthStore.getState();
|
||||
const body: RecordCreatePayload = {
|
||||
elderly: this.selectedElderlyId,
|
||||
raw_text: this.recordText.trim(),
|
||||
nurse: state.userId,
|
||||
images: imagePaths,
|
||||
};
|
||||
const extracted = this.buildExtracted();
|
||||
if (extracted) {
|
||||
body.extracted_json = extracted;
|
||||
}
|
||||
await api.post('/tasks/nursing-records/', body);
|
||||
promptAction.showToast({ message: '记录已提交' });
|
||||
this.recordText = '';
|
||||
this.selectedUris = [];
|
||||
this.parsedOriginal = null;
|
||||
router.back();
|
||||
} catch (err) {
|
||||
const message = err instanceof RequestError ? err.message : normalizeError(err as BusinessError | Error);
|
||||
promptAction.showToast({ message: message });
|
||||
} finally {
|
||||
this.submitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
@Builder
|
||||
ElderlyPickerModal() {
|
||||
Column() {
|
||||
Text('选择长者')
|
||||
.fontSize(18)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.margin({ bottom: 12 })
|
||||
List() {
|
||||
ForEach(this.elderlyOptions, (item: ElderlyOption) => {
|
||||
ListItem() {
|
||||
Row() {
|
||||
Text(item.name)
|
||||
.fontSize(15)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
if (item.room) {
|
||||
Text(` · ${item.room}床` + (item.bed ? `-${item.bed}` : ''))
|
||||
.fontSize(13)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
.margin({ left: 6 })
|
||||
}
|
||||
Blank()
|
||||
if (item.id === this.selectedElderlyId) {
|
||||
Text('✓')
|
||||
.fontSize(16)
|
||||
.fontColor($r('app.color.accent_blue'))
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.padding(14)
|
||||
.onClick(() => {
|
||||
this.selectedElderlyId = item.id;
|
||||
this.showElderlyPicker = false;
|
||||
})
|
||||
}
|
||||
}, (item: ElderlyOption) => String(item.id))
|
||||
}
|
||||
.layoutWeight(1)
|
||||
.scrollBar(BarState.Auto)
|
||||
}
|
||||
.width('100%')
|
||||
.padding(20)
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.borderRadius(16)
|
||||
.height(400)
|
||||
}
|
||||
|
||||
@Builder
|
||||
ImageThumb(uri: string, index: number) {
|
||||
Stack() {
|
||||
Image(uri)
|
||||
.width(72)
|
||||
.height(72)
|
||||
.borderRadius(10)
|
||||
.objectFit(ImageFit.Cover)
|
||||
|
||||
Text('×')
|
||||
.fontSize(14)
|
||||
.fontColor('#FFFFFF')
|
||||
.textAlign(TextAlign.Center)
|
||||
.width(20)
|
||||
.height(20)
|
||||
.backgroundColor('#CC000000')
|
||||
.borderRadius(10)
|
||||
.position({ x: 56, y: -4 })
|
||||
.onClick(() => {
|
||||
const next: Array<string> = [];
|
||||
this.selectedUris.forEach((item: string, idx: number) => {
|
||||
if (idx !== index) {
|
||||
next.push(item);
|
||||
}
|
||||
});
|
||||
this.selectedUris = next;
|
||||
})
|
||||
}
|
||||
.width(72)
|
||||
.height(72)
|
||||
.margin({ right: 8, top: 4 })
|
||||
}
|
||||
|
||||
@Builder
|
||||
ParsedField(label: string, value: string, onChange: (v: string) => void) {
|
||||
Row({ space: 10 }) {
|
||||
Text(label)
|
||||
.fontSize(13)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
.width(64)
|
||||
TextInput({ text: value })
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.fontSize(14)
|
||||
.height(38)
|
||||
.layoutWeight(1)
|
||||
.padding({ left: 10, right: 10 })
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.borderRadius(8)
|
||||
.onChange((v: string) => {
|
||||
onChange(v);
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
}
|
||||
|
||||
@Builder
|
||||
ParsedCard() {
|
||||
Column({ space: 10 }) {
|
||||
Row() {
|
||||
Text('结构化结果(可编辑)')
|
||||
.fontSize(15)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
Blank()
|
||||
Text('撤销')
|
||||
.fontSize(13)
|
||||
.fontColor($r('app.color.accent_red'))
|
||||
.onClick(() => {
|
||||
this.parsedOriginal = null;
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
|
||||
this.ParsedField('时间', this.eTime, (v: string) => {
|
||||
this.eTime = v;
|
||||
})
|
||||
this.ParsedField('体温', this.eTemp, (v: string) => {
|
||||
this.eTemp = v;
|
||||
})
|
||||
this.ParsedField('血压', this.eBP, (v: string) => {
|
||||
this.eBP = v;
|
||||
})
|
||||
this.ParsedField('体重', this.eWeight, (v: string) => {
|
||||
this.eWeight = v;
|
||||
})
|
||||
this.ParsedField('用药', this.eMedication, (v: string) => {
|
||||
this.eMedication = v;
|
||||
})
|
||||
this.ParsedField('饮食', this.eDiet, (v: string) => {
|
||||
this.eDiet = v;
|
||||
})
|
||||
this.ParsedField('情绪', this.eMood, (v: string) => {
|
||||
this.eMood = v;
|
||||
})
|
||||
this.ParsedField('压疮部位', this.ePressureSite, (v: string) => {
|
||||
this.ePressureSite = v;
|
||||
})
|
||||
this.ParsedField('其他', this.eOther, (v: string) => {
|
||||
this.eOther = v;
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
.padding(14)
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.borderRadius(12)
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
}
|
||||
|
||||
build() {
|
||||
HdsNavigation() {
|
||||
Column() {
|
||||
Scroll(this.scroller) {
|
||||
Column({ space: 16 }) {
|
||||
// 长者选择
|
||||
Row() {
|
||||
Text('长者')
|
||||
.fontSize(15)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
Blank()
|
||||
Text(this.selectedElderlyName())
|
||||
.fontSize(15)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
Text(' ›')
|
||||
.fontSize(15)
|
||||
.fontColor($r('app.color.inactive_tab'))
|
||||
.margin({ left: 4 })
|
||||
}
|
||||
.width('100%')
|
||||
.padding(16)
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.borderRadius(12)
|
||||
.onClick(() => {
|
||||
this.showElderlyPicker = true;
|
||||
})
|
||||
|
||||
// 记录内容
|
||||
TextArea({ placeholder: '请输入护理内容,如:体温37.2℃,血压130/85,精神状态良好', text: this.recordText })
|
||||
.placeholderColor($r('app.color.inactive_tab'))
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.height(120)
|
||||
.padding(14)
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.borderRadius(12)
|
||||
.onChange((value: string) => {
|
||||
this.recordText = value;
|
||||
})
|
||||
|
||||
// 拍纸质护理单识别:端侧 OCR 文字识别,灌入内容框,再走 AI 结构化解析
|
||||
Row({ space: 8 }) {
|
||||
Button(this.ocrRunning ? '识别中...' : '📄 拍护理单识别')
|
||||
.fontSize(13)
|
||||
.height(34)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.backgroundColor(Color.Transparent)
|
||||
.borderRadius(8)
|
||||
.enabled(!this.ocrRunning)
|
||||
.bindMenu(this.OcrMenu)
|
||||
.accessibilityLevel('yes')
|
||||
.accessibilityText('拍护理单识别')
|
||||
.accessibilityDescription('拍照或从相册选择纸质护理单,自动识别文字填入内容框')
|
||||
if (this.ocrRunning) {
|
||||
Text('正在识别文字...')
|
||||
.fontSize(13)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
|
||||
// AI 解析按钮
|
||||
Button(this.parsing ? '解析中...' : '✨ AI 结构化解析')
|
||||
.width('100%')
|
||||
.height(42)
|
||||
.fontSize(14)
|
||||
.fontColor('#FFFFFF')
|
||||
.backgroundColor('#7C3AED')
|
||||
.borderRadius(12)
|
||||
.enabled(!this.parsing)
|
||||
.onClick(() => {
|
||||
this.parseText();
|
||||
})
|
||||
|
||||
// 解析结果(可编辑)
|
||||
if (this.parsedOriginal) {
|
||||
this.ParsedCard()
|
||||
}
|
||||
|
||||
// 图片区
|
||||
Column({ space: 8 }) {
|
||||
Row() {
|
||||
Text(`图片(${this.selectedUris.length}/${MAX_IMAGES})`)
|
||||
.fontSize(14)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
Blank()
|
||||
Button('+ 添加图片')
|
||||
.fontSize(13)
|
||||
.height(30)
|
||||
.fontColor($r('app.color.accent_blue'))
|
||||
.backgroundColor(Color.Transparent)
|
||||
.borderRadius(8)
|
||||
.onClick(() => {
|
||||
this.pickImages();
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
|
||||
if (this.selectedUris.length > 0) {
|
||||
Flex({ wrap: FlexWrap.Wrap }) {
|
||||
ForEach(this.selectedUris, (uri: string, index: number) => {
|
||||
this.ImageThumb(uri, index)
|
||||
}, (uri: string) => uri)
|
||||
}
|
||||
.width('100%')
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.padding(14)
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.borderRadius(12)
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
|
||||
Button(this.submitting ? '提交中...' : '提交记录')
|
||||
.width('100%')
|
||||
.height(50)
|
||||
.fontColor('#FFFFFF')
|
||||
.backgroundColor($r('app.color.accent_blue'))
|
||||
.borderRadius(12)
|
||||
.enabled(!this.submitting)
|
||||
.onClick(() => {
|
||||
this.submit();
|
||||
})
|
||||
}
|
||||
.width('92%')
|
||||
.margin({ top: 108, bottom: 30 })
|
||||
}
|
||||
.width('100%')
|
||||
.layoutWeight(1)
|
||||
.scrollBar(BarState.Auto)
|
||||
.edgeEffect(EdgeEffect.Spring)
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
.alignItems(HorizontalAlign.Center)
|
||||
}
|
||||
.mode(NavigationMode.Stack)
|
||||
.titleBar({
|
||||
content: {
|
||||
title: {
|
||||
mainTitle: '事件录入',
|
||||
},
|
||||
},
|
||||
style: {
|
||||
scrollEffectOpts: {
|
||||
enableScrollEffect: false,
|
||||
scrollEffectType: ScrollEffectType.GRADIENT_BLUR,
|
||||
},
|
||||
systemMaterialEffect: {
|
||||
materialType: hdsMaterial.MaterialType.ADAPTIVE,
|
||||
materialLevel: hdsMaterial.MaterialLevel.EXQUISITE,
|
||||
},
|
||||
},
|
||||
avoidLayoutSafeArea: false,
|
||||
enableComponentSafeArea: false,
|
||||
})
|
||||
.bindToScrollable([this.scroller])
|
||||
.hideBackButton(false)
|
||||
.titleMode(HdsNavigationTitleMode.MINI)
|
||||
.ignoreLayoutSafeArea([LayoutSafeAreaType.SYSTEM], [LayoutSafeAreaEdge.TOP, LayoutSafeAreaEdge.BOTTOM])
|
||||
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
|
||||
.backgroundColor($r('app.color.background'))
|
||||
.bindSheet($$this.showElderlyPicker, this.ElderlyPickerModal(), {
|
||||
height: 400,
|
||||
dragBar: true,
|
||||
showClose: true,
|
||||
backgroundColor: $r('app.color.background_card'),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,398 @@
|
||||
// 护理员 · 风险巡视(ArkTS 严格类型,无 any)
|
||||
// 接口:GET /rules/patrol-records/today/
|
||||
// POST /rules/patrol-records/
|
||||
import { router } from '@kit.ArkUI';
|
||||
import { promptAction } from '@kit.ArkUI';
|
||||
import { HdsNavigation, HdsNavigationTitleMode, ScrollEffectType } from '@kit.UIDesignKit';
|
||||
import { hdsMaterial } from '@hms.hds.hdsMaterial';
|
||||
import { BusinessError } from '@kit.BasicServicesKit';
|
||||
import { normalizeError } from '../../common/ApiConstants';
|
||||
import { api, RequestError } from '../../utils/Request';
|
||||
|
||||
interface RiskItem {
|
||||
key: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface PatrolRecord {
|
||||
id: number;
|
||||
elderly: number;
|
||||
elderly_name: string;
|
||||
elderly_room: string;
|
||||
elderly_bed: string;
|
||||
scheduled_slot: string;
|
||||
actual_time: string;
|
||||
is_on_time: boolean;
|
||||
remark: string;
|
||||
}
|
||||
|
||||
interface PatrolTodayResponse {
|
||||
date: string;
|
||||
time_slots: Array<string>;
|
||||
risk_items: Array<RiskItem>;
|
||||
records: Array<PatrolRecord>;
|
||||
}
|
||||
|
||||
interface RiskChecklistPayload {
|
||||
fall: boolean;
|
||||
bed_fall: boolean;
|
||||
choking: boolean;
|
||||
ingestion: boolean;
|
||||
scald: boolean;
|
||||
self_harm: boolean;
|
||||
wandering: boolean;
|
||||
pressure_ulcer: boolean;
|
||||
recreation_accident: boolean;
|
||||
}
|
||||
|
||||
interface SubmitPatrolPayload {
|
||||
scheduled_slot: string;
|
||||
elderly: number;
|
||||
risk_checklist: RiskChecklistPayload;
|
||||
remark: string;
|
||||
}
|
||||
|
||||
interface ElderlyLite {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface ElderlyListResponse {
|
||||
results: Array<ElderlyLite>;
|
||||
}
|
||||
|
||||
function formatTime(iso: string): string {
|
||||
if (!iso || iso.length === 0) {
|
||||
return '--';
|
||||
}
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) {
|
||||
return '--';
|
||||
}
|
||||
const hh = String(d.getHours()).padStart(2, '0');
|
||||
const mi = String(d.getMinutes()).padStart(2, '0');
|
||||
return `${hh}:${mi}`;
|
||||
}
|
||||
|
||||
@Entry
|
||||
@Component
|
||||
struct RiskPatrol {
|
||||
@State riskItems: Array<RiskItem> = [];
|
||||
@State timeSlots: Array<string> = [];
|
||||
@State records: Array<PatrolRecord> = [];
|
||||
@State loading: boolean = true;
|
||||
@State showForm: boolean = false;
|
||||
@State submitting: boolean = false;
|
||||
// 表单
|
||||
@State slot: string = '';
|
||||
@State elderlyName: string = '';
|
||||
@State checked: Record<string, boolean> = {};
|
||||
@State remark: string = '';
|
||||
|
||||
private scroller: Scroller = new Scroller();
|
||||
|
||||
aboutToAppear(): void {
|
||||
this.load();
|
||||
}
|
||||
|
||||
private async load(): Promise<void> {
|
||||
this.loading = true;
|
||||
try {
|
||||
const res = await api.get<PatrolTodayResponse>('/rules/patrol-records/today/');
|
||||
this.riskItems = res.risk_items;
|
||||
this.timeSlots = res.time_slots;
|
||||
this.records = res.records;
|
||||
} catch (err) {
|
||||
const message = err instanceof RequestError ? err.message : normalizeError(err as BusinessError | Error);
|
||||
promptAction.showToast({ message: message });
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private openForm(): void {
|
||||
if (this.timeSlots.length === 0) {
|
||||
promptAction.showToast({ message: '当前无可巡视时段' });
|
||||
return;
|
||||
}
|
||||
this.slot = this.timeSlots[0];
|
||||
this.elderlyName = '';
|
||||
this.remark = '';
|
||||
this.checked = {};
|
||||
this.showForm = true;
|
||||
}
|
||||
|
||||
private toggleCheck(key: string): void {
|
||||
const next: Record<string, boolean> = JSON.parse(JSON.stringify(this.checked)) as Record<string, boolean>;
|
||||
next[key] = !next[key];
|
||||
this.checked = next;
|
||||
}
|
||||
|
||||
private async submit(): Promise<void> {
|
||||
if (this.elderlyName.trim().length === 0) {
|
||||
promptAction.showToast({ message: '请填写长者姓名' });
|
||||
return;
|
||||
}
|
||||
if (this.submitting) {
|
||||
return;
|
||||
}
|
||||
const allTrue = this.riskItems.every((item: RiskItem) => this.checked[item.key] === true);
|
||||
if (!allTrue) {
|
||||
promptAction.showToast({ message: '请逐项确认所有风险点' });
|
||||
return;
|
||||
}
|
||||
this.submitting = true;
|
||||
try {
|
||||
const elderlyId = await this.resolveElderlyId(this.elderlyName.trim());
|
||||
if (elderlyId === 0) {
|
||||
promptAction.showToast({ message: '未找到该长者,请核对姓名' });
|
||||
return;
|
||||
}
|
||||
const checklist: RiskChecklistPayload = {
|
||||
fall: this.checked['fall'],
|
||||
bed_fall: this.checked['bed_fall'],
|
||||
choking: this.checked['choking'],
|
||||
ingestion: this.checked['ingestion'],
|
||||
scald: this.checked['scald'],
|
||||
self_harm: this.checked['self_harm'],
|
||||
wandering: this.checked['wandering'],
|
||||
pressure_ulcer: this.checked['pressure_ulcer'],
|
||||
recreation_accident: this.checked['recreation_accident'],
|
||||
};
|
||||
const payload: SubmitPatrolPayload = {
|
||||
scheduled_slot: this.slot,
|
||||
elderly: elderlyId,
|
||||
risk_checklist: checklist,
|
||||
remark: this.remark.trim(),
|
||||
};
|
||||
await api.post('/rules/patrol-records/', payload);
|
||||
this.showForm = false;
|
||||
promptAction.showToast({ message: '巡视已提交' });
|
||||
await this.load();
|
||||
} catch (err) {
|
||||
const message = err instanceof RequestError ? err.message : normalizeError(err as BusinessError | Error);
|
||||
promptAction.showToast({ message: message });
|
||||
} finally {
|
||||
this.submitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async resolveElderlyId(name: string): Promise<number> {
|
||||
try {
|
||||
const res = await api.get<ElderlyListResponse>('/elderly/', { page_size: 200 });
|
||||
const found = res.results.find((item: ElderlyLite) => item.name === name);
|
||||
return found ? found.id : 0;
|
||||
} catch (err) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
@Builder
|
||||
RecordCard(record: PatrolRecord) {
|
||||
Row({ space: 10 }) {
|
||||
Text(record.elderly_name)
|
||||
.fontSize(16)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
Text(`${record.elderly_room}${record.elderly_bed ? '-' + record.elderly_bed : ''}`)
|
||||
.fontSize(13)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
Blank()
|
||||
Text(`${record.scheduled_slot} 已巡`)
|
||||
.fontSize(13)
|
||||
.fontColor($r('app.color.accent_green'))
|
||||
}
|
||||
.width('100%')
|
||||
.padding(14)
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.borderRadius(12)
|
||||
}
|
||||
|
||||
@Builder
|
||||
SlotChip(slot: string) {
|
||||
Text(slot)
|
||||
.fontSize(15)
|
||||
.fontColor(this.slot === slot ? '#FFFFFF' : $r('app.color.text_primary'))
|
||||
.padding({ left: 18, right: 18, top: 8, bottom: 8 })
|
||||
.backgroundColor(this.slot === slot ? $r('app.color.accent_blue') : $r('app.color.background'))
|
||||
.borderRadius(16)
|
||||
.onClick(() => {
|
||||
this.slot = slot;
|
||||
})
|
||||
}
|
||||
|
||||
@Builder
|
||||
CheckRow(item: RiskItem) {
|
||||
Row({ space: 10 }) {
|
||||
Text(item.label)
|
||||
.fontSize(16)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
Blank()
|
||||
Text(this.checked[item.key] ? '✓ 正常' : '○')
|
||||
.fontSize(15)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor(this.checked[item.key] ? $r('app.color.accent_green') : $r('app.color.inactive_tab'))
|
||||
}
|
||||
.width('100%')
|
||||
.padding(14)
|
||||
.backgroundColor(this.checked[item.key] ? '#1400C853' : $r('app.color.background'))
|
||||
.borderRadius(10)
|
||||
.onClick(() => {
|
||||
this.toggleCheck(item.key);
|
||||
})
|
||||
}
|
||||
|
||||
@Builder
|
||||
PatrolFormSheet() {
|
||||
Column({ space: 12 }) {
|
||||
Text('风险巡视登记')
|
||||
.fontSize(18)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
|
||||
Row({ space: 10 }) {
|
||||
ForEach(this.timeSlots, (slot: string) => {
|
||||
this.SlotChip(slot)
|
||||
}, (slot: string) => slot)
|
||||
}
|
||||
.width('100%')
|
||||
|
||||
TextInput({ placeholder: '长者姓名', text: this.elderlyName })
|
||||
.placeholderColor($r('app.color.inactive_tab'))
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.fontSize(16)
|
||||
.height(46)
|
||||
.padding({ left: 14, right: 14 })
|
||||
.backgroundColor($r('app.color.background'))
|
||||
.borderRadius(10)
|
||||
.onChange((value: string) => {
|
||||
this.elderlyName = value;
|
||||
})
|
||||
|
||||
Text('逐项确认(全部正常方可提交)')
|
||||
.fontSize(14)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
.width('100%')
|
||||
|
||||
Scroll() {
|
||||
Column({ space: 8 }) {
|
||||
ForEach(this.riskItems, (item: RiskItem) => {
|
||||
Column() {
|
||||
this.CheckRow(item)
|
||||
}
|
||||
.width('100%')
|
||||
}, (item: RiskItem) => item.key)
|
||||
}
|
||||
.width('100%')
|
||||
}
|
||||
.layoutWeight(1)
|
||||
.scrollBar(BarState.Auto)
|
||||
|
||||
TextInput({ placeholder: '备注(可选)', text: this.remark })
|
||||
.placeholderColor($r('app.color.inactive_tab'))
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.fontSize(14)
|
||||
.height(42)
|
||||
.padding({ left: 14, right: 14 })
|
||||
.backgroundColor($r('app.color.background'))
|
||||
.borderRadius(10)
|
||||
.onChange((value: string) => {
|
||||
this.remark = value;
|
||||
})
|
||||
|
||||
Button(this.submitting ? '提交中...' : '提交巡视')
|
||||
.width('100%')
|
||||
.height(48)
|
||||
.fontColor('#FFFFFF')
|
||||
.backgroundColor($r('app.color.accent_green'))
|
||||
.borderRadius(12)
|
||||
.enabled(!this.submitting)
|
||||
.onClick(() => {
|
||||
this.submit();
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
.padding(20)
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.borderRadius(16)
|
||||
}
|
||||
|
||||
build() {
|
||||
HdsNavigation() {
|
||||
Column() {
|
||||
Scroll(this.scroller) {
|
||||
Column({ space: 12 }) {
|
||||
if (this.loading) {
|
||||
Text('加载中...')
|
||||
.fontSize(15)
|
||||
.fontColor($r('app.color.inactive_tab'))
|
||||
.margin({ top: 40 })
|
||||
} else if (this.records.length === 0) {
|
||||
Column({ space: 8 }) {
|
||||
Text('今日暂无巡视记录')
|
||||
.fontSize(18)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
Text('点击右上角"登记"开始风险巡视')
|
||||
.fontSize(14)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
}
|
||||
.width('100%')
|
||||
.padding(24)
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.borderRadius(14)
|
||||
.alignItems(HorizontalAlign.Center)
|
||||
} else {
|
||||
ForEach(this.records, (record: PatrolRecord) => {
|
||||
Column() {
|
||||
this.RecordCard(record)
|
||||
}
|
||||
.width('100%')
|
||||
}, (record: PatrolRecord) => String(record.id))
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ left: 16, right: 16, top: 92, bottom: 30 })
|
||||
}
|
||||
.width('100%')
|
||||
.layoutWeight(1)
|
||||
.scrollBar(BarState.Auto)
|
||||
.edgeEffect(EdgeEffect.Spring)
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
}
|
||||
.mode(NavigationMode.Stack)
|
||||
.titleBar({
|
||||
content: {
|
||||
title: {
|
||||
mainTitle: '风险巡视',
|
||||
},
|
||||
},
|
||||
style: {
|
||||
scrollEffectOpts: {
|
||||
enableScrollEffect: false,
|
||||
scrollEffectType: ScrollEffectType.GRADIENT_BLUR,
|
||||
},
|
||||
systemMaterialEffect: {
|
||||
materialType: hdsMaterial.MaterialType.ADAPTIVE,
|
||||
materialLevel: hdsMaterial.MaterialLevel.EXQUISITE,
|
||||
},
|
||||
},
|
||||
avoidLayoutSafeArea: false,
|
||||
enableComponentSafeArea: false,
|
||||
})
|
||||
.bindToScrollable([this.scroller])
|
||||
.hideBackButton(false)
|
||||
.titleMode(HdsNavigationTitleMode.MINI)
|
||||
.ignoreLayoutSafeArea([LayoutSafeAreaType.SYSTEM], [LayoutSafeAreaEdge.TOP, LayoutSafeAreaEdge.BOTTOM])
|
||||
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
|
||||
.backgroundColor($r('app.color.background'))
|
||||
.bindSheet($$this.showForm, this.PatrolFormSheet(), {
|
||||
height: 560,
|
||||
dragBar: true,
|
||||
showClose: true,
|
||||
backgroundColor: $r('app.color.background_card'),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
// 护理员 · 周身皮肤/压疮检查(ArkTS 严格类型,无 any)
|
||||
// 老人列表:GET /elderly/ ;保存:POST /tasks/nursing-records/
|
||||
import { router, promptAction } from '@kit.ArkUI';
|
||||
import { HdsNavigation, HdsNavigationTitleMode, ScrollEffectType } from '@kit.UIDesignKit';
|
||||
import { hdsMaterial } from '@hms.hds.hdsMaterial';
|
||||
import { BusinessError } from '@kit.BasicServicesKit';
|
||||
import { Page, normalizeError } from '../../common/ApiConstants';
|
||||
import { api, RequestError } from '../../utils/Request';
|
||||
import { AuthStore } from '../../utils/AuthStore';
|
||||
|
||||
interface ElderlyMini {
|
||||
id: number;
|
||||
name: string;
|
||||
room: string;
|
||||
bed: string;
|
||||
}
|
||||
|
||||
interface PressureUlcerData {
|
||||
body_position: string;
|
||||
skin_statuses: Array<string>;
|
||||
pressure_site: string;
|
||||
protective_aid: string;
|
||||
shift: string;
|
||||
recorder_role: string;
|
||||
wound_area: string;
|
||||
}
|
||||
|
||||
interface SkinCheckExtracted {
|
||||
pressure_ulcer: PressureUlcerData;
|
||||
}
|
||||
|
||||
interface SkinCheckPayload {
|
||||
elderly: number;
|
||||
nurse: number;
|
||||
raw_text: string;
|
||||
extracted_json: SkinCheckExtracted;
|
||||
images: Array<string>;
|
||||
}
|
||||
|
||||
@Entry
|
||||
@Component
|
||||
struct SkinCheck {
|
||||
@State elderlyList: Array<ElderlyMini> = [];
|
||||
@State loading: boolean = true;
|
||||
@State saving: boolean = false;
|
||||
@State selectedId: number = 0;
|
||||
@State shift: string = 'day';
|
||||
@State detailText: string = '';
|
||||
@State woundArea: string = '';
|
||||
private scroller: Scroller = new Scroller();
|
||||
|
||||
aboutToAppear(): void {
|
||||
this.loadElderly();
|
||||
}
|
||||
|
||||
private async loadElderly(): Promise<void> {
|
||||
this.loading = true;
|
||||
try {
|
||||
const res = await api.get<Page<ElderlyMini>>('/elderly/', { page_size: 200 });
|
||||
this.elderlyList = res.results;
|
||||
} catch (err) {
|
||||
const message = err instanceof RequestError ? err.message : normalizeError(err as BusinessError | Error);
|
||||
promptAction.showToast({ message: message });
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private shiftLabel(): string {
|
||||
return this.shift === 'day' ? '白班' : '夜班';
|
||||
}
|
||||
|
||||
private async submit(): Promise<void> {
|
||||
if (this.selectedId === 0) {
|
||||
promptAction.showToast({ message: '请选择老人' });
|
||||
return;
|
||||
}
|
||||
if (this.detailText.trim().length === 0) {
|
||||
promptAction.showToast({ message: '请输入破损部位' });
|
||||
return;
|
||||
}
|
||||
if (this.saving) {
|
||||
return;
|
||||
}
|
||||
this.saving = true;
|
||||
try {
|
||||
const state = await AuthStore.getState();
|
||||
const pu: PressureUlcerData = {
|
||||
body_position: this.detailText.trim(),
|
||||
skin_statuses: [],
|
||||
pressure_site: this.detailText.trim(),
|
||||
protective_aid: '',
|
||||
shift: this.shift,
|
||||
recorder_role: 'nurse',
|
||||
wound_area: this.woundArea.trim(),
|
||||
};
|
||||
const extracted: SkinCheckExtracted = { pressure_ulcer: pu };
|
||||
const body: SkinCheckPayload = {
|
||||
elderly: this.selectedId,
|
||||
nurse: state.userId,
|
||||
raw_text: `${this.shiftLabel()}周身皮肤检查:${this.detailText.trim()}`,
|
||||
extracted_json: extracted,
|
||||
images: [],
|
||||
};
|
||||
await api.post('/tasks/nursing-records/', body);
|
||||
promptAction.showToast({ message: '皮肤检查已保存' });
|
||||
router.back();
|
||||
} catch (err) {
|
||||
const message = err instanceof RequestError ? err.message : normalizeError(err as BusinessError | Error);
|
||||
promptAction.showToast({ message: message });
|
||||
} finally {
|
||||
this.saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
@Builder
|
||||
ElderlyOption(item: ElderlyMini) {
|
||||
Row({ space: 10 }) {
|
||||
Text(item.name)
|
||||
.fontSize(15)
|
||||
.fontWeight(this.selectedId === item.id ? FontWeight.Bold : FontWeight.Normal)
|
||||
.fontColor(this.selectedId === item.id ? '#007AFF' : $r('app.color.text_primary'))
|
||||
Text(`${item.room || ''}${item.bed ? ' ' + item.bed : ''}`)
|
||||
.fontSize(12)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
Blank()
|
||||
if (this.selectedId === item.id) {
|
||||
Text('✓')
|
||||
.fontSize(16)
|
||||
.fontColor('#007AFF')
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ left: 14, right: 14, top: 12, bottom: 12 })
|
||||
.backgroundColor(this.selectedId === item.id ? '#1A007AFF' : $r('app.color.background_card'))
|
||||
.borderRadius(10)
|
||||
.onClick(() => {
|
||||
this.selectedId = item.id;
|
||||
})
|
||||
}
|
||||
|
||||
build() {
|
||||
HdsNavigation() {
|
||||
Scroll(this.scroller) {
|
||||
Column({ space: 16 }) {
|
||||
// 护理对象
|
||||
Column({ space: 8 }) {
|
||||
Text('护理对象')
|
||||
.fontSize(16)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.width('100%')
|
||||
if (this.loading) {
|
||||
Text('正在加载老人列表...')
|
||||
.fontSize(14)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
.padding(16)
|
||||
} else if (this.elderlyList.length === 0) {
|
||||
Text('暂无老人')
|
||||
.fontSize(14)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
.padding(16)
|
||||
} else {
|
||||
ForEach(this.elderlyList, (item: ElderlyMini) => {
|
||||
this.ElderlyOption(item)
|
||||
}, (item: ElderlyMini) => String(item.id))
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
|
||||
// 班次
|
||||
Column({ space: 8 }) {
|
||||
Text('班次')
|
||||
.fontSize(16)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.width('100%')
|
||||
Row({ space: 10 }) {
|
||||
Text('白班')
|
||||
.fontSize(15)
|
||||
.fontColor(this.shift === 'day' ? '#FFFFFF' : $r('app.color.text_primary'))
|
||||
.padding({ left: 20, right: 20, top: 8, bottom: 8 })
|
||||
.backgroundColor(this.shift === 'day' ? '#007AFF' : $r('app.color.background_card'))
|
||||
.borderRadius(10)
|
||||
.onClick(() => {
|
||||
this.shift = 'day';
|
||||
})
|
||||
Text('夜班')
|
||||
.fontSize(15)
|
||||
.fontColor(this.shift === 'night' ? '#FFFFFF' : $r('app.color.text_primary'))
|
||||
.padding({ left: 20, right: 20, top: 8, bottom: 8 })
|
||||
.backgroundColor(this.shift === 'night' ? '#007AFF' : $r('app.color.background_card'))
|
||||
.borderRadius(10)
|
||||
.onClick(() => {
|
||||
this.shift = 'night';
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
|
||||
// 破损部位
|
||||
Column({ space: 8 }) {
|
||||
Text('破损部位及状况')
|
||||
.fontSize(16)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.width('100%')
|
||||
TextArea({
|
||||
placeholder: '描述破损部位及皮肤状况,如:右踝外侧压疮,皮肤发红',
|
||||
text: this.detailText,
|
||||
})
|
||||
.width('100%')
|
||||
.height(100)
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.borderRadius(10)
|
||||
.onChange((value: string) => {
|
||||
this.detailText = value;
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
|
||||
// 创面面积
|
||||
Column({ space: 8 }) {
|
||||
Text('创面面积(可选)')
|
||||
.fontSize(16)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.width('100%')
|
||||
TextInput({ placeholder: '如:2cm × 3cm', text: this.woundArea })
|
||||
.width('100%')
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.borderRadius(10)
|
||||
.onChange((value: string) => {
|
||||
this.woundArea = value;
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
|
||||
Button(this.saving ? '保存中...' : '保存皮肤检查')
|
||||
.width('100%')
|
||||
.height(48)
|
||||
.fontSize(16)
|
||||
.fontColor('#FFFFFF')
|
||||
.backgroundColor('#007AFF')
|
||||
.borderRadius(12)
|
||||
.enabled(!this.saving)
|
||||
.onClick(() => {
|
||||
this.submit();
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ left: 16, right: 16, top: 120, bottom: 24 })
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
.scrollBar(BarState.Auto)
|
||||
.edgeEffect(EdgeEffect.Spring)
|
||||
}
|
||||
.mode(NavigationMode.Stack)
|
||||
.titleBar({
|
||||
content: {
|
||||
title: {
|
||||
mainTitle: '皮肤检查',
|
||||
},
|
||||
},
|
||||
style: {
|
||||
scrollEffectOpts: {
|
||||
enableScrollEffect: false,
|
||||
scrollEffectType: ScrollEffectType.GRADIENT_BLUR,
|
||||
},
|
||||
systemMaterialEffect: {
|
||||
materialType: hdsMaterial.MaterialType.ADAPTIVE,
|
||||
materialLevel: hdsMaterial.MaterialLevel.EXQUISITE,
|
||||
},
|
||||
},
|
||||
avoidLayoutSafeArea: false,
|
||||
enableComponentSafeArea: false,
|
||||
})
|
||||
.hideBackButton(false)
|
||||
.titleMode(HdsNavigationTitleMode.MINI)
|
||||
.ignoreLayoutSafeArea([LayoutSafeAreaType.SYSTEM], [LayoutSafeAreaEdge.TOP, LayoutSafeAreaEdge.BOTTOM])
|
||||
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
|
||||
.backgroundColor($r('app.color.background'))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,595 @@
|
||||
// 护理员 · 护理中心/工作中心(ArkTS 严格类型,无 any)
|
||||
// 接口:GET /tasks/work-center/?view=current
|
||||
// 写操作 /tasks/work-items/{pk}/claim|release|complete-duty|mark-not-needed/
|
||||
import { router } from '@kit.ArkUI';
|
||||
import { promptAction } from '@kit.ArkUI';
|
||||
import { HdsNavigation, HdsNavigationTitleMode, ScrollEffectType } from '@kit.UIDesignKit';
|
||||
import { hdsMaterial } from '@hms.hds.hdsMaterial';
|
||||
import { BusinessError } from '@kit.BasicServicesKit';
|
||||
import { normalizeError } from '../../common/ApiConstants';
|
||||
import { api, EmptyBody, RequestError } from '../../utils/Request';
|
||||
import { publishLocalNotify } from '../../utils/LocalNotify';
|
||||
|
||||
interface CareItemMetadata {
|
||||
on_demand?: boolean;
|
||||
}
|
||||
|
||||
interface WorkItem {
|
||||
id: number;
|
||||
work_type: string;
|
||||
title: string;
|
||||
service_name: string;
|
||||
elderly: number | null;
|
||||
elderly_name: string;
|
||||
floor: number | null;
|
||||
room: string;
|
||||
bed: string;
|
||||
scope_type: string;
|
||||
coverage_risk: boolean;
|
||||
scheduled_start: string;
|
||||
scheduled_end: string;
|
||||
status: string;
|
||||
display_status: string;
|
||||
time_bucket: string;
|
||||
claimed_by: number | null;
|
||||
claimed_by_name: string;
|
||||
claim_expires_at: string | null;
|
||||
completed_by_name: string;
|
||||
completed_at: string | null;
|
||||
outcome: string;
|
||||
assigned_nurse_name: string;
|
||||
metadata: CareItemMetadata;
|
||||
}
|
||||
|
||||
interface CareSummary {
|
||||
current: number;
|
||||
overdue: number;
|
||||
upcoming: number;
|
||||
completed: number;
|
||||
}
|
||||
|
||||
interface CompleteDutyPayload {
|
||||
note: string;
|
||||
images: Array<string>;
|
||||
}
|
||||
|
||||
interface WorkCenterResponse {
|
||||
current_shift: object | null;
|
||||
schedule_status: string;
|
||||
summary: CareSummary;
|
||||
items: Array<WorkItem>;
|
||||
}
|
||||
|
||||
function workTypeLabel(type: string): string {
|
||||
if (type === 'service') {
|
||||
return '护理';
|
||||
}
|
||||
if (type === 'risk_patrol') {
|
||||
return '巡视';
|
||||
}
|
||||
if (type === 'medication') {
|
||||
return '给药';
|
||||
}
|
||||
if (type === 'day_duty') {
|
||||
return '日值';
|
||||
}
|
||||
return '任务';
|
||||
}
|
||||
|
||||
function workTypeColor(type: string): string {
|
||||
if (type === 'service') {
|
||||
return '#007AFF';
|
||||
}
|
||||
if (type === 'risk_patrol') {
|
||||
return '#FF9500';
|
||||
}
|
||||
if (type === 'medication') {
|
||||
return '#34C759';
|
||||
}
|
||||
if (type === 'day_duty') {
|
||||
return '#AF52DE';
|
||||
}
|
||||
return '#8E8E93';
|
||||
}
|
||||
|
||||
function workTypeBg(type: string): string {
|
||||
if (type === 'service') {
|
||||
return '#33007AFF';
|
||||
}
|
||||
if (type === 'risk_patrol') {
|
||||
return '#33FF9500';
|
||||
}
|
||||
if (type === 'medication') {
|
||||
return '#3334C759';
|
||||
}
|
||||
if (type === 'day_duty') {
|
||||
return '#33AF52DE';
|
||||
}
|
||||
return '#338E8E93';
|
||||
}
|
||||
|
||||
function statusLabel(status: string): string {
|
||||
if (status === 'pending') {
|
||||
return '待护理';
|
||||
}
|
||||
if (status === 'in_progress') {
|
||||
return '护理中';
|
||||
}
|
||||
if (status === 'completed') {
|
||||
return '已完成';
|
||||
}
|
||||
if (status === 'resolved') {
|
||||
return '已处置';
|
||||
}
|
||||
if (status === 'cancelled') {
|
||||
return '已取消';
|
||||
}
|
||||
if (status === 'overdue') {
|
||||
return '已逾期';
|
||||
}
|
||||
return status;
|
||||
}
|
||||
|
||||
function statusColor(status: string): string {
|
||||
if (status === 'overdue') {
|
||||
return '#FF3B30';
|
||||
}
|
||||
if (status === 'in_progress') {
|
||||
return '#007AFF';
|
||||
}
|
||||
if (status === 'completed' || status === 'resolved') {
|
||||
return '#34C759';
|
||||
}
|
||||
return '#8E8E93';
|
||||
}
|
||||
|
||||
function formatTime(iso: string): string {
|
||||
if (!iso || iso.length === 0) {
|
||||
return '--';
|
||||
}
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) {
|
||||
return '--';
|
||||
}
|
||||
const hh = String(d.getHours()).padStart(2, '0');
|
||||
const mi = String(d.getMinutes()).padStart(2, '0');
|
||||
return `${hh}:${mi}`;
|
||||
}
|
||||
|
||||
function isOnDemand(item: WorkItem): boolean {
|
||||
return item.metadata.on_demand === true;
|
||||
}
|
||||
|
||||
@Entry
|
||||
@Component
|
||||
struct WorkCenter {
|
||||
@State items: Array<WorkItem> = [];
|
||||
@State scheduleStatus: string = 'unassigned';
|
||||
@State summary: CareSummary = { current: 0, overdue: 0, upcoming: 0, completed: 0 };
|
||||
@State loading: boolean = true;
|
||||
@State operatingId: number = 0;
|
||||
// 完成日值备注
|
||||
@State showDutySheet: boolean = false;
|
||||
@State dutyTargetId: number = 0;
|
||||
@State dutyNote: string = '';
|
||||
|
||||
private scroller: Scroller = new Scroller();
|
||||
|
||||
aboutToAppear(): void {
|
||||
this.load();
|
||||
}
|
||||
|
||||
private async load(): Promise<void> {
|
||||
this.loading = true;
|
||||
try {
|
||||
const res = await api.get<WorkCenterResponse>('/tasks/work-center/', { view: 'current' });
|
||||
this.scheduleStatus = res.schedule_status;
|
||||
this.summary = res.summary;
|
||||
this.items = res.items;
|
||||
// 逾期工作项:发本地通知栏提醒(每天最多一条,避免打扰)
|
||||
if (res.summary.overdue > 0) {
|
||||
const today = new Date();
|
||||
const dayKey = `${today.getFullYear()}-${today.getMonth() + 1}-${today.getDate()}`;
|
||||
if (AppStorage.get<string>('overdueNotifyDay') !== dayKey) {
|
||||
AppStorage.setOrCreate<string>('overdueNotifyDay', dayKey);
|
||||
publishLocalNotify(Date.now() % 100000, '逾期提醒', `您有 ${res.summary.overdue} 项护理工作已逾期,请尽快处理`);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof RequestError ? err.message : normalizeError(err as BusinessError | Error);
|
||||
promptAction.showToast({ message: message });
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async claim(item: WorkItem): Promise<void> {
|
||||
if (this.operatingId !== 0) {
|
||||
return;
|
||||
}
|
||||
this.operatingId = item.id;
|
||||
try {
|
||||
await api.post(`/tasks/work-items/${item.id}/claim/`, new EmptyBody());
|
||||
promptAction.showToast({ message: '已认领,请在 30 分钟内完成' });
|
||||
await this.load();
|
||||
} catch (err) {
|
||||
const message = err instanceof RequestError ? err.message : normalizeError(err as BusinessError | Error);
|
||||
promptAction.showToast({ message: message });
|
||||
} finally {
|
||||
this.operatingId = 0;
|
||||
}
|
||||
}
|
||||
|
||||
private async release(item: WorkItem): Promise<void> {
|
||||
if (this.operatingId !== 0) {
|
||||
return;
|
||||
}
|
||||
this.operatingId = item.id;
|
||||
try {
|
||||
await api.post(`/tasks/work-items/${item.id}/release/`, new EmptyBody());
|
||||
promptAction.showToast({ message: '已释放' });
|
||||
await this.load();
|
||||
} catch (err) {
|
||||
const message = err instanceof RequestError ? err.message : normalizeError(err as BusinessError | Error);
|
||||
promptAction.showToast({ message: message });
|
||||
} finally {
|
||||
this.operatingId = 0;
|
||||
}
|
||||
}
|
||||
|
||||
private async markNotNeeded(item: WorkItem): Promise<void> {
|
||||
if (this.operatingId !== 0) {
|
||||
return;
|
||||
}
|
||||
this.operatingId = item.id;
|
||||
try {
|
||||
await api.post(`/tasks/work-items/${item.id}/mark-not-needed/`, new EmptyBody());
|
||||
promptAction.showToast({ message: '已标记今日无需' });
|
||||
await this.load();
|
||||
} catch (err) {
|
||||
const message = err instanceof RequestError ? err.message : normalizeError(err as BusinessError | Error);
|
||||
promptAction.showToast({ message: message });
|
||||
} finally {
|
||||
this.operatingId = 0;
|
||||
}
|
||||
}
|
||||
|
||||
private openDutyComplete(item: WorkItem): void {
|
||||
this.dutyTargetId = item.id;
|
||||
this.dutyNote = '';
|
||||
this.showDutySheet = true;
|
||||
}
|
||||
|
||||
private async submitDutyComplete(): Promise<void> {
|
||||
if (this.operatingId !== 0) {
|
||||
return;
|
||||
}
|
||||
this.operatingId = this.dutyTargetId;
|
||||
try {
|
||||
const body: CompleteDutyPayload = {
|
||||
note: this.dutyNote.trim(),
|
||||
images: [],
|
||||
};
|
||||
await api.post(`/tasks/work-items/${this.dutyTargetId}/complete-duty/`, body);
|
||||
this.showDutySheet = false;
|
||||
promptAction.showToast({ message: '日值职责已完成' });
|
||||
await this.load();
|
||||
} catch (err) {
|
||||
const message = err instanceof RequestError ? err.message : normalizeError(err as BusinessError | Error);
|
||||
promptAction.showToast({ message: message });
|
||||
} finally {
|
||||
this.operatingId = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// 当前项可执行的操作:0 无 / 1 认领 / 2 完成日值 / 3 标记无需 / 4 释放
|
||||
private actionType(item: WorkItem): number {
|
||||
if (item.status === 'completed' || item.status === 'resolved' || item.status === 'cancelled') {
|
||||
return 0;
|
||||
}
|
||||
const mine = item.claimed_by !== null;
|
||||
if (!mine) {
|
||||
return 1;
|
||||
}
|
||||
if (item.work_type === 'day_duty') {
|
||||
return 2;
|
||||
}
|
||||
if (item.work_type === 'service' && isOnDemand(item)) {
|
||||
return 3;
|
||||
}
|
||||
return 4;
|
||||
}
|
||||
|
||||
@Builder
|
||||
WorkItemCard(item: WorkItem) {
|
||||
Column({ space: 8 }) {
|
||||
Row({ space: 10 }) {
|
||||
Text(workTypeLabel(item.work_type))
|
||||
.fontSize(12)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor(workTypeColor(item.work_type))
|
||||
.padding({ left: 10, right: 10, top: 3, bottom: 3 })
|
||||
.backgroundColor(workTypeBg(item.work_type))
|
||||
.borderRadius(8)
|
||||
Text(item.title)
|
||||
.fontSize(16)
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.layoutWeight(1)
|
||||
.maxLines(1)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
Text(statusLabel(item.display_status))
|
||||
.fontSize(12)
|
||||
.fontColor(statusColor(item.display_status))
|
||||
}
|
||||
.width('100%')
|
||||
|
||||
Row({ space: 12 }) {
|
||||
if (item.elderly_name && item.elderly_name.length > 0) {
|
||||
Text(item.elderly_name)
|
||||
.fontSize(13)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
}
|
||||
if (item.room && item.room.length > 0) {
|
||||
Text(`${item.room}${item.bed ? '-' + item.bed : ''}`)
|
||||
.fontSize(13)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
}
|
||||
Blank()
|
||||
Text(`${formatTime(item.scheduled_start)}-${formatTime(item.scheduled_end)}`)
|
||||
.fontSize(13)
|
||||
.fontColor($r('app.color.inactive_tab'))
|
||||
}
|
||||
.width('100%')
|
||||
|
||||
if (item.claimed_by_name && item.claimed_by_name.length > 0) {
|
||||
Text(`执行人:${item.claimed_by_name}`)
|
||||
.fontSize(12)
|
||||
.fontColor($r('app.color.accent_blue'))
|
||||
.width('100%')
|
||||
}
|
||||
|
||||
if (this.actionType(item) > 0) {
|
||||
Row({ space: 10 }) {
|
||||
if (this.actionType(item) === 1) {
|
||||
Button(this.operatingId === item.id ? '处理中...' : '认领')
|
||||
.height(34)
|
||||
.fontSize(14)
|
||||
.fontColor('#FFFFFF')
|
||||
.backgroundColor($r('app.color.accent_blue'))
|
||||
.borderRadius(10)
|
||||
.layoutWeight(1)
|
||||
.enabled(this.operatingId === 0)
|
||||
.onClick(() => {
|
||||
this.claim(item);
|
||||
})
|
||||
} else if (this.actionType(item) === 2) {
|
||||
Button(this.operatingId === item.id ? '处理中...' : '完成日值')
|
||||
.height(34)
|
||||
.fontSize(14)
|
||||
.fontColor('#FFFFFF')
|
||||
.backgroundColor('#AF52DE')
|
||||
.borderRadius(10)
|
||||
.layoutWeight(1)
|
||||
.enabled(this.operatingId === 0)
|
||||
.onClick(() => {
|
||||
this.openDutyComplete(item);
|
||||
})
|
||||
Button('释放')
|
||||
.height(34)
|
||||
.fontSize(14)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
.backgroundColor($r('app.color.background'))
|
||||
.borderRadius(10)
|
||||
.layoutWeight(1)
|
||||
.enabled(this.operatingId === 0)
|
||||
.onClick(() => {
|
||||
this.release(item);
|
||||
})
|
||||
} else if (this.actionType(item) === 3) {
|
||||
Button(this.operatingId === item.id ? '处理中...' : '今日无需')
|
||||
.height(34)
|
||||
.fontSize(14)
|
||||
.fontColor('#FFFFFF')
|
||||
.backgroundColor('#FF9500')
|
||||
.borderRadius(10)
|
||||
.layoutWeight(1)
|
||||
.enabled(this.operatingId === 0)
|
||||
.onClick(() => {
|
||||
this.markNotNeeded(item);
|
||||
})
|
||||
Button('释放')
|
||||
.height(34)
|
||||
.fontSize(14)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
.backgroundColor($r('app.color.background'))
|
||||
.borderRadius(10)
|
||||
.layoutWeight(1)
|
||||
.enabled(this.operatingId === 0)
|
||||
.onClick(() => {
|
||||
this.release(item);
|
||||
})
|
||||
} else if (this.actionType(item) === 4) {
|
||||
Button(this.operatingId === item.id ? '处理中...' : '释放')
|
||||
.height(34)
|
||||
.fontSize(14)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
.backgroundColor($r('app.color.background'))
|
||||
.borderRadius(10)
|
||||
.layoutWeight(1)
|
||||
.enabled(this.operatingId === 0)
|
||||
.onClick(() => {
|
||||
this.release(item);
|
||||
})
|
||||
Text('护理类任务请通过"事件录入"完成')
|
||||
.fontSize(11)
|
||||
.fontColor($r('app.color.inactive_tab'))
|
||||
.layoutWeight(1)
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.padding(14)
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.borderRadius(14)
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
}
|
||||
|
||||
@Builder
|
||||
DutyCompleteSheet() {
|
||||
Column({ space: 12 }) {
|
||||
Text('完成日值职责')
|
||||
.fontSize(17)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
TextArea({ placeholder: '交接备注(可选)', text: this.dutyNote })
|
||||
.placeholderColor($r('app.color.inactive_tab'))
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.height(120)
|
||||
.padding(14)
|
||||
.backgroundColor($r('app.color.background'))
|
||||
.borderRadius(12)
|
||||
.onChange((value: string) => {
|
||||
this.dutyNote = value;
|
||||
})
|
||||
Button(this.operatingId === this.dutyTargetId ? '提交中...' : '确认完成')
|
||||
.width('100%')
|
||||
.height(48)
|
||||
.fontColor('#FFFFFF')
|
||||
.backgroundColor('#AF52DE')
|
||||
.borderRadius(12)
|
||||
.enabled(this.operatingId === 0)
|
||||
.onClick(() => {
|
||||
this.submitDutyComplete();
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
.padding(20)
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.borderRadius(16)
|
||||
}
|
||||
|
||||
build() {
|
||||
HdsNavigation() {
|
||||
Column() {
|
||||
Scroll(this.scroller) {
|
||||
Column({ space: 12 }) {
|
||||
// 待办计数
|
||||
Row({ space: 10 }) {
|
||||
Column({ space: 4 }) {
|
||||
Text(String(this.summary.current))
|
||||
.fontSize(22)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor($r('app.color.accent_blue'))
|
||||
Text('进行中')
|
||||
.fontSize(12)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
}
|
||||
.layoutWeight(1)
|
||||
Column({ space: 4 }) {
|
||||
Text(String(this.summary.overdue))
|
||||
.fontSize(22)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor('#FF3B30')
|
||||
Text('已逾期')
|
||||
.fontSize(12)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
}
|
||||
.layoutWeight(1)
|
||||
Column({ space: 4 }) {
|
||||
Text(String(this.summary.upcoming))
|
||||
.fontSize(22)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor('#FF9500')
|
||||
Text('待开始')
|
||||
.fontSize(12)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
}
|
||||
.layoutWeight(1)
|
||||
Column({ space: 4 }) {
|
||||
Text(String(this.summary.completed))
|
||||
.fontSize(22)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor($r('app.color.accent_green'))
|
||||
Text('已完成')
|
||||
.fontSize(12)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
}
|
||||
.layoutWeight(1)
|
||||
}
|
||||
.width('100%')
|
||||
.padding(16)
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.borderRadius(14)
|
||||
|
||||
if (this.loading) {
|
||||
Text('加载中...')
|
||||
.fontSize(15)
|
||||
.fontColor($r('app.color.inactive_tab'))
|
||||
.margin({ top: 40 })
|
||||
} else if (this.items.length === 0) {
|
||||
Text(this.scheduleStatus === 'unassigned' ?
|
||||
'当前无排班,无待办工作项' : '当前没有待办工作项')
|
||||
.fontSize(15)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
.width('100%')
|
||||
.padding(24)
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.borderRadius(14)
|
||||
.textAlign(TextAlign.Center)
|
||||
} else {
|
||||
ForEach(this.items, (item: WorkItem) => {
|
||||
Column() {
|
||||
this.WorkItemCard(item)
|
||||
}
|
||||
.width('100%')
|
||||
}, (item: WorkItem) => String(item.id))
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ left: 16, right: 16, top: 92, bottom: 30 })
|
||||
}
|
||||
.width('100%')
|
||||
.layoutWeight(1)
|
||||
.scrollBar(BarState.Auto)
|
||||
.edgeEffect(EdgeEffect.Spring)
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
}
|
||||
.mode(NavigationMode.Stack)
|
||||
.titleBar({
|
||||
content: {
|
||||
title: {
|
||||
mainTitle: '护理中心',
|
||||
},
|
||||
},
|
||||
style: {
|
||||
scrollEffectOpts: {
|
||||
enableScrollEffect: false,
|
||||
scrollEffectType: ScrollEffectType.GRADIENT_BLUR,
|
||||
},
|
||||
systemMaterialEffect: {
|
||||
materialType: hdsMaterial.MaterialType.ADAPTIVE,
|
||||
materialLevel: hdsMaterial.MaterialLevel.EXQUISITE,
|
||||
},
|
||||
},
|
||||
avoidLayoutSafeArea: false,
|
||||
enableComponentSafeArea: false,
|
||||
})
|
||||
.bindToScrollable([this.scroller])
|
||||
.hideBackButton(false)
|
||||
.titleMode(HdsNavigationTitleMode.MINI)
|
||||
.ignoreLayoutSafeArea([LayoutSafeAreaType.SYSTEM], [LayoutSafeAreaEdge.TOP, LayoutSafeAreaEdge.BOTTOM])
|
||||
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
|
||||
.backgroundColor($r('app.color.background'))
|
||||
.bindSheet($$this.showDutySheet, this.DutyCompleteSheet(), {
|
||||
height: 280,
|
||||
dragBar: true,
|
||||
showClose: true,
|
||||
backgroundColor: $r('app.color.background_card'),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
// 关于应用(ArkTS 严格类型,无 any)
|
||||
import { HdsNavigation, HdsNavigationTitleMode, ScrollEffectType } from '@kit.UIDesignKit';
|
||||
import { hdsMaterial } from '@hms.hds.hdsMaterial';
|
||||
import { FontScaleLevel, FONT_SCALE_MAP } from '../../utils/FontScale';
|
||||
|
||||
@Entry
|
||||
@Component
|
||||
struct About {
|
||||
@StorageProp('fontScale') fontScaleLevel: FontScaleLevel = 'system';
|
||||
|
||||
private scroller: Scroller = new Scroller();
|
||||
|
||||
private scaleFor(): number {
|
||||
return FONT_SCALE_MAP[this.fontScaleLevel] ?? 1.0;
|
||||
}
|
||||
|
||||
build() {
|
||||
HdsNavigation() {
|
||||
Column() {
|
||||
Scroll(this.scroller) {
|
||||
Column({ space: 16 }) {
|
||||
// Logo
|
||||
Column({ space: 10 }) {
|
||||
Column() {
|
||||
Text('养')
|
||||
.fontSize(44)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor('#FFFFFF')
|
||||
}
|
||||
.width(88)
|
||||
.height(88)
|
||||
.justifyContent(FlexAlign.Center)
|
||||
.alignItems(HorizontalAlign.Center)
|
||||
.backgroundColor($r('app.color.accent_blue'))
|
||||
.borderRadius(24)
|
||||
|
||||
Text('数智护老')
|
||||
.fontSize(Math.round(24 * this.scaleFor()))
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
Text('SmartSenior · 智慧养老')
|
||||
.fontSize(Math.round(15 * this.scaleFor()))
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
Text('版本 1.0.0')
|
||||
.fontSize(Math.round(13 * this.scaleFor()))
|
||||
.fontColor($r('app.color.inactive_tab'))
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ top: 20 })
|
||||
.alignItems(HorizontalAlign.Center)
|
||||
|
||||
// 说明
|
||||
Column({ space: 8 }) {
|
||||
Text('关于本应用')
|
||||
.fontSize(Math.round(17 * this.scaleFor()))
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.width('100%')
|
||||
Text('数智护老是面向养老机构的智慧照护客户端,为护理员、护士、家属、老人及管理人员提供护理记录、健康预警、订餐服务、智能助手等功能,帮助提升照护质量与管理效率。')
|
||||
.fontSize(Math.round(15 * this.scaleFor()))
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
.lineHeight(Math.round(24 * this.scaleFor()))
|
||||
.width('100%')
|
||||
}
|
||||
.width('100%')
|
||||
.padding(16)
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.borderRadius(14)
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
|
||||
// 免责声明
|
||||
Column({ space: 8 }) {
|
||||
Text('免责声明')
|
||||
.fontSize(Math.round(17 * this.scaleFor()))
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.width('100%')
|
||||
Text('本应用内由 AI 生成的内容仅供参考,不构成医疗建议。老人的健康与照护请以医护人员专业判断为准。')
|
||||
.fontSize(Math.round(14 * this.scaleFor()))
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
.lineHeight(Math.round(22 * this.scaleFor()))
|
||||
.width('100%')
|
||||
}
|
||||
.width('100%')
|
||||
.padding(16)
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.borderRadius(14)
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
|
||||
Text('© 2026 SmartSenior鸿蒙版.CAPTAIN 1275 All rights reserved.')
|
||||
.fontSize(Math.round(12 * this.scaleFor()))
|
||||
.fontColor($r('app.color.inactive_tab'))
|
||||
.margin({ bottom: 20 })
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ left: 16, right: 16, top: 92, bottom: 30 })
|
||||
}
|
||||
.width('100%')
|
||||
.layoutWeight(1)
|
||||
.scrollBar(BarState.Auto)
|
||||
.edgeEffect(EdgeEffect.Spring)
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
}
|
||||
.mode(NavigationMode.Stack)
|
||||
.titleBar({
|
||||
content: {
|
||||
title: {
|
||||
mainTitle: '关于应用',
|
||||
},
|
||||
},
|
||||
style: {
|
||||
scrollEffectOpts: {
|
||||
enableScrollEffect: false,
|
||||
scrollEffectType: ScrollEffectType.GRADIENT_BLUR,
|
||||
},
|
||||
systemMaterialEffect: {
|
||||
materialType: hdsMaterial.MaterialType.ADAPTIVE,
|
||||
materialLevel: hdsMaterial.MaterialLevel.EXQUISITE,
|
||||
},
|
||||
},
|
||||
avoidLayoutSafeArea: false,
|
||||
enableComponentSafeArea: false,
|
||||
})
|
||||
.bindToScrollable([this.scroller])
|
||||
.hideBackButton(false)
|
||||
.titleMode(HdsNavigationTitleMode.MINI)
|
||||
.ignoreLayoutSafeArea([LayoutSafeAreaType.SYSTEM], [LayoutSafeAreaEdge.TOP, LayoutSafeAreaEdge.BOTTOM])
|
||||
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
|
||||
.backgroundColor($r('app.color.background'))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,421 @@
|
||||
// 意见反馈页(ArkTS 严格类型,无 any)
|
||||
// 接口:POST /feedback/ {content, contact, images, diagnostics}
|
||||
import { router } from '@kit.ArkUI';
|
||||
import { promptAction } from '@kit.ArkUI';
|
||||
import { photoAccessHelper } from '@kit.MediaLibraryKit';
|
||||
import { BusinessError } from '@kit.BasicServicesKit';
|
||||
import { HdsNavigation, HdsNavigationTitleMode, ScrollEffectType } from '@kit.UIDesignKit';
|
||||
import { hdsMaterial } from '@hms.hds.hdsMaterial';
|
||||
import { Page, normalizeError } from '../../common/ApiConstants';
|
||||
import { api, uploadMultipartFiles, RequestError } from '../../utils/Request';
|
||||
import { FontScaleLevel, FONT_SCALE_MAP } from '../../utils/FontScale';
|
||||
|
||||
interface UploadResponse {
|
||||
urls: Array<string>;
|
||||
}
|
||||
|
||||
// 反馈记录
|
||||
interface FeedbackRecordItem {
|
||||
id: number;
|
||||
content: string;
|
||||
status: string;
|
||||
status_label: string;
|
||||
created_at: string;
|
||||
handler_name: string | null;
|
||||
resolved_at: string | null;
|
||||
}
|
||||
|
||||
// 反馈请求体
|
||||
interface FeedbackPayload {
|
||||
content: string;
|
||||
contact?: string;
|
||||
images?: Array<string>;
|
||||
diagnostics?: DiagnosticsPayload;
|
||||
}
|
||||
|
||||
// 诊断信息(问题类型)
|
||||
interface DiagnosticsPayload {
|
||||
device_model: string;
|
||||
system_version: string;
|
||||
app_version: string;
|
||||
}
|
||||
|
||||
const MAX_IMAGES = 6;
|
||||
|
||||
function formatRecordTime(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) {
|
||||
return iso || '--';
|
||||
}
|
||||
const mm = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const dd = String(d.getDate()).padStart(2, '0');
|
||||
const hh = String(d.getHours()).padStart(2, '0');
|
||||
const mi = String(d.getMinutes()).padStart(2, '0');
|
||||
return `${mm}/${dd} ${hh}:${mi}`;
|
||||
}
|
||||
|
||||
@Entry
|
||||
@Component
|
||||
struct Feedback {
|
||||
@StorageProp('fontScale') fontScaleLevel: FontScaleLevel = 'system';
|
||||
|
||||
private scaleFor(): number {
|
||||
return FONT_SCALE_MAP[this.fontScaleLevel] ?? 1.0;
|
||||
}
|
||||
@State content: string = '';
|
||||
@State contact: string = '';
|
||||
@State selectedUris: Array<string> = [];
|
||||
@State submitting: boolean = false;
|
||||
@State records: Array<FeedbackRecordItem> = [];
|
||||
@State recordsLoading: boolean = true;
|
||||
@State showRecords: boolean = false;
|
||||
private scroller: Scroller = new Scroller();
|
||||
|
||||
aboutToAppear(): void {
|
||||
this.loadRecords();
|
||||
}
|
||||
|
||||
private async loadRecords(): Promise<void> {
|
||||
this.recordsLoading = true;
|
||||
try {
|
||||
const res = await api.get<Page<FeedbackRecordItem>>('/feedback/records/', {
|
||||
page_size: 20,
|
||||
});
|
||||
this.records = res.results;
|
||||
this.showRecords = true;
|
||||
} catch (err) {
|
||||
// 无查看权限(如家属):隐藏记录区,不打扰
|
||||
console.warn('[Feedback] load records failed:', normalizeError(err as BusinessError | Error));
|
||||
this.showRecords = false;
|
||||
} finally {
|
||||
this.recordsLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private pickImages(): void {
|
||||
if (this.selectedUris.length >= MAX_IMAGES) {
|
||||
promptAction.showToast({ message: `最多选择 ${MAX_IMAGES} 张图片` });
|
||||
return;
|
||||
}
|
||||
const options = new photoAccessHelper.PhotoSelectOptions();
|
||||
options.MIMEType = photoAccessHelper.PhotoViewMIMETypes.IMAGE_TYPE;
|
||||
options.maxSelectNumber = MAX_IMAGES - this.selectedUris.length;
|
||||
const picker = new photoAccessHelper.PhotoViewPicker();
|
||||
picker.select(options).then((result: photoAccessHelper.PhotoSelectResult) => {
|
||||
if (result.photoUris && result.photoUris.length > 0) {
|
||||
this.selectedUris = this.selectedUris.concat(result.photoUris);
|
||||
}
|
||||
}).catch((err: BusinessError) => {
|
||||
console.error('[Feedback] pick images failed:', err.message);
|
||||
});
|
||||
}
|
||||
|
||||
private async submit(): Promise<void> {
|
||||
if (this.content.trim().length === 0) {
|
||||
promptAction.showToast({ message: '请输入反馈内容' });
|
||||
return;
|
||||
}
|
||||
if (this.submitting) {
|
||||
return;
|
||||
}
|
||||
this.submitting = true;
|
||||
try {
|
||||
let imagePaths: Array<string> = [];
|
||||
if (this.selectedUris.length > 0) {
|
||||
try {
|
||||
const uploadRes = await uploadMultipartFiles<UploadResponse>(
|
||||
'/feedback/upload-images/', this.selectedUris, 'images');
|
||||
imagePaths = uploadRes.urls;
|
||||
} catch (uploadErr) {
|
||||
// 图片上传失败(如当前账号不支持图片):提示但跳过,仍可提交文字反馈
|
||||
console.warn('[Feedback] upload images failed:', normalizeError(uploadErr as BusinessError | Error));
|
||||
promptAction.showToast({ message: '图片上传失败,将仅提交文字反馈' });
|
||||
this.selectedUris = [];
|
||||
}
|
||||
}
|
||||
const diagnostics: DiagnosticsPayload = {
|
||||
device_model: 'HarmonyOS',
|
||||
system_version: '',
|
||||
app_version: '1.0.0',
|
||||
};
|
||||
const body: FeedbackPayload = { content: this.content.trim() };
|
||||
if (this.contact.trim().length > 0) {
|
||||
body.contact = this.contact.trim();
|
||||
}
|
||||
if (imagePaths.length > 0) {
|
||||
body.images = imagePaths;
|
||||
}
|
||||
body.diagnostics = diagnostics;
|
||||
await api.post('/feedback/records/', body);
|
||||
promptAction.showToast({ message: '反馈已提交,感谢您的建议' });
|
||||
this.content = '';
|
||||
this.contact = '';
|
||||
this.selectedUris = [];
|
||||
await this.loadRecords();
|
||||
} catch (err) {
|
||||
const message = err instanceof RequestError ? err.message : normalizeError(err as BusinessError | Error);
|
||||
promptAction.showToast({ message: message });
|
||||
} finally {
|
||||
this.submitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 状态颜色
|
||||
private statusColor(status: string): ResourceColor {
|
||||
if (status === 'pending') {
|
||||
return '#FF9500';
|
||||
}
|
||||
if (status === 'processing') {
|
||||
return $r('app.color.accent_blue');
|
||||
}
|
||||
return $r('app.color.accent_green');
|
||||
}
|
||||
|
||||
@Builder
|
||||
FeedbackRecordCard(item: FeedbackRecordItem) {
|
||||
Column({ space: 8 }) {
|
||||
Row() {
|
||||
Text(item.status_label || '待处理')
|
||||
.fontSize(Math.round(13 * this.scaleFor()))
|
||||
.fontColor('#FFFFFF')
|
||||
.padding({ left: 10, right: 10, top: 4, bottom: 4 })
|
||||
.backgroundColor(this.statusColor(item.status))
|
||||
.borderRadius(8)
|
||||
Blank()
|
||||
Text(formatRecordTime(item.created_at))
|
||||
.fontSize(Math.round(12 * this.scaleFor()))
|
||||
.fontColor($r('app.color.inactive_tab'))
|
||||
}
|
||||
.width('100%')
|
||||
|
||||
Text(item.content)
|
||||
.fontSize(Math.round(15 * this.scaleFor()))
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.maxLines(3)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
.width('100%')
|
||||
|
||||
if (item.handler_name && item.handler_name.length > 0) {
|
||||
Text(`处理人:${item.handler_name}`)
|
||||
.fontSize(Math.round(13 * this.scaleFor()))
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
.width('100%')
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.padding(16)
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.borderRadius(14)
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
}
|
||||
|
||||
@Builder
|
||||
ImageThumb(uri: string, index: number) {
|
||||
Stack() {
|
||||
Image(uri)
|
||||
.width(72)
|
||||
.height(72)
|
||||
.borderRadius(10)
|
||||
.objectFit(ImageFit.Cover)
|
||||
|
||||
Text('×')
|
||||
.fontSize(Math.round(14 * this.scaleFor()))
|
||||
.fontColor('#FFFFFF')
|
||||
.textAlign(TextAlign.Center)
|
||||
.width(20)
|
||||
.height(20)
|
||||
.backgroundColor('#CC000000')
|
||||
.borderRadius(10)
|
||||
.position({ x: 56, y: -4 })
|
||||
.onClick(() => {
|
||||
const next: Array<string> = [];
|
||||
this.selectedUris.forEach((item: string, idx: number) => {
|
||||
if (idx !== index) {
|
||||
next.push(item);
|
||||
}
|
||||
});
|
||||
this.selectedUris = next;
|
||||
})
|
||||
}
|
||||
.width(72)
|
||||
.height(72)
|
||||
.margin({ right: 8, top: 4 })
|
||||
}
|
||||
|
||||
build() {
|
||||
HdsNavigation() {
|
||||
Column() {
|
||||
Scroll(this.scroller) {
|
||||
Column({ space: 16 }) {
|
||||
// 反馈内容
|
||||
Column({ space: 8 }) {
|
||||
Text('反馈内容 *')
|
||||
.fontSize(Math.round(15 * this.scaleFor()))
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.width('100%')
|
||||
TextArea({ placeholder: '请描述您遇到的问题或建议', text: this.content })
|
||||
.placeholderColor($r('app.color.inactive_tab'))
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.height(140)
|
||||
.padding(14)
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.borderRadius(12)
|
||||
.onChange((value: string) => {
|
||||
this.content = value;
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
|
||||
// 联系方式
|
||||
Column({ space: 8 }) {
|
||||
Text('联系方式')
|
||||
.fontSize(Math.round(15 * this.scaleFor()))
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.width('100%')
|
||||
TextInput({ placeholder: '选填:手机号或微信号', text: this.contact })
|
||||
.placeholderColor($r('app.color.inactive_tab'))
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.height(46)
|
||||
.padding({ left: 14, right: 14 })
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.borderRadius(12)
|
||||
.onChange((value: string) => {
|
||||
this.contact = value;
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
|
||||
// 图片
|
||||
Column({ space: 8 }) {
|
||||
Row() {
|
||||
Text(`反馈图片(${this.selectedUris.length}/${MAX_IMAGES})`)
|
||||
.fontSize(Math.round(15 * this.scaleFor()))
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
Blank()
|
||||
Button('+ 添加')
|
||||
.fontSize(Math.round(13 * this.scaleFor()))
|
||||
.height(30)
|
||||
.fontColor($r('app.color.accent_blue'))
|
||||
.backgroundColor(Color.Transparent)
|
||||
.borderRadius(8)
|
||||
.onClick(() => {
|
||||
this.pickImages();
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
|
||||
if (this.selectedUris.length > 0) {
|
||||
Flex({ wrap: FlexWrap.Wrap }) {
|
||||
ForEach(this.selectedUris, (uri: string, index: number) => {
|
||||
this.ImageThumb(uri, index)
|
||||
}, (uri: string) => uri)
|
||||
}
|
||||
.width('100%')
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
|
||||
// 提交
|
||||
Button(this.submitting ? '提交中...' : '提交反馈')
|
||||
.width('100%')
|
||||
.height(50)
|
||||
.fontSize(Math.round(16 * this.scaleFor()))
|
||||
.fontColor('#FFFFFF')
|
||||
.backgroundColor($r('app.color.accent_blue'))
|
||||
.borderRadius(12)
|
||||
.enabled(!this.submitting)
|
||||
.onClick(() => {
|
||||
this.submit();
|
||||
})
|
||||
|
||||
// 我的反馈记录(仅管理员有查看权限)
|
||||
if (this.showRecords) {
|
||||
Column({ space: 12 }) {
|
||||
Row() {
|
||||
Text('我的反馈记录')
|
||||
.fontSize(Math.round(18 * this.scaleFor()))
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
Blank()
|
||||
Text('刷新')
|
||||
.fontSize(Math.round(13 * this.scaleFor()))
|
||||
.fontColor($r('app.color.accent_blue'))
|
||||
.onClick(() => {
|
||||
this.loadRecords();
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
|
||||
if (this.recordsLoading) {
|
||||
Text('加载中...')
|
||||
.fontSize(Math.round(14 * this.scaleFor()))
|
||||
.fontColor($r('app.color.inactive_tab'))
|
||||
.width('100%')
|
||||
} else if (this.records.length === 0) {
|
||||
Column({ space: 6 }) {
|
||||
Text('暂无反馈记录')
|
||||
.fontSize(Math.round(15 * this.scaleFor()))
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
}
|
||||
.width('100%')
|
||||
.padding(20)
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.borderRadius(14)
|
||||
.alignItems(HorizontalAlign.Center)
|
||||
} else {
|
||||
ForEach(this.records, (item: FeedbackRecordItem) => {
|
||||
Column() {
|
||||
this.FeedbackRecordCard(item)
|
||||
}
|
||||
.width('100%')
|
||||
.margin({ bottom: 10 })
|
||||
}, (item: FeedbackRecordItem) => String(item.id))
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
}
|
||||
}
|
||||
.width('92%')
|
||||
.padding({ top: 60, bottom: 30 })
|
||||
.alignItems(HorizontalAlign.Center)
|
||||
}
|
||||
.scrollBar(BarState.Auto)
|
||||
.edgeEffect(EdgeEffect.Spring)
|
||||
.layoutWeight(1)
|
||||
.width('100%')
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
}
|
||||
.mode(NavigationMode.Stack)
|
||||
.titleBar({
|
||||
content: {
|
||||
title: {
|
||||
mainTitle: '意见反馈',
|
||||
},
|
||||
},
|
||||
style: {
|
||||
scrollEffectOpts: {
|
||||
enableScrollEffect: false,
|
||||
scrollEffectType: ScrollEffectType.GRADIENT_BLUR,
|
||||
},
|
||||
systemMaterialEffect: {
|
||||
materialType: hdsMaterial.MaterialType.ADAPTIVE,
|
||||
materialLevel: hdsMaterial.MaterialLevel.EXQUISITE,
|
||||
},
|
||||
},
|
||||
avoidLayoutSafeArea: false,
|
||||
enableComponentSafeArea: false,
|
||||
})
|
||||
.bindToScrollable([this.scroller])
|
||||
.hideBackButton(false)
|
||||
.titleMode(HdsNavigationTitleMode.MINI)
|
||||
.ignoreLayoutSafeArea([LayoutSafeAreaType.SYSTEM], [LayoutSafeAreaEdge.TOP, LayoutSafeAreaEdge.BOTTOM])
|
||||
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
|
||||
.backgroundColor($r('app.color.background'))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
// 我的页签(对齐小程序 profile:用户信息 + 菜单 + 退出登录,ArkTS 严格类型,无 any)
|
||||
import { router, promptAction, uiMaterial } from '@kit.ArkUI';
|
||||
import { common } from '@kit.AbilityKit';
|
||||
import { pasteboard } from '@kit.BasicServicesKit';
|
||||
import { UnifiedRole, ROLE_NAME_MAP } from '../../common/ApiConstants';
|
||||
import { AuthStore } from '../../utils/AuthStore';
|
||||
import { PushManager } from '../../utils/PushManager';
|
||||
import { FontScaleLevel, FontScaleOption, FONT_SCALE_LABELS, loadFontScale, setFontScale } from '../../utils/FontScale';
|
||||
|
||||
interface ProfileMenu {
|
||||
key: string;
|
||||
title: string;
|
||||
desc: string;
|
||||
route: string; // 空字符串表示未实现
|
||||
}
|
||||
|
||||
@Component
|
||||
export struct ProfileTab {
|
||||
@State userName: string = '';
|
||||
@State role: UnifiedRole | null = null;
|
||||
@State isLoggedIn: boolean = false;
|
||||
@State pushToken: string = '';
|
||||
@State fontScale: FontScaleLevel = 'system';
|
||||
@StorageProp('fontScale') fontScaleLevel: FontScaleLevel = 'system';
|
||||
|
||||
menus: Array<ProfileMenu> = [
|
||||
{ key: 'notifications', title: '消息通知', desc: '系统与预警通知', route: 'pages/notifications/Notifications' },
|
||||
{ key: 'records', title: '我的记录', desc: '我提交过的护理记录', route: 'pages/nurse/History' },
|
||||
{ key: 'feedback', title: '意见反馈', desc: '提交使用建议', route: 'pages/profile/Feedback' },
|
||||
{ key: 'about', title: '关于应用', desc: '版本与说明', route: 'pages/profile/About' },
|
||||
];
|
||||
|
||||
// 按角色过滤"我的"菜单:消防的记录是巡更记录,销售无记录入口
|
||||
private visibleMenus(): Array<ProfileMenu> {
|
||||
const role = this.role;
|
||||
if (role === 'fire_control') {
|
||||
return this.menus.map((m: ProfileMenu): ProfileMenu => {
|
||||
if (m.key === 'records') {
|
||||
return { key: 'records_fire', title: '我的巡更记录', desc: '我提交过的巡更打卡', route: 'pages/fire-control/FirePatrolHome' } as ProfileMenu;
|
||||
}
|
||||
return m;
|
||||
});
|
||||
}
|
||||
if (role === 'sales') {
|
||||
return this.menus.filter((m: ProfileMenu): boolean => m.key !== 'records');
|
||||
}
|
||||
return this.menus;
|
||||
}
|
||||
|
||||
aboutToAppear(): void {
|
||||
this.loadAuth();
|
||||
}
|
||||
|
||||
private async loadAuth(): Promise<void> {
|
||||
const state = await AuthStore.getState();
|
||||
this.userName = state.userName;
|
||||
this.role = state.role;
|
||||
this.isLoggedIn = state.token.length > 0;
|
||||
this.pushToken = PushManager.getToken();
|
||||
loadFontScale(getContext(this) as common.UIAbilityContext).then((level: FontScaleLevel) => {
|
||||
this.fontScale = level;
|
||||
});
|
||||
}
|
||||
|
||||
private changeFontScale(level: FontScaleLevel): void {
|
||||
this.fontScale = level;
|
||||
setFontScale(getContext(this) as common.UIAbilityContext, level);
|
||||
const label = FONT_SCALE_LABELS.find((item: FontScaleOption) => item.level === level)?.label ?? '';
|
||||
promptAction.showToast({ message: `字体已调整为${label}` });
|
||||
}
|
||||
|
||||
private copyPushToken(): void {
|
||||
if (this.pushToken.length === 0) {
|
||||
promptAction.showToast({ message: '推送令牌为空(未获取成功)' });
|
||||
return;
|
||||
}
|
||||
const data = pasteboard.createData(pasteboard.MIMETYPE_TEXT_PLAIN, this.pushToken);
|
||||
pasteboard.getSystemPasteboard().setData(data).then(() => {
|
||||
promptAction.showToast({ message: '推送令牌已复制' });
|
||||
}).catch(() => {
|
||||
promptAction.showToast({ message: '复制失败' });
|
||||
});
|
||||
}
|
||||
|
||||
private onMenuClick(menu: ProfileMenu): void {
|
||||
if (menu.route.length === 0) {
|
||||
promptAction.showToast({ message: '功能开发中' });
|
||||
return;
|
||||
}
|
||||
router.pushUrl({ url: menu.route }).catch(() => {});
|
||||
}
|
||||
|
||||
private async logout(): Promise<void> {
|
||||
await AuthStore.logout();
|
||||
this.isLoggedIn = false;
|
||||
promptAction.showToast({ message: '已退出登录' });
|
||||
router.replaceUrl({ url: 'pages/Login' }).catch(() => {});
|
||||
}
|
||||
|
||||
private roleName(): string {
|
||||
if (!this.isLoggedIn) {
|
||||
return '未登录';
|
||||
}
|
||||
return this.role ? (ROLE_NAME_MAP[this.role] ?? '员工') : '员工';
|
||||
}
|
||||
|
||||
@Builder
|
||||
MenuRow(menu: ProfileMenu) {
|
||||
Row({ space: 12 }) {
|
||||
Column({ space: 4 }) {
|
||||
Text(menu.title)
|
||||
.fontSize(16)
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
Text(menu.desc)
|
||||
.fontSize(12)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
}
|
||||
.layoutWeight(1)
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
|
||||
Text('›')
|
||||
.fontSize(22)
|
||||
.fontColor($r('app.color.inactive_tab'))
|
||||
}
|
||||
.width('100%')
|
||||
.padding(16)
|
||||
.backgroundColor(Color.Transparent)
|
||||
.backgroundBlurStyle(BlurStyle.Thin)
|
||||
.borderRadius(14)
|
||||
.onClick(() => {
|
||||
this.onMenuClick(menu);
|
||||
})
|
||||
}
|
||||
|
||||
build() {
|
||||
Scroll() {
|
||||
Column({ space: 16 }) {
|
||||
// 用户信息卡
|
||||
Row({ space: 14 }) {
|
||||
Stack() {
|
||||
Circle()
|
||||
.width(60)
|
||||
.height(60)
|
||||
.fill(this.isLoggedIn ? $r('app.color.accent_blue') : $r('app.color.inactive_tab'))
|
||||
Text(this.isLoggedIn && this.userName.length > 0 ? this.userName.substring(0, 1) : '未')
|
||||
.fontSize(22)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor('#FFFFFF')
|
||||
}
|
||||
.width(60)
|
||||
.height(60)
|
||||
|
||||
Column({ space: 4 }) {
|
||||
Text(this.isLoggedIn ? this.userName : '未登录')
|
||||
.fontSize(20)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
Text(this.roleName())
|
||||
.fontSize(13)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
}
|
||||
.layoutWeight(1)
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
}
|
||||
.width('92%')
|
||||
.padding(20)
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.borderRadius(16)
|
||||
.margin({ top: 24 })
|
||||
|
||||
// 推送令牌(调试用)
|
||||
Column({ space: 6 }) {
|
||||
Text('推送令牌(点按复制)')
|
||||
.fontSize(14)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
Text(this.pushToken.length > 0 ? this.pushToken : '未获取到推送令牌')
|
||||
.fontSize(11)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.maxLines(3)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
.width('100%')
|
||||
}
|
||||
.width('92%')
|
||||
.padding(16)
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.borderRadius(14)
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
.onClick(() => {
|
||||
this.copyPushToken();
|
||||
})
|
||||
|
||||
// 字体大小(家属)
|
||||
if (this.role === 'family') {
|
||||
Column({ space: 8 }) {
|
||||
Text('字体大小')
|
||||
.fontSize(15)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.width('100%')
|
||||
Flex({ wrap: FlexWrap.Wrap, alignItems: ItemAlign.Center }) {
|
||||
ForEach(FONT_SCALE_LABELS, (item: FontScaleOption) => {
|
||||
Text(item.label)
|
||||
.fontSize(15)
|
||||
.fontColor(this.fontScale === item.level ? '#FFFFFF' : $r('app.color.text_primary'))
|
||||
.padding({ left: 18, right: 18, top: 10, bottom: 10 })
|
||||
.backgroundColor(this.fontScale === item.level ? $r('app.color.accent_blue') : $r('app.color.background_card'))
|
||||
.borderRadius(10)
|
||||
.margin({ right: 10, bottom: 10 })
|
||||
.onClick(() => {
|
||||
this.changeFontScale(item.level);
|
||||
})
|
||||
}, (item: FontScaleOption) => item.level)
|
||||
}
|
||||
.width('100%')
|
||||
}
|
||||
.width('92%')
|
||||
.padding(16)
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.borderRadius(14)
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
}
|
||||
|
||||
// 菜单列表
|
||||
Column({ space: 8 }) {
|
||||
ForEach(this.visibleMenus(), (menu: ProfileMenu) => {
|
||||
this.MenuRow(menu)
|
||||
}, (menu: ProfileMenu) => menu.key)
|
||||
}
|
||||
.width('92%')
|
||||
|
||||
// 登录/退出
|
||||
if (this.isLoggedIn) {
|
||||
Button('退出登录')
|
||||
.width('92%')
|
||||
.height(48)
|
||||
.fontColor('#FFFFFF')
|
||||
.backgroundColor($r('app.color.accent_red'))
|
||||
.borderRadius(12)
|
||||
.onClick(() => {
|
||||
this.logout();
|
||||
})
|
||||
} else {
|
||||
Button('去登录')
|
||||
.width('92%')
|
||||
.height(48)
|
||||
.fontColor('#FFFFFF')
|
||||
.backgroundColor($r('app.color.accent_blue'))
|
||||
.borderRadius(12)
|
||||
.onClick(() => {
|
||||
router.pushUrl({ url: 'pages/Login' }).catch(() => {});
|
||||
})
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(HorizontalAlign.Center)
|
||||
.padding({ bottom: 120 })
|
||||
}
|
||||
.scrollBar(BarState.Auto)
|
||||
.edgeEffect(EdgeEffect.Spring)
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,994 @@
|
||||
// 销售 · 工作台(ArkTS 严格类型,无 any)
|
||||
// 接口:GET /market/leads/、/market/contracts/;PATCH /market/leads/{id}/;POST /market/leads/
|
||||
import { promptAction, router } from '@kit.ArkUI';
|
||||
import { HdsNavigation, HdsNavigationTitleMode, ScrollEffectType } from '@kit.UIDesignKit';
|
||||
import { hdsMaterial } from '@hms.hds.hdsMaterial';
|
||||
import { BusinessError } from '@kit.BasicServicesKit';
|
||||
import { Page, normalizeError } from '../../common/ApiConstants';
|
||||
import { api, RequestError } from '../../utils/Request';
|
||||
|
||||
interface SalesLead {
|
||||
id: number;
|
||||
name: string;
|
||||
gender: string;
|
||||
age: number | null;
|
||||
contact_method: string;
|
||||
status: string;
|
||||
deal_amount: number | null;
|
||||
current_sales_name: string;
|
||||
latest_note: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface Contract {
|
||||
id: number;
|
||||
customer_name: string;
|
||||
sales_name: string;
|
||||
contract_amount: number | null;
|
||||
contract_code: string;
|
||||
contract_date_range: string;
|
||||
signed_date: string;
|
||||
}
|
||||
|
||||
interface FollowPayload {
|
||||
status: string;
|
||||
latest_note?: string;
|
||||
deal_amount?: string;
|
||||
}
|
||||
|
||||
interface NewLeadPayload {
|
||||
name: string;
|
||||
visit_date: string;
|
||||
gender: string;
|
||||
age: number | null;
|
||||
contact_method: string;
|
||||
id_card_number: string;
|
||||
original_follower: string;
|
||||
is_elderly_consultation: boolean;
|
||||
is_key_person: boolean | null;
|
||||
information_sources: Array<string>;
|
||||
status: string;
|
||||
deal_amount: string | null;
|
||||
latest_note: string;
|
||||
last_follow_up_at: string;
|
||||
}
|
||||
|
||||
const GENDER_OPTIONS: Array<string> = ['unknown', 'male', 'female'];
|
||||
const KEY_PERSON_OPTIONS: Array<string> = ['', 'yes', 'no'];
|
||||
const SOURCE_OPTIONS: Array<string> = ['美团', '活动', '摊位', '单张', '路过', '朋友', '小红书', '户外广告', '其他'];
|
||||
const LEAD_STATUS_OPTIONS: Array<string> = ['pending', 'in_progress', 'won', 'lost'];
|
||||
|
||||
function statusLabel(status: string): string {
|
||||
if (status === 'won') {
|
||||
return '成交';
|
||||
}
|
||||
if (status === 'in_progress') {
|
||||
return '跟进中';
|
||||
}
|
||||
if (status === 'lost') {
|
||||
return '失败';
|
||||
}
|
||||
return '待跟进';
|
||||
}
|
||||
|
||||
function statusColor(status: string): string {
|
||||
if (status === 'won') {
|
||||
return '#34C759';
|
||||
}
|
||||
if (status === 'in_progress') {
|
||||
return '#007AFF';
|
||||
}
|
||||
if (status === 'lost') {
|
||||
return '#8E8E93';
|
||||
}
|
||||
return '#FF9500';
|
||||
}
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
if (!iso) {
|
||||
return '--';
|
||||
}
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) {
|
||||
return '--';
|
||||
}
|
||||
const mm = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const dd = String(d.getDate()).padStart(2, '0');
|
||||
return `${mm}/${dd}`;
|
||||
}
|
||||
|
||||
function amountText(a: number | null): string {
|
||||
return formatAmount(a);
|
||||
}
|
||||
|
||||
// 金额规范化:去前导零、最多两位小数、千分位分组(114514.00 → ¥114,514)
|
||||
export function formatAmount(a: string | number | null | undefined): string {
|
||||
if (a === null || a === undefined || a === '') {
|
||||
return '--';
|
||||
}
|
||||
const n = Number(a);
|
||||
if (Number.isNaN(n) || !Number.isFinite(n)) {
|
||||
return `¥${a}`;
|
||||
}
|
||||
const abs = Math.abs(n);
|
||||
const fixed = Number.isInteger(abs) ? abs.toFixed(0) : abs.toFixed(2);
|
||||
const parts = fixed.split('.');
|
||||
let grouped = '';
|
||||
const intPart = parts[0];
|
||||
for (let i = 0; i < intPart.length; i++) {
|
||||
const idxFromEnd = intPart.length - i;
|
||||
grouped += intPart[i];
|
||||
if (idxFromEnd > 1 && idxFromEnd % 3 === 1) {
|
||||
grouped += ',';
|
||||
}
|
||||
}
|
||||
const dec = parts.length > 1 ? `.${parts[1]}` : '';
|
||||
return `${n < 0 ? '-' : ''}¥${grouped}${dec}`;
|
||||
}
|
||||
|
||||
// 金额输入规范化:'0114514.00' → '114514'、'01.5' → '1.5';非法输入返回空串
|
||||
export function normalizeAmountInput(raw: string): string {
|
||||
const n = Number(raw);
|
||||
if (raw.length === 0 || Number.isNaN(n) || !Number.isFinite(n) || n < 0) {
|
||||
return '';
|
||||
}
|
||||
return String(n);
|
||||
}
|
||||
|
||||
function genderLabel(g: string): string {
|
||||
if (g === 'male') {
|
||||
return '男';
|
||||
}
|
||||
if (g === 'female') {
|
||||
return '女';
|
||||
}
|
||||
return '未知';
|
||||
}
|
||||
|
||||
function keyPersonLabel(k: string): string {
|
||||
if (k === 'yes') {
|
||||
return '是';
|
||||
}
|
||||
if (k === 'no') {
|
||||
return '否';
|
||||
}
|
||||
return '未填写';
|
||||
}
|
||||
|
||||
@Entry
|
||||
@Component
|
||||
struct SalesHome {
|
||||
@State leads: Array<SalesLead> = [];
|
||||
@State contracts: Array<Contract> = [];
|
||||
@State loading: boolean = true;
|
||||
@State showSheet: boolean = false;
|
||||
@State sheetMode: string = 'new';
|
||||
@State followTarget: SalesLead | null = null;
|
||||
@State newName: string = '';
|
||||
@State newPhone: string = '';
|
||||
@State noteText: string = '';
|
||||
@State followStatus: string = 'in_progress';
|
||||
@State sheetDealAmount: string = '';
|
||||
@State submitting: boolean = false;
|
||||
// 新建线索字段(对齐 Web)
|
||||
@State visitDate: string = '';
|
||||
@State gender: string = 'unknown';
|
||||
@State ageText: string = '';
|
||||
@State idCard: string = '';
|
||||
@State originalFollower: string = '';
|
||||
@State isElderlyConsultation: boolean = false;
|
||||
@State isKeyPerson: string = '';
|
||||
@State informationSources: Array<string> = [];
|
||||
@State dealAmount: string = '';
|
||||
@State lastFollowUpAt: string = '';
|
||||
private scroller: Scroller = new Scroller();
|
||||
|
||||
aboutToAppear(): void {
|
||||
this.load();
|
||||
}
|
||||
|
||||
private async load(): Promise<void> {
|
||||
this.loading = true;
|
||||
try {
|
||||
const results: Array<Page<SalesLead> | Page<Contract> | null> = await Promise.all([
|
||||
api.get<Page<SalesLead>>('/market/leads/', { page_size: 50, ordering: '-created_at' }).catch((): Page<SalesLead> | null => null),
|
||||
api.get<Page<Contract>>('/market/contracts/', { page_size: 50 }).catch((): Page<Contract> | null => null),
|
||||
]);
|
||||
const l = results[0] as Page<SalesLead> | null;
|
||||
const c = results[1] as Page<Contract> | null;
|
||||
this.leads = l ? l.results : [];
|
||||
this.contracts = c ? c.results : [];
|
||||
console.info(`[SalesHome] leads=${this.leads.length} contracts=${this.contracts.length} rawL=${l ? 'ok' : 'null'} rawC=${c ? 'ok' : 'null'}`);
|
||||
} catch (err) {
|
||||
const message = err instanceof RequestError ? err.message : normalizeError(err as BusinessError | Error);
|
||||
promptAction.showToast({ message: message });
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private totalLeads(): number {
|
||||
return this.leads.length;
|
||||
}
|
||||
private pendingCount(): number {
|
||||
return this.leads.filter((l: SalesLead) => l.status === 'pending').length;
|
||||
}
|
||||
private wonCount(): number {
|
||||
return this.leads.filter((l: SalesLead) => l.status === 'won').length;
|
||||
}
|
||||
private wonAmount(): number {
|
||||
let sum = 0;
|
||||
this.leads.forEach((l: SalesLead) => {
|
||||
if (l.status === 'won' && l.deal_amount !== null && l.deal_amount !== undefined) {
|
||||
// 后端 deal_amount 可能是字符串 "114514.00",必须转数字再累加,否则字符串拼接
|
||||
sum += Number(l.deal_amount);
|
||||
}
|
||||
});
|
||||
return sum;
|
||||
}
|
||||
|
||||
private openNewLead(): void {
|
||||
this.newName = '';
|
||||
this.newPhone = '';
|
||||
this.visitDate = '';
|
||||
this.gender = 'unknown';
|
||||
this.ageText = '';
|
||||
this.idCard = '';
|
||||
this.originalFollower = '';
|
||||
this.isElderlyConsultation = false;
|
||||
this.isKeyPerson = '';
|
||||
this.informationSources = [];
|
||||
this.dealAmount = '';
|
||||
this.lastFollowUpAt = '';
|
||||
this.noteText = '';
|
||||
this.followStatus = 'pending';
|
||||
this.sheetMode = 'new';
|
||||
this.showSheet = true;
|
||||
}
|
||||
|
||||
private keyPersonValue(): boolean | null {
|
||||
if (this.isKeyPerson === 'yes') {
|
||||
return true;
|
||||
}
|
||||
if (this.isKeyPerson === 'no') {
|
||||
return false;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private toggleSource(source: string): void {
|
||||
if (this.informationSources.indexOf(source) >= 0) {
|
||||
this.informationSources = this.informationSources.filter((s: string) => s !== source);
|
||||
} else {
|
||||
this.informationSources = this.informationSources.concat([source]);
|
||||
}
|
||||
}
|
||||
|
||||
private pickVisitDate(): void {
|
||||
DatePickerDialog.show({
|
||||
start: new Date('2020-01-01'),
|
||||
end: new Date('2035-12-31'),
|
||||
selected: new Date(),
|
||||
onDateAccept: (value: Date) => {
|
||||
const mm = String(value.getMonth() + 1).padStart(2, '0');
|
||||
const dd = String(value.getDate()).padStart(2, '0');
|
||||
this.visitDate = `${value.getFullYear()}-${mm}-${dd}`;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private pickLastFollowUp(): void {
|
||||
DatePickerDialog.show({
|
||||
start: new Date('2020-01-01'),
|
||||
end: new Date('2035-12-31'),
|
||||
selected: new Date(),
|
||||
onDateAccept: (value: Date) => {
|
||||
const mm = String(value.getMonth() + 1).padStart(2, '0');
|
||||
const dd = String(value.getDate()).padStart(2, '0');
|
||||
const hh = String(value.getHours()).padStart(2, '0');
|
||||
const mi = String(value.getMinutes()).padStart(2, '0');
|
||||
this.lastFollowUpAt = `${value.getFullYear()}-${mm}-${dd}T${hh}:${mi}:00`;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private async submitNewLead(): Promise<void> {
|
||||
if (this.submitting) {
|
||||
return;
|
||||
}
|
||||
if (this.newName.trim().length === 0) {
|
||||
promptAction.showToast({ message: '请输入客户姓名' });
|
||||
return;
|
||||
}
|
||||
if (this.isElderlyConsultation && this.idCard.trim().length === 0) {
|
||||
promptAction.showToast({ message: '长者咨询需填写身份证号' });
|
||||
return;
|
||||
}
|
||||
this.submitting = true;
|
||||
try {
|
||||
const payload: NewLeadPayload = {
|
||||
name: this.newName.trim(),
|
||||
visit_date: this.visitDate,
|
||||
gender: this.gender,
|
||||
age: this.ageText.trim().length > 0 ? Number(this.ageText.trim()) : null,
|
||||
contact_method: this.newPhone.trim(),
|
||||
id_card_number: this.idCard.trim(),
|
||||
original_follower: this.originalFollower.trim(),
|
||||
is_elderly_consultation: this.isElderlyConsultation,
|
||||
is_key_person: this.keyPersonValue(),
|
||||
information_sources: this.informationSources,
|
||||
status: this.followStatus,
|
||||
deal_amount: this.followStatus === 'won' ? normalizeAmountInput(this.dealAmount.trim()) : null,
|
||||
latest_note: this.noteText.trim(),
|
||||
last_follow_up_at: this.lastFollowUpAt,
|
||||
};
|
||||
await api.post('/market/leads/', payload);
|
||||
this.showSheet = false;
|
||||
promptAction.showToast({ message: '线索已创建' });
|
||||
await this.load();
|
||||
} catch (err) {
|
||||
const message = err instanceof RequestError ? err.message : normalizeError(err as BusinessError | Error);
|
||||
promptAction.showToast({ message: message });
|
||||
} finally {
|
||||
this.submitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
private openFollow(lead: SalesLead): void {
|
||||
this.followTarget = lead;
|
||||
this.noteText = '';
|
||||
this.followStatus = lead.status;
|
||||
this.sheetDealAmount = lead.status === 'won' && lead.deal_amount !== null && lead.deal_amount !== undefined
|
||||
? normalizeAmountInput(String(lead.deal_amount))
|
||||
: '';
|
||||
this.sheetMode = 'follow';
|
||||
this.showSheet = true;
|
||||
}
|
||||
|
||||
private async submitFollow(): Promise<void> {
|
||||
if (this.submitting || this.followTarget === null) {
|
||||
return;
|
||||
}
|
||||
if (this.followStatus === 'won' && normalizeAmountInput(this.sheetDealAmount.trim()).length === 0) {
|
||||
promptAction.showToast({ message: '标记成交请填写成交金额' });
|
||||
return;
|
||||
}
|
||||
this.submitting = true;
|
||||
const id = this.followTarget.id;
|
||||
try {
|
||||
const body: FollowPayload = {
|
||||
status: this.followStatus,
|
||||
};
|
||||
if (this.noteText.trim().length > 0) {
|
||||
body.latest_note = this.noteText.trim();
|
||||
}
|
||||
if (this.followStatus === 'won') {
|
||||
body.deal_amount = normalizeAmountInput(this.sheetDealAmount.trim());
|
||||
}
|
||||
await api.patch(`/market/leads/${id}/`, body);
|
||||
this.showSheet = false;
|
||||
promptAction.showToast({ message: '已更新' });
|
||||
await this.load();
|
||||
} catch (err) {
|
||||
const message = err instanceof RequestError ? err.message : normalizeError(err as BusinessError | Error);
|
||||
promptAction.showToast({ message: message });
|
||||
} finally {
|
||||
this.submitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
@Builder
|
||||
LeadCard(lead: SalesLead) {
|
||||
Column({ space: 6 }) {
|
||||
Row({ space: 8 }) {
|
||||
Text(lead.name)
|
||||
.fontSize(16)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
if (lead.age !== null && lead.age !== undefined) {
|
||||
Text(`${lead.age}岁`)
|
||||
.fontSize(13)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
}
|
||||
Blank()
|
||||
Text(statusLabel(lead.status))
|
||||
.fontSize(12)
|
||||
.fontColor(statusColor(lead.status))
|
||||
}
|
||||
.width('100%')
|
||||
|
||||
if (lead.contact_method && lead.contact_method.length > 0) {
|
||||
Text(`📞 ${lead.contact_method}`)
|
||||
.fontSize(13)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
.width('100%')
|
||||
}
|
||||
if (lead.latest_note && lead.latest_note.length > 0) {
|
||||
Text(lead.latest_note)
|
||||
.fontSize(13)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
.maxLines(1)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
.width('100%')
|
||||
}
|
||||
|
||||
Row({ space: 10 }) {
|
||||
Text(amountText(lead.deal_amount))
|
||||
.fontSize(13)
|
||||
.fontColor('#FF9500')
|
||||
Blank()
|
||||
Button('详情')
|
||||
.height(30)
|
||||
.fontSize(12)
|
||||
.fontColor($r('app.color.accent_blue'))
|
||||
.backgroundColor($r('app.color.background'))
|
||||
.borderRadius(8)
|
||||
.onClick(() => {
|
||||
router.pushUrl({
|
||||
url: 'pages/sales/SalesLeadDetail',
|
||||
params: { leadId: lead.id },
|
||||
}).catch((err: BusinessError) => {
|
||||
promptAction.showToast({ message: `页面打开失败:${normalizeError(err)}` });
|
||||
});
|
||||
})
|
||||
Button('跟进')
|
||||
.height(30)
|
||||
.fontSize(12)
|
||||
.fontColor('#FFFFFF')
|
||||
.backgroundColor($r('app.color.accent_blue'))
|
||||
.borderRadius(8)
|
||||
.onClick(() => {
|
||||
this.openFollow(lead);
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
}
|
||||
.width('100%')
|
||||
.padding(14)
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.borderRadius(12)
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
}
|
||||
|
||||
@Builder
|
||||
ContractCard(c: Contract) {
|
||||
Column({ space: 6 }) {
|
||||
Row({ space: 8 }) {
|
||||
Text(c.customer_name)
|
||||
.fontSize(16)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
Blank()
|
||||
Text(amountText(c.contract_amount))
|
||||
.fontSize(14)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor('#FF9500')
|
||||
}
|
||||
.width('100%')
|
||||
if (c.contract_code && c.contract_code.length > 0) {
|
||||
Text(c.contract_code)
|
||||
.fontSize(12)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
.width('100%')
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.padding(14)
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.borderRadius(12)
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
}
|
||||
|
||||
@Builder
|
||||
FieldInput(label: string, text: string, onChangeCb: (v: string) => void, type: string = '') {
|
||||
Row({ space: 12 }) {
|
||||
Text(label)
|
||||
.fontSize(14)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
.width(88)
|
||||
TextInput({ placeholder: label, text: text })
|
||||
.placeholderColor($r('app.color.inactive_tab'))
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.fontSize(15)
|
||||
.height(44)
|
||||
.layoutWeight(1)
|
||||
.padding({ left: 12, right: 12 })
|
||||
.backgroundColor($r('app.color.background'))
|
||||
.borderRadius(10)
|
||||
.type(type === 'number' ? InputType.Number : (type === 'phone' ? InputType.PhoneNumber : InputType.Normal))
|
||||
.onChange(onChangeCb)
|
||||
}
|
||||
.width('100%')
|
||||
.padding(12)
|
||||
.backgroundColor($r('app.color.background'))
|
||||
.borderRadius(10)
|
||||
}
|
||||
|
||||
@Builder
|
||||
SheetContent() {
|
||||
if (this.sheetMode === 'new') {
|
||||
Scroll() {
|
||||
Column({ space: 10 }) {
|
||||
Text('新建线索')
|
||||
.fontSize(18)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.width('100%')
|
||||
|
||||
// 来访日期
|
||||
Row({ space: 12 }) {
|
||||
Text('来访日期')
|
||||
.fontSize(14)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
.width(88)
|
||||
Text(this.visitDate.length > 0 ? this.visitDate : '选择日期')
|
||||
.fontSize(15)
|
||||
.fontColor(this.visitDate.length > 0 ? $r('app.color.text_primary') : $r('app.color.inactive_tab'))
|
||||
.layoutWeight(1)
|
||||
Text('›')
|
||||
.fontSize(18)
|
||||
.fontColor($r('app.color.inactive_tab'))
|
||||
}
|
||||
.width('100%')
|
||||
.padding(12)
|
||||
.backgroundColor($r('app.color.background'))
|
||||
.borderRadius(10)
|
||||
.onClick(() => {
|
||||
this.pickVisitDate();
|
||||
})
|
||||
|
||||
this.FieldInput('姓名', this.newName, (v: string) => { this.newName = v; })
|
||||
|
||||
// 性别
|
||||
Row({ space: 12 }) {
|
||||
Text('性别')
|
||||
.fontSize(14)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
.width(88)
|
||||
Row({ space: 8 }) {
|
||||
ForEach(GENDER_OPTIONS, (g: string) => {
|
||||
Text(genderLabel(g))
|
||||
.fontSize(13)
|
||||
.fontColor(this.gender === g ? '#FFFFFF' : $r('app.color.text_primary'))
|
||||
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
|
||||
.backgroundColor(this.gender === g ? $r('app.color.accent_blue') : $r('app.color.background_card'))
|
||||
.borderRadius(8)
|
||||
.onClick(() => {
|
||||
this.gender = g;
|
||||
})
|
||||
}, (g: string) => g)
|
||||
}
|
||||
.layoutWeight(1)
|
||||
}
|
||||
.width('100%')
|
||||
.padding(12)
|
||||
.backgroundColor($r('app.color.background'))
|
||||
.borderRadius(10)
|
||||
|
||||
this.FieldInput('年龄', this.ageText, (v: string) => { this.ageText = v; }, 'number')
|
||||
this.FieldInput('联系方式', this.newPhone, (v: string) => { this.newPhone = v; }, 'phone')
|
||||
this.FieldInput('身份证号', this.idCard, (v: string) => { this.idCard = v; })
|
||||
this.FieldInput('原表跟进人', this.originalFollower, (v: string) => { this.originalFollower = v; })
|
||||
|
||||
// 是否为长者咨询
|
||||
Row({ space: 12 }) {
|
||||
Text('长者咨询')
|
||||
.fontSize(14)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
.width(88)
|
||||
Row({ space: 8 }) {
|
||||
ForEach([false, true], (v: boolean) => {
|
||||
Text(v ? '是' : '否')
|
||||
.fontSize(13)
|
||||
.fontColor(this.isElderlyConsultation === v ? '#FFFFFF' : $r('app.color.text_primary'))
|
||||
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
|
||||
.backgroundColor(this.isElderlyConsultation === v ? $r('app.color.accent_blue') : $r('app.color.background_card'))
|
||||
.borderRadius(8)
|
||||
.onClick(() => {
|
||||
this.isElderlyConsultation = v;
|
||||
})
|
||||
}, (v: boolean) => String(v))
|
||||
}
|
||||
.layoutWeight(1)
|
||||
}
|
||||
.width('100%')
|
||||
.padding(12)
|
||||
.backgroundColor($r('app.color.background'))
|
||||
.borderRadius(10)
|
||||
|
||||
// 是否重点关注
|
||||
Row({ space: 12 }) {
|
||||
Text('重点关注')
|
||||
.fontSize(14)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
.width(88)
|
||||
Row({ space: 8 }) {
|
||||
ForEach(KEY_PERSON_OPTIONS, (k: string) => {
|
||||
Text(keyPersonLabel(k))
|
||||
.fontSize(13)
|
||||
.fontColor(this.isKeyPerson === k ? '#FFFFFF' : $r('app.color.text_primary'))
|
||||
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
|
||||
.backgroundColor(this.isKeyPerson === k ? $r('app.color.accent_blue') : $r('app.color.background_card'))
|
||||
.borderRadius(8)
|
||||
.onClick(() => {
|
||||
this.isKeyPerson = k;
|
||||
})
|
||||
}, (k: string) => k)
|
||||
}
|
||||
.layoutWeight(1)
|
||||
}
|
||||
.width('100%')
|
||||
.padding(12)
|
||||
.backgroundColor($r('app.color.background'))
|
||||
.borderRadius(10)
|
||||
|
||||
// 信息来源(多选)
|
||||
Column({ space: 8 }) {
|
||||
Text('信息来源')
|
||||
.fontSize(14)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
.width('100%')
|
||||
Flex({ wrap: FlexWrap.Wrap }) {
|
||||
ForEach(SOURCE_OPTIONS, (s: string) => {
|
||||
Text(s)
|
||||
.fontSize(13)
|
||||
.fontColor(this.informationSources.indexOf(s) >= 0 ? '#FFFFFF' : $r('app.color.text_primary'))
|
||||
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
|
||||
.backgroundColor(this.informationSources.indexOf(s) >= 0 ? $r('app.color.accent_blue') : $r('app.color.background_card'))
|
||||
.borderRadius(8)
|
||||
.margin({ right: 8, bottom: 8 })
|
||||
.onClick(() => {
|
||||
this.toggleSource(s);
|
||||
})
|
||||
}, (s: string) => s)
|
||||
}
|
||||
.width('100%')
|
||||
}
|
||||
.width('100%')
|
||||
.padding(12)
|
||||
.backgroundColor($r('app.color.background'))
|
||||
.borderRadius(10)
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
|
||||
// 状态
|
||||
Row({ space: 12 }) {
|
||||
Text('状态')
|
||||
.fontSize(14)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
.width(88)
|
||||
Flex({ wrap: FlexWrap.Wrap }) {
|
||||
ForEach(LEAD_STATUS_OPTIONS, (s: string) => {
|
||||
Text(statusLabel(s))
|
||||
.fontSize(13)
|
||||
.fontColor(this.followStatus === s ? '#FFFFFF' : $r('app.color.text_primary'))
|
||||
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
|
||||
.backgroundColor(this.followStatus === s ? $r('app.color.accent_blue') : $r('app.color.background_card'))
|
||||
.borderRadius(8)
|
||||
.margin({ right: 8, bottom: 8 })
|
||||
.onClick(() => {
|
||||
this.followStatus = s;
|
||||
})
|
||||
}, (s: string) => s)
|
||||
}
|
||||
.layoutWeight(1)
|
||||
}
|
||||
.width('100%')
|
||||
.padding(12)
|
||||
.backgroundColor($r('app.color.background'))
|
||||
.borderRadius(10)
|
||||
|
||||
if (this.followStatus === 'won') {
|
||||
this.FieldInput('成交金额', this.dealAmount, (v: string) => { this.dealAmount = v; }, 'number')
|
||||
}
|
||||
|
||||
// 备注
|
||||
Column({ space: 8 }) {
|
||||
Text('备注')
|
||||
.fontSize(14)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
.width('100%')
|
||||
TextArea({ placeholder: '填写最新跟进情况', text: this.noteText })
|
||||
.placeholderColor($r('app.color.inactive_tab'))
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.height(70)
|
||||
.padding(12)
|
||||
.backgroundColor($r('app.color.background'))
|
||||
.borderRadius(10)
|
||||
.onChange((value: string) => {
|
||||
this.noteText = value;
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
.padding(12)
|
||||
.backgroundColor($r('app.color.background'))
|
||||
.borderRadius(10)
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
|
||||
// 最后跟进时间
|
||||
Row({ space: 12 }) {
|
||||
Text('最后跟进')
|
||||
.fontSize(14)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
.width(88)
|
||||
Text(this.lastFollowUpAt.length > 0 ? this.lastFollowUpAt : '选择时间')
|
||||
.fontSize(15)
|
||||
.fontColor(this.lastFollowUpAt.length > 0 ? $r('app.color.text_primary') : $r('app.color.inactive_tab'))
|
||||
.layoutWeight(1)
|
||||
Text('›')
|
||||
.fontSize(18)
|
||||
.fontColor($r('app.color.inactive_tab'))
|
||||
}
|
||||
.width('100%')
|
||||
.padding(12)
|
||||
.backgroundColor($r('app.color.background'))
|
||||
.borderRadius(10)
|
||||
.onClick(() => {
|
||||
this.pickLastFollowUp();
|
||||
})
|
||||
|
||||
Button(this.submitting ? '提交中...' : '创建线索')
|
||||
.width('100%')
|
||||
.height(48)
|
||||
.fontSize(16)
|
||||
.fontColor('#FFFFFF')
|
||||
.backgroundColor($r('app.color.accent_blue'))
|
||||
.borderRadius(12)
|
||||
.enabled(!this.submitting)
|
||||
.onClick(() => {
|
||||
this.submitNewLead();
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
.padding(20)
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
.scrollBar(BarState.Auto)
|
||||
.edgeEffect(EdgeEffect.Spring)
|
||||
} else {
|
||||
Column({ space: 12 }) {
|
||||
Text(`跟进:${this.followTarget ? this.followTarget.name : ''}`)
|
||||
.fontSize(18)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
Row({ space: 8 }) {
|
||||
ForEach(['pending', 'in_progress', 'won', 'lost'], (s: string) => {
|
||||
Text(statusLabel(s))
|
||||
.fontSize(13)
|
||||
.fontColor(this.followStatus === s ? '#FFFFFF' : $r('app.color.text_primary'))
|
||||
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
|
||||
.backgroundColor(this.followStatus === s ? $r('app.color.accent_blue') : $r('app.color.background'))
|
||||
.borderRadius(8)
|
||||
.onClick(() => {
|
||||
this.followStatus = s;
|
||||
})
|
||||
}, (s: string) => s)
|
||||
}
|
||||
.width('100%')
|
||||
if (this.followStatus === 'won') {
|
||||
this.FieldInput('成交金额', this.sheetDealAmount, (v: string) => { this.sheetDealAmount = v; }, 'number')
|
||||
}
|
||||
TextArea({ placeholder: '跟进备注', text: this.noteText })
|
||||
.placeholderColor($r('app.color.inactive_tab'))
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.height(90)
|
||||
.padding(14)
|
||||
.backgroundColor($r('app.color.background'))
|
||||
.borderRadius(10)
|
||||
.onChange((value: string) => {
|
||||
this.noteText = value;
|
||||
})
|
||||
Button(this.submitting ? '提交中...' : '保存跟进')
|
||||
.width('100%')
|
||||
.height(48)
|
||||
.fontSize(16)
|
||||
.fontColor('#FFFFFF')
|
||||
.backgroundColor($r('app.color.accent_blue'))
|
||||
.borderRadius(12)
|
||||
.enabled(!this.submitting)
|
||||
.onClick(() => {
|
||||
this.submitFollow();
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
.padding(20)
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.borderRadius(16)
|
||||
}
|
||||
}
|
||||
|
||||
build() {
|
||||
HdsNavigation() {
|
||||
Column() {
|
||||
Scroll(this.scroller) {
|
||||
Column({ space: 12 }) {
|
||||
// 统计
|
||||
Row() {
|
||||
Column({ space: 4 }) {
|
||||
Text(String(this.leads.length))
|
||||
.fontSize(22)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor('#007AFF')
|
||||
Text('总线索')
|
||||
.fontSize(12)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
}
|
||||
.layoutWeight(1)
|
||||
.justifyContent(FlexAlign.Center)
|
||||
.alignItems(HorizontalAlign.Center)
|
||||
|
||||
Column({ space: 4 }) {
|
||||
Text(String(this.pendingCount()))
|
||||
.fontSize(22)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor('#FF9500')
|
||||
Text('待跟进')
|
||||
.fontSize(12)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
}
|
||||
.layoutWeight(1)
|
||||
.justifyContent(FlexAlign.Center)
|
||||
.alignItems(HorizontalAlign.Center)
|
||||
|
||||
Column({ space: 4 }) {
|
||||
Text(String(this.wonCount()))
|
||||
.fontSize(22)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor('#34C759')
|
||||
Text('成交')
|
||||
.fontSize(12)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
}
|
||||
.layoutWeight(1)
|
||||
.justifyContent(FlexAlign.Center)
|
||||
.alignItems(HorizontalAlign.Center)
|
||||
|
||||
Column({ space: 4 }) {
|
||||
Text(formatAmount(this.wonAmount()))
|
||||
.fontSize(22)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor('#FF3B30')
|
||||
Text('成交额')
|
||||
.fontSize(12)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
}
|
||||
.layoutWeight(1)
|
||||
.justifyContent(FlexAlign.Center)
|
||||
.alignItems(HorizontalAlign.Center)
|
||||
}
|
||||
.width('100%')
|
||||
.padding(16)
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.borderRadius(16)
|
||||
|
||||
Button('+ 新建线索')
|
||||
.width('100%')
|
||||
.height(44)
|
||||
.fontSize(15)
|
||||
.fontColor('#FFFFFF')
|
||||
.backgroundColor($r('app.color.accent_blue'))
|
||||
.borderRadius(12)
|
||||
.onClick(() => {
|
||||
this.openNewLead();
|
||||
})
|
||||
|
||||
// 线索列表
|
||||
Column({ space: 8 }) {
|
||||
Row() {
|
||||
Text('我的线索')
|
||||
.fontSize(16)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
Blank()
|
||||
Text(`共 ${this.leads.length} 条`)
|
||||
.fontSize(12)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
}
|
||||
.width('100%')
|
||||
if (this.leads.length === 0) {
|
||||
Text('暂无线索')
|
||||
.fontSize(13)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
.width('100%')
|
||||
.padding(16)
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.borderRadius(12)
|
||||
} else {
|
||||
ForEach(this.leads, (lead: SalesLead) => {
|
||||
Column() {
|
||||
this.LeadCard(lead)
|
||||
}
|
||||
.width('100%')
|
||||
.onClick(() => {
|
||||
router.pushUrl({
|
||||
url: 'pages/sales/SalesLeadDetail',
|
||||
params: { leadId: lead.id },
|
||||
}).catch((err: BusinessError) => {
|
||||
promptAction.showToast({ message: `页面打开失败:${normalizeError(err)}` });
|
||||
});
|
||||
})
|
||||
}, (lead: SalesLead) => String(lead.id))
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
|
||||
// 合同
|
||||
Column({ space: 8 }) {
|
||||
Row() {
|
||||
Text('签约合同')
|
||||
.fontSize(16)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
Blank()
|
||||
Text(`共 ${this.contracts.length} 份`)
|
||||
.fontSize(12)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
}
|
||||
.width('100%')
|
||||
if (this.contracts.length === 0) {
|
||||
Text('暂无合同')
|
||||
.fontSize(13)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
.width('100%')
|
||||
.padding(16)
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.borderRadius(12)
|
||||
} else {
|
||||
ForEach(this.contracts, (c: Contract) => {
|
||||
Column() {
|
||||
this.ContractCard(c)
|
||||
}
|
||||
.width('100%')
|
||||
}, (c: Contract) => String(c.id))
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
.margin({ bottom: 20 })
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ left: 16, right: 16, top: 92, bottom: 90 })
|
||||
}
|
||||
.width('100%')
|
||||
.layoutWeight(1)
|
||||
.scrollBar(BarState.Auto)
|
||||
.edgeEffect(EdgeEffect.Spring)
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
}
|
||||
.mode(NavigationMode.Stack)
|
||||
.titleBar({
|
||||
content: {
|
||||
title: {
|
||||
mainTitle: '销售工作台',
|
||||
},
|
||||
},
|
||||
style: {
|
||||
scrollEffectOpts: {
|
||||
enableScrollEffect: false,
|
||||
scrollEffectType: ScrollEffectType.GRADIENT_BLUR,
|
||||
},
|
||||
systemMaterialEffect: {
|
||||
materialType: hdsMaterial.MaterialType.ADAPTIVE,
|
||||
materialLevel: hdsMaterial.MaterialLevel.EXQUISITE,
|
||||
},
|
||||
},
|
||||
avoidLayoutSafeArea: false,
|
||||
enableComponentSafeArea: false,
|
||||
})
|
||||
.bindToScrollable([this.scroller])
|
||||
.hideBackButton(false)
|
||||
.titleMode(HdsNavigationTitleMode.MINI)
|
||||
.ignoreLayoutSafeArea([LayoutSafeAreaType.SYSTEM], [LayoutSafeAreaEdge.TOP, LayoutSafeAreaEdge.BOTTOM])
|
||||
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
|
||||
.backgroundColor($r('app.color.background'))
|
||||
.bindSheet($$this.showSheet, this.SheetContent(), {
|
||||
height: 560,
|
||||
dragBar: true,
|
||||
showClose: true,
|
||||
backgroundColor: $r('app.color.background_card'),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,582 @@
|
||||
// 销售端 · 线索详情(ArkTS 严格类型,无 any)
|
||||
// 接口:GET /market/leads/{id}/;PATCH /market/leads/{id}/(跟进)
|
||||
// 展示对齐小程序 sales/detail:基础信息 + 线索信息 + 操作信息 + 备注,底部跟进操作
|
||||
import { promptAction, router } from '@kit.ArkUI';
|
||||
import { HdsNavigation, HdsNavigationTitleMode, ScrollEffectType } from '@kit.UIDesignKit';
|
||||
import { hdsMaterial } from '@hms.hds.hdsMaterial';
|
||||
import { BusinessError } from '@kit.BasicServicesKit';
|
||||
import { normalizeError } from '../../common/ApiConstants';
|
||||
import { api, RequestError } from '../../utils/Request';
|
||||
import { formatAmount, normalizeAmountInput } from './SalesHome';
|
||||
|
||||
interface LeadDetail {
|
||||
id: number;
|
||||
name: string;
|
||||
gender: string;
|
||||
age: number | null;
|
||||
contact_method: string;
|
||||
id_card_number: string;
|
||||
original_follower: string;
|
||||
is_elderly_consultation: boolean | null;
|
||||
is_key_person: boolean | null;
|
||||
information_sources: Array<string>;
|
||||
status: string;
|
||||
deal_amount: string | number | null;
|
||||
latest_note: string;
|
||||
last_follow_up_at: string;
|
||||
visit_date: string;
|
||||
current_sales_name: string;
|
||||
current_cycle_month: string;
|
||||
linked_elderly_name: string;
|
||||
linked_elderly_account_username: string;
|
||||
created_via: string;
|
||||
created_by_name: string;
|
||||
updated_by_name: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
interface FollowPayload {
|
||||
status: string;
|
||||
latest_note?: string;
|
||||
deal_amount?: string;
|
||||
last_follow_up_at?: string;
|
||||
}
|
||||
|
||||
const STATUS_OPTIONS: Array<string> = ['pending', 'in_progress', 'won', 'lost'];
|
||||
|
||||
function statusLabel(status: string): string {
|
||||
if (status === 'won') {
|
||||
return '成交';
|
||||
}
|
||||
if (status === 'in_progress') {
|
||||
return '跟进中';
|
||||
}
|
||||
if (status === 'lost') {
|
||||
return '失败';
|
||||
}
|
||||
return '待跟进';
|
||||
}
|
||||
|
||||
function statusColor(status: string): string {
|
||||
if (status === 'won') {
|
||||
return '#34C759';
|
||||
}
|
||||
if (status === 'in_progress') {
|
||||
return '#007AFF';
|
||||
}
|
||||
if (status === 'lost') {
|
||||
return '#8E8E93';
|
||||
}
|
||||
return '#FF9500';
|
||||
}
|
||||
|
||||
function genderLabel(g: string): string {
|
||||
if (g === 'male') {
|
||||
return '男';
|
||||
}
|
||||
if (g === 'female') {
|
||||
return '女';
|
||||
}
|
||||
return '未知';
|
||||
}
|
||||
|
||||
function boolLabel(v: boolean | null): string {
|
||||
if (v === true) {
|
||||
return '是';
|
||||
}
|
||||
if (v === false) {
|
||||
return '否';
|
||||
}
|
||||
return '未填写';
|
||||
}
|
||||
|
||||
function amountText(a: string | number | null): string {
|
||||
return formatAmount(a);
|
||||
}
|
||||
|
||||
function dateLabel(iso: string): string {
|
||||
if (!iso) {
|
||||
return '未填写';
|
||||
}
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) {
|
||||
return iso;
|
||||
}
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function dateTimeLabel(iso: string): string {
|
||||
if (!iso) {
|
||||
return '未填写';
|
||||
}
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) {
|
||||
return iso;
|
||||
}
|
||||
const mm = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const dd = String(d.getDate()).padStart(2, '0');
|
||||
const hh = String(d.getHours()).padStart(2, '0');
|
||||
const mi = String(d.getMinutes()).padStart(2, '0');
|
||||
return `${mm}/${dd} ${hh}:${mi}`;
|
||||
}
|
||||
|
||||
function sourcesText(list: Array<string>): string {
|
||||
if (list.length === 0) {
|
||||
return '未填写';
|
||||
}
|
||||
return list.join('、');
|
||||
}
|
||||
|
||||
@Entry
|
||||
@Component
|
||||
struct SalesLeadDetail {
|
||||
@State lead: LeadDetail | null = null;
|
||||
@State loading: boolean = true;
|
||||
@State errorMsg: string = '';
|
||||
@State leadId: number = 0;
|
||||
@State followStatus: string = 'pending';
|
||||
@State noteText: string = '';
|
||||
@State dealAmount: string = '';
|
||||
@State lastFollowUp: string = '';
|
||||
@State submitting: boolean = false;
|
||||
private scroller: Scroller = new Scroller();
|
||||
|
||||
aboutToAppear(): void {
|
||||
const params = router.getParams() as Record<string, Object> | null | undefined;
|
||||
if (params !== null && params !== undefined) {
|
||||
this.leadId = Number(params['leadId'] ?? 0);
|
||||
}
|
||||
if (this.leadId > 0) {
|
||||
this.load();
|
||||
} else {
|
||||
this.loading = false;
|
||||
this.errorMsg = '缺少线索参数';
|
||||
}
|
||||
}
|
||||
|
||||
// 后端字段可能缺失(undefined 会让 .length 崩溃),统一归一化为空串/空数组
|
||||
private normalizeLead(res: LeadDetail): LeadDetail {
|
||||
const s = (v: string | null | undefined): string => (typeof v === 'string' ? v : '');
|
||||
const sources: Array<string> = Array.isArray(res.information_sources) ? res.information_sources : [];
|
||||
return {
|
||||
id: res.id,
|
||||
name: s(res.name),
|
||||
gender: s(res.gender),
|
||||
age: res.age !== undefined ? res.age : null,
|
||||
contact_method: s(res.contact_method),
|
||||
id_card_number: s(res.id_card_number),
|
||||
original_follower: s(res.original_follower),
|
||||
is_elderly_consultation: res.is_elderly_consultation === true ? true : (res.is_elderly_consultation === false ? false : null),
|
||||
is_key_person: res.is_key_person === true ? true : (res.is_key_person === false ? false : null),
|
||||
information_sources: sources,
|
||||
status: s(res.status) === '' ? 'pending' : s(res.status),
|
||||
deal_amount: res.deal_amount !== undefined ? res.deal_amount : null,
|
||||
latest_note: s(res.latest_note),
|
||||
last_follow_up_at: s(res.last_follow_up_at),
|
||||
visit_date: s(res.visit_date),
|
||||
current_sales_name: s(res.current_sales_name),
|
||||
current_cycle_month: s(res.current_cycle_month),
|
||||
linked_elderly_name: s(res.linked_elderly_name),
|
||||
linked_elderly_account_username: s(res.linked_elderly_account_username),
|
||||
created_via: s(res.created_via),
|
||||
created_by_name: s(res.created_by_name),
|
||||
updated_by_name: s(res.updated_by_name),
|
||||
created_at: s(res.created_at),
|
||||
updated_at: s(res.updated_at),
|
||||
};
|
||||
}
|
||||
|
||||
private async load(): Promise<void> {
|
||||
this.loading = true;
|
||||
this.errorMsg = '';
|
||||
try {
|
||||
const res = await api.get<LeadDetail>(`/market/leads/${this.leadId}/`);
|
||||
this.lead = this.normalizeLead(res);
|
||||
this.followStatus = this.lead.status;
|
||||
this.dealAmount = this.lead.status === 'won' && this.lead.deal_amount !== null && this.lead.deal_amount !== undefined
|
||||
? normalizeAmountInput(String(this.lead.deal_amount))
|
||||
: '';
|
||||
this.lastFollowUp = this.lead.last_follow_up_at;
|
||||
} catch (err) {
|
||||
this.errorMsg = err instanceof RequestError ? err.message : normalizeError(err as BusinessError | Error);
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 选最后跟进日期:保留原时间部分(无则用当前时刻),合成 datetime 提交
|
||||
private pickLastFollowUp(): void {
|
||||
const current = this.lastFollowUp.length > 0 && !Number.isNaN(new Date(this.lastFollowUp).getTime())
|
||||
? new Date(this.lastFollowUp)
|
||||
: new Date();
|
||||
DatePickerDialog.show({
|
||||
start: new Date('2020-01-01'),
|
||||
end: new Date('2035-12-31'),
|
||||
selected: current,
|
||||
onDateAccept: (value: Date) => {
|
||||
const mm = String(value.getMonth() + 1).padStart(2, '0');
|
||||
const dd = String(value.getDate()).padStart(2, '0');
|
||||
const hh = String(current.getHours()).padStart(2, '0');
|
||||
const mi = String(current.getMinutes()).padStart(2, '0');
|
||||
this.lastFollowUp = `${value.getFullYear()}-${mm}-${dd}T${hh}:${mi}:00`;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private async submitFollow(): Promise<void> {
|
||||
if (this.submitting || this.lead === null) {
|
||||
return;
|
||||
}
|
||||
if (this.followStatus === 'won' && normalizeAmountInput(this.dealAmount.trim()).length === 0) {
|
||||
promptAction.showToast({ message: '标记成交请填写成交金额' });
|
||||
return;
|
||||
}
|
||||
this.submitting = true;
|
||||
try {
|
||||
const body: FollowPayload = { status: this.followStatus };
|
||||
if (this.noteText.trim().length > 0) {
|
||||
body.latest_note = this.noteText.trim();
|
||||
}
|
||||
if (this.followStatus === 'won') {
|
||||
body.deal_amount = normalizeAmountInput(this.dealAmount.trim());
|
||||
}
|
||||
if (this.lastFollowUp.length > 0) {
|
||||
body.last_follow_up_at = this.lastFollowUp;
|
||||
}
|
||||
await api.patch(`/market/leads/${this.leadId}/`, body);
|
||||
promptAction.showToast({ message: '已更新' });
|
||||
this.noteText = '';
|
||||
await this.load();
|
||||
} catch (err) {
|
||||
const message = err instanceof RequestError ? err.message : normalizeError(err as BusinessError | Error);
|
||||
promptAction.showToast({ message: message });
|
||||
} finally {
|
||||
this.submitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
@Builder
|
||||
Hero() {
|
||||
Column({ space: 10 }) {
|
||||
Row({ space: 8 }) {
|
||||
Text(this.lead?.name ?? '--')
|
||||
.fontSize(22)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.layoutWeight(1)
|
||||
Text(statusLabel(this.lead?.status ?? ''))
|
||||
.fontSize(13)
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor('#FFFFFF')
|
||||
.padding({ left: 12, right: 12, top: 5, bottom: 5 })
|
||||
.backgroundColor(statusColor(this.lead?.status ?? ''))
|
||||
.borderRadius(10)
|
||||
}
|
||||
.width('100%')
|
||||
|
||||
Row({ space: 8 }) {
|
||||
Text(this.lead?.current_sales_name && this.lead.current_sales_name.length > 0 ? this.lead.current_sales_name : '未分配')
|
||||
.fontSize(12)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
.padding({ left: 10, right: 10, top: 4, bottom: 4 })
|
||||
.backgroundColor($r('app.color.background'))
|
||||
.borderRadius(8)
|
||||
Text(this.lead !== null && this.lead.status === 'won' ? amountText(this.lead.deal_amount) : '未成交')
|
||||
.fontSize(12)
|
||||
.fontColor(this.lead !== null && this.lead.status === 'won' ? '#FF9500' : $r('app.color.text_secondary'))
|
||||
.padding({ left: 10, right: 10, top: 4, bottom: 4 })
|
||||
.backgroundColor($r('app.color.background'))
|
||||
.borderRadius(8)
|
||||
Blank()
|
||||
Text(`跟进 ${dateTimeLabel(this.lead?.last_follow_up_at ?? '')}`)
|
||||
.fontSize(12)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
}
|
||||
.width('100%')
|
||||
}
|
||||
.width('100%')
|
||||
.padding(16)
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.borderRadius(16)
|
||||
}
|
||||
|
||||
@Builder
|
||||
DetailItem(label: string, value: string) {
|
||||
Column({ space: 4 }) {
|
||||
Text(label)
|
||||
.fontSize(12)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
Text((value ?? '').length > 0 ? value : '未填写')
|
||||
.fontSize(14)
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.maxLines(2)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
}
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
.width('50%')
|
||||
.padding({ top: 8, bottom: 8 })
|
||||
}
|
||||
|
||||
@Builder
|
||||
SectionCard(title: string) {
|
||||
Text(title)
|
||||
.fontSize(15)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.width('100%')
|
||||
}
|
||||
|
||||
build() {
|
||||
HdsNavigation() {
|
||||
Column() {
|
||||
Scroll(this.scroller) {
|
||||
Column({ space: 12 }) {
|
||||
if (this.loading) {
|
||||
Text('详情加载中...')
|
||||
.fontSize(14)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
.width('100%')
|
||||
.padding({ top: 40 })
|
||||
.textAlign(TextAlign.Center)
|
||||
} else if (this.errorMsg.length > 0) {
|
||||
Column({ space: 8 }) {
|
||||
Text('加载失败')
|
||||
.fontSize(16)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
Text(this.errorMsg)
|
||||
.fontSize(13)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ top: 40 })
|
||||
.alignItems(HorizontalAlign.Center)
|
||||
} else if (this.lead !== null) {
|
||||
this.Hero()
|
||||
|
||||
// 基础信息
|
||||
Column({ space: 6 }) {
|
||||
this.SectionCard('基础信息')
|
||||
Flex({ wrap: FlexWrap.Wrap }) {
|
||||
this.DetailItem('来访日期', dateLabel(this.lead!.visit_date))
|
||||
this.DetailItem('性别', genderLabel(this.lead!.gender))
|
||||
this.DetailItem('年龄', this.lead!.age !== null && this.lead!.age !== undefined ? `${this.lead!.age}岁` : '未填写')
|
||||
this.DetailItem('联系方式', this.lead!.contact_method)
|
||||
this.DetailItem('身份证号', this.lead!.id_card_number)
|
||||
this.DetailItem('原表跟进人', this.lead!.original_follower)
|
||||
}
|
||||
.width('100%')
|
||||
}
|
||||
.width('100%')
|
||||
.padding(14)
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.borderRadius(16)
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
|
||||
// 线索信息
|
||||
Column({ space: 6 }) {
|
||||
this.SectionCard('线索信息')
|
||||
Flex({ wrap: FlexWrap.Wrap }) {
|
||||
this.DetailItem('信息来源', sourcesText(this.lead!.information_sources))
|
||||
this.DetailItem('是否为长者咨询', boolLabel(this.lead!.is_elderly_consultation))
|
||||
this.DetailItem('是否重点关注', boolLabel(this.lead!.is_key_person))
|
||||
this.DetailItem('当前状态', statusLabel(this.lead!.status))
|
||||
this.DetailItem('成交金额', this.lead!.status === 'won' ? amountText(this.lead!.deal_amount) : '未成交')
|
||||
this.DetailItem('最后跟进时间', dateTimeLabel(this.lead!.last_follow_up_at))
|
||||
this.DetailItem('负责销售', this.lead!.current_sales_name)
|
||||
this.DetailItem('当前周期', this.lead!.current_cycle_month)
|
||||
this.DetailItem('关联长者档案', this.lead!.linked_elderly_name)
|
||||
this.DetailItem('长者账号', this.lead!.linked_elderly_account_username)
|
||||
}
|
||||
.width('100%')
|
||||
}
|
||||
.width('100%')
|
||||
.padding(14)
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.borderRadius(16)
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
|
||||
// 操作信息
|
||||
Column({ space: 6 }) {
|
||||
this.SectionCard('操作信息')
|
||||
Flex({ wrap: FlexWrap.Wrap }) {
|
||||
this.DetailItem('创建方式', this.lead!.created_via === 'import' ? '导入' : '手工录入')
|
||||
this.DetailItem('创建人', this.lead!.created_by_name)
|
||||
this.DetailItem('更新人', this.lead!.updated_by_name)
|
||||
this.DetailItem('创建时间', dateTimeLabel(this.lead!.created_at))
|
||||
this.DetailItem('更新时间', dateTimeLabel(this.lead!.updated_at))
|
||||
}
|
||||
.width('100%')
|
||||
}
|
||||
.width('100%')
|
||||
.padding(14)
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.borderRadius(16)
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
|
||||
// 备注
|
||||
Column({ space: 6 }) {
|
||||
Text('备注')
|
||||
.fontSize(15)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.width('100%')
|
||||
Text(this.lead!.latest_note !== null && this.lead!.latest_note !== undefined && this.lead!.latest_note.trim().length > 0 ? this.lead!.latest_note.trim() : '暂无备注')
|
||||
.fontSize(14)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
.width('100%')
|
||||
}
|
||||
.width('100%')
|
||||
.padding(14)
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.borderRadius(16)
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
|
||||
// 跟进操作
|
||||
Column({ space: 10 }) {
|
||||
Text('跟进')
|
||||
.fontSize(15)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.width('100%')
|
||||
Row({ space: 8 }) {
|
||||
ForEach(STATUS_OPTIONS, (s: string) => {
|
||||
Text(statusLabel(s))
|
||||
.fontSize(13)
|
||||
.fontColor(this.followStatus === s ? '#FFFFFF' : $r('app.color.text_primary'))
|
||||
.padding({ left: 14, right: 14, top: 7, bottom: 7 })
|
||||
.backgroundColor(this.followStatus === s ? statusColor(s) : $r('app.color.background'))
|
||||
.borderRadius(9)
|
||||
.onClick(() => {
|
||||
this.followStatus = s;
|
||||
})
|
||||
.accessibilityLevel('yes')
|
||||
.accessibilityText(`状态设为${statusLabel(s)}${this.followStatus === s ? ',已选中' : ''}`)
|
||||
}, (s: string) => s)
|
||||
}
|
||||
.width('100%')
|
||||
|
||||
if (this.followStatus === 'won') {
|
||||
Row({ space: 12 }) {
|
||||
Text('成交金额')
|
||||
.fontSize(14)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
.width(88)
|
||||
TextInput({ placeholder: '请输入成交金额(元)', text: this.dealAmount })
|
||||
.placeholderColor($r('app.color.inactive_tab'))
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.fontSize(15)
|
||||
.height(44)
|
||||
.layoutWeight(1)
|
||||
.padding({ left: 12, right: 12 })
|
||||
.backgroundColor($r('app.color.background'))
|
||||
.borderRadius(10)
|
||||
.type(InputType.NUMBER_DECIMAL)
|
||||
.onChange((value: string) => {
|
||||
this.dealAmount = value;
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
.padding(12)
|
||||
.backgroundColor($r('app.color.background'))
|
||||
.borderRadius(10)
|
||||
}
|
||||
|
||||
Row({ space: 12 }) {
|
||||
Text('最后跟进')
|
||||
.fontSize(14)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
.width(88)
|
||||
Text(this.lastFollowUp.length > 0 ? dateTimeLabel(this.lastFollowUp) : '选择日期')
|
||||
.fontSize(15)
|
||||
.fontColor(this.lastFollowUp.length > 0 ? $r('app.color.text_primary') : $r('app.color.inactive_tab'))
|
||||
.layoutWeight(1)
|
||||
Text('›')
|
||||
.fontSize(18)
|
||||
.fontColor($r('app.color.inactive_tab'))
|
||||
}
|
||||
.width('100%')
|
||||
.padding(12)
|
||||
.backgroundColor($r('app.color.background'))
|
||||
.borderRadius(10)
|
||||
.onClick(() => {
|
||||
this.pickLastFollowUp();
|
||||
})
|
||||
.accessibilityLevel('yes')
|
||||
.accessibilityText(`最后跟进时间${this.lastFollowUp.length > 0 ? dateTimeLabel(this.lastFollowUp) : '未选择'},点按修改`)
|
||||
|
||||
TextArea({ placeholder: '跟进备注(选填)', text: this.noteText })
|
||||
.placeholderColor($r('app.color.inactive_tab'))
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.height(80)
|
||||
.padding(12)
|
||||
.backgroundColor($r('app.color.background'))
|
||||
.borderRadius(10)
|
||||
.onChange((value: string) => {
|
||||
this.noteText = value;
|
||||
})
|
||||
|
||||
Button(this.submitting ? '提交中...' : '保存跟进')
|
||||
.width('100%')
|
||||
.height(46)
|
||||
.fontSize(16)
|
||||
.fontColor('#FFFFFF')
|
||||
.backgroundColor($r('app.color.accent_blue'))
|
||||
.borderRadius(12)
|
||||
.enabled(!this.submitting)
|
||||
.onClick(() => {
|
||||
this.submitFollow();
|
||||
})
|
||||
.accessibilityLevel('yes')
|
||||
.accessibilityText('保存跟进')
|
||||
.accessibilityDescription('点按保存状态与备注')
|
||||
}
|
||||
.width('100%')
|
||||
.padding(14)
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.borderRadius(16)
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
.margin({ bottom: 20 })
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ left: 16, right: 16, top: 92, bottom: 90 })
|
||||
}
|
||||
.width('100%')
|
||||
.layoutWeight(1)
|
||||
.scrollBar(BarState.Auto)
|
||||
.edgeEffect(EdgeEffect.Spring)
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
}
|
||||
.mode(NavigationMode.Stack)
|
||||
.titleBar({
|
||||
content: {
|
||||
title: {
|
||||
mainTitle: '线索详情',
|
||||
},
|
||||
},
|
||||
style: {
|
||||
scrollEffectOpts: {
|
||||
enableScrollEffect: false,
|
||||
scrollEffectType: ScrollEffectType.GRADIENT_BLUR,
|
||||
},
|
||||
systemMaterialEffect: {
|
||||
materialType: hdsMaterial.MaterialType.ADAPTIVE,
|
||||
materialLevel: hdsMaterial.MaterialLevel.EXQUISITE,
|
||||
},
|
||||
},
|
||||
avoidLayoutSafeArea: false,
|
||||
enableComponentSafeArea: false,
|
||||
})
|
||||
.bindToScrollable([this.scroller])
|
||||
.hideBackButton(false)
|
||||
.titleMode(HdsNavigationTitleMode.MINI)
|
||||
.ignoreLayoutSafeArea([LayoutSafeAreaType.SYSTEM], [LayoutSafeAreaEdge.TOP, LayoutSafeAreaEdge.BOTTOM])
|
||||
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
|
||||
.backgroundColor($r('app.color.background'))
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user