11
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
node_modules
|
||||
dist
|
||||
dist.zip
|
||||
*.local
|
||||
.DS_Store
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
@@ -0,0 +1,62 @@
|
||||
# 智慧医院移动手持端 (smart_hospital_mobile)
|
||||
|
||||
模拟医院 PDA 手持机的 Web App,配合 RFID 标签完成物资的**出入库盘点**与**借用归还**管理。
|
||||
|
||||
> 移动端 Web 应用,无需调起摄像头,扫码仅模拟 RFID 手持机一键读取。
|
||||
|
||||
## 技术栈
|
||||
|
||||
- Vue 3 + TypeScript + Vite
|
||||
- Vue Router(Hash 模式,可 file:// 离线打开)
|
||||
- Pinia(带 localStorage 持久化)
|
||||
- Element Plus(移动端样式适配)
|
||||
|
||||
## 功能
|
||||
|
||||
| 模块 | 说明 |
|
||||
|------|------|
|
||||
| 首页 | 物资总览、快捷入口、待归还、最近操作 |
|
||||
| 物资管理 | 列表 + 搜索 + 多维筛选 + 详情 |
|
||||
| 出入库盘点 | 一键扫码入库 / 出库,自动更新库存 |
|
||||
| 借用管理 | 扫码识别自动判断借用或归还流程 |
|
||||
| 操作记录 | 全部历史记录,按类型筛选 |
|
||||
| 个人中心 | 设备信息、数据重置 |
|
||||
|
||||
## 启动
|
||||
|
||||
```bash
|
||||
cd smart_hospital_mobile
|
||||
npm install
|
||||
npm run dev # 开发服:http://localhost:5174
|
||||
npm run build # 生产构建(输出 dist/)
|
||||
npm run preview # 预览 dist
|
||||
```
|
||||
|
||||
构建产物 `dist/` 可直接用 `file://` 协议或静态服务器打开。
|
||||
|
||||
## 演示数据
|
||||
|
||||
首次打开自动生成 12 个物资 + 6 条历史操作,全部带 RFID 标签号:
|
||||
|
||||
- 医疗器械:心电监护仪、除颤仪、便携式呼吸机...
|
||||
- 设备:输液泵、轮椅、担架...
|
||||
- 仪器:B 超机、心电图机、血压计...
|
||||
- 耗材:口罩、注射器...
|
||||
|
||||
所有操作(入库/出库/借用/归还)会即时写入 localStorage,刷新页面不丢失。
|
||||
在"我的 → 重置演示数据"可恢复初始状态。
|
||||
|
||||
## 路由
|
||||
|
||||
```
|
||||
/ → /home
|
||||
/home 首页
|
||||
/material 物资列表
|
||||
/material/detail/:id 物资详情
|
||||
/inventory 出入库入口
|
||||
/inventory/scan?mode=... 扫码出入库(mode: inbound | outbound)
|
||||
/borrow 借用列表
|
||||
/borrow/scan 扫码借用/归还
|
||||
/history 操作记录
|
||||
/profile 个人中心
|
||||
```
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text x='50%' y='55%' font-size='80' text-anchor='middle' dominant-baseline='middle'>📱</text></svg>" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#1989fa" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<title>智慧医院移动手持端</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+1589
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "smart-hospital-mobile",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"description": "医院物资管理移动手持端 - RFID 扫码出入库借用",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc -b && vite build",
|
||||
"preview": "vite preview --port 5174"
|
||||
},
|
||||
"dependencies": {
|
||||
"@element-plus/icons-vue": "^2.3.2",
|
||||
"element-plus": "^2.13.7",
|
||||
"pinia": "^3.0.4",
|
||||
"vue": "^3.5.32",
|
||||
"vue-router": "^4.6.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.12.2",
|
||||
"@vitejs/plugin-vue": "^6.0.6",
|
||||
"@vue/tsconfig": "^0.9.1",
|
||||
"typescript": "~6.0.2",
|
||||
"vite": "^8.0.10",
|
||||
"vue-tsc": "^3.2.7"
|
||||
}
|
||||
}
|
||||
+92
@@ -0,0 +1,92 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, onMounted, provide } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ArrowLeft, User } from '@element-plus/icons-vue'
|
||||
import TabBar from './components/TabBar.vue'
|
||||
import { Toast } from './composables/useToast'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const showTabBar = computed(() => route.meta?.hideTabBar !== true)
|
||||
const showBack = computed(() => route.meta?.hideTabBar === true)
|
||||
const pageTitle = computed(() => (route.meta?.title as string) || '智慧医院')
|
||||
|
||||
const currentUser = ref({ name: '张三', role: '库管员' })
|
||||
|
||||
function goProfile() {
|
||||
router.push('/profile')
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
if (window.history.length > 1) {
|
||||
router.back()
|
||||
} else {
|
||||
router.push('/home')
|
||||
}
|
||||
}
|
||||
|
||||
provide('currentUser', currentUser)
|
||||
|
||||
onMounted(() => {
|
||||
console.log('Mobile app initialized')
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mobile-app">
|
||||
<header class="mobile-header">
|
||||
<div v-if="showBack" class="back-btn" @click="goBack" aria-label="返回">
|
||||
<el-icon :size="20"><ArrowLeft /></el-icon>
|
||||
</div>
|
||||
<div v-else style="width: 28px"></div>
|
||||
<div class="title">{{ pageTitle }}</div>
|
||||
<div class="header-action" @click="goProfile">
|
||||
<el-icon><User /></el-icon>
|
||||
<span>{{ currentUser.name }}</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="mobile-content">
|
||||
<router-view v-slot="{ Component }">
|
||||
<transition name="slide" mode="out-in">
|
||||
<component :is="Component" />
|
||||
</transition>
|
||||
</router-view>
|
||||
</main>
|
||||
|
||||
<TabBar v-if="showTabBar" />
|
||||
|
||||
<Toast />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.slide-enter-active,
|
||||
.slide-leave-active {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.slide-enter-from {
|
||||
transform: translateX(20px);
|
||||
opacity: 0;
|
||||
}
|
||||
.slide-leave-to {
|
||||
transform: translateX(-20px);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 14px;
|
||||
cursor: pointer;
|
||||
margin-right: 4px;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.back-btn:active {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,81 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
interface Props {
|
||||
rfid?: string
|
||||
scanning?: boolean
|
||||
showModeTabs?: boolean
|
||||
mode?: 'inbound' | 'outbound' | 'borrow' | 'return'
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
rfid: '',
|
||||
scanning: false,
|
||||
showModeTabs: false,
|
||||
mode: 'inbound',
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'scan'): void
|
||||
(e: 'mode-change', m: 'inbound' | 'outbound' | 'borrow' | 'return'): void
|
||||
}>()
|
||||
|
||||
const internalMode = ref(props.mode)
|
||||
|
||||
const modeList = [
|
||||
{ value: 'inbound', label: '入库' },
|
||||
{ value: 'outbound', label: '出库' },
|
||||
{ value: 'borrow', label: '借用' },
|
||||
{ value: 'return', label: '归还' },
|
||||
] as const
|
||||
|
||||
function handleScan() {
|
||||
emit('scan')
|
||||
}
|
||||
|
||||
function switchMode(m: 'inbound' | 'outbound' | 'borrow' | 'return') {
|
||||
internalMode.value = m
|
||||
emit('mode-change', m)
|
||||
}
|
||||
|
||||
const displayRfid = computed(() => {
|
||||
if (props.scanning) return '扫描中…'
|
||||
return props.rfid || '请按下扫码键'
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="scan-zone">
|
||||
<div v-if="showModeTabs" class="scan-mode-tabs">
|
||||
<div
|
||||
v-for="m in modeList"
|
||||
:key="m.value"
|
||||
class="scan-mode-tab"
|
||||
:class="{ active: internalMode === m.value }"
|
||||
@click="switchMode(m.value)"
|
||||
>
|
||||
{{ m.label }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="scan-title">RFID 标签识别</div>
|
||||
<div class="scan-rfid">{{ displayRfid }}</div>
|
||||
|
||||
<button class="scan-btn-big" :class="{ scanning }" @click="handleScan">
|
||||
<el-icon class="icon">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 1024 1024"
|
||||
width="20"
|
||||
height="20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
d="M448 192l-32 128H256l128 384-96 128h128l96-128h128l96 128h128l-96-128 128-384H768l-32-128H448zM384 416h384l-32 192h-320l-32-192z"
|
||||
/>
|
||||
</svg>
|
||||
</el-icon>
|
||||
<span>{{ scanning ? '扫描中…' : '扫码' }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,36 @@
|
||||
<script setup lang="ts">
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { computed } from 'vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const tabs = [
|
||||
{ name: 'home', path: '/home', label: '首页', icon: '🏠' },
|
||||
{ name: 'material', path: '/material', label: '物资', icon: '📦' },
|
||||
{ name: 'inventory', path: '/inventory', label: '出入库', icon: '📥' },
|
||||
{ name: 'borrow', path: '/borrow', label: '借用', icon: '🤝' },
|
||||
{ name: 'history', path: '/history', label: '记录', icon: '📋' },
|
||||
]
|
||||
|
||||
const active = computed(() => route.path)
|
||||
|
||||
function go(path: string) {
|
||||
if (route.path !== path) router.push(path)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav class="mobile-tabbar">
|
||||
<div
|
||||
v-for="t in tabs"
|
||||
:key="t.name"
|
||||
class="tab-item"
|
||||
:class="{ active: active.startsWith(t.path) }"
|
||||
@click="go(t.path)"
|
||||
>
|
||||
<div class="icon">{{ t.icon }}</div>
|
||||
<div class="label">{{ t.label }}</div>
|
||||
</div>
|
||||
</nav>
|
||||
</template>
|
||||
@@ -0,0 +1,48 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
// 单例 Toast 状态
|
||||
const visible = ref(false)
|
||||
const message = ref('')
|
||||
const type = ref<'default' | 'success' | 'warning' | 'error'>('default')
|
||||
let timer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
function show(msg: string, t: 'default' | 'success' | 'warning' | 'error' = 'default', duration = 1600) {
|
||||
message.value = msg
|
||||
type.value = t
|
||||
visible.value = true
|
||||
if (timer) clearTimeout(timer)
|
||||
timer = setTimeout(() => {
|
||||
visible.value = false
|
||||
}, duration)
|
||||
}
|
||||
|
||||
export function useToast() {
|
||||
return {
|
||||
show,
|
||||
success: (msg: string) => show(msg, 'success'),
|
||||
warning: (msg: string) => show(msg, 'warning'),
|
||||
error: (msg: string) => show(msg, 'error'),
|
||||
}
|
||||
}
|
||||
|
||||
// 单例组件渲染用
|
||||
export const ToastState = {
|
||||
visible,
|
||||
message,
|
||||
type,
|
||||
}
|
||||
|
||||
// 渲染组件
|
||||
import { defineComponent, h } from 'vue'
|
||||
|
||||
export const Toast = defineComponent({
|
||||
name: 'Toast',
|
||||
setup() {
|
||||
return () => {
|
||||
if (!ToastState.visible.value) return null
|
||||
const t = ToastState.type.value
|
||||
const cls = t === 'default' ? '' : t
|
||||
return h('div', { class: `toast ${cls}` }, ToastState.message.value)
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,355 @@
|
||||
// 物资类型定义
|
||||
export type MaterialStatus = 'in_stock' | 'borrowed' | 'maintenance'
|
||||
export type MaterialCategory =
|
||||
| 'medical_device'
|
||||
| 'consumable'
|
||||
| 'equipment'
|
||||
| 'instrument'
|
||||
| 'office'
|
||||
|
||||
export interface Material {
|
||||
id: string
|
||||
rfid: string // RFID 标签号
|
||||
code: string // 物资编号
|
||||
name: string
|
||||
category: MaterialCategory
|
||||
spec: string // 规格型号
|
||||
unit: string // 单位
|
||||
manufacturer: string
|
||||
warehouse: string // 仓库
|
||||
location: string // 位置
|
||||
status: MaterialStatus
|
||||
stock: number // 库存
|
||||
borrower?: string
|
||||
borrowTime?: string
|
||||
expectedReturn?: string
|
||||
borrowReason?: string
|
||||
lastUpdate: string
|
||||
}
|
||||
|
||||
// 出入库记录
|
||||
export type OpType = 'inbound' | 'outbound' | 'borrow' | 'return'
|
||||
|
||||
export interface Operation {
|
||||
id: string
|
||||
type: OpType
|
||||
rfid: string
|
||||
materialId: string
|
||||
materialName: string
|
||||
quantity: number
|
||||
operator: string
|
||||
department?: string
|
||||
reason?: string
|
||||
time: string
|
||||
}
|
||||
|
||||
const CATEGORY_LABELS: Record<MaterialCategory, string> = {
|
||||
medical_device: '医疗器械',
|
||||
consumable: '耗材',
|
||||
equipment: '设备',
|
||||
instrument: '仪器',
|
||||
office: '办公',
|
||||
}
|
||||
|
||||
export const categoryLabels = CATEGORY_LABELS
|
||||
|
||||
const STATUS_LABELS: Record<MaterialStatus, string> = {
|
||||
in_stock: '在库',
|
||||
borrowed: '已借出',
|
||||
maintenance: '维修中',
|
||||
}
|
||||
|
||||
export const statusLabels = STATUS_LABELS
|
||||
|
||||
const OP_LABELS: Record<OpType, string> = {
|
||||
inbound: '入库',
|
||||
outbound: '出库',
|
||||
borrow: '借用',
|
||||
return: '归还',
|
||||
}
|
||||
|
||||
export const opLabels = OP_LABELS
|
||||
|
||||
export const opIcons: Record<OpType, string> = {
|
||||
inbound: '📥',
|
||||
outbound: '📤',
|
||||
borrow: '🤝',
|
||||
return: '↩️',
|
||||
}
|
||||
|
||||
// 模拟物资数据(每个都有 RFID 标签)
|
||||
export const initialMaterials: Material[] = [
|
||||
{
|
||||
id: 'M001',
|
||||
rfid: 'RFID-A001-2401',
|
||||
code: 'WS-2024-0001',
|
||||
name: '心电监护仪',
|
||||
category: 'medical_device',
|
||||
spec: 'PM-9000',
|
||||
unit: '台',
|
||||
manufacturer: '迈瑞医疗',
|
||||
warehouse: '中心仓库',
|
||||
location: 'A-01-03',
|
||||
status: 'in_stock',
|
||||
stock: 5,
|
||||
lastUpdate: '2026-07-28 14:23',
|
||||
},
|
||||
{
|
||||
id: 'M002',
|
||||
rfid: 'RFID-A002-2402',
|
||||
code: 'WS-2024-0002',
|
||||
name: '除颤仪',
|
||||
category: 'medical_device',
|
||||
spec: 'HeartStart MRx',
|
||||
unit: '台',
|
||||
manufacturer: '飞利浦',
|
||||
warehouse: '中心仓库',
|
||||
location: 'A-01-05',
|
||||
status: 'in_stock',
|
||||
stock: 3,
|
||||
lastUpdate: '2026-07-29 09:15',
|
||||
},
|
||||
{
|
||||
id: 'M003',
|
||||
rfid: 'RFID-A003-2403',
|
||||
code: 'WS-2024-0003',
|
||||
name: '便携式呼吸机',
|
||||
category: 'equipment',
|
||||
spec: 'Oxylog 3000',
|
||||
unit: '台',
|
||||
manufacturer: '德尔格',
|
||||
warehouse: '急救仓库',
|
||||
location: 'B-02-01',
|
||||
status: 'borrowed',
|
||||
stock: 2,
|
||||
borrower: '急诊科',
|
||||
borrowTime: '2026-07-30 08:20',
|
||||
expectedReturn: '2026-07-31 18:00',
|
||||
borrowReason: '急诊抢救使用',
|
||||
lastUpdate: '2026-07-30 08:20',
|
||||
},
|
||||
{
|
||||
id: 'M004',
|
||||
rfid: 'RFID-A004-2404',
|
||||
code: 'WS-2024-0004',
|
||||
name: '一次性医用口罩',
|
||||
category: 'consumable',
|
||||
spec: 'N95 折叠式',
|
||||
unit: '盒',
|
||||
manufacturer: '3M',
|
||||
warehouse: '耗材仓库',
|
||||
location: 'C-03-12',
|
||||
status: 'in_stock',
|
||||
stock: 120,
|
||||
lastUpdate: '2026-07-30 11:00',
|
||||
},
|
||||
{
|
||||
id: 'M005',
|
||||
rfid: 'RFID-A005-2405',
|
||||
code: 'WS-2024-0005',
|
||||
name: '血糖仪',
|
||||
category: 'instrument',
|
||||
spec: 'Accu-Chek Active',
|
||||
unit: '台',
|
||||
manufacturer: '罗氏',
|
||||
warehouse: '中心仓库',
|
||||
location: 'A-02-08',
|
||||
status: 'in_stock',
|
||||
stock: 8,
|
||||
lastUpdate: '2026-07-25 16:40',
|
||||
},
|
||||
{
|
||||
id: 'M006',
|
||||
rfid: 'RFID-A006-2406',
|
||||
code: 'WS-2024-0006',
|
||||
name: '输液泵',
|
||||
category: 'equipment',
|
||||
spec: 'Infusomat Space',
|
||||
unit: '台',
|
||||
manufacturer: '贝朗',
|
||||
warehouse: '中心仓库',
|
||||
location: 'A-02-12',
|
||||
status: 'borrowed',
|
||||
stock: 6,
|
||||
borrower: 'ICU',
|
||||
borrowTime: '2026-07-29 14:00',
|
||||
expectedReturn: '2026-08-02 14:00',
|
||||
borrowReason: 'ICU 病房常规使用',
|
||||
lastUpdate: '2026-07-29 14:00',
|
||||
},
|
||||
{
|
||||
id: 'M007',
|
||||
rfid: 'RFID-A007-2407',
|
||||
code: 'WS-2024-0007',
|
||||
name: 'B 超机',
|
||||
category: 'instrument',
|
||||
spec: 'DC-80',
|
||||
unit: '台',
|
||||
manufacturer: '迈瑞',
|
||||
warehouse: '影像仓库',
|
||||
location: 'D-01-01',
|
||||
status: 'in_stock',
|
||||
stock: 2,
|
||||
lastUpdate: '2026-07-22 10:10',
|
||||
},
|
||||
{
|
||||
id: 'M008',
|
||||
rfid: 'RFID-A008-2408',
|
||||
code: 'WS-2024-0008',
|
||||
name: '轮椅',
|
||||
category: 'equipment',
|
||||
spec: '折叠式 H-060',
|
||||
unit: '台',
|
||||
manufacturer: '互邦',
|
||||
warehouse: '康复仓库',
|
||||
location: 'E-01-04',
|
||||
status: 'in_stock',
|
||||
stock: 15,
|
||||
lastUpdate: '2026-07-26 13:20',
|
||||
},
|
||||
{
|
||||
id: 'M009',
|
||||
rfid: 'RFID-A009-2409',
|
||||
code: 'WS-2024-0009',
|
||||
name: '心电图机',
|
||||
category: 'instrument',
|
||||
spec: 'ECG-1250',
|
||||
unit: '台',
|
||||
manufacturer: '光电',
|
||||
warehouse: '中心仓库',
|
||||
location: 'A-01-10',
|
||||
status: 'maintenance',
|
||||
stock: 1,
|
||||
lastUpdate: '2026-07-20 09:00',
|
||||
},
|
||||
{
|
||||
id: 'M010',
|
||||
rfid: 'RFID-A010-2410',
|
||||
code: 'WS-2024-0010',
|
||||
name: '一次性注射器',
|
||||
category: 'consumable',
|
||||
spec: '5ml 带针',
|
||||
unit: '盒',
|
||||
manufacturer: '威高',
|
||||
warehouse: '耗材仓库',
|
||||
location: 'C-03-15',
|
||||
status: 'in_stock',
|
||||
stock: 240,
|
||||
lastUpdate: '2026-07-30 10:30',
|
||||
},
|
||||
{
|
||||
id: 'M011',
|
||||
rfid: 'RFID-A011-2411',
|
||||
code: 'WS-2024-0011',
|
||||
name: '担架',
|
||||
category: 'equipment',
|
||||
spec: '折叠铝合金',
|
||||
unit: '台',
|
||||
manufacturer: '安保',
|
||||
warehouse: '急救仓库',
|
||||
location: 'B-01-02',
|
||||
status: 'in_stock',
|
||||
stock: 4,
|
||||
lastUpdate: '2026-07-18 15:00',
|
||||
},
|
||||
{
|
||||
id: 'M012',
|
||||
rfid: 'RFID-A012-2412',
|
||||
code: 'WS-2024-0012',
|
||||
name: '血压计',
|
||||
category: 'instrument',
|
||||
spec: 'M-300A',
|
||||
unit: '台',
|
||||
manufacturer: '欧姆龙',
|
||||
warehouse: '中心仓库',
|
||||
location: 'A-02-05',
|
||||
status: 'in_stock',
|
||||
stock: 12,
|
||||
lastUpdate: '2026-07-27 11:30',
|
||||
},
|
||||
]
|
||||
|
||||
// 初始操作记录
|
||||
export const initialOperations: Operation[] = [
|
||||
{
|
||||
id: 'OP001',
|
||||
type: 'borrow',
|
||||
rfid: 'RFID-A003-2403',
|
||||
materialId: 'M003',
|
||||
materialName: '便携式呼吸机',
|
||||
quantity: 1,
|
||||
operator: '李护士',
|
||||
department: '急诊科',
|
||||
reason: '急诊抢救使用',
|
||||
time: '2026-07-30 08:20',
|
||||
},
|
||||
{
|
||||
id: 'OP002',
|
||||
type: 'inbound',
|
||||
rfid: 'RFID-A004-2404',
|
||||
materialId: 'M004',
|
||||
materialName: '一次性医用口罩',
|
||||
quantity: 50,
|
||||
operator: '王库管',
|
||||
reason: '月度补货',
|
||||
time: '2026-07-30 11:00',
|
||||
},
|
||||
{
|
||||
id: 'OP003',
|
||||
type: 'borrow',
|
||||
rfid: 'RFID-A006-2406',
|
||||
materialId: 'M006',
|
||||
materialName: '输液泵',
|
||||
quantity: 1,
|
||||
operator: '陈医生',
|
||||
department: 'ICU',
|
||||
reason: 'ICU 病房常规使用',
|
||||
time: '2026-07-29 14:00',
|
||||
},
|
||||
{
|
||||
id: 'OP004',
|
||||
type: 'outbound',
|
||||
rfid: 'RFID-A010-2410',
|
||||
materialId: 'M010',
|
||||
materialName: '一次性注射器',
|
||||
quantity: 30,
|
||||
operator: '王库管',
|
||||
department: '门诊部',
|
||||
time: '2026-07-30 10:30',
|
||||
},
|
||||
{
|
||||
id: 'OP005',
|
||||
type: 'inbound',
|
||||
rfid: 'RFID-A008-2408',
|
||||
materialId: 'M008',
|
||||
materialName: '轮椅',
|
||||
quantity: 5,
|
||||
operator: '赵采购',
|
||||
reason: '新增采购',
|
||||
time: '2026-07-26 13:20',
|
||||
},
|
||||
{
|
||||
id: 'OP006',
|
||||
type: 'return',
|
||||
rfid: 'RFID-A005-2405',
|
||||
materialId: 'M005',
|
||||
materialName: '血糖仪',
|
||||
quantity: 1,
|
||||
operator: '李护士',
|
||||
department: '内科',
|
||||
time: '2026-07-25 16:40',
|
||||
},
|
||||
]
|
||||
|
||||
// 工具:根据 RFID 查找物资
|
||||
export function findMaterialByRfid(materials: Material[], rfid: string): Material | undefined {
|
||||
return materials.find((m) => m.rfid === rfid)
|
||||
}
|
||||
|
||||
export function getMaterialStatusLabel(status: MaterialStatus): string {
|
||||
return STATUS_LABELS[status]
|
||||
}
|
||||
|
||||
export function getCategoryLabel(category: MaterialCategory): string {
|
||||
return CATEGORY_LABELS[category]
|
||||
}
|
||||
Vendored
+9
@@ -0,0 +1,9 @@
|
||||
declare module '*.vue' {
|
||||
import { DefineComponent } from 'vue'
|
||||
const component: DefineComponent<{}, {}, any>
|
||||
export default component
|
||||
}
|
||||
|
||||
declare module '*.css'
|
||||
declare module '*.scss'
|
||||
declare module '*.svg'
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import ElementPlus from 'element-plus'
|
||||
import zhCn from 'element-plus/es/locale/lang/zh-cn'
|
||||
import 'element-plus/dist/index.css'
|
||||
import router from './router'
|
||||
import App from './App.vue'
|
||||
import './styles/main.css'
|
||||
|
||||
const app = createApp(App)
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
app.use(ElementPlus, { locale: zhCn })
|
||||
app.mount('#app')
|
||||
@@ -0,0 +1,67 @@
|
||||
import { createRouter, createWebHashHistory } from 'vue-router'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHashHistory(),
|
||||
routes: [
|
||||
{
|
||||
path: '/',
|
||||
redirect: '/home',
|
||||
},
|
||||
{
|
||||
path: '/home',
|
||||
name: 'home',
|
||||
component: () => import('../views/home/index.vue'),
|
||||
meta: { title: '智慧医院移动端' },
|
||||
},
|
||||
{
|
||||
path: '/material',
|
||||
name: 'material',
|
||||
component: () => import('../views/material/index.vue'),
|
||||
meta: { title: '物资管理' },
|
||||
},
|
||||
{
|
||||
path: '/material/detail/:id',
|
||||
name: 'material-detail',
|
||||
component: () => import('../views/material/detail.vue'),
|
||||
meta: { title: '物资详情', hideTabBar: true },
|
||||
},
|
||||
{
|
||||
path: '/inventory',
|
||||
name: 'inventory',
|
||||
component: () => import('../views/inventory/index.vue'),
|
||||
meta: { title: '出入库盘点' },
|
||||
},
|
||||
{
|
||||
path: '/inventory/scan',
|
||||
name: 'inventory-scan',
|
||||
component: () => import('../views/inventory/scan.vue'),
|
||||
meta: { title: '扫码出入库', hideTabBar: true },
|
||||
},
|
||||
{
|
||||
path: '/borrow',
|
||||
name: 'borrow',
|
||||
component: () => import('../views/borrow/index.vue'),
|
||||
meta: { title: '借用管理' },
|
||||
},
|
||||
{
|
||||
path: '/borrow/scan',
|
||||
name: 'borrow-scan',
|
||||
component: () => import('../views/borrow/scan.vue'),
|
||||
meta: { title: '扫码借用', hideTabBar: true },
|
||||
},
|
||||
{
|
||||
path: '/history',
|
||||
name: 'history',
|
||||
component: () => import('../views/history/index.vue'),
|
||||
meta: { title: '操作记录' },
|
||||
},
|
||||
{
|
||||
path: '/profile',
|
||||
name: 'profile',
|
||||
component: () => import('../views/profile/index.vue'),
|
||||
meta: { title: '我的', hideTabBar: true },
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,210 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import {
|
||||
initialMaterials,
|
||||
initialOperations,
|
||||
findMaterialByRfid,
|
||||
type Material,
|
||||
type Operation,
|
||||
type OpType,
|
||||
} from '../data/mockData'
|
||||
|
||||
const LS_KEY_MATERIAL = 'shm:materials'
|
||||
const LS_KEY_OPS = 'shm:operations'
|
||||
|
||||
function load<T>(key: string, fallback: T): T {
|
||||
try {
|
||||
const raw = localStorage.getItem(key)
|
||||
if (!raw) return fallback
|
||||
return JSON.parse(raw) as T
|
||||
} catch {
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
function save<T>(key: string, data: T) {
|
||||
localStorage.setItem(key, JSON.stringify(data))
|
||||
}
|
||||
|
||||
export const useHospitalStore = defineStore('hospital', () => {
|
||||
// 物资列表
|
||||
const materials = ref<Material[]>(load(LS_KEY_MATERIAL, initialMaterials))
|
||||
// 操作记录
|
||||
const operations = ref<Operation[]>(load(LS_KEY_OPS, initialOperations))
|
||||
|
||||
function persistMaterials() {
|
||||
save(LS_KEY_MATERIAL, materials.value)
|
||||
}
|
||||
function persistOps() {
|
||||
save(LS_KEY_OPS, operations.value)
|
||||
}
|
||||
|
||||
// 统计
|
||||
const stats = computed(() => ({
|
||||
total: materials.value.length,
|
||||
inStock: materials.value.filter((m) => m.status === 'in_stock').length,
|
||||
borrowed: materials.value.filter((m) => m.status === 'borrowed').length,
|
||||
maintenance: materials.value.filter((m) => m.status === 'maintenance').length,
|
||||
}))
|
||||
|
||||
const borrowedList = computed(() =>
|
||||
materials.value.filter((m) => m.status === 'borrowed'),
|
||||
)
|
||||
|
||||
// 根据 RFID 模拟扫码
|
||||
function scanRfid(rfid?: string): Material | undefined {
|
||||
if (rfid) return findMaterialByRfid(materials.value, rfid)
|
||||
// 未指定 RFID 时,随机返回一个物资(模拟真实手持机扫码不一定能扫到任意标签)
|
||||
const list = materials.value
|
||||
return list[Math.floor(Math.random() * list.length)]
|
||||
}
|
||||
|
||||
function findById(id: string): Material | undefined {
|
||||
return materials.value.find((m) => m.id === id)
|
||||
}
|
||||
|
||||
function findByRfid(rfid: string): Material | undefined {
|
||||
return findMaterialByRfid(materials.value, rfid)
|
||||
}
|
||||
|
||||
// 通用添加操作记录
|
||||
function recordOp(
|
||||
type: OpType,
|
||||
material: Material,
|
||||
quantity: number,
|
||||
operator: string,
|
||||
extras: { department?: string; reason?: string } = {},
|
||||
) {
|
||||
const op: Operation = {
|
||||
id: 'OP' + Date.now().toString(36).toUpperCase(),
|
||||
type,
|
||||
rfid: material.rfid,
|
||||
materialId: material.id,
|
||||
materialName: material.name,
|
||||
quantity,
|
||||
operator,
|
||||
department: extras.department,
|
||||
reason: extras.reason,
|
||||
time: formatNow(),
|
||||
}
|
||||
operations.value.unshift(op)
|
||||
persistOps()
|
||||
}
|
||||
|
||||
// 入库
|
||||
function doInbound(material: Material, quantity: number, operator: string, reason?: string) {
|
||||
material.stock += quantity
|
||||
material.status = 'in_stock'
|
||||
material.lastUpdate = formatNow()
|
||||
if (material.borrower) {
|
||||
material.borrower = undefined
|
||||
material.borrowTime = undefined
|
||||
material.expectedReturn = undefined
|
||||
material.borrowReason = undefined
|
||||
}
|
||||
recordOp('inbound', material, quantity, operator, { reason })
|
||||
persistMaterials()
|
||||
}
|
||||
|
||||
// 出库
|
||||
function doOutbound(
|
||||
material: Material,
|
||||
quantity: number,
|
||||
operator: string,
|
||||
department?: string,
|
||||
) {
|
||||
if (material.status !== 'in_stock') {
|
||||
throw new Error('物资不在库,无法出库')
|
||||
}
|
||||
if (material.stock < quantity) {
|
||||
throw new Error('库存不足')
|
||||
}
|
||||
material.stock -= quantity
|
||||
if (material.stock === 0) {
|
||||
// 单台设备出库后视为已借出(移动设备按整台管理)
|
||||
material.status = 'borrowed'
|
||||
material.borrower = department || '外借'
|
||||
material.borrowTime = formatNow()
|
||||
material.expectedReturn = formatNowPlus(7)
|
||||
}
|
||||
material.lastUpdate = formatNow()
|
||||
recordOp('outbound', material, quantity, operator, { department })
|
||||
persistMaterials()
|
||||
}
|
||||
|
||||
// 借用
|
||||
function doBorrow(
|
||||
material: Material,
|
||||
borrower: string,
|
||||
reason: string,
|
||||
days: number,
|
||||
operator: string,
|
||||
) {
|
||||
if (material.status !== 'in_stock') {
|
||||
throw new Error('仅在库物资可借用')
|
||||
}
|
||||
material.status = 'borrowed'
|
||||
material.borrower = borrower
|
||||
material.borrowTime = formatNow()
|
||||
material.expectedReturn = formatNowPlus(days)
|
||||
material.borrowReason = reason
|
||||
material.lastUpdate = formatNow()
|
||||
recordOp('borrow', material, 1, operator, {
|
||||
department: borrower,
|
||||
reason,
|
||||
})
|
||||
persistMaterials()
|
||||
}
|
||||
|
||||
// 归还
|
||||
function doReturn(material: Material, operator: string) {
|
||||
if (material.status !== 'borrowed') {
|
||||
throw new Error('物资不在借用状态')
|
||||
}
|
||||
const department = material.borrower
|
||||
material.status = 'in_stock'
|
||||
material.borrower = undefined
|
||||
material.borrowTime = undefined
|
||||
material.expectedReturn = undefined
|
||||
material.borrowReason = undefined
|
||||
material.lastUpdate = formatNow()
|
||||
recordOp('return', material, 1, operator, { department })
|
||||
persistMaterials()
|
||||
}
|
||||
|
||||
// 重置(演示用)
|
||||
function reset() {
|
||||
materials.value = JSON.parse(JSON.stringify(initialMaterials))
|
||||
operations.value = JSON.parse(JSON.stringify(initialOperations))
|
||||
persistMaterials()
|
||||
persistOps()
|
||||
}
|
||||
|
||||
return {
|
||||
materials,
|
||||
operations,
|
||||
stats,
|
||||
borrowedList,
|
||||
scanRfid,
|
||||
findById,
|
||||
findByRfid,
|
||||
doInbound,
|
||||
doOutbound,
|
||||
doBorrow,
|
||||
doReturn,
|
||||
reset,
|
||||
}
|
||||
})
|
||||
|
||||
function formatNow(): string {
|
||||
const d = new Date()
|
||||
const pad = (n: number) => n.toString().padStart(2, '0')
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
|
||||
}
|
||||
|
||||
function formatNowPlus(days: number): string {
|
||||
const d = new Date()
|
||||
d.setDate(d.getDate() + days)
|
||||
const pad = (n: number) => n.toString().padStart(2, '0')
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} 18:00`
|
||||
}
|
||||
@@ -0,0 +1,721 @@
|
||||
/* ====== 全局基础 ====== */
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#app {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Helvetica Neue',
|
||||
Helvetica, 'Microsoft YaHei', sans-serif;
|
||||
background: #f5f6f8;
|
||||
color: #323233;
|
||||
font-size: 14px;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
body {
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
/* 移动端安全区适配 */
|
||||
:root {
|
||||
--safe-area-top: env(safe-area-inset-top);
|
||||
--safe-area-bottom: env(safe-area-inset-bottom);
|
||||
}
|
||||
|
||||
/* ====== App 框架 ====== */
|
||||
.mobile-app {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
max-width: 480px;
|
||||
margin: 0 auto;
|
||||
background: #f5f6f8;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.mobile-header {
|
||||
background: linear-gradient(135deg, #1989fa, #1677d4);
|
||||
color: #fff;
|
||||
padding: calc(var(--safe-area-top) + 12px) 16px 12px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
box-shadow: 0 2px 6px rgba(25, 137, 250, 0.2);
|
||||
}
|
||||
|
||||
.mobile-header .title {
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
.mobile-header .header-action {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
font-size: 13px;
|
||||
opacity: 0.95;
|
||||
}
|
||||
|
||||
.mobile-content {
|
||||
flex: 1;
|
||||
padding: 12px 12px calc(var(--safe-area-bottom) + 70px);
|
||||
overflow-y: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.mobile-tabbar {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
max-width: 480px;
|
||||
margin: 0 auto;
|
||||
background: #fff;
|
||||
border-top: 1px solid #eee;
|
||||
display: flex;
|
||||
padding-bottom: var(--safe-area-bottom);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.tab-item {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 8px 0;
|
||||
color: #999;
|
||||
cursor: pointer;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.tab-item.active {
|
||||
color: #1989fa;
|
||||
}
|
||||
|
||||
.tab-item .icon {
|
||||
font-size: 22px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.tab-item .label {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
/* ====== 通用卡片 ====== */
|
||||
.m-card {
|
||||
background: #fff;
|
||||
border-radius: 10px;
|
||||
padding: 14px;
|
||||
margin-bottom: 12px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.m-card-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #323233;
|
||||
margin-bottom: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.m-card-title .extra {
|
||||
font-size: 12px;
|
||||
color: #1989fa;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
/* ====== 物资卡片 ====== */
|
||||
.material-card {
|
||||
background: #fff;
|
||||
border-radius: 10px;
|
||||
padding: 12px;
|
||||
margin-bottom: 10px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.material-icon {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 8px;
|
||||
background: linear-gradient(135deg, #e8f3ff, #c8e1ff);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 28px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.material-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.material-name {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #323233;
|
||||
margin-bottom: 4px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.material-meta {
|
||||
font-size: 12px;
|
||||
color: #969799;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.material-meta .sep {
|
||||
color: #dcdee0;
|
||||
}
|
||||
|
||||
.material-rfid {
|
||||
font-family: 'SF Mono', Monaco, Consolas, monospace;
|
||||
font-size: 11px;
|
||||
color: #1989fa;
|
||||
background: #f0f8ff;
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* ====== 状态标签 ====== */
|
||||
.status-tag {
|
||||
display: inline-block;
|
||||
font-size: 11px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.status-tag.in_stock {
|
||||
background: #e8f5e9;
|
||||
color: #2e7d32;
|
||||
}
|
||||
|
||||
.status-tag.borrowed {
|
||||
background: #fff3e0;
|
||||
color: #e65100;
|
||||
}
|
||||
|
||||
.status-tag.inbound {
|
||||
background: #e3f2fd;
|
||||
color: #1565c0;
|
||||
}
|
||||
|
||||
.status-tag.outbound {
|
||||
background: #fce4ec;
|
||||
color: #c2185b;
|
||||
}
|
||||
|
||||
.status-tag.maintenance {
|
||||
background: #f3e5f5;
|
||||
color: #6a1b9a;
|
||||
}
|
||||
|
||||
/* ====== 扫码大按钮 ====== */
|
||||
.scan-zone {
|
||||
background: linear-gradient(135deg, #1989fa, #4ba8ff);
|
||||
border-radius: 14px;
|
||||
padding: 28px 20px;
|
||||
color: #fff;
|
||||
text-align: center;
|
||||
margin-bottom: 16px;
|
||||
box-shadow: 0 4px 14px rgba(25, 137, 250, 0.3);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.scan-zone::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -50%;
|
||||
left: -50%;
|
||||
width: 200%;
|
||||
height: 200%;
|
||||
background: radial-gradient(circle, rgba(255, 255, 255, 0.15) 0%, transparent 60%);
|
||||
animation: pulse 3s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%,
|
||||
100% {
|
||||
transform: scale(1);
|
||||
opacity: 0.5;
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.2);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.scan-zone .scan-title {
|
||||
font-size: 14px;
|
||||
opacity: 0.95;
|
||||
margin-bottom: 8px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.scan-zone .scan-rfid {
|
||||
font-family: 'SF Mono', Monaco, Consolas, monospace;
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 1px;
|
||||
margin-bottom: 16px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.scan-btn-big {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: #fff;
|
||||
color: #1989fa;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
padding: 14px 32px;
|
||||
border-radius: 30px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
transition: transform 0.1s;
|
||||
}
|
||||
|
||||
.scan-btn-big:active {
|
||||
transform: scale(0.96);
|
||||
}
|
||||
|
||||
.scan-btn-big.scanning {
|
||||
background: #fff3e0;
|
||||
color: #e65100;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.scan-btn-big.scanning .icon {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.scan-mode-tabs {
|
||||
display: flex;
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border-radius: 24px;
|
||||
padding: 4px;
|
||||
margin: 0 auto 16px;
|
||||
width: fit-content;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.scan-mode-tab {
|
||||
padding: 6px 16px;
|
||||
font-size: 13px;
|
||||
border-radius: 20px;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.scan-mode-tab.active {
|
||||
background: #fff;
|
||||
color: #1989fa;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ====== 快捷功能 ====== */
|
||||
.quick-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 10px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.quick-item {
|
||||
background: #fff;
|
||||
border-radius: 10px;
|
||||
padding: 14px 8px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: transform 0.1s;
|
||||
}
|
||||
|
||||
.quick-item:active {
|
||||
transform: scale(0.96);
|
||||
}
|
||||
|
||||
.quick-item .icon {
|
||||
font-size: 26px;
|
||||
margin-bottom: 6px;
|
||||
color: #1989fa;
|
||||
}
|
||||
|
||||
.quick-item .label {
|
||||
font-size: 12px;
|
||||
color: #323233;
|
||||
}
|
||||
|
||||
/* ====== 数据条 ====== */
|
||||
.stat-row {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
text-align: center;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.stat-num {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: #1989fa;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 11px;
|
||||
color: #969799;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* ====== 搜索框 ====== */
|
||||
.m-search {
|
||||
background: #fff;
|
||||
border-radius: 22px;
|
||||
padding: 9px 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.m-search input {
|
||||
flex: 1;
|
||||
border: none;
|
||||
outline: none;
|
||||
font-size: 14px;
|
||||
background: transparent;
|
||||
color: #323233;
|
||||
}
|
||||
|
||||
.m-search input::placeholder {
|
||||
color: #c8c9cc;
|
||||
}
|
||||
|
||||
/* ====== 筛选条 ====== */
|
||||
.m-filter {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
overflow-x: auto;
|
||||
margin-bottom: 12px;
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
|
||||
.m-filter::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.filter-chip {
|
||||
flex-shrink: 0;
|
||||
padding: 6px 14px;
|
||||
background: #fff;
|
||||
border-radius: 16px;
|
||||
font-size: 12px;
|
||||
color: #646566;
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.filter-chip.active {
|
||||
background: #e8f3ff;
|
||||
color: #1989fa;
|
||||
border-color: #1989fa;
|
||||
}
|
||||
|
||||
/* ====== 列表行 ====== */
|
||||
.list-item {
|
||||
background: #fff;
|
||||
border-radius: 10px;
|
||||
padding: 12px;
|
||||
margin-bottom: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.list-icon {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 22px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.list-icon.inbound {
|
||||
background: #e3f2fd;
|
||||
}
|
||||
|
||||
.list-icon.outbound {
|
||||
background: #fce4ec;
|
||||
}
|
||||
|
||||
.list-icon.borrow {
|
||||
background: #fff3e0;
|
||||
}
|
||||
|
||||
.list-icon.return {
|
||||
background: #e8f5e9;
|
||||
}
|
||||
|
||||
.list-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.list-title {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #323233;
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
|
||||
.list-sub {
|
||||
font-size: 12px;
|
||||
color: #969799;
|
||||
}
|
||||
|
||||
.list-arrow {
|
||||
color: #c8c9cc;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
/* ====== 弹窗 ====== */
|
||||
.detail-modal {
|
||||
background: #fff;
|
||||
border-radius: 16px 16px 0 0;
|
||||
padding: 20px 16px calc(var(--safe-area-bottom) + 20px);
|
||||
max-height: 80vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.detail-modal .modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid #f5f5f5;
|
||||
}
|
||||
|
||||
.detail-modal .modal-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.detail-modal .modal-close {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 14px;
|
||||
background: #f5f5f5;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 16px;
|
||||
color: #999;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.detail-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px dashed #f0f0f0;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.detail-row .label {
|
||||
color: #969799;
|
||||
}
|
||||
|
||||
.detail-row .value {
|
||||
color: #323233;
|
||||
font-weight: 500;
|
||||
max-width: 60%;
|
||||
text-align: right;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.detail-row .value.rfid {
|
||||
font-family: 'SF Mono', Monaco, Consolas, monospace;
|
||||
color: #1989fa;
|
||||
}
|
||||
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
animation: fadeIn 0.2s;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.modal-actions button {
|
||||
flex: 1;
|
||||
height: 44px;
|
||||
border-radius: 22px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #1989fa;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-default {
|
||||
background: #f5f5f5;
|
||||
color: #323233;
|
||||
}
|
||||
|
||||
.btn-warning {
|
||||
background: #ff9800;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
background: #07c160;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* ====== 空状态 ====== */
|
||||
.empty {
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
color: #969799;
|
||||
}
|
||||
|
||||
.empty .icon {
|
||||
font-size: 60px;
|
||||
margin-bottom: 12px;
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.empty .text {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* ====== Toast 自定义 ====== */
|
||||
.toast {
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
background: rgba(0, 0, 0, 0.78);
|
||||
color: #fff;
|
||||
padding: 12px 20px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
z-index: 9999;
|
||||
pointer-events: none;
|
||||
animation: toastIn 0.25s ease-out;
|
||||
max-width: 80%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.toast.success {
|
||||
background: rgba(7, 193, 96, 0.92);
|
||||
}
|
||||
|
||||
.toast.warning {
|
||||
background: rgba(255, 152, 0, 0.92);
|
||||
}
|
||||
|
||||
.toast.error {
|
||||
background: rgba(255, 68, 68, 0.92);
|
||||
}
|
||||
|
||||
@keyframes toastIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translate(-50%, -50%) scale(0.9);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, -50%) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
/* ====== Element Plus 适配 ====== */
|
||||
.el-message-box {
|
||||
max-width: 320px;
|
||||
}
|
||||
|
||||
.el-input__inner,
|
||||
.el-textarea__inner {
|
||||
font-size: 14px !important;
|
||||
}
|
||||
|
||||
.el-button {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* 隐藏桌面滚动条 */
|
||||
::-webkit-scrollbar {
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useHospitalStore } from '../../stores/hospital'
|
||||
import { statusLabels } from '../../data/mockData'
|
||||
|
||||
const router = useRouter()
|
||||
const store = useHospitalStore()
|
||||
|
||||
const tab = ref<'borrowed' | 'history'>('borrowed')
|
||||
|
||||
const borrowedList = computed(() =>
|
||||
store.materials.filter((m) => m.status === 'borrowed'),
|
||||
)
|
||||
|
||||
function goScan() {
|
||||
router.push('/borrow/scan')
|
||||
}
|
||||
|
||||
function goDetail(id: string) {
|
||||
router.push(`/material/detail/${id}`)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="borrow-page">
|
||||
<div class="m-card" style="padding: 0; overflow: hidden">
|
||||
<div
|
||||
style="
|
||||
background: linear-gradient(135deg, #ff9800, #ff6d00);
|
||||
color: #fff;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
"
|
||||
@click="goScan"
|
||||
>
|
||||
<div style="font-size: 36px; margin-bottom: 8px">🤝</div>
|
||||
<div style="font-size: 16px; font-weight: 600">扫码借用 / 归还</div>
|
||||
<div style="font-size: 12px; opacity: 0.9; margin-top: 4px">
|
||||
按下扫码键扫描 RFID 标签
|
||||
</div>
|
||||
<button
|
||||
style="
|
||||
margin-top: 14px;
|
||||
background: #fff;
|
||||
color: #ff9800;
|
||||
border: none;
|
||||
padding: 10px 28px;
|
||||
border-radius: 20px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
"
|
||||
>
|
||||
立即扫码
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="m-card">
|
||||
<div class="m-card-title">
|
||||
<span style="display: flex; gap: 16px; align-items: center">
|
||||
<span
|
||||
:style="{
|
||||
fontSize: '15px',
|
||||
color: tab === 'borrowed' ? '#1989fa' : '#646566',
|
||||
borderBottom: tab === 'borrowed' ? '2px solid #1989fa' : '2px solid transparent',
|
||||
paddingBottom: '4px',
|
||||
}"
|
||||
@click="tab = 'borrowed'"
|
||||
>
|
||||
借用中 ({{ borrowedList.length }})
|
||||
</span>
|
||||
<span
|
||||
:style="{
|
||||
fontSize: '15px',
|
||||
color: tab === 'history' ? '#1989fa' : '#646566',
|
||||
borderBottom: tab === 'history' ? '2px solid #1989fa' : '2px solid transparent',
|
||||
paddingBottom: '4px',
|
||||
}"
|
||||
@click="tab = 'history'; $router.push('/history')"
|
||||
>
|
||||
历史记录
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="borrowedList.length === 0" class="empty" style="padding: 30px 20px">
|
||||
<div class="icon">📋</div>
|
||||
<div class="text">当前无借用中物资</div>
|
||||
<div style="font-size: 12px; color: #969799; margin-top: 8px">
|
||||
点击上方扫码按钮开始借用
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="m in borrowedList"
|
||||
:key="m.id"
|
||||
class="list-item"
|
||||
@click="goDetail(m.id)"
|
||||
>
|
||||
<div class="list-icon borrow">🤝</div>
|
||||
<div class="list-content">
|
||||
<div class="list-title">{{ m.name }}</div>
|
||||
<div class="list-sub">
|
||||
{{ m.borrower }} · 借于 {{ m.borrowTime }}
|
||||
</div>
|
||||
<div class="list-sub" style="color: #ff9800">
|
||||
预计归还:{{ m.expectedReturn }}
|
||||
</div>
|
||||
</div>
|
||||
<span class="status-tag borrowed">{{ statusLabels[m.status] }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,234 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useHospitalStore } from '../../stores/hospital'
|
||||
import { useToast } from '../../composables/useToast'
|
||||
import ScanButton from '../../components/ScanButton.vue'
|
||||
import { ElMessageBox } from 'element-plus'
|
||||
import type { Material } from '../../data/mockData'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const store = useHospitalStore()
|
||||
const toast = useToast()
|
||||
|
||||
// 如果是归还流程,从 query 携带的 materialId 预设
|
||||
const preselectId = ref((route.query.id as string) || '')
|
||||
|
||||
const scanning = ref(false)
|
||||
const currentRfid = ref('')
|
||||
const currentMaterial = ref<Material | undefined>()
|
||||
|
||||
const borrower = ref('')
|
||||
const reason = ref('')
|
||||
const days = ref(7)
|
||||
|
||||
// 如果传入了 id,先根据 id 找到物资
|
||||
onMounted(() => {
|
||||
if (preselectId.value) {
|
||||
const m = store.findById(preselectId.value)
|
||||
if (m) {
|
||||
currentMaterial.value = m
|
||||
currentRfid.value = m.rfid
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function doScan() {
|
||||
if (scanning.value) return
|
||||
scanning.value = true
|
||||
currentRfid.value = '扫描中…'
|
||||
setTimeout(() => {
|
||||
const mat = store.scanRfid()
|
||||
scanning.value = false
|
||||
if (mat) {
|
||||
currentRfid.value = mat.rfid
|
||||
currentMaterial.value = mat
|
||||
if (mat.status === 'in_stock') {
|
||||
toast.success(`识别到:${mat.name}(可借用)`)
|
||||
} else if (mat.status === 'borrowed') {
|
||||
toast.success(`识别到:${mat.name}(可归还)`)
|
||||
} else {
|
||||
toast.warning(`该物资当前维修中,无法操作`)
|
||||
}
|
||||
} else {
|
||||
currentRfid.value = '未识别'
|
||||
toast.error('未识别到 RFID 标签')
|
||||
}
|
||||
}, 800)
|
||||
}
|
||||
|
||||
const isReturn = computed(() => currentMaterial.value?.status === 'borrowed')
|
||||
const isInStock = computed(() => currentMaterial.value?.status === 'in_stock')
|
||||
|
||||
async function doBorrow() {
|
||||
if (!currentMaterial.value) return
|
||||
if (!borrower.value.trim()) {
|
||||
toast.warning('请填写借用方')
|
||||
return
|
||||
}
|
||||
const mat = currentMaterial.value
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确认借用 ${mat.name} 给 ${borrower.value}?\n借用天数:${days.value} 天`,
|
||||
'借用确认',
|
||||
{ confirmButtonText: '确认借用', cancelButtonText: '取消' },
|
||||
)
|
||||
store.doBorrow(mat, borrower.value.trim(), reason.value || '业务借用', days.value, '当前用户')
|
||||
toast.success('借用成功')
|
||||
reset()
|
||||
} catch (e: any) {
|
||||
if (e?.message) toast.error(e.message)
|
||||
}
|
||||
}
|
||||
|
||||
async function doReturn() {
|
||||
if (!currentMaterial.value) return
|
||||
const mat = currentMaterial.value
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确认归还 ${mat.name}?\n借出方:${mat.borrower}`,
|
||||
'归还确认',
|
||||
{ confirmButtonText: '确认归还', cancelButtonText: '取消' },
|
||||
)
|
||||
store.doReturn(mat, '当前用户')
|
||||
toast.success('归还成功')
|
||||
reset()
|
||||
} catch (e: any) {
|
||||
if (e?.message) toast.error(e.message)
|
||||
}
|
||||
}
|
||||
|
||||
function reset() {
|
||||
currentMaterial.value = undefined
|
||||
currentRfid.value = ''
|
||||
borrower.value = ''
|
||||
reason.value = ''
|
||||
days.value = 7
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
router.back()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="scan-page">
|
||||
<ScanButton
|
||||
:rfid="currentRfid"
|
||||
:scanning="scanning"
|
||||
:show-mode-tabs="false"
|
||||
:mode="isReturn ? 'return' : 'borrow'"
|
||||
@scan="doScan"
|
||||
/>
|
||||
|
||||
<div v-if="!currentMaterial" class="m-card">
|
||||
<div class="m-card-title"><span>🤝 借用归还说明</span></div>
|
||||
<div style="font-size: 13px; color: #646566; line-height: 1.8">
|
||||
• 扫描物资 RFID 标签,系统自动识别借用/归还<br />
|
||||
• <b style="color: #1989fa">在库</b>物资:填写借用方后可借出<br />
|
||||
• <b style="color: #ff9800">已借出</b>物资:可直接确认归还<br />
|
||||
• 维修中物资不可借用<br />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="m-card">
|
||||
<div class="m-card-title">
|
||||
<span>物资信息</span>
|
||||
<span :class="['status-tag', currentMaterial.status]">
|
||||
{{
|
||||
currentMaterial.status === 'in_stock'
|
||||
? '在库'
|
||||
: currentMaterial.status === 'borrowed'
|
||||
? '已借出'
|
||||
: '维修中'
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="detail-row">
|
||||
<span class="label">物资名称</span>
|
||||
<span class="value">{{ currentMaterial.name }}</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="label">RFID</span>
|
||||
<span class="value rfid">{{ currentMaterial.rfid }}</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="label">规格</span>
|
||||
<span class="value">{{ currentMaterial.spec }}</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="label">仓库/位置</span>
|
||||
<span class="value">{{ currentMaterial.warehouse }} / {{ currentMaterial.location }}</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="isReturn"
|
||||
style="
|
||||
margin-top: 14px;
|
||||
padding: 12px;
|
||||
background: #fff7e6;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
"
|
||||
>
|
||||
<div>借出方:<b>{{ currentMaterial.borrower }}</b></div>
|
||||
<div style="margin-top: 4px">借出时间:{{ currentMaterial.borrowTime }}</div>
|
||||
<div style="margin-top: 4px">预计归还:{{ currentMaterial.expectedReturn }}</div>
|
||||
<div v-if="currentMaterial.borrowReason" style="margin-top: 4px">
|
||||
借用原因:{{ currentMaterial.borrowReason }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="isInStock" style="margin-top: 14px">
|
||||
<div style="font-size: 13px; color: #646566; margin-bottom: 6px">
|
||||
借用方 <span style="color: #ff4d4f">*</span>
|
||||
</div>
|
||||
<el-input
|
||||
v-model="borrower"
|
||||
placeholder="如:急诊科、内科"
|
||||
size="large"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="isInStock" style="margin-top: 14px">
|
||||
<div style="font-size: 13px; color: #646566; margin-bottom: 6px">借用天数</div>
|
||||
<el-input-number
|
||||
v-model="days"
|
||||
:min="1"
|
||||
:max="365"
|
||||
size="large"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="isInStock" style="margin-top: 14px">
|
||||
<div style="font-size: 13px; color: #646566; margin-bottom: 6px">借用原因</div>
|
||||
<el-input
|
||||
v-model="reason"
|
||||
placeholder="业务用途说明"
|
||||
size="large"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="modal-actions">
|
||||
<button class="btn-default" @click="cancel">返回</button>
|
||||
<button
|
||||
v-if="isReturn"
|
||||
class="btn-success"
|
||||
@click="doReturn"
|
||||
>
|
||||
确认归还
|
||||
</button>
|
||||
<button
|
||||
v-if="isInStock"
|
||||
class="btn-warning"
|
||||
@click="doBorrow"
|
||||
>
|
||||
确认借用
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,88 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useHospitalStore } from '../../stores/hospital'
|
||||
import { opIcons, opLabels } from '../../data/mockData'
|
||||
|
||||
const store = useHospitalStore()
|
||||
|
||||
const typeFilters = [
|
||||
{ value: 'all', label: '全部' },
|
||||
{ value: 'inbound', label: '入库' },
|
||||
{ value: 'outbound', label: '出库' },
|
||||
{ value: 'borrow', label: '借用' },
|
||||
{ value: 'return', label: '归还' },
|
||||
]
|
||||
const active = ref('all')
|
||||
|
||||
const filtered = computed(() => {
|
||||
if (active.value === 'all') return store.operations
|
||||
return store.operations.filter((o) => o.type === active.value)
|
||||
})
|
||||
|
||||
const today = computed(() => {
|
||||
const d = new Date()
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
||||
})
|
||||
|
||||
function isToday(time: string) {
|
||||
return time.startsWith(today.value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="history-page">
|
||||
<!-- 筛选 -->
|
||||
<div class="m-filter">
|
||||
<div
|
||||
v-for="f in typeFilters"
|
||||
:key="f.value"
|
||||
class="filter-chip"
|
||||
:class="{ active: active === f.value }"
|
||||
@click="active = f.value"
|
||||
>
|
||||
{{ f.label }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="filtered.length === 0" class="empty">
|
||||
<div class="icon">📭</div>
|
||||
<div class="text">暂无操作记录</div>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="m-card" v-for="op in filtered" :key="op.id" style="padding: 12px">
|
||||
<div style="display: flex; gap: 12px">
|
||||
<div :class="['list-icon', op.type]" style="width: 40px; height: 40px; font-size: 18px">
|
||||
{{ opIcons[op.type] }}
|
||||
</div>
|
||||
<div style="flex: 1; min-width: 0">
|
||||
<div style="display: flex; align-items: center; justify-content: space-between">
|
||||
<span style="font-size: 14px; font-weight: 600">
|
||||
{{ opLabels[op.type] }} · {{ op.materialName }}
|
||||
</span>
|
||||
<span
|
||||
v-if="isToday(op.time)"
|
||||
style="font-size: 10px; background: #ffeceb; color: #ff4d4f; padding: 1px 6px; border-radius: 8px"
|
||||
>
|
||||
今日
|
||||
</span>
|
||||
</div>
|
||||
<div style="font-size: 12px; color: #969799; margin-top: 4px">
|
||||
RFID: <span style="font-family: monospace; color: #1989fa">{{ op.rfid }}</span>
|
||||
</div>
|
||||
<div style="font-size: 12px; color: #646566; margin-top: 4px">
|
||||
数量 {{ op.quantity }} · 操作员 {{ op.operator }}
|
||||
</div>
|
||||
<div v-if="op.department || op.reason" style="font-size: 12px; color: #646566; margin-top: 2px">
|
||||
<span v-if="op.department">科室:{{ op.department }}</span>
|
||||
<span v-if="op.reason" style="margin-left: 8px">备注:{{ op.reason }}</span>
|
||||
</div>
|
||||
<div style="font-size: 11px; color: #c8c9cc; margin-top: 4px">
|
||||
{{ op.time }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,130 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useHospitalStore } from '../../stores/hospital'
|
||||
import { useToast } from '../../composables/useToast'
|
||||
import { opIcons, opLabels } from '../../data/mockData'
|
||||
import ScanButton from '../../components/ScanButton.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const store = useHospitalStore()
|
||||
const toast = useToast()
|
||||
|
||||
const recentOps = computed(() => store.operations.slice(0, 5))
|
||||
|
||||
const quickLinks = [
|
||||
{ icon: '📦', label: '物资', path: '/material' },
|
||||
{ icon: '📥', label: '入库', path: '/inventory/scan?mode=inbound' },
|
||||
{ icon: '📤', label: '出库', path: '/inventory/scan?mode=outbound' },
|
||||
{ icon: '🤝', label: '借用', path: '/borrow' },
|
||||
]
|
||||
|
||||
function go(path: string) {
|
||||
router.push(path)
|
||||
}
|
||||
|
||||
function quickScan() {
|
||||
// 直接进入入库扫码
|
||||
router.push('/inventory/scan?mode=inbound')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="home-page">
|
||||
<ScanButton :rfid="'手持端就绪'" @scan="quickScan" />
|
||||
|
||||
<!-- 数据概览 -->
|
||||
<div class="m-card">
|
||||
<div class="m-card-title">
|
||||
<span>📊 物资总览</span>
|
||||
<span class="extra" @click="go('/material')">查看全部</span>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<div class="stat-item">
|
||||
<div class="stat-num">{{ store.stats.total }}</div>
|
||||
<div class="stat-label">总物资</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-num" style="color: #07c160">{{ store.stats.inStock }}</div>
|
||||
<div class="stat-label">在库</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-num" style="color: #ff9800">{{ store.stats.borrowed }}</div>
|
||||
<div class="stat-label">借出</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-num" style="color: #9c27b0">{{ store.stats.maintenance }}</div>
|
||||
<div class="stat-label">维修</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 快捷功能 -->
|
||||
<div class="m-card">
|
||||
<div class="m-card-title"><span>⚡ 快捷功能</span></div>
|
||||
<div class="quick-grid">
|
||||
<div
|
||||
v-for="q in quickLinks"
|
||||
:key="q.path"
|
||||
class="quick-item"
|
||||
@click="go(q.path)"
|
||||
>
|
||||
<div class="icon">{{ q.icon }}</div>
|
||||
<div class="label">{{ q.label }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 待归还 -->
|
||||
<div class="m-card">
|
||||
<div class="m-card-title">
|
||||
<span>⏰ 待归还物资</span>
|
||||
<span class="extra" @click="go('/borrow')">查看全部</span>
|
||||
</div>
|
||||
<div v-if="store.borrowedList.length === 0" class="empty" style="padding: 20px">
|
||||
<div class="text">暂无借用记录</div>
|
||||
</div>
|
||||
<div
|
||||
v-for="m in store.borrowedList.slice(0, 3)"
|
||||
:key="m.id"
|
||||
class="list-item"
|
||||
@click="go(`/material/detail/${m.id}`)"
|
||||
>
|
||||
<div class="list-icon borrow">🤝</div>
|
||||
<div class="list-content">
|
||||
<div class="list-title">{{ m.name }}</div>
|
||||
<div class="list-sub">
|
||||
借出方:{{ m.borrower }} · 归还:{{ m.expectedReturn }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="list-arrow">›</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 最近操作 -->
|
||||
<div class="m-card">
|
||||
<div class="m-card-title">
|
||||
<span>📝 最近操作</span>
|
||||
<span class="extra" @click="go('/history')">全部记录</span>
|
||||
</div>
|
||||
<div v-if="recentOps.length === 0" class="empty" style="padding: 20px">
|
||||
<div class="text">暂无操作记录</div>
|
||||
</div>
|
||||
<div
|
||||
v-for="op in recentOps"
|
||||
:key="op.id"
|
||||
class="list-item"
|
||||
>
|
||||
<div :class="['list-icon', op.type]">{{ opIcons[op.type] }}</div>
|
||||
<div class="list-content">
|
||||
<div class="list-title">
|
||||
{{ opLabels[op.type] }} · {{ op.materialName }}
|
||||
</div>
|
||||
<div class="list-sub">
|
||||
{{ op.time }} · {{ op.operator }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,85 @@
|
||||
<script setup lang="ts">
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useHospitalStore } from '../../stores/hospital'
|
||||
import { opIcons, opLabels } from '../../data/mockData'
|
||||
|
||||
const router = useRouter()
|
||||
const store = useHospitalStore()
|
||||
|
||||
const recentInbound = () =>
|
||||
store.operations.filter((o) => o.type === 'inbound' || o.type === 'outbound').slice(0, 5)
|
||||
|
||||
function go(path: string) {
|
||||
router.push(path)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="inventory-page">
|
||||
<div class="m-card">
|
||||
<div class="m-card-title"><span>📦 出入库操作</span></div>
|
||||
<div class="quick-grid" style="grid-template-columns: 1fr 1fr">
|
||||
<div class="quick-item" style="padding: 20px 8px" @click="go('/inventory/scan?mode=inbound')">
|
||||
<div class="icon" style="font-size: 36px">📥</div>
|
||||
<div class="label" style="font-size: 14px; font-weight: 600">扫码入库</div>
|
||||
<div style="font-size: 11px; color: #969799; margin-top: 4px">扫描 RFID 入库</div>
|
||||
</div>
|
||||
<div class="quick-item" style="padding: 20px 8px" @click="go('/inventory/scan?mode=outbound')">
|
||||
<div class="icon" style="font-size: 36px">📤</div>
|
||||
<div class="label" style="font-size: 14px; font-weight: 600">扫码出库</div>
|
||||
<div style="font-size: 11px; color: #969799; margin-top: 4px">扫描 RFID 出库</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="m-card">
|
||||
<div class="m-card-title">
|
||||
<span>📊 库存概览</span>
|
||||
<span class="extra" @click="go('/material')">详情</span>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<div class="stat-item">
|
||||
<div class="stat-num">{{ store.stats.total }}</div>
|
||||
<div class="stat-label">物资数</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-num" style="color: #07c160">{{ store.stats.inStock }}</div>
|
||||
<div class="stat-label">在库</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-num" style="color: #ff9800">{{ store.stats.borrowed }}</div>
|
||||
<div class="stat-label">出库中</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="m-card">
|
||||
<div class="m-card-title">
|
||||
<span>📝 最近出入库</span>
|
||||
<span class="extra" @click="go('/history')">全部</span>
|
||||
</div>
|
||||
<div v-if="recentInbound().length === 0" class="empty" style="padding: 20px">
|
||||
<div class="text">暂无出入库记录</div>
|
||||
</div>
|
||||
<div v-for="op in recentInbound()" :key="op.id" class="list-item">
|
||||
<div :class="['list-icon', op.type]">{{ opIcons[op.type] }}</div>
|
||||
<div class="list-content">
|
||||
<div class="list-title">{{ opLabels[op.type] }} · {{ op.materialName }}</div>
|
||||
<div class="list-sub">
|
||||
数量 {{ op.quantity }} · {{ op.time }} · {{ op.operator }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="m-card">
|
||||
<div class="m-card-title"><span>💡 盘点说明</span></div>
|
||||
<div style="font-size: 13px; color: #646566; line-height: 1.7">
|
||||
1. 选择入库或出库模式<br />
|
||||
2. 靠近 RFID 标签按下扫码键<br />
|
||||
3. 确认物资信息后填写数量<br />
|
||||
4. 提交后系统自动更新库存<br />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,211 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useHospitalStore } from '../../stores/hospital'
|
||||
import { useToast } from '../../composables/useToast'
|
||||
import ScanButton from '../../components/ScanButton.vue'
|
||||
import { ElMessageBox } from 'element-plus'
|
||||
import type { Material } from '../../data/mockData'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const store = useHospitalStore()
|
||||
const toast = useToast()
|
||||
|
||||
type Mode = 'inbound' | 'outbound'
|
||||
const mode = ref<Mode>(((route.query.mode as Mode) || 'inbound') as Mode)
|
||||
const scanning = ref(false)
|
||||
const currentRfid = ref('')
|
||||
const currentMaterial = ref<Material | undefined>()
|
||||
const quantity = ref(1)
|
||||
const department = ref('')
|
||||
const reason = ref('')
|
||||
const showDetail = ref(false)
|
||||
|
||||
const modeLabel = computed(() => (mode.value === 'inbound' ? '入库' : '出库'))
|
||||
|
||||
function switchMode(m: 'inbound' | 'outbound') {
|
||||
mode.value = m
|
||||
currentMaterial.value = undefined
|
||||
currentRfid.value = ''
|
||||
showDetail.value = false
|
||||
}
|
||||
|
||||
function doScan() {
|
||||
if (scanning.value) return
|
||||
scanning.value = true
|
||||
currentRfid.value = '扫描中…'
|
||||
// 模拟扫码耗时
|
||||
setTimeout(() => {
|
||||
const mat = store.scanRfid()
|
||||
scanning.value = false
|
||||
if (mat) {
|
||||
currentRfid.value = mat.rfid
|
||||
currentMaterial.value = mat
|
||||
showDetail.value = true
|
||||
// 检查状态
|
||||
if (mode.value === 'outbound' && mat.status !== 'in_stock') {
|
||||
toast.warning(`该物资当前为「${mat.status === 'borrowed' ? '借出' : '维修'}」状态`)
|
||||
}
|
||||
} else {
|
||||
currentRfid.value = '未识别到标签'
|
||||
toast.error('未识别到 RFID 标签,请靠近重试')
|
||||
}
|
||||
}, 800)
|
||||
}
|
||||
|
||||
async function doSubmit() {
|
||||
if (!currentMaterial.value) return
|
||||
const mat = currentMaterial.value
|
||||
try {
|
||||
if (mode.value === 'inbound') {
|
||||
await ElMessageBox.confirm(
|
||||
`确认入库 ${mat.name} × ${quantity.value}?`,
|
||||
'入库确认',
|
||||
{ confirmButtonText: '确认入库', cancelButtonText: '取消' },
|
||||
)
|
||||
store.doInbound(mat, quantity.value, '当前用户', reason.value || '日常入库')
|
||||
toast.success('入库成功')
|
||||
} else {
|
||||
if (!department.value.trim()) {
|
||||
toast.warning('请填写领用科室')
|
||||
return
|
||||
}
|
||||
await ElMessageBox.confirm(
|
||||
`确认出库 ${mat.name} × ${quantity.value} 给 ${department.value}?`,
|
||||
'出库确认',
|
||||
{ confirmButtonText: '确认出库', cancelButtonText: '取消' },
|
||||
)
|
||||
store.doOutbound(mat, quantity.value, '当前用户', department.value.trim())
|
||||
toast.success('出库成功')
|
||||
}
|
||||
// 重置
|
||||
currentMaterial.value = undefined
|
||||
currentRfid.value = ''
|
||||
quantity.value = 1
|
||||
department.value = ''
|
||||
reason.value = ''
|
||||
showDetail.value = false
|
||||
} catch (e: any) {
|
||||
if (e?.message) toast.error(e.message)
|
||||
}
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
currentMaterial.value = undefined
|
||||
currentRfid.value = ''
|
||||
showDetail.value = false
|
||||
}
|
||||
|
||||
function closeAndExit() {
|
||||
router.back()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// 自动触发一次扫描(演示效果)
|
||||
// doScan()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="scan-page">
|
||||
<ScanButton
|
||||
:rfid="currentRfid"
|
||||
:scanning="scanning"
|
||||
:mode="mode"
|
||||
:show-mode-tabs="true"
|
||||
@scan="doScan"
|
||||
@mode-change="(m) => m === 'inbound' || m === 'outbound' ? switchMode(m) : null"
|
||||
/>
|
||||
|
||||
<div v-if="!currentMaterial" class="m-card">
|
||||
<div class="m-card-title"><span>📋 操作说明</span></div>
|
||||
<div style="font-size: 13px; color: #646566; line-height: 1.8">
|
||||
• 切换上方模式:<b>入库</b> 或 <b>出库</b><br />
|
||||
• 按下 <b>扫码</b> 按钮模拟手持机读取 RFID 标签<br />
|
||||
• 读取成功后核对物资信息并提交<br />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="m-card">
|
||||
<div class="m-card-title">
|
||||
<span>{{ modeLabel }}确认</span>
|
||||
<span class="status-tag" :class="currentMaterial.status">
|
||||
{{ currentMaterial.status === 'in_stock' ? '在库' : currentMaterial.status === 'borrowed' ? '已借出' : '维修中' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="detail-row">
|
||||
<span class="label">物资名称</span>
|
||||
<span class="value">{{ currentMaterial.name }}</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="label">RFID</span>
|
||||
<span class="value rfid">{{ currentMaterial.rfid }}</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="label">规格</span>
|
||||
<span class="value">{{ currentMaterial.spec }}</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="label">仓库/位置</span>
|
||||
<span class="value">{{ currentMaterial.warehouse }} / {{ currentMaterial.location }}</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="label">当前库存</span>
|
||||
<span class="value">{{ currentMaterial.stock }} {{ currentMaterial.unit }}</span>
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 14px">
|
||||
<div style="font-size: 13px; color: #646566; margin-bottom: 6px">
|
||||
{{ modeLabel }}数量
|
||||
</div>
|
||||
<el-input-number
|
||||
v-model="quantity"
|
||||
:min="1"
|
||||
:max="9999"
|
||||
size="large"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="mode === 'outbound'" style="margin-top: 14px">
|
||||
<div style="font-size: 13px; color: #646566; margin-bottom: 6px">
|
||||
领用科室 <span style="color: #ff4d4f">*</span>
|
||||
</div>
|
||||
<el-input
|
||||
v-model="department"
|
||||
placeholder="如:急诊科、ICU"
|
||||
size="large"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="mode === 'inbound'" style="margin-top: 14px">
|
||||
<div style="font-size: 13px; color: #646566; margin-bottom: 6px">
|
||||
入库备注(可选)
|
||||
</div>
|
||||
<el-input
|
||||
v-model="reason"
|
||||
placeholder="如:月度补货"
|
||||
size="large"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="modal-actions">
|
||||
<button class="btn-default" @click="cancel">取消</button>
|
||||
<button
|
||||
:class="mode === 'inbound' ? 'btn-primary' : 'btn-warning'"
|
||||
@click="doSubmit"
|
||||
>
|
||||
确认{{ modeLabel }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.scan-page {
|
||||
padding-bottom: 20px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,162 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useHospitalStore } from '../../stores/hospital'
|
||||
import { useToast } from '../../composables/useToast'
|
||||
import {
|
||||
categoryLabels,
|
||||
statusLabels,
|
||||
} from '../../data/mockData'
|
||||
import { ElMessageBox } from 'element-plus'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const store = useHospitalStore()
|
||||
const toast = useToast()
|
||||
|
||||
const id = computed(() => route.params.id as string)
|
||||
const material = computed(() => store.findById(id.value))
|
||||
|
||||
const showModal = ref(false)
|
||||
|
||||
function goBack() {
|
||||
router.back()
|
||||
}
|
||||
|
||||
function openBorrow() {
|
||||
if (!material.value) return
|
||||
if (material.value.status !== 'in_stock') {
|
||||
toast.warning('该物资不在库,无法借用')
|
||||
return
|
||||
}
|
||||
router.push(`/borrow/scan?id=${material.value.id}`)
|
||||
}
|
||||
|
||||
async function doReturn() {
|
||||
if (!material.value) return
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确认归还 ${material.value.name}?`,
|
||||
'归还确认',
|
||||
{ confirmButtonText: '确认归还', cancelButtonText: '取消' },
|
||||
)
|
||||
store.doReturn(material.value, '当前用户')
|
||||
toast.success('已归还')
|
||||
showModal.value = false
|
||||
} catch {
|
||||
// 用户取消
|
||||
}
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
showModal.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="material" class="detail-page">
|
||||
<div class="m-card">
|
||||
<div style="display: flex; gap: 14px; align-items: center">
|
||||
<div class="material-icon" style="width: 64px; height: 64px; font-size: 32px">
|
||||
📦
|
||||
</div>
|
||||
<div style="flex: 1">
|
||||
<div style="font-size: 17px; font-weight: 600">{{ material.name }}</div>
|
||||
<div style="font-size: 12px; color: #969799; margin-top: 4px">
|
||||
{{ categoryLabels[material.category] }} · {{ material.spec }}
|
||||
</div>
|
||||
<span :class="['status-tag', material.status]" style="margin-top: 6px">
|
||||
{{ statusLabels[material.status] }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="m-card">
|
||||
<div class="m-card-title"><span>基本信息</span></div>
|
||||
<div class="detail-row">
|
||||
<span class="label">RFID 标签</span>
|
||||
<span class="value rfid">{{ material.rfid }}</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="label">物资编号</span>
|
||||
<span class="value">{{ material.code }}</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="label">规格型号</span>
|
||||
<span class="value">{{ material.spec }}</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="label">单位</span>
|
||||
<span class="value">{{ material.unit }}</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="label">生产厂商</span>
|
||||
<span class="value">{{ material.manufacturer }}</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="label">所属仓库</span>
|
||||
<span class="value">{{ material.warehouse }}</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="label">存放位置</span>
|
||||
<span class="value">{{ material.location }}</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="label">库存数量</span>
|
||||
<span class="value">{{ material.stock }} {{ material.unit }}</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="label">最近更新</span>
|
||||
<span class="value">{{ material.lastUpdate }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="material.status === 'borrowed'" class="m-card">
|
||||
<div class="m-card-title"><span>借用信息</span></div>
|
||||
<div class="detail-row">
|
||||
<span class="label">借出方</span>
|
||||
<span class="value">{{ material.borrower }}</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="label">借出时间</span>
|
||||
<span class="value">{{ material.borrowTime }}</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="label">预计归还</span>
|
||||
<span class="value" style="color: #ff9800">{{ material.expectedReturn }}</span>
|
||||
</div>
|
||||
<div v-if="material.borrowReason" class="detail-row">
|
||||
<span class="label">借用原因</span>
|
||||
<span class="value">{{ material.borrowReason }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 20px; display: flex; gap: 10px; justify-content: center">
|
||||
<div
|
||||
v-if="material.status === 'in_stock'"
|
||||
class="btn-warning"
|
||||
style="flex: 1; padding: 14px 0; border-radius: 22px; text-align: center; cursor: pointer; color: #fff; font-weight: 600; font-size: 15px"
|
||||
@click="openBorrow"
|
||||
>
|
||||
借用此物资
|
||||
</div>
|
||||
<div
|
||||
v-if="material.status === 'borrowed'"
|
||||
class="btn-success"
|
||||
style="flex: 1; padding: 14px 0; border-radius: 22px; text-align: center; cursor: pointer; color: #fff; font-weight: 600; font-size: 15px"
|
||||
@click="doReturn"
|
||||
>
|
||||
归还
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-overlay" v-if="showModal" @click="closeModal"></div>
|
||||
</div>
|
||||
|
||||
<div v-else class="empty">
|
||||
<div class="icon">❓</div>
|
||||
<div class="text">物资不存在</div>
|
||||
<el-button type="primary" @click="goBack" style="margin-top: 16px">返回</el-button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,127 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useHospitalStore } from '../../stores/hospital'
|
||||
import { categoryLabels, statusLabels } from '../../data/mockData'
|
||||
|
||||
const router = useRouter()
|
||||
const store = useHospitalStore()
|
||||
|
||||
const search = ref('')
|
||||
const activeCategory = ref<string>('all')
|
||||
const activeStatus = ref<string>('all')
|
||||
|
||||
const categoryFilters = [
|
||||
{ value: 'all', label: '全部' },
|
||||
{ value: 'medical_device', label: '医疗器械' },
|
||||
{ value: 'equipment', label: '设备' },
|
||||
{ value: 'instrument', label: '仪器' },
|
||||
{ value: 'consumable', label: '耗材' },
|
||||
{ value: 'office', label: '办公' },
|
||||
]
|
||||
|
||||
const statusFilters = [
|
||||
{ value: 'all', label: '全部状态' },
|
||||
{ value: 'in_stock', label: '在库' },
|
||||
{ value: 'borrowed', label: '借出' },
|
||||
{ value: 'maintenance', label: '维修' },
|
||||
]
|
||||
|
||||
const filtered = computed(() => {
|
||||
const kw = search.value.trim().toLowerCase()
|
||||
return store.materials.filter((m) => {
|
||||
if (activeCategory.value !== 'all' && m.category !== activeCategory.value) return false
|
||||
if (activeStatus.value !== 'all' && m.status !== activeStatus.value) return false
|
||||
if (kw) {
|
||||
return (
|
||||
m.name.toLowerCase().includes(kw) ||
|
||||
m.code.toLowerCase().includes(kw) ||
|
||||
m.rfid.toLowerCase().includes(kw) ||
|
||||
m.spec.toLowerCase().includes(kw)
|
||||
)
|
||||
}
|
||||
return true
|
||||
})
|
||||
})
|
||||
|
||||
function openDetail(id: string) {
|
||||
router.push(`/material/detail/${id}`)
|
||||
}
|
||||
|
||||
const categoryIcon: Record<string, string> = {
|
||||
medical_device: '🩺',
|
||||
equipment: '🛏️',
|
||||
instrument: '🔬',
|
||||
consumable: '🧤',
|
||||
office: '📎',
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="material-page">
|
||||
<!-- 搜索 -->
|
||||
<div class="m-search">
|
||||
<el-icon><svg viewBox="0 0 1024 1024" width="16" height="16" fill="currentColor"><path d="M448 768A320 320 0 1 1 448 128a320 320 0 0 1 0 640zm192-64l224 224-64 64-224-224z"/></svg></el-icon>
|
||||
<input
|
||||
v-model="search"
|
||||
placeholder="搜索名称 / 编号 / RFID"
|
||||
type="search"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 类别 -->
|
||||
<div class="m-filter">
|
||||
<div
|
||||
v-for="f in categoryFilters"
|
||||
:key="f.value"
|
||||
class="filter-chip"
|
||||
:class="{ active: activeCategory === f.value }"
|
||||
@click="activeCategory = f.value"
|
||||
>
|
||||
{{ f.label }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 状态 -->
|
||||
<div class="m-filter">
|
||||
<div
|
||||
v-for="f in statusFilters"
|
||||
:key="f.value"
|
||||
class="filter-chip"
|
||||
:class="{ active: activeStatus === f.value }"
|
||||
@click="activeStatus = f.value"
|
||||
>
|
||||
{{ f.label }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 列表 -->
|
||||
<div v-if="filtered.length === 0" class="empty">
|
||||
<div class="icon">📭</div>
|
||||
<div class="text">未找到匹配的物资</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="m in filtered"
|
||||
:key="m.id"
|
||||
class="material-card"
|
||||
@click="openDetail(m.id)"
|
||||
>
|
||||
<div class="material-icon">{{ categoryIcon[m.category] || '📦' }}</div>
|
||||
<div class="material-info">
|
||||
<div class="material-name">{{ m.name }}</div>
|
||||
<div class="material-meta">
|
||||
<span>{{ m.code }}</span>
|
||||
<span class="sep">·</span>
|
||||
<span>{{ m.spec }}</span>
|
||||
</div>
|
||||
<div class="material-meta">
|
||||
<span class="material-rfid">{{ m.rfid }}</span>
|
||||
<span class="sep">·</span>
|
||||
<span>{{ m.warehouse }}/{{ m.location }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span :class="['status-tag', m.status]">{{ statusLabels[m.status] }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,125 @@
|
||||
<script setup lang="ts">
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useHospitalStore } from '../../stores/hospital'
|
||||
import { useToast } from '../../composables/useToast'
|
||||
|
||||
const router = useRouter()
|
||||
const store = useHospitalStore()
|
||||
const toast = useToast()
|
||||
|
||||
function resetData() {
|
||||
if (confirm('确认重置所有数据?此操作不可撤销。')) {
|
||||
store.reset()
|
||||
toast.success('数据已重置')
|
||||
}
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
router.back()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="profile-page">
|
||||
<div class="m-card" style="text-align: center; padding: 24px 16px">
|
||||
<div
|
||||
style="
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #1989fa, #4ba8ff);
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 36px;
|
||||
color: #fff;
|
||||
"
|
||||
>
|
||||
👤
|
||||
</div>
|
||||
<div style="font-size: 18px; font-weight: 600; margin-top: 12px">张三</div>
|
||||
<div style="font-size: 12px; color: #969799; margin-top: 4px">库管员 · 工号 10086</div>
|
||||
</div>
|
||||
|
||||
<div class="m-card">
|
||||
<div class="m-card-title"><span>📱 设备信息</span></div>
|
||||
<div class="detail-row">
|
||||
<span class="label">手持机型号</span>
|
||||
<span class="value">PDA-RFID-V3</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="label">RFID 频段</span>
|
||||
<span class="value">UHF 860-960 MHz</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="label">设备状态</span>
|
||||
<span class="value" style="color: #07c160">● 在线</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="label">电量</span>
|
||||
<span class="value">87%</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="label">软件版本</span>
|
||||
<span class="value">v1.0.0</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="m-card" style="padding: 0; overflow: hidden">
|
||||
<div class="list-item" style="border-radius: 0">
|
||||
<div class="list-icon inbound">📦</div>
|
||||
<div class="list-content">
|
||||
<div class="list-title">物资总数</div>
|
||||
</div>
|
||||
<div style="font-size: 18px; font-weight: 600; color: #1989fa">
|
||||
{{ store.stats.total }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="list-item" style="border-radius: 0">
|
||||
<div class="list-icon outbound">📤</div>
|
||||
<div class="list-content">
|
||||
<div class="list-title">已借出</div>
|
||||
</div>
|
||||
<div style="font-size: 18px; font-weight: 600; color: #ff9800">
|
||||
{{ store.stats.borrowed }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="list-item" style="border-radius: 0">
|
||||
<div class="list-icon return">📝</div>
|
||||
<div class="list-content">
|
||||
<div class="list-title">操作记录</div>
|
||||
</div>
|
||||
<div style="font-size: 18px; font-weight: 600; color: #646566">
|
||||
{{ store.operations.length }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="m-card">
|
||||
<div class="m-card-title"><span>⚙️ 系统</span></div>
|
||||
<div
|
||||
class="list-item"
|
||||
style="border-radius: 0; cursor: pointer"
|
||||
@click="resetData"
|
||||
>
|
||||
<div class="list-icon" style="background: #fce4ec">🔄</div>
|
||||
<div class="list-content">
|
||||
<div class="list-title">重置演示数据</div>
|
||||
<div class="list-sub">清除本地数据并恢复初始状态</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="list-item" style="border-radius: 0">
|
||||
<div class="list-icon" style="background: #f3e5f5">📖</div>
|
||||
<div class="list-content">
|
||||
<div class="list-title">使用说明</div>
|
||||
</div>
|
||||
<div class="list-arrow">›</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="text-align: center; font-size: 12px; color: #c8c9cc; padding: 20px">
|
||||
智慧医院物资管理移动端 v1.0.0
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "preserve",
|
||||
"strict": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.vue"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
{"root":["./src/env.d.ts","./src/main.ts","./src/composables/usetoast.ts","./src/data/mockdata.ts","./src/router/index.ts","./src/stores/hospital.ts","./src/app.vue","./src/components/scanbutton.vue","./src/components/tabbar.vue","./src/views/borrow/index.vue","./src/views/borrow/scan.vue","./src/views/history/index.vue","./src/views/home/index.vue","./src/views/inventory/index.vue","./src/views/inventory/scan.vue","./src/views/material/detail.vue","./src/views/material/index.vue","./src/views/profile/index.vue"],"version":"6.0.3"}
|
||||
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
declare const _default: import("vite").UserConfig;
|
||||
export default _default;
|
||||
@@ -0,0 +1,11 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import vue from '@vitejs/plugin-vue';
|
||||
// 移动端 Web App 配置
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
base: './',
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
port: 5174,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
// 移动端 Web App 配置
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
base: './',
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
port: 5174,
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user