feat: 更新商品管理功能,添加虚拟礼物多Key支持和排序功能

- 在商品模型中添加密钥选择模式和虚拟密钥列表
- 更新商品展示组件,支持置顶标记和价格徽章
- 优化商品管理视图,添加排序功能和清空筛选条件的功能
- 改进礼物添加表单,增加输入验证和错误提示
This commit is contained in:
2025-04-30 04:39:36 +08:00
parent 968c34f57a
commit 6160c89c68
6 changed files with 858 additions and 332 deletions

View File

@@ -650,6 +650,14 @@ export enum GoodsTypes {
Physical,
Virtual
}
// 添加密钥选择模式枚举
export enum KeySelectionMode {
None,
Random, // 随机选择
Sequential // 顺序选择
}
export interface PointGoodsSetting {
guardFree?: { year: number; month: number }
allowGuardLevel?: GuardLevel
@@ -670,16 +678,22 @@ export interface ResponsePointGoodModel {
maxBuyCount?: number
collectUrl?: string
embedCollectUrl?: boolean
isPinned: boolean
canFreeBuy: boolean
allowGuardLevel: GuardLevel
setting: PointGoodsSetting
// 添加虚拟礼物多Key支持
virtualKeys?: string[]
keySelectionMode?: KeySelectionMode
currentKeyIndex?: number
}
export interface ImageUploadModel {
existImages: string[]
newImagesBase64: string[]
}
export interface PointGoodsModel {
export interface UploadPointGoodsModel {
id?: number
name: string
count?: number
@@ -694,8 +708,14 @@ export interface PointGoodsModel {
content?: string
isAllowRebuy: boolean
maxBuyCount?: number
isPinned: boolean
setting: PointGoodsSetting
// 添加虚拟礼物多Key支持
virtualKeys?: string[]
keySelectionMode?: KeySelectionMode
currentKeyIndex?: number
}
export interface AddressInfo {
id?: string

9
src/components.d.ts vendored
View File

@@ -20,28 +20,19 @@ declare module 'vue' {
MonacoEditorComponent: typeof import('./components/MonacoEditorComponent.vue')['default']
NAlert: typeof import('naive-ui')['NAlert']
NAvatar: typeof import('naive-ui')['NAvatar']
NBadge: typeof import('naive-ui')['NBadge']
NButton: typeof import('naive-ui')['NButton']
NCard: typeof import('naive-ui')['NCard']
NCollapse: typeof import('naive-ui')['NCollapse']
NDataTable: typeof import('naive-ui')['NDataTable']
NDivider: typeof import('naive-ui')['NDivider']
NEmpty: typeof import('naive-ui')['NEmpty']
NFlex: typeof import('naive-ui')['NFlex']
NFormItemGi: typeof import('naive-ui')['NFormItemGi']
NGridItem: typeof import('naive-ui')['NGridItem']
NIcon: typeof import('naive-ui')['NIcon']
NImage: typeof import('naive-ui')['NImage']
NInput: typeof import('naive-ui')['NInput']
NPopconfirm: typeof import('naive-ui')['NPopconfirm']
NScrollbar: typeof import('naive-ui')['NScrollbar']
NSpace: typeof import('naive-ui')['NSpace']
NSpin: typeof import('naive-ui')['NSpin']
NTabPane: typeof import('naive-ui')['NTabPane']
NTabs: typeof import('naive-ui')['NTabs']
NTag: typeof import('naive-ui')['NTag']
NText: typeof import('naive-ui')['NText']
NTooltip: typeof import('naive-ui')['NTooltip']
PointGoodsItem: typeof import('./components/manage/PointGoodsItem.vue')['default']
PointHistoryCard: typeof import('./components/manage/PointHistoryCard.vue')['default']
PointOrderCard: typeof import('./components/manage/PointOrderCard.vue')['default']

View File

@@ -2,7 +2,7 @@
import { GoodsTypes, ResponsePointGoodModel } from '@/api/api-models';
import { FILE_BASE_URL, IMGUR_URL } from '@/data/constants';
import { NAlert, NCard, NEllipsis, NEmpty, NFlex, NIcon, NImage, NTag, NText } from 'naive-ui';
import { VehicleShip20Filled } from '@vicons/fluent';
import { VehicleShip20Filled, Pin16Filled } from '@vicons/fluent';
const props = defineProps<{
goods: ResponsePointGoodModel | undefined;
@@ -22,77 +22,143 @@
v-else
embedded
:style="props.contentStyle"
size="small"
class="goods-card"
:class="{ 'pinned-card': goods.isPinned }"
>
<!-- 商品封面 -->
<template #cover>
<NImage
:src="goods.cover ? FILE_BASE_URL + goods.cover : emptyCover"
:fallback-src="emptyCover"
height="150"
object-fit="cover"
:preview-disabled="!goods.cover"
style="width: 100%"
/>
<div class="cover-container">
<NImage
:src="goods.cover ? FILE_BASE_URL + goods.cover : emptyCover"
:fallback-src="emptyCover"
height="150"
object-fit="cover"
:preview-disabled="!goods.cover"
style="width: 100%"
/>
<!-- 置顶标记 -->
<div
v-if="goods.isPinned"
class="pin-badge"
>
<NIcon :component="Pin16Filled" />
</div>
<!-- 价格徽章 -->
<div class="price-badge">
<NText class="price-text">
🪙 {{ goods.price > 0 ? goods.price : '免费' }}
</NText>
</div>
<!-- 标签容器 -->
<div class="tags-badge">
<!-- 商品类型标签 -->
<NTag
size="small"
:bordered="true"
style="background-color: transparent;"
:style="{
color: goods.type == GoodsTypes.Physical ? '#006633' : '#0066cc',
borderColor: goods.type == GoodsTypes.Physical ? '#009966' : '#3399ff',
backgroundColor: goods.type == GoodsTypes.Physical ? '#c2e6d290' : '#c2d6eb90'
}"
>
{{ goods.type == GoodsTypes.Physical ? '实物' : '虚拟' }}
</NTag>
<!-- 状态标签 -->
<NTag
v-if="goods.count == 0"
size="small"
type="error"
:bordered="false"
style="color: #ffffff; background-color: rgba(255, 85, 85, 0.7);"
>
已售完
</NTag>
<!-- 舰长限制标签 -->
<NTag
v-if="goods.allowGuardLevel > 0"
size="small"
type="warning"
:bordered="false"
style="color: #333333; background-color: rgba(255, 204, 0, 0.7);"
>
{{ goods.allowGuardLevel === 1 ? '总督' : goods.allowGuardLevel === 2 ? '提督' : '舰长' }}专属
</NTag>
</div>
</div>
</template>
<!-- 商品信息头部 -->
<template #header-extra>
<NFlex justify="space-between">
<NFlex>
<NText depth="3">
库存:
</NText>
<NText v-if="goods.count && goods.count > 0">
{{ goods.count }}
</NText>
<NText
v-else-if="goods.count == 0"
style="color: #5f5f5f;"
<!-- 商品信息头部 - 改为水平布局 -->
<template #header>
<NFlex vertical>
<!-- 标题行左侧标题右侧库存 -->
<NFlex
justify="space-between"
align="center"
class="title-row"
>
<NFlex
align="center"
class="title-container"
>
</NText>
<NText v-else>
</NText>
<NEllipsis
strong
class="goods-title"
:line-clamp="1"
>
{{ goods.name }}
</NEllipsis>
</NFlex>
<NFlex
align="center"
class="stock-info"
>
<NText
depth="3"
size="small"
>
库存:
</NText>
<NText
v-if="goods.count && goods.count > 0"
size="small"
>
{{ goods.count }}
</NText>
<NText
v-else-if="goods.count == 0"
size="small"
type="error"
>
</NText>
<NText
v-else
size="small"
>
</NText>
</NFlex>
</NFlex>
</NFlex>
</template>
<!-- 商品标题 -->
<template #header>
<NFlex
align="center"
:size="5"
>
<!-- 售罄标签 -->
<NTag
v-if="goods.count == 0"
size="small"
type="error"
:bordered="false"
>
已售完
</NTag>
<!-- 商品类型标签 -->
<NTag
size="small"
:bordered="goods.type != GoodsTypes.Physical"
>
{{ goods.type == GoodsTypes.Physical ? '实物' : '虚拟' }}
</NTag>
<!-- 商品名称 -->
<NEllipsis>
{{ goods.name }}
</NEllipsis>
</NFlex>
</template>
<!-- 商品描述和标签 -->
<NFlex vertical>
<NEllipsis :line-clamp="2">
<NFlex
vertical
:gap="8"
class="content-section"
>
<!-- 描述文本 -->
<NEllipsis
:line-clamp="2"
class="description-text"
>
<NText
:depth="goods.description ? 1 : 3"
:italic="!goods.description"
@@ -101,30 +167,24 @@
</NText>
</NEllipsis>
<!-- 标签展示 -->
<NFlex wrap>
<!-- 舰长限制标签 -->
<NTag
v-if="goods.allowGuardLevel > 0"
size="tiny"
:color="{ color: '#5f5f5f', textColor: 'gold' }"
>
<template #icon>
<NIcon :component="VehicleShip20Filled" />
</template>
仅限舰长
</NTag>
<!-- 商品标签 -->
<NTag
v-for="tag in goods.tags"
:key="tag"
:bordered="false"
size="tiny"
>
{{ tag }}
</NTag>
</NFlex>
<!-- 用户自定义标签展示 -->
<div
v-if="goods.tags && goods.tags.length > 0"
class="tags-container"
>
<div class="tags-wrapper">
<NTag
v-for="tag in goods.tags"
:key="tag"
:bordered="false"
size="tiny"
class="user-tag"
style="color: #f0f0f0; background-color: rgba(100, 100, 110, 0.7);"
>
{{ tag }}
</NTag>
</div>
</div>
</NFlex>
<!-- 自定义页脚 -->
@@ -137,10 +197,118 @@
<style scoped>
.goods-card {
transition: all 0.3s ease;
position: relative;
}
.goods-card:hover {
transform: translateY(-3px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.pinned-card {
border: 2px solid var(--primary-color);
box-shadow: 0 2px 10px rgba(var(--primary-color-rgb), 0.15);
}
.cover-container {
position: relative;
max-height: 100%;
}
.pin-badge {
position: absolute;
top: 8px;
right: 8px;
width: 28px;
height: 28px;
border-radius: 50%;
background-color: var(--error-color);
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2);
color: white;
transform: rotate(45deg);
z-index: 2;
}
.price-badge {
position: absolute;
bottom: 0;
right: 0;
background-color: rgba(0, 0, 0, 0.5);
color: white;
padding: 4px 8px;
border-top-left-radius: 6px;
z-index: 2;
}
.tags-badge {
position: absolute;
bottom: 0;
left: 0;
background-color: rgba(0, 0, 0, 0.5);
padding: 4px 8px;
border-top-right-radius: 6px;
z-index: 2;
display: flex;
flex-wrap: wrap;
gap: 4px;
max-width: 70%;
}
.price-text {
font-weight: bold;
font-size: 0.9em;
color: #ffffff;
}
.title-row {
margin-bottom: 4px;
}
.title-container {
max-width: 70%;
}
.goods-title {
font-size: 1em;
line-height: 1.3;
word-break: break-word;
}
.content-section {
margin-top: 6px;
}
.description-text {
margin-bottom: 4px;
}
.tags-container {
position: relative;
max-height: 40px;
overflow: hidden;
margin-top: 4px;
}
.tags-wrapper {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.user-tag {
transition: all 0.2s ease;
}
.user-tag:hover {
transform: translateY(-2px);
z-index: 1;
}
.stock-info {
font-size: 0.85em;
color: var(--text-color-3);
}
</style>

View File

@@ -1,7 +1,7 @@
<script setup lang="ts">
import { copyToClipboard, getImageUploadModel } from '@/Utils'
import { DisableFunction, EnableFunction, useAccount } from '@/api/account'
import { FunctionTypes, GoodsStatus, GoodsTypes, PointGoodsModel, ResponsePointGoodModel } from '@/api/api-models'
import { FunctionTypes, GoodsStatus, GoodsTypes, UploadPointGoodsModel, ResponsePointGoodModel, KeySelectionMode } from '@/api/api-models'
import { QueryGetAPI, QueryPostAPI } from '@/api/query'
import EventFetcherStatusCard from '@/components/EventFetcherStatusCard.vue'
import PointGoodsItem from '@/components/manage/PointGoodsItem.vue'
@@ -42,6 +42,7 @@ import {
UploadFileInfo,
useDialog,
useMessage,
NDynamicTags,
} from 'naive-ui'
import { computed, onMounted, ref } from 'vue'
import PointOrderManage from './PointOrderManage.vue'
@@ -76,13 +77,21 @@ const defaultGoodsModel = {
status: GoodsStatus.Normal,
maxBuyCount: 1,
isAllowRebuy: false,
isPinned: false,
setting: {
allowGuardLevel: 0
},
} as PointGoodsModel,
virtualKeys: [],
keySelectionMode: KeySelectionMode.None,
currentKeyIndex: 0,
name: '',
price: 0,
tags: [],
description: ''
} as UploadPointGoodsModel,
fileList: [],
} as { goods: PointGoodsModel; fileList: UploadFileInfo[] }
const currentGoodsModel = ref<{ goods: PointGoodsModel; fileList: UploadFileInfo[] }>(
} as { goods: UploadPointGoodsModel; fileList: UploadFileInfo[] }
const currentGoodsModel = ref<{ goods: UploadPointGoodsModel; fileList: UploadFileInfo[] }>(
JSON.parse(JSON.stringify(defaultGoodsModel))
)
@@ -478,37 +487,29 @@ onMounted(() => { })
>
<template #footer>
<NFlex
vertical
:gap="8"
style="width: 100%"
>
<span>价格: {{ item.price }}</span>
<NFlex
justify="space-between"
:gap="8"
<NButton
type="info"
size="small"
@click="onUpdateClick(item)"
>
<NButton
type="info"
size="small"
@click="onUpdateClick(item)"
>
修改
</NButton>
<NButton
type="warning"
size="small"
@click="onSetShelfClick(item, GoodsStatus.Discontinued)"
>
下架
</NButton>
<NButton
type="error"
size="small"
@click="onDeleteClick(item)"
>
删除
</NButton>
</NFlex>
修改
</NButton>
<NButton
type="warning"
size="small"
@click="onSetShelfClick(item, GoodsStatus.Discontinued)"
>
下架
</NButton>
<NButton
type="error"
size="small"
@click="onDeleteClick(item)"
>
删除
</NButton>
</NFlex>
</template>
</PointGoodsItem>
@@ -629,25 +630,24 @@ onMounted(() => { })
确定要重置此页面内容?
</NPopconfirm>
</template>
<NScrollbar style="max-height: 80vh">
<NForm
ref="formRef"
:model="currentGoodsModel"
:rules="rules"
style="width: 100%"
<div class="scrollable-container">
<NScrollbar
style="max-height: 70vh; padding-right: 12px;"
class="goods-scrollbar"
>
<!-- 基本信息分组 -->
<NDivider
title-placement="left"
style="margin: 8px 0 16px"
>
基本信息
</NDivider>
<NFlex
vertical
:gap="12"
style="margin-bottom: 16px"
<NForm
ref="formRef"
:model="currentGoodsModel"
:rules="rules"
style="width: 100%"
>
<!-- 基本信息分组 -->
<NDivider
title-placement="left"
style="margin: 8px 0 16px"
>
基本信息
</NDivider>
<NFormItem
path="goods.name"
label="名称"
@@ -693,20 +693,23 @@ onMounted(() => { })
/>
</NFlex>
</NFormItem>
</NFlex>
<!-- 详细描述分组 -->
<NDivider
title-placement="left"
style="margin: 16px 0"
>
详细描述
</NDivider>
<NFlex
vertical
:gap="12"
style="margin-bottom: 16px"
>
<NFormItem
path="goods.isPinned"
label="置顶显示"
>
<NCheckbox v-model:checked="currentGoodsModel.goods.isPinned">
在礼物列表中置顶显示
</NCheckbox>
</NFormItem>
<!-- 详细描述分组 -->
<NDivider
title-placement="left"
style="margin-top: 0;"
>
详细描述
</NDivider>
<NFormItem
path="goods.description"
label="描述"
@@ -737,6 +740,7 @@ onMounted(() => { })
<NFormItem
path="goods.cover"
label="封面"
style="margin-bottom: 16px;"
>
<NFlex
vertical
@@ -766,20 +770,14 @@ onMounted(() => { })
</NUpload>
</NFlex>
</NFormItem>
</NFlex>
<!-- 兑换规则分组 -->
<NDivider
title-placement="left"
style="margin: 16px 0"
>
兑换规则
</NDivider>
<NFlex
vertical
:gap="12"
style="margin-bottom: 16px"
>
<!-- 兑换规则分组 -->
<NDivider
title-placement="left"
style="margin: 16px 0"
>
兑换规则
</NDivider>
<NFormItem
path="goods.type"
label="礼物类型"
@@ -806,6 +804,7 @@ onMounted(() => { })
<NFormItem
path="goods.guardFree"
label="特殊权限"
style="margin-bottom: 16px;"
>
<NFlex
vertical
@@ -891,21 +890,15 @@ onMounted(() => { })
</NRadioGroup>
</NFlex>
</NFormItem>
</NFlex>
<!-- 礼物类型特定配置 -->
<template v-if="currentGoodsModel.goods.type == GoodsTypes.Physical">
<NDivider
title-placement="left"
style="margin: 16px 0"
>
实物礼物配置
</NDivider>
<NFlex
vertical
:gap="12"
style="margin-bottom: 16px"
>
<!-- 礼物类型特定配置 -->
<template v-if="currentGoodsModel.goods.type == GoodsTypes.Physical">
<NDivider
title-placement="left"
style="margin: 16px 0"
>
实物礼物配置
</NDivider>
<NFormItem
path="goods.maxBuyCount"
label="最大兑换数量"
@@ -920,6 +913,7 @@ onMounted(() => { })
<NFormItem
path="address"
label="收货地址"
style="margin-bottom: 16px;"
>
<NFlex
vertical
@@ -977,23 +971,67 @@ onMounted(() => { })
</NCheckbox>
</NFormItem>
</template>
</NFlex>
</template>
<template v-else>
<NDivider
title-placement="left"
style="margin: 16px 0"
>
虚拟礼物配置
</NDivider>
<NFlex
vertical
:gap="12"
style="margin-bottom: 16px"
>
</template>
<template v-else>
<NDivider
title-placement="left"
style="margin: 16px 0"
>
虚拟礼物配置
</NDivider>
<NFormItem
path="goods.keySelectionMode"
label="密钥选择模式"
>
<NRadioGroup v-model:value="currentGoodsModel.goods.keySelectionMode">
<NRadioButton :value="KeySelectionMode.None">
不使用
</NRadioButton>
<NRadioButton :value="KeySelectionMode.Random">
随机选择
</NRadioButton>
<NRadioButton :value="KeySelectionMode.Sequential">
顺序选择
</NRadioButton>
</NRadioGroup>
</NFormItem>
<!-- 添加多Key支持配置 -->
<NFormItem
v-if="currentGoodsModel.goods.keySelectionMode != KeySelectionMode.None"
path="goods.virtualKeys"
label="礼物密钥列表 (可选)"
>
<template #label>
礼物密钥列表
<NTooltip>
<template #trigger>
<NIcon :component="Info24Filled" />
</template>
添加多个密钥用户购买时会根据选择模式分配一个密钥. 可以留空
</NTooltip>
</template>
<NFlex
vertical
:gap="8"
>
<NDynamicTags
v-model:value="currentGoodsModel.goods.virtualKeys"
placeholder="输入密钥后按Enter添加"
/>
<NText
depth="3"
style="margin-top: 4px; display: block"
>
已添加 {{ (currentGoodsModel.goods.virtualKeys || []).length }} 个密钥
</NText>
</NFlex>
</NFormItem>
<NFormItem
path="goods.content"
required
style="margin-bottom: 16px;"
>
<template #label>
礼物内容
@@ -1001,36 +1039,40 @@ onMounted(() => { })
<template #trigger>
<NIcon :component="Info24Filled" />
</template>
虚拟礼物的具体内容, 网盘链接什么之类的
虚拟礼物的具体内容可使用 {key} 作为占位符
</NTooltip>
</template>
<NInput
v-model:value="currentGoodsModel.goods.content"
type="textarea"
placeholder="写这里咯"
placeholder="写这里咯,可使用 {key} 作为占位符,购买时会自动替换为上面密钥列表中的一个"
maxlength="10000"
show-count
clearable
/>
</NFormItem>
</NFlex>
</template>
<NFlex
justify="center"
style="margin-top: 24px"
</template>
</NForm>
<!-- 添加一个底部间距让滚动更自然 -->
<div style="height: 16px;" />
</NScrollbar>
<div class="scroll-shadow-top" />
<div class="scroll-shadow-bottom" />
</div>
<template #footer>
<NFlex
justify="center"
>
<NButton
type="primary"
size="large"
:loading="isUpdating"
@click="updateGoods"
>
<NButton
type="primary"
size="large"
:loading="isUpdating"
@click="updateGoods"
>
{{ currentGoodsModel.goods.id ? '修改' : '创建' }}
</NButton>
</NFlex>
</NForm>
</NScrollbar>
{{ currentGoodsModel.goods.id ? '修改' : '创建' }}
</NButton>
</NFlex>
</template>
</NModal>
</template>
@@ -1059,6 +1101,60 @@ onMounted(() => { })
}
.goods-modal :deep(.n-card-content) {
padding: 0 20px 20px;
padding: 0 20px 8px;
}
.goods-modal :deep(.n-card-footer) {
padding: 12px 20px 16px;
border-top: 1px solid var(--border-color);
background-color: var(--action-color);
}
.scrollable-container {
position: relative;
background-color: var(--body-color);
border: 1px solid var(--border-color);
border-radius: 6px;
margin: 0 4px;
}
.goods-scrollbar {
padding: 12px 16px;
border-radius: 6px;
background-color: var(--card-color);
}
.goods-scrollbar :deep(.n-scrollbar-rail) {
right: 0;
}
.goods-scrollbar :deep(.n-scrollbar-content) {
padding-bottom: 8px;
}
.scroll-shadow-top {
position: absolute;
top: 0;
left: 0;
right: 0;
height: 12px;
pointer-events: none;
background: linear-gradient(to bottom, rgba(0, 0, 0, 0.06), transparent);
z-index: 1;
border-top-left-radius: 6px;
border-top-right-radius: 6px;
}
.scroll-shadow-bottom {
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 12px;
pointer-events: none;
background: linear-gradient(to top, rgba(0, 0, 0, 0.06), transparent);
z-index: 1;
border-bottom-left-radius: 6px;
border-bottom-right-radius: 6px;
}
</style>

View File

@@ -12,6 +12,8 @@ import {
NCheckboxGroup,
NDivider,
NFlex,
NForm,
NFormItem,
NIcon,
NInput,
NInputGroup,
@@ -61,7 +63,12 @@ const setting = computed({
})
// 添加礼物表单模型
const addGiftModel = ref<{ name: string; point: number }>({ name: '', point: 1 })
const addGiftModel = ref<{ name: string; point: number; nameError: string; pointError: string }>({
name: '',
point: 1,
nameError: '',
pointError: ''
})
// 是否可以编辑设置
const canEdit = computed(() => {
@@ -101,25 +108,60 @@ async function updateSettings() {
// 添加礼物积分规则
async function addGift() {
// 重置错误信息
addGiftModel.value.nameError = ''
addGiftModel.value.pointError = ''
// 表单验证
if (!addGiftModel.value.name) {
message.error('请输入礼物名称')
return
let hasError = false
if (!addGiftModel.value.name.trim()) {
addGiftModel.value.nameError = '请输入礼物名称'
hasError = true
} else if (setting.value.giftPercentMap[addGiftModel.value.name] !== undefined) {
addGiftModel.value.nameError = '此礼物名称已存在'
hasError = true
}
if (addGiftModel.value.point > 2147483647) {
message.error('积分不能超过2147483647')
if (!addGiftModel.value.point) {
addGiftModel.value.pointError = '请输入积分数量'
hasError = true
} else if (addGiftModel.value.point <= 0) {
addGiftModel.value.pointError = '积分必须大于0'
hasError = true
} else if (addGiftModel.value.point > 2147483647) {
addGiftModel.value.pointError = '积分不能超过2147483647'
hasError = true
}
if (hasError) {
return
}
// 添加礼物积分规则
setting.value.giftPercentMap[addGiftModel.value.name] = addGiftModel.value.point
const success = await updateGift()
isLoading.value = true
// 添加成功后清空表单
if (success) {
addGiftModel.value = { name: '', point: 1 }
showAddGiftModal.value = false
try {
const success = await updateGift()
// 添加成功后清空表单
if (success) {
addGiftModel.value = { name: '', point: 1, nameError: '', pointError: '' }
showAddGiftModal.value = false
message.success('礼物添加成功')
}
} catch (error) {
console.error('添加礼物失败:', error)
message.error('添加礼物失败,请重试')
} finally {
isLoading.value = false
}
}
// 处理键盘按下事件
function handleKeyDown(event: KeyboardEvent) {
if (event.key === 'Enter' && !isLoading.value) {
addGift()
}
}
@@ -401,14 +443,20 @@ async function SaveComboSetting() {
vertical
:gap="12"
>
<NButton
type="primary"
:disabled="!canEdit"
class="add-gift-button"
@click="showAddGiftModal = true"
<NFlex
justify="space-between"
align="center"
>
添加礼物
</NButton>
<span class="section-title">自定义礼物列表</span>
<NButton
type="primary"
:disabled="!canEdit"
class="add-gift-button"
@click="showAddGiftModal = true"
>
添加礼物
</NButton>
</NFlex>
<NList bordered>
<NEmpty
@@ -422,47 +470,61 @@ async function SaveComboSetting() {
>
<NFlex
align="center"
:gap="8"
justify="space-between"
style="width: 100%"
>
<NTag
:bordered="false"
size="small"
type="success"
<NFlex
align="center"
:gap="8"
>
{{ item[0] }}
</NTag>
<NInputGroup
style="width: 200px"
:disabled="!canEdit"
>
<NInputNumber
:value="setting.giftPercentMap[item[0]]"
:disabled="!canEdit"
min="0"
@update:value="(v) => (setting.giftPercentMap[item[0]] = v ?? 0)"
/>
<NButton
type="info"
:disabled="!canEdit"
@click="updateSettings"
<NTag
:bordered="false"
size="medium"
type="success"
>
确定
</NButton>
</NInputGroup>
<NPopconfirm @positive-click="deleteGift(item[0])">
<template #trigger>
<NButton
type="error"
text
{{ item[0] }}
</NTag>
</NFlex>
<NFlex
align="center"
:gap="12"
>
<NInputGroup
style="width: 180px"
:disabled="!canEdit"
>
<NInputNumber
:value="setting.giftPercentMap[item[0]]"
:disabled="!canEdit"
min="0"
@update:value="(v) => (setting.giftPercentMap[item[0]] = v ? v : 0)"
/>
<NButton
type="info"
size="small"
:disabled="!canEdit"
@click="updateSettings"
>
<template #icon>
<NIcon :component="Delete24Regular" />
</template>
更新
</NButton>
</template>
确定要删除这个礼物吗?
</NPopconfirm>
</NInputGroup>
<NPopconfirm @positive-click="deleteGift(item[0])">
<template #trigger>
<NButton
type="error"
size="small"
:disabled="!canEdit"
>
<template #icon>
<NIcon :component="Delete24Regular" />
</template>
删除
</NButton>
</template>
确定要删除这个礼物吗?
</NPopconfirm>
</NFlex>
</NFlex>
</NListItem>
</NList>
@@ -475,45 +537,73 @@ async function SaveComboSetting() {
v-model:show="showAddGiftModal"
preset="card"
title="添加礼物"
style="max-width: 400px"
style="max-width: 480px"
:mask-closable="false"
>
<NFlex
align="center"
vertical
:gap="12"
>
<NForm>
<NAlert
title="注意"
type="warning"
closable
style="margin-bottom: 16px"
>
这里填写的积分是指这个礼物直接对应多少积分, 而不是兑换比例
<template #icon>
<NIcon :component="Info24Filled" />
</template>
这里填写的积分是指这个礼物直接对应多少积分而不是兑换比例
</NAlert>
<NInputGroup class="modal-input">
<NInputGroupLabel> 礼物名称 </NInputGroupLabel>
<NFormItem
label="礼物名称"
:validation-status="addGiftModel.nameError ? 'error' : undefined"
:feedback="addGiftModel.nameError"
required
>
<NInput
v-model:value="addGiftModel.name"
placeholder="礼物名称"
placeholder="请输入礼物名称"
clearable
autofocus
@keydown="handleKeyDown"
/>
</NInputGroup>
</NFormItem>
<NInputGroup class="modal-input">
<NInputGroupLabel> 给予积分 </NInputGroupLabel>
<NFormItem
label="给予积分"
:validation-status="addGiftModel.pointError ? 'error' : undefined"
:feedback="addGiftModel.pointError"
required
>
<NInputNumber
v-model:value="addGiftModel.point"
placeholder="积分数量"
min="0"
placeholder="请输入积分数量"
min="1"
clearable
style="width: 100%"
@keydown="handleKeyDown"
/>
</NInputGroup>
</NFormItem>
<NButton
type="info"
:loading="isLoading"
@click="addGift"
<NFlex
justify="end"
:gap="12"
style="margin-top: 24px"
>
确定
</NButton>
</NFlex>
<NButton
@click="showAddGiftModal = false"
>
取消
</NButton>
<NButton
type="primary"
:loading="isLoading"
:disabled="!addGiftModel.name || !addGiftModel.point || addGiftModel.point <= 0"
@click="addGift"
>
确定
</NButton>
</NFlex>
</NForm>
</NModal>
</template>
</NFlex>
@@ -539,16 +629,31 @@ async function SaveComboSetting() {
max-width: 100%;
}
.section-title {
font-size: 16px;
font-weight: 500;
margin: 4px 0;
}
.gift-card {
width: 100%;
margin-top: 8px;
}
.add-gift-button {
max-width: 200px;
max-width: 120px;
}
.modal-input {
width: 100%;
margin-bottom: 8px;
}
.error-text {
color: var(--error-color, #d03050);
font-size: 12px;
margin-top: -6px;
margin-bottom: 8px;
}
/* 响应式布局优化 */

View File

@@ -65,7 +65,7 @@ const selectedAddress = ref<AddressInfo>() // 选中的地址
const selectedTag = ref<string>() // 选中的标签
const onlyCanBuy = ref(false) // 只显示可兑换
const ignoreGuard = ref(false) // 忽略舰长限制
const priceOrder = ref<'asc' | 'desc' | null>(null) // 价格排序
const sortOrder = ref<string | null>(null) // 排序方式
const searchKeyword = ref('') // 搜索关键词
// --- 计算属性 ---
@@ -116,16 +116,53 @@ const selectedItems = computed(() => {
(item.description && item.description.toLowerCase().includes(searchKeyword.value.toLowerCase())),
)
// 价格排序
if (priceOrder.value) {
filteredItems = filteredItems.sort((a, b) => {
return priceOrder.value === 'asc' ? a.price - b.price : b.price - a.price
})
// 应用排序方式
if (sortOrder.value) {
switch (sortOrder.value) {
case 'price_asc':
filteredItems = filteredItems.sort((a, b) => a.price - b.price)
break
case 'price_desc':
filteredItems = filteredItems.sort((a, b) => b.price - a.price)
break
case 'name_asc':
filteredItems = filteredItems.sort((a, b) => a.name.localeCompare(b.name))
break
case 'name_desc':
filteredItems = filteredItems.sort((a, b) => b.name.localeCompare(a.name))
break
case 'type':
filteredItems = filteredItems.sort((a, b) => a.type - b.type)
break
case 'popular':
// 按照热门程度排序(置顶的排在前面)
filteredItems = filteredItems.sort((a, b) => {
if (a.isPinned && !b.isPinned) return -1
if (!a.isPinned && b.isPinned) return 1
return 0
})
break
}
}
return filteredItems
// 无论是否有其他排序,置顶礼物始终排在前面
return filteredItems.sort((a, b) => {
// 先按置顶状态排序
if (a.isPinned && !b.isPinned) return -1;
if (!a.isPinned && b.isPinned) return 1;
// 如果已有排序方式,则不再进行额外排序
if (sortOrder.value) return 0;
// 默认排序逻辑
return 0;
})
})
// 获取商品标签颜色
function getTagColor(index: number): 'default' | 'info' | 'success' | 'warning' | 'error' | 'primary' {
const colors: Array<'default' | 'info' | 'success' | 'warning' | 'error' | 'primary'> = ['default', 'info', 'success', 'warning', 'error'];
return colors[index % colors.length];
}
// --- 方法 ---
// 获取礼物兑换按钮的提示文本
@@ -297,6 +334,15 @@ function gotoAuthPage() {
NavigateToNewTab('/bili-user')
}
// 清空筛选条件
function clearFilters() {
selectedTag.value = undefined
searchKeyword.value = ''
onlyCanBuy.value = false
ignoreGuard.value = false
sortOrder.value = null
}
// --- 生命周期钩子 ---
onMounted(async () => {
isLoading.value = true // 开始加载
@@ -467,13 +513,17 @@ onMounted(async () => {
>
忽略舰长限制
</NCheckbox>
<!-- 价格排序 -->
<!-- 排序方式 -->
<NSelect
v-model:value="priceOrder"
v-model:value="sortOrder"
:options="[
{ label: '默认排序', value: 'null' },
{ label: '价格 ↑', value: 'asc' },
{ label: '价格 ↓', value: 'desc' }
{ label: '价格 ↑', value: 'price_asc' },
{ label: '价格 ↓', value: 'price_desc' },
{ label: '名称 ↑', value: 'name_asc' },
{ label: '名称 ↓', value: 'name_desc' },
{ label: '类型', value: 'type' },
{ label: '置顶', value: 'popular' }
]"
placeholder="排序方式"
size="small"
@@ -498,9 +548,9 @@ onMounted(async () => {
>
<template #extra>
<NButton
v-if="goods.length > 0 && (selectedTag || searchKeyword || onlyCanBuy || ignoreGuard || priceOrder)"
v-if="goods.length > 0 && (selectedTag || searchKeyword || onlyCanBuy || ignoreGuard || sortOrder)"
size="small"
@click="() => { selectedTag = undefined; searchKeyword = ''; onlyCanBuy = false; ignoreGuard = false; priceOrder = null; }"
@click="clearFilters"
>
清空筛选条件
</NButton>
@@ -516,6 +566,7 @@ onMounted(async () => {
:goods="item"
content-style="max-width: 300px; min-width: 250px; height: 380px;"
class="goods-item"
:class="{ 'pinned-item': item.isPinned }"
>
<template #footer>
<NFlex
@@ -532,28 +583,11 @@ onMounted(async () => {
class="exchange-btn"
@click="onBuyClick(item)"
>
兑换
{{ item.isPinned ? '🔥 兑换' : '兑换' }}
</NButton>
</template>
{{ getTooltip(item) }}
</NTooltip>
<NFlex
align="center"
justify="end"
class="price-display"
>
<NTooltip placement="bottom">
<template #trigger>
<NText
class="price-text"
:delete="item.canFreeBuy"
>
🪙 {{ item.price > 0 ? item.price : '免费' }}
</NText>
</template>
{{ item.canFreeBuy ? '你可以免费兑换此礼物' : '所需积分' }}
</NTooltip>
</NFlex>
</NFlex>
</template>
</PointGoodsItem>
@@ -775,34 +809,146 @@ onMounted(async () => {
.goods-item {
break-inside: avoid;
background-color: var(--card-color);
transition: all 0.2s ease-in-out;
transition: all 0.3s ease-in-out;
border-radius: var(--border-radius);
border: 1px solid var(--border-color);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.02);
position: relative;
overflow: hidden;
}
.goods-item:hover {
transform: translateY(-3px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
z-index: 1;
}
.pinned-item {
border: 2px solid var(--primary-color);
box-shadow: 0 2px 12px rgba(var(--primary-color-rgb), 0.15);
position: relative;
}
.pinned-item::before {
content: none;
}
.pin-icon {
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 0.9em;
margin-right: 2px;
}
.goods-content {
height: 100%;
display: flex;
flex-direction: column;
}
.price-section {
margin-bottom: 8px;
}
.price-display {
gap: 8px;
}
.price-text {
font-size: 1.2em;
font-weight: bold;
color: var(--primary-color);
}
.free-tag {
animation: pulse 2s infinite;
}
.description-container {
flex-grow: 1;
overflow-y: auto;
position: relative;
padding: 8px 0;
max-height: 120px;
margin-bottom: 8px;
}
.goods-description {
line-height: 1.5;
font-size: 0.95em;
white-space: pre-line;
}
.tags-section {
margin-top: auto;
margin-bottom: 8px;
}
.goods-tag {
transition: all 0.2s ease;
}
.goods-tag:hover {
transform: translateY(-2px);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
}
.stock-info {
margin-top: 4px;
}
.goods-footer {
padding: 8px;
border-top: 1px solid var(--border-color-1);
background-color: rgba(var(--card-color-rgb), 0.7);
}
.exchange-btn {
min-width: 70px;
min-width: 80px;
position: relative;
overflow: hidden;
transition: all 0.3s ease;
}
.price-text {
font-size: 1.1em;
font-weight: var(--font-weight-strong);
padding: 0 6px;
.exchange-btn::after {
content: '';
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(
90deg,
transparent,
rgba(255, 255, 255, 0.2),
transparent
);
transition: all 0.6s ease;
}
.exchange-btn:not(:disabled):hover::after {
left: 100%;
}
@keyframes pulse {
0% {
opacity: 1;
}
50% {
opacity: 0.7;
}
100% {
opacity: 1;
}
}
@media (max-width: 768px) {
.goods-grid {
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
}
.price-text {
font-size: 1.1em;
}
}
</style>