Project

General

Profile

Feature #152 » 異常原因_0716.html

鎮源 游, 07/16/2026 05:54 AM

 
<!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>
    (1-1/1)