feat: 活动管理与优惠券页面优化 - 活动管理步骤条改为自定义组件,匹配设计稿样式 - 优惠券列表金额筛选改为区间搜索,移除添加时间筛选 - 优惠券名称列改为可点击链接跳转详情 - 调整列顺序与宽度,优化表格展示
| | |
| | | { |
| | | "name": "xp-robotaxi-management", |
| | | "version": "1.2.10", |
| | | "version": "1.2.11", |
| | | "lockfileVersion": 3, |
| | | "requires": true, |
| | | "packages": { |
| | | "": { |
| | | "name": "xp-robotaxi-management", |
| | | "version": "1.2.10", |
| | | "version": "1.2.11", |
| | | "license": "MIT", |
| | | "dependencies": { |
| | | "@vueuse/core": "^13.6.0", |
| | |
| | | "simple-git-hooks": "^2.13.1", |
| | | "typescript": "^5.8.3", |
| | | "unocss": "^66.3.3", |
| | | "@unocss/vite": "^66.3.3", |
| | | "unplugin-auto-import": "^19.3.0", |
| | | "unplugin-icons": "^22.2.0", |
| | | "unplugin-vue-components": "^28.8.0", |
| | |
| | | NRadioGroup, |
| | | NSelect, |
| | | NSpace, |
| | | NStep, |
| | | NSteps, |
| | | NSwitch, |
| | | NTag, |
| | | NTimePicker, |
| | |
| | | |
| | | const activeTab = ref(1) |
| | | const isEditMode = computed(() => route.query.mode === 'edit' && !!route.query.id) |
| | | |
| | | // 步骤条:naive-ui 的 NSteps 是「图标在左、标题在右」且圈内带序号,与设计稿的 |
| | | // 「图标居中在上、标题在下」对不上,深度覆盖其内部结构不如自绘可控,故手写一层。 |
| | | const stepList = [ |
| | | { value: 1, title: '活动介绍内容' }, |
| | | { value: 2, title: '活动策略' }, |
| | | { value: 3, title: '发放用户' }, |
| | | { value: 4, title: '通知方式' }, |
| | | { value: 5, title: '信息确认' }, |
| | | ] |
| | | |
| | | function stepState(value: number) { |
| | | if (activeTab.value > value) |
| | | return 'finish' |
| | | if (activeTab.value === value) |
| | | return 'process' |
| | | return 'wait' |
| | | } |
| | | |
| | | interface UsagePeriodItem { |
| | | startTime: string | null |
| | |
| | | <template> |
| | | <div class=" w-full h-full relative content flex flex-col"> |
| | | <div class="pb-12px main flex flex-col flex-1 overflow-hidden"> |
| | | <NSteps :current="activeTab" size="small" class="custom-steps"> |
| | | <NStep title="活动介绍内容" /> |
| | | <NStep title="活动策略配置" /> |
| | | <NStep title="发放用户" /> |
| | | <NStep title="通知方式" /> |
| | | <NStep title="信息确认" /> |
| | | </NSteps> |
| | | <div class="custom-steps"> |
| | | <div class="steps-inner"> |
| | | <template v-for="(item, index) in stepList" :key="item.value"> |
| | | <div class="step-item" :class="`step-item--${stepState(item.value)}`"> |
| | | <span class="step-icon"> |
| | | <svg v-if="stepState(item.value) === 'finish'" class="step-check" viewBox="0 0 14 10"> |
| | | <path |
| | | d="M1 5L5 9L13 1" fill="none" stroke="currentColor" stroke-width="2" |
| | | stroke-linecap="round" stroke-linejoin="round" |
| | | /> |
| | | </svg> |
| | | <svg v-else-if="stepState(item.value) === 'process'" class="step-clock" viewBox="0 0 16 16"> |
| | | <circle cx="8" cy="8" r="8" fill="currentColor" /> |
| | | <path |
| | | d="M8 4.2V8.3L10.6 9.8" fill="none" stroke="#fff" stroke-width="1.4" |
| | | stroke-linecap="round" stroke-linejoin="round" |
| | | /> |
| | | </svg> |
| | | <span v-else class="step-dot" /> |
| | | </span> |
| | | <span class="step-title">{{ item.title }}</span> |
| | | </div> |
| | | <span |
| | | v-if="index < stepList.length - 1" |
| | | class="step-line" |
| | | :class="{ 'step-line--done': activeTab > item.value }" |
| | | /> |
| | | </template> |
| | | </div> |
| | | </div> |
| | | <div class="box flex-1 mx-12px"> |
| | | <!-- 活动介绍内容表单 --> |
| | | <NForm |
| | |
| | | <NRadio :value="3" label="使用后发放" /> |
| | | </NSpace> |
| | | </NRadioGroup> |
| | | <!-- 每日循环发放时间点:业务方要求隐藏 --> |
| | | <!-- 每日循环发放时间点:业务方要求隐藏 --> |
| | | <div v-if="false" class="flex items-center gap-16px mt-8px"> |
| | | <NTimePicker |
| | | v-model:formatted-value="strategy.distributionTime" |
| | |
| | | <NCheckboxGroup v-model:value="notifyForm.noticeType"> |
| | | <NSpace item-style="display: flex; align-items: center;" :size="16"> |
| | | <NCheckbox :value="1" label="微信消息通知" /> |
| | | <NCheckbox :value="2" v-if="false" label="短信通知" /> |
| | | <NCheckbox v-if="false" :value="2" label="短信通知" /> |
| | | <NCheckbox :value="4" label="小程序弹窗通知" /> |
| | | </NSpace> |
| | | </NCheckboxGroup> |
| | |
| | | border-radius: 6px; |
| | | width: 100%; |
| | | box-sizing: border-box; |
| | | margin-top: 50px; |
| | | /* 步骤条是 position: fixed 不占位,这里要手动让出它的 84px 高度再留 12px 间距 */ |
| | | margin-top: 96px; |
| | | --n-label-text-color: rgba(23, 31, 38, 0.8); |
| | | |
| | | :deep(.n-form-item-label) { |
| | |
| | | .custom-steps { |
| | | position: fixed; |
| | | width: 100%; |
| | | background-color: rgb(244, 246, 248); |
| | | height: 84px; |
| | | background-color: #fff; |
| | | z-index: 999; |
| | | padding: 12px 12px 0 12px; |
| | | padding: 18px 12px 0 12px; |
| | | box-sizing: border-box; |
| | | } |
| | | |
| | | .steps-inner { |
| | | display: flex; |
| | | align-items: flex-start; |
| | | justify-content: center; |
| | | } |
| | | |
| | | .step-item { |
| | | display: flex; |
| | | flex-direction: column; |
| | | align-items: center; |
| | | padding: 0 24px; |
| | | } |
| | | |
| | | .step-icon { |
| | | display: flex; |
| | | align-items: center; |
| | | justify-content: center; |
| | | width: 16px; |
| | | height: 16px; |
| | | } |
| | | |
| | | .step-clock { |
| | | width: 16px; |
| | | height: 16px; |
| | | color: #0068ff; |
| | | } |
| | | |
| | | .step-check { |
| | | width: 14px; |
| | | height: 10px; |
| | | color: #0068ff; |
| | | } |
| | | |
| | | .step-dot { |
| | | width: 16px; |
| | | height: 16px; |
| | | border: 2px solid #d9d9d9; |
| | | border-radius: 50%; |
| | | box-sizing: border-box; |
| | | } |
| | | |
| | | .step-title { |
| | | margin-top: 14px; |
| | | font-size: 14px; |
| | | line-height: 20px; |
| | | color: #d9d9d9; |
| | | white-space: nowrap; |
| | | } |
| | | |
| | | .step-item--finish .step-title { |
| | | color: #0068ff; |
| | | } |
| | | |
| | | .step-item--process .step-title { |
| | | color: #0068ff; |
| | | font-weight: 500; |
| | | } |
| | | |
| | | /* 连接线落在图标与标题之间的空隙里,已完成段只换色、不加粗 */ |
| | | .step-line { |
| | | flex: none; |
| | | width: 80px; |
| | | height: 2px; |
| | | margin-top: 21px; |
| | | background-color: #d9d9d9; |
| | | } |
| | | |
| | | .step-line--done { |
| | | background-color: #0068ff; |
| | | } |
| | | |
| | | .confirm-block { |
| | |
| | | word-break: break-all; |
| | | } |
| | | |
| | | .custom-tabs { |
| | | position: fixed; |
| | | background-color: rgb(244, 246, 248); |
| | | z-index: 999; |
| | | padding: 12px 12px 0 12px; |
| | | } |
| | | :deep(.n-upload-trigger--image-card) { |
| | | width: 56px !important; |
| | | height: 56px; |
| | |
| | | return amountText |
| | | } |
| | | |
| | | // 使用期限:按券自身的生效/失效规则展示 |
| | | // 使用期限:文案与优惠券列表保持一致 |
| | | function formatValidityDisplay(row: any) { |
| | | const days = Number(row.expireRulesDays) |
| | | if (!days || Number.isNaN(days) || days <= 0) |
| | | return '—' |
| | | const effectiveText = Number(row.effectiveRules) === 2 ? '领取次日0点生效' : '领取后生效' |
| | | return `${effectiveText},生效后${days}天内有效` |
| | | const effectiveText = Number(row.effectiveRules) === 2 ? '领取后次日0点生效' : '领取后生效' |
| | | return `${effectiveText}-有效天数${days}天` |
| | | } |
| | | |
| | | function handleSearch() { |
| | |
| | | return Number(formModel.value.effectiveRules) === 2 ? '领取后次日0点生效' : '领取后生效' |
| | | }) |
| | | |
| | | const pageTitle = computed(() => { |
| | | if (isReadonly.value) |
| | | return '优惠券详情' |
| | | return isEditMode.value ? '编辑优惠券' : '添加优惠券' |
| | | }) |
| | | |
| | | const couponTypeOptions = [ |
| | | { label: '折扣券', value: 1 }, |
| | | { label: '满减券', value: 2 }, |
| | |
| | | <div class="w-full h-full relative content"> |
| | | <div class="p-12px"> |
| | | <div class="box mb-12px" :class="{ 'full-height': isReadonly }"> |
| | | <div class="head"> |
| | | {{ pageTitle }} |
| | | </div> |
| | | <n-form |
| | | ref="formRef" |
| | | :model="formModel" |
| | |
| | | </n-form-item> |
| | | </div> |
| | | |
| | | <div class="form-row"> |
| | | <n-form-item label="生效规则" path="effectiveRules" :required="!isReadonly" style="width: 400px;"> |
| | | <div class="form-row full-width"> |
| | | <n-form-item label="生效规则" path="effectiveRules" :required="!isReadonly"> |
| | | <template v-if="!isReadonly"> |
| | | <n-radio-group v-model:value="formModel.effectiveRules"> |
| | | <n-space> |
| | | <n-space :size="48"> |
| | | <n-radio v-for="item in effectiveRulesOptions" :key="item.value" :value="item.value"> |
| | | {{ item.label }} |
| | | </n-radio> |
| | |
| | | </template> |
| | | <span v-else class="detail-text">{{ effectiveRulesLabel }}</span> |
| | | </n-form-item> |
| | | </div> |
| | | |
| | | <n-form-item label="失效规则" path="expireRulesDays" :required="!isReadonly" style="width: 400px;"> |
| | | <div class="form-row full-width"> |
| | | <n-form-item label="失效规则" :required="!isReadonly"> |
| | | <template v-if="!isReadonly"> |
| | | 生效后 |
| | | <!-- 目前只有「指定有效天数」一种失效规则,单选项恒选中,不需要额外的表单字段承载 --> |
| | | <n-radio-group :value="1"> |
| | | <n-radio :value="1"> |
| | | 指定有效天数 |
| | | </n-radio> |
| | | </n-radio-group> |
| | | </template> |
| | | <span v-else class="detail-text">指定有效天数</span> |
| | | </n-form-item> |
| | | </div> |
| | | |
| | | <div class="form-row sub-form-row"> |
| | | <n-form-item label="有效天数" path="expireRulesDays" :required="!isReadonly"> |
| | | <template v-if="!isReadonly"> |
| | | <n-input |
| | | :value="formModel.expireRulesDays" |
| | | placeholder="请输入天数" |
| | | clearable |
| | | style="width: 130px;margin: 0 8px;" |
| | | style="width: 130px;" |
| | | :input-props="{ inputmode: 'numeric' }" |
| | | @update:value="handleUpdateExpireRulesDays" |
| | | /> |
| | | 天失效 |
| | | <span class="unit-text">天</span> |
| | | </template> |
| | | <span v-else class="detail-text">{{ formModel.expireRulesDays ? `生效后${formModel.expireRulesDays}天失效` : '—' }}</span> |
| | | <span v-else class="detail-text">{{ formModel.expireRulesDays ? `${formModel.expireRulesDays} 天` : '—' }}</span> |
| | | </n-form-item> |
| | | </div> |
| | | |
| | |
| | | <n-form-item label="优惠券说明" path="description" :required="!isReadonly" label-placement="top"> |
| | | <div class="w-full rich-text-wrapper"> |
| | | <template v-if="!isReadonly"> |
| | | <AiRichTextEditor v-model="formModel.description" height="300px" /> |
| | | <AiRichTextEditor v-model="formModel.description" height="300px" :max-length="200" /> |
| | | </template> |
| | | <div v-else class="rich-text-content" v-html="formModel.description" /> |
| | | </div> |
| | |
| | | width: 100%; |
| | | } |
| | | } |
| | | |
| | | /* 「有效天数」是「失效规则」的下级配置项,缩进对齐到上一行单选项的位置 */ |
| | | .form-row.sub-form-row { |
| | | padding-left: 72px; |
| | | } |
| | | |
| | | .unit-text { |
| | | margin-left: 8px; |
| | | color: rgba(23, 31, 38, 0.8); |
| | | } |
| | | :deep(.n-form-item-label--left-mark){ |
| | | width:auto !important; |
| | | } |
| | |
| | | name: '', |
| | | couponType: null, |
| | | status: null, |
| | | conditionalAmount: '', |
| | | amount: '', |
| | | createTimeRange: null as [number, number] | null, |
| | | conditionalAmountMin: '', |
| | | conditionalAmountMax: '', |
| | | amountMin: '', |
| | | amountMax: '', |
| | | }) |
| | | |
| | | type AmountField = 'conditionalAmountMin' | 'conditionalAmountMax' | 'amountMin' | 'amountMax' |
| | | |
| | | // 空值要转成 undefined 而不能落成 0,否则只填下限时上限会变成「≤ 0」把结果全部滤空 |
| | | function toAmountParam(value: string) { |
| | | if (value === '') |
| | | return undefined |
| | | const numberValue = Number(value) |
| | | return Number.isNaN(numberValue) ? undefined : numberValue |
| | | } |
| | | |
| | | const isCouponTypeOpen = ref(false) |
| | | const isStatusOpen = ref(false) |
| | |
| | | 3: '失效', |
| | | } |
| | | |
| | | // 使用期限:按券自身的失效规则展示,存量券未配置时展示占位符 |
| | | // 使用期限:按「生效规则-失效规则」拼接,术语与建券表单的「有效天数」保持一致;存量券未配置时展示占位符 |
| | | function formatValidityDisplay(row: any) { |
| | | const days = Number(row.expireRulesDays) |
| | | if (!days || Number.isNaN(days) || days <= 0) |
| | | return '—' |
| | | const effectiveText = Number(row.effectiveRules) === 2 ? '领取次日0点生效' : '领取后生效' |
| | | return `${effectiveText},生效后${days}天内有效` |
| | | const effectiveText = Number(row.effectiveRules) === 2 ? '领取后次日0点生效' : '领取后生效' |
| | | return `${effectiveText}-有效天数${days}天` |
| | | } |
| | | |
| | | const loading = ref(false) |
| | |
| | | return amountText |
| | | } |
| | | |
| | | function handleAmountInput(field: 'conditionalAmount' | 'amount', value: string) { |
| | | function handleAmountInput(field: AmountField, value: string) { |
| | | // 仅保留数字和一个小数点,且最多两位小数。 |
| | | const normalizedValue = value |
| | | .replace(/[^\d.]/g, '') |
| | |
| | | .replace('$#$', '.') |
| | | .replace(/^(\d+)\.(\d{0,2}).*$/, '$1.$2') |
| | | searchModel.value[field] = normalizedValue |
| | | } |
| | | |
| | | // 区间筛选要塞进一个栅格列宽,两个输入框均分剩余宽度,分隔符和单位不参与伸缩 |
| | | function renderAmountRange(minField: AmountField, maxField: AmountField) { |
| | | return ( |
| | | <div class="flex items-center gap-8px w-full"> |
| | | <n-input |
| | | class="flex-1 min-w-0" |
| | | value={searchModel.value[minField]} |
| | | placeholder="" |
| | | onUpdateValue={(value: string) => handleAmountInput(minField, value)} |
| | | /> |
| | | <span class="flex-none text-[rgba(23,31,38,0.6)]">~</span> |
| | | <n-input |
| | | class="flex-1 min-w-0" |
| | | value={searchModel.value[maxField]} |
| | | placeholder="" |
| | | onUpdateValue={(value: string) => handleAmountInput(maxField, value)} |
| | | /> |
| | | <span class="flex-none text-[rgba(23,31,38,0.6)]">元</span> |
| | | </div> |
| | | ) |
| | | } |
| | | |
| | | function handleSearch() { |
| | |
| | | name: '', |
| | | couponType: null, |
| | | status: null, |
| | | conditionalAmount: '', |
| | | amount: '', |
| | | createTimeRange: null, |
| | | conditionalAmountMin: '', |
| | | conditionalAmountMax: '', |
| | | amountMin: '', |
| | | amountMax: '', |
| | | } |
| | | currentPage.value = 1 |
| | | getList() |
| | |
| | | async function getList() { |
| | | loading.value = true |
| | | try { |
| | | const [createStartTime, createEndTime] = searchModel.value.createTimeRange || [] |
| | | const { code, data } = await fetchCouponList({ |
| | | code: searchModel.value.code?.trim() || undefined, |
| | | couponName: searchModel.value.name?.trim() || undefined, |
| | | couponType: searchModel.value.couponType, |
| | | status: searchModel.value.status, |
| | | conditionalAmountMax: searchModel.value.conditionalAmount ? Number(searchModel.value.conditionalAmount) : undefined, |
| | | conditionalAmountMin: searchModel.value.conditionalAmount ? Number(searchModel.value.conditionalAmount) : undefined, |
| | | amountMin: searchModel.value.amount ? Number(searchModel.value.amount) : undefined, |
| | | amountMax: searchModel.value.amount ? Number(searchModel.value.amount) : undefined, |
| | | createTimeStart: createStartTime ? dayjs(createStartTime).startOf('day').format('YYYY-MM-DD HH:mm:ss') : undefined, |
| | | createTimeEnd: createEndTime ? dayjs(createEndTime).endOf('day').format('YYYY-MM-DD HH:mm:ss') : undefined, |
| | | conditionalAmountMin: toAmountParam(searchModel.value.conditionalAmountMin), |
| | | conditionalAmountMax: toAmountParam(searchModel.value.conditionalAmountMax), |
| | | amountMin: toAmountParam(searchModel.value.amountMin), |
| | | amountMax: toAmountParam(searchModel.value.amountMax), |
| | | pageNum: currentPage.value, |
| | | pageSize: pageSize.value, |
| | | }) |
| | |
| | | ), |
| | | }, |
| | | { |
| | | label: '条件金额', |
| | | path: 'conditionalAmount', |
| | | render: () => renderAmountRange('conditionalAmountMin', 'conditionalAmountMax'), |
| | | }, |
| | | { |
| | | label: '面额/折扣', |
| | | path: 'amount', |
| | | render: () => renderAmountRange('amountMin', 'amountMax'), |
| | | }, |
| | | { |
| | | label: '状态', |
| | | path: 'status', |
| | | render: () => ( |
| | |
| | | </NSelect> |
| | | ), |
| | | }, |
| | | { |
| | | label: '条件金额', |
| | | path: 'conditionalAmount', |
| | | render: () => ( |
| | | <n-input |
| | | value={searchModel.value.conditionalAmount} |
| | | placeholder="请输入" |
| | | clearable |
| | | onUpdateValue={value => handleAmountInput('conditionalAmount', value)} |
| | | /> |
| | | ), |
| | | }, |
| | | { |
| | | label: '面额/折扣', |
| | | path: 'amount', |
| | | render: () => ( |
| | | <n-input |
| | | value={searchModel.value.amount} |
| | | placeholder="请输入" |
| | | clearable |
| | | onUpdateValue={value => handleAmountInput('amount', value)} |
| | | /> |
| | | ), |
| | | }, |
| | | { |
| | | label: '添加时间', |
| | | path: 'createTimeRange', |
| | | render: () => ( |
| | | <n-date-picker |
| | | v-model:value={searchModel.value.createTimeRange} |
| | | type="daterange" |
| | | clearable |
| | | format="yyyy/MM/dd" |
| | | start-placeholder="开始时间" |
| | | end-placeholder="结束时间" |
| | | actions={[]} |
| | | update-value-on-close |
| | | close-on-select |
| | | /> |
| | | ), |
| | | }, |
| | | ]) |
| | | |
| | | const columns = computed<DataTableColumns<any>>(() => [ |
| | | { type: 'selection', width: 50, align: 'center', fixed: 'left' }, |
| | | { title: '序号', key: 'id', width: 60, align: 'left', resizable: true, render: (_, index) => (currentPage.value - 1) * pageSize.value + index + 1 }, |
| | | { title: '添加时间', key: 'createTime', align: 'left', width: 180, ellipsis: true, resizable: true, render: row => row.createTime ? dayjs(row.createTime).format('YYYY/MM/DD HH:mm') : '-' }, |
| | | { title: '优惠券编码', key: 'code', align: 'left', width: 200, ellipsis: true, resizable: true, render: row => row.id ? String(row.id) : '-' }, |
| | | { title: '优惠券名称', key: 'couponName', align: 'left', width: 260, ellipsis: true, resizable: true }, |
| | | { |
| | | title: '优惠券名称', |
| | | key: 'couponName', |
| | | align: 'left', |
| | | width: 260, |
| | | ellipsis: true, |
| | | resizable: true, |
| | | // 名称做成入口链接,点击直达详情;.n-button 已在行点击忽略名单里,不会连带勾选当前行 |
| | | render: row => ( |
| | | <NButton text type="primary" onClick={() => handleDetail(row)}> |
| | | {row.couponName || '-'} |
| | | </NButton> |
| | | ), |
| | | }, |
| | | { |
| | | title: '优惠券类型', |
| | | key: 'couponType', |
| | |
| | | render: row => <span>{formatCouponAmountDisplay(row)}</span>, |
| | | }, |
| | | { |
| | | title: '使用期限', |
| | | title: '优惠券使用期限', |
| | | key: 'expireRulesDays', |
| | | width: 200, |
| | | width: 220, |
| | | align: 'left', |
| | | ellipsis: true, |
| | | resizable: true, |
| | |
| | | ) |
| | | }, |
| | | }, |
| | | { title: '添加时间', key: 'createTime', align: 'left', width: 180, ellipsis: true, resizable: true, render: row => row.createTime ? dayjs(row.createTime).format('YYYY/MM/DD HH:mm') : '-' }, |
| | | { |
| | | title: '操作', |
| | | key: 'actions', |
| | |
| | | :row-class-name="getRowClassName" |
| | | :row-key="(row: any) => row.id" |
| | | flex-height |
| | | :scroll-x="1700" |
| | | :scroll-x="1610" |
| | | @update:checked-row-keys="handleCheck" |
| | | > |
| | | <template #empty> |