Compare commits

...

3 Commits

Author SHA1 Message Date
becomer ff3593f057 fix: VIP 展示与复制按钮等 7 项体验修复
ci/woodpecker/tag/build Pipeline was successful
- VIP 徽标:修复 position 覆盖导致徽标挤变形头像,加 z-10 浮于头像上层
- VIP 用户「可签到次数」显示无限次
- VIP 兑换弹窗标题统一「续费 VIP」,去掉提示文案;按钮统一「续费 VIP」
- 新增 utils/clipboard:兼容 HTTP/移动端的复制(Clipboard API + execCommand 回退),
  修复待适配学校密码复制、CDK 结果列表单个复制按钮不生效
- 新手引导新增一步:VIP 会员与普通签到次数的区别
2026-09-08 13:25:13 +08:00
becomer 74422b1848 fix: VIP 卡默认价改为周卡 30 元 / 月卡 100 元(对齐单次扣费 6 元的定价逻辑)
ci/woodpecker/tag/build Pipeline was successful
2026-09-08 12:40:20 +08:00
becomer 84f87cd168 feat: VIP CDK 改为周卡/月卡两档(7天/30天),价格分开配置
ci/woodpecker/tag/build Pipeline was successful
- 后端:vip_price_week(周卡,默认5元)/ vip_price_month(月卡,默认15元),
  VIP CDK 仅支持 7/30 天,vip-info 返回双价格
- 后台:生成弹窗改为周卡/月卡选择,设置页拆分周卡/月卡售价两项
- 前台:开通按钮展示 周¥x · 月¥y 双价格
2026-09-08 12:29:59 +08:00
9 changed files with 152 additions and 90 deletions
+25 -17
View File
@@ -38,17 +38,22 @@ func getBaseDeduction() float64 {
return config.Global.Agent.SystemCdkPrice
}
// getVipPrice 获取 VIP CDK 单张价格(元/张,系统设置,默认15元)
func getVipPrice() float64 {
// getVipCardPrice 获取 VIP 卡价格(元/张,系统设置)
// key: vip_price_week(周卡7天,默认30元)/ vip_price_month(月卡30天,默认100元)
// (单次运行基础扣费 6 元,周卡/月卡按次折算后再给折扣)
func getVipCardPrice(key string, def float64) float64 {
var cfg model.SystemConfig
if database.DB.Where("`key` = ?", "vip_price").First(&cfg).Error == nil {
if database.DB.Where("`key` = ?", key).First(&cfg).Error == nil {
if v, err := strconv.ParseFloat(cfg.Value, 64); err == nil && v > 0 {
return v
}
}
return 15.00
return def
}
func getVipWeekPrice() float64 { return getVipCardPrice("vip_price_week", 30.00) }
func getVipMonthPrice() float64 { return getVipCardPrice("vip_price_month", 100.00) }
// GenerateCDK 生成CDK
func GenerateCDK(c *fiber.Ctx) error {
agentID := c.Locals("adminId").(uint)
@@ -81,7 +86,6 @@ func GenerateCDK(c *fiber.Ctx) error {
}
baseDeduction := getBaseDeduction()
vipPrice := getVipPrice()
var maxRuns int
var vipDays int
@@ -89,21 +93,23 @@ func GenerateCDK(c *fiber.Ctx) error {
var costPerCDK float64
if req.Type == "vip" {
// VIP CDK:默认一个月(30天),上限365天
// VIP CDK:仅支持周卡(7天)/ 月卡(30天)两档
if req.VipDays <= 0 {
req.VipDays = 30
}
if req.VipDays > 365 {
return utils.Fail(c, 400, "单张VIP CDK最多365天")
}
if req.VipDays%30 != 0 {
return utils.Fail(c, 400, "VIP天数须为30的整数倍(一个月起)")
if req.VipDays != 7 && req.VipDays != 30 {
return utils.Fail(c, 400, "VIP CDK 仅支持 7 天(周卡)或 30 天(月卡)")
}
maxRuns = 0
vipDays = req.VipDays
months := req.VipDays / 30
unitPrice = vipPrice * float64(months) * agent.Rate
costPerCDK = vipPrice * float64(months)
var vipPrice float64
if req.VipDays == 7 {
vipPrice = getVipWeekPrice()
} else {
vipPrice = getVipMonthPrice()
}
unitPrice = vipPrice * agent.Rate
costPerCDK = vipPrice
} else {
// 次数 CDK
if req.MaxRuns <= 0 {
@@ -491,8 +497,10 @@ func GetUserVipInfo(c *fiber.Ctx) error {
isVip := user.VipExpireAt != nil && user.VipExpireAt.After(time.Now())
return utils.Success(c, fiber.Map{
"isVip": isVip,
"vipExpireAt": vipExpireAt,
"vipPrice": formatFloat(getVipPrice()),
"isVip": isVip,
"vipExpireAt": vipExpireAt,
"vipWeekPrice": formatFloat(getVipWeekPrice()),
"vipMonthPrice": formatFloat(getVipMonthPrice()),
"vipPrice": formatFloat(getVipMonthPrice()), // 兼容旧字段:月卡价
})
}
+7 -3
View File
@@ -34,8 +34,11 @@ func GetSettings(c *fiber.Ctx) error {
if _, ok := settings["register_reward"]; !ok {
settings["register_reward"] = "1.00"
}
if _, ok := settings["vip_price"]; !ok {
settings["vip_price"] = "15.00"
if _, ok := settings["vip_price_week"]; !ok {
settings["vip_price_week"] = "30.00"
}
if _, ok := settings["vip_price_month"]; !ok {
settings["vip_price_month"] = "100.00"
}
return utils.Success(c, settings)
@@ -63,7 +66,8 @@ func UpdateSetting(c *fiber.Ctx) error {
"inviter_reward_per_invite": true,
"invitee_reward_per_invite": true,
"register_reward": true,
"vip_price": true,
"vip_price_week": true,
"vip_price_month": true,
// 邮件通知(SMTP)
"smtp_host": true,
"smtp_port": true,
+1 -1
View File
@@ -135,7 +135,7 @@ export function redeemCDK(code: string, captchaId: string, captchaCode: string)
/** 当前用户 VIP 信息(是否 VIP、到期时间、系统配置单价) */
export function getVipInfo() {
return api.get<{ data: { isVip: boolean; vipExpireAt: string; vipPrice: string } }>(
return api.get<{ data: { isVip: boolean; vipExpireAt: string; vipWeekPrice: string; vipMonthPrice: string; vipPrice: string } }>(
'/cdk/vip-info'
)
}
+7
View File
@@ -57,6 +57,13 @@ const steps: GuideStep[] = [
descByKind: () =>
'新用户注册即送奖励积分,邀请好友也能赚积分。\n积分满 1 后,点这里「兑换」按钮即可换成签到次数,1 积分 = 1 次签到,可重复兑换。',
},
{
tab: 'profile',
title: 'VIP 会员:签到不限次',
targets: ['[data-guide="profile-vip-btn"]'],
descByKind: () =>
'普通签到次数:每执行一次任务扣 1 次,用完需要再兑换。\nVIP 会员:有效期内执行任务不限次数(无限次),不再扣签到次数。\n点「续费 VIP」输入 VIP 兑换码即可开通,到期时间自动顺延。',
},
{
tab: 'home',
title: '新建每日签到任务',
+38
View File
@@ -0,0 +1,38 @@
/**
* 复制文本到剪贴板(兼容 HTTP 环境与移动端)
* 优先 Clipboard API(需 HTTPS/localhost),失败回退 execCommand
* 返回是否复制成功
*/
export async function copyToClipboard(text: string): Promise<boolean> {
// 1) Clipboard API(仅安全上下文可用)
if (navigator.clipboard && window.isSecureContext) {
try {
await navigator.clipboard.writeText(text)
return true
} catch {
// 继续走 fallback
}
}
// 2) 隐藏 textarea + execCommand(HTTP / 老浏览器)
const ta = document.createElement('textarea')
ta.value = text
// 必须可编辑且不在屏幕外,否则 iOS Safari 会复制失败
ta.style.position = 'fixed'
ta.style.top = '0'
ta.style.left = '0'
ta.style.opacity = '0'
ta.setAttribute('readonly', '')
document.body.appendChild(ta)
ta.focus()
ta.select()
ta.setSelectionRange(0, text.length)
let ok = false
try {
ok = document.execCommand('copy')
} catch {
ok = false
}
document.body.removeChild(ta)
return ok
}
+28 -45
View File
@@ -18,6 +18,7 @@ import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { useMessage } from '@/composables/useMessage'
import { copyToClipboard } from '@/utils/clipboard'
import * as adminApi from '@/api/admin'
const message = useMessage()
@@ -121,8 +122,8 @@ async function handleGenerate() {
return
}
if (generateType.value === 'vip') {
if (generateVipDays.value <= 0 || generateVipDays.value > 365 || generateVipDays.value % 30 !== 0) {
message.info('VIP 天数须为 30 的整数倍(30-360,一个月起)')
if (generateVipDays.value !== 7 && generateVipDays.value !== 30) {
message.info('VIP CDK 仅支持周卡(7 天)或月卡(30 天)')
return
}
} else if (generateMaxRuns.value <= 0 || generateMaxRuns.value > 999) {
@@ -170,57 +171,32 @@ function closeGenerateModal() {
}
async function copyCode(code: string, index: number) {
try {
await navigator.clipboard.writeText(code)
copiedIndex.value = index
setTimeout(() => { copiedIndex.value = null }, 2000)
} catch {
// fallback
const ta = document.createElement('textarea')
ta.value = code
document.body.appendChild(ta)
ta.select()
document.execCommand('copy')
document.body.removeChild(ta)
if (await copyToClipboard(code)) {
copiedIndex.value = index
setTimeout(() => { copiedIndex.value = null }, 2000)
} else {
message.error('复制失败,请长按手动复制')
}
}
async function copyResultCode(code: string, index: number) {
try {
await navigator.clipboard.writeText(code)
resultCopiedIdx.value = index
setTimeout(() => { resultCopiedIdx.value = null }, 2000)
} catch {
const ta = document.createElement('textarea')
ta.value = code
document.body.appendChild(ta)
ta.select()
document.execCommand('copy')
document.body.removeChild(ta)
if (await copyToClipboard(code)) {
resultCopiedIdx.value = index
setTimeout(() => { resultCopiedIdx.value = null }, 2000)
} else {
message.error('复制失败,请长按手动复制')
}
}
async function copyAllCodes() {
if (resultCdks.value.length === 0) return
const all = resultCdks.value.map((c) => c.code).join('\n')
try {
await navigator.clipboard.writeText(all)
if (await copyToClipboard(all)) {
copiedAll.value = true
message.success(`已复制全部 ${resultCdks.value.length} 个CDK`)
setTimeout(() => { copiedAll.value = false }, 2000)
} catch {
const ta = document.createElement('textarea')
ta.value = all
document.body.appendChild(ta)
ta.select()
document.execCommand('copy')
document.body.removeChild(ta)
copiedAll.value = true
setTimeout(() => { copiedAll.value = false }, 2000)
} else {
message.error('复制失败,请长按手动复制')
}
}
@@ -490,14 +466,21 @@ function statusClass(s: string) {
</template>
<template v-else>
<div>
<Label class="mb-1 block text-sm text-gray-600">VIP 时长(天)</Label>
<select v-model.number="generateVipDays" class="h-10 w-full rounded-md border border-gray-300 bg-white px-3 text-sm">
<option :value="30">30 天(1 个月)</option>
<option :value="60">60 天(2 个月)</option>
<option :value="90">90 天(3 个月)</option>
<option :value="180">180 天(半年)</option>
<option :value="360">360 天(1 年)</option>
</select>
<Label class="mb-1 block text-sm text-gray-600">VIP 卡类型</Label>
<div class="grid grid-cols-2 gap-2">
<button
type="button"
class="h-10 rounded-md border text-sm font-medium transition-colors"
:class="generateVipDays === 7 ? 'bg-amber-500 text-white border-amber-500' : 'bg-white text-gray-700 border-gray-300 hover:border-amber-400'"
@click="generateVipDays = 7"
>周卡 · 7天</button>
<button
type="button"
class="h-10 rounded-md border text-sm font-medium transition-colors"
:class="generateVipDays === 30 ? 'bg-amber-500 text-white border-amber-500' : 'bg-white text-gray-700 border-gray-300 hover:border-amber-400'"
@click="generateVipDays = 30"
>月卡 · 30天</button>
</div>
<p class="text-xs text-gray-400 mt-1">兑换后从当前到期时间顺延,到期即享免扣次数执行任务</p>
</div>
</template>
@@ -522,7 +505,7 @@ function statusClass(s: string) {
</h2>
<p class="text-xs text-gray-400 mt-0.5">
共 {{ resultCdks.length }} 个CDK ·
<template v-if="generateType === 'vip'">VIP {{ generateVipDays }} 天 · </template>
<template v-if="generateType === 'vip'">{{ generateVipDays === 7 ? 'VIP 周卡 7 天' : 'VIP 月卡 30 天' }} · </template>
<template v-else>每张 {{ generateMaxRuns }} 次 · </template>
扣费
<span class="font-mono text-gray-600">{{ resultCost.toFixed(2) }} 元</span>
@@ -4,6 +4,7 @@ import { Shield, RefreshCw, CheckCircle, Search, Eye, EyeOff, Copy } from 'lucid
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { useMessage } from '@/composables/useMessage'
import { copyToClipboard } from '@/utils/clipboard'
import api from '@/api/index'
interface PendingSchoolItem {
@@ -102,10 +103,13 @@ async function handleMarkDone(record: PendingSchoolItem) {
}
}
function copyText(text: string) {
navigator.clipboard.writeText(text).then(() => {
async function copyText(text: string) {
const ok = await copyToClipboard(text)
if (ok) {
message.success('已复制')
})
} else {
message.error('复制失败,请长按手动复制')
}
}
</script>
+30 -8
View File
@@ -13,7 +13,8 @@ const message = useMessage()
const loading = ref(false)
const saving = ref(false)
const baseDeduction = ref('')
const vipPrice = ref('')
const vipWeekPrice = ref('')
const vipMonthPrice = ref('')
const inviterReward = ref('')
const inviteeReward = ref('')
const registerReward = ref('')
@@ -40,7 +41,8 @@ async function loadSettings() {
const res = await adminApi.getSettings()
const settings = res.data?.data || {}
baseDeduction.value = settings['base_deduction_per_run'] || '1.00'
vipPrice.value = settings['vip_price'] || '15.00'
vipWeekPrice.value = settings['vip_price_week'] || '30.00'
vipMonthPrice.value = settings['vip_price_month'] || '100.00'
inviterReward.value = settings['inviter_reward_per_invite'] || '0.50'
inviteeReward.value = settings['invitee_reward_per_invite'] || '0.50'
registerReward.value = settings['register_reward'] || '1.00'
@@ -125,8 +127,13 @@ async function handleSave() {
message.info('直接注册奖励积分请输入有效的非负数')
return
}
const vipP = parseFloat(vipPrice.value)
if (isNaN(vipP) || vipP <= 0) {
const vipWeek = parseFloat(vipWeekPrice.value)
if (isNaN(vipWeek) || vipWeek <= 0) {
message.info('VIP 周卡售价请输入有效的正数')
return
}
const vipMonth = parseFloat(vipMonthPrice.value)
if (isNaN(vipMonth) || vipMonth <= 0) {
message.info('VIP 月卡售价请输入有效的正数')
return
}
@@ -134,7 +141,8 @@ async function handleSave() {
saving.value = true
try {
await adminApi.updateSetting('base_deduction_per_run', deduction.toFixed(2))
await adminApi.updateSetting('vip_price', vipP.toFixed(2))
await adminApi.updateSetting('vip_price_week', vipWeek.toFixed(2))
await adminApi.updateSetting('vip_price_month', vipMonth.toFixed(2))
await adminApi.updateSetting('inviter_reward_per_invite', invReward.toFixed(2))
await adminApi.updateSetting('invitee_reward_per_invite', ineeReward.toFixed(2))
await adminApi.updateSetting('register_reward', regReward.toFixed(2))
@@ -177,16 +185,30 @@ async function handleSave() {
</div>
<div>
<Label class="mb-1 block text-sm text-gray-600">⭐ VIP 月卡售价(元 / 30天)</Label>
<Label class="mb-1 block text-sm text-gray-600">⭐ VIP 周卡售价(元 / 7天)</Label>
<Input
v-model="vipPrice"
v-model="vipWeekPrice"
type="number"
step="0.01"
min="0.01"
class="h-10 text-sm max-w-[200px]"
/>
<p class="text-xs text-gray-400 mt-1">
用户在前台兑换 VIP CDK 时展示的售价,VIP 有效期内执行任务免扣次数
生成 VIP 周卡 CDK 的单张售价,VIP 有效期内执行任务免扣次数
</p>
</div>
<div>
<Label class="mb-1 block text-sm text-gray-600">⭐ VIP 月卡售价(元 / 30天)</Label>
<Input
v-model="vipMonthPrice"
type="number"
step="0.01"
min="0.01"
class="h-10 text-sm max-w-[200px]"
/>
<p class="text-xs text-gray-400 mt-1">
生成 VIP 月卡 CDK 的单张售价,前台开通/续费 VIP 展示此价格
</p>
</div>
+9 -13
View File
@@ -61,7 +61,6 @@ const redeemKind = ref<'count' | 'vip'>('count')
// VIP 信息
const isVip = ref(false)
const vipExpireAt = ref('')
const vipPrice = ref('')
// 验证码
const captchaId = ref('')
const captchaImage = ref('')
@@ -121,7 +120,6 @@ onMounted(async () => {
const vipRes = await authApi.getVipInfo()
isVip.value = vipRes.data.data.isVip
vipExpireAt.value = vipRes.data.data.vipExpireAt
vipPrice.value = vipRes.data.data.vipPrice
} catch {
// VIP 信息加载失败不阻塞页面
}
@@ -157,7 +155,6 @@ async function reloadVipInfo() {
const vipRes = await authApi.getVipInfo()
isVip.value = vipRes.data.data.isVip
vipExpireAt.value = vipRes.data.data.vipExpireAt
vipPrice.value = vipRes.data.data.vipPrice
} catch {
// ignore
}
@@ -268,7 +265,7 @@ async function handleRedeemInvite() {
<img v-show="userInfo?.avatar && !avatarFailed" :src="userInfo?.avatar" class="w-full h-full object-cover" @error="avatarFailed = true" />
<span v-if="!userInfo?.avatar || avatarFailed" class="text-3xl font-bold">{{ userInfo?.realName?.[0] || userInfo?.username?.[0] || '?' }}</span>
</div>
<div v-if="isVip" class="vip-badge absolute -right-3 -top-1.5">VIP</div>
<div v-if="isVip" class="vip-badge absolute -right-3 -top-1.5 z-10 whitespace-nowrap">VIP</div>
</div>
<p class="text-xl font-bold">{{ userInfo?.realName || userInfo?.username || '--' }}</p>
<p class="text-sm text-white/80 mt-1">{{ userInfo?.schoolName || '--' }}</p>
@@ -283,10 +280,11 @@ async function handleRedeemInvite() {
</span>
<span v-else class="text-xs bg-white/20 rounded-full px-3 py-1.5 text-white/95">未开通 VIP</span>
<button
data-guide="profile-vip-btn"
class="vip-btn text-xs font-semibold px-3.5 py-1.5 rounded-full text-amber-900 shadow"
@click="openRedeemModal('vip')"
>
{{ isVip ? '续费 VIP' : `开通 VIP${vipPrice ? ` ¥${vipPrice}/月` : ''}` }}
续费 VIP
</button>
</div>
</div>
@@ -301,8 +299,8 @@ async function handleRedeemInvite() {
<ClipboardCheck class="w-4 h-4 text-green-500" />
</div>
<div>
<p class="text-xs text-gray-400">可签到次数</p>
<p class="text-sm font-medium text-gray-900">{{ (userInfo?.signCount ?? 0) }} 次</p>
<p class="text-xs text-gray-400">{{ isVip ? 'VIP 签到次数' : '可签到次数' }}</p>
<p class="text-sm font-medium text-gray-900">{{ isVip ? '无限次' : `${userInfo?.signCount ?? 0} 次` }}</p>
</div>
</div>
<button
@@ -467,7 +465,7 @@ async function handleRedeemInvite() {
<!-- 弹窗头部 -->
<div class="flex items-center justify-between px-5 pt-5 pb-2">
<h3 class="text-base font-semibold text-gray-900">
{{ redeemKind === 'vip' ? '开通 / 续费 VIP' : '兑换签到次数' }}
{{ redeemKind === 'vip' ? '续费 VIP' : '兑换签到次数' }}
</h3>
<button class="w-7 h-7 rounded-full bg-gray-100 flex items-center justify-center text-gray-400 hover:text-gray-600" @click="closeRedeemModal">
<X class="w-4 h-4" />
@@ -476,9 +474,6 @@ async function handleRedeemInvite() {
<!-- 弹窗内容 -->
<div class="px-5 pb-5 space-y-4">
<p v-if="redeemKind === 'vip'" class="text-xs text-gray-400 leading-relaxed -mt-1">
输入 VIP 兑换码即可开通/续费会员(默认一个月),VIP 有效期内执行任务不限次数、不再扣除签到次数
</p>
<!-- 兑换码输入 -->
<div>
<label class="block text-xs font-medium text-gray-500 mb-1.5">{{ redeemKind === 'vip' ? 'VIP 兑换码' : '兑换码' }}</label>
@@ -549,9 +544,10 @@ async function handleRedeemInvite() {
opacity: 0;
}
/* VIP 徽标:金色渐变 + 反光条扫过动画 */
/* VIP 徽标:金色渐变 + 反光条扫过动画
注意:不可设置 position,徽标由模板上的 Tailwind absolute 类控制浮层定位,
若此处声明 position:relative 会覆盖 absolute,导致徽标挤进头像布局使其变形 */
.vip-badge {
position: relative;
overflow: hidden;
padding: 2px 8px;
border-radius: 999px;