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