|
<!DOCTYPE html>
|
|
<html lang="zh-TW">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<title>異常原因與類型維護</title>
|
|
|
|
<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
|
|
<script src="https://cdn.tailwindcss.com"></script>
|
|
<script>
|
|
tailwind.config = {
|
|
theme: {
|
|
extend: {
|
|
colors: {
|
|
'teal-800': '#1e4646',
|
|
'teal-200': '#b2f0f0',
|
|
'teal-600': '#00a399',
|
|
'teal-50': '#f0faff',
|
|
'blue-500': '#3b82f6',
|
|
'blue-600': '#2563eb',
|
|
'red-500': '#ef4444',
|
|
'green-600': '#10b981',
|
|
'gray-100': '#f3f4f6',
|
|
}
|
|
}
|
|
}
|
|
}
|
|
</script>
|
|
</head>
|
|
<body class="bg-slate-50 p-6 md:p-10">
|
|
|
|
<div id="app" class="max-w-7xl mx-auto">
|
|
|
|
<div class="flex flex-col md:flex-row md:items-center justify-between mb-8 pb-4 border-b-4 border-teal-200">
|
|
<div>
|
|
<h1 class="text-3xl font-bold text-teal-800">⚙️ 異常項目設定</h1>
|
|
<p class="text-gray-500 text-sm mt-1">請先於左側選擇/定義「異常類型」,再於右側設定對應的「異常原因」</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="grid grid-cols-1 lg:grid-cols-12 gap-6">
|
|
|
|
<div class="lg:col-span-5 bg-white rounded-2xl shadow-md border border-gray-100 overflow-hidden flex flex-col h-[650px]">
|
|
<div class="p-4 bg-gray-50 border-b border-gray-200 flex justify-between items-center">
|
|
<div class="flex items-center gap-2">
|
|
<h2 class="text-lg font-bold text-gray-700">異常類型</h2>
|
|
</div>
|
|
<button @click="openDialog('type')" class="px-3 py-1.5 text-xs font-semibold bg-teal-600 hover:bg-teal-700 text-white rounded-lg transition flex items-center gap-1">
|
|
➕ 新增類型
|
|
</button>
|
|
</div>
|
|
|
|
<div class="p-3 border-b border-gray-100">
|
|
<input v-model="typeSearch" @input="typePage = 1" type="text" placeholder="搜尋類型名稱..." class="w-full px-3 py-1.5 text-sm border rounded-lg focus:outline-teal-600">
|
|
</div>
|
|
|
|
<div class="flex-1 overflow-y-auto p-2 space-y-1">
|
|
<div @click="selectType(null)"
|
|
:class="['p-3.5 rounded-xl cursor-pointer transition flex justify-between items-center', selectedTypeId === null ? 'bg-teal-50 text-teal-800 font-bold border-l-4 border-teal-600' : 'hover:bg-gray-50 text-gray-700']">
|
|
<span>顯示全部類型</span>
|
|
</div>
|
|
|
|
<div v-for="item in paginatedTypes" :key="item.id"
|
|
@click="selectType(item.id)"
|
|
:class="['group p-3.5 rounded-xl cursor-pointer transition flex justify-between items-center border border-transparent', selectedTypeId === item.id ? 'bg-teal-50 text-teal-800 font-bold border-l-4 border-teal-600' : 'hover:bg-gray-50 text-gray-700']">
|
|
<div class="truncate pr-2">
|
|
<p class="truncate">{{ item.name }}</p>
|
|
<p v-if="item.notes" class="text-xs text-gray-400 font-normal truncate mt-0.5">{{ item.notes }}</p>
|
|
</div>
|
|
<div class="flex items-center shrink-0">
|
|
<div class="opacity-0 group-hover:opacity-100 transition-opacity flex items-center">
|
|
<button @click.stop="openDialogForDelete('type', item)" class="p-1.5 bg-red-500 hover:bg-red-600 text-white rounded text-xs" title="刪除">🗑️</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div v-if="filteredTypes.length === 0" class="text-center py-8 text-gray-400 text-sm">
|
|
無符合的類型
|
|
</div>
|
|
</div>
|
|
|
|
<div v-if="totalTypePages > 1" class="p-3 bg-gray-50 border-t border-gray-100 flex items-center justify-between text-xs text-gray-500">
|
|
<button @click="typePage--" :disabled="typePage === 1" class="px-2 py-1 bg-white border rounded shadow-sm hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed">◀ 上一頁</button>
|
|
<span>第 {{ typePage }} / {{ totalTypePages }} 頁</span>
|
|
<button @click="typePage++" :disabled="typePage === totalTypePages" class="px-2 py-1 bg-white border rounded shadow-sm hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed">下一頁 ▶</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="lg:col-span-7 bg-white rounded-2xl shadow-md border border-gray-100 overflow-hidden flex flex-col h-[650px]">
|
|
<div class="p-4 bg-gray-50 border-b border-gray-200 flex justify-between items-center">
|
|
<div class="flex items-center gap-2">
|
|
<h2 class="text-lg font-bold text-gray-700">
|
|
異常原因說明
|
|
<span class="text-sm font-normal text-teal-600 ml-1">
|
|
({{ selectedTypeId ? `已篩選:${getTypeName(selectedTypeId)}` : '全部' }})
|
|
</span>
|
|
</h2>
|
|
</div>
|
|
<button @click="openDialog('reason')" class="px-4 py-1.5 text-sm font-semibold bg-teal-600 hover:bg-teal-700 text-white rounded-lg transition flex items-center gap-1">
|
|
➕ 新增原因
|
|
</button>
|
|
</div>
|
|
|
|
<div class="p-3 border-b border-gray-100">
|
|
<input v-model="reasonSearch" @input="reasonPage = 1" type="text" placeholder="搜尋原因說明..." class="w-full px-3 py-1.5 text-sm border rounded-lg focus:outline-teal-600">
|
|
</div>
|
|
|
|
<div class="flex-1 overflow-y-auto">
|
|
<table class="min-w-full table-auto text-sm">
|
|
<thead class="bg-gray-50 sticky top-0 border-b border-gray-100">
|
|
<tr>
|
|
<th class="px-4 py-3 text-left font-semibold text-gray-500 w-1/3">異常類型</th>
|
|
<th class="px-4 py-3 text-left font-semibold text-gray-500">異常原因說明</th>
|
|
<th class="px-4 py-3 text-center font-semibold text-gray-500 w-24">操作</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody class="divide-y divide-gray-100">
|
|
<tr v-for="item in paginatedReasons" :key="item.id" class="hover:bg-teal-50/50 transition">
|
|
<td class="px-4 py-3.5 font-medium text-teal-700">{{ getTypeName(item.typeId) }}</td>
|
|
<td class="px-4 py-3.5 text-gray-700">{{ item.description }}</td>
|
|
<td class="px-4 py-3.5 text-center">
|
|
<button @click="openDialogForDelete('reason', item)" class="p-1 px-2.5 bg-red-500 text-white rounded hover:bg-red-600 text-xs">🗑️ 刪除</button>
|
|
</td>
|
|
</tr>
|
|
<tr v-if="filteredReasons.length === 0">
|
|
<td colspan="3" class="px-4 py-12 text-center text-gray-400">
|
|
<p class="text-base">📭 目前無此類型的原因資料</p>
|
|
<button @click="openDialog('reason')" class="mt-2 text-xs text-teal-600 underline hover:text-teal-700">立即新增一個原因</button>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<div v-if="totalReasonPages > 1" class="p-3 bg-gray-50 border-t border-gray-100 flex items-center justify-between text-xs text-gray-500">
|
|
<button @click="reasonPage--" :disabled="reasonPage === 1" class="px-2 py-1 bg-white border rounded shadow-sm hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed">◀ 上一頁</button>
|
|
<span>第 {{ reasonPage }} / {{ totalReasonPages }} 頁 (共 {{ filteredReasons.length }} 筆)</span>
|
|
<button @click="reasonPage++" :disabled="reasonPage === totalReasonPages" class="px-2 py-1 bg-white border rounded shadow-sm hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed">下一頁 ▶</button>
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
|
|
<div v-if="editingItem" class="fixed inset-0 flex items-center justify-center bg-black bg-opacity-60 z-40">
|
|
<div class="bg-white p-6 rounded-xl shadow-2xl w-11/12 max-w-lg border border-gray-200">
|
|
<h2 class="text-xl font-bold text-teal-700 mb-4 flex items-center gap-2">
|
|
📝 {{ dialogTitle }}
|
|
</h2>
|
|
|
|
<div class="mb-6 space-y-4">
|
|
<div v-if="activeDialogTab === 'reason'">
|
|
<label class="block mb-1 font-semibold text-gray-700 text-sm">選擇異常類型 <span class="text-red-500">*</span></label>
|
|
<select v-model="editingItem.typeId" class="w-full p-2 border rounded-lg focus:ring-2 focus:ring-teal-200 focus:border-teal-600 outline-none text-sm" required>
|
|
<option disabled value="">--- 請選擇異常類型 ---</option>
|
|
<option v-for="t in types" :key="t.id" :value="t.id">{{ t.name }}</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div v-if="activeDialogTab === 'reason'">
|
|
<label class="block mb-1 font-semibold text-gray-700 text-sm">異常原因說明 <span class="text-red-500">*</span></label>
|
|
<textarea v-model="editingItem.description" placeholder="例如:機台馬達過熱停機" rows="3" class="w-full p-2 border rounded-lg focus:ring-2 focus:ring-teal-200 focus:border-teal-600 outline-none text-sm" required></textarea>
|
|
</div>
|
|
|
|
<div v-if="activeDialogTab === 'type'">
|
|
<label class="block mb-1 font-semibold text-gray-700 text-sm">異常類型名稱 <span class="text-red-500">*</span></label>
|
|
<input v-model="editingItem.name" placeholder="例如:設備故障" class="w-full p-2 border rounded-lg focus:ring-2 focus:ring-teal-200 focus:border-teal-600 outline-none text-sm" required>
|
|
</div>
|
|
|
|
<div v-if="activeDialogTab === 'type'">
|
|
<label class="block mb-1 font-semibold text-gray-700 text-sm">備註說明</label>
|
|
<textarea v-model="editingItem.notes" placeholder="可填寫此類型的定義或範圍" rows="2" class="w-full p-2 border rounded-lg focus:ring-2 focus:ring-teal-200 focus:border-teal-600 outline-none text-sm"></textarea>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex justify-end space-x-3">
|
|
<button @click="editingItem = null" class="px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 text-sm transition">取消</button>
|
|
<button @click="saveItem()" class="px-5 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 text-sm transition font-semibold">確認儲存</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-if="itemToDelete" class="fixed inset-0 flex items-center justify-center bg-black bg-opacity-60 z-30">
|
|
<div class="bg-white p-6 rounded-xl shadow-2xl w-96 border border-gray-200">
|
|
<h3 class="text-lg font-bold mb-2 text-gray-800">⚠️ 確定要刪除嗎?</h3>
|
|
<p class="mb-6 text-gray-600 text-sm">
|
|
即將刪除:<span class="font-bold text-red-600">{{ itemToDelete.name || itemToDelete.description }}</span>
|
|
<span v-if="activeDialogTab === 'type'" class="block mt-2 text-xs text-red-500 font-semibold bg-red-50 p-2 rounded">
|
|
※ 警告:刪除此類型將連同刪除其下屬的所有異常原因!
|
|
</span>
|
|
</p>
|
|
|
|
<div class="flex justify-end space-x-3">
|
|
<button @click="itemToDelete=null" class="px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 text-sm transition">取消</button>
|
|
<button @click="deleteItem" class="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 text-sm transition font-semibold">確認刪除</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
|
|
<script>
|
|
const { createApp, ref, computed } = Vue;
|
|
|
|
createApp({
|
|
setup() {
|
|
// 當前選中的類型 ID (null 代表顯示全部)
|
|
const selectedTypeId = ref(null);
|
|
|
|
// 搜尋用欄位
|
|
const typeSearch = ref('');
|
|
const reasonSearch = ref('');
|
|
|
|
// 分頁相關設定
|
|
const typePage = ref(1);
|
|
const typePerPage = 5; // 左側類型每頁顯示 5 筆
|
|
|
|
const reasonPage = ref(1);
|
|
const reasonPerPage = 8; // 右側原因每頁顯示 8 筆
|
|
|
|
// 假資料:異常類型 (Parent)
|
|
const types = ref([
|
|
{ id: 101, name: '設備故障', notes: '生產設備、模具、治具異常' },
|
|
{ id: 102, name: '品質異常', notes: '產品外觀、尺寸、檢驗不良' },
|
|
{ id: 103, name: '人員操作不良', notes: '未按SOP操作、漏檢等' },
|
|
{ id: 104, name: '系統連線異常', notes: '網路中斷、系統無回應' },
|
|
{ id: 105, name: '材料/物料不良', notes: '來料瑕疵、包裝破損' },
|
|
{ id: 106, name: '環境與安全異常', notes: '溫溼度超標、安全門未關' }
|
|
]);
|
|
|
|
// 假資料:異常原因 (Child)
|
|
const reasons = ref([
|
|
{ id: 1, typeId: 101, description: '機台馬達異常過熱停機' },
|
|
{ id: 2, typeId: 102, description: '產品表面有明顯刮傷' },
|
|
{ id: 3, typeId: 101, description: '氣壓缸氣體洩漏壓力不足' },
|
|
{ id: 4, typeId: 103, description: '作業員未依規定配戴防靜電手套' },
|
|
{ id: 5, typeId: 104, description: 'MES 系統資料回傳超時' },
|
|
{ id: 6, typeId: 105, description: '批次外箱受潮嚴重' },
|
|
{ id: 7, typeId: 101, description: '感應線圈訊號異常斷續' },
|
|
{ id: 8, typeId: 102, description: '成品邊緣有毛邊殘留' },
|
|
{ id: 9, typeId: 102, description: '量測尺寸超出規格上限 0.05mm' },
|
|
{ id: 10, typeId: 101, description: '切削刀具磨損過度需要更換' }
|
|
]);
|
|
|
|
const editingItem = ref(null);
|
|
const itemToDelete = ref(null);
|
|
const activeDialogTab = ref('reason'); // 當前彈窗處理的對象 ('reason' 或 'type')
|
|
|
|
// 選擇類型,同時將右側原因列表跳回第 1 頁
|
|
const selectType = (id) => {
|
|
selectedTypeId.value = id;
|
|
reasonPage.value = 1;
|
|
};
|
|
|
|
// 動態取得異常類型的名稱
|
|
const getTypeName = (typeId) => {
|
|
const type = types.value.find(t => t.id === typeId);
|
|
return type ? type.name : '未知類型';
|
|
};
|
|
|
|
// 【過濾後的異常類型列表】 (已套用搜尋)
|
|
const filteredTypes = computed(() => {
|
|
if (!typeSearch.value.trim()) return types.value;
|
|
return types.value.filter(t => t.name.toLowerCase().includes(typeSearch.value.toLowerCase()));
|
|
});
|
|
|
|
// 【分頁後的異常類型列表】
|
|
const paginatedTypes = computed(() => {
|
|
const start = (typePage.value - 1) * typePerPage;
|
|
return filteredTypes.value.slice(start, start + typePerPage);
|
|
});
|
|
|
|
// 總類型頁數
|
|
const totalTypePages = computed(() => {
|
|
return Math.ceil(filteredTypes.value.length / typePerPage) || 1;
|
|
});
|
|
|
|
// 【過濾後的異常原因列表】 (已套用選取與搜尋)
|
|
const filteredReasons = computed(() => {
|
|
let result = reasons.value;
|
|
|
|
// 1. 聯動左側選擇
|
|
if (selectedTypeId.value !== null) {
|
|
result = result.filter(r => r.typeId === selectedTypeId.value);
|
|
}
|
|
|
|
// 2. 關鍵字搜尋
|
|
if (reasonSearch.value.trim()) {
|
|
const search = reasonSearch.value.toLowerCase();
|
|
result = result.filter(r =>
|
|
r.description.toLowerCase().includes(search) ||
|
|
getTypeName(r.typeId).toLowerCase().includes(search)
|
|
);
|
|
}
|
|
return result;
|
|
});
|
|
|
|
// 【分頁後的異常原因列表】
|
|
const paginatedReasons = computed(() => {
|
|
const start = (reasonPage.value - 1) * reasonPerPage;
|
|
return filteredReasons.value.slice(start, start + reasonPerPage);
|
|
});
|
|
|
|
// 總原因頁數
|
|
const totalReasonPages = computed(() => {
|
|
return Math.ceil(filteredReasons.value.length / reasonPerPage) || 1;
|
|
});
|
|
|
|
const dialogTitle = computed(() => {
|
|
return activeDialogTab.value === 'reason' ? '新增異常原因' : '新增異常類型';
|
|
});
|
|
|
|
// 開啟新增彈窗
|
|
const openDialog = (tab) => {
|
|
activeDialogTab.value = tab;
|
|
if (tab === 'reason') {
|
|
editingItem.value = {
|
|
id: Date.now(),
|
|
// 自動帶入當前左側選取的分類,提升輸入效率
|
|
typeId: selectedTypeId.value || '',
|
|
description: ''
|
|
};
|
|
} else if (tab === 'type') {
|
|
editingItem.value = {
|
|
id: Date.now(),
|
|
name: '',
|
|
notes: ''
|
|
};
|
|
}
|
|
};
|
|
|
|
// 開啟刪除確認視窗
|
|
const openDialogForDelete = (tab, item) => {
|
|
activeDialogTab.value = tab;
|
|
itemToDelete.value = item;
|
|
};
|
|
|
|
const saveItem = () => {
|
|
const item = editingItem.value;
|
|
const tab = activeDialogTab.value;
|
|
|
|
// 欄位驗證
|
|
if (tab === 'reason' && (!item.typeId || !item.description.trim())) {
|
|
alert('請選擇異常類型並輸入說明!');
|
|
return;
|
|
}
|
|
if (tab === 'type' && !item.name.trim()) {
|
|
alert('請輸入異常類型名稱!');
|
|
return;
|
|
}
|
|
|
|
if (tab === 'type') {
|
|
if (types.value.some(t => t.name.trim() === item.name.trim())) {
|
|
alert('此類型名稱已存在!');
|
|
return;
|
|
}
|
|
types.value.push(JSON.parse(JSON.stringify(item)));
|
|
// 新增類型後自動跳到類型的最後一頁,方便查看
|
|
setTimeout(() => {
|
|
typePage.value = totalTypePages.value;
|
|
}, 50);
|
|
} else {
|
|
reasons.value.push(JSON.parse(JSON.stringify(item)));
|
|
// 新增原因後自動跳到原因的最後一頁
|
|
setTimeout(() => {
|
|
reasonPage.value = totalReasonPages.value;
|
|
}, 50);
|
|
}
|
|
|
|
editingItem.value = null;
|
|
};
|
|
|
|
const deleteItem = () => {
|
|
const item = itemToDelete.value;
|
|
const tab = activeDialogTab.value;
|
|
|
|
if (tab === 'reason') {
|
|
reasons.value = reasons.value.filter(i => i.id !== item.id);
|
|
// 防呆:如果刪除後該頁變空,自動回到前一頁
|
|
if (paginatedReasons.value.length === 0 && reasonPage.value > 1) {
|
|
reasonPage.value--;
|
|
}
|
|
} else if (tab === 'type') {
|
|
// 串聯刪除:刪除大分類時,自動清空底下的原因資料,避免遺留無效資料
|
|
reasons.value = reasons.value.filter(r => r.typeId !== item.id);
|
|
types.value = types.value.filter(t => t.id !== item.id);
|
|
if (selectedTypeId.value === item.id) {
|
|
selectedTypeId.value = null; // 重置為全部顯示
|
|
}
|
|
// 防呆:如果刪除後該頁變空,自動回到前一頁
|
|
if (paginatedTypes.value.length === 0 && typePage.value > 1) {
|
|
typePage.value--;
|
|
}
|
|
}
|
|
itemToDelete.value = null;
|
|
};
|
|
|
|
return {
|
|
selectedTypeId,
|
|
typeSearch,
|
|
reasonSearch,
|
|
reasons,
|
|
types,
|
|
filteredTypes,
|
|
paginatedTypes,
|
|
typePage,
|
|
totalTypePages,
|
|
filteredReasons,
|
|
paginatedReasons,
|
|
reasonPage,
|
|
totalReasonPages,
|
|
editingItem,
|
|
itemToDelete,
|
|
activeDialogTab,
|
|
dialogTitle,
|
|
getTypeName,
|
|
selectType,
|
|
openDialog,
|
|
openDialogForDelete,
|
|
saveItem,
|
|
deleteItem
|
|
};
|
|
}
|
|
}).mount('#app');
|
|
</script>
|
|
</body>
|
|
</html>
|