[TOC] # DAY24(2025/07/07)Vue+Highchart ## 進度日誌 - 結合UI庫(element) ## 筆記區📘 Vue3 ### 結合UI庫(element) App.vue ```vue <template> <el-button @click="showFilters = !showFilters" type="primary" plain size="small" class="mb-2" style="border-radius: 14px;"> <span v-if="showFilters">收合篩選</span> <span v-else>展開篩選</span> </el-button> <transition name="fade"> <div v-if="showFilters" class="filters-box"> <div v-for="grp in filterGroups" :key="grp.label" class="filter-group"> <label>{{ grp.label }}</label> <el-checkbox-group v-model="pendingFilters[grp.key]" size="large"> <el-checkbox v-for="opt in grp.options" :value="opt" :key="opt" class="custom-checkbox">{{ opt }}</el-checkbox> </el-checkbox-group> </div> <div class="filter-btn-row"> <el-button type="primary" plain @click="applyFilters" size="large">篩選</el-button> <el-button type="info" plain @click="resetFilters" size="large">重置</el-button> </div> <div class="filter-tags"> <template v-for="(arr, type) in pendingFilters" :key="type"> <el-tag v-for="val in arr" :key="type + '-' + val" closable size="large" @close="removeTag(type, val)" :type="filters[type].includes(val) ? 'success' : 'info'" effect="light" class="filter-tag" @click="() => handleTagClick(type, val)">{{ val }}</el-tag> </template> </div> </div> </transition> <!-- ====== 這裡是四格圖表 ====== --> <div class="dash-grid"> <GenderAgeBar /> <CityPieChart /> <TendencyBar /> <PowerHistogram /> </div> </template> <script setup> import { ref, provide } from 'vue' import { filters, pendingFilters } from './filters' import GenderAgeBar from './components/GenderAgeBar.vue' import CityPieChart from './components/CityPieChart.vue' import TendencyBar from './components/TendencyBar.vue' import PowerHistogram from './components/PowerHistogram.vue' const showFilters = ref(true) provide('filters', filters) provide('pendingFilters', pendingFilters) const filterGroups = [ { key: 'gender', label: '性別:', options: ['男', '女', '未填寫'] }, { key: 'age', label: '年齡:', options: ['<=20', '21~30', '31~40', '41~50', '51~60', '>=61', '未填寫'] }, { key: 'city', label: '註冊地:', options: ['台北市', '新北市', '基隆市', '宜蘭縣', '新竹市', '新竹縣', '桃園市', '苗栗縣', '台中市', '嘉義市', '雲林縣', '台南市', '高雄市', '未填寫'] }, { key: 'tendency', label: '消費傾向:', options: ['歲末酬賓必來', '母親節必來', '年中慶必來', '周年慶必來', '價格敏感客群'] }, { key: 'power', label: '消費力:', options: ['0~1K', '1K~2K', '2K~5K', '5K~10K', '10K~50K', '50K~100K', '100K~1M', '1000K~100M+'] }, ] // === 必須加這些 function === function applyFilters() { Object.keys(filters).forEach(k => { filters[k].splice(0) filters[k].push(...pendingFilters[k]) }) } function resetFilters() { Object.keys(filters).forEach(k => filters[k].splice(0)) Object.keys(pendingFilters).forEach(k => pendingFilters[k].splice(0)) } function removeTag(type, val) { const arr = pendingFilters[type] const idx = arr.indexOf(val) if (idx >= 0) arr.splice(idx, 1) } function handleTagClick(type, val) { const arr = pendingFilters[type] const idx = arr.indexOf(val) if (idx >= 0) arr.splice(idx, 1) else arr.push(val) } </script> <style> /* ...保持你之前那段 style 即可 ... */ .dash-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 24px; margin-top: 36px; } @media (max-width: 900px) { .dash-grid { grid-template-columns: 1fr; gap: 12px; } } </style> ``` main.js ```js import { createApp } from 'vue' import App from './App.vue' import HighchartsVue from 'highcharts-vue' import ElementPlus from 'element-plus' import 'element-plus/dist/index.css' const app = createApp(App) app.use(HighchartsVue) app.use(ElementPlus) app.mount('#app') ``` GenderAgeBar.vue ```vue <template> <div> <h2>性別與年齡分布</h2> <highcharts :options="chartOptions" /> </div> </template> <script setup> import { ref, watch, inject, onMounted } from 'vue' import { mockData } from '../mockData' const filters = inject('filters') const pendingFilters = inject('pendingFilters') const categories = ['<=20','21~30','31~40','41~50','51~60','>=61','未填寫'] const genders = ['男','女','未填寫'] const chartOptions = ref({ chart: { type: 'bar', height: 400 }, title: { text: null }, xAxis: { categories, title: { text: '年齡' } }, yAxis: { min: 0, title: { text: '人數', align: 'high' } }, legend: { reversed: true }, plotOptions: { series: { stacking: 'normal', cursor: 'pointer', allowPointSelect: true, point: { events: { click: function () { // 健壯性保護 if (!pendingFilters || !pendingFilters.gender || !pendingFilters.age) return const gender = this.series.name const age = this.category // 性別多選 const gidx = pendingFilters.gender.indexOf(gender) if (gidx === -1) pendingFilters.gender.push(gender) else pendingFilters.gender.splice(gidx, 1) // 年齡多選 const aidx = pendingFilters.age.indexOf(age) if (aidx === -1) pendingFilters.age.push(age) else pendingFilters.age.splice(aidx, 1) // (進階可加 applyFilters() 立即套用) } } } } }, series: [], credits: { enabled: false } }) function updateChart() { const filtered = mockData.filter(item => (filters.gender.length === 0 || filters.gender.includes(item.gender)) && (filters.age.length === 0 || filters.age.includes(item.age)) && (filters.city.length === 0 || filters.city.includes(item.city)) && (filters.tendency.length === 0 || filters.tendency.includes(item.tendency)) && (filters.power.length === 0 || filters.power.includes(item.power)) ) chartOptions.value.series = genders .filter(g => filters.gender.length === 0 || filters.gender.includes(g)) .map(gender => ({ name: gender, data: categories .filter(a => filters.age.length === 0 || filters.age.includes(a)) .map(age => filtered.filter(d => d.gender === gender && d.age === age).length ) })) chartOptions.value.xAxis.categories = categories.filter(a => filters.age.length === 0 || filters.age.includes(a)) } onMounted(updateChart) watch( () => [ filters.gender.slice(), filters.age.slice(), filters.city.slice(), filters.tendency.slice(), filters.power.slice() ], updateChart, { deep: true } ) </script> ``` CityPieChart.vue ```vue <template> <div> <h2>註冊地分布</h2> <highcharts :options="chartOptions" /> </div> </template> <script setup> import { ref, watch, inject, onMounted } from 'vue' import { mockData } from '../mockData' const filters = inject('filters') const pendingFilters = inject('pendingFilters') const cities = [ '台北市','新北市','基隆市','宜蘭縣','新竹市','新竹縣','桃園市','苗栗縣','台中市', '嘉義市','雲林縣','台南市','高雄市','未填寫' ] const chartOptions = ref({ chart: { type: 'pie', height: 320 }, title: { text: null }, plotOptions: { pie: { allowPointSelect: true, cursor: 'pointer', dataLabels: { enabled: true, format: '{point.name}: {point.y}' }, point: { events: { click: function () { if (!pendingFilters || !pendingFilters.city) return const city = this.name const idx = pendingFilters.city.indexOf(city) if (idx === -1) pendingFilters.city.push(city) else pendingFilters.city.splice(idx, 1) } } } } }, series: [], credits: { enabled: false } }) function updateChart() { const filtered = mockData.filter(item => (filters.gender.length === 0 || filters.gender.includes(item.gender)) && (filters.age.length === 0 || filters.age.includes(item.age)) && (filters.city.length === 0 || filters.city.includes(item.city)) && (filters.tendency.length === 0 || filters.tendency.includes(item.tendency)) && (filters.power.length === 0 || filters.power.includes(item.power)) ) chartOptions.value.series = [{ name: '人數', data: cities .filter(c => filters.city.length === 0 || filters.city.includes(c)) .map(city => ({ name: city, y: filtered.filter(d => d.city === city).length })) }] } onMounted(updateChart) watch( () => [ filters.gender.slice(), filters.age.slice(), filters.city.slice(), filters.tendency.slice(), filters.power.slice() ], updateChart, { deep: true } ) </script> ``` TendencyBar.vue ```vue <template> <div> <h2>消費傾向分布</h2> <highcharts :options="chartOptions" /> </div> </template> <script setup> import { ref, watch, inject, onMounted } from 'vue' import { mockData } from '../mockData' const filters = inject('filters') const pendingFilters = inject('pendingFilters') const tendencies = [ '歲末酬賓必來','母親節必來','年中慶必來','周年慶必來','價格敏感客群' ] const chartOptions = ref({ chart: { type: 'column', height: 320 }, title: { text: null }, xAxis: { categories: tendencies, title: { text: '消費傾向' } }, yAxis: { min: 0, title: { text: '人數', align: 'high' } }, plotOptions: { series: { cursor: 'pointer', point: { events: { click: function () { if (!pendingFilters || !pendingFilters.tendency) return const tendency = this.category const idx = pendingFilters.tendency.indexOf(tendency) if (idx === -1) pendingFilters.tendency.push(tendency) else pendingFilters.tendency.splice(idx, 1) } } } } }, series: [], credits: { enabled: false } }) function updateChart() { const filtered = mockData.filter(item => (filters.gender.length === 0 || filters.gender.includes(item.gender)) && (filters.age.length === 0 || filters.age.includes(item.age)) && (filters.city.length === 0 || filters.city.includes(item.city)) && (filters.tendency.length === 0 || filters.tendency.includes(item.tendency)) && (filters.power.length === 0 || filters.power.includes(item.power)) ) chartOptions.value.series = [{ name: '人數', data: tendencies .filter(t => filters.tendency.length === 0 || filters.tendency.includes(t)) .map(tendency => filtered.filter(d => d.tendency === tendency).length ) }] chartOptions.value.xAxis.categories = tendencies.filter(t => filters.tendency.length === 0 || filters.tendency.includes(t)) } onMounted(updateChart) watch( () => [ filters.gender.slice(), filters.age.slice(), filters.city.slice(), filters.tendency.slice(), filters.power.slice() ], updateChart, { deep: true } ) </script> ``` PowerHistogram.vue ```vue <template> <div> <h2>消費力分布</h2> <highcharts :options="chartOptions" /> </div> </template> <script setup> import { ref, watch, inject, onMounted } from 'vue' import { mockData } from '../mockData' const filters = inject('filters') const pendingFilters = inject('pendingFilters') const powers = [ '0~1K','1K~2K','2K~5K','5K~10K','10K~50K','50K~100K','100K~1M','1000K~100M+' ] const chartOptions = ref({ chart: { type: 'column', height: 320 }, title: { text: null }, xAxis: { categories: powers, title: { text: '消費力' } }, yAxis: { min: 0, title: { text: '人數', align: 'high' } }, plotOptions: { series: { cursor: 'pointer', point: { events: { click: function () { if (!pendingFilters || !pendingFilters.power) return const power = this.category const idx = pendingFilters.power.indexOf(power) if (idx === -1) pendingFilters.power.push(power) else pendingFilters.power.splice(idx, 1) } } } } }, series: [], credits: { enabled: false } }) function updateChart() { const filtered = mockData.filter(item => (filters.gender.length === 0 || filters.gender.includes(item.gender)) && (filters.age.length === 0 || filters.age.includes(item.age)) && (filters.city.length === 0 || filters.city.includes(item.city)) && (filters.tendency.length === 0 || filters.tendency.includes(item.tendency)) && (filters.power.length === 0 || filters.power.includes(item.power)) ) chartOptions.value.series = [{ name: '人數', data: powers .filter(p => filters.power.length === 0 || filters.power.includes(p)) .map(power => filtered.filter(d => d.power === power).length ) }] chartOptions.value.xAxis.categories = powers.filter(p => filters.power.length === 0 || filters.power.includes(p)) } onMounted(updateChart) watch( () => [ filters.gender.slice(), filters.age.slice(), filters.city.slice(), filters.tendency.slice(), filters.power.slice() ], updateChart, { deep: true } ) </script> ``` --- 結果呈現 ![image](https://hackmd.io/_uploads/BylItstSxx.png)