小程序接入支付功能

This commit is contained in:
Yeomai committed 2026-09-29 01:14:24 +08:00
1 parent edf1e51846
commit 07a90e860d
38 files changed
+3644 -90

No files matched your search

+302
View File
@@ -0,0 +1,302 @@
const { post, get, login } = require('../../utils/pay-api.js')
// 道具列表(与 MP 后台一致,单位:分)
const GOODS = [
{ id: '90001', name: '补单专用', price: 10, adminOnly: true, note: '0.1 元测试用,iOS 最低 1 元买不了' },
{ id: '10001', name: '基础激活码', price: 3900 },
{ id: '10002', name: '高级激活码', price: 6900 },
{ id: '10000', name: '永久激活码', price: 9900 },
]
// 只把常见的几个错误码翻译成人话,方便对着测
const PAY_ERR = {
1001: '参数错误',
'-1': '支付失败',
'-2': '用户取消支付',
'-4': '风控拦截',
'-15002': 'outTradeNo 重复,换新单号重试',
'-15005': '用户态签名 signature 错误',
'-15006': '支付签名 paySig 错误',
'-15007': 'session_key 过期,重进小程序再试',
'-15010': '道具未发布(未发布的道具不能下单)',
'-15011': '现网版本 env 只能填 0',
'-15013': 'goodsPrice 与后台道具价格不一致',
'-15014': '道具刚发布未生效,等几分钟再试',
}
const ORDER_STATUS = {
0: '订单初始化',
1: '订单创建成功(未支付)',
2: '已支付,待发货',
3: '发货中',
4: '已发货',
5: '已退款',
6: '已关闭',
7: '退款失败',
8: '用户退款完成',
9: '回收广告金完成',
10: '分账回退完成',
}
const IOS_MIN_VERSION = [8, 0, 68]
const BASE_LIB_MIN_VERSION = '2.19.2'
// 上次问出来的身份先当初始值:切到本页时,补单道具和沙箱开关就不会先闪一下再消失
const cachedIsAdmin = Boolean(wx.getStorageSync('isAdmin'))
// 页面文案的默认值(和 server/copy.js 保持一致);数据库 settings 表里的同名配置会覆盖它
const DEFAULT_COPY = {
tab: '开通',
title: '选择套餐',
subtitle: '一次付费,激活码自动发放到「我的」',
button: '购买',
tip: '购买成功后激活码会自动发放,在「我的」里可以随时查看和复制。同一笔订单重复点只会拿到同一张码,不用担心重复扣款。',
}
// 上次拿到的文案先当初始值,避免切页时文字闪一下
const cachedCopy = Object.assign({}, DEFAULT_COPY, wx.getStorageSync('copy') || {})
// 底部标签的文字也走配置(本页是 tabBar 的第 1 项:首页、开通、预览、我的)
function applyTabText(text) {
if (!text || text === cachedCopy.tab) return
try {
wx.setTabBarItem({ index: 1, text })
} catch (err) {
console.warn('设置标签文字失败', err)
}
}
// 1234 分 -> 12.34
function yuan(fen) {
return (fen / 100).toFixed(2).replace(/\.?0+$/, '')
}
// 价目表转成页面要的字段,顺便按身份过滤掉管理员专属道具
function toView(list, isAdmin) {
const view = list
.filter((item) => isAdmin || !item.adminOnly)
.sort((a, b) => a.price - b.price) // 价格从低到高
.map((item) => ({
id: String(item.productId || item.id),
name: item.name,
price: item.price,
priceText: yuan(item.price),
note: item.note || '',
isBest: false,
}))
if (view.length) view[view.length - 1].isBest = true // 最贵那档标「推荐」
return view
}
// 官方示例同款版本比较
function compareVersion(v1, v2) {
const a = String(v1).split('.')
const b = String(v2).split('.')
const len = Math.max(a.length, b.length)
while (a.length < len) a.push('0')
while (b.length < len) b.push('0')
for (let i = 0; i < len; i++) {
const n1 = parseInt(a[i], 10)
const n2 = parseInt(b[i], 10)
if (n1 > n2) return 1
if (n1 < n2) return -1
}
return 0
}
Page({
data: {
goods: toView(GOODS, cachedIsAdmin),
isAdmin: cachedIsAdmin, // 沙箱、补单道具、退款都是管理员功能,后端也会拦
copy: cachedCopy,
env: 0, // 0 现网(真机),1 沙箱(只在开发者工具里能拉起,且不扣钱)
buying: '',
result: null,
lastOrder: '',
needPay: false, // 上一笔还没付款成功(取消、失败),按钮就提示「重新支付」
retryProductId: '',
},
onEnv(e) {
this.setData({ env: Number(e.currentTarget.dataset.env), result: null, lastOrder: '' })
},
// 付过钱但没发货的单(关过小程序也算),重新进来还能补发货
async onShow() {
await this.loadMe()
if (this.data.lastOrder) return
try {
const code = await login()
const data = await post('/pay/pending', { code })
if (!data.outTradeNo) return
this.setData({
lastOrder: data.outTradeNo,
env: data.env === 1 ? 1 : 0,
result: { ok: true, title: '有一笔订单还没发货', detail: '刚才付过款的话,点「确认发货」把激活码补出来' },
})
} catch (err) {
console.warn('查询待发货订单失败', err.message)
}
},
// 后端带回「我是不是管理员」和该用户能看到的价格表(补单道具只有管理员能看到)
async loadMe() {
let me
try {
me = await post('/pay/me', { code: await login() })
} catch (err) {
// 拉不到就保持上次的身份和列表,别让管理员因为一次网络抖动丢了开关
console.warn('获取用户信息失败,沿用上次的身份:', err.message)
return
}
const isAdmin = Boolean(me.isAdmin)
wx.setStorageSync('isAdmin', isAdmin)
const list = me.goods && me.goods.length ? me.goods : GOODS
const fromServer = me.copy || {} // 服务端还没更新时这里会是空的,全部走默认值
const copy = {
tab: fromServer['tab.buy'] || DEFAULT_COPY.tab,
title: fromServer['buy.title'] || DEFAULT_COPY.title,
subtitle: fromServer['buy.subtitle'] || DEFAULT_COPY.subtitle,
button: fromServer['buy.button'] || DEFAULT_COPY.button,
tip: fromServer['buy.tip'] || DEFAULT_COPY.tip,
}
wx.setStorageSync('copy', copy)
applyTabText(copy.tab)
this.setData({ isAdmin, env: isAdmin ? this.data.env : 0, goods: toView(list, isAdmin), copy })
},
async onBuy(e) {
const productId = e.currentTarget.dataset.id
console.log('[购买] 点击', productId, 'env=', this.data.env, 'buying=', this.data.buying || '空')
if (this.data.buying) {
console.log('[购买] 上一次还在处理中,这次点击被忽略')
return
}
this.setData({ needPay: false, retryProductId: productId })
try {
// 检查也放进 try 里:万一环境判断本身抛错,也要在结果区看到原因
if (!this.checkEnv(productId)) {
console.log('[购买] 环境检查没通过')
return
}
this.setData({ buying: productId, result: null })
const code = await login()
console.log('[购买] 拿到登录 code,开始下单')
const order = await post('/pay/order', { productId, code, env: this.data.env })
if (!order.payData || !order.payData.signData) throw new Error('服务端没返回支付参数,请查看服务器日志')
console.log('[购买] 拿到支付参数,拉起支付')
this.setData({ lastOrder: order.outTradeNo })
await this.pay(order.payData)
this.setData({ result: { ok: true, title: '支付成功', detail: '正在发货…' } })
await this.onDeliver()
} catch (err) {
console.log('[购买] 中断:', err.message)
if (err.fromPay) {
// 支付没成功(取消/失败/风控):这次没扣钱,提示重新支付
this.setData({
needPay: true,
result: {
ok: false,
title: err.errCode === -2 ? '已取消支付' : '支付未完成',
detail: err.errCode === -2 ? '本次没有扣款,点下方可以重新支付' : err.message,
},
})
} else this.fail(err.message)
} finally {
this.setData({ buying: '' })
}
},
// 基础库和微信版本不满足时,拉起支付必失败,提前拦掉
checkEnv(productId) {
const sys = wx.getSystemInfoSync()
if (!(compareVersion(sys.SDKVersion, BASE_LIB_MIN_VERSION) >= 0 || wx.canIUse('requestVirtualPayment'))) {
return this.fail(`基础库版本过低(${sys.SDKVersion}),虚拟支付需要 ≥ ${BASE_LIB_MIN_VERSION}`)
}
if (sys.platform === 'ios' && compareVersion(sys.version, IOS_MIN_VERSION.join('.')) < 0) {
return this.fail('iOS 需微信 8.0.68 及以上,请先更新微信')
}
if (this.data.env === 1 && sys.platform === 'ios') {
return this.fail('沙箱不支持 iOS,苹果支付只有现网。请用开发者工具的模拟器,或切到安卓真机')
}
// 苹果支付最低 1 元,低于这个金额的单 iOS 上必然被拒(错误码 -15001)
if (sys.platform === 'ios') {
// 用页面当前展示的价格判断(改价从数据库来,可能和内置价目表不一致)
const goods = this.data.goods.find((item) => item.id === productId) || GOODS.find((item) => item.id === productId) || {}
if ((goods.price || 0) < 100) {
return this.fail(`iOS 最低支付 1 元,「${goods.name}」只有 ¥${yuan(goods.price || 0)},iPhone 上买不了`)
}
}
return true
},
pay(payData) {
return new Promise((resolve, reject) => {
wx.requestVirtualPayment(
Object.assign({}, payData, {
success: resolve,
fail: (err) => {
const error = new Error(`errCode ${err.errCode}:${PAY_ERR[err.errCode] || err.errMsg}`)
error.errCode = err.errCode
error.fromPay = true // 标记「卡在支付这一步」,用来决定按钮提示什么
reject(error)
},
})
)
})
},
// 重新支付:按上一笔的道具再走一遍下单+拉起
onRetryPay() {
if (!this.data.retryProductId) return
this.onBuy({ currentTarget: { dataset: { id: this.data.retryProductId } } })
},
// 支付回调可能丢,所以发货一律以服务端查单为准;重复点也只是把同一张码再给一遍
async onDeliver() {
const outTradeNo = this.data.lastOrder
if (!outTradeNo) return
wx.showLoading({ title: '发货中', mask: true })
try {
const code = await login()
this.showDeliver(await post('/pay/deliver', { outTradeNo, code }))
} catch (err) {
this.fail(err.message)
} finally {
wx.hideLoading()
}
},
showDeliver(data) {
if (!data.paid) {
return this.setData({
result: {
ok: false,
title: '订单状态:' + (ORDER_STATUS[data.status] || data.status),
detail: '还没查到支付成功,等半分钟再点「确认发货」',
},
})
}
this.setData({
result: {
ok: true,
title: '购买成功 · ' + (data.productName || ''),
code: data.code,
detail: data.notified
? '激活码已生成,点一下即可复制'
: `平台没确认发货:${data.notifyError || '未知原因'}(激活码已生成,不影响使用;稍后再点一次「确认发货」重试)`,
},
})
},
copyCode() {
if (!this.data.result || !this.data.result.code) return
wx.setClipboardData({ data: this.data.result.code })
},
fail(message) {
this.setData({ buying: '', result: { ok: false, title: '失败', detail: message, code: '' } })
wx.showToast({ title: message, icon: 'none', duration: 3000 })
return false
},
})
+4
View File
@@ -0,0 +1,4 @@
{
"navigationBarTitleText": "开通使用",
"usingComponents": {}
}
+34
View File
@@ -0,0 +1,34 @@
<view class="page">
<view class="head">
<view class="head-title">{{copy.title}}</view>
<view class="head-sub">{{isAdmin ? (env === 1 ? '沙箱环境 · 不扣钱,只有开发者工具能拉起' : '现网环境 · 会真扣款(管理员可见)') : copy.subtitle}}</view>
<view wx:if="{{isAdmin}}" class="env-switch">
<view class="env-chip {{env === 0 ? 'on' : ''}}" data-env="0" bindtap="onEnv">现网</view>
<view class="env-chip {{env === 1 ? 'on' : ''}}" data-env="1" bindtap="onEnv">沙箱</view>
</view>
</view>
<view class="card" wx:for="{{goods}}" wx:key="id" data-id="{{item.id}}" bindtap="onBuy">
<view class="card-main">
<view class="card-name">{{item.name}}<text wx:if="{{item.isBest}}" class="card-badge">推荐</text></view>
<view wx:if="{{item.note}}" class="card-note">{{item.note}}</view>
<view wx:if="{{isAdmin}}" class="card-id">道具 ID {{item.id}}</view>
</view>
<view class="card-right">
<view class="card-price">¥{{item.priceText}}</view>
<view class="card-btn {{buying === item.id ? 'dim' : ''}}">{{buying === item.id ? '进行中…' : copy.button}}</view>
</view>
</view>
<view wx:if="{{result}}" class="result {{result.ok ? 'ok' : 'bad'}}">
<view class="result-title">{{result.title}}</view>
<view wx:if="{{result.detail}}" class="result-detail">{{result.detail}}</view>
<view wx:if="{{result.code}}" class="result-code" bindtap="copyCode">{{result.code}}</view>
<view wx:if="{{lastOrder && isAdmin}}" class="result-order">业务单号 {{lastOrder}}</view>
<view wx:if="{{lastOrder && needPay}}" class="result-btn" bindtap="onRetryPay">重新支付</view>
<view wx:if="{{lastOrder && !needPay}}" class="result-btn" bindtap="onDeliver">{{isAdmin ? '确认发货' : '重新获取激活码'}}</view>
</view>
<view class="tip">{{copy.tip}}</view>
<view wx:if="{{isAdmin}}" class="tip dev">管理员备注:下单、查单、发货都走你自己的服务端(server/index.js),发货以查单结果为准;沙箱用自己的沙箱 AppKey 和订单池,和现网互不影响。</view>
</view>
+182
View File
@@ -0,0 +1,182 @@
.page {
min-height: 100vh;
padding: 32rpx 28rpx 48rpx;
background: var(--bg);
}
.head {
margin-bottom: 28rpx;
}
.head-title {
font-size: 40rpx;
font-weight: 600;
color: #ffffff;
}
.head-sub {
margin-top: 8rpx;
font-size: 24rpx;
color: rgba(255, 255, 255, 0.45);
}
.env-switch {
display: flex;
width: 320rpx;
margin-top: 22rpx;
padding: 6rpx;
border-radius: var(--r-pill);
background: var(--panel);
}
.env-chip {
flex: 1;
height: 58rpx;
line-height: 58rpx;
text-align: center;
border-radius: var(--r-pill);
font-size: 25rpx;
color: rgba(255, 255, 255, 0.5);
}
.env-chip.on {
background: var(--accent);
color: #ffffff;
font-weight: 600;
}
.card {
display: flex;
align-items: center;
justify-content: space-between;
padding: 26rpx 28rpx;
margin-bottom: 20rpx;
border-radius: var(--r-lg);
background: var(--panel);
}
.card:active {
background: var(--panel-2);
}
.card-name {
font-size: 32rpx;
font-weight: 500;
color: #ffffff;
}
.card-badge {
margin-left: 14rpx;
padding: 2rpx 14rpx;
border-radius: var(--r-pill);
background: rgba(242, 53, 59, 0.18);
color: var(--accent-2);
font-size: 20rpx;
vertical-align: 4rpx;
}
.card-id {
margin-top: 8rpx;
font-size: 22rpx;
color: rgba(255, 255, 255, 0.4);
}
.card-note {
margin-top: 10rpx;
font-size: 23rpx;
line-height: 1.5;
color: #f0b45c;
}
.card-right {
display: flex;
align-items: center;
}
.card-price {
font-size: 32rpx;
font-weight: 600;
color: #ffffff;
margin-right: 22rpx;
}
.card-btn {
min-width: 132rpx;
height: 62rpx;
line-height: 62rpx;
text-align: center;
border-radius: var(--r-pill);
background: linear-gradient(135deg, var(--accent) 0%, var(--accent-2) 100%);
color: #ffffff;
font-size: 26rpx;
}
.card-btn.dim {
opacity: 0.6;
}
.result {
margin-top: 12rpx;
padding: 26rpx 28rpx;
border-radius: var(--r-lg);
background: var(--panel);
border-left: 6rpx solid var(--accent);
}
.result.bad {
border-left-color: #ff6b6b;
}
.result-title {
font-size: 30rpx;
font-weight: 600;
color: #ffffff;
}
.result-detail {
margin-top: 10rpx;
font-size: 25rpx;
line-height: 1.6;
color: rgba(255, 255, 255, 0.6);
}
.result-order {
margin-top: 10rpx;
font-size: 23rpx;
color: rgba(255, 255, 255, 0.4);
word-break: break-all;
}
.result-code {
margin-top: 18rpx;
padding: 20rpx 0;
border-radius: var(--r-lg);
background: rgba(79, 193, 255, 0.12);
color: var(--cyan);
font-size: 38rpx;
font-weight: 600;
letter-spacing: 4rpx;
text-align: center;
}
.result-btn {
display: inline-block;
margin-top: 20rpx;
padding: 12rpx 30rpx;
border-radius: var(--r-pill);
background: rgba(79, 193, 255, 0.16);
color: var(--cyan);
font-size: 25rpx;
}
.tip {
margin-top: 26rpx;
font-size: 22rpx;
line-height: 1.7;
color: rgba(255, 255, 255, 0.35);
}
.tip.dev {
margin-top: 12rpx;
color: rgba(255, 255, 255, 0.22);
}
+90
View File
@@ -0,0 +1,90 @@
const SITE_URL = 'https://spineviewer.yeomai.site/'
Page({
data: {
stats: [
{ value: '9', label: '内置运行时版本' },
{ value: '3.4–4.3', label: '覆盖的 Spine 版本' },
{ value: '8', label: '界面语言 · 简繁英日韩俄西德' },
{ value: '400px', label: '缩略图自动生成' },
],
fileChips: ['skel', 'skel.bytes', 'json', 'atlas', 'atlas.txt'],
features: [
{
title: '本地运行时',
desc: '官方运行时全部放在插件本地 lib/ 目录,不依赖 CDN 下载。激活校验需要联网,其余预览过程都在本地完成。',
},
{
title: '跨文件夹自动配对',
desc: 'Eagle 把每个文件拆成独立 item 文件夹,插件扫描同级目录自动关联骨骼、atlas 与贴图,忽略 -pro / -ess / -pma 等后缀。',
},
{
title: '9 个版本自动识别',
desc: '从骨骼数据读取版本号并匹配运行时:3.4 / 3.5 / 3.6 / 3.7 / 3.8 / 4.0 / 4.1 / 4.2 / 4.3,带 -beta 的完整版本号也会显示在水印里。',
},
{
title: '画面与皮肤切换',
desc: '浮动播放列表按需自适应、条目多时独立滚动;切换皮肤后按 setup pose 重建槽位附件,画面立即刷新。',
},
{
title: '完整时间轴',
desc: '拖动定位、显示时长与帧号、秒刻度,蓝色短线标出骨骼 / 插槽 / 约束的关键帧位置,悬停即可查看。',
},
{
title: '缩略图自动化',
desc: '导入即渲染首帧缩略图(400×400),右下角带 Spine 官方 badge、左下角标注版本号;资源暂时缺失时先给回退图,补齐后自动重渲。',
},
],
versions: [
{ ver: '3.4 / 3.5', desc: '使用官方 3.4.02、3.5.51 运行时与匹配的二进制分支,已验证多组官方示例的姿势对比。' },
{ ver: '3.6 / 3.7', desc: '额外带二进制解析兼容层(移植自官方同版本 SkeletonBinary),支持 3.7 的 IK 压缩 / 拉伸与音频事件。' },
{ ver: '3.8 – 4.3', desc: '二进制文件完整支持;4.3 等带 -beta 后缀的完整版本号也会显示在缩略图水印中。' },
],
steps: [
'导入整套文件:把 .skel / .json + .atlas + .png 放在同一个文件夹导入 Eagle',
'自动配对资源:点击任一文件,插件从同级 item 文件夹定位骨骼、atlas 与贴图',
'识别版本:读取骨骼数据里的版本号,加载匹配的本地运行时',
'预览与缩略图:实时播放画面,缩略图自动生成,资源补齐后自动重渲并刷新列表',
],
install: [
'在 Eagle 插件社区获取 SpineViewer 插件包,插件页提供下载与安装说明',
'双击 .eagleplugin 安装;或在 Eagle 插件面板(快捷键 P)右键选择「打包 / 加载插件」',
'把同一套 Spine 导出文件放进同一个文件夹导入 Eagle,点击任意文件即可预览',
],
faqs: [
{ q: '插件会接管普通图片吗?', a: '不会。只接管 skel、skel.bytes、json、atlas、atlas.txt,普通图片仍由 Eagle 原生预览。' },
{
q: '预乘(PMA)设置保存在哪里?',
a: '存在 Eagle 用户数据目录的 SpineViewer/preview-settings/ 下,按素材库与素材 ID 区分;不会修改 metadata.json、标签或注释。',
},
{
q: '3.6 / 3.7 的二进制解析可靠吗?',
a: '解析层移植自官方同版本,已验证多套官方示例;文件损坏、截断或版本不匹配会直接报告解析错误,不会用错误姿势渲染。',
},
{
q: '缩略图是怎么生成的?',
a: '在 Node 里用 @napi-rs/canvas 跨 item 文件夹读取资源渲染首帧,尺寸 400×400,右下角为 Spine 官方 badge,左下角是完整版本号。移除这个依赖只影响缩略图,画面预览不受影响。',
},
{
q: '试用版和插件是什么关系?',
a: '试用版只支持 Spine 4.2 导出的文件,用来快速看一眼效果;3.4–4.3 全版本识别、皮肤切换、时间轴、缩略图这些完整能力都在桌面插件里。',
},
],
},
goPreview() {
wx.switchTab({ url: '/pages/viewer/viewer' })
},
goBuy() {
wx.switchTab({ url: '/pages/buy/buy' })
},
// 个人主体小程序打不开外部网页,只能把网址给用户自己复制
copySite() {
wx.setClipboardData({
data: SITE_URL,
success: () => wx.showToast({ title: '网址已复制', icon: 'none' }),
})
},
})
+4
View File
@@ -0,0 +1,4 @@
{
"navigationBarTitleText": "SpineViewer",
"usingComponents": {}
}
+95
View File
@@ -0,0 +1,95 @@
<view class="page">
<view class="glow"></view>
<!-- ===== 顶部 ===== -->
<view class="hero">
<view class="hero-title">美术师的省心小工具</view>
<view class="hero-title-sub">你的 <text class="hero-accent">Spine 数据资产</text> 可以进 Eagle 啦</view>
<view class="hero-sub">
SpineViewer 接管 skel / skel.bytes / json / atlas / atlas.txt,自动在素材库中配对骨骼、图集与贴图,识别版本并加载本地运行时。点开任意一个文件,就能实时播放、切换皮肤、导出缩略图。
</view>
<view class="hero-cta">
<view class="btn primary" bindtap="goBuy">查看套餐</view>
<view class="btn ghost" bindtap="goPreview">试用版体验</view>
</view>
<view class="stats">
<view class="stat" wx:for="{{stats}}" wx:key="label">
<view class="stat-value">{{item.value}}</view>
<view class="stat-label">{{item.label}}</view>
</view>
</view>
<view class="chips">
<text class="chip on" wx:for="{{fileChips}}" wx:key="*this">{{item}}</text>
</view>
<view class="note">png / jpg / webp 仍由 Eagle 原生预览</view>
</view>
<!-- ===== 小程序定位 ===== -->
<view class="notice">
<view class="notice-title">这里是试用版</view>
<view class="notice-desc">试用版只支持 Spine 4.2 导出的文件;桌面插件的完整能力包括 3.4–4.3 全版本识别、皮肤切换、时间轴、缩略图生成。</view>
<view class="notice-link" bindtap="goPreview">去试用版看看 →</view>
</view>
<!-- ===== 功能特性 ===== -->
<view class="section">
<view class="kicker">FEATURES</view>
<view class="h2">功能特性</view>
<view class="lead">下面每一项都对应插件的真实行为:跨 item 文件夹配对、旧版二进制解析、WebGL 渲染、缩略图补刷……不只是「能打开文件」。</view>
<view class="card" wx:for="{{features}}" wx:key="title">
<view class="card-title">{{item.title}}</view>
<view class="card-desc">{{item.desc}}</view>
</view>
</view>
<!-- ===== 支持版本 ===== -->
<view class="section alt">
<view class="kicker">VERSIONS</view>
<view class="h2">支持版本</view>
<view class="lead">覆盖 Spine 3.4 – 4.3:新版二进制直接支持,旧版二进制由同版本运行时与解析层处理。</view>
<view class="rows">
<view class="row" wx:for="{{versions}}" wx:key="ver">
<view class="row-ver">{{item.ver}}</view>
<view class="row-desc">{{item.desc}}</view>
</view>
</view>
</view>
<!-- ===== 使用流程 ===== -->
<view class="section">
<view class="kicker">WORKFLOW</view>
<view class="h2">从导入到播放</view>
<view class="step" wx:for="{{steps}}" wx:key="*this">
<view class="step-index">{{index + 1}}</view>
<view class="step-text">{{item}}</view>
</view>
</view>
<!-- ===== 安装 ===== -->
<view class="section alt">
<view class="kicker">INSTALL</view>
<view class="h2">安装使用</view>
<view class="lead">在 Eagle 插件社区获取插件包,然后在 Eagle 插件面板(快捷键 P)安装即可。</view>
<view class="step" wx:for="{{install}}" wx:key="*this">
<view class="step-index">{{index + 1}}</view>
<view class="step-text">{{item}}</view>
</view>
<view class="btn ghost wide" bindtap="copySite">复制插件页网址</view>
<view class="note">这里打不开外部网页,复制后在浏览器里打开就行。</view>
</view>
<!-- ===== 常见问题 ===== -->
<view class="section">
<view class="kicker">FAQ</view>
<view class="h2">常见问题</view>
<view class="faq" wx:for="{{faqs}}" wx:key="q">
<view class="faq-q">{{item.q}}</view>
<view class="faq-a">{{item.a}}</view>
</view>
</view>
<view class="foot">小程序版仅支持 4.2 版本</view>
</view>
+313
View File
@@ -0,0 +1,313 @@
.page {
position: relative;
min-height: 100vh;
padding: 0 28rpx 70rpx;
background: var(--bg);
overflow: hidden;
}
/* 顶部两团辉光,对应官网的红 / 青径向光 */
.glow {
position: absolute;
left: -30%;
top: -200rpx;
width: 160%;
height: 1000rpx;
pointer-events: none;
background: radial-gradient(560rpx 340rpx at 30% 38%, rgba(242, 53, 59, 0.22), transparent 66%),
radial-gradient(520rpx 320rpx at 72% 26%, rgba(79, 193, 255, 0.16), transparent 68%);
}
/* ===== 顶部 ===== */
.hero {
position: relative;
padding: 56rpx 4rpx 0;
}
.hero-title {
margin-top: 0;
font-size: 54rpx;
line-height: 1.24;
font-weight: 800;
color: var(--text);
}
.hero-title-sub {
margin-top: 16rpx;
font-size: 38rpx;
line-height: 1.4;
font-weight: 600;
color: var(--text);
}
.hero-accent {
color: var(--accent-2);
}
.hero-sub {
margin-top: 22rpx;
font-size: 25rpx;
line-height: 1.8;
color: var(--text-dim);
}
.hero-cta {
display: flex;
margin-top: 40rpx;
}
.btn {
flex: 1;
height: 92rpx;
line-height: 92rpx;
text-align: center;
border-radius: var(--r-lg);
font-size: 29rpx;
font-weight: 600;
}
.btn.primary {
background: linear-gradient(135deg, var(--accent) 0%, var(--accent-2) 100%);
color: #ffffff;
box-shadow: 0 12rpx 26rpx rgba(242, 53, 59, 0.26);
}
.btn.ghost {
margin-left: 20rpx;
border: 2rpx solid var(--border);
background: var(--panel);
color: var(--text);
}
/* 单独一行的宽按钮 */
.btn.ghost.wide {
display: block;
flex: none;
width: 100%;
margin: 30rpx 0 0;
}
.stats {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
margin-top: 36rpx;
}
.stat {
width: 48%;
margin-bottom: 16rpx;
padding: 22rpx 24rpx;
border: 2rpx solid var(--border);
border-radius: var(--r-lg);
background: rgba(28, 28, 34, 0.74);
}
.stat-value {
font-size: 40rpx;
font-weight: 700;
color: var(--text);
}
.stat-label {
margin-top: 6rpx;
font-size: 21rpx;
line-height: 1.5;
color: var(--text-dim);
}
.chips {
display: flex;
flex-wrap: wrap;
margin-top: 30rpx;
}
.chip {
margin: 0 14rpx 14rpx 0;
padding: 10rpx 22rpx;
border-radius: var(--r-pill);
border: 2rpx solid var(--border);
background: var(--panel);
color: var(--text-dim);
font-size: 21rpx;
}
.chip.on {
color: var(--cyan);
border-color: rgba(79, 193, 255, 0.4);
background: rgba(79, 193, 255, 0.08);
}
.note {
margin-top: 4rpx;
font-size: 22rpx;
line-height: 1.7;
color: var(--text-dim);
}
/* ===== 小程序定位 ===== */
.notice {
margin-top: 46rpx;
padding: 30rpx 32rpx;
border: 2rpx solid var(--border);
border-radius: var(--r-lg);
background: var(--panel);
}
.notice-title {
font-size: 28rpx;
font-weight: 600;
color: var(--accent-2);
}
.notice-desc {
margin-top: 12rpx;
font-size: 24rpx;
line-height: 1.75;
color: var(--text-dim);
}
.notice-link {
margin-top: 18rpx;
font-size: 24rpx;
color: var(--cyan);
}
/* ===== 通用分节 ===== */
.section {
position: relative;
margin-top: 76rpx;
}
.section.alt {
margin: 76rpx -28rpx 0;
padding: 56rpx 28rpx;
background: var(--bg-2);
border-top: 2rpx solid var(--border);
border-bottom: 2rpx solid var(--border);
}
.kicker {
font-size: 20rpx;
font-weight: 700;
letter-spacing: 4rpx;
color: var(--accent);
}
.h2 {
margin-top: 14rpx;
font-size: 40rpx;
font-weight: 700;
color: var(--text);
}
.lead {
margin: 16rpx 0 30rpx;
font-size: 24rpx;
line-height: 1.75;
color: var(--text-dim);
}
.card {
padding: 26rpx 30rpx;
margin-bottom: 18rpx;
border: 2rpx solid var(--border);
border-radius: var(--r-lg);
background: var(--panel);
}
.card-title {
font-size: 28rpx;
font-weight: 600;
color: var(--text);
}
.card-desc {
margin-top: 12rpx;
font-size: 24rpx;
line-height: 1.7;
color: var(--text-dim);
}
/* ===== 版本表 ===== */
.rows {
border: 2rpx solid var(--border);
border-radius: var(--r-lg);
background: var(--panel);
overflow: hidden;
}
.row {
padding: 26rpx 30rpx;
border-bottom: 2rpx solid var(--border);
}
.row:last-child {
border-bottom: none;
}
.row-ver {
font-size: 26rpx;
font-weight: 600;
color: var(--cyan);
}
.row-desc {
margin-top: 10rpx;
font-size: 23rpx;
line-height: 1.7;
color: var(--text-dim);
}
/* ===== 步骤 ===== */
.step {
display: flex;
align-items: flex-start;
margin-bottom: 24rpx;
}
.step-index {
flex-shrink: 0;
width: 48rpx;
height: 48rpx;
line-height: 48rpx;
text-align: center;
border-radius: var(--r-pill);
border: 2rpx solid rgba(242, 53, 59, 0.35);
background: rgba(242, 53, 59, 0.12);
color: var(--accent-2);
font-size: 23rpx;
}
.step-text {
flex: 1;
margin-left: 20rpx;
padding-top: 6rpx;
font-size: 25rpx;
line-height: 1.7;
color: var(--text);
}
/* ===== 常见问题 ===== */
.faq {
margin-bottom: 26rpx;
}
.faq-q {
font-size: 27rpx;
color: var(--text);
}
.faq-a {
margin-top: 10rpx;
font-size: 24rpx;
line-height: 1.75;
color: var(--text-dim);
}
.foot {
margin-top: 60rpx;
font-size: 22rpx;
line-height: 1.7;
color: rgba(157, 157, 168, 0.6);
}
+79
View File
@@ -0,0 +1,79 @@
const { post, login } = require('../../utils/pay-api.js')
// 同购买页:用上次的身份当初始值,退款按钮不会先冒出来再消失
const cachedIsAdmin = Boolean(wx.getStorageSync('isAdmin'))
function formatTime(timestamp) {
const d = new Date(timestamp)
const pad = (n) => (n < 10 ? '0' + n : '' + n)
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
}
Page({
data: {
loading: false,
list: [],
error: '',
isIos: false,
isAdmin: cachedIsAdmin, // 退款目前是管理员功能
},
// 每次切到这一页都刷新一次,买完回来看得到
onShow() {
// iPhone 上买的订单只能用户自己去 App Store 申请退款,按钮就不显示了
this.setData({ isIos: wx.getSystemInfoSync().platform === 'ios' })
this.load()
},
async load() {
this.setData({ loading: true, error: '' })
try {
const code = await login()
const data = await post('/pay/my', { code })
wx.setStorageSync('isAdmin', Boolean(data.isAdmin))
this.setData({
isAdmin: Boolean(data.isAdmin),
list: (data.list || []).map((item) => Object.assign({}, item, { timeText: formatTime(item.deliveredAt) })),
})
} catch (err) {
this.setData({ error: err.message })
} finally {
this.setData({ loading: false })
}
},
copyCode(e) {
wx.setClipboardData({ data: e.currentTarget.dataset.code })
},
onRefund(e) {
const outTradeNo = e.currentTarget.dataset.order
wx.showModal({
title: '申请退款',
content: '退款后这张激活码会作废,确定要退吗?',
confirmText: '退款',
cancelText: '再想想',
success: (res) => {
if (res.confirm) this.doRefund(outTradeNo)
},
})
},
async doRefund(outTradeNo) {
wx.showLoading({ title: '退款中', mask: true })
try {
const code = await login()
await post('/pay/refund', { outTradeNo, code })
wx.showToast({ title: '退款已发起', icon: 'none' })
await this.load()
} catch (err) {
wx.showToast({ title: err.message, icon: 'none', duration: 3000 })
} finally {
wx.hideLoading()
}
},
onPullDownRefresh() {
this.load().then(() => wx.stopPullDownRefresh())
},
})
+5
View File
@@ -0,0 +1,5 @@
{
"navigationBarTitleText": "我的",
"enablePullDownRefresh": true,
"usingComponents": {}
}
+31
View File
@@ -0,0 +1,31 @@
<view class="page">
<view class="head">
<view class="head-title">我的激活码</view>
<view class="head-sub">点一下激活码即可复制</view>
</view>
<view wx:if="{{error}}" class="banner">{{error}}</view>
<view wx:if="{{loading && !list.length}}" class="banner">加载中…</view>
<view wx:for="{{list}}" wx:key="code" class="card" data-code="{{item.code}}" bindtap="copyCode">
<view class="card-name">{{item.productName}}<text wx:if="{{item.env === 1}}" class="card-tag">沙箱测试</text></view>
<view class="card-code">{{item.code}}</view>
<view class="card-bottom">
<view class="card-time">{{item.timeText}}</view>
<view
wx:if="{{isAdmin && !isIos && item.env === 0}}"
class="card-refund"
data-order="{{item.outTradeNo}}"
catchtap="onRefund"
>申请退款</view>
</view>
</view>
<view wx:if="{{isIos && list.length}}" class="tip">iPhone 上买的订单开发者退不了,需要退款请到 App Store → 购买记录里申请。</view>
<view wx:if="{{!loading && !list.length && !error}}" class="placeholder">
<view class="placeholder-icon">🎫</view>
<view class="placeholder-title">还没有激活码</view>
<view class="placeholder-desc">去「购买测试」买一个,发货后这里就会出现</view>
</view>
</view>
+102
View File
@@ -0,0 +1,102 @@
.page {
min-height: 100vh;
padding: 32rpx 28rpx 48rpx;
background: var(--bg);
}
.head {
margin-bottom: 28rpx;
}
.head-title {
font-size: 40rpx;
font-weight: 600;
color: #ffffff;
}
.head-sub {
margin-top: 8rpx;
font-size: 24rpx;
color: rgba(255, 255, 255, 0.45);
}
.banner {
padding: 22rpx 26rpx;
margin-bottom: 20rpx;
border-radius: var(--r-lg);
background: var(--panel);
color: rgba(255, 255, 255, 0.6);
font-size: 25rpx;
line-height: 1.6;
}
.card {
padding: 26rpx 28rpx;
margin-bottom: 20rpx;
border-radius: var(--r-lg);
background: var(--panel);
}
.card:active {
background: var(--panel-2);
}
.card-name {
font-size: 26rpx;
color: rgba(255, 255, 255, 0.55);
}
.card-tag {
margin-left: 14rpx;
padding: 2rpx 14rpx;
border-radius: var(--r-pill);
background: rgba(245, 158, 11, 0.16);
color: #f0b45c;
font-size: 20rpx;
}
.card-code {
margin-top: 14rpx;
font-size: 38rpx;
font-weight: 600;
letter-spacing: 4rpx;
color: var(--cyan);
word-break: break-all;
}
.card-time {
font-size: 22rpx;
color: rgba(255, 255, 255, 0.35);
}
.card-bottom {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 12rpx;
}
.card-refund {
padding: 6rpx 22rpx;
border-radius: var(--r-pill);
border: 2rpx solid rgba(255, 255, 255, 0.14);
color: rgba(255, 255, 255, 0.55);
font-size: 22rpx;
}
.card-refund:active {
background: rgba(255, 255, 255, 0.06);
}
.tip {
margin-top: 10rpx;
font-size: 22rpx;
line-height: 1.7;
color: rgba(255, 255, 255, 0.35);
}
/* 空态跟着标题走,不用撑满整屏 */
.placeholder {
height: auto;
padding: 140rpx 40rpx 0;
}
+2 -2
View File
@@ -349,7 +349,7 @@ Page({
const animations = skeletonData.animations.map((a) => a.name)
if (!animations.length) {
this.setData({ status: 'error', errorMsg: '该骨骼文件里没有任何动画' })
this.setData({ status: 'error', errorMsg: '该骨骼文件里没有可播放的内容' })
return
}
@@ -390,7 +390,7 @@ Page({
skeletonData.slots.length +
'插槽 · ' +
animations.length +
'动画 · ' +
'段画面 · ' +
atlas.pages.length +
'图集页 · 文件声明PMA ' +
(this._pma ? '是' : '否'),
+7 -12
View File
@@ -15,9 +15,9 @@
<!-- 空状态 -->
<view wx:if="{{status === 'idle'}}" class="stage-mask">
<view class="empty-icon">🎬</view>
<view class="empty-title">Spine 动画预览</view>
<view class="empty-sub">支持 Spine 4.2 导出的 .skel / .atlas / .png</view>
<view class="empty-tip">把三个文件发到「文件传输助手」后在下方选择;真机可一次多选,PC 可分多次选</view>
<view class="empty-title">Spine 画面预览</view>
<view class="empty-sub">小程序版本可支持 Spine 4.2 导出的 .skel / .atlas / .png</view>
<view class="empty-tip">先把 .skel / .atlas / 图片发到「文件传输助手」,再在下方一次选中即可;文件只在本地解析,不会上传</view>
</view>
<!-- 加载中 -->
@@ -39,17 +39,12 @@
<!-- ===== 控制面板 ===== -->
<view class="sheet">
<view class="grabber"></view>
<!-- 文件 -->
<view class="section">
<view class="section-title">文件</view>
<button class="pick-btn" bindtap="chooseFiles">
<text class="pick-icon">📁</text>
<text class="pick-text">选择文件</text>
<text class="pick-hint">可多选</text>
<text class="pick-text">{{pickedCount ? '重新选择文件' : '选择文件'}}</text>
</button>
<view class="pick-sub">可一次选中多个文件 · .skel / .atlas / .png</view>
<view wx:if="{{pickedCount}}" class="chips">
<view wx:for="{{pickedFiles}}" wx:key="id" class="chip chip-{{item.kind}}">
@@ -58,7 +53,7 @@
</view>
</view>
<view wx:if="{{missingText}}" class="missing">● {{missingText}}</view>
<view wx:if="{{missingText}}" class="missing">{{missingText}}</view>
<view wx:if="{{pickedCount}}" class="link" bindtap="clearFiles">清空已选</view>
</view>
@@ -90,7 +85,7 @@
<slider
class="param-slider"
min="0.1" max="3" step="0.1" value="{{speed}}"
activeColor="#4a6cf7" backgroundColor="#e9ecf5" block-color="#4a6cf7" block-size="16"
activeColor="#f2353b" backgroundColor="#2c2c35" block-color="#f2353b" block-size="16"
bindchange="onSpeedChange"
/>
<text class="param-val">{{speed}}x</text>
+55 -66
View File
@@ -3,7 +3,7 @@
display: flex;
flex-direction: column;
height: 100vh;
background: #16181d;
background: var(--bg);
}
/* ===== 预览区(深色)===== */
@@ -11,7 +11,7 @@
position: relative;
flex: 1;
min-height: 0;
background: linear-gradient(180deg, #21252d 0%, #14161b 100%);
background: linear-gradient(180deg, var(--panel) 0%, #14161b 100%);
}
.stage-canvas {
@@ -70,26 +70,18 @@
left: 22rpx;
top: 22rpx;
padding: 8rpx 18rpx;
border-radius: 999rpx;
border-radius: var(--r-pill);
background: rgba(0, 0, 0, 0.34);
color: rgba(255, 255, 255, 0.72);
font-size: 22rpx;
}
/* ===== 控制面板(白色圆角卡片)===== */
/* ===== 控制面板(深色,和预览区统一)===== */
.sheet {
background: #ffffff;
border-radius: 32rpx 32rpx 0 0;
padding: 0 28rpx 28rpx;
box-shadow: 0 -10rpx 36rpx rgba(0, 0, 0, 0.3);
}
.grabber {
width: 76rpx;
height: 8rpx;
border-radius: 4rpx;
background: #e3e6ee;
margin: 14rpx auto 18rpx;
background: var(--bg-2);
border-radius: var(--r-lg) var(--r-lg) 0 0;
padding: 30rpx 28rpx 28rpx;
box-shadow: 0 -10rpx 36rpx rgba(0, 0, 0, 0.45);
}
.section {
@@ -101,10 +93,10 @@
}
.section-title {
font-size: 23rpx;
color: #9aa1b1;
font-size: 26rpx;
color: rgba(255, 255, 255, 0.92);
font-weight: 600;
letter-spacing: 2rpx;
letter-spacing: 1rpx;
margin-bottom: 14rpx;
}
@@ -114,35 +106,29 @@
align-items: center;
justify-content: center;
width: 100%;
height: 94rpx;
height: 100rpx;
padding: 0;
border-radius: 16rpx;
background: linear-gradient(135deg, #4a6cf7 0%, #6d8bff 100%);
border-radius: var(--r-lg);
background: linear-gradient(135deg, var(--accent) 0%, var(--accent-2) 100%);
color: #ffffff;
font-size: 30rpx;
font-weight: 500;
box-shadow: 0 8rpx 20rpx rgba(74, 108, 247, 0.3);
box-shadow: 0 10rpx 24rpx rgba(242, 53, 59, 0.28);
}
.pick-btn::after {
border: none;
}
.pick-icon {
font-size: 32rpx;
margin-right: 12rpx;
}
.pick-text {
font-size: 30rpx;
font-size: 31rpx;
letter-spacing: 2rpx;
}
.pick-hint {
margin-left: 14rpx;
padding: 4rpx 14rpx;
border-radius: 999rpx;
background: rgba(255, 255, 255, 0.22);
font-size: 21rpx;
.pick-sub {
margin-top: 16rpx;
text-align: center;
font-size: 22rpx;
color: rgba(255, 255, 255, 0.38);
}
/* ===== 文件类型标签 ===== */
@@ -157,26 +143,26 @@
align-items: center;
margin: 0 12rpx 12rpx 0;
padding: 8rpx 18rpx;
border-radius: 999rpx;
background: #f3f5f9;
border-radius: var(--r-pill);
background: var(--panel-2);
}
.chip-dot {
width: 12rpx;
height: 12rpx;
border-radius: 50%;
background: #94a3b8;
background: var(--text-dim);
margin-right: 10rpx;
flex-shrink: 0;
}
.chip-name {
font-size: 23rpx;
color: #4a5264;
color: rgba(255, 255, 255, 0.72);
}
.chip-skel .chip-dot {
background: #4a6cf7;
background: var(--accent);
}
.chip-atlas .chip-dot {
@@ -192,24 +178,27 @@
}
.missing {
margin-top: 2rpx;
font-size: 24rpx;
color: #f59e0b;
margin-top: 18rpx;
padding: 14rpx 20rpx;
border-radius: var(--r-lg);
background: rgba(245, 158, 11, 0.1);
font-size: 23rpx;
color: #f0b45c;
}
.link {
margin-top: 14rpx;
font-size: 24rpx;
color: #9aa1b1;
color: rgba(255, 255, 255, 0.45);
}
.link.inline {
margin-top: 0;
margin-left: 18rpx;
color: #4a6cf7;
color: var(--cyan);
}
/* ===== 动画选择 ===== */
/* ===== 播放选择 ===== */
.select {
display: flex;
align-items: center;
@@ -217,27 +206,27 @@
height: 86rpx;
padding: 0 26rpx;
margin-bottom: 14rpx;
background: #f5f6fa;
border-radius: 14rpx;
background: var(--panel-2);
border-radius: var(--r-lg);
}
.select-label {
font-size: 29rpx;
font-weight: 500;
color: #1f2430;
color: #ffffff;
}
.select-arrow {
font-size: 26rpx;
color: #9aa1b1;
color: rgba(255, 255, 255, 0.4);
}
/* ===== 分段控件 ===== */
.segment {
display: flex;
padding: 6rpx;
background: #f2f4f8;
border-radius: 14rpx;
background: var(--panel-2);
border-radius: var(--r-lg);
}
.seg {
@@ -246,15 +235,15 @@
line-height: 68rpx;
text-align: center;
font-size: 25rpx;
color: #7b8290;
border-radius: 10rpx;
color: rgba(255, 255, 255, 0.55);
border-radius: var(--r-md);
}
.seg.on {
background: #ffffff;
color: #4a6cf7;
background: #3d262e;
color: var(--cyan);
font-weight: 600;
box-shadow: 0 2rpx 10rpx rgba(0, 0, 0, 0.08);
box-shadow: 0 2rpx 10rpx rgba(0, 0, 0, 0.25);
}
/* ===== 参数行 ===== */
@@ -268,7 +257,7 @@
width: 76rpx;
flex-shrink: 0;
font-size: 26rpx;
color: #6b7280;
color: rgba(255, 255, 255, 0.55);
}
.param-slider {
@@ -282,7 +271,7 @@
text-align: right;
font-size: 25rpx;
font-weight: 600;
color: #4a6cf7;
color: var(--cyan);
}
/* ===== 缩放步进器 ===== */
@@ -290,8 +279,8 @@
display: flex;
align-items: center;
margin-left: 16rpx;
background: #f2f4f8;
border-radius: 12rpx;
background: var(--panel-2);
border-radius: var(--r-lg);
overflow: hidden;
}
@@ -301,11 +290,11 @@
line-height: 64rpx;
text-align: center;
font-size: 32rpx;
color: #4a5264;
color: rgba(255, 255, 255, 0.75);
}
.step-btn:active {
background: #e6e9f2;
background: var(--panel-2);
}
.step-val {
@@ -313,12 +302,12 @@
text-align: center;
font-size: 25rpx;
font-weight: 600;
color: #1f2430;
color: #ffffff;
}
.gesture-hint {
margin-top: 4rpx;
text-align: center;
font-size: 22rpx;
color: #b6bcc9;
color: rgba(255, 255, 255, 0.3);
}