refactor(query-tool): align UI to semantic CSS classes matching system-wide conventions

Convert all 18 query-tool Vue components from Tailwind utility classes to
semantic CSS classes (.header, .card, .btn-primary, .query-tool-tab, etc.)
consistent with reject-history, hold-overview, and other pages. Create
self-contained style.css with design tokens, base classes, and page-specific
styles. Fix portal-shell native module loader to load query-tool/style.css
instead of resource-shared/styles.css. Add CSS link tags to Django template
for standalone page rendering.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
egg
2026-02-23 13:56:14 +08:00
parent 5d570ca7a2
commit db756eb333
22 changed files with 837 additions and 403 deletions

View File

@@ -60,7 +60,7 @@ const NATIVE_MODULE_LOADERS = Object.freeze({
),
'/query-tool': createNativeLoader(
() => import('../query-tool/App.vue'),
[() => import('../resource-shared/styles.css')],
[() => import('../query-tool/style.css')],
),
'/tmtt-defect': createNativeLoader(
() => import('../tmtt-defect/App.vue'),

View File

@@ -433,23 +433,23 @@ watch(
</script>
<template>
<div class="u-content-shell space-y-3 p-3 lg:p-5">
<header class="rounded-shell bg-gradient-to-r from-brand-500 to-accent-500 px-5 py-4 text-white shadow-shell">
<h1 class="text-xl font-semibold tracking-wide">批次追蹤工具</h1>
<p class="mt-1 text-xs text-indigo-100">正向/反向批次追溯與設備生產批次查詢整合入口</p>
<div class="dashboard query-tool-page">
<header class="header query-tool-header">
<div class="header-left">
<h1>批次追蹤工具</h1>
<p class="query-tool-subtitle">正向/反向批次追溯與設備生產批次查詢整合入口</p>
</div>
</header>
<section class="rounded-shell border border-stroke-panel bg-surface-card shadow-panel">
<div class="border-b border-stroke-soft px-3 pt-3 lg:px-5">
<nav class="flex flex-wrap gap-2" aria-label="query-tool tabs">
<section class="card">
<div class="card-header">
<nav class="query-tool-tab-bar" aria-label="query-tool tabs">
<button
v-for="tab in tabItems"
:key="tab.key"
type="button"
class="rounded-card border px-4 py-2 text-sm font-medium transition"
:class="tab.key === activeTab
? 'border-brand-500 bg-brand-50 text-brand-700 shadow-soft'
: 'border-transparent bg-surface-muted text-slate-600 hover:border-stroke-soft hover:text-slate-800'"
class="query-tool-tab"
:class="{ active: tab.key === activeTab }"
:aria-selected="tab.key === activeTab"
:aria-current="tab.key === activeTab ? 'page' : undefined"
@click="activateTab(tab.key)"
@@ -459,11 +459,11 @@ watch(
</nav>
</div>
<div class="space-y-3 px-3 py-4 lg:px-5">
<div class="rounded-card border border-stroke-soft bg-surface-muted/60 px-4 py-3">
<p class="text-xs font-medium tracking-wide text-slate-500">目前頁籤</p>
<h2 class="mt-1 text-base font-semibold text-slate-800">{{ activeTabMeta.label }}</h2>
<p class="mt-1 text-sm text-slate-600">{{ activeTabMeta.subtitle }}</p>
<div class="card-body">
<div class="query-tool-tab-desc">
<p class="query-tool-tab-desc-label">目前頁籤</p>
<h2 class="query-tool-tab-desc-title">{{ activeTabMeta.label }}</h2>
<p class="query-tool-tab-desc-subtitle">{{ activeTabMeta.subtitle }}</p>
</div>
<LotTraceView

View File

@@ -63,9 +63,9 @@ const columns = Object.freeze([
</script>
<template>
<section class="rounded-card border border-stroke-soft bg-white p-3">
<div class="mb-2 flex items-center justify-between gap-2">
<h4 class="text-sm font-semibold text-slate-800">維修紀錄</h4>
<div>
<div class="query-tool-section-header">
<h4 class="card-title">維修紀錄</h4>
<ExportButton
:disabled="exportDisabled"
:loading="exporting"
@@ -74,28 +74,24 @@ const columns = Object.freeze([
/>
</div>
<p v-if="error" class="mb-2 rounded-card border border-state-danger/40 bg-rose-50 px-3 py-2 text-xs text-state-danger">
<p v-if="error" class="error-banner">
{{ error }}
</p>
<div v-if="loading" class="rounded-card border border-dashed border-stroke-soft bg-surface-muted/40 px-3 py-5 text-center text-xs text-slate-500">
<div v-if="loading" class="placeholder">
載入中...
</div>
<div v-else-if="rows.length === 0" class="rounded-card border border-dashed border-stroke-soft bg-surface-muted/40 px-3 py-5 text-center text-xs text-slate-500">
<div v-else-if="rows.length === 0" class="placeholder">
無維修紀錄
</div>
<div v-else class="max-h-[460px] overflow-auto rounded-card border border-stroke-soft">
<table class="min-w-full border-collapse text-xs">
<thead class="sticky top-0 z-10 bg-slate-100 text-slate-700">
<div v-else class="query-tool-table-wrap tall">
<table class="query-tool-table">
<thead>
<tr>
<th class="border-b border-stroke-soft px-2 py-1.5 text-left font-semibold">展開</th>
<th
v-for="column in columns"
:key="column"
class="whitespace-nowrap border-b border-stroke-soft px-2 py-1.5 text-left font-semibold"
>
<th>展開</th>
<th v-for="column in columns" :key="column">
{{ column }}
</th>
</tr>
@@ -103,19 +99,15 @@ const columns = Object.freeze([
<tbody>
<template v-for="(row, rowIndex) in rows" :key="rowKey(row, rowIndex)">
<tr class="cursor-pointer odd:bg-white even:bg-slate-50" @click="toggleRow(row, rowIndex)">
<td class="border-b border-stroke-soft/70 px-2 py-1.5 text-center text-slate-500">{{ isExpanded(row, rowIndex) ? '▾' : '▸' }}</td>
<td
v-for="column in columns"
:key="`${rowIndex}-${column}`"
class="whitespace-nowrap border-b border-stroke-soft/70 px-2 py-1.5 text-slate-700"
>
<tr style="cursor: pointer" @click="toggleRow(row, rowIndex)">
<td style="text-align: center">{{ isExpanded(row, rowIndex) ? '▾' : '▸' }}</td>
<td v-for="column in columns" :key="`${rowIndex}-${column}`">
{{ formatCellValue(row[column]) }}
</td>
</tr>
<tr v-if="isExpanded(row, rowIndex)" class="bg-slate-50/60">
<td class="border-b border-stroke-soft/70 px-2 py-2" colspan="9">
<tr v-if="isExpanded(row, rowIndex)">
<td colspan="9" style="padding: 8px 10px">
<div class="grid gap-2 text-[11px] text-slate-600 md:grid-cols-2">
<p><span class="font-semibold text-slate-700">RESOURCEID:</span> {{ formatCellValue(row.RESOURCEID) }}</p>
<p><span class="font-semibold text-slate-700">JOBMODELNAME:</span> {{ formatCellValue(row.JOBMODELNAME) }}</p>
@@ -129,5 +121,5 @@ const columns = Object.freeze([
</tbody>
</table>
</div>
</section>
</div>
</template>

View File

@@ -41,9 +41,9 @@ const columns = Object.freeze([
</script>
<template>
<section class="rounded-card border border-stroke-soft bg-white p-3">
<div class="mb-2 flex items-center justify-between gap-2">
<h4 class="text-sm font-semibold text-slate-800">生產紀錄</h4>
<div>
<div class="query-tool-section-header">
<h4 class="card-title">生產紀錄</h4>
<ExportButton
:disabled="exportDisabled"
:loading="exporting"
@@ -52,44 +52,36 @@ const columns = Object.freeze([
/>
</div>
<p v-if="error" class="mb-2 rounded-card border border-state-danger/40 bg-rose-50 px-3 py-2 text-xs text-state-danger">
<p v-if="error" class="error-banner">
{{ error }}
</p>
<div v-if="loading" class="rounded-card border border-dashed border-stroke-soft bg-surface-muted/40 px-3 py-5 text-center text-xs text-slate-500">
<div v-if="loading" class="placeholder">
載入中...
</div>
<div v-else-if="rows.length === 0" class="rounded-card border border-dashed border-stroke-soft bg-surface-muted/40 px-3 py-5 text-center text-xs text-slate-500">
<div v-else-if="rows.length === 0" class="placeholder">
無生產紀錄
</div>
<div v-else class="max-h-[460px] overflow-auto rounded-card border border-stroke-soft">
<table class="min-w-full border-collapse text-xs">
<thead class="sticky top-0 z-10 bg-slate-100 text-slate-700">
<div v-else class="query-tool-table-wrap tall">
<table class="query-tool-table">
<thead>
<tr>
<th
v-for="column in columns"
:key="column"
class="whitespace-nowrap border-b border-stroke-soft px-2 py-1.5 text-left font-semibold"
>
<th v-for="column in columns" :key="column">
{{ column }}
</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, rowIndex) in rows" :key="row.HISTORYMAINLINEID || rowIndex" class="odd:bg-white even:bg-slate-50">
<td
v-for="column in columns"
:key="`${rowIndex}-${column}`"
class="whitespace-nowrap border-b border-stroke-soft/70 px-2 py-1.5 text-slate-700"
>
<tr v-for="(row, rowIndex) in rows" :key="row.HISTORYMAINLINEID || rowIndex">
<td v-for="column in columns" :key="`${rowIndex}-${column}`">
{{ formatCellValue(row[column]) }}
</td>
</tr>
</tbody>
</table>
</div>
</section>
</div>
</template>

View File

@@ -37,9 +37,9 @@ const columns = Object.freeze([
</script>
<template>
<section class="rounded-card border border-stroke-soft bg-white p-3">
<div class="mb-2 flex items-center justify-between gap-2">
<h4 class="text-sm font-semibold text-slate-800">報廢紀錄</h4>
<div>
<div class="query-tool-section-header">
<h4 class="card-title">報廢紀錄</h4>
<ExportButton
:disabled="exportDisabled"
:loading="exporting"
@@ -48,44 +48,36 @@ const columns = Object.freeze([
/>
</div>
<p v-if="error" class="mb-2 rounded-card border border-state-danger/40 bg-rose-50 px-3 py-2 text-xs text-state-danger">
<p v-if="error" class="error-banner">
{{ error }}
</p>
<div v-if="loading" class="rounded-card border border-dashed border-stroke-soft bg-surface-muted/40 px-3 py-5 text-center text-xs text-slate-500">
<div v-if="loading" class="placeholder">
載入中...
</div>
<div v-else-if="rows.length === 0" class="rounded-card border border-dashed border-stroke-soft bg-surface-muted/40 px-3 py-5 text-center text-xs text-slate-500">
<div v-else-if="rows.length === 0" class="placeholder">
無報廢紀錄
</div>
<div v-else class="max-h-[460px] overflow-auto rounded-card border border-stroke-soft">
<table class="min-w-full border-collapse text-xs">
<thead class="sticky top-0 z-10 bg-slate-100 text-slate-700">
<div v-else class="query-tool-table-wrap tall">
<table class="query-tool-table">
<thead>
<tr>
<th
v-for="column in columns"
:key="column"
class="whitespace-nowrap border-b border-stroke-soft px-2 py-1.5 text-left font-semibold"
>
<th v-for="column in columns" :key="column">
{{ column }}
</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, rowIndex) in rows" :key="rowIndex" class="odd:bg-white even:bg-slate-50">
<td
v-for="column in columns"
:key="`${rowIndex}-${column}`"
class="whitespace-nowrap border-b border-stroke-soft/70 px-2 py-1.5 text-slate-700"
>
<tr v-for="(row, rowIndex) in rows" :key="rowIndex">
<td v-for="column in columns" :key="`${rowIndex}-${column}`">
{{ formatCellValue(row[column]) }}
</td>
</tr>
</tbody>
</table>
</div>
</section>
</div>
</template>

View File

@@ -209,9 +209,9 @@ const showEmpty = computed(() => tracks.value.length === 0 || (tracks.value.ever
</script>
<template>
<section class="rounded-card border border-stroke-soft bg-white p-3">
<div class="mb-2 flex flex-wrap items-center justify-between gap-2">
<h4 class="text-sm font-semibold text-slate-800">設備 Timeline</h4>
<div>
<div class="query-tool-section-header">
<h4 class="card-title">設備 Timeline</h4>
<ExportButton
:disabled="exportDisabled"
:loading="exporting"
@@ -220,15 +220,15 @@ const showEmpty = computed(() => tracks.value.length === 0 || (tracks.value.ever
/>
</div>
<p v-if="error" class="mb-2 rounded-card border border-state-danger/40 bg-rose-50 px-3 py-2 text-xs text-state-danger">
<p v-if="error" class="error-banner">
{{ error }}
</p>
<div v-if="loading" class="rounded-card border border-dashed border-stroke-soft bg-surface-muted/40 px-3 py-5 text-center text-xs text-slate-500">
<div v-if="loading" class="placeholder">
Timeline 資料載入中...
</div>
<div v-else-if="showEmpty" class="rounded-card border border-dashed border-stroke-soft bg-surface-muted/40 px-3 py-5 text-center text-xs text-slate-500">
<div v-else-if="showEmpty" class="placeholder">
Timeline 資料
</div>
@@ -242,5 +242,5 @@ const showEmpty = computed(() => tracks.value.length === 0 || (tracks.value.ever
:label-width="220"
:min-chart-width="1200"
/>
</section>
</div>
</template>

View File

@@ -88,82 +88,83 @@ const subTabs = Object.keys(tabMeta);
<template>
<div class="space-y-3">
<section class="rounded-card border border-stroke-soft bg-white p-3 shadow-soft">
<FilterToolbar>
<label class="flex min-w-[320px] flex-col gap-1 text-xs text-slate-500">
<span class="font-medium">設備可複選</span>
<MultiSelect
:model-value="selectedEquipmentIds"
:options="equipmentOptions"
:disabled="loading.bootstrapping"
searchable
placeholder="請選擇設備"
@update:model-value="emit('update:selected-equipment-ids', $event)"
/>
</label>
<section class="card">
<div class="card-body">
<FilterToolbar>
<label class="filter-group" style="min-width: 320px">
<span class="filter-label">設備可複選</span>
<MultiSelect
:model-value="selectedEquipmentIds"
:options="equipmentOptions"
:disabled="loading.bootstrapping"
searchable
placeholder="請選擇設備"
@update:model-value="emit('update:selected-equipment-ids', $event)"
/>
</label>
<label class="flex min-w-[180px] flex-col gap-1 text-xs text-slate-500">
<span class="font-medium">開始日期</span>
<input
type="date"
class="h-9 rounded-card border border-stroke-soft bg-white px-3 text-sm text-slate-700 outline-none focus:border-brand-500"
:value="startDate"
@input="emit('update:start-date', $event.target.value)"
/>
</label>
<label class="filter-group">
<span class="filter-label">開始日期</span>
<input
type="date"
class="query-tool-date-input"
:value="startDate"
@input="emit('update:start-date', $event.target.value)"
/>
</label>
<label class="flex min-w-[180px] flex-col gap-1 text-xs text-slate-500">
<span class="font-medium">結束日期</span>
<input
type="date"
class="h-9 rounded-card border border-stroke-soft bg-white px-3 text-sm text-slate-700 outline-none focus:border-brand-500"
:value="endDate"
@input="emit('update:end-date', $event.target.value)"
/>
</label>
<label class="filter-group">
<span class="filter-label">結束日期</span>
<input
type="date"
class="query-tool-date-input"
:value="endDate"
@input="emit('update:end-date', $event.target.value)"
/>
</label>
<template #actions>
<button
type="button"
class="h-9 rounded-card border border-stroke-soft bg-white px-3 text-xs font-medium text-slate-600 transition hover:bg-slate-50"
@click="emit('reset-date-range')"
>
30
</button>
<template #actions>
<button
type="button"
class="btn btn-ghost"
@click="emit('reset-date-range')"
>
30
</button>
<button
type="button"
class="h-9 rounded-card bg-brand-500 px-4 text-sm font-medium text-white transition hover:bg-brand-600"
:disabled="loading[activeSubTab] || loading.timeline"
@click="emit('query-active-sub-tab')"
>
{{ loading[activeSubTab] || loading.timeline ? '查詢中...' : '查詢' }}
</button>
</template>
</FilterToolbar>
<button
type="button"
class="btn btn-primary"
:disabled="loading[activeSubTab] || loading.timeline"
@click="emit('query-active-sub-tab')"
>
{{ loading[activeSubTab] || loading.timeline ? '查詢中...' : '查詢' }}
</button>
</template>
</FilterToolbar>
<p v-if="errors.filters" class="mt-2 rounded-card border border-state-danger/40 bg-rose-50 px-3 py-2 text-xs text-state-danger">
{{ errors.filters }}
</p>
<p v-if="errors.filters" class="error-banner" style="margin-top: 8px">
{{ errors.filters }}
</p>
</div>
</section>
<section class="rounded-card border border-stroke-soft bg-white p-3 shadow-soft">
<div class="mb-3 flex flex-wrap gap-2 border-b border-stroke-soft pb-2">
<button
v-for="tab in subTabs"
:key="tab"
type="button"
class="rounded-card border px-3 py-1.5 text-xs font-medium transition"
:class="tab === activeSubTab
? 'border-brand-500 bg-brand-50 text-brand-700'
: 'border-transparent bg-surface-muted/70 text-slate-600 hover:border-stroke-soft hover:text-slate-800'"
@click="emit('change-sub-tab', tab)"
>
{{ tabMeta[tab] }}
</button>
</div>
<section class="card">
<div class="card-body">
<div class="query-tool-sub-tab-bar">
<button
v-for="tab in subTabs"
:key="tab"
type="button"
class="query-tool-sub-tab"
:class="{ active: tab === activeSubTab }"
@click="emit('change-sub-tab', tab)"
>
{{ tabMeta[tab] }}
</button>
</div>
<EquipmentLotsTable
<EquipmentLotsTable
v-if="activeSubTab === 'lots'"
:rows="lotsRows"
:loading="loading.lots"
@@ -208,6 +209,7 @@ const subTabs = Object.keys(tabMeta);
:exporting="exporting.timeline"
@export="emit('export-sub-tab', 'timeline')"
/>
</div>
</section>
</div>
</template>

View File

@@ -18,7 +18,7 @@ const props = defineProps({
<template>
<button
type="button"
class="rounded-card bg-emerald-600 px-3 py-1.5 text-xs font-semibold text-white transition hover:bg-emerald-700 disabled:cursor-not-allowed disabled:bg-emerald-300"
class="btn btn-export"
:disabled="disabled || loading"
>
{{ loading ? '匯出中...' : label }}

View File

@@ -707,12 +707,12 @@ function exportRelationCsv() {
</script>
<template>
<section class="rounded-card border border-stroke-soft bg-white p-3 shadow-soft">
<div class="mb-3 flex flex-wrap items-center justify-between gap-2">
<section class="card"><div class="card-body">
<div class="query-tool-section-header">
<div>
<h3 class="text-sm font-semibold text-slate-800">{{ title }}</h3>
<p class="text-xs text-slate-500">{{ description }}</p>
<p class="text-[11px] text-slate-500">
<h3 class="card-title">{{ title }}</h3>
<p class="query-tool-muted">{{ description }}</p>
<p class="query-tool-muted" style="font-size: 11px">
讀圖方向由左至右節點前綴 <code>///</code> 代表本節點由左側來源而來
<code>///</code> 代表左側節點由本節點而來
</p>
@@ -775,20 +775,17 @@ function exportRelationCsv() {
</div>
</div>
</div>
<p v-if="exportErrorMessage" class="mb-2 rounded border border-rose-200 bg-rose-50 px-2 py-1 text-xs text-rose-700">
<p v-if="exportErrorMessage" class="error-banner">
{{ exportErrorMessage }}
</p>
<!-- Loading overlay -->
<div v-if="loading" class="flex items-center justify-center rounded-card border border-dashed border-stroke-soft bg-surface-muted/40 py-16">
<div class="flex flex-col items-center gap-2">
<span class="inline-block size-5 animate-spin rounded-full border-2 border-brand-500 border-t-transparent" />
<span class="text-xs text-slate-500">正在載入血緣資料</span>
</div>
<div v-if="loading" class="placeholder" style="padding: 48px 12px">
正在載入血緣資料
</div>
<!-- Empty state -->
<div v-else-if="!hasData" class="rounded-card border border-dashed border-stroke-soft bg-surface-muted/40 px-3 py-10 text-center text-xs text-slate-500">
<div v-else-if="!hasData" class="placeholder">
{{ emptyMessage }}
</div>
@@ -804,52 +801,51 @@ function exportRelationCsv() {
/>
</div>
<details v-if="relationRows.length > 0" class="mt-3 rounded-card border border-stroke-soft bg-surface-muted/50 px-3 py-2">
<summary class="cursor-pointer text-xs font-medium text-slate-700">
<details v-if="relationRows.length > 0" style="margin-top: 12px; border: 1px solid var(--border); border-radius: 8px; background: #f8fafc; padding: 8px 12px">
<summary style="cursor: pointer; font-size: 12px; font-weight: 600; color: #334155">
關係清單{{ relationRows.length }}
</summary>
<div class="mt-2 max-h-56 overflow-auto rounded border border-stroke-soft bg-white">
<table class="min-w-full text-left text-xs text-slate-700">
<thead class="bg-slate-50 text-[11px] text-slate-500">
<div class="query-tool-table-wrap short" style="margin-top: 8px; max-height: 224px">
<table class="query-tool-table">
<thead>
<tr>
<th class="px-2 py-1.5 font-medium">來源批次</th>
<th class="px-2 py-1.5 font-medium">目標批次</th>
<th class="px-2 py-1.5 font-medium">關係</th>
<th>來源批次</th>
<th>目標批次</th>
<th>關係</th>
</tr>
</thead>
<tbody>
<tr
v-for="row in relationRows.slice(0, 200)"
:key="row.key"
class="border-t border-slate-100"
>
<td class="px-2 py-1.5 font-mono text-[11px]">
<td style="font-family: monospace; font-size: 11px">
{{ row.fromName }}
</td>
<td class="px-2 py-1.5 font-mono text-[11px]">
<td style="font-family: monospace; font-size: 11px">
{{ row.toName }}
</td>
<td class="px-2 py-1.5 text-[11px]">
<td style="font-size: 11px">
{{ row.edgeLabel }}
</td>
</tr>
</tbody>
</table>
</div>
<p v-if="relationRows.length > 200" class="mt-1 text-[11px] text-slate-500">
<p v-if="relationRows.length > 200" class="query-tool-muted" style="margin-top: 4px; font-size: 11px">
僅顯示前 200 請搭配上方樹圖與節點點選進一步縮小範圍
</p>
</details>
<!-- Not found warning -->
<div v-if="notFound.length > 0" class="mt-3 rounded-card border border-state-warning/40 bg-amber-50 px-3 py-2 text-xs text-amber-700">
<div v-if="notFound.length > 0" class="query-tool-success" style="border-color: #fde68a; background: #fefce8; color: #92400e">
未命中{{ notFound.join(', ') }}
</div>
<!-- Selection summary -->
<div v-if="selectedContainerIds.length > 0" class="mt-3 rounded-card border border-brand-200 bg-brand-50/60 px-3 py-2">
<div v-if="selectedContainerIds.length > 0" class="query-tool-success" style="border-color: #c7d2fe; background: rgba(238,242,255,0.6); color: inherit">
<div class="flex flex-wrap items-center gap-1.5">
<span class="mr-1 text-xs font-medium text-brand-700">已選 {{ selectedContainerIds.length }} 個節點</span>
<span class="mr-1 text-xs font-medium" style="color: #4338ca">已選 {{ selectedContainerIds.length }} 個節點</span>
<span
v-for="cid in selectedContainerIds.slice(0, 8)"
:key="cid"
@@ -860,7 +856,7 @@ function exportRelationCsv() {
<span v-if="selectedContainerIds.length > 8" class="text-xs text-brand-600">+{{ selectedContainerIds.length - 8 }} 更多</span>
</div>
</div>
</section>
</div></section>
</template>
<style scoped>

View File

@@ -65,41 +65,33 @@ function resolveColumnLabel(column) {
</script>
<template>
<section class="rounded-card border border-stroke-soft bg-white p-3">
<div v-if="loading" class="rounded-card border border-dashed border-stroke-soft bg-surface-muted/40 px-3 py-5 text-center text-xs text-slate-500">
<div>
<div v-if="loading" class="placeholder">
讀取中...
</div>
<div v-else-if="rows.length === 0" class="rounded-card border border-dashed border-stroke-soft bg-surface-muted/40 px-3 py-5 text-center text-xs text-slate-500">
<div v-else-if="rows.length === 0" class="placeholder">
{{ emptyText }}
</div>
<div v-else class="max-h-[420px] overflow-auto rounded-card border border-stroke-soft">
<table class="min-w-full border-collapse text-xs">
<thead class="sticky top-0 z-10 bg-slate-100 text-slate-700">
<div v-else class="query-tool-table-wrap">
<table class="query-tool-table">
<thead>
<tr>
<th
v-for="column in columns"
:key="column"
class="whitespace-nowrap border-b border-stroke-soft px-2 py-1.5 text-left font-semibold"
>
<th v-for="column in columns" :key="column">
{{ resolveColumnLabel(column) }}
</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, rowIndex) in rows" :key="row.id || row.JOBID || rowIndex" class="odd:bg-white even:bg-slate-50">
<td
v-for="column in columns"
:key="`${rowIndex}-${column}`"
class="whitespace-nowrap border-b border-stroke-soft/70 px-2 py-1.5 text-slate-700"
>
<tr v-for="(row, rowIndex) in rows" :key="row.id || row.JOBID || rowIndex">
<td v-for="column in columns" :key="`${rowIndex}-${column}`">
{{ formatCellValue(row[column]) }}
</td>
</tr>
</tbody>
</table>
</div>
</section>
</div>
</template>

View File

@@ -193,46 +193,45 @@ const detailCountLabel = computed(() => {
</script>
<template>
<section class="rounded-card border border-stroke-soft bg-white p-3 shadow-soft">
<div v-if="!selectedContainerId" class="rounded-card border border-dashed border-stroke-soft bg-surface-muted/40 px-3 py-8 text-center text-xs text-slate-500">
請從上方血緣樹選擇節點後查看明細
</div>
<section class="card">
<div class="card-body">
<div v-if="!selectedContainerId" class="placeholder">
請從上方血緣樹選擇節點後查看明細
</div>
<template v-else>
<div class="mb-3 flex flex-wrap items-center justify-between gap-2">
<div>
<h3 class="text-sm font-semibold text-slate-800">LOT 明細{{ detailCountLabel }}</h3>
<p class="text-xs text-slate-500">{{ detailDisplayNames }}</p>
<template v-else>
<div class="query-tool-section-header">
<div>
<div class="card-title">LOT 明細{{ detailCountLabel }}</div>
<span class="query-tool-muted">{{ detailDisplayNames }}</span>
</div>
<ExportButton
:disabled="!canExport"
:loading="activeExporting"
:label="`${tabMeta[activeSubTab]?.label || ''} 匯出 CSV`"
@click="emit('export-tab', activeSubTab)"
/>
</div>
<ExportButton
:disabled="!canExport"
:loading="activeExporting"
:label="`${tabMeta[activeSubTab]?.label || ''} 匯出 CSV`"
@click="emit('export-tab', activeSubTab)"
/>
</div>
<div class="query-tool-sub-tab-bar">
<button
v-for="tab in subTabs"
:key="tab"
type="button"
class="query-tool-sub-tab"
:class="{ active: tab === activeSubTab }"
@click="emit('change-sub-tab', tab)"
>
{{ tabMeta[tab].label }}
</button>
</div>
<div class="mb-3 flex flex-wrap gap-2 border-b border-stroke-soft pb-2">
<button
v-for="tab in subTabs"
:key="tab"
type="button"
class="rounded-card border px-3 py-1.5 text-xs font-medium transition"
:class="tab === activeSubTab
? 'border-brand-500 bg-brand-50 text-brand-700'
: 'border-transparent bg-surface-muted/70 text-slate-600 hover:border-stroke-soft hover:text-slate-800'"
@click="emit('change-sub-tab', tab)"
>
{{ tabMeta[tab].label }}
</button>
</div>
<p v-if="activeError" class="error-banner">
{{ activeError }}
</p>
<p v-if="activeError" class="mb-2 rounded-card border border-state-danger/40 bg-rose-50 px-3 py-2 text-xs text-state-danger">
{{ activeError }}
</p>
<div v-if="activeSubTab === 'history'" class="space-y-3">
<div v-if="activeSubTab === 'history'" class="space-y-3">
<LotTimeline
:history-rows="historyRows"
:hold-rows="associationRows.holds || []"
@@ -271,6 +270,7 @@ const detailCountLabel = computed(() => {
:loading="activeLoading"
:empty-text="activeLoaded ? activeEmptyText : '尚未查詢此分頁資料'"
/>
</template>
</template>
</div>
</section>
</template>

View File

@@ -43,12 +43,12 @@ const workcenterOptions = computed(() => {
</script>
<template>
<section class="rounded-card border border-stroke-soft bg-white p-3">
<div class="mb-2 flex flex-wrap items-end justify-between gap-2">
<h4 class="text-sm font-semibold text-slate-800">歷程資料</h4>
<div>
<div class="query-tool-section-header">
<h4 class="card-title">歷程資料</h4>
<label class="min-w-[260px] text-xs text-slate-500">
<span class="mb-1 block font-medium">站點群組篩選</span>
<label class="filter-group" style="min-width: 260px">
<span class="filter-label">站點群組篩選</span>
<MultiSelect
:model-value="selectedWorkcenterGroups"
:options="workcenterOptions"
@@ -60,23 +60,19 @@ const workcenterOptions = computed(() => {
</label>
</div>
<div v-if="loading" class="rounded-card border border-dashed border-stroke-soft bg-surface-muted/40 px-3 py-5 text-center text-xs text-slate-500">
<div v-if="loading" class="placeholder">
歷程資料讀取中...
</div>
<div v-else-if="rows.length === 0" class="rounded-card border border-dashed border-stroke-soft bg-surface-muted/40 px-3 py-5 text-center text-xs text-slate-500">
<div v-else-if="rows.length === 0" class="placeholder">
無歷程資料
</div>
<div v-else class="max-h-[360px] overflow-auto rounded-card border border-stroke-soft">
<table class="min-w-full border-collapse text-xs">
<thead class="sticky top-0 z-10 bg-slate-100 text-slate-700">
<div v-else class="query-tool-table-wrap short">
<table class="query-tool-table">
<thead>
<tr>
<th
v-for="column in columns"
:key="column"
class="whitespace-nowrap border-b border-stroke-soft px-2 py-1.5 text-left font-semibold"
>
<th v-for="column in columns" :key="column">
{{ column }}
</th>
</tr>
@@ -86,18 +82,13 @@ const workcenterOptions = computed(() => {
<tr
v-for="(row, rowIndex) in rows"
:key="row.HISTORYMAINLINEID || row.TRACKINTIMESTAMP || rowIndex"
class="odd:bg-white even:bg-slate-50"
>
<td
v-for="column in columns"
:key="`${rowIndex}-${column}`"
class="whitespace-nowrap border-b border-stroke-soft/70 px-2 py-1.5 text-slate-700"
>
<td v-for="column in columns" :key="`${rowIndex}-${column}`">
{{ formatCellValue(row[column]) }}
</td>
</tr>
</tbody>
</table>
</div>
</section>
</div>
</template>

View File

@@ -174,46 +174,39 @@ async function loadTxn(jobId) {
<template>
<section class="space-y-3">
<div class="rounded-card border border-stroke-soft bg-white p-3">
<div v-if="loading" class="rounded-card border border-dashed border-stroke-soft bg-surface-muted/40 px-3 py-5 text-center text-xs text-slate-500">
<div>
<div v-if="loading" class="placeholder">
讀取中...
</div>
<div v-else-if="sortedRows.length === 0" class="rounded-card border border-dashed border-stroke-soft bg-surface-muted/40 px-3 py-5 text-center text-xs text-slate-500">
<div v-else-if="sortedRows.length === 0" class="placeholder">
{{ emptyText }}
</div>
<div v-else class="max-h-[420px] overflow-auto rounded-card border border-stroke-soft">
<table class="min-w-full border-collapse text-xs">
<thead class="sticky top-0 z-10 bg-slate-100 text-slate-700">
<div v-else class="query-tool-table-wrap">
<table class="query-tool-table">
<thead>
<tr>
<th class="whitespace-nowrap border-b border-stroke-soft px-2 py-1.5 text-left font-semibold">操作</th>
<th
v-for="column in jobColumns"
:key="column"
class="whitespace-nowrap border-b border-stroke-soft px-2 py-1.5 text-left font-semibold"
>
<th>操作</th>
<th v-for="column in jobColumns" :key="column">
{{ column }}
</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, rowIndex) in sortedRows" :key="rowKey(row, rowIndex)" class="odd:bg-white even:bg-slate-50">
<td class="border-b border-stroke-soft/70 px-2 py-1.5">
<tr v-for="(row, rowIndex) in sortedRows" :key="rowKey(row, rowIndex)">
<td>
<button
type="button"
class="rounded-card border border-stroke-soft bg-white px-2 py-1 text-[11px] font-medium text-slate-600 transition hover:bg-surface-muted/70 hover:text-slate-800"
class="btn btn-ghost"
style="padding: 2px 8px; font-size: 11px"
@click="loadTxn(row?.JOBID)"
>
查看交易歷程
</button>
</td>
<td
v-for="column in jobColumns"
:key="`${rowKey(row, rowIndex)}-${column}`"
class="whitespace-nowrap border-b border-stroke-soft/70 px-2 py-1.5 text-slate-700"
>
<td v-for="column in jobColumns" :key="`${rowKey(row, rowIndex)}-${column}`">
<StatusBadge
v-if="column === 'JOBSTATUS'"
:tone="buildStatusTone(row?.[column])"
@@ -227,33 +220,29 @@ async function loadTxn(jobId) {
</div>
</div>
<div v-if="selectedJobId" class="rounded-card border border-stroke-soft bg-white p-3">
<div class="mb-2 flex flex-wrap items-center justify-between gap-2">
<h4 class="text-sm font-semibold text-slate-800">交易歷程{{ selectedJobId }}</h4>
<span class="text-xs text-slate-500">{{ txnRows.length }} </span>
<div v-if="selectedJobId">
<div class="query-tool-section-header">
<h4 class="card-title">交易歷程{{ selectedJobId }}</h4>
<span class="query-tool-muted">{{ txnRows.length }} </span>
</div>
<p v-if="txnError" class="mb-2 rounded-card border border-state-danger/40 bg-rose-50 px-3 py-2 text-xs text-state-danger">
<p v-if="txnError" class="error-banner">
{{ txnError }}
</p>
<div v-if="loadingTxn" class="rounded-card border border-dashed border-stroke-soft bg-surface-muted/40 px-3 py-5 text-center text-xs text-slate-500">
<div v-if="loadingTxn" class="placeholder">
載入交易歷程中...
</div>
<div v-else-if="txnRows.length === 0" class="rounded-card border border-dashed border-stroke-soft bg-surface-muted/40 px-3 py-5 text-center text-xs text-slate-500">
<div v-else-if="txnRows.length === 0" class="placeholder">
無交易歷程資料
</div>
<div v-else class="max-h-[420px] overflow-auto rounded-card border border-stroke-soft">
<table class="min-w-full border-collapse text-xs">
<thead class="sticky top-0 z-10 bg-slate-100 text-slate-700">
<div v-else class="query-tool-table-wrap">
<table class="query-tool-table">
<thead>
<tr>
<th
v-for="column in txnColumns"
:key="column"
class="whitespace-nowrap border-b border-stroke-soft px-2 py-1.5 text-left font-semibold"
>
<th v-for="column in txnColumns" :key="column">
{{ column }}
</th>
</tr>
@@ -263,13 +252,8 @@ async function loadTxn(jobId) {
<tr
v-for="(row, rowIndex) in txnRows"
:key="row?.JOBTXNHISTORYID || `${selectedJobId}-${rowIndex}`"
class="odd:bg-white even:bg-slate-50"
>
<td
v-for="column in txnColumns"
:key="`${row?.JOBTXNHISTORYID || rowIndex}-${column}`"
class="whitespace-nowrap border-b border-stroke-soft/70 px-2 py-1.5 text-slate-700"
>
<td v-for="column in txnColumns" :key="`${row?.JOBTXNHISTORYID || rowIndex}-${column}`">
<StatusBadge
v-if="column === 'JOBSTATUS' || column === 'FROMJOBSTATUS'"
:tone="buildStatusTone(row?.[column])"

View File

@@ -96,43 +96,43 @@ const sortedRows = computed(() => {
</script>
<template>
<section class="rounded-card border border-stroke-soft bg-white p-3">
<div v-if="loading" class="rounded-card border border-dashed border-stroke-soft bg-surface-muted/40 px-3 py-5 text-center text-xs text-slate-500">
<div>
<div v-if="loading" class="placeholder">
讀取中...
</div>
<div v-else-if="sortedRows.length === 0" class="rounded-card border border-dashed border-stroke-soft bg-surface-muted/40 px-3 py-5 text-center text-xs text-slate-500">
<div v-else-if="sortedRows.length === 0" class="placeholder">
{{ emptyText }}
</div>
<div v-else class="max-h-[420px] overflow-auto rounded-card border border-stroke-soft">
<table class="min-w-full border-collapse text-xs">
<thead class="sticky top-0 z-10 bg-slate-100 text-slate-700">
<div v-else class="query-tool-table-wrap">
<table class="query-tool-table">
<thead>
<tr>
<th class="whitespace-nowrap border-b border-stroke-soft px-2 py-1.5 text-left font-semibold">LOT</th>
<th class="whitespace-nowrap border-b border-stroke-soft px-2 py-1.5 text-left font-semibold">WORKCENTER</th>
<th class="whitespace-nowrap border-b border-stroke-soft px-2 py-1.5 text-left font-semibold">Package</th>
<th class="whitespace-nowrap border-b border-stroke-soft px-2 py-1.5 text-left font-semibold">FUNCTION</th>
<th class="whitespace-nowrap border-b border-stroke-soft px-2 py-1.5 text-left font-semibold">TYPE</th>
<th class="whitespace-nowrap border-b border-stroke-soft px-2 py-1.5 text-left font-semibold">PRODUCT</th>
<th class="whitespace-nowrap border-b border-stroke-soft px-2 py-1.5 text-left font-semibold">原因</th>
<th class="whitespace-nowrap border-b border-stroke-soft px-2 py-1.5 text-left font-semibold">EQUIPMENT</th>
<th class="whitespace-nowrap border-b border-stroke-soft px-2 py-1.5 text-left font-semibold">COMMENT</th>
<th>LOT</th>
<th>WORKCENTER</th>
<th>Package</th>
<th>FUNCTION</th>
<th>TYPE</th>
<th>PRODUCT</th>
<th>原因</th>
<th>EQUIPMENT</th>
<th>COMMENT</th>
<th
class="cursor-pointer whitespace-nowrap border-b border-stroke-soft px-2 py-1.5 text-left font-semibold hover:text-brand-700"
style="cursor: pointer"
@click="showRejectBreakdown = !showRejectBreakdown"
>
扣帳報廢量 <span>{{ showRejectBreakdown ? '▾' : '▸' }}</span>
</th>
<template v-if="showRejectBreakdown">
<th class="whitespace-nowrap border-b border-stroke-soft px-2 py-1.5 text-left font-semibold">REJECT</th>
<th class="whitespace-nowrap border-b border-stroke-soft px-2 py-1.5 text-left font-semibold">STANDBY</th>
<th class="whitespace-nowrap border-b border-stroke-soft px-2 py-1.5 text-left font-semibold">QTYTOPROCESS</th>
<th class="whitespace-nowrap border-b border-stroke-soft px-2 py-1.5 text-left font-semibold">INPROCESS</th>
<th class="whitespace-nowrap border-b border-stroke-soft px-2 py-1.5 text-left font-semibold">PROCESSED</th>
<th>REJECT</th>
<th>STANDBY</th>
<th>QTYTOPROCESS</th>
<th>INPROCESS</th>
<th>PROCESSED</th>
</template>
<th class="whitespace-nowrap border-b border-stroke-soft px-2 py-1.5 text-left font-semibold">不扣帳報廢量</th>
<th class="whitespace-nowrap border-b border-stroke-soft px-2 py-1.5 text-left font-semibold">報廢時間</th>
<th>不扣帳報廢量</th>
<th>報廢時間</th>
</tr>
</thead>
@@ -140,30 +140,29 @@ const sortedRows = computed(() => {
<tr
v-for="(row, idx) in sortedRows"
:key="`${row.TXN_TIME_RAW}-${row.CONTAINERNAME}-${row.LOSSREASONNAME}-${idx}`"
class="odd:bg-white even:bg-slate-50"
>
<td class="whitespace-nowrap border-b border-stroke-soft/70 px-2 py-1.5 text-slate-700">{{ row.CONTAINERNAME }}</td>
<td class="whitespace-nowrap border-b border-stroke-soft/70 px-2 py-1.5 text-slate-700">{{ row.WORKCENTERNAME || '-' }}</td>
<td class="whitespace-nowrap border-b border-stroke-soft/70 px-2 py-1.5 text-slate-700">{{ row.PRODUCTLINENAME || '-' }}</td>
<td class="whitespace-nowrap border-b border-stroke-soft/70 px-2 py-1.5 text-slate-700">{{ row.PJ_FUNCTION || '-' }}</td>
<td class="whitespace-nowrap border-b border-stroke-soft/70 px-2 py-1.5 text-slate-700">{{ row.PJ_TYPE || '-' }}</td>
<td class="whitespace-nowrap border-b border-stroke-soft/70 px-2 py-1.5 text-slate-700">{{ row.PRODUCTNAME || '-' }}</td>
<td class="whitespace-nowrap border-b border-stroke-soft/70 px-2 py-1.5 text-slate-700">{{ row.LOSSREASONNAME || '-' }}</td>
<td class="whitespace-nowrap border-b border-stroke-soft/70 px-2 py-1.5 text-slate-700">{{ row.EQUIPMENTNAME || '-' }}</td>
<td class="whitespace-nowrap border-b border-stroke-soft/70 px-2 py-1.5 text-slate-700">{{ row.REJECTCOMMENT || '-' }}</td>
<td class="whitespace-nowrap border-b border-stroke-soft/70 px-2 py-1.5 text-slate-700">{{ formatNumber(row.REJECT_TOTAL_QTY) }}</td>
<td>{{ row.CONTAINERNAME }}</td>
<td>{{ row.WORKCENTERNAME || '-' }}</td>
<td>{{ row.PRODUCTLINENAME || '-' }}</td>
<td>{{ row.PJ_FUNCTION || '-' }}</td>
<td>{{ row.PJ_TYPE || '-' }}</td>
<td>{{ row.PRODUCTNAME || '-' }}</td>
<td>{{ row.LOSSREASONNAME || '-' }}</td>
<td>{{ row.EQUIPMENTNAME || '-' }}</td>
<td>{{ row.REJECTCOMMENT || '-' }}</td>
<td>{{ formatNumber(row.REJECT_TOTAL_QTY) }}</td>
<template v-if="showRejectBreakdown">
<td class="whitespace-nowrap border-b border-stroke-soft/70 px-2 py-1.5 text-slate-700">{{ formatNumber(row.REJECT_QTY) }}</td>
<td class="whitespace-nowrap border-b border-stroke-soft/70 px-2 py-1.5 text-slate-700">{{ formatNumber(row.STANDBY_QTY) }}</td>
<td class="whitespace-nowrap border-b border-stroke-soft/70 px-2 py-1.5 text-slate-700">{{ formatNumber(row.QTYTOPROCESS_QTY) }}</td>
<td class="whitespace-nowrap border-b border-stroke-soft/70 px-2 py-1.5 text-slate-700">{{ formatNumber(row.INPROCESS_QTY) }}</td>
<td class="whitespace-nowrap border-b border-stroke-soft/70 px-2 py-1.5 text-slate-700">{{ formatNumber(row.PROCESSED_QTY) }}</td>
<td>{{ formatNumber(row.REJECT_QTY) }}</td>
<td>{{ formatNumber(row.STANDBY_QTY) }}</td>
<td>{{ formatNumber(row.QTYTOPROCESS_QTY) }}</td>
<td>{{ formatNumber(row.INPROCESS_QTY) }}</td>
<td>{{ formatNumber(row.PROCESSED_QTY) }}</td>
</template>
<td class="whitespace-nowrap border-b border-stroke-soft/70 px-2 py-1.5 text-slate-700">{{ formatNumber(row.DEFECT_QTY) }}</td>
<td class="whitespace-nowrap border-b border-stroke-soft/70 px-2 py-1.5 text-slate-700">{{ row.TXN_TIME || '-' }}</td>
<td>{{ formatNumber(row.DEFECT_QTY) }}</td>
<td>{{ row.TXN_TIME || '-' }}</td>
</tr>
</tbody>
</table>
</div>
</section>
</div>
</template>

View File

@@ -325,10 +325,10 @@ const timeRange = computed(() => {
</script>
<template>
<section class="rounded-card border border-stroke-soft bg-white p-3">
<div class="mb-2 flex flex-wrap items-center justify-between gap-2">
<h4 class="text-sm font-semibold text-slate-800">LOT 生產 Timeline</h4>
<div class="flex items-center gap-3 text-xs text-slate-500">
<div>
<div class="query-tool-section-header">
<h4 class="card-title">LOT 生產 Timeline</h4>
<div class="flex items-center gap-3 query-tool-muted">
<span v-if="timeRange">{{ formatDateTime(timeRange.start) }} {{ formatDateTime(timeRange.end) }}</span>
<span>Hold / Material 事件已覆蓋標記</span>
<span v-if="materialMappingStats.total > 0">
@@ -340,11 +340,11 @@ const timeRange = computed(() => {
</div>
</div>
<div v-if="tracks.length === 0" class="rounded-card border border-dashed border-stroke-soft bg-surface-muted/40 px-3 py-5 text-center text-xs text-slate-500">
<div v-if="tracks.length === 0" class="placeholder">
歷程資料不足無法產生 Timeline
</div>
<div v-else class="max-h-[520px] overflow-y-auto">
<div v-else style="max-height: 520px; overflow-y: auto">
<TimelineChart
:tracks="tracks"
:events="events"
@@ -355,5 +355,5 @@ const timeRange = computed(() => {
:min-chart-width="600"
/>
</div>
</section>
</div>
</template>

View File

@@ -147,7 +147,7 @@ const emit = defineEmits([
@resolve="emit('resolve')"
/>
<p v-if="resolveSuccessMessage" class="rounded-card border border-state-success/40 bg-emerald-50 px-3 py-2 text-xs text-emerald-700">
<p v-if="resolveSuccessMessage" class="query-tool-success">
{{ resolveSuccessMessage }}
</p>

View File

@@ -51,53 +51,55 @@ function handleResolve() {
</script>
<template>
<section class="rounded-card border border-stroke-soft bg-white p-3 shadow-soft">
<FilterToolbar>
<label class="flex min-w-[220px] flex-col gap-1 text-xs text-slate-500">
<span class="font-medium">查詢類型</span>
<select
class="h-9 rounded-card border border-stroke-soft bg-white px-3 text-sm text-slate-700 outline-none focus:border-brand-500"
:value="inputType"
:disabled="resolving"
@change="emit('update:inputType', $event.target.value)"
>
<option
v-for="option in inputTypeOptions"
:key="option.value"
:value="option.value"
<section class="card">
<div class="card-body">
<FilterToolbar>
<label class="filter-group">
<span class="filter-label">查詢類型</span>
<select
class="query-tool-select"
:value="inputType"
:disabled="resolving"
@change="emit('update:inputType', $event.target.value)"
>
{{ option.label }}
</option>
</select>
</label>
<option
v-for="option in inputTypeOptions"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</option>
</select>
</label>
<template #actions>
<button
type="button"
class="h-9 rounded-card bg-brand-500 px-4 text-sm font-medium text-white transition hover:bg-brand-600 disabled:cursor-not-allowed disabled:opacity-60"
<template #actions>
<button
type="button"
class="btn btn-primary"
:disabled="resolving"
@click="handleResolve"
>
{{ resolving ? '解析中...' : '解析' }}
</button>
</template>
</FilterToolbar>
<div style="margin-top: 12px">
<textarea
:value="inputText"
class="query-tool-textarea"
:placeholder="`請輸入 ${inputTypeLabel}(換行或逗號分隔),最多 ${inputLimit} 筆`"
:disabled="resolving"
@click="handleResolve"
>
{{ resolving ? '解析中...' : '解析' }}
</button>
</template>
</FilterToolbar>
<div class="mt-3">
<textarea
:value="inputText"
class="min-h-28 w-full rounded-card border border-stroke-soft bg-surface-muted/40 px-3 py-2 text-sm text-slate-700 outline-none transition focus:border-brand-500"
:placeholder="`請輸入 ${inputTypeLabel}(換行或逗號分隔),最多 ${inputLimit} 筆`"
:disabled="resolving"
@input="emit('update:inputText', $event.target.value)"
/>
<p class="mt-2 text-xs text-slate-500">
支援萬用字元<code>%</code>任意長度<code>_</code>單一字元也可用 <code>*</code> 代表 <code>%</code>
例如<code>GA25%01</code><code>GA25%</code><code>GMSN-1173%</code>
</p>
<div class="mt-2 flex items-center justify-between text-xs">
<p class="text-slate-500">已輸入 {{ inputCount }} / {{ inputLimit }}</p>
<p v-if="errorMessage" class="text-state-danger">{{ errorMessage }}</p>
@input="emit('update:inputText', $event.target.value)"
/>
<p class="query-tool-input-help">
支援萬用字元<code>%</code>任意長度<code>_</code>單一字元也可用 <code>*</code> 代表 <code>%</code>
例如<code>GA25%01</code><code>GA25%</code><code>GMSN-1173%</code>
</p>
<div class="query-tool-input-counter">
<span>已輸入 {{ inputCount }} / {{ inputLimit }}</span>
<span v-if="errorMessage" class="query-tool-input-error">{{ errorMessage }}</span>
</div>
</div>
</div>
</section>

View File

@@ -147,7 +147,7 @@ const emit = defineEmits([
@resolve="emit('resolve')"
/>
<p v-if="resolveSuccessMessage" class="rounded-card border border-state-success/40 bg-emerald-50 px-3 py-2 text-xs text-emerald-700">
<p v-if="resolveSuccessMessage" class="query-tool-success">
{{ resolveSuccessMessage }}
</p>

View File

@@ -1,6 +1,8 @@
import { createApp } from 'vue';
import '../wip-shared/styles.css';
import '../styles/tailwind.css';
import './style.css';
import App from './App.vue';
createApp(App).mount('#app');

View File

@@ -0,0 +1,481 @@
/* ================================================================
query-tool/style.css — 自包含頁面樣式
包含 wip-shared 基礎變數與元件 + 頁面專屬樣式
================================================================ */
/* ---- 設計 Token (與 wip-shared/styles.css 同步) ---- */
:root {
--bg: #f5f7fa;
--card-bg: #ffffff;
--text: #222;
--muted: #666;
--border: #e2e6ef;
--primary: #667eea;
--primary-dark: #5568d3;
--shadow: 0 2px 10px rgba(0, 0, 0, 0.08);
--shadow-strong: 0 4px 15px rgba(102, 126, 234, 0.2);
--success: #22c55e;
--danger: #ef4444;
--warning: #f59e0b;
}
/* ---- 全域重置 ---- */
*,
*::before,
*::after {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Microsoft JhengHei', Arial, sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
}
/* ---- 頁面容器 ---- */
.dashboard {
max-width: 1900px;
margin: 0 auto;
padding: 20px;
}
.query-tool-page {
max-width: 1900px;
margin: 0 auto;
padding: 20px;
}
/* ---- Header (wip-shared) ---- */
.header {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 12px;
padding: 18px 22px;
background: var(--header-gradient, linear-gradient(135deg, #667eea 0%, #764ba2 100%));
border-radius: 10px;
margin-bottom: 16px;
box-shadow: var(--shadow-strong);
}
.header-left {
display: flex;
align-items: center;
gap: 16px;
flex-wrap: wrap;
}
.header h1 {
color: #fff;
font-size: 24px;
}
.query-tool-header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.query-tool-subtitle {
margin-top: 4px;
font-size: 13px;
color: rgba(255, 255, 255, 0.75);
}
/* ---- 按鈕 (wip-shared) ---- */
.btn {
padding: 9px 20px;
border: none;
border-radius: 8px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
}
.btn-primary {
padding: 9px 20px;
border: none;
border-radius: 8px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
background: var(--primary);
color: #fff;
transition: all 0.2s ease;
}
.btn-primary:hover {
background: var(--primary-dark);
}
.btn-primary:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.btn-export {
background: #0f766e;
color: #fff;
}
.btn-export:hover {
background: #0b5e59;
}
.btn-export:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.btn-ghost {
background: #f1f5f9;
color: #334155;
border: 1px solid var(--border);
}
.btn-ghost:hover {
background: #e2e8f0;
}
/* ---- 佔位 / 空狀態 (wip-shared) ---- */
.placeholder {
text-align: center;
padding: 40px 20px;
color: var(--muted);
}
/* ---- Card 基礎 ---- */
.card {
background: var(--card-bg);
border-radius: 10px;
box-shadow: var(--shadow);
overflow: visible;
margin-bottom: 14px;
}
.card-header {
padding: 14px 18px;
border-bottom: 1px solid var(--border);
background: #f8fafc;
border-radius: 10px 10px 0 0;
}
.card-title {
font-size: 15px;
font-weight: 700;
color: #0f172a;
}
.card-body {
padding: 14px 16px;
}
/* ---- Error 訊息 ---- */
.error-banner {
margin-bottom: 14px;
padding: 10px 12px;
border-radius: 6px;
background: #fef2f2;
color: #991b1b;
font-size: 13px;
}
/* ---- Tab 導航 (頂層) ---- */
.query-tool-tab-bar {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.query-tool-tab {
padding: 8px 16px;
border: 1px solid transparent;
border-radius: 8px;
background: #f8fafc;
font-size: 14px;
font-weight: 600;
color: #475569;
cursor: pointer;
transition: all 0.15s ease;
}
.query-tool-tab:hover:not(.active) {
border-color: var(--border);
color: #1e293b;
}
.query-tool-tab.active {
border-color: var(--primary);
background: #eef2ff;
color: #4c51bf;
box-shadow: var(--shadow);
}
/* ---- Tab 說明區塊 ---- */
.query-tool-tab-desc {
border: 1px solid var(--border);
border-radius: 8px;
background: #f8fafc;
padding: 12px 16px;
margin-bottom: 14px;
}
.query-tool-tab-desc-label {
font-size: 12px;
font-weight: 600;
color: var(--muted);
letter-spacing: 0.025em;
}
.query-tool-tab-desc-title {
margin-top: 4px;
font-size: 16px;
font-weight: 700;
color: #0f172a;
}
.query-tool-tab-desc-subtitle {
margin-top: 4px;
font-size: 14px;
color: #475569;
}
/* ---- Sub-tab 導航 (Detail / Equipment) ---- */
.query-tool-sub-tab-bar {
display: flex;
flex-wrap: wrap;
gap: 8px;
border-bottom: 1px solid var(--border);
padding-bottom: 8px;
margin-bottom: 12px;
}
.query-tool-sub-tab {
padding: 6px 12px;
border: 1px solid transparent;
border-radius: 8px;
background: #f1f5f9;
font-size: 12px;
font-weight: 600;
color: #475569;
cursor: pointer;
transition: all 0.15s ease;
}
.query-tool-sub-tab:hover:not(.active) {
border-color: var(--border);
color: #1e293b;
}
.query-tool-sub-tab.active {
border-color: var(--primary);
background: #eef2ff;
color: #4c51bf;
}
/* ---- 表單控制元素 ---- */
.query-tool-select {
width: 100%;
height: 34px;
padding: 6px 10px;
border: 1px solid var(--border);
border-radius: 8px;
background: #fff;
font-size: 13px;
color: #334155;
outline: none;
}
.query-tool-select:focus {
border-color: var(--primary);
box-shadow: 0 0 0 2px rgba(102, 126, 234, 0.15);
}
.query-tool-select:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.query-tool-textarea {
width: 100%;
min-height: 112px;
padding: 8px 12px;
border: 1px solid var(--border);
border-radius: 8px;
background: #f8fafc;
font-size: 13px;
color: #334155;
outline: none;
resize: vertical;
transition: border-color 0.15s ease;
}
.query-tool-textarea:focus {
border-color: var(--primary);
box-shadow: 0 0 0 2px rgba(102, 126, 234, 0.15);
}
.query-tool-textarea:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.query-tool-date-input {
width: 100%;
height: 34px;
padding: 6px 10px;
border: 1px solid var(--border);
border-radius: 8px;
background: #fff;
font-size: 13px;
color: #334155;
outline: none;
}
.query-tool-date-input:focus {
border-color: var(--primary);
box-shadow: 0 0 0 2px rgba(102, 126, 234, 0.15);
}
.query-tool-input-help {
margin-top: 8px;
font-size: 12px;
color: var(--muted);
}
.query-tool-input-help code {
padding: 1px 4px;
border-radius: 3px;
background: #e2e8f0;
font-size: 11px;
}
.query-tool-input-counter {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 8px;
font-size: 12px;
color: var(--muted);
}
.query-tool-input-error {
color: var(--danger);
}
/* ---- Filter label ---- */
.filter-group {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 180px;
}
.filter-label {
font-size: 12px;
font-weight: 700;
color: #475569;
}
/* ---- 區段標頭 (標題 + 動作列) ---- */
.query-tool-section-header {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 12px;
margin-bottom: 12px;
}
.query-tool-muted {
font-size: 12px;
color: var(--muted);
}
/* ---- 表格 ---- */
.query-tool-table-wrap {
overflow: auto;
max-height: 420px;
border: 1px solid var(--border);
border-radius: 8px;
}
.query-tool-table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
}
.query-tool-table th,
.query-tool-table td {
padding: 8px 10px;
border-bottom: 1px solid #f1f5f9;
white-space: nowrap;
text-align: left;
vertical-align: middle;
}
.query-tool-table thead th {
position: sticky;
top: 0;
z-index: 1;
background: #f8fafc;
font-weight: 700;
color: #334155;
}
.query-tool-table tbody tr:hover {
background: #f8fbff;
}
.query-tool-table tbody tr:nth-child(even) {
background: #fafbfd;
}
/* ---- 訊息 ---- */
.query-tool-success {
margin-bottom: 14px;
padding: 10px 12px;
border-radius: 6px;
border: 1px solid #bbf7d0;
background: #f0fdf4;
color: #166534;
font-size: 13px;
}
/* ---- 表格高度變體 ---- */
.query-tool-table-wrap.short {
max-height: 360px;
}
.query-tool-table-wrap.tall {
max-height: 460px;
}
/* ---- 響應式 ---- */
@media (max-width: 768px) {
.dashboard,
.query-tool-page {
padding: 16px;
}
.header {
padding: 14px 16px;
}
.header h1 {
font-size: 20px;
}
.query-tool-tab-bar {
gap: 6px;
}
.query-tool-tab {
padding: 6px 12px;
font-size: 13px;
}
}

View File

@@ -25,7 +25,7 @@ test('all route contracts satisfy governance metadata requirements', () => {
});
test('admin shell targets are governed and rendered as external targets', () => {
test('admin shell targets are governed with correct render modes', () => {
const pagesContract = getRouteContract('/admin/pages');
const perfContract = getRouteContract('/admin/performance');
@@ -34,7 +34,7 @@ test('admin shell targets are governed and rendered as external targets', () =>
assert.equal(pagesContract.visibilityPolicy, 'admin_only');
assert.equal(perfContract.visibilityPolicy, 'admin_only');
assert.equal(pagesContract.renderMode, 'external');
assert.equal(perfContract.renderMode, 'external');
assert.equal(perfContract.renderMode, 'native');
});