Merge remote-tracking branch 'origin/master' into feature/tongji-endless-game

# Conflicts:
#	TUICallKit-Vue3/tongji/endless-game/README.md
#	TUICallKit-Vue3/tongji/endless-game/index.vue
#	TUICallKit-Vue3/vite.config.ts
This commit is contained in:
大哥大哥的大哥哥
2026-07-18 11:24:59 +08:00
338 changed files with 1029 additions and 681 deletions
+7 -23
View File
@@ -1,36 +1,19 @@
# 控糖消消乐独立功能包
此目录包含“控糖消消乐”无尽三消版的全部运行文件,可以整体复制到另一个 uni-app Vue 3 项目的 `tongji/endless-game/`
此目录包含“控糖消消乐”无尽三消版的页面与运行代码。食品图片由远端 COS 提供,图标组件复用 `tongji` 分包的公共组件
## 目录内容
- `index.vue`:页面、棋盘算法、关卡主题、任务、道具、三/四/五连奖励和适老化交互。
- `game-endless.scss`:完整页面与动画样式。
- `components/TongjiIcon.vue`:本功能包使用的图标组件。
- `composables/useGameAuth.js`:复用主小程序账号并处理 token 过期重登。
- `composables/useGamePlatform.js`:周榜、成绩补传与微信分享的平台连接层。
- `composables/useGameSfx.js`:滑动、掉落、消除、连击和大奖音效。
- `utils/svgDataUrl.js`:图标编码工具。
- `assets/food/`:本游戏使用的全部食品与驼乳粉图片。
- `viteAssetOutput.js`:把食品图输出到本功能分包,避免增加微信小程序主包体积。
## 迁移步骤
1. 整体复制 `endless-game` 文件夹到目标项目的 `tongji/` 目录。
2. 在目标项目 `vite.config` 中引入素材输出规则,并放到 `build.rollupOptions.output`
```js
import { endlessGameAssetFileNames } from './tongji/endless-game/viteAssetOutput.js'
// defineConfig 内
build: {
rollupOptions: {
output: { assetFileNames: endlessGameAssetFileNames }
}
}
```
3. 在目标项目 `pages.json``tongji` 分包 `pages` 数组中加入:
1. 复制 `endless-game` 文件夹到目标项目的 `tongji/` 目录,并确保目标项目同时提供 `tongji/components/TongjiIcon.vue``tongji/utils/svgDataUrl.js`
2. 在目标项目 `pages.json``tongji` 分包 `pages` 数组中加入
```json
{
@@ -46,14 +29,15 @@ build: {
}
```
4. 使用 `/tongji/endless-game/index` 打开游戏。
5. 微信小程序后台需要允许音频域名:`https://gz-1349751149.cos.ap-guangzhou.myqcloud.com`
3. 使用 `/tongji/endless-game/index` 打开游戏。
4. 微信小程序后台需要`https://gz-1349751149.cos.ap-guangzhou.myqcloud.com` 配置为合法的音频与图片下载域名
## 说明
- 旧版“糖分突袭”页面及其独占依赖已经移除,项目只注册 `/tongji/endless-game/index` 这一款小游戏。
- 食品图片固定使用 `https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/games/food/` 前缀,文件名必须与 `FOODS` 配置 key 的大小写保持一致。
- 连消小目标按“连消×2”累计 2 次;连续 6 次普通消除没有连消时,下一次掉落会提供连消机会。“连消×3”保留为额外积分、驼乳粉与撒花惊喜,不阻挡主线任务。
- 横向 3 个与竖向 3 个相交形成 L/T 形五消时,会在交点生成带高对比 L 标记的范围爆破棋子;该棋子再次被消除时清除周围九格。
- 进入游戏时展示“本周 7 人同行榜”,包含真实平台昵称、前后名次、差距提示和可切换的同行/亲友鼓励;顶部“本周同行”可再次打开。后端部署方式见 `PLATFORM_INTEGRATION.md`
- 本功能包依赖 uni-app Vue 3`@dcloudio/uni-app`,不依赖当前项目其他业务组件。
- 本功能包依赖 uni-app Vue 3`@dcloudio/uni-app` 以及 `tongji` 分包内的公共 `TongjiIcon` 组件。
- 食物风险等级和科普文案集中在 `index.vue``FOODS` 配置中,正式上线前仍需由医院医生审核。
Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

@@ -1,190 +0,0 @@
<template>
<view class="tj-icon-wrap" :class="[`tj-icon-wrap--${size}`]">
<image
v-if="!useFallback"
class="tj-icon"
:class="[`tj-icon--${name}`, `tj-icon--${size}`]"
:src="iconSrc"
mode="aspectFit"
@error="onImageError"
/>
<!-- <text
v-else
class="tj-icon-fallback"
:class="[`tj-icon-fallback--${size}`]"
:style="{ color }"
>{{ fallbackGlyph }}</text> -->
</view>
</template>
<script setup>
import { computed, ref } from 'vue'
import { svgToDataUrl } from '../utils/svgDataUrl.js'
const props = defineProps({
name: { type: String, required: true },
size: { type: String, default: 'md' },
color: { type: String, default: '#204E2B' }
})
const useFallback = ref(false)
/** Lucide 风格描边路径 */
const ICON_PATHS = {
view: '<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7Z"/><circle cx="12" cy="12" r="3"/>',
ticket: '<path d="M2 9a3 3 0 0 1 0 6v2a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-2a3 3 0 0 1 0-6V7a2 2 0 0 0-2-2H4a2 2 0 0 0-2 2Z"/><path d="M13 5v2"/><path d="M13 17v2"/><path d="M13 11v2"/>',
calendar: '<rect width="18" height="18" x="3" y="4" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/>',
flame: '<path d="M8.5 14.5A2.5 2.5 0 0 0 11 12c0-1.38-.5-2-1-3-1.072-2.143-.224-4.054 2-6 .5 2.5 2 4.9 4 6.5 2 1.6 3 3.5 3 5.5a7 7 0 1 1-14 0c0-1.153.433-2.294 1-3a2.5 2.5 0 0 0 2.5 2.5z"/>',
'check-circle': '<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><path d="m9 11 3 3L22 4"/>',
glucose: '<path d="M12 2.69l5.66 5.66a8 8 0 1 1-11.31 0z"/>',
heart: '<path d="M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.76 0-3 .5-4.5 2-1.5-1.5-2.74-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.3 1.5 4.05 3 5.5l7 7Z"/>',
activity: '<path d="M22 12h-4l-3 9L9 3l-3 9H2"/>',
users: '<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>',
user: '<path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/>',
person: '<path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/>',
send: '<path d="m22 2-7 20-4-9-9-4Z"/><path d="M22 2 11 13"/>',
'alert-triangle': '<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z"/><path d="M12 9v4"/><path d="M12 17h.01"/>',
minus: '<path d="M5 12h14"/>',
droplet: '<path d="M12 22a7 7 0 0 0 7-7c0-2-1-3.5-2.5-5.5C15 7 12 2 12 2S9 7 7.5 9.5 5 13 5 15a7 7 0 0 0 7 7z"/>',
sparkles: '<path d="m12 3-1.9 5.8L4 12l5.8 1.9L12 21l1.9-5.8L20 12l-5.8-1.9L12 3Z"/><path d="M5 3v4M19 17v4M3 5h4M17 19h4"/>',
trophy: '<path d="M6 9H4.5a2.5 2.5 0 0 1 0-5H6M18 9h1.5a2.5 2.5 0 0 0 0-5H18M4 22h16M10 14.66V17c0 .55-.47.98-.97 1.21C7.85 18.75 7 20 7 22M14 14.66V17c0 .55.47.98.97 1.21C16.15 18.75 17 20 17 22M18 2H6v7a6 6 0 0 0 12 0V2Z"/>',
share: '<path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8M16 6l-4-4-4 4M12 2v13"/>',
plus: '<path d="M5 12h14M12 5v14"/>',
refresh: '<path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8M3 3v5h5M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16M21 21v-5h-5"/>',
volume: '<path d="M11 5 6 9H2v6h4l5 4V5zM15.54 8.46a5 5 0 0 1 0 7.07M19.07 4.93a10 10 0 0 1 0 14.14"/>',
pause: '<rect width="4" height="16" x="14" y="4" rx="1"/><rect width="4" height="16" x="6" y="4" rx="1"/>',
play: '<polygon points="6 3 20 12 6 21 6 3"/>',
info: '<circle cx="12" cy="12" r="10"/><path d="M12 16v-4M12 8h.01"/>',
sun: '<circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41-1.41M17.66 6.34l1.41-1.41M6.34 4.93l1.41 1.41"/>',
moon: '<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"/>',
sunset: '<path d="M12 10V2M18.364 5.636l-2.12 2.12M5.636 18.364l2.12-2.12M22 18h-3M5 18H2M18.364 18.364l-2.12-2.12M5.636 5.636l2.12 2.12M16 12a4 4 0 1 1-8 0 4 4 0 0 1 8 0Z"/>',
mic: '<path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" x2="12" y1="19" y2="22"/>',
camera: '<path d="M14.5 4h-5L7 7H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-3l-2.5-3z"/><circle cx="12" cy="13" r="3"/>',
bulb: '<path d="M15 14c.2-1 .7-1.7 1.5-2.5 1-.9 1.5-2.2 1.5-3.5A6 6 0 0 0 6 8c0 1.3.5 2.6 1.5 3.5.8.8 1.3 1.5 1.5 2.5"/><path d="M9 18h6"/><path d="M10 22h4"/>',
leaf: '<path d="M11 20A7 7 0 0 1 9.8 6.1C15.5 5 17 4.48 19 2c1 2 2 4.18 2 8 0 5.5-4.78 10-10 10Z"/><path d="M2 21c0-3 1.85-5.36 5.08-6"/>',
utensils: '<path d="M3 2v7c0 1.1.9 2 2 2a2 2 0 0 0 2-2V2"/><path d="M7 2v20"/><path d="M21 15V2a5 5 0 0 0-3 4.5v6a2 2 0 0 0 2 2h1Z"/><path d="M18 15v7"/>',
egg: '<path d="M12 22c4.97 0 8-3.27 8-7.31C20 9.65 16.42 2 12 2S4 9.65 4 14.69C4 18.73 7.03 22 12 22Z"/>',
home: '<path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/>',
'plus-circle': '<circle cx="12" cy="12" r="10"/><path d="M8 12h8M12 8v8"/>',
'chevron-right': '<path d="m9 18 6-6-6-6"/>',
'chevron-left': '<path d="m15 18-6-6 6-6"/>',
check: '<path d="M20 6 9 17l-5-5"/>',
settings: '<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/>',
syringe: '<path d="m18 2 4 4"/><path d="m17 7 3-3"/><path d="M19 9 8.7 19.3c-1 1-2.5 1-3.4 0l-.6-.6c-1-1-1-2.5 0-3.4L15 5"/><path d="m9 11 4 4"/><path d="m5 19-3 3"/><path d="m14 4 6 6"/>',
zap: '<path d="M4 14a1 1 0 0 1-.78-1.63l9.9-10.2a.5.5 0 0 1 .86.46l-1.92 6.02A1 1 0 0 0 13 10h7a1 1 0 0 1 .78 1.63l-9.9 10.2a.5.5 0 0 1-.86-.46l1.92-6.02A1 1 0 0 0 11 14z"/>'
}
/** 图片加载失败时的 emoji 回退 */
const ICON_FALLBACK = {
view: '👁',
ticket: '🎫',
calendar: '📅',
flame: '🔥',
'check-circle': '✓',
glucose: '💧',
heart: '❤',
activity: '🏃',
users: '👥',
user: '👤',
person: '👤',
send: '➤',
'alert-triangle': '⚠',
minus: '—',
droplet: '💧',
sparkles: '✨',
trophy: '🏆',
share: '↗',
plus: '',
refresh: '↻',
volume: '🔊',
pause: '⏸',
play: '▶',
info: '!',
sun: '☀',
moon: '🌙',
sunset: '☀',
mic: '🎤',
camera: '📷',
bulb: '💡',
leaf: '🥬',
utensils: '🍴',
egg: '🍳',
home: '🏠',
'plus-circle': '⊕',
'chevron-right': '',
'chevron-left': '',
check: '✓',
settings: '⚙',
syringe: '💉',
zap: '⚡'
}
const strokeColor = computed(() => {
const c = String(props.color || '#204E2B').trim()
return /^#[0-9A-Fa-f]{3,8}$/.test(c) ? c : '#204E2B'
})
const iconSrc = computed(() => {
const path = ICON_PATHS[props.name] || ICON_PATHS.view
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="${strokeColor.value}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${path}</svg>`
return svgToDataUrl(svg)
})
const fallbackGlyph = computed(() => ICON_FALLBACK[props.name] || ICON_FALLBACK.view)
function onImageError() {
useFallback.value = true
}
</script>
<style lang="scss" scoped>
.tj-icon-wrap {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.tj-icon-wrap--sm {
width: 32rpx;
height: 32rpx;
}
.tj-icon-wrap--md {
width: 40rpx;
height: 40rpx;
}
.tj-icon-wrap--lg {
width: 48rpx;
height: 48rpx;
}
.tj-icon-wrap--xl {
width: 56rpx;
height: 56rpx;
}
.tj-icon {
display: block;
width: 100%;
height: 100%;
}
/* Lucide send 路径重心偏右上,微调使圆形按钮内视觉居中 */
.tj-icon--send {
transform: translate(-10%, 10%);
}
.tj-icon-fallback {
display: block;
line-height: 1;
font-weight: 700;
text-align: center;
}
.tj-icon-fallback--sm {
font-size: 24rpx;
}
.tj-icon-fallback--md {
font-size: 30rpx;
}
.tj-icon-fallback--lg {
font-size: 36rpx;
}
.tj-icon-fallback--xl {
font-size: 42rpx;
}
</style>
@@ -25,6 +25,13 @@ const SRC = {
* rate 不同可在同一素材上听出明显差异
*/
const SFX = {
// —— 基础素材别名(供分层音效直接调用)——
tap: { src: SRC.tap, volume: 0.32, rate: 1, pool: 3 },
pop: { src: SRC.pop, volume: 0.3, rate: 1, pool: 4 },
fanfare: { src: SRC.fanfare, volume: 0.56, rate: 1, pool: 2 },
blast: { src: SRC.blast, volume: 0.62, rate: 1, pool: 2 },
sparkle: { src: SRC.sparkle, volume: 0.5, rate: 1, pool: 2 },
// —— 交互 ——
select: { src: SRC.tap, volume: 0.32, rate: 1.05, pool: 3 },
deselect: { src: SRC.tap, volume: 0.22, rate: 0.82, pool: 2 },
@@ -1,38 +0,0 @@
import { readdirSync, readFileSync } from 'node:fs'
import { resolve } from 'node:path'
const VIRTUAL_ID = 'virtual:tongji-endless-food-images'
const RESOLVED_VIRTUAL_ID = `\0${VIRTUAL_ID}`
/**
* 将游戏食物图内联到 tongji 分包代码。
* 微信代码质量会累计整个代码包内的图片/音频体积;源 JPG 继续保留,便于后续增补和迁移。
*/
export function endlessGameFoodInlinePlugin() {
return {
name: 'tongji-endless-game-food-inline',
enforce: 'pre',
resolveId(id) {
return id === VIRTUAL_ID ? RESOLVED_VIRTUAL_ID : null
},
load(id) {
if (id !== RESOLVED_VIRTUAL_ID) return null
const foodDir = resolve(process.cwd(), 'tongji/endless-game/assets/food')
const images = Object.fromEntries(
readdirSync(foodDir)
.filter((fileName) => fileName.toLowerCase().endsWith('.jpg'))
.sort()
.map((fileName) => {
const filePath = resolve(foodDir, fileName)
this.addWatchFile(filePath)
const key = fileName.replace(/\.jpg$/i, '')
const dataUrl = `data:image/jpeg;base64,${readFileSync(filePath).toString('base64')}`
return [key, dataUrl]
})
)
return `export default ${JSON.stringify(images)}`
},
}
}
@@ -20,14 +20,18 @@
.eg-nav {
position: relative;
display: flex;
display: grid;
box-sizing: border-box;
grid-template-columns: 156rpx minmax(0, 1fr) 156rpx;
align-items: center;
justify-content: space-between;
column-gap: 12rpx;
padding-right: 28rpx;
padding-bottom: 16rpx;
padding-left: 28rpx;
}
.eg-nav-leading { display: flex; width: 156rpx; justify-content: flex-start; }
.eg-nav-btn {
display: flex;
width: 72rpx;
@@ -39,10 +43,11 @@
background: rgba(255,255,255,.82);
}
.eg-nav-actions { display: flex; width: 156rpx; justify-content: flex-end; gap: 10rpx; }
.eg-nav-actions { display: flex; width: 156rpx; flex-shrink: 0; justify-content: flex-end; gap: 10rpx; }
.eg-nav-btn--small { width: 68rpx; height: 68rpx; border-radius: 22rpx; }
.eg-title-wrap { position: absolute; left: 50%; display: flex; flex-direction: column; align-items: center; transform: translateX(-50%); }
.eg-title-wrap { display: flex; min-width: 0; flex-direction: column; align-items: center; }
.eg-title, .eg-subtitle { overflow: hidden; max-width: 100%; white-space: nowrap; text-overflow: ellipsis; }
.eg-title { color: #155e4b; font-size: 40rpx; font-weight: 800; }
.eg-subtitle { margin-top: 2rpx; color: #648278; font-size: 24rpx; }
.eg-content { padding: 0 16rpx 48rpx; }
+48 -10
View File
@@ -13,9 +13,11 @@
:style="piece.style"
>{{ piece.emoji }}</text>
</view>
<view class="eg-nav" :style="{ paddingTop: safeTop + 'px' }">
<view class="eg-nav-btn" @tap="goBack">
<TongjiIcon name="chevron-left" size="md" color="#155e4b" />
<view class="eg-nav" :style="navStyle">
<view class="eg-nav-leading">
<view class="eg-nav-btn" @tap="goBack">
<TongjiIcon name="chevron-left" size="md" color="#155e4b" />
</view>
</view>
<view class="eg-title-wrap">
<text class="eg-title">控糖消消乐</text>
@@ -299,11 +301,12 @@
<script setup>
import { computed, getCurrentInstance, ref } from 'vue'
import { onLoad, onShareAppMessage, onShareTimeline, onShow, onUnload } from '@dcloudio/uni-app'
import TongjiIcon from './components/TongjiIcon.vue'
import TongjiIcon from '../components/TongjiIcon.vue'
import { useGameSfx } from './composables/useGameSfx.js'
import { useGamePlatform } from './composables/useGamePlatform.js'
import { useGameAuth } from './composables/useGameAuth.js'
import FOOD_IMAGES from 'virtual:tongji-endless-food-images'
const FOOD_IMAGE_BASE_URL = 'https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/games/food/'
const ROWS = 5
const COLS = 4
@@ -371,7 +374,7 @@ const FOODS = {
milkTea: { name: '含糖奶茶', short: '奶茶', emoji: '🧋', color: 'orange', level: 'high', levelLabel: '高糖', high: true, replaceable: true, tip: '含糖奶茶可能同时含有较多添加糖和能量,应关注配方与饮用量。' },
camel: { name: '驼乳', short: '驼乳', emoji: '🥛', color: 'milk', level: 'prop', levelLabel: '道具', product: true }
}
Object.keys(FOODS).forEach((key) => { FOODS[key].img = FOOD_IMAGES[key] || '' })
Object.keys(FOODS).forEach((key) => { FOODS[key].img = `${FOOD_IMAGE_BASE_URL}${key}.jpg` })
const FOOD_GROUPS = [
{ title: '中式早餐', safe: ['egg', 'cucumber', 'corn', 'grainMantou'], danger: 'congee' },
{ title: '家常粥餐', safe: ['celery', 'mushroom', 'chicken', 'taro'], danger: 'centuryCongee' },
@@ -426,7 +429,7 @@ const comboDryTurns = ref(0)
const taskRewardVisible = ref(false)
const taskRewardKey = ref(0)
const statusText = ref('交换相邻食物,三个相同即可消除')
const safeTop = ref(20)
const navStyle = ref({ paddingTop: '20px' })
const touch = { x: 0, y: 0, index: -1 }
const dragIndex = ref(-1)
const dragTarget = ref(-1)
@@ -557,9 +560,36 @@ onShareTimeline(() => {
}
})
function initNavLayout() {
let paddingTop = 20
let paddingRight = uni.upx2px(28)
try {
const info = uni.getSystemInfoSync()
paddingTop = Number(info.safeAreaInsets?.top || info.statusBarHeight) || 20
// #ifdef MP-WEIXIN
const menu = typeof uni.getMenuButtonBoundingClientRect === 'function'
? uni.getMenuButtonBoundingClientRect()
: null
if (menu && menu.width > 0) {
paddingTop = Math.max(paddingTop, Number(menu.top) || 0)
paddingRight = Math.max(
paddingRight,
(Number(info.windowWidth) || 0) - Number(menu.left) + uni.upx2px(8)
)
}
// #endif
} catch (_) {}
navStyle.value = {
paddingTop: `${paddingTop}px`,
paddingRight: `${paddingRight}px`
}
}
onLoad((options = {}) => {
const info = uni.getSystemInfoSync()
safeTop.value = info.safeAreaInsets?.top || info.statusBarHeight || 20
initNavLayout()
bestScore.value = Number(uni.getStorageSync('tongji_endless_best') || 0)
incomingInviteCode = String(options.invite_code || '').trim()
startGame()
@@ -684,7 +714,15 @@ function startGame() {
}
function goBack() {
uni.navigateBack({ fail: () => uni.switchTab({ url: '/pages/index/index' }) })
const pages = getCurrentPages()
if (pages.length > 1) {
uni.navigateBack({
delta: 1,
fail: () => uni.redirectTo({ url: '/tongji/pages/weekly' })
})
return
}
uni.redirectTo({ url: '/tongji/pages/weekly' })
}
function showRules() {
@@ -1,49 +0,0 @@
/**
* 将 SVG 字符串转为小程序可用的 data URL(base64 在真机上更稳定)
*/
function utf8ToBytes(str) {
const encoded = encodeURIComponent(str)
const bytes = []
for (let i = 0; i < encoded.length; i++) {
if (encoded.charCodeAt(i) === 37) {
bytes.push(parseInt(encoded.substring(i + 1, i + 3), 16))
i += 2
} else {
bytes.push(encoded.charCodeAt(i))
}
}
return new Uint8Array(bytes)
}
function bytesToBase64(bytes) {
if (typeof uni !== 'undefined' && typeof uni.arrayBufferToBase64 === 'function') {
return uni.arrayBufferToBase64(bytes.buffer)
}
if (typeof btoa !== 'undefined') {
let binary = ''
for (let i = 0; i < bytes.length; i++) {
binary += String.fromCharCode(bytes[i])
}
return btoa(binary)
}
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
let result = ''
for (let i = 0; i < bytes.length; i += 3) {
const a = bytes[i]
const b = i + 1 < bytes.length ? bytes[i + 1] : 0
const c = i + 2 < bytes.length ? bytes[i + 2] : 0
result += chars[a >> 2]
result += chars[((a & 3) << 4) | (b >> 4)]
result += i + 1 < bytes.length ? chars[((b & 15) << 2) | (c >> 6)] : '='
result += i + 2 < bytes.length ? chars[c & 63] : '='
}
return result
}
export function svgToDataUrl(svg) {
const normalized = String(svg || '').trim()
if (!normalized) return ''
const base64 = bytesToBase64(utf8ToBytes(normalized))
return `data:image/svg+xml;base64,${base64}`
}
@@ -1,33 +0,0 @@
const FOOD_ASSET_NAMES = new Set([
'apple.jpg', 'banana.jpg', 'blackCoffee.jpg', 'broccoli.jpg', 'brownRice.jpg',
'camel.jpg', 'celery.jpg', 'centuryCongee.jpg', 'chicken.jpg', 'coffee.jpg',
'congee.jpg', 'corn.jpg', 'cucumber.jpg', 'egg.jpg', 'eightCongee.jpg',
'grainMantou.jpg', 'greenBean.jpg', 'lettuce.jpg', 'mango.jpg', 'milk.jpg',
'milkTea.jpg', 'milletCongee.jpg', 'mushroom.jpg', 'noodleSoup.jpg', 'onion.jpg',
'orangeJuice.jpg', 'pepper.jpg', 'pineapple.jpg', 'potato.jpg', 'riceNoodleSoup.jpg',
'shrimp.jpg', 'soda.jpg', 'strawberry.jpg', 'taro.jpg', 'tea.jpg', 'tomato.jpg',
'udon.jpg', 'wonton.jpg'
])
/**
* 让游戏食品图进入 tongji 分包,避免增加微信小程序主包体积。
* 迁移到其他 uni-app 项目时,在 vite.config 中复用此函数即可。
*/
export function endlessGameAssetFileNames(assetInfo = {}) {
const candidates = [
assetInfo.name,
...(assetInfo.names || []),
assetInfo.originalFileName,
...(assetInfo.originalFileNames || [])
].filter(Boolean)
const isEndlessFood = candidates.some((value) => {
const normalized = String(value).replace(/\\/g, '/')
const basename = normalized.split('/').pop()
return normalized.includes('tongji/endless-game/assets/food/') || FOOD_ASSET_NAMES.has(basename)
})
return isEndlessFood
? 'tongji/endless-game/assets/food/[name].[hash][extname]'
: 'assets/[name].[hash][extname]'
}
+29 -9
View File
@@ -98,15 +98,27 @@
<view v-if="chartUseMockData" class="st-chart-foot">
<text class="st-chart-mock-hint">示例数据累计记录满 7 天后显示您的真实趋势</text>
</view>
<view class="st-game-entry st-game-entry--in-card" @click="goGamePage" style="margin: 0 20rpx 0 20rpx;">
<view class="st-game-entry-icon">
<TongjiIcon name="activity" size="sm" color="#006c49" />
<view class="st-game-entry-list">
<view class="st-game-entry st-game-entry--in-card" @click="goSugarRaidGamePage">
<view class="st-game-entry-icon">
<TongjiIcon name="activity" size="sm" color="#006c49" />
</view>
<view class="st-game-entry-body">
<text class="st-game-entry-title">识糖小课堂</text>
<text class="st-game-entry-sub">认一认哪些食物含糖高</text>
</view>
<TongjiIcon name="chevron-right" size="sm" color="#64748b" />
</view>
<view class="st-game-entry-body">
<text class="st-game-entry-title">识糖小课堂</text>
<text class="st-game-entry-sub">认一认哪些食物含糖高</text>
<view class="st-game-entry st-game-entry--in-card" @click="goEndlessGamePage">
<view class="st-game-entry-icon">
<TongjiIcon name="sparkles" size="sm" color="#006c49" />
</view>
<view class="st-game-entry-body">
<text class="st-game-entry-title">控糖消消乐</text>
<text class="st-game-entry-sub">无尽消除挑战控糖高分</text>
</view>
<TongjiIcon name="chevron-right" size="sm" color="#64748b" />
</view>
<TongjiIcon name="chevron-right" size="sm" color="#64748b" />
</view>
</view>
<view class="st-training-section">
@@ -1512,15 +1524,23 @@ async function goMorePage() {
uni.navigateTo({ url: buildMorePageUrl() })
}
function goGamePage() {
function openGamePage(url) {
uni.navigateTo({
url: '/tongji/endless-game/index',
url,
fail() {
uni.showToast({ title: '暂时无法打开游戏', icon: 'none' })
}
})
}
function goSugarRaidGamePage() {
openGamePage('/tongji/pages/game')
}
function goEndlessGamePage() {
openGamePage('/tongji/endless-game/index')
}
function navToUser() {
uni.redirectTo({ url: '/pages/user/user' })
}
@@ -177,7 +177,7 @@
transform: scale(0.98);
}
/* 糖分突袭游戏入口 */
/* 游戏入口 */
.weekly-page .st-game-entry {
margin-top: 24rpx;
@@ -388,8 +388,15 @@
}
/* 识糖入口嵌在趋势卡内:轻量分隔,避免大块留白 */
.weekly-page .st-chart-card .st-game-entry-list {
display: flex;
flex-direction: column;
gap: 12rpx;
margin: 12rpx 20rpx 0;
}
.weekly-page .st-chart-card .st-game-entry--in-card {
margin-top: 12rpx;
margin-top: 0;
padding: 16rpx 20rpx;
gap: 16rpx;
border-radius: 24rpx;
-10
View File
@@ -1,14 +1,11 @@
import { defineConfig } from 'vite'
import uni from '@dcloudio/vite-plugin-uni'
import Optimization from '@uni-ku/bundle-optimizer'
import { endlessGameAssetFileNames } from './tongji/endless-game/viteAssetOutput.js'
import { endlessGameFoodInlinePlugin } from './tongji/endless-game/foodInlinePlugin.js'
// uni-app 工程根目录就是源码目录(HBuilderX 兼容)
// 编译产物默认输出到 ./dist/<mode>/<platform>
export default defineConfig({
plugins: [
endlessGameFoodInlinePlugin(),
uni(),
Optimization({
enable: {
@@ -20,13 +17,6 @@ export default defineConfig({
logger: false,
}),
],
build: {
rollupOptions: {
output: {
assetFileNames: endlessGameAssetFileNames,
},
},
},
css: {
preprocessorOptions: {
scss: {