feat(manager): 优化列表操作列规范、会员分组统计、漏斗图重构与订单交互

1. 订单交互:移除 orderList 缓存机制修复默认带入订单号问题,支持 query 联动;

2. 会员模块:会员分组新增人数统计与联动,全量规范会员提现、充值、日志等操作列样式;

3. 商品与销量:虚拟销量优化首列缩略图与对齐方式,规范品牌与商品分组操作;

4. 数据统计:重构交易转化漏斗图为自适应 SVG 渐变图与指标卡片;

5. 系统与营销:统一菜单/部门操作列样式,优化 OSS 模块、营销活动与首页待办对齐。
This commit is contained in:
pikachu1995@126.com
2026-10-30 06:51:48 +08:00
parent 2563f122fc
commit c0cc8d8598
39 changed files with 16121 additions and 6705 deletions

View File

@@ -170,6 +170,10 @@ export const deleteMemberGroup = (id) => {
return deleteRequest(`/member/memberGroup/delete/${id}`);
};
export const getMemberGroupUsers = (groupId, params) => {
return getRequest(`/member/memberGroup/${groupId}/users`, params);
};
export const addMemberGroupUsers = (groupId, memberIds) => {
return postRequest(`/member/memberGroup/${groupId}/users`, {
memberIds: Array.isArray(memberIds) ? memberIds.join(",") : memberIds,

View File

@@ -1,100 +1,138 @@
<template>
<div class="wrapper">
<div class="wap-content">
<div class="query-wrapper">
<div class="query-item">
<div>搜索范围</div>
<el-input
v-model="goodsParams.goodsName"
placeholder="商品名称"
clearable
style="width: 150px"
@clear="onSearchGoods"
@keyup.enter="onSearchGoods"
/>
</div>
<div class="query-item">
<el-cascader
v-model="category"
:options="skuList"
placeholder="请选择商品分类"
popper-class="goods-dialog-cascader-popper"
style="width: 250px"
clearable
/>
</div>
<div class="query-item">
<el-button type="primary" @click="onSearchGoods">搜索</el-button>
</div>
</div>
<div>
<el-table
ref="tableRef"
v-loading="loading"
class="goods-select-table"
:data="goodsData"
row-key="id"
height="340"
empty-text="暂无商品信息"
@selection-change="onSelectionChange"
>
<el-table-column
type="selection"
label="选择"
width="60"
align="center"
reserve-selection
/>
<el-table-column label="商品图片" width="90" align="center">
<template #default="{ row }">
<img class="goods-thumb" :src="row.thumbnail" alt="" />
</template>
</el-table-column>
<el-table-column
prop="goodsName"
label="商品名称"
min-width="200"
show-overflow-tooltip
/>
<el-table-column label="商品规格" min-width="140" show-overflow-tooltip>
<template #default="{ row }">
{{ row.simpleSpecs || "默认规格" }}
</template>
</el-table-column>
<el-table-column label="价格" width="110">
<template #default="{ row }">
<span class="goods-price">¥{{ $filters.unitPrice(row.price) }}</span>
</template>
</el-table-column>
<el-table-column label="库存" width="90">
<template #default="{ row }">
{{ row.quantity != null ? row.quantity : "—" }}
</template>
</el-table-column>
<el-table-column label="销量" width="90">
<template #default="{ row }">
{{ row.buyCount != null ? row.buyCount : 0 }}
</template>
</el-table-column>
<el-table-column label="商品类型" width="110">
<template #default="{ row }">
{{ goodsTypeLabel(row.goodsType) }}
</template>
</el-table-column>
</el-table>
<el-pagination
v-model:current-page="goodsParams.pageNumber"
class="pageration"
:total="total"
:page-size="goodsParams.pageSize"
layout="total, prev, pager, next"
size="small"
@current-change="changePageSize"
<div class="goods-dialog-wrapper">
<!-- 顶部搜索筛选栏 -->
<div class="goods-query-bar">
<div class="query-item">
<span class="query-label">商品名称:</span>
<el-input
v-model="goodsParams.goodsName"
placeholder="请输入商品名称"
clearable
style="width: 220px"
@clear="onSearchGoods"
@keyup.enter="onSearchGoods"
/>
</div>
<div class="query-item">
<span class="query-label">商品编号:</span>
<el-input
v-model="goodsParams.sn"
placeholder="请输入商品编号/货号"
clearable
style="width: 190px"
@clear="onSearchGoods"
@keyup.enter="onSearchGoods"
/>
</div>
<div class="query-item">
<span class="query-label">商品分类:</span>
<el-cascader
v-model="category"
:options="skuList"
placeholder="请选择商品分类"
popper-class="goods-dialog-cascader-popper"
style="width: 240px"
clearable
/>
</div>
<div class="query-item query-actions">
<el-button type="primary" @click="onSearchGoods">搜索</el-button>
<el-button @click="onResetGoods">重置</el-button>
</div>
</div>
<!-- 表格展示区域 (自适应填充剩余高度) -->
<div class="goods-table-wrap">
<el-table
ref="tableRef"
v-loading="loading"
class="goods-select-table"
:data="goodsData"
row-key="id"
height="100%"
stripe
border
empty-text="暂无匹配商品信息"
@selection-change="onSelectionChange"
>
<el-table-column
type="selection"
width="50"
align="center"
reserve-selection
/>
<el-table-column label="商品图片" width="76" align="center">
<template #default="{ row }">
<el-image
class="goods-thumb"
:src="row.thumbnail"
fit="cover"
preview-teleported
:preview-src-list="row.thumbnail ? [row.thumbnail] : []"
>
<template #error>
<div class="thumb-slot">无图</div>
</template>
</el-image>
</template>
</el-table-column>
<el-table-column
prop="goodsName"
label="商品名称"
min-width="260"
show-overflow-tooltip
>
<template #default="{ row }">
<div class="goods-title-line">{{ row.goodsName || "-" }}</div>
<div v-if="row.sn" class="goods-sn-line font-mono">货号: {{ row.sn }}</div>
</template>
</el-table-column>
<el-table-column label="商品规格" min-width="140" show-overflow-tooltip>
<template #default="{ row }">
<span>{{ row.simpleSpecs || "默认规格" }}</span>
</template>
</el-table-column>
<el-table-column label="销售价" width="120" align="right">
<template #default="{ row }">
<span class="goods-price font-semibold">
¥ {{ Number(row.price || 0).toFixed(2) }}
</span>
</template>
</el-table-column>
<el-table-column label="可用库存" width="100" align="right">
<template #default="{ row }">
<span>{{ row.quantity != null ? row.quantity : "—" }}</span>
</template>
</el-table-column>
<el-table-column label="累计销量" width="100" align="right">
<template #default="{ row }">
<span>{{ row.buyCount != null ? row.buyCount : 0 }}</span>
</template>
</el-table-column>
<el-table-column label="商品类型" width="100" align="center">
<template #default="{ row }">
<el-tag size="small" type="info">{{ goodsTypeLabel(row.goodsType) }}</el-tag>
</template>
</el-table-column>
</el-table>
</div>
<!-- 底部翻页栏 -->
<div class="goods-pagination-bar">
<el-pagination
v-model:current-page="goodsParams.pageNumber"
v-model:page-size="goodsParams.pageSize"
:total="total"
:page-sizes="[15, 30, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
size="small"
@current-change="changePage"
@size-change="onPageSizeChange"
/>
</div>
</div>
</template>
<script setup>
import { nextTick, onMounted, reactive, ref, watch } from "vue";
import * as API_Goods from "@/api/goods";
@@ -131,6 +169,7 @@ const tableRef = ref(null);
watch(category, (val) => {
goodsParams.categoryPath = val && val.length ? val[2] : "";
});
watch(
() => props.selectedWay,
(val) => {
@@ -139,6 +178,7 @@ watch(
},
{ deep: true, immediate: true }
);
watch(
() => goodsParams.categoryPath,
() => {
@@ -158,10 +198,27 @@ function onSearchGoods() {
goodsParams.pageNumber = 1;
getQueryGoodsList();
}
function changePageSize(v) {
function onResetGoods() {
goodsParams.goodsName = "";
goodsParams.sn = "";
category.value = [];
goodsParams.categoryPath = "";
goodsParams.pageNumber = 1;
getQueryGoodsList();
}
function changePage(v) {
goodsParams.pageNumber = v;
getQueryGoodsList();
}
function onPageSizeChange(val) {
goodsParams.pageSize = val;
goodsParams.pageNumber = 1;
getQueryGoodsList();
}
function getQueryGoodsList() {
loading.value = true;
API_Goods.getGoodsSkuData(goodsParams)
@@ -172,6 +229,7 @@ function getQueryGoodsList() {
loading.value = false;
});
}
function initGoods(res) {
const records = res?.result?.records || [];
records.forEach((item) => {
@@ -181,6 +239,7 @@ function initGoods(res) {
goodsData.value = records;
nextTick(syncTableSelection);
}
function syncTableSelection() {
const table = tableRef.value;
if (!table) return;
@@ -189,16 +248,19 @@ function syncTableSelection() {
return;
}
goodsData.value.forEach((item) => {
const selected = selectedList.value.some((e) => (e.id || e.skuId) && String(e.id || e.skuId) === String(item.id || item.skuId));
const selected = selectedList.value.some(
(e) => (e.id || e.skuId) && String(e.id || e.skuId) === String(item.id || item.skuId)
);
table.toggleRowSelection(item, selected);
});
}
function emitSelected(list) {
selectedList.value = list;
emit("selected", selectedList.value);
}
function onSelectionChange(rows) {
// 选择链接时为单选:保留最后勾选的一项,多选框交互不变
if (type.value !== "multiple") {
if (!rows.length) {
emitSelected([]);
@@ -214,17 +276,18 @@ function onSelectionChange(rows) {
}
emitSelected(rows);
}
function init() {
API_Goods.getGoodsSkuData(goodsParams).then((res) => {
initGoods(res);
});
getQueryGoodsList();
API_Goods.getCategoryTree({ deleteFlag: false }).then((res) => {
if (res.success) {
if (res.success && res.result) {
deepGroup(res.result);
}
});
}
function deepGroup(val) {
skuList.value = [];
val.forEach((item) => {
let childWay = [];
if (item.children) {
@@ -252,34 +315,102 @@ function deepGroup(val) {
});
}
// 父组件 lili-dialog/index.vue 通过 $refs.goodsDialog.type = "single" 切换单选;
// expose 的 ref 外部读写等价于原 data 字段
defineExpose({ type });
</script>
<style scoped lang="scss">
@import "./style.scss";
.wap-content {
.goods-dialog-wrapper {
width: 100%;
flex: 1;
padding: 0;
height: 100%;
display: flex;
flex-direction: column;
}
.font-mono {
font-family: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
}
.font-semibold {
font-weight: 600;
}
/* 顶部搜索栏 */
.goods-query-bar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px;
padding: 0 0 12px;
flex-shrink: 0;
.query-item {
display: flex;
align-items: center;
.query-label {
font-size: 13px;
color: #606266;
white-space: nowrap;
margin-right: 4px;
}
}
.query-actions {
margin-left: auto;
display: flex;
gap: 8px;
}
}
/* 表格容器 */
.goods-table-wrap {
flex: 1;
min-height: 0;
width: 100%;
overflow: hidden;
}
.goods-thumb {
display: block;
width: 48px;
height: 48px;
margin: 0 auto;
border-radius: 4px;
object-fit: cover;
border: 1px solid #ebeef5;
}
.thumb-slot {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
font-size: 12px;
color: #c0c4cc;
}
.goods-title-line {
font-size: 13px;
color: #303133;
line-height: 1.4;
}
.goods-sn-line {
font-size: 12px;
color: #909399;
margin-top: 2px;
}
.goods-price {
color: $theme_color;
color: #f56c6c;
}
.pageration {
/* 底部翻页栏 */
.goods-pagination-bar {
display: flex;
justify-content: flex-end;
width: 100%;
margin-top: 16px;
padding-right: 8px;
box-sizing: border-box;
align-items: center;
padding-top: 12px;
flex-shrink: 0;
}
</style>

View File

@@ -2,12 +2,14 @@
<el-dialog
v-if="goodsFlag"
v-model="flag"
width="1160px"
top="120px"
title="选择商品"
width="85%"
top="5vh"
:z-index="10000"
:close-on-click-modal="false"
append-to-body
destroy-on-close
class="goods-selector-dialog"
@close="clickClose"
>
<goodsDialog
@@ -21,8 +23,15 @@
"
/>
<template #footer>
<el-button @click="clickClose">取消</el-button>
<el-button type="primary" @click="clickOK">确定</el-button>
<div class="goods-dialog-footer">
<span class="selected-count-tip">
已选择 <strong class="text-primary">{{ goodsData.length }}</strong> 件商品
</span>
<div class="footer-actions">
<el-button @click="clickClose">取消</el-button>
<el-button type="primary" @click="clickOK">确定</el-button>
</div>
</div>
</template>
</el-dialog>
@@ -53,6 +62,7 @@
</template>
</el-dialog>
</template>
<script setup>
import { ref } from "vue";
import goodsDialog from "./goods-dialog";
@@ -61,8 +71,8 @@ import linkDialog from "./link-dialog";
const emit = defineEmits(["closeFlag", "selectedGoodsData", "selectedLink"]);
const goodsFlag = ref(false); // 是否商品选择器
const goodsData = ref([]); //选择的商品
const linkData = ref(""); //选择的链接
const goodsData = ref([]); // 选择的商品
const linkData = ref(""); // 选择的链接
const flag = ref(false); // modal显隐
// 模板 ref:原 ref="goodsDialog" 与 import 的组件同名,改名避免互相覆盖
@@ -112,14 +122,7 @@ function close() {
flag.value = false;
}
// 外部通过 ref 访问的成员(grep manager/src 全量确认):
// - open:page-decoration 各 modelList、modelForm/modelFormItem、hotzone/Zone、wap/advertising、alertAdvertising 等打开弹窗(goods/link)
// - singleGoods:modelList/forYour.vue、mixModel.vue 直接调用
// - clearGoodsSelected:wap/decorate.vue、modelList/goodsAndType.vue 调用
// - goodsData:modelList/newGoodsSort.vue、notEnough.vue 回写已选商品
// - goodsFlag:wap/decorate.vue 直接写入
// - flag:wap/decorate.vue 直接写入 true 打开弹窗
// - close:未被 grep 到外部调用,原 Options API 实例天然暴露,保守保留
// 外部通过 ref 访问的成员:
defineExpose({
open,
close,
@@ -130,14 +133,73 @@ defineExpose({
flag,
});
</script>
<style scoped lang="scss">
:deep(.el-dialog__body) {
width: 100%;
height: 500px;
overflow: hidden;
}
.goods-dialog-footer {
display: flex;
justify-content: space-between;
align-items: center;
.selected-count-tip {
font-size: 13px;
color: #606266;
.text-primary {
color: #409eff;
font-weight: 600;
font-size: 15px;
margin: 0 2px;
}
}
.footer-actions {
display: flex;
gap: 10px;
}
}
</style>
<style lang="scss">
/* 商品选择器弹窗全局尺寸与布局增强 */
.goods-selector-dialog {
max-width: 1360px;
min-width: 900px;
border-radius: 8px;
.el-dialog__header {
padding: 16px 20px;
margin-right: 0;
border-bottom: 1px solid #ebeef5;
.el-dialog__title {
font-size: 16px;
font-weight: 600;
color: #303133;
}
}
.el-dialog__body {
width: 100% !important;
height: calc(84vh - 120px) !important;
min-height: 520px !important;
max-height: 760px !important;
padding: 16px 20px !important;
box-sizing: border-box !important;
overflow: hidden !important;
}
.el-dialog__footer {
padding: 12px 20px;
border-top: 1px solid #ebeef5;
}
}
// 抽屉 append-to-body 后不在当前组件 DOM 内,不能使用 scoped 样式
.link-selector-drawer {
max-width: 96vw;

View File

@@ -1,28 +1,56 @@
<template>
<div class="search">
<el-card>
<el-form inline>
<el-form-item label="会员">
<el-input v-model="searchForm.memberName" clearable style="width: 160px" />
<div class="finance-wallet-log-page search">
<el-card shadow="never" class="filter-card">
<el-form inline class="search-form" @keyup.enter="handleSearch">
<el-form-item label="客户ID">
<el-input v-model="searchForm.memberId" placeholder="请输入客户ID" clearable style="width: 180px" />
</el-form-item>
<el-form-item label="日期">
<el-date-picker v-model="dateRange" type="daterange" value-format="YYYY-MM-DD" />
<el-form-item label="会员名称">
<el-input v-model="searchForm.memberName" placeholder="请输入会员名称" clearable style="width: 160px" />
</el-form-item>
<el-form-item>
<el-form-item label="日期范围">
<el-date-picker v-model="dateRange" type="daterange" value-format="YYYY-MM-DD" style="width: 240px" />
</el-form-item>
<el-form-item class="form-actions">
<el-button type="primary" @click="handleSearch">搜索</el-button>
<el-button @click="handleReset">重置</el-button>
<el-button @click="handleExport">导出</el-button>
</el-form-item>
</el-form>
</el-card>
<el-card class="mt_10">
<el-table v-loading="loading" border :data="data">
<el-table-column prop="memberName" label="会员" width="140" />
<el-table-column prop="money" label="金额" width="110" />
<el-table-column prop="serviceType" label="业务类型" width="140" />
<el-table-column prop="detail" label="明细" min-width="200" show-overflow-tooltip />
<el-table-column prop="createTime" label="时间" width="170" />
<el-card shadow="never" class="table-card mt_10">
<template #header>
<div class="card-header-bar">
<div class="header-title">
<span>钱包流水明细</span>
</div>
<div class="header-actions">
<el-button size="small" @click="getDataList">刷新</el-button>
</div>
</div>
</template>
<el-table v-loading="loading" border stripe :data="data">
<el-table-column prop="memberId" label="客户ID" min-width="190" show-overflow-tooltip />
<el-table-column prop="memberName" label="会员名称" min-width="140" show-overflow-tooltip />
<el-table-column prop="money" label="金额" width="130" align="right">
<template #default="{ row }">
<span v-if="row" :class="row.money >= 0 ? 'money-increase' : 'money-decrease'">
{{ row.money >= 0 ? '+' : '' }}¥ {{ Number(row.money || 0).toFixed(2) }}
</span>
</template>
</el-table-column>
<el-table-column prop="serviceType" label="业务类型" width="130" align="center">
<template #default="{ row }">
{{ row ? row.serviceType || "-" : "-" }}
</template>
</el-table-column>
<el-table-column prop="detail" label="明细" min-width="240" show-overflow-tooltip />
<el-table-column prop="createTime" label="时间" width="170" align="center" />
</el-table>
<div class="mt_10" style="display: flex; justify-content: flex-end">
<div class="pagination-wrap mt_10">
<el-pagination
v-model:current-page="searchForm.pageNumber"
v-model:page-size="searchForm.pageSize"
@@ -42,15 +70,16 @@ import { useTable } from "@/utils/useTable";
import * as API_Finance from "@/api/finance";
import { getRequest } from "@/libs/axios";
import { downloadBlob } from "@/utils/downloadBlob";
import { ElMessage } from "element-plus";
defineOptions({ name: "finance-wallet-log" });
const dateRange = ref([]);
const { loading, data, total, searchForm, getDataList, handleSearch } = useTable(
const { loading, data, total, searchForm, getDataList, handleSearch, resetSearch } = useTable(
(p) => getRequest("/wallet/log", p),
{
pageSize: 20,
form: { memberName: "" },
form: { memberId: "", memberName: "" },
beforeRequest: (p) => {
const params = { ...p };
if (dateRange.value?.length === 2) {
@@ -62,8 +91,18 @@ const { loading, data, total, searchForm, getDataList, handleSearch } = useTable
}
);
function handleReset() {
dateRange.value = [];
searchForm.memberId = "";
searchForm.memberName = "";
resetSearch();
}
function handleExport() {
const params = { memberName: searchForm.memberName };
const params = {
memberId: searchForm.memberId || undefined,
memberName: searchForm.memberName || undefined,
};
if (dateRange.value?.length === 2) {
params.startDate = dateRange.value[0];
params.endDate = dateRange.value[1];
@@ -73,3 +112,78 @@ function handleExport() {
});
}
</script>
<style lang="scss" scoped>
.finance-wallet-log-page {
padding: 16px 20px;
background-color: #f6f8fa;
min-height: calc(100vh - 110px);
}
.font-mono {
font-family: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
}
.font-semibold {
font-weight: 600;
}
.text-dark {
color: #303133;
}
.filter-card {
border-radius: 8px;
border: 1px solid #ebeef5;
:deep(.el-card__body) {
padding: 16px 16px 2px;
}
.search-form {
display: flex;
flex-wrap: wrap;
align-items: center;
.el-form-item {
margin-bottom: 14px;
margin-right: 16px;
}
.form-actions {
margin-left: auto;
}
}
}
.table-card {
border-radius: 8px;
border: 1px solid #ebeef5;
.card-header-bar {
display: flex;
justify-content: space-between;
align-items: center;
.header-title {
font-size: 15px;
font-weight: 600;
color: #303133;
}
}
}
.money-increase {
color: #67c23a;
}
.money-decrease {
color: #f56c6c;
}
.pagination-wrap {
display: flex;
justify-content: flex-end;
padding-top: 6px;
}
</style>

View File

@@ -1,47 +1,82 @@
<template>
<div class="search">
<el-card>
<div class="brand-manage-page search">
<!-- 顶部筛选表单 -->
<el-card shadow="never" class="filter-card">
<el-form
ref="searchFormRef"
:model="searchForm"
inline
label-width="70px"
label-width="76px"
class="search-form"
@keyup.enter="handleSearch"
>
<el-form-item label="品牌名称">
<el-form-item label="品牌名称" prop="name">
<el-input
v-model="searchForm.name"
placeholder="请输入品牌名称"
clearable
style="width: 240px"
style="width: 220px"
@clear="handleSearch"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch">搜索</el-button>
<el-form-item class="form-actions">
<el-button type="primary" class="search-btn" @click="handleSearch">搜索</el-button>
<el-button @click="resetSearch">重置</el-button>
</el-form-item>
</el-form>
</el-card>
<el-card class="mt_10">
<div class="operation padding-row" style="margin-bottom: 10px;">
<el-button type="primary" @click="add">添加</el-button>
</div>
<!-- 品牌数据表格卡片 -->
<el-card shadow="never" class="table-card mt_10">
<template #header>
<div class="card-header-bar">
<div class="header-title">
<span>商品品牌列表</span>
</div>
<div class="header-actions">
<el-button size="small" @click="getDataList">刷新</el-button>
<el-button type="primary" size="small" @click="add">添加品牌</el-button>
</div>
</div>
</template>
<el-table ref="table" v-loading="loading" border :data="data" style="width: 100%">
<el-table-column prop="name" label="品牌名称" width="200" />
<el-table-column label="品牌图标" align="left">
<el-table
ref="table"
v-loading="loading"
border
stripe
:data="data"
style="width: 100%"
class="custom-brand-table"
>
<!-- 品牌ID -->
<el-table-column prop="id" label="品牌ID" min-width="190" show-overflow-tooltip />
<!-- 品牌图标 -->
<el-table-column label="品牌图标" width="96" align="center">
<template #default="{ row }">
<img
v-if="row"
:src="row.logo || ''"
alt="加载图片失败"
style="cursor: pointer; width: 80px; height: 60px; margin: 10px 0; object-fit: contain"
/>
<div v-if="row && row.logo" class="logo-box">
<el-image
:src="row.logo"
fit="contain"
preview-teleported
:preview-src-list="[row.logo]"
class="brand-logo-img"
>
<template #error>
<div class="logo-slot">无图</div>
</template>
</el-image>
</div>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column label="状态" align="left" width="150">
<!-- 品牌名称 -->
<el-table-column prop="name" label="品牌名称" min-width="180" show-overflow-tooltip />
<!-- 状态 -->
<el-table-column label="状态" width="110" align="center">
<template #default="{ row }">
<el-switch
v-if="row"
@@ -53,8 +88,12 @@
/>
</template>
</el-table-column>
<el-table-column prop="createTime" label="创建时间" min-width="170" show-overflow-tooltip />
<el-table-column label="操作" width="230" align="center" fixed="right">
<!-- 创建时间 -->
<el-table-column prop="createTime" label="创建时间" min-width="170" align="center" show-overflow-tooltip />
<!-- 操作栏 -->
<el-table-column label="操作" width="200" align="center" fixed="right">
<template #default="{ row }">
<template v-if="row">
<a class="link-text" @click="edit(row)">编辑</a>
@@ -67,7 +106,8 @@
</el-table-column>
</el-table>
<div class="mt_10" style="display: flex; justify-content: flex-end">
<!-- 底部分页 -->
<div class="pagination-wrap mt_10">
<el-pagination
v-model:current-page="searchForm.pageNumber"
v-model:page-size="searchForm.pageSize"
@@ -81,24 +121,31 @@
</div>
</el-card>
<!-- 添加 / 编辑品牌弹窗 -->
<el-dialog
v-model="modalVisible"
:title="modalTitle"
width="500px"
width="520px"
:close-on-click-modal="false"
destroy-on-close
>
<el-form ref="formRef" :model="form" label-width="100px" :rules="formValidate">
<el-form ref="formRef" :model="form" label-width="90px" :rules="formValidate">
<el-form-item v-if="modalType === 1 && form.id" label="品牌ID">
<span class="font-mono text-dark">{{ form.id }}</span>
</el-form-item>
<el-form-item label="品牌名称" prop="name">
<el-input v-model="form.name" style="width: 100%" />
<el-input v-model="form.name" placeholder="请输入品牌名称" style="width: 100%" />
</el-form-item>
<el-form-item label="品牌图标" prop="logo">
<div style="display: flex; align-items: center; gap: 8px">
<img
:src="form.logo || defaultPic"
alt=""
style="width: 60px; height: 60px; object-fit: contain; border: 1px solid #dcdfe6; border-radius: 4px"
/>
<el-button type="primary" link @click="openLogoPicker">修改</el-button>
<div class="dialog-logo-picker">
<div class="logo-preview-box">
<img
:src="form.logo || defaultPic"
alt="品牌图标"
class="dialog-logo-img"
/>
</div>
<el-button type="primary" link @click="openLogoPicker">上传/选择图标</el-button>
</div>
</el-form-item>
</el-form>
@@ -108,7 +155,8 @@
</template>
</el-dialog>
<el-dialog v-model="picModelFlag" width="1200px" :show-close="true">
<!-- OSS 图片选择组件弹窗 -->
<el-dialog v-model="picModelFlag" width="1200px" title="选择品牌图标" :show-close="true">
<ossManage
ref="ossManageRef"
:is-component="true"
@@ -117,13 +165,15 @@
/>
</el-dialog>
<!-- 关联分类弹窗 -->
<el-dialog
v-model="categoryModalVisible"
:title="categoryModalTitle"
width="700px"
width="680px"
:close-on-click-modal="false"
destroy-on-close
>
<div v-loading="categoryTreeLoading" style="position: relative; max-height: 520px; overflow: auto">
<div v-loading="categoryTreeLoading" class="category-tree-wrap">
<el-tree
ref="categoryTree"
:key="categoryTreeKey"
@@ -139,7 +189,7 @@
<template #footer>
<el-button @click="categoryModalVisible = false">取消</el-button>
<el-button type="primary" :loading="categorySubmitLoading" @click="submitBrandCategory">
提交
提交关联
</el-button>
</template>
</el-dialog>
@@ -299,7 +349,7 @@ function handleStatusSwitchChange(row, checked) {
}
function delBrand(row) {
ElMessageBox.confirm("您确认要删除 " + row.name + " ?", "确认删除", { type: "warning" }).then(() => {
ElMessageBox.confirm("您确认要删除品牌 " + row.name + " ?", "确认删除", { type: "warning" }).then(() => {
return delBrandApi(row.id).then((res) => {
if (res.success) {
ElMessage.success("品牌删除成功!");
@@ -339,7 +389,7 @@ function handleSubmit() {
function add() {
modalType.value = 0;
modalTitle.value = "添加";
modalTitle.value = "添加品牌";
formRef.value?.resetFields();
delete form.value.id;
modalVisible.value = true;
@@ -347,7 +397,7 @@ function add() {
function edit(v) {
modalType.value = 1;
modalTitle.value = "编辑";
modalTitle.value = "编辑品牌";
formRef.value?.resetFields();
for (const attr in v) {
if (v[attr] === null) {
@@ -360,13 +410,129 @@ function edit(v) {
</script>
<style lang="scss" scoped>
.link-text {
color: #409eff;
cursor: pointer;
text-decoration: none;
.brand-manage-page {
.filter-card {
.search-form {
display: flex;
align-items: center;
flex-wrap: wrap;
margin-bottom: 0;
:deep(.el-form-item) {
margin-bottom: 0;
margin-right: 16px;
}
.form-actions {
margin-left: auto;
}
}
}
.card-header-bar {
display: flex;
justify-content: space-between;
align-items: center;
.header-title {
display: flex;
align-items: center;
gap: 10px;
font-size: 15px;
font-weight: 600;
color: #303133;
}
.header-actions {
display: flex;
align-items: center;
gap: 8px;
}
}
.font-mono {
font-family: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
}
.font-semibold {
font-weight: 600;
}
.text-dark {
color: #303133;
}
.text-placeholder {
color: #c0c4cc;
}
.time-text {
font-size: 12px;
color: #909399;
font-family: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
}
.custom-brand-table {
.logo-box {
width: 52px;
height: 40px;
margin: 0 auto;
border: 1px solid #ebeef5;
border-radius: 4px;
background: #fafafa;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
.brand-logo-img {
width: 48px;
height: 36px;
}
.logo-slot {
font-size: 11px;
color: #c0c4cc;
}
}
}
.pagination-wrap {
display: flex;
justify-content: flex-end;
}
}
.op-split {
margin: 0 8px;
color: #dcdee2;
.dialog-logo-picker {
display: flex;
align-items: center;
gap: 12px;
.logo-preview-box {
width: 64px;
height: 48px;
border: 1px solid #dcdfe6;
border-radius: 4px;
background: #fafafa;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
.dialog-logo-img {
width: 100%;
height: 100%;
object-fit: contain;
}
}
}
.category-tree-wrap {
position: relative;
max-height: 520px;
overflow: auto;
border: 1px solid #ebeef5;
border-radius: 4px;
padding: 10px;
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -1,27 +1,84 @@
<template>
<div class="search">
<el-card>
<div class="operation padding-row">
<el-button type="primary" @click="openAdd">添加分组</el-button>
<div class="goods-group-page search">
<!-- 顶部筛选与操作栏 -->
<el-card shadow="never" class="filter-card">
<div class="card-toolbar">
<el-form inline :model="searchForm" class="filter-form" @keyup.enter="handleSearch">
<el-form-item label="分组名称">
<el-input
v-model="searchForm.groupName"
placeholder="请输入分组名称"
clearable
style="width: 220px"
@clear="handleSearch"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch">搜索</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
<div class="toolbar-right">
<el-button type="primary" @click="openAdd">添加分组</el-button>
</div>
</div>
</el-card>
<el-table ref="table" v-loading="loading" :data="data" border class="mt_10" style="width: 100%">
<!-- 分组数据表格卡片 -->
<el-card shadow="never" class="table-card mt_10">
<template #header>
<div class="card-header-bar">
<div class="header-title">
<span>商品分组列表</span>
</div>
<div class="header-actions">
<el-button size="small" @click="getData">刷新</el-button>
</div>
</div>
</template>
<el-table
ref="table"
v-loading="loading"
:data="data"
border
stripe
style="width: 100%"
class="custom-group-table"
>
<!-- 分组ID -->
<el-table-column prop="id" label="分组ID" min-width="190" show-overflow-tooltip />
<!-- 分组名称 -->
<el-table-column prop="groupName" label="分组名称" min-width="160" show-overflow-tooltip />
<el-table-column prop="description" label="分组描述" min-width="240" show-overflow-tooltip />
<el-table-column prop="createTime" label="创建时间" width="180" />
<el-table-column prop="updateTime" label="更新时间" width="180" />
<el-table-column label="操作" width="200" align="center" fixed="right">
<!-- 分组描述 -->
<el-table-column prop="description" label="分组描述" min-width="240" show-overflow-tooltip>
<template #default="{ row }">
<div v-if="row" class="ops" style="display: flex; justify-content: center">
{{ row ? row.description || "-" : "-" }}
</template>
</el-table-column>
<!-- 创建时间 -->
<el-table-column prop="createTime" label="创建时间" min-width="170" align="center" />
<!-- 更新时间 -->
<el-table-column prop="updateTime" label="更新时间" min-width="170" align="center" />
<!-- 操作栏 -->
<el-table-column label="操作" width="140" align="center" fixed="right">
<template #default="{ row }">
<template v-if="row">
<a class="link-text" @click="openEdit(row)">编辑</a>
<span class="op-split">|</span>
<a class="link-text" @click="remove(row)">删除</a>
</div>
</template>
</template>
</el-table-column>
</el-table>
<div class="mt_10" style="display: flex; justify-content: flex-end">
<!-- 底部分页 -->
<div class="pagination-wrap mt_10">
<el-pagination
v-model:current-page="searchForm.pageNumber"
v-model:page-size="searchForm.pageSize"
@@ -35,13 +92,32 @@
</div>
</el-card>
<el-dialog v-model="addFlag" title="添加商品分组" width="500px" :close-on-click-modal="false" destroy-on-close>
<!-- 添加商品分组弹窗 -->
<el-dialog
v-model="addFlag"
title="添加商品分组"
width="520px"
:close-on-click-modal="false"
destroy-on-close
>
<el-form ref="addFormRef" :model="formAdd" :rules="rulesAdd" label-width="90px">
<el-form-item label="分组名称" prop="groupName" style="width: 90%">
<el-input v-model="formAdd.groupName" maxlength="30" placeholder="请输入分组名称" />
<el-form-item label="分组名称" prop="groupName">
<el-input
v-model="formAdd.groupName"
maxlength="30"
show-word-limit
placeholder="请输入分组名称"
/>
</el-form-item>
<el-form-item label="分组描述" prop="description" style="width: 90%">
<el-input v-model="formAdd.description" maxlength="200" placeholder="请输入分组描述" />
<el-form-item label="分组描述" prop="description">
<el-input
v-model="formAdd.description"
type="textarea"
:rows="3"
maxlength="200"
show-word-limit
placeholder="请输入分组描述信息"
/>
</el-form-item>
</el-form>
<template #footer>
@@ -50,14 +126,35 @@
</template>
</el-dialog>
<el-dialog v-model="editFlag" title="编辑商品分组" width="500px" :close-on-click-modal="false" destroy-on-close>
<!-- 编辑商品分组弹窗 -->
<el-dialog
v-model="editFlag"
title="编辑商品分组"
width="520px"
:close-on-click-modal="false"
destroy-on-close
>
<el-form ref="editFormRef" :model="formEdit" :rules="rulesEdit" label-width="90px">
<el-input v-model="formEdit.id" style="display: none" />
<el-form-item label="分组名称" prop="groupName" style="width: 90%">
<el-input v-model="formEdit.groupName" maxlength="30" placeholder="请输入分组名称" />
<el-form-item label="分组ID">
<span>{{ formEdit.id }}</span>
</el-form-item>
<el-form-item label="分组描述" prop="description" style="width: 90%">
<el-input v-model="formEdit.description" maxlength="200" placeholder="请输入分组描述" />
<el-form-item label="分组名称" prop="groupName">
<el-input
v-model="formEdit.groupName"
maxlength="30"
show-word-limit
placeholder="请输入分组名称"
/>
</el-form-item>
<el-form-item label="分组描述" prop="description">
<el-input
v-model="formEdit.description"
type="textarea"
:rows="3"
maxlength="200"
show-word-limit
placeholder="请输入分组描述信息"
/>
</el-form-item>
</el-form>
<template #footer>
@@ -79,6 +176,7 @@ const loading = ref(true);
const searchForm = reactive({
pageNumber: 1,
pageSize: 20,
groupName: "",
});
const data = ref([]);
const total = ref(0);
@@ -86,18 +184,22 @@ const addFlag = ref(false);
const editFlag = ref(false);
const submitAddLoading = ref(false);
const submitEditLoading = ref(false);
const formAdd = ref({
groupName: "",
description: "",
});
const formEdit = ref({
id: "",
groupName: "",
description: "",
});
const rulesAdd = reactive({
groupName: [{ required: true, message: "请输入分组名称", trigger: "blur" }],
});
const rulesEdit = reactive({
groupName: [{ required: true, message: "请输入分组名称", trigger: "blur" }],
});
@@ -105,7 +207,14 @@ const rulesEdit = reactive({
const addFormRef = ref(null);
const editFormRef = ref(null);
function init() {
function handleSearch() {
searchForm.pageNumber = 1;
getData();
}
function handleReset() {
searchForm.pageNumber = 1;
searchForm.groupName = "";
getData();
}
@@ -216,36 +325,78 @@ function submitEdit() {
}
function remove(row) {
ElMessageBox.confirm("确定删除该分组?", "提示", { type: "warning" }).then(() => {
return API_Goods.deleteGoodsGroup(row.id)
.then((res) => {
if (res && res.success) {
ElMessage.success("删除成功");
getData();
} else if (res && res.message) {
ElMessage.error(res.message);
}
})
.catch(() => {
ElMessage.error("删除失败,请检查权限或后端接口");
});
}).catch(() => {});
ElMessageBox.confirm(`确定删除分组 "${row.groupName || "该分组"}" 吗?`, "提示", {
type: "warning",
confirmButtonText: "确定删除",
cancelButtonText: "取消",
})
.then(() => {
return API_Goods.deleteGoodsGroup(row.id)
.then((res) => {
if (res && res.success) {
ElMessage.success("删除成功");
getData();
} else if (res && res.message) {
ElMessage.error(res.message);
}
})
.catch(() => {
ElMessage.error("删除失败,请检查权限或后端接口");
});
})
.catch(() => {});
}
onMounted(() => {
init();
getData();
});
</script>
<style scoped>
.ops a {
color: #2d8cf0;
cursor: pointer;
text-decoration: none;
}
.ops span {
display: inline-block;
margin: 0 8px;
color: #dcdee2;
<style scoped lang="scss">
.goods-group-page {
.card-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 12px;
.filter-form {
display: flex;
align-items: center;
flex-wrap: wrap;
margin-bottom: 0;
:deep(.el-form-item) {
margin-bottom: 0;
margin-right: 16px;
}
}
.toolbar-right {
display: flex;
align-items: center;
}
}
.card-header-bar {
display: flex;
justify-content: space-between;
align-items: center;
.header-title {
display: flex;
align-items: center;
gap: 10px;
font-size: 15px;
font-weight: 600;
color: #303133;
}
}
.pagination-wrap {
display: flex;
justify-content: flex-end;
}
}
</style>

View File

@@ -51,7 +51,6 @@
<el-card class="mt_10">
<el-alert
type="info"
show-icon
:closable="false"
title="支持按规格逐条设置虚拟销量,列表总销量 = 真实销量 + 虚拟销量。"
style="margin-bottom: 10px"
@@ -71,55 +70,65 @@
ref="tableRef"
v-loading="loading"
:data="data"
border
class="mt_10"
style="width: 100%"
@sort-change="changeSort"
@selection-change="handleSelectionChange"
>
<el-table-column type="selection" width="55" align="center" />
<el-table-column prop="id" label="SKU ID" width="180" show-overflow-tooltip />
<el-table-column prop="goodsId" label="SPU编码" width="180" show-overflow-tooltip />
<el-table-column label="商品信息" min-width="360">
<el-table-column label="商品信息" min-width="320">
<template #default="{ row }">
<div v-if="row" class="goods-info">
<img
v-if="row.thumbnail"
:src="row.thumbnail"
class="goods-thumbnail"
alt=""
/>
<div class="goods-thumb-box">
<el-image
v-if="row.thumbnail"
:src="row.thumbnail"
fit="cover"
preview-teleported
:preview-src-list="[row.thumbnail]"
class="goods-thumbnail"
>
<template #error>
<div class="thumb-slot">无图</div>
</template>
</el-image>
<div v-else class="thumb-slot">无图</div>
</div>
<div class="goods-text">
<div class="div-zoom">{{ row.goodsName }}</div>
<div v-if="row.simpleSpecs" class="sub-title">
<div class="goods-title" :title="row.goodsName">{{ row.goodsName || "-" }}</div>
<div v-if="row.simpleSpecs" class="goods-specs">
规格:{{ row.simpleSpecs.trim() || "-" }}
</div>
</div>
</div>
</template>
</el-table-column>
<el-table-column label="真实销量" width="110">
<el-table-column prop="goodsId" label="SPU编码" width="160" show-overflow-tooltip />
<el-table-column prop="id" label="SKU ID" width="160" show-overflow-tooltip />
<el-table-column label="真实销量" width="110" align="right">
<template #default="{ row }">
<span v-if="row">{{ row.buyCount || 0 }}</span>
</template>
</el-table-column>
<el-table-column label="虚拟销量" width="110">
<el-table-column label="虚拟销量" width="110" align="right">
<template #default="{ row }">
<span v-if="row">{{ row.virtualSalesInput || 0 }}</span>
</template>
</el-table-column>
<el-table-column label="总销量" width="110">
<el-table-column label="总销量" width="110" align="right">
<template #default="{ row }">
<span v-if="row">{{ getTotalSales(row) }}</span>
</template>
</el-table-column>
<el-table-column label="状态" width="100">
<el-table-column label="状态" width="90" align="center">
<template #default="{ row }">
<el-tag v-if="row" :type="row.marketEnable === 'UPPER' ? 'success' : 'warning'">
<span v-if="row">
{{ row.marketEnable === "UPPER" ? "上架" : "下架" }}
</el-tag>
</span>
</template>
</el-table-column>
<el-table-column label="操作" width="100" align="center" fixed="right">
<el-table-column label="操作" width="90" align="center" fixed="right">
<template #default="{ row, $index }">
<a
v-if="row"
@@ -445,32 +454,63 @@ onMounted(() => {
</script>
<style lang="scss" scoped>
.sub-title {
margin-top: 6px;
color: #808695;
font-size: 12px;
}
.goods-info {
display: flex;
align-items: center;
margin: 5px 0;
padding: 10px 0;
padding: 4px 0;
}
.goods-thumb-box {
width: 44px;
height: 44px;
min-width: 44px;
margin-right: 12px;
border-radius: 4px;
overflow: hidden;
border: 1px solid #ebeef5;
background-color: #f8f9fa;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.goods-thumbnail {
width: 50px;
height: 50px;
margin-right: 12px;
object-fit: cover;
flex-shrink: 0;
width: 44px;
height: 44px;
display: block;
}
.thumb-slot {
width: 44px;
height: 44px;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
color: #909399;
background-color: #f5f7fa;
}
.goods-text {
min-width: 0;
flex: 1;
}
.div-zoom {
.goods-title {
line-height: 1.4;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
word-break: break-all;
font-weight: normal;
}
.goods-specs {
margin-top: 4px;
font-size: 12px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@@ -493,11 +533,28 @@ onMounted(() => {
.sku-info-label {
width: 90px;
color: #808695;
flex-shrink: 0;
}
.mt_10 {
margin-top: 10px;
}
.link-text {
color: #2d8cf0;
cursor: pointer;
text-decoration: none;
font-size: 14px;
line-height: 1;
display: inline-block;
&:hover {
color: #57a3f3;
}
&.disabled {
color: #c5c8ce;
cursor: not-allowed;
}
}
</style>

View File

@@ -77,9 +77,9 @@
<div class="counts">{{ notices.appointmentWorkOrder || 0 }}</div>
<div>未完成预约工单</div>
</div>
<div class="todo-item" @click="goProcurementAudit">
<div class="counts">{{ notices.procurementAudit || 0 }}</div>
<div>待审核采购单</div>
<div class="todo-item" @click="goMemberWithdrawApply">
<div class="counts">{{ notices.balanceCash || 0 }}</div>
<div>待审核会员提现</div>
</div>
</div>
</div>
@@ -321,10 +321,10 @@ function goAppointmentWorkOrders() {
});
}
function goProcurementAudit() {
function goMemberWithdrawApply() {
router.push({
name: "manager-procurement-order-list",
query: { status: "SUBMITTED" },
name: "withdrawApply",
query: { applyStatus: "APPLY" },
});
}

View File

@@ -7,8 +7,8 @@
</a>
<template #dropdown>
<el-dropdown-menu v-if="value != 0">
<el-dropdown-item v-if="res.balanceCash" command="deposit">
<el-badge :value="res.balanceCash">待处理预存款提现申请</el-badge>
<el-dropdown-item v-if="res.balanceCash" command="withdrawApply">
<el-badge :value="res.balanceCash">待处理会员提现申请</el-badge>
</el-dropdown-item>
<el-dropdown-item v-if="res.complain" command="orderComplaint">
<el-badge :value="res.complain">待处理投诉审核</el-badge>
@@ -28,9 +28,6 @@
<el-dropdown-item v-if="res.appointmentWorkOrder" command="manager-appointment-work-orders">
<el-badge :value="res.appointmentWorkOrder">未完成预约工单</el-badge>
</el-dropdown-item>
<el-dropdown-item v-if="res.procurementAudit" command="manager-procurement-order-list">
<el-badge :value="res.procurementAudit">待审核采购单</el-badge>
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
@@ -63,12 +60,12 @@ const value = computed(() => {
});
function navigateTo(name) {
if (name === "manager-appointment-work-orders") {
router.push({ name, query: { serviceStatus: "OPEN" } });
if (name === "withdrawApply") {
router.push({ name: "withdrawApply", query: { applyStatus: "APPLY" } });
return;
}
if (name === "manager-procurement-order-list") {
router.push({ name, query: { status: "SUBMITTED" } });
if (name === "manager-appointment-work-orders") {
router.push({ name, query: { serviceStatus: "OPEN" } });
return;
}
router.push({ name });

View File

@@ -1,20 +1,29 @@
<template>
<div class="search">
<el-card>
<div class="member-recharge-page search">
<!-- 顶部筛选表单 -->
<el-card shadow="never" class="filter-card">
<el-form
ref="searchFormRef"
:model="searchForm"
inline
label-width="70px"
label-width="76px"
class="search-form"
@keyup.enter="handleSearch"
>
<el-form-item label="客户ID" prop="memberId">
<el-input
v-model="searchForm.memberId"
placeholder="请输入客户ID"
clearable
style="width: 180px"
/>
</el-form-item>
<el-form-item label="客户名称" prop="memberName">
<el-input
v-model="searchForm.memberName"
placeholder="请输入客户名称"
clearable
style="width: 240px"
style="width: 160px"
/>
</el-form-item>
<el-form-item label="充值单号" prop="rechargeSn">
@@ -22,59 +31,106 @@
v-model="searchForm.rechargeSn"
placeholder="请输入充值单号"
clearable
style="width: 240px"
style="width: 190px"
/>
</el-form-item>
<el-form-item label="支付时间">
<el-form-item label="充值时间">
<el-date-picker
v-model="selectDate"
type="datetimerange"
value-format="YYYY-MM-DD HH:mm:ss"
clearable
range-separator="至"
start-placeholder="开始时间"
end-placeholder="结束时间"
placeholder="选择起始时间"
style="width: 360px"
style="width: 320px"
@change="selectDateRange"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" class="search-btn" @click="handleSearch">搜索</el-button>
<el-form-item class="form-actions">
<el-button type="primary" class="search-btn" @click="handleSearch">
搜索
</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
</el-card>
<el-card>
<!-- 充值数据表格 -->
<el-card shadow="never" class="table-card mt_10">
<template #header>
<div class="card-header-bar">
<div class="header-title">
<span>会员充值记录</span>
</div>
<div class="header-actions">
<el-button size="small" @click="getDataList">刷新</el-button>
</div>
</div>
</template>
<el-table
ref="table"
v-loading="loading"
border
stripe
:data="data"
ref="table"
class="mt_10"
style="width: 100%"
>
<el-table-column prop="memberName" label="客户名称" min-width="120" show-overflow-tooltip />
<el-table-column prop="rechargeSn" label="订单号" min-width="180" show-overflow-tooltip />
<el-table-column label="充值金额" width="160" sortable>
<!-- 客户ID -->
<el-table-column prop="memberId" label="客户ID" min-width="170" show-overflow-tooltip />
<!-- 客户名称 -->
<el-table-column prop="memberName" label="客户名称" min-width="140" show-overflow-tooltip />
<!-- 充值单号 -->
<el-table-column prop="rechargeSn" label="充值单号" min-width="180" show-overflow-tooltip />
<!-- 充值金额 -->
<el-table-column label="充值金额" width="140" align="right" sortable prop="rechargeMoney">
<template #default="{ row }">
<priceColorScheme v-if="row" :value="row.rechargeMoney" :color="$mainColor" unit="+" />
<span v-if="row" class="recharge-money-text">
+¥ {{ formatMoney(row.rechargeMoney) }}
</span>
</template>
</el-table-column>
<el-table-column label="充值方式" width="120">
<!-- 充值方式 -->
<el-table-column label="充值方式" width="120" align="center">
<template #default="{ row }">
<span v-if="row">{{ rechargeWayText(row.rechargeWay) }}</span>
{{ row ? rechargeWayText(row.rechargeWay) : "-" }}
</template>
</el-table-column>
<el-table-column label="支付状态" width="120">
<!-- 支付状态 -->
<el-table-column label="支付状态" width="110" align="center">
<template #default="{ row }">
<el-tag v-if="row" :type="row.payStatus === 'PAID' ? 'success' : 'danger'">
{{ row.payStatus === "PAID" ? "已付款" : "未付款" }}
</el-tag>
{{ row ? (row.payStatus === "PAID" ? "已付款" : "未付款") : "-" }}
</template>
</el-table-column>
<!-- 第三方流水号 -->
<el-table-column prop="receivableNo" label="第三方流水号" min-width="180" show-overflow-tooltip />
<!-- 充值时间 -->
<el-table-column prop="createTime" label="充值时间" width="170" align="center" />
<!-- 支付时间 -->
<el-table-column prop="payTime" label="支付时间" width="170" align="center" />
<!-- 操作 -->
<el-table-column label="操作" width="110" align="center" fixed="right">
<template #default="{ row }">
<template v-if="row">
<a v-if="row.memberId" class="link-text" @click="viewMemberDetail(row)">客户详情</a>
<span v-else>-</span>
</template>
</template>
</el-table-column>
<el-table-column prop="createTime" label="充值时间" width="190" />
<el-table-column prop="payTime" label="支付时间" width="190" />
</el-table>
<div class="mt_10" style="display: flex; justify-content: flex-end">
<!-- 底部分页 -->
<div class="pagination-wrap mt_10">
<el-pagination
v-model:current-page="searchForm.pageNumber"
v-model:page-size="searchForm.pageSize"
@@ -91,49 +147,85 @@
</template>
<script setup>
import { ref, onMounted } from "vue";
import { onMounted, ref } from "vue";
import { useTable } from "@/utils/useTable";
import { getUserRecharge } from "@/api/member";
import { customRouterPush } from "@/utils/filters";
import { ElMessage } from "element-plus";
defineOptions({ name: "recharge" });
// 模板 el-form 的 ref(原 ref="searchForm",与 data 同名会互相覆盖,改名为 searchFormRef)
const searchFormRef = ref(null);
const table = ref(null);
const selectDate = ref(null);
const { loading, data, total, searchForm, getDataList } = useTable(
const { loading, data, total, searchForm, getDataList, handleSearch, resetSearch } = useTable(
getUserRecharge,
{
pageSize: 20,
immediate: false,
form: { sort: "createTime", order: "desc", startDate: "", endDate: "", memberName: "" },
form: {
sort: "createTime",
order: "desc",
memberId: "",
memberName: "",
rechargeSn: "",
startDate: "",
endDate: "",
},
beforeRequest: (p) => {
const params = { ...p };
if (selectDate.value && selectDate.value.length === 2) {
params.startDate = selectDate.value[0];
params.endDate = selectDate.value[1];
} else {
params.startDate = "";
params.endDate = "";
}
return params;
},
}
);
function rechargeWayText(way) {
if (way === "ALIPAY") return "支付宝";
if (way === "WECHAT") return "微信";
if (way === "WECHAT") return "微信支付";
if (way === "BANK_TRANSFER") return "线下转账";
return "";
return way || "-";
}
function init() {
getDataList();
function formatMoney(val) {
const num = Number(val || 0);
return num.toFixed(2);
}
function viewMemberDetail(row) {
if (!row || !row.memberId) return;
customRouterPush({ name: "member-detail", query: { id: row.memberId } });
}
function handleReset() {
selectDate.value = null;
searchForm.memberId = "";
searchForm.memberName = "";
searchForm.rechargeSn = "";
searchForm.startDate = "";
searchForm.endDate = "";
searchForm.pageNumber = 1;
resetSearch();
}
function changePage(v) {
searchForm.pageNumber = v;
getDataList();
}
function changePageSize(v) {
searchForm.pageNumber = 1;
searchForm.pageSize = v;
getDataList();
}
function handleSearch() {
searchForm.pageNumber = 1;
searchForm.pageSize = 20;
getDataList();
}
function selectDateRange(v) {
if (v && v.length === 2) {
searchForm.startDate = v[0];
@@ -145,6 +237,84 @@ function selectDateRange(v) {
}
onMounted(() => {
init();
getDataList();
});
</script>
<style lang="scss" scoped>
.member-recharge-page {
padding: 16px 20px;
background-color: #f6f8fa;
min-height: calc(100vh - 110px);
}
.font-mono {
font-family: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
}
.font-semibold {
font-weight: 600;
}
.text-dark {
color: #303133;
}
.text-muted {
color: #909399;
}
/* 筛选卡片 */
.filter-card {
border-radius: 8px;
border: 1px solid #ebeef5;
:deep(.el-card__body) {
padding: 16px 16px 2px;
}
.search-form {
display: flex;
flex-wrap: wrap;
align-items: center;
.el-form-item {
margin-bottom: 14px;
margin-right: 16px;
}
.form-actions {
margin-left: auto;
}
}
}
/* 表格卡片 */
.table-card {
border-radius: 8px;
border: 1px solid #ebeef5;
.card-header-bar {
display: flex;
justify-content: space-between;
align-items: center;
.header-title {
font-size: 15px;
font-weight: 600;
color: #303133;
}
}
}
.recharge-money-text {
font-size: 14px;
color: #67c23a;
}
.pagination-wrap {
display: flex;
justify-content: flex-end;
padding-top: 6px;
}
</style>

View File

@@ -1,75 +1,123 @@
<template>
<div class="search">
<el-card>
<div class="member-wallet-log-page search">
<!-- 顶部筛选表单 -->
<el-card shadow="never" class="filter-card">
<el-form
ref="searchFormRef"
:model="searchForm"
inline
label-width="70px"
label-width="76px"
class="search-form"
@keyup.enter="handleSearch"
>
<el-form-item label="客户ID" prop="memberId">
<el-input
v-model="searchForm.memberId"
placeholder="请输入客户ID"
clearable
style="width: 180px"
/>
</el-form-item>
<el-form-item label="客户名称" prop="memberName">
<el-input
v-model="searchForm.memberName"
placeholder="请输入客户名称"
clearable
style="width: 240px"
style="width: 160px"
/>
</el-form-item>
<el-form-item label="支付时间">
<el-form-item label="变动时间">
<el-date-picker
v-model="selectDate"
type="datetimerange"
value-format="YYYY-MM-DD HH:mm:ss"
clearable
range-separator="至"
start-placeholder="开始时间"
end-placeholder="结束时间"
placeholder="选择起始时间"
style="width: 360px"
style="width: 320px"
@change="selectDateRange"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" class="search-btn" @click="handleSearch">搜索</el-button>
<el-form-item class="form-actions">
<el-button type="primary" class="search-btn" @click="handleSearch">
搜索
</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
</el-card>
<el-card>
<!-- 变动记录表格 -->
<el-card shadow="never" class="table-card mt_10">
<template #header>
<div class="card-header-bar">
<div class="header-title">
<span>预存款变动明细</span>
</div>
<div class="header-actions">
<el-button size="small" @click="getDataList">刷新</el-button>
</div>
</div>
</template>
<el-table
ref="table"
v-loading="loading"
border
stripe
:data="data"
ref="table"
class="mt_10"
style="width: 100%"
>
<el-table-column prop="memberName" label="客户名称" min-width="100" />
<el-table-column label="变动金额" width="150">
<!-- 客户ID -->
<el-table-column prop="memberId" label="客户ID" min-width="190" show-overflow-tooltip />
<!-- 客户名称 -->
<el-table-column prop="memberName" label="客户名称" min-width="140" show-overflow-tooltip />
<!-- 变动金额 -->
<el-table-column label="变动金额" width="140" align="right" sortable prop="money">
<template #default="{ row }">
<template v-if="row">
<priceColorScheme
v-if="row.money > 0"
:value="row.money"
color="green"
/>
<priceColorScheme
v-else-if="row.money < 0"
:value="row.money"
:color="$mainColor"
/>
<span v-if="row.money > 0" class="money-increase">
+¥ {{ formatMoney(row.money) }}
</span>
<span v-else-if="row.money < 0" class="money-decrease">
-¥ {{ formatMoney(Math.abs(row.money)) }}
</span>
<span v-else class="money-zero">
¥ 0.00
</span>
</template>
</template>
</el-table-column>
<el-table-column prop="createTime" label="变更时间" width="200" />
<el-table-column label="业务类型" width="200">
<!-- 业务类型 -->
<el-table-column label="业务类型" width="120" align="center">
<template #default="{ row }">
<span v-if="row">{{ serviceTypeText(row.serviceType) }}</span>
{{ row ? serviceTypeText(row.serviceType) : "-" }}
</template>
</el-table-column>
<!-- 日志详细说明 -->
<el-table-column prop="detail" label="变动说明" min-width="260" show-overflow-tooltip />
<!-- 变动时间 -->
<el-table-column prop="createTime" label="变动时间" width="170" align="center" />
<!-- 操作 -->
<el-table-column label="操作" width="110" align="center" fixed="right">
<template #default="{ row }">
<template v-if="row">
<a v-if="row.memberId" class="link-text" @click="viewMemberDetail(row)">客户详情</a>
<span v-else>-</span>
</template>
</template>
</el-table-column>
<el-table-column prop="detail" label="详细" min-width="300" show-overflow-tooltip />
</el-table>
<div class="mt_10" style="display: flex; justify-content: flex-end">
<!-- 底部分页 -->
<div class="pagination-wrap mt_10">
<el-pagination
v-model:current-page="searchForm.pageNumber"
v-model:page-size="searchForm.pageSize"
@@ -86,23 +134,42 @@
</template>
<script setup>
import { ref, onMounted } from "vue";
import { onMounted, ref } from "vue";
import { useTable } from "@/utils/useTable";
import { getUserWallet } from "@/api/member";
import { customRouterPush } from "@/utils/filters";
import { ElMessage } from "element-plus";
defineOptions({ name: "walletLog" });
// 模板 el-form 的 ref(原 ref="searchForm",与 data 同名会互相覆盖,改名为 searchFormRef)
const searchFormRef = ref(null);
const table = ref(null);
const selectDate = ref(null);
const { loading, data, total, searchForm, getDataList } = useTable(
const { loading, data, total, searchForm, getDataList, handleSearch, resetSearch } = useTable(
getUserWallet,
{
pageSize: 20,
immediate: false,
form: { sort: "createTime", order: "desc", startDate: "", endDate: "", memberName: "" },
form: {
sort: "createTime",
order: "desc",
memberId: "",
memberName: "",
startDate: "",
endDate: "",
},
beforeRequest: (p) => {
const params = { ...p };
if (selectDate.value && selectDate.value.length === 2) {
params.startDate = selectDate.value[0];
params.endDate = selectDate.value[1];
} else {
params.startDate = "";
params.endDate = "";
}
return params;
},
}
);
@@ -113,23 +180,46 @@ function serviceTypeText(type) {
if (type === "WALLET_RECHARGE") return "余额充值";
return "佣金提成";
}
function init() {
getDataList();
function getServiceTagType(type) {
if (type === "WALLET_RECHARGE") return "success";
if (type === "WALLET_REFUND") return "primary";
if (type === "WALLET_PAY") return "warning";
if (type === "WALLET_WITHDRAWAL") return "danger";
return "info";
}
function formatMoney(val) {
const num = Number(val || 0);
return num.toFixed(2);
}
function viewMemberDetail(row) {
if (!row || !row.memberId) return;
customRouterPush({ name: "member-detail", query: { id: row.memberId } });
}
function handleReset() {
selectDate.value = null;
searchForm.memberId = "";
searchForm.memberName = "";
searchForm.startDate = "";
searchForm.endDate = "";
searchForm.pageNumber = 1;
resetSearch();
}
function changePage(v) {
searchForm.pageNumber = v;
getDataList();
}
function changePageSize(v) {
searchForm.pageNumber = 1;
searchForm.pageSize = v;
getDataList();
}
function handleSearch() {
searchForm.pageNumber = 1;
searchForm.pageSize = 20;
getDataList();
}
function selectDateRange(v) {
if (v && v.length === 2) {
searchForm.startDate = v[0];
@@ -141,6 +231,93 @@ function selectDateRange(v) {
}
onMounted(() => {
init();
getDataList();
});
</script>
<style lang="scss" scoped>
.member-wallet-log-page {
padding: 16px 20px;
background-color: #f6f8fa;
min-height: calc(100vh - 110px);
}
.font-mono {
font-family: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
}
.font-semibold {
font-weight: 600;
}
.text-dark {
color: #303133;
}
.text-muted {
color: #909399;
}
/* 筛选卡片 */
.filter-card {
border-radius: 8px;
border: 1px solid #ebeef5;
:deep(.el-card__body) {
padding: 16px 16px 2px;
}
.search-form {
display: flex;
flex-wrap: wrap;
align-items: center;
.el-form-item {
margin-bottom: 14px;
margin-right: 16px;
}
.form-actions {
margin-left: auto;
}
}
}
/* 表格卡片 */
.table-card {
border-radius: 8px;
border: 1px solid #ebeef5;
.card-header-bar {
display: flex;
justify-content: space-between;
align-items: center;
.header-title {
font-size: 15px;
font-weight: 600;
color: #303133;
}
}
}
.money-increase {
font-size: 14px;
color: #67c23a;
}
.money-decrease {
font-size: 14px;
color: #f56c6c;
}
.money-zero {
font-size: 14px;
}
.pagination-wrap {
display: flex;
justify-content: flex-end;
padding-top: 6px;
}
</style>

View File

@@ -1,29 +1,52 @@
<template>
<div class="search">
<el-card>
<div class="member-withdraw-apply-page search">
<!-- 顶部筛选表单 -->
<el-card shadow="never" class="filter-card">
<el-form
ref="searchFormRef"
:model="searchForm"
inline
label-width="70px"
label-width="76px"
class="search-form"
@keyup.enter="handleSearch"
>
<el-form-item label="客户ID" prop="memberId">
<el-input
v-model="searchForm.memberId"
placeholder="请输入客户ID"
clearable
style="width: 180px"
/>
</el-form-item>
<el-form-item label="客户名称" prop="memberName">
<el-input
v-model="searchForm.memberName"
placeholder="请输入客户名称"
clearable
style="width: 240px"
style="width: 160px"
/>
</el-form-item>
<el-form-item label="申请编号" prop="sn">
<el-input
v-model="searchForm.sn"
placeholder="请输入申请编号"
clearable
style="width: 180px"
/>
</el-form-item>
<el-form-item label="提现状态" prop="applyStatus">
<el-select v-model="searchForm.applyStatus" clearable style="width: 240px">
<el-select
v-model="searchForm.applyStatus"
placeholder="全部状态"
clearable
style="width: 130px"
>
<el-option label="全部" value="" />
<el-option label="申请中" value="APPLY" />
<el-option label="审核通过" value="VIA_AUDITING" />
<el-option label="提现成功" value="SUCCESS" />
<el-option label="用户确认" value="WAIT_USER_CONFIRM" />
<el-option label="审核拒绝" value="FAIL_AUDITING" />
<el-option label="提现成功" value="SUCCESS" />
<el-option label="提现失败" value="ERROR" />
</el-select>
</el-form-item>
@@ -33,55 +56,90 @@
type="datetimerange"
value-format="YYYY-MM-DD HH:mm:ss"
clearable
range-separator="至"
start-placeholder="开始时间"
end-placeholder="结束时间"
placeholder="选择起始时间"
style="width: 360px"
style="width: 320px"
@change="selectDateRange"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch">搜索</el-button>
<el-form-item class="form-actions">
<el-button type="primary" class="search-btn" @click="handleSearch">
搜索
</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
</el-card>
<el-card>
<!-- 提现申请数据表格 -->
<el-card shadow="never" class="table-card mt_10">
<template #header>
<div class="card-header-bar">
<div class="header-title">
<span>会员提现申请</span>
</div>
<div class="header-actions">
<el-button size="small" @click="getDataList">刷新</el-button>
</div>
</div>
</template>
<el-table
ref="table"
v-loading="loading"
border
stripe
:data="data"
ref="table"
class="mt_10"
style="width: 100%"
@sort-change="changeSort"
@selection-change="changeSelect"
>
<el-table-column prop="sn" label="申请编号" min-width="160" show-overflow-tooltip />
<el-table-column prop="memberName" label="客户名称" min-width="120" show-overflow-tooltip />
<el-table-column label="申请金额" width="120">
<!-- 客户ID -->
<el-table-column prop="memberId" label="客户ID" min-width="170" show-overflow-tooltip />
<!-- 客户名称 -->
<el-table-column prop="memberName" label="客户名称" min-width="140" show-overflow-tooltip />
<!-- 申请编号 -->
<el-table-column prop="sn" label="申请编号" min-width="180" show-overflow-tooltip />
<!-- 申请提现金额 -->
<el-table-column label="申请金额" width="130" align="right" sortable="custom" prop="applyMoney">
<template #default="{ row }">
<priceColorScheme v-if="row" :value="row.applyMoney" :color="$mainColor" />
<span v-if="row">¥ {{ formatMoney(row.applyMoney) }}</span>
</template>
</el-table-column>
<el-table-column label="提现状态" width="120">
<!-- 提现状态 -->
<el-table-column label="提现状态" width="120" align="center">
<template #default="{ row }">
<el-tag v-if="row" :type="applyStatusTagType(row.applyStatus)">
{{ applyStatusText(row.applyStatus) }}
</el-tag>
{{ row ? applyStatusText(row.applyStatus) : "-" }}
</template>
</el-table-column>
<el-table-column prop="createTime" label="申请时间" width="170" sortable="custom" />
<el-table-column prop="inspectTime" label="审核时间" width="170" />
<el-table-column label="操作" width="120" align="center" fixed="right">
<!-- 申请时间 -->
<el-table-column prop="createTime" label="申请时间" width="170" align="center" sortable="custom" />
<!-- 审核时间 -->
<el-table-column prop="inspectTime" label="审核时间" width="170" align="center" />
<!-- 操作 -->
<el-table-column label="操作" width="160" align="center" fixed="right">
<template #default="{ row }">
<div v-if="row" class="ops">
<a class="link-text" v-if="row.applyStatus === 'APPLY'" @click="openAudit(row)">审核</a>
<a class="link-text" v-else @click="openQuery(row)">查看</a>
</div>
<template v-if="row">
<a v-if="row.applyStatus === 'APPLY'" class="link-text" @click="openAudit(row)">审核</a>
<a v-else class="link-text" @click="openQuery(row)">查看</a>
<template v-if="row.memberId">
<span class="op-split">|</span>
<a class="link-text" @click="viewMemberDetail(row)">客户详情</a>
</template>
</template>
</template>
</el-table-column>
</el-table>
<div class="mt_10" style="display: flex; justify-content: flex-end">
<!-- 底部分页 -->
<div class="pagination-wrap mt_10">
<el-pagination
v-model:current-page="searchForm.pageNumber"
v-model:page-size="searchForm.pageSize"
@@ -95,114 +153,170 @@
</div>
</el-card>
<!-- 审核弹窗 -->
<el-dialog
v-model="roleModalVisible"
:title="modalTitle"
width="500px"
title="提现申请审核"
width="520px"
:close-on-click-modal="false"
destroy-on-close
>
<el-form label-width="80px">
<el-form-item label="申请编号">
<span>{{ showList.sn }}</span>
</el-form-item>
<el-form-item label="用户名称">
<span>{{ showList.memberName }}</span>
</el-form-item>
<el-form-item label="申请金额">
<priceColorScheme :value="showList.applyMoney" :color="$mainColor" />
</el-form-item>
<el-form-item label="提现状态">
<span>{{ paramTypeFilter(showList.applyStatus) }}</span>
</el-form-item>
<el-form-item label="申请时间">
<span>{{ showList.createTime }}</span>
</el-form-item>
<el-form-item label="审核备注">
<el-input v-model="audit" type="textarea" />
</el-form-item>
</el-form>
<div class="dialog-body">
<el-descriptions :column="1" border size="small" class="custom-dialog-descriptions">
<el-descriptions-item label="申请编号">
<span class="font-mono">{{ showList.sn }}</span>
</el-descriptions-item>
<el-descriptions-item label="客户ID">
<span class="font-mono">{{ showList.memberId || "-" }}</span>
</el-descriptions-item>
<el-descriptions-item label="客户名称">
<span class="font-semibold">{{ showList.memberName }}</span>
</el-descriptions-item>
<el-descriptions-item label="申请金额">
<span class="money-text font-semibold">¥ {{ formatMoney(showList.applyMoney) }}</span>
</el-descriptions-item>
<el-descriptions-item label="申请时间">
<span>{{ showList.createTime }}</span>
</el-descriptions-item>
</el-descriptions>
<div class="remark-form-item" style="margin-top: 14px">
<div class="remark-label" style="font-size: 13px; color: #606266; margin-bottom: 6px">
审核备注说明(拒绝时必填):
</div>
<el-input
v-model="audit"
type="textarea"
:rows="3"
maxlength="200"
show-word-limit
placeholder="请输入审核备注说明..."
/>
</div>
</div>
<template #footer>
<template v-if="showList.applyStatus === 'APPLY'">
<el-button @click="submitRole(false)">拒绝</el-button>
<el-button type="primary" :loading="submitLoading" @click="submitRole(true)">通过</el-button>
</template>
<el-button @click="roleModalVisible = false">取消</el-button>
<el-button
type="danger"
:loading="submitLoading"
@click="submitRole(false)"
>
拒绝
</el-button>
<el-button
type="primary"
:loading="submitLoading"
@click="submitRole(true)"
>
通过
</el-button>
</template>
</el-dialog>
<!-- 查看详情弹窗 -->
<el-dialog
v-model="queryModalVisible"
:title="modalTitle"
width="500px"
title="提现申请详情"
width="520px"
:close-on-click-modal="false"
destroy-on-close
>
<el-form label-width="100px">
<el-form-item label="申请编号:">
<span>{{ showList.sn }}</span>
</el-form-item>
<el-form-item label="用户名称:">
<span>{{ showList.memberName }}</span>
</el-form-item>
<el-form-item label="申请金额:">
<priceColorScheme :value="showList.applyMoney" :color="$mainColor" />
</el-form-item>
<el-form-item label="提现状态:">
<span>{{ paramTypeFilter(showList.applyStatus) }}</span>
</el-form-item>
<el-form-item label="申请时间:">
<span>{{ showList.createTime }}</span>
</el-form-item>
<el-form-item label="审核时间:">
<span>{{ showList.inspectTime }}</span>
</el-form-item>
<el-form-item label="审核备注:">
<span>{{ showList.inspectRemark || "暂无备注" }}</span>
</el-form-item>
</el-form>
<div class="dialog-body">
<el-descriptions :column="1" border size="small" class="custom-dialog-descriptions">
<el-descriptions-item label="申请编号">
<span class="font-mono">{{ showList.sn }}</span>
</el-descriptions-item>
<el-descriptions-item label="客户ID">
<span class="font-mono">{{ showList.memberId || "-" }}</span>
</el-descriptions-item>
<el-descriptions-item label="客户名称">
<span class="font-semibold">{{ showList.memberName }}</span>
</el-descriptions-item>
<el-descriptions-item label="申请金额">
<span class="money-text font-semibold">¥ {{ formatMoney(showList.applyMoney) }}</span>
</el-descriptions-item>
<el-descriptions-item label="提现状态">
<el-tag :type="applyStatusTagType(showList.applyStatus)" size="small">
{{ paramTypeFilter(showList.applyStatus) }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="申请时间">
<span>{{ showList.createTime }}</span>
</el-descriptions-item>
<el-descriptions-item label="审核时间">
<span>{{ showList.inspectTime || "-" }}</span>
</el-descriptions-item>
<el-descriptions-item label="审核备注">
<span>{{ showList.inspectRemark || "暂无备注" }}</span>
</el-descriptions-item>
</el-descriptions>
</div>
<template #footer>
<template v-if="showList.applyStatus === 'APPLY'">
<el-button @click="submitRole(false)">拒绝</el-button>
<el-button type="primary" :loading="submitLoading" @click="submitRole(true)">通过</el-button>
</template>
<el-button v-else @click="queryModalVisible = false">取消</el-button>
<el-button @click="queryModalVisible = false">关 闭</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from "vue";
import { onMounted, ref } from "vue";
import { useRoute } from "vue-router";
import { useTable } from "@/utils/useTable";
import { getUserWithdrawApply, withdrawApply } from "@/api/member";
import { customRouterPush } from "@/utils/filters";
import { ElMessage } from "element-plus";
defineOptions({ name: "withdrawApply" });
// 模板 el-form 的 ref(原 ref="searchForm",与 data 同名会互相覆盖,改名为 searchFormRef)
const route = useRoute();
const searchFormRef = ref(null);
// 模板 el-table 的 ref
const table = ref(null);
const selectDate = ref(null);
const modalTitle = ref("");
const loading = ref(true);
const audit = ref("");
const roleModalVisible = ref(false);
const queryModalVisible = ref(false);
const searchForm = reactive({
pageNumber: 1,
pageSize: 20,
sort: "createTime",
order: "desc",
startDate: "",
endDate: "",
memberName: "",
applyStatus: "",
});
const selectDate = ref(null);
const submitLoading = ref(false);
const selectList = ref([]);
const selectCount = ref(0);
const showList = ref({});
const data = ref([]);
const total = ref(0);
const { loading, data, total, searchForm, getDataList, handleSearch, resetSearch } = useTable(
getUserWithdrawApply,
{
pageSize: 20,
immediate: false,
form: {
sort: "createTime",
order: "desc",
memberId: "",
memberName: "",
sn: "",
applyStatus: "",
startDate: "",
endDate: "",
},
beforeRequest: (p) => {
const params = { ...p };
if (selectDate.value && selectDate.value.length === 2) {
params.startDate = selectDate.value[0];
params.endDate = selectDate.value[1];
} else {
params.startDate = "";
params.endDate = "";
}
return params;
},
}
);
function formatMoney(val) {
const num = Number(val || 0);
return num.toFixed(2);
}
function viewMemberDetail(row) {
if (!row || !row.memberId) return;
customRouterPush({ name: "member-detail", query: { id: row.memberId } });
}
function paramTypeFilter(val) {
if (val === "APPLY") return "申请中";
@@ -211,41 +325,47 @@ function paramTypeFilter(val) {
if (val === "FAIL_AUDITING") return "审核拒绝";
if (val === "SUCCESS") return "提现成功";
if (val === "ERROR") return "提现失败";
return "未知状态";
return val || "未知状态";
}
function applyStatusText(status) {
if (status === "APPLY") return "申请中";
if (status === "VIA_AUDITING") return "审核通过";
if (status === "WAIT_USER_CONFIRM") return "用户确认";
if (status === "SUCCESS") return "提现成功";
if (status === "ERROR") return "提现失败";
return "审核拒绝";
if (status === "FAIL_AUDITING") return "审核拒绝";
return status || "-";
}
function applyStatusTagType(status) {
if (status === "APPLY") return "warning";
if (status === "VIA_AUDITING") return "success";
if (status === "WAIT_USER_CONFIRM") return "warning";
if (status === "SUCCESS") return "primary";
if (status === "ERROR") return "primary";
if (status === "ERROR") return "danger";
return "danger";
}
function openAudit(row) {
showList.value = { ...row };
audit.value = "";
roleModalVisible.value = true;
}
function openQuery(row) {
showList.value = { ...row };
modalTitle.value = "查看";
queryModalVisible.value = true;
}
function submitRole(res) {
const params = {};
params.applyId = showList.value.id;
params.result = res;
params.remark = audit.value;
if (res === false && params.remark === "") {
ElMessage.error("审核备注不能为空");
const params = {
applyId: showList.value.id,
result: res,
remark: audit.value ? audit.value.trim() : "",
};
if (res === false && !params.remark) {
ElMessage.error("审核拒绝时备注说明不能为空");
return;
}
submitLoading.value = true;
@@ -265,50 +385,36 @@ function submitRole(res) {
submitLoading.value = false;
});
}
function init() {
function handleReset() {
selectDate.value = null;
searchForm.memberId = "";
searchForm.memberName = "";
searchForm.sn = "";
searchForm.applyStatus = "";
searchForm.startDate = "";
searchForm.endDate = "";
searchForm.pageNumber = 1;
resetSearch();
}
function changeSort(e) {
searchForm.sort = e.prop || e.key || "createTime";
searchForm.order = e.order === "ascending" ? "asc" : e.order === "descending" ? "desc" : "";
getDataList();
}
function changePage(v) {
searchForm.pageNumber = v;
getDataList();
clearSelectAll();
}
function changePageSize(v) {
searchForm.pageNumber = 1;
searchForm.pageSize = v;
getDataList();
}
function handleSearch() {
searchForm.pageNumber = 1;
searchForm.pageSize = 20;
getDataList();
}
function handleReset() {
searchFormRef.value.resetFields();
searchForm.pageNumber = 1;
searchForm.pageSize = 20;
selectDate.value = null;
searchForm.startDate = "";
searchForm.endDate = "";
searchForm.memberName = "";
getDataList();
}
function changeSort(e) {
searchForm.sort = e.prop || e.key;
searchForm.order = e.order === "ascending" ? "asc" : e.order === "descending" ? "desc" : "";
if (!e.order) {
searchForm.order = "";
}
getDataList();
}
function clearSelectAll() {
if (table.value) {
table.value.clearSelection();
}
}
function changeSelect(e) {
selectList.value = e;
selectCount.value = e.length;
}
function selectDateRange(v) {
if (v && v.length === 2) {
searchForm.startDate = v[0];
@@ -318,30 +424,97 @@ function selectDateRange(v) {
searchForm.endDate = "";
}
}
function getDataList() {
loading.value = true;
getUserWithdrawApply(searchForm).then((res) => {
loading.value = false;
if (res.success) {
data.value = res.result.records;
total.value = res.result.total;
}
});
}
onMounted(() => {
init();
if (route.query && route.query.applyStatus) {
searchForm.applyStatus = route.query.applyStatus;
}
getDataList();
});
</script>
<style lang="scss" scoped>
.ops a {
color: #2d8cf0;
cursor: pointer;
text-decoration: none;
.member-withdraw-apply-page {
padding: 16px 20px;
background-color: #f6f8fa;
min-height: calc(100vh - 110px);
}
.ops span {
display: inline-block;
margin: 0 8px;
color: #dcdee2;
.font-mono {
font-family: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
}
.font-semibold {
font-weight: 600;
}
.text-dark {
color: #303133;
}
.text-muted {
color: #909399;
}
/* 筛选卡片 */
.filter-card {
border-radius: 8px;
border: 1px solid #ebeef5;
:deep(.el-card__body) {
padding: 16px 16px 2px;
}
.search-form {
display: flex;
flex-wrap: wrap;
align-items: center;
.el-form-item {
margin-bottom: 14px;
margin-right: 16px;
}
.form-actions {
margin-left: auto;
}
}
}
/* 表格卡片 */
.table-card {
border-radius: 8px;
border: 1px solid #ebeef5;
.card-header-bar {
display: flex;
justify-content: space-between;
align-items: center;
.header-title {
font-size: 15px;
font-weight: 600;
color: #303133;
}
}
}
.custom-dialog-descriptions {
:deep(.el-descriptions__label) {
width: 100px;
background-color: #fafafa;
color: #606266;
}
.money-text {
font-size: 15px;
color: #f56c6c;
}
}
.pagination-wrap {
display: flex;
justify-content: flex-end;
padding-top: 6px;
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -1,24 +1,32 @@
<template>
<div class="search">
<el-card>
<div class="member-experience-log-page search">
<!-- 顶部筛选表单 -->
<el-card shadow="never" class="filter-card">
<el-form
ref="searchFormRef"
:model="searchForm"
inline
label-width="90px"
label-width="84px"
class="search-form"
@keyup.enter="handleSearch"
>
<el-form-item label="客户手机号" prop="mobile">
<el-form-item label="客户手机号" prop="memberMobile">
<el-input
v-model="searchForm.memberMobile"
clearable
placeholder="请输入客户手机号"
style="width: 220px"
style="width: 200px"
/>
</el-form-item>
<el-form-item label="规则" prop="ruleKey">
<el-select v-model="searchForm.ruleKey" clearable filterable style="width: 220px">
<el-form-item label="规则类型" prop="ruleKey">
<el-select
v-model="searchForm.ruleKey"
clearable
filterable
placeholder="全部规则"
style="width: 180px"
>
<el-option label="全部规则" value="" />
<el-option
v-for="item in ruleOptions"
:key="item.value"
@@ -27,42 +35,103 @@
/>
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch">搜索</el-button>
<el-form-item class="form-actions">
<el-button type="primary" class="search-btn" @click="handleSearch">
搜索
</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
</el-card>
<el-card>
<el-table v-loading="loading" border :data="data" class="mt_10" style="width: 100%">
<el-table-column label="客户手机号" width="140" show-overflow-tooltip>
<!-- 经验值记录表格 -->
<el-card shadow="never" class="table-card mt_10">
<template #header>
<div class="card-header-bar">
<div class="header-title">
<span>客户经验值流水</span>
</div>
<div class="header-actions">
<el-button size="small" @click="getData">刷新</el-button>
</div>
</div>
</template>
<el-table
v-loading="loading"
border
stripe
:data="data"
style="width: 100%"
>
<!-- 客户ID -->
<el-table-column prop="memberId" label="客户ID" min-width="190" show-overflow-tooltip />
<!-- 客户名称 -->
<el-table-column prop="memberName" label="客户名称" min-width="130" show-overflow-tooltip />
<!-- 客户手机号 -->
<el-table-column label="客户手机号" width="130" align="center">
<template #default="{ row }">
<span v-if="row">{{ row.memberMobile || row.mobile || "-" }}</span>
{{ row ? (row.memberMobile || row.mobile || "-") : "-" }}
</template>
</el-table-column>
<el-table-column label="规则名称" width="150" show-overflow-tooltip>
<!-- 规则名称 -->
<el-table-column label="规则名称" width="130" align="center">
<template #default="{ row }">
<span v-if="row">{{ row.ruleName || findRuleName(row.ruleKey) || "-" }}</span>
{{ row ? (row.ruleName || findRuleName(row.ruleKey) || "-") : "-" }}
</template>
</el-table-column>
<el-table-column label="变化经验值" width="120">
<!-- 变动前经验值 -->
<el-table-column label="变动前经验值" width="120" align="right">
<template #default="{ row }">
<span v-if="row">{{ formatExperienceValue(row) }}</span>
<span v-if="row">{{ row.beforeExperience != null ? formatNumber(row.beforeExperience) : "-" }}</span>
</template>
</el-table-column>
<el-table-column label="经验值限额" width="120">
<!-- 变动经验值 -->
<el-table-column label="变动经验值" width="120" align="right">
<template #default="{ row }">
<span v-if="row">{{ formatMaxValue(row) }}</span>
<span v-if="row" class="experience-increase">
+{{ formatNumber(row.variableExperience ?? row.value ?? 0) }}
</span>
</template>
</el-table-column>
<!-- 当前经验值 -->
<el-table-column label="变动后经验值" width="120" align="right">
<template #default="{ row }">
<span v-if="row" class="experience-after">
{{ formatNumber(row.experience ?? 0) }}
</span>
</template>
</el-table-column>
<!-- 操作说明 / 备注 -->
<el-table-column label="操作说明" min-width="200" show-overflow-tooltip>
<template #default="{ row }">
<span v-if="row">{{ row.content || row.remark || row.description || "-" }}</span>
<span>{{ row ? row.content || row.remark || row.description || "-" : "-" }}</span>
</template>
</el-table-column>
<!-- 创建时间 -->
<el-table-column prop="createTime" label="创建时间" width="170" align="center" />
<!-- 操作 -->
<el-table-column label="操作" width="110" align="center" fixed="right">
<template #default="{ row }">
<template v-if="row">
<a v-if="row.memberId" class="link-text" @click="viewMemberDetail(row)">客户详情</a>
<span v-else>-</span>
</template>
</template>
</el-table-column>
<el-table-column prop="createTime" label="创建时间" width="170" />
</el-table>
<div class="mt_10" style="display: flex; justify-content: flex-end">
<!-- 底部分页 -->
<div class="pagination-wrap mt_10">
<el-pagination
v-model:current-page="searchForm.pageNumber"
v-model:page-size="searchForm.pageSize"
@@ -82,8 +151,10 @@
import { onMounted, ref } from "vue";
import { useTable } from "@/utils/useTable";
import * as API_Member from "@/api/member";
import { customRouterPush } from "@/utils/filters";
import { ElMessage } from "element-plus";
const RULE_OPTIONS = [
const ruleOptions = [
{ value: "CONSUME", label: "消费" },
{ value: "REGISTER", label: "注册" },
{ value: "SIGN_IN", label: "签到" },
@@ -101,7 +172,7 @@ defineOptions({ name: "memberGradeExperienceLog" });
const searchFormRef = ref(null);
const { loading, data, total, searchForm, getDataList: getData } = useTable(
const { loading, data, total, searchForm, getDataList: getData, resetSearch } = useTable(
API_Member.getMemberExperienceByPage,
{
pageSize: 20,
@@ -111,45 +182,138 @@ const { loading, data, total, searchForm, getDataList: getData } = useTable(
}
);
onMounted(() => {
init();
});
function formatNumber(val) {
const num = Number(val || 0);
return num.toLocaleString();
}
function formatExperienceValue(row) {
const v = row.value ?? row.variableExperience ?? row.experience ?? row.variableValue;
return v == null ? "-" : v;
}
function formatMaxValue(row) {
const v = row.maxValue ?? row.maxExperience ?? row.limitValue;
return v == null ? "-" : v;
}
function findRuleName(ruleKey) {
const hit = ruleOptions.find((item) => item.value === ruleKey);
return hit ? hit.label : "";
return hit ? hit.label : ruleKey || "";
}
function init() {
getData();
function viewMemberDetail(row) {
if (!row || !row.memberId) return;
customRouterPush({ name: "member-detail", query: { id: row.memberId } });
}
function buildParams(base = searchForm) {
const params = {
pageNumber: base.pageNumber,
pageSize: base.pageSize,
};
if (base.memberMobile) params.mobile = base.memberMobile;
if (base.memberMobile) params.mobile = base.memberMobile.trim();
if (base.ruleKey) params.ruleKey = base.ruleKey;
return params;
}
function handleSearch() {
searchForm.pageNumber = 1;
getData();
}
function handleReset() {
searchForm.memberMobile = "";
searchForm.ruleKey = "";
searchForm.pageNumber = 1;
resetSearch();
}
function changePage(v) {
searchForm.pageNumber = v;
getData();
}
function changePageSize(v) {
searchForm.pageNumber = 1;
searchForm.pageSize = v;
getData();
}
onMounted(() => {
getData();
});
</script>
<style lang="scss" scoped>
.member-experience-log-page {
padding: 16px 20px;
background-color: #f6f8fa;
min-height: calc(100vh - 110px);
}
.font-mono {
font-family: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
}
.font-semibold {
font-weight: 600;
}
.text-dark {
color: #303133;
}
.text-muted {
color: #909399;
}
/* 筛选卡片 */
.filter-card {
border-radius: 8px;
border: 1px solid #ebeef5;
:deep(.el-card__body) {
padding: 16px 16px 2px;
}
.search-form {
display: flex;
flex-wrap: wrap;
align-items: center;
.el-form-item {
margin-bottom: 14px;
margin-right: 16px;
}
.form-actions {
margin-left: auto;
}
}
}
/* 表格卡片 */
.table-card {
border-radius: 8px;
border: 1px solid #ebeef5;
.card-header-bar {
display: flex;
justify-content: space-between;
align-items: center;
.header-title {
font-size: 15px;
font-weight: 600;
color: #303133;
}
}
}
.experience-increase {
font-size: 13px;
color: #67c23a;
}
.experience-after {
font-size: 13px;
color: #e6a23c;
}
.pagination-wrap {
display: flex;
justify-content: flex-end;
padding-top: 6px;
}
</style>

View File

@@ -1,29 +1,102 @@
<template>
<div class="search">
<el-card>
<div class="operation padding-row">
<el-button type="primary" @click="openAdd">添加分组</el-button>
<div class="member-group-page search">
<!-- 顶部筛选与操作栏 -->
<el-card shadow="never" class="filter-card">
<div class="card-toolbar">
<el-form inline :model="searchForm" class="filter-form" @keyup.enter="handleSearch">
<el-form-item label="分组名称">
<el-input
v-model="searchForm.groupName"
placeholder="请输入分组名称"
clearable
style="width: 220px"
@clear="handleSearch"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch">搜索</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
<div class="toolbar-right">
<el-button type="primary" @click="openAdd">添加分组</el-button>
</div>
</div>
</el-card>
<el-card>
<el-table ref="table" v-loading="loading" :data="data" border class="mt_10" style="width: 100%">
<!-- 分组数据表格 -->
<el-card shadow="never" class="table-card mt_10">
<template #header>
<div class="card-header-bar">
<div class="header-title">
<span>会员分组列表</span>
</div>
<div class="header-actions">
<el-button size="small" @click="getData">刷新</el-button>
</div>
</div>
</template>
<el-table
ref="table"
v-loading="loading"
:data="data"
border
stripe
style="width: 100%"
class="custom-group-table"
>
<!-- 分组ID -->
<el-table-column prop="id" label="分组ID" min-width="190" />
<!-- 分组名称 -->
<el-table-column prop="groupName" label="分组名称" min-width="160" show-overflow-tooltip />
<el-table-column prop="description" label="分组描述" min-width="240" show-overflow-tooltip />
<el-table-column prop="createTime" label="创建时间" min-width="180" />
<el-table-column prop="updateTime" label="更新时间" min-width="180" />
<el-table-column label="操作" width="200" align="center" fixed="right">
<!-- 分组人数 -->
<el-table-column prop="memberCount" label="分组人数" width="110" align="center">
<template #default="{ row }">
<div v-if="row" class="ops" style="display: flex; justify-content: center">
<template v-if="row">
<a
v-if="row.memberCount > 0"
class="link-text"
@click="viewGroupMembers(row)"
>
{{ row.memberCount }}
</a>
<span v-else>{{ row.memberCount ?? 0 }}</span>
</template>
</template>
</el-table-column>
<!-- 分组描述 -->
<el-table-column prop="description" label="分组描述" min-width="240" show-overflow-tooltip>
<template #default="{ row }">
{{ row ? row.description || "-" : "-" }}
</template>
</el-table-column>
<!-- 创建时间 -->
<el-table-column prop="createTime" label="创建时间" min-width="170" align="center" />
<!-- 更新时间 -->
<el-table-column prop="updateTime" label="更新时间" min-width="170" align="center" />
<!-- 操作栏 -->
<el-table-column label="操作" width="180" align="center" fixed="right">
<template #default="{ row }">
<template v-if="row">
<a class="link-text" @click="viewGroupMembers(row)">查看会员</a>
<span class="op-split">|</span>
<a class="link-text" @click="openEdit(row)">编辑</a>
<span class="op-split">|</span>
<a class="link-text" @click="remove(row)">删除</a>
</div>
<a class="link-text error-text" @click="remove(row)">删除</a>
</template>
</template>
</el-table-column>
</el-table>
<div class="mt_10" style="display: flex; justify-content: flex-end">
<!-- 底部分页 -->
<div class="pagination-wrap mt_10">
<el-pagination
v-model:current-page="searchForm.pageNumber"
v-model:page-size="searchForm.pageSize"
@@ -37,34 +110,78 @@
</div>
</el-card>
<el-dialog v-model="addFlag" title="添加分组" width="500px" :close-on-click-modal="false" destroy-on-close>
<!-- 添加分组弹窗 -->
<el-dialog
v-model="addFlag"
title="添加分组"
width="520px"
:close-on-click-modal="false"
destroy-on-close
>
<el-form ref="addForm" :model="formAdd" :rules="rulesAdd" label-width="90px">
<el-form-item label="分组名称" prop="groupName" style="width: 90%">
<el-input v-model="formAdd.groupName" maxlength="30" placeholder="请输入分组名称" />
<el-form-item label="分组名称" prop="groupName">
<el-input
v-model="formAdd.groupName"
maxlength="30"
show-word-limit
placeholder="请输入分组名称"
/>
</el-form-item>
<el-form-item label="分组描述" prop="description" style="width: 90%">
<el-input v-model="formAdd.description" maxlength="200" placeholder="请输入分组描述" />
<el-form-item label="分组描述" prop="description">
<el-input
v-model="formAdd.description"
type="textarea"
:rows="3"
maxlength="200"
show-word-limit
placeholder="请输入分组描述信息"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="addFlag = false">取消</el-button>
<el-button type="primary" :loading="submitAddLoading" @click="submitAdd">确定</el-button>
<el-button type="primary" :loading="submitAddLoading" @click="submitAdd">
确定
</el-button>
</template>
</el-dialog>
<el-dialog v-model="editFlag" title="编辑分组" width="500px" :close-on-click-modal="false" destroy-on-close>
<!-- 编辑分组弹窗 -->
<el-dialog
v-model="editFlag"
title="编辑分组"
width="520px"
:close-on-click-modal="false"
destroy-on-close
>
<el-form ref="editForm" :model="formEdit" :rules="rulesEdit" label-width="90px">
<el-input v-model="formEdit.id" style="display: none" />
<el-form-item label="分组名称" prop="groupName" style="width: 90%">
<el-input v-model="formEdit.groupName" maxlength="30" placeholder="请输入分组名称" />
<el-form-item label="分组ID">
<span>{{ formEdit.id }}</span>
</el-form-item>
<el-form-item label="分组描述" prop="description" style="width: 90%">
<el-input v-model="formEdit.description" maxlength="200" placeholder="请输入分组描述" />
<el-form-item label="分组名称" prop="groupName">
<el-input
v-model="formEdit.groupName"
maxlength="30"
show-word-limit
placeholder="请输入分组名称"
/>
</el-form-item>
<el-form-item label="分组描述" prop="description">
<el-input
v-model="formEdit.description"
type="textarea"
:rows="3"
maxlength="200"
show-word-limit
placeholder="请输入分组描述信息"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="editFlag = false">取消</el-button>
<el-button type="primary" :loading="submitEditLoading" @click="submitEdit">确定</el-button>
<el-button type="primary" :loading="submitEditLoading" @click="submitEdit">
确定
</el-button>
</template>
</el-dialog>
</div>
@@ -72,11 +189,21 @@
<script setup>
import { nextTick, onMounted, reactive, ref } from "vue";
import { useRouter } from "vue-router";
import * as API_Member from "@/api/member.js";
import { ElMessage, ElMessageBox } from "element-plus";
defineOptions({ name: "memberGroup" });
const router = useRouter();
function viewGroupMembers(row) {
router.push({
name: "memberList",
query: { groupId: row.id },
});
}
const addForm = ref(null);
const editForm = ref(null);
@@ -84,6 +211,7 @@ const loading = ref(true);
const searchForm = reactive({
pageNumber: 1,
pageSize: 20,
groupName: "",
});
const data = ref([]);
const total = ref(0);
@@ -91,44 +219,81 @@ const addFlag = ref(false);
const editFlag = ref(false);
const submitAddLoading = ref(false);
const submitEditLoading = ref(false);
const formAdd = ref({
groupName: "",
description: "",
});
const formEdit = ref({
id: "",
groupName: "",
description: "",
});
const rulesAdd = reactive({
groupName: [{ required: true, message: "请输入分组名称", trigger: "blur" }],
});
const rulesEdit = reactive({
groupName: [{ required: true, message: "请输入分组名称", trigger: "blur" }],
});
function init() {
function handleSearch() {
searchForm.pageNumber = 1;
getData();
}
function handleReset() {
searchForm.pageNumber = 1;
searchForm.groupName = "";
getData();
}
function changePage(v) {
searchForm.pageNumber = v;
getData();
}
function changePageSize(v) {
searchForm.pageNumber = 1;
searchForm.pageSize = v;
getData();
}
function getData() {
loading.value = true;
API_Member.getMemberGroupByPage(searchForm).then((res) => {
loading.value = false;
if (res && res.success && res.result) {
data.value = res.result.records || [];
total.value = res.result.total || 0;
}
});
API_Member.getMemberGroupByPage(searchForm)
.then((res) => {
loading.value = false;
if (res && res.success && res.result) {
const records = res.result.records || [];
data.value = records;
total.value = res.result.total || 0;
// 异步获取各分组的会员总数
records.forEach((row) => {
if (row.memberCount == null) {
API_Member.getMemberGroupUsers(row.id, { pageNumber: 1, pageSize: 1 })
.then((userRes) => {
if (userRes && userRes.success && userRes.result) {
row.memberCount = userRes.result.total || 0;
} else {
row.memberCount = 0;
}
})
.catch(() => {
row.memberCount = 0;
});
}
});
}
})
.catch(() => {
loading.value = false;
});
}
function openAdd() {
addFlag.value = true;
nextTick(() => {
@@ -136,20 +301,26 @@ function openAdd() {
formAdd.value = { groupName: "", description: "" };
});
}
function submitAdd() {
addForm.value.validate((valid) => {
if (!valid) return;
submitAddLoading.value = true;
API_Member.addMemberGroup(formAdd.value).then((res) => {
submitAddLoading.value = false;
if (res && res.success) {
ElMessage.success("添加成功");
addFlag.value = false;
getData();
}
});
API_Member.addMemberGroup(formAdd.value)
.then((res) => {
submitAddLoading.value = false;
if (res && res.success) {
ElMessage.success("添加成功");
addFlag.value = false;
getData();
}
})
.catch(() => {
submitAddLoading.value = false;
});
});
}
function openEdit(row) {
editFlag.value = true;
submitEditLoading.value = false;
@@ -169,48 +340,120 @@ function openEdit(row) {
}
});
}
function submitEdit() {
editForm.value.validate((valid) => {
if (!valid) return;
submitEditLoading.value = true;
const { id, groupName, description } = formEdit.value;
API_Member.updateMemberGroup(id, { groupName, description }).then((res) => {
submitEditLoading.value = false;
if (res && res.success) {
ElMessage.success("修改成功");
editFlag.value = false;
getData();
}
});
API_Member.updateMemberGroup(id, { groupName, description })
.then((res) => {
submitEditLoading.value = false;
if (res && res.success) {
ElMessage.success("修改成功");
editFlag.value = false;
getData();
}
})
.catch(() => {
submitEditLoading.value = false;
});
});
}
function remove(row) {
ElMessageBox.confirm("确定删除该分组?", "提示", { type: "warning" }).then(() => {
API_Member.deleteMemberGroup(row.id).then((res) => {
if (res && res.success) {
ElMessage.success("删除成功");
getData();
} else if (res && res.message) {
ElMessage.error(res.message);
}
});
}).catch(() => {});
ElMessageBox.confirm(`确定删除分组 "${row.groupName || "该分组"}" 吗?`, "删除确认", {
type: "warning",
confirmButtonText: "确定删除",
cancelButtonText: "取消",
})
.then(() => {
API_Member.deleteMemberGroup(row.id).then((res) => {
if (res && res.success) {
ElMessage.success("删除成功");
getData();
} else if (res && res.message) {
ElMessage.error(res.message);
}
});
})
.catch(() => {});
}
onMounted(() => {
init();
getData();
});
</script>
<style scoped>
.ops a {
<style scoped lang="scss">
.member-group-page {
.card-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 12px;
.filter-form {
display: flex;
align-items: center;
flex-wrap: wrap;
margin-bottom: 0;
:deep(.el-form-item) {
margin-bottom: 0;
margin-right: 16px;
}
}
.toolbar-right {
display: flex;
align-items: center;
}
}
.card-header-bar {
display: flex;
justify-content: space-between;
align-items: center;
.header-title {
font-size: 15px;
font-weight: 600;
color: #303133;
}
}
.pagination-wrap {
display: flex;
justify-content: flex-end;
}
}
.link-text {
color: #2d8cf0;
cursor: pointer;
text-decoration: none;
}
.ops span {
font-size: 14px;
line-height: 1;
display: inline-block;
&:hover {
color: #57a3f3;
}
&.error-text {
color: #ed4014;
&:hover {
color: #f16643;
}
}
}
.op-split {
margin: 0 8px;
color: #dcdee2;
font-size: 14px;
}
</style>

View File

@@ -308,6 +308,7 @@
<script setup>
import { ref, watch, nextTick, onMounted } from "vue";
import { useRoute } from "vue-router";
import { ArrowDown } from "@element-plus/icons-vue";
import multipleMap from "@/components/map/multiple-map";
import * as API_Member from "@/api/member.js";
@@ -320,6 +321,8 @@ import defaultPic from "@/assets/default.png";
defineOptions({ name: "member" });
const route = useRoute();
const props = defineProps({
selectedMember: {
type: Boolean,
@@ -511,7 +514,15 @@ function submitSetMemberGroup() {
const groupId = memberGroupForm.value.groupId;
memberGroupLoading.value = true;
Promise.all(
memberIds.map((memberId) => API_Member.setMemberUserGroups(memberId, [groupId]))
memberIds.map((memberId) =>
API_Member.setMemberUserGroups(memberId, [groupId]).then((res) => {
if (!res || !res.success) {
// 后端 updateUserGroups 若因原无分组返回失败,自动降级尝试 addMemberGroupUsers
return API_Member.addMemberGroupUsers(groupId, [memberId]);
}
return res;
})
)
)
.then((results) => {
memberGroupLoading.value = false;
@@ -758,11 +769,25 @@ function handleSubmitModal() {
}
onMounted(() => {
if (route.query && route.query.groupId) {
searchForm.value.groupId = String(route.query.groupId);
}
getData();
loadMemberGroupList();
loadMemberGradeList();
});
watch(
() => route.query.groupId,
(val) => {
if (val !== undefined) {
searchForm.value.groupId = val ? String(val) : "";
searchForm.value.pageNumber = 1;
getData();
}
}
);
// 外部通过 ref 调用(promotions/coupon-activity/coupon-publish.vue 的 memberLayoutRef.getSelection())
defineExpose({ getSelection });
</script>

View File

@@ -1,11 +1,12 @@
<template>
<div class="search">
<el-card>
<div class="member-recycle-page search">
<!-- 顶部筛选条件表单 -->
<el-card shadow="never" class="filter-card">
<el-form
ref="searchFormRef"
:model="searchForm"
inline
label-width="70px"
label-width="76px"
class="search-form"
@keyup.enter="handleSearch"
>
@@ -14,7 +15,7 @@
v-model="searchForm.id"
placeholder="请输入客户ID"
clearable
style="width: 240px"
style="width: 200px"
/>
</el-form-item>
<el-form-item label="客户名称" prop="username">
@@ -22,66 +23,94 @@
v-model="searchForm.username"
placeholder="请输入客户名称"
clearable
style="width: 240px"
style="width: 180px"
/>
</el-form-item>
<el-form-item label="联系方式" prop="mobile">
<el-input
v-model="searchForm.mobile"
placeholder="请输入会员联系方式"
placeholder="请输入手机联系方式"
clearable
style="width: 240px"
style="width: 180px"
/>
</el-form-item>
<el-form-item>
<el-form-item class="form-actions">
<el-button type="primary" class="search-btn" @click="handleSearch">搜索</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
</el-card>
<el-card>
<!-- 回收站会员表格卡片 -->
<el-card shadow="never" class="table-card mt_10">
<template #header>
<div class="card-header-bar">
<div class="header-title">
<span>会员回收站列表</span>
</div>
<div class="header-actions">
<el-button size="small" @click="getData">刷新</el-button>
</div>
</div>
</template>
<el-table
ref="table"
v-loading="loading"
border
stripe
:data="data"
class="mt_10"
style="width: 100%"
>
<el-table-column prop="id" label="客户ID" min-width="120" show-overflow-tooltip />
<el-table-column prop="username" label="客户名称" min-width="150" align="left" show-overflow-tooltip />
<el-table-column prop="nickName" label="昵称" min-width="120" align="left" show-overflow-tooltip />
<el-table-column label="联系方式" min-width="130">
<!-- 客户ID -->
<el-table-column prop="id" label="客户ID" min-width="190" show-overflow-tooltip />
<!-- 客户名称 -->
<el-table-column prop="username" label="客户名称" min-width="150" show-overflow-tooltip />
<!-- 昵称 -->
<el-table-column prop="nickName" label="昵称" min-width="140" show-overflow-tooltip />
<!-- 联系方式 -->
<el-table-column label="联系方式" min-width="140">
<template #default="{ row }">
<span v-if="row">{{ row.mobile || "" }}</span>
{{ row && row.mobile ? row.mobile : "-" }}
</template>
</el-table-column>
<el-table-column prop="createTime" label="注册时间" min-width="180" />
<el-table-column label="积分数量" min-width="120" align="left">
<!-- 积分数量 -->
<el-table-column label="积分数量" width="120">
<template #default="{ row }">
<span v-if="row">{{ row.point == void 0 ? "0" : row.point }}</span>
{{ row ? (row.point == void 0 ? "0" : row.point) : "0" }}
</template>
</el-table-column>
<el-table-column label="操作" width="200" align="center" fixed="right">
<!-- 注册时间 -->
<el-table-column prop="createTime" label="注册时间" min-width="180" align="center" />
<!-- 操作栏 -->
<el-table-column label="操作" width="180" align="center" fixed="right">
<template #default="{ row }">
<div v-if="row" class="ops" style="display: flex; justify-content: center">
<template v-if="row">
<template v-if="selectedMember">
<a class="link-text" @click="callback(row)">选择</a>
<span class="op-split">|</span>
<a class="link-text" @click="callback(row)">
{{ row.___selected ? "已选择" : "选择" }}
</a>
</template>
<a class="link-text" @click="detail(row)">查看</a>
<template v-if="!selectedMember">
<template v-else>
<a class="link-text" @click="detail(row)">查看</a>
<span class="op-split">|</span>
<a class="link-text" @click="enable(row)">启用</a>
<span class="op-split">|</span>
<a class="link-text" @click="editPerm(row)">编辑</a>
</template>
</div>
</template>
</template>
</el-table-column>
</el-table>
<div class="mt_10" style="display: flex; justify-content: flex-end">
<!-- 底部分页 -->
<div class="pagination-wrap mt_10">
<el-pagination
v-model:current-page="searchForm.pageNumber"
v-model:page-size="searchForm.pageSize"
@@ -95,23 +124,38 @@
</div>
</el-card>
<el-dialog v-model="descFlag" :title="descTitle" width="500px" destroy-on-close>
<el-form ref="formValidateRef" :model="formValidate" :rules="ruleValidate" label-width="80px">
<!-- 编辑会员弹窗 -->
<el-dialog
v-model="descFlag"
:title="descTitle"
width="540px"
destroy-on-close
class="edit-member-dialog"
>
<el-form ref="formValidateRef" :model="formValidate" :rules="ruleValidate" label-width="90px">
<el-form-item label="客户ID">
<span>{{ formValidate.id }}</span>
</el-form-item>
<el-form-item label="头像">
<img alt="会员头像" :src="formValidate.face" class="face" />
<el-button
type="primary"
link
class="upload"
@click="openPicSelector"
>修改</el-button>
<input type="file" style="display: none" id="file" />
<div class="avatar-edit-box">
<el-avatar :size="50" :src="formValidate.face || undefined" class="dialog-avatar">
{{ (formValidate.username || "用")[0] }}
</el-avatar>
<el-button
type="primary"
link
class="upload-btn"
@click="openPicSelector"
>
修改头像
</el-button>
</div>
</el-form-item>
<el-form-item label="客户名称" prop="name">
<el-input v-model="formValidate.username" style="width: 200px" disabled />
<el-input v-model="formValidate.username" style="width: 260px" disabled />
</el-form-item>
<el-form-item label="用户昵称" prop="name">
<el-input v-model="formValidate.nickName" style="width: 200px" />
<el-input v-model="formValidate.nickName" placeholder="请输入用户昵称" style="width: 260px" />
</el-form-item>
<el-form-item label="性别" prop="sex">
<el-radio-group v-model="formValidate.sex">
@@ -119,20 +163,29 @@
<el-radio-button :value="0">女</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item label="修改密码" prop="password">
<el-input v-model="formValidate.newPassword" type="password" show-password style="width: 220px" />
<el-form-item label="重置密码" prop="password">
<el-input
v-model="formValidate.newPassword"
type="password"
show-password
placeholder="留空则保持原密码"
style="width: 260px"
/>
</el-form-item>
<el-form-item label="生日" prop="birthday">
<el-date-picker
v-model="formValidate.birthday"
type="date"
value-format="YYYY-MM-DD"
style="width: 220px"
placeholder="请选择生日"
style="width: 260px"
/>
</el-form-item>
<el-form-item label="所在地" prop="mail">
{{ formValidate.region || "暂无地址" }}
<el-button style="margin-left: 10px" @click="$refs.map.open()">选择</el-button>
<div class="region-box">
<span class="region-text">{{ formValidate.region || "暂无地址" }}</span>
<el-button size="small" @click="$refs.map.open()">选择地区</el-button>
</div>
</el-form-item>
</el-form>
<template #footer>
@@ -141,7 +194,7 @@
</template>
</el-dialog>
<el-dialog v-model="picModelFlag" width="1200px">
<el-dialog v-model="picModelFlag" width="1200px" title="选择头像图片">
<ossManage
ref="ossManageRef"
@callback="callbackSelected"
@@ -166,19 +219,16 @@ defineOptions({ name: "memberRecycle" });
const emit = defineEmits(["callback"]);
// 模板 el-form 的 ref(原 ref="searchForm",与 data 同名会互相覆盖,改名为 searchFormRef)
const searchFormRef = ref(null);
const table = ref(null);
// 模板编辑弹窗 el-form 的 ref(原 ref="formValidate",与 data 同名会互相覆盖,改名为 formValidateRef)
const formValidateRef = ref(null);
// 模板 ossManage 组件的 ref(原 ref="ossManage",与组件 import 同名会冲突,改名为 ossManageRef)
const ossManageRef = ref(null);
const selectedMember = ref(false);
const descTitle = ref("");
const descFlag = ref(false);
const openSearch = ref(true);
const loading = ref(true);
const searchForm = ref({
pageNumber: 1,
pageSize: 20,
@@ -188,6 +238,7 @@ const searchForm = ref({
mobile: "",
disabled: "CLOSE",
});
const picModelFlag = ref(false);
const formValidate = ref({});
const ruleValidate = ref({});
@@ -197,26 +248,40 @@ const total = ref(0);
function callback(val) {
emit("callback", val);
}
function init() {
getData();
}
function changePage() {
getData();
}
function changePageSize() {
searchForm.value.pageNumber = 1;
getData();
}
function handleSearch() {
searchForm.value.pageNumber = 1;
searchForm.value.pageSize = 20;
getData();
}
function handleReset() {
searchForm.value.pageNumber = 1;
searchForm.value.id = "";
searchForm.value.username = "";
searchForm.value.mobile = "";
getData();
}
function editPerm(val) {
descTitle.value = `查看用户 ${val.username}`;
descTitle.value = `编辑停用客户 - ${val.username || "客户"}`;
descFlag.value = true;
getMemberInfo(val.id);
}
function getMemberInfo(id) {
API_Member.getMemberInfoData(id).then((res) => {
if (res.result) {
@@ -224,16 +289,22 @@ function getMemberInfo(id) {
}
});
}
function getData() {
loading.value = true;
API_Member.getMemberListData(searchForm.value).then((res) => {
loading.value = false;
if (res.success) {
data.value = res.result.records;
total.value = res.result.total;
}
});
API_Member.getMemberListData(searchForm.value)
.then((res) => {
loading.value = false;
if (res.success && res.result) {
data.value = res.result.records || [];
total.value = res.result.total || 0;
}
})
.catch(() => {
loading.value = false;
});
}
function openPicSelector() {
picModelFlag.value = true;
nextTick(() => {
@@ -242,10 +313,12 @@ function openPicSelector() {
}
});
}
function callbackSelected(val) {
picModelFlag.value = false;
formValidate.value.face = val.url;
}
function selectedRegion(val) {
if (val.type === "select") {
const paths = val.data.map((item) => item.name).join(",");
@@ -257,23 +330,32 @@ function selectedRegion(val) {
formValidate.value.regionId = val.data.addrId;
}
}
function detail(row) {
customRouterPush({ name: "member-detail", query: { id: row.id } });
}
function enable(v) {
let params = {
memberIds: [v.id],
disabled: true,
};
ElMessageBox.confirm("确定启用此会员?", "提示", { type: "warning" }).then(() => {
API_Member.updateMemberStatus(params).then((res) => {
if (res.success) {
ElMessage.success("启用成功");
getData();
}
});
}).catch(() => {});
ElMessageBox.confirm(`确定启用客户 "${v.username || v.nickName || "该客户"}" 吗?`, "提示", {
type: "warning",
confirmButtonText: "确定启用",
cancelButtonText: "取消",
})
.then(() => {
API_Member.updateMemberStatus(params).then((res) => {
if (res.success) {
ElMessage.success("启用成功");
getData();
}
});
})
.catch(() => {});
}
function handleSubmitModal() {
const { nickName, sex, username, face, newPassword, id, regionId, region } = formValidate.value;
let time = new Date(formValidate.value.birthday);
@@ -288,7 +370,6 @@ function handleSubmitModal() {
id,
};
if (newPassword) {
// 原 this.md5 为 main.js 注册的全局属性,改为具名导入(同 login.vue / userManage.vue)
submit.password = md5(newPassword);
}
API_Member.updateMember(submit).then((res) => {
@@ -304,20 +385,67 @@ onMounted(() => {
init();
});
</script>
<style lang="scss" scoped>
.face {
width: 60px;
height: 60px;
border-radius: 50%;
.member-recycle-page {
.filter-card {
.search-form {
display: flex;
align-items: center;
flex-wrap: wrap;
margin-bottom: 0;
:deep(.el-form-item) {
margin-bottom: 0;
margin-right: 16px;
}
.form-actions {
margin-left: auto;
}
}
}
.card-header-bar {
display: flex;
justify-content: space-between;
align-items: center;
.header-title {
font-size: 15px;
font-weight: 600;
color: #303133;
}
}
.pagination-wrap {
display: flex;
justify-content: flex-end;
}
}
.ops a {
color: #2d8cf0;
cursor: pointer;
text-decoration: none;
.avatar-edit-box {
display: flex;
align-items: center;
gap: 12px;
.dialog-avatar {
background-color: #f56c6c;
color: #ffffff;
}
.upload-btn {
padding: 0;
}
}
.ops span {
display: inline-block;
margin: 0 8px;
color: #dcdee2;
.region-box {
display: flex;
align-items: center;
gap: 8px;
.region-text {
color: #606266;
}
}
</style>

View File

@@ -1,37 +1,54 @@
<template>
<div class="search">
<el-card class="points-statistics-card">
<el-row type="flex" justify="space-around" align="middle" class="points-statistics">
<div class="member-point-page search">
<!-- 顶部数据统计概览看板 -->
<el-card shadow="never" class="points-statistics-card">
<el-row :gutter="24" class="points-statistics">
<el-col :xs="24" :sm="12" class="points-statistics-item">
<div class="points-statistics-title">已发放积分数</div>
<div class="points-statistics-subtitle">历史累计发放积分数</div>
<div class="points-statistics-value">{{ formatNumber(pointsStatistics.totalPoint) }}</div>
<div class="stat-inner-box">
<div class="points-statistics-title">已发放积分数</div>
<div class="points-statistics-subtitle">平台历史累计发放积分总量</div>
<div class="points-statistics-value">{{ formatNumber(pointsStatistics.totalPoint) }}</div>
</div>
</el-col>
<el-col :xs="24" :sm="12" class="points-statistics-item">
<div class="points-statistics-title">未使用积分数</div>
<div class="points-statistics-subtitle">会员账户未使用积分数</div>
<div class="points-statistics-value">{{ formatNumber(pointsStatistics.unUsedPoint) }}</div>
<div class="stat-inner-box">
<div class="points-statistics-title">未使用积分数</div>
<div class="points-statistics-subtitle">会员账户当前剩余未使用积分总量</div>
<div class="points-statistics-value text-warning">{{ formatNumber(pointsStatistics.unUsedPoint) }}</div>
</div>
</el-col>
</el-row>
</el-card>
<!-- 选项卡与数据主区域 -->
<div class="point-tabs-wrap">
<el-tabs v-model="activeTab" class="point-tabs">
<el-tab-pane label="积分列表" name="pointList">
<el-card class="point-content-card">
<!-- 选项卡 1:会员积分列表 -->
<el-tab-pane label="会员积分列表" name="pointList">
<!-- 筛选条件 -->
<el-card shadow="never" class="point-filter-card">
<el-form
ref="memberSearchFormRef"
:model="memberSearchForm"
inline
label-width="70px"
label-width="76px"
class="search-form"
@keyup.enter="handleMemberSearch"
>
<el-form-item label="客户ID" prop="id">
<el-input
v-model="memberSearchForm.id"
placeholder="请输入客户ID"
clearable
style="width: 190px"
/>
</el-form-item>
<el-form-item label="客户名称" prop="nickName">
<el-input
v-model="memberSearchForm.nickName"
placeholder="请输入客户名称"
clearable
style="width: 180px"
style="width: 160px"
/>
</el-form-item>
<el-form-item label="客户账号" prop="username">
@@ -39,82 +56,128 @@
v-model="memberSearchForm.username"
placeholder="请输入客户账号"
clearable
style="width: 180px"
style="width: 160px"
/>
</el-form-item>
<el-form-item label="账号状态" prop="disabled">
<el-select
v-model="memberSearchForm.disabled"
clearable
placeholder="请选择账号状态"
style="width: 160px"
placeholder="全部状态"
style="width: 130px"
>
<el-option label="全部" value="" />
<el-option label="启用" value="OPEN" />
<el-option label="禁用" value="CLOSE" />
</el-select>
</el-form-item>
<el-form-item label="积分值">
<el-form-item label="积分区间">
<el-input-number
v-model="memberSearchForm.minPoint"
:min="0"
:precision="0"
placeholder="最小积分值"
style="width: 140px"
placeholder="最小"
controls-position="right"
style="width: 120px"
/>
<span class="point-range-separator">-</span>
<el-input-number
v-model="memberSearchForm.maxPoint"
:min="0"
:precision="0"
placeholder="最大积分值"
style="width: 140px"
placeholder="最大"
controls-position="right"
style="width: 120px"
/>
</el-form-item>
<el-form-item>
<el-button class="search-btn" type="primary" @click="handleMemberSearch">搜索</el-button>
<el-form-item class="form-actions">
<el-button class="search-btn" type="primary" @click="handleMemberSearch">
搜索
</el-button>
<el-button @click="handleMemberReset">重置</el-button>
</el-form-item>
</el-form>
</el-card>
<el-card class="point-content-card member-list-card">
<!-- 会员积分表格 -->
<el-card shadow="never" class="point-content-card member-list-card mt_10">
<template #header>
<div class="card-title">用户列表</div>
<div class="card-header-bar">
<div class="header-title">
<span>会员积分信息</span>
<span class="total-badge">共 {{ memberTotal }} 位会员</span>
</div>
<div class="header-actions">
<el-button size="small" @click="getMemberList">刷新</el-button>
</div>
</div>
</template>
<el-table
v-loading="memberLoading"
border
stripe
:data="memberData"
class="member-table"
class="custom-point-table"
style="width: 100%"
>
<!-- 客户ID -->
<el-table-column label="客户ID" min-width="170">
<template #default="{ row }">
<span v-if="row" class="font-mono text-dark">{{ row.id || "-" }}</span>
</template>
</el-table-column>
<!-- 客户名称 -->
<el-table-column label="客户名称" min-width="160" show-overflow-tooltip>
<template #default="{ row }">
<span v-if="row">{{ row.nickName || "-" }}</span>
<span v-if="row" class="font-semibold text-dark">{{ row.nickName || "-" }}</span>
</template>
</el-table-column>
<el-table-column label="客户账号" min-width="260" show-overflow-tooltip>
<!-- 客户账号 -->
<el-table-column label="客户账号" min-width="180" show-overflow-tooltip>
<template #default="{ row }">
<span v-if="row">{{ row.username || "-" }}</span>
<span v-if="row" class="font-mono">{{ row.username || "-" }}</span>
</template>
</el-table-column>
<el-table-column label="账号状态" min-width="120" align="center">
<!-- 账号状态 -->
<el-table-column label="账号状态" width="110" align="center">
<template #default="{ row }">
<el-tag v-if="row" :type="row.disabled === true ? 'success' : 'info'">
<el-tag
v-if="row"
:type="row.disabled === true ? 'success' : 'info'"
size="small"
>
{{ row.disabled === true ? "启用" : "禁用" }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="积分余额" min-width="120" align="center">
<!-- 积分余额 -->
<el-table-column label="积分余额" width="130" align="right">
<template #default="{ row }">
<span v-if="row">{{ row.point == null ? 0 : row.point }}</span>
<span v-if="row" class="point-num-text">
{{ formatNumber(row.point == null ? 0 : row.point) }}
</span>
</template>
</el-table-column>
<el-table-column label="操作" width="120" align="center" fixed="right">
<!-- 操作 -->
<el-table-column label="操作" width="180" align="center" fixed="right">
<template #default="{ row }">
<a v-if="row" class="link-text" @click="detail(row)">详情</a>
<template v-if="row">
<a class="link-text" @click="detail(row)">客户详情</a>
<span class="op-split">|</span>
<a class="link-text" @click="viewMemberHistory(row)">变动明细</a>
</template>
</template>
</el-table-column>
</el-table>
<div class="mt_10" style="display: flex; justify-content: flex-end">
<!-- 分页 -->
<div class="pagination-wrap mt_10">
<el-pagination
v-model:current-page="memberSearchForm.pageNumber"
v-model:page-size="memberSearchForm.pageSize"
@@ -128,63 +191,140 @@
</div>
</el-card>
</el-tab-pane>
<!-- 选项卡 2:积分增减记录 -->
<el-tab-pane label="积分增减记录" name="pointChangeRecord">
<el-card class="point-content-card">
<!-- 筛选条件 -->
<el-card shadow="never" class="point-filter-card">
<el-form
ref="searchFormRef"
:model="searchForm"
inline
label-width="70px"
label-width="76px"
class="search-form"
@keyup.enter="handleSearch"
>
<el-form-item label="会员名称" prop="username">
<el-form-item label="客户ID" prop="memberId">
<el-input
v-model="searchForm.memberId"
placeholder="请输入客户ID"
clearable
style="width: 200px"
/>
</el-form-item>
<el-form-item label="会员名称" prop="memberName">
<el-input
v-model="searchForm.memberName"
placeholder="请输入会员名称"
clearable
style="width: 240px"
style="width: 180px"
/>
</el-form-item>
<el-form-item>
<el-button class="search-btn" type="primary" @click="handleSearch">搜索</el-button>
<el-form-item class="form-actions">
<el-button class="search-btn" type="primary" @click="handleSearch">
搜索
</el-button>
<el-button @click="handleHistoryReset">重置</el-button>
</el-form-item>
</el-form>
</el-card>
<el-card class="point-content-card">
<!-- 积分记录表格 -->
<el-card shadow="never" class="point-content-card mt_10">
<template #header>
<div class="card-header-bar">
<div class="header-title">
<span>积分流水记录</span>
<span class="total-badge">共 {{ total }} 条明细</span>
</div>
<div class="header-actions">
<el-button size="small" @click="getData">刷新</el-button>
</div>
</div>
</template>
<el-table
v-loading="loading"
border
stripe
:data="data"
ref="table"
class="mt_10 point-table"
class="custom-point-table"
style="width: 100%"
>
<el-table-column prop="memberName" label="会员名称" min-width="120" show-overflow-tooltip />
<el-table-column prop="content" label="操作内容" min-width="200" show-overflow-tooltip />
<el-table-column prop="beforePoint" label="之前积分" width="200" />
<el-table-column label="变动积分" width="200">
<!-- 客户ID -->
<el-table-column label="客户ID" min-width="170">
<template #default="{ row }">
<span v-if="row" class="font-mono text-dark">{{ row.memberId || "-" }}</span>
</template>
</el-table-column>
<!-- 会员名称 -->
<el-table-column prop="memberName" label="会员名称" min-width="140" show-overflow-tooltip>
<template #default="{ row }">
<span v-if="row" class="font-semibold text-dark">{{ row.memberName || "-" }}</span>
</template>
</el-table-column>
<!-- 操作内容/原因 -->
<el-table-column prop="content" label="操作内容" min-width="200" show-overflow-tooltip>
<template #default="{ row }">
<span>{{ row ? row.content || "-" : "-" }}</span>
</template>
</el-table-column>
<!-- 变动前积分 -->
<el-table-column label="变动前积分" width="130" align="right">
<template #default="{ row }">
<span v-if="row">{{ formatNumber(row.beforePoint) }}</span>
</template>
</el-table-column>
<!-- 变动积分 -->
<el-table-column label="变动积分" width="130" align="right">
<template #default="{ row }">
<template v-if="row">
<priceColorScheme
<el-tag
v-if="row.pointType === 'INCREASE'"
:value="row.variablePoint"
color="green"
unit="+"
/>
<priceColorScheme
type="success"
size="small"
effect="light"
class="point-change-tag"
>
+{{ formatNumber(row.variablePoint) }}
</el-tag>
<el-tag
v-else
:value="row.variablePoint"
:color="$mainColor"
unit="-"
/>
type="danger"
size="small"
effect="light"
class="point-change-tag"
>
-{{ formatNumber(row.variablePoint) }}
</el-tag>
</template>
</template>
</el-table-column>
<el-table-column prop="point" label="当前积分" width="200" />
<el-table-column prop="createTime" label="操作时间" width="200" />
<!-- 变动后积分 -->
<el-table-column label="变动后积分" width="130" align="right">
<template #default="{ row }">
<span v-if="row" class="point-num-text">
{{ formatNumber(row.point) }}
</span>
</template>
</el-table-column>
<!-- 操作时间 -->
<el-table-column prop="createTime" label="操作时间" width="170" align="center">
<template #default="{ row }">
<span v-if="row" class="time-text">{{ row.createTime }}</span>
</template>
</el-table-column>
</el-table>
<div class="mt_10" style="display: flex; justify-content: flex-end">
<!-- 分页 -->
<div class="pagination-wrap mt_10">
<el-pagination
v-model:current-page="searchForm.pageNumber"
v-model:page-size="searchForm.pageSize"
@@ -207,12 +347,12 @@
import { onMounted, reactive, ref } from "vue";
import * as API_Member from "@/api/member.js";
import { customRouterPush } from "@/utils/filters";
import { ElMessage } from "element-plus";
defineOptions({ name: "point" });
const emit = defineEmits(["callback"]);
// 模板 ref,script 内未使用
const memberSearchFormRef = ref(null);
const searchFormRef = ref(null);
const table = ref(null);
@@ -224,35 +364,60 @@ const pointsStatistics = ref({
totalPoint: 0,
unUsedPoint: 0,
});
const searchForm = reactive({
pageNumber: 1,
pageSize: 20,
memberId: "",
memberName: "",
});
const memberSearchForm = reactive({
pageNumber: 1,
pageSize: 20,
id: "",
nickName: "",
username: "",
disabled: "",
minPoint: null,
maxPoint: null,
});
const data = ref([]);
const memberData = ref([]);
const memberTotal = ref(0);
const total = ref(0);
function callback(val) {
emit("callback", val);
function copyText(text, label = "客户ID") {
if (!text) return;
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard
.writeText(String(text))
.then(() => ElMessage.success(`已复制${label}到剪贴板`))
.catch(() => ElMessage.info(String(text)));
} else {
ElMessage.info(String(text));
}
}
function detail(row) {
customRouterPush({ name: "member-detail", query: { id: row.id } });
}
function viewMemberHistory(row) {
activeTab.value = "pointChangeRecord";
searchForm.memberId = row.id;
searchForm.memberName = row.nickName || row.username || "";
searchForm.pageNumber = 1;
getData();
}
function init() {
getStatistics();
getMemberList();
getData();
}
function getStatistics() {
API_Member.queryMemberPointsStatistics().then((res) => {
if (res && res.success && res.result) {
@@ -263,45 +428,71 @@ function getStatistics() {
}
});
}
function formatNumber(value) {
const numericValue = Number(value || 0);
if (!Number.isFinite(numericValue)) return "0";
return numericValue.toLocaleString();
}
function changePage(v) {
searchForm.pageNumber = v;
getData();
}
function changePageSize(v) {
searchForm.pageNumber = 1;
searchForm.pageSize = v;
getData();
}
function handleSearch() {
searchForm.pageNumber = 1;
searchForm.pageSize = 20;
getData();
}
function handleHistoryReset() {
searchForm.memberId = "";
searchForm.memberName = "";
searchForm.pageNumber = 1;
getData();
}
function handleMemberSearch() {
memberSearchForm.pageNumber = 1;
getMemberList();
}
function handleMemberReset() {
memberSearchForm.id = "";
memberSearchForm.nickName = "";
memberSearchForm.username = "";
memberSearchForm.disabled = "";
memberSearchForm.minPoint = null;
memberSearchForm.maxPoint = null;
memberSearchForm.pageNumber = 1;
getMemberList();
}
function changeMemberPage(v) {
memberSearchForm.pageNumber = v;
getMemberList();
}
function changeMemberPageSize(v) {
memberSearchForm.pageNumber = 1;
memberSearchForm.pageSize = v;
getMemberList();
}
function getMemberList() {
memberLoading.value = true;
const params = {
pageNumber: memberSearchForm.pageNumber,
pageSize: memberSearchForm.pageSize,
nickName: memberSearchForm.nickName || undefined,
username: memberSearchForm.username || undefined,
id: memberSearchForm.id ? memberSearchForm.id.trim() : undefined,
nickName: memberSearchForm.nickName ? memberSearchForm.nickName.trim() : undefined,
username: memberSearchForm.username ? memberSearchForm.username.trim() : undefined,
disabled: memberSearchForm.disabled || undefined,
minPoint: memberSearchForm.minPoint,
maxPoint: memberSearchForm.maxPoint,
@@ -318,31 +509,111 @@ function getMemberList() {
memberLoading.value = false;
});
}
function getData() {
loading.value = true;
API_Member.getHistoryPointData(searchForm).then((res) => {
loading.value = false;
if (res.success) {
data.value = res.result.records;
total.value = res.result.total;
}
});
const params = {
pageNumber: searchForm.pageNumber,
pageSize: searchForm.pageSize,
memberId: searchForm.memberId ? searchForm.memberId.trim() : undefined,
memberName: searchForm.memberName ? searchForm.memberName.trim() : undefined,
};
API_Member.getHistoryPointData(params)
.then((res) => {
loading.value = false;
if (res && res.success && res.result) {
data.value = res.result.records || [];
total.value = res.result.total || 0;
}
})
.catch(() => {
loading.value = false;
});
}
onMounted(() => {
init();
});
</script>
<style lang="scss" scoped>
.points-statistics-card {
margin-bottom: 10px;
.member-point-page {
padding: 16px 20px;
background-color: #f6f8fa;
min-height: calc(100vh - 110px);
}
.font-mono {
font-family: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
}
.font-semibold {
font-weight: 600;
}
.text-dark {
color: #303133;
}
.text-warning {
color: #e6a23c;
}
/* 顶部统计卡片 */
.points-statistics-card {
margin-bottom: 14px;
border-radius: 8px;
border: 1px solid #ebeef5;
:deep(.el-card__body) {
padding: 16px 24px;
}
.points-statistics {
width: 100%;
.points-statistics-item {
padding: 6px 0;
.stat-inner-box {
text-align: center;
padding: 12px 16px;
background: #fbfbfc;
border-radius: 6px;
border: 1px solid #f2f3f5;
.points-statistics-title {
font-size: 14px;
font-weight: 600;
color: #303133;
line-height: 20px;
}
.points-statistics-subtitle {
font-size: 12px;
color: #909399;
line-height: 18px;
margin-top: 4px;
}
.points-statistics-value {
font-size: 22px;
font-weight: 700;
color: #f56c6c;
line-height: 30px;
margin-top: 8px;
}
}
}
}
}
/* 选项卡容器 */
.point-tabs-wrap {
padding: 12px 16px 16px;
padding: 14px 18px 18px;
background: #fff;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(23, 35, 61, 0.04);
border: 1px solid #ebeef5;
}
.point-tabs {
@@ -352,107 +623,108 @@ onMounted(() => {
:deep(.el-tabs__item) {
padding: 8px 18px;
color: #515a6e;
font-size: 14px;
color: #606266;
transition: all 0.2s ease;
}
:deep(.el-tabs__item.is-active) {
color: #409eff;
font-weight: 500;
}
:deep(.el-tabs__active-bar) {
height: 2px;
border-radius: 2px;
font-weight: 600;
}
}
.points-statistics {
width: 100%;
}
/* 筛选表单 */
.point-filter-card {
border-radius: 6px;
border: 1px solid #ebeef5;
background-color: #fafbfe;
.points-statistics-item {
padding: 10px 0;
text-align: center;
}
.points-statistics-title {
font-size: 14px;
color: #17233d;
line-height: 20px;
}
.points-statistics-subtitle {
font-size: 12px;
color: #808695;
line-height: 18px;
margin-top: 4px;
}
.points-statistics-value {
font-size: 18px;
font-weight: 600;
color: #fa6419;
line-height: 26px;
margin-top: 8px;
}
.point-content-card {
margin-bottom: 12px;
}
.member-list-card {
:deep(.el-card__header) {
border-bottom: 1px solid #f5f5f5;
}
}
.card-title {
font-size: 14px;
font-weight: 500;
color: #17233d;
}
.member-table {
width: 100%;
:deep(.el-table__header th.el-table__cell) {
background: #fafafa;
padding: 14px 0;
font-size: 14px;
color: #17233d;
:deep(.el-card__body) {
padding: 16px 16px 2px;
}
:deep(.el-table__header .cell) {
padding: 0 16px;
line-height: 22px;
}
.search-form {
display: flex;
flex-wrap: wrap;
align-items: center;
:deep(.el-table__body td.el-table__cell) {
padding: 12px 0;
}
.el-form-item {
margin-bottom: 14px;
margin-right: 16px;
}
:deep(.el-table__body .cell) {
padding: 0 16px;
line-height: 22px;
.form-actions {
margin-left: auto;
}
}
}
.point-range-separator {
display: inline-block;
margin: 0 8px;
color: #808695;
margin: 0 6px;
color: #909399;
}
.link-text {
color: #409eff;
cursor: pointer;
text-decoration: none;
/* 表格区域卡片 */
.point-content-card {
border-radius: 6px;
border: 1px solid #ebeef5;
.card-header-bar {
display: flex;
justify-content: space-between;
align-items: center;
.header-title {
display: flex;
align-items: center;
gap: 10px;
font-size: 15px;
font-weight: 600;
color: #303133;
.total-badge {
font-size: 12px;
font-weight: normal;
color: #909399;
}
}
}
}
.face {
width: 60px;
height: 60px;
border-radius: 50%;
/* 表格单元格 */
.custom-point-table {
.id-cell {
display: flex;
align-items: center;
gap: 8px;
.copy-btn {
padding: 0;
font-size: 12px;
}
}
.point-num-text {
font-weight: 600;
color: #f56c6c;
}
.point-change-tag {
font-weight: 600;
font-family: SFMono-Regular, Menlo, Monaco, Consolas, monospace;
}
.time-text {
font-size: 12px;
color: #909399;
}
}
.pagination-wrap {
display: flex;
justify-content: flex-end;
padding-top: 6px;
}
</style>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -150,12 +150,11 @@
</template>
<script setup>
import { computed, onMounted, ref } from "vue";
import { computed, onMounted, ref, watch } from "vue";
import { useRoute } from "vue-router";
import * as API_Order from "@/api/order";
import { ElMessage, ElMessageBox } from "element-plus";
import { unitPrice, customRouterPush } from "@/utils/filters";
const QUERY_KEY = "manager-order-list-query";
const PAGE_SIZES = [20, 50, 100];
const PROMOTION_OPTIONS = [
@@ -201,37 +200,11 @@ function emptySearchForm(pageSize = 20) {
};
}
function readSavedQuery() {
const raw = sessionStorage.getItem(QUERY_KEY);
if (!raw) return null;
try {
const saved = JSON.parse(raw);
const form = emptySearchForm();
const src = saved && saved.searchForm ? saved.searchForm : {};
Object.keys(form).forEach((key) => {
if (src[key] !== undefined && src[key] !== null) {
form[key] = src[key];
}
});
form.pageNumber = Number(form.pageNumber) || 1;
form.pageSize = PAGE_SIZES.includes(Number(form.pageSize)) ? Number(form.pageSize) : 20;
const currentStatus = saved.currentStatus || form.orderStatus || "ALL";
if (!currentStatus || currentStatus === "ALL") {
form.orderStatus = "";
}
return {
searchForm: form,
selectDate: Array.isArray(saved.selectDate) ? saved.selectDate : null,
currentStatus: currentStatus || "ALL",
};
} catch (e) {
sessionStorage.removeItem(QUERY_KEY);
return null;
}
}
defineOptions({ name: "orderList" });
const route = useRoute();
const searchFormRef = ref(null);
const tableRef = ref(null);
@@ -248,6 +221,25 @@ const total = ref(0);
const orderNumData = ref({});
const currentStatus = ref("ALL");
function applyRouteQuery() {
const q = route.query;
if (!q) return;
if (q.orderSn) searchForm.value.orderSn = String(q.orderSn);
if (q.buyerName) searchForm.value.buyerName = String(q.buyerName);
if (q.goodsName) searchForm.value.goodsName = String(q.goodsName);
if (q.storeName) searchForm.value.storeName = String(q.storeName);
if (q.orderType) searchForm.value.orderType = String(q.orderType);
if (q.orderStatus) {
searchForm.value.orderStatus = String(q.orderStatus);
currentStatus.value = String(q.orderStatus);
}
if (q.startDate && q.endDate) {
searchForm.value.startDate = String(q.startDate);
searchForm.value.endDate = String(q.endDate);
selectDate.value = [String(q.startDate), String(q.endDate)];
}
}
const orderStatusWithCount = computed(() => {
const count = (value) => (value ? `(${value})` : "");
return [
@@ -340,23 +332,6 @@ function orderStatusTagType(v) {
};
return map[v] || "info";
}
function rememberQuery() {
sessionStorage.setItem(
QUERY_KEY,
JSON.stringify({
searchForm: searchForm.value,
selectDate: selectDate.value,
currentStatus: currentStatus.value,
})
);
}
function restoreQuery() {
const saved = readSavedQuery();
if (!saved) return;
searchForm.value = saved.searchForm;
selectDate.value = saved.selectDate;
currentStatus.value = saved.currentStatus;
}
function init() {
getDataList();
getOrderNumData();
@@ -380,7 +355,6 @@ function handleReset() {
searchForm.value = emptySearchForm(pageSize);
selectDate.value = null;
currentStatus.value = "ALL";
sessionStorage.removeItem(QUERY_KEY);
init();
}
function selectDateRange(v) {
@@ -403,7 +377,6 @@ function countParams() {
}
function getDataList() {
loading.value = true;
rememberQuery();
API_Order.getOrderList(searchForm.value)
.then((res) => {
if (res.success) {
@@ -478,10 +451,24 @@ function getOrderNumData() {
});
}
// created
restoreQuery();
watch(
() => route.query,
(newQuery) => {
if (newQuery && Object.keys(newQuery).length > 0) {
applyRouteQuery();
searchForm.value.pageNumber = 1;
init();
}
}
);
onMounted(() => {
try {
sessionStorage.removeItem("manager-order-list-query");
} catch (e) {
// ignore
}
applyRouteQuery();
init();
});
</script>

View File

@@ -1,76 +1,162 @@
<template>
<div>
<div class="content-goods-publish">
<el-form ref="form" label-width="70px">
<div class="base-info-item">
<h4>优惠券活动详情</h4>
<div class="form-item-view">
<el-form-item label="活动名称">
<span>{{ couponActivity.promotionName }}</span>
</el-form-item>
<el-form-item label="活动类型">
<span v-if="couponActivity.couponActivityType === 'REGISTERED'">新人发券</span>
<span v-else-if="couponActivity.couponActivityType === 'AUTO_COUPON'">自动发券</span>
<span v-else>精确发券</span>
</el-form-item>
<el-form-item
v-if="couponActivity.couponFrequencyEnum != '' && couponActivity.couponFrequencyEnum != null && couponActivity.couponFrequencyEnum != undefined"
label="领取频率"
>
<span v-if="couponActivity.couponFrequencyEnum === 'DAY'">每日</span>
<span v-else-if="couponActivity.couponFrequencyEnum === 'WEEK'">每周一次</span>
<span v-else>每月一次</span>
</el-form-item>
<el-form-item
v-if="couponActivity.couponActivityType === 'SPECIFY' || couponActivity.couponActivityType === 'AUTO_COUPON'"
label="活动范围"
>
<span v-if="couponActivity.activityScope === 'ALL'">全部会员</span>
<span v-else>指定会员</span>
</el-form-item>
<el-form-item label="活动时间">
<span>{{ couponActivity.startTime }}~{{ couponActivity.endTime }}</span>
</el-form-item>
<el-form-item label="活动状态">
<span v-if="couponActivity.promotionStatus === 'NEW'">未开始</span>
<span v-if="couponActivity.promotionStatus === 'START'">已开始</span>
<span v-if="couponActivity.promotionStatus === 'END'">已结束</span>
<span v-if="couponActivity.promotionStatus === 'CLOSE'">已关闭</span>
</el-form-item>
<div class="coupon-activity-info-page">
<!-- 顶部标题与返回栏 -->
<el-card shadow="never" class="header-card">
<div class="page-header-row">
<div class="header-left">
<div class="header-title">
<span>优惠券活动详情</span>
<el-tag :type="statusTagType(couponActivity.promotionStatus)" size="small" effect="plain" class="status-tag">
{{ statusText(couponActivity.promotionStatus) }}
</el-tag>
<span v-if="id" class="activity-id font-mono">ID: {{ id }}</span>
</div>
<h4>优惠券列表</h4>
<el-table :data="couponData" style="width: 100%">
<el-table-column prop="couponName" label="优惠券名称" />
<el-table-column label="优惠券金额">
<template #default="{ row }">
<template v-if="row">
<span v-if="row.couponType === 'DISCOUNT'">{{ row.couponDiscount }}折</span>
<priceColorScheme v-else-if="row.couponType === 'PRICE'" :value="row.price" :color="$mainColor" />
<span v-else>未知</span>
</template>
</template>
</el-table-column>
<el-table-column label="优惠券类型">
<template #default="{ row }">
<span v-if="row">{{ couponTypeText(row.couponType) }}</span>
</template>
</el-table-column>
<el-table-column prop="num" label="赠送数量" />
</el-table>
<template v-if="couponActivity.activityScopeInfo && memberData.length > 0">
<h4 class="mt_10">会员列表列表</h4>
<el-table :data="memberData" style="width: 100%">
<el-table-column prop="id" label="会员id" />
<el-table-column prop="nickName" label="昵称" />
</el-table>
</template>
</div>
</el-form>
</div>
<div class="header-right">
<el-button @click="back">返回活动列表</el-button>
</div>
</div>
</el-card>
<div class="footer">
<el-button type="primary" @click="back">返回活动列表</el-button>
</div>
<!-- 活动基本信息卡片 -->
<el-card shadow="never" class="info-card mt_10">
<div class="section-header">
<span class="section-indicator"></span>
<span class="section-title">基本信息</span>
</div>
<el-descriptions :column="2" border class="custom-descriptions">
<el-descriptions-item label="活动名称">
<span class="font-semibold text-dark">{{ couponActivity.promotionName || "-" }}</span>
</el-descriptions-item>
<el-descriptions-item label="活动类型">
<el-tag size="small" effect="light" type="primary">
{{ activityTypeText(couponActivity.couponActivityType) }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item
v-if="couponActivity.couponFrequencyEnum"
label="领取频率"
>
<span>{{ frequencyText(couponActivity.couponFrequencyEnum) }}</span>
</el-descriptions-item>
<el-descriptions-item
v-if="couponActivity.couponActivityType === 'SPECIFY' || couponActivity.couponActivityType === 'AUTO_COUPON'"
label="活动范围"
>
<el-tag size="small" effect="light" :type="couponActivity.activityScope === 'ALL' ? 'success' : 'warning'">
{{ couponActivity.activityScope === "ALL" ? "全部会员" : "指定会员" }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="活动时间" :span="2">
<span class="font-mono text-dark">
{{ couponActivity.startTime || "-" }} 至 {{ couponActivity.endTime || "-" }}
</span>
</el-descriptions-item>
<el-descriptions-item label="活动状态" :span="2">
<el-tag :type="statusTagType(couponActivity.promotionStatus)" size="small" effect="dark">
{{ statusText(couponActivity.promotionStatus) }}
</el-tag>
</el-descriptions-item>
</el-descriptions>
</el-card>
<!-- 关联优惠券列表卡片 -->
<el-card shadow="never" class="info-card mt_10">
<div class="section-header">
<span class="section-indicator"></span>
<span class="section-title">赠送优惠券列表</span>
<span class="section-badge">共 {{ couponData.length }} 张优惠券</span>
</div>
<el-table
border
stripe
:data="couponData"
style="width: 100%"
empty-text="暂无关联优惠券"
class="custom-table"
>
<el-table-column prop="couponName" label="优惠券名称" min-width="200" show-overflow-tooltip>
<template #default="{ row }">
<span class="font-semibold text-dark">{{ row.couponName || "-" }}</span>
</template>
</el-table-column>
<el-table-column label="优惠券类型" width="130" align="center">
<template #default="{ row }">
<el-tag v-if="row" size="small" effect="light" :type="row.couponType === 'DISCOUNT' ? 'warning' : 'danger'">
{{ couponTypeText(row.couponType) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="面额 / 折扣" width="140" align="right">
<template #default="{ row }">
<template v-if="row">
<span v-if="row.couponType === 'DISCOUNT'" class="price-val font-mono font-semibold">
{{ row.couponDiscount }} 折
</span>
<span v-else-if="row.couponType === 'PRICE'" class="price-val font-mono font-semibold">
¥ {{ Number(row.price || 0).toFixed(2) }}
</span>
<span v-else class="text-placeholder">-</span>
</template>
</template>
</el-table-column>
<el-table-column prop="num" label="赠送数量" width="120" align="right">
<template #default="{ row }">
<span class="font-mono text-dark">{{ row.num ?? "-" }}</span>
</template>
</el-table-column>
</el-table>
</el-card>
<!-- 指定会员列表(若存在) -->
<el-card
v-if="memberData && memberData.length > 0"
shadow="never"
class="info-card mt_10"
>
<div class="section-header">
<span class="section-indicator"></span>
<span class="section-title">指定参与会员列表</span>
<span class="section-badge">共 {{ memberData.length }} 位会员</span>
</div>
<el-table
border
stripe
:data="memberData"
style="width: 100%"
class="custom-table"
>
<el-table-column prop="id" label="会员ID" min-width="220">
<template #default="{ row }">
<span class="font-mono text-dark">{{ row.id || "-" }}</span>
</template>
</el-table-column>
<el-table-column prop="nickName" label="会员昵称" min-width="200" show-overflow-tooltip>
<template #default="{ row }">
<span class="font-semibold text-dark">{{ row.nickName || row.username || "-" }}</span>
</template>
</el-table-column>
</el-table>
</el-card>
<!-- 底部操作栏 -->
<el-card shadow="never" class="footer-card mt_10">
<div class="footer-actions">
<el-button type="primary" @click="back">返回活动列表</el-button>
</div>
</el-card>
</div>
</template>
@@ -96,65 +182,175 @@ onMounted(() => {
});
function couponTypeText(v) {
if (v === "DISCOUNT") return "打折";
if (v === "PRICE") return "减免现金";
return "未知";
if (v === "DISCOUNT") return "打折券";
if (v === "PRICE") return "减免现金券";
return "未知类型";
}
function activityTypeText(t) {
if (t === "REGISTERED") return "新人发券";
if (t === "AUTO_COUPON") return "自动发券";
if (t === "SPECIFY") return "精确发券";
return t || "-";
}
function frequencyText(f) {
if (f === "DAY") return "每日一次";
if (f === "WEEK") return "每周一次";
if (f === "MONTH") return "每月一次";
return f || "-";
}
function statusText(s) {
if (s === "NEW") return "未开始";
if (s === "START") return "已开始";
if (s === "END") return "已结束";
if (s === "CLOSE") return "已关闭";
return s || "-";
}
function statusTagType(s) {
if (s === "START") return "success";
if (s === "NEW") return "info";
if (s === "END") return "danger";
if (s === "CLOSE") return "warning";
return "info";
}
function getCouponActivity() {
if (!id) return;
apiGetCouponActivity(id).then((res) => {
couponActivity.value = res.result;
couponData.value = couponActivity.value.couponActivityItems;
memberData.value = JSON.parse(couponActivity.value.activityScopeInfo);
if (res && res.success && res.result) {
couponActivity.value = res.result;
couponData.value = Array.isArray(res.result.couponActivityItems)
? res.result.couponActivityItems
: [];
if (res.result.activityScopeInfo) {
try {
const parsed = JSON.parse(res.result.activityScopeInfo);
memberData.value = Array.isArray(parsed) ? parsed : [];
} catch {
memberData.value = [];
}
} else {
memberData.value = [];
}
}
});
}
function back() {
appStore.removeTag("coupon-activity-info");
appStore.removeTag("coupon-activity");
router.go(-1);
}
</script>
<style lang="scss" scoped>
.content-goods-publish {
padding: 15px;
margin: 0 auto;
text-align: center;
border: 1px solid #ddd;
background: none repeat 0 0 #fff;
height: 100%;
margin-bottom: 20px;
}
.coupon-activity-info-page {
padding-bottom: 20px;
div.base-info-item {
h4 {
margin-bottom: 10px;
padding: 0 10px;
border: 1px solid #ddd;
background-color: #f8f8f8;
font-weight: bold;
color: #333;
font-size: 14px;
line-height: 40px;
text-align: left;
.header-card {
.page-header-row {
display: flex;
justify-content: space-between;
align-items: center;
.header-left {
.header-title {
display: flex;
align-items: center;
gap: 10px;
font-size: 16px;
font-weight: 600;
color: #303133;
.status-tag {
font-weight: normal;
}
.activity-id {
font-size: 13px;
font-weight: normal;
color: #909399;
background-color: #f4f4f5;
padding: 2px 8px;
border-radius: 4px;
}
}
}
}
}
.form-item-view {
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: space-between;
padding-left: 40px;
.info-card {
.section-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 16px;
.section-indicator {
width: 4px;
height: 16px;
border-radius: 2px;
background-color: #409eff;
}
.section-title {
font-size: 15px;
font-weight: 600;
color: #303133;
}
.section-badge {
font-size: 12px;
color: #909399;
margin-left: 6px;
}
}
.custom-descriptions {
:deep(.el-descriptions__label) {
width: 140px;
background-color: #fafafa;
color: #606266;
font-weight: 500;
}
:deep(.el-descriptions__content) {
color: #303133;
}
}
.custom-table {
.price-val {
color: #f56c6c;
}
}
}
.footer-card {
.footer-actions {
display: flex;
justify-content: center;
align-items: center;
}
}
.font-mono {
font-family: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
}
.font-semibold {
font-weight: 600;
}
.text-dark {
color: #303133;
}
.text-placeholder {
color: #c0c4cc;
}
}
.footer {
width: 88.7%;
padding: 10px;
background-color: #ffc;
position: fixed;
bottom: 0px;
left: 10%;
text-align: center;
z-index: 9999;
}
</style>

View File

@@ -1,34 +1,82 @@
<template>
<div>
<el-card>
<el-form ref="formRef" :model="form" label-width="120px" :rules="formRule">
<div class="base-info-item">
<h4>基本信息</h4>
<div class="form-item-view">
<div class="coupon-publish-page">
<!-- 顶部页面标题与操作栏 -->
<el-card shadow="never" class="header-card">
<div class="page-header-row">
<div class="header-left">
<div class="header-title">
<span>{{ isOnlyView ? "平台优惠券详情" : (id ? "编辑平台优惠券" : "添加平台优惠券") }}</span>
<el-tag v-if="isOnlyView" type="info" size="small" effect="plain" class="mode-tag">
只读模式
</el-tag>
<el-tag v-else-if="id" type="warning" size="small" effect="plain" class="mode-tag">
编辑模式
</el-tag>
<el-tag v-else type="success" size="small" effect="plain" class="mode-tag">
新建模式
</el-tag>
<span v-if="id" class="coupon-id-tip font-mono">ID: {{ id }}</span>
</div>
</div>
<div class="header-right">
<el-button @click="closeCurrentPage">返回</el-button>
<el-button
v-if="!isOnlyView"
type="primary"
:loading="submitLoading"
@click="handleSubmit"
>
保存提交
</el-button>
</div>
</div>
</el-card>
<!-- 表单卡片 -->
<el-card shadow="never" class="form-card mt_10">
<el-form
ref="formRef"
:model="form"
label-width="130px"
:rules="formRule"
class="custom-coupon-form"
>
<!-- 模块一:基本信息 -->
<div class="form-section">
<div class="section-header">
<span class="section-indicator"></span>
<span class="section-title">基本信息</span>
<span class="section-subtitle">配置活动名称、券面额及发放规则</span>
</div>
<div class="section-body">
<el-form-item label="活动名称" prop="promotionName">
<el-input
:disabled="disabled"
v-model="form.promotionName"
placeholder="活动名称"
placeholder="请输入活动名称"
clearable
style="width: 260px"
style="width: 360px"
/>
</el-form-item>
<el-form-item label="优惠券名称" prop="couponName">
<el-input
:disabled="disabled"
v-model="form.couponName"
placeholder="优惠券名称"
placeholder="请输入优惠券名称"
clearable
style="width: 260px"
style="width: 360px"
/>
</el-form-item>
<el-form-item label="优惠券类型" prop="couponType">
<el-select :disabled="disabled" v-model="form.couponType" style="width: 260px">
<el-select :disabled="disabled" v-model="form.couponType" style="width: 240px">
<el-option label="打折" value="DISCOUNT" />
<el-option label="减免现金" value="PRICE" />
</el-select>
</el-form-item>
<el-form-item
label="折扣"
prop="couponDiscount"
@@ -42,21 +90,25 @@
:step="0.1"
:precision="1"
v-model="form.couponDiscount"
style="width: 260px"
style="width: 240px"
/>
<span class="describe">请输入0-10的数字,可有一位小数</span>
<span class="form-describe">请输入 0.1 ~ 9.9 之间的数值(1位小数)</span>
</el-form-item>
<el-form-item label="面额" prop="price" v-if="form.couponType == 'PRICE'">
<el-input
:disabled="disabled"
v-model="form.price"
placeholder="面额"
placeholder="请输入减免面额"
clearable
style="width: 260px"
/>
style="width: 240px"
>
<template #prefix>¥</template>
</el-input>
</el-form-item>
<el-form-item label="活动类型" prop="getType">
<el-select :disabled="disabled" v-model="form.getType" style="width: 260px">
<el-select :disabled="disabled" v-model="form.getType" style="width: 240px">
<el-option label="免费领取" value="FREE" />
<el-option label="活动赠送" value="ACTIVITY" />
</el-select>
@@ -67,21 +119,23 @@
:disabled="disabled"
v-model="form.storeCommission"
placeholder="店铺承担比例"
style="width: 260px"
style="width: 240px"
>
<template #append>%</template>
</el-input>
<span class="describe">店铺承担比例,输入0-100之间数值</span>
<span class="form-describe">店铺承担的资金比例,范围 0-100</span>
</el-form-item>
<el-form-item label="发放数量" prop="publishNum" v-if="form.getType === 'FREE'">
<el-input
:disabled="disabled"
v-model="form.publishNum"
placeholder="发放数量"
style="width: 260px"
placeholder="请输入发放数量"
style="width: 240px"
/>
<div class="tips">如果发放数量为0时,则代表不限制发放数量</div>
<span class="form-describe">若设置为 0,则代表不限制发放数量</span>
</el-form-item>
<el-form-item
label="领取数量限制"
prop="couponLimitNum"
@@ -90,43 +144,58 @@
<el-input
:disabled="disabled"
v-model="form.couponLimitNum"
placeholder="领取限制"
placeholder="每人限领张数"
clearable
style="width: 260px"
style="width: 240px"
/>
<div class="tips">如果领取数量为0时,则代表不限制领取数量</div>
<span class="form-describe">若设置为 0,则代表不限制每人领取数量</span>
</el-form-item>
<el-form-item label="范围描述" prop="description">
<el-input
:disabled="disabled"
v-model="form.description"
type="textarea"
:rows="4"
:rows="3"
maxlength="50"
show-word-limit
clearable
style="width: 260px"
placeholder="请输入优惠券适用范围或简要说明"
style="width: 480px"
/>
</el-form-item>
</div>
<h4>使用限制</h4>
<div class="form-item-view">
</div>
<!-- 模块二:使用限制 -->
<div class="form-section mt_10">
<div class="section-header">
<span class="section-indicator"></span>
<span class="section-title">使用限制</span>
<span class="section-subtitle">配置消费门槛、有效时间以及指定适用商品范围</span>
</div>
<div class="section-body">
<el-form-item label="消费门槛" prop="consumeThreshold">
<el-input
:disabled="disabled"
v-model="form.consumeThreshold"
placeholder="消费门槛"
placeholder="请输入消费门槛金额"
clearable
style="width: 260px"
/>
style="width: 240px"
>
<template #prefix>¥</template>
</el-input>
<span class="form-describe">订单满此金额方可使用该优惠券</span>
</el-form-item>
<el-form-item label="有效期" prop="rangeTime">
<div v-if="form.getType == 'ACTIVITY'">
<div v-if="form.getType == 'ACTIVITY'" class="mb_10">
<el-radio-group v-model="rangeTimeType">
<el-radio :disabled="disabled" :value="1" v-if="form.getType !== 'ACTIVITY'">起止时间</el-radio>
<el-radio :disabled="disabled" :value="0">固定时间</el-radio>
<el-radio :disabled="disabled" :value="0">固定有效天数</el-radio>
</el-radio-group>
</div>
<div v-if="rangeTimeType == 1">
<el-date-picker
:disabled="disabled"
@@ -137,101 +206,159 @@
start-placeholder="开始时间"
end-placeholder="结束时间"
:disabled-date="options.disabledDate"
style="width: 260px"
style="width: 380px"
/>
</div>
<div class="effectiveDays" v-if="rangeTimeType == 0">
领取当天开始
<div class="effectiveDays-row" v-if="rangeTimeType == 0">
<span class="days-label">领取当天开始</span>
<el-input-number
:disabled="disabled"
v-model="form.effectiveDays"
:min="1"
style="width: 100px"
:max="365"
style="width: 130px"
/>
天内有效(1-365间的整数)
<span class="days-unit">天内有效(1-365天的整数)</span>
</div>
</el-form-item>
<el-form-item label="使用范围" prop="scopeType">
<el-radio-group v-model="form.scopeType">
<el-radio-button :disabled="disabled" value="ALL">全品类</el-radio-button>
<el-radio-button :disabled="disabled" value="ALL">全品类通用</el-radio-button>
<el-radio-button :disabled="disabled" value="PORTION_GOODS">指定商品</el-radio-button>
<el-radio-button :disabled="disabled" value="PORTION_GOODS_CATEGORY">部分商品分类</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item style="width: 100%" v-if="form.scopeType == 'PORTION_GOODS'">
<div style="display: flex; margin-bottom: 10px">
<el-button :disabled="disabled" type="primary" @click="openSkuList">选择商品</el-button>
<el-button
:disabled="disabled"
type="danger"
plain
style="margin-left: 10px"
@click="delSelectGoods"
>批量删除</el-button>
<!-- 指定适用商品表格 -->
<el-form-item
v-if="form.scopeType == 'PORTION_GOODS'"
label="指定商品"
style="width: 100%"
>
<div class="portion-goods-wrapper">
<div v-if="!isOnlyView" class="portion-goods-toolbar">
<el-button type="primary" @click="openSkuList">选择商品</el-button>
<el-button
type="danger"
plain
:disabled="selectedGoods.length === 0"
@click="delSelectGoods"
>
批量删除 ({{ selectedGoods.length }})
</el-button>
</div>
<el-table
border
stripe
:data="form.promotionGoodsList"
style="width: 100%"
empty-text="暂无指定商品数据"
class="portion-goods-table"
@selection-change="changeSelect"
>
<el-table-column
v-if="!isOnlyView"
type="selection"
width="50"
align="center"
/>
<el-table-column label="商品图片" width="76" align="center">
<template #default="{ row }">
<el-image
v-if="row && row.thumbnail"
:src="row.thumbnail"
fit="cover"
class="table-goods-thumb"
preview-teleported
:preview-src-list="[row.thumbnail]"
>
<template #error>
<div class="thumb-slot">无图</div>
</template>
</el-image>
<div v-else class="table-goods-thumb thumb-slot">无图</div>
</template>
</el-table-column>
<el-table-column
prop="goodsName"
label="商品名称"
min-width="220"
show-overflow-tooltip
/>
<el-table-column label="商品单价" width="130" align="right">
<template #default="{ row }">
<span v-if="row" class="price-text font-mono font-semibold">
{{ $filters.unitPrice(row.price, "¥") }}
</span>
</template>
</el-table-column>
<el-table-column label="库存" width="100" align="right">
<template #default="{ row }">
<span v-if="row" class="font-mono">{{ row.quantity ?? "-" }}</span>
</template>
</el-table-column>
<el-table-column
v-if="!isOnlyView"
label="操作"
width="90"
align="center"
fixed="right"
>
<template #default="{ $index }">
<el-button
type="danger"
size="small"
link
@click="delGoods($index)"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
</div>
<el-table
border
:data="form.promotionGoodsList"
style="width: 100%"
@selection-change="changeSelect"
>
<el-table-column type="selection" width="60" align="center" />
<el-table-column prop="goodsName" label="商品名称" min-width="120" show-overflow-tooltip />
<el-table-column label="商品价格" width="110">
<template #default="{ row }">
<span v-if="row" :style="{ color: $mainColor }">
{{ $filters.unitPrice(row.price, "¥") }}</span>
</template>
</el-table-column>
<el-table-column label="库存" width="90">
<template #default="{ row }">
<span v-if="row">{{ row.quantity }}</span>
</template>
</el-table-column>
<el-table-column label="操作" width="100" align="center">
<template #default="{ $index }">
<el-button
:disabled="disabled"
type="danger"
size="small"
plain
@click="delGoods($index)"
>删除</el-button>
</template>
</el-table-column>
</el-table>
</el-form-item>
<el-form-item v-if="form.scopeType == 'PORTION_GOODS_CATEGORY'">
<!-- 部分商品分类 -->
<el-form-item v-if="form.scopeType == 'PORTION_GOODS_CATEGORY'" label="指定分类">
<el-cascader
:disabled="disabled"
:options="goodsCategoryList"
style="width: 260px"
placeholder="请选择适用的商品分类"
style="width: 360px"
v-model="form.scopeIdGoods"
clearable
/>
</el-form-item>
<div>
<el-button :disabled="disabled" link @click="closeCurrentPage">返回</el-button>
<el-button
:disabled="disabled"
type="primary"
:loading="submitLoading"
@click="handleSubmit"
>提交</el-button>
</div>
</div>
</div>
</el-form>
</el-card>
<!-- 底部固定操作栏 -->
<el-card shadow="never" class="footer-card mt_10">
<div class="footer-actions">
<el-button @click="closeCurrentPage">返回列表</el-button>
<el-button
v-if="!isOnlyView"
type="primary"
:loading="submitLoading"
@click="handleSubmit"
>
保存提交
</el-button>
</div>
</el-card>
<sku-select ref="skuSelectRef" @selectedGoodsData="selectedGoodsData"></sku-select>
</div>
</template>
<script setup>
import { onMounted, reactive, ref, watch } from "vue";
import { computed, onMounted, reactive, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import {
savePlatformCoupon,
@@ -277,7 +404,9 @@ const checkWeight = (rule, value, callback) => {
}
};
const disabled = ref(route.query.onlyView);
const isOnlyView = computed(() => route.query.onlyView === "true" || route.query.onlyView === true);
const disabled = computed(() => isOnlyView.value);
const rangeTimeType = ref(1);
const modalType = ref(0);
const form = ref({
@@ -298,6 +427,7 @@ const id = ref(route.query.id);
const submitLoading = ref(false);
const selectedGoods = ref([]);
const goodsCategoryList = ref([]);
const formRule = reactive({
promotionName: [{ required: true, message: "活动名称不能为空" }],
couponName: [{ required: true, message: "优惠券名称不能为空" }],
@@ -328,6 +458,7 @@ const formRule = reactive({
],
description: [{ required: true, message: "请输入范围描述" }],
});
const options = {
disabledDate(date) {
return date && date.valueOf() < Date.now() - 86400000;
@@ -348,6 +479,7 @@ watch(
},
{ deep: true }
);
watch(
() => route.query,
(e) => {
@@ -355,7 +487,7 @@ watch(
if (id.value) {
getCoupon();
} else {
formRef.value.resetFields();
formRef.value?.resetFields();
}
}
);
@@ -374,7 +506,7 @@ function getCoupon() {
if (!data.promotionGoodsList) data.promotionGoodsList = [];
rangeTimeType.value = data.rangeDayType === "DYNAMICTIME" ? 0 : 1;
if (data.scopeType == "PORTION_GOODS_CATEGORY") {
let prevCascader = data.scopeId.split(",");
let prevCascader = data.scopeId ? data.scopeId.split(",") : [];
function next(params, prev) {
for (let i = 0; i < params.length; i++) {
const item = params[i];
@@ -407,6 +539,7 @@ function getCoupon() {
form.value = data;
});
}
function handleSubmit() {
formRef.value.validate((valid) => {
if (valid) {
@@ -465,7 +598,7 @@ function handleSubmit() {
savePlatformCoupon(params).then((res) => {
submitLoading.value = false;
if (res.success) {
Message.success("优惠券发送成功");
Message.success("优惠券创建成功");
closeCurrentPage();
}
});
@@ -484,11 +617,14 @@ function handleSubmit() {
}
});
}
function closeCurrentPage() {
appStore.removeTag("edit-platform-coupon");
appStore.removeTag("add-platform-coupon");
localStorage.pageOpenedList = JSON.stringify(appStore.pageOpenedList);
router.go(-1);
}
function openSkuList() {
skuSelectRef.value.open("goods");
let data = JSON.parse(JSON.stringify(form.value.promotionGoodsList));
@@ -497,9 +633,11 @@ function openSkuList() {
});
skuSelectRef.value.goodsData = data;
}
function changeSelect(e) {
selectedGoods.value = e;
}
function delSelectGoods() {
if (selectedGoods.value.length <= 0) {
Message.warning("您还未选择要删除的数据");
@@ -520,9 +658,11 @@ function delSelectGoods() {
},
});
}
function delGoods(index) {
form.value.promotionGoodsList.splice(index, 1);
}
function selectedGoodsData(item) {
let list = [];
item.forEach((e) => {
@@ -535,7 +675,7 @@ function selectedGoodsData(item) {
storeName: e.storeName,
skuId: e.id,
categoryPath: e.categoryPath,
thumbnail: e.small,
thumbnail: e.thumbnail || e.small,
goodsType: e.goodsType,
goodsId: e.goodsId,
originPrice: e.price,
@@ -543,6 +683,7 @@ function selectedGoodsData(item) {
});
form.value.promotionGoodsList = list;
}
async function getCagetoryList() {
let data = await getCategoryTree();
goodsCategoryList.value = data.result;
@@ -572,6 +713,7 @@ async function getCagetoryList() {
return { value: item.id, label: item.name, children: item.children };
});
}
function filterCategoryId(list, idArr) {
list.forEach((e) => {
if (e instanceof Array) {
@@ -584,36 +726,161 @@ function filterCategoryId(list, idArr) {
}
</script>
<style lang="scss" scpoed>
h4 {
margin-bottom: 10px;
padding: 0 10px;
border: 1px solid #ddd;
background-color: #f8f8f8;
font-weight: bold;
color: #333;
font-size: 14px;
line-height: 40px;
text-align: left;
}
<style lang="scss" scoped>
.coupon-publish-page {
padding-bottom: 20px;
.describe {
font-size: 12px;
margin-left: 10px;
color: #999;
}
.header-card {
.page-header-row {
display: flex;
justify-content: space-between;
align-items: center;
.effectiveDays {
font-size: 12px;
color: #999;
.header-left {
.header-title {
display: flex;
align-items: center;
gap: 10px;
font-size: 16px;
font-weight: 600;
color: #303133;
> * {
margin: 0 4px;
.mode-tag {
font-weight: normal;
}
.coupon-id-tip {
font-size: 13px;
font-weight: normal;
color: #909399;
background-color: #f4f4f5;
padding: 2px 8px;
border-radius: 4px;
}
}
}
.header-right {
display: flex;
align-items: center;
gap: 8px;
}
}
}
.form-card {
.custom-coupon-form {
padding: 10px 10px 0;
}
.form-section {
border: 1px solid #ebeef5;
border-radius: 6px;
padding: 18px 20px;
background-color: #ffffff;
.section-header {
display: flex;
align-items: center;
gap: 8px;
padding-bottom: 14px;
margin-bottom: 16px;
border-bottom: 1px solid #f0f2f5;
.section-indicator {
width: 4px;
height: 16px;
border-radius: 2px;
background-color: #409eff;
}
.section-title {
font-size: 15px;
font-weight: 600;
color: #303133;
}
.section-subtitle {
font-size: 12px;
color: #909399;
margin-left: 6px;
}
}
.section-body {
max-width: 900px;
}
}
}
.form-describe {
font-size: 12px;
margin-left: 10px;
color: #909399;
}
.effectiveDays-row {
display: flex;
align-items: center;
gap: 8px;
.days-label,
.days-unit {
font-size: 13px;
color: #606266;
}
}
.portion-goods-wrapper {
width: 100%;
.portion-goods-toolbar {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 10px;
}
.portion-goods-table {
.table-goods-thumb {
display: block;
width: 44px;
height: 44px;
margin: 0 auto;
border-radius: 4px;
border: 1px solid #ebeef5;
}
.thumb-slot {
display: flex;
justify-content: center;
align-items: center;
font-size: 11px;
color: #c0c4cc;
background-color: #fafafa;
}
.price-text {
color: #f56c6c;
}
}
}
.footer-card {
.footer-actions {
display: flex;
justify-content: center;
align-items: center;
gap: 14px;
}
}
.font-mono {
font-family: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
}
.font-semibold {
font-weight: 600;
}
}
.tips {
font-size: 12px;
color: #999;
margin-left: 10px;
}
</style>

View File

@@ -1,237 +1,223 @@
<template>
<div>
<el-card>
<el-form ref="formRef" :model="form" label-width="120px">
<div class="base-info-item">
<h4>基本信息</h4>
<div class="form-item-view">
<el-form-item label="活动名称" prop="promotionName">
<el-input
v-model="form.promotionName"
disabled
placeholder="活动名称"
clearable
style="width: 260px"
/>
</el-form-item>
<el-form-item label="活动时间" prop="rangeTime">
<el-date-picker
type="datetimerange"
v-model="form.rangeTime"
disabled
format="YYYY-MM-DD HH:mm:ss"
start-placeholder="开始时间"
end-placeholder="结束时间"
placeholder="请选择"
:disabled-date="options.disabledDate"
style="width: 320px"
/>
</el-form-item>
<el-form-item label="活动描述" prop="description">
<el-input
v-model="form.description"
disabled
type="textarea"
:rows="4"
clearable
style="width: 260px"
/>
</el-form-item>
</div>
<h4>优惠设置</h4>
<div class="form-item-view">
<el-form-item label="优惠门槛" prop="fullMoney">
<el-input
v-model="form.fullMoney"
disabled
placeholder="优惠门槛"
clearable
style="width: 260px"
/>
<span class="describe">消费达到当前金额可以参与优惠</span>
</el-form-item>
<el-form-item label="赠送优惠券">
<el-radio-group v-model="form.discountType">
<el-radio-button value="fullMinusFlag" disabled>减现金</el-radio-button>
<el-radio-button value="fullRateFlag" disabled>打折</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item
v-if="form.discountType == 'fullMinusFlag'"
label="优惠金额"
prop="fullMinus"
>
<el-input
disabled
v-model="form.fullMinus"
placeholder="优惠金额"
clearable
style="width: 260px"
/>
</el-form-item>
<el-form-item
v-if="form.discountType == 'fullRateFlag'"
label="优惠折扣"
prop="fullRate"
>
<el-input-number
placeholder="优惠折扣"
:max="9.9"
:min="0.1"
:step="0.1"
:precision="1"
v-model="form.fullRate"
style="width: 260px"
/>
<span class="describe">优惠折扣为0-10之间数字,可有一位小数</span>
</el-form-item>
<el-form-item label="额外赠送">
<el-checkbox v-model="form.freeFreightFlag" disabled>免邮费</el-checkbox>
<el-checkbox v-model="form.couponFlag" disabled>送优惠券</el-checkbox>
<el-checkbox v-model="form.giftFlag" disabled>送赠品</el-checkbox>
<el-checkbox v-model="form.pointFlag" disabled>送积分</el-checkbox>
</el-form-item>
<el-form-item v-if="form.couponFlag" label="赠送优惠券" prop="couponId">
<el-select
v-model="form.couponId"
:disabled="form.promotionStatus != 'NEW'"
filterable
remote
:remote-method="getCouponList"
placeholder="输入优惠券名称搜索"
:loading="couponLoading"
style="width: 280px"
>
<el-option
v-for="item in couponList"
:value="item.id"
:key="item.id"
:label="item.couponName"
/>
</el-select>
</el-form-item>
<el-form-item v-if="form.giftFlag" label="赠品" prop="giftId">
<el-select
v-model="form.giftId"
filterable
remote
:remote-method="getGiftList"
placeholder="输入赠品名称搜索"
disabled
:loading="giftLoading"
style="width: 260px"
>
<el-option
v-for="item in giftList"
:value="item.id"
:key="item.id"
:label="item.goodsName"
/>
</el-select>
</el-form-item>
<el-form-item v-if="form.pointFlag" label="赠积分" prop="point">
<el-input
v-model="form.point"
type="number"
disabled
style="width: 260px"
/>
</el-form-item>
<el-form-item label="使用范围" prop="scopeType">
<el-radio-group v-model="form.scopeType">
<el-radio-button value="ALL" disabled>全品类</el-radio-button>
<el-radio-button value="PORTION_GOODS" disabled>指定商品</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item
style="width: 100%"
v-if="form.scopeType == 'PORTION_GOODS'"
>
<el-table border :data="form.promotionGoodsList" style="width: 100%">
<el-table-column type="selection" width="60" align="center" />
<el-table-column label="商品名称" min-width="120" show-overflow-tooltip>
<template #default="{ row }">
<template v-if="row">
<a class="link-text mr_10" @click="linkTo(row.goodsId, row.skuId)">{{
row.goodsName
}}</a>
<el-popover trigger="hover" title="扫码在手机中查看" placement="top" width="180">
<template #reference>
<img
src="../../../assets/qrcode.svg"
style="vertical-align: middle"
class="hover-pointer"
width="20"
height="20"
alt=""
/>
</template>
<vue-qr
:text="wapLinkTo(row.goodsId, row.skuId)"
:margin="0"
color-dark="#000"
color-light="#fff"
:size="150"
/>
</el-popover>
</template>
</template>
</el-table-column>
<el-table-column label="商品价格" width="110">
<template #default="{ row }">
<span v-if="row" :style="{ color: $mainColor }">
{{ $filters.unitPrice(row.price, "¥") }}</span>
</template>
</el-table-column>
<el-table-column label="库存" width="90">
<template #default="{ row }">
<span v-if="row">{{ row.quantity }}</span>
</template>
</el-table-column>
</el-table>
</el-form-item>
<div>
<el-button @click="$router.push({ name: 'promotions/full-discount' })">返回</el-button>
</div>
<div class="full-discount-detail-page">
<!-- 顶部标题与返回栏 -->
<el-card shadow="never" class="header-card">
<div class="page-header-row">
<div class="header-left">
<div class="header-title">
<span>满优惠活动详情</span>
<el-tag type="info" size="small" effect="plain" class="mode-tag">
只读模式
</el-tag>
<span v-if="id" class="activity-id font-mono">ID: {{ id }}</span>
</div>
</div>
</el-form>
<div class="header-right">
<el-button @click="back">返回</el-button>
</div>
</div>
</el-card>
<!-- 活动基本信息卡片 -->
<el-card shadow="never" class="info-card mt_10">
<div class="section-header">
<span class="section-indicator"></span>
<span class="section-title">基本信息</span>
</div>
<el-descriptions :column="2" border class="custom-descriptions">
<el-descriptions-item label="活动名称">
<span class="font-semibold text-dark">{{ form.promotionName || "-" }}</span>
</el-descriptions-item>
<el-descriptions-item label="活动范围">
<el-tag size="small" effect="light" :type="form.scopeType === 'ALL' ? 'success' : 'primary'">
{{ form.scopeType === "ALL" ? "全品类通用" : "指定商品适用" }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="活动时间" :span="2">
<span class="font-mono text-dark">
{{ form.startTime || "-" }} 至 {{ form.endTime || "-" }}
</span>
</el-descriptions-item>
<el-descriptions-item label="活动描述" :span="2">
<span class="text-secondary">{{ form.description || "暂无活动描述" }}</span>
</el-descriptions-item>
</el-descriptions>
</el-card>
<!-- 优惠规则与赠品权益卡片 -->
<el-card shadow="never" class="info-card mt_10">
<div class="section-header">
<span class="section-indicator"></span>
<span class="section-title">优惠规则与权益</span>
</div>
<el-descriptions :column="2" border class="custom-descriptions">
<el-descriptions-item label="优惠门槛">
<span class="money-highlight font-mono font-semibold">
满 ¥ {{ Number(form.fullMoney || 0).toFixed(2) }}
</span>
<span class="text-secondary ml_10" style="font-size: 12px">(达到当前金额可享优惠)</span>
</el-descriptions-item>
<el-descriptions-item label="优惠形式">
<template v-if="form.discountType === 'fullMinusFlag'">
<el-tag size="small" effect="light" type="danger">减现金</el-tag>
<span class="money-highlight font-mono font-semibold ml_10">
减 ¥ {{ Number(form.fullMinus || 0).toFixed(2) }}
</span>
</template>
<template v-else-if="form.discountType === 'fullRateFlag'">
<el-tag size="small" effect="light" type="warning">打折</el-tag>
<span class="money-highlight font-mono font-semibold ml_10">
打 {{ form.fullRate }} 折
</span>
</template>
<template v-else>
<span class="text-secondary">无直接立减/打折</span>
</template>
</el-descriptions-item>
<el-descriptions-item label="附加赠送权益" :span="2">
<div class="extra-rights-row">
<el-tag :type="form.freeFreightFlag ? 'success' : 'info'" effect="plain" size="small">
{{ form.freeFreightFlag ? "✓ 包邮(免运费)" : "不包邮" }}
</el-tag>
<el-tag :type="form.couponFlag ? 'success' : 'info'" effect="plain" size="small">
{{ form.couponFlag ? "✓ 赠送优惠券" : "不送券" }}
</el-tag>
<el-tag :type="form.giftFlag ? 'success' : 'info'" effect="plain" size="small">
{{ form.giftFlag ? "✓ 赠送赠品" : "不送赠品" }}
</el-tag>
<el-tag :type="form.pointFlag ? 'success' : 'info'" effect="plain" size="small">
{{ form.pointFlag ? "✓ 赠送积分" : "不送积分" }}
</el-tag>
</div>
</el-descriptions-item>
<el-descriptions-item v-if="form.couponFlag" label="赠送优惠券" :span="2">
<span class="font-semibold text-dark">{{ couponNameText }}</span>
</el-descriptions-item>
<el-descriptions-item v-if="form.giftFlag" label="赠品信息" :span="2">
<span class="font-semibold text-dark">{{ giftNameText }}</span>
</el-descriptions-item>
<el-descriptions-item v-if="form.pointFlag" label="赠送积分数" :span="2">
<span class="font-mono font-semibold text-dark">{{ form.point ?? 0 }} 积分</span>
</el-descriptions-item>
</el-descriptions>
</el-card>
<!-- 指定参与商品列表(当指定商品时展示) -->
<el-card
v-if="form.scopeType === 'PORTION_GOODS'"
shadow="never"
class="info-card mt_10"
>
<div class="section-header">
<span class="section-indicator"></span>
<span class="section-title">指定适用商品列表</span>
<span class="section-badge">共 {{ (form.promotionGoodsList || []).length }} 件商品</span>
</div>
<el-table
border
stripe
:data="form.promotionGoodsList || []"
style="width: 100%"
empty-text="暂无指定商品数据"
class="custom-table"
>
<el-table-column label="商品图片" width="76" align="center">
<template #default="{ row }">
<el-image
v-if="row && (row.thumbnail || row.small)"
:src="row.thumbnail || row.small"
fit="cover"
class="table-goods-thumb"
preview-teleported
:preview-src-list="[row.thumbnail || row.small]"
>
<template #error>
<div class="thumb-slot">无图</div>
</template>
</el-image>
<div v-else class="table-goods-thumb thumb-slot">无图</div>
</template>
</el-table-column>
<el-table-column label="商品名称" min-width="240" show-overflow-tooltip>
<template #default="{ row }">
<template v-if="row">
<span class="goods-title font-semibold text-dark">{{ row.goodsName || "-" }}</span>
</template>
</template>
</el-table-column>
<el-table-column label="商品单价" width="130" align="right">
<template #default="{ row }">
<span v-if="row" class="price-val font-mono font-semibold">
{{ $filters.unitPrice(row.price, "¥") }}
</span>
</template>
</el-table-column>
<el-table-column label="库存" width="100" align="right">
<template #default="{ row }">
<span v-if="row" class="font-mono text-dark">{{ row.quantity ?? "-" }}</span>
</template>
</el-table-column>
</el-table>
</el-card>
<!-- 底部操作栏 -->
<el-card shadow="never" class="footer-card mt_10">
<div class="footer-actions">
<el-button type="primary" @click="back">返回活动列表</el-button>
</div>
</el-card>
</div>
</template>
<script setup>
import { onMounted, ref } from "vue";
import { useRoute } from "vue-router";
import { computed, onMounted, ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import { getPlatformCouponList, getFullDiscountById } from "@/api/promotion";
import { getGoodsSkuData } from "@/api/goods";
import vueQr from "vue-qr";
import { useAppStore } from "@/stores/app";
defineOptions({ name: "add-full-discount" });
defineOptions({ name: "full-discount-detail" });
const route = useRoute();
const router = useRouter();
const appStore = useAppStore();
const formRef = ref(null);
const id = ref(route.query.id);
const form = ref({
discountType: "fullMinusFlag",
scopeType: "ALL",
promotionGoodsList: [],
});
const id = ref(route.query.id);
const couponList = ref([]);
const giftList = ref([]);
const giftLoading = ref(false);
const couponLoading = ref(false);
const options = {
disabledDate(date) {
return date && date.valueOf() < Date.now() - 86400000;
},
};
onMounted(async () => {
const couponNameText = computed(() => {
if (!form.value.couponId) return "未指定优惠券";
const found = couponList.value.find((c) => String(c.id) === String(form.value.couponId));
return found ? found.couponName : `优惠券 ID: ${form.value.couponId}`;
});
const giftNameText = computed(() => {
if (!form.value.giftId) return "未指定赠品";
const found = giftList.value.find((g) => String(g.id) === String(form.value.giftId));
return found ? found.goodsName : `赠品 ID: ${form.value.giftId}`;
});
onMounted(() => {
if (id.value) {
getDetail();
}
@@ -241,79 +227,194 @@ onMounted(async () => {
function getDetail() {
getFullDiscountById(id.value).then((res) => {
let data = res.result;
if (!data.scopeType === "ALL") {
data.promotionGoodsList = [];
if (res && res.success && res.result) {
let data = res.result;
if (data.scopeType !== "PORTION_GOODS") {
data.promotionGoodsList = [];
}
if (data.fullRateFlag) {
data.discountType = "fullRateFlag";
} else {
data.discountType = "fullMinusFlag";
}
form.value = data;
}
if (data.fullMinusFlag) {
data.discountType = "fullMinusFlag";
delete data.fullMinusFlag;
} else {
data.discountType = "fullMinusFlag";
delete data.fullRateFlag;
}
data.rangeTime = [];
data.rangeTime.push(new Date(data.startTime), new Date(data.endTime));
form.value = data;
});
}
function getCouponList(query) {
let params = {
pageSize: 20,
function getCouponList() {
const params = {
pageSize: 50,
pageNumber: 1,
getType: "ACTIVITY",
storeId: "",
couponName: query,
promotionStatus: "START",
};
couponLoading.value = true;
getPlatformCouponList(params).then((res) => {
couponLoading.value = false;
if (res.success) {
couponList.value = res.result.records;
if (res && res.success && res.result) {
couponList.value = res.result.records || [];
}
});
}
function getGiftList(query) {
let params = {
pageSize: 20,
function getGiftList() {
const params = {
pageSize: 50,
pageNumber: 1,
id: query === form.value.giftId ? form.value.giftId : null,
goodsName: query === form.value.giftId ? null : query,
marketEnable: "UPPER",
authFlag: "PASS"
authFlag: "PASS",
};
giftLoading.value = true;
getGoodsSkuData(params).then((res) => {
giftLoading.value = false;
if (res.success) {
giftList.value = res.result.records;
if (res && res.success && res.result) {
giftList.value = res.result.records || [];
}
});
}
function back() {
appStore.removeTag("full-discount-detail");
router.go(-1);
}
</script>
<style lang="scss" scoped>
h4 {
margin-bottom: 10px;
padding: 0 10px;
border: 1px solid #ddd;
background-color: #f8f8f8;
font-weight: bold;
color: #333;
font-size: 14px;
line-height: 40px;
text-align: left;
}
.describe {
font-size: 12px;
margin-left: 10px;
color: #999;
}
.link-text {
color: #409eff;
cursor: pointer;
text-decoration: none;
.full-discount-detail-page {
padding-bottom: 20px;
.header-card {
.page-header-row {
display: flex;
justify-content: space-between;
align-items: center;
.header-left {
.header-title {
display: flex;
align-items: center;
gap: 10px;
font-size: 16px;
font-weight: 600;
color: #303133;
.mode-tag {
font-weight: normal;
}
.activity-id {
font-size: 13px;
font-weight: normal;
color: #909399;
background-color: #f4f4f5;
padding: 2px 8px;
border-radius: 4px;
}
}
}
}
}
.info-card {
.section-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 16px;
.section-indicator {
width: 4px;
height: 16px;
border-radius: 2px;
background-color: #409eff;
}
.section-title {
font-size: 15px;
font-weight: 600;
color: #303133;
}
.section-badge {
font-size: 12px;
color: #909399;
margin-left: 6px;
}
}
.custom-descriptions {
:deep(.el-descriptions__label) {
width: 140px;
background-color: #fafafa;
color: #606266;
font-weight: 500;
}
:deep(.el-descriptions__content) {
color: #303133;
}
}
.money-highlight {
color: #f56c6c;
font-size: 14px;
}
.extra-rights-row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 10px;
}
.custom-table {
.table-goods-thumb {
display: block;
width: 44px;
height: 44px;
margin: 0 auto;
border-radius: 4px;
border: 1px solid #ebeef5;
}
.thumb-slot {
display: flex;
justify-content: center;
align-items: center;
font-size: 11px;
color: #c0c4cc;
background-color: #fafafa;
}
.price-val {
color: #f56c6c;
}
}
}
.footer-card {
.footer-actions {
display: flex;
justify-content: center;
align-items: center;
}
}
.font-mono {
font-family: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
}
.font-semibold {
font-weight: 600;
}
.text-dark {
color: #303133;
}
.text-secondary {
color: #909399;
}
.ml_10 {
margin-left: 10px;
}
}
</style>

View File

@@ -1,103 +1,196 @@
<template>
<div>
<el-card v-loading="detailLoading">
<el-form ref="formRef" :model="form" label-width="130px" :rules="onlyView ? {} : formRule">
<div class="base-info-item">
<h4>{{ pageTitle }}</h4>
<div class="form-item-view gift-card-activity-form">
<el-form-item label="礼品卡名称" prop="giftCardName">
<el-input
v-model="form.giftCardName"
type="text"
placeholder="请输入礼品卡名称"
clearable
maxlength="64"
show-word-limit
style="width: 360px"
:disabled="onlyView"
/>
</el-form-item>
<el-form-item label="礼品卡类型" prop="cardType" class="gift-card-type-form-item">
<el-radio-group v-model="form.cardType" class="gift-card-type-group">
<el-radio value="CASH" :disabled="onlyView">现金卡</el-radio>
<el-radio value="PICKUP" disabled>提货卡</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="面值" prop="faceValue">
<el-input
v-model="form.faceValue"
type="text"
placeholder="面值"
clearable
style="width: 200px"
:disabled="onlyView"
/>
</el-form-item>
<el-form-item label="总库存" prop="stockTotal">
<el-input-number
v-model="form.stockTotal"
:min="1"
:precision="0"
placeholder="最多可制发凭证张数"
style="width: 200px"
:disabled="onlyView"
/>
</el-form-item>
<el-form-item prop="validityType" :label-width="0" class="gift-card-validity-form-item">
<div class="gift-card-validity-outer">
<div class="gift-card-validity-label-col">
<span v-if="!onlyView" class="gift-card-validity-required">*</span>
<span>有效期</span>
</div>
<div class="gift-card-validity-radios">
<el-radio-group v-model="form.validityType" class="gift-card-validity-group">
<el-radio value="LONG_TERM" class="gift-card-validity-row" :disabled="onlyView"
>长期有效</el-radio
>
<el-radio value="AFTER_ACTIVATE_MONTHS" class="gift-card-validity-row" :disabled="onlyView">
<span>自激活后</span>
<el-input-number
v-model="form.validMonthsAfterActivate"
:min="1"
:max="99"
:precision="0"
placeholder="请输入1-99的整数"
:disabled="onlyView || form.validityType !== 'AFTER_ACTIVATE_MONTHS'"
class="gift-card-validity-input-gap"
/>
<span>月内有效</span>
</el-radio>
<el-radio value="FIXED_UNTIL" class="gift-card-validity-row" :disabled="onlyView">
<span>指定有效期至</span>
<el-date-picker
v-model="form.validEndTime"
type="datetime"
format="YYYY-MM-DD HH:mm:ss"
value-format="YYYY-MM-DD HH:mm:ss"
placeholder="选择结束时间"
clearable
:disabled="onlyView || form.validityType !== 'FIXED_UNTIL'"
class="gift-card-validity-date-gap"
style="width: 220px"
/>
</el-radio>
</el-radio-group>
</div>
</div>
</el-form-item>
</div>
<div class="foot-btn">
<el-button style="margin-right: 8px" @click="closeCurrentPage">返回</el-button>
<el-button
v-if="!onlyView"
type="primary"
:loading="submitLoading"
@click="handleSubmit"
>提交</el-button
<div class="gift-card-activity-page">
<!-- 顶部标题与返回栏 -->
<el-card shadow="never" class="header-card">
<div class="page-header-row">
<div class="header-left">
<div class="header-title">
<span>{{ pageTitle }}</span>
<el-tag
size="small"
effect="plain"
:type="onlyView ? 'info' : (isEditMode ? 'warning' : 'primary')"
class="mode-tag"
>
{{ onlyView ? "查看模式" : (isEditMode ? "编辑模式" : "新建模式") }}
</el-tag>
<el-tag
v-if="form.cardType"
size="small"
effect="dark"
type="primary"
class="type-tag"
>
{{ form.cardType === "CASH" ? "现金卡" : "提货卡" }}
</el-tag>
<span v-if="route.query.id" class="activity-id font-mono">
ID: {{ route.query.id }}
</span>
</div>
</div>
</el-form>
<div class="header-right">
<el-button @click="closeCurrentPage">返回礼品卡列表</el-button>
</div>
</div>
</el-card>
<!-- 礼品卡基础信息卡片 -->
<el-card shadow="never" v-loading="detailLoading" class="info-card mt_10">
<div class="section-header">
<span class="section-indicator"></span>
<span class="section-title">礼品卡配置信息</span>
</div>
<!-- 查看模式:以结构化描述列表呈现 -->
<template v-if="onlyView">
<el-descriptions :column="2" border class="custom-descriptions">
<el-descriptions-item label="礼品卡名称">
<span class="font-semibold text-dark">{{ form.giftCardName || "-" }}</span>
</el-descriptions-item>
<el-descriptions-item label="礼品卡类型">
<el-tag size="small" effect="plain" type="primary">
{{ form.cardType === "CASH" ? "现金卡" : "提货卡" }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="礼品卡面值">
<span class="price-highlight font-mono font-semibold">
{{ form.faceValue ? $filters.unitPrice(form.faceValue, "¥") : "-" }}
</span>
</el-descriptions-item>
<el-descriptions-item label="总库存">
<span class="font-mono text-dark font-semibold">
{{ form.stockTotal != null ? `${form.stockTotal} 张` : "-" }}
</span>
<span class="hint-inline">(最多可制发凭证张数)</span>
</el-descriptions-item>
<el-descriptions-item label="有效期限" :span="2">
<span class="font-mono text-dark">{{ validityDisplayText }}</span>
</el-descriptions-item>
<el-descriptions-item v-if="activityDetail.createTime" label="创建时间">
<span class="font-mono text-secondary">{{ activityDetail.createTime }}</span>
</el-descriptions-item>
<el-descriptions-item v-if="route.query.id" label="活动ID">
<span class="font-mono text-secondary">{{ route.query.id }}</span>
</el-descriptions-item>
</el-descriptions>
</template>
<!-- 编辑 / 新增模式:表单输入 -->
<template v-else>
<el-form
ref="formRef"
:model="form"
label-width="120px"
:rules="formRule"
class="edit-form"
>
<el-form-item label="礼品卡名称" prop="giftCardName">
<el-input
v-model="form.giftCardName"
type="text"
placeholder="请输入礼品卡名称"
clearable
maxlength="64"
show-word-limit
style="width: 380px"
/>
</el-form-item>
<el-form-item label="礼品卡类型" prop="cardType">
<el-radio-group v-model="form.cardType">
<el-radio value="CASH">现金卡</el-radio>
<el-radio value="PICKUP" disabled>提货卡(暂未开放)</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="面值" prop="faceValue">
<el-input
v-model="form.faceValue"
type="number"
placeholder="请填写面值金额"
clearable
style="width: 260px"
>
<template #append>元</template>
</el-input>
</el-form-item>
<el-form-item label="总库存" prop="stockTotal">
<el-input-number
v-model="form.stockTotal"
:min="1"
:precision="0"
placeholder="最多可制发凭证张数"
style="width: 220px"
/>
<span class="input-tip-text">张(最多可制发凭证张数)</span>
</el-form-item>
<el-form-item label="有效期限" required class="validity-form-item">
<el-radio-group v-model="form.validityType" class="validity-radio-group">
<div class="validity-row">
<el-radio value="LONG_TERM">长期有效</el-radio>
</div>
<div class="validity-row">
<el-radio value="AFTER_ACTIVATE_MONTHS">
<span class="radio-inline-text">自激活后</span>
<el-input-number
v-model="form.validMonthsAfterActivate"
:min="1"
:max="99"
:precision="0"
placeholder="1-99"
:disabled="form.validityType !== 'AFTER_ACTIVATE_MONTHS'"
class="validity-input-num"
/>
<span class="radio-inline-text">个月内有效</span>
</el-radio>
</div>
<div class="validity-row">
<el-radio value="FIXED_UNTIL">
<span class="radio-inline-text">指定有效期至</span>
<el-date-picker
v-model="form.validEndTime"
type="datetime"
format="YYYY-MM-DD HH:mm:ss"
value-format="YYYY-MM-DD HH:mm:ss"
placeholder="选择有效截止时间"
clearable
:disabled="form.validityType !== 'FIXED_UNTIL'"
class="validity-date-picker"
style="width: 220px"
/>
</el-radio>
</div>
</el-radio-group>
</el-form-item>
</el-form>
</template>
</el-card>
<!-- 底部操作栏 -->
<el-card shadow="never" class="footer-card mt_10">
<div class="footer-actions">
<el-button @click="closeCurrentPage">
{{ onlyView ? "返回" : "取消" }}
</el-button>
<el-button
v-if="!onlyView"
type="primary"
:loading="submitLoading"
@click="handleSubmit"
>
{{ isEditMode ? "保存修改" : "提交创建" }}
</el-button>
</div>
</el-card>
</div>
</template>
@@ -121,6 +214,56 @@ const route = useRoute();
const router = useRouter();
const appStore = useAppStore();
const detailLoading = ref(false);
const submitLoading = ref(false);
const activityDetail = ref({});
const form = reactive({
giftCardName: "",
cardType: "CASH",
faceValue: "",
stockTotal: null,
validityType: "LONG_TERM",
validEndTime: null,
validMonthsAfterActivate: null,
});
const formRef = ref(null);
const onlyView = computed(() => {
const q = route.query.onlyView;
return q === "true" || q === true;
});
const isEditMode = computed(() => {
return !!route.query.id && !onlyView.value;
});
const pageTitle = computed(() => {
if (onlyView.value) {
return "查看礼品卡";
}
if (isEditMode.value) {
return "编辑礼品卡";
}
return "添加礼品卡";
});
const validityDisplayText = computed(() => {
const type = String(form.validityType || "").toUpperCase();
if (type === "LONG_TERM") {
return "长期有效";
}
if (type === "AFTER_ACTIVATE_MONTHS") {
const m = form.validMonthsAfterActivate;
return m != null && m !== "" ? `自激活后 ${m} 个月内有效` : "自激活后指定月数有效";
}
if (type === "FIXED_UNTIL") {
return form.validEndTime ? `指定有效期至 ${form.validEndTime}` : "指定时间有效";
}
return "-";
});
const checkFaceValue = (rule, value, callback) => {
if (!value && value !== 0) {
return callback(new Error("面额不能为空"));
@@ -135,6 +278,7 @@ const checkFaceValue = (rule, value, callback) => {
callback();
}
};
const validateStock = (rule, value, callback) => {
if (value === null || value === undefined || value === "") {
callback(new Error("请填写总库存"));
@@ -147,17 +291,6 @@ const validateStock = (rule, value, callback) => {
callback();
};
const detailLoading = ref(false);
const submitLoading = ref(false);
const form = reactive({
giftCardName: "",
cardType: "CASH",
faceValue: "",
stockTotal: null,
validityType: "LONG_TERM",
validEndTime: null,
validMonthsAfterActivate: null,
});
const formRule = {
giftCardName: [
{ required: true, message: "活动名称不能为空", trigger: "blur" },
@@ -167,51 +300,6 @@ const formRule = {
stockTotal: [{ required: true, validator: validateStock, trigger: "blur" }],
};
const formRef = ref(null);
const onlyView = computed(() => {
const q = route.query.onlyView;
return q === "true" || q === true;
});
const isEditMode = computed(() => {
return !!route.query.id && !onlyView.value;
});
const pageTitle = computed(() => {
if (onlyView.value) {
return "查看礼品卡";
}
if (isEditMode.value) {
return "编辑礼品卡";
}
return "添加礼品卡";
});
onMounted(() => {
if (route.query.id) {
loadDetail(route.query.id);
}
});
watch(
() => form.validityType,
(v) => {
if (v !== "FIXED_UNTIL") {
form.validEndTime = null;
}
if (v !== "AFTER_ACTIVATE_MONTHS") {
form.validMonthsAfterActivate = null;
}
}
);
watch(
() => route.query.id,
(id) => {
if (id) {
loadDetail(id);
}
}
);
const parseDetailDate = (val) => {
if (!val) {
return null;
@@ -222,14 +310,15 @@ const parseDetailDate = (val) => {
const d = new Date(val);
return Number.isNaN(d.getTime()) ? null : d;
};
const loadDetail = (id) => {
detailLoading.value = true;
getGiftCardCashActivity(id)
.then((res) => {
detailLoading.value = false;
if (!res.success || !res.result) {
return;
}
activityDetail.value = res.result;
const d = res.result;
form.giftCardName = d.giftCardName || "";
form.cardType = d.cardType || "CASH";
@@ -247,18 +336,26 @@ const loadDetail = (id) => {
? d.validMonthsAfterActivate
: null;
})
.catch(() => {
.finally(() => {
detailLoading.value = false;
});
};
const closeCurrentPage = () => {
const name = route.name;
if (name) {
if (name && appStore?.removeTag) {
appStore.removeTag(name);
}
localStorage.pageOpenedList = JSON.stringify(appStore.pageOpenedList);
router.go(-1);
if (localStorage?.pageOpenedList && appStore?.pageOpenedList) {
localStorage.pageOpenedList = JSON.stringify(appStore.pageOpenedList);
}
if (window.history.length > 1) {
router.go(-1);
} else {
router.push({ name: "manager-gift-card-cash" });
}
};
const validateByValidityType = () => {
const t = form.validityType;
if (t === "FIXED_UNTIL") {
@@ -276,6 +373,7 @@ const validateByValidityType = () => {
}
return true;
};
const buildPayload = () => {
const payload = {
giftCardName: (form.giftCardName || "").trim(),
@@ -295,6 +393,7 @@ const buildPayload = () => {
}
return payload;
};
const handleSubmit = () => {
if (onlyView.value) {
return;
@@ -312,97 +411,222 @@ const handleSubmit = () => {
: saveGiftCardCashActivity(buildPayload());
req
.then((res) => {
submitLoading.value = false;
if (res.success) {
Message.success(isEditMode.value ? "礼品卡活动已更新" : "礼品卡活动创建成功");
closeCurrentPage();
}
})
.catch(() => {
.finally(() => {
submitLoading.value = false;
});
});
};
watch(
() => form.validityType,
(v) => {
if (v !== "FIXED_UNTIL") {
form.validEndTime = null;
}
if (v !== "AFTER_ACTIVATE_MONTHS") {
form.validMonthsAfterActivate = null;
}
}
);
watch(
() => route.query.id,
(id) => {
if (id) {
loadDetail(id);
}
}
);
onMounted(() => {
if (route.query.id) {
loadDetail(route.query.id);
}
});
</script>
<style scoped>
.gift-card-activity-form :deep(.el-form-item) {
margin-bottom: 26px;
}
.gift-card-type-form-item :deep(.el-form-item__label) {
line-height: 32px;
padding-top: 1px;
}
.gift-card-type-form-item :deep(.el-form-item__content) {
display: flex;
align-items: center;
min-height: 32px;
}
.gift-card-type-group {
gap: 20px;
}
.gift-card-validity-form-item :deep(.el-form-item__content) {
margin-left: 0 !important;
}
.gift-card-validity-outer {
display: flex;
align-items: flex-start;
width: 100%;
}
.gift-card-validity-label-col {
flex: 0 0 130px;
width: 130px;
padding-right: 12px;
<style lang="scss" scoped>
.gift-card-activity-page {
padding: 16px 20px;
background-color: #f0f2f5;
min-height: 100vh;
box-sizing: border-box;
text-align: right;
line-height: 32px;
min-height: 32px;
display: flex;
align-items: center;
justify-content: flex-end;
padding-top: 1px;
}
.gift-card-validity-required {
color: #ed4014;
margin-right: 4px;
font-family: SimSun, serif;
}
.gift-card-validity-radios {
flex: 1;
min-width: 0;
padding-top: 1px;
}
.gift-card-validity-group {
display: flex;
flex-direction: column;
align-items: flex-start;
width: 100%;
padding-top: 0;
padding-bottom: 0;
}
.gift-card-validity-group :deep(.el-radio) {
display: inline-flex;
align-items: center;
margin-right: 0;
margin-bottom: 10px;
line-height: 32px;
min-height: 32px;
height: auto;
}
.gift-card-validity-group :deep(.el-radio:last-child) {
margin-bottom: 0;
}
.gift-card-validity-group :deep(.el-radio__label) {
display: inline-flex;
align-items: center;
flex-wrap: wrap;
line-height: 32px;
}
.gift-card-validity-input-gap {
width: 168px;
margin: 0 10px;
}
.gift-card-validity-date-gap {
margin-left: 10px;
.mt_10 {
margin-top: 12px;
}
.font-mono {
font-family: Monaco, Menlo, Consolas, "Courier New", monospace;
}
.font-semibold {
font-weight: 600;
}
.text-dark {
color: #303133;
}
.text-secondary {
color: #606266;
}
.header-card {
border: none;
border-radius: 4px;
:deep(.el-card__body) {
padding: 16px 20px;
}
.page-header-row {
display: flex;
align-items: center;
justify-content: space-between;
.header-left {
.header-title {
display: flex;
align-items: center;
gap: 12px;
font-size: 18px;
font-weight: 600;
color: #1f2d3d;
.mode-tag,
.type-tag {
font-weight: normal;
}
.activity-id {
font-size: 13px;
color: #909399;
font-weight: normal;
}
}
}
}
}
.info-card {
border: none;
border-radius: 4px;
:deep(.el-card__body) {
padding: 20px;
}
.section-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 16px;
.section-indicator {
width: 4px;
height: 16px;
background-color: #409eff;
border-radius: 2px;
}
.section-title {
font-size: 15px;
font-weight: 600;
color: #303133;
}
}
.custom-descriptions {
:deep(.el-descriptions__label) {
width: 130px;
background-color: #f8f9fb;
color: #606266;
font-weight: 500;
}
:deep(.el-descriptions__content) {
color: #303133;
}
.price-highlight {
color: #f56c6c;
font-size: 16px;
}
.hint-inline {
font-size: 12px;
color: #909399;
margin-left: 6px;
}
}
.edit-form {
padding: 8px 12px;
.input-tip-text {
font-size: 13px;
color: #909399;
margin-left: 10px;
}
.validity-radio-group {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 12px;
width: 100%;
.validity-row {
display: flex;
align-items: center;
:deep(.el-radio) {
margin-right: 0;
display: inline-flex;
align-items: center;
}
:deep(.el-radio__label) {
display: inline-flex;
align-items: center;
}
.radio-inline-text {
font-size: 14px;
}
.validity-input-num {
width: 130px;
margin: 0 10px;
}
.validity-date-picker {
margin-left: 10px;
}
}
}
}
}
.footer-card {
border: none;
border-radius: 4px;
:deep(.el-card__body) {
padding: 16px 20px;
}
.footer-actions {
display: flex;
justify-content: center;
gap: 16px;
}
}
}
</style>

View File

@@ -1,109 +1,260 @@
<template>
<div>
<el-card>
<el-form ref="formRef" :model="form" label-width="120px" :rules="formRule">
<div class="base-info-item">
<div class="form-item-view">
<h4>商品信息</h4>
<el-form-item label="选择商品" prop="promotionGoodsList">
<el-button type="primary" @click="openSkuList">选择商品</el-button>
</el-form-item>
<el-form-item style="width: 100%">
<el-table
border
class="kanjia-goods-table"
:data="form.promotionGoodsList"
style="width: 100%"
@selection-change="changeSelect"
>
<el-table-column type="selection" width="60" align="center" />
<el-table-column prop="goodsName" label="商品名称" min-width="100" show-overflow-tooltip />
<el-table-column label="商品价格" width="150">
<template #default="{ row }">
<span v-if="row" :style="{ color: $mainColor }">
{{ $filters.unitPrice(row.price, "¥") }}</span>
</template>
</el-table-column>
<el-table-column label="库存" width="120">
<template #default="{ row }">
<span v-if="row">{{ row.quantity }}</span>
</template>
</el-table-column>
<el-table-column label="结算价格" width="160">
<template #default="{ $index }">
<el-input-number
:min="0"
v-model="form.promotionGoodsList[$index].settlementPrice"
controls-position="right"
class="kanjia-goods-input"
/>
</template>
</el-table-column>
<el-table-column label="最低砍" width="160">
<template #default="{ $index }">
<el-input-number
:min="0"
v-model="form.promotionGoodsList[$index].lowestPrice"
controls-position="right"
class="kanjia-goods-input"
/>
</template>
</el-table-column>
<el-table-column label="最高砍" width="160">
<template #default="{ $index }">
<el-input-number
:min="0"
v-model="form.promotionGoodsList[$index].highestPrice"
controls-position="right"
class="kanjia-goods-input"
/>
</template>
</el-table-column>
<el-table-column label="活动库存" width="160">
<template #default="{ $index }">
<el-input-number
:min="0"
v-model="form.promotionGoodsList[$index].stock"
controls-position="right"
class="kanjia-goods-input"
/>
</template>
</el-table-column>
<el-table-column label="操作" width="120" align="center">
<template #default="{ $index }">
<el-button type="danger" size="small" plain @click="delGoods($index)">删除</el-button>
</template>
</el-table-column>
</el-table>
</el-form-item>
<el-form-item label="活动时间" prop="rangeTime" class="kanjia-activity-time-form-item">
<el-date-picker
type="datetimerange"
v-model="form.rangeTime"
format="YYYY-MM-DD HH:mm:ss"
start-placeholder="开始时间"
end-placeholder="结束时间"
placeholder="请选择"
:disabled-date="options.disabledDate"
/>
</el-form-item>
<div>
<el-button link @click="closeCurrentPage">返回</el-button>
<el-button type="primary" :loading="submitLoading" @click="handleSubmit">提交</el-button>
</div>
<div class="kanjia-add-page">
<!-- 顶部标题与返回栏 -->
<el-card shadow="never" class="header-card">
<div class="page-header-row">
<div class="header-left">
<div class="header-title">
<span>添加砍价活动商品</span>
<el-tag size="small" effect="plain" type="primary" class="mode-tag">
新增活动
</el-tag>
</div>
</div>
</el-form>
<div class="header-right">
<el-button @click="closeCurrentPage">返回砍价列表</el-button>
</div>
</div>
</el-card>
<el-form
ref="formRef"
:model="form"
label-width="110px"
:rules="formRule"
class="main-form"
>
<!-- 活动时间设置卡片 -->
<el-card shadow="never" class="info-card mt_10">
<div class="section-header">
<span class="section-indicator"></span>
<span class="section-title">活动时间设置</span>
</div>
<el-form-item
label="活动时间"
prop="rangeTime"
class="kanjia-activity-time-form-item"
>
<el-date-picker
v-model="form.rangeTime"
type="datetimerange"
format="YYYY-MM-DD HH:mm:ss"
value-format="YYYY-MM-DD HH:mm:ss"
start-placeholder="开始时间"
end-placeholder="结束时间"
placeholder="请选择砍价活动有效时间范围"
:disabled-date="options.disabledDate"
style="width: 380px"
/>
<span class="time-tip-text">请设置砍价活动开展的时间段</span>
</el-form-item>
</el-card>
<!-- 砍价商品列表卡片 -->
<el-card shadow="never" class="info-card mt_10">
<div class="card-header-bar">
<div class="header-title-group">
<span class="section-indicator"></span>
<span class="section-title">砍价活动商品</span>
<span class="total-badge">已选 {{ form.promotionGoodsList.length }} 件商品</span>
</div>
<div class="header-actions">
<el-button
v-if="selectedGoods.length"
type="danger"
plain
size="small"
@click="delBatch"
>
批量删除 ({{ selectedGoods.length }})
</el-button>
<el-button
v-if="form.promotionGoodsList.length"
link
type="danger"
size="small"
@click="clearAllGoods"
>
清空商品
</el-button>
<el-button type="primary" size="small" @click="openSkuList">
选择商品
</el-button>
</div>
</div>
<el-form-item prop="promotionGoodsList" :label-width="0" class="table-form-item">
<el-table
border
stripe
class="kanjia-goods-table"
:data="form.promotionGoodsList"
style="width: 100%"
empty-text="暂未添加商品,请点击右上角【选择商品】按钮添加"
@selection-change="changeSelect"
>
<el-table-column type="selection" width="50" align="center" />
<!-- 商品图片 -->
<el-table-column label="商品图片" width="76" align="center">
<template #default="{ row }">
<div v-if="row && row.thumbnail" class="goods-thumb-box">
<el-image
:src="row.thumbnail"
fit="cover"
preview-teleported
:preview-src-list="[row.thumbnail]"
class="goods-thumb-img"
>
<template #error>
<div class="thumb-slot">无图</div>
</template>
</el-image>
</div>
<div v-else class="goods-thumb-box thumb-slot">无图</div>
</template>
</el-table-column>
<!-- 商品名称与SKU -->
<el-table-column
prop="goodsName"
label="商品名称"
min-width="180"
show-overflow-tooltip
>
<template #default="{ row }">
<div v-if="row">
<div class="font-semibold text-dark">{{ row.goodsName || "-" }}</div>
<div v-if="row.skuId" class="sku-sub-text font-mono">
SKU: {{ row.skuId }}
</div>
</div>
</template>
</el-table-column>
<!-- 所属商家 -->
<el-table-column
prop="storeName"
label="所属商家"
min-width="120"
show-overflow-tooltip
>
<template #default="{ row }">
<span class="text-secondary">{{ (row && row.storeName) || "-" }}</span>
</template>
</el-table-column>
<!-- 商品原价 -->
<el-table-column label="商品原价" width="110" align="right">
<template #default="{ row }">
<span v-if="row" class="font-mono text-dark">
{{ $filters.unitPrice(row.price || row.originalPrice, "¥") }}
</span>
</template>
</el-table-column>
<!-- 当前库存 -->
<el-table-column label="可用库存" width="90" align="right">
<template #default="{ row }">
<span v-if="row" class="font-mono text-dark">{{ row.quantity ?? "-" }}</span>
</template>
</el-table-column>
<!-- 结算价格 -->
<el-table-column label="结算价格(元)" width="140" align="center">
<template #default="{ $index }">
<el-input-number
v-model="form.promotionGoodsList[$index].settlementPrice"
:min="0"
:precision="2"
controls-position="right"
class="kanjia-goods-input"
placeholder="结算价"
/>
</template>
</el-table-column>
<!-- 最低砍 -->
<el-table-column label="最低可砍(元)" width="140" align="center">
<template #default="{ $index }">
<el-input-number
v-model="form.promotionGoodsList[$index].lowestPrice"
:min="0"
:precision="2"
controls-position="right"
class="kanjia-goods-input"
placeholder="最低可砍"
/>
</template>
</el-table-column>
<!-- 最高砍 -->
<el-table-column label="最高可砍(元)" width="140" align="center">
<template #default="{ $index }">
<el-input-number
v-model="form.promotionGoodsList[$index].highestPrice"
:min="0"
:precision="2"
controls-position="right"
class="kanjia-goods-input"
placeholder="最高可砍"
/>
</template>
</el-table-column>
<!-- 活动库存 -->
<el-table-column label="活动库存(件)" width="130" align="center">
<template #default="{ $index }">
<el-input-number
v-model="form.promotionGoodsList[$index].stock"
:min="1"
:precision="0"
controls-position="right"
class="kanjia-goods-input"
placeholder="活动库存"
/>
</template>
</el-table-column>
<!-- 操作 -->
<el-table-column label="操作" width="80" align="center" fixed="right">
<template #default="{ $index }">
<el-button
type="danger"
link
@click="delGoods($index)"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
</el-form-item>
</el-card>
<!-- 底部操作栏 -->
<el-card shadow="never" class="footer-card mt_10">
<div class="footer-actions">
<el-button @click="closeCurrentPage">取消</el-button>
<el-button
type="primary"
:loading="submitLoading"
@click="handleSubmit"
>
提交创建
</el-button>
</div>
</el-card>
</el-form>
<sku-select ref="skuSelectRef" @selectedGoodsData="selectedGoodsData"></sku-select>
</div>
</template>
<script setup>
import { nextTick, onMounted, reactive, ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import { nextTick, reactive, ref } from "vue";
import { useRouter } from "vue-router";
import { saveKanJiaActivityGoods } from "@/api/promotion";
import { regular } from "@/utils";
import skuSelect from "@/components/lili-dialog";
@@ -114,19 +265,17 @@ import { useAppStore } from "@/stores/app";
defineOptions({ name: "add-kanjia-activity-goods" });
const route = useRoute();
const router = useRouter();
const appStore = useAppStore();
const modalType = ref(0);
const form = reactive({
promotionGoodsList: [],
rangeTime: [],
});
const id = ref(route.query.id);
const submitLoading = ref(false);
const selectedGoods = ref([]);
const promotionGoodsList = ref([]);
const formRule = {
promotionGoodsList: [
{
@@ -146,6 +295,7 @@ const formRule = {
},
],
};
const options = {
disabledDate(date) {
return date && date.valueOf() < Date.now() - 86400000;
@@ -155,14 +305,6 @@ const options = {
const formRef = ref(null);
const skuSelectRef = ref(null);
onMounted(async () => {
if (id.value) {
// 历史遗留:原代码调用了未定义的 getCoupon 方法,此处原样保留
getCoupon();
modalType.value = 1;
}
});
function handleSubmit() {
formRef.value.validate((valid) => {
if (!valid) {
@@ -182,18 +324,19 @@ function handleSubmit() {
delete params.rangeTime;
let checkResult = true;
form.promotionGoodsList.forEach((res) => {
for (let i = 0; i < form.promotionGoodsList.length; i++) {
const res = form.promotionGoodsList[i];
const eCoupon = isECoupon(res.goodsType);
if (res.stock <= 0 || res.stock > res.quantity) {
checkResult = false;
ElMessage.error("活动库存不能为0且不能超过商品库存");
return;
ElMessage.error(`商品【${res.goodsName}】活动库存不能为0且不能超过可用库存`);
break;
}
if (!regular.money.test(res.settlementPrice)) {
checkResult = false;
ElMessage.error("结算价格金额格式不正确");
return;
ElMessage.error(`商品【${res.goodsName}】结算价格金额格式不正确`);
break;
}
if (
(eCoupon ? res.settlementPrice < 0 : res.settlementPrice <= 0) ||
@@ -202,63 +345,78 @@ function handleSubmit() {
checkResult = false;
ElMessage.error(
eCoupon
? "结算价格不能小于0且不能超过商品价格"
: "结算价格金额不能为0且不能超过商品价格"
? `商品【${res.goodsName}】结算价格不能小于0且不能超过商品原价`
: `商品【${res.goodsName}】结算价格不能为0且不能超过商品原价`
);
return;
break;
}
if (!regular.money.test(res.highestPrice)) {
checkResult = false;
ElMessage.error("最高可砍金额格式错误");
return;
ElMessage.error(`商品【${res.goodsName}】最高可砍金额格式错误`);
break;
}
if (res.highestPrice <= 0 || res.highestPrice > res.price) {
checkResult = false;
ElMessage.error("最高可砍金额不能为0且不能超过商品价格");
return;
ElMessage.error(`商品【${res.goodsName}】最高可砍金额不能为0且不能超过商品原价`);
break;
}
if (!regular.money.test(res.lowestPrice)) {
checkResult = false;
ElMessage.error("最低可砍金额格式错误");
return;
ElMessage.error(`商品【${res.goodsName}】最低可砍金额格式错误`);
break;
}
if (res.lowestPrice < 0 || (!eCoupon && res.lowestPrice <= 0) || res.lowestPrice > res.price) {
if (
res.lowestPrice < 0 ||
(!eCoupon && res.lowestPrice <= 0) ||
res.lowestPrice > res.price
) {
checkResult = false;
ElMessage.error(eCoupon ? "最低可砍金额不能小于0" : "最低可砍金额不能为0");
return;
ElMessage.error(
eCoupon
? `商品【${res.goodsName}】最低可砍金额不能小于0`
: `商品【${res.goodsName}】最低可砍金额不能为0`
);
break;
}
if (parseInt(res.lowestPrice) > parseInt(res.highestPrice)) {
if (parseFloat(res.lowestPrice) > parseFloat(res.highestPrice)) {
checkResult = false;
ElMessage.error("最低砍价金额不能大于最高砍价金额");
return;
ElMessage.error(`商品【${res.goodsName}】最低砍价金额不能大于最高砍价金额`);
break;
}
});
}
if (!checkResult) {
return;
}
submitLoading.value = true;
saveKanJiaActivityGoods(params).then((res) => {
submitLoading.value = false;
if (res.success) {
ElMessage.success(
modalType.value === 1 ? "砍价活动修改成功" : "砍价活动添加成功"
);
closeCurrentPage();
}
});
saveKanJiaActivityGoods(params)
.then((res) => {
if (res && res.success) {
ElMessage.success("砍价活动添加成功");
closeCurrentPage();
}
})
.finally(() => {
submitLoading.value = false;
});
});
}
function closeCurrentPage() {
appStore.removeTag("add-kan-jia-goods");
localStorage.pageOpenedList = JSON.stringify(appStore.pageOpenedList);
if (appStore?.removeTag) {
appStore.removeTag("add-kanjia-activity-goods");
appStore.removeTag("add-kan-jia-goods");
}
if (localStorage?.pageOpenedList && appStore?.pageOpenedList) {
localStorage.pageOpenedList = JSON.stringify(appStore.pageOpenedList);
}
router.go(-1);
}
function openSkuList() {
skuSelectRef.value.open("goods");
let data = JSON.parse(JSON.stringify(form.promotionGoodsList));
const data = JSON.parse(JSON.stringify(form.promotionGoodsList));
data.forEach((e) => {
e.id = e.skuId;
});
@@ -266,7 +424,7 @@ function openSkuList() {
}
function changeSelect(e) {
selectedGoods.value = e;
selectedGoods.value = e || [];
}
function delGoods(index) {
@@ -274,17 +432,33 @@ function delGoods(index) {
touchPromotionGoodsValidate();
}
function delBatch() {
if (!selectedGoods.value.length) return;
const skuIds = new Set(selectedGoods.value.map((g) => g.skuId || g.id));
form.promotionGoodsList = form.promotionGoodsList.filter(
(g) => !skuIds.has(g.skuId || g.id)
);
selectedGoods.value = [];
touchPromotionGoodsValidate();
}
function clearAllGoods() {
form.promotionGoodsList = [];
selectedGoods.value = [];
touchPromotionGoodsValidate();
}
function touchPromotionGoodsValidate() {
nextTick(() => {
const form = formRef.value;
if (form && typeof form.validateField === "function") {
form.validateField("promotionGoodsList", () => {});
const f = formRef.value;
if (f && typeof f.validateField === "function") {
f.validateField("promotionGoodsList", () => {});
}
});
}
function selectedGoodsData(item) {
let list = [];
const list = [];
item.forEach((e) => {
list.push({
settlementPrice: e.settlementPrice || 0,
@@ -310,55 +484,203 @@ function selectedGoodsData(item) {
</script>
<style lang="scss" scoped>
.kanjia-goods-table {
:deep(.el-table__body .el-table__cell .cell) {
overflow: visible;
text-overflow: clip;
white-space: normal;
.kanjia-add-page {
padding: 16px 20px;
background-color: #f0f2f5;
min-height: 100vh;
box-sizing: border-box;
.mt_10 {
margin-top: 12px;
}
}
.kanjia-goods-input {
width: 100%;
}
.kanjia-activity-time-form-item {
:deep(.el-date-editor) {
width: 360px !important;
max-width: 360px;
flex-grow: 0;
.font-mono {
font-family: Monaco, Menlo, Consolas, "Courier New", monospace;
}
}
h4 {
margin-bottom: 10px;
padding: 0 10px;
border: 1px solid #ddd;
background-color: #f8f8f8;
font-weight: bold;
color: #333;
font-size: 14px;
line-height: 40px;
text-align: left;
}
.describe {
font-size: 12px;
margin-left: 10px;
color: #999;
}
.effectiveDays {
font-size: 12px;
color: #999;
> * {
margin: 0 4px;
.font-semibold {
font-weight: 600;
}
}
.tips {
font-size: 12px;
color: #999;
.text-dark {
color: #303133;
}
.text-secondary {
color: #606266;
}
.header-card {
border: none;
border-radius: 4px;
:deep(.el-card__body) {
padding: 16px 20px;
}
.page-header-row {
display: flex;
align-items: center;
justify-content: space-between;
.header-left {
.header-title {
display: flex;
align-items: center;
gap: 12px;
font-size: 18px;
font-weight: 600;
color: #1f2d3d;
.mode-tag {
font-weight: normal;
}
}
}
}
}
.main-form {
.table-form-item {
margin-bottom: 0;
:deep(.el-form-item__content) {
margin-left: 0 !important;
}
}
}
.info-card {
border: none;
border-radius: 4px;
:deep(.el-card__body) {
padding: 20px;
}
.section-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 16px;
.section-indicator {
width: 4px;
height: 16px;
background-color: #409eff;
border-radius: 2px;
}
.section-title {
font-size: 15px;
font-weight: 600;
color: #303133;
}
}
.time-tip-text {
font-size: 13px;
color: #909399;
margin-left: 12px;
}
.card-header-bar {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
.header-title-group {
display: flex;
align-items: center;
gap: 8px;
.section-indicator {
width: 4px;
height: 16px;
background-color: #409eff;
border-radius: 2px;
}
.section-title {
font-size: 15px;
font-weight: 600;
color: #303133;
}
.total-badge {
font-size: 13px;
color: #909399;
margin-left: 6px;
}
}
.header-actions {
display: flex;
align-items: center;
gap: 10px;
}
}
.kanjia-goods-table {
:deep(th.el-table__cell) {
background-color: #fafafa;
color: #303133;
font-weight: 600;
}
.goods-thumb-box {
width: 44px;
height: 44px;
border-radius: 4px;
overflow: hidden;
background-color: #f5f7fa;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid #ebeef5;
margin: 0 auto;
.goods-thumb-img {
width: 100%;
height: 100%;
display: block;
}
}
.thumb-slot {
font-size: 11px;
color: #c0c4cc;
display: flex;
align-items: center;
justify-content: center;
}
.sku-sub-text {
font-size: 12px;
color: #909399;
margin-top: 2px;
}
.kanjia-goods-input {
width: 100%;
}
}
}
.footer-card {
border: none;
border-radius: 4px;
:deep(.el-card__body) {
padding: 16px 20px;
}
.footer-actions {
display: flex;
justify-content: center;
gap: 16px;
}
}
}
</style>

View File

@@ -1,94 +1,238 @@
<template>
<div>
<el-card>
<el-form ref="formRef" :model="form" label-width="120px" :rules="formRule">
<div class="base-info-item">
<div class="form-item-view">
<h4>商品信息</h4>
<el-form-item label="商品名称">
<div>{{ form.goodsName }}</div>
</el-form-item>
<el-form-item label="SKU编码">
<div>{{ form.skuId }}</div>
</el-form-item>
<el-form-item label="店铺名称">
<div>{{ form.goodsSku.storeName }}</div>
</el-form-item>
<el-form-item label="商品价格">
<div>
<priceColorScheme :value="form.goodsSku.price" :color="$mainColor" />
</div>
</el-form-item>
<el-form-item label="商品库存">
<div>{{ form.goodsSku.quantity }}</div>
</el-form-item>
<el-form-item label="结算价格" prop="settlementPrice">
<el-input
:disabled="onlyView"
type="number"
v-model="form.settlementPrice"
placeholder="请填写结算价格"
clearable
style="width: 260px"
/>
</el-form-item>
<el-form-item label="最低可砍" prop="lowestPrice">
<el-input
:disabled="onlyView"
type="number"
v-model="form.lowestPrice"
placeholder="请填写最低可砍金额"
clearable
style="width: 260px"
/>
</el-form-item>
<el-form-item label="最高可砍" prop="highestPrice">
<el-input
:disabled="onlyView"
type="number"
v-model="form.highestPrice"
placeholder="请填写最高可砍金额"
clearable
style="width: 260px"
/>
</el-form-item>
<el-form-item label="活动库存" prop="stock">
<el-input
:disabled="onlyView"
type="number"
v-model="form.stock"
placeholder="请填写活动库存"
clearable
style="width: 260px"
/>
</el-form-item>
<el-form-item label="活动时间" prop="rangeTime" class="kanjia-activity-time-form-item">
<el-date-picker
:disabled="onlyView"
v-model="form.rangeTime"
type="datetimerange"
format="YYYY-MM-DD HH:mm:ss"
start-placeholder="开始时间"
end-placeholder="结束时间"
placeholder="请选择"
:disabled-date="options.disabledDate"
/>
</el-form-item>
<div>
<el-button link @click="closeCurrentPage">返回</el-button>
<el-button v-if="!onlyView" type="primary" :loading="submitLoading" @click="handleSubmit">
提交
</el-button>
</div>
<div class="kanjia-edit-page">
<!-- 顶部标题与返回栏 -->
<el-card shadow="never" class="header-card">
<div class="page-header-row">
<div class="header-left">
<div class="header-title">
<span>{{ onlyView ? "砍价活动商品详情" : "编辑砍价活动商品" }}</span>
<el-tag
size="small"
effect="plain"
:type="onlyView ? 'info' : 'warning'"
class="mode-tag"
>
{{ onlyView ? "查看模式" : "编辑模式" }}
</el-tag>
<el-tag
v-if="form.promotionStatus"
size="small"
effect="dark"
:type="promotionStatusTagType(form.promotionStatus)"
class="status-tag"
>
{{ promotionStatusText(form.promotionStatus) }}
</el-tag>
<span v-if="id" class="activity-id font-mono">ID: {{ id }}</span>
</div>
</div>
</el-form>
<div class="header-right">
<el-button @click="closeCurrentPage">返回砍价列表</el-button>
</div>
</div>
</el-card>
<!-- 商品基本信息卡片 -->
<el-card shadow="never" class="info-card mt_10">
<div class="section-header">
<span class="section-indicator"></span>
<span class="section-title">商品基本信息</span>
</div>
<el-descriptions :column="2" border class="custom-descriptions">
<el-descriptions-item label="商品图片" width="120px">
<div class="goods-thumb-box">
<el-image
v-if="goodsThumbnail"
:src="goodsThumbnail"
fit="cover"
preview-teleported
:preview-src-list="[goodsThumbnail]"
class="goods-thumb-img"
>
<template #error>
<div class="thumb-slot">无图</div>
</template>
</el-image>
<div v-else class="thumb-slot">无图</div>
</div>
</el-descriptions-item>
<el-descriptions-item label="商品名称">
<div class="font-semibold text-dark">{{ form.goodsName || "-" }}</div>
<div v-if="form.goodsId" class="sub-id-text font-mono">
商品ID: {{ form.goodsId }}
</div>
</el-descriptions-item>
<el-descriptions-item label="SKU编码">
<span class="font-mono text-dark">{{ form.skuId || "-" }}</span>
</el-descriptions-item>
<el-descriptions-item label="所属店铺">
<span class="text-secondary">{{ goodsStoreName }}</span>
</el-descriptions-item>
<el-descriptions-item label="商品原价">
<span class="original-price-val font-mono">
{{ $filters.unitPrice(goodsPrice, "¥") }}
</span>
</el-descriptions-item>
<el-descriptions-item label="商品库存">
<span class="font-mono text-dark">{{ goodsStock }} 件</span>
</el-descriptions-item>
</el-descriptions>
</el-card>
<!-- 砍价活动配置区域 -->
<el-card shadow="never" class="info-card mt_10">
<div class="section-header">
<span class="section-indicator"></span>
<span class="section-title">砍价规则与参数</span>
</div>
<!-- 查看模式:以结构化描述列表呈现 -->
<template v-if="onlyView">
<el-descriptions :column="2" border class="custom-descriptions">
<el-descriptions-item label="结算价格">
<span class="price-highlight font-mono font-semibold">
{{ $filters.unitPrice(form.settlementPrice, "¥") }}
</span>
</el-descriptions-item>
<el-descriptions-item label="活动库存">
<span class="font-mono text-dark font-semibold">
{{ form.stock != null ? `${form.stock} 件` : "-" }}
</span>
</el-descriptions-item>
<el-descriptions-item label="最低可砍金额">
<span class="font-mono text-dark">
{{ $filters.unitPrice(form.lowestPrice, "¥") }}
</span>
</el-descriptions-item>
<el-descriptions-item label="最高可砍金额">
<span class="font-mono text-dark">
{{ $filters.unitPrice(form.highestPrice, "¥") }}
</span>
</el-descriptions-item>
<el-descriptions-item
v-if="form.purchasePrice != null"
label="最低购买金额"
>
<span class="font-mono text-dark">
{{ $filters.unitPrice(form.purchasePrice, "¥") }}
</span>
</el-descriptions-item>
<el-descriptions-item label="活动时间" :span="form.purchasePrice != null ? 1 : 2">
<span class="font-mono text-dark">
{{ activityTimeRange }}
</span>
</el-descriptions-item>
</el-descriptions>
</template>
<!-- 编辑模式:表单输入 -->
<template v-else>
<el-form
ref="formRef"
:model="form"
label-width="120px"
:rules="formRule"
class="edit-form"
>
<el-form-item label="结算价格" prop="settlementPrice">
<el-input
v-model="form.settlementPrice"
type="number"
placeholder="请填写结算价格"
clearable
style="width: 280px"
>
<template #append>元</template>
</el-input>
</el-form-item>
<el-form-item label="最低可砍" prop="lowestPrice">
<el-input
v-model="form.lowestPrice"
type="number"
placeholder="请填写最低可砍金额"
clearable
style="width: 280px"
>
<template #append>元</template>
</el-input>
</el-form-item>
<el-form-item label="最高可砍" prop="highestPrice">
<el-input
v-model="form.highestPrice"
type="number"
placeholder="请填写最高可砍金额"
clearable
style="width: 280px"
>
<template #append>元</template>
</el-input>
</el-form-item>
<el-form-item label="活动库存" prop="stock">
<el-input
v-model="form.stock"
type="number"
placeholder="请填写活动库存"
clearable
style="width: 280px"
>
<template #append>件</template>
</el-input>
</el-form-item>
<el-form-item
label="活动时间"
prop="rangeTime"
class="kanjia-activity-time-form-item"
>
<el-date-picker
v-model="form.rangeTime"
type="datetimerange"
format="YYYY-MM-DD HH:mm:ss"
start-placeholder="开始时间"
end-placeholder="结束时间"
placeholder="请选择活动时间"
:disabled-date="options.disabledDate"
/>
</el-form-item>
</el-form>
</template>
</el-card>
<!-- 底部操作栏 -->
<el-card shadow="never" class="footer-card mt_10">
<div class="footer-actions">
<el-button @click="closeCurrentPage">
{{ onlyView ? "返回" : "取消" }}
</el-button>
<el-button
v-if="!onlyView"
type="primary"
:loading="submitLoading"
@click="handleSubmit"
>
保存修改
</el-button>
</div>
</el-card>
</div>
</template>
<script setup>
import { ref, onMounted } from "vue";
import { ref, onMounted, computed } from "vue";
import { useRoute, useRouter } from "vue-router";
import { getKanJiaActivityGoodsById, editKanJiaActivityGoods } from "@/api/promotion";
import { regular } from "@/utils";
@@ -103,6 +247,66 @@ const route = useRoute();
const router = useRouter();
const appStore = useAppStore();
const id = computed(() => route.query.id || "");
const onlyView = computed(() => {
return route.query.onlyView === "true" || route.query.onlyView === true;
});
const submitLoading = ref(false);
const form = ref({
purchasePrice: 0,
goodsSku: {},
rangeTime: [],
});
const goodsThumbnail = computed(() => {
return form.value.thumbnail || form.value.goodsSku?.thumbnail || "";
});
const goodsStoreName = computed(() => {
return form.value.goodsSku?.storeName || form.value.storeName || "-";
});
const goodsPrice = computed(() => {
if (form.value.goodsSku?.price != null) return form.value.goodsSku.price;
if (form.value.originalPrice != null) return form.value.originalPrice;
return 0;
});
const goodsStock = computed(() => {
if (form.value.goodsSku?.quantity != null) return form.value.goodsSku.quantity;
return "-";
});
const activityTimeRange = computed(() => {
const start = form.value.startTime || (form.value.rangeTime && form.value.rangeTime[0]);
const end = form.value.endTime || (form.value.rangeTime && form.value.rangeTime[1]);
if (start && end) {
return `${start} 至 ${end}`;
}
return start || end || "-";
});
function promotionStatusText(status) {
const map = {
NEW: "未开始",
START: "已开始",
END: "已结束",
CLOSE: "已关闭",
};
return map[status] || status;
}
function promotionStatusTagType(status) {
const map = {
NEW: "info",
START: "success",
END: "danger",
CLOSE: "danger",
};
return map[status] || "info";
}
const checkSettlementPrice = (rule, value, callback) => {
if (!value && value !== 0) {
return callback(new Error("结算金额不能为空"));
@@ -115,6 +319,7 @@ const checkSettlementPrice = (rule, value, callback) => {
callback();
}
};
const checkLowestPrice = (rule, value, callback) => {
if (!value && value !== 0) {
return callback(new Error("最低可砍金额不能为空"));
@@ -127,6 +332,7 @@ const checkLowestPrice = (rule, value, callback) => {
callback();
}
};
const checkHighestPrice = (rule, value, callback) => {
if (!value && value !== 0) {
return callback(new Error("最高可砍金额不能为空"));
@@ -140,15 +346,6 @@ const checkHighestPrice = (rule, value, callback) => {
}
};
const modalType = ref(0);
const form = ref({
purchasePrice: 0,
goodsSku: {},
rangeTime: [],
});
const id = route.query.id;
const onlyView = route.query.onlyView;
const submitLoading = ref(false);
const formRule = {
settlementPrice: [
{ required: true, message: "请输入结算金额" },
@@ -172,6 +369,7 @@ const formRule = {
],
stock: [{ required: true, message: "请输入活动库存" }],
};
const options = {
disabledDate(date) {
return date && date.valueOf() < Date.now() - 86400000;
@@ -180,17 +378,21 @@ const options = {
const formRef = ref(null);
onMounted(async () => {
if (id) {
onMounted(() => {
if (id.value) {
getKanJiaActivityGoods();
modalType.value = 1;
}
});
function getKanJiaActivityGoods() {
getKanJiaActivityGoodsById(id).then((res) => {
form.value = res.result;
form.value.rangeTime = [res.result.startTime, res.result.endTime];
getKanJiaActivityGoodsById(id.value).then((res) => {
if (res && res.success && res.result) {
form.value = {
...res.result,
goodsSku: res.result.goodsSku || {},
rangeTime: [res.result.startTime, res.result.endTime],
};
}
});
}
@@ -213,7 +415,7 @@ function handleSubmit() {
const eCoupon = isECoupon(params.goodsType || params.goodsSku?.goodsType);
if (params.stock <= 0 || params.stock > params.goodsSku.quantity) {
if (params.stock <= 0 || (params.goodsSku?.quantity != null && params.stock > params.goodsSku.quantity)) {
ElMessage.error("活动库存不能为0且不能超过商品库存");
return;
}
@@ -223,7 +425,7 @@ function handleSubmit() {
}
if (
(eCoupon ? params.settlementPrice < 0 : params.settlementPrice <= 0) ||
params.settlementPrice > params.price
params.settlementPrice > (params.price || goodsPrice.value)
) {
ElMessage.error(
eCoupon
@@ -236,7 +438,7 @@ function handleSubmit() {
ElMessage.error("最高可砍金额格式错误");
return;
}
if (params.highestPrice <= 0 || params.highestPrice > params.price) {
if (params.highestPrice <= 0 || params.highestPrice > (params.price || goodsPrice.value)) {
ElMessage.error("最高可砍金额不能为0且不能超过商品价格");
return;
}
@@ -247,52 +449,212 @@ function handleSubmit() {
if (
params.lowestPrice < 0 ||
(!eCoupon && params.lowestPrice <= 0) ||
params.lowestPrice > params.price
params.lowestPrice > (params.price || goodsPrice.value)
) {
ElMessage.error(eCoupon ? "最低可砍金额不能小于0" : "最低可砍金额不能为0");
return;
}
if (params.lowestPrice > params.highestPrice) {
if (parseFloat(params.lowestPrice) > parseFloat(params.highestPrice)) {
ElMessage.error("最低砍价金额不能大于最高砍价金额");
return;
}
submitLoading.value = true;
editKanJiaActivityGoods(params).then((res) => {
submitLoading.value = false;
if (res.success) {
ElMessage.success("砍价活动修改成功");
closeCurrentPage();
}
});
editKanJiaActivityGoods(params)
.then((res) => {
if (res && res.success) {
ElMessage.success("砍价活动修改成功");
closeCurrentPage();
}
})
.finally(() => {
submitLoading.value = false;
});
});
}
function closeCurrentPage() {
appStore.removeTag("add-kan-jia-goods");
localStorage.pageOpenedList = JSON.stringify(appStore.pageOpenedList);
if (appStore?.removeTag) {
appStore.removeTag("add-kan-jia-goods");
appStore.removeTag("edit-kanJia-activity-goods");
}
if (localStorage?.pageOpenedList && appStore?.pageOpenedList) {
localStorage.pageOpenedList = JSON.stringify(appStore.pageOpenedList);
}
router.go(-1);
}
</script>
<style lang="scss" scoped>
h4 {
margin-bottom: 10px;
padding: 0 10px;
border: 1px solid #ddd;
background-color: #f8f8f8;
font-weight: bold;
color: #333;
font-size: 14px;
line-height: 40px;
text-align: left;
}
.kanjia-edit-page {
padding: 16px 20px;
background-color: #f0f2f5;
min-height: 100vh;
box-sizing: border-box;
.kanjia-activity-time-form-item {
:deep(.el-date-editor) {
width: 360px !important;
max-width: 360px;
flex-grow: 0;
.mt_10 {
margin-top: 12px;
}
.font-mono {
font-family: Monaco, Menlo, Consolas, "Courier New", monospace;
}
.font-semibold {
font-weight: 600;
}
.text-dark {
color: #303133;
}
.text-secondary {
color: #606266;
}
.header-card {
border: none;
border-radius: 4px;
:deep(.el-card__body) {
padding: 16px 20px;
}
.page-header-row {
display: flex;
align-items: center;
justify-content: space-between;
.header-left {
.header-title {
display: flex;
align-items: center;
gap: 12px;
font-size: 18px;
font-weight: 600;
color: #1f2d3d;
.mode-tag,
.status-tag {
font-weight: normal;
}
.activity-id {
font-size: 13px;
color: #909399;
font-weight: normal;
}
}
}
}
}
.info-card {
border: none;
border-radius: 4px;
:deep(.el-card__body) {
padding: 20px;
}
.section-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 16px;
.section-indicator {
width: 4px;
height: 16px;
background-color: #409eff;
border-radius: 2px;
}
.section-title {
font-size: 15px;
font-weight: 600;
color: #303133;
}
}
.custom-descriptions {
:deep(.el-descriptions__label) {
width: 130px;
background-color: #f8f9fb;
color: #606266;
font-weight: 500;
}
:deep(.el-descriptions__content) {
color: #303133;
}
.goods-thumb-box {
width: 56px;
height: 56px;
border-radius: 4px;
overflow: hidden;
background-color: #f5f7fa;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid #ebeef5;
.goods-thumb-img {
width: 100%;
height: 100%;
display: block;
}
.thumb-slot {
font-size: 12px;
color: #c0c4cc;
}
}
.sub-id-text {
font-size: 12px;
color: #909399;
margin-top: 4px;
}
.original-price-val {
color: #303133;
font-size: 14px;
}
.price-highlight {
color: #f56c6c;
font-size: 16px;
}
}
.edit-form {
padding: 8px 12px;
.kanjia-activity-time-form-item {
:deep(.el-date-editor) {
width: 380px !important;
max-width: 380px;
flex-grow: 0;
}
}
}
}
.footer-card {
border: none;
border-radius: 4px;
:deep(.el-card__body) {
padding: 16px 20px;
}
.footer-actions {
display: flex;
justify-content: center;
gap: 16px;
}
}
}
</style>

View File

@@ -1,98 +1,290 @@
<template>
<div class="pintuan-goods">
<el-card>
<h4>活动详情</h4>
<el-table border :data="data" style="width: 100%; margin: 10px 0">
<el-table-column prop="promotionName" label="活动名称" min-width="120" />
<el-table-column prop="startTime" label="活动开始时间" min-width="120" />
<el-table-column prop="endTime" label="活动结束时间" min-width="120" />
<el-table-column prop="requiredNum" label="成团人数" min-width="90" />
<el-table-column prop="limitNum" label="限购数量" min-width="90" />
<el-table-column label="状态" min-width="100">
<template #default="{ row }">
<el-tag v-if="row" :type="promotionStatusTagType(row.promotionStatus)">
{{ promotionStatusText(row.promotionStatus) }}
<div class="pintuan-goods-page">
<!-- 顶部标题与返回栏 -->
<el-card shadow="never" class="header-card">
<div class="page-header-row">
<div class="header-left">
<div class="header-title">
<span>拼团活动商品管理</span>
<el-tag
v-if="pintuanInfo.promotionStatus"
:type="promotionStatusTagType(pintuanInfo.promotionStatus)"
size="small"
effect="plain"
class="status-tag"
>
{{ promotionStatusText(pintuanInfo.promotionStatus) }}
</el-tag>
</template>
</el-table-column>
</el-table>
<span v-if="pintuanId" class="activity-id font-mono">ID: {{ pintuanId }}</span>
</div>
</div>
<div class="header-right">
<el-button @click="back">返回拼团列表</el-button>
</div>
</div>
</el-card>
<!-- 活动信息卡片 -->
<el-card shadow="never" class="info-card mt_10">
<div class="section-header">
<span class="section-indicator"></span>
<span class="section-title">活动信息</span>
</div>
<el-descriptions :column="3" border class="custom-descriptions">
<el-descriptions-item label="活动名称">
<span class="font-semibold text-dark">{{ pintuanInfo.promotionName || "-" }}</span>
</el-descriptions-item>
<el-descriptions-item label="活动状态">
<el-tag
v-if="pintuanInfo.promotionStatus"
:type="promotionStatusTagType(pintuanInfo.promotionStatus)"
size="small"
effect="dark"
>
{{ promotionStatusText(pintuanInfo.promotionStatus) }}
</el-tag>
<span v-else>-</span>
</el-descriptions-item>
<el-descriptions-item label="所属店铺">
<span class="text-secondary">{{ pintuanInfo.storeName || "全平台 / 自营" }}</span>
</el-descriptions-item>
<el-descriptions-item label="活动开始时间">
<span class="font-mono text-dark">{{ pintuanInfo.startTime || "-" }}</span>
</el-descriptions-item>
<el-descriptions-item label="活动结束时间">
<span class="font-mono text-dark">{{ pintuanInfo.endTime || "-" }}</span>
</el-descriptions-item>
<el-descriptions-item label="成团人数">
<span class="font-mono text-dark">
{{ pintuanInfo.requiredNum ? `${pintuanInfo.requiredNum} 人` : "-" }}
</span>
</el-descriptions-item>
<el-descriptions-item label="限购数量">
<span class="font-mono text-dark">
{{ pintuanInfo.limitNum > 0 ? `${pintuanInfo.limitNum} 件` : "不限购" }}
</span>
</el-descriptions-item>
<el-descriptions-item label="虚拟成团">
<el-tag
v-if="pintuanInfo.fictitious != null"
:type="pintuanInfo.fictitious ? 'success' : 'info'"
size="small"
effect="plain"
>
{{ pintuanInfo.fictitious ? "已开启" : "未开启" }}
</el-tag>
<span v-else>-</span>
</el-descriptions-item>
<el-descriptions-item label="活动ID">
<span class="font-mono text-secondary">{{ pintuanId || "-" }}</span>
</el-descriptions-item>
<el-descriptions-item
v-if="pintuanInfo.pintuanRule"
label="规则说明"
:span="3"
>
<span class="text-secondary rule-desc">{{ pintuanInfo.pintuanRule }}</span>
</el-descriptions-item>
</el-descriptions>
</el-card>
<!-- 拼团商品列表卡片 -->
<el-card shadow="never" class="info-card mt_10">
<div class="card-header-bar">
<div class="header-title-group">
<span class="section-indicator"></span>
<span class="section-title">已报名拼团商品</span>
<span class="total-badge">共 {{ total }} 件商品</span>
</div>
<div class="header-actions">
<el-button size="small" @click="getDataList">刷新</el-button>
</div>
</div>
<h4>商品信息</h4>
<el-table
ref="table"
v-loading="loading"
border
class="operation"
stripe
:data="goodsData"
style="width: 100%"
empty-text="暂无报名的拼团商品"
class="custom-table"
>
<el-table-column label="商品名称" min-width="120">
<!-- 商品图片 -->
<el-table-column label="商品图片" width="76" align="center">
<template #default="{ row }">
<div v-if="row && (row.thumbnail || row.small || row.goodsImage)" class="goods-thumb-box">
<el-image
:src="row.thumbnail || row.small || row.goodsImage"
fit="cover"
preview-teleported
:preview-src-list="[row.thumbnail || row.small || row.goodsImage]"
class="goods-thumb-img"
>
<template #error>
<div class="thumb-slot">无图</div>
</template>
</el-image>
</div>
<div v-else class="goods-thumb-box thumb-slot">无图</div>
</template>
</el-table-column>
<!-- 商品名称 -->
<el-table-column prop="goodsName" label="商品名称" min-width="220" show-overflow-tooltip>
<template #default="{ row }">
<div v-if="row">
<a
class="goods-link"
@click="handleLinkTo(row.goodsId, row.skuId)"
>
{{ row.goodsName || "-" }}
</a>
<div v-if="row.skuId" class="sku-sub-text font-mono">
SKU: {{ row.skuId }}
</div>
</div>
</template>
</el-table-column>
<!-- 所属商家 -->
<el-table-column prop="storeName" label="所属商家" min-width="140" show-overflow-tooltip>
<template #default="{ row }">
<span class="text-secondary">{{ (row && row.storeName) || "-" }}</span>
</template>
</el-table-column>
<!-- 商品原价 -->
<el-table-column label="商品原价" width="120" align="right">
<template #default="{ row }">
<span v-if="row" class="original-price font-mono">
{{ $filters.unitPrice(row.originalPrice, "¥") }}
</span>
</template>
</el-table-column>
<!-- 拼团价格 -->
<el-table-column label="拼团价格" width="130" align="right">
<template #default="{ row }">
<span v-if="row" class="pintuan-price font-mono font-semibold">
{{ $filters.unitPrice(row.price, "¥") }}
</span>
</template>
</el-table-column>
<!-- 活动库存 -->
<el-table-column label="活动库存" width="100" align="right">
<template #default="{ row }">
<span v-if="row" class="font-mono text-dark">{{ row.quantity ?? "-" }}</span>
</template>
</el-table-column>
<!-- 限购数量 -->
<el-table-column label="限购数量" width="100" align="right">
<template #default="{ row }">
<span v-if="row" class="font-mono text-dark">
{{ row.limitNum > 0 ? row.limitNum : "不限" }}
</span>
</template>
</el-table-column>
<!-- 操作 -->
<el-table-column label="查看" width="140" align="center" fixed="right">
<template #default="{ row }">
<template v-if="row">
<a class="link-text mr_10" @click="linkTo(row.goodsId, row.skuId)">{{ row.goodsName }}</a>
<el-popover trigger="hover" title="扫码在手机中查看" placement="top" width="180">
<el-button
link
type="primary"
@click="handleLinkTo(row.goodsId, row.skuId)"
>
PC查看
</el-button>
<el-popover
trigger="hover"
placement="top"
width="180"
>
<template #reference>
<img
src="../../../assets/qrcode.svg"
style="vertical-align: middle"
class="hover-pointer"
width="20"
height="20"
alt="qrcode"
/>
<el-button link type="primary" class="ml_8">
手机扫码
</el-button>
</template>
<vue-qr
:text="wapLinkTo(row.goodsId, row.skuId)"
:margin="0"
color-dark="#000"
color-light="#fff"
:size="150"
/>
<div class="qr-popover-content">
<div class="qr-popover-title">扫码在手机中查看</div>
<vue-qr
:text="handleWapLinkTo(row.goodsId, row.skuId)"
:margin="4"
color-dark="#000"
color-light="#fff"
:size="140"
/>
</div>
</el-popover>
</template>
</template>
</el-table-column>
<el-table-column prop="quantity" label="库存" min-width="80" />
<el-table-column label="拼团价格" min-width="100">
<template #default="{ row }">
<span v-if="row" :style="{ color: $mainColor }">
{{ $filters.unitPrice(row.price, "¥") }}</span>
</template>
</el-table-column>
</el-table>
<div class="page operation mt_10" style="display: flex; justify-content: flex-end">
<!-- 分页区域 -->
<div class="pagination-container">
<el-pagination
v-model:current-page="searchForm.pageNumber"
v-model:page-size="searchForm.pageSize"
:page-sizes="[20, 50, 100]"
:page-sizes="[10, 20, 50, 100]"
:total="total"
background
layout="total, sizes, prev, pager, next, jumper"
size="small"
size="default"
@current-change="changePage"
@size-change="changePageSize"
/>
</div>
</el-card>
<!-- 底部返回卡片 -->
<el-card shadow="never" class="footer-card mt_10">
<div class="footer-actions">
<el-button @click="back">返回拼团列表</el-button>
</div>
</el-card>
</div>
</template>
<script setup>
import { onMounted, reactive, ref } from "vue";
import { useRoute } from "vue-router";
import { onMounted, reactive, ref, computed, getCurrentInstance } from "vue";
import { useRoute, useRouter } from "vue-router";
import { getPintuanGoodsList, getPintuanDetail } from "@/api/promotion.js";
import vueQr from "vue-qr";
const route = useRoute();
const router = useRouter();
const { proxy } = getCurrentInstance() || {};
const loading = ref(false);
const pintuanInfo = ref({});
const goodsData = ref([]);
const total = ref(0);
const searchForm = reactive({
pageNumber: 1,
pageSize: 20,
});
const data = ref([]);
const total = ref(0);
const goodsData = ref([]);
const pintuanId = computed(() => {
return route.query.id || pintuanInfo.value.id || "";
});
function promotionStatusText(status) {
const map = {
@@ -103,6 +295,7 @@ function promotionStatusText(status) {
};
return map[status] || "未知";
}
function promotionStatusTagType(status) {
const map = {
NEW: "info",
@@ -110,34 +303,77 @@ function promotionStatusTagType(status) {
END: "danger",
CLOSE: "danger",
};
return map[status] || "danger";
return map[status] || "info";
}
function init() {
getDataList();
getPintuanMsg();
function handleLinkTo(goodsId, skuId) {
if (proxy && typeof proxy.linkTo === "function") {
proxy.linkTo(goodsId, skuId);
} else {
const pcUrl = window.BASE?.PC_URL || "";
const url = skuId
? `${pcUrl}/goodsDetail?skuId=${skuId}&goodsId=${goodsId}`
: `${pcUrl}/goodsDetail?goodsId=${goodsId}`;
window.open(url, "_blank");
}
}
function handleWapLinkTo(goodsId, skuId) {
if (proxy && typeof proxy.wapLinkTo === "function") {
return proxy.wapLinkTo(goodsId, skuId);
}
const wapUrl = window.BASE?.WAP_URL || "";
if (skuId) {
return `${wapUrl}/pages/product/goods?id=${skuId}&goodsId=${goodsId}`;
}
return `${wapUrl}/pages/product/goods?goodsId=${goodsId}`;
}
function back() {
if (window.history.length > 1) {
router.go(-1);
} else {
router.push({ name: "manager-pintuan" });
}
}
function changePage() {
getDataList();
}
function changePageSize() {
searchForm.pageNumber = 1;
getDataList();
}
function getDataList() {
if (!pintuanId.value) return;
loading.value = true;
searchForm.pintuanId = route.query.id;
getPintuanGoodsList(searchForm).then((res) => {
loading.value = false;
if (res.success) {
goodsData.value = res.result.records;
total.value = res.result.total;
searchForm.pintuanId = pintuanId.value;
getPintuanGoodsList(searchForm)
.then((res) => {
if (res && res.success && res.result) {
goodsData.value = res.result.records || [];
total.value = res.result.total || 0;
}
})
.finally(() => {
loading.value = false;
});
}
function getPintuanMsg() {
if (!pintuanId.value) return;
getPintuanDetail(pintuanId.value).then((res) => {
if (res && res.success && res.result) {
pintuanInfo.value = res.result;
}
});
}
function getPintuanMsg() {
getPintuanDetail(route.query.id).then((res) => {
if (res.success) data.value.push(res.result);
});
function init() {
getPintuanMsg();
getDataList();
}
onMounted(() => {
@@ -146,31 +382,246 @@ onMounted(() => {
</script>
<style lang="scss" scoped>
h4 {
margin: 20px 0;
padding: 0 10px;
font-weight: bold;
color: #333;
font-size: 14px;
text-align: left;
border-left: 3px solid red;
.pintuan-goods-page {
padding: 16px 20px;
background-color: #f0f2f5;
min-height: 100vh;
box-sizing: border-box;
.mt_10 {
margin-top: 12px;
}
.ml_8 {
margin-left: 8px;
}
.font-mono {
font-family: Monaco, Menlo, Consolas, "Courier New", monospace;
}
.font-semibold {
font-weight: 600;
}
.text-dark {
color: #303133;
}
.text-secondary {
color: #606266;
}
.header-card {
border: none;
border-radius: 4px;
:deep(.el-card__body) {
padding: 16px 20px;
}
.page-header-row {
display: flex;
align-items: center;
justify-content: space-between;
.header-left {
.header-title {
display: flex;
align-items: center;
gap: 12px;
font-size: 18px;
font-weight: 600;
color: #1f2d3d;
.status-tag {
font-weight: normal;
}
.activity-id {
font-size: 13px;
color: #909399;
font-weight: normal;
}
}
}
}
}
.info-card {
border: none;
border-radius: 4px;
:deep(.el-card__body) {
padding: 20px;
}
.section-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 16px;
.section-indicator {
width: 4px;
height: 16px;
background-color: #409eff;
border-radius: 2px;
}
.section-title {
font-size: 15px;
font-weight: 600;
color: #303133;
}
}
.custom-descriptions {
:deep(.el-descriptions__label) {
width: 120px;
background-color: #f8f9fb;
color: #606266;
font-weight: 500;
}
:deep(.el-descriptions__content) {
color: #303133;
}
.rule-desc {
white-space: pre-wrap;
line-height: 1.6;
}
}
.card-header-bar {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
.header-title-group {
display: flex;
align-items: center;
gap: 8px;
.section-indicator {
width: 4px;
height: 16px;
background-color: #409eff;
border-radius: 2px;
}
.section-title {
font-size: 15px;
font-weight: 600;
color: #303133;
}
.total-badge {
font-size: 13px;
color: #909399;
margin-left: 6px;
}
}
}
.custom-table {
:deep(th.el-table__cell) {
background-color: #fafafa;
color: #303133;
font-weight: 600;
}
.goods-thumb-box {
width: 44px;
height: 44px;
border-radius: 4px;
overflow: hidden;
background-color: #f5f7fa;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid #ebeef5;
margin: 0 auto;
.goods-thumb-img {
width: 100%;
height: 100%;
display: block;
}
}
.thumb-slot {
font-size: 11px;
color: #c0c4cc;
display: flex;
align-items: center;
justify-content: center;
}
.goods-link {
color: #303133;
font-weight: 500;
cursor: pointer;
transition: color 0.2s;
text-decoration: none;
&:hover {
color: #409eff;
}
}
.sku-sub-text {
font-size: 12px;
color: #909399;
margin-top: 2px;
}
.original-price {
color: #909399;
text-decoration: line-through;
font-size: 13px;
}
.pintuan-price {
color: #f56c6c;
font-size: 14px;
}
}
.pagination-container {
display: flex;
justify-content: flex-end;
margin-top: 16px;
}
}
.footer-card {
border: none;
border-radius: 4px;
:deep(.el-card__body) {
padding: 16px 20px;
}
.footer-actions {
display: flex;
justify-content: center;
gap: 12px;
}
}
}
.link-text {
color: #409eff;
cursor: pointer;
text-decoration: none;
}
.qr-popover-content {
text-align: center;
padding: 6px 0;
.mr_10 {
margin-right: 10px;
}
.hover-pointer {
cursor: pointer;
}
.mt_10 {
margin-top: 10px;
.qr-popover-title {
font-size: 13px;
font-weight: 500;
color: #303133;
margin-bottom: 8px;
}
}
</style>

View File

@@ -1,89 +1,239 @@
<template>
<div>
<el-card>
<el-form ref="formRef" :model="form" label-width="120px" :rules="formRule">
<div class="base-info-item">
<h4>积分商品信息</h4>
<div class="form-item-view">
<el-form-item label="商品名称">
<div>{{ form.goodsSku.goodsName }}</div>
</el-form-item>
<el-form-item label="SKU编码">
<div>{{ form.goodsSku.id }}</div>
</el-form-item>
<el-form-item label="店铺名称">
<div>{{ form.goodsSku.storeName }}</div>
</el-form-item>
<el-form-item label="商品价格">
<div>
<priceColorScheme :value="form.goodsSku.price" :color="$mainColor" />
</div>
</el-form-item>
<el-form-item label="库存">
<div>{{ form.goodsSku.quantity }}</div>
</el-form-item>
<el-form-item label="结算价格" prop="settlementPrice">
<el-input
type="number"
v-model="form.settlementPrice"
placeholder="请填写结算价格"
clearable
style="width: 260px"
/>
</el-form-item>
<el-form-item label="分类" prop="pointsGoodsCategoryId">
<el-select
v-model="form.pointsGoodsCategoryId"
placeholder="请选择分类"
style="width: 260px"
@change="changeCategory"
>
<el-option v-for="item in categoryList" :key="item.id" :label="item.name" :value="item.id" />
</el-select>
</el-form-item>
<el-form-item label="活动库存" prop="activeStock">
<el-input
type="number"
v-model="form.activeStock"
placeholder="请填写活动库存"
clearable
style="width: 260px"
/>
</el-form-item>
<el-form-item label="兑换积分" prop="points">
<el-input
type="number"
v-model="form.points"
placeholder="请填写兑换积分"
clearable
style="width: 260px"
/>
</el-form-item>
<el-form-item label="活动开始时间">
<el-date-picker
v-model="form.rangeTime"
type="datetimerange"
format="YYYY-MM-DD HH:mm:ss"
value-format="YYYY-MM-DD HH:mm:ss"
start-placeholder="开始时间"
end-placeholder="结束时间"
:disabled-date="disabledDate"
style="width: 360px"
/>
</el-form-item>
</div>
<div class="footer">
<el-button @click="closeCurrentPage" style="margin-right: 5px">返回</el-button>
<el-button type="primary" :loading="submitLoading" @click="handleSubmit">保存</el-button>
<div class="points-goods-edit-page">
<!-- 顶部标题与返回栏 -->
<el-card shadow="never" class="header-card">
<div class="page-header-row">
<div class="header-left">
<div class="header-title">
<span>{{ onlyView ? "积分商品详情" : "编辑积分商品" }}</span>
<el-tag
size="small"
effect="plain"
:type="onlyView ? 'info' : 'warning'"
class="mode-tag"
>
{{ onlyView ? "查看模式" : "编辑模式" }}
</el-tag>
<el-tag
v-if="form.promotionStatus"
size="small"
effect="dark"
:type="promotionStatusTagType(form.promotionStatus)"
class="status-tag"
>
{{ promotionStatusText(form.promotionStatus) }}
</el-tag>
<span v-if="id" class="activity-id font-mono">ID: {{ id }}</span>
</div>
</div>
</el-form>
<div class="header-right">
<el-button @click="closeCurrentPage">返回积分商品列表</el-button>
</div>
</div>
</el-card>
<!-- 商品基本信息卡片 -->
<el-card shadow="never" class="info-card mt_10">
<div class="section-header">
<span class="section-indicator"></span>
<span class="section-title">商品基本信息</span>
</div>
<el-descriptions :column="2" border class="custom-descriptions">
<el-descriptions-item label="商品图片" width="120px">
<div class="goods-thumb-box">
<el-image
v-if="goodsThumbnail"
:src="goodsThumbnail"
fit="cover"
preview-teleported
:preview-src-list="[goodsThumbnail]"
class="goods-thumb-img"
>
<template #error>
<div class="thumb-slot">无图</div>
</template>
</el-image>
<div v-else class="thumb-slot">无图</div>
</div>
</el-descriptions-item>
<el-descriptions-item label="商品名称">
<div class="font-semibold text-dark">
{{ form.goodsName || form.goodsSku?.goodsName || "-" }}
</div>
<div v-if="form.goodsId" class="sub-id-text font-mono">
商品ID: {{ form.goodsId }}
</div>
</el-descriptions-item>
<el-descriptions-item label="SKU编码">
<span class="font-mono text-dark">
{{ form.skuId || form.goodsSku?.id || "-" }}
</span>
</el-descriptions-item>
<el-descriptions-item label="所属店铺">
<span class="text-secondary">
{{ form.goodsSku?.storeName || form.storeName || "-" }}
</span>
</el-descriptions-item>
<el-descriptions-item label="商品原价">
<span class="original-price-val font-mono">
{{ $filters.unitPrice(goodsPrice, "¥") }}
</span>
</el-descriptions-item>
<el-descriptions-item label="商品库存">
<span class="font-mono text-dark">
{{ form.goodsSku?.quantity != null ? `${form.goodsSku.quantity} 件` : "-" }}
</span>
</el-descriptions-item>
</el-descriptions>
</el-card>
<!-- 积分兑换配置区域 -->
<el-card shadow="never" class="info-card mt_10">
<div class="section-header">
<span class="section-indicator"></span>
<span class="section-title">积分兑换规则与参数</span>
</div>
<!-- 查看模式:以结构化描述列表呈现 -->
<template v-if="onlyView">
<el-descriptions :column="2" border class="custom-descriptions">
<el-descriptions-item label="兑换所需积分">
<span class="points-highlight font-mono font-semibold">
{{ form.points != null ? `${form.points} 积分` : "-" }}
</span>
</el-descriptions-item>
<el-descriptions-item label="结算价格">
<span class="font-mono font-semibold text-dark">
{{ form.settlementPrice != null ? $filters.unitPrice(form.settlementPrice, "¥") : "-" }}
</span>
</el-descriptions-item>
<el-descriptions-item label="积分商品分类">
<el-tag size="small" effect="plain" type="primary">
{{ currentCategoryName || "未设置分类" }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="活动库存">
<span class="font-mono text-dark font-semibold">
{{ form.activeStock != null ? `${form.activeStock} 件` : "-" }}
</span>
</el-descriptions-item>
<el-descriptions-item label="活动时间" :span="2">
<span class="font-mono text-dark">
{{ activityTimeRange }}
</span>
</el-descriptions-item>
</el-descriptions>
</template>
<!-- 编辑模式:表单输入 -->
<template v-else>
<el-form
ref="formRef"
:model="form"
label-width="120px"
:rules="formRule"
class="edit-form"
>
<el-form-item label="兑换积分" prop="points">
<el-input
v-model="form.points"
type="number"
placeholder="请填写所需兑换积分"
clearable
style="width: 280px"
>
<template #append>积分</template>
</el-input>
</el-form-item>
<el-form-item label="结算价格" prop="settlementPrice">
<el-input
v-model="form.settlementPrice"
type="number"
placeholder="请填写结算价格"
clearable
style="width: 280px"
>
<template #append>元</template>
</el-input>
</el-form-item>
<el-form-item label="积分分类" prop="pointsGoodsCategoryId">
<el-select
v-model="form.pointsGoodsCategoryId"
placeholder="请选择积分商品分类"
style="width: 280px"
@change="changeCategory"
>
<el-option
v-for="item in categoryList"
:key="item.id"
:label="item.name"
:value="item.id"
/>
</el-select>
</el-form-item>
<el-form-item label="活动库存" prop="activeStock">
<el-input
v-model="form.activeStock"
type="number"
placeholder="请填写活动库存数量"
clearable
style="width: 280px"
>
<template #append>件</template>
</el-input>
</el-form-item>
<el-form-item label="活动时间" required class="points-activity-time-item">
<el-date-picker
v-model="form.rangeTime"
type="datetimerange"
format="YYYY-MM-DD HH:mm:ss"
value-format="YYYY-MM-DD HH:mm:ss"
start-placeholder="开始时间"
end-placeholder="结束时间"
placeholder="请选择活动时间"
:disabled-date="disabledDate"
style="width: 380px"
/>
</el-form-item>
</el-form>
</template>
</el-card>
<!-- 底部操作栏 -->
<el-card shadow="never" class="footer-card mt_10">
<div class="footer-actions">
<el-button @click="closeCurrentPage">
{{ onlyView ? "返回" : "取消" }}
</el-button>
<el-button
v-if="!onlyView"
type="primary"
:loading="submitLoading"
@click="handleSubmit"
>
保存修改
</el-button>
</div>
</el-card>
</div>
</template>
<script setup>
import { onMounted, ref } from "vue";
import { onMounted, ref, computed } from "vue";
import { useRoute, useRouter } from "vue-router";
import {
updatePointsGoods,
@@ -99,6 +249,11 @@ const route = useRoute();
const router = useRouter();
const appStore = useAppStore();
const id = computed(() => route.query.id || "");
const onlyView = computed(() => {
return route.query.onlyView === "true" || route.query.onlyView === true;
});
const pointsGoodsCategoryId = ref("");
const pointsGoodsCategoryName = ref("");
const form = ref({
@@ -111,49 +266,106 @@ const form = ref({
promotionStatus: "NEW",
rangeTime: [],
});
const categoryList = ref([]);
const id = route.query.id;
const submitLoading = ref(false);
const formRule = {
settlementPrice: [{ required: true, message: "请填写结算价格" }],
pointsGoodsCategoryId: [{ required: true, message: "请选择积分商品分类" }],
points: [{ required: true, message: "请填写兑换积分" }],
activeStock: [{ required: true, message: "请填写库存" }],
settlementPrice: [{ required: true, message: "请填写结算价格", trigger: "blur" }],
pointsGoodsCategoryId: [
{ required: true, message: "请选择积分商品分类", trigger: "change" },
],
points: [{ required: true, message: "请填写兑换积分", trigger: "blur" }],
activeStock: [{ required: true, message: "请填写库存", trigger: "blur" }],
};
const formRef = ref(null);
onMounted(async () => {
await getCategory();
if (id) {
getData();
}
const goodsThumbnail = computed(() => {
return form.value.thumbnail || form.value.goodsSku?.thumbnail || "";
});
const goodsPrice = computed(() => {
if (form.value.goodsSku?.price != null) return form.value.goodsSku.price;
if (form.value.originalPrice != null) return form.value.originalPrice;
return 0;
});
const currentCategoryName = computed(() => {
if (pointsGoodsCategoryName.value) return pointsGoodsCategoryName.value;
if (form.value.pointsGoodsCategoryName) return form.value.pointsGoodsCategoryName;
const match = categoryList.value.find(
(c) => c.id === (pointsGoodsCategoryId.value || form.value.pointsGoodsCategoryId)
);
return match ? match.name : "";
});
const activityTimeRange = computed(() => {
const start = form.value.startTime || (form.value.rangeTime && form.value.rangeTime[0]);
const end = form.value.endTime || (form.value.rangeTime && form.value.rangeTime[1]);
if (start && end) {
return `${start} 至 ${end}`;
}
return start || end || "-";
});
function promotionStatusText(status) {
const map = {
NEW: "未开始",
START: "已开始",
END: "已结束",
CLOSE: "已关闭",
};
return map[status] || status;
}
function promotionStatusTagType(status) {
const map = {
NEW: "info",
START: "success",
END: "danger",
CLOSE: "danger",
};
return map[status] || "info";
}
function disabledDate(date) {
return date && date.getTime() < Date.now() - 86400000;
}
function changeCategory(id) {
const item = categoryList.value.find((c) => c.id === id);
pointsGoodsCategoryId.value = id;
function changeCategory(catId) {
const item = categoryList.value.find((c) => c.id === catId);
pointsGoodsCategoryId.value = catId;
pointsGoodsCategoryName.value = item ? item.name : "";
}
function closeCurrentPage() {
appStore.removeTag("edit-points-goods");
localStorage.pageOpenedList = JSON.stringify(appStore.pageOpenedList);
router.go(-1);
if (appStore?.removeTag) {
appStore.removeTag("edit-points-goods");
}
if (localStorage?.pageOpenedList && appStore?.pageOpenedList) {
localStorage.pageOpenedList = JSON.stringify(appStore.pageOpenedList);
}
if (window.history.length > 1) {
router.go(-1);
} else {
router.push({ name: "points-goods" });
}
}
function getData() {
getPointsGoodsById(id).then((res) => {
if (res.success) {
if (!id.value) return;
getPointsGoodsById(id.value).then((res) => {
if (res && res.success && res.result) {
const data = res.result;
form.value = data;
data.rangeTime = [];
form.value = {
...data,
goodsSku: data.goodsSku || {},
};
if (data.startTime && data.endTime) {
data.rangeTime = [data.startTime, data.endTime];
form.value.rangeTime = [data.startTime, data.endTime];
} else {
form.value.rangeTime = [];
}
if (data.pointsGoodsCategoryId) {
changeCategory(data.pointsGoodsCategoryId);
@@ -163,8 +375,14 @@ function getData() {
}
async function getCategory() {
const res = await getPointsGoodsCategoryList();
categoryList.value = res.result.records;
try {
const res = await getPointsGoodsCategoryList();
if (res && res.success && res.result) {
categoryList.value = res.result.records || [];
}
} catch (e) {
console.error("加载积分商品分类失败", e);
}
}
function handleSubmit() {
@@ -179,69 +397,203 @@ function handleSubmit() {
submitLoading.value = true;
const params = {
...form.value,
pointsGoodsCategoryId: pointsGoodsCategoryId.value || form.value.pointsGoodsCategoryId,
pointsGoodsCategoryId:
pointsGoodsCategoryId.value || form.value.pointsGoodsCategoryId,
pointsGoodsCategoryName:
pointsGoodsCategoryName.value || form.value.pointsGoodsCategoryName,
};
updatePointsGoods(params).then((res) => {
submitLoading.value = false;
if (res.success) {
Message.success("积分商品修改成功");
closeCurrentPage();
}
});
updatePointsGoods(params)
.then((res) => {
if (res && res.success) {
Message.success("积分商品修改成功");
closeCurrentPage();
}
})
.finally(() => {
submitLoading.value = false;
});
}
});
}
onMounted(async () => {
await getCategory();
if (id.value) {
getData();
}
});
</script>
<style lang="scss" scoped>
.el-form {
padding-bottom: 80px;
.points-goods-edit-page {
padding: 16px 20px;
background-color: #f0f2f5;
min-height: 100vh;
box-sizing: border-box;
.el-form-item {
width: 100%;
color: gray;
text-align: left;
}
}
div.base-info-item > div {
margin-left: 5%;
}
div.base-info-item {
margin-bottom: 10px;
h4 {
margin-bottom: 10px;
padding: 0 10px;
border: 1px solid #ddd;
background-color: #f8f8f8;
font-weight: bold;
color: #333;
font-size: 14px;
line-height: 40px;
text-align: left;
.mt_10 {
margin-top: 12px;
}
.form-item-view {
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: space-between;
padding-left: 80px;
.font-mono {
font-family: Monaco, Menlo, Consolas, "Courier New", monospace;
}
}
.footer {
width: 100%;
padding: 10px;
background-color: #ffc;
position: fixed;
bottom: 0;
right: 0;
text-align: center;
z-index: 9999;
.font-semibold {
font-weight: 600;
}
.text-dark {
color: #303133;
}
.text-secondary {
color: #606266;
}
.header-card {
border: none;
border-radius: 4px;
:deep(.el-card__body) {
padding: 16px 20px;
}
.page-header-row {
display: flex;
align-items: center;
justify-content: space-between;
.header-left {
.header-title {
display: flex;
align-items: center;
gap: 12px;
font-size: 18px;
font-weight: 600;
color: #1f2d3d;
.mode-tag,
.status-tag {
font-weight: normal;
}
.activity-id {
font-size: 13px;
color: #909399;
font-weight: normal;
}
}
}
}
}
.info-card {
border: none;
border-radius: 4px;
:deep(.el-card__body) {
padding: 20px;
}
.section-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 16px;
.section-indicator {
width: 4px;
height: 16px;
background-color: #409eff;
border-radius: 2px;
}
.section-title {
font-size: 15px;
font-weight: 600;
color: #303133;
}
}
.custom-descriptions {
:deep(.el-descriptions__label) {
width: 130px;
background-color: #f8f9fb;
color: #606266;
font-weight: 500;
}
:deep(.el-descriptions__content) {
color: #303133;
}
.goods-thumb-box {
width: 56px;
height: 56px;
border-radius: 4px;
overflow: hidden;
background-color: #f5f7fa;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid #ebeef5;
.goods-thumb-img {
width: 100%;
height: 100%;
display: block;
}
.thumb-slot {
font-size: 12px;
color: #c0c4cc;
}
}
.sub-id-text {
font-size: 12px;
color: #909399;
margin-top: 4px;
}
.original-price-val {
color: #303133;
font-size: 14px;
}
.points-highlight {
color: #f56c6c;
font-size: 16px;
}
}
.edit-form {
padding: 8px 12px;
.points-activity-time-item {
:deep(.el-date-editor) {
width: 380px !important;
max-width: 380px;
flex-grow: 0;
}
}
}
}
.footer-card {
border: none;
border-radius: 4px;
:deep(.el-card__body) {
padding: 16px 20px;
}
.footer-actions {
display: flex;
justify-content: center;
gap: 16px;
}
}
}
</style>

View File

@@ -1,70 +1,192 @@
<template>
<div class="seckill-goods">
<el-card>
<el-table border :data="data" style="width: 100%">
<el-table-column prop="promotionName" label="活动名称" min-width="120" />
<el-table-column prop="startTime" label="活动开始时间" min-width="160" />
<el-table-column label="报名截止时间" min-width="160">
<template #default="{ row }">
<span v-if="row">{{ unixDate(row.applyEndTime) }}</span>
</template>
</el-table-column>
<el-table-column label="时间场次" min-width="160">
<template #default="{ row }">
<template v-if="row">
<el-tag v-for="item in unixHours(row.hours)" :key="item" class="hour-tag">{{ item }}</el-tag>
</template>
</template>
</el-table-column>
<el-table-column label="活动状态" min-width="100">
<template #default="{ row }">
<el-tag v-if="row" :type="seckillStatusTagType(row.promotionStatus)">
{{ seckillStatusText(row.promotionStatus) }}
<div class="seckill-goods-page">
<!-- 顶部标题与返回栏 -->
<el-card shadow="never" class="header-card">
<div class="page-header-row">
<div class="header-left">
<div class="header-title">
<span>秒杀活动商品管理</span>
<el-tag
v-if="seckillInfo.promotionStatus"
:type="seckillStatusTagType(seckillInfo.promotionStatus)"
size="small"
effect="plain"
class="status-tag"
>
{{ seckillStatusText(seckillInfo.promotionStatus) }}
</el-tag>
</template>
</el-table-column>
</el-table>
<span v-if="seckillId" class="activity-id font-mono">ID: {{ seckillId }}</span>
</div>
</div>
<div class="header-right">
<el-button @click="back">返回秒杀列表</el-button>
</div>
</div>
</el-card>
<!-- 活动信息卡片 -->
<el-card shadow="never" class="info-card mt_10">
<div class="section-header">
<span class="section-indicator"></span>
<span class="section-title">活动信息</span>
</div>
<el-descriptions :column="2" border class="custom-descriptions">
<el-descriptions-item label="活动名称">
<span class="font-semibold text-dark">{{ seckillInfo.promotionName || "-" }}</span>
</el-descriptions-item>
<el-descriptions-item label="活动状态">
<el-tag
v-if="seckillInfo.promotionStatus"
:type="seckillStatusTagType(seckillInfo.promotionStatus)"
size="small"
effect="dark"
>
{{ seckillStatusText(seckillInfo.promotionStatus) }}
</el-tag>
<span v-else>-</span>
</el-descriptions-item>
<el-descriptions-item label="活动开始时间">
<span class="font-mono text-dark">{{ seckillInfo.startTime || "-" }}</span>
</el-descriptions-item>
<el-descriptions-item label="报名截止时间">
<span class="font-mono text-dark">{{ unixDate(seckillInfo.applyEndTime) }}</span>
</el-descriptions-item>
<el-descriptions-item label="时间场次" :span="2">
<div class="hours-tags-row">
<template v-if="seckillInfo.hours">
<el-tag
v-for="item in unixHours(seckillInfo.hours)"
:key="item"
size="small"
effect="light"
class="hour-tag"
>
{{ item }}
</el-tag>
</template>
<span v-else class="text-placeholder">未设置场次</span>
</div>
</el-descriptions-item>
<el-descriptions-item v-if="seckillInfo.seckillRule" label="规则说明" :span="2">
<span class="text-secondary">{{ seckillInfo.seckillRule }}</span>
</el-descriptions-item>
</el-descriptions>
</el-card>
<!-- 秒杀商品列表卡片 -->
<el-card shadow="never" class="info-card mt_10">
<div class="card-header-bar">
<div class="header-title-group">
<span class="section-indicator"></span>
<span class="section-title">已报名秒杀商品</span>
<span class="total-badge">共 {{ total }} 件商品</span>
</div>
<div class="header-actions">
<el-button size="small" @click="getDataList">刷新</el-button>
</div>
</div>
<el-table
ref="table"
v-loading="loading"
border
class="operation"
stripe
:data="goodsList"
style="width: 100%"
empty-text="暂无报名的秒杀商品"
class="custom-table"
>
<el-table-column prop="goodsName" label="商品名称" min-width="120" show-overflow-tooltip />
<el-table-column label="商品价格" width="110">
<!-- 商品图片 -->
<el-table-column label="商品图片" width="76" align="center">
<template #default="{ row }">
<span v-if="row" :style="{ color: $mainColor }">
{{ $filters.unitPrice(row.originalPrice, "¥") }}</span>
<div v-if="row && (row.thumbnail || row.small || row.goodsImage)" class="goods-thumb-box">
<el-image
:src="row.thumbnail || row.small || row.goodsImage"
fit="cover"
preview-teleported
:preview-src-list="[row.thumbnail || row.small || row.goodsImage]"
class="goods-thumb-img"
>
<template #error>
<div class="thumb-slot">无图</div>
</template>
</el-image>
</div>
<div v-else class="goods-thumb-box thumb-slot">无图</div>
</template>
</el-table-column>
<el-table-column label="库存" width="90">
<!-- 商品名称 -->
<el-table-column prop="goodsName" label="商品名称" min-width="200" show-overflow-tooltip>
<template #default="{ row }">
<span v-if="row">{{ row.quantity }}</span>
<span class="font-semibold text-dark">{{ row.goodsName || "-" }}</span>
</template>
</el-table-column>
<el-table-column label="活动价格" width="100">
<!-- 商家名称 -->
<el-table-column prop="storeName" label="所属商家" min-width="140" show-overflow-tooltip>
<template #default="{ row }">
<span v-if="row" :style="{ color: $mainColor }">
{{ $filters.unitPrice(row.price, "¥") }}</span>
<span class="text-secondary">{{ row.storeName || "-" }}</span>
</template>
</el-table-column>
<el-table-column prop="storeName" label="商家名称" min-width="100" show-overflow-tooltip />
<el-table-column label="活动场次" width="100">
<!-- 活动场次 -->
<el-table-column label="活动场次" width="110" align="center">
<template #default="{ row }">
<el-tag v-if="row">{{ row.timeLine + ":00" }}</el-tag>
<el-tag v-if="row && row.timeLine != null" size="small" effect="plain" type="warning">
{{ row.timeLine }}:00 场
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="150" align="center">
<!-- 原价 -->
<el-table-column label="商品原价" width="120" align="right">
<template #default="{ row }">
<span v-if="row" class="original-price font-mono">
{{ $filters.unitPrice(row.originalPrice, "¥") }}
</span>
</template>
</el-table-column>
<!-- 秒杀价格 -->
<el-table-column label="秒杀价格" width="130" align="right">
<template #default="{ row }">
<span v-if="row" class="seckill-price font-mono font-semibold">
{{ $filters.unitPrice(row.price, "¥") }}
</span>
</template>
</el-table-column>
<!-- 库存 -->
<el-table-column label="活动库存" width="100" align="right">
<template #default="{ row }">
<span v-if="row" class="font-mono text-dark">{{ row.quantity ?? "-" }}</span>
</template>
</el-table-column>
<!-- 操作 -->
<el-table-column label="操作" width="110" align="center" fixed="right">
<template #default="{ row, $index }">
<a v-if="row" class="link-text" @click="delGoods($index, row)">删除</a>
<el-button
v-if="row"
link
type="danger"
@click="delGoods($index, row)"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
<div class="mt_10" style="display: flex; justify-content: flex-end">
<!-- 底部分页 -->
<div class="pagination-wrap mt_10">
<el-pagination
v-model:current-page="searchForm.pageNumber"
v-model:page-size="searchForm.pageSize"
@@ -77,108 +199,142 @@
/>
</div>
</el-card>
<!-- 底部操作栏 -->
<el-card shadow="never" class="footer-card mt_10">
<div class="footer-actions">
<el-button type="primary" @click="back">返回秒杀列表</el-button>
</div>
</el-card>
</div>
</template>
<script setup>
import { onMounted, reactive, ref } from "vue";
import { useRoute } from "vue-router";
import { useRoute, useRouter } from "vue-router";
import {
seckillGoodsList,
seckillDetail,
delSeckillGoods,
} from "@/api/promotion.js";
import { Message, Modal } from "@/utils/message";
import { unixToDate } from "@/utils/filters";
import { ElMessage, ElMessageBox } from "element-plus";
import { useAppStore } from "@/stores/app";
defineOptions({ name: "seckill-goods" });
const route = useRoute();
const router = useRouter();
const appStore = useAppStore();
const promotionStatus = ref("");
const seckillId = route.query.id;
const seckillInfo = ref({});
const loading = ref(false);
const searchForm = reactive({
pageNumber: 1,
pageSize: 20,
});
const total = ref(0);
const data = ref([]);
const goodsList = ref([]);
function seckillStatusText(status) {
const map = {
NEW: "新建",
START: "开始",
END: "结束",
CLOSE: "废弃",
NEW: "未开始",
START: "进行中",
END: "已结束",
CLOSE: "已关闭",
};
return map[status] || status || "-";
}
function seckillStatusTagType(status) {
const map = {
NEW: "danger",
NEW: "info",
START: "success",
END: "danger",
CLOSE: "danger",
CLOSE: "warning",
};
return map[status] || "danger";
return map[status] || "info";
}
function init() {
getSeckillMsg();
}
function changePage() {
getDataList();
}
function changePageSize() {
searchForm.pageNumber = 1;
getDataList();
}
function getDataList() {
if (!seckillId) return;
loading.value = true;
searchForm.seckillId = route.query.id;
seckillGoodsList(searchForm).then((res) => {
loading.value = false;
if (res.success && res.result) {
goodsList.value = res.result.records;
total.value = res.result.total;
}
});
searchForm.seckillId = seckillId;
seckillGoodsList(searchForm)
.then((res) => {
loading.value = false;
if (res && res.success && res.result) {
goodsList.value = res.result.records || [];
total.value = res.result.total || 0;
}
})
.catch(() => {
loading.value = false;
});
}
function getSeckillMsg() {
seckillDetail(route.query.id).then((res) => {
if (res.success && res.result) {
data.value = [];
data.value.push(res.result);
promotionStatus.value = res.result.promotionStatus;
if (!seckillId) return;
seckillDetail(seckillId).then((res) => {
if (res && res.success && res.result) {
seckillInfo.value = res.result;
getDataList();
}
});
}
function delGoods(index, row) {
Modal.confirm({
title: "确认删除",
content: "您确认要删除该商品吗?删除后不可恢复",
onOk: () => {
ElMessageBox.confirm(`确认要删除商品 "${row.goodsName || "该商品"}" 吗?删除后不可恢复。`, "确认删除", {
type: "warning",
confirmButtonText: "确定删除",
cancelButtonText: "取消",
})
.then(() => {
const params = {
seckillId: row.seckillId,
id: row.id,
};
delSeckillGoods(params).then((res) => {
if (res.success) {
if (res && res.success) {
goodsList.value.splice(index, 1);
Message.success("删除成功!");
total.value = Math.max(0, total.value - 1);
ElMessage.success("删除成功!");
} else if (res && res.message) {
ElMessage.error(res.message);
}
});
},
});
})
.catch(() => {});
}
function unixDate(time) {
if (!time) return "-";
return unixToDate(new Date(time) / 1000);
}
function unixHours(item) {
const hourArr = item.split(",");
for (let i = 0; i < hourArr.length; i++) {
hourArr[i] += ":00";
}
return hourArr;
if (!item) return [];
const hourArr = String(item).split(",");
return hourArr.map((h) => `${h.trim()}:00`);
}
function back() {
appStore.removeTag("seckill-goods");
router.go(-1);
}
onMounted(() => {
@@ -187,22 +343,193 @@ onMounted(() => {
</script>
<style lang="scss" scoped>
.operation {
margin: 10px 0;
}
.seckill-goods-page {
padding-bottom: 20px;
.hour-tag {
margin-right: 4px;
margin-bottom: 4px;
}
.header-card {
.page-header-row {
display: flex;
justify-content: space-between;
align-items: center;
.link-text {
color: #409eff;
cursor: pointer;
text-decoration: none;
}
.header-left {
.header-title {
display: flex;
align-items: center;
gap: 10px;
font-size: 16px;
font-weight: 600;
color: #303133;
.mt_10 {
margin-top: 10px;
.status-tag {
font-weight: normal;
}
.activity-id {
font-size: 13px;
font-weight: normal;
color: #909399;
background-color: #f4f4f5;
padding: 2px 8px;
border-radius: 4px;
}
}
}
}
}
.info-card {
.section-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 16px;
.section-indicator {
width: 4px;
height: 16px;
border-radius: 2px;
background-color: #409eff;
}
.section-title {
font-size: 15px;
font-weight: 600;
color: #303133;
}
}
.card-header-bar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 14px;
.header-title-group {
display: flex;
align-items: center;
gap: 8px;
.section-indicator {
width: 4px;
height: 16px;
border-radius: 2px;
background-color: #409eff;
}
.section-title {
font-size: 15px;
font-weight: 600;
color: #303133;
}
.total-badge {
font-size: 12px;
font-weight: normal;
color: #909399;
background-color: #f4f4f5;
padding: 2px 8px;
border-radius: 10px;
margin-left: 4px;
}
}
.header-actions {
display: flex;
align-items: center;
}
}
.custom-descriptions {
:deep(.el-descriptions__label) {
width: 140px;
background-color: #fafafa;
color: #606266;
font-weight: 500;
}
:deep(.el-descriptions__content) {
color: #303133;
}
}
.hours-tags-row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
.hour-tag {
margin: 0;
}
}
.custom-table {
.goods-thumb-box {
width: 44px;
height: 44px;
margin: 0 auto;
border: 1px solid #ebeef5;
border-radius: 4px;
background-color: #fafafa;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
.goods-thumb-img {
width: 40px;
height: 40px;
}
}
.thumb-slot {
font-size: 11px;
color: #c0c4cc;
}
.original-price {
color: #909399;
text-decoration: line-through;
}
.seckill-price {
color: #f56c6c;
}
}
}
.footer-card {
.footer-actions {
display: flex;
justify-content: center;
align-items: center;
}
}
.font-mono {
font-family: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
}
.font-semibold {
font-weight: 600;
}
.text-dark {
color: #303133;
}
.text-secondary {
color: #909399;
}
.text-placeholder {
color: #c0c4cc;
}
.pagination-wrap {
display: flex;
justify-content: flex-end;
}
}
</style>

View File

@@ -9,7 +9,7 @@
<el-card class="card">
<div>
<h4>交易概况</h4>
<div class="flex">
<div class="flex" style="align-items: flex-start;">
<div class="transactionList">
<div
class="transaction-item"
@@ -98,45 +98,159 @@
</div>
</div>
<div class="shap">
<div id="overViewChart">
<!-- 上 -->
<div class="block">
<div class="box">
<span>访客数</span>
<span>{{ overViewList.uvNum || 0 }}</span>
</div>
</div>
<!-- 中 -->
<div class="block">
<div class="box">
<span>下单笔数</span>
<span>{{ overViewList.orderNum || 0 }}</span>
</div>
</div>
<!-- 下 -->
<div class="block">
<div class="box">
<span>付款笔数</span>
<span>{{ overViewList.paymentOrderNum || 0 }}</span>
</div>
<div class="funnel-card">
<div class="funnel-header">
<h4>交易转化漏斗</h4>
<span class="funnel-sub">全链路转化分析</span>
</div>
<div class="funnel-body">
<svg
class="funnel-svg"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 460 236"
width="460"
height="236"
>
<defs>
<linearGradient id="gradTier1" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#FF5C58" />
<stop offset="100%" stop-color="#E83D39" />
</linearGradient>
<linearGradient id="gradTier2" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#FF8259" />
<stop offset="100%" stop-color="#F25B30" />
</linearGradient>
<linearGradient id="gradTier3" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#FFA454" />
<stop offset="100%" stop-color="#F28528" />
</linearGradient>
<!-- 线 -->
<div class="rightBorder"></div>
<div class="leftTopBorder"></div>
<div class="leftBottomBorder"></div>
<!--数据 -->
<div class="leftTopTips">
<div>下单转化率</div>
<div>{{ overViewList.orderConversionRate || "0%" }}</div>
<filter id="funnelShadow" x="-10%" y="-10%" width="120%" height="130%">
<feDropShadow dx="0" dy="2" stdDeviation="2.5" flood-opacity="0.12" />
</filter>
<filter id="badgeShadow" x="-10%" y="-10%" width="120%" height="130%">
<feDropShadow dx="0" dy="1" stdDeviation="2" flood-opacity="0.06" />
</filter>
</defs>
<!-- 连线:左侧 访客 -> 下单 -->
<path
class="bracket-line"
d="M 138 42 L 98 42 Q 90 42 90 50 L 90 102 Q 90 110 98 110 L 161 110"
/>
<!-- 连线:左侧 下单 -> 付款 -->
<path
class="bracket-line"
d="M 161 110 L 98 110 Q 90 110 90 118 L 90 170 Q 90 178 98 178 L 182 178"
/>
<!-- 连线:右侧 全店转化率 -->
<path
class="bracket-line"
d="M 322 42 L 362 42 Q 370 42 370 50 L 370 170 Q 370 178 362 178 L 278 178"
/>
<!-- 漏斗层级 1:访客数 -->
<g class="funnel-tier">
<polygon
points="128,14 332,14 312,70 148,70"
fill="url(#gradTier1)"
stroke="url(#gradTier1)"
stroke-width="6"
stroke-linejoin="round"
filter="url(#funnelShadow)"
/>
<text x="230" y="37" text-anchor="middle" class="funnel-label">访客数 UV</text>
<text x="230" y="59" text-anchor="middle" class="funnel-value">
{{ overViewList.uvNum || 0 }}
</text>
</g>
<!-- 漏斗层级 2:下单笔数 -->
<g class="funnel-tier">
<polygon
points="152,82 308,82 290,138 170,138"
fill="url(#gradTier2)"
stroke="url(#gradTier2)"
stroke-width="6"
stroke-linejoin="round"
filter="url(#funnelShadow)"
/>
<text x="230" y="105" text-anchor="middle" class="funnel-label">下单笔数</text>
<text x="230" y="127" text-anchor="middle" class="funnel-value">
{{ overViewList.orderNum || 0 }}
</text>
</g>
<!-- 漏斗层级 3:付款笔数 -->
<g class="funnel-tier">
<polygon
points="174,150 286,150 270,206 190,206"
fill="url(#gradTier3)"
stroke="url(#gradTier3)"
stroke-width="6"
stroke-linejoin="round"
filter="url(#funnelShadow)"
/>
<text x="230" y="173" text-anchor="middle" class="funnel-label">付款笔数</text>
<text x="230" y="195" text-anchor="middle" class="funnel-value">
{{ overViewList.paymentOrderNum || 0 }}
</text>
</g>
<!-- 左侧卡片 1:下单转化率 -->
<g transform="translate(6, 57)" filter="url(#badgeShadow)">
<rect width="80" height="38" class="badge-bg" />
<text x="40" y="16" text-anchor="middle" class="badge-title">下单转化率</text>
<text x="40" y="31" text-anchor="middle" class="badge-value">
{{ overViewList.orderConversionRate || "0%" }}
</text>
</g>
<!-- 左侧卡片 2:付款转化率 -->
<g transform="translate(6, 125)" filter="url(#badgeShadow)">
<rect width="80" height="38" class="badge-bg" />
<text x="40" y="16" text-anchor="middle" class="badge-title">付款转化率</text>
<text x="40" y="31" text-anchor="middle" class="badge-value">
{{ overViewList.paymentsConversionRate || "0%" }}
</text>
</g>
<!-- 右侧卡片:全店转化率 -->
<g transform="translate(374, 91)" filter="url(#badgeShadow)">
<rect width="80" height="38" class="badge-bg-highlight" />
<text x="40" y="16" text-anchor="middle" class="badge-title">全店转化率</text>
<text x="40" y="31" text-anchor="middle" class="badge-value-highlight">
{{ overViewList.overallConversionRate || "0%" }}
</text>
</g>
</svg>
</div>
<div class="leftBottomTips">
<div>付款转化率</div>
<div>{{ overViewList.paymentsConversionRate || "0%" }}</div>
</div>
<div class="rightTips">
<div>整体转换率</div>
<div>{{ overViewList.overallConversionRate || "0%" }}</div>
<div class="funnel-summary">
<div class="summary-col">
<div class="dot dot-tier1"></div>
<div class="summary-text">
<span class="label">访客UV</span>
<span class="val">{{ overViewList.uvNum || 0 }}</span>
</div>
</div>
<div class="summary-arrow">➔</div>
<div class="summary-col">
<div class="dot dot-tier2"></div>
<div class="summary-text">
<span class="label">下单笔数</span>
<span class="val">{{ overViewList.orderNum || 0 }}</span>
</div>
</div>
<div class="summary-arrow">➔</div>
<div class="summary-col">
<div class="dot dot-tier3"></div>
<div class="summary-text">
<span class="label">付款笔数</span>
<span class="val">{{ overViewList.paymentOrderNum || 0 }}</span>
</div>
</div>
</div>
</div>
</div>
@@ -553,128 +667,168 @@ onMounted(() => {
text-align: right;
margin: 20px 0;
}
#overViewChart {
.shap {
width: 480px;
flex-shrink: 0;
margin-left: 10px;
}
.funnel-card {
background: #fbfcfe;
border: 1px solid #ebedf0;
border-radius: 8px;
padding: 16px 14px 14px;
display: flex;
flex-direction: column;
}
.funnel-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 6px;
padding: 0 6px;
h4 {
margin: 0;
font-size: 15px;
font-weight: 600;
color: #303133;
}
.funnel-sub {
font-size: 12px;
color: #909399;
}
}
.funnel-body {
width: 100%;
display: flex;
justify-content: center;
flex-direction: column;
}
.funnel-svg {
display: block;
max-width: 100%;
height: auto;
.funnel-label {
font-size: 13px;
fill: rgba(255, 255, 255, 0.94);
font-weight: 500;
}
.funnel-value {
font-size: 17px;
font-weight: 700;
fill: #ffffff;
letter-spacing: 0.5px;
}
.bracket-line {
fill: none;
stroke: #cbd5e1;
stroke-width: 1.5;
stroke-dasharray: 4, 3;
}
.badge-bg {
fill: #ffffff;
stroke: #e2e8f0;
stroke-width: 1;
rx: 6px;
}
.badge-bg-highlight {
fill: #fff7f6;
stroke: #ffccc7;
stroke-width: 1;
rx: 6px;
}
.badge-title {
font-size: 12px;
fill: #64748b;
font-weight: 400;
}
.badge-value {
font-size: 13px;
font-weight: 700;
fill: #1e293b;
}
.badge-value-highlight {
font-size: 13px;
font-weight: 700;
fill: #e83d39;
}
}
.funnel-tier {
cursor: pointer;
transition: transform 0.2s ease, filter 0.2s ease;
&:hover {
filter: brightness(1.04);
}
}
.funnel-summary {
display: flex;
align-items: center;
position: relative;
margin-left: 20px;
> .leftTopTips {
position: absolute;
top: 68px;
left: -2px;
width: 85px;
text-align: center;
background: rgb(255, 255, 255);
z-index: 1;
padding: 5px 0px;
}
> .leftBottomTips {
position: absolute;
bottom: 100px;
left: -2px;
width: 85px;
text-align: center;
background: rgb(255, 255, 255);
z-index: 1;
padding: 5px 0px;
}
> .rightTips {
position: absolute;
bottom: 240px;
right: 0px;
width: 85px;
text-align: center;
background: rgb(255, 255, 255);
z-index: 1;
padding: 5px 0px;
}
> .rightBorder {
width: 110px;
position: absolute;
top: 30px;
right: 40px;
border: 2px solid #d9d9d9;
border-left: 0;
height: 280px;
}
> .leftTopBorder {
border: 2px solid #d9d9d9;
height: 118px;
width: 56px;
position: absolute;
left: 40px;
top: 30px;
border-right: 0;
}
> .leftBottomBorder {
width: 108px;
border: 2px solid #d9d9d9;
height: 150px;
position: absolute;
bottom: 45px;
left: 40px;
border-right: 0;
}
> .block {
height: 0px;
border-left: 30px solid transparent;
border-right: 30px solid transparent;
position: relative;
background: rgb(255, 255, 255);
z-index: 1;
position: relative;
}
> .block:nth-of-type(1) {
width: 240px;
border-top: 90px solid #ff4646;
> .box {
left: -30px;
top: -90px;
width: 240px;
height: 90px;
}
}
> .block:nth-of-type(2) {
width: 172px;
border-top: 100px solid #ff8585;
margin-top: 10px;
> .box {
left: -29px;
top: -100px;
width: 172px;
height: 100px;
}
}
> .block:nth-of-type(3) {
width: 100px;
margin-top: 10px;
border-top: 150px solid #ffb396;
border-left: 50px solid transparent;
border-right: 50px solid transparent;
justify-content: space-around;
margin-top: 12px;
padding: 10px 14px;
background: #ffffff;
border-radius: 6px;
border: 1px solid #f0f2f5;
> .box {
left: -50px;
top: -150px;
width: 100px;
height: 120px;
z-index: 2;
.summary-col {
display: flex;
align-items: center;
gap: 8px;
}
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
&.dot-tier1 {
background: #ff5c58;
}
&.dot-tier2 {
background: #ff8259;
}
&.dot-tier3 {
background: #ffa454;
}
}
:deep(.box){
color: #fff;
position: absolute;
z-index: 2;
.summary-text {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
> span {
font-size: 16px;
font-weight: bold;
.label {
font-size: 11px;
color: #909399;
line-height: 1.2;
}
.val {
font-size: 13px;
font-weight: 600;
color: #303133;
line-height: 1.3;
}
}
.summary-arrow {
color: #dcdfe6;
font-size: 12px;
}
}
@@ -685,13 +839,9 @@ h4 {
margin: 0 0 20px 0;
}
.transactionList {
flex: 7;
padding: 0 20px;
}
.shap {
width: 400px;
margin-left: 20px;
margin-top: 50px;
flex: 1;
min-width: 0;
padding-right: 20px;
}
.transaction-card {
@@ -702,7 +852,7 @@ h4 {
}
.card-item-label {
font-weight: bold;
font-size: #666;
color: #666;
font-size: 15px;
margin-bottom: 10px;
}

View File

@@ -1,113 +1,138 @@
<template>
<div class="search">
<el-card>
<el-row class="operation">
<el-switch
v-model="strict"
active-text="级联"
inactive-text="单选"
style="margin-right: 5px"
/>
<el-button @click="addRoot">添加部门</el-button>
<el-button type="primary" @click="add">添加子部门</el-button>
<el-button @click="delAll">批量删除</el-button>
<el-button @click="getParentList">刷新</el-button>
</el-row>
<el-row type="flex" justify="start">
<el-col :md="8" :lg="8" :xl="6">
<el-alert type="info" show-icon :closable="false">
当前选择编辑:
<span class="select-title">{{ editTitle }}</span>
<a v-if="form.id" class="select-clear" @click="cancelSelect">取消选择</a>
</el-alert>
<div v-loading="loading" class="tree-bar" :style="{ maxHeight: maxHeight }">
<el-tree
ref="treeRef"
:data="data"
:props="treeProps"
node-key="id"
show-checkbox
highlight-current
default-expand-all
:check-strictly="!strict"
:current-node-key="currentNodeKey"
@node-click="onNodeClick"
@check="onCheckChange"
/>
</div>
</el-col>
<el-col :md="15" :lg="13" :xl="9" style="margin-left: 10px">
<el-form ref="formRef" :model="form" label-width="100px" :rules="formValidate">
<el-form-item label="部门名称" prop="title">
<el-input v-model="form.title" />
</el-form-item>
<el-form-item label="选择角色">
<el-select
v-model="selectedRole"
multiple
:loading="userLoading"
placeholder="请选择角色"
style="width: 100%"
>
<el-option
v-for="item in users"
:key="item.id"
:label="item.name"
:value="item.id"
/>
</el-select>
</el-form-item>
<el-form-item label="排序值" prop="sortOrder">
<el-tooltip placement="right" content="值越小越靠前,支持小数">
<el-input-number v-model="form.sortOrder" :max="1000" :min="0" />
</el-tooltip>
</el-form-item>
<el-form-item>
<el-button
type="primary"
style="margin-right: 5px"
:loading="submitLoading"
@click="submitEdit"
>
修改并保存
</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
</el-col>
</el-row>
<div class="department-manage">
<el-card shadow="never" class="manage-card">
<!-- 顶部操作工具栏 -->
<div class="table-toolbar">
<div class="toolbar-left">
<el-button type="primary" @click="addRoot">添加一级部门</el-button>
<el-button
type="danger"
plain
:disabled="selectCount === 0"
@click="delAll"
>
批量删除 {{ selectCount > 0 ? `(${selectCount})` : "" }}
</el-button>
<el-button @click="toggleExpandAll">
{{ isAllExpanded ? "折叠全部" : "展开全部" }}
</el-button>
<el-button @click="getParentList">刷新</el-button>
</div>
<div class="toolbar-right">
<el-input
v-model="searchQuery"
placeholder="按部门名称筛选..."
clearable
:prefix-icon="Search"
class="search-input"
/>
</div>
</div>
<!-- 纯表格展示:树形表格 -->
<el-table
ref="tableRef"
v-loading="loading"
:data="filteredData"
row-key="id"
default-expand-all
:tree-props="{ children: 'children', hasChildren: 'hasChildren' }"
border
stripe
class="dept-table"
@selection-change="onSelectionChange"
>
<el-table-column type="selection" width="50" align="center" />
<el-table-column prop="title" label="部门名称" min-width="260" />
<el-table-column prop="sortOrder" label="排序值" width="100" align="center">
<template #default="{ row }">
<span v-if="row">{{ row.sortOrder ?? 0 }}</span>
</template>
</el-table-column>
<el-table-column prop="createTime" label="创建时间" width="180" align="center">
<template #default="{ row }">
<span>{{ row.createTime || "-" }}</span>
</template>
</el-table-column>
<el-table-column label="操作" width="180" fixed="right" align="center">
<template #default="{ row }">
<a class="link-text" @click="addChild(row)">添加子部门</a>
<span class="op-split">|</span>
<a class="link-text" @click="editRow(row)">编辑</a>
<span class="op-split">|</span>
<a class="link-text error-text" @click="deleteRow(row)">删除</a>
</template>
</el-table-column>
</el-table>
</el-card>
<!-- 部门新建 / 编辑弹窗 -->
<el-dialog
v-model="modalVisible"
:title="modalTitle"
width="500px"
width="540px"
:close-on-click-modal="false"
destroy-on-close
>
<el-form ref="formAddRef" :model="formAdd" label-width="85px" :rules="formValidate">
<div v-if="showParent">
<el-form-item label="上级部门:">{{ form.title }}</el-form-item>
</div>
<el-form-item label="部门名称" prop="title">
<el-input v-model="formAdd.title" />
<el-form
ref="formRef"
:model="form"
label-width="95px"
:rules="formValidate"
>
<el-form-item v-if="form.parentTitle" label="上级部门">
<el-input :model-value="form.parentTitle" disabled />
</el-form-item>
<el-form-item label="部门名称" prop="title">
<el-input v-model="form.title" placeholder="请输入部门名称" />
</el-form-item>
<el-form-item label="选择角色">
<el-select
v-model="selectedRole"
multiple
:loading="userLoading"
placeholder="请选择部门关联角色"
style="width: 100%"
>
<el-option
v-for="item in users"
:key="item.id"
:label="item.name"
:value="item.id"
/>
</el-select>
</el-form-item>
<el-form-item label="排序值" prop="sortOrder">
<el-tooltip placement="right" content="值越小越靠前,支持小数">
<el-input-number v-model="formAdd.sortOrder" :max="1000" :min="0" />
<el-tooltip placement="right" content="值越小越靠前">
<el-input-number v-model="form.sortOrder" :min="0" :max="1000" />
</el-tooltip>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="cancelAdd">取消</el-button>
<el-button type="primary" :loading="submitLoading" @click="submitAdd">提交</el-button>
<div class="dialog-footer">
<el-button @click="modalVisible = false">取消</el-button>
<el-button type="primary" :loading="submitLoading" @click="submitForm">
确定
</el-button>
</div>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from "vue";
import { ref, reactive, computed, onMounted } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
import { Search } from "@element-plus/icons-vue";
import {
initDepartment,
addDepartment,
@@ -121,35 +146,34 @@ import {
defineOptions({ name: "department-manage" });
const loading = ref(true);
const maxHeight = ref("500px");
const strict = ref(true);
const submitLoading = ref(false);
const userLoading = ref(false);
const loadingEdit = ref(false);
const modalVisible = ref(false);
const modalTitle = ref("");
const isEditMode = ref(false);
const data = ref([]);
const users = ref([]);
const selectedRole = ref([]);
const selectList = ref([]);
const selectCount = ref(0);
const showParent = ref(false);
const modalTitle = ref("");
const editTitle = ref("");
const selectedRole = ref([]);
const searchKey = ref("");
const currentNodeKey = ref(null);
const treeProps = reactive({
label: "title",
children: "children",
isLeaf: "isLeaf",
});
const searchQuery = ref("");
const isAllExpanded = ref(true);
const tableRef = ref(null);
const formRef = ref(null);
const form = ref({
id: "",
title: "",
parentId: "",
parentId: "0",
parentTitle: "",
sortOrder: 0,
status: 0,
});
const formAdd = ref({});
const formValidate = reactive({
title: [{ required: true, message: "名称不能为空", trigger: "blur" }],
title: [{ required: true, message: "部门名称不能为空", trigger: "blur" }],
sortOrder: [
{
required: true,
@@ -159,26 +183,58 @@ const formValidate = reactive({
},
],
});
const submitLoading = ref(false);
const data = ref([]);
const dataEdit = ref([]);
const users = ref([]);
const treeRef = ref(null);
const formRef = ref(null);
const formAddRef = ref(null);
// 前端树形过滤
const filteredData = computed(() => {
const query = searchQuery.value.trim().toLowerCase();
if (!query) {
return data.value;
}
const filterTree = (nodes) => {
return nodes
.map((node) => {
const matches = node.title && node.title.toLowerCase().includes(query);
const filteredChildren = node.children ? filterTree(node.children) : [];
if (matches || filteredChildren.length > 0) {
return {
...node,
children: filteredChildren.length > 0 ? filteredChildren : undefined,
};
}
return null;
})
.filter(Boolean);
};
return filterTree(data.value);
});
function init() {
getParentList();
loadRoles();
}
function getParentList() {
loading.value = true;
initDepartment().then((res) => {
loading.value = false;
initDepartment()
.then((res) => {
loading.value = false;
if (res.success) {
const list = Array.isArray(res.result) ? res.result : [];
data.value = bubbleSort(normalizeDepartmentTree(list));
}
})
.catch(() => {
loading.value = false;
});
}
function loadRoles() {
getRoleList({
pageNumber: 1,
pageSize: 10000,
}).then((res) => {
if (res.success) {
const list = Array.isArray(res.result) ? res.result : [];
data.value = bubbleSort(normalizeDepartmentTree(list));
users.value = res.result.records || [];
}
});
}
@@ -195,47 +251,6 @@ function normalizeDepartmentTree(list) {
});
}
function onNodeClick(data) {
if (!data) {
cancelSelect();
return;
}
const row = JSON.parse(JSON.stringify(data));
for (const attr in row) {
if (row[attr] == null) {
row[attr] = "";
}
}
editTitle.value = row.title;
currentNodeKey.value = row.id;
userLoading.value = true;
getUserByDepartmentId(row.id).then((res) => {
const way = [];
res.result &&
res.result.forEach((item) => {
way.push(item.roleId);
});
selectedRole.value = way;
});
getRoleList({
pageNumber: 1,
pageSize: 10000,
}).then((res) => {
userLoading.value = false;
if (res.success) {
users.value = res.result.records;
form.value = row;
}
});
}
function onCheckChange(_data, { checkedNodes }) {
selectList.value = checkedNodes;
selectCount.value = checkedNodes.length;
}
function bubbleSort(array) {
if (!Array.isArray(array)) return [];
const len = array.length;
@@ -252,139 +267,263 @@ function bubbleSort(array) {
return array;
}
function cancelSelect() {
treeRef.value?.setCurrentKey(null);
currentNodeKey.value = null;
formRef.value.resetFields();
delete form.value.id;
editTitle.value = "";
selectedRole.value = [];
function onSelectionChange(selection) {
selectList.value = selection;
selectCount.value = selection.length;
}
function selectTreeEdit(v) {
if (v.length > 0) {
for (const attr in v[0]) {
if (v[0][attr] == null) {
v[0][attr] = "";
function toggleExpandAll() {
isAllExpanded.value = !isAllExpanded.value;
const toggleRows = (rows) => {
rows.forEach((row) => {
tableRef.value?.toggleRowExpansion(row, isAllExpanded.value);
if (row.children && row.children.length) {
toggleRows(row.children);
}
}
const data = JSON.parse(JSON.stringify(v[0]));
form.value.parentId = data.id;
form.value.parentTitle = data.title;
}
});
};
toggleRows(data.value);
}
function cancelAdd() {
modalVisible.value = false;
// 添加一级部门
function addRoot() {
isEditMode.value = false;
modalTitle.value = "添加一级部门";
form.value = {
id: "",
title: "",
parentId: 0,
parentTitle: "",
sortOrder: 0,
status: 0,
};
selectedRole.value = [];
modalVisible.value = true;
}
function handleReset() {
formRef.value.resetFields();
form.value.status = 0;
// 添加子部门
function addChild(row) {
isEditMode.value = false;
modalTitle.value = `添加子部门 (上级: ${row.title})`;
form.value = {
id: "",
title: "",
parentId: row.id,
parentTitle: row.title,
sortOrder: 0,
status: 0,
};
selectedRole.value = [];
modalVisible.value = true;
}
function submitEdit() {
// 编辑当前部门
function editRow(row) {
isEditMode.value = true;
modalTitle.value = `编辑部门 - ${row.title}`;
form.value = {
id: row.id,
title: row.title,
parentId: row.parentId,
parentTitle: "",
sortOrder: row.sortOrder ?? 0,
status: row.status ?? 0,
};
selectedRole.value = [];
userLoading.value = true;
modalVisible.value = true;
getUserByDepartmentId(row.id)
.then((res) => {
userLoading.value = false;
if (res && res.result) {
selectedRole.value = res.result.map((item) => item.roleId);
}
})
.catch(() => {
userLoading.value = false;
});
}
// 提交表单(统一处理新增与修改)
function submitForm() {
formRef.value.validate((valid) => {
if (valid) {
if (!form.value.id) {
ElMessage.warning("请先点击选择要修改的部门");
return;
}
const roleWay = [];
selectedRole.value.forEach((item) => {
roleWay.push({
departmentId: form.value.id,
roleId: item,
});
});
Promise.all([
editDepartment(form.value.id, form.value),
updateDepartmentRole(form.value.id, roleWay),
]).then((res) => {
submitLoading.value = false;
if (res[0].success) {
ElMessage.success("编辑成功");
init();
modalVisible.value = false;
}
});
}
});
}
function submitAdd() {
formAddRef.value.validate((valid) => {
if (valid) {
submitLoading.value = true;
addDepartment(formAdd.value).then((res) => {
submitLoading.value = false;
if (res.success) {
ElMessage.success("添加成功");
init();
modalVisible.value = false;
}
});
if (isEditMode.value) {
// 编辑模式
const roleWay = selectedRole.value.map((roleId) => ({
departmentId: form.value.id,
roleId,
}));
Promise.all([
editDepartment(form.value.id, form.value),
updateDepartmentRole(form.value.id, roleWay),
])
.then((res) => {
submitLoading.value = false;
if (res[0].success) {
ElMessage.success("编辑部门成功");
modalVisible.value = false;
getParentList();
}
})
.catch(() => {
submitLoading.value = false;
});
} else {
// 新增模式
addDepartment(form.value)
.then((res) => {
submitLoading.value = false;
if (res.success) {
ElMessage.success("添加部门成功");
modalVisible.value = false;
getParentList();
}
})
.catch(() => {
submitLoading.value = false;
});
}
}
});
}
function add() {
if (form.value.id == "" || form.value.id == null) {
ElMessage.warning("请先点击选择一个部门");
return;
}
modalTitle.value = "添加子部门";
showParent.value = true;
formAdd.value = {
parentId: form.value.id,
sortOrder: 0,
status: 0,
};
modalVisible.value = true;
}
function addRoot() {
modalTitle.value = "添加一级部门";
showParent.value = false;
formAdd.value = {
parentId: 0,
sortOrder: 0,
status: 0,
};
modalVisible.value = true;
// 单个删除
function deleteRow(row) {
ElMessageBox.confirm(
`您确认要删除部门 "${row.title}" 及其下级所有数据?`,
"确认删除",
{
type: "warning",
confirmButtonText: "确定删除",
cancelButtonText: "取消",
}
)
.then(() => {
deleteDepartment(row.id).then((res) => {
if (res.success) {
ElMessage.success("删除成功");
getParentList();
}
});
})
.catch(() => {});
}
// 批量删除
function delAll() {
if (selectCount.value <= 0) {
ElMessage.warning("您还未勾选要删除的数据");
return;
}
ElMessageBox.confirm("您确认要删除所选的 " + selectCount.value + " 条数据及其下级所有数据?", "确认删除", { type: "warning" }).then(() => {
let ids = "";
selectList.value.forEach(function (e) {
ids += e.id + ",";
});
ids = ids.substring(0, ids.length - 1);
ElMessageBox.confirm(
`您确认要删除所选的 ${selectCount.value} 条部门数据及其下级所有数据?`,
"确认删除",
{
type: "warning",
confirmButtonText: "确定删除",
cancelButtonText: "取消",
}
)
.then(() => {
const ids = selectList.value.map((e) => e.id).join(",");
deleteDepartment(ids).then((res) => {
if (res.success) {
ElMessage.success("删除成功");
selectList.value = [];
selectCount.value = 0;
cancelSelect();
init();
getParentList();
}
});
}).catch(() => {});
})
.catch(() => {});
}
onMounted(() => {
const height = document.documentElement.clientHeight;
maxHeight.value = Number(height - 287) + "px";
init();
});
</script>
<style lang="scss" scoped>
@import "@/styles/tree-common.scss";
.department-manage {
width: 100%;
.manage-card {
border-radius: 8px;
border: 1px solid #ebeef5;
background: #fff;
:deep(.el-card__body) {
padding: 18px 20px 24px;
}
}
.table-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 12px;
margin-bottom: 16px;
.toolbar-left {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.toolbar-right {
display: flex;
align-items: center;
.search-input {
width: 240px;
}
}
}
.dept-table {
width: 100%;
:deep(.el-table__row) {
transition: background-color 0.2s ease;
}
}
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 10px;
}
}
.link-text {
color: #2d8cf0;
cursor: pointer;
text-decoration: none;
font-size: 14px;
line-height: 1;
display: inline-block;
&:hover {
color: #57a3f3;
}
&.error-text {
color: #ed4014;
&:hover {
color: #f16643;
}
}
}
.op-split {
margin: 0 8px;
color: #dcdee2;
font-size: 14px;
}
</style>

View File

@@ -1,245 +1,215 @@
<template>
<div class="search">
<el-card>
<el-row class="operation">
<el-switch
v-model="strict"
class="selectModel"
active-text="级联"
inactive-text="单选"
style="margin-right: 5px"
/>
<el-button @click="addRootMenu">添加顶级菜单</el-button>
<el-button type="primary" @click="addMenu">添加子菜单</el-button>
<el-button @click="delAll">批量删除</el-button>
<el-dropdown @command="handleDropdown">
<el-button>
更多操作
<el-icon class="el-icon--right"><ArrowDown /></el-icon>
<div class="menu-manage-container">
<el-card shadow="never" class="manage-card">
<!-- 顶部操作工具栏 -->
<div class="table-toolbar">
<div class="toolbar-left">
<el-button type="primary" @click="addRootMenu">添加顶级菜单</el-button>
<el-button
type="danger"
plain
:disabled="selectCount === 0"
@click="delAll"
>
批量删除 {{ selectCount > 0 ? `(${selectCount})` : "" }}
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="refresh">刷新</el-dropdown-item>
<el-dropdown-item command="expandOne">收合所有</el-dropdown-item>
<el-dropdown-item command="expandTwo">展开一级</el-dropdown-item>
<el-dropdown-item command="expandThree">展开两级</el-dropdown-item>
<el-dropdown-item command="expandAll">展开所有</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</el-row>
<el-row type="flex" justify="start">
<el-col :md="8" :lg="8" :xl="6">
<el-alert type="info" show-icon :closable="false">
当前选择编辑:
<span class="select-title">{{ editTitle }}</span>
<a class="select-clear" v-if="form.id" @click="cancelEdit">取消选择</a>
</el-alert>
<el-button @click="toggleExpandAll">
{{ isAllExpanded ? "折叠全部" : "展开全部" }}
</el-button>
<el-button @click="getAllList">刷新</el-button>
</div>
<div class="toolbar-right">
<el-input
v-model="searchKey"
v-model="searchQuery"
placeholder="按菜单名称筛选..."
clearable
placeholder="输入菜单名搜索"
@input="search"
@clear="search"
:prefix-icon="Search"
class="search-input"
/>
<div v-loading="loading" class="tree-bar" :style="{ maxHeight: maxHeight }">
<el-tree
:key="treeKey"
ref="treeRef"
:data="data"
:props="treeProps"
node-key="id"
show-checkbox
highlight-current
:check-strictly="!strict"
:default-expanded-keys="expandedKeys"
:current-node-key="currentNodeKey"
@node-click="onNodeClick"
@check="onCheckChange"
>
<template #default="{ data: nodeData }">
<span class="tree-node-label">
<el-icon v-if="nodeData.level == 0" class="tree-node-icon"><Menu /></el-icon>
<el-icon v-else class="tree-node-icon"><List /></el-icon>
<span>{{ nodeData.title }}</span>
</span>
</template>
</el-tree>
</div>
</el-col>
<el-col :md="15" :lg="13" :xl="9" style="margin-left: 10px">
<el-form ref="formRef" :model="form" label-width="110px" :rules="formValidate">
<el-form-item label="类型" prop="type">
<div v-show="form.level == 0">
<el-icon :size="16" style="margin-right: 5px"><Menu /></el-icon>
<span>顶级菜单</span>
</div>
<div v-show="form.level == 1 || form.level == 2">
<el-icon :size="16" style="margin-right: 5px"><List /></el-icon>
<span>页面菜单</span>
</div>
</el-form-item>
<el-form-item label="菜单名称" prop="title">
<el-input class="menu-input" v-model="form.title" />
</el-form-item>
<el-form-item
v-if="form.level != 0"
label="路由地址"
prop="path"
class="block-tool"
>
<el-tooltip placement="right" content="路由地址,英文唯一,跳转页面,路径展示用 ">
<el-input class="menu-input" v-model="form.path" />
</el-tooltip>
</el-form-item>
<el-form-item label="路由名称" prop="name" class="block-tool">
<el-tooltip placement="right" content="路由name,需英文唯一,跳转页面用">
<el-input class="menu-input" v-model="form.name" />
</el-tooltip>
</el-form-item>
<el-form-item v-if="form.level != 0" label="文件路径" prop="frontRoute">
<el-input class="menu-input" v-model="form.frontRoute" />
</el-form-item>
<el-form-item v-if="form.level != 0" label="权限url" class="block-tool">
<el-tooltip placement="right" content="*号模糊匹配,逗号分割">
<el-input
class="menu-input"
v-model="form.permission"
type="textarea"
maxlength="1000"
/>
</el-tooltip>
</el-form-item>
<el-form-item label="排序值" prop="sortOrder">
<el-tooltip placement="right" content="值越小越靠前,支持小数">
<el-input-number v-model="form.sortOrder" :max="1000" :min="0" />
</el-tooltip>
</el-form-item>
<el-form-item>
<el-button
type="primary"
style="margin-right: 5px"
:loading="submitLoading"
@click="submitEdit"
>
保存
</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
</el-col>
</el-row>
</div>
</div>
<!-- 纯列表树形表格 -->
<el-table
ref="tableRef"
v-loading="loading"
:data="filteredData"
row-key="id"
default-expand-all
:tree-props="{ children: 'children', hasChildren: 'hasChildren' }"
border
stripe
class="menu-table"
@selection-change="onSelectionChange"
>
<el-table-column type="selection" width="50" align="center" />
<el-table-column prop="title" label="菜单名称" min-width="220" />
<el-table-column prop="level" label="类型" width="100" align="center">
<template #default="{ row }">
<span v-if="row">{{ row.level === 0 ? "顶级菜单" : row.level === 1 ? "一级页面" : "二级页面" }}</span>
</template>
</el-table-column>
<el-table-column prop="path" label="路由地址" min-width="160">
<template #default="{ row }">
<span>{{ row.path || "-" }}</span>
</template>
</el-table-column>
<el-table-column prop="name" label="路由名称" min-width="150">
<template #default="{ row }">
<span>{{ row.name || "-" }}</span>
</template>
</el-table-column>
<el-table-column prop="frontRoute" label="前端组件" min-width="180" show-overflow-tooltip>
<template #default="{ row }">
<span>{{ row.frontRoute || "-" }}</span>
</template>
</el-table-column>
<el-table-column prop="sortOrder" label="排序值" width="90" align="center">
<template #default="{ row }">
<span>{{ row.sortOrder ?? 0 }}</span>
</template>
</el-table-column>
<el-table-column label="操作" width="180" fixed="right" align="center">
<template #default="{ row }">
<template v-if="row.level < 2">
<a class="link-text" @click="addChildMenu(row)">添加子菜单</a>
<span class="op-split">|</span>
</template>
<a class="link-text" @click="editRow(row)">编辑</a>
<span class="op-split">|</span>
<a class="link-text error-text" @click="deleteRow(row)">删除</a>
</template>
</el-table-column>
</el-table>
</el-card>
<!-- 菜单新建 / 编辑弹窗 -->
<el-dialog
v-model="menuModalVisible"
v-model="modalVisible"
:title="modalTitle"
width="500px"
align-center
append-to-body
width="560px"
:close-on-click-modal="false"
class="menu-manage-dialog"
destroy-on-close
>
<el-form ref="formAddRef" :model="formAdd" label-width="100px" :rules="formValidate">
<div v-if="showParent">
<el-form-item label="上级节点:">{{ parentTitle }}</el-form-item>
</div>
<el-form-item label="类型" prop="type">
<div v-show="formAdd.level == 0">
<el-icon :size="16" style="margin-right: 5px"><Menu /></el-icon>
<span>顶级菜单</span>
</div>
<div v-show="formAdd.level != 0">
<el-icon :size="16" style="margin-right: 5px"><List /></el-icon>
<span>页面菜单</span>
</div>
<el-form
ref="formRef"
:model="form"
label-width="100px"
:rules="formValidate"
>
<el-form-item v-if="parentTitle" label="上级菜单">
<el-input :model-value="parentTitle" disabled />
</el-form-item>
<el-form-item label="菜单类型">
<el-tag v-if="form.level === 0" type="primary">顶级菜单</el-tag>
<el-tag v-else type="success">页面菜单 (Level {{ form.level }})</el-tag>
</el-form-item>
<el-form-item label="菜单名称" prop="title">
<el-input class="menu-input" v-model="formAdd.title" />
<el-input v-model="form.title" placeholder="请输入菜单名称" />
</el-form-item>
<el-form-item v-if="formAdd.level != 0" label="路由地址" prop="path">
<el-input v-model="formAdd.path" />
<el-form-item v-if="form.level != 0" label="路由地址" prop="path">
<el-input v-model="form.path" placeholder="英文唯一,如 /goods" />
</el-form-item>
<el-form-item label="路由名称" prop="name" class="block-tool">
<el-tooltip placement="right" content="路由name,需英文唯一,跳转页面用">
<el-input v-model="formAdd.name" />
<el-form-item label="路由名称" prop="name">
<el-tooltip placement="right" content="英文唯一,用于路由跳转">
<el-input v-model="form.name" placeholder="请输入路由名称" />
</el-tooltip>
</el-form-item>
<el-form-item v-if="formAdd.level != 0" label="文件路径" prop="frontRoute">
<el-input v-model="formAdd.frontRoute" />
<el-form-item v-if="form.level != 0" label="前端组件" prop="frontRoute">
<el-input v-model="form.frontRoute" placeholder="例如 Main 或 views/goods/index" />
</el-form-item>
<el-form-item v-if="formAdd.level != 0" label="权限url">
<el-input v-model="formAdd.permission" type="textarea" />
<div class="desc">*号模糊匹配,逗号分割</div>
<el-form-item v-if="form.level != 0" label="权限URL">
<el-input
v-model="form.permission"
type="textarea"
:rows="3"
placeholder="支持 * 号模糊匹配,多个用英文逗号分割"
/>
</el-form-item>
<el-form-item label="排序值" prop="sortOrder">
<el-tooltip placement="right" content="值越小越靠前,支持小数">
<el-input-number v-model="formAdd.sortOrder" :max="1000" :min="0" />
<el-tooltip placement="right" content="值越小越靠前">
<el-input-number v-model="form.sortOrder" :min="0" :max="1000" />
</el-tooltip>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="menuModalVisible = false">取消</el-button>
<el-button type="primary" :loading="submitLoading" @click="submitAdd">提交</el-button>
<div class="dialog-footer">
<el-button @click="modalVisible = false">取消</el-button>
<el-button type="primary" :loading="submitLoading" @click="submitForm">
确定
</el-button>
</div>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from "vue";
import { ref, reactive, computed, onMounted } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
import { Menu, List, ArrowDown } from "@element-plus/icons-vue";
import { Search } from "@element-plus/icons-vue";
import {
getAllPermissionList,
addPermission,
editPermission,
deletePermission,
searchPermission,
} from "@/api/index";
import util from "@/libs/util.js";
import { useAppStore } from "@/stores/app";
defineOptions({ name: "menu-manage" });
defineOptions({ name: "manager-menu-manage" });
const appStore = useAppStore();
const loading = ref(true);
const strict = ref(true);
const maxHeight = ref("500px");
const expandLevel = ref(1);
const menuModalVisible = ref(false);
const submitLoading = ref(false);
const modalVisible = ref(false);
const modalTitle = ref("");
const isEditMode = ref(false);
const parentTitle = ref("");
const data = ref([]);
const selectList = ref([]);
const selectCount = ref(0);
const showParent = ref(false);
const searchKey = ref("");
const parentTitle = ref("");
const editTitle = ref("");
const modalTitle = ref("");
const expandedKeys = ref([]);
const currentNodeKey = ref(null);
const treeKey = ref(0);
const treeProps = reactive({
label: "title",
children: "children",
});
const searchQuery = ref("");
const isAllExpanded = ref(true);
const tableRef = ref(null);
const formRef = ref(null);
const form = ref({
id: "",
title: "",
name: "",
path: "",
frontRoute: "",
parentId: "",
parentId: "0",
sortOrder: 0,
level: 0,
permission: "",
});
const formAdd = ref({});
const formValidate = reactive({
title: [{ required: true, message: "菜单名称名称不能为空", trigger: "blur" }],
title: [{ required: true, message: "菜单名称不能为空", trigger: "blur" }],
name: [{ required: true, message: "路由名称不能为空", trigger: "blur" }],
path: [{ required: true, message: "路由地址不能为空", trigger: "blur" }],
frontRoute: [{ required: true, message: "文件地址不能为空", trigger: "blur" }],
frontRoute: [{ required: true, message: "前端组件路径不能为空", trigger: "blur" }],
sortOrder: [
{
required: true,
@@ -249,280 +219,217 @@ const formValidate = reactive({
},
],
});
const submitLoading = ref(false);
const data = ref([]);
const treeRef = ref(null);
const formRef = ref(null);
const formAddRef = ref(null);
// 前端树形过滤
const filteredData = computed(() => {
const query = searchQuery.value.trim().toLowerCase();
if (!query) {
return data.value;
}
const filterTree = (nodes) => {
return nodes
.map((node) => {
const matches =
(node.title && node.title.toLowerCase().includes(query)) ||
(node.name && node.name.toLowerCase().includes(query)) ||
(node.path && node.path.toLowerCase().includes(query));
const filteredChildren = node.children ? filterTree(node.children) : [];
if (matches || filteredChildren.length > 0) {
return {
...node,
children: filteredChildren.length > 0 ? filteredChildren : undefined,
};
}
return null;
})
.filter(Boolean);
};
return filterTree(data.value);
});
function init() {
getAllList();
}
function collectExpandedKeys(nodes, keys = []) {
if (!nodes) return keys;
nodes.forEach((node) => {
if (node.expand === true) {
keys.push(node.id);
}
if (node.children && node.children.length > 0) {
collectExpandedKeys(node.children, keys);
}
});
return keys;
}
function handleDropdown(name) {
if (name == "expandOne") {
expandLevel.value = 1;
getAllList();
} else if (name == "expandTwo") {
expandLevel.value = 2;
getAllList();
} else if (name == "expandThree") {
expandLevel.value = 3;
getAllList();
}
if (name == "expandAll") {
expandLevel.value = 4;
getAllList();
} else if (name == "refresh") {
getAllList();
}
}
function getAllList() {
loading.value = true;
getAllPermissionList().then((res) => {
loading.value = false;
if (res.success) {
const level = expandLevel.value;
res.result.forEach(function (e) {
if (level == 1) {
if (e.level == 0) {
e.expand = false;
}
if (e.children && e.children.length > 0) {
e.children.forEach(function (c) {
if (c.level == 1) {
c.expand = false;
}
if (c.children && c.children.length > 0) {
c.children.forEach(function (b) {
if (b.level == 2) {
b.expand = false;
}
});
}
});
}
} else if (level == 2) {
if (e.level == 0) {
e.expand = true;
}
if (e.children && e.children.length > 0) {
e.children.forEach(function (c) {
if (c.level == 1) {
c.expand = false;
}
if (c.children && c.children.length > 0) {
c.children.forEach(function (b) {
if (b.level == 2) {
b.expand = false;
}
});
}
});
}
} else if (level == 3) {
if (e.level == 0) {
e.expand = true;
}
if (e.children && e.children.length > 0) {
e.children.forEach(function (c) {
if (c.level == 1) {
c.expand = true;
}
if (c.children && c.children.length > 0) {
c.children.forEach(function (b) {
if (b.level == 2) {
b.expand = false;
}
});
}
});
}
} else if (level == 4) {
if (e.level == 0) {
e.expand = true;
}
if (e.children && e.children.length > 0) {
e.children.forEach(function (c) {
if (c.level == 1) {
c.expand = true;
}
if (c.children && c.children.length > 0) {
c.children.forEach(function (b) {
if (b.level == 2) {
b.expand = true;
}
});
}
});
}
}
});
data.value = res.result;
expandedKeys.value = collectExpandedKeys(res.result);
treeKey.value += 1;
}
});
}
function onNodeClick(nodeData) {
if (nodeData) {
const menu = JSON.parse(JSON.stringify(nodeData));
form.value = menu;
editTitle.value = menu.title;
currentNodeKey.value = menu.id;
} else {
cancelEdit();
}
}
function onCheckChange(_data, { checkedNodes }) {
selectList.value = checkedNodes;
selectCount.value = checkedNodes.length;
}
function search() {
if (searchKey.value) {
loading.value = true;
searchPermission({ title: searchKey.value }).then((res) => {
getAllPermissionList()
.then((res) => {
loading.value = false;
if (res.success) {
data.value = res.result;
expandedKeys.value = [];
treeKey.value += 1;
data.value = Array.isArray(res.result) ? res.result : [];
}
})
.catch(() => {
loading.value = false;
});
}
function onSelectionChange(selection) {
selectList.value = selection;
selectCount.value = selection.length;
}
function toggleExpandAll() {
isAllExpanded.value = !isAllExpanded.value;
const toggleRows = (rows) => {
rows.forEach((row) => {
tableRef.value?.toggleRowExpansion(row, isAllExpanded.value);
if (row.children && row.children.length) {
toggleRows(row.children);
}
});
} else {
getAllList();
}
};
toggleRows(data.value);
}
function cancelEdit() {
treeRef.value?.setCurrentKey(null);
currentNodeKey.value = null;
formRef.value.resetFields();
form.value.id = "";
editTitle.value = "";
}
function handleReset() {
formRef.value.resetFields();
form.value.frontRoute = "";
}
function submitEdit() {
formRef.value.validate((valid) => {
if (valid) {
if (!form.value.id) {
ElMessage.warning("请先点击选择要修改的菜单节点");
return;
}
submitLoading.value = true;
if (form.value.sortOrder == null) {
form.value.sortOrder = 0;
}
delete form.value.icon;
delete form.value.frontComponent;
delete form.value.buttonType;
delete form.value.updateTime;
delete form.value.selected;
delete form.value.description;
delete form.value.children;
editPermission(form.value).then((res) => {
submitLoading.value = false;
if (res.success) {
ElMessage.success("编辑成功");
appStore.setAdded(false);
util.initRouter();
init();
menuModalVisible.value = false;
}
});
}
});
}
function submitAdd() {
formAddRef.value.validate((valid) => {
if (valid) {
submitLoading.value = true;
addPermission(formAdd.value).then((res) => {
submitLoading.value = false;
if (res.success) {
ElMessage.success("添加成功");
appStore.setAdded(false);
util.initRouter();
init();
menuModalVisible.value = false;
}
});
}
});
}
function addMenu() {
if (!form.value.id) {
ElMessage.warning("请先点击选择一个菜单权限节点");
return;
}
parentTitle.value = form.value.title;
modalTitle.value = "添加子节点";
showParent.value = true;
if (form.value.level == 2) {
ElMessageBox.alert("仅支持2级菜单目录", "抱歉,不能添加啦", { type: "warning" });
return;
}
formAdd.value = {
parentId: form.value.id,
level: Number(form.value.level) + 1,
// 添加顶级菜单
function addRootMenu() {
isEditMode.value = false;
parentTitle.value = "";
modalTitle.value = "添加顶级菜单";
form.value = {
id: "",
title: "",
name: "",
path: "/",
frontRoute: "Main",
parentId: 0,
level: 0,
sortOrder: 0,
permission: "",
};
if (form.value.level == 0) {
formAdd.value.path = "/";
formAdd.value.frontRoute = "Main";
modalVisible.value = true;
}
// 添加子菜单
function addChildMenu(row) {
if (row.level >= 2) {
ElMessageBox.alert("仅支持2级菜单目录,不能再添加子菜单", "提示", {
type: "warning",
});
return;
}
menuModalVisible.value = true;
}
function addRootMenu() {
modalTitle.value = "添加顶级菜单";
showParent.value = false;
formAdd.value = {
level: 0,
isEditMode.value = false;
parentTitle.value = row.title;
modalTitle.value = `添加子菜单 (上级: ${row.title})`;
form.value = {
id: "",
title: "",
name: "",
path: "",
frontRoute: "",
parentId: row.id,
level: Number(row.level) + 1,
sortOrder: 0,
permission: "",
};
menuModalVisible.value = true;
if (row.level === 0) {
form.value.path = "/";
form.value.frontRoute = "Main";
}
modalVisible.value = true;
}
// 编辑当前菜单
function editRow(row) {
isEditMode.value = true;
parentTitle.value = "";
modalTitle.value = `编辑菜单 - ${row.title}`;
form.value = {
id: row.id,
title: row.title,
name: row.name,
path: row.path,
frontRoute: row.frontRoute,
parentId: row.parentId,
level: row.level,
sortOrder: row.sortOrder ?? 0,
permission: row.permission || "",
};
modalVisible.value = true;
}
// 提交保存
function submitForm() {
formRef.value.validate((valid) => {
if (valid) {
submitLoading.value = true;
const payload = { ...form.value };
if (payload.sortOrder == null) {
payload.sortOrder = 0;
}
delete payload.children;
delete payload.icon;
delete payload.frontComponent;
delete payload.buttonType;
delete payload.updateTime;
delete payload.selected;
delete payload.description;
const request = isEditMode.value
? editPermission(payload)
: addPermission(payload);
request
.then((res) => {
submitLoading.value = false;
if (res.success) {
ElMessage.success(isEditMode.value ? "编辑成功" : "添加成功");
appStore.setAdded(false);
util.initRouter();
modalVisible.value = false;
getAllList();
}
})
.catch(() => {
submitLoading.value = false;
});
}
});
}
// 删除单个
function deleteRow(row) {
ElMessageBox.confirm(
`您确认要删除菜单 "${row.title}" 及其下级所有数据?`,
"确认删除",
{
type: "warning",
confirmButtonText: "确定删除",
cancelButtonText: "取消",
}
)
.then(() => {
deletePermission(row.id).then((res) => {
if (res.success) {
ElMessage.success("删除成功");
appStore.setAdded(false);
util.initRouter();
getAllList();
}
});
})
.catch(() => {});
}
// 批量删除
function delAll() {
if (selectCount.value <= 0) {
ElMessage.warning("您还未勾选要删除的数据");
return;
}
ElMessageBox.confirm("您确认要删除所选的 " + selectCount.value + " 条数据?", "确认删除", { type: "warning" }).then(() => {
let ids = "";
selectList.value.forEach(function (e) {
ids += e.id + ",";
});
ids = ids.substring(0, ids.length - 1);
ElMessageBox.confirm(
`您确认要删除所选的 ${selectCount.value} 条数据?`,
"确认删除",
{
type: "warning",
confirmButtonText: "确定删除",
cancelButtonText: "取消",
}
)
.then(() => {
const ids = selectList.value.map((e) => e.id).join(",");
deletePermission(ids).then((res) => {
if (res.success) {
ElMessage.success("删除成功");
@@ -530,42 +437,96 @@ function delAll() {
util.initRouter();
selectList.value = [];
selectCount.value = 0;
cancelEdit();
init();
getAllList();
}
});
}).catch(() => {});
})
.catch(() => {});
}
onMounted(() => {
const height = document.documentElement.clientHeight;
maxHeight.value = Number(height - 287) + "px";
init();
});
</script>
<style lang="scss" scoped>
@import "@/styles/tree-common.scss";
.desc {
font-size: 12px;
color: #999;
}
.menu-input {
width: 362px;
}
.tree-node-icon {
margin-right: 8px;
vertical-align: middle;
}
.tree-node-label {
display: inline-flex;
align-items: center;
}
</style>
.menu-manage-container {
width: 100%;
<style lang="scss">
.menu-manage-dialog .el-dialog__body {
max-height: calc(100vh - 220px);
overflow-y: auto;
.manage-card {
border-radius: 8px;
border: 1px solid #ebeef5;
background: #fff;
:deep(.el-card__body) {
padding: 18px 20px 24px;
}
}
.table-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 12px;
margin-bottom: 16px;
.toolbar-left {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.toolbar-right {
display: flex;
align-items: center;
.search-input {
width: 240px;
}
}
}
.menu-table {
width: 100%;
:deep(.el-table__row) {
transition: background-color 0.2s ease;
}
}
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 10px;
}
}
.link-text {
color: #2d8cf0;
cursor: pointer;
text-decoration: none;
font-size: 14px;
line-height: 1;
display: inline-block;
&:hover {
color: #57a3f3;
}
&.error-text {
color: #ed4014;
&:hover {
color: #f16643;
}
}
}
.op-split {
margin: 0 8px;
color: #dcdee2;
font-size: 14px;
}
</style>

View File

@@ -1,201 +1,174 @@
<template>
<div class="search">
<el-card>
<el-row class="operation">
<el-switch
v-model="strict"
class="selectModel"
active-text="级联"
inactive-text="单选"
style="margin-right: 5px"
/>
<el-button @click="addRootMenu">添加顶级菜单</el-button>
<el-button type="primary" @click="addMenu">添加子菜单</el-button>
<el-button @click="delAll">批量删除</el-button>
<el-dropdown @command="handleDropdown">
<el-button>
更多操作
<el-icon class="el-icon--right"><ArrowDown /></el-icon>
<div class="menu-manage-container">
<el-card shadow="never" class="manage-card">
<!-- 顶部操作工具栏 -->
<div class="table-toolbar">
<div class="toolbar-left">
<el-button type="primary" @click="addRootMenu">添加顶级菜单</el-button>
<el-button
type="danger"
plain
:disabled="selectCount === 0"
@click="delAll"
>
批量删除 {{ selectCount > 0 ? `(${selectCount})` : "" }}
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="refresh">刷新</el-dropdown-item>
<el-dropdown-item command="expandOne">收合所有</el-dropdown-item>
<el-dropdown-item command="expandTwo">展开一级</el-dropdown-item>
<el-dropdown-item command="expandThree">展开两级</el-dropdown-item>
<el-dropdown-item command="expandAll">展开所有</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</el-row>
<el-row type="flex" justify="start">
<el-col :md="8" :lg="8" :xl="6">
<el-alert type="info" show-icon :closable="false">
当前选择编辑:
<span class="select-title">{{ editTitle }}</span>
<a class="select-clear" v-if="form.id" @click="cancelEdit">取消选择</a>
</el-alert>
<el-button @click="toggleExpandAll">
{{ isAllExpanded ? "折叠全部" : "展开全部" }}
</el-button>
<el-button @click="getAllList">刷新</el-button>
</div>
<div class="toolbar-right">
<el-input
v-model="searchKey"
v-model="searchQuery"
placeholder="按菜单名称筛选..."
clearable
placeholder="输入菜单名搜索"
@input="search"
@clear="search"
:prefix-icon="Search"
class="search-input"
/>
<div v-loading="loading" class="tree-bar" :style="{ maxHeight: maxHeight }">
<el-tree
:key="treeKey"
ref="treeRef"
:data="data"
:props="treeProps"
node-key="id"
show-checkbox
highlight-current
:check-strictly="!strict"
:default-expanded-keys="expandedKeys"
:current-node-key="currentNodeKey"
@node-click="onNodeClick"
@check="onCheckChange"
>
<template #default="{ data: nodeData }">
<span class="tree-node-label">
<el-icon v-if="nodeData.level == 0" class="tree-node-icon"><Menu /></el-icon>
<el-icon v-else class="tree-node-icon"><List /></el-icon>
<span>{{ nodeData.title }}</span>
</span>
</template>
</el-tree>
</div>
</el-col>
<el-col :md="15" :lg="13" :xl="9" style="margin-left: 10px">
<el-form ref="formRef" :model="form" label-width="110px" :rules="formValidate">
<el-form-item label="类型" prop="type">
<div v-show="form.level == 0">
<el-icon :size="16" style="margin-right: 5px"><Menu /></el-icon>
<span>顶级菜单</span>
</div>
<div v-show="form.level == 1 || form.level == 2">
<el-icon :size="16" style="margin-right: 5px"><List /></el-icon>
<span>页面菜单</span>
</div>
</el-form-item>
<el-form-item label="菜单名称" prop="title">
<el-input class="menu-input" v-model="form.title" />
</el-form-item>
<el-form-item
v-if="form.level != 0"
label="路由地址"
prop="path"
class="block-tool"
>
<el-tooltip placement="right" content="路由地址,英文唯一,跳转页面,路径展示用 ">
<el-input class="menu-input" v-model="form.path" />
</el-tooltip>
</el-form-item>
<el-form-item label="路由名称" prop="name" class="block-tool">
<el-tooltip placement="right" content="路由name,需英文唯一,跳转页面用">
<el-input class="menu-input" v-model="form.name" />
</el-tooltip>
</el-form-item>
<el-form-item v-if="form.level != 0" label="文件路径" prop="frontRoute">
<el-input class="menu-input" v-model="form.frontRoute" />
</el-form-item>
<el-form-item v-if="form.level != 0" label="权限url" class="block-tool">
<el-tooltip placement="right" content="*号模糊匹配,逗号分割">
<el-input
class="menu-input"
v-model="form.permission"
type="textarea"
maxlength="1000"
/>
</el-tooltip>
</el-form-item>
<el-form-item label="排序值" prop="sortOrder">
<el-tooltip placement="right" content="值越小越靠前,支持小数">
<el-input-number v-model="form.sortOrder" :max="1000" :min="0" />
</el-tooltip>
</el-form-item>
<el-form-item>
<el-button
type="primary"
style="margin-right: 5px"
:loading="submitLoading"
@click="submitEdit"
>
保存
</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
</el-col>
</el-row>
</div>
</div>
<!-- 纯列表树形表格 -->
<el-table
ref="tableRef"
v-loading="loading"
:data="filteredData"
row-key="id"
default-expand-all
:tree-props="{ children: 'children', hasChildren: 'hasChildren' }"
border
stripe
class="menu-table"
@selection-change="onSelectionChange"
>
<el-table-column type="selection" width="50" align="center" />
<el-table-column prop="title" label="菜单名称" min-width="220" />
<el-table-column prop="level" label="类型" width="100" align="center">
<template #default="{ row }">
<span v-if="row">{{ row.level === 0 ? "顶级菜单" : row.level === 1 ? "一级页面" : "二级页面" }}</span>
</template>
</el-table-column>
<el-table-column prop="path" label="路由地址" min-width="160">
<template #default="{ row }">
<span>{{ row.path || "-" }}</span>
</template>
</el-table-column>
<el-table-column prop="name" label="路由名称" min-width="150">
<template #default="{ row }">
<span>{{ row.name || "-" }}</span>
</template>
</el-table-column>
<el-table-column prop="frontRoute" label="前端组件" min-width="180" show-overflow-tooltip>
<template #default="{ row }">
<span>{{ row.frontRoute || "-" }}</span>
</template>
</el-table-column>
<el-table-column prop="sortOrder" label="排序值" width="90" align="center">
<template #default="{ row }">
<span>{{ row.sortOrder ?? 0 }}</span>
</template>
</el-table-column>
<el-table-column label="操作" width="180" fixed="right" align="center">
<template #default="{ row }">
<template v-if="row.level < 2">
<a class="link-text" @click="addChildMenu(row)">添加子菜单</a>
<span class="op-split">|</span>
</template>
<a class="link-text" @click="editRow(row)">编辑</a>
<span class="op-split">|</span>
<a class="link-text error-text" @click="deleteRow(row)">删除</a>
</template>
</el-table-column>
</el-table>
</el-card>
<!-- 菜单新建 / 编辑弹窗 -->
<el-dialog
v-model="menuModalVisible"
v-model="modalVisible"
:title="modalTitle"
width="500px"
align-center
append-to-body
width="560px"
:close-on-click-modal="false"
class="menu-manage-dialog"
destroy-on-close
>
<el-form ref="formAddRef" :model="formAdd" label-width="100px" :rules="formValidate">
<div v-if="showParent">
<el-form-item label="上级节点:">{{ parentTitle }}</el-form-item>
</div>
<el-form-item label="类型" prop="type">
<div v-show="formAdd.level == 0">
<el-icon :size="16" style="margin-right: 5px"><Menu /></el-icon>
<span>顶级菜单</span>
</div>
<div v-show="formAdd.level != 0">
<el-icon :size="16" style="margin-right: 5px"><List /></el-icon>
<span>页面菜单</span>
</div>
<el-form
ref="formRef"
:model="form"
label-width="100px"
:rules="formValidate"
>
<el-form-item v-if="parentTitle" label="上级菜单">
<el-input :model-value="parentTitle" disabled />
</el-form-item>
<el-form-item label="菜单类型">
<el-tag v-if="form.level === 0" type="primary">顶级菜单</el-tag>
<el-tag v-else type="success">页面菜单 (Level {{ form.level }})</el-tag>
</el-form-item>
<el-form-item label="菜单名称" prop="title">
<el-input class="menu-input" v-model="formAdd.title" />
<el-input v-model="form.title" placeholder="请输入菜单名称" />
</el-form-item>
<el-form-item v-if="formAdd.level != 0" label="路由地址" prop="path">
<el-input v-model="formAdd.path" />
<el-form-item v-if="form.level != 0" label="路由地址" prop="path">
<el-input v-model="form.path" placeholder="英文唯一,如 /goods" />
</el-form-item>
<el-form-item label="路由名称" prop="name" class="block-tool">
<el-tooltip placement="right" content="路由name,需英文唯一,跳转页面用">
<el-input v-model="formAdd.name" />
<el-form-item label="路由名称" prop="name">
<el-tooltip placement="right" content="英文唯一,用于路由跳转">
<el-input v-model="form.name" placeholder="请输入路由名称" />
</el-tooltip>
</el-form-item>
<el-form-item v-if="formAdd.level != 0" label="文件路径" prop="frontRoute">
<el-input v-model="formAdd.frontRoute" />
<el-form-item v-if="form.level != 0" label="前端组件" prop="frontRoute">
<el-input v-model="form.frontRoute" placeholder="例如 Main 或 views/goods/index" />
</el-form-item>
<el-form-item v-if="formAdd.level != 0" label="权限url">
<el-input v-model="formAdd.permission" type="textarea" />
<div class="desc">*号模糊匹配,逗号分割</div>
<el-form-item v-if="form.level != 0" label="权限URL">
<el-input
v-model="form.permission"
type="textarea"
:rows="3"
placeholder="支持 * 号模糊匹配,多个用英文逗号分割"
/>
</el-form-item>
<el-form-item label="排序值" prop="sortOrder">
<el-tooltip placement="right" content="值越小越靠前,支持小数">
<el-input-number v-model="formAdd.sortOrder" :max="1000" :min="0" />
<el-tooltip placement="right" content="值越小越靠前">
<el-input-number v-model="form.sortOrder" :min="0" :max="1000" />
</el-tooltip>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="menuModalVisible = false">取消</el-button>
<el-button type="primary" :loading="submitLoading" @click="submitAdd">提交</el-button>
<div class="dialog-footer">
<el-button @click="modalVisible = false">取消</el-button>
<el-button type="primary" :loading="submitLoading" @click="submitForm">
确定
</el-button>
</div>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from "vue";
import { ref, reactive, computed, onMounted } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
import { Menu, List, ArrowDown } from "@element-plus/icons-vue";
import { Search } from "@element-plus/icons-vue";
import {
getStoreAllPermissionList,
addStorePermission,
editStorePermission,
deleteStorePermission,
searchStorePermission,
} from "@/api/index";
import util from "@/libs/util.js";
import { useAppStore } from "@/stores/app";
@@ -205,41 +178,38 @@ defineOptions({ name: "store-menu-manage" });
const appStore = useAppStore();
const loading = ref(true);
const strict = ref(true);
const maxHeight = ref("500px");
const expandLevel = ref(1);
const menuModalVisible = ref(false);
const submitLoading = ref(false);
const modalVisible = ref(false);
const modalTitle = ref("");
const isEditMode = ref(false);
const parentTitle = ref("");
const data = ref([]);
const selectList = ref([]);
const selectCount = ref(0);
const showParent = ref(false);
const searchKey = ref("");
const parentTitle = ref("");
const editTitle = ref("");
const modalTitle = ref("");
const expandedKeys = ref([]);
const currentNodeKey = ref(null);
const treeKey = ref(0);
const treeProps = reactive({
label: "title",
children: "children",
});
const searchQuery = ref("");
const isAllExpanded = ref(true);
const tableRef = ref(null);
const formRef = ref(null);
const form = ref({
id: "",
title: "",
name: "",
path: "",
frontRoute: "",
parentId: "",
parentId: "0",
sortOrder: 0,
level: 0,
permission: "",
});
const formAdd = ref({});
const formValidate = reactive({
title: [{ required: true, message: "菜单名称名称不能为空", trigger: "blur" }],
title: [{ required: true, message: "菜单名称不能为空", trigger: "blur" }],
name: [{ required: true, message: "路由名称不能为空", trigger: "blur" }],
path: [{ required: true, message: "路由地址不能为空", trigger: "blur" }],
frontRoute: [{ required: true, message: "文件地址不能为空", trigger: "blur" }],
frontRoute: [{ required: true, message: "前端组件路径不能为空", trigger: "blur" }],
sortOrder: [
{
required: true,
@@ -249,267 +219,217 @@ const formValidate = reactive({
},
],
});
const submitLoading = ref(false);
const data = ref([]);
const treeRef = ref(null);
const formRef = ref(null);
const formAddRef = ref(null);
// 前端树形过滤
const filteredData = computed(() => {
const query = searchQuery.value.trim().toLowerCase();
if (!query) {
return data.value;
}
const filterTree = (nodes) => {
return nodes
.map((node) => {
const matches =
(node.title && node.title.toLowerCase().includes(query)) ||
(node.name && node.name.toLowerCase().includes(query)) ||
(node.path && node.path.toLowerCase().includes(query));
const filteredChildren = node.children ? filterTree(node.children) : [];
if (matches || filteredChildren.length > 0) {
return {
...node,
children: filteredChildren.length > 0 ? filteredChildren : undefined,
};
}
return null;
})
.filter(Boolean);
};
return filterTree(data.value);
});
function init() {
getAllList();
}
function collectExpandedKeys(nodes, keys = []) {
if (!nodes) return keys;
nodes.forEach((node) => {
if (node.expand === true) {
keys.push(node.id);
}
if (node.children && node.children.length > 0) {
collectExpandedKeys(node.children, keys);
}
});
return keys;
}
function handleDropdown(name) {
if (name == "expandOne") {
expandLevel.value = 1;
getAllList();
} else if (name == "expandTwo") {
expandLevel.value = 2;
getAllList();
} else if (name == "expandThree") {
expandLevel.value = 3;
getAllList();
}
if (name == "expandAll") {
expandLevel.value = 4;
getAllList();
} else if (name == "refresh") {
getAllList();
}
}
function getAllList() {
loading.value = true;
getStoreAllPermissionList().then((res) => {
loading.value = false;
if (res.success) {
const level = expandLevel.value;
res.result.forEach(function (e) {
if (level == 1) {
if (e.level == 0) {
e.expand = false;
}
if (e.children && e.children.length > 0) {
e.children.forEach(function (c) {
if (c.level == 1) {
c.expand = false;
}
if (c.children && c.children.length > 0) {
c.children.forEach(function (b) {
if (b.level == 2) {
b.expand = false;
}
});
}
});
}
} else if (level == 2) {
if (e.level == 0) {
e.expand = true;
}
if (e.children && e.children.length > 0) {
e.children.forEach(function (c) {
if (c.level == 1) {
c.expand = false;
}
if (c.children && c.children.length > 0) {
c.children.forEach(function (b) {
if (b.level == 2) {
b.expand = false;
}
});
}
});
}
} else if (level == 3) {
if (e.level == 0) {
e.expand = true;
}
if (e.children && e.children.length > 0) {
e.children.forEach(function (c) {
if (c.level == 1) {
c.expand = true;
}
if (c.children && c.children.length > 0) {
c.children.forEach(function (b) {
if (b.level == 2) {
b.expand = false;
}
});
}
});
}
} else if (level == 4) {
if (e.level == 0) {
e.expand = true;
}
if (e.children && e.children.length > 0) {
e.children.forEach(function (c) {
if (c.level == 1) {
c.expand = true;
}
if (c.children && c.children.length > 0) {
c.children.forEach(function (b) {
if (b.level == 2) {
b.expand = true;
}
});
}
});
}
}
});
data.value = res.result;
expandedKeys.value = collectExpandedKeys(res.result);
treeKey.value += 1;
}
});
}
function onNodeClick(nodeData) {
if (nodeData) {
const menu = JSON.parse(JSON.stringify(nodeData));
form.value = menu;
editTitle.value = menu.title;
currentNodeKey.value = menu.id;
} else {
cancelEdit();
}
}
function onCheckChange(_data, { checkedNodes }) {
selectList.value = checkedNodes;
selectCount.value = checkedNodes.length;
}
function search() {
if (searchKey.value) {
loading.value = true;
searchStorePermission({ title: searchKey.value }).then((res) => {
getStoreAllPermissionList()
.then((res) => {
loading.value = false;
if (res.success) {
data.value = res.result;
expandedKeys.value = [];
treeKey.value += 1;
data.value = Array.isArray(res.result) ? res.result : [];
}
})
.catch(() => {
loading.value = false;
});
}
function onSelectionChange(selection) {
selectList.value = selection;
selectCount.value = selection.length;
}
function toggleExpandAll() {
isAllExpanded.value = !isAllExpanded.value;
const toggleRows = (rows) => {
rows.forEach((row) => {
tableRef.value?.toggleRowExpansion(row, isAllExpanded.value);
if (row.children && row.children.length) {
toggleRows(row.children);
}
});
} else {
getAllList();
}
};
toggleRows(data.value);
}
function cancelEdit() {
treeRef.value?.setCurrentKey(null);
currentNodeKey.value = null;
formRef.value.resetFields();
form.value.id = "";
editTitle.value = "";
}
function handleReset() {
formRef.value.resetFields();
form.value.frontRoute = "";
}
function submitEdit() {
formRef.value.validate((valid) => {
if (valid) {
if (!form.value.id) {
ElMessage.warning("请先点击选择要修改的菜单节点");
return;
}
submitLoading.value = true;
if (form.value.sortOrder == null) {
form.value.sortOrder = 0;
}
delete form.value.icon;
delete form.value.frontComponent;
delete form.value.buttonType;
delete form.value.updateTime;
delete form.value.selected;
delete form.value.description;
delete form.value.children;
editStorePermission(form.value).then((res) => {
submitLoading.value = false;
if (res.success) {
ElMessage.success("编辑成功");
appStore.setAdded(false);
util.initRouter();
init();
menuModalVisible.value = false;
}
});
}
});
}
function submitAdd() {
formAddRef.value.validate((valid) => {
if (valid) {
submitLoading.value = true;
addStorePermission(formAdd.value).then((res) => {
submitLoading.value = false;
if (res.success) {
ElMessage.success("添加成功");
appStore.setAdded(false);
util.initRouter();
init();
menuModalVisible.value = false;
}
});
}
});
}
function addMenu() {
if (!form.value.id) {
ElMessage.warning("请先点击选择一个菜单权限节点");
return;
}
parentTitle.value = form.value.title;
modalTitle.value = "添加子节点";
showParent.value = true;
if (form.value.level == 2) {
ElMessageBox.alert("仅支持2级菜单目录", "抱歉,不能添加啦", { type: "warning" });
return;
}
formAdd.value = {
parentId: form.value.id,
level: Number(form.value.level) + 1,
// 添加顶级菜单
function addRootMenu() {
isEditMode.value = false;
parentTitle.value = "";
modalTitle.value = "添加顶级菜单";
form.value = {
id: "",
title: "",
name: "",
path: "/",
frontRoute: "Main",
parentId: 0,
level: 0,
sortOrder: 0,
permission: "",
};
if (form.value.level == 0) {
formAdd.value.path = "/";
formAdd.value.frontRoute = "Main";
modalVisible.value = true;
}
// 添加子菜单
function addChildMenu(row) {
if (row.level >= 2) {
ElMessageBox.alert("仅支持2级菜单目录,不能再添加子菜单", "提示", {
type: "warning",
});
return;
}
menuModalVisible.value = true;
}
function addRootMenu() {
modalTitle.value = "添加顶级菜单";
showParent.value = false;
formAdd.value = {
level: 0,
isEditMode.value = false;
parentTitle.value = row.title;
modalTitle.value = `添加子菜单 (上级: ${row.title})`;
form.value = {
id: "",
title: "",
name: "",
path: "",
frontRoute: "",
parentId: row.id,
level: Number(row.level) + 1,
sortOrder: 0,
permission: "",
};
menuModalVisible.value = true;
if (row.level === 0) {
form.value.path = "/";
form.value.frontRoute = "Main";
}
modalVisible.value = true;
}
// 编辑当前菜单
function editRow(row) {
isEditMode.value = true;
parentTitle.value = "";
modalTitle.value = `编辑菜单 - ${row.title}`;
form.value = {
id: row.id,
title: row.title,
name: row.name,
path: row.path,
frontRoute: row.frontRoute,
parentId: row.parentId,
level: row.level,
sortOrder: row.sortOrder ?? 0,
permission: row.permission || "",
};
modalVisible.value = true;
}
// 提交保存
function submitForm() {
formRef.value.validate((valid) => {
if (valid) {
submitLoading.value = true;
const payload = { ...form.value };
if (payload.sortOrder == null) {
payload.sortOrder = 0;
}
delete payload.children;
delete payload.icon;
delete payload.frontComponent;
delete payload.buttonType;
delete payload.updateTime;
delete payload.selected;
delete payload.description;
const request = isEditMode.value
? editStorePermission(payload)
: addStorePermission(payload);
request
.then((res) => {
submitLoading.value = false;
if (res.success) {
ElMessage.success(isEditMode.value ? "编辑成功" : "添加成功");
appStore.setAdded(false);
util.initRouter();
modalVisible.value = false;
getAllList();
}
})
.catch(() => {
submitLoading.value = false;
});
}
});
}
// 删除单个
function deleteRow(row) {
ElMessageBox.confirm(
`您确认要删除菜单 "${row.title}" 及其下级所有数据?`,
"确认删除",
{
type: "warning",
confirmButtonText: "确定删除",
cancelButtonText: "取消",
}
)
.then(() => {
deleteStorePermission(row.id).then((res) => {
if (res.success) {
ElMessage.success("删除成功");
appStore.setAdded(false);
util.initRouter();
getAllList();
}
});
})
.catch(() => {});
}
// 批量删除
function delAll() {
if (selectCount.value <= 0) {
ElMessage.warning("您还未勾选要删除的数据");
return;
}
ElMessageBox.confirm("您确认要删除所选的 " + selectCount.value + " 条数据?", "确认删除", { type: "warning" }).then(() => {
let ids = "";
selectList.value.forEach(function (e) {
ids += e.id + ",";
});
ids = ids.substring(0, ids.length - 1);
ElMessageBox.confirm(
`您确认要删除所选的 ${selectCount.value} 条数据?`,
"确认删除",
{
type: "warning",
confirmButtonText: "确定删除",
cancelButtonText: "取消",
}
)
.then(() => {
const ids = selectList.value.map((e) => e.id).join(",");
deleteStorePermission(ids).then((res) => {
if (res.success) {
ElMessage.success("删除成功");
@@ -517,42 +437,96 @@ function delAll() {
util.initRouter();
selectList.value = [];
selectCount.value = 0;
cancelEdit();
init();
getAllList();
}
});
}).catch(() => {});
})
.catch(() => {});
}
onMounted(() => {
const height = document.documentElement.clientHeight;
maxHeight.value = Number(height - 287) + "px";
init();
});
</script>
<style lang="scss" scoped>
@import "@/styles/tree-common.scss";
.desc {
font-size: 12px;
color: #999;
}
.menu-input {
width: 362px;
}
.tree-node-icon {
margin-right: 8px;
vertical-align: middle;
}
.tree-node-label {
display: inline-flex;
align-items: center;
}
</style>
.menu-manage-container {
width: 100%;
<style lang="scss">
.menu-manage-dialog .el-dialog__body {
max-height: calc(100vh - 220px);
overflow-y: auto;
.manage-card {
border-radius: 8px;
border: 1px solid #ebeef5;
background: #fff;
:deep(.el-card__body) {
padding: 18px 20px 24px;
}
}
.table-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 12px;
margin-bottom: 16px;
.toolbar-left {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.toolbar-right {
display: flex;
align-items: center;
.search-input {
width: 240px;
}
}
}
.menu-table {
width: 100%;
:deep(.el-table__row) {
transition: background-color 0.2s ease;
}
}
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 10px;
}
}
.link-text {
color: #2d8cf0;
cursor: pointer;
text-decoration: none;
font-size: 14px;
line-height: 1;
display: inline-block;
&:hover {
color: #57a3f3;
}
&.error-text {
color: #ed4014;
&:hover {
color: #f16643;
}
}
}
.op-split {
margin: 0 8px;
color: #dcdee2;
font-size: 14px;
}
</style>

View File

@@ -1,45 +1,149 @@
.search {
.oss-operation {
margin-bottom: 2vh;
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
width: 100%;
button {
margin-right: 5px;
.oss-header-card {
margin-bottom: 16px;
border-radius: 8px;
border: 1px solid #ebeef5;
background: #fff;
:deep(.el-card__body) {
padding: 14px 20px;
}
.header-content {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 12px;
}
.header-left {
display: flex;
align-items: center;
gap: 16px;
flex-wrap: wrap;
.divider {
width: 1px;
height: 20px;
background: #e2e8f0;
}
}
.header-right {
display: flex;
align-items: center;
gap: 10px;
.search-input {
width: 240px;
}
}
}
}
.none {
display: none;
.oss-main-card {
border-radius: 8px;
border: 1px solid #ebeef5;
background: #fff;
min-height: 600px;
:deep(.el-card__body) {
padding: 16px 20px;
}
}
.oss-manage-box {
display: flex;
.file-list {
height: 100%;
box-sizing: border-box;
border-right: 1px solid #e5e6eb;
padding: 20px 12px 28px;
border-right: 1px solid #f0f2f5;
padding: 0 16px 0 0;
.group-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 4px 6px 12px;
margin-bottom: 4px;
border-bottom: 1px solid #f0f2f5;
.group-title {
display: flex;
align-items: center;
gap: 6px;
font-size: 14px;
font-weight: 600;
color: #1f2937;
}
.add-group-btn {
padding: 4px 8px;
height: auto;
font-size: 13px;
}
}
}
.pic-list {
height: 100%;
box-sizing: border-box;
padding: 0 0 24px 0;
padding: 0 0 0 18px;
display: flex;
flex-direction: column;
min-width: 0;
.oss-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 2px 0 14px;
border-bottom: 1px solid #f0f2f5;
gap: 12px;
.toolbar-left {
display: flex;
align-items: center;
gap: 14px;
.select-all-checkbox {
font-weight: 500;
}
.selection-badge {
font-size: 13px;
color: #64748b;
b {
color: #ff6a00;
font-weight: 600;
}
}
}
.toolbar-right {
display: flex;
align-items: center;
gap: 10px;
}
}
}
.img-box {
width: 100%;
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 14px;
gap: 16px 14px;
margin-top: 16px;
box-sizing: border-box;
.img-item {
width: 100%;
box-sizing: border-box;
margin: 0;
position: relative;
display: flex;
flex-direction: column;
@@ -47,10 +151,11 @@
.card {
width: 100%;
max-width: 220px;
aspect-ratio: 1 / 1;
height: auto;
border-radius: 8px;
border: 2px solid #eef2f6;
border: 1.5px solid #edf2f7;
overflow: hidden;
box-sizing: border-box;
position: relative;
@@ -58,130 +163,343 @@
display: flex;
align-items: center;
justify-content: center;
padding: 4px;
transition: all 0.2s ease;
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
cursor: pointer;
img,
.card-media {
max-width: 100%;
max-height: 100%;
width: auto;
height: auto;
object-fit: contain;
display: block;
margin: auto;
&:hover {
border-color: #ffaa66;
box-shadow: 0 6px 16px -3px rgba(255, 106, 0, 0.16);
.card-actions {
opacity: 1;
transform: translateY(0);
}
.card-checkbox {
opacity: 1;
}
}
.card-file {
&.custom-checkbox-card-checked {
border-color: #ff6a00 !important;
background: #fffaf5 !important;
box-shadow: 0 0 0 1px #ff6a00, 0 4px 12px -2px rgba(255, 106, 0, 0.25);
.card-checkbox {
opacity: 1;
}
}
.card-preview-media {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: #f5f7fa;
color: #909399;
position: relative;
overflow: hidden;
padding: 6px;
box-sizing: border-box;
img,
.card-media {
max-width: 100%;
max-height: 100%;
width: auto;
height: auto;
object-fit: contain;
display: block;
border-radius: 4px;
transition: transform 0.3s ease;
}
.video-play-overlay {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 36px;
height: 36px;
background: rgba(15, 23, 42, 0.6);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
backdrop-filter: blur(2px);
transition: all 0.2s ease;
pointer-events: none;
}
&:hover .video-play-overlay {
transform: translate(-50%, -50%) scale(1.1);
background: rgba(255, 106, 0, 0.85);
}
}
.card-file-generic {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #f8fafc 0%, #edf2f7 100%);
gap: 6px;
.file-badge {
font-size: 10px;
font-weight: 700;
padding: 1px 6px;
border-radius: 3px;
background: #e2e8f0;
color: #475569;
letter-spacing: 0.5px;
}
&.pdf .file-badge { background: #fee2e2; color: #dc2626; }
&.doc .file-badge { background: #dbeafe; color: #2563eb; }
&.xls .file-badge { background: #dcfce7; color: #16a34a; }
&.zip .file-badge { background: #ffedd5; color: #ea580c; }
&.code .file-badge { background: #f3e8ff; color: #9333ea; }
}
.card-checkbox {
position: absolute;
top: 8px;
right: 8px;
z-index: 1000;
top: 6px;
right: 6px;
z-index: 10;
height: auto;
margin: 0;
opacity: 0;
transition: opacity 0.2s ease;
:deep(.el-checkbox__inner) {
border-radius: 3px;
width: 16px;
height: 16px;
border: 1.5px solid #cbd5e1;
background: rgba(255, 255, 255, 0.9);
backdrop-filter: blur(4px);
&::after {
top: 1.5px;
left: 4.5px;
width: 3.5px;
height: 7px;
}
}
:deep(.is-checked .el-checkbox__inner) {
background-color: #ff6a00;
border-color: #ff6a00;
}
:deep(.el-checkbox__label) {
display: none;
}
}
}
.card:hover {
border-color: #ff6a00;
}
.card-actions {
position: absolute;
bottom: 0;
left: 0;
right: 0;
z-index: 15;
padding: 6px 8px;
background: linear-gradient(to top, rgba(15, 23, 42, 0.88) 0%, rgba(15, 23, 42, 0.65) 85%, transparent 100%);
backdrop-filter: blur(3px);
display: flex;
align-items: center;
justify-content: space-around;
opacity: 0;
transform: translateY(100%);
transition: all 0.22s cubic-bezier(0.4, 0, 0.2, 1);
.custom-checkbox-card-checked {
border: 2px solid #ff6a00 !important;
background: #fffaf5 !important;
}
.action-btn {
color: #f1f5f9;
padding: 3px 5px;
border-radius: 3px;
transition: all 0.15s ease;
font-size: 14px;
display: flex;
align-items: center;
justify-content: center;
.oss-card-selectable {
cursor: pointer;
&:hover {
color: #ff6a00;
background: rgba(255, 255, 255, 0.2);
transform: scale(1.15);
}
&.btn-delete:hover {
color: #f87171;
}
}
}
}
.text {
width: 100%;
max-width: 100%;
height: 20px;
cursor: pointer;
color: #252931;
font-size: 13px;
line-height: 20px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-align: center;
margin-top: 6px;
}
.sub-info {
width: 100%;
max-width: 100%;
max-width: 220px;
height: 18px;
color: #94a3b8;
font-size: 11px;
cursor: pointer;
color: #1e293b;
font-size: 12px;
line-height: 18px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-align: center;
margin-top: 2px;
margin-top: 6px;
transition: color 0.15s ease;
&:hover {
color: #ff6a00;
}
}
.sub-info {
width: 100%;
max-width: 220px;
height: 16px;
color: #94a3b8;
font-size: 11px;
line-height: 16px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-align: center;
margin-top: 1px;
}
}
}
.empty-box {
padding: 60px 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.page-box {
display: flex;
justify-content: flex-end;
margin-top: 20px;
}
.pagination-box {
display: flex;
flex-direction: row-reverse;
}
.demo-tree-render .el-tree-node__label {
width: 94%;
margin-top: 24px;
padding-top: 14px;
border-top: 1px solid #f0f2f5;
}
}
.oss-group-tree {
padding: 4px 0 0;
.pc-image-picker-dialog {
margin-top: 16px !important;
margin-bottom: 16px !important;
max-height: calc(100vh - 32px);
display: flex;
flex-direction: column;
.el-dialog__header {
padding: 12px 20px 8px;
flex-shrink: 0;
:deep(.el-tree-node__expand-icon) {
display: none;
}
.el-dialog__body {
padding: 6px 16px 12px;
overflow: hidden !important;
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* IE and Edge */
flex: 1;
:deep(.el-tree-node) {
margin-bottom: 4px;
}
&::-webkit-scrollbar {
display: none !important; /* Chrome, Safari, Edge */
width: 0 !important;
height: 0 !important;
:deep(.el-tree-node__content) {
padding: 0 10px !important;
height: 38px;
border-radius: 6px;
font-size: 14px;
line-height: 38px;
color: #334155;
transition: all 0.15s ease;
}
:deep(.el-tree-node__content:hover) {
background: #f1f5f9;
}
:deep(.el-tree-node.is-current > .el-tree-node__content) {
background: #fff7ed;
color: #ff6a00;
font-weight: 600;
.oss-tree-icon {
color: #ff6a00;
}
}
}
.oss-tree-node {
display: inline-flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding-right: 4px;
font-size: 13px;
.node-left {
display: flex;
align-items: center;
gap: 8px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
.oss-tree-icon {
font-size: 16px;
color: #94a3b8;
transition: color 0.15s ease;
}
.node-title {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.oss-tree-more {
cursor: pointer;
color: #94a3b8;
padding: 2px 6px;
border-radius: 4px;
font-size: 14px;
opacity: 0;
transition: all 0.15s ease;
&:hover {
background: #e2e8f0;
color: #1e293b;
}
}
&:hover .oss-tree-more {
opacity: 1;
}
}
.file-preview-fallback {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 48px 16px;
color: #606266;
.file-preview-name {
margin: 16px 0 8px;
font-size: 16px;
font-weight: 500;
word-break: break-all;
text-align: center;
}
.file-preview-tip {
margin: 0 0 20px;
color: #909399;
}
}
// 弹窗组件调用时的兼容样式
.is-component-search {
.is-component-card {
border: none !important;
@@ -196,22 +514,32 @@
.oss-manage-box {
.file-list {
padding: 0 10px 10px;
padding: 0 10px 10px 0;
}
.pic-list {
padding: 0;
padding: 0 0 0 10px;
}
.oss-operation {
margin-bottom: 6px;
.oss-toolbar {
padding: 0 0 8px;
margin-bottom: 4px;
}
.img-box {
margin-top: 6px;
margin-top: 8px;
gap: 10px 12px;
.img-item {
.card {
max-width: 128px;
max-width: 130px;
margin: 0 auto;
// 在选图组件模式下,不需要hover遮罩把选择搞复杂
.card-actions {
display: none !important;
}
.card-checkbox {
opacity: 1 !important;
}
}
.text {
height: 18px;
@@ -228,12 +556,17 @@
}
}
.page-box {
margin-top: 8px;
margin-top: 10px;
padding-top: 8px;
}
.oss-select-footer {
margin-top: 8px;
padding-top: 8px;
margin-top: 12px;
padding-top: 10px;
border-top: 1px solid #ededed;
display: flex;
justify-content: flex-end;
gap: 10px;
}
}
@@ -241,13 +574,13 @@
padding: 0;
.el-tree-node {
margin-bottom: 4px;
margin-bottom: 2px;
}
.el-tree-node__content {
height: 36px;
line-height: 36px;
font-size: 14px;
height: 34px;
line-height: 34px;
font-size: 13px;
}
}
}

File diff suppressed because it is too large Load Diff