feat(shopGoods): 新增商品置顶功能及批量上下架操作
- 在商品模型中新增 isTop 字段支持置顶状态(1置顶,0不置顶) - 商品列表新增置顶列,支持列表内联切换置顶状态 - 启用排序号列排序功能,置顶列参与前端排序 - 新增单个商品上架/下架切换接口及批量上下架操作 - 优化商品列表工具栏和选中操作栏,加入批量上架、下架按钮 - 商品分类及图片列表调用 OSS 图片压缩接口处理展示图片 - 新增删除商品导入模板及图片导入模板下载链接与使用说明 - 优化图片压缩工具,新增去除 URL 中 x-oss-process 参数功能,确保调用参数生效
This commit is contained in:
15
.workbuddy/memory/2026-07-24.md
Normal file
15
.workbuddy/memory/2026-07-24.md
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
# 2026-07-24 工作日志
|
||||||
|
|
||||||
|
## shopGoods 商品列表排序机制分析
|
||||||
|
- 现状:列表「排序号」列(`src/views/shop/shopGoods/index.vue` 约 273-279 行)只做展示,**未启用 `sorter`**,列表默认也不按 `sortNumber` 排序(默认排序由后端决定,前端 `orders` 仅来自「创建时间」列的排序)。
|
||||||
|
- 编辑表单里「排序号」是 `a-input-number`(0-9999,默认 100),且在 `rules` 里是 **必填**。
|
||||||
|
- 已存在类似的布尔开关 `recommend`(列表内联切换,index.vue 约 47-58 行),「置顶」可复用同一 UI 模式。
|
||||||
|
- 结论倾向:纯排序号在商品多时难用,建议「置顶开关 + 少量置顶项内排序 + 其余按默认(创建时间/销量)」的混合方案,而非完全替换。
|
||||||
|
|
||||||
|
## 已实现:方案 B(商品置顶功能)
|
||||||
|
- 数据模型 `src/api/shop/shopGoods/model/index.ts`:新增 `isTop?: number`(置顶 1/不置顶 0)。
|
||||||
|
- 列表 `src/views/shop/shopGoods/index.vue`:
|
||||||
|
- 新增「置顶」列,列表内联一键切换(复用 recommend 的 CheckOutlined/CloseOutlined 模式),新增 `onTop` 处理函数。
|
||||||
|
- 「排序号」列开启 `sorter: true`(之前只展示、不可排序)。
|
||||||
|
- 后端迁移脚本 `add-is-top-shop-goods.sql`:新增 `is_top` TINYINT 字段;分页默认排序需改为 `ORDER BY is_top DESC, sort_number ASC, create_time DESC`(后端不在本仓库,需手动改 page 接口 ORDER BY / 放开 orders 白名单)。
|
||||||
|
- 注意:置顶真正"排到最前"依赖后端默认排序改动;点击列排序也需后端放开 is_top / sort_number。
|
||||||
@@ -21,3 +21,12 @@
|
|||||||
- `2` = 商家送货
|
- `2` = 商家送货
|
||||||
|
|
||||||
来源:管理后台 `src/views/shop/shopOrder/components/deliveryModal.vue`
|
来源:管理后台 `src/views/shop/shopOrder/components/deliveryModal.vue`
|
||||||
|
|
||||||
|
### shopGoods status 字段(上架/下架)
|
||||||
|
- `0` = 已上架 / 上架
|
||||||
|
- `1` = 待上架 / 下架
|
||||||
|
- `2` = 待审核,`3` = 审核不通过
|
||||||
|
- 编辑表单「状态」radio 绑定 `form.status`:0=上架、1=下架。
|
||||||
|
- 一键上下架、批量上下架均通过 `updateShopGoods({ ...record, status })` 直接改 status 实现(不额外用 isShow 字段)。
|
||||||
|
- 列表 tag 仍显示:0=已上架、1=待上架、2=待审核、3=审核不通过。
|
||||||
|
|
||||||
|
|||||||
35
add-is-top-shop-goods.sql
Normal file
35
add-is-top-shop-goods.sql
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
-- ============================================================
|
||||||
|
-- 商品表新增「置顶」字段 is_top,并调整默认排序(方案 B)
|
||||||
|
-- ------------------------------------------------------------
|
||||||
|
-- 约定说明(依据本项目代码 /Users/gxwebsoft/VUE/guilixu-admin):
|
||||||
|
-- 1. is_top = 1 表示置顶,0(默认)表示不置顶
|
||||||
|
-- 2. 表名/字段名 snake_case 约定(Node + TypeORM 常见);
|
||||||
|
-- 若你的库列名是驼峰(goodsId / isTop),请自行替换
|
||||||
|
-- 3. 与 recommend(推荐)同为「列表内联一键切换」的布尔开关
|
||||||
|
-- 4. 混合排序思路:置顶的永远在最前;置顶内部按排序号升序;
|
||||||
|
-- 其余商品按创建时间倒序(绝大多数商品无需手动排)
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
-- 第一步:新增字段(已存在则跳过;MySQL 5.7 / 8 通用)
|
||||||
|
ALTER TABLE shop_goods
|
||||||
|
ADD COLUMN is_top TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否置顶:1置顶 0不置顶';
|
||||||
|
|
||||||
|
-- (可选)联合索引,置顶 + 排序号分页查询更稳
|
||||||
|
-- ALTER TABLE shop_goods
|
||||||
|
-- ADD INDEX idx_is_top_sort (is_top, sort_number);
|
||||||
|
|
||||||
|
-- 第二步:调整分页查询(page 接口)默认排序
|
||||||
|
-- 原(示例):ORDER BY create_time DESC
|
||||||
|
-- 改: ORDER BY is_top DESC, sort_number ASC, create_time DESC
|
||||||
|
-- 说明:若后端排序字段来自前端 orders 参数,需放开 is_top / sort_number 参与排序
|
||||||
|
-- (前端「置顶」「排序号」列已开启 sorter,会向后端传 orders)
|
||||||
|
|
||||||
|
-- 第三步(验证):确认字段已加、数据默认 0,且排序符合预期
|
||||||
|
SELECT goods_id,
|
||||||
|
name,
|
||||||
|
is_top,
|
||||||
|
sort_number,
|
||||||
|
create_time
|
||||||
|
FROM shop_goods
|
||||||
|
ORDER BY is_top DESC, sort_number ASC, create_time DESC
|
||||||
|
LIMIT 20;
|
||||||
37
offline-noimage-goods-10332.sql
Normal file
37
offline-noimage-goods-10332.sql
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
-- ============================================================
|
||||||
|
-- 下架「分类ID = 10332 且 没有图片」的商品
|
||||||
|
-- ------------------------------------------------------------
|
||||||
|
-- 约定说明(依据本项目代码 /Users/gxwebsoft/VUE/guilixu-admin):
|
||||||
|
-- 1. 下架 = status = 1(项目约定:0 上架 / 1 下架,见 shopGoodsEdit.vue 状态 radio)
|
||||||
|
-- 2. 「没有图片」以封面图 image 是否为空判断(封面图为必填项)
|
||||||
|
-- 3. 已删除商品(deleted = 1)不参与,避免误改
|
||||||
|
-- 4. 表名/字段名按 snake_case 约定(Node + TypeORM 常见);
|
||||||
|
-- 若你的库列名是驼峰(goodsId / categoryId),请自行替换
|
||||||
|
-- 5. 分类 10332 若是父分类、需连同其子分类商品一起处理,
|
||||||
|
-- 请把 category_id = 10332 改为 category_id IN (10332, 子分类ID...)
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
-- 第一步:先预览,确认影响范围(务必先执行核对,再跑 UPDATE)
|
||||||
|
SELECT goods_id,
|
||||||
|
name,
|
||||||
|
image,
|
||||||
|
status
|
||||||
|
FROM shop_goods
|
||||||
|
WHERE category_id = 10332
|
||||||
|
AND (image IS NULL OR image = '')
|
||||||
|
AND (deleted = 0 OR deleted IS NULL);
|
||||||
|
|
||||||
|
-- 第二步:确认无误后执行下架
|
||||||
|
UPDATE shop_goods
|
||||||
|
SET status = 1,
|
||||||
|
update_time = NOW()
|
||||||
|
WHERE category_id = 10332
|
||||||
|
AND (image IS NULL OR image = '')
|
||||||
|
AND (deleted = 0 OR deleted IS NULL);
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------
|
||||||
|
-- (可选)若你认为「没有图片」应同时要求轮播图也为空,
|
||||||
|
-- 即封面图和轮播图都没有才算没图,使用下面更严格的条件:
|
||||||
|
-- AND (image IS NULL OR image = '')
|
||||||
|
-- AND (files IS NULL OR files = '' OR files = '[]')
|
||||||
|
-- ------------------------------------------------------------
|
||||||
@@ -87,6 +87,8 @@ export interface ShopGoods {
|
|||||||
gainIntegral?: number;
|
gainIntegral?: number;
|
||||||
// 推荐
|
// 推荐
|
||||||
recommend?: number;
|
recommend?: number;
|
||||||
|
// 置顶(1置顶 0不置顶)
|
||||||
|
isTop?: number;
|
||||||
// 商户ID
|
// 商户ID
|
||||||
merchantId?: number;
|
merchantId?: number;
|
||||||
// 商户名称
|
// 商户名称
|
||||||
|
|||||||
@@ -41,6 +41,26 @@ export function ensureFullUrl(url: string): string {
|
|||||||
return `${OSS_BASE_URL}${url.startsWith('/') ? '' : '/'}${url}`;
|
return `${OSS_BASE_URL}${url.startsWith('/') ? '' : '/'}${url}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 移除 URL 中已有的 x-oss-process 参数(无论其在 query 中的位置)
|
||||||
|
*
|
||||||
|
* 阿里云 OSS 的图片处理参数通常以 `x-oss-process=image/...` 形式附加在 query 中,
|
||||||
|
* 可能位于首个或后续参数位。本函数精确剔除该参数,保留其余 query 参数。
|
||||||
|
*
|
||||||
|
* @param url 完整图片 URL
|
||||||
|
* @returns 已剥离 x-oss-process 的 URL
|
||||||
|
*/
|
||||||
|
function removeOssProcess(url: string): string {
|
||||||
|
const qIndex = url.indexOf('?');
|
||||||
|
if (qIndex === -1) return url;
|
||||||
|
const base = url.slice(0, qIndex);
|
||||||
|
const params = url
|
||||||
|
.slice(qIndex + 1)
|
||||||
|
.split('&')
|
||||||
|
.filter((p) => !p.startsWith('x-oss-process='));
|
||||||
|
return params.length ? `${base}?${params.join('&')}` : base;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取 OSS 压缩后的图片 URL
|
* 获取 OSS 压缩后的图片 URL
|
||||||
*
|
*
|
||||||
@@ -49,8 +69,11 @@ export function ensureFullUrl(url: string): string {
|
|||||||
*
|
*
|
||||||
* 注意:预览原图时不需要调用此函数,直接用原始 URL 即可。
|
* 注意:预览原图时不需要调用此函数,直接用原始 URL 即可。
|
||||||
*
|
*
|
||||||
|
* 优先级:以调用方指定的 width/quality 为准。若 URL 已自带 x-oss-process
|
||||||
|
* (接口已返回压缩参数),会先剥离再按本函数参数重新拼接,确保调用方参数生效。
|
||||||
|
*
|
||||||
* @param url 原始图片 URL
|
* @param url 原始图片 URL
|
||||||
* @param options 压缩选项
|
* @param options 压缩选项(width/quality 不传则使用默认值)
|
||||||
* @returns 处理后的图片 URL
|
* @returns 处理后的图片 URL
|
||||||
*/
|
*/
|
||||||
export function getCompressedImageUrl(
|
export function getCompressedImageUrl(
|
||||||
@@ -66,11 +89,12 @@ export function getCompressedImageUrl(
|
|||||||
// 未启用压缩,原样返回
|
// 未启用压缩,原样返回
|
||||||
if (!enabled) return fullUrl;
|
if (!enabled) return fullUrl;
|
||||||
|
|
||||||
// 已包含 OSS 处理参数,避免重复拼接
|
// 优先使用调用方指定的 width/quality:
|
||||||
if (fullUrl.includes('x-oss-process')) return fullUrl;
|
// 先剥离 URL 自带的 x-oss-process,再按本函数参数重新拼接,确保以调用方为准。
|
||||||
|
const baseUrl = removeOssProcess(fullUrl);
|
||||||
|
|
||||||
// 已有其他 query 参数用 & 拼接,否则用 ?
|
// 已有其他 query 参数用 & 拼接,否则用 ?
|
||||||
const separator = fullUrl.includes('?') ? '&' : '?';
|
const separator = baseUrl.includes('?') ? '&' : '?';
|
||||||
|
|
||||||
return `${fullUrl}${separator}x-oss-process=image/resize,w_${width}/quality,Q_${quality}`;
|
return `${baseUrl}${separator}x-oss-process=image/resize,w_${width}/quality,Q_${quality}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,89 +1,112 @@
|
|||||||
<!-- 搜索表单 -->
|
<!-- 搜索表单 -->
|
||||||
<template>
|
<template>
|
||||||
<a-space :size="10" style="flex-wrap: wrap">
|
<div class="goods-toolbar">
|
||||||
<a-button type="primary" class="ele-btn-icon" @click="add">
|
<!-- 常驻工具栏 -->
|
||||||
<template #icon>
|
<a-space :size="10" style="flex-wrap: wrap">
|
||||||
<PlusOutlined />
|
<a-button type="primary" class="ele-btn-icon" @click="add">
|
||||||
</template>
|
<template #icon>
|
||||||
<span>添加</span>
|
<PlusOutlined />
|
||||||
</a-button>
|
</template>
|
||||||
<a-button
|
<span>添加</span>
|
||||||
danger
|
</a-button>
|
||||||
type="primary"
|
|
||||||
class="ele-btn-icon"
|
<!-- 导入(合并为下拉菜单) -->
|
||||||
:disabled="selection?.length === 0"
|
<a-dropdown>
|
||||||
@click="removeBatch"
|
<a-button type="dashed" class="ele-btn-icon">
|
||||||
>
|
<template #icon>
|
||||||
<template #icon>
|
<UploadOutlined />
|
||||||
<DeleteOutlined />
|
</template>
|
||||||
</template>
|
<span>导入</span>
|
||||||
<span>批量删除</span>
|
<DownOutlined class="ele-btn-icon-arrow" />
|
||||||
</a-button>
|
</a-button>
|
||||||
<a-button type="dashed" class="ele-btn-icon" @click="openImport">
|
<template #overlay>
|
||||||
<template #icon>
|
<a-menu>
|
||||||
<UploadOutlined />
|
<a-menu-item key="goods" @click="openImport">
|
||||||
</template>
|
导入商品
|
||||||
<span>导入</span>
|
</a-menu-item>
|
||||||
</a-button>
|
<a-menu-item key="image" @click="openImageImport">
|
||||||
<a-button type="dashed" class="ele-btn-icon" @click="openImageImport">
|
导入图片
|
||||||
<template #icon>
|
</a-menu-item>
|
||||||
<UploadOutlined />
|
<a-menu-item key="detail" @click="openDetailImport">
|
||||||
</template>
|
导入详情
|
||||||
<span>导入图片</span>
|
</a-menu-item>
|
||||||
</a-button>
|
</a-menu>
|
||||||
<a-button type="dashed" class="ele-btn-icon" @click="openDetailImport">
|
</template>
|
||||||
<template #icon>
|
</a-dropdown>
|
||||||
<UploadOutlined />
|
|
||||||
</template>
|
<a-button type="dashed" class="ele-btn-icon" @click="handleExport">
|
||||||
<span>导入详情</span>
|
导出xls
|
||||||
</a-button>
|
</a-button>
|
||||||
<a-button
|
|
||||||
type="dashed"
|
<a-radio-group v-model:value="type" @change="handleSearch">
|
||||||
class="ele-btn-icon"
|
<a-radio-button value="出售中"
|
||||||
:disabled="selection?.length === 0"
|
>出售中({{ goodsCount?.totalNum }})
|
||||||
@click="convertContent"
|
</a-radio-button>
|
||||||
>
|
<a-radio-button value="待上架"
|
||||||
<template #icon>
|
>待上架({{ goodsCount?.totalNum2 }})
|
||||||
<SyncOutlined />
|
</a-radio-button>
|
||||||
</template>
|
<a-radio-button value="已售罄"
|
||||||
<span>转换详情</span>
|
>已售罄({{ goodsCount?.totalNum3 }})
|
||||||
</a-button>
|
</a-radio-button>
|
||||||
<a-button type="dashed" class="ele-btn-icon" @click="handleExport">
|
</a-radio-group>
|
||||||
导出xls
|
|
||||||
</a-button>
|
<a-tree-select
|
||||||
<a-radio-group v-model:value="type" @change="handleSearch">
|
allow-clear
|
||||||
<a-radio-button value="出售中"
|
:tree-data="navigationList"
|
||||||
>出售中({{ goodsCount?.totalNum }})
|
tree-default-expand-all
|
||||||
</a-radio-button>
|
style="width: 180px"
|
||||||
<a-radio-button value="待上架"
|
:listHeight="700"
|
||||||
>待上架({{ goodsCount?.totalNum2 }})
|
placeholder="请选择分类"
|
||||||
</a-radio-button>
|
:value="where.categoryId || undefined"
|
||||||
<a-radio-button value="已售罄"
|
:dropdown-style="{ overflow: 'auto' }"
|
||||||
>已售罄({{ goodsCount?.totalNum3 }})
|
@update:value="(value?: number) => (where.categoryId = value)"
|
||||||
</a-radio-button>
|
@change="onCategoryId"
|
||||||
</a-radio-group>
|
/>
|
||||||
<a-tree-select
|
<a-input-search
|
||||||
allow-clear
|
allow-clear
|
||||||
:tree-data="navigationList"
|
placeholder="请输入关键词"
|
||||||
tree-default-expand-all
|
style="width: 220px"
|
||||||
style="width: 180px"
|
v-model:value="where.keywords"
|
||||||
:listHeight="700"
|
@pressEnter="reload"
|
||||||
placeholder="请选择分类"
|
@search="reload"
|
||||||
:value="where.categoryId || undefined"
|
/>
|
||||||
:dropdown-style="{ overflow: 'auto' }"
|
<a-button type="text" @click="reset">重置</a-button>
|
||||||
@update:value="(value?: number) => (where.categoryId = value)"
|
</a-space>
|
||||||
@change="onCategoryId"
|
|
||||||
/>
|
<!-- 选中操作栏:勾选商品后才显示 -->
|
||||||
<a-input-search
|
<div v-if="selection && selection.length > 0" class="selection-bar">
|
||||||
allow-clear
|
<span class="selection-bar-count">已选 {{ selection.length }} 项</span>
|
||||||
placeholder="请输入关键词"
|
<a-button
|
||||||
style="width: 220px"
|
danger
|
||||||
v-model:value="where.keywords"
|
type="primary"
|
||||||
@pressEnter="reload"
|
class="ele-btn-icon"
|
||||||
@search="reload"
|
@click="removeBatch"
|
||||||
/>
|
>
|
||||||
<a-button type="text" @click="reset">重置</a-button>
|
<template #icon>
|
||||||
</a-space>
|
<DeleteOutlined />
|
||||||
|
</template>
|
||||||
|
<span>批量删除</span>
|
||||||
|
</a-button>
|
||||||
|
<a-button type="primary" class="ele-btn-icon" @click="batchUp">
|
||||||
|
<template #icon>
|
||||||
|
<ArrowUpOutlined />
|
||||||
|
</template>
|
||||||
|
<span>批量上架</span>
|
||||||
|
</a-button>
|
||||||
|
<a-button danger class="ele-btn-icon" @click="batchDown">
|
||||||
|
<template #icon>
|
||||||
|
<ArrowDownOutlined />
|
||||||
|
</template>
|
||||||
|
<span>批量下架</span>
|
||||||
|
</a-button>
|
||||||
|
<a-button type="dashed" class="ele-btn-icon" @click="convertContent">
|
||||||
|
<template #icon>
|
||||||
|
<SyncOutlined />
|
||||||
|
</template>
|
||||||
|
<span>转换详情</span>
|
||||||
|
</a-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
@@ -91,7 +114,10 @@
|
|||||||
PlusOutlined,
|
PlusOutlined,
|
||||||
DeleteOutlined,
|
DeleteOutlined,
|
||||||
UploadOutlined,
|
UploadOutlined,
|
||||||
SyncOutlined
|
SyncOutlined,
|
||||||
|
ArrowUpOutlined,
|
||||||
|
ArrowDownOutlined,
|
||||||
|
DownOutlined
|
||||||
} from '@ant-design/icons-vue';
|
} from '@ant-design/icons-vue';
|
||||||
import { ref, watch } from 'vue';
|
import { ref, watch } from 'vue';
|
||||||
import { getCount } from '@/api/shop/shopGoods';
|
import { getCount } from '@/api/shop/shopGoods';
|
||||||
@@ -102,8 +128,8 @@
|
|||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
// 选中的角色
|
// 选中的商品
|
||||||
selection?: [];
|
selection?: any[];
|
||||||
merchantId?: number;
|
merchantId?: number;
|
||||||
navigationList?: ShopGoodsCategory[];
|
navigationList?: ShopGoodsCategory[];
|
||||||
}>(),
|
}>(),
|
||||||
@@ -131,6 +157,8 @@
|
|||||||
(e: 'search', where?: ShopGoodsParam): void;
|
(e: 'search', where?: ShopGoodsParam): void;
|
||||||
(e: 'add'): void;
|
(e: 'add'): void;
|
||||||
(e: 'remove'): void;
|
(e: 'remove'): void;
|
||||||
|
(e: 'batchUp'): void;
|
||||||
|
(e: 'batchDown'): void;
|
||||||
(e: 'batchMove'): void;
|
(e: 'batchMove'): void;
|
||||||
(e: 'import'): void;
|
(e: 'import'): void;
|
||||||
(e: 'imageImport'): void;
|
(e: 'imageImport'): void;
|
||||||
@@ -149,6 +177,16 @@
|
|||||||
emit('remove');
|
emit('remove');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 批量上架
|
||||||
|
const batchUp = () => {
|
||||||
|
emit('batchUp');
|
||||||
|
};
|
||||||
|
|
||||||
|
// 批量下架
|
||||||
|
const batchDown = () => {
|
||||||
|
emit('batchDown');
|
||||||
|
};
|
||||||
|
|
||||||
// 打开导入
|
// 打开导入
|
||||||
const openImport = () => {
|
const openImport = () => {
|
||||||
emit('import');
|
emit('import');
|
||||||
@@ -220,3 +258,35 @@
|
|||||||
{ immediate: true }
|
{ immediate: true }
|
||||||
);
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style lang="less" scoped>
|
||||||
|
.goods-toolbar {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 选中后浮现的操作栏 */
|
||||||
|
.selection-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
background: #e6f4ff;
|
||||||
|
border: 1px solid #91caff;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selection-bar-count {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1677ff;
|
||||||
|
margin-right: 4px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ele-btn-icon-arrow {
|
||||||
|
font-size: 12px;
|
||||||
|
margin-left: 2px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -20,16 +20,53 @@
|
|||||||
</a-upload-dragger>
|
</a-upload-dragger>
|
||||||
</a-spin>
|
</a-spin>
|
||||||
<div class="ele-text-center">
|
<div class="ele-text-center">
|
||||||
<span>只能上传 xls、xlsx 文件</span>
|
<span>只能上传 xls、xlsx 文件,</span>
|
||||||
|
<a :href="templateUrl" download="商品详情导入模板.xlsx">下载导入模板</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 使用说明 -->
|
||||||
|
<a-alert type="info" show-icon :style="{ marginTop: '16px' }">
|
||||||
|
<template #message>
|
||||||
|
<div class="import-tips">
|
||||||
|
<div class="import-tips-title">使用说明:</div>
|
||||||
|
<ul class="import-tips-list">
|
||||||
|
<li>Excel 中每行对应一个商品的详情内容</li>
|
||||||
|
<li>图片按<strong>文件夹</strong>组织,<strong>文件夹名称为商品编号</strong></li>
|
||||||
|
<li>文件夹内的图片按<strong>数字序号</strong>命名排序(如 1.jpg、2.png、3.jpg …)</li>
|
||||||
|
<li>图片序号对应 Excel 中的图片展示顺序</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</a-alert>
|
||||||
</ele-modal>
|
</ele-modal>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style lang="less" scoped>
|
||||||
|
.import-tips {
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.8;
|
||||||
|
color: #555;
|
||||||
|
}
|
||||||
|
.import-tips-title {
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
.import-tips-list {
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 20px;
|
||||||
|
li {
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { ref } from 'vue';
|
import { ref, computed } from 'vue';
|
||||||
import { message } from 'ant-design-vue/es';
|
import { message } from 'ant-design-vue/es';
|
||||||
import { CloudUploadOutlined } from '@ant-design/icons-vue';
|
import { CloudUploadOutlined } from '@ant-design/icons-vue';
|
||||||
import { importShopGoodsDetails } from '@/api/shop/shopGoods';
|
import { importShopGoodsDetails } from '@/api/shop/shopGoods';
|
||||||
|
import { MODULES_API_URL } from '@/config/setting';
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
@@ -42,6 +79,10 @@
|
|||||||
|
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
|
|
||||||
|
const templateUrl = computed(() => {
|
||||||
|
return MODULES_API_URL + '/shop/shop-goods/import/details/template';
|
||||||
|
});
|
||||||
|
|
||||||
const doUpload = ({ file }) => {
|
const doUpload = ({ file }) => {
|
||||||
if (
|
if (
|
||||||
![
|
![
|
||||||
|
|||||||
@@ -20,16 +20,18 @@
|
|||||||
</a-upload-dragger>
|
</a-upload-dragger>
|
||||||
</a-spin>
|
</a-spin>
|
||||||
<div class="ele-text-center">
|
<div class="ele-text-center">
|
||||||
<span>只能上传 zip 文件,图片文件名需与商品编号对应</span>
|
<span>只能上传 zip 文件,图片文件名需与商品编号对应,</span>
|
||||||
|
<a :href="templateUrl" download="商品图片导入示例.zip">下载导入模板</a>
|
||||||
</div>
|
</div>
|
||||||
</ele-modal>
|
</ele-modal>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { ref } from 'vue';
|
import { ref, computed } from 'vue';
|
||||||
import { message } from 'ant-design-vue/es';
|
import { message } from 'ant-design-vue/es';
|
||||||
import { CloudUploadOutlined } from '@ant-design/icons-vue';
|
import { CloudUploadOutlined } from '@ant-design/icons-vue';
|
||||||
import { importShopGoodsImages } from '@/api/shop/shopGoods';
|
import { importShopGoodsImages } from '@/api/shop/shopGoods';
|
||||||
|
import { MODULES_API_URL } from '@/config/setting';
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
@@ -42,6 +44,10 @@
|
|||||||
|
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
|
|
||||||
|
const templateUrl = computed(() => {
|
||||||
|
return MODULES_API_URL + '/shop/shop-goods/import/images/template';
|
||||||
|
});
|
||||||
|
|
||||||
const doUpload = ({ file }) => {
|
const doUpload = ({ file }) => {
|
||||||
const isZip =
|
const isZip =
|
||||||
file.type === 'application/zip' ||
|
file.type === 'application/zip' ||
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
:columns="columns"
|
:columns="columns"
|
||||||
:datasource="datasource"
|
:datasource="datasource"
|
||||||
:customRow="customRow"
|
:customRow="customRow"
|
||||||
|
size="small"
|
||||||
v-model:selection="selection"
|
v-model:selection="selection"
|
||||||
tool-class="ele-toolbar-form"
|
tool-class="ele-toolbar-form"
|
||||||
class="sys-org-table"
|
class="sys-org-table"
|
||||||
@@ -21,6 +22,8 @@
|
|||||||
:navigationList="navigationList"
|
:navigationList="navigationList"
|
||||||
@add="openEdit"
|
@add="openEdit"
|
||||||
@remove="removeBatch"
|
@remove="removeBatch"
|
||||||
|
@batchUp="batchUpShelf"
|
||||||
|
@batchDown="batchDownShelf"
|
||||||
@batchMove="openMove"
|
@batchMove="openMove"
|
||||||
@import="openImport"
|
@import="openImport"
|
||||||
@imageImport="openImageImport"
|
@imageImport="openImageImport"
|
||||||
@@ -53,6 +56,18 @@
|
|||||||
/></span>
|
/></span>
|
||||||
</a-space>
|
</a-space>
|
||||||
</template>
|
</template>
|
||||||
|
<template v-if="column.key === 'isTop'">
|
||||||
|
<a-space @click.stop="onTop(record)">
|
||||||
|
<span
|
||||||
|
v-if="record.isTop === 1"
|
||||||
|
class="ele-text-success cursor-pointer"
|
||||||
|
><CheckOutlined
|
||||||
|
/></span>
|
||||||
|
<span v-else class="ele-text-placeholder cursor-pointer"
|
||||||
|
><CloseOutlined
|
||||||
|
/></span>
|
||||||
|
</a-space>
|
||||||
|
</template>
|
||||||
<template v-if="column.key === 'status'">
|
<template v-if="column.key === 'status'">
|
||||||
<a-tag v-if="record.status === 0" color="green">已上架</a-tag>
|
<a-tag v-if="record.status === 0" color="green">已上架</a-tag>
|
||||||
<a-tag v-if="record.status === 1" color="orange">待上架</a-tag>
|
<a-tag v-if="record.status === 1" color="orange">待上架</a-tag>
|
||||||
@@ -63,6 +78,19 @@
|
|||||||
<a-space>
|
<a-space>
|
||||||
<a @click.stop="openEdit(record)">修改</a>
|
<a @click.stop="openEdit(record)">修改</a>
|
||||||
<a-divider type="vertical" />
|
<a-divider type="vertical" />
|
||||||
|
<a
|
||||||
|
v-if="record.status === 0"
|
||||||
|
class="ele-text-warning"
|
||||||
|
@click.stop="toggleShelf(record, 1)"
|
||||||
|
>下架</a
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
v-else
|
||||||
|
class="ele-text-success"
|
||||||
|
@click.stop="toggleShelf(record, 0)"
|
||||||
|
>上架</a
|
||||||
|
>
|
||||||
|
<a-divider type="vertical" />
|
||||||
<a-popconfirm
|
<a-popconfirm
|
||||||
title="确定要删除此记录吗?"
|
title="确定要删除此记录吗?"
|
||||||
@confirm.stop="remove(record)"
|
@confirm.stop="remove(record)"
|
||||||
@@ -242,6 +270,13 @@
|
|||||||
key: 'recommend',
|
key: 'recommend',
|
||||||
align: 'center'
|
align: 'center'
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: '置顶',
|
||||||
|
dataIndex: 'isTop',
|
||||||
|
key: 'isTop',
|
||||||
|
align: 'center',
|
||||||
|
sorter: true
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: '状态',
|
title: '状态',
|
||||||
dataIndex: 'status',
|
dataIndex: 'status',
|
||||||
@@ -259,7 +294,8 @@
|
|||||||
dataIndex: 'sortNumber',
|
dataIndex: 'sortNumber',
|
||||||
key: 'sortNumber',
|
key: 'sortNumber',
|
||||||
align: 'center',
|
align: 'center',
|
||||||
width: 120
|
width: 120,
|
||||||
|
sorter: true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '创建时间',
|
title: '创建时间',
|
||||||
@@ -270,15 +306,15 @@
|
|||||||
ellipsis: true,
|
ellipsis: true,
|
||||||
width: 180,
|
width: 180,
|
||||||
customRender: ({ text }) => toDateString(text, 'yyyy-MM-dd HH:mm:ss')
|
customRender: ({ text }) => toDateString(text, 'yyyy-MM-dd HH:mm:ss')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
key: 'action',
|
||||||
|
width: 200,
|
||||||
|
fixed: 'right',
|
||||||
|
align: 'center',
|
||||||
|
hideInSetting: true
|
||||||
}
|
}
|
||||||
// {
|
|
||||||
// title: '操作',
|
|
||||||
// key: 'action',
|
|
||||||
// width: 180,
|
|
||||||
// fixed: 'right',
|
|
||||||
// align: 'center',
|
|
||||||
// hideInSetting: true
|
|
||||||
// }
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
/* 搜索 */
|
/* 搜索 */
|
||||||
@@ -506,6 +542,76 @@
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/* 单个商品置顶/取消置顶(isTop: 1 置顶 0 不置顶) */
|
||||||
|
const onTop = (row: ShopGoods) => {
|
||||||
|
const hide = message.loading('请求中..', 0);
|
||||||
|
updateShopGoods({
|
||||||
|
...row,
|
||||||
|
isTop: row.isTop == 1 ? 0 : 1
|
||||||
|
})
|
||||||
|
.then((msg) => {
|
||||||
|
hide();
|
||||||
|
message.success(msg);
|
||||||
|
reload();
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
hide();
|
||||||
|
message.error(e.message);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/* 单个商品上/下架切换(status: 0 上架 1 下架) */
|
||||||
|
const toggleShelf = (row: ShopGoods, status: number) => {
|
||||||
|
const hide = message.loading('请求中..', 0);
|
||||||
|
updateShopGoods({ ...row, status })
|
||||||
|
.then((msg) => {
|
||||||
|
hide();
|
||||||
|
message.success(msg);
|
||||||
|
reload();
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
hide();
|
||||||
|
message.error(e.message);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/* 批量上架 */
|
||||||
|
const batchUpShelf = () => {
|
||||||
|
batchSetShelf(0, '上架');
|
||||||
|
};
|
||||||
|
|
||||||
|
/* 批量下架 */
|
||||||
|
const batchDownShelf = () => {
|
||||||
|
batchSetShelf(1, '下架');
|
||||||
|
};
|
||||||
|
|
||||||
|
/* 批量设置上下架 */
|
||||||
|
const batchSetShelf = (status: number, label: string) => {
|
||||||
|
if (!selection.value.length) {
|
||||||
|
message.error('请至少选择一条商品');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Modal.confirm({
|
||||||
|
title: '提示',
|
||||||
|
content: `确定要批量${label}选中的 ${selection.value.length} 个商品吗?`,
|
||||||
|
icon: createVNode(ExclamationCircleOutlined),
|
||||||
|
maskClosable: true,
|
||||||
|
onOk: () => {
|
||||||
|
const hide = message.loading('请求中..', 0);
|
||||||
|
Promise.all(selection.value.map((d) => updateShopGoods({ ...d, status })))
|
||||||
|
.then(() => {
|
||||||
|
hide();
|
||||||
|
message.success(`已批量${label} ${selection.value.length} 个商品`);
|
||||||
|
reload();
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
hide();
|
||||||
|
message.error(e.message);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
/* 自定义行属性 */
|
/* 自定义行属性 */
|
||||||
const customRow = (record: ShopGoods) => {
|
const customRow = (record: ShopGoods) => {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -24,7 +24,7 @@
|
|||||||
<template #bodyCell="{ column, record }">
|
<template #bodyCell="{ column, record }">
|
||||||
<template v-if="column.key === 'title'">
|
<template v-if="column.key === 'title'">
|
||||||
<a-space :style="{ paddingLeft: ((depthMap.get(record.categoryId || 0) || 0) * 20) + 'px' }">
|
<a-space :style="{ paddingLeft: ((depthMap.get(record.categoryId || 0) || 0) * 20) + 'px' }">
|
||||||
<img v-if="record.image" :src="record.image" style="width: 24px; height: 24px; object-fit: cover; border-radius: 4px;" />
|
<img v-if="record.image" :src="getCompressedImageUrl(record.image, { width: 48, quality: 90 })" style="width: 24px; height: 24px; object-fit: cover; border-radius: 4px;" />
|
||||||
<span class="font-bold">{{ record.title }}</span>
|
<span class="font-bold">{{ record.title }}</span>
|
||||||
</a-space>
|
</a-space>
|
||||||
</template>
|
</template>
|
||||||
@@ -36,7 +36,7 @@
|
|||||||
<template v-if="column.key === 'adImage'">
|
<template v-if="column.key === 'adImage'">
|
||||||
<img
|
<img
|
||||||
v-if="record.adImage"
|
v-if="record.adImage"
|
||||||
:src="record.adImage"
|
:src="getCompressedImageUrl(record.adImage, { width: 144, quality: 85 })"
|
||||||
style="width: 72px; height: 40px; object-fit: cover; border-radius: 4px;"
|
style="width: 72px; height: 40px; object-fit: cover; border-radius: 4px;"
|
||||||
/>
|
/>
|
||||||
<span v-else>-</span>
|
<span v-else>-</span>
|
||||||
@@ -91,6 +91,7 @@
|
|||||||
} from 'ele-admin-pro/es/ele-pro-table/types';
|
} from 'ele-admin-pro/es/ele-pro-table/types';
|
||||||
import Search from './components/search.vue';
|
import Search from './components/search.vue';
|
||||||
import ShopGoodsCategoryEdit from './components/shopGoodsCategoryEdit.vue';
|
import ShopGoodsCategoryEdit from './components/shopGoodsCategoryEdit.vue';
|
||||||
|
import { getCompressedImageUrl } from '@/utils/image';
|
||||||
import {
|
import {
|
||||||
listShopGoodsCategory,
|
listShopGoodsCategory,
|
||||||
removeShopGoodsCategory,
|
removeShopGoodsCategory,
|
||||||
|
|||||||
Reference in New Issue
Block a user