front/src/views/marketing/memberLevel/index.vue
zzh bd97ed76fd feat: ?????????????????/???????
??????????????????????????????? H5/?????

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 15:16:55 +08:00

839 lines
24 KiB
Vue

<template>
<MarketingPageShell :tip="t('marketing.memberLevel.pageTip')">
<div class="table-toolbar">
<div class="toolbar-left">
<a-input
v-model="keyword"
allow-clear
class="mkt-filter-search"
:placeholder="t('marketing.memberLevel.searchPlaceholder')"
@press-enter="handleSearch"
@clear="handleSearch"
/>
<a-button type="outline" @click="handleSearch">
<template #icon><icon-search /></template>
{{ t('common.search') }}
</a-button>
<a-button type="primary" @click="openDrawer()">
<template #icon><icon-plus /></template>
{{ t('common.add') }}
</a-button>
</div>
<div class="toolbar-right">
<a-space :size="8">
<a-button @click="loadList">
<template #icon><icon-refresh /></template>
</a-button>
<PaginationPro
:total-count="totalCount"
v-model:current-page="currentPage"
v-model:page-size="pageSize"
@page-change="loadList"
@page-size-change="loadList"
/>
</a-space>
</div>
</div>
<a-spin :loading="listLoading" class="list-spin">
<div ref="tableContainerRef" class="list-wrap">
<HandsontablePro
:columns="hotColumns"
:data="tableRows"
:total="totalCount"
:current-page="currentPage"
:page-size="pageSize"
:show-action-column="true"
:action-column-width="100"
:action-renderer="actionRenderer"
:stretch-columns="true"
:height="handsontableHeight"
:read-only="true"
@action-click="handleActionClick"
/>
</div>
</a-spin>
<a-drawer
v-model:visible="drawerVisible"
:width="560"
unmount-on-close
:esc-to-close="true"
:mask-closable="!drawerLoading"
class="member-level-drawer"
:footer="false"
@cancel="handleDrawerCancel"
>
<div class="ml-drawer">
<header class="ml-drawer__header">
<div class="ml-drawer__title-row">
<div>
<p class="ml-drawer__eyebrow">{{ t('menu.marketing.memberLevel') }}</p>
<h3 class="ml-drawer__title">
{{
editingId
? t('marketing.memberLevel.modal.edit')
: t('marketing.memberLevel.modal.add')
}}
</h3>
</div>
<button type="button" class="ml-drawer__close" @click="handleDrawerCancel">
<icon-close />
</button>
</div>
<p class="ml-drawer__desc">{{ t('marketing.memberLevel.modalTip') }}</p>
</header>
<div class="ml-drawer__body">
<section class="ml-preview" :class="{ 'is-disabled': formState.status === 1 }">
<div class="ml-preview__badge">
<span class="ml-preview__dot" />
{{
formState.status === 2
? t('common.enabled')
: t('common.disabled')
}}
</div>
<div class="ml-preview__main">
<div class="ml-preview__icon" aria-hidden="true">
<img v-if="formState.iconUrl" :src="formState.iconUrl" alt="" />
<span v-else>{{ previewInitial }}</span>
</div>
<div class="ml-preview__meta">
<h4 class="ml-preview__name">
{{ formState.levelName || t('marketing.memberLevel.preview.nameFallback') }}
</h4>
<p class="ml-preview__code">
{{ formState.levelCode || t('marketing.memberLevel.preview.codeFallback') }}
</p>
</div>
</div>
<div class="ml-preview__stats">
<div class="ml-preview__stat">
<span class="ml-preview__stat-label">{{ t('marketing.memberLevel.form.minPoints') }}</span>
<span class="ml-preview__stat-value">{{ formState.minPoints ?? 0 }}</span>
</div>
<div class="ml-preview__stat">
<span class="ml-preview__stat-label">{{ t('marketing.memberLevel.form.extraDiscountRate') }}</span>
<span class="ml-preview__stat-value">{{ previewDiscountText }}</span>
</div>
</div>
</section>
<a-form
ref="formRef"
class="ml-form"
:model="formState"
:rules="formRules"
layout="vertical"
:label-col-props="{ span: 24 }"
:wrapper-col-props="{ span: 24 }"
>
<section class="ml-section">
<div class="ml-section__head">
<span class="ml-section__index">01</span>
<div>
<h5 class="ml-section__title">{{ t('marketing.memberLevel.section.basic') }}</h5>
<p class="ml-section__hint">{{ t('marketing.memberLevel.section.basicHint') }}</p>
</div>
</div>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item
:label="t('marketing.memberLevel.form.levelCode')"
field="levelCode"
:tooltip="t('marketing.memberLevel.form.levelCodeExtra')"
>
<a-input
v-model="formState.levelCode"
:placeholder="t('marketing.memberLevel.form.levelCodePlaceholder')"
:max-length="32"
:disabled="!!editingId"
allow-clear
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item :label="t('marketing.memberLevel.form.levelName')" field="levelName">
<a-input
v-model="formState.levelName"
:placeholder="t('marketing.memberLevel.form.levelNamePlaceholder')"
:max-length="64"
allow-clear
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item :label="t('marketing.memberLevel.form.status')" field="status">
<a-radio-group v-model="formState.status" type="button" class="ml-status-group">
<a-radio :value="2">{{ t('common.enabled') }}</a-radio>
<a-radio :value="1">{{ t('common.disabled') }}</a-radio>
</a-radio-group>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item
:label="t('marketing.memberLevel.form.sort')"
field="sort"
:tooltip="t('marketing.memberLevel.form.sortExtra')"
>
<a-input-number
v-model="formState.sort"
:min="0"
:precision="0"
style="width: 100%"
placeholder="0"
/>
</a-form-item>
</a-col>
</a-row>
</section>
<section class="ml-section">
<div class="ml-section__head">
<span class="ml-section__index">02</span>
<div>
<h5 class="ml-section__title">{{ t('marketing.memberLevel.section.pricing') }}</h5>
<p class="ml-section__hint">{{ t('marketing.memberLevel.section.pricingHint') }}</p>
</div>
</div>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item
:label="t('marketing.memberLevel.form.minPoints')"
field="minPoints"
:tooltip="t('marketing.memberLevel.form.minPointsExtra')"
>
<a-input-number
v-model="formState.minPoints"
:min="0"
:precision="0"
style="width: 100%"
placeholder="0"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item
:label="t('marketing.memberLevel.form.extraDiscountRate')"
field="extraDiscountRate"
:tooltip="t('marketing.memberLevel.form.extraDiscountRateExtra')"
>
<a-input-number
v-model="formState.extraDiscountRate"
:min="0"
:max="0.99"
:step="0.01"
:precision="4"
style="width: 100%"
placeholder="0"
/>
</a-form-item>
</a-col>
</a-row>
</section>
<section class="ml-section">
<div class="ml-section__head">
<span class="ml-section__index">03</span>
<div>
<h5 class="ml-section__title">{{ t('marketing.memberLevel.section.display') }}</h5>
<p class="ml-section__hint">{{ t('marketing.memberLevel.section.displayHint') }}</p>
</div>
</div>
<a-form-item
:label="t('marketing.memberLevel.form.iconUrl')"
field="iconUrl"
:tooltip="t('marketing.memberLevel.form.iconUrlExtra')"
>
<a-input
v-model="formState.iconUrl"
:placeholder="t('marketing.memberLevel.form.iconUrlPlaceholder')"
:max-length="256"
allow-clear
/>
</a-form-item>
<a-form-item :label="t('marketing.memberLevel.form.benefitDesc')" field="benefitDesc">
<a-textarea
v-model="formState.benefitDesc"
:auto-size="{ minRows: 3, maxRows: 5 }"
:max-length="500"
show-word-limit
:placeholder="t('marketing.memberLevel.form.benefitDescPlaceholder')"
/>
</a-form-item>
</section>
</a-form>
</div>
<footer class="ml-drawer__footer">
<a-button :disabled="drawerLoading" @click="handleDrawerCancel">
{{ t('common.cancel') }}
</a-button>
<a-button type="primary" :loading="drawerLoading" @click="handleDrawerOk">
{{ t('common.save') }}
</a-button>
</footer>
</div>
</a-drawer>
</MarketingPageShell>
</template>
<script lang="ts" setup>
import { computed, reactive, ref, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { Message, Modal } from '@arco-design/web-vue'
import HandsontablePro from '@/components/HandsontablePro/index.vue'
import PaginationPro from '@/components/PaginationPro/index.vue'
import { useTableContainerHeight } from '@/composables/useTableContainerHeight'
import { isEnableStatusEnabled, toEnableStatus } from '@/utils/enableStatus'
import {
createMemberLevel,
deleteMemberLevel,
getMemberLevelList,
updateMemberLevel,
} from '@/api/marketing/memberLevel'
import MarketingPageShell from '../components/MarketingPageShell.vue'
defineOptions({ name: 'MarketingMemberLevel' })
const { t } = useI18n()
const { tableContainerRef, tableHeight } = useTableContainerHeight()
const handsontableHeight = computed(() => Math.max(tableHeight.value || 0, 400))
interface MemberLevelRow {
id: number
levelCode: string
levelName: string
minPoints: number
extraDiscountRate: number
sort: number
iconUrl: string
status: boolean
statusName: string
customerCount: number
benefitDesc?: string
}
interface MemberLevelFormState {
levelCode: string
levelName: string
minPoints: number
extraDiscountRate: number
sort: number
iconUrl: string
status: number
benefitDesc: string
}
const tableRows = ref<MemberLevelRow[]>([])
const totalCount = ref(0)
const listLoading = ref(false)
const keyword = ref('')
const currentPage = ref(1)
const pageSize = ref(30)
const drawerVisible = ref(false)
const drawerLoading = ref(false)
const editingId = ref<number | undefined>(undefined)
const formRef = ref()
const formState = reactive<MemberLevelFormState>(createEmptyForm())
const formRules = computed(() => ({
levelCode: [{ required: true, message: t('marketing.memberLevel.form.levelCodePlaceholder') }],
levelName: [{ required: true, message: t('marketing.memberLevel.form.levelNamePlaceholder') }],
minPoints: [{ required: true, message: t('marketing.memberLevel.form.minPointsExtra') }],
status: [{ required: true, message: t('marketing.memberLevel.form.status') }],
}))
const previewInitial = computed(() => {
const name = formState.levelName?.trim()
if (name) return name.slice(0, 1)
const code = formState.levelCode?.trim()
return code ? code.slice(0, 1).toUpperCase() : 'M'
})
const previewDiscountText = computed(() => {
const rate = Number(formState.extraDiscountRate || 0)
return `${(rate * 100).toFixed(2)}%`
})
function createEmptyForm(): MemberLevelFormState {
return {
levelCode: '',
levelName: '',
minPoints: 0,
extraDiscountRate: 0,
sort: 0,
iconUrl: '',
status: 2,
benefitDesc: '',
}
}
function resetForm(values?: Partial<MemberLevelFormState>) {
Object.assign(formState, createEmptyForm(), values || {})
}
function resolveStatusName(status: unknown, statusName?: string): string {
if (statusName === '启用' || statusName === '禁用') return statusName
return isEnableStatusEnabled(status) ? t('common.enabled') : t('common.disabled')
}
function mapRow(item: any): MemberLevelRow {
const status = item.status ?? item.Status ?? 2
return {
id: item.id ?? item.Id,
levelCode: item.levelCode ?? item.LevelCode ?? '',
levelName: item.levelName ?? item.LevelName ?? '',
minPoints: Number(item.minPoints ?? item.MinPoints ?? 0),
extraDiscountRate: Number(item.extraDiscountRate ?? item.ExtraDiscountRate ?? 0),
sort: Number(item.sort ?? item.Sort ?? 0),
iconUrl: String(item.iconUrl ?? item.IconUrl ?? ''),
status: isEnableStatusEnabled(status),
statusName: resolveStatusName(status, item.statusName ?? item.StatusName),
customerCount: Number(item.customerCount ?? item.CustomerCount ?? 0),
benefitDesc: item.benefitDesc ?? item.BenefitDesc ?? '',
}
}
const hotColumns = computed(() => [
{ key: 'levelCode', label: t('marketing.memberLevel.form.levelCode'), width: 100 },
{ key: 'levelName', label: t('marketing.memberLevel.form.levelName'), width: 120 },
{ key: 'minPoints', label: t('marketing.memberLevel.form.minPoints'), width: 100 },
{
key: 'extraDiscountRate',
label: t('marketing.memberLevel.form.extraDiscountRate'),
width: 100,
formatter: (v: number) => `${(Number(v) * 100).toFixed(2)}%`,
},
{ key: 'sort', label: t('marketing.memberLevel.form.sort'), width: 70 },
{ key: 'statusName', label: t('marketing.memberLevel.form.status'), width: 80 },
{ key: 'customerCount', label: t('marketing.memberLevel.form.customerCount'), width: 90 },
])
const iconEdit =
'<svg viewBox="0 0 48 48" fill="none" stroke="currentColor" stroke-width="2" style="width:16px;height:16px"><path d="m30.48 19.038 5.733-5.734a1 1 0 0 0 0-1.414l-5.586-5.586a1 1 0 0 0-1.414 0l-5.734 5.734m7 7L15.763 33.754a1 1 0 0 1-.59.286l-6.048.708a1 1 0 0 1-1.113-1.069l.477-6.31a1 1 0 0 1 .29-.631l14.7-14.7m7 7-7-7M6 42h36"/></svg>'
const iconDelete =
'<svg viewBox="0 0 48 48" fill="none" stroke="currentColor" stroke-width="2" style="width:16px;height:16px"><path d="M5 11h5.5m0 0v29a1 1 0 0 0 1 1h25a1 1 0 0 0 1-1V11m-27 0H16m21.5 0H43m-5.5 0H32m-16 0V7h16v4m-16 0h16M20 18v15m8-15v15"/></svg>'
function actionRenderer(_row: MemberLevelRow, rowIndex: number): string {
return `
<div class="action-btns" style="display:flex;gap:8px;align-items:center;justify-content:center;width:100%;">
<span data-action="edit" data-row-index="${rowIndex}" title="${t('common.edit')}"
style="cursor:pointer;color:var(--app-primary-color);display:inline-flex;">${iconEdit}</span>
<span data-action="delete" data-row-index="${rowIndex}" title="${t('common.delete')}"
style="cursor:pointer;color:#f53f3f;display:inline-flex;">${iconDelete}</span>
</div>
`
}
async function loadList() {
listLoading.value = true
try {
const { items, total } = await getMemberLevelList({
keyword: keyword.value.trim() || undefined,
page: currentPage.value,
pageSize: pageSize.value,
})
tableRows.value = (items || []).map(mapRow)
totalCount.value = Number(total || 0)
} catch (e: any) {
Message.error(e?.message || t('common.loadFailed'))
tableRows.value = []
totalCount.value = 0
} finally {
listLoading.value = false
}
}
function handleSearch() {
currentPage.value = 1
loadList()
}
function openDrawer(row?: MemberLevelRow) {
editingId.value = row?.id
if (row) {
resetForm({
levelCode: row.levelCode,
levelName: row.levelName,
minPoints: row.minPoints,
extraDiscountRate: row.extraDiscountRate,
sort: row.sort,
iconUrl: row.iconUrl || '',
status: toEnableStatus(row.status),
benefitDesc: row.benefitDesc || '',
})
} else {
resetForm()
}
drawerVisible.value = true
}
function handleDrawerCancel() {
if (drawerLoading.value) return
drawerVisible.value = false
editingId.value = undefined
}
function handleActionClick(row: MemberLevelRow, _rowIndex: number, action?: string) {
if (action === 'edit') openDrawer(row)
if (action === 'delete') handleDelete(row)
}
function handleDelete(row: MemberLevelRow) {
Modal.confirm({
title: t('common.deleteConfirm'),
content: t('marketing.memberLevel.deleteConfirm', {
name: row.levelName,
count: row.customerCount,
}),
onOk: async () => {
await deleteMemberLevel(row.id)
Message.success(t('common.deleteSuccess'))
await loadList()
},
})
}
async function handleDrawerOk() {
try {
await formRef.value?.validate()
} catch {
return
}
const levelCode = String(formState.levelCode || '').trim()
const levelName = String(formState.levelName || '').trim()
if (!levelCode || !levelName) {
Message.warning(t('marketing.memberLevel.form.requiredHint'))
return
}
drawerLoading.value = true
try {
const payload = {
id: editingId.value,
levelCode,
levelName,
minPoints: Number(formState.minPoints ?? 0),
extraDiscountRate: Number(formState.extraDiscountRate ?? 0),
sort: Number(formState.sort ?? 0),
iconUrl: String(formState.iconUrl || '').trim() || undefined,
status: Number(formState.status ?? 2),
benefitDesc: String(formState.benefitDesc || '').trim() || undefined,
}
if (editingId.value) {
await updateMemberLevel(payload)
} else {
await createMemberLevel(payload)
}
Message.success(t('common.saveSuccess'))
drawerVisible.value = false
editingId.value = undefined
await loadList()
} catch (e: any) {
Message.error(e?.message || t('common.saveFailed'))
} finally {
drawerLoading.value = false
}
}
onMounted(() => loadList())
</script>
<style scoped lang="less">
@import '../styles/admin-shared.less';
.ml-drawer {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
margin: -16px;
}
.ml-drawer__header {
flex-shrink: 0;
padding: 20px 24px 16px;
background:
linear-gradient(
135deg,
color-mix(in srgb, var(--app-primary-color) 12%, #fff) 0%,
#fff 58%
);
border-bottom: 1px solid var(--color-border-2, #e5e6eb);
}
.ml-drawer__title-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.ml-drawer__eyebrow {
margin: 0 0 4px;
font-size: 12px;
letter-spacing: 0.04em;
color: var(--app-primary-color);
font-weight: 600;
}
.ml-drawer__title {
margin: 0;
font-size: 18px;
line-height: 1.35;
font-weight: 600;
color: var(--color-text-1, #1d2129);
}
.ml-drawer__desc {
margin: 10px 0 0;
font-size: 13px;
line-height: 1.55;
color: var(--color-text-2, #4e5969);
}
.ml-drawer__close {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: none;
border-radius: 8px;
background: rgba(0, 0, 0, 0.04);
color: var(--color-text-2, #4e5969);
cursor: pointer;
transition: background 0.2s ease, color 0.2s ease;
&:hover {
background: color-mix(in srgb, var(--app-primary-color) 12%, #fff);
color: var(--app-primary-color);
}
}
.ml-drawer__body {
flex: 1;
min-height: 0;
overflow: auto;
padding: 16px 24px 8px;
}
.ml-preview {
margin-bottom: 18px;
padding: 16px;
border-radius: 12px;
background:
radial-gradient(
120% 140% at 0% 0%,
color-mix(in srgb, #fff 28%, transparent),
transparent 55%
),
linear-gradient(
135deg,
color-mix(in srgb, var(--app-primary-color) 92%, #102020) 0%,
color-mix(in srgb, var(--app-primary-color) 62%, #1c3333) 100%
);
color: #fff;
box-shadow: 0 10px 28px color-mix(in srgb, var(--app-primary-color) 28%, transparent);
transition: opacity 0.2s ease;
&.is-disabled {
opacity: 0.72;
filter: grayscale(0.25);
}
}
.ml-preview__badge {
display: inline-flex;
align-items: center;
gap: 6px;
margin-bottom: 12px;
padding: 2px 10px;
border-radius: 999px;
font-size: 12px;
background: rgba(255, 255, 255, 0.12);
}
.ml-preview__dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: #00b42a;
}
.ml-preview.is-disabled .ml-preview__dot {
background: #c9cdd4;
}
.ml-preview__main {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 14px;
}
.ml-preview__icon {
width: 48px;
height: 48px;
border-radius: 12px;
display: inline-flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.14);
font-size: 20px;
font-weight: 700;
overflow: hidden;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.ml-preview__name {
margin: 0;
font-size: 18px;
font-weight: 650;
line-height: 1.3;
}
.ml-preview__code {
margin: 4px 0 0;
font-size: 12px;
opacity: 0.72;
letter-spacing: 0.02em;
}
.ml-preview__stats {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
}
.ml-preview__stat {
padding: 10px 10px;
border-radius: 8px;
background: rgba(255, 255, 255, 0.08);
min-width: 0;
}
.ml-preview__stat-label {
display: block;
font-size: 11px;
opacity: 0.7;
margin-bottom: 4px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.ml-preview__stat-value {
display: block;
font-size: 14px;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.ml-section {
margin-bottom: 18px;
padding: 14px 14px 4px;
border-radius: 10px;
background: #f7f8fa;
border: 1px solid var(--color-border-2, #e5e6eb);
}
.ml-section__head {
display: flex;
gap: 10px;
align-items: flex-start;
margin-bottom: 12px;
}
.ml-section__index {
flex-shrink: 0;
width: 28px;
height: 28px;
border-radius: 8px;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 11px;
font-weight: 700;
color: var(--app-primary-color);
background: color-mix(in srgb, var(--app-primary-color) 14%, #fff);
}
.ml-section__title {
margin: 0;
font-size: 14px;
font-weight: 600;
color: var(--color-text-1, #1d2129);
}
.ml-section__hint {
margin: 2px 0 0;
font-size: 12px;
color: var(--color-text-3, #86909c);
line-height: 1.4;
}
.ml-form {
:deep(.arco-form-item) {
margin-bottom: 14px;
}
:deep(.arco-form-item-label) {
font-size: 13px;
font-weight: 500;
color: var(--color-text-1, #1d2129);
}
:deep(.arco-input-wrapper),
:deep(.arco-select-view-single),
:deep(.arco-textarea-wrapper),
:deep(.arco-input-number) {
border-radius: 6px;
}
}
.ml-status-group {
display: flex;
width: 100%;
:deep(.arco-radio-button) {
flex: 1;
text-align: center;
}
}
.ml-drawer__footer {
flex-shrink: 0;
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 14px 24px 18px;
border-top: 1px solid var(--color-border-2, #e5e6eb);
background: #fff;
}
</style>
<style lang="less">
.member-level-drawer {
.arco-drawer-header {
display: none;
}
.arco-drawer-body {
padding: 16px;
height: 100%;
overflow: hidden;
}
}
</style>