4 Commity 0527a8150f ... bc0b424880

Autor SHA1 Wiadomość Data
  ylong bc0b424880 Merge branch 'feature-sell' of http://47.104.88.16:10880/shuhi/shuhi-ui into feature-sell 1 tydzień temu
  ylong 32b7a6d9c2 feat(refund): update refund statistics display and fetch logic 1 tydzień temu
  ylong 097a7b559a feat(customer-detail): add red packet tab and component for customer details 1 tydzień temu
  ylong 61c6970854 feat(statsAnalysis): update search functionality and API integration 1 tydzień temu

+ 1 - 1
.env.development

@@ -1,3 +1,3 @@
 # 开发环境接口地址
-VITE_API_URL=https://bpi.shuhi.com
+VITE_API_URL=https://bk.shuhi.com
 #VITE_API_URL=http://localhost:8080

+ 49 - 0
src/api/statsAnalysis/search.js

@@ -0,0 +1,49 @@
+import request from '@/utils/request';
+
+/**
+ * 搜索使用次数汇总 + 趋势曲线
+ */
+export async function getSearchSummary(params) {
+    const res = await request.get('/shop/shopSearch/stat/summary', { params });
+    if (res.data.code === 200) {
+        return res.data.data || {};
+    }
+    return Promise.reject(new Error(res.data.msg || '获取搜索汇总失败'));
+}
+
+/**
+ * 搜索词明细列表
+ */
+export async function getSearchWordPage(params) {
+    const res = await request.get('/shop/shopSearch/stat/wordPage', { params });
+    if (res.data.code === 200) {
+        return res.data;
+    }
+    return Promise.reject(new Error(res.data.msg || '获取搜索词列表失败'));
+}
+
+/**
+ * 搜索词近 7 天趋势
+ */
+export async function getSearchWordTrend(params) {
+    const res = await request.get('/shop/shopSearch/stat/wordTrend', {
+        params
+    });
+    if (res.data.code === 200) {
+        return res.data.data || [];
+    }
+    return Promise.reject(new Error(res.data.msg || '获取搜索词趋势失败'));
+}
+
+/**
+ * 搜索词引导商品效果
+ */
+export async function getSearchProductEffect(params) {
+    const res = await request.get('/shop/shopSearch/stat/productEffect', {
+        params
+    });
+    if (res.data.code === 200) {
+        return res.data;
+    }
+    return Promise.reject(new Error(res.data.msg || '获取商品效果失败'));
+}

+ 4 - 0
src/views/customer/list/components/customer-detail.vue

@@ -57,6 +57,9 @@
             <el-tab-pane label="搜索记录" name="searchLog">
                 <search-log :row="currentRow" />
             </el-tab-pane>
+            <el-tab-pane label="红包" name="redPacket">
+                <red-packet :row="currentRow" />
+            </el-tab-pane>
         </el-tabs>
     </ele-drawer>
 </template>
@@ -66,6 +69,7 @@
     import accountChange from '@/views/customer/list/components/detail/account-change.vue';
     import scanLog from '@/views/customer/list/components/detail/scan-log.vue';
     import searchLog from '@/views/customer/list/components/detail/search-log.vue';
+    import redPacket from '@/views/customer/list/components/detail/red-packet.vue';
     import recommendInfo from '@/views/customer/list/components/detail/recommend-info.vue';
     import fallbackInfo from '@/views/customer/list/components/detail/fallback-info.vue';
     import request from '@/utils/request';

+ 147 - 0
src/views/customer/list/components/detail/red-packet.vue

@@ -0,0 +1,147 @@
+<template>
+    <div>
+        <common-table
+            ref="pageRef"
+            :pageConfig="pageConfig"
+            :columns="columns"
+            :tools="false"
+            :bodyStyle="{ padding: 0 }"
+        >
+            <template #orderNo="{ row }">
+                <el-button
+                    v-if="orderNoOf(row)"
+                    type="primary"
+                    link
+                    @click="openOrder(row)"
+                >
+                    {{ orderNoOf(row) }}
+                </el-button>
+            </template>
+        </common-table>
+        <order-detail ref="orderDetailRef" />
+    </div>
+</template>
+
+<script setup>
+    import { ref, reactive, watch } from 'vue';
+    import CommonTable from '@/components/CommonPage/CommonTable.vue';
+    import OrderDetail from '@/views/mallOrder/all/components/order-detail.vue';
+
+    defineOptions({ name: 'CustomerRedPacket' });
+
+    const props = defineProps({
+        row: {
+            type: Object,
+            default() {
+                return {};
+            }
+        }
+    });
+
+    const pageRef = ref(null);
+    const orderDetailRef = ref(null);
+
+    const pageConfig = reactive({
+        fileName: '客户红包',
+        cacheKey: 'customerRedPacketTable',
+        pageUrl: '',
+        params: {}
+    });
+
+    const sourceMap = {
+        1: '普通红包',
+        2: '惊喜红包',
+        3: '红包码红包'
+    };
+
+    function formatSource(row) {
+        const text = row.sourceName || row.couponSourceName;
+        if (text) return text;
+        const type = row.type ?? row.redType ?? row.couponType;
+        if (type == null || type === '') return '';
+        return sourceMap[type] || sourceMap[String(type)] || type;
+    }
+
+    function formatThreshold(row) {
+        if (row.thresholdText) return row.thresholdText;
+        const money = row.thresholdMoney;
+        if (money === 0 || money === '0') return '无门槛';
+        if (money == null || money === '') return '';
+        return `满${money}可用`;
+    }
+
+    function formatAmount(row) {
+        const money = row.faceMoney ?? row.redPrice ?? row.amount;
+        if (money == null || money === '') return '';
+        return money;
+    }
+
+    function orderNoOf(row) {
+        return row.orderId || row.orderNo || '';
+    }
+
+    const columns = ref([
+        { label: '序号', type: 'index', align: 'center', width: 70 },
+        {
+            label: '红包来源',
+            prop: 'type',
+            align: 'center',
+            minWidth: 120,
+            formatter: (row) => formatSource(row)
+        },
+        {
+            label: '红包金额',
+            prop: 'faceMoney',
+            align: 'center',
+            minWidth: 100,
+            formatter: (row) => formatAmount(row)
+        },
+        {
+            label: '红包门槛',
+            prop: 'thresholdMoney',
+            align: 'center',
+            minWidth: 140,
+            formatter: (row) => formatThreshold(row)
+        },
+        {
+            label: '领取时间',
+            prop: 'createTime',
+            align: 'center',
+            minWidth: 170,
+            formatter: (row) => row.createTime || row.drawTime || ''
+        },
+        {
+            label: '使用时间',
+            prop: 'useTime',
+            align: 'center',
+            minWidth: 170
+        },
+        {
+            label: '订单编号',
+            prop: 'orderId',
+            align: 'center',
+            minWidth: 120,
+            slot: 'orderNo'
+        }
+    ]);
+
+    watch(
+        () => props.row,
+        (row) => {
+            if (!row?.id) return;
+            pageConfig.pageUrl = '/shop/couponInfo/getClaimPagelist';
+            pageConfig.params = { userId: row.id };
+            pageRef.value?.reload();
+        },
+        {
+            immediate: true,
+            deep: true
+        }
+    );
+
+    function openOrder(row) {
+        const orderId = orderNoOf(row);
+        if (!orderId) return;
+        orderDetailRef.value?.handleOpen({ orderId });
+    }
+</script>

+ 21 - 9
src/views/customer/list/components/detail/search-log.vue

@@ -28,14 +28,16 @@
     const pageConfig = reactive({
         fileName: '搜索记录',
         cacheKey: 'searchLogTable',
-        pageUrl: '/shop/shopSearch/searchLog'
+        pageUrl: '',
+        params: {}
     });
 
     watch(
         () => props.row,
         (row) => {
-            if (!row || (row && !row.id)) return;
-            pageConfig.pageUrl = `/shop/shopSearch/searchLog?userId=${row.id}`;
+            if (!row?.id) return;
+            pageConfig.pageUrl = '/shop/shopSearch/searchLog';
+            pageConfig.params = { userId: row.id };
             pageRef.value?.reload();
         },
         {
@@ -44,23 +46,33 @@
         }
     );
 
-    /** 表格列配置 */
+    const yesNo = (value) => (Number(value) === 1 ? '是' : '否');
+
+    /** 表格列配置,对应 UserSearchInfoManageVo */
     const columns = ref([
         { label: '序号', type: 'index', align: 'center', width: 60 },
-        { label: '搜索词', prop: 'searchWord', align: 'center' },
-        { label: '点击商品', prop: 'clientBookName', align: 'center' },
-        { label: '搜索时间', prop: 'createTime', align: 'center' },
+        { label: '搜索词', prop: 'searchWord', align: 'center', minWidth: 140 },
+        {
+            label: '点击商品',
+            prop: 'clientBookName',
+            align: 'center',
+            minWidth: 160
+        },
+        { label: 'ISBN', prop: 'clientBookIsbn', align: 'center', minWidth: 140 },
+        { label: '搜索时间', prop: 'createTime', align: 'center', minWidth: 170 },
         {
             label: '是否加购',
             prop: 'addCard',
             align: 'center',
-            formatter: (row) => (row.addCard === 1 ? '是' : '否')
+            width: 90,
+            formatter: (row) => yesNo(row.addCard)
         },
         {
             label: '是否下单',
             prop: 'addOrder',
             align: 'center',
-            formatter: (row) => (row.addOrder === 1 ? '是' : '否')
+            width: 90,
+            formatter: (row) => yesNo(row.addOrder)
         }
     ]);
 

+ 29 - 26
src/views/mallOrder/all/components/order-base-info.vue

@@ -195,6 +195,7 @@
 
     defineEmits(['add-package', 'edit-receiver']);
 
+    const textOf = (value) => (value === undefined || value === null ? '' : String(value).trim());
     const isTrackNode = (item) => {
         if (!item || typeof item !== 'object') return false;
         const hasTrack = item.context || item.ftime || item.status;
@@ -205,38 +206,40 @@
         if (!item || typeof item !== 'object') return false;
         return Boolean(item.waybillCode || item.expressName || Array.isArray(item.trackingVoList));
     };
+    const pushPackage = (list, pkg) => {
+        const waybillCode = textOf(pkg.waybillCode);
+        const expressName = textOf(pkg.expressName);
+        const tracks = Array.isArray(pkg.tracks) ? pkg.tracks : [];
+        if (!waybillCode && !expressName && !tracks.length) return;
+        const same = waybillCode ? list.find((item) => item.waybillCode === waybillCode) : null;
+        if (same) {
+            if (!same.expressName && expressName) same.expressName = expressName;
+            if (!same.tracks.length && tracks.length) same.tracks = tracks;
+            return;
+        }
+        list.push({ expressName, waybillCode, tracks });
+    };
     const expressPackages = computed(() => {
         const detail = props.detail || {};
         const list = Array.isArray(detail.trackingVoList) ? detail.trackingVoList : [];
-        const packages = list.filter(isPackageNode).map((item) => ({
-            expressName: item.expressName || '',
-            waybillCode: item.waybillCode || '',
-            tracks: Array.isArray(item.trackingVoList) ? item.trackingVoList : []
-        }));
-        const flatTracks = list.filter(isTrackNode);
-        const legacy = {
-            expressName: detail.expressName || '',
-            waybillCode: detail.waybillCode || '',
-            tracks: flatTracks
-        };
-        const hasLegacy = legacy.expressName || legacy.waybillCode || flatTracks.length;
-        const duplicated = packages.some((pkg) => {
-            if (legacy.waybillCode && pkg.waybillCode) return pkg.waybillCode === legacy.waybillCode;
-            return legacy.expressName && pkg.expressName === legacy.expressName && !legacy.waybillCode && !pkg.waybillCode;
+        const packages = [];
+        list.filter(isPackageNode).forEach((item) => {
+            pushPackage(packages, {
+                expressName: item.expressName,
+                waybillCode: item.waybillCode,
+                tracks: Array.isArray(item.trackingVoList) ? item.trackingVoList : []
+            });
         });
-        if (hasLegacy && duplicated) {
-            const hit = packages.find((pkg) => legacy.waybillCode && pkg.waybillCode === legacy.waybillCode) || packages[0];
-            if (hit && !hit.tracks.length && flatTracks.length) hit.tracks = flatTracks;
-        } else if (hasLegacy) {
-            packages.unshift(legacy);
-        }
-        return packages.filter((pkg) => pkg.expressName || pkg.waybillCode || pkg.tracks.length);
+        pushPackage(packages, {
+            expressName: detail.expressName,
+            waybillCode: detail.waybillCode,
+            tracks: list.filter(isTrackNode)
+        });
+        return packages;
     });
     const popoverTitle = (pkg) => {
-        if (expressPackages.value.length > 1) {
-            return ((pkg.expressName || '物流') + ' ' + (pkg.waybillCode || '')).trim();
-        }
-        return '物流动态';
+        const title = (textOf(pkg.expressName) + ' ' + textOf(pkg.waybillCode)).trim();
+        return title || '物流动态';
     };
 
     const getOutOfStockText = (value) => {

+ 40 - 8
src/views/mallOrder/refund/index.vue

@@ -10,7 +10,7 @@
                         :body-style="{ padding: '10px' }"
                     >
                         <div class="stat-title">退款处理时长</div>
-                        <div class="stat-value">6.35h</div>
+                        <div class="stat-value">{{ handleDuration }}</div>
                     </el-card>
                 </el-col>
                 <el-col :span="4">
@@ -20,7 +20,7 @@
                         :body-style="{ padding: '10px' }"
                     >
                         <div class="stat-title">待平台处理</div>
-                        <div class="stat-value">10</div>
+                        <div class="stat-value">{{ stat.pendingHandleCount }}</div>
                     </el-card>
                 </el-col>
                 <el-col :span="4">
@@ -30,7 +30,7 @@
                         :body-style="{ padding: '10px' }"
                     >
                         <div class="stat-title">退款待处理</div>
-                        <div class="stat-value">10</div>
+                        <div class="stat-value">{{ stat.pendingHandleCount }}</div>
                     </el-card>
                 </el-col>
                 <el-col :span="4">
@@ -40,7 +40,7 @@
                         :body-style="{ padding: '10px' }"
                     >
                         <div class="stat-title">待平台收货</div>
-                        <div class="stat-value">10</div>
+                        <div class="stat-value">{{ stat.waitingPlatformReceiveCount }}</div>
                     </el-card>
                 </el-col>
                 <el-col :span="4">
@@ -49,8 +49,8 @@
                         class="stat-card bg-gray-50"
                         :body-style="{ padding: '10px' }"
                     >
-                        <div class="stat-title">24小时内即将追期</div>
-                        <div class="stat-value">10</div>
+                        <div class="stat-title">24小时内即将逾期</div>
+                        <div class="stat-value">{{ stat.expiringIn24hCount }}</div>
                     </el-card>
                 </el-col>
                 <el-col :span="4">
@@ -60,7 +60,7 @@
                         :body-style="{ padding: '10px' }"
                     >
                         <div class="stat-title">待买家处理</div>
-                        <div class="stat-value">10</div>
+                        <div class="stat-value">{{ stat.waitingBuyerHandleCount }}</div>
                     </el-card>
                 </el-col>
             </el-row>
@@ -135,7 +135,7 @@
 </template>
 
 <script setup>
-    import { ref, reactive, onMounted } from 'vue';
+    import { ref, reactive, computed, onMounted } from 'vue';
     import { EleMessage } from 'ele-admin-plus/es';
     import request from '@/utils/request';
     import PageSearch from './components/page-search.vue';
@@ -160,12 +160,44 @@
 
     const list = ref([]);
 
+    const stat = ref({
+        pendingHandleCount: 0,
+        waitingPlatformReceiveCount: 0,
+        expiringIn24hCount: 0,
+        waitingBuyerHandleCount: 0,
+        avgHandleSeconds: 0
+    });
+
+    const handleDuration = computed(() => {
+        const seconds = Number(stat.value.avgHandleSeconds || 0);
+        return `${(seconds / 3600).toFixed(2)}h`;
+    });
+
+    const fetchStat = () => {
+        request
+            .get('/shop/shopOrder/refundStat')
+            .then((res) => {
+                if (res.data.code !== 200) {
+                    EleMessage.error(res.data.msg);
+                    return;
+                }
+                stat.value = {
+                    ...stat.value,
+                    ...(res.data.data || {})
+                };
+            })
+            .catch((e) => {
+                EleMessage.error(e.message || '获取退款统计失败');
+            });
+    };
+
     const pushSmsDialogRef = ref(null);
     const refundDetailDialogRef = ref(null);
     const confirmRefundDialogRef = ref(null);
     const remarkRef = ref(null);
 
     const fetchData = () => {
+        fetchStat();
         loading.value = true;
         const params = {
             ...pageParams,

+ 119 - 106
src/views/statsAnalysis/search/components/SearchEffectModal.vue

@@ -2,136 +2,147 @@
     <el-dialog
         v-model="visible"
         :title="title"
-        width="800px"
+        width="860px"
         @close="handleClose"
     >
-        <div class="modal-content">
-            <!-- Product Effect Section -->
-            <div class="section">
-                <div class="table-wrapper">
-                    <el-table :data="productData" border size="small">
-                        <el-table-column label="引导商品效果" min-width="200">
-                            <template #default="{ row }">
-                                <div class="product-info">
-                                    <img :src="row.image" class="product-img" />
-                                    <div class="product-details">
-                                        <div class="name">{{ row.name }}</div>
-                                        <div class="meta"
-                                            >ISBN: {{ row.isbn }}</div
-                                        >
-                                        <div class="meta"
-                                            >价格: {{ row.price }} 库存:
-                                            {{ row.stock }}</div
-                                        >
-                                    </div>
-                                </div>
-                            </template>
-                        </el-table-column>
-                        <el-table-column
-                            prop="visitors"
-                            label="带来的访客数"
-                            sortable
-                            width="120"
-                        />
-                        <el-table-column
-                            prop="views"
-                            label="带来的浏览量"
-                            sortable
-                            width="120"
-                        />
-                        <el-table-column
-                            prop="buyers"
-                            label="引导下单买家数"
-                            sortable
-                            width="140"
-                        />
-                        <el-table-column
-                            prop="conversion"
-                            label="引导下单转化率"
-                            sortable
-                            width="140"
-                        />
-                    </el-table>
-
-                    <div class="pagination">
-                        <el-pagination
-                            layout="total, prev, pager, next, jumper"
-                            :total="100"
-                            small
-                        />
-                    </div>
-                </div>
+        <div v-loading="loading" class="section">
+            <el-table :data="productData" border size="small">
+                <el-table-column label="引导商品效果" min-width="240">
+                    <template #default="{ row }">
+                        <div class="product-info">
+                            <img
+                                v-if="row.cover"
+                                :src="row.cover"
+                                class="product-img"
+                            />
+                            <div v-else class="product-img placeholder"></div>
+                            <div class="product-details">
+                                <div class="name">{{ row.bookName || '—' }}</div>
+                                <div class="meta">ISBN: {{ row.isbn || '—' }}</div>
+                                <div class="meta">价格: {{ priceText(row) }}</div>
+                            </div>
+                        </div>
+                    </template>
+                </el-table-column>
+                <el-table-column
+                    prop="visitUserNum"
+                    label="带来的访客数"
+                    width="120"
+                />
+                <el-table-column
+                    prop="visitTimes"
+                    label="带来的浏览量"
+                    width="120"
+                />
+                <el-table-column
+                    prop="orderUserNum"
+                    label="引导下单买家数"
+                    width="140"
+                />
+                <el-table-column
+                    prop="orderConversionRate"
+                    label="引导下单转化率"
+                    width="140"
+                />
+            </el-table>
+
+            <div class="pagination">
+                <el-pagination
+                    layout="total, prev, pager, next, jumper"
+                    :total="total"
+                    :current-page="pageNum"
+                    :page-size="pageSize"
+                    small
+                    @current-change="handlePageChange"
+                />
             </div>
         </div>
     </el-dialog>
 </template>
 
 <script setup>
-    import { ref, computed, watch } from 'vue';
-    import { Goods } from '@element-plus/icons-vue';
+    import { computed, ref, watch } from 'vue';
+    import { EleMessage } from 'ele-admin-plus/es';
+    import { getSearchProductEffect } from '@/api/statsAnalysis/search';
 
     const props = defineProps({
         modelValue: Boolean,
-        rowData: Object
+        rowData: Object,
+        startDate: {
+            type: String,
+            default: ''
+        },
+        endDate: {
+            type: String,
+            default: ''
+        }
     });
 
     const emit = defineEmits(['update:modelValue']);
 
     const visible = ref(false);
+    const loading = ref(false);
+    const productData = ref([]);
+    const total = ref(0);
+    const pageNum = ref(1);
+    const pageSize = ref(10);
+
+    const title = computed(() => {
+        const word = props.rowData?.searchWord;
+        return word ? `搜索词详情: ${word} - 商品效果` : '商品效果详情';
+    });
+
+    function priceText(row) {
+        const price = row.productPrice ?? row.price;
+        return price == null || price === '' ? '—' : price;
+    }
+
+    async function loadEffect() {
+        const searchWord = props.rowData?.searchWord;
+        if (!searchWord) {
+            productData.value = [];
+            total.value = 0;
+            return;
+        }
+        loading.value = true;
+        try {
+            const res = await getSearchProductEffect({
+                searchWord,
+                startDate: props.startDate,
+                endDate: props.endDate,
+                pageNum: pageNum.value,
+                pageSize: pageSize.value
+            });
+            productData.value = res.rows || [];
+            total.value = res.total || 0;
+        } catch (error) {
+            productData.value = [];
+            total.value = 0;
+            EleMessage.error(error.message || '获取商品效果失败');
+        } finally {
+            loading.value = false;
+        }
+    }
+
+    function handlePageChange(page) {
+        pageNum.value = page;
+        loadEffect();
+    }
 
     watch(
         () => props.modelValue,
         (val) => {
             visible.value = val;
+            if (val) {
+                pageNum.value = 1;
+                loadEffect();
+            }
         }
     );
 
     const handleClose = () => {
         emit('update:modelValue', false);
     };
-
-    const title = computed(() => {
-        return props.rowData
-            ? `搜索词详情: ${props.rowData.term} - 商品效果`
-            : '商品效果详情';
-    });
-
-    // Mock Product Data
-    const productData = ref([
-        {
-            image: 'https://via.placeholder.com/60',
-            name: '马克思主义基本原理',
-            isbn: '9787123654587',
-            price: '22.12',
-            stock: 5,
-            visitors: 23,
-            views: 33,
-            buyers: 2,
-            conversion: '8.7%'
-        },
-        {
-            image: 'https://via.placeholder.com/60',
-            name: '马克思主义基本原理',
-            isbn: '9787123654587',
-            price: '22.12',
-            stock: 5,
-            visitors: 23,
-            views: 33,
-            buyers: 2,
-            conversion: '8.7%'
-        },
-        {
-            image: 'https://via.placeholder.com/60',
-            name: '马克思主义基本原理',
-            isbn: '9787123654587',
-            price: '22.12',
-            stock: 5,
-            visitors: 23,
-            views: 33,
-            buyers: 2,
-            conversion: '8.7%'
-        }
-    ]);
 </script>
 
 <style scoped lang="scss">
@@ -145,6 +156,11 @@
             object-fit: cover;
             margin-right: 10px;
             border-radius: 4px;
+            flex-shrink: 0;
+
+            &.placeholder {
+                background: #f0f2f5;
+            }
         }
 
         .product-details {
@@ -153,9 +169,6 @@
                 color: #333;
                 font-size: 14px;
                 margin-bottom: 5px;
-                &.green {
-                    color: #4caf50;
-                }
             }
             .meta {
                 font-size: 12px;

+ 112 - 51
src/views/statsAnalysis/search/components/SearchOverview.vue

@@ -2,28 +2,28 @@
     <div class="search-overview">
         <div class="header">
             <div class="title">搜索的使用次数</div>
-            <div class="subtitle">2024-06-01至2024-06-25</div>
+            <div class="subtitle">{{ dateText || '—' }}</div>
         </div>
 
         <div class="content">
-            <!-- Stats -->
             <div class="stats-section">
                 <div class="stat-item">
                     <div class="label">今日</div>
-                    <div class="value">369次</div>
-                    <div class="compare down">对比昨日 59% ↓</div>
+                    <div class="value">{{ summary.todayTimes || 0 }}次</div>
+                    <div class="compare" :class="compareInfo.cls">
+                        对比昨日 {{ compareInfo.text }}
+                    </div>
                 </div>
                 <div class="stat-item">
                     <div class="label">合计</div>
-                    <div class="value">36009次</div>
+                    <div class="value">{{ summary.totalTimes || 0 }}次</div>
                 </div>
                 <div class="stat-item">
                     <div class="label">人均搜索次数</div>
-                    <div class="value">3600次</div>
+                    <div class="value">{{ avgText }}</div>
                 </div>
             </div>
 
-            <!-- Chart -->
             <div class="chart-section">
                 <base-chart :option="chartOption" />
             </div>
@@ -35,49 +35,96 @@
     import { computed } from 'vue';
     import BaseChart from '@/components/Chart/BaseChart.vue';
 
-    const chartOption = computed(() => ({
-        tooltip: {
-            trigger: 'axis'
+    const props = defineProps({
+        summary: {
+            type: Object,
+            default: () => ({})
         },
-        grid: {
-            top: '10%',
-            bottom: '10%',
-            left: '3%',
-            right: '4%',
-            containLabel: true
-        },
-        xAxis: {
-            type: 'category',
-            data: ['1日', '2日', '3日', '4日', '5日'],
-            axisTick: { alignWithLabel: true }
-        },
-        yAxis: {
-            type: 'value',
-            splitLine: { lineStyle: { type: 'dashed' } }
-        },
-        series: [
-            {
-                name: '搜索总次数',
-                type: 'line',
-                smooth: true,
-                data: [100, 140, 230, 100, 130],
-                itemStyle: { color: '#5b8ff9' },
-                areaStyle: {
-                    color: {
-                        type: 'linear',
-                        x: 0,
-                        y: 0,
-                        x2: 0,
-                        y2: 1,
-                        colorStops: [
-                            { offset: 0, color: 'rgba(91, 143, 249, 0.2)' },
-                            { offset: 1, color: 'rgba(91, 143, 249, 0)' }
-                        ]
-                    }
+        dateText: {
+            type: String,
+            default: ''
+        }
+    });
+
+    const avgText = computed(() => {
+        const value = props.summary.avgTimesPerUser;
+        if (value == null || value === '') {
+            return '0次';
+        }
+        return String(value).includes('次') ? value : `${value}次`;
+    });
+
+    const compareInfo = computed(() => {
+        const direction = props.summary.todayVsYesterdayDirection;
+        const rate = props.summary.todayVsYesterdayRate ?? 0;
+        const up =
+            direction === 1 || direction === '1' || direction === '上升';
+        const down =
+            direction === 2 ||
+            direction === -1 ||
+            direction === '2' ||
+            direction === '-1' ||
+            direction === '下降';
+        if (up) {
+            return { cls: 'up', text: `${rate}% ↑` };
+        }
+        if (down) {
+            return { cls: 'down', text: `${rate}% ↓` };
+        }
+        return { cls: 'flat', text: '持平' };
+    });
+
+    function axisLabel(date) {
+        if (!date) {
+            return '';
+        }
+        const text = String(date);
+        const matched = text.match(/(\d{4})-(\d{1,2})-(\d{1,2})/);
+        if (matched) {
+            return `${Number(matched[3])}日`;
+        }
+        return text;
+    }
+
+    const chartOption = computed(() => {
+        const trendList = props.summary.trendList || [];
+        return {
+            tooltip: {
+                trigger: 'axis'
+            },
+            legend: {
+                top: 0,
+                data: ['搜索总次数']
+            },
+            grid: {
+                top: 36,
+                bottom: 8,
+                left: '3%',
+                right: '4%',
+                containLabel: true
+            },
+            xAxis: {
+                type: 'category',
+                data: trendList.map((item) => axisLabel(item.date)),
+                axisTick: { alignWithLabel: true }
+            },
+            yAxis: {
+                type: 'value',
+                splitLine: { lineStyle: { type: 'dashed' } }
+            },
+            series: [
+                {
+                    name: '搜索总次数',
+                    type: 'line',
+                    smooth: true,
+                    data: trendList.map((item) => item.searchTimes || 0),
+                    itemStyle: { color: '#5b8ff9' },
+                    symbol: 'circle',
+                    symbolSize: 6
                 }
-            }
-        ]
-    }));
+            ]
+        };
+    });
 </script>
 
 <style scoped lang="scss">
@@ -86,6 +133,7 @@
         padding: 20px;
         border-radius: 8px;
         margin-bottom: 20px;
+        overflow: hidden;
     }
 
     .header {
@@ -129,9 +177,14 @@
 
                 .compare {
                     font-size: 12px;
+                    color: #999;
+
+                    &.up {
+                        color: #ff4d4f;
+                    }
+
                     &.down {
-                        color: #52c41a; // Green for down usually means good? Or red? In finance Green is down (CN) or Up (US).
-                        // Design shows green arrow down. Let's use green.
+                        color: #52c41a;
                     }
                 }
             }
@@ -139,7 +192,15 @@
 
         .chart-section {
             flex: 1;
-            height: 200px;
+            height: 180px;
+            min-width: 0;
+            overflow: hidden;
+
+            :deep(.base-chart) {
+                width: 100%;
+                height: 180px;
+                min-height: 0;
+            }
         }
     }
 </style>

+ 66 - 11
src/views/statsAnalysis/search/components/SearchTable.vue

@@ -1,33 +1,66 @@
 <template>
     <div class="search-table">
         <el-table :data="tableData" style="width: 100%" border>
-            <el-table-column prop="term" label="搜索词" />
-            <el-table-column prop="searchCount" label="搜索次数" sortable />
-            <el-table-column prop="cartCount" label="加购人数" sortable />
-            <el-table-column prop="favCount" label="商品收藏人数" sortable />
-            <el-table-column prop="payCount" label="支付买家数" sortable />
+            <el-table-column prop="searchWord" label="搜索词" min-width="160" />
             <el-table-column
-                prop="conversionRate"
+                prop="searchTimes"
+                label="搜索次数"
+                sortable
+                min-width="110"
+            />
+            <el-table-column
+                prop="cartUserNum"
+                label="加购人数"
+                sortable
+                min-width="110"
+            />
+            <el-table-column
+                prop="collectUserNum"
+                label="商品收藏人数"
+                sortable
+                min-width="130"
+            />
+            <el-table-column
+                prop="payUserNum"
+                label="支付买家数"
+                sortable
+                min-width="120"
+            />
+            <el-table-column
+                prop="payConversionRate"
                 label="支付转化率"
                 sortable
+                min-width="120"
             />
-            <el-table-column label="操作" width="200">
+            <el-table-column label="操作" width="180" align="center">
                 <template #default="scope">
                     <el-button
                         link
-                        type="primary"
+                        class="trend-link"
                         @click="$emit('view-trend', scope.row)"
-                        >[趋势]</el-button
                     >
+                        [趋势]
+                    </el-button>
                     <el-button
                         link
                         type="warning"
                         @click="$emit('view-effect', scope.row)"
-                        >[商品效果]</el-button
                     >
+                        [商品效果]
+                    </el-button>
                 </template>
             </el-table-column>
         </el-table>
+
+        <div class="pagination">
+            <el-pagination
+                layout="total, prev, pager, next, jumper"
+                :total="total"
+                :current-page="pageNum"
+                :page-size="pageSize"
+                @current-change="$emit('page-change', $event)"
+            />
+        </div>
     </div>
 </template>
 
@@ -36,14 +69,36 @@
         tableData: {
             type: Array,
             default: () => []
+        },
+        total: {
+            type: Number,
+            default: 0
+        },
+        pageNum: {
+            type: Number,
+            default: 1
+        },
+        pageSize: {
+            type: Number,
+            default: 10
         }
     });
 
-    defineEmits(['view-trend', 'view-effect']);
+    defineEmits(['view-trend', 'view-effect', 'page-change']);
 </script>
 
 <style scoped>
     .search-table {
         margin-top: 20px;
     }
+
+    .trend-link {
+        color: #52c41a;
+    }
+
+    .pagination {
+        margin-top: 15px;
+        display: flex;
+        justify-content: flex-end;
+    }
 </style>

+ 57 - 37
src/views/statsAnalysis/search/components/SearchTrendModal.vue

@@ -5,21 +5,19 @@
         width="800px"
         @close="handleClose"
     >
-        <div class="modal-content">
-            <!-- Trend Section -->
-            <div class="section">
-                <div class="chart-wrapper">
-                    <base-chart :option="trendOption" />
-                </div>
+        <div v-loading="loading" class="section">
+            <div class="chart-wrapper">
+                <base-chart :option="trendOption" />
             </div>
         </div>
     </el-dialog>
 </template>
 
 <script setup>
-    import { ref, computed, watch } from 'vue';
-    import { DataLine } from '@element-plus/icons-vue';
+    import { computed, ref, watch } from 'vue';
+    import { EleMessage } from 'ele-admin-plus/es';
     import BaseChart from '@/components/Chart/BaseChart.vue';
+    import { getSearchWordTrend } from '@/api/statsAnalysis/search';
 
     const props = defineProps({
         modelValue: Boolean,
@@ -29,25 +27,22 @@
     const emit = defineEmits(['update:modelValue']);
 
     const visible = ref(false);
-
-    watch(
-        () => props.modelValue,
-        (val) => {
-            visible.value = val;
-        }
-    );
-
-    const handleClose = () => {
-        emit('update:modelValue', false);
-    };
+    const loading = ref(false);
+    const trendList = ref([]);
 
     const title = computed(() => {
-        return props.rowData
-            ? `搜索词详情: ${props.rowData.term} - 趋势`
-            : '趋势详情';
+        const word = props.rowData?.searchWord;
+        return word ? `搜索词详情: ${word} - 趋势` : '趋势详情';
     });
 
-    // Mock Trend Data
+    function toNumber(value) {
+        if (value == null || value === '') {
+            return 0;
+        }
+        const num = parseFloat(String(value).replace('%', ''));
+        return Number.isFinite(num) ? num : 0;
+    }
+
     const trendOption = computed(() => ({
         tooltip: { trigger: 'axis' },
         legend: { top: 0 },
@@ -55,15 +50,7 @@
         xAxis: {
             type: 'category',
             boundaryGap: false,
-            data: [
-                '09-07',
-                '09-08',
-                '09-10',
-                '09-11',
-                '09-12',
-                '09-13',
-                '09-14'
-            ]
+            data: trendList.value.map((item) => item.date)
         },
         yAxis: { type: 'value' },
         series: [
@@ -71,39 +58,72 @@
                 name: '搜索次数',
                 type: 'line',
                 smooth: true,
-                data: [100, 120, 300, 100, 130, 210, 150],
+                data: trendList.value.map((item) => item.searchTimes || 0),
                 itemStyle: { color: '#5b8ff9' }
             },
             {
                 name: '加购人数',
                 type: 'line',
                 smooth: true,
-                data: [150, 100, 200, 140, 100, 160, 110],
+                data: trendList.value.map((item) => item.cartUserNum || 0),
                 itemStyle: { color: '#5ebcb9' }
             },
             {
                 name: '商品收藏人数',
                 type: 'line',
                 smooth: true,
-                data: [150, 180, 110, 150, 200, 160, 220],
+                data: trendList.value.map((item) => item.collectUserNum || 0),
                 itemStyle: { color: '#4caf50' }
             },
             {
                 name: '支付买家数',
                 type: 'line',
                 smooth: true,
-                data: [100, 110, 220, 140, 150, 160, 130],
+                data: trendList.value.map((item) => item.payUserNum || 0),
                 itemStyle: { color: '#ffc107' }
             },
             {
                 name: '支付转化率',
                 type: 'line',
                 smooth: true,
-                data: [100, 100, 120, 140, 140, 190, 180],
+                data: trendList.value.map((item) =>
+                    toNumber(item.payConversionRate)
+                ),
                 itemStyle: { color: '#ff5722' }
             }
         ]
     }));
+
+    async function loadTrend() {
+        const searchWord = props.rowData?.searchWord;
+        if (!searchWord) {
+            trendList.value = [];
+            return;
+        }
+        loading.value = true;
+        try {
+            trendList.value = await getSearchWordTrend({ searchWord });
+        } catch (error) {
+            trendList.value = [];
+            EleMessage.error(error.message || '获取搜索词趋势失败');
+        } finally {
+            loading.value = false;
+        }
+    }
+
+    watch(
+        () => props.modelValue,
+        (val) => {
+            visible.value = val;
+            if (val) {
+                loadTrend();
+            }
+        }
+    );
+
+    const handleClose = () => {
+        emit('update:modelValue', false);
+    };
 </script>
 
 <style scoped lang="scss">

+ 248 - 66
src/views/statsAnalysis/search/index.vue

@@ -6,20 +6,25 @@
                 <el-select
                     v-model="searchType"
                     placeholder="搜索词分类"
-                    style="width: 120px; margin-right: 15px"
+                    class="type-select"
+                    clearable
+                    @change="handleTypeChange"
                 >
-                    <el-option label="搜索词分类" value="category" />
+                    <el-option label="ISBN" value="isbn" />
+                    <el-option label="其他" value="other" />
                 </el-select>
 
-                <div class="date-ranges">
-                    <el-button-group>
-                        <el-button type="primary">实时</el-button>
-                        <el-button>昨日</el-button>
-                        <el-button>7日</el-button>
-                        <el-button>15日</el-button>
-                        <el-button>30日</el-button>
-                    </el-button-group>
-                </div>
+                <div class="filter-main">
+                    <div class="date-ranges">
+                        <el-button
+                            v-for="range in dateRanges"
+                            :key="range.value"
+                            :type="activeRange === range.value ? 'primary' : ''"
+                            @click="handleRangeChange(range.value)"
+                        >
+                            {{ range.label }}
+                        </el-button>
+                    </div>
 
                 <div class="date-picker-wrapper">
                     <el-date-picker
@@ -28,115 +33,292 @@
                         range-separator="-"
                         start-placeholder="选择时间"
                         end-placeholder="选择时间"
+                        value-format="YYYY-MM-DD"
                         size="default"
+                        @change="handleDateChange"
                     />
                 </div>
 
-                <el-button type="primary">搜索</el-button>
-                <el-button>重置</el-button>
+                    <el-button type="primary" @click="handleSearch">搜索</el-button>
+                    <el-button @click="handleReset">重置</el-button>
+                </div>
             </div>
 
-            <!-- Overview Stats -->
-            <search-overview />
+            <search-overview
+                v-loading="summaryLoading"
+                :summary="summary"
+                :date-text="dateText"
+            />
 
-            <!-- Data Table -->
             <search-table
+                v-loading="tableLoading"
                 :table-data="tableData"
+                :total="total"
+                :page-num="pageNum"
+                :page-size="pageSize"
+                @page-change="handlePageChange"
                 @view-trend="handleViewTrend"
                 @view-effect="handleViewEffect"
             />
 
-            <!-- Trend Modal -->
             <search-trend-modal
                 v-model="trendModalVisible"
                 :row-data="currentRow"
             />
 
-            <!-- Effect Modal -->
             <search-effect-modal
                 v-model="effectModalVisible"
                 :row-data="currentRow"
+                :start-date="queryDates.startDate"
+                :end-date="queryDates.endDate"
             />
         </ele-card>
     </ele-page>
 </template>
 
 <script setup>
-    import { ref } from 'vue';
+    import { computed, onMounted, ref } from 'vue';
+    import dayjs from 'dayjs';
+    import { EleMessage } from 'ele-admin-plus/es';
     import SearchOverview from './components/SearchOverview.vue';
     import SearchTable from './components/SearchTable.vue';
     import SearchTrendModal from './components/SearchTrendModal.vue';
     import SearchEffectModal from './components/SearchEffectModal.vue';
+    import {
+        getSearchSummary,
+        getSearchWordPage
+    } from '@/api/statsAnalysis/search';
 
-    const searchType = ref('category');
-    const dateRange = ref([]);
+    const dateRanges = [
+        { label: '实时', value: 'realtime' },
+        { label: '昨日', value: 'yesterday' },
+        { label: '7日', value: '7days' },
+        { label: '15日', value: '15days' },
+        { label: '30日', value: '30days' }
+    ];
 
+    const activeRange = ref('realtime');
+    const dateRange = ref([]);
+    const searchType = ref('');
+    const pageNum = ref(1);
+    const pageSize = ref(10);
+    const total = ref(0);
+    const summary = ref({});
+    const summaryLoading = ref(false);
+    const tableLoading = ref(false);
     const trendModalVisible = ref(false);
     const effectModalVisible = ref(false);
     const currentRow = ref(null);
 
-    const tableData = ref([
-        {
-            term: '中医基础理论',
-            searchCount: 23,
-            cartCount: 5,
-            favCount: 0,
-            payCount: 2,
-            conversionRate: '8.70%'
-        },
-        {
-            term: '大学语文',
-            searchCount: 6,
-            cartCount: 3,
-            favCount: 1,
-            payCount: 0,
-            conversionRate: '0.00%'
-        },
-        {
-            term: '马克思主义发展史',
-            searchCount: 6,
-            cartCount: 3,
-            favCount: 0,
-            payCount: 3,
-            conversionRate: '50.00%'
-        },
-        {
-            term: '高等数学',
-            searchCount: 15,
-            cartCount: 8,
-            favCount: 2,
-            payCount: 5,
-            conversionRate: '33.33%'
-        },
-        {
-            term: 'Python编程',
-            searchCount: 12,
-            cartCount: 4,
-            favCount: 3,
-            payCount: 1,
-            conversionRate: '8.33%'
+    const tableData = ref([]);
+    const filteredRows = ref([]);
+
+    const rangeDays = {
+        realtime: 0,
+        '7days': 6,
+        '15days': 14,
+        '30days': 29
+    };
+
+    function buildRange(value) {
+        const today = dayjs();
+        if (value === 'yesterday') {
+            const day = today.subtract(1, 'day').format('YYYY-MM-DD');
+            return [day, day];
         }
-    ]);
+        const offset = rangeDays[value] ?? 0;
+        return [
+            today.subtract(offset, 'day').format('YYYY-MM-DD'),
+            today.format('YYYY-MM-DD')
+        ];
+    }
+
+    const queryDates = computed(() => {
+        const [startDate, endDate] = dateRange.value || [];
+        return {
+            startDate: startDate || '',
+            endDate: endDate || ''
+        };
+    });
+
+    const dateText = computed(() => {
+        const { startDate, endDate } = queryDates.value;
+        if (!startDate || !endDate) {
+            return '';
+        }
+        return `${startDate}至${endDate}`;
+    });
+
+    function listParams() {
+        return {
+            ...queryDates.value,
+            pageNum: pageNum.value,
+            pageSize: pageSize.value
+        };
+    }
+
+    async function loadSummary() {
+        summaryLoading.value = true;
+        try {
+            summary.value = await getSearchSummary(queryDates.value);
+        } catch (error) {
+            summary.value = {};
+            EleMessage.error(error.message || '获取搜索汇总失败');
+        } finally {
+            summaryLoading.value = false;
+        }
+    }
+
+    function isIsbn(word) {
+        const text = String(word || '').replace(/[-\s]/g, '');
+        return /^\d{13}$/.test(text) || /^\d{9}[\dXx]$/.test(text);
+    }
+
+    function matchSearchType(row) {
+        if (!searchType.value) {
+            return true;
+        }
+        const isbn = isIsbn(row.searchWord);
+        return searchType.value === 'isbn' ? isbn : !isbn;
+    }
+
+    function sliceFiltered() {
+        const start = (pageNum.value - 1) * pageSize.value;
+        tableData.value = filteredRows.value.slice(
+            start,
+            start + pageSize.value
+        );
+    }
+
+    async function loadTable() {
+        tableLoading.value = true;
+        try {
+            if (!searchType.value) {
+                const res = await getSearchWordPage(listParams());
+                tableData.value = res.rows || [];
+                total.value = res.total || 0;
+                return;
+            }
+            const matched = [];
+            let page = 1;
+            const size = 100;
+            let fetched = 0;
+            let serverTotal = Infinity;
+            while (fetched < serverTotal && page <= 30) {
+                const res = await getSearchWordPage({
+                    ...queryDates.value,
+                    pageNum: page,
+                    pageSize: size
+                });
+                const rows = res.rows || [];
+                serverTotal = res.total || 0;
+                matched.push(...rows.filter(matchSearchType));
+                fetched += rows.length;
+                if (!rows.length || rows.length < size) {
+                    break;
+                }
+                page += 1;
+            }
+            filteredRows.value = matched;
+            total.value = matched.length;
+            sliceFiltered();
+        } catch (error) {
+            tableData.value = [];
+            filteredRows.value = [];
+            total.value = 0;
+            EleMessage.error(error.message || '获取搜索词列表失败');
+        } finally {
+            tableLoading.value = false;
+        }
+    }
 
-    const handleViewTrend = (row) => {
+    function loadPage() {
+        loadSummary();
+        loadTable();
+    }
+
+    function handleRangeChange(value) {
+        activeRange.value = value;
+        dateRange.value = buildRange(value);
+        pageNum.value = 1;
+        loadPage();
+    }
+
+    function handleDateChange() {
+        activeRange.value = '';
+    }
+
+    function handleSearch() {
+        if (!dateRange.value?.length) {
+            dateRange.value = buildRange('realtime');
+            activeRange.value = 'realtime';
+        }
+        pageNum.value = 1;
+        loadPage();
+    }
+
+    function handleReset() {
+        searchType.value = '';
+        handleRangeChange('realtime');
+    }
+
+    function handleTypeChange() {
+        pageNum.value = 1;
+        loadTable();
+    }
+
+    function handlePageChange(page) {
+        pageNum.value = page;
+        if (searchType.value) {
+            sliceFiltered();
+            return;
+        }
+        loadTable();
+    }
+
+    function handleViewTrend(row) {
         currentRow.value = row;
         trendModalVisible.value = true;
-    };
+    }
 
-    const handleViewEffect = (row) => {
+    function handleViewEffect(row) {
         currentRow.value = row;
         effectModalVisible.value = true;
-    };
+    }
+
+    onMounted(() => {
+        dateRange.value = buildRange('realtime');
+        loadPage();
+    });
 </script>
 
 <style scoped lang="scss">
     .filter-container {
+        position: relative;
         display: flex;
         align-items: center;
+        justify-content: center;
         margin-bottom: 20px;
+        min-height: 32px;
+
+        .type-select {
+            position: absolute;
+            left: 0;
+            width: 140px;
+        }
+
+        .filter-main {
+            display: flex;
+            align-items: center;
+        }
 
         .date-ranges {
             margin-right: 15px;
+
+            .el-button {
+                margin-right: 8px;
+                margin-left: 0;
+            }
         }
 
         .date-picker-wrapper {