This commit is contained in:
2026-08-07 11:35:11 +08:00
commit 750418e3b7
31 changed files with 4675 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
node_modules
dist
dist.zip
*.local
.DS_Store
.vscode/*
!.vscode/extensions.json
+62
View File
@@ -0,0 +1,62 @@
# 智慧医院移动手持端 (smart_hospital_mobile)
模拟医院 PDA 手持机的 Web App,配合 RFID 标签完成物资的**出入库盘点**与**借用归还**管理。
> 移动端 Web 应用,无需调起摄像头,扫码仅模拟 RFID 手持机一键读取。
## 技术栈
- Vue 3 + TypeScript + Vite
- Vue RouterHash 模式,可 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
View File
@@ -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>
+1589
View File
File diff suppressed because it is too large Load Diff
+27
View File
@@ -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
View File
@@ -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>
+81
View File
@@ -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>
+36
View File
@@ -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>
+48
View File
@@ -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)
}
},
})
+355
View File
@@ -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]
}
+9
View File
@@ -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
View File
@@ -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')
+67
View File
@@ -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
+210
View File
@@ -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`
}
+721
View File
@@ -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;
}
+116
View File
@@ -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>
+234
View File
@@ -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>
+88
View File
@@ -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>
+130
View File
@@ -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>
+85
View File
@@ -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>
+211
View File
@@ -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>
+162
View File
@@ -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>
+127
View File
@@ -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>
+125
View File
@@ -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>
+24
View File
@@ -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" }]
}
+12
View File
@@ -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
+1
View File
@@ -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"}
+2
View File
@@ -0,0 +1,2 @@
declare const _default: import("vite").UserConfig;
export default _default;
+11
View File
@@ -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,
},
});
+12
View File
@@ -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,
},
})