1168 lines
56 KiB
Plaintext
1168 lines
56 KiB
Plaintext
<%@ Page Title="" Language="C#" MasterPageFile="~/admin/Templates/TBS5ADM001/MasterPage.master" AutoEventWireup="true" CodeFile="index.aspx.cs" Inherits="admin_region_bed_index" %>
|
|
|
|
<asp:Content ID="Content1" ContentPlaceHolderID="page_header" Runat="Server">
|
|
<script type="importmap">
|
|
{
|
|
"imports": {
|
|
"three": "https://unpkg.com/three@0.160.0/build/three.module.js",
|
|
"three/addons/": "https://unpkg.com/three@0.160.0/examples/jsm/"
|
|
}
|
|
}
|
|
</script>
|
|
<script src="/js/httpVueLoader.js"></script>
|
|
<style>
|
|
/* Breadcrumb 樣式 */
|
|
.drilldown-breadcrumb {
|
|
position: absolute;
|
|
top: 15px;
|
|
left: 15px;
|
|
z-index: 10;
|
|
background: rgba(255, 255, 255, 0.95);
|
|
padding: 10px 20px;
|
|
border-radius: 8px;
|
|
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
|
|
font-size: 1.1rem;
|
|
font-weight: bold;
|
|
}
|
|
.drilldown-breadcrumb a { text-decoration: none; color: #1976d2; cursor: pointer; }
|
|
.drilldown-breadcrumb a:hover { text-decoration: underline; }
|
|
.drilldown-breadcrumb .separator { margin: 0 10px; color: #757575; }
|
|
.drilldown-breadcrumb .active { color: #424242; }
|
|
|
|
/* 提示訊息 */
|
|
.drilldown-hint {
|
|
position: absolute;
|
|
bottom: 15px;
|
|
left: 50%;
|
|
transform: translateX(-50%);
|
|
background: rgba(0, 0, 0, 0.7);
|
|
color: #fff;
|
|
padding: 8px 16px;
|
|
border-radius: 20px;
|
|
font-size: 0.9rem;
|
|
z-index: 10;
|
|
pointer-events: none;
|
|
letter-spacing: 1px;
|
|
}
|
|
|
|
/* 篩選面板動畫 */
|
|
.filter-panel {
|
|
transition: all 0.3s ease-in-out;
|
|
overflow: hidden;
|
|
}
|
|
</style>
|
|
</asp:Content>
|
|
|
|
<asp:Content ID="Content2" ContentPlaceHolderID="page_nav" Runat="Server">
|
|
<div class="card mb-3 w-100 shadow-sm border-0">
|
|
<!-- 查詢條件收合標題列 -->
|
|
<div class="card-header bg-white d-flex justify-content-between align-items-center"
|
|
style="cursor:pointer; padding: 10px 15px; border-bottom: 1px solid #eee;"
|
|
@click="isFilterExpanded = !isFilterExpanded">
|
|
<h6 class="mb-0 text-primary fw-bold">
|
|
<span class="mdi mdi-filter-variant"></span> 查詢與統計條件
|
|
</h6>
|
|
<span :class="isFilterExpanded ? 'mdi mdi-chevron-up' : 'mdi mdi-chevron-down'" style="font-size: 24px; color: #1976d2;"></span>
|
|
</div>
|
|
|
|
<!-- 面板內容 -->
|
|
<div class="card-body bg-light p-3 filter-panel" v-show="isFilterExpanded" style="height: 200px;">
|
|
<div class="d-flex h-25 w-100" style="align-items:center;align-content:center;justify-content: center;" >
|
|
<v-btn elevation="2" @click.prevent="changeDate('-')"><span class="mdi mdi-menu-left mdi-36px"></span></v-btn> {{ todayStr }}
|
|
<v-btn elevation="2" @click.prevent="changeDate('+')"><span class="mdi mdi-menu-right mdi-36px"></span></v-btn>
|
|
</div>
|
|
|
|
<div class="d-flex h-75 w-100 mt-2">
|
|
|
|
<!-- 左側統計區塊 40% -->
|
|
<div class="d-flex gap-2 pe-3" style="flex: 0 0 40%; height: 100%; border-right: 2px solid #ccc;">
|
|
<!-- 總房間 -->
|
|
<div class="p-2 border rounded text-center flex-fill d-flex flex-column justify-content-center" style="background-color: #f0f8ff;">
|
|
<div><strong>總房間</strong></div>
|
|
<div>{{ summary?.totalRooms || 0 }}</div>
|
|
<div class="text-primary">男: {{ summary?.totalMaleRooms || 0 }}</div>
|
|
<div class="text-danger">女: {{ (summary?.totalRooms || 0) - (summary?.totalMaleRooms || 0) }}</div>
|
|
</div>
|
|
|
|
<!-- 總床位 -->
|
|
<div class="p-2 border rounded text-center flex-fill d-flex flex-column justify-content-center" style="background-color: #fff0f5;">
|
|
<div><strong>總床位</strong></div>
|
|
<div>{{ summary?.totalBeds || 0 }}</div>
|
|
<div class="text-primary">男: {{ summary?.totalMaleBeds || 0 }}</div>
|
|
<div class="text-danger">女: {{ (summary?.totalBeds || 0) - (summary?.totalMaleBeds || 0) }}</div>
|
|
</div>
|
|
|
|
<!-- 可用床位 -->
|
|
<div class="p-2 border rounded text-center flex-fill d-flex flex-column justify-content-center" style="background-color: #f5fff0;">
|
|
<!-- 標題 -->
|
|
<h6 class="mb-2">查詢結果</h6>
|
|
<!-- 內容 -->
|
|
<div><strong>可用床位</strong></div>
|
|
<div class="text-primary">男: {{ filteredStats.maleBeds }}|女: {{ filteredStats.femaleBeds }}</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 右側篩選區塊 60% -->
|
|
<div class="d-flex gap-3 ms-10" style="flex: 0 0 60%; height: 100%; align-items: flex-start;">
|
|
|
|
<!-- 分組 1: 區域 & 房間下拉框 -->
|
|
<div class="p-2 rounded shadow-sm" style="background-color: #fff; min-width: 160px;">
|
|
<h6 class="mb-2">區域與房間</h6>
|
|
<div class="d-flex flex-column gap-2">
|
|
<select class="form-select" v-model="filter.selectedArea">
|
|
<option :value="null">全部區域</option>
|
|
<option v-for="region in filter.areas" :value="region.uuid" :key="region.uuid">{{region.name}}</option>
|
|
</select>
|
|
<select class="form-select" v-model="filter.selectedRoom">
|
|
<option :value="null">房間</option>
|
|
<option v-for="room in filter.rooms" :value="room.uuid" :key="room.uuid">{{room.name}}</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 分組 2: 日期篩選 -->
|
|
<div class="p-2 rounded shadow-sm" style="background-color: #fff; min-width: 160px;">
|
|
<h6 class="mb-2">日期篩選</h6>
|
|
<div class="d-flex flex-column gap-2">
|
|
<input type="date" class="form-control" v-model="filter.startDate" :min="today" @change="onStartDateChange"/>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 分組 3: 占用床位 & 性別復選框 -->
|
|
<div class="p-2 rounded shadow-sm" style="background-color: #fff; min-width: 200px;">
|
|
<h6 class="mb-2">床位與性別</h6>
|
|
<div class="d-flex gap-3 p-2 mb-2 border rounded shadow-sm" style="background-color: #f8f9fa;">
|
|
<div class="form-check">
|
|
<input class="form-check-input" type="checkbox" id="occupied" v-model="filter.occupied" @change="toggleOccupied('occupied')">
|
|
<label class="form-check-label" for="occupied">已佔用床位</label>
|
|
</div>
|
|
<div class="form-check">
|
|
<input class="form-check-input" type="checkbox" id="unoccupied" v-model="filter.unoccupied" @change="toggleOccupied('unoccupied')">
|
|
<label class="form-check-label" for="unoccupied">可用床位</label>
|
|
</div>
|
|
</div>
|
|
<div class="d-flex gap-3 p-2 border rounded shadow-sm" style="background-color: #f8f9fa;">
|
|
<div class="form-check">
|
|
<input type="checkbox" id="male" :checked="filter.Gender === true" @change="toggleGender(true)">
|
|
<label class="form-check-label" for="male">男</label>
|
|
</div>
|
|
<div class="form-check">
|
|
<input type="checkbox" id="female" :checked="filter.Gender === false" @change="toggleGender(false)">
|
|
<label class="form-check-label" for="female">女</label>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="p-2 rounded shadow-sm" style="background-color: #fff; min-width: 160px;" v-if="uniqueLimits.length > 0">
|
|
<h6 class="mb-2">特殊需求</h6>
|
|
<div class="d-flex flex-wrap gap-2 p-2 border rounded shadow-sm" style="background-color: #f8f9fa;">
|
|
<div class="form-check" v-for="limit in uniqueLimits" :key="limit.paramNo">
|
|
<input class="form-check-input" type="checkbox" :id="'limit_' + limit.paramNo" :value="limit.paramNo" v-model="filter.selectedLimits">
|
|
<label class="form-check-label" :for="'limit_' + limit.paramNo">{{ limit.paramName }}</label>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 分組 4: 查詢與清空按鈕 -->
|
|
<div class="p-2 rounded shadow-sm" style="background-color: #fff; min-width: 120px;">
|
|
<h6 class="mb-2">操作</h6>
|
|
<div class="d-flex flex-column gap-2">
|
|
<button class="btn btn-success" type="button" @click="search">查詢</button>
|
|
<button class="btn btn-primary" type="button" @click="clearFilter">清空條件</button>
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</asp:Content>
|
|
|
|
<asp:Content ID="Content3" ContentPlaceHolderID="ContentPlaceHolder1" Runat="Server">
|
|
<!-- 3D 視圖容器 -->
|
|
<div class="mb-4 shadow-sm rounded" style="background-color: #fff; padding: 10px;">
|
|
<h4 class="mb-2 text-primary fw-bold" style="padding-left: 10px;">3D 寮房空間總覽</h4>
|
|
<div id="three-container" ref="threeContainer" style="width: 100%; height: 650px; position: relative; border: 1px solid #ddd; border-radius: 8px; overflow: hidden;">
|
|
|
|
<!-- 視角切換開關 (Drilldown vs Flat) -->
|
|
<div style="position: absolute; top: 15px; left: 50%; transform: translateX(-50%); z-index: 10; display:flex; gap:0; box-shadow: 0 4px 6px rgba(0,0,0,0.1); border-radius: 4px; overflow: hidden;">
|
|
<button type="button" class="btn btn-sm fw-bold" :class="viewMode === 'DRILLDOWN' ? 'btn-primary' : 'btn-light border'" @click="setViewMode('DRILLDOWN')" style="padding: 8px 16px;">階層導覽</button>
|
|
<button type="button" class="btn btn-sm fw-bold" :class="viewMode === 'FLAT' ? 'btn-primary' : 'btn-light border'" @click="setViewMode('FLAT')" style="padding: 8px 16px;">展開全部</button>
|
|
</div>
|
|
|
|
<!-- Drilldown Breadcrumb UI -->
|
|
<div class="drilldown-breadcrumb" v-show="viewMode === 'DRILLDOWN'">
|
|
<a @click.prevent="goToTop"><i class="mdi mdi-domain"></i> 全部樓層</a>
|
|
<span v-if="viewState.level === 'REGION' || viewState.level === 'ROOM'">
|
|
<span class="separator">/</span>
|
|
<a @click.prevent="drillToRegion(viewState.region)">{{ viewState.region.name }}</a>
|
|
</span>
|
|
<span v-if="viewState.level === 'ROOM'">
|
|
<span class="separator">/</span>
|
|
<span class="active">{{ viewState.room.name }}</span>
|
|
</span>
|
|
</div>
|
|
|
|
<!-- 畫面操作提示 -->
|
|
<div class="drilldown-hint">
|
|
<span v-if="viewMode === 'FLAT'"><i class="mdi mdi-cursor-default-click"></i> 點擊【立體床位】查看/新增排程掛單</span>
|
|
<span v-else-if="viewState.level === 'ALL'"><i class="mdi mdi-cursor-default-click"></i> 點擊【樓層區塊】進入樓層視角</span>
|
|
<span v-else-if="viewState.level === 'REGION'"><i class="mdi mdi-cursor-default-click"></i> 點擊【房間地板】進入獨立房間,或直接點擊【立體床位】</span>
|
|
<span v-else-if="viewState.level === 'ROOM'"><i class="mdi mdi-cursor-default-click"></i> 點擊【立體床位】查看/新增排程掛單</span>
|
|
</div>
|
|
|
|
<!-- 圖例 -->
|
|
<div style="position: absolute; top: 15px; right: 15px; background: rgba(255,255,255,0.9); padding: 12px; border-radius: 8px; z-index: 10; pointer-events: none; box-shadow: 0 2px 5px rgba(0,0,0,0.1);">
|
|
<div v-if="viewMode === 'FLAT' || viewState.level === 'ROOM' || viewState.level === 'REGION'" style="display: flex; align-items: center; margin-bottom: 5px;">
|
|
<div style="width: 15px; height: 15px; background-color: #4caf50; margin-right: 8px;"></div> 空床
|
|
</div>
|
|
<div v-if="viewMode === 'FLAT' || viewState.level === 'ROOM' || viewState.level === 'REGION'" style="display: flex; align-items: center; margin-bottom: 5px;">
|
|
<div style="width: 15px; height: 15px; background-color: #f44336; margin-right: 8px;"></div> 佔用
|
|
</div>
|
|
<div v-if="viewMode === 'FLAT' || viewState.level !== 'ALL'" style="display: flex; align-items: center; margin-bottom: 5px;">
|
|
<div style="width: 15px; height: 15px; background-color: #bbdefb; margin-right: 8px; border: 1px solid #ccc;"></div> 男眾房
|
|
</div>
|
|
<div v-if="viewMode === 'FLAT' || viewState.level !== 'ALL'" style="display: flex; align-items: center;">
|
|
<div style="width: 15px; height: 15px; background-color: #f8bbd0; margin-right: 8px; border: 1px solid #ccc;"></div> 女眾房
|
|
</div>
|
|
<div v-if="viewMode === 'DRILLDOWN' && viewState.level === 'ALL'" style="display: flex; align-items: center;">
|
|
<div style="width: 15px; height: 15px; background-color: #3f51b5; margin-right: 8px; border: 1px solid #ccc;"></div> 樓層/區域
|
|
</div>
|
|
<div v-if="viewMode === 'FLAT' || viewState.level === 'ROOM' || viewState.level === 'REGION'" style="display: flex; align-items: center; margin-bottom: 5px;">
|
|
<div style="width: 15px; height: 15px; background-color: #ff9800; margin-right: 8px;"></div> 空床(特殊需求)
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<v-dialog v-model="bedSchedule.dialogVisible" max-width="900px">
|
|
<v-card style="min-height:50vh; max-height:80vh; display:flex; flex-direction:column;">
|
|
<v-card-title class="d-flex flex-column align-items-start">
|
|
<div class="d-flex w-100 align-items-center">
|
|
<span class="text-h6">床位排程明細 - {{ bedSchedule.selectedBed?.name }}</span>
|
|
<v-spacer></v-spacer>
|
|
<v-btn icon @click="closeBedSchedule"><v-icon>mdi-close</v-icon></v-btn>
|
|
</div>
|
|
|
|
<!-- 新增:顯示床位需求條件的標籤 -->
|
|
<div v-if="bedSchedule.selectedBed?.limits && bedSchedule.selectedBed.limits.length > 0" class="mt-2 w-100">
|
|
<span class="text-subtitle-2 mr-2">床位需求:</span>
|
|
<v-chip
|
|
v-for="limit in bedSchedule.selectedBed.limits"
|
|
:key="limit.paramNo"
|
|
color="orange darken-1"
|
|
text-color="white"
|
|
small
|
|
class="mr-2 mb-1"
|
|
>
|
|
<v-icon left small>mdi-alert-circle-outline</v-icon>
|
|
{{ limit.paramName }}
|
|
</v-chip>
|
|
</div>
|
|
</v-card-title>
|
|
|
|
<v-card-text style="flex:1 1 auto; min-height:0; overflow-y:auto;">
|
|
<div style="min-height:0;">
|
|
<v-data-table
|
|
:headers="bedSchedule.scheduleHeaders"
|
|
:items="bedSchedule.selectedBed?.schedules || []"
|
|
:items-per-page="9999"
|
|
class="elevation-1"
|
|
dense
|
|
hide-default-footer
|
|
>
|
|
<template #item.scheduleDate="{item}">
|
|
{{ item.scheduledate | timeString('YYYY-MM-DD') }}
|
|
</template>
|
|
<template #item.actions="{item}">
|
|
<%-- <a :href="'/admin/guadan/create.aspx?orderId='+item.guaDanOrderNo" class="btn btn-primary">
|
|
查看掛單
|
|
</a>--%>
|
|
<v-btn @click.prevent="viewGuadan(item.uuid)"></v-btn>
|
|
</template>
|
|
</v-data-table>
|
|
</div>
|
|
</v-card-text>
|
|
|
|
<v-card-actions>
|
|
<v-spacer></v-spacer>
|
|
<v-btn text color="primary" @click="closeBedSchedule">關閉</v-btn>
|
|
</v-card-actions>
|
|
</v-card>
|
|
</v-dialog>
|
|
|
|
<v-dialog
|
|
v-model="viewdialog"
|
|
min-width="85%"
|
|
max-width="95%"
|
|
min-height="900"
|
|
max-height="95%"
|
|
hide-overlay
|
|
transition="dialog-bottom-transition">
|
|
<v-card>
|
|
<v-toolbar dark color="primary">
|
|
<v-btn icon dark @click="viewdialog = false">
|
|
<v-icon>mdi-close</v-icon>
|
|
</v-btn>
|
|
<v-toolbar-title>掛單作業</v-toolbar-title>
|
|
</v-toolbar>
|
|
<v-card-text>
|
|
<view-component v-if="viewdialog" @close="viewdialog = false" :form-Data="formData" />
|
|
</v-card-text>
|
|
</v-card>
|
|
</v-dialog>
|
|
</div>
|
|
</asp:Content>
|
|
<asp:Content ID="Content4" ContentPlaceHolderID="offCanvasRight" Runat="Server">
|
|
</asp:Content>
|
|
<asp:Content ID="Content5" ContentPlaceHolderID="footer_script" Runat="Server">
|
|
|
|
<script type="module">
|
|
import * as THREE from 'three';
|
|
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
|
|
|
|
Vue.filter('timeString', function (value, myFormat) {
|
|
return value == null || value == "" ? "" : moment(value).format(myFormat || 'YYYY-MM-DD, HH:mm:ss');
|
|
});
|
|
|
|
// 縮放設定常數 (調整字體與佈局間距)
|
|
// - TEXT_SCALE_DIVISOR 控制文字看板大小 (越小字越大,原本是 15,前一版是 7.5,現在改為 11)
|
|
// - BED_WIDTH, BED_DEPTH 影響單一床的物理尺寸
|
|
// - SPACING 影響床與床、房間與房間之間的距離倍率
|
|
const CFG = {
|
|
TEXT_SCALE: 13, // [微調] 配合整體放大,文字立牌稍微調大一點點
|
|
BED_W: 6, // [放大] 床位寬度 (原 4)
|
|
BED_D: 12, // [放大] 床位深度 (原 8)
|
|
BED_H: 2.5, // [放大] 床位高度 (原 1.5)
|
|
COL_SPACING: 16, // [放大] 配合床變寬,調整 X 軸間距 (原 12)
|
|
ROW_SPACING: 20, // [放大] 配合床變長,調整 Z 軸間距 (原 14)
|
|
ROOM_MARGIN: 10, // [放大] 房間邊緣走道寬度 (原 8)
|
|
ROOM_GAP: 25, // [縮小] 房間與房間的間距,讓房間靠攏 (原 60)
|
|
REGION_GAP: 60, // [縮小] 樓層與樓層的間距 (原 120)
|
|
WALL_H: 12 // [放大] 牆壁高度 (原 8)
|
|
};
|
|
new Vue({
|
|
el: '#app',
|
|
vuetify: new Vuetify(vuetify_options),
|
|
data: {
|
|
viewdialog: false,
|
|
formData: null,
|
|
regions: [],
|
|
summary: {},
|
|
todayStr: new Date().toISOString().split('T')[0],
|
|
today: new Date().toISOString().split('T')[0],
|
|
thisDate: new Date(),
|
|
|
|
// 查詢面板收合狀態
|
|
isFilterExpanded: true,
|
|
|
|
filter: {
|
|
startDate: null,
|
|
endDate: null,
|
|
Gender: null,
|
|
selectedArea: null,
|
|
selectedRoom: null,
|
|
occupied: false,
|
|
unoccupied: false,
|
|
areas: [],
|
|
rooms: [],
|
|
selectedLimits: []
|
|
},
|
|
|
|
// 視角模式切換 (DRILLDOWN 階層 / FLAT 展開全部)
|
|
viewMode: 'DRILLDOWN',
|
|
|
|
// Drilldown 狀態機
|
|
viewState: {
|
|
level: 'ALL', // 'ALL', 'REGION', 'ROOM'
|
|
region: null,
|
|
room: null
|
|
},
|
|
bedSchedule: {
|
|
dialogVisible: false,
|
|
selectedBed: null,
|
|
scheduleHeaders: [
|
|
{ text: '使用日期', value: 'scheduledate' },
|
|
{ text: '掛單單號', value: 'guaDanOrderNo' },
|
|
{ text: '標題', value: 'title' },
|
|
{ text: '掛單人', value: 'usename' },
|
|
{ text: '查看掛單', value: 'actions' },
|
|
],
|
|
},
|
|
// Three.js 狀態管理
|
|
three: {
|
|
scene: null,
|
|
camera: null,
|
|
renderer: null,
|
|
controls: null,
|
|
raycaster: null,
|
|
mouse: null,
|
|
animationId: null,
|
|
meshes: []
|
|
}
|
|
},
|
|
components: {
|
|
'view-component': httpVueLoader('../../guadan/view.vue'),
|
|
},
|
|
methods: {
|
|
viewGuadan(no) {
|
|
this.formData = no;
|
|
this.viewdialog = true;
|
|
},
|
|
uniqueLimits() {
|
|
const limitsMap = new Map();
|
|
if (this.regions) {
|
|
this.regions.forEach(region => {
|
|
(region.room || []).forEach(room => {
|
|
(room.beds || []).forEach(bed => {
|
|
(bed.limits || []).forEach(limit => {
|
|
if (!limitsMap.has(limit.paramNo)) {
|
|
limitsMap.set(limit.paramNo, limit.paramName);
|
|
}
|
|
});
|
|
});
|
|
});
|
|
});
|
|
}
|
|
return Array.from(limitsMap, ([paramNo, paramName]) => ({ paramNo, paramName }));
|
|
},
|
|
// [修改] 原本的 getBedColor 改為 getBedStyle,回傳完整材質設定
|
|
getBedStyle(bed) {
|
|
if (!bed.canuse) {
|
|
return { color: 0xf44336, emissive: 0x000000, intensity: 0 }; // 佔用: 紅色,不發光
|
|
}
|
|
if (bed.limits && bed.limits.length > 0) {
|
|
// 空床(有限制): 橘色,加上自發光效果 (Emissive) 吸引目光
|
|
return { color: 0xff9800, emissive: 0xff5722, intensity: 0.7 };
|
|
}
|
|
return { color: 0x4caf50, emissive: 0x000000, intensity: 0 }; // 空床(無限制): 綠色,不發光
|
|
},
|
|
getBedLabelText(bed) {
|
|
let text = `${bed.name}\n[${bed.canuse ? '空床' : '佔用'}]`;
|
|
// 如果有 limits 資料,就換行並將 paramName 串接起來
|
|
if (bed.limits && bed.limits.length > 0) {
|
|
const limitNames = bed.limits.map(l => l.paramName).join('、');
|
|
text += `\n(${limitNames})`;
|
|
}
|
|
return text;
|
|
},
|
|
changeDate(plusOrMinus) {
|
|
if (plusOrMinus == "+") this.thisDate.setDate(this.thisDate.getDate() + 1);
|
|
else if (plusOrMinus == "-") this.thisDate.setDate(this.thisDate.getDate() - 1);
|
|
this.filter.startDate = this.thisDate.toISOString().split('T')[0];
|
|
this.filter.endDate = this.thisDate.toISOString().split('T')[0];
|
|
this.todayStr = this.thisDate.toISOString().split('T')[0];
|
|
},
|
|
// [新增] 集中管理床位顏色邏輯
|
|
getBedColor(bed) {
|
|
if (!bed.canuse) return 0xf44336; // 佔用: 紅色
|
|
if (bed.limits && bed.limits.length > 0) return 0xff9800; // 空床(有需求): 橘色
|
|
return 0x4caf50; // 空床(無需求): 綠色
|
|
},
|
|
showBedSchedule(bed) {
|
|
this.bedSchedule.selectedBed = bed;
|
|
this.bedSchedule.dialogVisible = true;
|
|
},
|
|
closeBedSchedule() {
|
|
this.bedSchedule.selectedBed = null;
|
|
this.bedSchedule.dialogVisible = false;
|
|
},
|
|
search() { this.GetRegionList(); },
|
|
onStartDateChange() {
|
|
if (this.filter.endDate && this.filter.endDate < this.filter.startDate) this.filter.endDate = this.filter.startDate;
|
|
if (!this.filter.endDate) this.filter.endDate = this.filter.startDate;
|
|
},
|
|
resetFilter() {
|
|
this.filter.startDate = null; this.filter.endDate = null;
|
|
this.filter.selectedArea = null; this.filter.selectedRoom = null;
|
|
this.filter.occupied = false; this.filter.unoccupied = false;
|
|
this.filter.Gender = null;
|
|
this.filter.selectedLimits = []; // [新增] 清空選擇
|
|
},
|
|
clearFilter() {
|
|
this.resetFilter();
|
|
this.GetRegionList();
|
|
},
|
|
GetRegionList() {
|
|
const payload = {
|
|
startDate: this.filter.startDate, endDate: this.filter.endDate,
|
|
areaId: this.filter.selectedArea, roomId: this.filter.selectedRoom,
|
|
occupied: this.filter.occupied, unoccupied: this.filter.unoccupied,
|
|
gender: this.filter.Gender,
|
|
};
|
|
axios.post(HTTP_HOST + 'api/region/list', payload)
|
|
.then((res) => {
|
|
this.regions = res.data.regions;
|
|
this.summary = res.data.summary;
|
|
try { this.regions.sort((a, b) => Number(a.isStop) - Number(b.isStop)); } catch (e) {}
|
|
|
|
this.restoreViewState();
|
|
this.render3DRooms();
|
|
})
|
|
.catch((err) => console.error('API 錯誤', err));
|
|
},
|
|
toggleOccupied(type) {
|
|
if (type === "occupied" && this.filter.occupied) this.filter.unoccupied = false;
|
|
if (type === "unoccupied" && this.filter.unoccupied) this.filter.occupied = false;
|
|
},
|
|
toggleGender(value) {
|
|
this.filter.Gender = this.filter.Gender === value ? null : value;
|
|
},
|
|
getRegionWithRoom() {
|
|
axios.get(HTTP_HOST + 'api/region/regionwithroom').then((res) => { this.filter.areas = res.data; });
|
|
},
|
|
async loadRooms() {
|
|
try {
|
|
let url = "/api/room/roomwithbed";
|
|
const params = {};
|
|
if (this.filter.selectedArea) params.RegionUuid = this.filter.selectedArea;
|
|
const res = await axios.get(url, { params });
|
|
this.filter.rooms = res.data;
|
|
this.filter.selectedRoom = null;
|
|
} catch (error) {}
|
|
},
|
|
|
|
shouldShowBed(bed) {
|
|
if (this.filter.occupied && bed.canuse === true) return false;
|
|
if (this.filter.unoccupied && bed.canuse === false) return false;
|
|
if (this.filter.Gender !== null && bed.gender !== this.filter.Gender) return false;
|
|
|
|
// 判斷需求條件:如果使用者有勾選,床位必須包含「至少一個」勾選的條件
|
|
if (this.filter.selectedLimits && this.filter.selectedLimits.length > 0) {
|
|
if (!bed.limits || bed.limits.length === 0) return false;
|
|
const hasMatch = bed.limits.some(l => this.filter.selectedLimits.includes(l.paramNo));
|
|
if (!hasMatch) return false;
|
|
}
|
|
|
|
return true;
|
|
},
|
|
|
|
setViewMode(mode) {
|
|
this.viewMode = mode;
|
|
this.resetCamera();
|
|
this.render3DRooms();
|
|
},
|
|
|
|
goToTop() {
|
|
this.viewState.level = 'ALL';
|
|
this.viewState.region = null;
|
|
this.viewState.room = null;
|
|
this.resetCamera();
|
|
this.render3DRooms();
|
|
},
|
|
drillToRegion(regionInfo) {
|
|
this.viewState.level = 'REGION';
|
|
this.viewState.region = regionInfo;
|
|
this.viewState.room = null;
|
|
this.resetCamera();
|
|
this.render3DRooms();
|
|
},
|
|
drillToRoom(roomInfo) {
|
|
this.viewState.level = 'ROOM';
|
|
this.viewState.room = roomInfo;
|
|
this.resetCamera();
|
|
this.render3DRooms();
|
|
},
|
|
restoreViewState() {
|
|
if (this.viewState.level === 'REGION') {
|
|
const foundReg = this.regions.find(r => r.uuid === this.viewState.region.uuid);
|
|
if (foundReg) this.viewState.region = foundReg;
|
|
else this.goToTop();
|
|
} else if (this.viewState.level === 'ROOM') {
|
|
const foundReg = this.regions.find(r => r.uuid === this.viewState.region.uuid);
|
|
if (foundReg) {
|
|
this.viewState.region = foundReg;
|
|
const foundRoom = foundReg.room.find(rm => rm.uuid === this.viewState.room.uuid);
|
|
if (foundRoom) this.viewState.room = foundRoom;
|
|
else this.drillToRegion(foundReg);
|
|
} else {
|
|
this.goToTop();
|
|
}
|
|
}
|
|
},
|
|
resetCamera() {
|
|
if (!this.three.camera || !this.three.controls) return;
|
|
|
|
if (this.viewMode === 'FLAT') {
|
|
this.three.camera.position.set(0, 250, 300); // 因總體尺寸放大,鏡頭需更遠
|
|
this.three.controls.target.set(0, 0, 50);
|
|
} else {
|
|
if (this.viewState.level === 'ALL') {
|
|
this.three.camera.position.set(0, 100, 130);
|
|
} else if (this.viewState.level === 'REGION') {
|
|
this.three.camera.position.set(0, 120, 150);
|
|
} else if (this.viewState.level === 'ROOM') {
|
|
this.three.camera.position.set(0, 45, 60);
|
|
}
|
|
this.three.controls.target.set(0, 0, 0);
|
|
}
|
|
},
|
|
|
|
initThree() {
|
|
const container = this.$refs.threeContainer;
|
|
if (!container) return;
|
|
|
|
this.three.raycaster = new THREE.Raycaster();
|
|
this.three.mouse = new THREE.Vector2();
|
|
this.three.scene = new THREE.Scene();
|
|
this.three.scene.background = new THREE.Color(0xf5f7fa);
|
|
|
|
this.three.camera = new THREE.PerspectiveCamera(50, container.clientWidth / container.clientHeight, 0.1, 4000);
|
|
|
|
this.three.renderer = new THREE.WebGLRenderer({ antialias: true });
|
|
this.three.renderer.setSize(container.clientWidth, container.clientHeight);
|
|
this.three.renderer.setPixelRatio(window.devicePixelRatio);
|
|
container.appendChild(this.three.renderer.domElement);
|
|
|
|
this.three.controls = new OrbitControls(this.three.camera, this.three.renderer.domElement);
|
|
this.three.controls.enableDamping = true;
|
|
this.three.controls.dampingFactor = 0.05;
|
|
this.three.controls.maxPolarAngle = Math.PI / 2 - 0.05;
|
|
|
|
const ambientLight = new THREE.AmbientLight(0xffffff, 0.7);
|
|
this.three.scene.add(ambientLight);
|
|
const dirLight = new THREE.DirectionalLight(0xffffff, 0.6);
|
|
dirLight.position.set(20, 50, 30);
|
|
this.three.scene.add(dirLight);
|
|
|
|
this.onCanvasClick = this.onCanvasClick.bind(this);
|
|
this.onWindowResize = this.onWindowResize.bind(this);
|
|
this.animate = this.animate.bind(this);
|
|
|
|
container.addEventListener('click', this.onCanvasClick, false);
|
|
window.addEventListener('resize', this.onWindowResize, false);
|
|
|
|
this.resetCamera();
|
|
this.animate();
|
|
},
|
|
|
|
makeTextSprite(message, fontsize = 32, fontColor = "rgba(0, 0, 0, 1.0)", bgColor = null) {
|
|
const lines = message.split('\n');
|
|
const canvas = document.createElement('canvas');
|
|
const context = canvas.getContext('2d');
|
|
context.font = `Bold ${fontsize}px "Microsoft JhengHei", Arial, sans-serif`;
|
|
|
|
let maxWidth = 0;
|
|
lines.forEach(line => {
|
|
const metrics = context.measureText(line);
|
|
if (metrics.width > maxWidth) maxWidth = metrics.width;
|
|
});
|
|
|
|
canvas.width = maxWidth + 40;
|
|
canvas.height = (fontsize * 1.5) * lines.length + 10;
|
|
|
|
context.font = `Bold ${fontsize}px "Microsoft JhengHei", Arial, sans-serif`;
|
|
if (bgColor) {
|
|
context.fillStyle = bgColor;
|
|
context.roundRect ? context.roundRect(0, 0, canvas.width, canvas.height, 10) : context.fillRect(0,0, canvas.width, canvas.height);
|
|
context.fill();
|
|
}
|
|
|
|
context.fillStyle = fontColor;
|
|
context.textAlign = "center";
|
|
context.textBaseline = "middle";
|
|
|
|
lines.forEach((line, index) => {
|
|
const yPos = (fontsize * 1.5) * index + (fontsize * 1.5) / 2 + 5;
|
|
context.fillText(line, canvas.width / 2, yPos);
|
|
});
|
|
|
|
const texture = new THREE.CanvasTexture(canvas);
|
|
texture.generateMipmaps = false;
|
|
texture.minFilter = THREE.LinearFilter;
|
|
|
|
const spriteMaterial = new THREE.SpriteMaterial({ map: texture, transparent: true });
|
|
const sprite = new THREE.Sprite(spriteMaterial);
|
|
|
|
// 套用全域縮放常數
|
|
sprite.scale.set(canvas.width / CFG.TEXT_SCALE, canvas.height / CFG.TEXT_SCALE, 1);
|
|
return sprite;
|
|
},
|
|
|
|
clearMeshes() {
|
|
this.three.meshes.forEach(mesh => {
|
|
this.three.scene.remove(mesh);
|
|
if (mesh.geometry) mesh.geometry.dispose();
|
|
if (mesh.material) {
|
|
if (Array.isArray(mesh.material)) mesh.material.forEach(m => m.dispose());
|
|
else mesh.material.dispose();
|
|
}
|
|
if (mesh.material && mesh.material.map) mesh.material.map.dispose();
|
|
});
|
|
this.three.meshes = [];
|
|
},
|
|
|
|
render3DRooms() {
|
|
if (!this.three.scene) return;
|
|
this.clearMeshes();
|
|
|
|
if (this.viewMode === 'FLAT') {
|
|
this.renderAllBedsFlat();
|
|
} else {
|
|
if (this.viewState.level === 'ALL') {
|
|
this.renderAllRegions();
|
|
} else if (this.viewState.level === 'REGION') {
|
|
this.renderRoomsInRegion();
|
|
} else if (this.viewState.level === 'ROOM') {
|
|
this.renderBedsInRoom();
|
|
}
|
|
}
|
|
},
|
|
|
|
// 【模式: 全展開 (FLAT)】
|
|
renderAllBedsFlat() {
|
|
let regionOffsetZ = -20;
|
|
|
|
this.regions.forEach((region) => {
|
|
|
|
if (region.isStop) return;
|
|
|
|
const validRooms = (region.room || []).filter(r => r.beds && r.beds.length > 0);
|
|
if (validRooms.length === 0) return;
|
|
|
|
let totalWidth = 0;
|
|
validRooms.forEach(room => {
|
|
const cols = 2;
|
|
const roomWidth = (cols * CFG.COL_SPACING) + CFG.ROOM_MARGIN;
|
|
totalWidth += roomWidth + CFG.ROOM_GAP;
|
|
});
|
|
totalWidth -= CFG.ROOM_GAP;
|
|
|
|
const rLabel = this.makeTextSprite(region.name, 56, "#ffffff", "rgba(63, 81, 181, 0.9)");
|
|
rLabel.position.set(0, 35, regionOffsetZ - 20); // 調高
|
|
this.three.scene.add(rLabel);
|
|
this.three.meshes.push(rLabel);
|
|
|
|
let roomOffsetX = -totalWidth / 2;
|
|
let maxRoomDepth = 0;
|
|
|
|
validRooms.forEach((room) => {
|
|
const bedCount = room.beds.length;
|
|
const cols = 2;
|
|
const rows = Math.ceil(bedCount / cols);
|
|
const roomWidth = (cols * CFG.COL_SPACING) + CFG.ROOM_MARGIN;
|
|
const roomDepth = (rows * CFG.ROW_SPACING) + CFG.ROOM_MARGIN;
|
|
if (roomDepth > maxRoomDepth) maxRoomDepth = roomDepth;
|
|
|
|
const centerX = roomOffsetX + roomWidth / 2;
|
|
const centerZ = regionOffsetZ + roomDepth / 2;
|
|
|
|
const floorColor = room.gender ? 0xe3f2fd : 0xfce4ec;
|
|
const floorGeo = new THREE.PlaneGeometry(roomWidth, roomDepth);
|
|
const floorMat = new THREE.MeshLambertMaterial({ color: floorColor, side: THREE.DoubleSide });
|
|
const floorMesh = new THREE.Mesh(floorGeo, floorMat);
|
|
floorMesh.rotation.x = -Math.PI / 2;
|
|
floorMesh.position.set(centerX, 0, centerZ);
|
|
|
|
const edges = new THREE.EdgesGeometry(floorGeo);
|
|
const line = new THREE.LineSegments(edges, new THREE.LineBasicMaterial({ color: 0x999999 }));
|
|
floorMesh.add(line);
|
|
this.three.scene.add(floorMesh);
|
|
this.three.meshes.push(floorMesh);
|
|
|
|
const wallMat = new THREE.MeshLambertMaterial({ color: 0xb0bec5, transparent: true, opacity: 0.3, side: THREE.DoubleSide });
|
|
|
|
const backWall = new THREE.Mesh(new THREE.PlaneGeometry(roomWidth, CFG.WALL_H), wallMat);
|
|
backWall.position.set(centerX, CFG.WALL_H/2, centerZ - roomDepth/2);
|
|
this.three.scene.add(backWall);
|
|
this.three.meshes.push(backWall);
|
|
|
|
const leftWall = new THREE.Mesh(new THREE.PlaneGeometry(roomDepth, CFG.WALL_H), wallMat);
|
|
leftWall.rotation.y = Math.PI / 2;
|
|
leftWall.position.set(centerX - roomWidth/2, CFG.WALL_H/2, centerZ);
|
|
this.three.scene.add(leftWall);
|
|
this.three.meshes.push(leftWall);
|
|
|
|
const rightWall = new THREE.Mesh(new THREE.PlaneGeometry(roomDepth, CFG.WALL_H), wallMat);
|
|
rightWall.rotation.y = Math.PI / 2;
|
|
rightWall.position.set(centerX + roomWidth/2, CFG.WALL_H/2, centerZ);
|
|
this.three.scene.add(rightWall);
|
|
this.three.meshes.push(rightWall);
|
|
|
|
let avail = 0;
|
|
room.beds.forEach(b => { if(b.canuse) avail++; });
|
|
const labelStr = `${room.name} ${room.gender ? '(男)' : '(女)'}\n(可用: ${avail}/${bedCount})`;
|
|
const label = this.makeTextSprite(labelStr, 32, "#333333", "rgba(255,255,255,0.85)");
|
|
label.position.set(centerX, CFG.WALL_H + 5, centerZ - roomDepth/2);
|
|
this.three.scene.add(label);
|
|
this.three.meshes.push(label);
|
|
|
|
const startX = centerX - roomWidth/2 + CFG.ROOM_MARGIN;
|
|
const startZ = centerZ - roomDepth/2 + CFG.ROOM_MARGIN;
|
|
|
|
room.beds.forEach((bed, index) => {
|
|
if (!this.shouldShowBed(bed)) return;
|
|
|
|
const bedGeometry = new THREE.BoxGeometry(CFG.BED_W, CFG.BED_H, CFG.BED_D);
|
|
//const bedColor = bed.canuse ? 0x4caf50 : 0xf44336;
|
|
//const bedColor = this.getBedColor(bed);
|
|
const style = this.getBedStyle(bed);
|
|
const bedMaterial = new THREE.MeshLambertMaterial({
|
|
color: style.color,
|
|
emissive: style.emissive,
|
|
emissiveIntensity: style.intensity
|
|
});
|
|
//const bedMaterial = new THREE.MeshLambertMaterial({ color: bedColor });
|
|
const bedMesh = new THREE.Mesh(bedGeometry, bedMaterial);
|
|
|
|
const r = Math.floor(index / cols);
|
|
const c = index % cols;
|
|
|
|
const xPos = startX + (c * CFG.COL_SPACING);
|
|
const zPos = startZ + (r * CFG.ROW_SPACING);
|
|
|
|
bedMesh.position.set(xPos, CFG.BED_H/2, zPos);
|
|
bedMesh.userData = { type: 'bed', bedInfo: bed };
|
|
|
|
this.three.scene.add(bedMesh);
|
|
this.three.meshes.push(bedMesh);
|
|
|
|
//const bedLabelStr = `${bed.name}\n[${bed.canuse ? '空床' : '佔用'}]`;
|
|
const bedLabelStr = this.getBedLabelText(bed);
|
|
const bedLabel = this.makeTextSprite(bedLabelStr, 20, "#ffffff", "rgba(0,0,0,0.7)");
|
|
bedLabel.position.set(xPos, CFG.BED_H + 4, zPos);
|
|
this.three.scene.add(bedLabel);
|
|
this.three.meshes.push(bedLabel);
|
|
});
|
|
|
|
roomOffsetX += roomWidth + CFG.ROOM_GAP;
|
|
});
|
|
|
|
regionOffsetZ += maxRoomDepth + CFG.REGION_GAP;
|
|
});
|
|
},
|
|
|
|
// 【Level 1: 渲染所有大樓/樓層區塊 (Drilldown 模式)】
|
|
renderAllRegions() {
|
|
// 區塊基礎大小
|
|
const bW = 40, bH = 15, bD = 40;
|
|
let offsetX = - (Math.min(this.regions.length, 3) * (bW+20)) / 2 + (bW/2);
|
|
let offsetZ = -10;
|
|
|
|
this.regions.forEach((region, i) => {
|
|
if (region.isStop) return;
|
|
|
|
let total = 0, avail = 0;
|
|
(region.room || []).forEach(r => (r.beds || []).forEach(b => {
|
|
total++;
|
|
if (b.canuse) avail++;
|
|
}));
|
|
|
|
const boxGeo = new THREE.BoxGeometry(bW, bH, bD);
|
|
const boxMat = new THREE.MeshLambertMaterial({ color: 0x3f51b5, transparent: true, opacity: 0.85 });
|
|
const boxMesh = new THREE.Mesh(boxGeo, boxMat);
|
|
boxMesh.position.set(offsetX, bH/2, offsetZ);
|
|
|
|
const edges = new THREE.EdgesGeometry(boxGeo);
|
|
const line = new THREE.LineSegments(edges, new THREE.LineBasicMaterial({ color: 0xffffff, linewidth: 2 }));
|
|
boxMesh.add(line);
|
|
|
|
boxMesh.userData = { type: 'region', regionInfo: region };
|
|
this.three.scene.add(boxMesh);
|
|
this.three.meshes.push(boxMesh);
|
|
|
|
const labelStr = `${region.name}\n(床位: ${avail} 可用 / ${total} 總計)`;
|
|
const label = this.makeTextSprite(labelStr, 32, "#ffffff", "rgba(0,0,0,0.6)");
|
|
label.position.set(offsetX, bH + 10, offsetZ);
|
|
this.three.scene.add(label);
|
|
this.three.meshes.push(label);
|
|
|
|
offsetX += bW + 20;
|
|
if ((i + 1) % 3 === 0) {
|
|
offsetX = - (Math.min(this.regions.length, 3) * (bW+20)) / 2 + (bW/2);
|
|
offsetZ += bD + 20;
|
|
}
|
|
});
|
|
},
|
|
|
|
// 【Level 2: 渲染選擇樓層內的所有房間與床位 (Drilldown 模式)】
|
|
renderRoomsInRegion() {
|
|
const rooms = this.viewState.region.room || [];
|
|
const maxCols = 3;
|
|
let offsetZ = -10;
|
|
|
|
for (let i = 0; i < rooms.length; i += maxCols) {
|
|
const rowRooms = rooms.slice(i, i + maxCols);
|
|
// 假設所有房間寬度差不多,先算個大概置中點
|
|
let offsetX = - ((rowRooms.length - 1) * 60) / 2;
|
|
let maxRoomDepth = 0;
|
|
|
|
rowRooms.forEach((room) => {
|
|
const bedCount = room.beds ? room.beds.length : 0;
|
|
const cols = 2;
|
|
const rows = bedCount > 0 ? Math.ceil(bedCount / cols) : 1;
|
|
const roomWidth = (cols * CFG.COL_SPACING) + CFG.ROOM_MARGIN;
|
|
const roomDepth = (rows * CFG.ROW_SPACING) + CFG.ROOM_MARGIN;
|
|
if (roomDepth > maxRoomDepth) maxRoomDepth = roomDepth;
|
|
|
|
const floorColor = room.gender ? 0xe3f2fd : 0xfce4ec;
|
|
const floorGeo = new THREE.PlaneGeometry(roomWidth, roomDepth);
|
|
const floorMat = new THREE.MeshLambertMaterial({ color: floorColor, side: THREE.DoubleSide });
|
|
const floorMesh = new THREE.Mesh(floorGeo, floorMat);
|
|
floorMesh.rotation.x = -Math.PI / 2;
|
|
floorMesh.position.set(offsetX, 0, offsetZ);
|
|
|
|
const edges = new THREE.EdgesGeometry(floorGeo);
|
|
const line = new THREE.LineSegments(edges, new THREE.LineBasicMaterial( { color: 0x999999 } ) );
|
|
floorMesh.add(line);
|
|
|
|
floorMesh.userData = { type: 'room', roomInfo: room };
|
|
this.three.scene.add(floorMesh);
|
|
this.three.meshes.push(floorMesh);
|
|
|
|
const wallMat = new THREE.MeshLambertMaterial({ color: 0xb0bec5, transparent: true, opacity: 0.4, side: THREE.DoubleSide });
|
|
const backWall = new THREE.Mesh(new THREE.PlaneGeometry(roomWidth, CFG.WALL_H), wallMat);
|
|
backWall.position.set(offsetX, CFG.WALL_H/2, offsetZ - roomDepth/2);
|
|
this.three.scene.add(backWall);
|
|
this.three.meshes.push(backWall);
|
|
|
|
const leftWall = new THREE.Mesh(new THREE.PlaneGeometry(roomDepth, CFG.WALL_H), wallMat);
|
|
leftWall.rotation.y = Math.PI / 2;
|
|
leftWall.position.set(offsetX - roomWidth/2, CFG.WALL_H/2, offsetZ);
|
|
this.three.scene.add(leftWall);
|
|
this.three.meshes.push(leftWall);
|
|
|
|
const rightWall = new THREE.Mesh(new THREE.PlaneGeometry(roomDepth, CFG.WALL_H), wallMat);
|
|
rightWall.rotation.y = Math.PI / 2;
|
|
rightWall.position.set(offsetX + roomWidth/2, CFG.WALL_H/2, offsetZ);
|
|
this.three.scene.add(rightWall);
|
|
this.three.meshes.push(rightWall);
|
|
|
|
let total = 0, avail = 0;
|
|
(room.beds || []).forEach(b => {
|
|
total++;
|
|
if (b.canuse) avail++;
|
|
});
|
|
const labelStr = `${room.name} ${room.gender ? '(男)' : '(女)'}\n(可用: ${avail}/${total})`;
|
|
const label = this.makeTextSprite(labelStr, 32, "#333333", "rgba(255,255,255,0.85)");
|
|
label.position.set(offsetX, CFG.WALL_H + 5, offsetZ - roomDepth/2);
|
|
this.three.scene.add(label);
|
|
this.three.meshes.push(label);
|
|
|
|
if (room.beds && room.beds.length > 0) {
|
|
const startX = offsetX - roomWidth/2 + CFG.ROOM_MARGIN;
|
|
const startZ = offsetZ - roomDepth/2 + CFG.ROOM_MARGIN;
|
|
|
|
room.beds.forEach((bed, index) => {
|
|
if (!this.shouldShowBed(bed)) return;
|
|
|
|
const bedGeometry = new THREE.BoxGeometry(CFG.BED_W, CFG.BED_H, CFG.BED_D);
|
|
//const bedColor = bed.canuse ? 0x4caf50 : 0xf44336;
|
|
//const bedColor = this.getBedColor(bed);
|
|
//const bedMaterial = new THREE.MeshLambertMaterial({ color: bedColor });
|
|
const style = this.getBedStyle(bed);
|
|
const bedMaterial = new THREE.MeshLambertMaterial({
|
|
color: style.color,
|
|
emissive: style.emissive,
|
|
emissiveIntensity: style.intensity
|
|
});
|
|
const bedMesh = new THREE.Mesh(bedGeometry, bedMaterial);
|
|
|
|
const r = Math.floor(index / cols);
|
|
const c = index % cols;
|
|
|
|
const xPos = startX + (c * CFG.COL_SPACING);
|
|
const zPos = startZ + (r * CFG.ROW_SPACING);
|
|
|
|
bedMesh.position.set(xPos, CFG.BED_H/2, zPos);
|
|
bedMesh.userData = { type: 'bed', bedInfo: bed };
|
|
|
|
this.three.scene.add(bedMesh);
|
|
this.three.meshes.push(bedMesh);
|
|
|
|
//const bedLabelStr = `${bed.name}\n[${bed.canuse ? '空床' : '佔用'}]`;
|
|
const bedLabelStr = this.getBedLabelText(bed);
|
|
const bedLabel = this.makeTextSprite(bedLabelStr, 20, "#ffffff", "rgba(0,0,0,0.7)");
|
|
bedLabel.position.set(xPos, CFG.BED_H + 4, zPos);
|
|
this.three.scene.add(bedLabel);
|
|
this.three.meshes.push(bedLabel);
|
|
});
|
|
}
|
|
offsetX += roomWidth + CFG.ROOM_GAP;
|
|
});
|
|
offsetZ += maxRoomDepth + CFG.REGION_GAP;
|
|
}
|
|
},
|
|
|
|
// 【Level 3: 渲染單一聚焦房間內的所有立體床鋪 (Drilldown 模式)】
|
|
renderBedsInRoom() {
|
|
const room = this.viewState.room;
|
|
if (!room || !room.beds) return;
|
|
|
|
const bedCount = room.beds.length;
|
|
if (bedCount === 0) return;
|
|
|
|
const cols = 2;
|
|
const rows = Math.ceil(bedCount / cols);
|
|
const roomWidth = (cols * CFG.COL_SPACING) + CFG.ROOM_MARGIN;
|
|
const roomDepth = (rows * CFG.ROW_SPACING) + CFG.ROOM_MARGIN;
|
|
|
|
const floorColor = room.gender ? 0xe3f2fd : 0xfce4ec;
|
|
const floorGeo = new THREE.PlaneGeometry(roomWidth, roomDepth);
|
|
const floorMat = new THREE.MeshLambertMaterial({ color: floorColor, side: THREE.DoubleSide });
|
|
const floorMesh = new THREE.Mesh(floorGeo, floorMat);
|
|
floorMesh.rotation.x = -Math.PI / 2;
|
|
floorMesh.position.set(0, 0, 0);
|
|
|
|
const edges = new THREE.EdgesGeometry(floorGeo);
|
|
const line = new THREE.LineSegments(edges, new THREE.LineBasicMaterial( { color: 0x999999 } ) );
|
|
floorMesh.add(line);
|
|
this.three.scene.add(floorMesh);
|
|
this.three.meshes.push(floorMesh);
|
|
|
|
const wallMat = new THREE.MeshLambertMaterial({ color: 0xb0bec5, transparent: true, opacity: 0.4, side: THREE.DoubleSide });
|
|
const backWall = new THREE.Mesh(new THREE.PlaneGeometry(roomWidth, CFG.WALL_H), wallMat);
|
|
backWall.position.set(0, CFG.WALL_H/2, -roomDepth/2);
|
|
this.three.scene.add(backWall);
|
|
this.three.meshes.push(backWall);
|
|
|
|
const leftWall = new THREE.Mesh(new THREE.PlaneGeometry(roomDepth, CFG.WALL_H), wallMat);
|
|
leftWall.rotation.y = Math.PI / 2;
|
|
leftWall.position.set(-roomWidth/2, CFG.WALL_H/2, 0);
|
|
this.three.scene.add(leftWall);
|
|
this.three.meshes.push(leftWall);
|
|
|
|
const rightWall = new THREE.Mesh(new THREE.PlaneGeometry(roomDepth, CFG.WALL_H), wallMat);
|
|
rightWall.rotation.y = Math.PI / 2;
|
|
rightWall.position.set(roomWidth/2, CFG.WALL_H/2, 0);
|
|
this.three.scene.add(rightWall);
|
|
this.three.meshes.push(rightWall);
|
|
|
|
const startX = -roomWidth/2 + CFG.ROOM_MARGIN;
|
|
const startZ = -roomDepth/2 + CFG.ROOM_MARGIN;
|
|
|
|
room.beds.forEach((bed, index) => {
|
|
if (!this.shouldShowBed(bed)) return;
|
|
|
|
const bedGeometry = new THREE.BoxGeometry(CFG.BED_W, CFG.BED_H, CFG.BED_D);
|
|
//const bedColor = bed.canuse ? 0x4caf50 : 0xf44336;
|
|
//const bedColor = this.getBedColor(bed);
|
|
//const bedMaterial = new THREE.MeshLambertMaterial({ color: bedColor });
|
|
const style = this.getBedStyle(bed);
|
|
const bedMaterial = new THREE.MeshLambertMaterial({
|
|
color: style.color,
|
|
emissive: style.emissive,
|
|
emissiveIntensity: style.intensity
|
|
});
|
|
const bedMesh = new THREE.Mesh(bedGeometry, bedMaterial);
|
|
|
|
const row = Math.floor(index / cols);
|
|
const col = index % cols;
|
|
|
|
const xPos = startX + (col * CFG.COL_SPACING);
|
|
const zPos = startZ + (row * CFG.ROW_SPACING);
|
|
|
|
bedMesh.position.set(xPos, CFG.BED_H/2, zPos);
|
|
bedMesh.userData = { type: 'bed', bedInfo: bed };
|
|
|
|
this.three.scene.add(bedMesh);
|
|
this.three.meshes.push(bedMesh);
|
|
|
|
//const labelStr = `${bed.name}\n[${bed.canuse ? '空床' : '佔用'}]`;
|
|
const labelStr = this.getBedLabelText(bed);
|
|
const bedLabel = this.makeTextSprite(labelStr, 24, "#ffffff", "rgba(0,0,0,0.7)");
|
|
bedLabel.position.set(xPos, CFG.BED_H + 4, zPos);
|
|
this.three.scene.add(bedLabel);
|
|
this.three.meshes.push(bedLabel);
|
|
});
|
|
},
|
|
|
|
animate() {
|
|
this.three.animationId = requestAnimationFrame(this.animate);
|
|
if(this.three.controls) this.three.controls.update();
|
|
if(this.three.renderer && this.three.scene && this.three.camera) {
|
|
this.three.renderer.render(this.three.scene, this.three.camera);
|
|
}
|
|
},
|
|
|
|
onCanvasClick(event) {
|
|
const container = this.$refs.threeContainer;
|
|
if (!container || !this.three.raycaster || !this.three.mouse) return;
|
|
|
|
const rect = container.getBoundingClientRect();
|
|
this.three.mouse.x = ((event.clientX - rect.left) / container.clientWidth) * 2 - 1;
|
|
this.three.mouse.y = -((event.clientY - rect.top) / container.clientHeight) * 2 + 1;
|
|
|
|
this.three.raycaster.setFromCamera(this.three.mouse, this.three.camera);
|
|
|
|
const meshesOnly = this.three.scene.children.filter(child => child instanceof THREE.Mesh);
|
|
const intersects = this.three.raycaster.intersectObjects(meshesOnly, true);
|
|
|
|
if (intersects.length > 0) {
|
|
const clickedObject = intersects[0].object;
|
|
const userData = clickedObject.userData;
|
|
|
|
if (userData) {
|
|
if (userData.type === 'region' && this.viewMode === 'DRILLDOWN') {
|
|
this.drillToRegion(userData.regionInfo);
|
|
} else if (userData.type === 'room' && this.viewMode === 'DRILLDOWN') {
|
|
this.drillToRoom(userData.roomInfo);
|
|
} else if (userData.type === 'bed') {
|
|
this.showBedSchedule(userData.bedInfo);
|
|
}
|
|
}
|
|
}
|
|
},
|
|
|
|
onWindowResize() {
|
|
const container = this.$refs.threeContainer;
|
|
if (!container || !this.three.camera || !this.three.renderer) return;
|
|
this.three.camera.aspect = container.clientWidth / container.clientHeight;
|
|
this.three.camera.updateProjectionMatrix();
|
|
this.three.renderer.setSize(container.clientWidth, container.clientHeight);
|
|
}
|
|
},
|
|
watch: {
|
|
'filter.selectedArea'() { this.loadRooms(); },
|
|
'filter.startDate'() { this.GetRegionList(); },
|
|
'filter.occupied'() { this.render3DRooms(); },
|
|
'filter.unoccupied'() { this.render3DRooms(); },
|
|
'filter.Gender'() { this.render3DRooms(); },
|
|
'isFilterExpanded'() {
|
|
setTimeout(() => { this.onWindowResize(); }, 350);
|
|
},
|
|
'filter.selectedLimits'() { this.render3DRooms(); },
|
|
},
|
|
mounted() {
|
|
if (!this.regions) this.regions = [];
|
|
|
|
this.filter.startDate = this.today;
|
|
this.getRegionWithRoom();
|
|
this.loadRooms();
|
|
|
|
this.$nextTick(() => {
|
|
this.initThree();
|
|
this.GetRegionList();
|
|
});
|
|
},
|
|
beforeDestroy() {
|
|
if (this.three.animationId) cancelAnimationFrame(this.three.animationId);
|
|
window.removeEventListener('resize', this.onWindowResize);
|
|
const container = this.$refs.threeContainer;
|
|
if (container) container.removeEventListener('click', this.onCanvasClick);
|
|
},
|
|
computed: {
|
|
filteredStats() {
|
|
if (!this.regions || !Array.isArray(this.regions) || this.regions.length === 0) {
|
|
return { maleBeds: 0, femaleBeds: 0 };
|
|
}
|
|
let maleBeds = 0;
|
|
let femaleBeds = 0;
|
|
this.regions.forEach(region => {
|
|
(region.room || []).forEach(room => {
|
|
(room.beds || []).forEach(bed => {
|
|
if (bed.canuse) {
|
|
if (bed.gender) maleBeds++;
|
|
else femaleBeds++;
|
|
}
|
|
});
|
|
});
|
|
});
|
|
return { maleBeds, femaleBeds };
|
|
}
|
|
}
|
|
});
|
|
</script>
|
|
</asp:Content>
|