瀏覽代碼

素材列表页面

Alex 11 月之前
父節點
當前提交
8f65a536f8

+ 12 - 0
src/api/system/file/index.js

@@ -193,3 +193,15 @@ export async function removeUserFile(id) {
   console.log('removeUserFile:', id);
   return Promise.reject(new Error('没有访问权限'));
 }
+
+/**
+ * 分页查询素材库文件
+ */
+export async function fetchSourcePageList(params) {
+  const res = await request.get('/baseinfo/source/pagelist', { params });
+  console.log('res:', res);
+  if (res.data.code === 200) {
+    return res.data;
+  }
+  return Promise.reject(new Error(res.data.msg || '获取数据失败'));
+}

+ 281 - 0
src/views/marketing/dialog/components/DialogEdit.vue

@@ -0,0 +1,281 @@
+<template>
+  <el-dialog
+    v-model="visible"
+    :title="form.id ? '编辑弹窗' : '弹窗广告设置'"
+    width="800px"
+    destroy-on-close
+    @closed="handleClose"
+    top="5vh"
+  >
+    <el-form :model="form" ref="formRef" label-width="120px" :rules="rules">
+      <el-form-item label="名称:" prop="name">
+        <el-input v-model="form.name" placeholder="请输入"></el-input>
+      </el-form-item>
+
+      <el-form-item label="弹窗图片:" prop="imageUrl">
+        <ImageUpload
+          v-model="form.imageUrl"
+          :limit="1"
+          :drag="true"
+          accept="image/*"
+          tips="建议尺寸200*350"
+        />
+      </el-form-item>
+
+      <el-form-item label="跳转页面:" prop="jumpPage">
+        <el-input v-model="form.jumpPage" placeholder="请输入"></el-input>
+      </el-form-item>
+
+      <el-form-item label="投放端:" prop="plat">
+        <el-select
+          v-model="form.plat"
+          placeholder="请选择投放端"
+          style="width: 100%"
+        >
+          <el-option label="微信" value="1" />
+          <el-option label="支付宝" value="2" />
+        </el-select>
+      </el-form-item>
+
+      <el-form-item label="投放页面:" prop="deliveryPage">
+        <el-select
+          v-model="form.deliveryPage"
+          placeholder="请选择投放页面"
+          style="width: 100%"
+        >
+          <el-option label="首页" value="1" />
+          <el-option label="订单完成页" value="2" />
+        </el-select>
+      </el-form-item>
+
+      <el-form-item label="关闭设置:" prop="userClose">
+        <div class="close-settings">
+          <el-checkbox v-model="form.userClose" true-value="1" false-value="2"
+            >用户手动关闭</el-checkbox
+          >
+          <div class="auto-close-section">
+            <el-radio v-model="form.autoClose" :true-value="1" :false-value="2"
+              >显示</el-radio
+            >
+            <el-select
+              v-model="form.autoCloseTime"
+              placeholder="5秒"
+              :disabled="form.autoClose == 2"
+              style="width: 100px"
+            >
+              <el-option label="5秒" :value="5" />
+              <el-option label="10秒" :value="10" />
+              <el-option label="15秒" :value="15" />
+              <el-option label="30秒" :value="30" />
+            </el-select>
+            <span class="auto-close-text">后自动关闭</span>
+          </div>
+        </div>
+      </el-form-item>
+
+      <el-form-item label="出现频次:" prop="frequency">
+        <el-radio-group v-model="form.frequency">
+          <div class="frequency-settings">
+            <el-radio :value="1">首次访问时出现,之后不出现</el-radio>
+            <div class="interval-section">
+              <el-radio :value="2">间隔</el-radio>
+
+              <div style="display: flex; align-items: center">
+                <el-select
+                  v-model="form.frequencyTime"
+                  placeholder="24小时"
+                  :disabled="form.frequency == 1"
+                  style="width: 120px"
+                >
+                  <el-option label="24小时" :value="24" />
+                  <el-option label="48小时" :value="48" />
+                  <el-option label="72小时" :value="72" />
+                </el-select>
+                <div style="margin-left: 5px; width: fit-content"
+                  >后再次出现</div
+                >
+              </div>
+            </div>
+          </div>
+        </el-radio-group>
+      </el-form-item>
+
+      <el-form-item label="起止时间:" prop="timeRange">
+        <el-date-picker
+          v-model="form.timeRange"
+          type="datetimerange"
+          range-separator="至"
+          start-placeholder="开始日期时间"
+          end-placeholder="结束日期时间"
+          value-format="YYYY-MM-DD HH:mm:ss"
+          format="YYYY-MM-DD HH:mm:ss"
+          style="width: 100%"
+        />
+      </el-form-item>
+    </el-form>
+
+    <template #footer>
+      <div class="dialog-footer">
+        <el-button @click="visible = false">取消</el-button>
+        <el-button type="primary" @click="handleSubmit" :loading="loading">
+          {{ form.id ? '保存' : '创建' }}
+        </el-button>
+      </div>
+    </template>
+  </el-dialog>
+</template>
+
+<script setup>
+  import { ref, reactive, computed } from 'vue';
+  import { EleMessage } from 'ele-admin-plus/es';
+  import request from '@/utils/request';
+  import ImageUpload from '@/components/ImageUpload/index.vue';
+  import dayjs from 'dayjs';
+
+  defineOptions({ name: 'DialogEdit' });
+
+  const emit = defineEmits(['success']);
+  const visible = ref(false);
+  const loading = ref(false);
+  const formRef = ref(null);
+
+  let defaultForm = {
+    id: '',
+    name: '',
+    jumpPage: '',
+    plat: '',
+    deliveryPage: '',
+    userClose: '1',
+    autoClose: 2,
+    autoCloseTime: 5,
+    frequency: 1,
+    frequencyTime: 24,
+    status: 1,
+    timeRange: [],
+    imageUrl: ''
+  };
+
+  // 表单数据
+  const form = reactive(defaultForm);
+
+  // 表单验证规则
+  const rules = {
+    name: [{ required: true, message: '请输入弹窗名称', trigger: 'blur' }],
+    jumpPage: [{ required: true, message: '请输入跳转页面', trigger: 'blur' }],
+    plat: [{ required: true, message: '请选择投放端', trigger: 'change' }],
+    deliveryPage: [
+      { required: true, message: '请选择投放页面', trigger: 'change' }
+    ],
+    timeRange: [
+      { required: true, message: '请选择起止时间', trigger: 'change' }
+    ],
+    imageUrl: [
+      { required: true, message: '请上传弹窗图片', trigger: 'change' }
+    ],
+    frequency: [
+      { required: true, message: '请选择出现频次', trigger: 'change' }
+    ]
+  };
+
+  // 打开弹窗
+  function handleOpen(data) {
+    // 重置表单为默认值
+    Object.assign(form, defaultForm);
+
+    // 如果有数据,则填充表单
+    if (data) {
+      // 设置基本字段
+      Object.assign(form, data);
+
+      // 设置时间范围
+      if (data.startTime && data.endTime) {
+        form.timeRange = [data.startTime, data.endTime];
+      }
+    }
+
+    visible.value = true;
+  }
+
+  // 关闭弹窗
+  function handleClose() {
+    formRef.value?.resetFields();
+  }
+
+  // 提交表单
+  function handleSubmit() {
+    formRef.value?.validate((valid) => {
+      if (!valid) return;
+
+      loading.value = true;
+
+      // 根据是否有ID决定是新增还是修改
+      const url = form.id ? '/sys/dialogInfo/update' : '/sys/dialogInfo/add';
+
+      // 构建符合API要求的参数对象
+      const params = JSON.parse(JSON.stringify(form));
+      params.startTime =
+        form.timeRange && form.timeRange.length > 0 ? form.timeRange[0] : '';
+      params.endTime =
+        form.timeRange && form.timeRange.length > 0 ? form.timeRange[1] : '';
+      params.status = 1;
+
+      request
+        .post(url, params)
+        .then((res) => {
+          loading.value = false;
+          if (res.data.code === 200) {
+            EleMessage.success('保存成功');
+            visible.value = false;
+            emit('success');
+          } else {
+            EleMessage.error(res.data.msg || '保存失败');
+          }
+        })
+        .catch(() => {
+          loading.value = false;
+        });
+    });
+  }
+
+  // 暴露方法给父组件调用
+  defineExpose({
+    handleOpen
+  });
+</script>
+
+<style scoped>
+  .close-settings {
+    display: flex;
+    align-items: center;
+    gap: 20px;
+  }
+
+  .auto-close-section {
+    display: flex;
+    align-items: center;
+    gap: 10px;
+  }
+
+  .auto-close-text {
+    margin-left: 5px;
+  }
+
+  .frequency-settings {
+    display: flex;
+    flex-direction: column;
+    gap: 15px;
+  }
+
+  .interval-section {
+    display: flex;
+    align-items: center;
+    gap: 10px;
+  }
+
+  .dialog-footer {
+    display: flex;
+    justify-content: center;
+    gap: 20px;
+    margin-top: 20px;
+  }
+</style>

+ 300 - 2
src/views/marketing/dialog/index.vue

@@ -1,4 +1,302 @@
 <template>
-  <div> 弹窗管理 </div>
+  <ele-page flex-table>
+    <common-table ref="pageRef" :pageConfig="pageConfig" :columns="columns">
+      <template #toolbar>
+        <div style="display: flex; align-items: center; gap: 10px">
+          <el-button
+            type="primary"
+            plain
+            :icon="PlusOutlined"
+            @click="handleUpdate()"
+          >
+            新建弹窗
+          </el-button>
+
+          <div style="display: flex; align-items: center; gap: 10px">
+            <el-input
+              v-model="search"
+              placeholder="请输入弹窗名称"
+              style="width: 300px"
+            />
+            <el-select
+              v-model="deliveryPage"
+              placeholder="请选择投放页面"
+              clearable
+              style="width: 300px"
+            >
+              <el-option label="首页" value="1" />
+              <el-option label="订单完成页" value="2" />
+              <!-- 可以根据实际需求添加更多选项 -->
+            </el-select>
+            <el-button type="primary" @click="reload()" style="width: 90px">查询</el-button>
+            <el-button type="primary" plain @click="reset" style="width: 90px">重置</el-button>
+          </div>
+        </div>
+      </template>
+
+      <template #status="{ row }">
+        <el-tag :type="row.status === '进行中' ? 'success' : 'info'">
+          {{ row.status }}
+        </el-tag>
+      </template>
+
+      <template #afterCloseType="{ row }">
+        <el-tag
+          :type="row.autoClose === '用户手动关闭' ? 'primary' : 'warning'"
+        >
+          {{ row.autoClose }}
+        </el-tag>
+      </template>
+
+      <template #time="{ row }">
+        <div>
+          <div>起:{{ row.startTime }}</div>
+          <div>止:{{ row.endTime }}</div>
+        </div>
+      </template>
+
+      <template #action="{ row }">
+        <div>
+          <el-button type="primary" link @click="handleUpdate(row)">
+            [编辑]
+          </el-button>
+          <el-button
+            :type="row.status === '进行中' ? 'warning' : 'success'"
+            link
+            @click="handleStatusUpdate(row)"
+          >
+            [{{ row.status === '进行中' ? '停用' : '启用' }}]
+          </el-button>
+          <el-button type="danger" link @click="handleDelete(row)">
+            [删除]
+          </el-button>
+        </div>
+      </template>
+    </common-table>
+
+    <!-- 弹窗编辑组件 -->
+    <dialog-edit ref="dialogEditRef" @success="handleDialogSuccess" />
+  </ele-page>
 </template>
-<script setup></script>
+
+<script setup>
+  import { ref, reactive } from 'vue';
+  import { ElMessageBox } from 'element-plus/es';
+  import { EleMessage } from 'ele-admin-plus/es';
+  import { PlusOutlined, DeleteOutlined } from '@/components/icons';
+  import CommonTable from '@/components/CommonPage/CommonTable.vue';
+  import DialogEdit from './components/DialogEdit.vue';
+  import request from '@/utils/request';
+
+  const search = ref('');
+  const deliveryPage = ref('');
+
+  const reset = () => {
+    search.value = '';
+    deliveryPage.value = '';
+    reload();
+  };
+
+  defineOptions({ name: 'DialogManage' });
+
+  /** 表格列配置 */
+  const columns = ref([
+    {
+      type: 'selection',
+      columnKey: 'selection',
+      width: 50,
+      align: 'center',
+      fixed: 'left'
+    },
+    {
+      label: '名称',
+      prop: 'name',
+      align: 'center',
+      minWidth: 120
+    },
+    {
+      label: '跳转页面',
+      prop: 'jumpPage',
+      align: 'center',
+      minWidth: 160
+    },
+    {
+      label: '投放端',
+      prop: 'plat',
+      align: 'center',
+      minWidth: 100
+    },
+    {
+      label: '投放页面',
+      prop: 'deliveryPage',
+      align: 'center',
+      minWidth: 100
+    },
+    {
+      label: '弹窗关闭设置',
+      prop: 'autoClose',
+      align: 'center',
+      minWidth: 140,
+      slot: 'afterCloseType'
+    },
+    {
+      label: '出现频次',
+      prop: 'frequency',
+      align: 'center',
+      minWidth: 100
+    },
+    {
+      label: '状态',
+      prop: 'status',
+      align: 'center',
+      minWidth: 100,
+      slot: 'status'
+    },
+    {
+      label: '起止时间',
+      prop: 'time',
+      align: 'center',
+      minWidth: 180,
+      slot: 'time'
+    },
+    {
+      columnKey: 'action',
+      label: '操作',
+      width: 160,
+      align: 'center',
+      slot: 'action'
+    }
+  ]);
+
+  /** 页面组件实例 */
+  const pageRef = ref(null);
+  const dialogEditRef = ref(null);
+
+  const pageConfig = reactive({
+    pageUrl: '/sys/dialogInfo/pagelist',
+    fileName: '弹窗管理',
+    cacheKey: 'dialogManageTable'
+  });
+
+  // 刷新表格
+  function reload(where) {
+    pageRef.value?.reload({
+      ...where,
+      name: search.value,
+      deliveryPage: deliveryPage.value
+    });
+  }
+
+  // 处理弹窗成功提交的事件
+  function handleDialogSuccess() {
+    reload();
+  }
+
+  // 编辑/新建弹窗
+  function handleUpdate(row) {
+    if (row?.id) {
+      // 编辑模式,获取弹窗详情
+      request.get(`/sys/dialogInfo/getInfo/${row.id}`).then((res) => {
+        if (res.data.code === 200) {
+          const data = res.data.data;
+          // 转换API返回的数据格式为表单需要的格式
+          const formData = {
+            id: data.id,
+            name: data.name,
+            jumpPage: data.jumpPage,
+            plat: data.plat,
+            deliveryPage: data.deliveryPage,
+            autoClose: data.userClose ? '用户手动关闭' : '自动关闭',
+            frequency: data.frequency,
+            status: data.status === 1 ? '进行中' : '已结束',
+            startTime: data.startTime,
+            endTime: data.endTime,
+            imageUrl: data.img
+          };
+          dialogEditRef.value.handleOpen(formData);
+        } else {
+          EleMessage.error(res.data.msg || '获取弹窗详情失败');
+        }
+      });
+    } else {
+      // 新建模式
+      dialogEditRef.value.handleOpen({
+        id: '',
+        name: '',
+        jumpPage: '',
+        plat: '',
+        deliveryPage: '',
+        autoClose: '用户手动关闭',
+        frequency: '',
+        status: '进行中',
+        startTime: '',
+        endTime: '',
+        imageUrl: ''
+      });
+    }
+  }
+
+  // 删除弹窗
+  function handleDelete(row) {
+    let ids = [];
+    let message = '确认删除该弹窗?';
+
+    if (row?.id) {
+      // 单个删除
+      ids = [row.id];
+    } else {
+      // 批量删除
+      const selections = pageRef.value?.getSelections();
+      if (!selections || selections.length === 0) {
+        EleMessage.warning('请选择要删除的数据');
+        return;
+      }
+      ids = selections.map((item) => item.id);
+      message = `确认删除选中的 ${ids.length} 条数据?`;
+    }
+
+    ElMessageBox.confirm(message, '提示', {
+      confirmButtonText: '确定',
+      cancelButtonText: '取消',
+      type: 'warning'
+    }).then(() => {
+      // 根据API格式,使用正确的删除接口
+      request.post('/sys/dialogInfo/delete', { idList: ids }).then((res) => {
+        if (res.data.code === 200) {
+          EleMessage.success('删除成功');
+          reload();
+        } else {
+          EleMessage.error(res.data.msg);
+        }
+      });
+    });
+  }
+
+  // 处理弹窗状态更新
+  function handleStatusUpdate(row) {
+    const newStatus = row.status === '进行中' ? '已结束' : '进行中';
+    const statusValue = newStatus === '进行中' ? 1 : 0;
+
+    ElMessageBox.confirm(`确认${newStatus === '进行中' ? '启用' : '停用'}该弹窗?`, '提示', {
+      confirmButtonText: '确定',
+      cancelButtonText: '取消',
+      type: 'warning'
+    }).then(() => {
+      request.post('/sys/dialogInfo/updateStatus', {
+        id: row.id,
+        status: statusValue
+      }).then((res) => {
+        if (res.data.code === 200) {
+          EleMessage.success(`${newStatus === '进行中' ? '启用' : '停用'}成功`);
+          reload();
+        } else {
+          EleMessage.error(res.data.msg || '操作失败');
+        }
+      });
+    });
+  }
+</script>
+
+<style scoped>
+  /* 可以根据需要添加样式 */
+</style>

+ 114 - 0
src/views/marketing/material/components/file-group.vue

@@ -0,0 +1,114 @@
+<template>
+  <div class="file-group-container">
+    <el-tabs v-model="materialType" class="file-picker-tabs">
+      <el-tab-pane label="图片" name="1"></el-tab-pane>
+      <el-tab-pane label="视频" name="2"></el-tab-pane>
+    </el-tabs>
+
+    <div
+      v-for="item in groupData"
+      :key="item.id"
+      class="file-group-item"
+      :class="{ 'file-group-item-selected': selectedId === item.id }"
+      @click="handleGroupSelect(item)"
+    >
+      <img src="/ele-file-list/ic_file_folder.png" class="file-group-icon" />
+      <span class="file-group-label" :title="item.name">
+        {{ item.name }}
+      </span>
+    </div>
+  </div>
+</template>
+
+<script setup>
+  import { ref, watch, onMounted, getCurrentInstance } from 'vue';
+  const { proxy } = getCurrentInstance();
+
+  const props = defineProps({
+    /** 选中的分组ID */
+    selectedId: {
+      type: [String, Number],
+      default: '-1'
+    },
+    /** 素材类型 */
+    sourceType: {
+      type: [String, Number],
+      default: 1
+    }
+  });
+
+  const materialType = ref(1);
+  watch(
+    () => props.sourceType,
+    (newVal) => {
+      materialType.value = newVal;
+    }
+  );
+
+  const groupData = ref([]);
+  //获取分组数据
+  const getGroupData = () => {
+    proxy.$http.get(`/system/dict/data/type/material_file`).then((res) => {
+      if (res.data.code === 200) {
+        groupData.value = res.data.data.map((item) => ({
+          ...item,
+          name: item.dictLabel,
+          id: item.dictValue
+        }));
+      }
+    });
+  };
+
+  const emit = defineEmits(['groupSelect', 'hideGroupCtxMenu']);
+
+  /** 分组选中事件 */
+  const handleGroupSelect = (item) => {
+    emit('groupSelect', { ...item, sourceType: materialType.value });
+  };
+
+  onMounted(() => {
+    getGroupData();
+  });
+
+  defineExpose({});
+</script>
+
+<style scoped>
+  .file-group-container {
+    display: flex;
+    flex-direction: column;
+    width: 200px;
+    padding-right: 10px;
+    border-right: 1px solid #e5e5e5;
+  }
+
+  .file-group-item {
+    display: flex;
+    align-items: center;
+    padding: 8px 12px;
+    cursor: pointer;
+    transition: background-color 0.2s;
+  }
+
+  .file-group-item:hover {
+    background-color: #f5f7fa;
+  }
+
+  .file-group-item-selected {
+    background-color: #ecf5ff;
+    color: #409eff;
+    font-weight: 500;
+  }
+
+  .file-group-icon {
+    width: 16px;
+    height: 16px;
+    margin-right: 6px;
+  }
+
+  .file-group-label {
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+  }
+</style>

+ 471 - 0
src/views/marketing/material/components/file-list.vue

@@ -0,0 +1,471 @@
+<template>
+  <div class="file-picker-main">
+    <div class="file-picker-body">
+      <div class="file-picker-toolbar">
+        <ElUpload
+          action=""
+          :accept="accept"
+          :showFileList="false"
+          :beforeUpload="handleUpload"
+        >
+          <ElButton type="primary" class="ele-btn-icon" :icon="UploadOutlined">
+            上传
+          </ElButton>
+        </ElUpload>
+        <div class="file-picker-search">
+          <ElInput
+            :clearable="true"
+            v-model="searchKeyword"
+            placeholder="请输入文件名"
+            @clear="handleSearch"
+            @change="handleSearch"
+          />
+          <ElButton type="primary" @click="handleSearch">搜索</ElButton>
+        </div>
+        <EleSegmented v-model="isGridMode" :items="modeSegmentedItems" />
+      </div>
+      <template v-if="fileData.length">
+        <div class="file-picker-file-list" @scroll="handleFileListScroll">
+          <EleFileList
+            :boxChoose="true"
+            :icons="localIcons"
+            :smallIcons="localSmallIcons"
+            :contextMenuProps="contextMenuProps"
+            v-bind="fileListProps || {}"
+            :data="fileData"
+            :grid="isGridMode === 1"
+            :selectionType="limit === 1 ? 'radio' : 'checkbox'"
+            :selections="fileSelections"
+            v-model:current="fileCurrent"
+            :contextMenus="fileContextMenus"
+            :class="[{ 'is-ping-top': isPingTop }]"
+            @itemClick="handleFileItemClick"
+            @itemContextMenu="handleFileCtxMenuClick"
+            @update:selections="updateSelections"
+            @itemContextOpen="handleFileCtxMenuOpen"
+          />
+        </div>
+        <ElePagination
+          size="small"
+          :teleported="false"
+          :pageSizes="[18, 24, 30, 36, 40, 42]"
+          layout="total,prev,pager,next,sizes"
+          v-bind="paginationProps || {}"
+          :currentPage="currentPage"
+          :pageSize="pageSize"
+          :total="total"
+          @update:currentPage="handleCurrentPageChange"
+          @update:pageSize="handlePageSizeChange"
+        />
+      </template>
+      <ElEmpty
+        v-else
+        :imageSize="80"
+        description="无数据"
+        v-bind="emptyProps || {}"
+      />
+    </div>
+  </div>
+</template>
+
+<script setup>
+  import { ref, reactive, watch, markRaw } from 'vue';
+  import {
+    localIcons,
+    localSmallIcons
+  } from 'ele-admin-plus/es/ele-file-list/icons';
+  import {
+    UploadOutlined,
+    MenuOutlined,
+    AppstoreOutlined,
+    EditOutlined,
+    DeleteOutlined
+  } from '@/components/icons';
+  import { uploadFile, fetchSourcePageList } from '@/api/system/file';
+  import request from '@/utils/request';
+
+  const props = defineProps({
+    /** 最大选择数量 */
+    limit: Number,
+    /** 文件大小限制, 单位MB */
+    fileLimit: Number,
+    /** 接受上传的文件类型 */
+    accept: String,
+    /** 接口查询参数 */
+    params: Object,
+    /** 文件列表自定义属性 */
+    fileListProps: Object,
+    /** 空组件属性 */
+    emptyProps: Object,
+    /** 分页组件属性 */
+    paginationProps: Object,
+    /** 统一设置层级 */
+    baseIndex: Number,
+    /** 消息提示组件 */
+    messageIns: [Object, Function],
+    /** 文件分类 */
+    sourceCate: String,
+    /** 文件类型 */
+    sourceType: String
+  });
+
+  const emit = defineEmits([
+    'queryStart',
+    'queryDone',
+    'renameFile',
+    'moveFile',
+    'removeFile',
+    'fileItemContextOpen'
+  ]);
+
+  /** 当前分组id */
+  const fileParentId = ref();
+
+  /** 文件数据 */
+  const fileData = ref([]);
+
+  /** 选中的文件数据 */
+  const fileSelections = ref([]);
+
+  /** 单选选中的文件数据 */
+  const fileCurrent = ref();
+
+  /** 是否网格模式 */
+  const isGridMode = ref(1);
+
+  /** 搜索关键字 */
+  const searchKeyword = ref('');
+
+  /** 当前页码 */
+  const currentPage = ref(1);
+
+  /** 每页显示数量 */
+  const pageSize = ref(40);
+
+  /** 总数量 */
+  const total = ref(0);
+
+  /** 文件列表是否固定表头 */
+  const isPingTop = ref(false);
+
+  /** 视图模式分段器数据 */
+  const modeSegmentedItems = [
+    { icon: MenuOutlined, value: 0, iconStyle: { transform: 'scale(0.9)' } },
+    { icon: AppstoreOutlined, value: 1 }
+  ];
+
+  /** 文件列表右键菜单属性 */
+  const contextMenuProps = reactive({
+    menuStyle: { minWidth: '120px' },
+    iconProps: { size: 15 },
+    popperOptions: { strategy: 'fixed' },
+    zIndex: props.baseIndex
+  });
+
+  /** 校验最大选择数量 */
+  const checkLimit = (selections, isAdd) => {
+    if (
+      props.limit &&
+      props.limit > 1 &&
+      (isAdd
+        ? selections.length >= props.limit
+        : selections.length > props.limit)
+    ) {
+      props.messageIns?.error?.(`最多只能选择 ${props.limit} 个`);
+      return false;
+    }
+  };
+
+  /** 更新选中数据 */
+  const updateSelections = (selections) => {
+    if (checkLimit(selections) !== false) {
+      fileSelections.value = selections;
+      return;
+    }
+    if (props.limit && props.limit > 1) {
+      fileSelections.value = selections.slice(0, props.limit);
+    }
+  };
+
+  /** 清空选中 */
+  const clearSelections = () => {
+    fileSelections.value = [];
+    fileCurrent.value = void 0;
+  };
+
+  /** 搜索 */
+  const handleSearch = () => {
+    queryData({ pageNum: 1 });
+  };
+
+  /** 切换当前页码 */
+  const handleCurrentPageChange = (page) => {
+    if (currentPage.value !== page) {
+      currentPage.value = page;
+      queryData();
+    }
+  };
+
+  /** 切换每页显示数量 */
+  const handlePageSizeChange = (limit) => {
+    if (pageSize.value !== limit) {
+      currentPage.value = 1;
+      pageSize.value = limit;
+      const maxPage = Math.ceil(total.value / limit);
+      if (maxPage && currentPage.value > maxPage) {
+        currentPage.value = maxPage;
+      }
+      queryData();
+    }
+  };
+
+  /** 文件列表点击事件 */
+  const handleFileItemClick = (item) => {
+    if (props.limit === 1) {
+      fileCurrent.value = item;
+      return;
+    }
+    const index = fileSelections.value.findIndex((d) => d.key === item.key);
+    if (index !== -1) {
+      fileSelections.value.splice(index, 1);
+      return;
+    }
+    if (checkLimit(fileSelections.value, true) !== false) {
+      fileSelections.value.push(item);
+    }
+  };
+
+  /** 文件列表右键菜单点击事件 */
+  const handleFileCtxMenuClick = (option) => {
+    const { key, item } = option;
+    const userFileItem = item.userFile;
+    if (key === 'preview') {
+      if (!item.thumbnail) {
+        window.open(item.url);
+        return;
+      }
+      const data = fileData.value.filter((d) => !!d.thumbnail);
+      const urls = data.map((d) => d.url);
+      const index = data.indexOf(item);
+      if (index !== -1) {
+        window.open(item.url);
+      }
+    } else if (key === 'rename') {
+      emit('renameFile', userFileItem, true);
+    } else if (key === 'move') {
+      emit('moveFile', userFileItem, true);
+    } else if (key === 'remove') {
+      emit('removeFile', userFileItem, true);
+    }
+  };
+
+  /** 文件列表右键菜单数据 */
+  const fileContextMenus = (item) => {
+    const menus = [
+      {
+        title: '重命名',
+        command: 'rename',
+        icon: markRaw(EditOutlined)
+      },
+      {
+        title: '删除',
+        command: 'remove',
+        icon: markRaw(DeleteOutlined),
+        divided: true,
+        danger: true
+      }
+    ];
+    if (item.thumbnail) {
+      menus[0].divided = true;
+      menus.unshift({ title: '预览', command: 'preview' });
+    } else {
+      menus[0].divided = true;
+      menus.unshift({ title: '打开', command: 'preview' });
+    }
+    return menus;
+  };
+
+  /** 文件列表右键菜单打开事件 */
+  const handleFileCtxMenuOpen = () => {
+    emit('fileItemContextOpen');
+  };
+
+  /** 校验选择的文件 */
+  const checkFile = (file) => {
+    if (!file) {
+      return;
+    }
+    if (props.accept === 'image/*') {
+      if (!file.type.startsWith('image')) {
+        props.messageIns?.error?.('只能选择图片');
+        return;
+      }
+    } else if (props.accept === '.xls,.xlsx') {
+      if (
+        ![
+          'application/vnd.ms-excel',
+          'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
+        ].includes(file.type)
+      ) {
+        props.messageIns?.error?.('只能选择 excel 文件');
+        return;
+      }
+    }
+    if (props.fileLimit && file.size / 1024 / 1024 > props.fileLimit) {
+      props.messageIns?.error?.(`大小不能超过 ${props.fileLimit}MB`);
+      return;
+    }
+    return true;
+  };
+
+  /** 文件上传事件 */
+  const handleUpload = (file) => {
+    if (checkFile(file)) {
+      const loading = props.messageIns?.loading?.({
+        message: '上传中..',
+        plain: true,
+        mask: true
+      });
+      uploadFile(file).then((res) => {
+        if (res.code === 200) {
+          file.url = res.url;
+          addFileToDir(file);
+          props.messageIns?.success?.('上传成功');
+        } else {
+          props.messageIns?.error?.(res.msg);
+        }
+      }).finally(() => {
+        loading?.close?.();
+      });
+    }
+    return false;
+  };
+  //新增文件到指定目录
+  const addFileToDir = (file) => {
+    let data = {
+      sourceName: file.name,
+      sourceUrl: file.url,
+      sourceType: props.sourceType,
+      sourceCate: props.sourceCate
+    };
+    request
+      .post('/baseinfo/source/add', data)
+      .then((res) => {
+        if (res.data.code === 200) {
+          props.messageIns?.success?.('上传成功');
+          queryData();
+        } else {
+          props.messageIns?.error?.(res.data.msg);
+        }
+      })
+  };
+
+  /** 文件列表滚动事件 */
+  const handleFileListScroll = (e) => {
+    const wrapEl = e.currentTarget;
+    const scrollTop = wrapEl.scrollTop;
+    isPingTop.value = scrollTop > 1;
+  };
+
+  /** 触发文件数据请求完成 */
+  const handleQueryDone = () => {
+    emit('queryDone');
+  };
+
+  /** 判断是否是图片文件 */
+  const isImageFile = (item) => {
+    // 根据sourceType判断是否为图片,1为图片
+    if (item.sourceType === '1') {
+      return true;
+    }
+    return (
+      typeof item.contentType === 'string' &&
+      item.contentType.startsWith('image/') &&
+      item.sourceUrl
+    );
+  };
+
+  /** 格式化文件大小 */
+  const formatLength = (length) => {
+    if (length == null) {
+      return '-';
+    }
+    if (length < 1024) {
+      return length + 'B';
+    } else if (length < 1024 * 1024) {
+      return (length / 1024).toFixed(1) + 'KB';
+    } else if (length < 1024 * 1024 * 1024) {
+      return (length / 1024 / 1024).toFixed(1) + 'M';
+    } else {
+      return (length / 1024 / 1024 / 1024).toFixed(1) + 'G';
+    }
+  };
+
+  /** 查询文件数据 */
+  const queryData = (params) => {
+    emit('queryStart');
+
+    // 构建请求参数
+    const requestParams = {
+      sourceNameLike: searchKeyword.value,
+      sourceType: props.sourceType,
+      sourceCate: props.sourceCate,
+      pageNum: currentPage.value,
+      pageSize: pageSize.value,
+    };
+
+    // 使用新的API
+    fetchSourcePageList(requestParams)
+      .then((result) => {
+        console.log('result:', result);
+        if (!result?.rows?.length && result?.total) {
+          const maxPage = Math.ceil(result.total / pageSize.value);
+          if (maxPage && currentPage.value > maxPage) {
+            queryData({ pageNum: maxPage });
+            return;
+          }
+        }
+        total.value = result?.total || 0;
+        fileData.value =
+          result?.rows?.map?.((d) => {
+            return {
+              key: d.id,
+              name: d.sourceName,
+              url: d.sourceUrl,
+              thumbnail: isImageFile(d) ? d.sourceUrl : void 0,
+              length: formatLength(d.length),
+              updateTime: d.createTime,
+              isDirectory: false,
+              userFile: {
+                ...d,
+                name: d.sourceName,
+                url: d.sourceUrl
+              }
+            };
+          }) || [];
+        console.log('fileData:', fileData.value);
+        handleQueryDone();
+      })
+      .catch((e) => {
+        fileData.value = [];
+        props.messageIns?.error?.(e.message);
+        handleQueryDone();
+      });
+  };
+
+  watch(
+    () => props.limit,
+    () => {
+      clearSelections();
+    }
+  );
+
+  watch(
+    () => props.baseIndex,
+    (baseIndex) => {
+      contextMenuProps.zIndex = baseIndex;
+    }
+  );
+
+  defineExpose({ queryData, clearSelections });
+</script>

+ 269 - 1
src/views/marketing/material/index.vue

@@ -1,3 +1,271 @@
 <template>
-  <div> 材料管理 </div>
+  <ele-page flex-table>
+    <ele-card flex-table header="营销素材">
+      <div class="flex">
+        <FileGroup
+          :selected-id="searchParams.sourceCate"
+          :source-type="searchParams.sourceType"
+          @groupSelect="handleGroupSelect"
+        />
+        <div class="flex-1 ml-4" v-loading="loading" element-loading-text="上传中...">
+          <div class="flex items-center">
+            <el-button
+              type="primary"
+              v-permission="'data:material:download'"
+              @click="handleDownload"
+              >下载图片</el-button
+            >
+            <el-button
+              type="danger"
+              @click="handleDelete"
+              v-permission="'data:material:delete'"
+              >删除图片</el-button
+            >
+            <el-button
+              type="danger"
+              @click="handleDelete"
+              v-permission="'data:material:batchDelete'"
+              >批量删除</el-button
+            >
+
+            <el-input
+              v-model="searchParams.sourceNameLike"
+              placeholder="请输入图片名称"
+              style="width: 200px; margin: 0 14px 0 30px"
+            ></el-input>
+            <el-button type="primary" @click="getImageList" style="width: 90px"
+              >查询</el-button
+            >
+            <el-button
+              plain
+              type="primary"
+              @click="handleReset"
+              style="width: 90px"
+              >重置</el-button
+            >
+          </div>
+
+          <el-checkbox-group
+            style="height: calc(100vh - 270px)"
+            v-model="checkList"
+            class="flex flex-wrap items-start content-start overflow-auto"
+            @change="handlecheckboxChange"
+          >
+            <el-upload
+              ref="uploadRef"
+              v-model:file-list="form.images"
+              list-type="picture-card"
+              :show-file-list="false"
+              multiple
+              accept="image/*"
+              :http-request="uploadFile"
+              style="margin-right: 14px; margin-top: 12px"
+            >
+              <div class="flex flex-col items-center">
+                <el-icon :size="24"><Plus /></el-icon>
+                <el-text type="info" style="margin-top: 20px">上传图片</el-text>
+              </div>
+            </el-upload>
+            <div
+              class="pos-relative default-image-item"
+              v-for="item in imageList"
+            >
+              <el-checkbox :value="item.id" class="checkbox-item"></el-checkbox>
+              <el-image
+                style="width: 146px; height: 146px; border-radius: 6px"
+                :src="item.sourceUrl"
+                fit="cover"
+              />
+
+              <div
+                class="mask-error flex items-center justify-center"
+                v-if="item.defaultUse == 1"
+              >
+                <el-text type="primary" size="small">默认图片</el-text>
+              </div>
+            </div>
+          </el-checkbox-group>
+
+          <ele-pagination
+            v-model:current-page="pageParams.pageNum"
+            v-model:page-size="pageParams.pageSize"
+            :total="total"
+            layout="total,prev, pager, next, sizes, jumper"
+            @update:currentPage="handleCurrentChange"
+            @update:pageSize="handlePageSizeChange"
+          />
+        </div>
+      </div>
+    </ele-card>
+  </ele-page>
 </template>
+
+<script setup>
+  import { ref, reactive, nextTick } from 'vue';
+  import { Plus } from '@element-plus/icons-vue';
+  import request from '@/utils/request';
+  import { ElMessage, ElMessageBox, ElLoading } from 'element-plus';
+  import FileGroup from './components/file-group.vue';
+
+  const imageList = ref([]);
+  const checkList = ref([]);
+  const total = ref(0);
+  const form = reactive({
+    images: []
+  });
+
+  const searchParams = ref({
+    sourceNameLike: '',
+    sourceType: '1', //1图片 2视频
+    sourceCate: '-1' //-1全部 1素材
+  });
+  const selectedGroupId = ref('-1');
+  const handleGroupSelect = (item) => {
+    selectedGroupId.value = item.id;
+    getImageList();
+  };
+
+  const pageParams = ref({ pageNum: 1, pageSize: 50, ...searchParams.value });
+  const getImageList = (data = {}) => {
+    request
+      .get('/baseinfo/source/pagelist', {
+        params: { ...pageParams.value, ...data }
+      })
+      .then((res) => {
+        if (res.data.code == 200) {
+          imageList.value = res.data.rows;
+          total.value = res.data.total || 0;
+        }
+      });
+  };
+  getImageList();
+  //重置
+  const handleReset = () => {
+    searchParams.value.sourceNameLike = '';
+    getImageList();
+  };
+
+  //页码变化
+  const handleCurrentChange = (val) => {
+    pageParams.value.pageNum = val;
+    getImageList();
+  };
+  //每页显示多少条
+  const handlePageSizeChange = (val) => {
+    pageParams.value.pageSize = val;
+    getImageList();
+  };
+
+  //执行图片上传并新增到列表
+  const loading = ref(false);
+  function uploadFile(file) {
+    loading.value = true;
+    let formData = new FormData();
+    formData.append('file', file.file);
+    request
+      .post('/common/upload', formData, { showTips: false })
+      .then((res) => {
+        if (res.data.code == 200) {
+          handleAddImage(res.data, file.name);
+        }
+      })
+      .finally(() => {
+        loading.value = false;
+      });
+  }
+  //添加图片
+  const handleAddImage = (res, name) => {
+    let params = {
+      sourceUrl: res.url,
+      sourceName: name,
+      sourceType: searchParams.value.sourceType,
+      sourceCate: searchParams.value.sourceCate
+    };
+    request.post('/baseinfo/source/add', params).then((res) => {
+      if (res.data.code == 200) {
+        getImageList();
+      }
+    });
+  };
+
+  //选中图片
+  const handlecheckboxChange = (value) => {
+    let last = value[value.length - 1];
+    checkList.value = [last];
+  };
+
+  const handleSetDefault = () => {
+    if (checkList.value.length == 0) {
+      ElMessage({ type: 'warning', message: '请选择图片' });
+      return;
+    }
+    ElMessageBox.confirm('是否将该图片设置默认图片?', '提示').then(() => {
+      request
+        .post('/baseinfo/img/setDefault?id=' + checkList.value[0])
+        .then((res) => {
+          if (res.data.code == 200) {
+            ElMessage({ type: 'success', message: '设置成功' });
+            getImageList();
+          }
+        });
+    });
+  };
+
+  const handleDelete = () => {
+    if (checkList.value.length == 0) {
+      ElMessage({ type: 'warning', message: '请选择图片' });
+      return;
+    }
+    ElMessageBox.confirm('是否删除选中的图片?', '提示').then(() => {
+      request
+        .post('/baseinfo/source/deleteBatch', { idList: checkList.value })
+        .then((res) => {
+          if (res.data.code == 200) {
+            getImageList();
+          }
+        });
+    });
+  };
+  //下载图片
+  const handleDownload = () => {
+    if (checkList.value.length == 0) {
+      ElMessage({ type: 'warning', message: '请选择图片' });
+      return;
+    }
+    window.open(imageList.value[0].imgUrl);
+  };
+</script>
+
+<style lang="scss" scoped>
+  .default-image-item {
+    margin-right: 14px;
+    margin-top: 14px;
+    margin-bottom: 0;
+    position: relative;
+    border-radius: 6px;
+    border: 1px solid #e4e7ed;
+    &:hover {
+      .checkbox-item {
+        opacity: 1;
+      }
+    }
+    .checkbox-item {
+      position: absolute;
+      top: -8px;
+      left: 0;
+      opacity: 0;
+      &.is-checked {
+        opacity: 1;
+      }
+    }
+    .mask-error {
+      width: 100%;
+      height: 26px;
+      background-color: rgba(0, 0, 0, 0.1);
+      position: absolute;
+      overflow: hidden;
+      bottom: 0;
+      left: 0;
+    }
+  }
+</style>

+ 301 - 0
src/views/marketing/material/index1.vue

@@ -0,0 +1,301 @@
+<!-- 文件选择器 -->
+<template>
+  <div class="file-picker-container">
+    <el-tabs v-model="searchParams.sourceType" class="file-picker-tabs">
+      <el-tab-pane label="图片" name="1"></el-tab-pane>
+      <el-tab-pane label="视频" name="2"></el-tab-pane>
+    </el-tabs>
+
+    <EleSplitPanel
+      ref="splitRef"
+      space="0px"
+      size="186px"
+      :flexTable="true"
+      :allowCollapse="mobile"
+      :customStyle="{ borderWidth: '0 1px 0 0' }"
+      class="file-picker-wrapper"
+    >
+      <FileGroup
+        ref="fileGroupRef"
+        :groupData="groupData"
+        :selectedId="groupSelected?.id"
+        @groupSelect="handleGroupSelect"
+      />
+      <template #body>
+        <FileList
+          ref="fileListRef"
+          :limit="limit"
+          :sourceType="searchParams.sourceType"
+          :sourceCate="searchParams.sourceCate"
+          :fileLimit="fileLimit"
+          :accept="searchParams.sourceType == 1 ? 'image/*' : '.mp4'"
+          :fileListProps="fileListProps"
+          :selectionListProps="selectionListProps"
+          :emptyProps="emptyProps"
+          :paginationProps="paginationProps"
+          :baseIndex="componentIndex"
+          :messageIns="messageIns"
+          @queryStart="showLoading"
+          @queryDone="hideLoading"
+        />
+      </template>
+    </EleSplitPanel>
+    <EleLoading :loading="loading" class="file-picker-loading" />
+    <!-- 消息提示容器 -->
+    <div
+      ref="messageWrapRef"
+      class="ele-message-wrapper"
+      :style="{ position: 'fixed', zIndex: messageIndex }"
+    ></div>
+    <div
+      ref="messageBoxWrapRef"
+      class="ele-message-box-wrapper"
+      :style="{ position: 'fixed', zIndex: messageIndex }"
+    ></div>
+  </div>
+</template>
+
+<script setup>
+  import { ref, computed, nextTick, onMounted, getCurrentInstance } from 'vue';
+  import { useMessage, useMessageBox } from 'ele-admin-plus/es';
+  import { useMobile } from '@/utils/use-mobile';
+  import FileGroup from './components/file-group.vue';
+  import FileList from './components/file-list.vue';
+  import { listUserFiles } from '@/api/system/file';
+
+  const { proxy } = getCurrentInstance();
+
+  defineOptions({ name: 'FilePicker' });
+
+  const props = defineProps({
+    /** 标题 */
+    title: {
+      type: String,
+      default: '文件选择'
+    },
+    /** 弹窗参数 */
+    modalProps: Object,
+    /** 最大选择数量 */
+    limit: Number,
+    /** 文件大小限制, 单位MB */
+    fileLimit: {
+      type: Number,
+      default: 100
+    },
+    /** 接受上传的文件类型 */
+    accept: String,
+    /** 接口查询参数 */
+    params: Object,
+    /** 文件列表自定义属性 */
+    fileListProps: Object,
+    /** 已选列表自定义属性 */
+    selectionListProps: Object,
+    /** 空组件属性 */
+    emptyProps: Object,
+    /** 分页组件属性 */
+    paginationProps: Object,
+    /** 统一设置层级 */
+    baseIndex: Number
+  });
+
+  const emit = defineEmits(['done', 'close']);
+
+  /** 是否是移动端 */
+  const { mobile } = useMobile();
+
+  /** 分割面板组件 */
+  const splitRef = ref(null);
+
+  /** 文件分组组件 */
+  const fileGroupRef = ref(null);
+
+  /** 文件列表组件 */
+  const fileListRef = ref(null);
+
+  /** 数据请求状态 */
+  const loading = ref(false);
+
+  /** 分组数据 */
+  const groupData = ref([]);
+
+  /** 选中分组数据 */
+  const groupSelected = ref(null);
+
+  /** 组件统一层级 */
+  const componentIndex = computed(() => {
+    if (props.baseIndex == null) {
+      return;
+    }
+    return props.baseIndex + 100;
+  });
+
+  /** 消息提示组件层级 */
+  const messageIndex = computed(() => {
+    if (componentIndex.value == null) {
+      return 3000;
+    }
+    return componentIndex.value + 100;
+  });
+
+  /** 消息提示容器 */
+  const messageWrapRef = ref();
+
+  /** 消息弹出框容器 */
+  const messageBoxWrapRef = ref();
+
+  /** 消息提示框 */
+  const messageIns = useMessage({
+    inner: true,
+    appendTo: messageWrapRef
+  });
+
+  /** 分组选中 */
+  const handleGroupSelect = (item, isTreeClick) => {
+    if (groupSelected.value != null && mobile.value) {
+      splitRef.value?.toggleCollapse?.(true);
+    }
+    if (item && item.id != null) {
+      if (!isTreeClick) {
+        fileGroupRef.value?.setSelectedGroup?.(item);
+      }
+      if (groupSelected.value == null || groupSelected.value.id !== item.id) {
+        groupSelected.value = item;
+        queryData({ sourceCate: item.id });
+      }
+    }
+  };
+
+  /** 关闭请求加载图标 */
+  const hideLoading = () => {
+    loading.value = false;
+  };
+
+  /** 打开请求加载图标 */
+  const showLoading = () => {
+    loading.value = true;
+  };
+
+  //获取分组数据
+  const getGroupData = () => {
+    proxy.$http.get(`/system/dict/data/type/material_file`).then((res) => {
+      if (res.data.code === 200) {
+        groupData.value = res.data.data.map((item) => ({
+          ...item,
+          name: item.dictLabel,
+          id: item.dictValue
+        }));
+      }
+    });
+  };
+
+  const searchParams = ref({
+    sourceNameLike: '',
+    sourceType: '1',
+    sourceCate: '-1'
+  });
+
+  /** 查询分组数据 */
+  const queryGroup = (sourceCate) => {
+    showLoading();
+    listUserFiles({ isDirectory: 1, sourceCate })
+      .then((list) => {
+        const oldSelected =
+          groupSelected.value == null
+            ? void 0
+            : findTree(result, (d) => d.id === groupSelected.value?.id);
+        groupSelected.value = null;
+        nextTick(() => {
+          handleGroupSelect(oldSelected || result[0]);
+        });
+      })
+      .catch((e) => {
+        messageIns.error(e.message);
+        hideLoading();
+      });
+  };
+
+  /** 刷新文件数据 */
+  const queryData = (params) => {
+    showLoading();
+    const queryParams =
+      params == null
+        ? void 0
+        : { page: 1, sourceNameLike: '', sourceCate: params.sourceCate };
+    fileListRef.value && fileListRef.value.queryData(queryParams);
+  };
+
+  /** 清空文件选中 */
+  const clearSelections = () => {
+    fileListRef.value && fileListRef.value.clearSelections();
+  };
+
+  /** 初始化数据 */
+  const initData = () => {
+    clearSelections();
+    queryGroup();
+  };
+
+  /** 弹窗打开事件 */
+  const handleOpen = () => {
+    clearSelections();
+    queryGroup();
+  };
+
+  /** 弹窗关闭事件 */
+  const handleClose = () => {
+    clearSelections();
+    emit('close');
+  };
+
+  /** 确定按钮点击事件 */
+  const handleConfirm = () => {
+    showLoading();
+    emit('done', fileListRef.value?.getSelections?.() || []);
+  };
+
+  // 组件挂载时初始化数据
+  onMounted(() => {
+    getGroupData();
+  });
+
+  defineExpose({ hideLoading });
+</script>
+
+<style lang="scss">
+  @use './style/index.scss';
+
+  .file-picker-container {
+    display: flex;
+    flex-direction: column;
+    height: 100%;
+    width: calc(100% - 24px);
+    position: relative;
+    background-color: #fff;
+    margin: 12px;
+
+    .file-picker-tabs {
+      width: 100%;
+      padding: 0 12px;
+    }
+
+    .file-picker-wrapper {
+      flex: 1;
+      overflow: hidden;
+    }
+
+    .file-picker-footer {
+      padding: 12px 16px;
+      text-align: right;
+      border-top: 1px solid #ebeef5;
+    }
+
+    .file-picker-loading {
+      position: absolute;
+      top: 0;
+      left: 0;
+      right: 0;
+      bottom: 0;
+      z-index: 100;
+    }
+  }
+</style>

+ 407 - 0
src/views/marketing/material/style/index.scss

@@ -0,0 +1,407 @@
+.file-picker-modal {
+  max-height: 94vh;
+  max-height: 94dvh;
+  min-height: 434px;
+  display: flex;
+  flex-direction: column;
+
+  & > .el-dialog__body {
+    flex: 1;
+    overflow: hidden;
+
+    & > .ele-modal-body {
+      height: 100%;
+      overflow: hidden;
+      padding: 0;
+    }
+  }
+}
+
+.file-picker-wrapper {
+  height: 580px;
+  max-height: 100%;
+  overflow: hidden !important;
+
+  .ele-split-collapse-button {
+    top: auto;
+    bottom: 124px;
+    margin-top: 0;
+  }
+
+  &.is-collapse .ele-split-collapse-button {
+    margin-left: 6px;
+  }
+}
+
+.file-picker-loading {
+  position: absolute;
+  top: 0;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  pointer-events: none;
+
+  & > .ele-loading-spinner {
+    background: none;
+    border-radius: var(--el-border-radius-base);
+    pointer-events: auto;
+  }
+}
+
+.file-picker-tree-icon {
+  width: 18px;
+  height: 18px;
+  margin-right: 6px;
+  user-select: none;
+}
+
+.file-picker-wrapper .file-picker-left {
+  flex: 1;
+  padding-top: 8px;
+  padding-bottom: 8px;
+  padding-left: calc(var(--ele-tree-item-radius) * 2);
+  padding-right: calc(var(--ele-tree-item-radius) * 2);
+  box-sizing: border-box;
+  overflow-x: hidden;
+  overflow-y: auto;
+  user-select: none;
+  --ele-tree-item-height: 36px;
+
+  .el-tree-node__content {
+    position: relative;
+
+    & > .el-tree-node__label::before {
+      content: '';
+      position: absolute;
+      top: 0;
+      left: 0;
+      right: 0;
+      bottom: 0;
+      z-index: 2;
+    }
+
+    & > .file-picker-tree-more {
+      flex-shrink: 0;
+      width: 20px;
+      height: 20px;
+      display: flex;
+      align-items: center;
+      justify-content: center;
+      margin: 0 -8px 0 0;
+      color: var(--el-text-color-placeholder);
+      font-size: 12px;
+      border-radius: var(--el-border-radius-base);
+      transition: all 0.2s;
+      z-index: 3;
+
+      &:hover {
+        color: var(--el-text-color-regular);
+        background: hsla(0, 0%, 60%, 0.15);
+      }
+    }
+  }
+}
+
+.file-picker-left-add {
+  flex-shrink: 0;
+  padding: 0 6px;
+  margin: 2px 12px 8px 12px;
+  border-radius: var(--el-border-radius-base);
+  border: 1px dashed var(--el-color-primary);
+  box-sizing: border-box;
+  font-size: 12px;
+  line-height: 20px;
+  user-select: none;
+
+  &:hover {
+    border-color: var(--el-color-primary-light-5);
+  }
+}
+
+.file-picker-main {
+  flex: 1;
+  display: flex;
+  overflow: auto;
+}
+
+.file-picker-body {
+  flex: 1;
+  display: flex;
+  flex-direction: column;
+  overflow: auto;
+}
+
+.file-picker-toolbar {
+  flex-shrink: 0;
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 8px 12px;
+  border-bottom: 1px solid var(--el-border-color-light);
+  box-sizing: border-box;
+}
+
+.file-picker-search {
+  flex: 1;
+  display: flex;
+  max-width: 220px;
+  margin: 0 8px;
+
+  & > .el-input {
+    flex: 1;
+
+    & > .el-input__wrapper {
+      border-top-right-radius: 0;
+      border-bottom-right-radius: 0;
+    }
+  }
+
+  & > .el-button {
+    flex-shrink: 0;
+    margin-left: -1px;
+    position: relative;
+    border-top-left-radius: 0;
+    border-bottom-left-radius: 0;
+  }
+}
+
+.file-picker-toolbar > .ele-segmented {
+  --ele-segmented-height: 26px;
+  --ele-segmented-font-size: 16px;
+  --ele-segmented-item-padding: 8px;
+}
+
+.file-picker-file-list {
+  flex: 1;
+  overflow: auto;
+}
+
+.file-picker-file-list > .ele-file-list-group {
+  --ele-file-item-width: 102px;
+  --ele-file-item-padding: 4px 2px 2px 2px;
+
+  & > .ele-file-list {
+    & > .ele-file-list-header {
+      display: none;
+    }
+
+    & > .ele-file-list-body {
+      display: grid;
+      grid-gap: 14px 0px;
+      grid-template-columns: repeat(6, 1fr);
+      padding: 6px 1px 1px 1px;
+
+      & > .ele-file-list-item {
+        margin: 0 auto;
+
+        .ele-file-list-item-title {
+          font-size: 12px;
+          margin-top: 0;
+        }
+      }
+    }
+  }
+
+  & > .ele-file-list-table {
+    min-width: 528px;
+
+    & > .ele-file-list-header {
+      position: sticky;
+      top: 0;
+      background: var(--el-bg-color-overlay);
+    }
+  }
+
+  &.is-ping-top > .ele-file-list-table > .ele-file-list-header {
+    z-index: 2;
+  }
+}
+
+.file-picker-body > .el-empty {
+  padding: 0;
+  flex: 1;
+}
+
+.file-picker-body > .el-pagination {
+  padding-bottom: 8px;
+}
+
+.file-picker-right {
+  flex-shrink: 0;
+  width: 128px;
+  border-left: 1px solid var(--el-border-color-light);
+  box-sizing: border-box;
+  display: flex;
+  flex-direction: column;
+  position: relative;
+}
+
+.file-picker-right-header {
+  flex-shrink: 0;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  padding: 6px 12px 8px 12px;
+  box-sizing: border-box;
+}
+
+.file-picker-right-title {
+  flex: 1;
+  width: 100%;
+  overflow: hidden;
+  padding-right: 8px;
+  box-sizing: border-box;
+  font-size: 13px;
+}
+
+.file-picker-right-clear {
+  width: 100%;
+  padding: 0 6px;
+  margin-top: 6px;
+  border-radius: var(--el-border-radius-base);
+  border: 1px dashed var(--el-color-danger);
+  box-sizing: border-box;
+  font-size: 12px;
+  line-height: 20px;
+  user-select: none;
+
+  &:hover {
+    border-color: var(--el-color-danger-light-5);
+  }
+}
+
+.file-picker-right > .ele-upload-list {
+  flex: 1;
+  overflow: auto;
+  padding: 6px 0 8px 0;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  flex-wrap: nowrap;
+  gap: 0;
+
+  & > .ele-upload-item {
+    flex-shrink: 0;
+    margin: 0;
+
+    & + .ele-upload-item {
+      margin-top: 8px;
+    }
+  }
+}
+
+@media screen and (max-width: 980px) {
+  .file-picker-file-list {
+    & > .ele-file-list-group > .ele-file-list > .ele-file-list-body {
+      grid-template-columns: repeat(5, 1fr);
+    }
+  }
+}
+
+@media screen and (max-width: 888px) {
+  .file-picker-file-list {
+    & > .ele-file-list-group > .ele-file-list > .ele-file-list-body {
+      grid-template-columns: repeat(4, 1fr);
+    }
+  }
+}
+
+@media screen and (max-width: 788px) {
+  .file-picker-main {
+    flex-direction: column;
+  }
+
+  .file-picker-right {
+    width: auto;
+    height: 156px;
+    border-left: none;
+    border-top: 1px solid var(--el-border-color-light);
+
+    & > .file-picker-right-header {
+      padding-top: 8px;
+      padding-bottom: 0;
+      flex-direction: row;
+
+      & > .file-picker-right-title,
+      & > .file-picker-right-clear {
+        width: auto;
+        margin: 0;
+      }
+    }
+
+    & > .ele-upload-list {
+      flex-direction: row;
+      padding: 0 8px;
+
+      & > .ele-upload-item + .ele-upload-item {
+        margin-top: 0;
+        margin-left: 8px;
+      }
+    }
+  }
+}
+
+@media screen and (max-width: 768px) {
+  .file-picker-right-title {
+    text-align: right;
+  }
+}
+
+@media screen and (max-width: 460px) {
+  .file-picker-file-list {
+    & > .ele-file-list-group > .ele-file-list > .ele-file-list-body {
+      grid-template-columns: repeat(3, 1fr);
+    }
+  }
+}
+
+@media screen and (max-width: 358px) {
+  .file-picker-file-list {
+    & > .ele-file-list-group > .ele-file-list > .ele-file-list-body {
+      grid-template-columns: repeat(2, 1fr);
+    }
+  }
+}
+
+.file-picker-move-wrapper {
+  border: 1px solid var(--el-border-color-light);
+  border-radius: var(--el-border-radius-base);
+
+  & > .file-picker-move-tree {
+    padding-top: 8px;
+    padding-bottom: 8px;
+    padding-left: calc(var(--ele-tree-item-radius) * 2);
+    padding-right: calc(var(--ele-tree-item-radius) * 2);
+    box-sizing: border-box;
+    overflow-x: hidden;
+    overflow-y: auto;
+    user-select: none;
+    --ele-tree-item-height: 36px;
+
+    .el-tree-node__content {
+      position: relative;
+      z-index: 1;
+
+      & > .el-tree-node__label.is-active {
+        color: var(--el-color-primary);
+        font-weight: bold;
+
+        &::before {
+          content: '';
+          position: absolute;
+          top: 0;
+          left: 0;
+          right: 0;
+          bottom: 0;
+          z-index: -1;
+          background: var(--el-color-primary-light-9);
+          border-radius: var(--ele-tree-item-radius);
+        }
+      }
+
+      & > .el-radio {
+        margin: 0 -12px 0 0;
+      }
+    }
+  }
+}

+ 1 - 8
src/views/recycle/fallbackLog/index.vue

@@ -28,14 +28,7 @@
         </div>
       </template>
       <template #baseInfo="{ row }">
-        <div class="flex justify-start items-center">
-          <div style="flex: 2">
-            <book-info :row="row"></book-info>
-          </div>
-          <div style="flex: 1.5; margin-left: 15px">
-            <book-other-info :row="row"></book-other-info>
-          </div>
-        </div>
+        <book-info :row="row" :showFormat="false"></book-info>
       </template>
 
       <template #action="{ row }">

+ 1 - 1
src/views/recycleOrder/components/order-page-all.vue

@@ -376,7 +376,7 @@
     {
       columnKey: 'action',
       label: '操作',
-      width: 176,
+      width: 182,
       align: 'center',
       slot: 'action',
       hideInPrint: true,

+ 86 - 41
src/views/recycleOrder/detail/order-status.vue

@@ -1,55 +1,100 @@
 <template>
-    <el-steps style="width: 100%" :active="active" align-center finish-status="success">
-        <el-step v-for="item in stepList" :title="item.label">
-            <template #description>
-                <div>{{ item.time }}</div>
-                <div>{{ item.createdBy }}</div>
-            </template>
-        </el-step>
-    </el-steps>
+  <el-steps
+    style="width: 100%"
+    :active="active"
+    align-center
+    finish-status="success"
+  >
+    <el-step
+      v-for="(item, index) in stepList"
+      :title="item.statusName"
+      :status="isError && index === stepList.length - 1 ? 'error' : ''"
+    >
+      <template #description>
+        <div>{{ item.createTime }}</div>
+        <div>{{ item.createName }}</div>
+      </template>
+    </el-step>
+  </el-steps>
 </template>
 
 <script setup>
-import { ref, reactive, watch } from 'vue';
-const stepList = reactive([
-    { label: '创建', time: '', createdBy: '', stutus: '0' },
-    { label: '下单', time: '', createdBy: '', stutus: '2' },
-    { label: '初审', time: '', createdBy: '', stutus: '3' },
-    { label: '快递取书', time: '', createdBy: '', stutus: '5' },
-    { label: '快递签收', time: '', createdBy: '', stutus: '6' },
-    { label: '仓库收货', time: '', createdBy: '', stutus: '8' },
-    { label: '到货审核', time: '', createdBy: '', stutus: '9' },
-    { label: '支付书款(完成)', time: '', createdBy: '', stutus: '11' }
-]);
-
-const props = defineProps({
+  import { ref, reactive, watch, computed } from 'vue';
+
+  //已取消 20 初审未通过 4
+  const defaultStepList = reactive([
+    { statusName: '创建', createTime: '', createName: '', stutus: '0' },
+    { statusName: '下单', createTime: '', createName: '', stutus: '2' },
+    { statusName: '初审', createTime: '', createName: '', stutus: '3' },
+    { statusName: '快递取书', createTime: '', createName: '', stutus: '5' },
+    { statusName: '快递签收', createTime: '', createName: '', stutus: '6' },
+    { statusName: '仓库收货', createTime: '', createName: '', stutus: '8' },
+    { statusName: '到货审核', createTime: '', createName: '', stutus: '9' },
+    {
+      statusName: '支付书款(完成)',
+      createTime: '',
+      createName: '',
+      stutus: '11'
+    }
+  ]);
+
+  const stepList = ref([]);
+
+  const props = defineProps({
     status: {
-        type: [Number, String],
-        default: '0'
+      type: [Number, String],
+      default: '0'
     },
     logVoList: {
-        type: Array,
-        default: []
+      type: Array,
+      default: []
     }
-});
+  });
+
+  const isError = computed(() => {
+    return props.logVoList.find((log) => ['4', '20'].includes(log.status));
+  });
+
+  watch(
+    () => props.logVoList,
+    (newVal) => {
+      if (newVal.length > 0) {
+        stepList.value = [];
 
-watch(() => props.logVoList, (newVal) => {
-    if (newVal.length > 0) {
-        stepList.forEach(item => {
-            item.time = newVal.find(log => log.status == item.stutus)?.createTime || '';
-            item.createdBy = newVal.find(log => log.status == item.stutus)?.createName || '';
+        if (isError.value) {
+          return (stepList.value = newVal);
+        }
+        stepList.value = defaultStepList;
+        stepList.value.forEach((item) => {
+          item.createTime =
+            newVal.find((log) => log.status == item.stutus)?.createTime || '';
+          item.createName =
+            newVal.find((log) => log.status == item.stutus)?.createName || '';
         });
-    } else {
-        stepList.forEach(item => {
-            item.time = '';
-            item.createdBy = '';
+      } else {
+        stepList.value.forEach((item) => {
+          item.createTime = '';
+          item.createName = '';
         });
-    }
-}, { deep: true, immediate: true });
+      }
+    },
+    { deep: true, immediate: true }
+  );
 
-const active = computed(() => {
+  const active = computed(() => {
+    if (isError.value) {
+      return stepList.value.length;
+    }
     let status = props.logVoList[props.logVoList.length - 1]?.status || 0;
-    return stepList.findIndex(item => item.stutus == status) + 1;
-});
-
+    return stepList.value.findIndex((item) => item.stutus == status) + 1;
+  });
 </script>
+
+<style lang="scss">
+  .error-step {
+    .el-step__title.is-success,
+    .el-step__description.is-success {
+      color: red;
+    }
+  }
+</style>

+ 1 - 1
src/views/riskControl/restrictLog/index.vue

@@ -35,7 +35,7 @@
 
   /** 表格列配置 */
   const columns = ref([
-    { label: '用户UID', prop: 'userId', align: 'center' },
+    { label: '用户昵称', prop: 'nickName', align: 'center' },
     { label: '限制类型', prop: 'limitType', align: 'center' },
     { label: '限制时间', prop: 'limitTime', align: 'center' },
     { label: '限制ip', prop: 'userIp', align: 'center' },