Bläddra i källkod

修复授权的bug;新增小程序首页配置

Huanyi 2 dagar sedan
förälder
incheckning
9b247f4a45

BIN
dist.zip


+ 13 - 1
src/api/erp/client/index.ts

@@ -3,7 +3,7 @@ import { AxiosPromise } from 'axios';
 import { ErpClientVO, ErpClientQuery } from './types';
 
 /**
- * 查询ERP客户列表
+ * 查询ERP客户列表(分页)
  * @param query
  * @Author: Antigravity
  */
@@ -14,3 +14,15 @@ export const listErpClient = (query?: ErpClientQuery): AxiosPromise<ErpClientVO[
     params: query
   });
 };
+
+/**
+ * 根据名称模糊检索客户(全量,不分页)
+ * @Author: Trae
+ */
+export const searchErpClient = (name: string): AxiosPromise<ErpClientVO[]> => {
+  return request({
+    url: '/erp/client/search',
+    method: 'get',
+    params: { name }
+  });
+};

+ 17 - 0
src/api/system/applet/categories/index.ts

@@ -0,0 +1,17 @@
+import request from '@/utils/request';
+import { CategoriesVO, CategoriesForm } from './types';
+
+/** 查询全部精选分类 */
+export const listCategories = () => {
+  return request<CategoriesVO[]>({ url: '/system/applet/categories/list', method: 'get' });
+};
+
+/** 根据ID查询 */
+export const getCategories = (id: string) => {
+  return request<CategoriesVO>({ url: `/system/applet/categories/${id}`, method: 'get' });
+};
+
+/** 修改分类 */
+export const editCategories = (data: CategoriesForm) => {
+  return request({ url: '/system/applet/categories', method: 'put', data });
+};

+ 17 - 0
src/api/system/applet/categories/types.ts

@@ -0,0 +1,17 @@
+export interface CategoriesVO {
+  id: string;
+  title: string;
+  remark: string;
+  background: string;
+  /** 翻译后的URL */
+  backgroundUrl: string;
+  createTime: string;
+  updateTime: string;
+}
+
+export interface CategoriesForm {
+  id: string;
+  title: string;
+  remark: string;
+  background: string;
+}

+ 28 - 0
src/api/system/applet/slideshow/index.ts

@@ -0,0 +1,28 @@
+import request from '@/utils/request';
+import { SlideshowVO, SlideshowForm } from './types';
+
+/** 查询全部轮播图 */
+export const listSlideshow = () => {
+  return request<SlideshowVO[]>({ url: '/system/applet/slideshow/list', method: 'get' });
+};
+
+/** 根据ID查询 */
+export const getSlideshow = (id: string) => {
+  return request<SlideshowVO>({ url: `/system/applet/slideshow/${id}`, method: 'get' });
+};
+
+/** 新增轮播图 */
+export const addSlideshow = (data: SlideshowForm) => {
+  return request({ url: '/system/applet/slideshow', method: 'post', data });
+};
+
+/** 修改轮播图 */
+export const editSlideshow = (data: SlideshowForm) => {
+  return request({ url: '/system/applet/slideshow', method: 'put', data });
+};
+
+/** 删除轮播图 */
+export const delSlideshow = (ids: string | string[]) => {
+  const idStr = Array.isArray(ids) ? ids.join(',') : ids;
+  return request({ url: `/system/applet/slideshow/${idStr}`, method: 'delete' });
+};

+ 13 - 0
src/api/system/applet/slideshow/types.ts

@@ -0,0 +1,13 @@
+export interface SlideshowVO {
+  id: string;
+  ossId: string;
+  /** 翻译后的URL */
+  ossUrl: string;
+  createTime: string;
+  updateTime: string;
+}
+
+export interface SlideshowForm {
+  id: string;
+  ossId: string;
+}

+ 242 - 60
src/views/customer/index.vue

@@ -60,33 +60,55 @@
         v-model:limit="queryParams.pageSize" @pagination="getList" />
     </el-card>
 
-    <!-- 授权客户对话框(支持多选) -->
-    <el-dialog v-model="authDialog.visible" title="授权客户" width="900px" append-to-body>
-      <el-form :model="erpQueryParams" ref="erpQueryFormRef" :inline="true" label-width="68px">
-        <el-form-item label="客户名称" prop="name">
-          <el-input v-model="erpQueryParams.name" placeholder="请输入客户名称" clearable style="width: 240px"
-            @keyup.enter="getErpList" />
-        </el-form-item>
-        <el-form-item>
-          <el-button type="primary" icon="Search" @click="getErpList">搜索</el-button>
-          <el-button icon="Refresh" @click="resetErpQuery">重置</el-button>
-        </el-form-item>
-      </el-form>
-      <el-table v-loading="erpLoading" :data="erpList" @selection-change="handleErpSelectionChange" ref="erpTableRef">
-        <el-table-column type="selection" width="50" align="center" />
-        <el-table-column label="代码" align="center" prop="num" width="100"
-          :formatter="(row: ErpClientVO) => row.num || '-'" />
-        <el-table-column label="名称" align="left" prop="name" min-width="180" :show-overflow-tooltip="true"
-          :formatter="(row: ErpClientVO) => row.name || '-'" />
-        <el-table-column label="客户类型" align="center" prop="clientClass" width="100"
-          :formatter="(row: ErpClientVO) => row.clientClass || '-'" />
-        <el-table-column label="加入名称" align="center" prop="enterName" width="100"
-          :formatter="(row: ErpClientVO) => row.enterName || '-'" />
-        <el-table-column label="加入时间" align="center" prop="enterDate" width="170"
-          :formatter="(row: ErpClientVO) => row.enterDate || '-'" />
-      </el-table>
-      <pagination v-show="erpTotal > 0" v-model:page="erpQueryParams.pageNum" v-model:limit="erpQueryParams.pageSize"
-        :total="erpTotal" layout="total, prev, pager, next" @pagination="getErpList" />
+    <!-- 授权客户对话框 -->
+    <el-dialog v-model="authDialog.visible" title="授权客户" width="700px" append-to-body @opened="initAuthDialog">
+      <!-- 已选中客户卡片 -->
+      <div class="selected-header">
+        <span class="section-label">已授权客户</span>
+        <span class="selected-count">共 {{ selectedAuthClients.length }} 家</span>
+      </div>
+      <div v-if="selectedAuthClients.length > 0" class="selected-card-grid">
+        <div v-for="(client, idx) in selectedAuthClients" :key="idx" class="selected-client-card">
+          <span class="card-index">{{ idx + 1 }}</span>
+          <span class="card-name">{{ client.name }}</span>
+          <el-icon class="card-remove" @click="removeAuthClient(idx)">
+            <Close />
+          </el-icon>
+        </div>
+      </div>
+      <el-empty v-else description="暂无已授权客户" :image-size="40" />
+
+      <el-divider />
+
+      <!-- 搜索区 -->
+      <div class="search-row">
+        <el-input v-model="authSearchKeyword" placeholder="输入客户名称搜索" clearable @keyup.enter="handleAuthSearch"
+          style="flex: 1">
+          <template #append>
+            <el-button :loading="authSearchLoading" icon="Search" @click="handleAuthSearch">搜索</el-button>
+          </template>
+        </el-input>
+      </div>
+
+      <!-- 搜索结果 -->
+      <div v-if="authSearchResults.length > 0" class="search-results">
+        <div class="section-label mb-2">搜索结果</div>
+        <div v-for="item in authSearchResults" :key="item.rowId" class="search-result-item"
+          :class="{ disabled: isAuthClientSelected(item.rowId) }" @click="addAuthClient(item)">
+          <div class="result-info">
+            <span class="result-name">{{ item.name }}</span>
+            <span class="result-meta">{{ item.num || '-' }} | {{ item.clientClass || '-' }}</span>
+          </div>
+          <el-icon v-if="isAuthClientSelected(item.rowId)" class="result-check">
+            <CircleCheckFilled />
+          </el-icon>
+          <el-icon v-else class="result-add">
+            <CirclePlus />
+          </el-icon>
+        </div>
+      </div>
+      <el-empty v-else-if="authSearched" description="未找到匹配的客户" :image-size="40" />
+
       <template #footer>
         <div class="dialog-footer">
           <el-button :loading="buttonLoading" type="primary" icon="Check" @click="confirmAuth">确 定</el-button>
@@ -123,7 +145,7 @@
             <span class="client-index">{{ idx + 1 }}</span>
             <div class="client-info">
               <div class="client-row"><span class="client-label">名称</span><span class="client-value">{{ client.name
-              }}</span></div>
+                  }}</span></div>
               <div class="client-row"><span class="client-label">类型</span><span class="client-value">{{
                 client.clientClass || '-' }}</span></div>
               <div class="client-row"><span class="client-label">加入时间</span><span class="client-value">{{
@@ -140,8 +162,8 @@
 <script setup name="Customer" lang="ts">
 import { listCustomer, getCustomerDetail, authCustomer, changeCustomerStatus } from '@/api/system/customer';
 import { CustomerVO, CustomerQuery, CustomerForm, ErpClientBriefVO } from '@/api/system/customer/types';
-import { listErpClient } from '@/api/erp/client';
-import { ErpClientVO, ErpClientQuery } from '@/api/erp/client/types';
+import { searchErpClient } from '@/api/erp/client';
+import { ErpClientVO } from '@/api/erp/client/types';
 import { checkPermi } from '@/utils/permission';
 
 /** @Author: Antigravity */
@@ -162,17 +184,11 @@ const authDialog = reactive({
   visible: false,
   customerId: undefined as string | number | undefined
 });
-const erpLoading = ref(false);
-const erpList = ref<ErpClientVO[]>([]);
-const erpTotal = ref(0);
-const erpQueryParams = reactive<ErpClientQuery>({
-  pageNum: 1,
-  pageSize: 10,
-  name: undefined
-});
-const selectedErpClients = ref<ErpClientVO[]>([]);
-const erpTableRef = ref();
-const erpQueryFormRef = ref<ElFormInstance>();
+const authSearchKeyword = ref('');
+const authSearchLoading = ref(false);
+const authSearchResults = ref<ErpClientVO[]>([]);
+const authSearched = ref(false);
+const selectedAuthClients = ref<ErpClientVO[]>([]);
 
 // 详情对话框
 const detailDialog = reactive({
@@ -252,40 +268,74 @@ const handleStatusChange = async (row: CustomerVO & { _statusActive: boolean; _s
 const handleAuth = (row: CustomerVO) => {
   authDialog.customerId = row.id;
   authDialog.visible = true;
-  selectedErpClients.value = [];
-  resetErpQuery();
 };
 
-/** 获取ERP客户列表 */
-const getErpList = async () => {
-  erpLoading.value = true;
-  const res = await listErpClient(erpQueryParams);
-  erpList.value = res.rows;
-  erpTotal.value = res.total;
-  erpLoading.value = false;
+/** 弹窗打开时初始化 */
+const initAuthDialog = async () => {
+  authSearchKeyword.value = '';
+  authSearchResults.value = [];
+  authSearched.value = false;
+  selectedAuthClients.value = [];
+  if (!authDialog.customerId) return;
+  try {
+    const res = await getCustomerDetail(authDialog.customerId);
+    if (res.data?.authClientList) {
+      selectedAuthClients.value = res.data.authClientList.map(c => ({
+        rowId: c.rowId,
+        name: c.name,
+        num: c.rowId,
+        clientClass: c.clientClass
+      } as ErpClientVO));
+    }
+  } catch (e) {
+    console.error('加载已授权客户失败', e);
+  }
 };
 
-/** 重置ERP查询 */
-const resetErpQuery = () => {
-  erpQueryFormRef.value?.resetFields();
-  erpQueryParams.pageNum = 1;
-  getErpList();
+/** 搜索ERP客户 */
+const handleAuthSearch = async () => {
+  const keyword = authSearchKeyword.value?.trim();
+  if (!keyword) {
+    proxy?.$modal.msgWarning('请输入客户名称');
+    return;
+  }
+  authSearchLoading.value = true;
+  authSearched.value = true;
+  try {
+    const res = await searchErpClient(keyword);
+    authSearchResults.value = res.data || [];
+  } catch (e) {
+    authSearchResults.value = [];
+  } finally {
+    authSearchLoading.value = false;
+  }
 };
 
-/** ERP客户多选选中 */
-const handleErpSelectionChange = (val: ErpClientVO[]) => {
-  selectedErpClients.value = val;
+/** 判断客户是否已选中 */
+const isAuthClientSelected = (rowId: string) => {
+  return selectedAuthClients.value.some(c => c.rowId === rowId);
+};
+
+/** 添加客户到已选列表 */
+const addAuthClient = (client: ErpClientVO) => {
+  if (isAuthClientSelected(client.rowId)) return;
+  selectedAuthClients.value.push({ ...client });
+};
+
+/** 从已选列表移除客户 */
+const removeAuthClient = (idx: number) => {
+  selectedAuthClients.value.splice(idx, 1);
 };
 
 /** 确认授权 */
 const confirmAuth = async () => {
-  if (!selectedErpClients.value || selectedErpClients.value.length === 0) {
+  if (!selectedAuthClients.value || selectedAuthClients.value.length === 0) {
     proxy?.$modal.msgError('请至少选择一个ERP客户');
     return;
   }
   buttonLoading.value = true;
   try {
-    const authClientFRowIDs = selectedErpClients.value.map(c => c.rowId).join(',');
+    const authClientFRowIDs = selectedAuthClients.value.map(c => c.rowId).join(',');
     await authCustomer(authDialog.customerId!, authClientFRowIDs);
     proxy?.$modal.msgSuccess('授权成功');
     authDialog.visible = false;
@@ -314,6 +364,138 @@ onMounted(() => {
 </script>
 
 <style scoped>
+/* ========== 授权对话框 ========== */
+.selected-header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  margin-bottom: 12px;
+}
+
+.section-label {
+  font-size: 14px;
+  font-weight: 600;
+  color: #303133;
+}
+
+.selected-count {
+  font-size: 12px;
+  color: #909399;
+}
+
+.selected-card-grid {
+  display: grid;
+  grid-template-columns: repeat(4, 1fr);
+  gap: 10px;
+}
+
+.selected-client-card {
+  display: flex;
+  align-items: center;
+  gap: 6px;
+  background: #f0f9eb;
+  border: 1px solid #c2e7b0;
+  border-radius: 6px;
+  padding: 6px 10px;
+  font-size: 12px;
+  overflow: hidden;
+}
+
+.card-index {
+  width: 18px;
+  height: 18px;
+  background: #67C23A;
+  color: #fff;
+  border-radius: 50%;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-size: 11px;
+  font-weight: 600;
+  flex-shrink: 0;
+}
+
+.card-name {
+  flex: 1;
+  color: #303133;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.card-remove {
+  color: #909399;
+  cursor: pointer;
+  flex-shrink: 0;
+  font-size: 14px;
+}
+
+.card-remove:hover {
+  color: #F56C6C;
+}
+
+.search-row {
+  display: flex;
+}
+
+.search-results {
+  margin-top: 12px;
+  max-height: 260px;
+  overflow-y: auto;
+}
+
+.search-result-item {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 10px 14px;
+  border: 1px solid #ebeef5;
+  border-radius: 6px;
+  margin-bottom: 8px;
+  cursor: pointer;
+  transition: all 0.2s;
+}
+
+.search-result-item:hover {
+  border-color: #409EFF;
+  background: #ecf5ff;
+}
+
+.search-result-item.disabled {
+  opacity: 0.5;
+  cursor: not-allowed;
+  border-color: #c2e7b0;
+  background: #f0f9eb;
+}
+
+.result-info {
+  display: flex;
+  flex-direction: column;
+  gap: 2px;
+}
+
+.result-name {
+  font-size: 14px;
+  font-weight: 500;
+  color: #303133;
+}
+
+.result-meta {
+  font-size: 12px;
+  color: #909399;
+}
+
+.result-add {
+  color: #409EFF;
+  font-size: 20px;
+}
+
+.result-check {
+  color: #67C23A;
+  font-size: 20px;
+}
+
+/* ========== 详情弹窗 ========== */
 .section-title {
   font-size: 14px;
   font-weight: 600;

+ 266 - 0
src/views/system/applet/index/index.vue

@@ -0,0 +1,266 @@
+<template>
+    <div class="p-2">
+        <el-card shadow="never">
+            <template #header>
+                <span class="font-bold text-base">小程序首页配置</span>
+            </template>
+
+            <el-tabs v-model="activeTab">
+                <el-tab-pane label="轮播图管理" name="slideshow" />
+                <el-tab-pane label="精选分类管理" name="categories" />
+            </el-tabs>
+
+            <!-- ========== 轮播图管理 ========== -->
+            <div v-if="activeTab === 'slideshow'" v-loading="slideshowLoading">
+                <el-button type="primary" icon="Plus" class="mb-3" @click="handleSlideshowAdd">新增轮播图</el-button>
+                <el-table :data="slideshowList" border stripe>
+                    <el-table-column label="序号" type="index" width="60" align="center" />
+                    <el-table-column label="预览" align="center" width="120">
+                        <template #default="scope">
+                            <el-image v-if="scope.row.ossUrl" :src="scope.row.ossUrl" fit="cover"
+                                style="width: 80px; height: 60px; border-radius: 4px"
+                                :preview-src-list="[scope.row.ossUrl]" />
+                            <span v-else class="text-gray-400">暂无图片</span>
+                        </template>
+                    </el-table-column>
+                    <el-table-column prop="ossId" label="OSS资源ID" align="center" />
+                    <el-table-column prop="createTime" label="创建时间" align="center" />
+                    <el-table-column label="操作" align="center" width="180">
+                        <template #default="scope">
+                            <el-button link type="warning" icon="Edit"
+                                @click="handleSlideshowEdit(scope.row)">编辑</el-button>
+                            <el-button link type="danger" icon="Delete"
+                                @click="handleSlideshowDelete(scope.row)">删除</el-button>
+                        </template>
+                    </el-table-column>
+                </el-table>
+            </div>
+
+            <!-- ========== 精选分类管理 ========== -->
+            <div v-if="activeTab === 'categories'" v-loading="categoriesLoading">
+                <el-alert title="精选分类固定为四项,仅可修改不可增删" type="info" :closable="false" show-icon class="mb-3" />
+                <el-row :gutter="16">
+                    <el-col :span="6" v-for="item in categoriesList" :key="item.id">
+                        <el-card shadow="hover" class="category-card mb-3 cursor-pointer"
+                            @click="handleCategoriesEdit(item)">
+                            <div class="category-preview">
+                                <el-image v-if="item.backgroundUrl" :src="item.backgroundUrl" fit="cover"
+                                    style="width: 100%; height: 120px; border-radius: 8px" />
+                                <div v-else class="category-placeholder">
+                                    <el-icon :size="40">
+                                        <Picture />
+                                    </el-icon>
+                                    <span class="text-gray-400">暂无背景图</span>
+                                </div>
+                            </div>
+                            <div class="category-info mt-2">
+                                <div class="font-bold text-base">{{ item.title }}</div>
+                                <div class="text-sm text-gray-500 mt-1">{{ item.remark || '暂无备注' }}</div>
+                            </div>
+                        </el-card>
+                    </el-col>
+                </el-row>
+            </div>
+        </el-card>
+
+        <!-- ========== 轮播图弹窗 ========== -->
+        <el-dialog v-model="slideshowDialog.visible" :title="slideshowDialog.title" width="500px" append-to-body
+            @close="resetSlideshowForm">
+            <el-form ref="slideshowFormRef" :model="slideshowForm" :rules="slideshowRules" label-width="90px">
+                <el-form-item label="轮播图片" prop="ossId">
+                    <image-upload v-model="slideshowForm.ossId" :limit="1" />
+                </el-form-item>
+            </el-form>
+            <template #footer>
+                <el-button :loading="slideshowDialog.loading" type="primary" icon="Check"
+                    @click="submitSlideshowForm">确定</el-button>
+                <el-button icon="Close" @click="slideshowDialog.visible = false">取消</el-button>
+            </template>
+        </el-dialog>
+
+        <!-- ========== 分类编辑弹窗 ========== -->
+        <el-dialog v-model="categoriesDialog.visible" :title="'编辑分类:' + categoriesForm.title" width="550px"
+            append-to-body @close="resetCategoriesForm">
+            <el-form ref="categoriesFormRef" :model="categoriesForm" :rules="categoriesRules" label-width="90px">
+                <el-form-item label="分类标题" prop="title">
+                    <el-input v-model="categoriesForm.title" placeholder="请输入分类标题" />
+                </el-form-item>
+                <el-form-item label="备注说明" prop="remark">
+                    <el-input v-model="categoriesForm.remark" type="textarea" :rows="3" placeholder="请输入备注说明" />
+                </el-form-item>
+                <el-form-item label="背景图" prop="background">
+                    <image-upload v-model="categoriesForm.background" :limit="1" />
+                </el-form-item>
+            </el-form>
+            <template #footer>
+                <el-button :loading="categoriesDialog.loading" type="primary" icon="Check"
+                    @click="submitCategoriesForm">确定</el-button>
+                <el-button icon="Close" @click="categoriesDialog.visible = false">取消</el-button>
+            </template>
+        </el-dialog>
+    </div>
+</template>
+
+<script setup lang="ts">
+import {
+    listSlideshow, getSlideshow, addSlideshow, editSlideshow, delSlideshow
+} from '@/api/system/applet/slideshow';
+import { SlideshowVO, SlideshowForm } from '@/api/system/applet/slideshow/types';
+import {
+    listCategories, getCategories, editCategories
+} from '@/api/system/applet/categories';
+import { CategoriesVO, CategoriesForm } from '@/api/system/applet/categories/types';
+
+const { proxy } = getCurrentInstance() as ComponentInternalInstance;
+
+const activeTab = ref<string>('slideshow');
+
+/* ==================== 轮播图 ==================== */
+const slideshowLoading = ref(false);
+const slideshowList = ref<SlideshowVO[]>([]);
+
+const initSlideshowForm: SlideshowForm = { id: '', ossId: '' };
+const slideshowForm = reactive<SlideshowForm>({ ...initSlideshowForm });
+const slideshowFormRef = ref<ElFormInstance>();
+const slideshowDialog = reactive({ visible: false, title: '', loading: false });
+
+const slideshowRules = {
+    ossId: [{ required: true, message: '请上传轮播图片', trigger: 'change' }]
+};
+
+const loadSlideshowList = async () => {
+    slideshowLoading.value = true;
+    try {
+        const res = await listSlideshow();
+        slideshowList.value = res.data || [];
+    } finally {
+        slideshowLoading.value = false;
+    }
+};
+
+const handleSlideshowAdd = () => {
+    resetSlideshowForm();
+    slideshowDialog.title = '新增轮播图';
+    slideshowDialog.visible = true;
+};
+
+const handleSlideshowEdit = async (row: SlideshowVO) => {
+    const res = await getSlideshow(row.id);
+    Object.assign(slideshowForm, { id: res.data.id, ossId: res.data.ossId || '' });
+    slideshowDialog.title = '编辑轮播图';
+    slideshowDialog.visible = true;
+};
+
+const handleSlideshowDelete = async (row: SlideshowVO) => {
+    await proxy?.$modal.confirm(`确认删除该轮播图吗?`);
+    await delSlideshow(row.id);
+    proxy?.$modal.msgSuccess('删除成功');
+    loadSlideshowList();
+};
+
+const submitSlideshowForm = async () => {
+    const valid = await slideshowFormRef.value?.validate().catch(() => false);
+    if (!valid) return;
+    slideshowDialog.loading = true;
+    try {
+        if (slideshowForm.id) {
+            await editSlideshow(slideshowForm);
+        } else {
+            await addSlideshow(slideshowForm);
+        }
+        proxy?.$modal.msgSuccess('保存成功');
+        slideshowDialog.visible = false;
+        loadSlideshowList();
+    } finally {
+        slideshowDialog.loading = false;
+    }
+};
+
+const resetSlideshowForm = () => {
+    Object.assign(slideshowForm, initSlideshowForm);
+    slideshowFormRef.value?.resetFields();
+};
+
+/* ==================== 精选分类 ==================== */
+const categoriesLoading = ref(false);
+const categoriesList = ref<CategoriesVO[]>([]);
+
+const initCategoriesForm: CategoriesForm = { id: '', title: '', remark: '', background: '' };
+const categoriesForm = reactive<CategoriesForm>({ ...initCategoriesForm });
+const categoriesFormRef = ref<ElFormInstance>();
+const categoriesDialog = reactive({ visible: false, loading: false });
+
+const categoriesRules = {
+    title: [{ required: true, message: '分类标题不能为空', trigger: 'blur' }]
+};
+
+const loadCategoriesList = async () => {
+    categoriesLoading.value = true;
+    try {
+        const res = await listCategories();
+        categoriesList.value = res.data || [];
+    } finally {
+        categoriesLoading.value = false;
+    }
+};
+
+const handleCategoriesEdit = async (item: CategoriesVO) => {
+    const res = await getCategories(item.id);
+    Object.assign(categoriesForm, {
+        ...res.data,
+        background: res.data.background || ''
+    });
+    categoriesDialog.visible = true;
+};
+
+const submitCategoriesForm = async () => {
+    const valid = await categoriesFormRef.value?.validate().catch(() => false);
+    if (!valid) return;
+    categoriesDialog.loading = true;
+    try {
+        await editCategories(categoriesForm);
+        proxy?.$modal.msgSuccess('保存成功');
+        categoriesDialog.visible = false;
+        loadCategoriesList();
+    } finally {
+        categoriesDialog.loading = false;
+    }
+};
+
+const resetCategoriesForm = () => {
+    Object.assign(categoriesForm, initCategoriesForm);
+    categoriesFormRef.value?.resetFields();
+};
+
+/** tab切换时加载对应数据 */
+watch(activeTab, (val) => {
+    if (val === 'slideshow') loadSlideshowList();
+    else if (val === 'categories') loadCategoriesList();
+});
+
+onMounted(() => {
+    loadSlideshowList();
+});
+</script>
+
+<style scoped>
+.category-card {
+    transition: transform 0.2s;
+}
+
+.category-card:hover {
+    transform: translateY(-2px);
+}
+
+.category-placeholder {
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+    justify-content: center;
+    height: 120px;
+    background: #f5f7fa;
+    border-radius: 8px;
+    color: #c0c4cc;
+    gap: 6px;
+}
+</style>