Alpine.js 3.x 和 Chart.js 集成错误:“未捕获的 TypeError:无法读取 null 的属性(读取“保存”)”

问题描述 投票:0回答:1

我正在开发一个 Laravel 项目,其中使用 Alpine.js 进行交互,使用 Chart.js 进行数据可视化。我的设置在 Alpine.js 2.8.2 上运行良好,但是当我升级到 Alpine.js 3.x 时,遇到以下错误:

chart.js:19 Uncaught TypeError: Cannot read properties of null (reading 'save')
    at Ie (chart.js:19:18331)
    at An._drawDataset (chart.js:19:98188)
    at An._drawDatasets (chart.js:19:97819)
    at An.draw (chart.js:19:97350)
    at chart.js:13:6948
    at Map.forEach (<anonymous>)
    at xt._update (chart.js:13:6727)
    at chart.js:13:6620

相关代码:

这是我的 Blade 模板与 JavaScript 初始化的相关部分:

@extends('admin.layouts.app')

@section('vendor-styles')
    <link rel="stylesheet" href="{{ asset('plugins/flatpickr/flatpickr.min.css') }}">
@endsection

@section('breadcrumbs')
    <li>
        <a href="#" class="text-gray-500 hover:text-gray-700">Dashboard</a>
    </li>
@endsection

@section('content')
    <div x-data="dashboard()" x-init="init()">
        <!-- Time Range Filter -->
        <div class="bg-white p-4 rounded shadow mb-4">
            <div class="flex space-x-4 mb-4">
                <button @click="timeRange = 'today'; customRange = false"
                        :class="{ 'bg-blue-600 text-white': timeRange === 'today', 'bg-gray-200 text-gray-800': timeRange !== 'today' }"
                        class="p-2 rounded">Hôm nay
                </button>
                <button @click="timeRange = 'thisWeek'; customRange = false"
                        :class="{ 'bg-blue-600 text-white': timeRange === 'thisWeek', 'bg-gray-200 text-gray-800': timeRange !== 'thisWeek' }"
                        class="p-2 rounded">Tuần này
                </button>
                <button @click="timeRange = 'thisMonth'; customRange = false"
                        :class="{ 'bg-blue-600 text-white': timeRange === 'thisMonth', 'bg-gray-200 text-gray-800': timeRange !== 'thisMonth' }"
                        class="p-2 rounded">Tháng này
                </button>
                <button @click="timeRange = 'thisYear'; customRange = false"
                        :class="{ 'bg-blue-600 text-white': timeRange === 'thisYear', 'bg-gray-200 text-gray-800': timeRange !== 'thisYear' }"
                        class="p-2 rounded">Năm này
                </button>
                <button @click="timeRange = 'custom'; customRange = true"
                        :class="{ 'bg-blue-600 text-white': timeRange === 'custom', 'bg-gray-200 text-gray-800': timeRange !== 'custom' }"
                        class="p-2 rounded">Khoảng thời gian
                </button>
            </div>
            <div x-show="customRange" class="flex space-x-4">
                <input x-ref="datepicker" class="p-2 bg-gray-200 rounded" placeholder="Chọn khoảng thời gian">
            </div>
        </div>

        <!-- Sales Overview -->
        <div class="bg-white p-4 rounded shadow mb-4">
            <h2 class="text-xl font-semibold mb-2">Tổng quan</h2>
            <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
                <div class="p-6 bg-green-100 rounded-lg shadow-md flex items-center">
                    <div class="flex-1">
                        <div class="text-lg font-medium text-green-800 flex items-center">
                            <i class="fas fa-dollar-sign mr-2"></i>
                            Tổng doanh thu
                        </div>
                        <div class="text-3xl font-bold text-green-900" x-text="totalSales.revenue"></div>
                    </div>
                    {{--                    <div class="text-green-600 flex items-center">--}}
                    {{--                        <i class="fas fa-arrow-up text-2xl"></i>--}}
                    {{--                        <span class="ml-1 text-xl font-semibold">15%</span>--}}
                    {{--                    </div>--}}
                </div>

                <div class="p-6 bg-blue-100 rounded-lg shadow-md flex items-center">
                    <div class="flex-1">
                        <div class="text-lg font-medium text-green-800 flex items-center">
                            <i class="fas fa-dollar-sign mr-2"></i>
                            Tổng chi phí
                        </div>
                        <div class="text-3xl font-bold text-green-900" x-text="totalSales.cost"></div>
                    </div>
                    {{--                    <div class="text-green-600 flex items-center">--}}
                    {{--                        <i class="fas fa-arrow-up text-2xl"></i>--}}
                    {{--                        <span class="ml-1 text-xl font-semibold">15%</span>--}}
                    {{--                    </div>--}}
                </div>

                <div class="p-6 bg-yellow-100 rounded-lg shadow-md flex items-center">
                    <div class="flex-1">
                        <div class="text-lg font-medium text-green-800 flex items-center">
                            <i class="fas fa-dollar-sign mr-2"></i>
                            Tổng lợi nhuận
                        </div>
                        <div class="text-3xl font-bold text-green-900" x-text="totalSales.profit"></div>
                    </div>
                    {{--                    <div class="text-green-600 flex items-center">--}}
                    {{--                        <i class="fas fa-arrow-up text-2xl"></i>--}}
                    {{--                        <span class="ml-1 text-xl font-semibold">15%</span>--}}
                    {{--                    </div>--}}
                </div>
            </div>
        </div>

        <!-- Sales Chart -->
        <div class="bg-white p-4 rounded shadow mb-4">
            <h2 class="text-xl font-semibold mb-2">Biểu Đồ Kinh Doanh</h2>
            <canvas id="salesChart" width="400" height="200"></canvas>
        </div>

        <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
            <!-- Sales by Category Chart -->
            <div class="bg-white p-4 rounded shadow mb-4">
                <h2 class="text-xl font-semibold mb-2 text-center">Doanh Thu Theo Danh Mục Sản Phẩm</h2>
                <canvas id="categoryChart" width="400" height="200"></canvas>
            </div>

            <!-- Sales by Brand Chart -->
            <div class="bg-white p-4 rounded shadow mb-4">
                <h2 class="text-xl font-semibold mb-2 text-center">Doanh Thu Theo Hãng Sản Xuất</h2>
                <canvas id="brandChart" width="400" height="200"></canvas>
            </div>
        </div>

        <!-- Recent Orders -->
        <div class="bg-white p-4 rounded shadow mb-4">
            <h2 class="text-xl font-semibold mb-2">Đơn Hàng Gần Đây</h2>
            <table class="w-full text-left border-collapse">
                <thead>
                <tr>
                    <th class="p-2 border-b">Mã Đơn Hàng</th>
                    <th class="p-2 border-b">Khách Hàng</th>
                    <th class="p-2 border-b">Trạng Thái</th>
                    <th class="p-2 border-b">Ngày Đặt</th>
                    <th class="p-2 border-b">Tổng tiền</th>
                </tr>
                </thead>
                <tbody>
                <template x-for="order in recentOrders" :key="order.id">
                    <tr>
                        <td class="p-2 border-b">
                            <a :href="order?.url" x-text="order?.order_code" class="text-blue-600"></a>
                        </td>
                        <td class="p-2 border-b" x-text="order?.customer.name"></td>
                        <td class="p-2 border-b" x-text="order?.status"></td>
                        <td class="p-2 border-b" x-text="order?.formatted_created_at"></td>
                        <td class="p-2 border-b" x-text="order?.amount"></td>
                    </tr>
                </template>
                </tbody>
            </table>
        </div>

        <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
            <!-- Top Selling Products -->
            <div class="bg-white p-4 rounded shadow mb-4">
                <h2 class="text-xl font-semibold mb-2">Top Sản Phẩm Bán Chạy</h2>
                <ul>
                    <template x-for="product in topProducts" :key="product.id">
                        <li class="border-b py-2">
                            <span x-text="product.name"></span> - <span x-text="product.total_sales"></span>
                        </li>
                    </template>
                </ul>
            </div>

            <!-- Inventory -->
            <div class="bg-white p-4 rounded shadow mb-4">
                <h2 class="text-xl font-semibold mb-2">Sản Phẩm Sắp Hết Hàng</h2>
                <table class="w-full text-left border-collapse">
                    <thead>
                    <tr>
                        <th class="p-2 border-b">Tên Sản Phẩm</th>
                        <th class="p-2 border-b">Màu Sắc</th>
                        <th class="p-2 border-b">Số Lượng Còn Lại</th>
                    </tr>
                    </thead>
                    <tbody>
                    <template x-for="product in lowStockProducts" :key="product.id">
                        <tr>
                            <td class="p-2 border-b">
                                <a :href="product?.url" x-text="product?.product_name" class="text-blue-600"></a>
                            </td>
                            <td class="p-2 border-b" x-text="product?.color"></td>
                            <td class="p-2 border-b" x-text="product?.quantity"></td>
                        </tr>
                    </template>
                    </tbody>
                </table>
            </div>
        </div>

        <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
            <div class="bg-white p-4 rounded shadow mb-4">
                <h2 class="text-xl font-semibold mb-2">Trạng thái đơn hàng</h2>
                <table class="w-full text-left border-collapse">
                    <thead>
                    <tr>
                        <th class="p-2 border-b">Trạng Thái</th>
                        <th class="p-2 border-b">Số Đơn</th>
                        <th class="p-2 border-b">Tổng Doanh Thu</th>
                    </tr>
                    </thead>
                    <tbody>
                    <template x-for="(groupStatus, index) in orderByStatus" :key="index">
                        <tr>
                            <td class="p-2 border-b" x-text="groupStatus?.status"></td>
                            <td class="p-2 border-b" x-text="groupStatus?.count"></td>
                            <td class="p-2 border-b" x-text="groupStatus?.revenue"></td>
                        </tr>
                    </template>
                    </tbody>
                </table>
            </div>
        </div>
    </div>
@endsection

@section('vendor-scripts')
    <script src="{{ asset('plugins/chartjs/chart.js') }}"></script>
    <script src="{{ asset('plugins/flatpickr/flatpickr.min.js') }}"></script>
    <script src="{{ asset('plugins/flatpickr/lang/vn.js') }}"></script>
@endsection

@section('custom-scripts')
    <script>
        function formatTooltip(tooltipItem) {
            let value = tooltipItem.raw;
            let formattedValue = new Intl.NumberFormat('vi-VN', {
                style: 'currency',
                currency: 'VND'
            }).format(value);
            return `${tooltipItem.dataset.label}: ${formattedValue}`;
        }

        function dashboard() {
            return {
                customRange: false,
                startDate: '',
                endDate: '',
                selectedDate: '',
                totalSales: {
                    today: '',
                    week: '',
                    month: '',
                    year: '',
                },
                recentOrders: [],
                topProducts: [],
                lowStockProducts: [],
                salesChartData: {
                    labels: [],
                    revenue: [],
                    cost: [],
                    profit: []
                },
                categoryChartData: {
                    labels: [],
                    data: []
                },
                brandChartData: {
                    labels: [],
                    data: []
                },
                salesChart: null,
                categoryChart: null,
                brandChart: null,
                timeRange: 'today',
                timeRangePicker: null,
                orderByStatus: [],
                init() {
                    this.fetchDashboardData();
                    this.initFlatpickr();
                    this.$watch('timeRange', (value) => {
                        if (value !== 'custom') {
                            this.fetchDashboardData();
                        }
                    });
                    this.$watch('startDate', () => {
                        if (this.customRange && this.startDate && this.endDate) {
                            this.fetchDashboardData();
                        }
                    });
                    this.$watch('endDate', () => {
                        if (this.customRange && this.startDate && this.endDate) {
                            this.fetchDashboardData();
                        }
                    });
                    this.$watch('customRange', (value) => {
                        if (!value) {
                            this.startDate = '';
                            this.endDate = '';
                            this.timeRangePicker?.clear();
                        }
                    });
                },
                fetchDashboardData() {
                    const url = `{{ route('admin.dashboardData') }}?timeRange=${this.timeRange}&startDate=${this.startDate}&endDate=${this.endDate}`;
                    fetch(url, {headers: {'Accept': 'application/json'}})
                        .then(response => response.json())
                        .then(data => {
                            this.totalSales.revenue = `${data.totalSales.revenue}`;
                            this.totalSales.cost = `${data.totalSales.cost}`;
                            this.totalSales.profit = `${data.totalSales.profit}`;

                            this.recentOrders = data.recentOrders;
                            this.topProducts = data.topProducts;
                            this.lowStockProducts = data.lowStockProducts;
                            this.orderByStatus = data.orderByStatus;

                            this.salesChartData.labels = data.salesChartData.labels;
                            this.salesChartData.revenue = data.salesChartData.revenue;
                            this.salesChartData.cost = data.salesChartData.cost;
                            this.salesChartData.profit = data.salesChartData.profit;

                            this.categoryChartData.labels = data.salesByCategory.labels;
                            this.categoryChartData.data = data.salesByCategory.data;

                            this.brandChartData.labels = data.salesByBrand.labels;
                            this.brandChartData.data = data.salesByBrand.data;

                            this.updateSalesChart();
                            this.updateCategoryChart();
                            this.updateBrandChart();
                        });
                },
                updateSalesChart() {
                    const ctx = document.getElementById('salesChart').getContext('2d');

                    if (this.salesChart) {
                        this.salesChart.destroy();
                    }

                    this.salesChart = new Chart(ctx, {
                        type: 'bar',
                        data: {
                            labels: this.salesChartData.labels,
                            datasets: [
                                {
                                    label: 'Doanh thu',
                                    data: this.salesChartData.revenue,
                                    backgroundColor: 'rgba(75, 192, 192, 0.2)',
                                    borderColor: 'rgba(75, 192, 192, 1)',
                                    borderWidth: 1
                                },
                                {
                                    label: 'Chi phí',
                                    data: this.salesChartData.cost,
                                    backgroundColor: 'rgba(255, 99, 132, 0.2)',
                                    borderColor: 'rgba(255, 99, 132, 1)',
                                    borderWidth: 1
                                },
                                {
                                    label: 'Lợi nhuận',
                                    data: this.salesChartData.profit,
                                    backgroundColor: 'rgba(54, 162, 235, 0.2)',
                                    borderColor: 'rgba(54, 162, 235, 1)',
                                    borderWidth: 1,
                                    type: 'line'
                                }
                            ]
                        },
                        options: {
                            scales: {
                                y: {
                                    beginAtZero: true
                                }
                            },
                            plugins: {
                                tooltip: {
                                    callbacks: {
                                        label: formatTooltip
                                    }
                                }
                            },
                        }
                    });
                },
                updateCategoryChart() {
                    const ctx = document.getElementById('categoryChart').getContext('2d');

                    if (this.categoryChart) {
                        this.categoryChart.destroy();
                    }

                    this.categoryChart = new Chart(ctx, {
                        type: 'pie',
                        data: {
                            labels: this.categoryChartData.labels,
                            datasets: [
                                {
                                    label: 'Doanh thu',
                                    data: this.categoryChartData.data,
                                    backgroundColor: this.categoryChartData.labels.map(() => `rgba(${Math.floor(Math.random() * 255)}, ${Math.floor(Math.random() * 255)}, ${Math.floor(Math.random() * 255)}, 0.2)`),
                                    borderColor: this.categoryChartData.labels.map(() => `rgba(${Math.floor(Math.random() * 255)}, ${Math.floor(Math.random() * 255)}, ${Math.floor(Math.random() * 255)}, 1)`),
                                    borderWidth: 1
                                }
                            ]
                        },
                        options: {
                            plugins: {
                                tooltip: {
                                    callbacks: {
                                        label: formatTooltip
                                    }
                                }
                            },
                        }
                    });
                },
                updateBrandChart() {
                    const ctx = document.getElementById('brandChart').getContext('2d');

                    if (this.brandChart) {
                        this.brandChart.destroy();
                    }

                    this.brandChart = new Chart(ctx, {
                        type: 'pie',
                        data: {
                            labels: this.brandChartData.labels,
                            datasets: [
                                {
                                    label: 'Doanh thu',
                                    data: this.brandChartData.data,
                                    backgroundColor: this.brandChartData.labels.map(() => `rgba(${Math.floor(Math.random() * 255)}, ${Math.floor(Math.random() * 255)}, ${Math.floor(Math.random() * 255)}, 0.2)`),
                                    borderColor: this.brandChartData.labels.map(() => `rgba(${Math.floor(Math.random() * 255)}, ${Math.floor(Math.random() * 255)}, ${Math.floor(Math.random() * 255)}, 1)`),
                                    borderWidth: 1
                                }
                            ]
                        },
                        options: {
                            plugins: {
                                tooltip: {
                                    callbacks: {
                                        label: formatTooltip
                                    }
                                }
                            },
                        }
                    });
                },
                initFlatpickr() {
                    this.timeRangePicker = flatpickr(this.$refs.datepicker, {
                        onChange: ([startDate, endDate]) => {
                            if (startDate && endDate) {
                                this.startDate = flatpickr.formatDate(startDate, 'Y-m-d');
                                this.endDate = flatpickr.formatDate(endDate, 'Y-m-d');
                            }
                        },
                        locale: "vn",
                        mode: "range",
                        altInput: true,
                        conjunction: " - ",
                        maxDate: "today",
                        altFormat: "d/m/Y",
                        dateFormat: "Y-m-d",
                    });
                },
            }
        }
    </script>
@endsection

这是admin.layouts.app

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="Content-Security-Policy" content="upgrade-insecure-requests">
    <title>Admin Dashboard - Product Management</title>
    {{--    <link rel="stylesheet" href="{{ asset('AdminLTE/dist/css/AdminLTE.min.css') }}">--}}
    <script src="{{ asset('plugins/tailwindcss/tailwindcss.min.css') }}"></script>
    <link rel="stylesheet" href="{{ asset('plugins/fontawesome/css/all.min.css') }}"/>
    <script src="{{ asset('plugins/alpinejs/alpine.min.js') }}" defer></script>

    @yield('vendor-styles')

    @yield('custom-styles')
</head>
<body class="bg-gray-100">

<!-- Sidebar -->
@include('admin.layouts.includes.sidebar')

<!-- Main Content -->
<div class="flex-1 flex flex-col">
    <!-- Navbar -->
    @include('admin.layouts.includes.header')

    <!-- Breadcrumbs -->
    <nav class="bg-gray border-b border-gray-200">
        <div class="max-w-7xl mx-auto py-3 px-4 sm:px-6 lg:px-8">
            <ol class="flex items-center space-x-4">
                @yield('breadcrumbs')
            </ol>
        </div>
    </nav>

    <!-- Main Section -->
    <main class="flex-1 bg-gray-100">
        <div class="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
            @yield('content')
        </div>
    </main>

    <!-- Footer -->
    @include('admin.layouts.includes.footer')
</div>
<script src="{{ asset('plugins/htmx/htmx.js') }}"></script>

@yield('vendor-scripts')

@yield('custom-scripts')
</body>
</html>

问题:

图表在 Alpine.js 2.8.2 中渲染正确,但在 Alpine.js 3.x 中渲染失败,显示上述错误。Alpine.js 3.x 可能导致此问题的原因是什么,如何解决?

环境:

  • Laravel 版本:9.x
  • Alpine.js 版本:3.14.1
  • Chart.js 版本:4.4.3

任何见解或解决方案将不胜感激。

javascript laravel charts alpine.js
1个回答
0
投票

在 AlpineJs v3 中,数据对象的 init() 方法(如果存在)会在启动时自动执行。
在你的 main

中指定 x-init="init()" (可能是因为代码是为 AlpineJs v2 设计的,不支持此功能),因此 init() 方法被执行两次,并且这个导致错误。
您可以在 init() 中添加 console.log 来验证这种情况:

init() {
   console.log ("init executed", Date.now());

   .....

要解决该问题,请从主中删除

x-init =“init()”

© www.soinside.com 2019 - 2024. All rights reserved.