This commit is contained in:
2026-09-24 16:25:22 +08:00
commit 7428184f01
1198 changed files with 314515 additions and 0 deletions
+223
View File
@@ -0,0 +1,223 @@
<template>
<hr-page page-bg="#F3F4F6" status-bg="#FFFFFF" nav-title="答题卡" @right-click="submit">
<template v-slot:right>
<text class="ac-nav-submit">交卷</text>
</template>
<view class="hr-body">
<!-- ============ 图例 ============ -->
<hr-card>
<view class="hr-row hr-wrap">
<view class="ac-lg">
<view class="ac-lg-s" style="background-color:#EFF6FF;border:1px solid #BFDBFE"></view>
<text class="ac-lg-t">已答 {{ doneCount }}</text>
</view>
<view class="ac-lg">
<view class="ac-lg-s" style="background-color:#FFFFFF;border:1px solid #E5E7EB"></view>
<text class="ac-lg-t">未答 {{ total - doneCount }}</text>
</view>
<view class="ac-lg">
<view class="ac-lg-s" style="background-color:#FEF3C7;border:1px solid #FDE68A"></view>
<text class="ac-lg-t">标记 {{ markCount }}</text>
</view>
</view>
</hr-card>
<!-- ============ 题号九宫格 ============ -->
<hr-card style="margin-top:10px">
<view class="ac-grid">
<view v-for="(q, i) in questions" :key="i" :class="cellClass(i)" :style="cellStyle" @click="jump(i)">
<text :class="cellTextClass(i)">{{ i + 1 }}</text>
</view>
</view>
</hr-card>
<hr-tip style="margin-top:10px" :theme="answeredAll ? 'ok' : 'warn'"
:icon="answeredAll ? '✓' : '⚑'"
:text="answeredAll ? '全部题目已作答,可以交卷了。' : ('仍有 ' + (total - doneCount) + ' 题未作答,交卷后将无法返回修改。')"></hr-tip>
<view style="height:14px"></view>
</view>
<template v-slot:footer>
<view class="hr-fixedbar">
<view class="hr-btn hr-btn-ghost ac-foot-prev" @click="resume">
<text class="ac-foot-t">继续答题</text>
</view>
<view class="hr-btn hr-btn-primary" style="flex:1" @click="submit">
<text class="ac-foot-t2">交卷并查看成绩</text>
</view>
</view>
</template>
</hr-page>
</template>
<script lang="uts" setup>
import { ref, computed } from 'vue'
import { quizQuestions } from '@/mock/index.uts'
import { quizState, answeredCount, markedCount } from '@/utils/quiz.uts'
import { navBack, relaunchTo } from '@/utils/nav.uts'
/** s18 答题卡 */
const questions = quizQuestions
const total = questions.length
const refreshKey = ref(0)
const cellSize = ref(92)
const doneCount = computed(() : number => {
const k = refreshKey.value
return answeredCount()
})
const markCount = computed(() : number => {
const k = refreshKey.value
return markedCount()
})
const answeredAll = computed(() : boolean => {
return doneCount.value == total
})
const cellStyle = computed(() : string => {
return `width:${cellSize.value}rpx;height:${cellSize.value}rpx;`
})
onLoad(() => {
// 6 列均分:内容宽 750rpx - 页面左右 64 - 卡片左右 52 - 5 个 16rpx 间隙 80
cellSize.value = Math.floor((750 - 64 - 52 - 80) / 6)
})
onPageShow(() => {
refreshKey.value = refreshKey.value + 1
})
function cellClass(i : number) : string {
const k = refreshKey.value
if (quizState.current == i) { return 'ac-cell ac-cell-cur' }
if (quizState.marked[i]) { return 'ac-cell ac-cell-mark' }
if (quizState.answers[i] >= 0) { return 'ac-cell ac-cell-done' }
return 'ac-cell'
}
function cellTextClass(i : number) : string {
const k = refreshKey.value
if (quizState.current == i) { return 'ac-cell-t ac-cell-t-cur' }
return 'ac-cell-t'
}
function jump(i : number) : void {
quizState.current = i
navBack()
}
function resume() : void {
navBack()
}
function submit() : void {
uni.showModal({
title: '交卷确认',
content: `已作答 ${doneCount.value} / ${total} 题。交卷后无法修改。`,
cancelText: '继续答题',
confirmText: '交卷',
success: (res) => {
if (res.confirm) {
quizState.submitted = true
uni.showToast({ title: '已交卷 · 得分 92', icon: 'success' })
setTimeout(() => {
relaunchTo('/pages/study/exams')
}, 700)
}
}
})
}
</script>
<style>
.ac-nav-submit {
font-size: 31.4rpx;
color: #2563EB;
font-weight: bold;
}
.ac-lg {
display: flex;
flex-direction: row;
align-items: center;
margin-right: 28rpx;
margin-bottom: 8rpx;
}
.ac-lg-s {
width: 24rpx;
height: 24rpx;
border-radius: 8rpx;
margin-right: 10rpx;
}
.ac-lg-t {
font-size: 25.8rpx;
color: #6B7280;
}
.ac-grid {
display: flex;
flex-direction: row;
flex-wrap: wrap;
}
.ac-cell {
border-radius: 18rpx;
background-color: #FFFFFF;
border: 2rpx solid #E5E7EB;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
margin-right: 16rpx;
margin-bottom: 16rpx;
}
.ac-cell-done {
background-color: #EFF6FF;
border: 2rpx solid #BFDBFE;
}
.ac-cell-mark {
background-color: #FEF3C7;
border: 2rpx solid #FDE68A;
}
.ac-cell-cur {
background-color: #2563EB;
border: 2rpx solid #2563EB;
}
.ac-cell-t {
font-size: 26.8rpx;
font-weight: bold;
color: #6B7280;
}
.ac-cell-t-cur {
color: #FFFFFF;
}
.ac-foot-prev {
flex: 0 0 220rpx;
margin-right: 18rpx;
}
.ac-foot-t {
font-size: 31.4rpx;
font-weight: bold;
color: #1F2937;
}
.ac-foot-t2 {
font-size: 32.4rpx;
font-weight: bold;
color: #FFFFFF;
}
</style>
+331
View File
@@ -0,0 +1,331 @@
<template>
<hr-page page-bg="#FFFFFF" status-bg="#FFFFFF" nav-title="课程库" :no-scroll="true" @right-click="goCert">
<!-- ============ 搜索行(xSearch ============ -->
<view class="cl-search">
<view class="hr-flex1">
<x-search v-model="keyword" placeholder="搜索课题,如 成人 CPR / AED" round="20rpx" height="72rpx"
input-bg-color="#F3F4F6" icon-color="#9CA3AF" font-color="#1F2937"
:show-cancel="false"></x-search>
</view>
</view>
<!-- ============ 一级侧栏 + 三级内容 ============ -->
<view class="cl-split">
<scroll-view class="cl-side" :scroll-y="true" :show-scrollbar="false">
<view v-for="(c, i) in catalogs" :key="c.id"
:class="activeCatalog == i ? 'cl-side-item cl-side-item-on' : 'cl-side-item'" @click="onCatalog(i)">
<view class="cl-side-ic" :style="{ backgroundColor: c.iconBg }">
<text class="cl-side-ic-t" :style="{ color: c.iconColor }">{{ c.icon }}</text>
</view>
<text :class="activeCatalog == i ? 'cl-side-b cl-side-b-on' : 'cl-side-b'">{{ c.name }}</text>
<text class="cl-side-s">{{ c.count }} 课题</text>
</view>
</scroll-view>
<scroll-view class="cl-main" :scroll-y="true" :show-scrollbar="false">
<!-- 搜索过滤结果 -->
<view v-if="keyword != ''">
<view v-for="(r, i) in searchResult" :key="i" class="cl-l3" @click="openSubject(r)">
<view class="cl-l3-no">
<text class="cl-l3-no-t">{{ r.no }}</text>
</view>
<view class="hr-flex1">
<text class="cl-l3-b">{{ r.name }}</text>
<text class="cl-l3-s">{{ r.meta }}</text>
</view>
<text class="cl-l3-go"></text>
</view>
<hr-empty v-if="searchResult.length == 0" icon="⌕" title="没有匹配的课题"
sub="换个关键词试试,例如「CPR」「AED」「气道」"></hr-empty>
</view>
<!-- 二级组标题 + 三级条目 -->
<view v-else-if="activeCatalog == 0">
<view v-for="(g, gi) in groups" :key="gi">
<view class="cl-group">
<view class="cl-group-bar"></view>
<text class="cl-group-b">{{ g.name }}</text>
<view class="cl-group-cnt">
<text class="cl-group-cnt-t">{{ g.count }} 课题</text>
</view>
<view class="cl-group-line"></view>
</view>
<view v-for="(it, ii) in g.items" :key="ii" class="cl-l3" @click="openSubject(it)">
<view class="cl-l3-no">
<text class="cl-l3-no-t">{{ it.no }}</text>
</view>
<view class="hr-flex1">
<text class="cl-l3-b">{{ it.name }}</text>
<text class="cl-l3-s">{{ it.meta }}</text>
</view>
<text class="cl-l3-go"></text>
</view>
</view>
<view style="margin:6px 13px 14px 13px">
<hr-tip theme="info" icon=""
:text="'共 ' + catalogs.length + ' 个一级分类 · 49 个课题。其余分类的课题内容正在建设,敬请期待。'"></hr-tip>
</view>
</view>
<hr-empty v-else icon="▦" :title="catalogName + ' · 内容建设中'" sub="该分类的课题与题库正在录入,可先学习「急救基础」"></hr-empty>
<view style="height:24px"></view>
</scroll-view>
</view>
</hr-page>
</template>
<script lang="uts" setup>
import { ref, computed } from 'vue'
import { catalogList, courseGroups } from '@/mock/index.uts'
import { HrCatalog, HrGroup, HrSubject } from '@/mock/types.uts'
import { navTo } from '@/utils/nav.uts'
/**
* s10 课程库(三级导航)
* 一级侧栏分类 + 二级组标题(不可点,仅作分组)+ 三级课题条目(唯一可点)。
*/
const catalogs = ref<HrCatalog[]>(catalogList)
const groups = ref<HrGroup[]>(courseGroups)
const activeCatalog = ref(0)
const keyword = ref('')
const catalogName = computed(() : string => {
return catalogs.value[activeCatalog.value].name
})
/** 全量课题(用于搜索) */
const allSubjects = computed(() : HrSubject[] => {
const list : HrSubject[] = []
for (let gi = 0; gi < groups.value.length; gi++) {
const g = groups.value[gi]
for (let ii = 0; ii < g.items.length; ii++) {
list.push(g.items[ii])
}
}
return list
})
const searchResult = computed(() : HrSubject[] => {
if (keyword.value == '') { return [] as HrSubject[] }
const kw = keyword.value.toLowerCase()
const list : HrSubject[] = []
for (let i = 0; i < allSubjects.value.length; i++) {
const s = allSubjects.value[i]
if (s.name.toLowerCase().indexOf(kw) > -1) { list.push(s) }
}
return list
})
function onCatalog(i : number) : void {
activeCatalog.value = i
keyword.value = ''
}
function openSubject(s : HrSubject) : void {
navTo('/pages/course/subject?name=' + s.name)
}
function goCert() : void {
navTo('/pages/study/certificates')
}
function onScanSearch() : void {
uni.showToast({ title: '扫码搜索课题', icon: 'none' })
}
</script>
<style>
.cl-search {
display: flex;
flex-direction: row;
align-items: center;
background-color: #FFFFFF;
border-bottom: 2rpx solid #F3F4F6;
padding: 16rpx 28rpx 16rpx 28rpx;
}
.cl-search-scan {
margin-left: 16rpx;
width: 72rpx;
height: 72rpx;
border-radius: 20rpx;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
}
.cl-split {
flex: 1;
display: flex;
flex-direction: row;
}
/* ---------- 左侧一级分类 ---------- */
.cl-side {
width: 184rpx;
background-color: #FAFBFC;
border-right: 2rpx solid #F3F4F6;
}
.cl-side-item {
flex-direction: column;
align-items: center;
padding: 22rpx 12rpx 22rpx 12rpx;
border-left: 6rpx solid transparent;
}
.cl-side-item-on {
background-color: #FFFFFF;
border-left: 6rpx solid #2563EB;
}
.cl-side-ic {
width: 72rpx;
height: 72rpx;
border-radius: 22rpx;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
margin-bottom: 12rpx;
}
.cl-side-ic-t {
font-size: 33.6rpx;
font-weight: bold;
}
.cl-side-b {
font-size: 25.8rpx;
color: #1F2937;
}
.cl-side-b-on {
color: #2563EB;
font-weight: bold;
}
.cl-side-s {
font-size: 20.2rpx;
color: #9CA3AF;
margin-top: 4rpx;
}
/* ---------- 右侧内容 ---------- */
.cl-main {
flex: 1;
background-color: #F3F4F6;
}
.cl-hint {
display: flex;
flex-direction: row;
align-items: center;
padding: 20rpx 26rpx 8rpx 26rpx;
}
.cl-hint-i {
font-size: 23.6rpx;
color: #2563EB;
font-weight: bold;
margin-right: 12rpx;
}
.cl-hint-t {
font-size: 23.6rpx;
color: #9CA3AF;
}
.cl-group {
display: flex;
flex-direction: row;
align-items: center;
padding: 26rpx 26rpx 16rpx 26rpx;
}
.cl-group-bar {
width: 6rpx;
height: 24rpx;
border-radius: 4rpx;
background-color: #2563EB;
margin-right: 14rpx;
}
.cl-group-b {
font-size: 29.2rpx;
font-weight: bold;
color: #1F2937;
}
.cl-group-cnt {
margin-left: 14rpx;
background-color: #EFF6FF;
border-radius: 12rpx;
padding: 2rpx 12rpx 2rpx 12rpx;
}
.cl-group-cnt-t {
font-size: 21.2rpx;
font-weight: bold;
color: #2563EB;
}
.cl-group-line {
flex: 1;
height: 2rpx;
background-color: #F3F4F6;
margin-left: 14rpx;
}
.cl-l3 {
display: flex;
flex-direction: row;
align-items: center;
margin: 0rpx 26rpx 16rpx 26rpx;
background-color: #FFFFFF;
border: 2rpx solid #F3F4F6;
border-radius: 22rpx;
padding: 22rpx 24rpx 22rpx 24rpx;
}
.cl-l3-no {
width: 48rpx;
height: 48rpx;
border-radius: 16rpx;
background-color: #EFF6FF;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
margin-right: 20rpx;
}
.cl-l3-no-t {
font-size: 23.6rpx;
font-weight: bold;
color: #2563EB;
}
.cl-l3-b {
font-size: 29.2rpx;
color: #1F2937;
lines: 1;
}
.cl-l3-s {
font-size: 23.6rpx;
color: #9CA3AF;
margin-top: 4rpx;
}
.cl-l3-go {
font-size: 33.6rpx;
color: #9CA3AF;
margin-left: 16rpx;
}
</style>
+490
View File
@@ -0,0 +1,490 @@
<template>
<hr-page page-bg="#F3F4F6" status-bg="#FFFFFF" :nav-title="material.title" @right-click="onStar">
<template v-slot:right>
<x-icon :name="starred ? 'star-fill' : 'star-line'" :color="starred ? '#F59E0B' : '#6B7280'"
font-size="19"></x-icon>
</template>
<view class="hr-body" style="padding-bottom:22px">
<!-- ============ 视频资料 ============ -->
<view v-if="isVideo">
<view class="vd" :style="{ height: videoH + 'rpx' }">
<!-- 播放/暂停 -->
<view class="vd-play" @click="togglePlay">
<x-icon :name="playing ? 'pause-fill' : 'play-fill'" :color="playing ? '#FFFFFF' : '#1D4ED8'"
font-size="20"></x-icon>
</view>
<!-- 进度条 -->
<view class="vd-bar">
<view class="vd-track">
<view class="vd-track-i" :style="{ width: played + '%' }"></view>
</view>
<view class="vd-row">
<text class="vd-time">{{ curTime }} / {{ totalTime }}</text>
<view class="vd-acts">
<view @click="togglePlay">
<x-icon :name="playing ? 'pause-line' : 'play-line'" color="#FFFFFF"
font-size="16"></x-icon>
</view>
<view style="margin-left:14px" @click="onFullscreen">
<x-icon name="fullscreen-line" color="#FFFFFF" font-size="16"></x-icon>
</view>
</view>
</view>
</view>
</view>
<hr-card style="margin-top:10px">
<view class="hr-row-top">
<text class="md-title">{{ material.title }}</text>
<view class="md-badge" :style="{ backgroundColor: material.badgeColor }">
<text class="md-badge-t">{{ material.badge }}</text>
</view>
</view>
<text class="md-meta">{{ material.meta }}</text>
<text class="md-from">来自 {{ material.from }}</text>
<view class="hr-row" style="margin-top:12px">
<view style="flex:1">
<hr-progress :percent="played"></hr-progress>
</view>
<text class="md-seen">已看 {{ played }}%</text>
</view>
</hr-card>
</view>
<!-- ============ 文档资料 ============ -->
<view v-else>
<view class="dv">
<view class="dv-sheet">
<view class="dv-t"></view>
<view class="dv-l dv-l-s"></view>
<view class="dv-l"></view>
<view class="dv-l dv-l-m"></view>
<view class="dv-img"></view>
<view class="dv-l dv-l-s"></view>
<view class="dv-l dv-l-m"></view>
<text class="dv-pg">{{ page }} / 12</text>
</view>
</view>
<view class="dv-pager">
<view class="dv-pager-b" @click="prevPage">
<x-icon name="arrow-left-s-line" color="#6B7280" font-size="16"></x-icon>
</view>
<text class="dv-pager-t">第 {{ page }} 页 / 共 12 页</text>
<view class="dv-pager-b" @click="nextPage">
<x-icon name="arrow-right-s-line" color="#6B7280" font-size="16"></x-icon>
</view>
</view>
<hr-card style="margin-top:10px">
<view class="hr-row-top">
<text class="md-title">{{ material.title }}</text>
<view class="md-badge" :style="{ backgroundColor: material.badgeColor }">
<text class="md-badge-t">{{ material.badge }}</text>
</view>
</view>
<text class="md-meta">{{ material.meta }}</text>
<text class="md-from">来自 {{ material.from }}</text>
</hr-card>
</view>
<!-- ============ 资料简介 ============ -->
<hr-sec title="资料简介" :first="false"></hr-sec>
<hr-card>
<text class="md-desc">{{ material.summary }}</text>
</hr-card>
<!-- ============ 学习笔记(仅视频) ============ -->
<view v-if="isVideo">
<hr-sec title="学习笔记" more="编辑 " @more-click="onEditNote"></hr-sec>
<hr-card>
<text class="md-desc">{{ note }}</text>
<text class="md-note-time">最后编辑:昨天 21:32</text>
</hr-card>
</view>
<!-- ============ 离线下载 ============ -->
<hr-sec title="离线下载" :more="downloaded ? '⇅ 管理 ' : '⇅ 下载 '" @more-click="onDownload"></hr-sec>
<hr-card variant="list">
<hr-lrow :icon="downloaded ? '✓' : '⇅'" :theme="downloaded ? 'grn' : 'pri'"
:title="downloaded ? '已下载到本机' : '尚未下载'" :sub="downloaded ? '2.4 MB · 无网络时仍可查阅' : '点击下载后可离线查阅'"
:value="downloaded ? '已存' : '下载'" :value-theme="downloaded ? 'grn' : 'pri'" :value-bold="true"
:is-last="true" @click="onDownload"></hr-lrow>
</hr-card>
<view style="height:8px"></view>
</view>
<template v-slot:footer>
<view class="hr-fixedbar">
<view class="hr-btn hr-btn-ghost md-fav" @click="onStar">
<x-icon :name="starred ? 'star-fill' : 'star-line'" :color="starred ? '#F59E0B' : '#1F2937'"
font-size="17"></x-icon>
<text class="md-fav-t">{{ starred ? '已收藏' : '收藏' }}</text>
</view>
<view class="hr-btn hr-btn-primary md-main" @click="onMainAction">
<text class="md-main-t">{{ isVideo ? (downloaded ? '已下载离线观看' : '下载离线观看') : '在线查看' }}</text>
</view>
</view>
</template>
</hr-page>
</template>
<script lang="uts" setup>
import { ref, computed } from 'vue'
import { subjectMaterials } from '@/mock/index.uts'
import { HrMaterial } from '@/mock/types.uts'
/**
* s02 / s41 资料详情(视频与文档共用同一页)
* 标题即资料名称;不再有章节目录与章节学习。
*/
const material = ref<HrMaterial>(subjectMaterials[1])
const starred = ref(false)
const downloaded = ref(false)
const page = ref(1)
const playing = ref(false)
const played = ref(62)
const videoH = ref(386)
const totalSeconds = 504
let timer : number = 0
const note = '按压深度 5~6 cm,频率 100~120 次/分;每次按压后必须让胸廓完全回弹,尽量减少中断(<10 秒)……'
// ⚠ UTS 不提升函数声明(App 端编译报 UTSC error18: 找不到名称),
// 被调用的函数一律写在调用点之前
function formatTime(sec : number) : string {
const m = Math.floor(sec / 60)
const s = sec % 60
const ss = s < 10 ? '0' + s.toString() : s.toString()
return `${m}:${ss}`
}
function stopTimer() : void {
if (timer != 0) {
clearInterval(timer)
timer = 0
}
}
const isVideo = computed(() : boolean => {
return material.value.kind == 'video'
})
const curTime = computed(() : string => {
const sec = Math.floor(totalSeconds * played.value / 100)
return formatTime(sec)
})
const totalTime = computed(() : string => {
return formatTime(totalSeconds)
})
onLoad((opt) => {
const raw = opt['id']
if (raw != null) {
const id = raw as string
for (let i = 0; i < subjectMaterials.length; i++) {
if (subjectMaterials[i].id == id) {
material.value = subjectMaterials[i]
played.value = subjectMaterials[i].progress > 0 ? subjectMaterials[i].progress : 0
break
}
}
}
// 视频区保持 16:9:内容宽 = 750rpx - 页面左右各 32rpx
videoH.value = Math.round((750 - 64) * 9 / 16)
})
onUnload(() => {
stopTimer()
})
function togglePlay() : void {
playing.value = !playing.value
if (playing.value) {
stopTimer()
timer = setInterval(() => {
if (played.value >= 100) {
played.value = 100
playing.value = false
stopTimer()
return
}
played.value = played.value + 1
}, 1000)
} else {
stopTimer()
}
}
function prevPage() : void {
if (page.value <= 1) {
uni.showToast({ title: '已是第一页', icon: 'none' })
return
}
page.value = page.value - 1
}
function nextPage() : void {
if (page.value >= 12) {
uni.showToast({ title: '已是最后一页', icon: 'none' })
return
}
page.value = page.value + 1
}
function onStar() : void {
starred.value = !starred.value
uni.showToast({ title: starred.value ? '已加入收藏' : '已取消收藏', icon: 'none' })
}
function onDownload() : void {
if (downloaded.value) {
uni.showToast({ title: '已下载到本机', icon: 'none' })
return
}
uni.showLoading({ title: '下载中' })
setTimeout(() => {
uni.hideLoading()
downloaded.value = true
uni.showToast({ title: '下载完成 · 可离线查阅', icon: 'success' })
}, 900)
}
function onMainAction() : void {
if (isVideo.value) {
onDownload()
return
}
uni.showToast({ title: '已用系统阅读器打开', icon: 'none' })
}
function onEditNote() : void {
uni.showToast({ title: '编辑学习笔记', icon: 'none' })
}
function onFullscreen() : void {
uni.showToast({ title: '已进入全屏播放', icon: 'none' })
}
</script>
<style>
/* ---------- 视频 ---------- */
.vd {
width: 100%;
background-color: #111827;
border-radius: 24rpx;
overflow: hidden;
position: relative;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
}
.vd-play {
width: 104rpx;
height: 104rpx;
border-radius: 52rpx;
background-color: rgba(255, 255, 255, 0.92);
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
}
.vd-bar {
position: absolute;
left: 0rpx;
right: 0rpx;
bottom: 0rpx;
padding: 0rpx 24rpx 20rpx 24rpx;
}
.vd-track {
height: 6rpx;
border-radius: 6rpx;
background-color: rgba(255, 255, 255, 0.28);
}
.vd-track-i {
height: 6rpx;
border-radius: 6rpx;
background-color: #2563EB;
}
.vd-row {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
margin-top: 16rpx;
}
.vd-time {
font-size: 24.6rpx;
color: #FFFFFF;
}
.vd-acts {
display: flex;
flex-direction: row;
align-items: center;
}
/* ---------- 文档预览 ---------- */
.dv {
background-color: #E5E7EB;
border-radius: 24rpx;
padding: 24rpx 24rpx 24rpx 24rpx;
display: flex;
flex-direction: row;
justify-content: center;
}
.dv-sheet {
width: 80%;
background-color: #FFFFFF;
border-radius: 8rpx;
padding: 30rpx 26rpx 32rpx 26rpx;
box-shadow: 0 6rpx 24rpx rgba(15, 23, 42, 0.16);
position: relative;
}
.dv-t {
height: 18rpx;
width: 58%;
border-radius: 6rpx;
background-color: #CBD5E1;
margin-bottom: 22rpx;
}
.dv-l {
height: 12rpx;
border-radius: 6rpx;
background-color: #E5E7EB;
margin-bottom: 16rpx;
}
.dv-l-s {
width: 88%;
}
.dv-l-m {
width: 70%;
}
.dv-img {
height: 100rpx;
border-radius: 12rpx;
background-color: #F1F5F9;
margin: 6rpx 0rpx 20rpx 0rpx;
}
.dv-pg {
font-size: 20.2rpx;
color: #9CA3AF;
}
.dv-pager {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
padding-top: 16rpx;
}
.dv-pager-b {
width: 48rpx;
height: 48rpx;
border-radius: 16rpx;
background-color: #FFFFFF;
border: 2rpx solid #E5E7EB;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
}
.dv-pager-t {
font-size: 23.6rpx;
color: #9CA3AF;
margin-left: 24rpx;
margin-right: 24rpx;
}
/* ---------- 详情 ---------- */
.md-title {
flex: 1;
font-size: 33.6rpx;
font-weight: bold;
color: #1F2937;
margin-right: 18rpx;
}
.md-badge {
border-radius: 14rpx;
padding: 6rpx 18rpx 6rpx 18rpx;
}
.md-badge-t {
font-size: 23.6rpx;
font-weight: bold;
color: #FFFFFF;
}
.md-meta {
font-size: 25.8rpx;
color: #6B7280;
margin-top: 12rpx;
}
.md-from {
font-size: 24.6rpx;
color: #9CA3AF;
margin-top: 8rpx;
}
.md-seen {
font-size: 24.6rpx;
font-weight: bold;
color: #2563EB;
margin-left: 20rpx;
}
.md-desc {
font-size: 28rpx;
color: #6B7280;
line-height: 1.8;
}
.md-note-time {
font-size: 23.6rpx;
color: #9CA3AF;
margin-top: 18rpx;
}
.md-fav {
flex: 0 0 220rpx;
margin-right: 18rpx;
}
.md-fav-t {
font-size: 31.4rpx;
font-weight: bold;
color: #1F2937;
margin-left: 10rpx;
}
.md-main {
flex: 1;
}
.md-main-t {
font-size: 32.4rpx;
font-weight: bold;
color: #FFFFFF;
}
</style>
+166
View File
@@ -0,0 +1,166 @@
<template>
<hr-page page-bg="#F3F4F6" status-bg="#FFFFFF" nav-title="顺序练习" @right-click="onMore">
<template v-slot:right>
<x-icon name="more-fill" color="#6B7280" font-size="18"></x-icon>
</template>
<view class="hr-body">
<!-- ============ hero ============ -->
<view class="prac-hero">
<view class="prac-c1"></view>
<view class="prac-ic">
<x-icon name="play-fill" color="#FFFFFF" font-size="27"></x-icon>
</view>
<text class="prac-t">{{ meta.subject }}</text>
<text class="prac-s">顺序练习 · 按章节顺序出题</text>
</view>
<!-- ============ 练习信息 ============ -->
<hr-card variant="list" style="margin-top:10px">
<hr-lrow icon="▦" theme="pri" title="题目数量" sub="覆盖本课题全部章节">
<template v-slot:right>
<text class="prac-val">{{ meta.total }} 题</text>
</template>
</hr-lrow>
<hr-lrow icon="◔" theme="amb" title="预计用时" sub="按每题 36 秒估算">
<template v-slot:right>
<text class="prac-val">约 {{ meta.minutes }} 分钟</text>
</template>
</hr-lrow>
<hr-lrow icon="✓" theme="grn" title="目标正确率" sub="达标后计入课题完成度" :is-last="true">
<template v-slot:right>
<text class="prac-val prac-val-grn">{{ meta.goal }}</text>
</template>
</hr-lrow>
</hr-card>
<hr-tip style="margin-top:10px" theme="warn" icon="⚑"
:text="'顺序练习不限制次数,答错的题目会自动进入错题本,可随时回来复习。'"></hr-tip>
<!-- ============ 上次练习 ============ -->
<hr-sec title="上次练习" :first="false"></hr-sec>
<hr-card>
<view class="hr-row-between">
<text class="prac-last">{{ meta.lastTime }} · 已练 {{ meta.lastDone }} 题</text>
<text class="prac-rate">正确率 {{ meta.lastRate }}%</text>
</view>
<view style="margin-top:9px">
<hr-progress :percent="lastPct"></hr-progress>
</view>
<text class="prac-note">上次练到第 {{ meta.lastDone }} 题,可继续或重新开始。</text>
</hr-card>
<view style="height:14px"></view>
</view>
<template v-slot:footer>
<view class="hr-fixedbar">
<view class="hr-btn hr-btn-primary hr-btn-lg" style="flex:1" @click="onStart">
<text class="prac-btn-t">开始练习</text>
</view>
</view>
</template>
</hr-page>
</template>
<script lang="uts" setup>
import { computed } from 'vue'
import { practiceMeta } from '@/mock/index.uts'
import { navTo } from '@/utils/nav.uts'
/** s36 顺序练习 */
const meta = practiceMeta
const lastPct = computed(() : number => {
return Math.round(meta.lastDone * 100 / meta.total)
})
function onStart() : void {
navTo('/pages/course/quiz?idx=0')
}
function onMore() : void {
uni.showToast({ title: '练习记录 / 重置进度', icon: 'none' })
}
</script>
<style>
.prac-hero {
background-image: linear-gradient(to bottom right, #2563EB, #1D4ED8);
border-radius: 32rpx;
padding: 44rpx 32rpx 44rpx 32rpx;
flex-direction: column;
align-items: center;
overflow: hidden;
position: relative;
}
.prac-c1 {
position: absolute;
right: -80rpx;
bottom: -104rpx;
width: 240rpx;
height: 240rpx;
border-radius: 120rpx;
background-color: rgba(255, 255, 255, 0.09);
}
.prac-ic {
width: 128rpx;
height: 128rpx;
border-radius: 40rpx;
background-color: rgba(255, 255, 255, 0.2);
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
margin-bottom: 24rpx;
}
.prac-t {
font-size: 35.8rpx;
font-weight: bold;
color: #FFFFFF;
}
.prac-s {
font-size: 25.8rpx;
color: rgba(255, 255, 255, 0.86);
margin-top: 10rpx;
}
.prac-val {
font-size: 33.6rpx;
font-weight: bold;
color: #1F2937;
margin-left: 16rpx;
}
.prac-val-grn {
color: #059669;
}
.prac-last {
font-size: 28rpx;
color: #6B7280;
}
.prac-rate {
font-size: 28rpx;
font-weight: bold;
color: #2563EB;
}
.prac-note {
font-size: 24.6rpx;
color: #9CA3AF;
margin-top: 14rpx;
}
.prac-btn-t {
font-size: 35.8rpx;
font-weight: bold;
color: #FFFFFF;
}
</style>
+396
View File
@@ -0,0 +1,396 @@
<template>
<hr-page page-bg="#F3F4F6" status-bg="#FFFFFF" :nav-title="'顺序练习 · 第 ' + (cur + 1) + ' 题'"
@right-click="onMarkCurrent">
<template v-slot:right>
<x-icon :name="isMarked ? 'bookmark-fill' : 'bookmark-line'" :color="isMarked ? '#F59E0B' : '#6B7280'"
font-size="19"></x-icon>
</template>
<view class="hr-body">
<!-- ============ 进度 ============ -->
<view class="hr-row">
<view style="flex:1">
<hr-progress :percent="pct"></hr-progress>
</view>
<text class="qz-prog">{{ cur + 1 }} / {{ total }}</text>
</view>
<!-- ============ 题卡 ============ -->
<hr-card style="margin-top:10px;padding:16px 14px 16px 14px">
<text class="qz-stem">{{ q.stem }}</text>
<view class="qz-opts">
<view v-for="(o, i) in q.options" :key="i" :class="optClass(i)" @click="choose(i)">
<view :class="optKeyClass(i)">
<text :class="optKeyTextClass(i)">{{ o.key }}</text>
</view>
<text class="qz-opt-t">{{ o.text }}</text>
</view>
</view>
<view class="qz-acts">
<view class="hr-btn hr-btn-ghost qz-act" @click="onMarkCurrent">
<x-icon :name="isMarked ? 'bookmark-fill' : 'bookmark-line'"
:color="isMarked ? '#F59E0B' : '#1F2937'" font-size="16"></x-icon>
<text class="qz-act-t">{{ isMarked ? '已标记' : '标记本题' }}</text>
</view>
<view class="hr-btn hr-btn-ghost qz-act qz-act-last" @click="openCard">
<x-icon name="layout-grid-line" color="#1F2937" font-size="16"></x-icon>
<text class="qz-act-t">答题卡</text>
</view>
</view>
</hr-card>
<hr-tip style="margin-top:10px" theme="info" icon=""
text="答错的题目会自动进入错题本,可在课题功能页统一复习。"></hr-tip>
<!-- 作答反馈 -->
<view v-if="showAnswer" class="qz-feedback" :class="isRight ? 'qz-feedback-ok' : 'qz-feedback-err'">
<view class="qz-fb-ic" :class="isRight ? 'qz-fb-ic-ok' : 'qz-fb-ic-err'">
<text class="qz-fb-ic-t">{{ isRight ? '✓' : '✕' }}</text>
</view>
<view class="hr-flex1">
<text class="qz-fb-b">{{ isRight ? '回答正确' : '回答错误,已加入错题本' }}</text>
<text class="qz-fb-s">正确答案:{{ rightAnswer }}</text>
<text class="qz-fb-s">{{ q.analysis }}</text>
</view>
</view>
<view style="height:14px"></view>
</view>
<template v-slot:footer>
<view class="hr-fixedbar">
<view class="hr-btn hr-btn-ghost qz-foot-prev" @click="prev">
<text class="qz-foot-t">上一题</text>
</view>
<view class="hr-btn hr-btn-primary" style="flex:1" @click="next">
<text class="qz-foot-t2">{{ isLast ? '交卷并查看成绩' : '下一题' }}</text>
</view>
</view>
</template>
</hr-page>
</template>
<script lang="uts" setup>
import { ref, computed } from 'vue'
import { quizQuestions } from '@/mock/index.uts'
import { quizState, setAnswer, toggleMark, answeredCount } from '@/utils/quiz.uts'
import { relaunchTo, navTo } from '@/utils/nav.uts'
import { HrQuestion } from '@/mock/types.uts'
/** s03 答题界面 */
const total = quizQuestions.length
const cur = ref(0)
const refreshKey = ref(0)
const q = computed(() : HrQuestion => {
return quizQuestions[cur.value]
})
const selected = computed(() : number => {
const k = refreshKey.value
return quizState.answers[cur.value]
})
const isMarked = computed(() : boolean => {
const k = refreshKey.value
return quizState.marked[cur.value]
})
const isLast = computed(() : boolean => {
return cur.value == total - 1
})
const pct = computed(() : number => {
return Math.round((cur.value + 1) * 100 / total)
})
const showAnswer = computed(() : boolean => {
const k = refreshKey.value
return quizState.answers[cur.value] >= 0
})
const isRight = computed(() : boolean => {
const k = refreshKey.value
return quizState.answers[cur.value] == q.value.answer
})
const rightAnswer = computed(() : string => {
const o = q.value.options[q.value.answer]
return `${o.key}. ${o.text}`
})
// uni-app x 页面显示生命周期:从答题卡返回时同步当前题号与作答状态
onPageShow(() => {
cur.value = quizState.current
refreshKey.value = refreshKey.value + 1
})
function optClass(i : number) : string {
if (selected.value == i) {
if (i == q.value.answer) { return 'qz-opt qz-opt-ok' }
return 'qz-opt qz-opt-sel'
}
if (showAnswer.value && i == q.value.answer) { return 'qz-opt qz-opt-ok' }
return 'qz-opt'
}
function optKeyClass(i : number) : string {
if (selected.value == i) {
if (i == q.value.answer) { return 'qz-ok qz-ok-ok' }
return 'qz-ok qz-ok-sel'
}
if (showAnswer.value && i == q.value.answer) { return 'qz-ok qz-ok-ok' }
return 'qz-ok'
}
function optKeyTextClass(i : number) : string {
if (selected.value == i || (showAnswer.value && i == q.value.answer)) {
return 'qz-ok-t qz-ok-t-on'
}
return 'qz-ok-t'
}
function choose(i : number) : void {
if (selected.value >= 0) {
uni.showToast({ title: '本题已作答', icon: 'none' })
return
}
setAnswer(cur.value, i)
refreshKey.value = refreshKey.value + 1
if (i != q.value.answer) {
// 答错自动进错题本(mock 阶段仅提示)
uni.showToast({ title: '已加入错题本', icon: 'none' })
}
}
function onMarkCurrent() : void {
toggleMark(cur.value)
refreshKey.value = refreshKey.value + 1
uni.showToast({ title: isMarked.value ? '已标记本题' : '已取消标记', icon: 'none' })
}
function prev() : void {
if (cur.value == 0) {
uni.showToast({ title: '已是第一题', icon: 'none' })
return
}
cur.value = cur.value - 1
quizState.current = cur.value
refreshKey.value = refreshKey.value + 1
}
// ⚠ submit 被 next() 调用,必须先定义(UTS 不提升函数声明)
function submit() : void {
const done = answeredCount()
uni.showModal({
title: '交卷确认',
content: `已作答 ${done} / ${total} 题${done < total ? ',仍有题目未作答' : ''}。交卷后无法修改。`,
cancelText: '继续答题',
confirmText: '交卷',
success: (res) => {
if (res.confirm) {
quizState.submitted = true
uni.showToast({ title: '已交卷 · 得分 92', icon: 'success' })
setTimeout(() => {
relaunchTo('/pages/study/exams')
}, 700)
}
}
})
}
function next() : void {
if (isLast.value) {
submit()
return
}
cur.value = cur.value + 1
quizState.current = cur.value
refreshKey.value = refreshKey.value + 1
}
function openCard() : void {
quizState.current = cur.value
navTo('/pages/course/answer-card')
}
</script>
<style>
.qz-prog {
font-size: 25.8rpx;
font-weight: bold;
color: #6B7280;
margin-left: 20rpx;
}
.qz-stem {
font-size: 33.6rpx;
font-weight: bold;
color: #1F2937;
line-height: 1.6;
}
.qz-opts {
flex-direction: column;
margin-top: 30rpx;
}
.qz-opt {
display: flex;
flex-direction: row;
align-items: center;
min-height: 112rpx;
padding: 20rpx 26rpx 20rpx 26rpx;
border: 3rpx solid #E5E7EB;
border-radius: 24rpx;
background-color: #FFFFFF;
margin-bottom: 18rpx;
}
.qz-opt-sel {
border: 3rpx solid #2563EB;
background-color: #EFF6FF;
}
.qz-opt-ok {
border: 3rpx solid #10B981;
background-color: #ECFDF5;
}
.qz-ok {
width: 52rpx;
height: 52rpx;
border-radius: 26rpx;
border: 3rpx solid #E5E7EB;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
margin-right: 22rpx;
}
.qz-ok-sel {
background-color: #2563EB;
border: 3rpx solid #2563EB;
}
.qz-ok-ok {
background-color: #10B981;
border: 3rpx solid #10B981;
}
.qz-ok-t {
font-size: 26.8rpx;
font-weight: bold;
color: #6B7280;
}
.qz-ok-t-on {
color: #FFFFFF;
}
.qz-opt-t {
flex: 1;
font-size: 30.2rpx;
color: #1F2937;
}
.qz-acts {
display: flex;
flex-direction: row;
margin-top: 30rpx;
}
.qz-act {
flex: 1;
height: 88rpx;
margin-right: 20rpx;
}
.qz-act-last {
margin-right: 0rpx;
}
.qz-act-t {
font-size: 30.2rpx;
font-weight: bold;
color: #1F2937;
margin-left: 12rpx;
}
.qz-feedback {
display: flex;
flex-direction: row;
align-items: flex-start;
margin-top: 20rpx;
padding: 22rpx 24rpx 22rpx 24rpx;
border-radius: 22rpx;
}
.qz-feedback-ok {
background-color: #ECFDF5;
border: 2rpx solid #A7F3D0;
}
.qz-feedback-err {
background-color: #FEF2F2;
border: 2rpx solid #FECACA;
}
.qz-fb-ic {
width: 52rpx;
height: 52rpx;
border-radius: 16rpx;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
margin-right: 20rpx;
}
.qz-fb-ic-ok {
background-color: #10B981;
}
.qz-fb-ic-err {
background-color: #DC2626;
}
.qz-fb-ic-t {
font-size: 29.2rpx;
font-weight: bold;
color: #FFFFFF;
}
.qz-fb-b {
font-size: 28rpx;
font-weight: bold;
color: #1F2937;
}
.qz-fb-s {
font-size: 24.6rpx;
color: #6B7280;
margin-top: 6rpx;
}
.qz-foot-prev {
flex: 0 0 220rpx;
margin-right: 18rpx;
}
.qz-foot-t {
font-size: 31.4rpx;
font-weight: bold;
color: #1F2937;
}
.qz-foot-t2 {
font-size: 32.4rpx;
font-weight: bold;
color: #FFFFFF;
}
</style>
+361
View File
@@ -0,0 +1,361 @@
<template>
<hr-page page-bg="#F3F4F6" status-bg="#FFFFFF" nav-title="课题详情" :xloading="true" @right-click="onMore">
<template v-slot:right>
<x-icon name="more-fill" color="#6B7280" font-size="18"></x-icon>
</template>
<!-- ============ 课题 hero ============ -->
<view class="subj-bar">
<view class="subj-c1"></view>
<view class="subj-info">
<view class="subj-nav" @click="prevSubject">
<x-icon name="arrow-left-s-line" color="#FFFFFF" font-size="18"></x-icon>
</view>
<view class="subj-mid">
<view class="hr-flex1">
<text class="subj-name">{{ subject }}</text>
<text class="subj-sub">急救基础 · 心肺复苏 · 资料 6 份 · 题库 50</text>
</view>
</view>
<view class="subj-nav" @click="nextSubject">
<x-icon name="arrow-right-s-line" color="#FFFFFF" font-size="18"></x-icon>
</view>
</view>
</view>
<view class="hr-body">
<!-- ============ 学习进度 ============ -->
<hr-card>
<view class="hr-row-between" style="margin-bottom:9px">
<text class="sj-lb">学习进度</text>
<text class="sj-pct">{{ progress }}%</text>
</view>
<hr-progress :percent="progress"></hr-progress>
<view class="hr-row" style="margin-top:11px">
<text class="sj-meta">已学资料 <text class="sj-meta-v">4/6</text></text>
<text class="sj-meta sj-meta-gap">已练题 <text class="sj-meta-v">32/50</text></text>
<text class="sj-meta sj-meta-gap">正确率 <text class="sj-meta-ok">{{ rate }}%</text></text>
</view>
</hr-card>
<!-- ============ 功能入口 ============ -->
<hr-sec title="功能入口"></hr-sec>
<view class="sj-entries">
<view v-for="(e, i) in entries" :key="i" class="sj-entry"
:class="i == entries.length - 1 ? 'sj-entry-last' : ''" @click="onEntry(i)">
<view class="sj-entry-ic" :style="{ backgroundColor: e.bg }">
<x-icon :name="entryIcon(e.icon)" :color="e.color" font-size="40rpx"></x-icon>
</view>
<text class="sj-entry-b">{{ e.name }}</text>
<text class="sj-entry-s">{{ e.sub }}</text>
</view>
</view>
<!-- ============ 文档资料 ============ -->
<hr-sec title="文档资料" more="共 6 份 " @more-click="onMore"></hr-sec>
<hr-card variant="list">
<view v-for="(m, i) in materials" :key="m.id" class="doc-row"
:class="i == materials.length - 1 ? 'doc-row-last' : ''" @click="openMaterial(m)">
<view class="doc-ic" :style="{ backgroundColor: m.badgeColor }">
<x-icon v-if="m.kind == 'video'" name="play-fill" color="#FFFFFF" font-size="32rpx"></x-icon>
<text v-else class="doc-ic-tx">{{ m.icon }}</text>
</view>
<view class="hr-flex1">
<text class="doc-b">{{ m.title }}</text>
<text class="doc-s">{{ m.meta }}</text>
</view>
<view class="doc-go">
<x-icon name="arrow-right-s-line" color="#9CA3AF" font-size="34rpx"></x-icon>
</view>
</view>
</hr-card>
<view class="doc-hint">
<text class="doc-hint-t">✦ 点击任意资料查看详情(视频与文档共用同一详情页)</text>
</view>
<view style="height:14px"></view>
</view>
</hr-page>
</template>
<script lang="uts" setup>
import { ref, computed } from 'vue'
import { subjectMaterials, courseGroups } from '@/mock/index.uts'
import { HrMaterial, HrSubject } from '@/mock/types.uts'
import { navTo } from '@/utils/nav.uts'
import { toRemixIcon } from '@/utils/icon.uts'
/**
* s35 课题详情
* 不再设章节目录与章节学习;改为「学习进度 + 错题/收藏/顺序练习 三入口 + 文档资料列表」。
*/
type Entry = { icon : string, name : string, sub : string, bg : string, color : string }
/*
* ⚠ 模板里不能直接调用「import 进来的函数」:
* uvue 编译器会把 `<x-icon :name="toRemixIcon(e.icon)">` 编译成 `unref(toRemixIcon)(e.icon)`
* UTS/Kotlin 侧报 `找不到名称"invoke"` / `Function invocation 'toRemixIcon(...)' expected`。
* 必须包一层本文件内定义的函数(模板对 script 内定义的函数会走 _ctx.xxx,正常)。
*/
function entryIcon(glyph : string) : string {
return toRemixIcon(glyph)
}
const subject = ref('成人 CPR(高级生命支持)')
const progress = ref(68)
const rate = ref(78)
const materials = ref<HrMaterial[]>(subjectMaterials)
const entries : Entry[] = [
{ icon: '✗', name: '我的错题', sub: '12 道', bg: '#FEF2F2', color: '#DC2626' } as Entry,
{ icon: '★', name: '我的收藏', sub: '8 道', bg: '#FEF3C7', color: '#B45309' } as Entry,
{ icon: '▸', name: '顺序练习', sub: '50 题 · 约 30 分钟', bg: '#ECFDF5', color: '#059669' } as Entry
]
/** 全部三级课题,用于上一个 / 下一个切换 */
const allSubjects = computed(() : HrSubject[] => {
const list : HrSubject[] = []
for (let gi = 0; gi < courseGroups.length; gi++) {
const g = courseGroups[gi]
for (let ii = 0; ii < g.items.length; ii++) {
list.push(g.items[ii])
}
}
return list
})
function indexOfSubject() : number {
for (let i = 0; i < allSubjects.value.length; i++) {
if (allSubjects.value[i].name == subject.value) { return i }
}
return 0
}
function prevSubject() : void {
let i = indexOfSubject() - 1
if (i < 0) { i = allSubjects.value.length - 1 }
subject.value = allSubjects.value[i].name
}
function nextSubject() : void {
let i = indexOfSubject() + 1
if (i >= allSubjects.value.length) { i = 0 }
subject.value = allSubjects.value[i].name
}
function onEntry(i : number) : void {
if (i == 0) { navTo('/pages/study/wrong-book'); return }
if (i == 1) { uni.showToast({ title: '我的收藏 · 8 道题', icon: 'none' }); return }
navTo('/pages/course/practice')
}
function openMaterial(m : HrMaterial) : void {
navTo('/pages/course/media?id=' + m.id)
}
function onMore() : void {
uni.showToast({ title: '资料管理 / 下载全部', icon: 'none' })
}
</script>
<style>
/* ---------- hero ---------- */
.subj-bar {
background-image: linear-gradient(to bottom right, #2563EB, #1D4ED8);
padding: 24rpx 28rpx 24rpx 28rpx;
overflow: hidden;
position: relative;
}
.subj-c1 {
position: absolute;
right: -72rpx;
top: -92rpx;
width: 260rpx;
height: 260rpx;
border-radius: 130rpx;
background-color: rgba(255, 255, 255, 0.09);
}
.subj-info {
display: flex;
flex-direction: row;
align-items: center;
}
.subj-nav {
width: 52rpx;
height: 52rpx;
border-radius: 16rpx;
background-color: rgba(255, 255, 255, 0.18);
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
}
.subj-mid {
flex: 1;
display: flex;
flex-direction: row;
align-items: center;
margin-left: 18rpx;
margin-right: 18rpx;
}
.subj-name {
font-size: 31.4rpx;
font-weight: bold;
color: #FFFFFF;
lines: 1;
}
.subj-sub {
font-size: 23.6rpx;
color: rgba(255, 255, 255, 0.85);
margin-top: 4rpx;
lines: 1;
}
/* ---------- 学习进度 ---------- */
.sj-lb {
font-size: 29.2rpx;
font-weight: bold;
color: #1F2937;
}
.sj-pct {
font-size: 25.8rpx;
font-weight: bold;
color: #2563EB;
}
.sj-meta {
font-size: 24.6rpx;
color: #6B7280;
}
.sj-meta-gap {
margin-left: 28rpx;
}
.sj-meta-v {
font-size: 24.6rpx;
color: #1F2937;
font-weight: bold;
}
.sj-meta-ok {
font-size: 24.6rpx;
color: #059669;
font-weight: bold;
}
/* ---------- 功能入口 ---------- */
.sj-entries {
display: flex;
flex-direction: row;
}
.sj-entry {
flex: 1;
background-color: #FFFFFF;
border: 2rpx solid #E5E7EB;
border-radius: 26rpx;
padding: 26rpx 12rpx 22rpx 12rpx;
flex-direction: column;
align-items: center;
margin-right: 18rpx;
}
.sj-entry-last {
margin-right: 0rpx;
}
.sj-entry-ic {
width: 80rpx;
height: 80rpx;
border-radius: 26rpx;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
margin-bottom: 14rpx;
}
.sj-entry-b {
font-size: 25.8rpx;
font-weight: bold;
color: #1F2937;
}
.sj-entry-s {
font-size: 21.2rpx;
color: #9CA3AF;
margin-top: 4rpx;
}
/* ---------- 资料行 ---------- */
.doc-row {
display: flex;
flex-direction: row;
align-items: center;
padding-top: 24rpx;
padding-bottom: 24rpx;
border-bottom: 2rpx solid #F3F4F6;
}
.doc-row-last {
border-bottom-width: 0rpx;
}
.doc-ic {
width: 72rpx;
height: 72rpx;
border-radius: 20rpx;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
margin-right: 22rpx;
}
.doc-ic-tx {
font-size: 21.2rpx;
font-weight: bold;
color: #FFFFFF;
}
.doc-b {
font-size: 29.2rpx;
color: #1F2937;
lines: 1;
}
.doc-s {
font-size: 23.6rpx;
color: #9CA3AF;
margin-top: 4rpx;
}
.doc-go {
margin-left: 16rpx;
display: flex;
flex-direction: row;
align-items: center;
}
.doc-hint {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
padding: 16rpx 0rpx 4rpx 0rpx;
}
.doc-hint-t {
font-size: 23.6rpx;
color: #9CA3AF;
}
</style>
+291
View File
@@ -0,0 +1,291 @@
<template>
<hr-page page-bg="#F3F4F6" status-bg="#FFFFFF" nav-title="事件详情" :xloading="true" @right-click="onMore">
<template v-slot:right>
<x-icon name="more-fill" color="#6B7280" font-size="18"></x-icon>
</template>
<view class="hr-body">
<!-- ============ 事件 hero ============ -->
<view class="evt-head">
<view class="evt-head-c1"></view>
<view class="evt-head-c2"></view>
<text class="evt-head-t">{{ detail.name }}</text>
<text class="evt-head-s">{{ detail.sub }}</text>
<view class="eh-team">
<text class="eh-team-k">{{ detail.teamKey }}</text>
<view class="eh-team-line"></view>
<text class="eh-team-v">{{ detail.teamValue }}</text>
</view>
<view class="evt-tags">
<view v-for="(tg, i) in detail.tags" :key="i" class="evt-tag">
<text class="evt-tag-t">{{ tg }}</text>
</view>
</view>
<view class="evt-kpi">
<view class="evt-kpi-cell">
<text class="evt-kpi-b">{{ detail.victimTotal }}</text>
<text class="evt-kpi-s">伤员总数</text>
</view>
<view class="evt-kpi-sep"></view>
<view class="evt-kpi-cell">
<text class="evt-kpi-b">{{ detail.transferred }}</text>
<text class="evt-kpi-s">已转运</text>
</view>
<view class="evt-kpi-sep"></view>
<view class="evt-kpi-cell">
<text class="evt-kpi-b">{{ detail.pending }}</text>
<text class="evt-kpi-s">待处置</text>
</view>
</view>
</view>
<!-- ============ 4 色分级计数条(常驻) ============ -->
<view style="margin-top:10px">
<hr-wband :red="detail.redCount" :amber="detail.amberCount" :green="detail.greenCount"
:black="detail.blackCount"></hr-wband>
</view>
<!-- ============ 工作台 ============ -->
<hr-sec title="工作台" more="4 个入口 "></hr-sec>
<view class="ws-grid">
<view v-for="(w, i) in works" :key="i" class="ws-item"
:class="i == works.length - 1 ? 'ws-item-last' : ''" @click="onWorkClick(i)">
<view class="ws-ic" :style="{ backgroundColor: w.bg }">
<x-icon :name="w.icon" :color="w.color" font-size="36rpx"></x-icon>
</view>
<text class="ws-t">{{ w.name }}</text>
</view>
</view>
<!-- ============ 救援团队 ============ -->
<hr-sec title="救援团队" more="5 人"></hr-sec>
<hr-card>
<x-avatar-group :list="teamAvatars" size="88rpx" round="28rpx" gutter="18rpx" :max-count="5"
:show-count="false" :flat="true" :random-bg-color="true" font-size="30rpx"></x-avatar-group>
<text class="tm-line">组长 张明远 · 检伤 李承志 · 转运 王志远</text>
</hr-card>
<!-- ============ 事件时间线 ============ -->
<hr-sec title="事件时间线"></hr-sec>
<hr-card>
<hr-timeline :nodes="detail.timeline"></hr-timeline>
</hr-card>
<view style="height:14px"></view>
</view>
</hr-page>
</template>
<script lang="uts" setup>
import { eventDetail } from '@/mock/index.uts'
import { navTo } from '@/utils/nav.uts'
/**
* s34 事件详情(执行态工作台)
* 工作台由 8 入口精简为 4 入口:检伤建档 / 伤员查询 / 批量处置 / 批量转运。
* 4 色分级计数条常驻 hero 下方。救援端不开放事件上报入口(救援员是接单方)。
*/
type WorkItem = { icon : string, name : string, bg : string, color : string, url : string }
const detail = eventDetail
/** xAvatarGroup 需要 string[] */
const teamAvatars : string[] = ['张', '李', '王', '陈', '赵']
const works : WorkItem[] = [
{ icon: 'heart-pulse-line', name: '检伤建档', bg: '#FEF2F2', color: '#DC2626', url: '/pages/triage/create' } as WorkItem,
{ icon: 'file-list-3-line', name: '伤员查询', bg: '#EFF6FF', color: '#2563EB', url: '/pages/triage/victims' } as WorkItem,
{ icon: 'clipboard-line', name: '批量处置', bg: '#ECFDF5', color: '#059669', url: '/pages/triage/batch-treat' } as WorkItem,
{ icon: 'arrow-left-right-line', name: '批量转运', bg: '#FFFBEB', color: '#D97706', url: '/pages/triage/batch-transfer' } as WorkItem
]
function onWorkClick(i : number) : void {
navTo(works[i].url)
}
function onMore() : void {
uni.showToast({ title: '事件归档 / 分享 / 报错', icon: 'none' })
}
</script>
<style>
/* ---------- hero ---------- */
.evt-head {
background-image: linear-gradient(to bottom right, #1E3A8A, #2563EB);
border-radius: 32rpx;
padding: 28rpx 28rpx 28rpx 28rpx;
overflow: hidden;
position: relative;
}
.evt-head-c1 {
position: absolute;
right: -68rpx;
top: -88rpx;
width: 252rpx;
height: 252rpx;
border-radius: 126rpx;
background-color: rgba(255, 255, 255, 0.10);
}
.evt-head-c2 {
position: absolute;
right: 52rpx;
bottom: -96rpx;
width: 156rpx;
height: 156rpx;
border-radius: 78rpx;
background-color: rgba(255, 255, 255, 0.07);
}
.evt-head-t {
font-size: 34.8rpx;
font-weight: bold;
color: #FFFFFF;
}
.evt-head-s {
font-size: 25.8rpx;
color: rgba(255, 255, 255, 0.85);
margin-top: 12rpx;
}
.eh-team {
display: flex;
flex-direction: row;
align-items: center;
margin-top: 20rpx;
background-color: rgba(255, 255, 255, 0.14);
border: 2rpx solid rgba(255, 255, 255, 0.22);
border-radius: 20rpx;
padding: 18rpx 22rpx 18rpx 22rpx;
}
.eh-team-k {
font-size: 22.4rpx;
font-weight: bold;
color: rgba(255, 255, 255, 0.78);
}
.eh-team-line {
width: 2rpx;
height: 24rpx;
background-color: rgba(255, 255, 255, 0.28);
margin-left: 18rpx;
margin-right: 18rpx;
}
.eh-team-v {
flex: 1;
font-size: 26.8rpx;
color: #FFFFFF;
}
.evt-tags {
display: flex;
flex-direction: row;
margin-top: 20rpx;
}
.evt-tag {
background-color: rgba(255, 255, 255, 0.18);
border-radius: 14rpx;
padding: 6rpx 18rpx 6rpx 18rpx;
margin-right: 12rpx;
}
.evt-tag-t {
font-size: 23.6rpx;
font-weight: bold;
color: #FFFFFF;
}
.evt-kpi {
display: flex;
flex-direction: row;
margin-top: 28rpx;
}
.evt-kpi-cell {
flex: 1;
flex-direction: column;
align-items: center;
}
.evt-kpi-b {
font-size: 42.6rpx;
font-weight: bold;
color: #FFFFFF;
}
.evt-kpi-s {
font-size: 22.4rpx;
color: rgba(255, 255, 255, 0.82);
margin-top: 6rpx;
}
.evt-kpi-sep {
width: 2rpx;
height: 60rpx;
background-color: rgba(255, 255, 255, 0.22);
margin-top: 4rpx;
}
/* ---------- 工作台 ---------- */
.ws-grid {
display: flex;
flex-direction: row;
}
.ws-item {
flex: 1;
background-color: #FFFFFF;
border: 2rpx solid #E5E7EB;
border-radius: 26rpx;
padding: 28rpx 8rpx 28rpx 8rpx;
flex-direction: column;
align-items: center;
margin-right: 16rpx;
}
.ws-item-last {
margin-right: 0rpx;
}
.ws-ic {
width: 76rpx;
height: 76rpx;
border-radius: 24rpx;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
margin-bottom: 16rpx;
}
.ws-ic-t {
font-size: 38rpx;
font-weight: bold;
}
.ws-t {
font-size: 25.8rpx;
color: #1F2937;
}
/* ---------- 团队 ---------- */
.tm-avt {
margin-right: 18rpx;
margin-bottom: 18rpx;
}
.tm-line {
font-size: 25.8rpx;
color: #6B7280;
margin-top: 8rpx;
}
</style>
+275
View File
@@ -0,0 +1,275 @@
<template>
<view class="hr-page" style="background-color:#F3F4F6;position:relative">
<hr-statusbar bg="#FFFFFF"></hr-statusbar>
<!-- ============ 执行状态 Tab(进行中 / 历史) ============ -->
<x-tabs :list="tabs" :model-value="activeId" item-width="50%" height="88rpx" font-size="28rpx"
active-font-size="28rpx" title-color="#6B7280" active-title-color="#2563EB" line-color="#2563EB"
title-padding="0" :is-item-center="true" @change="onTabChange"></x-tabs>
<view class="hr-flow">
<scroll-view class="hr-scroll" :scroll-y="true" :show-scrollbar="false">
<view class="hr-body">
<view v-for="(e, i) in shownList" :key="e.id" :class="cardClass(e)" @click="onCardClick(e)">
<view class="ev-top">
<view class="evt-dot" :style="dotStyle(e)"></view>
<text class="ev-name">{{ e.name }}</text>
<hr-pc :level="e.priority"></hr-pc>
</view>
<text class="ev-meta">{{ e.kind }}</text>
<text class="ev-meta2">现场:{{ e.place }} · {{ e.distance }}</text>
<view class="ev-foot">
<x-avatar-group :list="avatarsOf(e)" size="44rpx" round="14rpx" gutter="12rpx"
:max-count="5" :random-bg-color="true" font-size="22rpx"></x-avatar-group>
<view class="hr-flex1"></view>
<view class="hr-row">
<text class="ev-go">{{ e.stage == 'history' ? '已完成' : '详情' }}</text>
<x-icon name="arrow-right-s-line" color="#2563EB" font-size="26rpx"></x-icon>
</view>
</view>
</view>
<!-- 进行中说明 -->
<text v-if="tab == 0" class="ev-note">仅显示与我相关的派单</text>
<text v-if="tab == 0" class="ev-note2">时间与处理状态由后台同步,APP 端不展示</text>
<hr-empty v-if="shownList.length == 0" icon="alarm-warning-line" title="暂无事件"
sub="分配给你的派单会出现在「进行中」,处置完成后归档到「历史」"></hr-empty>
<view style="height:28rpx"></view>
</view>
</scroll-view>
<!-- 加载中覆盖层(组件库 xLoading -->
<view v-if="loading" class="hr-loading-mask">
<x-loading label="加载中…" :icon-size="'44rpx'" :text-size="'26rpx'" :vertical="true" color="#2563EB"
text-color="#6B7280"></x-loading>
</view>
</view>
<hr-tabbar :active="1"></hr-tabbar>
</view>
</template>
<script lang="uts" setup>
import { ref, computed } from 'vue'
import { eventList, eventDetail } from '@/mock/index.uts'
import { HrEventItem, HrEventMember } from '@/mock/types.uts'
import { navTo } from '@/utils/nav.uts'
import { mockRequest } from '@/utils/request.uts'
import { TABS_ITEM_INFO, TABS_ITEM } from '@/uni_modules/tmx-ui/interface.uts'
import { markEventsRead } from '@/utils/unread.uts'
/**
* s07 / s42 事件中心(执行态)
* 用「执行状态」Tab 切换:进行中 / 历史(组件库 xTabs)。
* ⚠ 原「待接收」Tab 及其全屏推送接收流程(60 秒倒计时 / 立即接收 / 稍后处理)已移除,
* 页面只服务进行中与历史两类事件。
*/
const stageIds : string[] = ['0', '1']
const activeId = ref('0')
const loading = ref(true)
/*
* 紧急事件红点「呼吸」光环
* ⚠ App 端(uvue)不支持 @keyframes / animation(编译期直接报错),
* 改为定时器逐帧推进 pulseF,再由 dotStyle() 下发 box-shadow。
* 周期 1.7s = 25 帧 × 68ms;第 0~19 帧扩散(19/25 ≈ 设计稿 75%),第 20~24 帧静默。
* 触发条件只看数据的 urgent 字段(与 stage 无关)。
*/
const PULSE_FRAMES = 25
const PULSE_RAMP = 19
const pulseF = ref(0)
let pulseTimer : number = 0
function startPulse() : void {
if (pulseTimer != 0) { return }
pulseTimer = setInterval(() => {
const f = pulseF.value + 1
pulseF.value = f >= PULSE_FRAMES ? 0 : f
}, 68)
}
function stopPulse() : void {
if (pulseTimer != 0) {
clearInterval(pulseTimer)
pulseTimer = 0
}
}
/** 红点样式:底色取数据里的 dotColor;紧急事件额外叠加扩散光环 */
function dotStyle(e : HrEventItem) : string {
const base = 'background-color:' + e.dotColor + ';'
if (!e.urgent) { return base }
const i = pulseF.value
// 扩散结束后的静默帧:半径与透明度都归零(设计稿 75% → 100%)
if (i > PULSE_RAMP) { return base + 'box-shadow:0 0 0 0rpx rgba(220, 38, 38, 0);' }
const k = i / PULSE_RAMP
const r = Math.round(k * 16)
const a = Math.round(55 * (1 - k))
return base + 'box-shadow:0 0 0 ' + r.toString() + 'rpx rgba(220, 38, 38, 0.' + a.toString() + ');'
}
onLoad(() => {
// 模拟 1s 请求,期间展示 xSkeleton 骨架屏
mockRequest(() => {
loading.value = false
})
startPulse()
})
// 进入事件列表即视为已查看 → TabBar 事件角标隐藏;同时恢复红点呼吸
onPageShow(() => {
markEventsRead()
startPulse()
})
// 页面不可见时停掉呼吸定时器,避免后台空转
onPageHide(() => {
stopPulse()
})
/** 当前 tab 下标(由 xTabs 的字符串 id 换算) */
const tab = computed(() : number => {
const i = stageIds.indexOf(activeId.value)
return i < 0 ? 0 : i
})
/** 顶部执行状态切换:只展示标题,不带数量角标(角标会挤压标题并干扰视线) */
const tabs = computed(() : TABS_ITEM_INFO[] => {
return [
{ title: '进行中', id: '0' } as TABS_ITEM_INFO,
{ title: '历史', id: '1' } as TABS_ITEM_INFO
]
})
const shownList = computed(() : HrEventItem[] => {
const stage = tab.value == 0 ? 'ongoing' : 'history'
const list : HrEventItem[] = []
for (let i = 0; i < eventList.length; i++) {
if (eventList[i].stage == stage) { list.push(eventList[i]) }
}
return list
})
/** xAvatarGroup 需要 string[] */
function avatarsOf(e : HrEventItem) : string[] {
const arr : string[] = []
for (let i = 0; i < e.members.length; i++) {
arr.push(e.members[i].avatar)
}
return arr
}
// ⚠ x-tabs 的 change 下发的是 TABS_ITEM(库内部把 TABS_ITEM_INFO 转成了 TABS_ITEM),
// 形参写 TABS_ITEM_INFO 会在 App 端报 Kotlin 类型不匹配
function onTabChange(item : TABS_ITEM, index : number) : void {
activeId.value = item.id == '' ? index.toString() : item.id
}
function cardClass(e : HrEventItem) : string {
return e.urgent ? 'evt-card evt-urgent' : 'evt-card'
}
function onFilter() : void {
uni.showToast({ title: '筛选:按状态 / 优先级 / 时间', icon: 'none' })
}
/** 点卡片统一进入事件详情(原「待接收 → 唤起全屏推送」分支已随待接收 Tab 一并移除) */
function onCardClick(e : HrEventItem) : void {
navTo('/pages/event/detail')
}
onUnload(() => {
stopPulse()
})
</script>
<style>
/* 顶部标题栏 / 自绘 Tab / 数量角标的样式均已移除:
导航由 hr-statusbar 承担,状态切换改用组件库 x-tabs。 */
/* ---------- 事件卡片 ---------- */
.evt-card {
background-color: #FFFFFF;
border: 2rpx solid #E5E7EB;
border-radius: 26rpx;
padding: 24rpx 26rpx 24rpx 26rpx;
margin-bottom: 18rpx;
}
.evt-urgent {
border: 2rpx solid #FECACA;
background-color: #FFFBFB;
}
.ev-top {
display: flex;
flex-direction: row;
align-items: center;
}
.evt-dot {
width: 16rpx;
height: 16rpx;
border-radius: 8rpx;
margin: 0 16rpx;
}
.ev-name {
flex: 1;
font-size: 30.2rpx;
font-weight: bold;
color: #1F2937;
margin-right: 16rpx;
}
.ev-meta {
font-size: 25.8rpx;
color: #6B7280;
margin-top: 12rpx;
}
.ev-meta2 {
font-size: 25.8rpx;
color: #6B7280;
margin-top: 2rpx;
}
.ev-foot {
display: flex;
flex-direction: row;
align-items: center;
margin-top: 18rpx;
}
.ev-go {
font-size: 25.8rpx;
color: #2563EB;
font-weight: bold;
}
.ev-note {
text-align: center;
font-size: 23.6rpx;
color: #9CA3AF;
margin-top: 16rpx;
}
.ev-note2 {
text-align: center;
font-size: 23.6rpx;
color: #9CA3AF;
margin-top: 2rpx;
}
/* 紧急事件红点呼吸(设计稿 evtPulse)
⚠ App 端 uvue 不支持 @keyframes / animation(编译期直接报 ERROR),
已改为页面内 setInterval 推进 pulseF,再由 dotStyle() 下发 box-shadow —— 见 script 顶部。
原 P0 全屏推送的 .ev-push* 整组样式已随「待接收」流程一并移除。 */
</style>
+314
View File
@@ -0,0 +1,314 @@
<template>
<hr-page page-bg="#F3F4F6" status-bg="#FFFFFF" nav-title="" :xloading="true">
<!-- ============ 顶部 hero(日常学习总览) ============ -->
<view class="tr-hero">
<view class="tr-hero-c1"></view>
<view class="tr-hero-c2"></view>
<view class="tr-hero-row">
<view class="hr-row hr-flex1">
<view class="tr-avt">
<text class="tr-avt-t">{{ user.avatar }}</text>
</view>
<view class="hr-flex1">
<text class="tr-hi">{{ user.name }} · {{ user.title }}</text>
<text class="tr-sub">{{ user.dept }} · 工号 {{ user.jobNo }} · 培训等级 B</text>
</view>
</view>
<view class="tr-bell" @click="goMsg">
<x-icon name="notification-3-line" color="#FFFFFF" font-size="17"></x-icon>
<view class="tr-bell-dot"></view>
</view>
</view>
<view class="tr-statbar">
<view class="tr-sb-cell">
<view class="hr-row tr-sb-bwrap">
<text class="tr-sb-b">3.5</text>
<text class="tr-sb-u">h</text>
</view>
<text class="tr-sb-s">本周学时</text>
</view>
<view class="tr-sb-sep"></view>
<view class="tr-sb-cell">
<view class="hr-row tr-sb-bwrap">
<text class="tr-sb-b">12</text>
<text class="tr-sb-u">门</text>
</view>
<text class="tr-sb-s">已完成课程</text>
</view>
<view class="tr-sb-sep"></view>
<view class="tr-sb-cell">
<view class="hr-row tr-sb-bwrap">
<text class="tr-sb-b">3</text>
<text class="tr-sb-u">张</text>
</view>
<text class="tr-sb-s">有效证书</text>
</view>
</view>
</view>
<view class="hr-body">
<!-- ============ 待办任务 ============ -->
<hr-sec title="待办任务" more="全部 " first @more-click="onTodoAll"></hr-sec>
<hr-card variant="list">
<hr-lrow v-for="(t, i) in todos" :key="i" :icon="t.icon" :theme="t.iconTheme" :title="t.title"
:sub="t.sub" :value="t.action" value-theme="pri" :value-bold="true" :is-last="i == todos.length - 1"
@click="onTodoClick(i)"></hr-lrow>
</hr-card>
<!-- ============ 学习动态 ============ -->
<hr-sec title="学习动态" more="全部 " @more-click="onActivityAll"></hr-sec>
<hr-card variant="list">
<hr-lrow v-for="(a, i) in acts" :key="i" :icon="a.icon" :theme="a.iconTheme" :title="a.title"
:sub="a.sub" value="" :is-last="i == acts.length - 1" @click="onActivityClick(i)"></hr-lrow>
</hr-card>
<!-- ============ 工具箱 ============ -->
<hr-sec title="工具箱"></hr-sec>
<view class="tr-quick">
<view v-for="(q, i) in tools" :key="i" class="tr-q" :class="i == tools.length - 1 ? 'tr-q-last' : ''"
@click="onToolClick(i)">
<view class="tr-q-ic" :style="{ backgroundColor: q.iconBg }">
<x-icon :name="q.icon" :color="q.iconColor" font-size="36rpx"></x-icon>
</view>
<text class="tr-q-t">{{ q.name }}</text>
</view>
</view>
<view style="height:14px"></view>
</view>
<template v-slot:tabbar>
<hr-tabbar :active="0"></hr-tabbar>
</template>
</hr-page>
</template>
<script lang="uts" setup>
import { ref } from 'vue'
import { hrSession } from '@/utils/role.uts'
import { navTo } from '@/utils/nav.uts'
import { todoTasks, activities, toolBox } from '@/mock/index.uts'
import { HrTodoTask, HrActivity, HrToolBox } from '@/mock/types.uts'
/**
* s01 学员端首页(日常态 · 纯学习)
* 设计约束:首页只承载日常学习(学习总览 / 待办任务 / 学习动态 / 工具箱),
* 不含任何现场作业入口 —— 现场作业从「事件」Tab 进入。
*/
const user = hrSession
const todos = ref<HrTodoTask[]>(todoTasks)
const acts = ref<HrActivity[]>(activities)
const tools = ref<HrToolBox[]>(toolBox)
function goMsg() : void {
navTo('/pages/message/list')
}
function onTodoAll() : void {
navTo('/pages/course/library')
}
function onTodoClick(i : number) : void {
if (i == 0) {
navTo('/pages/course/subject')
} else {
navTo('/pages/study/exams')
}
}
function onActivityAll() : void {
navTo('/pages/study/center')
}
function onActivityClick(i : number) : void {
navTo('/pages/study/center')
}
function onToolClick(i : number) : void {
if (i == 0) { navTo('/pages/course/library'); return }
if (i == 1) { navTo('/pages/study/exams'); return }
if (i == 2) { navTo('/pages/study/certificates'); return }
navTo('/pages/study/wrong-book')
}
</script>
<style>
/* ---------- hero ---------- */
.tr-hero {
background-image: linear-gradient(to bottom right, #2563EB, #1E40AF);
padding: 28rpx 32rpx 44rpx 32rpx;
overflow: hidden;
position: relative;
}
.tr-hero-c1 {
position: absolute;
right: -80rpx;
top: -100rpx;
width: 300rpx;
height: 300rpx;
border-radius: 150rpx;
background-color: rgba(255, 255, 255, 0.10);
}
.tr-hero-c2 {
position: absolute;
right: 64rpx;
bottom: -112rpx;
width: 192rpx;
height: 192rpx;
border-radius: 96rpx;
background-color: rgba(255, 255, 255, 0.07);
}
.tr-hero-row {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
}
.tr-avt {
width: 88rpx;
height: 88rpx;
border-radius: 44rpx;
background-color: rgba(255, 255, 255, 0.22);
border: 3rpx solid rgba(255, 255, 255, 0.5);
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
margin-right: 22rpx;
}
.tr-avt-t {
font-size: 35.8rpx;
font-weight: bold;
color: #FFFFFF;
}
.tr-hi {
font-size: 40.4rpx;
font-weight: bold;
color: #FFFFFF;
}
.tr-sub {
font-size: 25.8rpx;
color: rgba(255, 255, 255, 0.82);
margin-top: 6rpx;
}
.tr-bell {
width: 68rpx;
height: 68rpx;
border-radius: 34rpx;
background-color: rgba(255, 255, 255, 0.16);
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
}
.tr-bell-dot {
position: absolute;
margin-left: 28rpx;
margin-top: -24rpx;
width: 14rpx;
height: 14rpx;
border-radius: 8rpx;
background-color: #DC2626;
}
.tr-statbar {
display: flex;
flex-direction: row;
background-color: #FFFFFF;
border-radius: 28rpx;
margin-top: 32rpx;
padding: 28rpx 8rpx 28rpx 8rpx;
box-shadow: 0 12rpx 36rpx rgba(15, 23, 42, 0.10);
}
.tr-sb-cell {
flex: 1;
flex-direction: column;
align-items: center;
}
.tr-sb-bwrap {
justify-content: center;
}
.tr-sb-b {
font-size: 44.8rpx;
font-weight: bold;
color: #1F2937;
}
.tr-sb-u {
font-size: 24.6rpx;
color: #6B7280;
margin-left: 2rpx;
}
.tr-sb-s {
font-size: 24.6rpx;
color: #6B7280;
margin-top: 8rpx;
}
.tr-sb-sep {
width: 2rpx;
height: 64rpx;
margin-top: 4rpx;
background-color: #E5E7EB;
}
/* ---------- 工具箱 ---------- */
.tr-quick {
display: flex;
flex-direction: row;
}
.tr-q {
flex: 1;
background-color: #FFFFFF;
border: 2rpx solid #E5E7EB;
border-radius: 24rpx;
padding: 24rpx 4rpx 20rpx 4rpx;
flex-direction: column;
align-items: center;
margin-right: 16rpx;
}
.tr-q-last {
margin-right: 0rpx;
}
.tr-q-ic {
width: 72rpx;
height: 72rpx;
border-radius: 22rpx;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
margin-bottom: 14rpx;
}
.tr-q-ic-t {
font-size: 35.8rpx;
font-weight: bold;
}
.tr-q-t {
font-size: 24.6rpx;
color: #1F2937;
}
</style>
+237
View File
@@ -0,0 +1,237 @@
<template>
<view class="hr-page" style="background-color:#FFFFFF">
<hr-statusbar bg="#FFFFFF"></hr-statusbar>
<view class="lg-wrap">
<!-- 品牌标识 -->
<view class="lg-logo">
<x-icon name="add-line" color="#FFFFFF" font-size="34" font-weight="bold"></x-icon>
</view>
<text class="lg-title">医院救援管理平台</text>
<text class="lg-sub">请使用院内工号登录\n角色由后台根据工号自动识别</text>
<!-- 角色识别结果(点击可切换演示角色) -->
<view class="lg-role" @click="toggleRole">
<view class="lg-role-ic">
<x-icon :name="role == 'keeper' ? 'archive-line' : 'shield-cross-line'" color="#FFFFFF"
font-size="19"></x-icon>
</view>
<view class="hr-flex1">
<text class="lg-role-t">{{ roleLabel }}</text>
<text class="lg-role-s">工号识别后自动进入对应工作台 · 点此切换演示角色</text>
</view>
<x-icon name="arrow-left-right-line" color="#3B6BC7" font-size="16"></x-icon>
</view>
<!-- 表单(xInput -->
<view class="lg-form">
<x-input v-model="jobNo" left-text="工号" left-icon="user-3-line" placeholder="请输入院内工号"
round="22rpx" height="96rpx" font-size="30rpx" color="#F9FAFB" font-color="#1F2937"
icon-color="#9CA3AF"></x-input>
<view class="lg-field-gap">
<x-input v-model="password" :password="true" left-text="密码" left-icon="lock-line"
placeholder="请输入密码" round="22rpx" height="96rpx" font-size="30rpx" color="#F9FAFB"
font-color="#1F2937" icon-color="#9CA3AF"></x-input>
</view>
</view>
<view class="lg-opt">
<view class="hr-row">
<x-switch v-model="remember" size="small" color="#10B981"></x-switch>
<text class="lg-opt-t">记住工号</text>
</view>
<text class="lg-opt-forget" @click="onForget">忘记密码?</text>
</view>
<view class="hr-btn hr-btn-primary hr-btn-lg lg-btn" @click="onLogin">
<text class="lg-btn-t">登 录</text>
</view>
<text class="lg-foot">登录即表示同意《服务协议》与《隐私政策》\n遇到问题请联系急救中心值班室 8801</text>
</view>
</view>
</template>
<script lang="uts" setup>
import { ref, computed } from 'vue'
import { loginAs, hrSession } from '@/utils/role.uts'
import { relaunchTo } from '@/utils/nav.uts'
import { HrRole } from '@/mock/types.uts'
/**
* s26 登录
* 双角色入口:默认按设计稿为「救援人员 · 急救中心」,
* 点击角色卡可在「救援人员端 / 仓管人员端」之间切换,用于演示双角色差异。
*/
const role = ref<HrRole>('rescuer')
const jobNo = ref('1247')
const password = ref('123456')
const remember = ref(true)
const roleLabel = computed(() : string => {
return role.value == 'keeper' ? '仓管人员 · 后勤保障科' : '救援人员 · 急救中心'
})
function toggleRole() : void {
if (role.value == 'rescuer') {
role.value = 'keeper'
jobNo.value = '2083'
} else {
role.value = 'rescuer'
jobNo.value = '1247'
}
}
function onForget() : void {
uni.showModal({ title: '忘记密码', content: '请联系急救中心值班室 8801 重置工号密码。', showCancel: false })
}
function onLogin() : void {
if (jobNo.value == '') {
uni.showToast({ title: '请输入工号', icon: 'none' })
return
}
if (password.value == '') {
uni.showToast({ title: '请输入密码', icon: 'none' })
return
}
uni.showLoading({ title: '登录中' })
// 工号 2083 段自动识别为仓管人员端
let r : HrRole = role.value
if (jobNo.value.startsWith('2')) { r = 'keeper' }
if (jobNo.value.startsWith('1')) { r = 'rescuer' }
loginAs(r)
setTimeout(() => {
uni.hideLoading()
relaunchTo(hrSession.role == 'keeper' ? '/pages/warehouse/scan' : '/pages/home/index')
}, 400)
}
</script>
<style>
.lg-wrap {
flex: 1;
padding-left: 52rpx;
padding-right: 52rpx;
padding-bottom: 60rpx;
flex-direction: column;
}
.lg-logo {
width: 132rpx;
height: 132rpx;
border-radius: 40rpx;
background-image: linear-gradient(to bottom right, #2563EB, #1D4ED8);
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
margin-top: 52rpx;
align-self: center;
box-shadow: 0 20rpx 52rpx rgba(37, 99, 235, 0.30);
}
.lg-title {
text-align: center;
font-size: 42.6rpx;
font-weight: bold;
color: #1F2937;
margin-top: 36rpx;
}
.lg-sub {
text-align: center;
font-size: 26.8rpx;
color: #9CA3AF;
margin-top: 14rpx;
line-height: 1.6;
}
.lg-role {
margin-top: 48rpx;
display: flex;
flex-direction: row;
align-items: center;
background-color: #EFF6FF;
border: 2rpx solid #BFDBFE;
border-radius: 26rpx;
padding: 24rpx 26rpx 24rpx 26rpx;
}
.lg-role-ic {
width: 76rpx;
height: 76rpx;
border-radius: 24rpx;
background-color: #2563EB;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
margin-right: 22rpx;
}
.lg-role-t {
font-size: 29.2rpx;
font-weight: bold;
color: #1E40AF;
}
.lg-role-s {
font-size: 24.6rpx;
color: #3B6BC7;
margin-top: 6rpx;
}
.lg-form {
margin-top: 36rpx;
flex-direction: column;
}
.lg-field-gap {
margin-top: 22rpx;
}
.lg-opt {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
margin-top: 26rpx;
}
.lg-opt-t {
font-size: 26.8rpx;
color: #6B7280;
margin-left: 16rpx;
}
.lg-opt-forget {
font-size: 26.8rpx;
color: #2563EB;
font-weight: bold;
}
.lg-btn {
margin-top: 44rpx;
}
.lg-btn-t {
font-size: 35.8rpx;
font-weight: bold;
color: #FFFFFF;
}
.lg-foot {
text-align: center;
font-size: 25.8rpx;
color: #9CA3AF;
margin-top: 32rpx;
}
.lg-foot2 {
text-align: center;
font-size: 25.8rpx;
color: #9CA3AF;
margin-top: 2rpx;
}
</style>
+147
View File
@@ -0,0 +1,147 @@
<template>
<hr-page page-bg="#F3F4F6" status-bg="#FFFFFF" nav-title="通讯录" @right-click="onSearch">
<template v-slot:right>
<x-icon name="search-line" color="#6B7280" font-size="34rpx"></x-icon>
</template>
<view class="hr-body">
<!-- ============ 分组列表:指挥调度 / 协作医院 / 本队同事 ============ -->
<view v-for="(g, gi) in groups" :key="gi">
<hr-sec :title="g.title" :first="gi == 0"></hr-sec>
<!-- 卡片内每行:左侧头像/机构图标 + 姓名与号码 + 呼叫按钮 -->
<hr-card padding="4rpx 26rpx">
<view v-for="(c, ci) in g.items" :key="ci"
:class="ci == g.items.length - 1 ? 'hr-lrow hr-lrow-last' : 'hr-lrow'" @click="onCall(c)">
<view class="ct-blk" :style="blkStyle(c)">
<!-- 机构(医院 / 协作单位)用图标,个人用姓名首字 -->
<x-icon v-if="!c.useAvatar" name="hospital-line" color="#2563EB"
font-size="30rpx"></x-icon>
<text v-else class="ct-blk-t">{{ blkText(c) }}</text>
</view>
<view class="hr-lmain">
<text class="hr-lmain-b">{{ c.name }}</text>
<text class="hr-lmain-s">{{ c.phone }}</text>
</view>
<x-icon name="phone-fill" color="#2563EB" font-size="32rpx"></x-icon>
</view>
</hr-card>
</view>
<view style="height:14px"></view>
</view>
</hr-page>
</template>
<script lang="uts" setup>
import { contactGroups } from '@/mock/index.uts'
import { HrContact, HrContactGroup } from '@/mock/types.uts'
/**
* s28 通讯录
* 与设计稿一致:按「指挥调度 / 协作医院 / 本队同事」三段分组,
* 每组 = 小节标题 + 卡片列表行(头像 + 姓名 + 号码 + 呼叫)。
* 数据全部来自 mock/index.uts,换接口只改取数处。
*/
const groups : HrContactGroup[] = contactGroups
/** 头像文字:优先用数据里的字母头,缺省回落到姓名首字 */
function blkText(c : HrContact) : string {
if (c.avatar != '') { return c.avatar }
if (c.name.length > 0) { return c.name.substring(0, 1) }
return '医'
}
/**
* 头像底色。uvue 不支持复合类选择器(.ct-blk.ct-blk-gn),
* 因此主题色用内联样式下发;机构块固定浅蓝底。
*/
function blkStyle(c : HrContact) : string {
if (!c.useAvatar) { return 'background-color:#EFF6FF;' }
if (c.theme == 'gn') { return 'background-color:#059669;' }
if (c.theme == 'ag') { return 'background-color:#D97706;' }
if (c.theme == 'rd') { return 'background-color:#DC2626;' }
if (c.theme == 'gy') { return 'background-color:#94A3B8;' }
return 'background-color:#2563EB;'
}
/** 只保留数字字符(0-9 */
function digitsOnly(s : string) : string {
const table = '0123456789'
let out = ''
for (let i = 0; i < s.length; i++) {
const ch = s.charAt(i)
if (table.indexOf(ch) > -1) { out += ch }
}
return out
}
/**
* 从「值班室 8801 · 手机 138****2201」这类文案里取出可拨号码。
* 规则:按「·」分段,取「标签 号码」中的号码部分,
* 纯数字(不含 * 掩码)、长度≥4 才算号码;「工号 1248」这类直接跳过。
*/
function extractPhone(text : string) : string {
const parts = text.split('·')
for (let i = 0; i < parts.length; i++) {
const seg = parts[i].trim()
const idx = seg.indexOf(' ')
const label = idx >= 0 ? seg.substring(0, idx) : ''
const raw = idx >= 0 ? seg.substring(idx + 1) : seg
if (label.indexOf('工号') > -1) { continue }
const digits = digitsOnly(raw)
const compact = raw.replaceAll(' ', '').replaceAll('-', '')
if (digits.length >= 4 && digits.length == compact.length) { return digits }
}
return ''
}
// ⚠ UTS 不提升函数声明:extractPhone / digitsOnly 必须写在 onCall 之前
function onCall(c : HrContact) : void {
const digits = extractPhone(c.phone)
if (digits == '') {
uni.showToast({ title: '暂无可用号码', icon: 'none' })
return
}
uni.showModal({
title: c.name,
content: `呼叫 ${digits}`,
confirmText: '呼叫',
success: (res) => {
if (!res.confirm) { return }
// #ifdef APP
uni.makePhoneCall({ phoneNumber: digits })
// #endif
// #ifndef APP
uni.showToast({ title: '已复制号码 ' + digits, icon: 'none' })
uni.setClipboardData({ data: digits })
// #endif
}
})
}
function onSearch() : void {
uni.showToast({ title: '搜索联系人 / 医院', icon: 'none' })
}
</script>
<style>
/* 头像 / 机构图标块:设计稿 .avt.sm / .lic30~32px */
.ct-blk {
width: 64rpx;
height: 64rpx;
border-radius: 18rpx;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
margin-right: 20rpx;
}
.ct-blk-t {
font-size: 25rpx;
font-weight: bold;
color: #FFFFFF;
}
</style>
+191
View File
@@ -0,0 +1,191 @@
<template>
<view class="hr-page" style="background-color:#F3F4F6">
<hr-statusbar bg="#FFFFFF"></hr-statusbar>
<view class="ms-nav">
<text class="ms-nav-t">消息</text>
<view class="ms-nav-a" @click="onReadAll">
<x-icon name="check-double-line" color="#6B7280" font-size="16"></x-icon>
<text class="ms-nav-a-t">全部已读</text>
</view>
</view>
<view class="hr-flow">
<scroll-view class="hr-scroll" :scroll-y="true" :show-scrollbar="false">
<view class="hr-body">
<!-- 消息分类筛选(组件库 xRadioButton 分段切换) -->
<view style="margin-bottom:8rpx">
<x-radio-button v-model="chipId" :list="chipTabs" height="64rpx" space="4rpx" round="22rpx"
text-style="font-weight:bold;" bg-color="#E5E7EB" active-color="#2563EB"
active-font-color="#FFFFFF" font-color="#6B7280" font-size="22rpx"></x-radio-button>
</view>
<view v-for="(g, gi) in groups" :key="gi">
<hr-sec :title="g.title" :more="g.count > 0 ? g.count.toString() + ' 条' : ''"></hr-sec>
<hr-card :variant="g.danger ? 'danger' : 'list'">
<hr-lrow v-for="(m, i) in g.items" :key="i" :icon="m.icon" :theme="m.theme" :title="m.title"
:sub="m.sub" :value="m.action" :value-theme="m.theme == 'red' ? 'red' : 'pri'"
:value-bold="true" :is-last="i == g.items.length - 1" @click="onMsgClick(m, gi, i)">
<template v-slot:right>
<view v-if="isUnread(gi, i, m.unread)" class="hr-badge">
<text class="hr-badge-t">{{ m.unread }}</text>
</view>
</template>
</hr-lrow>
</hr-card>
</view>
<hr-empty v-if="groups.length == 0" icon="✉" title="暂无消息"
sub="库存预警、借用审批与系统通知会汇总到这里"></hr-empty>
<view style="height:28rpx"></view>
</view>
</scroll-view>
<!-- 加载中覆盖层(组件库 xLoading -->
<view v-if="loading" class="hr-loading-mask">
<x-loading label="加载中…" :icon-size="'44rpx'" :text-size="'26rpx'" :vertical="true" color="#2563EB"
text-color="#6B7280"></x-loading>
</view>
</view>
<hr-tabbar :active="3"></hr-tabbar>
</view>
</template>
<script lang="uts" setup>
import { ref, computed } from 'vue'
import { keeperMsgGroups } from '@/mock/index.uts'
import { HrMsg, HrMsgGroup } from '@/mock/types.uts'
import { navTo } from '@/utils/nav.uts'
import { mockRequest } from '@/utils/request.uts'
import { markMessagesRead } from '@/utils/unread.uts'
import { RADIO_BUTTON } from '@/uni_modules/tmx-ui/interface.uts'
/**
* s33 消息中心 · 物资端(仓管 Tab「消息」)
* 库存预警自动置顶;借用审批「去审批」直达设备借出台账。
* 角标 = 未读数,阅读后消失:整页查看清 tabbar/入口角标,点开单条清该条角标。
*/
/**
* 消息分类筛选(组件库 xRadioButton 分段切换,替代原 hr-chip 横向滚动)
* ⚠ RADIO_BUTTON 的 id 是 string:统一用下标字符串,选中值即筛选序号。
*/
const chipTabs : RADIO_BUTTON[] = [
{ id: '0', title: '全部 9' } as RADIO_BUTTON,
{ id: '1', title: '库存预警 4' } as RADIO_BUTTON,
{ id: '2', title: '借用审批 3' } as RADIO_BUTTON,
{ id: '3', title: '系统 2' } as RADIO_BUTTON
]
const chipId = ref('0')
/** 由字符串 id 换算分类序号,供分组筛选使用 */
const chip = computed(() : number => {
const i = parseInt(chipId.value)
return isNaN(i) ? 0 : i
})
/** 已读的条目(key = 分组下标-条目下标) */
const readKeys = ref<string[]>([])
const loading = ref(true)
onLoad(() => {
mockRequest(() => {
loading.value = false
})
})
onPageShow(() => {
markMessagesRead()
})
function keyOf(gi : number, i : number) : string {
return gi.toString() + '-' + i.toString()
}
function isRead(gi : number, i : number) : boolean {
return readKeys.value.indexOf(keyOf(gi, i)) > -1
}
function isUnread(gi : number, i : number, n : number) : boolean {
if (n <= 0) { return false }
return !isRead(gi, i)
}
function markRead(gi : number, i : number) : void {
const k = keyOf(gi, i)
if (readKeys.value.indexOf(k) > -1) { return }
const arr = readKeys.value
arr.push(k)
readKeys.value = arr
}
const groups = computed(() : HrMsgGroup[] => {
if (chip.value == 1) {
return [keeperMsgGroups[0]]
}
if (chip.value == 2) {
return [keeperMsgGroups[1]]
}
if (chip.value == 3) {
return [keeperMsgGroups[2]]
}
return keeperMsgGroups
})
function onMsgClick(m : HrMsg, gi : number, i : number) : void {
markRead(gi, i)
if (m.action == '去审批 ') {
navTo('/pages/warehouse/devices')
return
}
uni.showModal({ title: m.title, content: m.sub, showCancel: false })
}
function onReadAll() : void {
// 全部标记已读 → 所有条目角标一并消失
const arr : string[] = []
for (let gi = 0; gi < groups.value.length; gi++) {
const g = groups.value[gi]
for (let i = 0; i < g.items.length; i++) {
arr.push(keyOf(gi, i))
}
}
readKeys.value = arr
markMessagesRead()
uni.showToast({ title: '已全部标记为已读', icon: 'success' })
}
</script>
<style>
.ms-nav {
height: 96rpx;
display: flex;
flex-direction: row;
align-items: center;
padding-left: 32rpx;
padding-right: 32rpx;
background-color: #FFFFFF;
border-bottom: 2rpx solid #F3F4F6;
}
.ms-nav-t {
font-size: 33.6rpx;
font-weight: bold;
color: #1F2937;
}
.ms-nav-a {
margin-left: auto;
display: flex;
flex-direction: row;
align-items: center;
}
.ms-nav-a-t {
font-size: 29.2rpx;
color: #6B7280;
margin-left: 8rpx;
}
</style>
+88
View File
@@ -0,0 +1,88 @@
<template>
<hr-page page-bg="#F3F4F6" status-bg="#FFFFFF" nav-title="消息中心" :xloading="true" @right-click="onReadAll">
<template v-slot:right>
<x-icon name="check-double-line" color="#6B7280" font-size="17"></x-icon>
</template>
<view class="hr-body">
<!-- 消息分类筛选(组件库 xRadioButton 分段切换) -->
<view style="margin-bottom:8rpx">
<x-radio-button v-model="chipId" :list="chipTabs" height="64rpx" space="4rpx" round="22rpx"
text-style="font-weight:bold;" bg-color="#E5E7EB" active-color="#2563EB"
active-font-color="#FFFFFF" font-color="#6B7280" font-size="22rpx"></x-radio-button>
</view>
<view v-for="(g, gi) in groups" :key="gi">
<hr-sec :title="g.title" :more="g.count > 0 ? g.count.toString() + ' 条' : ''"></hr-sec>
<hr-card :variant="g.danger ? 'danger' : 'list'">
<hr-lrow v-for="(m, i) in g.items" :key="i" :icon="m.icon" :theme="m.theme" :title="m.title"
:sub="m.sub" :value="m.action" :value-theme="m.theme == 'red' ? 'red' : 'pri'"
:value-bold="true" :is-last="i == g.items.length - 1" @click="onMsgClick(m)"></hr-lrow>
</hr-card>
</view>
<view style="height:14px"></view>
</view>
</hr-page>
</template>
<script lang="uts" setup>
import { ref, computed } from 'vue'
import { rescuerMsgGroups } from '@/mock/index.uts'
import { HrMsg, HrMsgGroup } from '@/mock/types.uts'
import { navTo } from '@/utils/nav.uts'
import { markMessagesRead } from '@/utils/unread.uts'
import { RADIO_BUTTON } from '@/uni_modules/tmx-ui/interface.uts'
/**
* s27 消息中心 · 救护端
* P0 派单消息可直接「去接收」,跳转到事件中心查看进行中的派单。
* 查看后清未读 → 「我的」页消息中心入口角标消失。
*/
/**
* 消息分类筛选(组件库 xRadioButton 分段切换,替代原 hr-chip 横向滚动)
* ⚠ RADIO_BUTTON 的 id 是 string:统一用下标字符串,选中值即筛选序号。
*/
const chipTabs : RADIO_BUTTON[] = [
{ id: '0', title: '全部 5' } as RADIO_BUTTON,
{ id: '1', title: '派单通知 2' } as RADIO_BUTTON,
{ id: '2', title: '系统消息 3' } as RADIO_BUTTON
]
const chipId = ref('0')
/** 由字符串 id 换算分类序号,供 groups 筛选使用 */
const chip = computed(() : number => {
const i = parseInt(chipId.value)
return isNaN(i) ? 0 : i
})
onPageShow(() => {
markMessagesRead()
})
const groups = computed(() : HrMsgGroup[] => {
if (chip.value == 1) { return [rescuerMsgGroups[0]] }
if (chip.value == 2) { return [rescuerMsgGroups[1]] }
return rescuerMsgGroups
})
function onMsgClick(m : HrMsg) : void {
if (m.action == '去接收 ') {
navTo('/pages/event/index')
return
}
if (m.title == 'BLS 证书即将到期提醒') {
navTo('/pages/study/certificates')
return
}
if (m.title.indexOf('新课题') > -1) {
navTo('/pages/course/library')
return
}
uni.showModal({ title: m.title, content: m.sub, showCancel: false })
}
function onReadAll() : void {
uni.showToast({ title: '已全部标记为已读', icon: 'success' })
}
</script>
+300
View File
@@ -0,0 +1,300 @@
<template>
<hr-page page-bg="#F3F4F6" status-bg="#FFFFFF" nav-title="" :xloading="true">
<!-- ============ 个人 hero ============ -->
<view class="mine-hero">
<view class="mine-c1"></view>
<view class="mine-c2"></view>
<view class="mine-row">
<view class="mine-avt">
<text class="mine-avt-t">{{ user.avatar }}</text>
</view>
<view class="hr-flex1">
<text class="mine-name">{{ user.name }}</text>
<text class="mine-role">{{ user.title }} · {{ user.dept }} · 工号 {{ user.jobNo }}</text>
</view>
<view class="mine-edit" @click="onEdit">
<text class="mine-edit-t">编辑</text>
</view>
</view>
<view class="mine-tags">
<view v-for="(t, i) in user.tags" :key="i" class="mine-tag">
<text class="mine-tag-t">{{ t }}</text>
</view>
</view>
</view>
<!-- ============ 数据条(上浮压住 hero ============ -->
<view class="mine-stats">
<view class="mine-stat-cell">
<text class="mine-stat-b">{{ user.hours }}</text>
<text class="mine-stat-s">累计学时</text>
</view>
<view class="mine-stat-sep"></view>
<view class="mine-stat-cell">
<text class="mine-stat-b">{{ user.courseCount }}</text>
<text class="mine-stat-s">完成课程</text>
</view>
<view class="mine-stat-sep"></view>
<view class="mine-stat-cell">
<text class="mine-stat-b">{{ user.certCount }}</text>
<text class="mine-stat-s">有效证书</text>
</view>
</view>
<view class="hr-body" style="padding-top:16px">
<!-- 救援人员端:学习档案 -->
<hr-card v-if="isRescuer" variant="list">
<hr-lrow icon="▦" theme="pri" title="学习中心" sub="错题 12 · 收藏 8 · 笔记 5" value=""
@click="go('/pages/study/center')"></hr-lrow>
<hr-lrow icon="▤" theme="grn" title="我的证书" sub="3 张有效 · 1 张即将到期" value="" :is-last="true"
@click="go('/pages/study/certificates')"></hr-lrow>
</hr-card>
<!-- 仓管人员端:仓储概览 -->
<hr-card v-if="!isRescuer" variant="list">
<hr-lrow icon="!" theme="red" title="库存预警" sub="6 项低于预警线" value="去处理 " value-theme="red"
:value-bold="true" @click="go('/pages/warehouse/stock')"></hr-lrow>
<hr-lrow icon="⚑" theme="amb" title="借用审批" sub="3 条待审批" value="去审批 " value-theme="pri"
:value-bold="true" @click="go('/pages/message/index')"></hr-lrow>
<hr-lrow icon="⚙" theme="pri" title="设备台账" sub="24 台 · 4 台借出中" value="" :is-last="true"
@click="go('/pages/warehouse/devices')"></hr-lrow>
</hr-card>
<!-- 通用条目 -->
<hr-card variant="list" style="margin-top:10px">
<hr-lrow icon="✉" theme="amb" title="消息中心" :sub="msgSub" value=""
@click="go(isRescuer ? '/pages/message/list' : '/pages/message/index')">
<template v-slot:right>
<view v-if="hrUnread.msgCenter > 0" class="hr-badge">
<text class="hr-badge-t">{{ hrUnread.msgCenter }}</text>
</view>
</template>
</hr-lrow>
<hr-lrow v-if="isRescuer" icon="☎" title="通讯录" sub="指挥员 / 医院 / 同事" value=""
@click="go('/pages/message/contacts')"></hr-lrow>
<hr-lrow v-if="!isRescuer" icon="▦" theme="pri" title="扫码出入库" sub="连续扫码 · 三态即时反馈"
value="" @click="go('/pages/warehouse/scan')"></hr-lrow>
<hr-lrow icon="⚙" title="设置" sub="通知 / 隐私 / 关于" value="" :is-last="true"
@click="go('/pages/mine/settings')"></hr-lrow>
</hr-card>
<hr-card variant="list" style="margin-top:10px">
<hr-lrow icon="question-line" title="帮助与反馈" sub="使用手册 · 问题上报" value="" :is-last="true"
@click="onHelp"></hr-lrow>
</hr-card>
<view class="mine-logout" @click="onLogout">
<text class="mine-logout-t">退出登录</text>
</view>
<view style="height:14px"></view>
</view>
<template v-slot:tabbar>
<hr-tabbar :active="isRescuer ? 2 : 4"></hr-tabbar>
</template>
</hr-page>
</template>
<script lang="uts" setup>
import { computed } from 'vue'
import { hrSession, logout } from '@/utils/role.uts'
import { navTo, relaunchTo } from '@/utils/nav.uts'
import { hrUnread } from '@/utils/unread.uts'
/**
* s08 个人中心(双角色共用)
* 救援端:学习档案(学习中心 / 证书)+ 消息 + 通讯录
* 仓管端:仓储概览(库存预警 / 借用审批 / 设备台账)+ 消息 + 扫码
*/
const user = hrSession
const isRescuer = computed(() : boolean => {
return user.role != 'keeper'
})
const msgSub = computed(() : string => {
return isRescuer.value ? '派单通知 · 系统消息' : '库存预警 · 借用审批 · 系统'
})
function go(url : string) : void {
navTo(url)
}
function onEdit() : void {
uni.showToast({ title: '编辑个人资料', icon: 'none' })
}
function onHelp() : void {
uni.showModal({ title: '帮助与反馈', content: '使用手册 / 问题上报\n值班室电话 8801', showCancel: false })
}
function onLogout() : void {
uni.showModal({
title: '退出登录',
content: '确认退出当前账号?',
success: (res) => {
if (res.confirm) {
logout()
relaunchTo('/pages/login/index')
}
}
})
}
</script>
<style>
.mine-hero {
background-image: linear-gradient(to bottom right, #2563EB, #1E40AF);
padding: 32rpx 32rpx 52rpx 32rpx;
overflow: hidden;
position: relative;
}
.mine-c1 {
position: absolute;
right: -80rpx;
top: -96rpx;
width: 296rpx;
height: 296rpx;
border-radius: 148rpx;
background-color: rgba(255, 255, 255, 0.10);
}
.mine-c2 {
position: absolute;
right: 68rpx;
bottom: -104rpx;
width: 184rpx;
height: 184rpx;
border-radius: 92rpx;
background-color: rgba(255, 255, 255, 0.07);
}
.mine-row {
display: flex;
flex-direction: row;
align-items: center;
}
.mine-avt {
width: 112rpx;
height: 112rpx;
border-radius: 36rpx;
background-color: rgba(255, 255, 255, 0.22);
border: 3rpx solid rgba(255, 255, 255, 0.5);
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
margin-right: 26rpx;
}
.mine-avt-t {
font-size: 49.2rpx;
font-weight: bold;
color: #FFFFFF;
}
.mine-name {
font-size: 38rpx;
font-weight: bold;
color: #FFFFFF;
}
.mine-role {
font-size: 25.8rpx;
color: rgba(255, 255, 255, 0.85);
margin-top: 8rpx;
}
.mine-edit {
padding: 14rpx 24rpx 14rpx 24rpx;
border-radius: 18rpx;
background-color: rgba(255, 255, 255, 0.2);
}
.mine-edit-t {
font-size: 26.8rpx;
font-weight: bold;
color: #FFFFFF;
}
.mine-tags {
display: flex;
flex-direction: row;
flex-wrap: wrap;
margin-top: 18rpx;
}
.mine-tag {
padding: 6rpx 18rpx 6rpx 18rpx;
border-radius: 14rpx;
background-color: rgba(255, 255, 255, 0.18);
margin-right: 12rpx;
margin-bottom: 12rpx;
}
.mine-tag-t {
font-size: 23.6rpx;
color: #FFFFFF;
}
.mine-stats {
display: flex;
flex-direction: row;
background-color: #FFFFFF;
border-radius: 28rpx;
margin-top: -28rpx;
margin-left: 32rpx;
margin-right: 32rpx;
padding: 26rpx 8rpx 26rpx 8rpx;
box-shadow: 0 12rpx 36rpx rgba(15, 23, 42, 0.10);
}
.mine-stat-cell {
flex: 1;
flex-direction: column;
align-items: center;
}
.mine-stat-b {
font-size: 42.6rpx;
font-weight: bold;
color: #1F2937;
}
.mine-stat-s {
font-size: 23.6rpx;
color: #6B7280;
margin-top: 6rpx;
}
.mine-stat-sep {
width: 2rpx;
height: 56rpx;
background-color: #F3F4F6;
margin-top: 6rpx;
}
.mine-logout {
margin-top: 32rpx;
height: 88rpx;
border-radius: 24rpx;
background-color: #FFFFFF;
border: 2rpx solid #E5E7EB;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
}
.mine-logout-t {
font-size: 32.4rpx;
font-weight: bold;
color: #DC2626;
}
</style>
+80
View File
@@ -0,0 +1,80 @@
<template>
<hr-page page-bg="#F3F4F6" status-bg="#FFFFFF" nav-title="设置" :xloading="true">
<view class="hr-body">
<!-- ============ 通知 ============ -->
<hr-sec title="通知" first></hr-sec>
<hr-card variant="list">
<view v-for="(s, i) in switches" :key="i" class="hr-lrow"
:class="i == switches.length - 1 ? 'hr-lrow-last' : ''">
<view class="hr-lmain">
<text class="hr-lmain-b">{{ s.title }}</text>
<text class="hr-lmain-s">{{ s.sub }}</text>
</view>
<x-switch v-model="s.on" color="#10B981" size="small"></x-switch>
</view>
</hr-card>
<!-- ============ 离线与存储 ============ -->
<hr-sec title="离线与存储"></hr-sec>
<hr-card variant="list">
<hr-lrow v-for="(r, i) in offlineRows" :key="i" :title="r.title" :sub="r.sub" :value="r.value"
:is-last="i == offlineRows.length - 1" @click="onRowClick(i, true)"></hr-lrow>
</hr-card>
<!-- ============ 通用 ============ -->
<hr-sec title="通用"></hr-sec>
<hr-card variant="list">
<hr-lrow v-for="(r, i) in commonRows" :key="i" :title="r.title" :sub="r.sub" :value="r.value"
:is-last="i == commonRows.length - 1" @click="onRowClick(i, false)"></hr-lrow>
</hr-card>
<hr-tip style="margin-top:16px" theme="info" icon=""
text="P0 派单强提醒开启后,收到 P0 派单将全屏告警并强制震动,请保持开启。"></hr-tip>
<view style="height:14px"></view>
</view>
</hr-page>
</template>
<script lang="uts" setup>
import { ref } from 'vue'
import { settingSwitches, offlineSettingRows, commonSettingRows } from '@/mock/index.uts'
import { HrSettingSwitch, HrSettingRow } from '@/mock/types.uts'
import { navTo } from '@/utils/nav.uts'
/** s39 设置 */
const switches = ref<HrSettingSwitch[]>(settingSwitches)
const offlineRows = ref<HrSettingRow[]>(offlineSettingRows)
const commonRows = ref<HrSettingRow[]>(commonSettingRows)
function onRowClick(i : number, isOffline : boolean) : void {
if (isOffline) {
if (i == 0) { navTo('/pages/triage/offline-sync'); return }
uni.showModal({
title: '清理已下载资料',
content: '将删除 8.6 MB 离线学习资料,无网络时需重新下载。',
success: (res) => {
if (res.confirm) { uni.showToast({ title: '已清理 8.6 MB', icon: 'success' }) }
}
})
return
}
if (i == 0) {
uni.showModal({
title: '清理缓存',
content: '当前缓存 24.3 MB,清理后不影响账号数据。',
success: (res) => {
if (res.confirm) { uni.showToast({ title: '已清理 24.3 MB', icon: 'success' }) }
}
})
return
}
if (i == 1) {
uni.showModal({ title: '医院救援管理平台', content: '版本 v2.0.0\n移动端逻辑重构版', showCancel: false })
return
}
uni.showModal({ title: '隐私政策', content: '本平台严格保护伤患与医护人员的个人信息,数据仅用于院前急救调度。', showCancel: false })
}
</script>
+288
View File
@@ -0,0 +1,288 @@
<template>
<hr-page page-bg="#F3F4F6" status-bg="#FFFFFF" nav-title="学习中心" :xloading="true" @right-click="onSort">
<template v-slot:right>
<x-icon name="arrow-up-down-line" color="#6B7280" font-size="17"></x-icon>
</template>
<view class="hr-body">
<!-- ============ 四大类目(xTabs ============ -->
<x-tabs :list="catTabs" :model-value="activeId" item-width="25%" height="84rpx" font-size="28rpx"
active-font-size="28rpx" title-color="#6B7280" active-title-color="#2563EB" line-color="#2563EB"
title-padding="0" :is-item-center="true" @change="onCatChange"></x-tabs>
<!-- ============ 课题筛选(组件库 xRadioButton 分段切换) ============ -->
<view style="margin-top:10px;margin-bottom:8rpx">
<x-radio-button v-model="chipId" :list="chipTabs" height="64rpx" space="4rpx" round="22rpx"
text-style="font-weight:bold;" bg-color="#E5E7EB" active-color="#2563EB"
active-font-color="#FFFFFF" font-color="#6B7280" font-size="22rpx"></x-radio-button>
</view>
<!-- ============ 题目列表 ============ -->
<view v-if="cat == 0" style="margin-top:10px">
<view v-for="(q, i) in wrongList" :key="q.id" class="qa-card">
<view class="qa-top">
<text class="qa-stem">{{ q.stem }}</text>
<hr-chip :text="wrongTimes(i)" theme="red"></hr-chip>
</view>
<text class="qa-ans">正确答案 <text class="qa-ans-hl">{{ rightAnswer(q) }}</text></text>
<view class="qa-meta">
<text class="qa-meta-t">来自 <text class="qa-meta-hl">{{ q.from }}</text></text>
<text class="qa-meta-t qa-meta-date">{{ wrongDates[i] }}</text>
<view class="hr-flex1"></view>
<view class="qa-fold" @click="toggle(i)">
<x-icon :name="opened[i] ? 'arrow-up-s-line' : 'arrow-down-s-line'" color="#2563EB"
font-size="26rpx"></x-icon>
<text class="qa-fold-t">{{ opened[i] ? '收起解析' : '查看解析' }}</text>
</view>
</view>
<!-- 手风琴展开:展开进度由 accStyle() 逐帧下发 -->
<view v-if="opened[i]" :style="accStyle(i)">
<view class="qa-analysis">
<text class="qa-analysis-t">{{ q.analysis }}</text>
</view>
</view>
</view>
</view>
<!-- 其它类目占位 -->
<hr-empty v-else :icon="emptyIcon" :title="emptyTitle" :sub="emptySub" action="去课程库学习"
@action="goLibrary"></hr-empty>
<view style="height:14px"></view>
</view>
</hr-page>
</template>
<script lang="uts" setup>
import { ref, computed } from 'vue'
import { wrongQuestions } from '@/mock/index.uts'
import { HrQuestion } from '@/mock/types.uts'
import { navTo } from '@/utils/nav.uts'
import { TABS_ITEM_INFO, TABS_ITEM, RADIO_BUTTON } from '@/uni_modules/tmx-ui/interface.uts'
/**
* s38 学习中心
* 汇总「错题 / 收藏 / 历史 / 笔记」四类目,替代原先散落各处的入口。
* 四类目改用组件库 xTabs(原 lc-tabs 自绘分段控件已移除)。
*/
const catTabs : TABS_ITEM_INFO[] = [
{ title: '错题 12', id: '0' } as TABS_ITEM_INFO,
{ title: '收藏 8', id: '1' } as TABS_ITEM_INFO,
{ title: '历史', id: '2' } as TABS_ITEM_INFO,
{ title: '笔记 5', id: '3' } as TABS_ITEM_INFO
]
/**
* 课题筛选(组件库 xRadioButton 分段切换,替代原 hr-chip 横向滚动)
* ⚠ RADIO_BUTTON 的 id 是 string:统一用下标字符串,选中值即筛选序号。
*/
const chipTabs : RADIO_BUTTON[] = [
{ id: '0', title: '全部' } as RADIO_BUTTON,
{ id: '1', title: '心肺复苏 5' } as RADIO_BUTTON,
{ id: '2', title: '气道管理 4' } as RADIO_BUTTON,
{ id: '3', title: 'AED 3' } as RADIO_BUTTON
]
const chipId = ref('0')
const wrongDates : string[] = ['09-15', '09-14', '09-12']
const activeId = ref('0')
const opened = ref<boolean[]>([false, false, false])
const wrongList = ref<HrQuestion[]>(wrongQuestions)
const cat = computed(() : number => {
const i = parseInt(activeId.value)
return isNaN(i) ? 0 : i
})
// ⚠ x-tabs 的 change 下发的是 TABS_ITEM(不是 TABS_ITEM_INFO),形参类型必须一致
function onCatChange(item : TABS_ITEM, index : number) : void {
activeId.value = item.id == '' ? index.toString() : item.id
}
const emptyIcon = computed(() : string => {
if (cat.value == 1) { return '★' }
if (cat.value == 2) { return '◔' }
return '✎'
})
const emptyTitle = computed(() : string => {
if (cat.value == 1) { return '暂无收藏题目' }
if (cat.value == 2) { return '暂无学习历史' }
return '暂无学习笔记'
})
const emptySub = computed(() : string => {
return '在课题中收藏题目、记录笔记后会自动汇总到这里'
})
function wrongTimes(i : number) : string {
if (i == 0) { return '错 2 次' }
return '错 1 次'
}
function rightAnswer(q : HrQuestion) : string {
if (q.answer < q.options.length) {
const o = q.options[q.answer]
return `${o.key}. ${o.text}`
}
return '—'
}
/*
* 手风琴展开入场动画
* ⚠ App 端 uvue 不支持 @keyframes / animation(编译期直接报 ERROR),
* 改为定时器逐帧推进 accT0 → 1),再由 accStyle() 下发 opacity + translateY。
* 240ms30ms × 8 帧),与设计稿 qa-acc-in 一致。
*/
const accIdx = ref(-1)
const accT = ref(1)
let accTimer : number = 0
function stopAcc() : void {
if (accTimer != 0) {
clearInterval(accTimer)
accTimer = 0
}
}
function runAcc(i : number) : void {
stopAcc()
accIdx.value = i
accT.value = 0
accTimer = setInterval(() => {
const t = accT.value + 0.16
if (t >= 1) {
accT.value = 1
stopAcc()
return
}
accT.value = t
}, 30)
}
/** 仅正在展开的那一项需要过渡样式,其余返回空串(即最终态) */
function accStyle(i : number) : string {
if (i != accIdx.value || accT.value >= 1) { return '' }
const t = accT.value
const y = Math.round(-16 * (1 - t))
return 'opacity:' + t.toString() + ';transform:translateY(' + y.toString() + 'rpx);'
}
/** 手风琴:展开一项时其余自动收起 */
function toggle(i : number) : void {
const wasOpen = opened.value[i]
const arr : boolean[] = []
for (let k = 0; k < wrongList.value.length; k++) { arr.push(false) }
arr[i] = !wasOpen
opened.value = arr
if (wasOpen) {
stopAcc()
accIdx.value = -1
accT.value = 1
} else {
runAcc(i)
}
}
onUnload(() => {
stopAcc()
})
function goLibrary() : void {
navTo('/pages/course/library')
}
function onSort() : void {
uni.showToast({ title: '按时间 / 次数排序', icon: 'none' })
}
</script>
<style>
/* ---------- 题目卡 ---------- */
.qa-card {
background-color: #FFFFFF;
border: 2rpx solid #E5E7EB;
border-left: 8rpx solid #2563EB;
border-radius: 20rpx;
padding: 22rpx 24rpx 22rpx 24rpx;
margin-bottom: 20rpx;
}
.qa-top {
display: flex;
flex-direction: row;
align-items: flex-start;
}
.qa-stem {
flex: 1;
font-size: 29.2rpx;
font-weight: bold;
color: #1F2937;
margin-right: 18rpx;
}
.qa-ans {
font-size: 26.8rpx;
color: #6B7280;
margin-top: 14rpx;
}
.qa-ans-hl {
font-size: 26.8rpx;
color: #059669;
font-weight: bold;
}
.qa-meta {
display: flex;
flex-direction: row;
align-items: center;
margin-top: 18rpx;
}
.qa-meta-t {
font-size: 24.6rpx;
color: #9CA3AF;
}
.qa-meta-hl {
font-size: 24.6rpx;
color: #1F2937;
font-weight: bold;
}
.qa-meta-date {
margin-left: 24rpx;
}
.qa-fold {
display: flex;
flex-direction: row;
align-items: center;
}
.qa-fold-t {
font-size: 25.8rpx;
color: #2563EB;
font-weight: bold;
margin-left: 4rpx;
}
/* ---------- 手风琴展开 ----------
⚠ App 端 uvue 不支持 @keyframes/animation
入场过渡已改为 script 内定时器推进 accT + accStyle() 下发样式。 */
.qa-analysis {
margin-top: 20rpx;
padding: 18rpx 20rpx 18rpx 20rpx;
border-radius: 18rpx;
background-color: #F9FAFB;
}
.qa-analysis-t {
font-size: 25.8rpx;
color: #6B7280;
}
</style>
+81
View File
@@ -0,0 +1,81 @@
<template>
<hr-page page-bg="#F3F4F6" status-bg="#FFFFFF" nav-title="我的证书" :xloading="true" @right-click="onSort">
<template v-slot:right>
<x-icon name="arrow-up-down-line" color="#6B7280" font-size="17"></x-icon>
</template>
<view class="hr-body">
<hr-card>
<view class="hr-row">
<view class="hr-avt hr-avt-lg hr-avt-grn ct-avt">
<text>▤</text>
</view>
<view class="hr-flex1">
<text class="ct-title">有效证书 {{ certs.length }} 张</text>
<text class="ct-sub">其中 1 张将于 2026-10-15 到期</text>
</view>
</view>
</hr-card>
<hr-sec title="证书列表"></hr-sec>
<hr-card variant="list">
<hr-lrow v-for="(c, i) in certs" :key="i" :icon="c.statusTheme == 'grn' ? '▤' : '!'"
:theme="c.statusTheme == 'grn' ? 'grn' : 'amb'" :title="c.name"
:sub="c.gotAt + ' 取得 · ' + (c.statusTheme == 'grn' ? '有效至 ' + c.expireAt : c.expireAt + ' 到期')"
:value="c.status + ' '" :value-theme="c.statusTheme" :value-bold="true"
:is-last="i == certs.length - 1" @click="onCertClick(c)"></hr-lrow>
</hr-card>
<hr-tip style="margin-top:14px" theme="warn" icon="⚑"
text="BLS 证书将在 22 天后到期,请完成年度复训后自动续期。"></hr-tip>
<view style="height:14px"></view>
</view>
</hr-page>
</template>
<script lang="uts" setup>
import { ref } from 'vue'
import { certList } from '@/mock/index.uts'
import { HrCert } from '@/mock/types.uts'
/** s13 我的证书 */
const certs = ref<HrCert[]>(certList)
function onCertClick(c : HrCert) : void {
uni.showModal({
title: c.name,
content: `取得时间:${c.gotAt}\n有效期至:${c.expireAt}\n状态:${c.status}`,
cancelText: '关闭',
confirmText: '查看证书',
success: (res) => {
if (res.confirm) {
uni.showToast({ title: '已生成证书图片', icon: 'none' })
}
}
})
}
function onSort() : void {
uni.showToast({ title: '按取得时间排序', icon: 'none' })
}
</script>
<style>
.ct-avt {
margin-right: 24rpx;
}
.ct-title {
font-size: 31.4rpx;
font-weight: bold;
color: #1F2937;
}
.ct-sub {
font-size: 25.8rpx;
color: #6B7280;
margin-top: 8rpx;
}
</style>
+185
View File
@@ -0,0 +1,185 @@
<template>
<hr-page page-bg="#F3F4F6" status-bg="#FFFFFF" nav-title="我的考试" :xloading="true" @right-click="onSort">
<template v-slot:right>
<x-icon name="arrow-up-down-line" color="#6B7280" font-size="17"></x-icon>
</template>
<view class="hr-body">
<view class="hr-seg">
<view v-for="(s, i) in segs" :key="i" :class="seg == i ? 'hr-seg-i hr-seg-i-on' : 'hr-seg-i'"
@click="seg = i">
<text :class="seg == i ? 'ex-seg-t ex-seg-t-on' : 'ex-seg-t'">{{ s }}</text>
</view>
</view>
<!-- ============ 待考(P0 紧急) ============ -->
<view v-if="seg != 2 && plans.length > 0" class="ex-plan">
<view class="hr-row-top">
<view class="ex-plan-ic">
<text class="ex-plan-ic-t">!</text>
</view>
<view class="hr-flex1">
<view class="hr-row-between">
<text class="ex-plan-t">{{ plans[0].name }}</text>
<hr-pc :level="plans[0].priority"></hr-pc>
</view>
<text class="ex-plan-cd">{{ plans[0].countdown }}</text>
<text class="ex-plan-meta">{{ plans[0].time }} · {{ plans[0].place }}</text>
</view>
</view>
<view class="ex-plan-btn" @click="onPrepare">
<text class="ex-plan-btn-t">进入备考</text>
</view>
</view>
<!-- ============ 已完成 ============ -->
<hr-card v-if="seg != 1" variant="list" style="margin-top:10px">
<hr-lrow v-for="(r, i) in shownRecords" :key="i" :icon="r.passed ? '✓' : '✗'"
:theme="r.passed ? 'grn' : 'red'" :title="r.name"
:sub="r.date + ' · 得分 ' + r.score + ' · ' + (r.passed ? '已通过' : '未通过')"
:value="r.score.toString() + ' '" :value-theme="r.passed ? 'grn' : 'red'" :value-bold="true"
:is-last="i == shownRecords.length - 1" @click="onRecordClick(r)"></hr-lrow>
</hr-card>
<hr-empty v-if="seg == 1 && plans.length == 0" icon="✓" title="暂无待考安排"
sub="有新的考试安排时会在消息中心提醒你"></hr-empty>
<hr-tip style="margin-top:14px" theme="info" icon=""
text="理论测验通过后自动计入课题完成度;未通过的测验可在 7 天后重新参加。"></hr-tip>
<view style="height:14px"></view>
</view>
</hr-page>
</template>
<script lang="uts" setup>
import { ref, computed } from 'vue'
import { examPlans, examRecords } from '@/mock/index.uts'
import { HrExamRecord, HrExamPlan } from '@/mock/types.uts'
import { navTo } from '@/utils/nav.uts'
/** s12 我的考试 */
const segs : string[] = ['全部', '待考', '已完成']
const seg = ref(0)
const plans = ref<HrExamPlan[]>(examPlans)
const records = ref<HrExamRecord[]>(examRecords)
const passOnly = ref(false)
const shownRecords = computed(() : HrExamRecord[] => {
if (passOnly.value) {
const list : HrExamRecord[] = []
for (let i = 0; i < records.value.length; i++) {
if (records.value[i].passed) { list.push(records.value[i]) }
}
return list
}
return records.value
})
function onPrepare() : void {
navTo('/pages/course/practice')
}
function onRecordClick(r : HrExamRecord) : void {
if (r.passed) {
uni.showModal({
title: r.name,
content: `得分 ${r.score} 分 · ${r.date}\n可查看错题解析与答卷详情。`,
cancelText: '关闭',
confirmText: '查看错题',
success: (res) => {
if (res.confirm) { navTo('/pages/study/wrong-book') }
}
})
return
}
uni.showModal({
title: r.name,
content: `得分 ${r.score} 分 · 未通过\n补考开放时间:${r.date} 起 7 天后`,
cancelText: '关闭',
confirmText: '进入复习',
success: (res) => {
if (res.confirm) { navTo('/pages/course/practice') }
}
})
}
function onSort() : void {
uni.showToast({ title: '按时间排序', icon: 'none' })
}
</script>
<style>
.ex-seg-t {
font-size: 29.2rpx;
color: #6B7280;
}
.ex-seg-t-on {
color: #2563EB;
font-weight: bold;
}
.ex-plan {
margin-top: 20rpx;
background-color: #FFF5F5;
border: 2rpx solid #FECACA;
border-radius: 26rpx;
padding: 24rpx 26rpx 24rpx 26rpx;
}
.ex-plan-ic {
width: 68rpx;
height: 68rpx;
border-radius: 20rpx;
background-color: #DC2626;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
margin-right: 20rpx;
}
.ex-plan-ic-t {
font-size: 35.8rpx;
font-weight: bold;
color: #FFFFFF;
}
.ex-plan-t {
font-size: 30.2rpx;
font-weight: bold;
color: #1F2937;
}
.ex-plan-cd {
font-size: 25.8rpx;
color: #DC2626;
font-weight: bold;
margin-top: 12rpx;
}
.ex-plan-meta {
font-size: 24.6rpx;
color: #6B7280;
margin-top: 10rpx;
}
.ex-plan-btn {
margin-top: 24rpx;
height: 88rpx;
border-radius: 24rpx;
background-color: #DC2626;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
}
.ex-plan-btn-t {
font-size: 31.4rpx;
font-weight: bold;
color: #FFFFFF;
}
</style>
+270
View File
@@ -0,0 +1,270 @@
<template>
<hr-page page-bg="#F3F4F6" status-bg="#FFFFFF" nav-title="错题本" :xloading="true" @right-click="onSort">
<template v-slot:right>
<x-icon name="arrow-up-down-line" color="#6B7280" font-size="17"></x-icon>
</template>
<view class="hr-body">
<!-- 课题筛选(组件库 xRadioButton 分段切换) -->
<view style="margin-bottom:8rpx">
<x-radio-button v-model="chipId" :list="chipTabs" height="64rpx" space="4rpx" round="22rpx"
text-style="font-weight:bold;" bg-color="#E5E7EB" active-color="#2563EB"
active-font-color="#FFFFFF" font-color="#6B7280" font-size="22rpx"></x-radio-button>
</view>
<!-- 错题卡片 -->
<view style="margin-top:10px">
<view v-for="(q, i) in list" :key="q.id" class="qa-card">
<view class="qa-top">
<text class="qa-stem">{{ q.stem }}</text>
<hr-chip :text="wrongTimes[i]" theme="red"></hr-chip>
</view>
<view class="qa-row">
<text class="qa-k">你的答案</text>
<text class="qa-wrong">{{ wrongKeys[i] }}</text>
<text class="qa-k qa-k2">正确答案</text>
<text class="qa-right">{{ rightAnswer(q) }}</text>
</view>
<view class="qa-meta">
<text class="qa-meta-t">来自 <text class="qa-meta-hl">{{ q.from }}</text></text>
<text class="qa-meta-t qa-meta-date">{{ dates[i] }} 答错</text>
<view class="hr-flex1"></view>
<view class="qa-fold" @click="toggle(i)">
<x-icon :name="opened[i] ? 'arrow-up-s-line' : 'arrow-down-s-line'" color="#2563EB"
font-size="26rpx"></x-icon>
<text class="qa-fold-t">{{ opened[i] ? '收起解析' : '查看解析' }}</text>
</view>
</view>
<!-- 手风琴展开:展开进度由 accStyle() 逐帧下发 -->
<view v-if="opened[i]" :style="accStyle(i)">
<view class="qa-analysis">
<text class="qa-analysis-t">{{ q.analysis }}</text>
</view>
</view>
</view>
</view>
<hr-tip theme="info" icon="" text="已复习的错题不会自动移除;连续答对 2 次后可手动移出错题本。"></hr-tip>
<view style="height:14px"></view>
</view>
</hr-page>
</template>
<script lang="uts" setup>
import { ref, computed } from 'vue'
import { wrongQuestions } from '@/mock/index.uts'
import { HrQuestion } from '@/mock/types.uts'
import { RADIO_BUTTON } from '@/uni_modules/tmx-ui/interface.uts'
/** s16 错题本 */
/**
* 课题筛选(组件库 xRadioButton 分段切换,替代原 hr-chip 横向滚动)
* ⚠ RADIO_BUTTON 的 id 是 string:统一用下标字符串,选中值即筛选序号。
*/
const chipTabs : RADIO_BUTTON[] = [
{ id: '0', title: '全部 12' } as RADIO_BUTTON,
{ id: '1', title: '心肺复苏 5' } as RADIO_BUTTON,
{ id: '2', title: '气道管理 4' } as RADIO_BUTTON,
{ id: '3', title: 'AED 3' } as RADIO_BUTTON
]
const chipId = ref('0')
const wrongTimes : string[] = ['答错 2 次', '答错 1 次', '答错 1 次']
const dates : string[] = ['09-15', '09-14', '09-12']
const wrongKeys : string[] = ['A. 3~4 cm', 'C. 60~80 次', 'D. 每 6 秒 1 次']
const opened = ref<boolean[]>([false, false, false])
const all = ref<HrQuestion[]>(wrongQuestions)
const list = computed(() : HrQuestion[] => {
return all.value
})
/** 选项文案里若已含「A. 」前缀则去掉,避免出现「A. A. xxx」 */
function stripKey(text : string) : string {
const i = text.indexOf('. ')
if (i == 1 || i == 2) { return text.substring(i + 2) }
return text
}
// ⚠ UTS 不提升函数声明:stripKey 必须写在 rightAnswer 之前
function rightAnswer(q : HrQuestion) : string {
if (q.answer < q.options.length) {
const o = q.options[q.answer]
return `${o.key}. ${stripKey(o.text)}`
}
return '—'
}
/*
* 手风琴展开入场动画
* ⚠ App 端 uvue 不支持 @keyframes / animation(编译期直接报 ERROR),
* 改为定时器逐帧推进 accT0 → 1),再由 accStyle() 下发 opacity + translateY。
* 240ms30ms × 8 帧),与设计稿 qa-acc-in 一致。
*/
const accIdx = ref(-1)
const accT = ref(1)
let accTimer : number = 0
function stopAcc() : void {
if (accTimer != 0) {
clearInterval(accTimer)
accTimer = 0
}
}
function runAcc(i : number) : void {
stopAcc()
accIdx.value = i
accT.value = 0
accTimer = setInterval(() => {
const t = accT.value + 0.16
if (t >= 1) {
accT.value = 1
stopAcc()
return
}
accT.value = t
}, 30)
}
/** 仅正在展开的那一项需要过渡样式,其余返回空串(即最终态) */
function accStyle(i : number) : string {
if (i != accIdx.value || accT.value >= 1) { return '' }
const t = accT.value
const y = Math.round(-16 * (1 - t))
return 'opacity:' + t.toString() + ';transform:translateY(' + y.toString() + 'rpx);'
}
/** 手风琴:先全部收起,再切换当前项(同时只展开一条) */
function toggle(i : number) : void {
const wasOpen = opened.value[i]
const arr : boolean[] = []
for (let k = 0; k < list.value.length; k++) { arr.push(false) }
arr[i] = !wasOpen
opened.value = arr
if (wasOpen) {
stopAcc()
accIdx.value = -1
accT.value = 1
} else {
runAcc(i)
}
}
onUnload(() => {
stopAcc()
})
function onSort() : void {
uni.showToast({ title: '按错误次数 / 时间排序', icon: 'none' })
}
</script>
<style>
.qa-card {
background-color: #FFFFFF;
border: 2rpx solid #E5E7EB;
border-left: 8rpx solid #2563EB;
border-radius: 20rpx;
padding: 22rpx 24rpx 22rpx 24rpx;
margin-bottom: 20rpx;
}
.qa-top {
display: flex;
flex-direction: row;
align-items: flex-start;
}
.qa-stem {
flex: 1;
font-size: 29.2rpx;
font-weight: bold;
color: #1F2937;
margin-right: 18rpx;
}
.qa-row {
display: flex;
flex-direction: row;
align-items: center;
flex-wrap: wrap;
margin-top: 14rpx;
}
.qa-k {
font-size: 26.8rpx;
color: #6B7280;
}
.qa-k2 {
margin-left: 12rpx;
}
.qa-wrong {
font-size: 26.8rpx;
color: #DC2626;
font-weight: bold;
margin-left: 6rpx;
}
.qa-right {
font-size: 26.8rpx;
color: #059669;
font-weight: bold;
margin-left: 6rpx;
}
.qa-meta {
display: flex;
flex-direction: row;
align-items: center;
margin-top: 18rpx;
}
.qa-meta-t {
font-size: 24.6rpx;
color: #9CA3AF;
}
.qa-meta-hl {
font-size: 24.6rpx;
color: #1F2937;
font-weight: bold;
}
.qa-meta-date {
margin-left: 24rpx;
}
.qa-fold {
display: flex;
flex-direction: row;
align-items: center;
}
.qa-fold-t {
font-size: 25.8rpx;
color: #2563EB;
font-weight: bold;
margin-left: 4rpx;
}
/* ---------- 手风琴展开 ----------
⚠ App 端 uvue 不支持 @keyframes/animation
入场过渡已改为 script 内定时器推进 accT + accStyle() 下发样式。 */
.qa-analysis {
margin-top: 20rpx;
padding: 18rpx 20rpx 18rpx 20rpx;
border-radius: 18rpx;
background-color: #F9FAFB;
}
.qa-analysis-t {
font-size: 25.8rpx;
color: #6B7280;
}
</style>
+356
View File
@@ -0,0 +1,356 @@
<template>
<hr-page page-bg="#F3F4F6" status-bg="#FFFFFF" nav-title="批量转运" @right-click="onHistory">
<template v-slot:right>
<text class="op-nav-act">历史</text>
</template>
<view class="op-page">
<!-- ① 顶部:连续扫码 -->
<hr-scan-cam :count="list.length" @scan="onScan" @manual="onManual"></hr-scan-cam>
<!-- ② 中部:伤员记录(唯一滚动区;死亡伤员不参与转运,故未列入) -->
<hr-rec-list :list="list" @remove="onRemove" @clear="onClear"></hr-rec-list>
<!-- ③ 底部:转运表单 + 右下角悬浮提交 -->
<view class="op-form">
<text class="op-lb">接收医院</text>
<view class="op-pick" @click="openHos">
<text class="op-pick-n">{{ hospitalList[hosIdx] }}</text>
<text :class="hospitalAvail[hosIdx] ? 'op-pick-b' : 'op-pick-b op-pick-b-no'">{{ hospitalBed[hosIdx] }}</text>
<x-icon name="arrow-down-s-line" color="#9CA3AF" font-size="14"></x-icon>
</view>
<text class="op-lb op-lb-gap">转运方式</text>
<view class="hr-seg">
<view v-for="(m, i) in modes" :key="i"
:class="i == modeIdx ? 'hr-seg-i hr-seg-i-on' : 'hr-seg-i'" @click="modeIdx = i">
<text :class="i == modeIdx ? 'op-seg-t op-seg-t-on' : 'op-seg-t'">{{ m }}</text>
</view>
</view>
<text class="op-lb op-lb-gap">随车联系人</text>
<view class="op-foot">
<view class="op-foot-col">
<view class="hr-field">
<text class="hr-field-label">姓名</text>
<input class="hr-field-input" type="text" v-model="contactName" placeholder-style="color:#9CA3AF" />
</view>
</view>
<view class="op-foot-col op-foot-col-last">
<view class="hr-field">
<text class="hr-field-label">联系电话</text>
<input class="hr-field-input" type="number" v-model="contactPhone" placeholder-style="color:#9CA3AF" />
</view>
</view>
</view>
<view class="op-send" @click="onSubmit">
<x-icon name="check-line" color="#FFFFFF" font-size="24"></x-icon>
</view>
</view>
</view>
<!-- 接收医院选择:hr-sheet 底部弹层(点击上方「接收医院」整行唤起) -->
<template v-slot:overlay>
<view v-if="hosSheet" class="hr-mask" @click="closeHos">
<view class="hr-sheet" @click="stop">
<view class="hr-sheet-h">
<text class="hr-sheet-h-b">选择接收医院</text>
<view class="hr-sheet-h-x" @click="closeHos">
<x-icon name="close-line" color="#9CA3AF" font-size="18"></x-icon>
</view>
</view>
<view v-for="(h, i) in hospitalList" :key="i" :class="hosOptClass(i)" @click="pickHospital(i)">
<view class="os-txt">
<text :class="hosNameClass(i)">{{ h }}</text>
<text class="os-meta">{{ hospitalMeta[i] }}</text>
</view>
<text :class="hospitalAvail[i] ? 'op-pick-b' : 'op-pick-b op-pick-b-no'">{{ hospitalBed[i] }}</text>
<x-icon v-if="i == hosIdx" name="check-line" color="#2563EB" font-size="17"></x-icon>
</view>
</view>
</view>
</template>
</hr-page>
</template>
<script lang="uts" setup>
import { ref } from 'vue'
import { scanVictims, hospitalList, hospitalMeta, hospitalBed, hospitalAvail } from '@/mock/index.uts'
import { HrVictim } from '@/mock/types.uts'
import { navTo } from '@/utils/nav.uts'
/**
* s44 批量转运(扫腕带 · 三段式作业页)
* 与批量处置同构:顶部相机(点击开启)/ 中部记录列表 / 底部表单;
* 底部换成 接收医院(hr-sheet 弹层选择)/ 转运方式 / 随车联系人。
*/
const modes : string[] = ['救护车 🚑', '直升机 🚁']
const modeIdx = ref(0)
const hosIdx = ref(0)
const hosSheet = ref(false)
const contactName = ref('刘伟')
const contactPhone = ref('13800006621')
const list = ref<HrVictim[]>([
scanVictims[0], scanVictims[1], scanVictims[2], scanVictims[3]
])
/* ⚠ UTS 不提升函数声明,被调用的函数一律写在调用点之前 */
/** 把新伤员追加进列表(uvue 原地改数组不刷新,必须重建数组) */
function push(v : HrVictim) : void {
const arr : HrVictim[] = []
for (let i = 0; i < list.value.length; i++) { arr.push(list.value[i]) }
arr.push(v)
list.value = arr
}
function addByCode(code : string) : void {
for (let i = 0; i < list.value.length; i++) {
if (list.value[i].code == code) {
uni.showToast({ title: '该伤员已在列表中', icon: 'none' })
return
}
}
for (let i = 0; i < scanVictims.length; i++) {
const v = scanVictims[i]
if (v.code == code) {
if (v.level == 4) {
uni.showToast({ title: '死亡伤员不参与转运', icon: 'none' })
return
}
push(v)
uni.showToast({ title: '已加入 ' + v.name, icon: 'success' })
return
}
}
push({
id: 'tmp_' + code, name: '未登记伤员', gender: '未知', age: 0, code: code, level: 3,
injury: '待现场检伤补录', status: '', statusTheme: '', createdAt: ''
} as HrVictim)
uni.showToast({ title: '未登记腕带,已临时加入', icon: 'none' })
}
function onScan(code : string) : void {
addByCode(code)
}
function onRemove(i : number) : void {
const arr : HrVictim[] = []
for (let k = 0; k < list.value.length; k++) {
if (k != i) { arr.push(list.value[k]) }
}
list.value = arr
}
function onClear() : void {
uni.showModal({
title: '清空列表',
content: '将移除当前已识别的全部伤员记录,确认清空?',
success: (res) => {
if (res.confirm) { list.value = [] as HrVictim[] }
}
})
}
function onManual() : void {
uni.showModal({
title: '手动输入腕带编号',
editable: true,
placeholderText: '如 TRIAGE-20260922-0006',
success: (res) => {
if (!res.confirm) { return }
const code = res.content
if (code == null || code == '') { return }
addByCode(code as string)
}
})
}
/* ---------- 接收医院:hr-sheet 底部弹层 ---------- */
function openHos() : void {
hosSheet.value = true
}
function closeHos() : void {
hosSheet.value = false
}
/** 点击弹层面板本身时阻止冒泡,避免误关闭 */
function stop() : void {
}
function hosOptClass(i : number) : string {
if (i == hospitalList.length - 1) { return 'hr-sheet-opt hr-sheet-opt-last' }
return 'hr-sheet-opt'
}
/** 弹层选项:医院名(两行式第一行,选中蓝 / 满员置灰) */
function hosNameClass(i : number) : string {
if (!hospitalAvail[i]) { return 'os-name os-name-dim' }
if (i == hosIdx.value) { return 'os-name os-name-on' }
return 'os-name'
}
function pickHospital(i : number) : void {
if (!hospitalAvail[i]) {
uni.showToast({ title: '该医院急诊已满员,不可选择', icon: 'none' })
return
}
hosIdx.value = i
hosSheet.value = false
}
function onSubmit() : void {
if (list.value.length == 0) {
uni.showToast({ title: '请先扫码添加伤员', icon: 'none' })
return
}
if (contactName.value == '' || contactPhone.value == '') {
uni.showToast({ title: '请填写随车联系人', icon: 'none' })
return
}
uni.showLoading({ title: '提交中' })
setTimeout(() => {
uni.hideLoading()
uni.showToast({ title: '已转运登记 ' + list.value.length + ' 人', icon: 'success' })
setTimeout(() => {
navTo('/pages/triage/transfer')
}, 700)
}, 700)
}
function onHistory() : void {
navTo('/pages/triage/treat-records')
}
</script>
<style>
.op-page {
flex: 1;
flex-direction: column;
}
.op-nav-act {
font-size: 29.2rpx;
color: #6B7280;
}
.op-form {
background-color: #FFFFFF;
border-top: 2rpx solid #E5E7EB;
border-radius: 32rpx 32rpx 0rpx 0rpx;
padding: 22rpx 32rpx 28rpx 32rpx;
position: relative;
}
.op-lb {
font-size: 25.8rpx;
font-weight: bold;
color: #6B7280;
margin-bottom: 12rpx;
}
.op-lb-gap {
margin-top: 18rpx;
}
/* 接收医院:折叠字段(点击整行唤起 hr-sheet 弹层) */
.op-pick {
display: flex;
flex-direction: row;
align-items: center;
background-color: #F9FAFB;
border: 2rpx solid #E5E7EB;
border-radius: 20rpx;
padding: 20rpx 24rpx 20rpx 24rpx;
}
.op-pick-n {
flex: 1;
font-size: 28rpx;
font-weight: bold;
color: #1F2937;
}
.op-pick-b {
font-size: 22.4rpx;
font-weight: bold;
color: #059669;
margin-right: 12rpx;
}
.op-pick-b-no {
color: #DC2626;
}
/* 弹层选项:左侧「医院名 + 距离/预计时间」两行 */
.os-txt {
flex: 1;
display: flex;
flex-direction: column;
}
.os-name {
font-size: 30rpx;
font-weight: bold;
color: #1F2937;
}
.os-name-on {
color: #2563EB;
}
.os-name-dim {
color: #6B7280;
}
.os-meta {
font-size: 22.4rpx;
color: #9CA3AF;
margin-top: 6rpx;
}
.op-seg-t {
font-size: 29.2rpx;
color: #6B7280;
}
.op-seg-t-on {
color: #2563EB;
font-weight: bold;
}
.op-foot {
display: flex;
flex-direction: row;
padding-right: 140rpx;
}
.op-foot-col {
flex: 1;
margin-right: 16rpx;
}
.op-foot-col-last {
margin-right: 0rpx;
}
.op-send {
position: absolute;
right: 32rpx;
bottom: 32rpx;
width: 112rpx;
height: 112rpx;
border-radius: 56rpx;
background-image: linear-gradient(to bottom right, #2563EB, #1D4ED8);
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
box-shadow: 0 12rpx 36rpx rgba(37, 99, 235, 0.42);
}
</style>
+247
View File
@@ -0,0 +1,247 @@
<template>
<hr-page page-bg="#F3F4F6" status-bg="#FFFFFF" nav-title="批量处置" @right-click="onHistory">
<template v-slot:right>
<text class="op-nav-act">历史</text>
</template>
<view class="op-page">
<!-- ① 顶部:连续扫码 -->
<hr-scan-cam :count="list.length" @scan="onScan" @manual="onManual"></hr-scan-cam>
<!-- ② 中部:伤员记录(唯一滚动区) -->
<hr-rec-list :list="list" @remove="onRemove" @clear="onClear"></hr-rec-list>
<!-- ③ 底部:处置表单 + 右下角悬浮提交 -->
<view class="op-form">
<text class="op-lb">处置类型</text>
<view class="op-chips">
<view v-for="(t, i) in types" :key="i" :class="i == typeIdx ? 'op-chip op-chip-on' : 'op-chip'"
@click="typeIdx = i">
<text :class="i == typeIdx ? 'op-chip-t op-chip-t-on' : 'op-chip-t'">{{ t }}</text>
</view>
</view>
<text class="op-lb op-lb-gap">处置内容</text>
<textarea class="op-ta" v-model="content" placeholder="记录处置措施、用药与伤情变化…"
placeholder-style="color:#9CA3AF" />
<text class="op-note">提交后同时写入 {{ list.length }} 名伤员的处置记录</text>
<view class="op-send" @click="onSubmit">
<x-icon name="check-line" color="#FFFFFF" font-size="24"></x-icon>
</view>
</view>
</view>
</hr-page>
</template>
<script lang="uts" setup>
import { ref } from 'vue'
import { scanVictims, treatTypes } from '@/mock/index.uts'
import { HrVictim } from '@/mock/types.uts'
import { navTo } from '@/utils/nav.uts'
/**
* s43 批量处置(扫腕带 · 三段式作业页)
* 顶部相机(点击取景区才开启)→ 中部伤员记录滚动列表 → 底部处置表单 + 右下角悬浮提交。
*/
const types : string[] = treatTypes
const typeIdx = ref(0)
const content = ref('现场统一止血包扎,右前臂夹板固定,建立静脉通路并给氧。')
const list = ref<HrVictim[]>([
scanVictims[0], scanVictims[1], scanVictims[2], scanVictims[3], scanVictims[4]
])
/* ⚠ UTS 不提升函数声明,被调用的函数一律写在调用点之前 */
/** 把新伤员追加进列表(uvue 原地改数组不刷新,必须重建数组) */
function push(v : HrVictim) : void {
const arr : HrVictim[] = []
for (let i = 0; i < list.value.length; i++) { arr.push(list.value[i]) }
arr.push(v)
list.value = arr
}
function addByCode(code : string) : void {
for (let i = 0; i < list.value.length; i++) {
if (list.value[i].code == code) {
uni.showToast({ title: '该伤员已在列表中', icon: 'none' })
return
}
}
for (let i = 0; i < scanVictims.length; i++) {
if (scanVictims[i].code == code) {
push(scanVictims[i])
uni.showToast({ title: '已加入 ' + scanVictims[i].name, icon: 'success' })
return
}
}
// 未登记腕带:兜底创建临时记录
push({
id: 'tmp_' + code, name: '未登记伤员', gender: '未知', age: 0, code: code, level: 3,
injury: '待现场检伤补录', status: '', statusTheme: '', createdAt: ''
} as HrVictim)
uni.showToast({ title: '未登记腕带,已临时加入', icon: 'none' })
}
/** 扫码结果处理:按腕带编码匹配伤员并加入列表 */
function onScan(code : string) : void {
addByCode(code)
}
function onRemove(i : number) : void {
const arr : HrVictim[] = []
for (let k = 0; k < list.value.length; k++) {
if (k != i) { arr.push(list.value[k]) }
}
list.value = arr
}
function onClear() : void {
uni.showModal({
title: '清空列表',
content: '将移除当前已识别的全部伤员记录,确认清空?',
success: (res) => {
if (res.confirm) { list.value = [] as HrVictim[] }
}
})
}
function onManual() : void {
uni.showModal({
title: '手动输入腕带编号',
editable: true,
placeholderText: '如 TRIAGE-20260922-0006',
success: (res) => {
if (!res.confirm) { return }
const code = res.content
if (code == null || code == '') { return }
addByCode(code as string)
}
})
}
function onSubmit() : void {
if (list.value.length == 0) {
uni.showToast({ title: '请先扫码添加伤员', icon: 'none' })
return
}
if (content.value == '') {
uni.showToast({ title: '请填写处置内容', icon: 'none' })
return
}
uni.showLoading({ title: '提交中' })
setTimeout(() => {
uni.hideLoading()
uni.showToast({ title: '已写入 ' + list.value.length + ' 名伤员', icon: 'success' })
setTimeout(() => {
navTo('/pages/triage/treat-records')
}, 700)
}, 700)
}
function onHistory() : void {
navTo('/pages/triage/treat-records')
}
</script>
<style>
.op-page {
flex: 1;
flex-direction: column;
}
.op-nav-act {
font-size: 29.2rpx;
color: #6B7280;
}
/* ---------- 底部表单 ---------- */
.op-form {
background-color: #FFFFFF;
border-top: 2rpx solid #E5E7EB;
border-radius: 32rpx 32rpx 0rpx 0rpx;
padding: 22rpx 32rpx 28rpx 32rpx;
position: relative;
}
.op-lb {
font-size: 25.8rpx;
font-weight: bold;
color: #6B7280;
margin-bottom: 12rpx;
}
.op-lb-gap {
margin-top: 18rpx;
}
.op-chips {
display: flex;
flex-direction: row;
flex-wrap: wrap;
}
.op-chip {
height: 80rpx;
border-radius: 20rpx;
padding: 0rpx 26rpx 0rpx 26rpx;
background-color: #FFFFFF;
border: 2rpx solid #E5E7EB;
display: flex;
flex-direction: row;
align-items: center;
margin-right: 14rpx;
margin-bottom: 14rpx;
}
.op-chip-on {
background-color: #2563EB;
border: 2rpx solid #2563EB;
}
.op-chip-t {
font-size: 26.8rpx;
font-weight: bold;
color: #6B7280;
}
.op-chip-t-on {
color: #FFFFFF;
}
.op-ta {
width: 100%;
background-color: #F9FAFB;
border: 2rpx solid #E5E7EB;
border-radius: 22rpx;
padding: 20rpx 20rpx 20rpx 20rpx;
font-size: 28rpx;
color: #1F2937;
height: 192rpx;
}
.op-note {
font-size: 23.6rpx;
color: #9CA3AF;
text-align: right;
margin-top: 14rpx;
padding-right: 140rpx;
}
.op-send {
position: absolute;
right: 32rpx;
bottom: 32rpx;
width: 112rpx;
height: 112rpx;
border-radius: 56rpx;
background-image: linear-gradient(to bottom right, #2563EB, #1D4ED8);
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
box-shadow: 0 12rpx 36rpx rgba(37, 99, 235, 0.42);
}
</style>
File diff suppressed because it is too large Load Diff
+693
View File
@@ -0,0 +1,693 @@
<template>
<view class="hr-page" style="background-color:#F3F4F6">
<hr-statusbar bg="#FFFFFF"></hr-statusbar>
<!-- ============ 顶部伤情头信息 ============ -->
<view class="dh">
<view class="dh-c1"></view>
<view class="dh-top">
<view class="dh-back" @click="onBack">
<x-icon name="arrow-left-s-line" color="#FFFFFF" font-size="20"></x-icon>
</view>
<text class="dh-title">伤员详情</text>
<view class="hr-flex1"></view>
<text class="dh-state">⌖ 转运中</text>
</view>
<text class="dh-w">{{ victim.code }}</text>
<view class="dh-n">
<text class="dh-name">{{ victim.name }}</text>
<text class="dh-age">{{ victim.gender }} · {{ victim.age }} 岁</text>
<view class="hr-flex1"></view>
<view class="dh-level">
<text class="dh-level-t">{{ levelLabel }}</text>
</view>
</view>
<view class="dh-ch">
<view class="dh-chip">
<text class="dh-chip-t">⚑ 事件 EVT-20260922-014</text>
</view>
<view class="dh-chip">
<text class="dh-chip-t">⌖ 转运中</text>
</view>
</view>
<text class="dh-foot">建档 14:23 · 操作人 张明 · 现场检伤组 3 组</text>
</view>
<!-- ============ 快捷操作 ============ -->
<view class="dh-acts">
<view v-for="(a, i) in acts" :key="i" :class="i == 0 ? 'dha' : 'dha dha-sep'" @click="onAct(i)">
<view class="dha-ic" :style="{ backgroundColor: a.bg }">
<x-icon :name="a.icon" :color="a.color" font-size="32rpx"></x-icon>
</view>
<text class="dha-t" :style="{ color: a.color }">{{ a.name }}</text>
</view>
</view>
<!-- ============ 分页 Tab(基本信息 / 伤情 / 转运 / 处置) ============ -->
<x-tabs :list="tabList" :model-value="activeId" item-width="25%" height="84rpx" font-size="27rpx"
active-font-size="27rpx" title-color="#6B7280" active-title-color="#2563EB" line-color="#2563EB"
title-padding="0" :is-item-center="true" @change="onTabChange"></x-tabs>
<scroll-view class="hr-scroll" :scroll-y="true" :show-scrollbar="false">
<view class="hr-body">
<!-- ---------- 基本信息 ---------- -->
<view v-if="tab == 0">
<hr-sec title="基本信息" more="编辑 " :first="true"></hr-sec>
<view class="ig">
<view class="ig-row">
<view class="ig-it">
<text class="ig-l">身份证号</text>
<text class="ig-b">4406041991••••1234</text>
</view>
<view class="ig-it ig-it-r">
<text class="ig-l">联系电话</text>
<text class="ig-b">138••••6621</text>
</view>
</view>
<view class="ig-row">
<view class="ig-it">
<text class="ig-l">送诊时间</text>
<text class="ig-b">2026-09-22 14:35</text>
</view>
<view class="ig-it ig-it-r">
<text class="ig-l">事件来源</text>
<text class="ig-b">建筑坍塌 · 现场检伤</text>
</view>
</view>
<view class="ig-row ig-row-last">
<view class="ig-it">
<text class="ig-l">检伤分级</text>
<text class="ig-b" style="color:#B45309">{{ levelLabel }}</text>
</view>
<view class="ig-it ig-it-r">
<text class="ig-l">检伤状态</text>
<text class="ig-b">已检伤 · 转运中</text>
</view>
</view>
</view>
<hr-sec title="生命体征(最近一次 14:35" more="趋势 "></hr-sec>
<hr-card>
<view class="vs5">
<view v-for="(v, i) in vitals" :key="i"
:class="v.abnormal ? 'vc vc-warn' : 'vc'">
<text :class="v.abnormal ? 'vc-b vc-b-warn' : 'vc-b'">{{ v.value }}</text>
<text :class="v.abnormal ? 'vc-s vc-s-warn' : 'vc-s'">{{ v.unit }}</text>
</view>
</view>
<view class="dh-warn">
<x-icon name="alert-line" color="#DC2626" font-size="14"></x-icon>
<text class="dh-warn-t">脉搏偏快 · 血压偏低 · 血氧 88% 低于阈值,建议优先处置</text>
</view>
</hr-card>
</view>
<!-- ---------- 伤情 ---------- -->
<view v-if="tab == 1">
<hr-sec title="伤情标记" more="查看人体图 " :first="true"></hr-sec>
<hr-card>
<view class="dh-mks">
<view v-for="(m, i) in marks" :key="i" class="dh-mk">
<view class="dh-mk-no" :style="{ backgroundColor: m.theme == 'red' ? '#DC2626' : '#F59E0B' }">
<text class="dh-mk-no-t">{{ m.no }}</text>
</view>
<view class="hr-flex1">
<text class="dh-mk-t">{{ m.title }}</text>
<text class="dh-mk-s">{{ m.sub }}</text>
</view>
<text class="dh-mk-time">14:2{{ i + 3 }}</text>
</view>
</view>
</hr-card>
<hr-sec title="多模态记录" more=" 添加 "></hr-sec>
<hr-card>
<text class="dh-desc">{{ victim.injury }}。14:23 于坍塌现场 B 区发现,俯卧位,呼之可睁眼。移除压覆物后前臂出现活动性出血,已加压包扎。</text>
<view class="dh-upls">
<view class="dh-upl">
<x-icon name="image-line" color="#9CA3AF" font-size="19"></x-icon>
<text class="dh-upl-t">现场照片</text>
<view class="dh-upl-tag">
<text class="dh-upl-tag-t">图片 · 1.2 MB</text>
</view>
</view>
<view class="dh-upl">
<x-icon name="music-2-line" color="#9CA3AF" font-size="19"></x-icon>
<text class="dh-upl-t">现场录音</text>
<view class="dh-upl-tag">
<text class="dh-upl-tag-t">音频 · 0:42</text>
</view>
</view>
<view class="dh-upl">
<x-icon name="video-line" color="#9CA3AF" font-size="19"></x-icon>
<text class="dh-upl-t">处置视频</text>
<view class="dh-upl-tag">
<text class="dh-upl-tag-t">视频 · 0:18</text>
</view>
</view>
</view>
</hr-card>
</view>
<!-- ---------- 转运 ---------- -->
<view v-if="tab == 2">
<hr-sec title="转运信息" more="重新登记 " :first="true" @more-click="goTransfer"></hr-sec>
<view class="ig">
<view class="ig-row">
<view class="ig-it">
<text class="ig-l">接收医院</text>
<text class="ig-b">协和医院 · 急诊创伤中心</text>
</view>
<view class="ig-it ig-it-r">
<text class="ig-l">转运方式</text>
<text class="ig-b">救护车 🚑</text>
</view>
</view>
<view class="ig-row ig-row-last">
<view class="ig-it">
<text class="ig-l">转出方</text>
<text class="ig-b">张明(急救中心)</text>
</view>
<view class="ig-it ig-it-r">
<text class="ig-l">接收方</text>
<text class="ig-b">刘伟(急诊创伤中心)</text>
</view>
</view>
</view>
<hr-sec title="流转时间线" more="全部 4 条"></hr-sec>
<hr-card>
<hr-timeline :nodes="timeline"></hr-timeline>
</hr-card>
</view>
<!-- ---------- 处置 ---------- -->
<view v-if="tab == 3">
<hr-sec title="处置记录" more="全部记录 " :first="true" @more-click="goRecords"></hr-sec>
<hr-card>
<hr-timeline :nodes="treatNodes"></hr-timeline>
</hr-card>
<view class="hr-btn hr-btn-soft" style="margin-top:12px" @click="goRecords">
<x-icon name="add-line" color="#2563EB" font-size="16"></x-icon>
<text class="dh-add-t">新增处置记录</text>
</view>
</view>
<view style="height:14px"></view>
</view>
</scroll-view>
</view>
</template>
<script lang="uts" setup>
import { ref, computed } from 'vue'
import { victimList, vitalSigns, injuryMarks, victimTimeline, treatRecords } from '@/mock/index.uts'
import { HrVictim, HrVital, HrInjuryMark, HrTimelineNode } from '@/mock/types.uts'
import { navTo, navBack } from '@/utils/nav.uts'
import { TABS_ITEM_INFO, TABS_ITEM } from '@/uni_modules/tmx-ui/interface.uts'
/**
* s21 伤员详情
* 顶部伤情头信息 + 3 个快捷操作 + 4 个分页(基本信息 / 伤情 / 转运 / 处置,xTabs 驱动)。
*/
type QuickAct = { icon : string, name : string, color : string, bg : string }
const tabList : TABS_ITEM_INFO[] = [
{ title: '基本信息', id: '0' } as TABS_ITEM_INFO,
{ title: '伤情', id: '1' } as TABS_ITEM_INFO,
{ title: '转运', id: '2' } as TABS_ITEM_INFO,
{ title: '处置', id: '3' } as TABS_ITEM_INFO
]
const activeId = ref('0')
const tab = computed(() : number => {
const i = parseInt(activeId.value)
return isNaN(i) ? 0 : i
})
const victim = ref<HrVictim>(victimList[1])
const vitals = ref<HrVital[]>(vitalSigns)
const marks = ref<HrInjuryMark[]>(injuryMarks)
const timeline = ref<HrTimelineNode[]>(victimTimeline)
const treatNodes = ref<HrTimelineNode[]>(treatRecords)
const acts : QuickAct[] = [
{ icon: 'arrow-left-right-line', name: '调整分级', color: '#B45309', bg: '#FFFBEB' } as QuickAct,
{ icon: 'route-line', name: '转运登记', color: '#2563EB', bg: '#EFF6FF' } as QuickAct,
{ icon: 'phone-line', name: '联系家属', color: '#6B7280', bg: '#F3F4F6' } as QuickAct
]
// ⚠ x-tabs 的 change 下发的是 TABS_ITEM(不是 TABS_ITEM_INFO),形参类型必须一致
function onTabChange(item : TABS_ITEM, index : number) : void {
activeId.value = item.id == '' ? index.toString() : item.id
}
const levelLabel = computed(() : string => {
const l = victim.value.level
if (l == 1) { return '危重' }
if (l == 2) { return '重伤' }
if (l == 3) { return '轻伤' }
return '死亡'
})
onLoad((opt) => {
const raw = opt['id']
if (raw != null) {
const id = raw as string
for (let i = 0; i < victimList.length; i++) {
if (victimList[i].id == id) {
victim.value = victimList[i]
break
}
}
}
})
function onBack() : void {
navBack()
}
// ⚠ UTS 不提升函数声明:goTransfer 必须写在调用它的 onAct 之前
function goTransfer() : void {
navTo('/pages/triage/transfer')
}
function onAct(i : number) : void {
if (i == 0) {
uni.showModal({
title: '调整检伤分级',
content: '当前分级:' + levelLabel.value + '\n调整后需现场检伤组复核。',
cancelText: '取消',
confirmText: '去调整',
success: (res) => {
if (res.confirm) { navTo('/pages/triage/create') }
}
})
return
}
if (i == 1) { goTransfer(); return }
uni.showToast({ title: '正在呼叫登记的联系电话', icon: 'none' })
}
function goRecords() : void {
navTo('/pages/triage/treat-records')
}
</script>
<style>
.dh {
background-image: linear-gradient(to bottom right, #F59E0B, #D97706);
padding: 0rpx 28rpx 32rpx 28rpx;
position: relative;
overflow: hidden;
}
.dh-c1 {
position: absolute;
right: -80rpx;
top: -100rpx;
width: 300rpx;
height: 300rpx;
border-radius: 150rpx;
background-color: rgba(255, 255, 255, 0.10);
}
.dh-top {
display: flex;
flex-direction: row;
align-items: center;
padding-top: 12rpx;
}
.dh-back {
width: 60rpx;
height: 60rpx;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
}
.dh-title {
font-size: 31.4rpx;
font-weight: bold;
color: #FFFFFF;
margin-left: 4rpx;
}
.dh-state {
font-size: 26.8rpx;
color: rgba(255, 255, 255, 0.92);
}
.dh-w {
font-size: 35.8rpx;
font-weight: bold;
color: #FFFFFF;
margin-top: 24rpx;
letter-spacing: 0.8rpx;
}
.dh-n {
display: flex;
flex-direction: row;
align-items: center;
margin-top: 16rpx;
}
.dh-name {
font-size: 33.6rpx;
font-weight: bold;
color: #FFFFFF;
margin-right: 16rpx;
}
.dh-age {
font-size: 29.2rpx;
color: rgba(255, 255, 255, 0.9);
}
.dh-level {
background-color: #FFFFFF;
border-radius: 40rpx;
padding: 4rpx 20rpx 4rpx 20rpx;
}
.dh-level-t {
font-size: 24.6rpx;
font-weight: bold;
color: #B45309;
}
.dh-ch {
display: flex;
flex-direction: row;
flex-wrap: wrap;
margin-top: 18rpx;
}
.dh-chip {
background-color: rgba(255, 255, 255, 0.22);
border: 2rpx solid rgba(255, 255, 255, 0.38);
border-radius: 40rpx;
padding: 4rpx 18rpx 4rpx 18rpx;
margin-right: 12rpx;
margin-bottom: 8rpx;
}
.dh-chip-t {
font-size: 23.6rpx;
font-weight: bold;
color: #FFFFFF;
}
.dh-foot {
font-size: 24.6rpx;
color: rgba(255, 255, 255, 0.9);
margin-top: 12rpx;
}
/* ---------- 快捷操作 ---------- */
.dh-acts {
display: flex;
flex-direction: row;
background-color: #FFFFFF;
border-bottom: 2rpx solid #E5E7EB;
}
.dha {
flex: 1;
flex-direction: column;
align-items: center;
padding: 22rpx 8rpx 20rpx 8rpx;
}
.dha-sep {
border-left: 2rpx solid #E5E7EB;
}
.dha-ic {
width: 68rpx;
height: 68rpx;
border-radius: 22rpx;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
margin-bottom: 10rpx;
}
.dha-ic-t {
font-size: 35.8rpx;
}
.dha-t {
font-size: 25.8rpx;
font-weight: bold;
}
/* ---------- 分页 Tab ---------- */
.tabs2 {
display: flex;
flex-direction: row;
background-color: #FFFFFF;
border-bottom: 2rpx solid #E5E7EB;
padding: 0rpx 8rpx 0rpx 8rpx;
}
.tabs2-tb {
flex: 1;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
padding-top: 20rpx;
padding-bottom: 18rpx;
position: relative;
}
.tabs2-t {
font-size: 28rpx;
color: #6B7280;
}
.tabs2-t-on {
color: #2563EB;
font-weight: bold;
}
.tabs2-bar {
position: absolute;
bottom: 0rpx;
width: 52rpx;
height: 5rpx;
border-radius: 4rpx;
background-color: #2563EB;
}
/* ---------- 信息栅格 ---------- */
.ig {
border: 2rpx solid #E5E7EB;
border-radius: 24rpx;
overflow: hidden;
}
.ig-row {
display: flex;
flex-direction: row;
border-bottom: 2rpx solid #E5E7EB;
}
.ig-row-last {
border-bottom-width: 0rpx;
}
.ig-it {
flex: 1;
background-color: #FFFFFF;
padding: 18rpx 20rpx 18rpx 20rpx;
}
.ig-it-r {
border-left: 2rpx solid #E5E7EB;
}
.ig-l {
font-size: 23.6rpx;
color: #9CA3AF;
}
.ig-b {
font-size: 28rpx;
font-weight: bold;
color: #1F2937;
margin-top: 6rpx;
}
/* ---------- 生命体征 ---------- */
.vs5 {
display: flex;
flex-direction: row;
}
.vc {
flex: 1;
background-color: #F9FAFB;
border: 2rpx solid #E5E7EB;
border-radius: 18rpx;
padding: 16rpx 2rpx 16rpx 2rpx;
align-items: center;
margin-right: 12rpx;
}
.vc-warn {
background-color: #FEF2F2;
border: 2rpx solid #FECACA;
}
.vc-b {
font-size: 30.2rpx;
font-weight: bold;
color: #1F2937;
}
.vc-b-warn {
color: #DC2626;
}
.vc-s {
font-size: 20.2rpx;
color: #9CA3AF;
margin-top: 6rpx;
}
.vc-s-warn {
color: #B91C1C;
font-weight: bold;
}
.dh-warn {
display: flex;
flex-direction: row;
align-items: flex-start;
margin-top: 20rpx;
}
.dh-warn-t {
flex: 1;
font-size: 24.6rpx;
color: #DC2626;
margin-left: 12rpx;
line-height: 1.5;
}
/* ---------- 伤情标记 ---------- */
.dh-mks {
flex-direction: column;
}
.dh-mk {
display: flex;
flex-direction: row;
align-items: center;
background-color: #F9FAFB;
border: 2rpx solid #E5E7EB;
border-radius: 22rpx;
padding: 20rpx 24rpx 20rpx 24rpx;
margin-bottom: 16rpx;
}
.dh-mk-no {
width: 44rpx;
height: 44rpx;
border-radius: 22rpx;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
margin-right: 20rpx;
}
.dh-mk-no-t {
font-size: 24.6rpx;
font-weight: bold;
color: #FFFFFF;
}
.dh-mk-t {
font-size: 28rpx;
font-weight: bold;
color: #1F2937;
}
.dh-mk-s {
font-size: 23.6rpx;
color: #9CA3AF;
margin-top: 4rpx;
}
.dh-mk-time {
font-size: 23.6rpx;
color: #9CA3AF;
}
/* ---------- 多模态 ---------- */
.dh-desc {
font-size: 26.8rpx;
color: #6B7280;
line-height: 1.65;
}
.dh-upls {
display: flex;
flex-direction: row;
margin-top: 20rpx;
}
.dh-upl {
width: 31%;
height: 168rpx;
border-radius: 22rpx;
background-color: #F9FAFB;
border: 2rpx solid #E5E7EB;
flex-direction: column;
align-items: center;
justify-content: center;
margin-right: 2.5%;
position: relative;
overflow: hidden;
}
.dh-upl-t {
font-size: 23.6rpx;
color: #9CA3AF;
margin-top: 8rpx;
}
.dh-upl-tag {
position: absolute;
left: 0rpx;
right: 0rpx;
bottom: 0rpx;
background-color: rgba(17, 24, 39, 0.62);
padding: 4rpx 0rpx 4rpx 0rpx;
}
.dh-upl-tag-t {
font-size: 21.2rpx;
color: #FFFFFF;
text-align: center;
}
.dh-add-t {
font-size: 31.4rpx;
font-weight: bold;
color: #2563EB;
margin-left: 12rpx;
}
</style>
+138
View File
@@ -0,0 +1,138 @@
<template>
<hr-page page-bg="#F3F4F6" status-bg="#FFFFFF" nav-title="离线同步" :xloading="true" @right-click="onSort">
<template v-slot:right>
<x-icon name="arrow-up-down-line" color="#6B7280" font-size="17"></x-icon>
</template>
<view class="hr-body">
<view class="os-box">
<x-icon name="cloud-off-line" color="#92400E" font-size="15"></x-icon>
<text class="os-box-t">当前网络不可用,以下记录已暂存本机</text>
<view class="hr-offbar-cnt">
<text class="os-box-c">{{ pendingCount }}</text>
</view>
</view>
<hr-sec title="待上传队列" :more="'共 ' + tasks.length + ' 条'"></hr-sec>
<hr-card variant="list">
<hr-lrow v-for="(t, i) in tasks" :key="i" :icon="t.icon" :theme="t.theme" :title="t.title"
:sub="t.sub" :value="t.status" :value-bold="true" :is-last="i == tasks.length - 1"
@click="onTaskClick(t)"></hr-lrow>
</hr-card>
<hr-tip style="margin-top:14px" theme="info" icon=""
text="提交失败的记录会自动进入本队列;网络恢复后按时间顺序自动重传,无需手动操作。"></hr-tip>
<view v-if="syncing" class="os-progress">
<view class="hr-row-between" style="margin-bottom:9px">
<text class="os-p-t">正在重传…</text>
<text class="os-p-v">{{ progress }}%</text>
</view>
<hr-progress :percent="progress"></hr-progress>
</view>
<view style="height:14px"></view>
</view>
<template v-slot:footer>
<view class="hr-fixedbar">
<view :class="syncing ? 'hr-btn hr-btn-disabled' : 'hr-btn hr-btn-primary'" style="flex:1"
@click="onRetryAll">
<text class="os-foot-t">{{ syncing ? '正在重传…' : '立即重试全部上传' }}</text>
</view>
</view>
</template>
</hr-page>
</template>
<script lang="uts" setup>
import { ref, computed } from 'vue'
import { offlineTasks } from '@/mock/index.uts'
import { HrOfflineTask } from '@/mock/types.uts'
/**
* s19 离线同步
* 提交失败就地拦截 + 进离线队列;网络恢复后按时间顺序自动重传。
*/
const tasks = ref<HrOfflineTask[]>(offlineTasks)
const syncing = ref(false)
const progress = ref(0)
let timer : number = 0
const pendingCount = computed(() : number => {
return tasks.value.length
})
function onTaskClick(t : HrOfflineTask) : void {
uni.showModal({ title: t.title, content: t.sub, showCancel: false })
}
function onRetryAll() : void {
if (syncing.value) { return }
syncing.value = true
progress.value = 0
timer = setInterval(() => {
progress.value = progress.value + 10
if (progress.value >= 100) {
clearInterval(timer)
timer = 0
syncing.value = false
tasks.value = [] as HrOfflineTask[]
uni.showToast({ title: '全部同步完成', icon: 'success' })
}
}, 260)
}
function onSort() : void {
uni.showToast({ title: '按暂存时间排序', icon: 'none' })
}
</script>
<style>
.os-box {
display: flex;
flex-direction: row;
align-items: center;
background-color: #FFFBEB;
border: 2rpx solid #FDE68A;
border-radius: 24rpx;
padding: 18rpx 32rpx 18rpx 32rpx;
}
.os-box-t {
flex: 1;
font-size: 26.8rpx;
font-weight: bold;
color: #92400E;
margin-left: 18rpx;
}
.os-box-c {
font-size: 22.4rpx;
font-weight: bold;
color: #FFFFFF;
}
.os-progress {
margin-top: 28rpx;
}
.os-p-t {
font-size: 26.8rpx;
font-weight: bold;
color: #1F2937;
}
.os-p-v {
font-size: 25.8rpx;
font-weight: bold;
color: #2563EB;
}
.os-foot-t {
font-size: 32.4rpx;
font-weight: bold;
color: #FFFFFF;
}
</style>
+246
View File
@@ -0,0 +1,246 @@
<template>
<hr-page page-bg="#F3F4F6" status-bg="#FFFFFF" nav-title="转运登记" @right-click="onMore">
<template v-slot:right>
<x-icon name="more-fill" color="#6B7280" font-size="18"></x-icon>
</template>
<view class="hr-body">
<!-- ============ 伤员摘要 ============ -->
<hr-card>
<view class="hr-row">
<view class="tr-bar"></view>
<view class="hr-flex1">
<view class="hr-row">
<text class="tr-name">李华</text>
<text class="tr-sub">男 · 35 岁</text>
<view class="hr-flex1"></view>
<hr-wb :level="2"></hr-wb>
</view>
<text class="tr-inj">头部开放性外伤 · 右前臂骨折 · 已夹板固定</text>
</view>
</view>
</hr-card>
<!-- ============ 接收医院(床位实时查询) ============ -->
<hr-sec title="接收医院" more="🔍 搜索" @more-click="onSearchHos"></hr-sec>
<hr-card variant="list">
<view v-for="(h, i) in hospitalList" :key="i" :class="hosClass(i)" @click="pickHospital(i)">
<view :class="i == hosIdx ? 'tr-radio tr-radio-on' : 'tr-radio'"></view>
<view class="hr-flex1">
<text class="tr-hos-n">{{ h }}</text>
<text class="tr-hos-s">{{ hospitalMeta[i] }}</text>
</view>
<text :class="hospitalAvail[i] ? 'tr-hos-b' : 'tr-hos-b tr-hos-b-no'">{{ hospitalBed[i] }}</text>
</view>
</hr-card>
<hr-tip style="margin-top:10px" theme="warn" icon="⚠"
text="同仁医院急诊床位已满,不可选择。若接收医院拒绝接收,系统将自动回到本页提示重派。"></hr-tip>
<!-- ============ 转运方式 ============ -->
<hr-sec title="转运方式" :first="false"></hr-sec>
<view class="hr-seg">
<view v-for="(m, i) in modes" :key="i" :class="i == modeIdx ? 'hr-seg-i hr-seg-i-on' : 'hr-seg-i'"
@click="modeIdx = i">
<text :class="i == modeIdx ? 'tr-seg-t tr-seg-t-on' : 'tr-seg-t'">{{ m }}</text>
</view>
</view>
<!-- ============ 随车联系人 ============ -->
<hr-sec title="随车联系人"></hr-sec>
<view class="hr-field">
<text class="hr-field-label">接收方联系人</text>
<input class="hr-field-input" type="text" v-model="contact" placeholder-style="color:#9CA3AF" />
</view>
<view class="hr-field" style="margin-top:9px">
<text class="hr-field-label">联系电话</text>
<input class="hr-field-input hr-mono" type="number" v-model="phone" placeholder-style="color:#9CA3AF" />
</view>
<view style="height:14px"></view>
</view>
<template v-slot:footer>
<view class="hr-fixedbar">
<view class="hr-btn hr-btn-ghost tr-foot-cancel" @click="onCancel">
<text class="tr-foot-t">取消</text>
</view>
<view class="hr-btn hr-btn-primary" style="flex:1" @click="onSubmit">
<text class="tr-foot-t2">转运登记</text>
</view>
</view>
</template>
</hr-page>
</template>
<script lang="uts" setup>
import { ref } from 'vue'
import { hospitalList, hospitalMeta, hospitalBed, hospitalAvail } from '@/mock/index.uts'
import { navBack } from '@/utils/nav.uts'
/**
* s14 转运登记
* 接收医院床位实时查询,满员自动拦截并提示重派。
*/
const modes : string[] = ['救护车 🚑', '直升机 🚁']
const modeIdx = ref(0)
const hosIdx = ref(0)
const contact = ref('刘主任(急诊)')
const phone = ref('13800006621')
function hosClass(i : number) : string {
if (i == 0) {
return i == hosIdx.value ? 'tr-hos tr-hos-on' : 'tr-hos'
}
if (i == 2) { return 'tr-hos tr-hos-dim' }
return i == hosIdx.value ? 'tr-hos tr-hos-on' : 'tr-hos'
}
function pickHospital(i : number) : void {
if (!hospitalAvail[i]) {
uni.showModal({
title: '无法选择',
content: '同仁医院急诊床位已满,请改选其他接收医院。',
showCancel: false
})
return
}
hosIdx.value = i
}
function onSearchHos() : void {
uni.showToast({ title: '搜索附近的接收医院', icon: 'none' })
}
function onSubmit() : void {
if (contact.value == '' || phone.value == '') {
uni.showToast({ title: '请填写随车联系人', icon: 'none' })
return
}
uni.showLoading({ title: '登记中' })
setTimeout(() => {
uni.hideLoading()
uni.showToast({ title: '转运登记成功', icon: 'success' })
setTimeout(() => {
navBack()
}, 700)
}, 700)
}
function onCancel() : void {
navBack()
}
function onMore() : void {
uni.showToast({ title: '转运单 / 取消转运', icon: 'none' })
}
</script>
<style>
.tr-bar {
width: 12rpx;
height: 76rpx;
border-radius: 6rpx;
background-color: #F59E0B;
margin-right: 22rpx;
}
.tr-name {
font-size: 31.4rpx;
font-weight: bold;
color: #1F2937;
margin-right: 16rpx;
}
.tr-sub {
font-size: 25.8rpx;
color: #6B7280;
}
.tr-inj {
font-size: 24.6rpx;
color: #9CA3AF;
margin-top: 8rpx;
}
.tr-hos {
display: flex;
flex-direction: row;
align-items: center;
padding-top: 24rpx;
padding-bottom: 24rpx;
border-bottom: 2rpx solid #F3F4F6;
}
.tr-hos-on {
/* 选中态仅体现在单选圈 */
}
.tr-hos-dim {
opacity: 0.55;
}
.tr-radio {
width: 40rpx;
height: 40rpx;
border-radius: 20rpx;
border: 3.2rpx solid #E5E7EB;
margin-right: 22rpx;
}
.tr-radio-on {
border: 3.2rpx solid #2563EB;
background-color: #2563EB;
}
.tr-hos-n {
font-size: 30.2rpx;
font-weight: bold;
color: #1F2937;
}
.tr-hos-s {
font-size: 23.6rpx;
color: #9CA3AF;
margin-top: 4rpx;
}
.tr-hos-b {
font-size: 24.6rpx;
font-weight: bold;
color: #059669;
margin-left: 16rpx;
}
.tr-hos-b-no {
color: #DC2626;
}
.tr-seg-t {
font-size: 29.2rpx;
color: #6B7280;
}
.tr-seg-t-on {
color: #2563EB;
font-weight: bold;
}
.tr-foot-cancel {
flex: 0 0 200rpx;
margin-right: 18rpx;
}
.tr-foot-t {
font-size: 31.4rpx;
font-weight: bold;
color: #1F2937;
}
.tr-foot-t2 {
font-size: 32.4rpx;
font-weight: bold;
color: #FFFFFF;
}
</style>
+97
View File
@@ -0,0 +1,97 @@
<template>
<hr-page page-bg="#F3F4F6" status-bg="#FFFFFF" nav-title="处置记录 · 李华" :xloading="true"
@right-click="onAdd">
<template v-slot:right>
<x-icon name="add-line" color="#6B7280" font-size="19"></x-icon>
</template>
<view class="hr-body">
<!-- 处置类型筛选(组件库 xRadioButton 分段切换) -->
<view style="margin-bottom:8rpx">
<x-radio-button v-model="chipId" :list="chipTabs" height="64rpx" space="4rpx" round="22rpx"
text-style="font-weight:bold;" bg-color="#E5E7EB" active-color="#2563EB"
active-font-color="#FFFFFF" font-color="#6B7280" font-size="22rpx"></x-radio-button>
</view>
<hr-card style="margin-top:10px">
<hr-timeline :nodes="shownNodes"></hr-timeline>
</hr-card>
<hr-empty v-if="shownNodes.length == 0" icon="▤" title="该分类下暂无处置记录"
sub="切换其他分类,或点击右下角新增一条处置记录"></hr-empty>
<view style="height:14px"></view>
</view>
<template v-slot:footer>
<view class="hr-fixedbar">
<view class="hr-btn hr-btn-primary" style="flex:1" @click="onAdd">
<x-icon name="add-line" color="#FFFFFF" font-size="16"></x-icon>
<text class="trc-foot-t">新增处置记录</text>
</view>
</view>
</template>
</hr-page>
</template>
<script lang="uts" setup>
import { ref, computed } from 'vue'
import { treatRecords } from '@/mock/index.uts'
import { HrTimelineNode } from '@/mock/types.uts'
import { RADIO_BUTTON } from '@/uni_modules/tmx-ui/interface.uts'
/**
* s15 处置记录
* 按处置类型分类查看,时间线倒序。
*/
/**
* 处置类型筛选(组件库 xRadioButton 分段切换,替代原 hr-chip 横向滚动)
* ⚠ RADIO_BUTTON 的 id 是 string:统一用下标字符串,选中值即筛选序号。
*/
const chipTabs : RADIO_BUTTON[] = [
{ id: '0', title: '全部 4' } as RADIO_BUTTON,
{ id: '1', title: '止血 1' } as RADIO_BUTTON,
{ id: '2', title: '循环 1' } as RADIO_BUTTON,
{ id: '3', title: '分级 2' } as RADIO_BUTTON
]
const chipId = ref('0')
/** 由字符串 id 换算分类序号,供 shownNodes 筛选使用 */
const chip = computed(() : number => {
const i = parseInt(chipId.value)
return isNaN(i) ? 0 : i
})
const shownNodes = computed(() : HrTimelineNode[] => {
if (chip.value == 0) { return treatRecords }
const kw = chip.value == 1 ? '止血' : (chip.value == 2 ? '静脉' : '分级')
const arr : HrTimelineNode[] = []
for (let i = 0; i < treatRecords.length; i++) {
if (treatRecords[i].title.indexOf(kw) > -1) { arr.push(treatRecords[i]) }
}
return arr
})
function onAdd() : void {
uni.showModal({
title: '新增处置记录',
editable: true,
placeholderText: '如:15:40 清创缝合完成',
success: (res) => {
if (!res.confirm) { return }
const text = res.content
if (text == null || text == '') { return }
uni.showToast({ title: '处置记录已提交', icon: 'success' })
}
})
}
</script>
<style>
.trc-foot-t {
font-size: 32.4rpx;
font-weight: bold;
color: #FFFFFF;
margin-left: 12rpx;
}
</style>
+432
View File
@@ -0,0 +1,432 @@
<template>
<hr-page page-bg="#F3F4F6" status-bg="#FFFFFF" nav-title="伤员查询" :xloading="true"
@right-click="onSort">
<template v-slot:right>
<x-icon name="arrow-up-down-line" color="#6B7280" font-size="17"></x-icon>
</template>
<!-- 离线条 -->
<view class="hr-offbar" @click="goOffline">
<x-icon name="cloud-off-line" color="#92400E" font-size="15"></x-icon>
<text class="vs-off-t">离线中 · 数据暂存本地</text>
<view class="hr-offbar-cnt">
<text class="vs-off-cnt-t">3</text>
</view>
<text class="hr-offbar-go">查看队列 </text>
</view>
<view class="hr-body">
<!-- ============ 搜索(xSearch ============ -->
<x-search v-model="keyword" placeholder="姓名 / 身份证 / 编号" round="24rpx" height="84rpx"
:border="searchBorder" input-bg-color="#FFFFFF" icon-color="#9CA3AF" font-color="#1F2937"
:show-cancel="false"></x-search>
<!-- ============ 双下拉筛选 ============ -->
<view class="vs-filter">
<view :class="levelIdx > 0 ? 'vs-sel vs-sel-on' : 'vs-sel'" @click="openSheet(0)">
<text class="vs-sel-l">检伤等级</text>
<text :class="levelIdx > 0 ? 'vs-sel-v vs-sel-v-on' : 'vs-sel-v'">{{ levelOptions[levelIdx] }}</text>
<x-icon name="arrow-down-s-line" color="#9CA3AF" font-size="14"></x-icon>
</view>
<view :class="statusIdx > 0 ? 'vs-sel vs-sel-on' : 'vs-sel'"
style="margin-left:8px" @click="openSheet(1)">
<text class="vs-sel-l">检伤状态</text>
<text :class="statusIdx > 0 ? 'vs-sel-v vs-sel-v-on' : 'vs-sel-v'">{{ statusOptions[statusIdx] }}</text>
<x-icon name="arrow-down-s-line" color="#9CA3AF" font-size="14"></x-icon>
</view>
</view>
<!-- ============ 伤员列表 ============ -->
<view style="margin-top:4px">
<view v-for="(v, i) in shown" :key="v.id" :class="cardClass(v.level)" @click="openDetail(v)">
<view class="hr-row">
<view :class="avatarClass(v.level)">
<text class="vs-avt-t">{{ v.name.substring(0, 1) }}</text>
</view>
<view class="hr-flex1">
<view class="hr-row">
<text class="vs-name">{{ v.name }}</text>
<hr-wb :level="v.level" small></hr-wb>
</view>
<text class="vs-code">{{ v.gender }} · {{ v.age }} 岁 · {{ v.code }}</text>
</view>
</view>
<view class="vs-inj">
<text class="vs-inj-t">伤情:{{ v.injury }}</text>
</view>
<view class="vs-bot">
<view class="vs-status" :style="statusStyle(v.statusTheme)">
<text class="vs-status-t" :style="statusTextStyle(v.statusTheme)">{{ v.status }}</text>
</view>
<view class="hr-flex1"></view>
<text class="vs-time">{{ v.createdAt }}</text>
</view>
</view>
</view>
<hr-empty v-if="shown.length == 0" icon="♡" title="没有匹配的伤员"
sub="换个筛选条件,或点击右上角扫码按腕带编号查询"></hr-empty>
<view v-if="shown.length > 0" class="vs-hint">
<text class="vs-hint-t">✦ 点击卡片查看伤员详情</text>
</view>
<view style="height:14px"></view>
</view>
<!-- ============ 筛选选择器 ============ -->
<template v-slot:overlay>
<view v-if="sheet >= 0" class="hr-mask" @click="closeSheet">
<view class="hr-sheet" @click="stop">
<view class="hr-sheet-h">
<text class="hr-sheet-h-b">{{ sheet == 0 ? '选择检伤等级' : '选择检伤状态' }}</text>
<view class="hr-sheet-h-x" @click="closeSheet">
<x-icon name="close-line" color="#9CA3AF" font-size="18"></x-icon>
</view>
</view>
<view v-for="(o, i) in currentOptions" :key="i" class="hr-sheet-opt"
:class="i == currentOptions.length - 1 ? 'hr-sheet-opt-last' : ''" @click="pickOption(i)">
<text :class="isPicked(i) ? 'hr-sheet-opt-t hr-sheet-opt-on' : 'hr-sheet-opt-t'">{{ o }}</text>
<x-icon v-if="isPicked(i)" name="check-line" color="#2563EB" font-size="17"></x-icon>
</view>
</view>
</view>
</template>
</hr-page>
</template>
<script lang="uts" setup>
import { ref, computed } from 'vue'
import { victimList } from '@/mock/index.uts'
import { HrVictim } from '@/mock/types.uts'
import { navTo } from '@/utils/nav.uts'
/**
* s04 伤员查询
* 支持按「检伤等级 / 检伤状态」双下拉筛选 + 关键字搜索 + 扫码查询。
*/
const levelOptions : string[] = ['全部', '危重', '重伤', '轻伤', '死亡']
const statusOptions : string[] = ['全部', '已检伤', '转运中', '待转运', '未转运']
const keyword = ref('')
const levelIdx = ref(0)
const statusIdx = ref(0)
const sheet = ref(-1)
const list = ref<HrVictim[]>(victimList)
/**
* xSearch 的 border 是「数组」prop,固定 3 项 = [宽度, 线型, 颜色],
* 少于 3 项内部直接返回 none。设计稿搜索框为 1px solid #E5E7EB。
*/
const searchBorder : string[] = ['1px', 'solid', '#E5E7EB']
const currentOptions = computed(() : string[] => {
return sheet.value == 0 ? levelOptions : statusOptions
})
const shown = computed(() : HrVictim[] => {
const out : HrVictim[] = []
for (let i = 0; i < list.value.length; i++) {
const v = list.value[i]
if (levelIdx.value > 0 && v.level != levelIdx.value) { continue }
if (statusIdx.value > 0) {
const label = statusOptions[statusIdx.value]
if (label == '转运中' && v.status.indexOf('转运中') < 0) { continue }
if (label == '待转运' && v.status.indexOf('待转运') < 0) { continue }
if (label == '未转运' && v.status.indexOf('未转运') < 0) { continue }
if (label == '已检伤' && v.status.indexOf('已转运') < 0 && v.status.indexOf('转运中') < 0) { continue }
}
if (keyword.value != '') {
const kw = keyword.value.toLowerCase()
const hit = v.name.toLowerCase().indexOf(kw) > -1 || v.code.toLowerCase().indexOf(kw) > -1
if (!hit) { continue }
}
out.push(v)
}
return out
})
function cardClass(level : number) : string {
return `vs-card vs-card-l${level}`
}
function avatarClass(level : number) : string {
return `vs-avt vs-avt-l${level}`
}
function statusStyle(theme : string) : string {
if (theme == 'pri') { return 'background-color:#EFF6FF;border:1px solid #BFDBFE' }
if (theme == 'amb') { return 'background-color:#FFFBEB;border:1px solid #FDE68A' }
if (theme == 'grn') { return 'background-color:#ECFDF5;border:1px solid #A7F3D0' }
return 'background-color:#F3F4F6;border:1px solid #E5E7EB'
}
function statusTextStyle(theme : string) : string {
if (theme == 'pri') { return 'color:#1D4ED8' }
if (theme == 'amb') { return 'color:#B45309' }
if (theme == 'grn') { return 'color:#047857' }
return 'color:#6B7280'
}
function openSheet(i : number) : void {
sheet.value = i
}
function closeSheet() : void {
sheet.value = -1
}
function stop() : void {
// 阻止点击面板时关闭
}
function isPicked(i : number) : boolean {
return sheet.value == 0 ? levelIdx.value == i : statusIdx.value == i
}
function pickOption(i : number) : void {
if (sheet.value == 0) { levelIdx.value = i } else { statusIdx.value = i }
sheet.value = -1
}
function openDetail(v : HrVictim) : void {
navTo('/pages/triage/detail?id=' + v.id)
}
function onSort() : void {
uni.showToast({ title: '按建档时间 / 分级排序', icon: 'none' })
}
function goOffline() : void {
navTo('/pages/triage/offline-sync')
}
</script>
<style>
.vs-off-t {
font-size: 26.8rpx;
font-weight: bold;
color: #92400E;
margin-left: 18rpx;
}
.vs-off-cnt-t {
font-size: 22.4rpx;
font-weight: bold;
color: #FFFFFF;
}
/* ---------- 搜索 ---------- */
.vs-srch {
display: flex;
flex-direction: row;
align-items: center;
background-color: #FFFFFF;
border: 2rpx solid #E5E7EB;
border-radius: 24rpx;
height: 84rpx;
padding: 0rpx 20rpx 0rpx 20rpx;
}
.vs-srch-input {
flex: 1;
font-size: 29.2rpx;
color: #1F2937;
height: 80rpx;
padding: 0rpx 16rpx 0rpx 16rpx;
}
.vs-srch-scan {
width: 52rpx;
height: 52rpx;
border-radius: 16rpx;
background-color: #EFF6FF;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
}
.vs-scan-btn {
width: 84rpx;
height: 84rpx;
border-radius: 24rpx;
background-color: #EFF6FF;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
margin-left: 16rpx;
}
/* ---------- 筛选 ---------- */
.vs-filter {
display: flex;
flex-direction: row;
margin-top: 20rpx;
}
.vs-sel {
flex: 1;
display: flex;
flex-direction: row;
align-items: center;
background-color: #FFFFFF;
border: 2rpx solid #E5E7EB;
border-radius: 22rpx;
padding: 18rpx 22rpx 18rpx 22rpx;
}
.vs-sel-on {
border: 2rpx solid #2563EB;
background-color: #EFF6FF;
}
.vs-sel-l {
font-size: 23.6rpx;
color: #9CA3AF;
margin-right: 14rpx;
}
.vs-sel-v {
flex: 1;
font-size: 28rpx;
font-weight: bold;
color: #1F2937;
lines: 1;
}
.vs-sel-v-on {
color: #2563EB;
}
/* ---------- 伤员卡 ---------- */
.vs-card {
position: relative;
background-color: #FFFFFF;
border: 2rpx solid #E5E7EB;
border-radius: 26rpx;
padding: 22rpx 24rpx 22rpx 32rpx;
margin-bottom: 18rpx;
overflow: hidden;
}
.vs-card-bar {
position: absolute;
left: 0rpx;
top: 0rpx;
bottom: 0rpx;
width: 8rpx;
}
.vs-card-l1 {
border-left: 8rpx solid #DC2626;
}
.vs-card-l2 {
border-left: 8rpx solid #F59E0B;
}
.vs-card-l3 {
border-left: 8rpx solid #10B981;
}
.vs-card-l4 {
border-left: 8rpx solid #1F2937;
}
.vs-avt {
width: 76rpx;
height: 76rpx;
border-radius: 22rpx;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
margin-right: 20rpx;
}
.vs-avt-l1 {
background-color: #DC2626;
}
.vs-avt-l2 {
background-color: #D97706;
}
.vs-avt-l3 {
background-color: #059669;
}
.vs-avt-l4 {
background-color: #1F2937;
}
.vs-avt-t {
font-size: 33.6rpx;
font-weight: bold;
color: #FFFFFF;
}
.vs-name {
font-size: 30.2rpx;
font-weight: bold;
color: #1F2937;
margin-right: 12rpx;
}
.vs-code {
font-size: 23.6rpx;
color: #9CA3AF;
margin-top: 6rpx;
}
.vs-inj {
margin-top: 16rpx;
background-color: #F9FAFB;
border-radius: 16rpx;
padding: 12rpx 16rpx 12rpx 16rpx;
}
.vs-inj-t {
font-size: 24.6rpx;
color: #6B7280;
line-height: 1.5;
}
.vs-bot {
display: flex;
flex-direction: row;
align-items: center;
margin-top: 16rpx;
}
.vs-status {
border-radius: 18rpx;
padding: 8rpx 20rpx 8rpx 20rpx;
}
.vs-status-t {
font-size: 24.6rpx;
font-weight: bold;
}
.vs-time {
font-size: 23.6rpx;
color: #9CA3AF;
}
.vs-hint {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
padding: 12rpx 0rpx 16rpx 0rpx;
}
.vs-hint-t {
font-size: 23.6rpx;
color: #9CA3AF;
}
</style>
+234
View File
@@ -0,0 +1,234 @@
<template>
<hr-page page-bg="#F3F4F6" status-bg="#FFFFFF" nav-title="设备详情" :xloading="true" @right-click="onMore">
<template v-slot:right>
<x-icon name="more-fill" color="#6B7280" font-size="18"></x-icon>
</template>
<view class="hr-body">
<!-- ============ 概览 ============ -->
<hr-card>
<view class="hr-row">
<view class="hr-avt hr-avt-lg dd-avt">
<text>{{ device.icon }}</text>
</view>
<view class="hr-flex1">
<text class="dd-name">{{ device.name }}</text>
<text class="dd-meta">{{ device.meta }}</text>
<view style="margin-top:7px">
<hr-pc :level="2" :text="device.status"></hr-pc>
</view>
</view>
</view>
</hr-card>
<!-- ============ 借用前自检 ============ -->
<hr-sec :title="'借用前自检(5 项缺一不可)'" :more="checkedCount + ' / 5'"></hr-sec>
<hr-card variant="list">
<view v-for="(c, i) in checks" :key="i" :class="c.checked ? 'dd-ck dd-ck-on' : 'dd-ck'"
@click="toggleCheck(i)">
<view :class="c.checked ? 'dd-ck-box dd-ck-box-on' : 'dd-ck-box'">
<x-icon v-if="c.checked" name="check-line" color="#FFFFFF" font-size="14"></x-icon>
</view>
<view class="hr-flex1">
<text class="dd-ck-t">{{ c.title }}</text>
<text class="dd-ck-s">{{ c.sub }}</text>
</view>
</view>
</hr-card>
<!-- ============ 维护记录 ============ -->
<hr-sec title="维护记录" :first="false"></hr-sec>
<hr-card>
<hr-timeline :nodes="device.maintain"></hr-timeline>
</hr-card>
<hr-tip style="margin-top:14px" theme="warn" icon="⚠"
text="发现设备异常可直接一键报修,系统将自动流转至设备科并暂停借出。"></hr-tip>
<view style="height:14px"></view>
</view>
<template v-slot:footer>
<view class="hr-fixedbar">
<view class="hr-btn hr-btn-ghost dd-foot-report" @click="onReport">
<x-icon name="error-warning-line" color="#1F2937" font-size="16"></x-icon>
<text class="dd-foot-t">报修</text>
</view>
<view :class="allChecked ? 'hr-btn hr-btn-primary' : 'hr-btn hr-btn-disabled'" style="flex:1"
@click="onBorrow">
<text :class="allChecked ? 'dd-foot-t2' : 'dd-foot-t3'">
{{ allChecked ? '确认自检并借用' : '请完成 5 项自检' }}
</text>
</view>
</view>
</template>
</hr-page>
</template>
<script lang="uts" setup>
import { ref, computed } from 'vue'
import { deviceDetail } from '@/mock/index.uts'
import { HrDeviceCheck } from '@/mock/types.uts'
import { navBack } from '@/utils/nav.uts'
/**
* s32 设备详情
* 借用前 5 项自检缺一不可,全部确认后才可借出。
*/
const device = deviceDetail
const checks = ref<HrDeviceCheck[]>([])
// 深拷贝一份,避免污染 mock 数据
const arr : HrDeviceCheck[] = []
for (let i = 0; i < device.checkList.length; i++) {
const c = device.checkList[i]
arr.push({ title: c.title, sub: c.sub, checked: c.checked } as HrDeviceCheck)
}
checks.value = arr
const checkedCount = computed(() : number => {
let n = 0
for (let i = 0; i < checks.value.length; i++) {
if (checks.value[i].checked) { n++ }
}
return n
})
const allChecked = computed(() : boolean => {
return checkedCount.value == checks.value.length
})
function toggleCheck(i : number) : void {
const list : HrDeviceCheck[] = []
for (let k = 0; k < checks.value.length; k++) {
const c = checks.value[k]
list.push({
title: c.title, sub: c.sub, checked: k == i ? !c.checked : c.checked
} as HrDeviceCheck)
}
checks.value = list
}
function onBorrow() : void {
if (!allChecked.value) {
uni.showToast({ title: '请先完成 5 项自检', icon: 'none' })
return
}
uni.showLoading({ title: '提交中' })
setTimeout(() => {
uni.hideLoading()
uni.showModal({
title: '借用成功',
content: `${device.name}\n预计归还时间:3 天后(09-27)\n已生成借用单,逾期将自动提醒。`,
showCancel: false,
success: (res) => {
navBack()
}
})
}, 700)
}
function onReport() : void {
uni.showModal({
title: '一键报修',
editable: true,
placeholderText: '请描述设备异常,如:屏幕无法点亮',
success: (res) => {
if (res.confirm) {
uni.showToast({ title: '已流转至设备科', icon: 'success' })
}
}
})
}
function onMore() : void {
uni.showToast({ title: '设备档案 / 借还记录', icon: 'none' })
}
</script>
<style>
.dd-avt {
margin-right: 26rpx;
background-color: #2563EB;
}
.dd-name {
font-size: 33.6rpx;
font-weight: bold;
color: #1F2937;
}
.dd-meta {
font-size: 25.8rpx;
color: #6B7280;
margin-top: 10rpx;
}
.dd-ck {
display: flex;
flex-direction: row;
align-items: center;
padding-top: 22rpx;
padding-bottom: 22rpx;
border-bottom: 2rpx solid #F3F4F6;
}
.dd-ck-on {
/* 选中态体现在勾选框 */
}
.dd-ck-box {
width: 44rpx;
height: 44rpx;
border-radius: 14rpx;
border: 3rpx solid #D1D5DB;
background-color: #FFFFFF;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
margin-right: 22rpx;
}
.dd-ck-box-on {
border: 3rpx solid #10B981;
background-color: #10B981;
}
.dd-ck-t {
font-size: 30.2rpx;
font-weight: bold;
color: #1F2937;
}
.dd-ck-s {
font-size: 23.6rpx;
color: #9CA3AF;
margin-top: 4rpx;
}
.dd-foot-report {
flex: 0 0 220rpx;
margin-right: 18rpx;
}
.dd-foot-t {
font-size: 31.4rpx;
font-weight: bold;
color: #1F2937;
margin-left: 10rpx;
}
.dd-foot-t2 {
font-size: 32.4rpx;
font-weight: bold;
color: #FFFFFF;
}
.dd-foot-t3 {
font-size: 32.4rpx;
font-weight: bold;
color: #9CA3AF;
}
</style>
+277
View File
@@ -0,0 +1,277 @@
<template>
<view class="hr-page" style="background-color:#F3F4F6">
<hr-statusbar bg="#FFFFFF"></hr-statusbar>
<view class="wk-nav">
<text class="wk-nav-t">设备借用</text>
<view class="wk-nav-a" @click="onLedger">
<x-icon name="arrow-up-down-line" color="#6B7280" font-size="16"></x-icon>
<text class="wk-nav-a-t">台账</text>
</view>
</view>
<view class="hr-flow">
<scroll-view class="hr-scroll" :scroll-y="true" :show-scrollbar="false">
<view class="hr-body">
<!-- 状态筛选(xTabs -->
<x-tabs :list="tabList" :model-value="activeId" item-width="33.33%" height="84rpx" font-size="28rpx"
active-font-size="28rpx" title-color="#6B7280" active-title-color="#2563EB" line-color="#2563EB"
title-padding="0" :is-item-center="true" @change="onTabChange"></x-tabs>
<view style="margin-top:20rpx">
<view v-for="(d, i) in shown" :key="d.id" class="dv-card" @click="openDetail(d)">
<view class="dv-ic" :style="{ backgroundColor: d.iconBg }">
<x-icon :name="d.icon" :color="d.iconColor" font-size="44rpx"></x-icon>
</view>
<view class="hr-flex1">
<text class="dv-name">{{ d.name }}</text>
<text class="dv-meta">{{ d.meta }}</text>
</view>
<view class="dv-st" :style="stStyle(d.theme)">
<text class="dv-st-t" :style="stTextStyle(d.theme)">{{ d.status }}</text>
</view>
</view>
</view>
<hr-empty v-if="shown.length == 0" icon="⚙" title="该状态下暂无设备"
sub="切换上方分段查看其它状态的设备"></hr-empty>
<hr-empty v-if="shown.length == 0" icon="⚙" title="该状态下暂无设备"
sub="切换上方分段查看其它状态的设备"></hr-empty>
<hr-sec title="设备台账" more="全部 24 台 "></hr-sec>
<hr-card variant="list">
<hr-lrow icon="check-line" theme="grn" title="状态正常" sub="可随时借用">
<template v-slot:right>
<text class="dv-v dv-v-grn">18 台</text>
</template>
</hr-lrow>
<hr-lrow icon="file-list-3-line" theme="amb" title="借用中" sub="预计陆续归还">
<template v-slot:right>
<text class="dv-v dv-v-amb">4 台</text>
</template>
</hr-lrow>
<hr-lrow icon="monitor-line" theme="red" title="维修 / 保养中" sub="暂不可借" :is-last="true">
<template v-slot:right>
<text class="dv-v dv-v-red">2 台</text>
</template>
</hr-lrow>
</hr-card>
<view style="height:28rpx"></view>
</view>
</scroll-view>
<!-- 加载中覆盖层(组件库 xLoading -->
<view v-if="loading" class="hr-loading-mask">
<x-loading label="加载中…" :icon-size="'44rpx'" :text-size="'26rpx'" :vertical="true" color="#2563EB"
text-color="#6B7280"></x-loading>
</view>
</view>
<view class="hr-fixedbar">
<view class="hr-btn hr-btn-primary" style="flex:1" @click="onScanBorrow">
<x-icon name="scan-line" color="#FFFFFF" font-size="17"></x-icon>
<text class="dv-bt">扫码借用设备</text>
</view>
</view>
<hr-tabbar :active="2"></hr-tabbar>
</view>
</template>
<script lang="uts" setup>
import { ref, computed } from 'vue'
import { deviceList } from '@/mock/index.uts'
import { HrDevice } from '@/mock/types.uts'
import { navTo } from '@/utils/nav.uts'
import { mockRequest } from '@/utils/request.uts'
import { TABS_ITEM_INFO, TABS_ITEM } from '@/uni_modules/tmx-ui/interface.uts'
/**
* s31 设备借用(仓管 Tab「设备」)
* 按「可借用 / 借用中 / 全部」分段筛选,维修中设备不可借。
*/
const loading = ref(true)
const segIds : string[] = ['0', '1', '2']
const activeId = ref('0')
/** xTabs 数据 */
const tabList : TABS_ITEM_INFO[] = [
{ title: '可借用', id: '0' } as TABS_ITEM_INFO,
{ title: '借用中', id: '1' } as TABS_ITEM_INFO,
{ title: '全部', id: '2' } as TABS_ITEM_INFO
]
const seg = computed(() : number => {
const i = parseInt(activeId.value)
return isNaN(i) ? 0 : i
})
// ⚠ x-tabs 的 change 下发的是 TABS_ITEM(不是 TABS_ITEM_INFO),形参类型必须一致
function onTabChange(item : TABS_ITEM, index : number) : void {
activeId.value = item.id == '' ? index.toString() : item.id
}
onLoad(() => {
mockRequest(() => {
loading.value = false
})
})
const list = ref<HrDevice[]>(deviceList)
const shown = computed(() : HrDevice[] => {
const arr : HrDevice[] = []
for (let i = 0; i < list.value.length; i++) {
const d = list.value[i]
if (seg.value == 0 && d.status != '可借') { continue }
if (seg.value == 1 && d.status != '借用中') { continue }
arr.push(d)
}
return arr
})
function stStyle(theme : string) : string {
if (theme == 'grn') { return 'background-color:#ECFDF5' }
if (theme == 'amb') { return 'background-color:#FEF3C7' }
return 'background-color:#FEF2F2'
}
function stTextStyle(theme : string) : string {
if (theme == 'grn') { return 'color:#059669' }
if (theme == 'amb') { return 'color:#92400E' }
return 'color:#DC2626'
}
function openDetail(d : HrDevice) : void {
navTo('/pages/warehouse/device-detail?id=' + d.id)
}
function onScanBorrow() : void {
uni.showToast({ title: '对准设备二维码开始借用', icon: 'none' })
}
function onLedger() : void {
uni.showToast({ title: '设备台账(24 台)', icon: 'none' })
}
</script>
<style>
.wk-nav {
height: 96rpx;
display: flex;
flex-direction: row;
align-items: center;
padding-left: 32rpx;
padding-right: 32rpx;
background-color: #FFFFFF;
border-bottom: 2rpx solid #F3F4F6;
}
.wk-nav-t {
font-size: 33.6rpx;
font-weight: bold;
color: #1F2937;
}
.wk-nav-a {
margin-left: auto;
display: flex;
flex-direction: row;
align-items: center;
}
.wk-nav-a-t {
font-size: 29.2rpx;
color: #6B7280;
margin-left: 8rpx;
}
.dv-seg-t {
font-size: 29.2rpx;
color: #6B7280;
}
.dv-seg-t-on {
color: #2563EB;
font-weight: bold;
}
.dv-card {
display: flex;
flex-direction: row;
align-items: center;
background-color: #FFFFFF;
border: 2rpx solid #E5E7EB;
border-radius: 26rpx;
padding: 24rpx 24rpx 24rpx 24rpx;
margin-bottom: 18rpx;
}
.dv-ic {
width: 104rpx;
height: 104rpx;
border-radius: 26rpx;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
margin-right: 22rpx;
}
.dv-ic-t {
font-size: 49.2rpx;
}
.dv-name {
font-size: 30.2rpx;
font-weight: bold;
color: #1F2937;
lines: 1;
}
.dv-meta {
font-size: 23.6rpx;
color: #9CA3AF;
margin-top: 6rpx;
}
.dv-st {
border-radius: 12rpx;
padding: 6rpx 16rpx 6rpx 16rpx;
margin-left: 16rpx;
}
.dv-st-t {
font-size: 23.6rpx;
font-weight: bold;
}
.dv-v {
font-size: 31.4rpx;
font-weight: bold;
margin-left: 16rpx;
}
.dv-v-grn {
color: #059669;
}
.dv-v-amb {
color: #D97706;
}
.dv-v-red {
color: #DC2626;
}
.dv-bt {
font-size: 32.4rpx;
font-weight: bold;
color: #FFFFFF;
margin-left: 12rpx;
}
</style>
+273
View File
@@ -0,0 +1,273 @@
<template>
<hr-page page-bg="#F3F4F6" status-bg="#FFFFFF" nav-title="物资详情" :xloading="true" @right-click="onMore">
<template v-slot:right>
<x-icon name="more-fill" color="#6B7280" font-size="18"></x-icon>
</template>
<view class="hr-body">
<!-- ============ 概览 ============ -->
<hr-card>
<view class="hr-row">
<view class="gd-ring">
<x-circle-progress :size="76" :line-width="8" color="#F3F4F6" active-color="#F59E0B"
:model-value="ringPercent" :show-label="false" :duration="600"></x-circle-progress>
<view class="gd-ring-label">
<text class="gd-ring-b">{{ goods.stock }}</text>
<text class="gd-ring-u">{{ goods.unit }}</text>
</view>
</view>
<view class="hr-flex1">
<text class="gd-name">{{ goods.name }}</text>
<text class="gd-code">编码 {{ goods.code }} · {{ goods.spec }}</text>
<view style="margin-top:7px">
<hr-pc :level="0" :text="goods.statusLabel"></hr-pc>
</view>
</view>
</view>
</hr-card>
<!-- ============ 库存信息 ============ -->
<hr-sec title="库存信息"></hr-sec>
<hr-card variant="list">
<hr-lrow icon="▦" title="当前库存" :sub="'预警线 ' + goods.warnLine + ' ' + goods.unit">
<template v-slot:right>
<text class="gd-v gd-v-red">{{ goods.stock }} {{ goods.unit }}</text>
</template>
</hr-lrow>
<hr-lrow icon="⇅" theme="pri" title="近 30 天出库" :sub="goods.dailyUse">
<template v-slot:right>
<text class="gd-v">{{ goods.out30 }} {{ goods.unit }}</text>
</template>
</hr-lrow>
<hr-lrow icon="▤" theme="grn" title="最近入库" :sub="goods.lastIn" :is-last="true">
<template v-slot:right>
<text class="gd-v gd-v-grn">{{ goods.lastInQty }}</text>
</template>
</hr-lrow>
</hr-card>
<!-- ============ 消耗趋势 ============ -->
<hr-sec title="30 天消耗趋势" more="查看明细 "></hr-sec>
<hr-card>
<view class="gd-chart">
<view v-for="(h, i) in barHeights" :key="i" class="gd-bar-wrap">
<view class="gd-bar" :style="barStyle(i, h)"></view>
</view>
</view>
<view class="gd-xaxis">
<text v-for="(x, i) in xLabels" :key="i" class="gd-x">{{ x }}</text>
</view>
<view class="gd-legend">
<view class="gd-lg">
<view class="gd-lg-d" style="background-color:#EFF6FF"></view>
<text class="gd-lg-t">正常区间</text>
</view>
<view class="gd-lg">
<view class="gd-lg-d" style="background-color:#F59E0B"></view>
<text class="gd-lg-t">接近预警</text>
</view>
<view class="gd-lg">
<view class="gd-lg-d" style="background-color:#DC2626"></view>
<text class="gd-lg-t">低于预警线</text>
</view>
</view>
</hr-card>
<hr-tip style="margin-top:14px" theme="warn" icon="⚠"
:text="'按当前消耗速度(' + goods.dailyUse + '),库存预计 ' + daysLeft + ' 天后耗尽,建议尽快补货。'"></hr-tip>
<view class="hr-btn hr-btn-primary hr-btn-lg" style="margin-top:12px" @click="onRestock">
<x-icon name="add-line" color="#FFFFFF" font-size="17"></x-icon>
<text class="gd-bt">发起补货申请</text>
</view>
<view style="height:14px"></view>
</view>
</hr-page>
</template>
<script lang="uts" setup>
import { ref, computed } from 'vue'
import { goodsDetail } from '@/mock/index.uts'
import { navTo } from '@/utils/nav.uts'
/**
* s25 物资详情
* 库存环 + 库存信息 + 30 天消耗趋势 + 补货入口。
*/
const goods = goodsDetail
const xLabels : string[] = ['08-24', '08-31', '09-07', '09-14', '09-21']
const ringPercent = computed(() : number => {
// 库存 / (预警线 × 2.5) —— 8 / 50 ≈ 16%,与设计稿 18% 量级一致
return Math.round(goods.stock * 100 / (goods.warnLine * 2.5))
})
const barHeights = computed(() : number[] => {
const arr : number[] = []
for (let i = 0; i < goods.trend.length; i++) {
arr.push(Math.round(176 * goods.trend[i] / 100))
}
return arr
})
const daysLeft = computed(() : number => {
const daily = goods.out30 / 30
if (daily <= 0) { return 0 }
return Math.floor(goods.stock / daily)
})
function barStyle(i : number, h : number) : string {
let color = '#EFF6FF'
if (i == goods.trend.length - 1) { color = '#DC2626' }
else if (i == goods.trend.length - 2) { color = '#F59E0B' }
return `height:${h}rpx;background-color:${color};`
}
function onMore() : void {
uni.showToast({ title: '出入库流水 / 编辑物资', icon: 'none' })
}
function onRestock() : void {
uni.showModal({
title: '发起补货申请',
content: `物资:${goods.name}\n建议补货量:40 ${goods.unit}(约 2 周用量)`,
cancelText: '取消',
confirmText: '提交申请',
success: (res) => {
if (res.confirm) {
uni.showToast({ title: '补货申请已提交', icon: 'success' })
setTimeout(() => {
navTo('/pages/message/index')
}, 700)
}
}
})
}
</script>
<style>
.gd-ring {
width: 152rpx;
height: 152rpx;
position: relative;
margin-right: 26rpx;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
}
.gd-ring-label {
position: absolute;
left: 0rpx;
top: 0rpx;
width: 152rpx;
height: 152rpx;
flex-direction: row;
align-items: center;
justify-content: center;
}
.gd-ring-b {
font-size: 42.6rpx;
font-weight: bold;
color: #1F2937;
}
.gd-ring-u {
font-size: 22.4rpx;
color: #9CA3AF;
margin-left: 2rpx;
}
.gd-name {
font-size: 33.6rpx;
font-weight: bold;
color: #1F2937;
}
.gd-code {
font-size: 25.8rpx;
color: #6B7280;
margin-top: 10rpx;
}
.gd-v {
font-size: 33.6rpx;
font-weight: bold;
color: #1F2937;
margin-left: 16rpx;
}
.gd-v-red {
color: #DC2626;
}
.gd-v-grn {
color: #059669;
}
/* ---------- 趋势图 ---------- */
.gd-chart {
display: flex;
flex-direction: row;
align-items: flex-end;
height: 176rpx;
}
.gd-bar-wrap {
flex: 1;
justify-content: flex-end;
margin-right: 10rpx;
}
.gd-bar {
border-radius: 8rpx 8rpx 0rpx 0rpx;
}
.gd-xaxis {
display: flex;
flex-direction: row;
justify-content: space-between;
margin-top: 14rpx;
}
.gd-x {
font-size: 22.4rpx;
color: #9CA3AF;
}
.gd-legend {
display: flex;
flex-direction: row;
margin-top: 20rpx;
}
.gd-lg {
display: flex;
flex-direction: row;
align-items: center;
margin-right: 28rpx;
}
.gd-lg-d {
width: 20rpx;
height: 20rpx;
border-radius: 6rpx;
margin-right: 10rpx;
}
.gd-lg-t {
font-size: 23.6rpx;
color: #6B7280;
}
.gd-bt {
font-size: 35.8rpx;
font-weight: bold;
color: #FFFFFF;
margin-left: 12rpx;
}
</style>
+397
View File
@@ -0,0 +1,397 @@
<template>
<view class="hr-page" style="background-color:#F3F4F6">
<hr-statusbar bg="#FFFFFF"></hr-statusbar>
<view class="wk-nav">
<text class="wk-nav-t">扫码出入库</text>
<view class="wk-nav-a" @click="toggleFlash">
<x-icon name="flashlight-line" :color="flash ? '#2563EB' : '#6B7280'" font-size="16"></x-icon>
<text class="wk-nav-a-t">手电</text>
</view>
</view>
<scroll-view class="hr-scroll" :scroll-y="true" :show-scrollbar="false">
<view class="hr-body">
<!-- ============ 取景框 ============ -->
<view class="sc-box">
<!-- 真实相机(APP / H5 -->
<!-- #ifdef APP || WEB -->
<x-mlkit-scannig-u :flash="flash" :autoOpenCamera="true" :cameraWidth="1080"
:cameraeiHght="1080" style="width:100%;height:100%;position:absolute;left:0;top:0"
@scan="onScan"></x-mlkit-scannig-u>
<!-- #endif -->
<view class="sc-frame">
<view class="sc-cf sc-cf-tl"></view>
<view class="sc-cf sc-cf-tr"></view>
<view class="sc-cf sc-cf-bl"></view>
<view class="sc-cf sc-cf-br"></view>
<view class="sc-line" :style="laserStyle"></view>
</view>
<text class="sc-tip">将条码对准框内 · 自动识别</text>
</view>
<!-- ============ 业务动作 ============ -->
<view class="hr-seg" style="margin-top:12px">
<view v-for="(m, i) in modes" :key="i" :class="i == modeIdx ? 'hr-seg-i hr-seg-i-on' : 'hr-seg-i'"
@click="modeIdx = i">
<text :class="i == modeIdx ? 'sc-seg-t sc-seg-t-on' : 'sc-seg-t'">{{ m }}</text>
</view>
</view>
<!-- ============ 识别反馈(三态) ============ -->
<hr-sec title="识别反馈" :first="false"></hr-sec>
<view v-if="feedbacks.length == 0" class="sc-empty">
<x-icon name="scan-line" color="#9CA3AF" font-size="24"></x-icon>
<text class="sc-empty-t">等待扫码…</text>
<text class="sc-empty-s">识别后会在此即时给出 ✅ 成功 / ⚠ 库存不足 / ❌ 未登记 三种反馈</text>
</view>
<view v-for="(f, i) in feedbacks" :key="i" :class="feedbackClass(f.theme)">
<view class="sc-st-ic" :style="{ backgroundColor: feedbackColor(f.theme) }">
<text class="sc-st-ic-t">{{ f.icon }}</text>
</view>
<view class="hr-flex1">
<text class="sc-st-b">{{ f.title }}</text>
<text class="sc-st-s">{{ f.sub }}</text>
</view>
</view>
<!-- ============ 本次连续扫入 ============ -->
<hr-sec title="本次连续扫入" :more="records.length + ' 条'"></hr-sec>
<hr-card variant="list">
<hr-lrow v-for="(r, i) in records" :key="i" :icon="r.icon"
:theme="r.theme == 'grn' ? 'grn' : (r.theme == 'amb' ? 'amb' : 'red')" :title="r.name"
:sub="r.time + ' · ' + r.delta" :value="r.status"
:value-theme="r.theme == 'grn' ? 'grn' : 'amb'" :value-bold="true"
:is-last="i == records.length - 1" @click="onRecord(r)"></hr-lrow>
</hr-card>
<view style="height:14px"></view>
</view>
</scroll-view>
<hr-tabbar :active="0"></hr-tabbar>
</view>
</template>
<script lang="uts" setup>
import { ref, computed } from 'vue'
import { scanFeedbacks, scanRecords } from '@/mock/index.uts'
import { HrScanFeedback, HrScanRecord } from '@/mock/types.uts'
/**
* s23 扫码出入库(仓管 Tab「扫码」)
* 连续扫码三态即时反馈:✅ 入库成功 / ⚠ 库存不足 / ❌ 未登记。
*/
const modes : string[] = ['入库', '出库', '盘点']
const modeIdx = ref(0)
const flash = ref(false)
const feedbacks = ref<HrScanFeedback[]>([
scanFeedbacks[0], scanFeedbacks[1], scanFeedbacks[2]
])
const records = ref<HrScanRecord[]>([
scanRecords[0], scanRecords[1], scanRecords[2]
])
let seq = 3
function shortCode(code : string) : string {
if (code.length <= 10) { return code }
return code.substring(0, 10)
}
function nowTime() : string {
const d = new Date()
const hh = d.getHours() < 10 ? '0' + d.getHours().toString() : d.getHours().toString()
const mm = d.getMinutes() < 10 ? '0' + d.getMinutes().toString() : d.getMinutes().toString()
return hh + ':' + mm
}
/** 依据条码特征给出三态反馈(mock 规则,后续替换为接口返回) */
function handleCode(code : string) : void {
seq = seq + 1
const fb : HrScanFeedback = {
theme: 'ok', icon: '✓',
title: '一次性医用口罩 · ' + modes[modeIdx.value] + '成功',
sub: '规格 50 只/盒 · 现存 320 盒 · 张明远 · 编码 ' + shortCode(code)
} as HrScanFeedback
const rec : HrScanRecord = {
name: '一次性医用口罩', time: nowTime(), delta: '+1 盒', status: '已入库', theme: 'grn', icon: '✓'
} as HrScanRecord
const fbArr : HrScanFeedback[] = []
fbArr.push(fb)
for (let i = 0; i < feedbacks.value.length; i++) {
if (i < 2) { fbArr.push(feedbacks.value[i]) }
}
feedbacks.value = fbArr
const recArr : HrScanRecord[] = []
recArr.push(rec)
for (let i = 0; i < records.value.length; i++) {
if (i < 4) { recArr.push(records.value[i]) }
}
records.value = recArr
uni.showToast({ title: '识别成功', icon: 'success' })
}
function onScan(res : any) : void {
const arr = res as string[]
if (arr == null || arr.length == 0) { return }
handleCode(arr[0])
}
function feedbackClass(theme : string) : string {
return `sc-st sc-st-${theme}`
}
function feedbackColor(theme : string) : string {
if (theme == 'ok') { return '#10B981' }
if (theme == 'warn') { return '#F59E0B' }
return '#DC2626'
}
function onRecord(r : HrScanRecord) : void {
uni.showModal({ title: r.name, content: r.time + ' · ' + r.delta + '\n状态:' + r.status, showCancel: false })
}
function toggleFlash() : void {
flash.value = !flash.value
uni.showToast({ title: flash.value ? '已打开手电' : '已关闭手电', icon: 'none' })
}
/*
* 取景框扫描线
* ⚠ App 端 uvue 不支持 @keyframes / animation(编译期直接报 ERROR),
* 改为定时器逐帧推进 topsc-frame 高 372rpx16%~84% ⇒ 60~313rpx。
* 周期 2.2s68ms × 32 帧),与设计稿 hrScanMove 一致。
*/
const laserTop = ref(60)
let laserT = 0.0
let laserTimer : number = 0
const laserStyle = computed(() : string => {
return 'top:' + laserTop.value.toString() + 'rpx;'
})
function startLaser() : void {
if (laserTimer != 0) { return }
laserTimer = setInterval(() => {
laserT = laserT + 0.03125 // 1/3232 帧正好走完一个往返,端点(16%/84%)可精确命中
if (laserT > 1) { laserT = laserT - 1 }
const k = laserT < 0.5 ? laserT * 2 : (1 - laserT) * 2
laserTop.value = Math.round(60 + k * 253)
}, 68)
}
function stopLaser() : void {
if (laserTimer != 0) {
clearInterval(laserTimer)
laserTimer = 0
}
}
onLoad(() => {
startLaser()
})
onUnload(() => {
stopLaser()
})
</script>
<style>
.wk-nav {
height: 96rpx;
display: flex;
flex-direction: row;
align-items: center;
padding-left: 32rpx;
padding-right: 32rpx;
background-color: #FFFFFF;
border-bottom: 2rpx solid #F3F4F6;
}
.wk-nav-t {
font-size: 33.6rpx;
font-weight: bold;
color: #1F2937;
}
.wk-nav-a {
margin-left: auto;
display: flex;
flex-direction: row;
align-items: center;
}
.wk-nav-a-t {
font-size: 29.2rpx;
color: #6B7280;
margin-left: 8rpx;
}
/* ---------- 取景框 ---------- */
.sc-box {
width: 100%;
height: 686rpx;
background-color: #0F172A;
border-radius: 32rpx;
overflow: hidden;
position: relative;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
}
.sc-frame {
width: 372rpx;
height: 372rpx;
position: relative;
}
.sc-cf {
position: absolute;
width: 52rpx;
height: 52rpx;
border: 6rpx solid #10B981;
border-radius: 8rpx;
}
.sc-cf-tl {
top: 0rpx;
left: 0rpx;
border-right-width: 0rpx;
border-bottom-width: 0rpx;
}
.sc-cf-tr {
top: 0rpx;
right: 0rpx;
border-left-width: 0rpx;
border-bottom-width: 0rpx;
}
.sc-cf-bl {
bottom: 0rpx;
left: 0rpx;
border-right-width: 0rpx;
border-top-width: 0rpx;
}
.sc-cf-br {
bottom: 0rpx;
right: 0rpx;
border-left-width: 0rpx;
border-top-width: 0rpx;
}
/* 扫描线:⚠ App 端 uvue 不支持 @keyframes/animation
上下位移已改为 script 内定时器推进 laserTop + laserStyle 下发 top。 */
.sc-line {
position: absolute;
left: 9%;
right: 9%;
top: 60rpx;
height: 4rpx;
background-color: #10B981;
}
.sc-tip {
position: absolute;
bottom: 24rpx;
left: 0rpx;
right: 0rpx;
text-align: center;
font-size: 25.8rpx;
color: rgba(255, 255, 255, 0.78);
}
.sc-seg-t {
font-size: 29.2rpx;
color: #6B7280;
}
.sc-seg-t-on {
color: #2563EB;
font-weight: bold;
}
/* ---------- 三态反馈 ---------- */
.sc-st {
display: flex;
flex-direction: row;
align-items: center;
padding: 22rpx 24rpx 22rpx 24rpx;
border-radius: 22rpx;
margin-bottom: 16rpx;
}
.sc-st-ok {
background-color: #ECFDF5;
border: 2rpx solid #A7F3D0;
}
.sc-st-warn {
background-color: #FFFBEB;
border: 2rpx solid #FDE68A;
}
.sc-st-err {
background-color: #FEF2F2;
border: 2rpx solid #FECACA;
}
.sc-st-ic {
width: 52rpx;
height: 52rpx;
border-radius: 16rpx;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
margin-right: 20rpx;
}
.sc-st-ic-t {
font-size: 29.2rpx;
font-weight: bold;
color: #FFFFFF;
}
.sc-st-b {
font-size: 28rpx;
font-weight: bold;
}
.sc-st-s {
font-size: 23.6rpx;
margin-top: 4rpx;
}
.sc-empty {
align-items: center;
padding-top: 40rpx;
padding-bottom: 40rpx;
}
.sc-empty-t {
font-size: 28rpx;
font-weight: bold;
color: #6B7280;
margin-top: 16rpx;
}
.sc-empty-s {
font-size: 23.6rpx;
color: #9CA3AF;
margin-top: 8rpx;
text-align: center;
}
</style>
+206
View File
@@ -0,0 +1,206 @@
<template>
<view class="hr-page" style="background-color:#F3F4F6">
<hr-statusbar bg="#FFFFFF"></hr-statusbar>
<view class="wk-nav">
<text class="wk-nav-t">库存查询</text>
<view class="wk-nav-a" @click="onExport">
<x-icon name="arrow-up-down-line" color="#6B7280" font-size="16"></x-icon>
<text class="wk-nav-a-t">导出</text>
</view>
</view>
<view class="hr-flow">
<scroll-view class="hr-scroll" :scroll-y="true" :show-scrollbar="false">
<view class="hr-body">
<!-- 搜索(xSearch -->
<x-search v-model="keyword" placeholder="搜索物资名称 / 编码" round="24rpx" height="84rpx"
:border="searchBorder" input-bg-color="#FFFFFF" icon-color="#9CA3AF" font-color="#1F2937"
:show-cancel="false"></x-search>
<view class="st-bar">
<view class="st-cell">
<text class="st-b">128</text>
<text class="st-s">物资品类</text>
</view>
<view class="st-sep"></view>
<view class="st-cell">
<text class="st-b" style="color:#DC2626">6</text>
<text class="st-s">低库存预警</text>
</view>
<view class="st-sep"></view>
<view class="st-cell">
<text class="st-b" style="color:#2563EB">14</text>
<text class="st-s">借用中</text>
</view>
</view>
<!-- ============ 库存预警 ============ -->
<hr-sec title="库存预警" :more="warnList.length + ' 项 '"></hr-sec>
<hr-card variant="danger">
<hr-lrow v-for="(w, i) in warnList" :key="i" :icon="w.icon" :theme="w.low ? 'red' : 'amb'"
:title="w.name" :sub="w.spec" :value="w.value" :value-theme="w.low ? 'red' : 'amb'"
:value-bold="true" :is-last="i == warnList.length - 1" @click="openGoods(w)"></hr-lrow>
</hr-card>
<!-- ============ 全部物资 ============ -->
<hr-sec title="全部物资" more="按库存排序 "></hr-sec>
<hr-card variant="list">
<hr-lrow v-for="(s, i) in shownAll" :key="i" :icon="s.icon"
:theme="s.low ? 'amb' : 'pri'" :title="s.name" :sub="s.spec" :value="s.value"
:value-theme="s.valueTheme" :value-bold="true" :value-size="'26rpx'"
:is-last="i == shownAll.length - 1" @click="openGoods(s)"></hr-lrow>
</hr-card>
<hr-empty v-if="shownAll.length == 0" icon="⌕" title="没有匹配的物资"
sub="换个物资名称或编码试试"></hr-empty>
<view style="height:28rpx"></view>
</view>
</scroll-view>
<!-- 加载中覆盖层(组件库 xLoading -->
<view v-if="loading" class="hr-loading-mask">
<x-loading label="加载中…" :icon-size="'44rpx'" :text-size="'26rpx'" :vertical="true" color="#2563EB"
text-color="#6B7280"></x-loading>
</view>
</view>
<hr-tabbar :active="1"></hr-tabbar>
</view>
</template>
<script lang="uts" setup>
import { ref, computed } from 'vue'
import { stockWarn, stockAll } from '@/mock/index.uts'
import { HrStockItem } from '@/mock/types.uts'
import { navTo } from '@/utils/nav.uts'
import { mockRequest } from '@/utils/request.uts'
/**
* s24 库存查询(仓管 Tab「仓库」)
* 库存预警自动置顶,支持关键字过滤。
*/
const loading = ref(true)
const keyword = ref('')
/**
* xSearch 的 border 是「数组」prop,固定 3 项 = [宽度, 线型, 颜色],
* 少于 3 项内部直接返回 none。设计稿搜索框为 1px solid #E5E7EB。
*/
const searchBorder : string[] = ['1px', 'solid', '#E5E7EB']
onLoad(() => {
mockRequest(() => {
loading.value = false
})
})
const warnList = ref<HrStockItem[]>(stockWarn)
const allList = ref<HrStockItem[]>(stockAll)
const shownAll = computed(() : HrStockItem[] => {
if (keyword.value == '') { return allList.value }
const kw = keyword.value.toLowerCase()
const arr : HrStockItem[] = []
for (let i = 0; i < allList.value.length; i++) {
if (allList.value[i].name.toLowerCase().indexOf(kw) > -1) { arr.push(allList.value[i]) }
}
return arr
})
function openGoods(s : HrStockItem) : void {
navTo('/pages/warehouse/goods-detail?name=' + s.name)
}
function onExport() : void {
uni.showToast({ title: '已生成库存清单 Excel', icon: 'none' })
}
</script>
<style>
.wk-nav {
height: 96rpx;
display: flex;
flex-direction: row;
align-items: center;
padding-left: 32rpx;
padding-right: 32rpx;
background-color: #FFFFFF;
border-bottom: 2rpx solid #F3F4F6;
}
.wk-nav-t {
font-size: 33.6rpx;
font-weight: bold;
color: #1F2937;
}
.wk-nav-a {
margin-left: auto;
display: flex;
flex-direction: row;
align-items: center;
}
.wk-nav-a-t {
font-size: 29.2rpx;
color: #6B7280;
margin-left: 8rpx;
}
.st-srch {
display: flex;
flex-direction: row;
align-items: center;
background-color: #FFFFFF;
border: 2rpx solid #E5E7EB;
border-radius: 22rpx;
height: 92rpx;
padding: 0rpx 24rpx 0rpx 24rpx;
}
.st-srch-input {
flex: 1;
font-size: 29.2rpx;
color: #1F2937;
height: 88rpx;
padding: 0rpx 18rpx 0rpx 18rpx;
}
.st-bar {
display: flex;
flex-direction: row;
background-color: #FFFFFF;
border: 2rpx solid #E5E7EB;
border-radius: 26rpx;
padding: 26rpx 8rpx 26rpx 8rpx;
margin-top: 20rpx;
}
.st-cell {
flex: 1;
align-items: center;
}
.st-b {
font-size: 44.8rpx;
font-weight: bold;
color: #1F2937;
}
.st-s {
font-size: 23.6rpx;
color: #6B7280;
margin-top: 6rpx;
}
.st-sep {
width: 2rpx;
height: 60rpx;
background-color: #F3F4F6;
margin-top: 6rpx;
}
</style>