简介:这是一份H5独立版英文塔罗牌占卜系统源码,主要面向前端开发者与H5应用学习者;系统同时包含前端展示与部分后端逻辑,可用于研究英文界面下的交互流程、随机抽牌算法、占卜结果生成逻辑以及移动端适配思路,具有一定完整度。压缩包共259个文件,总大小约8.6MB,以199个PHP文件为主,配合HTML/TPL模板、JSON配置、SQL脚本以及ini、yml等环境配置;PHP负责核心业务与数据处理,模板决定页面呈现,SQL可用于初始化数据结构,整体目录结构清晰,便于按模块定位和学习。目前已有269人学习下载;资源虽标注“仅供学习”,但提供完整前端资源结构、后端接口参考和部署配置样例,适合想快速了解塔罗牌占卜类H5产品实现的读者作为练手项目,并结合自身技术栈进行二次改造与功能扩展。
1. 轻量级H5塔罗占卜系统:一次随机抽牌背后的前端工作量
某团队接了个海外演示需求:一个英文版、独立部署、能在手机浏览器直接打开的塔罗牌占卜H5。需求方最开始觉得“这就是一个随机抽牌页”,但真正落地才发现,光把78张牌的数据结构、三种牌阵的抽牌位置、洗牌手感和3D翻牌动效做好,就已经足够让前端团队忙上整整一周。这份源码的价值正在于此:它把“洗牌—抽牌—翻牌—展示英文牌义”的完整主链路跑通了,同时附带轻量级后端接口骨架。适合想研究移动端交互动效的人,也适合想看清小工具型产品前后端边界的新手和熟手。整体定位是学习参考,不是开箱即用的商业成品,这一点在后文很多细节里都能感受到。
2. 牌库与牌阵的数据结构:78张牌在代码里怎么建模
2.1 大阿卡纳与小阿卡纳:英文牌义表为什么推荐常量配置
塔罗牌的标准组成是78张:22张大阿卡纳(Major Arcana)加56张小阿卡纳(Minor Arcana)。小阿卡纳又分成四组花色,每组从Ace到十号牌,外加四张宫廷牌(Page、Knight、Queen、King)。这套体系很固定,所以用一组常量来描述牌库是完全够用的,不需要后端数据库参与。
const DECK_META = { arcana: [ { type: 'major', count: 22 }, { type: 'minor', suits: ['wands', 'cups', 'swords', 'pentacles'], ranks: ['ace', 'two', 'three', 'four', 'five', 'six', 'seven', 'eight', 'nine', 'ten', 'page', 'knight', 'queen', 'king'] } ], suitLabels: { wands: 'Wands', cups: 'Cups', swords: 'Swords', pentacles: 'Pentacles' } };逻辑说明:上面这段元数据的作用是“生成牌库”,不是直接写死78个对象。代码先定义花色和等级,再通过两重循环去生成小阿卡纳,避免手工维护78条记录时出现复制粘贴错误。这个设计我最喜欢的一点是——如果要加第四套牌或者自定义牌组,只需要改这个元数据就可以,渲染层根本不用动。
参数说明:count是大阿卡纳的固定数量,suits和ranks决定小阿卡纳的遍历范围。实际生成时,每张牌会拿到一个唯一编号id、正位牌义uprightMeaning、逆位牌义reversedMeaning,以及一张牌面图片路径。英文文案在源码里以字符串常量形式存放,不混在模板里。这样做的好处是后面想加德语、法语版本时,只需要替换文本配置,不需要动页面结构。
2.2 三种牌阵抽牌逻辑:单张、三张、凯尔特十字的差异
牌阵决定了“抽几张牌、放在什么位置、每个位置代表什么含义”。最常用的是单张(One Card)、三张(Past/Present/Future)和凯尔特十字(Celtic Cross,十张)。三者的差异不只是张数,还有“是否区分位置语义”。
const SPREADS = { 'one-card': { positions: [ { key: 'now', label: 'Now', x: 50, y: 50, rotate: 0 } ] }, 'three-card': { positions: [ { key: 'past', label: 'The Past', x: 20, y: 50, rotate: -8 }, { key: 'present', label: 'The Present', x: 50, y: 50, rotate: 0 }, { key: 'future', label: 'The Future', x: 80, y: 50, rotate: 8 } ] } }; function buildPositions(spreadId) { return SPREADS[spreadId].positions.map(pos => ({ ...pos, // 百分比坐标转像素坐标由渲染层负责 style: `left: ${pos.x}%; top: ${pos.y}%; transform: rotate(${pos.rotate}deg);` })); }逻辑说明:把牌阵抽象成“一组带坐标和角度的位置”,抽牌逻辑就变得很纯粹。无论是一个位置还是十个位置,代码都只需要遍历positions数组,从洗好的牌堆里按顺序取牌,再赋值给对应key。这种数据驱动的方式让牌阵扩展成本极低。
参数说明:x和y用的是百分比,这样在不同屏幕尺寸下都能保持相对位置。rotate是牌位的初始倾斜角度,营造手排牌时“随手一放”的随意感。取值上我一般建议单张牌用0度,三张牌用-8到+8度,凯尔特十字里交叉的牌用-18度,这样视觉层次会清晰很多。
凯尔特十字的十个位置分别是:现状、挑战、过去、近未来、目标、近因、自我认知、环境、希望与恐惧、最终结果。中间两张牌要对齐交叉放置,外围八张环绕排列。源码里给这十个位置写了一套完整的坐标系,坐标值经过移动端适配,在小屏幕上的间距还能维持可读性。
2.3 洗牌算法:Fisher-Yates与随机分布的坑
现代浏览器上洗牌最简单的方式是arr.sort(() => Math.random() - 0.5),但这条路会牺牲随机分布性。用 sort 洗牌的问题在于:比较器不满足一致性要求,最终的排列结果并不是等概率分布,出现“某张牌总落在边缘”的偏斜是常态。塔罗牌讲究信任随机性,至少代码层面要让分布均匀。
function shuffleDeck(deck) { const result = deck.slice(); for (let i = result.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [result[i], result[j]] = [result[j], result[i]]; } return result; } function drawCards(deck, spreadId) { const positions = buildPositions(spreadId); const shuffled = shuffleDeck(deck); return positions.map((pos, index) => ({ positionKey: pos.key, positionLabel: pos.label, card: shuffled[index] })); }逻辑说明:Fisher-Yates 洗牌从数组末尾开始,每次从“还未处理的牌”里随机选一张换到当前位置。这个算法时间复杂度是 O(n),而且每一张牌出现在任意位置的概率一致。抽牌流程把它放在buildPositions之后执行,先确定牌位,再洗牌,最后用下标顺序填充,保证“每个牌位拿到的牌都来自同一轮洗牌”。
参数说明:deck是包含78张牌的完整数组,spreadId是牌阵标识。函数返回的是带位置语义的抽牌结果,前端拿到它就可以进入渲染流程。有一点需要注意,抽完的牌要立即存起来,因为后续翻牌动效需要知道“哪个牌位对应哪张牌”,如果在这里丢了映射关系,后面就得重新随机,体验就崩了。
3. 洗牌与翻牌的移动端交互:手感、动效与触摸事件
3.1 牌堆渲染:DOM重复占位与牌背纹理
塔罗牌H5的视觉核心在牌背。用户第一眼看到的是牌堆,牌背纹理的质感直接决定整个产品是精致还是廉价。这份源码的牌堆用的是重复DOM方案:78张卡片在洗牌开始前全部渲染成绝对定位的元素,牌背朝上,等待洗牌动画。
<div class="card-stack" id="cardStack"> <!-- 由 JS 动态生成,结构如下 --> <div class="card">@keyframes shuffle-move { 0% { transform: translate(0, 0) rotate(0deg); } 20% { transform: translate(8px, -12px) rotate(4deg); } 40% { transform: translate(-10px, 6px) rotate(-6deg); } 60% { transform: translate(12px, 10px) rotate(3deg); } 80% { transform: translate(-6px, -8px) rotate(-4deg); } 100% { transform: translate(0, 0) rotate(0deg); } } .card-stack .card { animation: shuffle-move 0.9s ease-in-out both; } .card-stack .card:nth-child(3n) { animation-delay: 0.1s; } .card-stack .card:nth-child(3n+1) { animation-delay: 0.2s; }逻辑说明:nth-child(3n)系列选择器把78张牌分成三组,每组延迟差0.1秒,动画整体在视觉上形成流动感。这里的关键是写动画时不要直接引用animation-name,而是通过延迟和方向组合出差异。归拢的感觉来自最后一个关键帧,所有牌的transform统一回到原点,而且动画填充模式both确保结束后状态停留,牌堆恢复整齐。
参数说明:延迟0.1到0.2秒是个人经验值。太短看不出层次,太长会让用户觉得卡顿。低端机上如果掉帧,可以把关键帧减少到三个(0%、50%、100%),减少重绘工作量。另外一个实用细节:洗牌动画期间要给卡片容器加pointer-events: none,防止用户在动画中途就点牌,导致翻牌时牌的坐标还是动画前的值,出现牌从错误位置旋转出来的情况。
3.3 翻牌3D细节:preserve-3d与backface-visibility
翻牌是塔罗牌H5的“交付时刻”。这张动画如果做得拖沓,用户会觉得系统反应慢;做得太生硬,又少了仪式感。源码里用的是一个非常标准的3D翻牌结构,核心是父元素开启preserve-3d、正反面元素关闭背面渲染。
.card { perspective: 800px; } .card-inner { position: relative; width: 100%; height: 100%; transform-style: preserve-3d; transition: transform 0.6s cubic-bezier(0.4, 0.2, 0.2, 1); } .card-inner.flipped { transform: rotateY(180deg); } .card-face { position: absolute; width: 100%; height: 100%; backface-visibility: hidden; -webkit-backface-visibility: hidden; } .card-front { transform: rotateY(180deg); }逻辑说明:.card-front先旋转180度藏到背面,当父元素.card-inner也旋转180度时,前表面正好转到正面,视觉上就是“这张牌翻了过来”。perspective: 800px写在父级.card上,值越小透视畸变越强,800px 在手机屏幕上的立体感比较舒适。cubic-bezier(0.4, 0.2, 0.2, 1)是一个“先慢后快再缓收”的曲线,模拟人手翻牌时手腕发力、末段减速的节奏,比默认的ease更有手感。
参数说明:backface-visibility: hidden必须同时写带-webkit-前缀的版本,否则iOS Safari上翻牌过程中会出现背面文字透视穿透的怪影。transform-style: preserve-3d有一个很隐蔽的坑:一旦父元素加overflow: hidden,这个属性就会被浏览器强制降级为flat,3D效果瞬间失效。这份源码在牌堆和卡片容器上都避开了overflow,改用的clip-path或直接铺满全屏来裁剪视觉边界。
3.4 触摸点选:300ms延迟与快速连点的状态锁
移动端H5最容易踩的一个老坑是click事件的300毫秒延迟。现代浏览器在设置了viewport后基本消除了这个延迟,但如果源码里的页面没有正确配置,用户点一张牌会明显感觉“顿了半拍”。塔罗牌这个场景对点击手感极其敏感,所以直接用pointerdown替代click。
card.addEventListener('pointerdown', function (e) { if (turnLock.active) return; turnLock.active = true; const cardId = this.dataset.cardId; const flipResult = flipCard(this, cardId); flipResult.onfinish = function () { showCardMeaning(cardId); turnLock.active = false; }; }); function flipCard(cardEl, cardId) { cardEl.querySelector('.card-inner').classList.add('flipped'); return cardEl.querySelector('.card-inner').getAnimations()[0] || { onfinish: null }; }逻辑说明:pointerdown在手指接触屏幕的瞬间触发,省去click的延迟和判定过程。但pointerdown的问题是触发时机太早,用户手指可能只是误触,所以要用turnLock做状态锁:翻牌动画结束前,其余所有点击直接忽略。这样既避免了连点导致两张牌同时翻开,也避免动画未完成时状态错乱。
参数说明:turnLock.active是一个模块级布尔变量。注意锚点语义不能直接放在全局window上,否则一个页面多个牌阵实例时会互相干扰。flipCard里通过getAnimations()拿动画结束回调,比监听transitionend更可靠,因为transitionend在快速切换样式时偶尔会被浏览器吞掉。
4. 前后端源码的组织方式:Express接口与一次占卜的数据流
4.1 技术栈选型:前后端同一语言的好处
“部分前后端源码”这几个字说明了一个现实:前端主链路是完整的,后端是骨架级的。源码里的后端选择了Node.js加Express,和前端共用JavaScript,好处是开发者不需要维护两套心智模型。Express在这个场景的核心价值不是性能,而是“几行代码就能把静态资源和API路由都服务起来”。
server/ app.js routes/ deck.js reading.js data/ cards.js package.json public/ index.html css/ style.css js/ main.js tarot.js assets/ cards/ major/ wands/ cups/ swords/ pentacles/目录结构说明:server管数据源和记录存储,public管页面与交互。cards.js是完整的78张牌数据文件,作为数据源被两个地方引用;前端启动时会拉取一份副本,之后所有抽牌逻辑都在客户端完成。后端不再重复提供随机接口,只提供“牌库拉取”和“占卜记录保存”两个能力。
参数说明:这个结构对学习型项目来说正好。如果你一开始就把所有抽牌逻辑放到后端API里,每个动效阶段的牌状态都要请求服务器,网络抖动会让体验变得不可用。实践证明,抽牌结果在客户端算,后端只做数据分发和结果存档,是这类工具型产品最合理的边界。
4.2 接口设计:GET /api/cards 与 POST /api/reading
后端接口的数量被刻意控制在两个,这是“部分源码”的另一个体现——接口骨架演示了数据的输入输出,但没有做权限、限流、多语言服务等生产级能力。
const express = require('express'); const router = express.Router(); const deckData = require('../data/cards'); // 返回完整牌库,前端启动时拉取一次 router.get('/cards', (req, res) => { res.json({ code: 0, data: deckData, ts: Date.now() }); }); // 保存一次占卜结果,返回记录 id router.post('/reading', (req, res) => { const { spreadId, result, deviceId } = req.body; if (!spreadId || !Array.isArray(result) || result.length === 0) { return res.status(400).json({ code: 1, message: 'invalid params' }); } const recordId = saveReading({ spreadId, result, deviceId, createdAt: Date.now() }); res.json({ code: 0, data: { recordId } }); });逻辑说明:GET /api/cards目的是让前端拿到一份带最新修正的牌库数据,避免每次发布都要重新打包前端文件。POST /api/reading是一次完整的业务闭环,前端把牌阵类型、抽牌结果、设备标识传到后端,后端存下来并返回记录编号。这里的saveReading在源码里是写入一个本地JSON文件,方便演示数据流。
参数说明:spreadId对应前端SPREADS里的键值;result是数组,元素结构为{ positionKey, cardId, reversed };deviceId是前端在用户首次访问时生成的随机字符串,用来区分同一设备的历史记录。请求体里面result必须做空数组校验,否则一次异常的前端调用会把脏数据写进记录文件,排查起来会让人头疼。
4.3 刷新恢复:localStorage版本化存储
用户最反感的一件事:占卜到一半退出浏览器,回来发现结果没了。塔罗牌H5属于典型的一次性工具产品,用户愿意重来第二次的比例很低,所以记录“最近一次占卜”是必须做的。源码用的是 localStorage,而不是后端记录,原因是刷新恢复要的是即时读取,不依赖网络。
const STORAGE_KEY = 'tarot_last_reading_v1'; function persistLastReading(spreadId, result) { const payload = { version: 1, ts: Date.now(), spreadId, result }; localStorage.setItem(STORAGE_KEY, JSON.stringify(payload)); } function restoreLastReading() { try { const raw = localStorage.getItem(STORAGE_KEY); if (!raw) return null; const parsed = JSON.parse(raw); if (parsed.version !== 1 || !Array.isArray(parsed.result)) { localStorage.removeItem(STORAGE_KEY); return null; } return parsed; } catch (err) { localStorage.removeItem(STORAGE_KEY); return null; } }逻辑说明:STORAGE_KEY里带上了_v1版本后缀。这是为后期数据结构升级留的后悔药,一旦result数组里的对象结构发生变化,旧数据解析后理论上还能兼容,但字段缺失会让前端渲染出错。所以恢复数据时要校验version和result的数组类型,不合法就直接清掉,不污染新一轮占卜。
参数说明:ts时间戳在恢复后可以用于判断“这份结果是不是一个月前的旧数据”。如果想做得更细,可以把一天前的记录视为失效,恢复时提示用户“重新开始”。源码里没有做这个过期逻辑,但我在实际复现时加了30分钟失效策略,让每次打开都更像一次完整的仪式。
5. 避坑与排查:H5塔罗牌最容易翻车的五个地方
5.1 iPhone上翻牌出现黑屏闪烁
现象:用iPhone打开页面,点击翻牌后牌面先闪一下黑块,然后才显示正常图片,有时干脆一直黑屏。原因是iOS Safari对backface-visibility和transform-style: preserve-3d的渲染存在层级问题,当卡片图片是一个跨域或未缓存的<img>时,GPU合成会短暂失败。解决办法是给牌面图片设置固定尺寸和背景色,同时把翻牌动画触发前的图片预加载一遍,确保翻转时图片已经在内存里。按照这个思路处理之后,闪烁问题基本消失。
5.2 长英文单词撑破卡片布局
现象:牌义文本如果写的是 “Unconsciousness” 或 “Transformation” 这类长单词,在窄屏幕小卡片上会横向溢出,文字顶到卡片边缘,甚至把整张卡片撑宽。原因是英文单词默认不换行,当单词长度超过容器宽度就会溢出。解决方案是把.card-front的文字容器加上overflow-wrap: break-word和hyphens: auto,同时给卡片一个min-width: 0的约束,防止 flex 子项自动撑开。
.card-front .card-meaning { overflow-wrap: break-word; word-break: break-word; hyphens: auto; line-height: 1.5; padding: 8px 10px; }逻辑说明:overflow-wrap: break-word允许长单词在到达容器边界时强行折行,hyphens: auto会让浏览器根据英语连字符规则自动加连字符,排版更接近书籍效果。桌面端 Chrome 上不加这两个属性也能看,但在iOS Safari上表现截然不同,移动端适配时一定要显式声明。
参数说明:line-height和padding数值需要根据卡片尺寸微调。塔罗牌占卜结果界面的卡片通常是 280px 宽、420px 高的竖卡,牌义文本最多显示三行即可收起,再长就用line-clamp截断,不要试图在卡片里展示全部解释。
5.3 快速连点导致多张牌同时翻转
现象:用户手速较快时,连续点击两张牌,结果两张牌同时翻开,且其中一张的位置状态错乱。原因是点击事件没有加锁,每一次点击都会创建一个动画任务,浏览器并发处理导致状态互相覆盖。解决方式是用一个简单的模块级状态锁turnLock.active,在动画结束后再释放。这里有一个微妙的点:释放锁的动作不是监听click,而是监听动画结束,否则锁会在动画播放中提前解开,等于没加。
let turnLock = { active: false }; function handlePick(e) { if (turnLock.active) return; turnLock.active = true; const cardEl = e.currentTarget; const inner = cardEl.querySelector('.card-inner'); inner.classList.add('flipped'); inner.addEventListener('transitionend', function handler() { inner.removeEventListener('transitionend', handler); turnLock.active = false; }, { once: true }); }逻辑说明:{ once: true }确保回调执行一次后自动移除监听,省去手动removeEventListener的冗余代码。注意这里用的是transitionend,和前面代码里的getAnimations().onfinish是两条不同路线,实际项目中任选其一即可,不要两套混用,否则同一个动画完成会触发两次解锁,锁就失去作用了。
参数说明:只开一个锁不区分“正在翻开的牌”,用户仍然可以翻牌,只是要等上一张动画完全结束。如果想做得更顺手,可以把锁改成队列,但塔罗牌占卜流程本身是逐张翻开的节奏,锁一次一张反而是最贴合交互的体验。
5.4 洗牌动画低端机掉帧卡顿
现象:在千元级安卓机上打开页面,洗牌动画一启动,整个页面掉到十几帧,牌堆像幻灯片一样跳动。原因是78张卡片同时执行CSS位移动画,每一帧都要进行大量重绘,再加上3D变换打开时GPU显存占用过高。解决思路是分两层处理:先给动画中的卡片加上will-change: transform和translateZ(0)提示浏览器走合成层,再在动画结束后去掉这些属性,避免长期占用GPU资源。
.card-stack.card-animating .card { will-change: transform; transform: translateZ(0); animation: shuffle-move 0.9s ease-in-out both; }逻辑说明:will-change是提前通知浏览器该元素即将变化,让浏览器预先分配合成层。这个提示用的时候要克制,它只该出现在动画进行中,动画结束就该移除,否则每个牌位都占一层合成层,内存反而更吃紧。源码里在洗牌动画类名添加时同时加card-animating,动画结束后用监听移除,就是为这个目的。
参数说明:如果做了上述优化仍然卡顿,说明问题在图片解码。78张牌背如果是同一张PNG纹理,只需要一次解码,这是最理想的情况。如果每张牌背都换成不同纹理,解码成本会直接乘以78,低端机必然扛不住。确认牌背纹理是否复用了同一张图片,是排查性能问题时要放在前面的步骤。
5.5 localStorage数据结构升级后解析失败
现象:改版后result数组的元素从原来两个字段增加到三个字段,旧版本用户刷新页面时报错,白屏,控制台提示Cannot read properties of undefined。原因是旧数据里没有新字段,恢复逻辑运行时读不到数据导致渲染崩溃。解决方式就是前面提到的版本化存储。每次结构变更就把STORAGE_KEY里的_v1改成_v2,解析时校验版本号,不匹配直接清空并不做恢复。
function restoreLastReading() { const CURRENT_VERSION = 2; const raw = localStorage.getItem('tarot_last_reading_v' + CURRENT_VERSION); if (!raw) return null; try { const parsed = JSON.parse(raw); if (parsed.version !== CURRENT_VERSION) return null; return parsed; } catch (e) { return null; } }逻辑说明:restoreLastReading只认当前版本的键名,旧版本键值留在localStorage里没人读取,也无所谓。这种做法牺牲了跨版本迁移能力,换取的是绝对的解析安全性。对于一个学习型项目,这个取舍是合理的,你不需要为了兼容旧数据写一套映射逻辑。
参数说明:version字段和键名后缀要保持一致,两者是同一份数据的身份证。实际操作中,如果迭代里新增了一个字段,且旧字段含义不变,可以尝试写一个最多两层结构的迁移函数;但如果字段含义变了或结构嵌套变深,直接换版本号反而省心。
6. 进阶:把固定牌阵改成配置驱动的自定义占卜
前面几个固定牌阵的坐标都是写死在SPREADS里的,新增一个牌阵等于改配置、加坐标,还要确保渲染层的循环逻辑覆盖所有位置。这一步可以再往前走深一点:把牌阵定义抽成一份独立的 JSON 配置,让前端通过读配置来动态生成牌位,完全不改渲染层的代码。
const CUSTOM_SPREAD = [ { key: 'foundation', label: 'The Root', x: 30, y: 35, rotate: -6, layer: 1 }, { key: 'obstacle', label: 'The Block', x: 50, y: 50, rotate: 0, layer: 2 }, { key: 'outcome', label: 'The Peak', x: 70, y: 65, rotate: 6, layer: 1 }, { key: 'advice', label: 'The Support', x: 50, y: 80, rotate: 0, layer: 3 } ];逻辑说明:每个牌位对象增加一个layer字段,渲染时按layer从低到高依次堆叠。这样设计的价值很直接:牌位之间有重叠时,layer高的牌会浮在低层牌上面,点击命中区域不会被遮挡。以前写固定牌阵时这个层级关系是隐含在数组顺序里的,但现在配置化之后,显式的layer字段反而更不容易出错。渲染逻辑可以保持positions.map(...)不变,只需要在生成DOM时按layer做一次排序。
参数说明:x、y控制位置,rotate控制牌位倾斜角,layer控制叠加顺序,这三个字段已经足够覆盖绝大部分自定义牌阵的视觉需求。抽牌逻辑也不需要改,drawCards(deck, spreadConfig)完全可以接受一个自定义数组。我实际复现的时候,把原本写死的凯尔特十字也换成了这个格式,最终抽牌和翻牌过程完全一致。
从那以后,我每次给这类工具型H5设计牌阵,都会把配置和渲染逻辑分开。前端只负责“读配置、发牌、翻转、展示”,所有的牌位语义都躺在配置里。这样无论是加一张“今日指引”,还是做一个十张的大型占卜,都只是数据和坐标的事。这份源码帮我打通的不只是塔罗牌的流程,更是“数据驱动交互”的一个具体切片。
好的组装逻辑之后,换牌阵就像换一张菜单表,渲染层基本不需要改动。希望这份拆解帮你在复现时少走几步弯路。
本文还有配套的精品资源,点击获取