|
@@ -0,0 +1,752 @@
|
|
|
|
|
+<template>
|
|
|
|
|
+ <div class="special-price-manage admin-page">
|
|
|
|
|
+ <el-card class="pm-card" shadow="never">
|
|
|
|
|
+ <!-- 页面标题区 -->
|
|
|
|
|
+ <div class="pm-header">
|
|
|
|
|
+ <div class="pm-header__left">
|
|
|
|
|
+ <h1 class="pm-title">限时特价活动</h1>
|
|
|
|
|
+ <span class="pm-subtitle">共 {{ pagination.total }} 个活动</span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="pm-header__right">
|
|
|
|
|
+ <el-button size="small" type="primary" icon="el-icon-plus" @click="openCreate">新建活动</el-button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <!-- 筛选区 -->
|
|
|
|
|
+ <div class="pm-filter">
|
|
|
|
|
+ <div class="pm-filter__fields">
|
|
|
|
|
+ <div class="pm-field">
|
|
|
|
|
+ <label class="pm-field__label">关键词</label>
|
|
|
|
|
+ <el-input
|
|
|
|
|
+ v-model="filters.keyword"
|
|
|
|
|
+ placeholder="搜索活动名称"
|
|
|
|
|
+ prefix-icon="el-icon-search"
|
|
|
|
|
+ size="small"
|
|
|
|
|
+ clearable
|
|
|
|
|
+ class="pm-field__control pm-field__control--search"
|
|
|
|
|
+ @clear="loadList"
|
|
|
|
|
+ @keyup.enter.native="loadList"
|
|
|
|
|
+ />
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="pm-field">
|
|
|
|
|
+ <label class="pm-field__label">运行状态</label>
|
|
|
|
|
+ <el-select v-model="filters.runStatus" placeholder="全部状态" size="small" clearable class="pm-field__control" @change="loadList">
|
|
|
|
|
+ <el-option label="全部状态" value="" />
|
|
|
|
|
+ <el-option label="进行中" value="ACTIVE" />
|
|
|
|
|
+ <el-option label="未开始" value="NOT_STARTED" />
|
|
|
|
|
+ <el-option label="已售罄" value="SOLD_OUT" />
|
|
|
|
|
+ <el-option label="已结束" value="ENDED" />
|
|
|
|
|
+ <el-option label="草稿" value="draft" />
|
|
|
|
|
+ <el-option label="已停用" value="disabled" />
|
|
|
|
|
+ </el-select>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="pm-filter__actions">
|
|
|
|
|
+ <el-button size="small" @click="resetFilters">重置</el-button>
|
|
|
|
|
+ <el-button size="small" type="primary" @click="loadList">查询</el-button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <!-- 列表 -->
|
|
|
|
|
+ <div class="pm-table-wrap">
|
|
|
|
|
+ <div class="adm-table-wrap">
|
|
|
|
|
+ <el-table
|
|
|
|
|
+ :data="list"
|
|
|
|
|
+ v-loading="loading"
|
|
|
|
|
+ border
|
|
|
|
|
+ size="small"
|
|
|
|
|
+ @selection-change="handleSelectionChange"
|
|
|
|
|
+ >
|
|
|
|
|
+ <el-table-column type="selection" width="48" align="center" />
|
|
|
|
|
+ <el-table-column prop="id" label="ID" width="72" align="right" class-name="pm-num" />
|
|
|
|
|
+ <el-table-column prop="name" label="活动名" min-width="140" show-overflow-tooltip />
|
|
|
|
|
+ <el-table-column label="商品" min-width="180" show-overflow-tooltip>
|
|
|
|
|
+ <template slot-scope="{ row }">
|
|
|
|
|
+ <span class="pm-product-link" @click="goProduct(row)">{{ row.productName || '—' }}</span>
|
|
|
|
|
+ <span class="pm-product-id">#{{ row.productId }}</span>
|
|
|
|
|
+ </template>
|
|
|
|
|
+ </el-table-column>
|
|
|
|
|
+ <el-table-column label="原价 / 特价" width="150">
|
|
|
|
|
+ <template slot-scope="{ row }">
|
|
|
|
|
+ <div class="pm-price">
|
|
|
|
|
+ <span class="pm-price__original">{{ formatPriceWithSymbol(row.productPrice) }}</span>
|
|
|
|
|
+ <span
|
|
|
|
|
+ class="pm-price__special"
|
|
|
|
|
+ :class="{ 'pm-price__special--discount': row.productPrice && row.specialPrice && row.specialPrice < row.productPrice }"
|
|
|
|
|
+ >{{ formatPriceWithSymbol(row.specialPrice) }}</span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </template>
|
|
|
|
|
+ </el-table-column>
|
|
|
|
|
+ <el-table-column label="时间窗口" width="230">
|
|
|
|
|
+ <template slot-scope="{ row }">
|
|
|
|
|
+ <div class="pm-time-window">
|
|
|
|
|
+ <el-tag size="mini" type="info">{{ formatTime(row.startTime) }}</el-tag>
|
|
|
|
|
+ <span class="pm-time-window__sep">至</span>
|
|
|
|
|
+ <el-tag size="mini" type="info">{{ formatTime(row.endTime) }}</el-tag>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </template>
|
|
|
|
|
+ </el-table-column>
|
|
|
|
|
+ <el-table-column label="已售 / 剩余" width="140">
|
|
|
|
|
+ <template slot-scope="{ row }">
|
|
|
|
|
+ <div class="pm-quota">
|
|
|
|
|
+ <div class="pm-quota__bar">
|
|
|
|
|
+ <div class="pm-quota__fill" :style="{ width: quotaPercent(row) + '%' }"></div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <span class="pm-quota__text">{{ row.soldQuantity || 0 }} / {{ Math.max((row.totalQuantity || 0) - (row.soldQuantity || 0), 0) }}</span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </template>
|
|
|
|
|
+ </el-table-column>
|
|
|
|
|
+ <el-table-column prop="perUserLimit" label="每人限购" width="90" align="right" class-name="pm-num">
|
|
|
|
|
+ <template slot-scope="{ row }">{{ row.perUserLimit || '—' }}</template>
|
|
|
|
|
+ </el-table-column>
|
|
|
|
|
+ <el-table-column label="券开关" width="88">
|
|
|
|
|
+ <template slot-scope="{ row }">
|
|
|
|
|
+ <el-tag :type="row.allowCoupon === 1 ? 'success' : 'danger'" size="mini">{{ row.allowCoupon === 1 ? '可用券' : '禁券' }}</el-tag>
|
|
|
|
|
+ </template>
|
|
|
|
|
+ </el-table-column>
|
|
|
|
|
+ <el-table-column label="运行状态" width="96">
|
|
|
|
|
+ <template slot-scope="{ row }">
|
|
|
|
|
+ <span class="pm-status" :class="'pm-status--' + row._runStatus">
|
|
|
|
|
+ <i class="pm-status__dot"></i>{{ runStatusLabel(row._runStatus) }}
|
|
|
|
|
+ </span>
|
|
|
|
|
+ </template>
|
|
|
|
|
+ </el-table-column>
|
|
|
|
|
+ <el-table-column label="操作" width="232" fixed="right" align="left">
|
|
|
|
|
+ <template slot-scope="{ row }">
|
|
|
|
|
+ <el-button v-if="row._runStatus === 'draft' || row._runStatus === 'disabled'" type="text" size="small" @click="openEdit(row)">编辑</el-button>
|
|
|
|
|
+ <el-button v-if="row._runStatus === 'draft' || row._runStatus === 'disabled'" type="text" size="small" class="pm-text-success" @click="toggleStatus(row, 'enable')">启用</el-button>
|
|
|
|
|
+ <el-button v-if="row._runStatus === 'active' || row._runStatus === 'not_started' || row._runStatus === 'sold_out'" type="text" size="small" class="pm-text-warning" @click="toggleStatus(row, 'disable')">停用</el-button>
|
|
|
|
|
+ <el-button type="text" size="small" @click="showQrcode(row)">二维码</el-button>
|
|
|
|
|
+ <el-button type="text" size="small" class="pm-text-danger" @click="handleDelete(row)">删除</el-button>
|
|
|
|
|
+ </template>
|
|
|
|
|
+ </el-table-column>
|
|
|
|
|
+ </el-table>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <!-- 分页 -->
|
|
|
|
|
+ <div class="pm-pagination">
|
|
|
|
|
+ <el-pagination
|
|
|
|
|
+ @size-change="handleSizeChange"
|
|
|
|
|
+ @current-change="handlePageChange"
|
|
|
|
|
+ :current-page="pagination.page"
|
|
|
|
|
+ :page-sizes="[10, 20, 50]"
|
|
|
|
|
+ :page-size="pagination.size"
|
|
|
|
|
+ layout="total, sizes, prev, pager, next, jumper"
|
|
|
|
|
+ :total="pagination.total"
|
|
|
|
|
+ />
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </el-card>
|
|
|
|
|
+
|
|
|
|
|
+ <!-- 新建 / 编辑弹窗 -->
|
|
|
|
|
+ <el-dialog :title="form.id ? '编辑特价活动' : '新建特价活动'" :visible.sync="dialogVisible" width="560px" @closed="resetForm">
|
|
|
|
|
+ <el-form label-width="96px">
|
|
|
|
|
+ <el-form-item label="活动名称" required>
|
|
|
|
|
+ <el-input v-model="form.name" placeholder="如:开学季限时特价" maxlength="60" />
|
|
|
|
|
+ </el-form-item>
|
|
|
|
|
+ <el-form-item label="商品" required>
|
|
|
|
|
+ <el-select
|
|
|
|
|
+ v-model="form.productId"
|
|
|
|
|
+ filterable
|
|
|
|
|
+ remote
|
|
|
|
|
+ reserve-keyword
|
|
|
|
|
+ :remote-method="searchProducts"
|
|
|
|
|
+ :loading="productSearchLoading"
|
|
|
|
|
+ placeholder="输入关键词搜索商品"
|
|
|
|
|
+ style="width: 100%;"
|
|
|
|
|
+ @change="onProductSelected"
|
|
|
|
|
+ >
|
|
|
|
|
+ <el-option v-for="p in productOptions" :key="p.id" :label="(p.name || '') + ' #' + p.id" :value="p.id" />
|
|
|
|
|
+ </el-select>
|
|
|
|
|
+ </el-form-item>
|
|
|
|
|
+ <el-form-item label="特价(元)" required>
|
|
|
|
|
+ <el-input-number v-model="form.specialPriceYuan" :min="0.01" :precision="2" :step="1" controls-position="right" style="width: 200px;" @change="onSpecialPriceYuanChange" />
|
|
|
|
|
+ <span class="pm-form-hint">商品原价:{{ formatPriceWithSymbol(form.productPrice) }},特价须低于原价</span>
|
|
|
|
|
+ </el-form-item>
|
|
|
|
|
+ <el-form-item label="起止时间" required>
|
|
|
|
|
+ <el-date-picker
|
|
|
|
|
+ v-model="form.timeRange"
|
|
|
|
|
+ type="datetimerange"
|
|
|
|
|
+ range-separator="至"
|
|
|
|
|
+ start-placeholder="开始时间"
|
|
|
|
|
+ end-placeholder="结束时间"
|
|
|
|
|
+ value-format="yyyy-MM-dd HH:mm:ss"
|
|
|
|
|
+ style="width: 100%;"
|
|
|
|
|
+ />
|
|
|
|
|
+ </el-form-item>
|
|
|
|
|
+ <el-form-item label="总供应量" required>
|
|
|
|
|
+ <el-input-number v-model="form.totalQuantity" :min="1" :step="1" controls-position="right" style="width: 200px;" />
|
|
|
|
|
+ </el-form-item>
|
|
|
|
|
+ <el-form-item label="每人限购" required>
|
|
|
|
|
+ <el-input-number v-model="form.perUserLimit" :min="1" :step="1" controls-position="right" style="width: 200px;" />
|
|
|
|
|
+ </el-form-item>
|
|
|
|
|
+ <el-form-item label="允许优惠券">
|
|
|
|
|
+ <el-switch v-model="form.allowCoupon" :active-value="1" :inactive-value="0" />
|
|
|
|
|
+ </el-form-item>
|
|
|
|
|
+ </el-form>
|
|
|
|
|
+ <div slot="footer">
|
|
|
|
|
+ <el-button @click="dialogVisible = false">取消</el-button>
|
|
|
|
|
+ <el-button @click="handleSave">保存</el-button>
|
|
|
|
|
+ <el-button v-if="!form.id" type="primary" @click="handleSaveAndEnable">保存并启用</el-button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div v-if="form.id && (form._runStatus === 'draft' || form._runStatus === 'disabled')" class="pm-dialog-tip">
|
|
|
|
|
+ 保存后可在列表中对活动执行「启用」
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </el-dialog>
|
|
|
|
|
+
|
|
|
|
|
+ <!-- 二维码弹窗 -->
|
|
|
|
|
+ <el-dialog title="特价活动小程序码" :visible.sync="qrDialogVisible" width="420px">
|
|
|
|
|
+ <div v-loading="qrLoading" class="pm-qr-wrap">
|
|
|
|
|
+ <img v-if="qrData && qrData.qrCodeBase64" :src="qrData.qrCodeBase64" class="pm-qr-img" alt="特价小程序码" />
|
|
|
|
|
+ <div v-else-if="!qrLoading" class="pm-qr-empty">{{ qrData && qrData.note ? qrData.note : '二维码未生成' }}</div>
|
|
|
|
|
+ <div v-if="qrData && qrData.scene" class="pm-qr-scene">scene:{{ qrData.scene }}</div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div slot="footer">
|
|
|
|
|
+ <a v-if="qrData && qrData.qrCodeBase64" :href="qrData.qrCodeBase64" download="special-price-qrcode.png" class="pm-qr-download">下载二维码</a>
|
|
|
|
|
+ <el-button @click="qrDialogVisible = false">关闭</el-button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </el-dialog>
|
|
|
|
|
+ </div>
|
|
|
|
|
+</template>
|
|
|
|
|
+
|
|
|
|
|
+<script>
|
|
|
|
|
+import {
|
|
|
|
|
+ getSpecialPriceList,
|
|
|
|
|
+ createSpecialPrice,
|
|
|
|
|
+ updateSpecialPrice,
|
|
|
|
|
+ setSpecialPriceStatus,
|
|
|
|
|
+ deleteSpecialPrice,
|
|
|
|
|
+ getSpecialPriceQrcode,
|
|
|
|
|
+ getProductList
|
|
|
|
|
+} from '@/api/admin'
|
|
|
|
|
+
|
|
|
|
|
+export default {
|
|
|
|
|
+ name: 'SpecialPriceManage',
|
|
|
|
|
+ data() {
|
|
|
|
|
+ return {
|
|
|
|
|
+ list: [],
|
|
|
|
|
+ loading: false,
|
|
|
|
|
+ filters: {
|
|
|
|
|
+ keyword: '',
|
|
|
|
|
+ runStatus: ''
|
|
|
|
|
+ },
|
|
|
|
|
+ pagination: {
|
|
|
|
|
+ page: 1,
|
|
|
|
|
+ size: 20,
|
|
|
|
|
+ total: 0
|
|
|
|
|
+ },
|
|
|
|
|
+ selectedIds: [],
|
|
|
|
|
+ dialogVisible: false,
|
|
|
|
|
+ form: this.emptyForm(),
|
|
|
|
|
+ productOptions: [],
|
|
|
|
|
+ productSearchLoading: false,
|
|
|
|
|
+ qrDialogVisible: false,
|
|
|
|
|
+ qrLoading: false,
|
|
|
|
|
+ qrData: null
|
|
|
|
|
+ }
|
|
|
|
|
+ },
|
|
|
|
|
+ mounted() {
|
|
|
|
|
+ this.loadList()
|
|
|
|
|
+ },
|
|
|
|
|
+ methods: {
|
|
|
|
|
+ emptyForm() {
|
|
|
|
|
+ return {
|
|
|
|
|
+ id: null,
|
|
|
|
|
+ name: '',
|
|
|
|
|
+ productId: null,
|
|
|
|
|
+ productPrice: 0,
|
|
|
|
|
+ specialPriceYuan: 0,
|
|
|
|
|
+ specialPrice: 0,
|
|
|
|
|
+ timeRange: [],
|
|
|
|
|
+ totalQuantity: 1,
|
|
|
|
|
+ perUserLimit: 1,
|
|
|
|
|
+ allowCoupon: 0,
|
|
|
|
|
+ _runStatus: null
|
|
|
|
|
+ }
|
|
|
|
|
+ },
|
|
|
|
|
+ async loadList() {
|
|
|
|
|
+ this.loading = true
|
|
|
|
|
+ try {
|
|
|
|
|
+ const params = {
|
|
|
|
|
+ page: this.pagination.page,
|
|
|
|
|
+ size: this.pagination.size,
|
|
|
|
|
+ keyword: this.filters.keyword || null,
|
|
|
|
|
+ runStatus: this.filters.runStatus || null
|
|
|
|
|
+ }
|
|
|
|
|
+ const res = await getSpecialPriceList(params)
|
|
|
|
|
+ const records = (res.data && res.data.records) || []
|
|
|
|
|
+ this.list = records.map(r => Object.assign({}, r, { _runStatus: this.runStatusOf(r) }))
|
|
|
|
|
+ this.pagination.total = (res.data && res.data.total) || 0
|
|
|
|
|
+ } catch (e) {
|
|
|
|
|
+ this.$message.error(e.message || '加载失败')
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ this.loading = false
|
|
|
|
|
+ }
|
|
|
|
|
+ },
|
|
|
|
|
+ resetFilters() {
|
|
|
|
|
+ this.filters.keyword = ''
|
|
|
|
|
+ this.filters.runStatus = ''
|
|
|
|
|
+ this.pagination.page = 1
|
|
|
|
|
+ this.loadList()
|
|
|
|
|
+ },
|
|
|
|
|
+ handleSizeChange(size) {
|
|
|
|
|
+ this.pagination.size = size
|
|
|
|
|
+ this.pagination.page = 1
|
|
|
|
|
+ this.loadList()
|
|
|
|
|
+ },
|
|
|
|
|
+ handlePageChange(page) {
|
|
|
|
|
+ this.pagination.page = page
|
|
|
|
|
+ this.loadList()
|
|
|
|
|
+ },
|
|
|
|
|
+ handleSelectionChange(selection) {
|
|
|
|
|
+ this.selectedIds = selection.map(s => s.id)
|
|
|
|
|
+ },
|
|
|
|
|
+ // 运行状态推导:镜像后端 SQL(listWithProductName 的 runStatusCondition)
|
|
|
|
|
+ runStatusOf(row) {
|
|
|
|
|
+ if (row.status === 'draft') return 'draft'
|
|
|
|
|
+ if (row.status === 'disabled') return 'disabled'
|
|
|
|
|
+ const now = Date.now()
|
|
|
|
|
+ const start = this.parseTs(row.startTime)
|
|
|
|
|
+ const end = this.parseTs(row.endTime)
|
|
|
|
|
+ if (start && now < start) return 'not_started'
|
|
|
|
|
+ if (end && now > end) return 'ended'
|
|
|
|
|
+ if ((row.soldQuantity || 0) >= (row.totalQuantity || 0)) return 'sold_out'
|
|
|
|
|
+ return 'active'
|
|
|
|
|
+ },
|
|
|
|
|
+ parseTs(v) {
|
|
|
|
|
+ if (!v) return 0
|
|
|
|
|
+ if (typeof v === 'number') return v
|
|
|
|
|
+ const t = Date.parse(String(v).replace(' ', 'T'))
|
|
|
|
|
+ return isNaN(t) ? 0 : t
|
|
|
|
|
+ },
|
|
|
|
|
+ runStatusLabel(status) {
|
|
|
|
|
+ const map = {
|
|
|
|
|
+ active: '进行中',
|
|
|
|
|
+ not_started: '未开始',
|
|
|
|
|
+ sold_out: '已售罄',
|
|
|
|
|
+ ended: '已结束',
|
|
|
|
|
+ draft: '草稿',
|
|
|
|
|
+ disabled: '已停用'
|
|
|
|
|
+ }
|
|
|
|
|
+ return map[status] || status
|
|
|
|
|
+ },
|
|
|
|
|
+ quotaPercent(row) {
|
|
|
|
|
+ if (!row.totalQuantity) return 0
|
|
|
|
|
+ return Math.min(100, Math.round((row.soldQuantity || 0) * 100 / row.totalQuantity))
|
|
|
|
|
+ },
|
|
|
|
|
+ formatPriceWithSymbol(price) {
|
|
|
|
|
+ if (!price) return '免费'
|
|
|
|
|
+ return '¥' + (price / 100).toFixed(2)
|
|
|
|
|
+ },
|
|
|
|
|
+ goProduct(row) {
|
|
|
|
|
+ this.$router.push('/product-edit?id=' + row.productId)
|
|
|
|
|
+ },
|
|
|
|
|
+ // ---------------- 新建/编辑 ----------------
|
|
|
|
|
+ openCreate() {
|
|
|
|
|
+ this.form = this.emptyForm()
|
|
|
|
|
+ this.productOptions = []
|
|
|
|
|
+ this.dialogVisible = true
|
|
|
|
|
+ },
|
|
|
|
|
+ openEdit(row) {
|
|
|
|
|
+ this.form = {
|
|
|
|
|
+ id: row.id,
|
|
|
|
|
+ name: row.name || '',
|
|
|
|
|
+ productId: row.productId || null,
|
|
|
|
|
+ productPrice: row.productPrice || 0,
|
|
|
|
|
+ specialPriceYuan: row.specialPrice ? row.specialPrice / 100 : 0,
|
|
|
|
|
+ specialPrice: row.specialPrice || 0,
|
|
|
|
|
+ timeRange: (row.startTime && row.endTime) ? [this.formatTime(row.startTime), this.formatTime(row.endTime)] : [],
|
|
|
|
|
+ totalQuantity: row.totalQuantity || 1,
|
|
|
|
|
+ perUserLimit: row.perUserLimit || 1,
|
|
|
|
|
+ allowCoupon: row.allowCoupon === 1 ? 1 : 0,
|
|
|
|
|
+ _runStatus: row._runStatus
|
|
|
|
|
+ }
|
|
|
|
|
+ // 预置当前商品为唯一选项,避免编辑态回显空
|
|
|
|
|
+ this.productOptions = [{ id: row.productId, name: row.productName, price: row.productPrice }]
|
|
|
|
|
+ this.dialogVisible = true
|
|
|
|
|
+ },
|
|
|
|
|
+ resetForm() {
|
|
|
|
|
+ this.form = this.emptyForm()
|
|
|
|
|
+ },
|
|
|
|
|
+ formatTime(t) {
|
|
|
|
|
+ return t ? t.replace('T', ' ').substring(0, 19) : '-'
|
|
|
|
|
+ },
|
|
|
|
|
+ async searchProducts(keyword) {
|
|
|
|
|
+ if (!keyword) return
|
|
|
|
|
+ this.productSearchLoading = true
|
|
|
|
|
+ try {
|
|
|
|
|
+ const res = await getProductList({ keyword: keyword.trim(), page: 1, size: 20 })
|
|
|
|
|
+ this.productOptions = (res.data && res.data.records) || []
|
|
|
|
|
+ } catch (e) {
|
|
|
|
|
+ this.$message.error('商品搜索失败')
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ this.productSearchLoading = false
|
|
|
|
|
+ }
|
|
|
|
|
+ },
|
|
|
|
|
+ onProductSelected(id) {
|
|
|
|
|
+ const p = this.productOptions.find(x => x.id === id)
|
|
|
|
|
+ if (p) this.form.productPrice = p.price || 0
|
|
|
|
|
+ },
|
|
|
|
|
+ onSpecialPriceYuanChange(val) {
|
|
|
|
|
+ this.form.specialPrice = Math.round((val || 0) * 100)
|
|
|
|
|
+ },
|
|
|
|
|
+ validateForm() {
|
|
|
|
|
+ const f = this.form
|
|
|
|
|
+ if (!f.name || !f.name.trim()) return '请填写活动名称'
|
|
|
|
|
+ if (!f.productId) return '请选择商品'
|
|
|
|
|
+ if (!f.specialPrice || f.specialPrice <= 0) return '请填写特价金额'
|
|
|
|
|
+ if (f.productPrice && f.specialPrice >= f.productPrice) return '特价必须低于商品原价'
|
|
|
|
|
+ if (!f.timeRange || f.timeRange.length !== 2) return '请选择起止时间'
|
|
|
|
|
+ if (f.timeRange[0] >= f.timeRange[1]) return '开始时间必须早于结束时间'
|
|
|
|
|
+ if (!f.totalQuantity || f.totalQuantity < 1) return '总供应量必须大于等于1'
|
|
|
|
|
+ if (!f.perUserLimit || f.perUserLimit < 1) return '每人限购必须大于等于1'
|
|
|
|
|
+ return null
|
|
|
|
|
+ },
|
|
|
|
|
+ buildPayload() {
|
|
|
|
|
+ const f = this.form
|
|
|
|
|
+ return {
|
|
|
|
|
+ name: f.name.trim(),
|
|
|
|
|
+ productId: f.productId,
|
|
|
|
|
+ specialPrice: f.specialPrice,
|
|
|
|
|
+ startTime: f.timeRange[0],
|
|
|
|
|
+ endTime: f.timeRange[1],
|
|
|
|
|
+ totalQuantity: f.totalQuantity,
|
|
|
|
|
+ perUserLimit: f.perUserLimit,
|
|
|
|
|
+ allowCoupon: f.allowCoupon === 1 ? 1 : 0
|
|
|
|
|
+ }
|
|
|
|
|
+ },
|
|
|
|
|
+ async handleSave() {
|
|
|
|
|
+ const err = this.validateForm()
|
|
|
|
|
+ if (err) return this.$message.warning(err)
|
|
|
|
|
+ const payload = this.buildPayload()
|
|
|
|
|
+ try {
|
|
|
|
|
+ if (this.form.id) {
|
|
|
|
|
+ await updateSpecialPrice(Object.assign({ id: this.form.id }, payload))
|
|
|
|
|
+ this.$message.success('修改成功')
|
|
|
|
|
+ } else {
|
|
|
|
|
+ await createSpecialPrice(payload)
|
|
|
|
|
+ this.$message.success('创建成功')
|
|
|
|
|
+ }
|
|
|
|
|
+ this.dialogVisible = false
|
|
|
|
|
+ this.loadList()
|
|
|
|
|
+ } catch (e) {
|
|
|
|
|
+ this.$message.error(e.message || '保存失败')
|
|
|
|
|
+ }
|
|
|
|
|
+ },
|
|
|
|
|
+ async handleSaveAndEnable() {
|
|
|
|
|
+ const err = this.validateForm()
|
|
|
|
|
+ if (err) return this.$message.warning(err)
|
|
|
|
|
+ const payload = this.buildPayload()
|
|
|
|
|
+ try {
|
|
|
|
|
+ let id = this.form.id
|
|
|
|
|
+ if (id) {
|
|
|
|
|
+ await updateSpecialPrice(Object.assign({ id }, payload))
|
|
|
|
|
+ } else {
|
|
|
|
|
+ const res = await createSpecialPrice(payload)
|
|
|
|
|
+ id = (res.data && res.data.id) || id
|
|
|
|
|
+ }
|
|
|
|
|
+ if (!id) throw new Error('活动创建失败')
|
|
|
|
|
+ await setSpecialPriceStatus({ id, action: 'enable' })
|
|
|
|
|
+ this.$message.success('已创建并启用')
|
|
|
|
|
+ this.dialogVisible = false
|
|
|
|
|
+ this.loadList()
|
|
|
|
|
+ } catch (e) {
|
|
|
|
|
+ this.$message.error(e.message || '操作失败')
|
|
|
|
|
+ }
|
|
|
|
|
+ },
|
|
|
|
|
+ // ---------------- 状态 / 删除 ----------------
|
|
|
|
|
+ async toggleStatus(row, action) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ await this.$confirm(action === 'enable' ? '确认启用该特价活动?启用后按时间窗生效(同商品进行中活动互斥)。' : '确认停用该特价活动?停用后扫码进入将回落原价。', '提示')
|
|
|
|
|
+ await setSpecialPriceStatus({ id: row.id, action })
|
|
|
|
|
+ this.$message.success(action === 'enable' ? '已启用' : '已停用')
|
|
|
|
|
+ this.loadList()
|
|
|
|
|
+ } catch (e) {
|
|
|
|
|
+ if (e !== 'cancel') this.$message.error(e.message || '操作失败')
|
|
|
|
|
+ }
|
|
|
|
|
+ },
|
|
|
|
|
+ async handleDelete(row) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ await this.$confirm('确认删除该特价活动?若已有特价订单则无法删除。', '提示', { type: 'warning' })
|
|
|
|
|
+ await deleteSpecialPrice(row.id)
|
|
|
|
|
+ this.$message.success('删除成功')
|
|
|
|
|
+ this.loadList()
|
|
|
|
|
+ } catch (e) {
|
|
|
|
|
+ if (e !== 'cancel') this.$message.error(e.message || '删除失败')
|
|
|
|
|
+ }
|
|
|
|
|
+ },
|
|
|
|
|
+ // ---------------- 二维码 ----------------
|
|
|
|
|
+ async showQrcode(row) {
|
|
|
|
|
+ this.qrDialogVisible = true
|
|
|
|
|
+ this.qrLoading = true
|
|
|
|
|
+ this.qrData = null
|
|
|
|
|
+ try {
|
|
|
|
|
+ const res = await getSpecialPriceQrcode(row.id)
|
|
|
|
|
+ this.qrData = res.data || null
|
|
|
|
|
+ } catch (e) {
|
|
|
|
|
+ this.$message.error(e.message || '二维码生成失败')
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ this.qrLoading = false
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+</script>
|
|
|
|
|
+
|
|
|
|
|
+<style scoped>
|
|
|
|
|
+.pm-card /deep/ .el-card__body {
|
|
|
|
|
+ padding: 24px;
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ flex-direction: column;
|
|
|
|
|
+ min-height: calc(100vh - 60px - 48px);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/* ---------- 页面标题区 ---------- */
|
|
|
|
|
+.pm-header {
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ align-items: center;
|
|
|
|
|
+ justify-content: space-between;
|
|
|
|
|
+ margin-bottom: 24px;
|
|
|
|
|
+}
|
|
|
|
|
+.pm-header__left {
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ align-items: baseline;
|
|
|
|
|
+ gap: 12px;
|
|
|
|
|
+}
|
|
|
|
|
+.pm-title {
|
|
|
|
|
+ margin: 0;
|
|
|
|
|
+ font-size: 20px;
|
|
|
|
|
+ line-height: 1.4;
|
|
|
|
|
+ font-weight: 600;
|
|
|
|
|
+ color: rgba(0, 0, 0, 0.88);
|
|
|
|
|
+}
|
|
|
|
|
+.pm-subtitle {
|
|
|
|
|
+ font-size: 12px;
|
|
|
|
|
+ color: rgba(0, 0, 0, 0.45);
|
|
|
|
|
+}
|
|
|
|
|
+.pm-header__right {
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ align-items: center;
|
|
|
|
|
+ gap: 8px;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/* ---------- 筛选区 ---------- */
|
|
|
|
|
+.pm-filter {
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ align-items: flex-end;
|
|
|
|
|
+ justify-content: space-between;
|
|
|
|
|
+ flex-wrap: wrap;
|
|
|
|
|
+ gap: 16px;
|
|
|
|
|
+ padding-bottom: 16px;
|
|
|
|
|
+ margin-bottom: 16px;
|
|
|
|
|
+ border-bottom: 1px solid #f0f0f0;
|
|
|
|
|
+}
|
|
|
|
|
+.pm-filter__fields {
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ align-items: center;
|
|
|
|
|
+ flex-wrap: wrap;
|
|
|
|
|
+ gap: 16px;
|
|
|
|
|
+}
|
|
|
|
|
+.pm-field {
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ align-items: center;
|
|
|
|
|
+ gap: 8px;
|
|
|
|
|
+}
|
|
|
|
|
+.pm-field__label {
|
|
|
|
|
+ font-size: 14px;
|
|
|
|
|
+ color: rgba(0, 0, 0, 0.65);
|
|
|
|
|
+ white-space: nowrap;
|
|
|
|
|
+}
|
|
|
|
|
+.pm-field__control {
|
|
|
|
|
+ width: 150px;
|
|
|
|
|
+}
|
|
|
|
|
+.pm-field__control--search {
|
|
|
|
|
+ width: 220px;
|
|
|
|
|
+}
|
|
|
|
|
+.pm-filter__actions {
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ align-items: center;
|
|
|
|
|
+ gap: 8px;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/* ---------- 表格 ---------- */
|
|
|
|
|
+.pm-table-wrap {
|
|
|
|
|
+ flex: 1;
|
|
|
|
|
+ min-height: 0;
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ flex-direction: column;
|
|
|
|
|
+}
|
|
|
|
|
+.pm-table-wrap /deep/ .el-table {
|
|
|
|
|
+ flex: 1;
|
|
|
|
|
+ min-height: 0;
|
|
|
|
|
+ font-size: 14px;
|
|
|
|
|
+ border-radius: 6px;
|
|
|
|
|
+}
|
|
|
|
|
+.pm-table-wrap /deep/ .el-table th {
|
|
|
|
|
+ height: 46px;
|
|
|
|
|
+ padding: 0;
|
|
|
|
|
+ background: #fafafa;
|
|
|
|
|
+ color: rgba(0, 0, 0, 0.88);
|
|
|
|
|
+ font-weight: 500;
|
|
|
|
|
+}
|
|
|
|
|
+.pm-table-wrap /deep/ .el-table td {
|
|
|
|
|
+ padding: 12px 0;
|
|
|
|
|
+}
|
|
|
|
|
+.pm-table-wrap /deep/ .pm-num {
|
|
|
|
|
+ font-variant-numeric: tabular-nums;
|
|
|
|
|
+ font-feature-settings: 'tnum';
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/* ---------- 价格列 ---------- */
|
|
|
|
|
+.pm-price {
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ flex-direction: column;
|
|
|
|
|
+ gap: 2px;
|
|
|
|
|
+}
|
|
|
|
|
+.pm-price__original {
|
|
|
|
|
+ font-size: 12px;
|
|
|
|
|
+ color: rgba(0, 0, 0, 0.45);
|
|
|
|
|
+ text-decoration: line-through;
|
|
|
|
|
+}
|
|
|
|
|
+.pm-price__special {
|
|
|
|
|
+ font-size: 14px;
|
|
|
|
|
+ font-weight: 600;
|
|
|
|
|
+ color: rgba(0, 0, 0, 0.88);
|
|
|
|
|
+}
|
|
|
|
|
+.pm-price__special--discount {
|
|
|
|
|
+ color: #cf1322;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/* ---------- 时间窗口 ---------- */
|
|
|
|
|
+.pm-time-window {
|
|
|
|
|
+ display: inline-flex;
|
|
|
|
|
+ align-items: center;
|
|
|
|
|
+ gap: 4px;
|
|
|
|
|
+ white-space: nowrap;
|
|
|
|
|
+}
|
|
|
|
|
+.pm-time-window__sep {
|
|
|
|
|
+ font-size: 12px;
|
|
|
|
|
+ color: rgba(0, 0, 0, 0.45);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/* ---------- 库存进度条 ---------- */
|
|
|
|
|
+.pm-quota {
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ flex-direction: column;
|
|
|
|
|
+ gap: 4px;
|
|
|
|
|
+}
|
|
|
|
|
+.pm-quota__bar {
|
|
|
|
|
+ height: 6px;
|
|
|
|
|
+ width: 100%;
|
|
|
|
|
+ min-width: 60px;
|
|
|
|
|
+ background: #f0f0f0;
|
|
|
|
|
+ border-radius: 3px;
|
|
|
|
|
+ overflow: hidden;
|
|
|
|
|
+}
|
|
|
|
|
+.pm-quota__fill {
|
|
|
|
|
+ height: 100%;
|
|
|
|
|
+ background: linear-gradient(90deg, #ff7a45, #ff4d4f);
|
|
|
|
|
+ border-radius: 3px;
|
|
|
|
|
+}
|
|
|
|
|
+.pm-quota__text {
|
|
|
|
|
+ font-size: 12px;
|
|
|
|
|
+ color: rgba(0, 0, 0, 0.65);
|
|
|
|
|
+ font-variant-numeric: tabular-nums;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/* ---------- 商品链接 ---------- */
|
|
|
|
|
+.pm-product-link {
|
|
|
|
|
+ color: #1677ff;
|
|
|
|
|
+ cursor: pointer;
|
|
|
|
|
+}
|
|
|
|
|
+.pm-product-link:hover {
|
|
|
|
|
+ text-decoration: underline;
|
|
|
|
|
+}
|
|
|
|
|
+.pm-product-id {
|
|
|
|
|
+ margin-left: 4px;
|
|
|
|
|
+ font-size: 12px;
|
|
|
|
|
+ color: rgba(0, 0, 0, 0.45);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/* ---------- 状态标签 ---------- */
|
|
|
|
|
+.pm-status {
|
|
|
|
|
+ display: inline-flex;
|
|
|
|
|
+ align-items: center;
|
|
|
|
|
+ gap: 6px;
|
|
|
|
|
+ padding: 2px 8px;
|
|
|
|
|
+ font-size: 12px;
|
|
|
|
|
+ line-height: 20px;
|
|
|
|
|
+ border-radius: 2px;
|
|
|
|
|
+ white-space: nowrap;
|
|
|
|
|
+}
|
|
|
|
|
+.pm-status__dot {
|
|
|
|
|
+ width: 6px;
|
|
|
|
|
+ height: 6px;
|
|
|
|
|
+ border-radius: 50%;
|
|
|
|
|
+ background: currentColor;
|
|
|
|
|
+}
|
|
|
|
|
+.pm-status--active { background: #f6ffed; color: #389e0d; }
|
|
|
|
|
+.pm-status--not_started { background: #e6f4ff; color: #0958d9; }
|
|
|
|
|
+.pm-status--sold_out { background: #fff1f0; color: #cf1322; }
|
|
|
|
|
+.pm-status--ended { background: #f5f5f5; color: rgba(0, 0, 0, 0.65); }
|
|
|
|
|
+.pm-status--draft { background: #fffbe6; color: #d48806; }
|
|
|
|
|
+.pm-status--disabled { background: #f5f5f5; color: rgba(0, 0, 0, 0.65); }
|
|
|
|
|
+
|
|
|
|
|
+/* ---------- 操作按钮颜色 ---------- */
|
|
|
|
|
+.pm-text-success { color: #389e0d; }
|
|
|
|
|
+.pm-text-warning { color: #d48806; }
|
|
|
|
|
+.pm-text-danger { color: #cf1322; }
|
|
|
|
|
+
|
|
|
|
|
+/* ---------- 分页 ---------- */
|
|
|
|
|
+.pm-pagination {
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ justify-content: flex-end;
|
|
|
|
|
+ margin-top: 16px;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/* ---------- 弹窗 ---------- */
|
|
|
|
|
+.pm-form-hint {
|
|
|
|
|
+ margin-left: 12px;
|
|
|
|
|
+ font-size: 12px;
|
|
|
|
|
+ color: rgba(0, 0, 0, 0.45);
|
|
|
|
|
+}
|
|
|
|
|
+.pm-dialog-tip {
|
|
|
|
|
+ margin-top: -12px;
|
|
|
|
|
+ padding: 8px 12px;
|
|
|
|
|
+ font-size: 12px;
|
|
|
|
|
+ color: #d48806;
|
|
|
|
|
+ background: #fffbe6;
|
|
|
|
|
+ border: 1px solid #ffe58f;
|
|
|
|
|
+ border-radius: 4px;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/* ---------- 二维码 ---------- */
|
|
|
|
|
+.pm-qr-wrap {
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ flex-direction: column;
|
|
|
|
|
+ align-items: center;
|
|
|
|
|
+ gap: 12px;
|
|
|
|
|
+ min-height: 200px;
|
|
|
|
|
+ justify-content: center;
|
|
|
|
|
+}
|
|
|
|
|
+.pm-qr-img {
|
|
|
|
|
+ width: 240px;
|
|
|
|
|
+ height: 240px;
|
|
|
|
|
+ border: 1px solid #f0f0f0;
|
|
|
|
|
+ border-radius: 6px;
|
|
|
|
|
+}
|
|
|
|
|
+.pm-qr-empty {
|
|
|
|
|
+ color: rgba(0, 0, 0, 0.45);
|
|
|
|
|
+ font-size: 14px;
|
|
|
|
|
+}
|
|
|
|
|
+.pm-qr-scene {
|
|
|
|
|
+ font-size: 12px;
|
|
|
|
|
+ color: rgba(0, 0, 0, 0.65);
|
|
|
|
|
+ word-break: break-all;
|
|
|
|
|
+}
|
|
|
|
|
+.pm-qr-download {
|
|
|
|
|
+ float: left;
|
|
|
|
|
+ line-height: 32px;
|
|
|
|
|
+ color: #1677ff;
|
|
|
|
|
+}
|
|
|
|
|
+</style>
|