Browse Source

feat: 添加优惠码管理前端

新增优惠码 API、管理端列表/编辑页及路由配置。

Co-authored-by: Cursor <cursoragent@cursor.com>
Pchen. 3 weeks ago
parent
commit
11c3f29e32
4 changed files with 517 additions and 0 deletions
  1. 40 0
      src/api/coupon.js
  2. 258 0
      src/pages/admin/goods/couponEdit.vue
  3. 188 0
      src/pages/admin/goods/couponList.vue
  4. 31 0
      src/router/index.js

+ 40 - 0
src/api/coupon.js

@@ -0,0 +1,40 @@
+import request from '../utils/request'
+
+const api = {
+  Validate: '/Coupon/Validate',
+  AdminList: '/Admin/Coupon/List',
+  AdminDetail: '/Admin/Coupon/Detail',
+  AdminSave: '/Admin/Coupon/Save'
+}
+
+export function validateCoupon(data) {
+  return request({
+    url: api.Validate,
+    method: 'post',
+    data
+  })
+}
+
+export function adminCouponList(params) {
+  return request({
+    url: api.AdminList,
+    method: 'get',
+    params
+  })
+}
+
+export function adminCouponDetail(params) {
+  return request({
+    url: api.AdminDetail,
+    method: 'get',
+    params
+  })
+}
+
+export function adminSaveCoupon(data) {
+  return request({
+    url: api.AdminSave,
+    method: 'post',
+    data
+  })
+}

+ 258 - 0
src/pages/admin/goods/couponEdit.vue

@@ -0,0 +1,258 @@
+<template>
+  <div class="admin-page">
+    <Breadcrumb />
+
+    <a-card :bordered="false" class="page-card" :title="isEdit ? '编辑优惠码' : '新建优惠码'">
+      <a-spin :loading="pageLoading">
+        <a-form :model="form" layout="vertical" @submit-success="handleSubmit">
+          <a-row :gutter="24">
+            <a-col :xs="24" :md="8">
+              <a-form-item label="优惠码" required>
+                <a-input
+                  v-model="form.code"
+                  placeholder="如 SPRING2026"
+                  :max-length="32"
+                  allow-clear
+                  :disabled="isEdit"
+                />
+              </a-form-item>
+            </a-col>
+            <a-col :xs="24" :md="8">
+              <a-form-item label="名称(可选)">
+                <a-input v-model="form.name" placeholder="内部备注名称" :max-length="64" allow-clear />
+              </a-form-item>
+            </a-col>
+            <a-col :xs="24" :md="8">
+              <a-form-item label="状态">
+                <a-radio-group v-model="form.state" type="button">
+                  <a-radio :value="1">启用</a-radio>
+                  <a-radio :value="0">停用</a-radio>
+                </a-radio-group>
+              </a-form-item>
+            </a-col>
+          </a-row>
+
+          <a-divider orientation="left">折扣规则</a-divider>
+          <a-row :gutter="24">
+            <a-col :xs="24" :md="8">
+              <a-form-item label="折扣类型">
+                <a-radio-group v-model="form.discount_type" type="button">
+                  <a-radio value="percent">百分比</a-radio>
+                  <a-radio value="fixed">固定减免</a-radio>
+                </a-radio-group>
+              </a-form-item>
+            </a-col>
+            <a-col :xs="24" :md="8">
+              <a-form-item :label="form.discount_type === 'percent' ? '折扣比例(%)' : '减免金额(元)'">
+                <a-input-number
+                  v-model="form.discount_value"
+                  :min="form.discount_type === 'percent' ? 1 : 0.01"
+                  :max="form.discount_type === 'percent' ? 100 : 99999"
+                  :precision="form.discount_type === 'percent' ? 0 : 2"
+                  style="width: 100%"
+                />
+              </a-form-item>
+            </a-col>
+            <a-col :xs="24" :md="8">
+              <a-form-item label="最低消费(元,0 表示不限)">
+                <a-input-number v-model="form.min_amount" :min="0" :precision="2" style="width: 100%" />
+              </a-form-item>
+            </a-col>
+          </a-row>
+
+          <a-divider orientation="left">使用范围</a-divider>
+          <a-row :gutter="24">
+            <a-col :xs="24" :md="12">
+              <a-form-item label="适用用户">
+                <a-radio-group v-model="form.user_scope" direction="vertical">
+                  <a-radio :value="0">所有人可用</a-radio>
+                  <a-radio :value="1">仅指定用户可用</a-radio>
+                </a-radio-group>
+                <a-textarea
+                  v-if="form.user_scope === 1"
+                  v-model="form.allowed_usernames"
+                  class="scope-textarea"
+                  placeholder="每行一个用户名,或用逗号分隔"
+                  :auto-size="{ minRows: 3, maxRows: 6 }"
+                />
+              </a-form-item>
+            </a-col>
+            <a-col :xs="24" :md="12">
+              <a-form-item label="适用商品">
+                <a-radio-group v-model="form.goods_scope" direction="vertical">
+                  <a-radio :value="0">全部商品可用</a-radio>
+                  <a-radio :value="1">仅指定商品可用</a-radio>
+                </a-radio-group>
+                <a-select
+                  v-if="form.goods_scope === 1"
+                  v-model="form.allowed_goods_ids"
+                  multiple
+                  allow-search
+                  placeholder="选择适用商品"
+                  class="goods-select"
+                  :loading="goodsLoading"
+                >
+                  <a-option v-for="g in goodsOptions" :key="g.id" :value="g.id">
+                    {{ g.name }}(¥{{ g.price }})
+                  </a-option>
+                </a-select>
+              </a-form-item>
+            </a-col>
+          </a-row>
+
+          <a-divider orientation="left">使用限制</a-divider>
+          <a-row :gutter="24">
+            <a-col :xs="24" :md="8">
+              <a-form-item label="总使用次数(0=不限)">
+                <a-input-number v-model="form.total_limit" :min="0" :precision="0" style="width: 100%" />
+              </a-form-item>
+            </a-col>
+            <a-col :xs="24" :md="8">
+              <a-form-item label="每人限用次数">
+                <a-input-number v-model="form.per_user_limit" :min="1" :precision="0" style="width: 100%" />
+              </a-form-item>
+            </a-col>
+            <a-col :xs="24" :md="8">
+              <a-form-item label="有效期">
+                <a-range-picker
+                  v-model="validityRange"
+                  show-time
+                  format="YYYY-MM-DD HH:mm"
+                  value-format="x"
+                  style="width: 100%"
+                />
+              </a-form-item>
+            </a-col>
+          </a-row>
+
+          <a-form-item>
+            <a-space>
+              <a-button type="primary" html-type="submit" :loading="submitting">保存</a-button>
+              <a-button @click="$router.push('/goodsManage/goods/couponList')">返回列表</a-button>
+            </a-space>
+          </a-form-item>
+        </a-form>
+      </a-spin>
+    </a-card>
+  </div>
+</template>
+
+<script setup>
+import { ref, reactive, computed, onMounted } from 'vue'
+import { useRoute, useRouter } from 'vue-router'
+import { adminCouponDetail, adminSaveCoupon } from '@/api/coupon'
+import { adminGetGoodsList } from '@/api/goods'
+import { Notification } from '@arco-design/web-vue'
+
+const route = useRoute()
+const router = useRouter()
+const isEdit = computed(() => Boolean(route.params.id))
+
+const pageLoading = ref(false)
+const submitting = ref(false)
+const goodsLoading = ref(false)
+const goodsOptions = ref([])
+const validityRange = ref([])
+
+const form = reactive({
+  code: '',
+  name: '',
+  discount_type: 'percent',
+  discount_value: 10,
+  user_scope: 0,
+  goods_scope: 0,
+  total_limit: 0,
+  per_user_limit: 1,
+  min_amount: 0,
+  state: 1,
+  allowed_usernames: '',
+  allowed_goods_ids: []
+})
+
+const loadGoods = async () => {
+  try {
+    goodsLoading.value = true
+    const res = await adminGetGoodsList({ current: 1, pagesize: 500, keyword: '' })
+    if (res?.code === 0) goodsOptions.value = res.data || []
+  } finally {
+    goodsLoading.value = false
+  }
+}
+
+const loadDetail = async () => {
+  if (!route.params.id) return
+  pageLoading.value = true
+  try {
+    const res = await adminCouponDetail({ id: route.params.id })
+    if (!res || res.code !== 0) {
+      return Notification.error({ title: '加载失败', content: res?.msg })
+    }
+    const d = res.data
+    form.code = d.code
+    form.name = d.name || ''
+    form.discount_type = d.discount_type
+    form.discount_value = Number(d.discount_value)
+    form.user_scope = Number(d.user_scope)
+    form.goods_scope = Number(d.goods_scope)
+    form.total_limit = Number(d.total_limit)
+    form.per_user_limit = Number(d.per_user_limit)
+    form.min_amount = Number(d.min_amount)
+    form.state = Number(d.state)
+    form.allowed_usernames = (d.allowedUsers || []).map((u) => u.username).filter(Boolean).join('\n')
+    form.allowed_goods_ids = (d.allowedGoods || []).map((g) => g.id).filter(Boolean)
+    if (d.start_time || d.end_time) {
+      validityRange.value = [d.start_time ? String(d.start_time) : null, d.end_time ? String(d.end_time) : null].filter(Boolean)
+    }
+  } finally {
+    pageLoading.value = false
+  }
+}
+
+const handleSubmit = async () => {
+  if (!form.code?.trim()) {
+    return Notification.warning({ title: '请填写优惠码' })
+  }
+  try {
+    submitting.value = true
+    const payload = {
+      id: route.params.id || null,
+      ...form,
+      code: form.code.trim().toUpperCase(),
+      start_time: validityRange.value?.[0] ? Number(validityRange.value[0]) : null,
+      end_time: validityRange.value?.[1] ? Number(validityRange.value[1]) : null
+    }
+    const res = await adminSaveCoupon(payload)
+    if (!res || res.code !== 0) {
+      return Notification.error({ title: '保存失败', content: res?.msg })
+    }
+    Notification.success({ title: '保存成功' })
+    router.push('/goodsManage/goods/couponList')
+  } catch (e) {
+    Notification.error({ title: '保存失败', content: e.message })
+  } finally {
+    submitting.value = false
+  }
+}
+
+onMounted(async () => {
+  await loadGoods()
+  await loadDetail()
+})
+</script>
+
+<style scoped>
+.admin-page {
+  padding: 0 20px 20px;
+}
+.page-card {
+  border-radius: 12px;
+  max-width: 960px;
+}
+.scope-textarea {
+  margin-top: 12px;
+}
+.goods-select {
+  margin-top: 12px;
+  width: 100%;
+}
+</style>

+ 188 - 0
src/pages/admin/goods/couponList.vue

@@ -0,0 +1,188 @@
+<template>
+  <div class="admin-page">
+    <Breadcrumb />
+
+    <a-card :bordered="false" class="page-card">
+      <template #title>
+        <div class="card-title-row">
+          <span>优惠码管理</span>
+          <a-button type="primary" @click="goEdit()">
+            <template #icon><icon-plus /></template>
+            新建优惠码
+          </a-button>
+        </div>
+      </template>
+
+      <a-form :model="query" layout="inline" class="filter-form">
+        <a-form-item label="关键词">
+          <a-input v-model="query.keyword" placeholder="优惠码 / 名称" allow-clear style="width: 200px" />
+        </a-form-item>
+        <a-form-item label="状态">
+          <a-select v-model="query.state" :options="stateOptions" style="width: 120px" />
+        </a-form-item>
+        <a-form-item>
+          <a-space>
+            <a-button type="primary" @click="search"><template #icon><icon-search /></template>搜索</a-button>
+            <a-button @click="reset"><template #icon><icon-refresh /></template>重置</a-button>
+          </a-space>
+        </a-form-item>
+      </a-form>
+
+      <a-table
+        :data="list"
+        :loading="loading"
+        :pagination="{
+          total: pagination.total,
+          current: pagination.current,
+          pageSize: pagination.pageSize,
+          showTotal: true,
+          showPageSize: true
+        }"
+        @page-change="onPageChange"
+        @page-size-change="onPageSizeChange"
+      >
+        <template #columns>
+          <a-table-column title="优惠码" data-index="code" :width="140">
+            <template #cell="{ record }">
+              <a-tag color="arcoblue">{{ record.code }}</a-tag>
+            </template>
+          </a-table-column>
+          <a-table-column title="名称" data-index="name" ellipsis />
+          <a-table-column title="折扣" :width="120">
+            <template #cell="{ record }">{{ formatDiscount(record) }}</template>
+          </a-table-column>
+          <a-table-column title="用户范围" :width="100">
+            <template #cell="{ record }">{{ record.user_scope === 1 ? '指定用户' : '所有人' }}</template>
+          </a-table-column>
+          <a-table-column title="商品范围" :width="100">
+            <template #cell="{ record }">{{ record.goods_scope === 1 ? '指定商品' : '全部商品' }}</template>
+          </a-table-column>
+          <a-table-column title="已用/总量" :width="110">
+            <template #cell="{ record }">
+              {{ record.used_count }} / {{ record.total_limit > 0 ? record.total_limit : '∞' }}
+            </template>
+          </a-table-column>
+          <a-table-column title="状态" :width="90">
+            <template #cell="{ record }">
+              <a-tag :color="record.state === 1 ? 'green' : 'gray'">
+                {{ record.state === 1 ? '启用' : '停用' }}
+              </a-tag>
+            </template>
+          </a-table-column>
+          <a-table-column title="有效期" :width="200">
+            <template #cell="{ record }">{{ formatValidity(record) }}</template>
+          </a-table-column>
+          <a-table-column title="操作" :width="100" fixed="right">
+            <template #cell="{ record }">
+              <a-button type="text" size="small" @click="goEdit(record.id)">编辑</a-button>
+            </template>
+          </a-table-column>
+        </template>
+      </a-table>
+    </a-card>
+  </div>
+</template>
+
+<script setup>
+import { ref, reactive, onMounted } from 'vue'
+import { useRouter } from 'vue-router'
+import { adminCouponList } from '@/api/coupon'
+import { Notification } from '@arco-design/web-vue'
+import { formatStoreTime } from '@/utils/storeFormat'
+
+const router = useRouter()
+const loading = ref(false)
+const list = ref([])
+
+const query = reactive({ keyword: '', state: -1 })
+const pagination = reactive({ total: 0, current: 1, pageSize: 15 })
+
+const stateOptions = [
+  { label: '全部', value: -1 },
+  { label: '启用', value: 1 },
+  { label: '停用', value: 0 }
+]
+
+const formatDiscount = (row) => {
+  if (row.discount_type === 'percent') return `${row.discount_value}% 折扣`
+  return `立减 ¥${row.discount_value}`
+}
+
+const formatValidity = (row) => {
+  const start = row.start_time ? formatStoreTime(row.start_time) : '不限'
+  const end = row.end_time ? formatStoreTime(row.end_time) : '不限'
+  return `${start} ~ ${end}`
+}
+
+const fetchList = async () => {
+  try {
+    loading.value = true
+    const params = {
+      keyword: query.keyword || undefined,
+      current: pagination.current,
+      pagesize: pagination.pageSize
+    }
+    if (query.state !== -1 && query.state !== '-1') {
+      params.state = query.state
+    }
+    const res = await adminCouponList(params)
+    if (!res || res.code !== 0) {
+      return Notification.error({ title: '获取失败', content: res?.msg })
+    }
+    list.value = res.data || []
+    pagination.total = res.pagination?.total ?? 0
+  } catch (e) {
+    Notification.error({ title: '获取失败', content: e.message })
+  } finally {
+    loading.value = false
+  }
+}
+
+const search = () => {
+  pagination.current = 1
+  fetchList()
+}
+
+const reset = () => {
+  query.keyword = ''
+  query.state = -1
+  pagination.current = 1
+  fetchList()
+}
+
+const onPageChange = (p) => {
+  pagination.current = p
+  fetchList()
+}
+
+const onPageSizeChange = (s) => {
+  pagination.pageSize = s
+  pagination.current = 1
+  fetchList()
+}
+
+const goEdit = (id) => {
+  if (id) router.push(`/goodsManage/goods/couponEdit/${id}`)
+  else router.push('/goodsManage/goods/couponEdit')
+}
+
+onMounted(fetchList)
+</script>
+
+<style scoped>
+.admin-page {
+  padding: 0 20px 20px;
+}
+.page-card {
+  border-radius: 12px;
+}
+.card-title-row {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  width: 100%;
+}
+.filter-form {
+  margin-bottom: 16px;
+}
+</style>

+ 31 - 0
src/router/index.js

@@ -490,6 +490,37 @@ const routes = [
                     permission: ['admin', 'product']
                 }
             },
+            {
+                path: 'goods/couponList',
+                name: 'admin.goods.couponList',
+                component: () => import('../pages/admin/goods/couponList.vue'),
+                meta: {
+                    title: '优惠码管理',
+                    permission: ['admin', 'product']
+                }
+            },
+            {
+                path: 'goods/couponEdit',
+                name: 'admin.goods.couponAdd',
+                component: () => import('../pages/admin/goods/couponEdit.vue'),
+                meta: {
+                    title: '新建优惠码',
+                    hideInMenu: true,
+                    parent: 'admin.goods.couponList',
+                    permission: ['admin', 'product']
+                }
+            },
+            {
+                path: 'goods/couponEdit/:id',
+                name: 'admin.goods.couponEdit',
+                component: () => import('../pages/admin/goods/couponEdit.vue'),
+                meta: {
+                    title: '编辑优惠码',
+                    hideInMenu: true,
+                    parent: 'admin.goods.couponList',
+                    permission: ['admin', 'product']
+                }
+            },
             {
                 path: 'goods/orderList',
                 name: 'admin.goods.orderList',