小程序接入支付功能
This commit is contained in:
1 parent
edf1e51846
commit
07a90e860d
38 files changed
+3644
-90
No files matched your search
@@ -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
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"navigationBarTitleText": "开通使用",
|
||||
"usingComponents": {}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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' }),
|
||||
})
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"navigationBarTitleText": "SpineViewer",
|
||||
"usingComponents": {}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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())
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"navigationBarTitleText": "我的",
|
||||
"enablePullDownRefresh": true,
|
||||
"usingComponents": {}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 ? '是' : '否'),
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user