chore(config): 添加项目配置文件和隐私协议

- 添加 .editorconfig 文件统一代码风格
- 添加 .env.development 和 .env.example 环境配置文件
- 添加 .eslintignore 和 .eslintrc.js 代码检查配置
- 添加 .gitignore 版本控制忽略文件配置
- 添加 .prettierignore 格式化忽略配置
- 添加隐私协议HTML文件
- 添加API密钥管理组件基础结构
This commit is contained in:
2026-01-26 14:05:01 +08:00
commit 482e2a2718
1192 changed files with 238401 additions and 0 deletions

View File

@@ -0,0 +1,42 @@
<!-- 搜索表单 -->
<template>
<a-space :size="10" style="flex-wrap: wrap">
<a-button type="primary" class="ele-btn-icon" @click="add">
<template #icon>
<PlusOutlined />
</template>
<span>添加</span>
</a-button>
</a-space>
</template>
<script lang="ts" setup>
import { PlusOutlined } from '@ant-design/icons-vue';
import type { GradeParam } from '@/api/user/grade/model';
import { watch } from 'vue';
const props = withDefaults(
defineProps<{
// 选中的角色
selection?: [];
}>(),
{}
);
const emit = defineEmits<{
(e: 'search', where?: GradeParam): void;
(e: 'add'): void;
(e: 'remove'): void;
(e: 'batchMove'): void;
}>();
// 新增
const add = () => {
emit('add');
};
watch(
() => props.selection,
() => {}
);
</script>

View File

@@ -0,0 +1,958 @@
<!-- 编辑弹窗 -->
<template>
<ele-modal
:width="1200"
:visible="visible"
:maskClosable="false"
:maxable="maxable"
:title="isUpdate ? '编辑优惠券' : '新增优惠券'"
:body-style="{ paddingBottom: '28px' }"
@update:visible="updateVisible"
@ok="save"
>
<a-form
ref="formRef"
:model="form"
:rules="rules"
:label-col="{ span: 6 }"
:wrapper-col="{ span: 18 }"
>
<!-- 基本信息 -->
<a-divider orientation="left">
<span style="color: #1890ff; font-weight: 600">基本信息</span>
</a-divider>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="优惠券名称" name="name">
<a-input placeholder="请输入优惠券名称" v-model:value="form.name" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="优惠券类型" name="type">
<a-select
v-model:value="form.type"
placeholder="请选择优惠券类型"
@change="onTypeChange"
>
<a-select-option :value="10">
<div class="coupon-type-option">
<a-tag color="red">满减券</a-tag>
<span>满足条件减免金额</span>
</div>
</a-select-option>
<a-select-option :value="20">
<div class="coupon-type-option">
<a-tag color="orange">折扣券</a-tag>
<span>按比例折扣</span>
</div>
</a-select-option>
<a-select-option :value="30">
<div class="coupon-type-option">
<a-tag color="green">免费券</a-tag>
<span>免费使用</span>
</div>
</a-select-option>
</a-select>
</a-form-item>
</a-col>
</a-row>
<a-form-item label="优惠券描述" name="description">
<a-textarea
placeholder="请输入优惠券描述"
v-model:value="form.description"
:rows="3"
:maxlength="200"
show-count
/>
</a-form-item>
<!-- 优惠设置 -->
<a-divider orientation="left">
<span style="color: #1890ff; font-weight: 600">优惠设置</span>
</a-divider>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="最低消费金额" name="minPrice">
<a-input-number
:min="0"
:precision="2"
placeholder="请输入最低消费金额"
v-model:value="form.minPrice"
style="width: 100%"
>
<template #addonAfter></template>
</a-input-number>
</a-form-item>
</a-col>
<a-col :span="12">
<!-- 满减券设置 -->
<a-form-item
v-if="form.type === 10"
label="减免金额"
name="reducePrice"
>
<a-input-number
:min="0"
:precision="2"
placeholder="请输入减免金额"
v-model:value="form.reducePrice"
style="width: 100%"
>
<template #addonAfter></template>
</a-input-number>
</a-form-item>
<!-- 折扣券设置 -->
<a-form-item v-if="form.type === 20" label="折扣率" name="discount">
<a-input-number
:min="0.1"
:max="99.9"
:precision="1"
placeholder="请输入折扣率"
v-model:value="form.discount"
style="width: 100%"
>
<template #addonAfter></template>
</a-input-number>
</a-form-item>
</a-col>
</a-row>
<!-- 有效期设置 -->
<a-divider orientation="left">
<span style="color: #1890ff; font-weight: 600">有效期设置</span>
</a-divider>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="到期类型" name="expireType">
<a-radio-group
v-model:value="form.expireType"
@change="onExpireTypeChange"
>
<a-radio :value="10">
<a-tag color="blue">领取后生效</a-tag>
<span style="margin-left: 8px">用户领取后开始计时</span>
</a-radio>
<a-radio :value="20">
<a-tag color="purple">固定时间</a-tag>
<span style="margin-left: 8px">指定有效期时间段</span>
</a-radio>
</a-radio-group>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item
v-if="form.expireType === 10"
label="有效天数"
name="expireDay"
>
<a-input-number
:min="1"
placeholder="请输入有效天数"
v-model:value="form.expireDay"
style="width: 100%"
>
<template #addonAfter></template>
</a-input-number>
</a-form-item>
</a-col>
</a-row>
<div v-if="form.expireType === 20" class="fixed-time-config">
<a-alert
message="固定时间配置"
description="设置优惠券的固定有效期时间段"
type="info"
show-icon
style="margin-bottom: 16px"
/>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="有效期开始时间" name="startTime">
<a-date-picker
placeholder="请选择开始时间"
v-model:value="form.startTime"
style="width: 100%"
show-time
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="有效期结束时间" name="endTime">
<a-date-picker
placeholder="请选择结束时间"
v-model:value="form.endTime"
style="width: 100%"
show-time
/>
</a-form-item>
</a-col>
</a-row>
</div>
<!-- 适用范围 -->
<a-divider orientation="left">
<span style="color: #1890ff; font-weight: 600">适用范围</span>
</a-divider>
<a-form-item label="适用范围" name="applyRange">
<a-radio-group
v-model:value="form.applyRange"
@change="onApplyRangeChange"
>
<a-radio :value="10">
<a-tag color="cyan">全部商品</a-tag>
<span style="margin-left: 8px">适用于所有商品</span>
</a-radio>
<a-radio :value="20">
<a-tag color="geekblue">指定商品</a-tag>
<span style="margin-left: 8px">仅适用于指定商品</span>
</a-radio>
<a-radio :value="30">
<a-tag color="purple">指定分类</a-tag>
<span style="margin-left: 8px">适用于指定商品分类</span>
</a-radio>
</a-radio-group>
</a-form-item>
<div v-if="form.applyRange === 30" class="apply-range-config">
<a-alert
message="分类限制配置"
description="选择优惠券适用的商品分类"
type="info"
show-icon
style="margin-bottom: 16px"
/>
<a-form-item label="限制分类">
<a-cascader
v-model:value="applyCateListValue"
:options="goodsCateList"
multiple
placeholder="请选择商品分类"
:fieldNames="{ label: 'title', value: 'categoryId' }"
style="width: 100%"
/>
</a-form-item>
</div>
<div v-if="form.applyRange === 20" class="apply-range-config">
<a-alert
message="商品限制配置"
description="选择优惠券适用的具体商品"
type="info"
show-icon
style="margin-bottom: 16px"
/>
<a-form-item label="限制商品">
<a-select
mode="multiple"
v-model:value="applyItemListValue"
placeholder="请选择商品"
style="width: 100%"
:filter-option="false"
:show-search="true"
@search="searchGoods"
>
<a-select-option
v-for="(item, index) in goodsList"
:key="index"
:value="item.goodsId"
>
{{ item.name }}
</a-select-option>
</a-select>
</a-form-item>
</div>
<!-- 发放设置 -->
<a-divider orientation="left">
<span style="color: #1890ff; font-weight: 600">发放设置</span>
</a-divider>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="发放总数量" name="totalCount">
<a-input-number
:min="-1"
placeholder="请输入发放总数量"
v-model:value="form.totalCount"
style="width: 100%"
>
<template #addonAfter></template>
</a-input-number>
<div style="color: #999; font-size: 12px; margin-top: 4px">
-1 表示无限制
</div>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="每人限领数量" name="limitPerUser">
<a-input-number
:min="-1"
placeholder="请输入每人限领数量"
v-model:value="form.limitPerUser"
style="width: 100%"
>
<template #addonAfter></template>
</a-input-number>
<div style="color: #999; font-size: 12px; margin-top: 4px">
-1 表示无限制
</div>
</a-form-item>
</a-col>
</a-row>
<!-- 状态设置 -->
<a-divider orientation="left">
<span style="color: #1890ff; font-weight: 600">状态设置</span>
</a-divider>
<a-row :gutter="16">
<a-col :span="8">
<a-form-item label="启用状态" name="enabled">
<a-switch
v-model:checked="form.enabled"
:checked-value="1"
:un-checked-value="0"
checked-children="启用"
un-checked-children="禁用"
/>
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item label="显示状态" name="status">
<a-switch
v-model:checked="form.status"
:checked-value="0"
:un-checked-value="1"
checked-children="显示"
un-checked-children="隐藏"
/>
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item label="排序" name="sortNumber">
<a-input-number
:min="0"
placeholder="数字越小越靠前"
v-model:value="form.sortNumber"
style="width: 100%"
/>
</a-form-item>
</a-col>
</a-row>
<!-- 优惠券预览 -->
<div class="coupon-preview" v-if="form.name && form.type">
<a-divider orientation="left">
<span style="color: #1890ff; font-weight: 600">优惠券预览</span>
</a-divider>
<div class="coupon-card">
<div class="coupon-header">
<div class="coupon-type">
<a-tag :color="getCouponTypeColor()">{{
getCouponTypeName()
}}</a-tag>
</div>
<div class="coupon-value">
{{ getCouponValueText() }}
</div>
</div>
<div class="coupon-body">
<div class="coupon-name">{{ form.name }}</div>
<div class="coupon-desc">{{ form.description || '暂无描述' }}</div>
<div class="coupon-condition">
{{ form.minPrice || 0 }}元可用
</div>
</div>
<div class="coupon-footer">
<div class="coupon-expire">
{{ getExpireText() }}
</div>
</div>
</div>
</div>
</a-form>
</ele-modal>
</template>
<script lang="ts" setup>
import { ref, reactive, watch } from 'vue';
import { Form, message } from 'ant-design-vue';
import { assignObject } from 'ele-admin-pro';
import { addShopCoupon, updateShopCoupon } from '@/api/shop/shopCoupon';
import { ShopCoupon } from '@/api/shop/shopCoupon/model';
import { FormInstance } from 'ant-design-vue/es/form';
import { ShopGoods } from '@/api/shop/shopGoods/model';
import { listShopGoods } from '@/api/shop/shopGoods';
import { ShopGoodsCategory } from '@/api/shop/shopGoodsCategory/model';
import { listShopGoodsCategory } from '@/api/shop/shopGoodsCategory';
import dayjs from 'dayjs';
// 是否是修改
const isUpdate = ref(false);
const useForm = Form.useForm;
const props = defineProps<{
// 弹窗是否打开
visible: boolean;
// 修改回显的数据
data?: ShopCoupon | null;
}>();
const emit = defineEmits<{
(e: 'done'): void;
(e: 'update:visible', visible: boolean): void;
}>();
// 提交状态
const loading = ref(false);
// 是否显示最大化切换按钮
const maxable = ref(true);
// 表格选中数据
const formRef = ref<FormInstance | null>(null);
// 表单数据
const form = reactive<ShopCoupon>({
id: undefined,
name: '',
description: '',
type: 10,
reducePrice: undefined,
discount: undefined,
minPrice: 0,
expireType: 10,
expireDay: 30,
startTime: undefined,
endTime: undefined,
applyRange: 10,
applyRangeConfig: '',
isExpire: 0,
sortNumber: 100,
status: 0,
deleted: 0,
userId: undefined,
tenantId: undefined,
createTime: undefined,
updateTime: undefined,
totalCount: -1,
issuedCount: 0,
limitPerUser: 1,
enabled: 1
});
// 商品分类列表
const goodsCateList = ref<ShopGoodsCategory[]>([]);
// 商品列表
const goodsList = ref<ShopGoods[]>([]);
// 适用分类值
const applyCateListValue = ref<number[]>([]);
// 适用商品值
const applyItemListValue = ref<number[]>([]);
/* 更新visible */
const updateVisible = (value: boolean) => {
emit('update:visible', value);
};
// 表单验证规则
const rules = reactive({
name: [
{
required: true,
message: '请输入优惠券名称',
trigger: 'blur'
},
{
min: 2,
max: 50,
message: '优惠券名称长度应在2-50个字符之间',
trigger: 'blur'
}
],
type: [
{
required: true,
message: '请选择优惠券类型',
trigger: 'change'
}
],
reducePrice: [
{
validator: (rule: any, value: any) => {
if (form.type === 10 && (!value || value <= 0)) {
return Promise.reject('满减券减免金额必须大于0');
}
return Promise.resolve();
},
trigger: 'blur'
}
],
discount: [
{
validator: (rule: any, value: any) => {
if (form.type === 20 && (!value || value <= 0 || value >= 100)) {
return Promise.reject('折扣率必须在0-100之间');
}
return Promise.resolve();
},
trigger: 'blur'
}
],
minPrice: [
{
required: true,
message: '请输入最低消费金额',
trigger: 'blur'
}
],
expireType: [
{
required: true,
message: '请选择到期类型',
trigger: 'change'
}
],
expireDay: [
{
validator: (rule: any, value: any) => {
if (form.expireType === 10 && (!value || value <= 0)) {
return Promise.reject('有效天数必须大于0');
}
return Promise.resolve();
},
trigger: 'blur'
}
],
startTime: [
{
validator: (rule: any, value: any) => {
if (form.expireType === 20 && !value) {
return Promise.reject('请选择有效期开始时间');
}
return Promise.resolve();
},
trigger: 'change'
}
],
endTime: [
{
validator: (rule: any, value: any) => {
if (form.expireType === 20 && !value) {
return Promise.reject('请选择有效期结束时间');
}
if (
form.expireType === 20 &&
value &&
form.startTime &&
dayjs(value).isBefore(dayjs(form.startTime))
) {
return Promise.reject('结束时间不能早于开始时间');
}
return Promise.resolve();
},
trigger: 'change'
}
],
totalCount: [
{
required: true,
message: '请输入发放总数量',
trigger: 'blur'
}
],
limitPerUser: [
{
required: true,
message: '请输入每人限领数量',
trigger: 'blur'
}
]
});
/* 优惠券类型改变 */
const onTypeChange = (value: number) => {
// 清空相关字段
if (value !== 10) {
form.reducePrice = undefined;
}
if (value !== 20) {
form.discount = undefined;
}
};
/* 到期类型改变 */
const onExpireTypeChange = (e: any) => {
const value = e.target.value;
if (value === 10) {
form.startTime = undefined;
form.endTime = undefined;
form.expireDay = 30;
} else {
form.expireDay = undefined;
}
};
/* 适用范围改变 */
const onApplyRangeChange = (e: any) => {
const value = e.target.value;
applyCateListValue.value = [];
applyItemListValue.value = [];
form.applyRangeConfig = '';
};
/* 搜索商品 */
const searchGoods = async (value: string) => {
if (value && value.trim()) {
try {
const res = await listShopGoods({ keywords: value.trim() });
goodsList.value = res || [];
console.log('搜索到的商品:', goodsList.value);
} catch (e) {
console.error('搜索商品失败:', e);
goodsList.value = [];
}
}
};
/* 获取优惠券类型颜色 */
const getCouponTypeColor = () => {
const colorMap = {
10: 'red',
20: 'orange',
30: 'green'
};
return colorMap[form.type] || 'blue';
};
/* 获取优惠券类型名称 */
const getCouponTypeName = () => {
const nameMap = {
10: '满减券',
20: '折扣券',
30: '免费券'
};
return nameMap[form.type] || '优惠券';
};
/* 获取优惠券价值文本 */
const getCouponValueText = () => {
switch (form.type) {
case 10:
return `${form.reducePrice || 0}`;
case 20:
return `${form.discount || 0}`;
case 30:
return '免费';
default:
return '优惠';
}
};
/* 获取有效期文本 */
const getExpireText = () => {
if (form.expireType === 10) {
return `领取后${form.expireDay || 0}天内有效`;
} else {
const start = form.startTime
? dayjs(form.startTime).format('YYYY.MM.DD')
: '';
const end = form.endTime ? dayjs(form.endTime).format('YYYY.MM.DD') : '';
return start && end ? `${start} - ${end}` : '请设置有效期';
}
};
const { resetFields } = useForm(form, rules);
/* 保存编辑 */
const save = () => {
if (!formRef.value) {
return;
}
formRef.value
.validate()
.then(() => {
loading.value = true;
const formData = {
...form
};
// 处理时间字段转换
if (formData.startTime && dayjs.isDayjs(formData.startTime)) {
formData.startTime = formData.startTime.format('YYYY-MM-DD HH:mm:ss');
}
if (formData.endTime && dayjs.isDayjs(formData.endTime)) {
formData.endTime = formData.endTime.format('YYYY-MM-DD HH:mm:ss');
}
// 处理适用范围配置
if (form.applyRange === 20 && applyItemListValue.value.length) {
formData.couponApplyItemList = applyItemListValue.value.map((pk) => ({
pk,
type: 0
}));
} else {
formData.couponApplyItemList = [];
}
if (form.applyRange === 30 && applyCateListValue.value.length) {
formData.couponApplyCateList = applyCateListValue.value.map(
(cateId) => ({
cateId,
cateLevel: 0
})
);
} else {
formData.couponApplyCateList = [];
}
const saveOrUpdate = isUpdate.value ? updateShopCoupon : addShopCoupon;
saveOrUpdate(formData)
.then((msg) => {
loading.value = false;
message.success(msg);
updateVisible(false);
emit('done');
})
.catch((e) => {
loading.value = false;
message.error(e.message);
});
})
.catch(() => {});
};
/* 获取商品列表 */
const getGoodsList = async () => {
try {
const res = await listShopGoods({ pageSize: 50 });
goodsList.value = res || [];
console.log('获取到的商品列表:', goodsList.value);
} catch (e) {
console.error('获取商品列表失败:', e);
goodsList.value = [];
}
};
/* 获取商品分类列表 */
const getGoodsCateList = async () => {
try {
const res = await listShopGoodsCategory();
goodsCateList.value = res || [];
console.log('获取到的商品分类列表:', goodsCateList.value);
} catch (e) {
console.error('获取商品分类列表失败:', e);
goodsCateList.value = [];
}
};
watch(
() => props.visible,
async (visible) => {
if (visible) {
await getGoodsList();
await getGoodsCateList();
if (props.data) {
assignObject(form, props.data);
// 处理时间字段转换
if (props.data.startTime) {
form.startTime = dayjs(props.data.startTime);
}
if (props.data.endTime) {
form.endTime = dayjs(props.data.endTime);
}
// 处理适用范围数据
if (
props.data.couponApplyCateList &&
props.data.couponApplyCateList.length > 0
) {
applyCateListValue.value = props.data.couponApplyCateList.map(
(item) => item.cateId
);
}
if (
props.data.couponApplyItemList &&
props.data.couponApplyItemList.length > 0
) {
applyItemListValue.value = props.data.couponApplyItemList.map(
(item) => item.pk
);
}
isUpdate.value = true;
} else {
// 重置为默认值
Object.assign(form, {
id: undefined,
name: '',
description: '',
type: 10,
reducePrice: undefined,
discount: undefined,
minPrice: 0,
expireType: 10,
expireDay: 30,
startTime: undefined,
endTime: undefined,
applyRange: 10,
applyRangeConfig: '',
isExpire: 0,
sortNumber: 100,
status: 0,
deleted: 0,
userId: undefined,
tenantId: undefined,
createTime: undefined,
updateTime: undefined,
totalCount: -1,
issuedCount: 0,
limitPerUser: 1,
enabled: 1
});
isUpdate.value = false;
}
} else {
applyCateListValue.value = [];
applyItemListValue.value = [];
resetFields();
}
},
{ immediate: true }
);
</script>
<style lang="less" scoped>
.coupon-type-option {
display: flex;
align-items: center;
.ant-tag {
margin-right: 8px;
}
span {
color: #666;
font-size: 12px;
}
}
.fixed-time-config,
.apply-range-config {
background: #fafafa;
padding: 16px;
border-radius: 6px;
margin-bottom: 16px;
}
.coupon-preview {
margin-top: 24px;
.coupon-card {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 12px;
padding: 20px;
color: white;
position: relative;
overflow: hidden;
&::before {
content: '';
position: absolute;
top: 0;
right: 0;
width: 100px;
height: 100px;
background: rgba(255, 255, 255, 0.1);
border-radius: 50%;
transform: translate(30px, -30px);
}
.coupon-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
.coupon-value {
font-size: 24px;
font-weight: bold;
}
}
.coupon-body {
.coupon-name {
font-size: 18px;
font-weight: 600;
margin-bottom: 8px;
}
.coupon-desc {
font-size: 14px;
opacity: 0.9;
margin-bottom: 12px;
}
.coupon-condition {
font-size: 12px;
opacity: 0.8;
}
}
.coupon-footer {
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid rgba(255, 255, 255, 0.2);
.coupon-expire {
font-size: 12px;
opacity: 0.8;
}
}
}
}
:deep(.ant-divider-horizontal.ant-divider-with-text-left) {
margin: 24px 0 16px 0;
.ant-divider-inner-text {
padding: 0 16px 0 0;
}
}
:deep(.ant-form-item) {
margin-bottom: 16px;
}
:deep(.ant-radio) {
display: flex;
align-items: center;
margin-bottom: 8px;
.ant-radio-inner {
margin-right: 8px;
}
}
:deep(.ant-select-selection-item) {
display: flex;
align-items: center;
}
:deep(.ant-input-number) {
width: 100%;
}
:deep(.ant-alert) {
.ant-alert-message {
font-weight: 600;
}
}
</style>

View File

@@ -0,0 +1,744 @@
<template>
<a-page-header :title="getPageTitle()" @back="() => $router.go(-1)">
<template #extra>
<a-space>
<a-button type="primary" @click="openEdit()">
<template #icon>
<PlusOutlined />
</template>
新增优惠券
</a-button>
<a-button @click="reload()">
<template #icon>
<ReloadOutlined />
</template>
刷新
</a-button>
</a-space>
</template>
<a-card :bordered="false" :body-style="{ padding: '16px' }">
<!-- 搜索区域 -->
<div class="search-container">
<a-form layout="inline" :model="searchForm" class="search-form">
<a-form-item label="优惠券名称">
<a-input
v-model:value="searchForm.name"
placeholder="请输入优惠券名称"
allow-clear
style="width: 200px"
/>
</a-form-item>
<a-form-item label="优惠券类型">
<a-select
v-model:value="searchForm.type"
placeholder="请选择类型"
allow-clear
style="width: 150px"
>
<a-select-option :value="10">满减券</a-select-option>
<a-select-option :value="20">折扣券</a-select-option>
<a-select-option :value="30">免费券</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="到期类型">
<a-select
v-model:value="searchForm.expireType"
placeholder="请选择到期类型"
allow-clear
style="width: 150px"
>
<a-select-option :value="10">领取后生效</a-select-option>
<a-select-option :value="20">固定时间</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="是否过期">
<a-select
v-model:value="searchForm.isExpire"
placeholder="请选择状态"
allow-clear
style="width: 120px"
>
<a-select-option :value="0">未过期</a-select-option>
<a-select-option :value="1">已过期</a-select-option>
</a-select>
</a-form-item>
<a-form-item>
<a-space>
<a-button type="primary" @click="handleSearch">
<template #icon>
<SearchOutlined />
</template>
搜索
</a-button>
<a-button @click="handleReset">
<template #icon>
<ClearOutlined />
</template>
重置
</a-button>
</a-space>
</a-form-item>
</a-form>
</div>
<!-- 批量操作区域 -->
<div v-if="selection.length > 0" class="batch-actions">
<a-alert
:message="`已选择 ${selection.length} 项`"
type="info"
show-icon
style="margin-bottom: 16px"
>
<template #action>
<a-space>
<a-button size="small" @click="clearSelection">取消选择</a-button>
<a-popconfirm
title="确定要删除选中的优惠券吗?"
@confirm="removeBatch"
>
<a-button size="small" danger>批量删除</a-button>
</a-popconfirm>
</a-space>
</template>
</a-alert>
</div>
<!-- 表格 -->
<ele-pro-table
ref="tableRef"
row-key="id"
:columns="columns"
:datasource="datasource"
:customRow="customRow"
:row-selection="rowSelection"
:scroll="{ x: 1800 }"
tool-class="ele-toolbar-form"
class="coupon-table"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'name'">
<div class="coupon-name">
<a-typography-text strong>{{ record.name }}</a-typography-text>
<div class="coupon-description">{{
record.description || '暂无描述'
}}</div>
</div>
</template>
<template v-if="column.key === 'type'">
<a-tag :color="getCouponTypeColor(record.type)">
{{ getCouponTypeText(record.type) }}
</a-tag>
</template>
<template v-if="column.key === 'value'">
<div class="coupon-value">
<template v-if="record.type === 10">
<span class="value-amount"
>¥{{ Number(record.reducePrice).toFixed(2) || '0.00' }}</span
>
<div class="value-condition"
>¥{{
Number(record.minPrice).toFixed(2) || '0.00'
}}可用</div
>
</template>
<template v-else-if="record.type === 20">
<span class="value-discount">{{ record.discount }}</span>
<div class="value-condition"
>¥{{
Number(record.minPrice)?.toFixed(2) || '0.00'
}}可用</div
>
</template>
<template v-else-if="record.type === 30">
<span class="value-free">免费券</span>
<div class="value-condition"
>¥{{
Number(record.minPrice)?.toFixed(2) || '0.00'
}}可用</div
>
</template>
</div>
</template>
<template v-if="column.key === 'expireInfo'">
<div class="expire-info">
<a-tag :color="record.expireType === 10 ? 'blue' : 'green'">
{{ record.expireType === 10 ? '领取后生效' : '固定时间' }}
</a-tag>
<div class="expire-detail">
<template v-if="record.expireType === 10">
{{ record.expireDay }}天有效
</template>
<template v-else>
{{ formatDate(record.startTime) }}
{{ formatDate(record.endTime) }}
</template>
</div>
</div>
</template>
<template v-if="column.key === 'applyRange'">
<a-tag :color="getApplyRangeColor(record.applyRange)">
{{ getApplyRangeText(record.applyRange) }}
</a-tag>
</template>
<template v-if="column.key === 'usage'">
<div class="usage-info">
<a-progress
:percent="getUsagePercent(record)"
:stroke-color="getUsageColor(record)"
size="small"
/>
<div class="usage-text">
已发放: {{ record.issuedCount || 0 }}
<template v-if="record.totalCount !== -1">
/ {{ record.totalCount }}
</template>
<template v-else> (无限制) </template>
</div>
</div>
</template>
<template v-if="column.key === 'isExpire'">
<a-tag :color="record.isExpire === 0 ? 'success' : 'error'">
{{ record.isExpire === 0 ? '未过期' : '已过期' }}
</a-tag>
</template>
<template v-if="column.key === 'action'">
<a-space>
<a-tooltip title="编辑">
<a-button type="link" size="small" @click="openEdit(record)">
<template #icon>
<EditOutlined />
</template>
</a-button>
</a-tooltip>
<a-tooltip title="复制">
<a-button type="link" size="small" @click="copyRecord(record)">
<template #icon>
<CopyOutlined />
</template>
</a-button>
</a-tooltip>
<a-popconfirm
title="确定要删除此优惠券吗?"
@confirm="remove(record)"
>
<a-tooltip title="删除">
<a-button type="link" size="small" danger>
<template #icon>
<DeleteOutlined />
</template>
</a-button>
</a-tooltip>
</a-popconfirm>
</a-space>
</template>
</template>
</ele-pro-table>
</a-card>
<!-- 编辑弹窗 -->
<ShopCouponEdit v-model:visible="showEdit" :data="current" @done="reload" />
</a-page-header>
</template>
<script lang="ts" setup>
import { createVNode, ref, reactive, computed } from 'vue';
import { message, Modal } from 'ant-design-vue';
import {
ExclamationCircleOutlined,
PlusOutlined,
ReloadOutlined,
SearchOutlined,
ClearOutlined,
EditOutlined,
DeleteOutlined,
CopyOutlined
} from '@ant-design/icons-vue';
import type { EleProTable } from 'ele-admin-pro';
import { toDateString } from 'ele-admin-pro';
import type {
DatasourceFunction,
ColumnItem
} from 'ele-admin-pro/es/ele-pro-table/types';
import { getPageTitle } from '@/utils/common';
import ShopCouponEdit from './components/shopCouponEdit.vue';
import {
pageShopCoupon,
removeShopCoupon,
removeBatchShopCoupon
} from '@/api/shop/shopCoupon';
import type {
ShopCoupon,
ShopCouponParam
} from '@/api/shop/shopCoupon/model';
// 表格实例
const tableRef = ref<InstanceType<typeof EleProTable> | null>(null);
// 表格选中数据
const selection = ref<ShopCoupon[]>([]);
// 当前编辑数据
const current = ref<ShopCoupon | null>(null);
// 是否显示编辑弹窗
const showEdit = ref(false);
// 加载状态
const loading = ref(false);
// 搜索表单
const searchForm = reactive<ShopCouponParam>({
keywords: '',
name: undefined,
type: undefined
});
// 表格数据源
const datasource: DatasourceFunction = ({
page,
limit,
where,
orders,
filters
}) => {
const params = {
...where,
...searchForm,
...orders,
page,
limit
};
if (filters) {
Object.assign(params, filters);
}
return pageShopCoupon(params);
};
// 行选择配置
const rowSelection = computed(() => ({
selectedRowKeys: selection.value.map((item) => item.id),
onChange: (
selectedRowKeys: (string | number)[],
selectedRows: ShopCoupon[]
) => {
selection.value = selectedRows;
},
onSelect: (record: ShopCoupon, selected: boolean) => {
if (selected) {
selection.value.push(record);
} else {
const index = selection.value.findIndex(
(item) => item.id === record.id
);
if (index > -1) {
selection.value.splice(index, 1);
}
}
},
onSelectAll: (
selected: boolean,
selectedRows: ShopCoupon[],
changeRows: ShopCoupon[]
) => {
if (selected) {
changeRows.forEach((row) => {
if (!selection.value.find((item) => item.id === row.id)) {
selection.value.push(row);
}
});
} else {
changeRows.forEach((row) => {
const index = selection.value.findIndex((item) => item.id === row.id);
if (index > -1) {
selection.value.splice(index, 1);
}
});
}
}
}));
// 表格列配置
const columns = ref<ColumnItem[]>([
{
title: 'ID',
dataIndex: 'id',
key: 'id',
align: 'center',
width: 80,
fixed: 'left'
},
{
title: '优惠券信息',
dataIndex: 'name',
key: 'name',
align: 'left',
width: 250,
fixed: 'left',
ellipsis: true
},
{
title: '类型',
dataIndex: 'type',
key: 'type',
align: 'center',
width: 100
},
{
title: '优惠价值',
dataIndex: 'value',
key: 'value',
align: 'center',
width: 150
},
{
title: '有效期信息',
dataIndex: 'expireInfo',
key: 'expireInfo',
align: 'center',
width: 180
},
{
title: '适用范围',
dataIndex: 'applyRange',
key: 'applyRange',
align: 'center',
width: 120
},
{
title: '使用情况',
dataIndex: 'usage',
key: 'usage',
align: 'center',
width: 150
},
{
title: '每人限领',
dataIndex: 'limitPerUser',
key: 'limitPerUser',
align: 'center',
width: 100,
customRender: ({ text }) => (text === -1 ? '无限制' : text)
},
{
title: '状态',
dataIndex: 'isExpire',
key: 'isExpire',
align: 'center',
width: 100
},
{
title: '创建时间',
dataIndex: 'createTime',
key: 'createTime',
align: 'center',
width: 120,
sorter: true,
ellipsis: true,
customRender: ({ text }) => toDateString(text, 'yyyy-MM-dd')
},
{
title: '操作',
key: 'action',
width: 150,
fixed: 'right',
align: 'center',
hideInSetting: true
}
]);
// 工具方法
const getCouponTypeText = (type: number) => {
const typeMap = {
10: '满减券',
20: '折扣券',
30: '免费券'
};
return typeMap[type as keyof typeof typeMap] || '未知';
};
const getCouponTypeColor = (type: number) => {
const colorMap = {
10: 'red',
20: 'orange',
30: 'green'
};
return colorMap[type as keyof typeof colorMap] || 'default';
};
const getApplyRangeText = (range: number) => {
const rangeMap = {
10: '全部商品',
20: '指定商品',
30: '指定分类'
};
return rangeMap[range as keyof typeof rangeMap] || '未知';
};
const getApplyRangeColor = (range: number) => {
const colorMap = {
10: 'blue',
20: 'purple',
30: 'cyan'
};
return colorMap[range as keyof typeof colorMap] || 'default';
};
const formatDate = (dateStr: string) => {
return dateStr ? toDateString(dateStr, 'yyyy-MM-dd') : '-';
};
const getUsagePercent = (record: ShopCoupon) => {
if (record.totalCount === -1) return 0;
return Math.round(
((record.issuedCount || 0) / Number(record.totalCount)) * 100
);
};
const getUsageColor = (record: ShopCoupon) => {
const percent = getUsagePercent(record);
if (percent >= 90) return '#ff4d4f';
if (percent >= 70) return '#faad14';
return '#52c41a';
};
/* 搜索 */
const reload = (where?: ShopCouponParam) => {
selection.value = [];
tableRef?.value?.reload({ where: where });
};
/* 处理搜索 */
const handleSearch = () => {
reload();
};
/* 重置搜索 */
const handleReset = () => {
Object.assign(searchForm, {
name: '',
type: undefined,
expireType: undefined,
isExpire: undefined
});
reload();
};
/* 清除选择 */
const clearSelection = () => {
selection.value = [];
};
/* 打开编辑弹窗 */
const openEdit = (row?: ShopCoupon) => {
current.value = row ?? null;
showEdit.value = true;
};
/* 复制记录 */
const copyRecord = (record: ShopCoupon) => {
const copyData = {
...record,
id: undefined,
name: `${record.name}_副本`,
createTime: undefined,
updateTime: undefined,
issuedCount: 0
};
current.value = copyData;
showEdit.value = true;
message.success('已复制优惠券信息,请修改后保存');
};
/* 删除单个 */
const remove = (row: ShopCoupon) => {
if (row.issuedCount && row.issuedCount > 0) {
message.warning('该优惠券已有用户领取,无法删除');
return;
}
const hide = message.loading('删除中...', 0);
removeShopCoupon(row.id)
.then((msg) => {
hide();
message.success(msg);
reload();
})
.catch((e) => {
hide();
message.error(e.message);
});
};
/* 批量删除 */
const removeBatch = () => {
if (!selection.value.length) {
message.error('请至少选择一条数据');
return;
}
// 检查是否有已发放的优惠券
const issuedCoupons = selection.value.filter(
(item) => item.issuedCount && item.issuedCount > 0
);
if (issuedCoupons.length > 0) {
message.warning(
`选中的优惠券中有 ${issuedCoupons.length} 个已被用户领取,无法删除`
);
return;
}
Modal.confirm({
title: '批量删除确认',
content: `确定要删除选中的 ${selection.value.length} 个优惠券吗?此操作不可恢复。`,
icon: createVNode(ExclamationCircleOutlined),
maskClosable: true,
okText: '确定删除',
okType: 'danger',
cancelText: '取消',
onOk: () => {
const hide = message.loading('批量删除中...', 0);
removeBatchShopCoupon(selection.value.map((d) => d.id))
.then((msg) => {
hide();
message.success(msg);
selection.value = [];
reload();
})
.catch((e) => {
hide();
message.error(e.message);
});
}
});
};
/* 自定义行属性 */
const customRow = (record: ShopCoupon) => {
return {
// 行点击事件
onClick: () => {
// console.log(record);
},
// 行双击事件
onDblclick: () => {
openEdit(record);
},
// 行样式
class: record.isExpire === 1 ? 'expired-row' : ''
};
};
</script>
<script lang="ts">
export default {
name: 'ShopCoupon'
};
</script>
<style lang="less" scoped>
.shop-coupon-container {
.search-container {
background: #fafafa;
padding: 16px;
border-radius: 6px;
margin-bottom: 16px;
.search-form {
.ant-form-item {
margin-bottom: 8px;
}
}
}
.batch-actions {
margin-bottom: 16px;
}
.coupon-table {
.coupon-name {
text-align: left;
.coupon-description {
font-size: 12px;
color: #999;
margin-top: 4px;
}
}
.coupon-value {
.value-amount {
font-size: 16px;
font-weight: bold;
color: #f5222d;
}
.value-discount {
font-size: 16px;
font-weight: bold;
color: #fa8c16;
}
.value-free {
font-size: 14px;
font-weight: bold;
color: #52c41a;
}
.value-condition {
font-size: 12px;
color: #666;
margin-top: 2px;
}
}
.expire-info {
.expire-detail {
font-size: 12px;
color: #666;
margin-top: 4px;
}
}
.usage-info {
.usage-text {
font-size: 12px;
color: #666;
margin-top: 4px;
}
}
.expired-row {
background-color: #fff2f0;
td {
opacity: 0.7;
}
}
}
}
:deep(.ant-table) {
.ant-table-tbody > tr:hover > td {
background-color: #e6f7ff;
}
}
:deep(.ant-progress) {
.ant-progress-text {
font-size: 12px;
}
}
:deep(.ant-alert) {
.ant-alert-message {
font-weight: 500;
}
}
</style>