diff --git a/docker/sql/migrations/V5__mint_cost_config.sql b/docker/sql/migrations/V5__mint_cost_config.sql
new file mode 100644
index 0000000..7c98ac5
--- /dev/null
+++ b/docker/sql/migrations/V5__mint_cost_config.sql
@@ -0,0 +1,53 @@
+-- 铸造经济:按「第几次铸爱」阶梯扣水晶(见 docs/specs/2026-04-15-economic-system-design.md)
+-- 未建表/未插数时,资产服务查配置会得到 record not found,网关表现为 code 17。
+
+-- 用户在某 star 下的累计铸爱次数(CreateMintOrder 会读/写)
+CREATE TABLE IF NOT EXISTS public.user_mint_count (
+ id BIGSERIAL PRIMARY KEY,
+ user_id BIGINT NOT NULL,
+ star_id BIGINT NOT NULL,
+ mint_count INT NOT NULL DEFAULT 0,
+ revenue_boost_bps INT NOT NULL DEFAULT 0,
+ updated_at BIGINT NOT NULL,
+ CONSTRAINT uk_user_mint_count_user_star UNIQUE (user_id, star_id)
+);
+
+CREATE INDEX IF NOT EXISTS idx_user_mint_count_user ON public.user_mint_count USING btree (user_id);
+CREATE INDEX IF NOT EXISTS idx_user_mint_count_star ON public.user_mint_count USING btree (star_id);
+
+COMMENT ON TABLE public.user_mint_count IS '用户在某偶像下的累计铸爱次数与收益加成(基点)';
+
+-- 第 N 次铸爱对应扣费与保底概率(mint_count 唯一)
+CREATE TABLE IF NOT EXISTS public.mint_cost_config (
+ id BIGSERIAL PRIMARY KEY,
+ mint_count INT NOT NULL,
+ cost_crystal BIGINT NOT NULL,
+ probability BIGINT NOT NULL DEFAULT 0,
+ reward_type VARCHAR(50) DEFAULT NULL,
+ reward_value BIGINT NOT NULL DEFAULT 0,
+ description VARCHAR(255),
+ updated_at BIGINT NOT NULL,
+ CONSTRAINT uk_mint_cost_config_mint_count UNIQUE (mint_count)
+);
+
+COMMENT ON TABLE public.mint_cost_config IS '铸爱次数阶梯消耗与保底配置';
+
+INSERT INTO public.mint_cost_config (mint_count, cost_crystal, probability, reward_type, reward_value, description, updated_at)
+VALUES
+ (1, 2, 0, NULL, 0, '第1次', 1773322573872),
+ (2, 4, 0, NULL, 0, '第2次', 1773322573872),
+ (3, 8, 0, NULL, 0, '第3次', 1773322573872),
+ (4, 16, 0, NULL, 0, '第4次', 1773322573872),
+ (5, 32, 0, NULL, 0, '第5次', 1773322573872),
+ (6, 64, 0, NULL, 0, '第6次', 1773322573872),
+ (7, 128, 0, NULL, 0, '第7次', 1773322573872),
+ (8, 256, 0, NULL, 0, '第8次', 1773322573872),
+ (9, 512, 20, '收益提升', 5, '小保底', 1773322573872),
+ (10, 1024, 100, '收益提升', 5, '大保底', 1773322573872)
+ON CONFLICT (mint_count) DO UPDATE SET
+ cost_crystal = EXCLUDED.cost_crystal,
+ probability = EXCLUDED.probability,
+ reward_type = EXCLUDED.reward_type,
+ reward_value = EXCLUDED.reward_value,
+ description = EXCLUDED.description,
+ updated_at = EXCLUDED.updated_at;
diff --git a/docs/specs/2026-05-15-notification-system-design.md b/docs/specs/2026-05-15-notification-system-design.md
new file mode 100644
index 0000000..46a19d5
--- /dev/null
+++ b/docs/specs/2026-05-15-notification-system-design.md
@@ -0,0 +1,345 @@
+# 通知系统设计方案
+
+**日期**: 2026-05-15
+**状态**: 已确认
+**版本**: v1.0
+
+---
+
+## 1. 概述
+
+### 1.1 目标
+
+构建统一的通知系统,支持以下通知类型:
+- **点赞通知** - 用户点赞藏品时触发
+- **系统通知** - 后台运营人员/系统触发
+- **活动通知** - 系统活动事件触发
+
+### 1.2 设计原则
+
+- **统一存储** - 所有通知类型使用同一张表,通过 `type` 字段区分
+- **轻量服务** - Notification Service 只负责存储和查询,不包含业务逻辑
+- **扩展性** - JSON 字段存储类型特定的扩展数据,便于后续扩展新通知类型
+
+---
+
+## 2. 数据库设计
+
+### 2.1 通知主表
+
+```sql
+CREATE TABLE notifications (
+ id BIGSERIAL PRIMARY KEY,
+ user_id BIGINT NOT NULL, -- 接收通知的用户ID
+ star_id BIGINT NOT NULL, -- 数据隔离(star ID)
+ type VARCHAR(20) NOT NULL, -- 通知类型: like / system / activity
+ title VARCHAR(200) NOT NULL, -- 通知标题
+ content VARCHAR(500), -- 通知内容
+ data JSONB, -- 扩展数据(类型特定)
+ is_read BOOLEAN DEFAULT FALSE, -- 是否已读
+ is_deleted BOOLEAN DEFAULT FALSE, -- 是否删除(软删除)
+ created_at BIGINT NOT NULL, -- 创建时间(毫秒时间戳)
+ read_at BIGINT, -- 阅读时间(毫秒时间戳)
+
+ -- 索引
+ INDEX idx_notifications_user_type_created (user_id, star_id, type, created_at DESC),
+ INDEX idx_notifications_user_unread (user_id, star_id, is_read, created_at DESC)
+);
+```
+
+### 2.2 通知统计表
+
+用于快速查询未读数,支持 TabBar 角标显示。
+
+```sql
+CREATE TABLE notification_stats (
+ user_id BIGINT NOT NULL, -- 用户ID
+ star_id BIGINT NOT NULL, -- 数据隔离(star ID)
+ like_unread_count INT DEFAULT 0, -- 点赞通知未读数
+ system_unread_count INT DEFAULT 0, -- 系统通知未读数
+ activity_unread_count INT DEFAULT 0, -- 活动通知未读数
+ total_unread_count INT DEFAULT 0, -- 总未读数
+ updated_at BIGINT NOT NULL, -- 更新时间
+
+ PRIMARY KEY (user_id, star_id)
+);
+```
+
+> **注意**:`notification_stats` 使用 `(user_id, star_id)` 作为联合主键,支持多 star 场景下各 star 独立统计未读数。
+
+### 2.3 JSON data 字段示例
+
+```json
+// 点赞通知 (type: "like")
+{
+ "target_type": "asset",
+ "target_id": 123,
+ "actor_id": 456,
+ "actor_name": "张三",
+ "actor_avatar": "https://example.com/avatar/456.png",
+ "asset_title": "我的藏品",
+ "asset_cover": "https://example.com/asset/123/cover.png",
+ "star_id": 1
+}
+
+// 系统通知 (type: "system")
+{
+ "action_type": "url",
+ "action_url": "/pages/settings/detail",
+ "action_text": "查看详情",
+ "attachments": ["https://example.com/img1.jpg"]
+}
+
+// 活动通知 (type: "activity")
+{
+ "activity_id": 789,
+ "activity_title": "端午节活动",
+ "activity_cover": "https://example.com/activity/789/cover.png",
+ "reward_type": "badge",
+ "reward_name": "端午限定徽章",
+ "star_id": 1
+}
+```
+
+---
+
+## 3. 服务架构
+
+### 3.1 Notification Service 职责
+
+| 模块 | 职责 |
+|-----|-----|
+| Repository | 通知记录的 CRUD,查询列表 |
+| Service | 业务逻辑:写入通知、更新统计、查询未读 |
+| Provider | RPC 接口,供其他服务调用 |
+
+### 3.2 服务边界
+
+- **Notification Service** 只负责通知的存储和查询
+- **业务逻辑** 由各自的服务处理(如点赞由 Social Service 处理)
+- 其他服务在业务发生时调用 Notification Service 写入通知
+
+### 3.3 调用关系
+
+```
+┌─────────────────┐ ┌─────────────────────┐
+│ Social Service │────▶│ Notification Service │
+│ (点赞业务) │ │ (存储 + 查询) │
+└─────────────────┘ └─────────────────────┘
+ │ │
+ ▼ ▼
+┌─────────────────┐ ┌─────────────────────┐
+│ Asset Service │ │ PostgreSQL │
+│ (更新点赞数) │ │ notifications + │
+└─────────────────┘ │ notification_stats │
+ └─────────────────────┘
+```
+
+> **事务边界**:Notification Service 在数据库事务中同时写入 `notifications` 表和更新 `notification_stats` 表。
+
+---
+
+## 4. API 接口设计
+
+### 4.1 RPC 接口(供内部服务调用)
+
+| 方法 | 描述 | 参数 |
+|-----|------|------|
+| CreateNotification | 创建通知 | userID, starID, type, title, content, data |
+| GetNotifications | 查询通知列表 | userID, starID, type, page, pageSize |
+| GetUnreadCount | 获取未读数 | userID, starID |
+| MarkAsRead | 标记已读 | notificationID, userID, starID |
+| MarkAllAsRead | 全部标已读 | userID, starID, type |
+| DeleteNotification | 删除通知 | notificationID, userID, starID |
+
+> **说明**:所有接口都需要传入 `starID`,确保数据隔离。
+
+### 4.2 HTTP 接口(供前端调用)
+
+| 方法 | 路径 | 描述 |
+|-----|------|------|
+| GET | /api/v1/notifications | 查询通知列表 |
+| GET | /api/v1/notifications/unread-count | 获取未读数 |
+| POST | /api/v1/notifications/:id/read | 标记单条已读 |
+| POST | /api/v1/notifications/read-all | 全部标已读 |
+| DELETE | /api/v1/notifications/:id | 删除通知 |
+
+> **说明**:所有 HTTP 接口都需要通过 Header 或 Cookie 传递 `star_id` 进行数据隔离。
+
+### 4.3 查询参数
+
+```
+GET /api/v1/notifications?type=like&tab=today&page=1&pageSize=20
+
+参数说明:
+- type: 通知类型 (like / system / activity)
+- tab: 查询tab (today / history)
+- page: 页码
+- pageSize: 每页数量
+- star_id: 数据隔离 ID(从 Header 或上下文获取)
+```
+
+---
+
+## 5. 功能详细设计
+
+### 5.1 点赞通知生成流程
+
+```
+1. 用户点赞 → Social Service 处理
+2. Social Service 调用 Asset Service RPC 更新点赞数
+3. Social Service 调用 Notification Service CreateNotification
+4. Notification Service(在事务中完成):
+ - 写入 notifications 表
+ - 更新 notification_stats 表 (+1 未读数,INSERT ... ON CONFLICT DO UPDATE 处理首次创建)
+5. 返回成功响应
+```
+
+> **事务保证**:创建通知和更新统计必须在同一个事务中,确保数据一致性。
+
+### 5.2 查询逻辑
+
+```
+今日 Tab:
+ WHERE type = 'like' AND user_id = ? AND star_id = ? AND created_at >= 今日零点
+ ORDER BY created_at DESC
+
+历史 Tab:
+ WHERE type = 'like' AND user_id = ? AND star_id = ? AND created_at < 今日零点
+ ORDER BY created_at DESC
+```
+
+> **说明**:所有查询都需要 `star_id` 确保数据隔离。
+
+### 5.3 未读数统计
+
+- 每次创建通知时,在同一事务中更新 `notification_stats` 表对应类型的未读数
+- 使用 `INSERT ... ON CONFLICT DO UPDATE` 确保首次创建时自动插入记录
+- 每次标记已读时,减少对应类型的未读数
+- 批量标已读时,重置对应类型的未读数为 0
+- 删除通知时,同步减少未读数
+
+### 5.4 通知直达
+
+| 通知类型 | 跳转逻辑 |
+|---------|---------|
+| like | 跳转藏品详情页: `/pages/asset/detail?id={target_id}` |
+| system | 跳转 `data.action_url` 指定页面 |
+| activity | 跳转活动详情页: `/pages/activity/detail?id={activity_id}` |
+
+---
+
+## 6. 支持的功能
+
+### 6.1 TabBar 角标
+- 查询 `notification_stats` 表获取各类型未读数
+- 前端展示: 点赞未读数、系统未读数、活动未读数
+
+### 6.2 今日/历史 Tab
+- 今日: 当天 00:00:00 至今的点赞通知
+- 历史: 更早的点赞通知
+
+### 6.3 通知直达
+- 点击通知跳转到对应详情页面
+
+### 6.4 批量操作
+- 全部标为已读(支持按类型)
+- 删除历史通知(软删除)
+
+---
+
+## 7. 记录合并规则
+
+- **不合并记录** - 同一用户对同一藏品当天多次点赞,每条点赞都产生独立通知
+- 例如: 用户A当天点赞藏品B 3次,产生3条独立记录
+
+---
+
+## 8. 数据一致性保证
+
+### 8.1 事务边界
+
+创建通知和更新统计必须在同一个数据库事务中完成:
+
+```go
+func (s *NotificationService) CreateNotification(ctx context.Context, ...) error {
+ return s.db.Transaction(func(tx *sql.Tx) error {
+ // 1. 写入 notifications 表
+ _, err := tx.Exec("INSERT INTO notifications (...) VALUES (...)", ...)
+ if err != nil {
+ return err
+ }
+
+ // 2. 更新 notification_stats 表(使用 INSERT ... ON CONFLICT DO UPDATE)
+ _, err = tx.Exec(`
+ INSERT INTO notification_stats (user_id, star_id, like_unread_count, updated_at)
+ VALUES (?, ?, 1, ?)
+ ON CONFLICT (user_id, star_id) DO UPDATE SET
+ like_unread_count = notification_stats.like_unread_count + 1,
+ total_unread_count = notification_stats.total_unread_count + 1,
+ updated_at = ?
+ `, userID, starID, now, now)
+ return err
+ })
+}
+```
+
+### 8.2 点赞通知防重复
+
+由于藏品可下架再上架,同一用户对同一藏品当天可能产生多条点赞通知。为避免重复:
+
+```sql
+-- 为点赞通知添加唯一约束(可选,取决于业务需求)
+CREATE UNIQUE INDEX idx_like_notification_daily
+ON notifications (user_id, star_id, type, data->>'target_id', data->>'actor_id', date_trunc('day', to_timestamp(created_at/1000)));
+```
+
+> **说明**:如果业务上允许同一天多条点赞通知(每条都展示),则不需要此唯一约束。
+
+### 8.3 补偿机制
+
+如果事务提交后 RPC 调用方未收到响应,调用方会重试。此时:
+- 使用唯一约束 `UNIQUE (user_id, star_id, type, target_type, target_id, actor_id, date)` 防止重复创建点赞通知
+- 或者通过幂等性设计(如每次点赞生成唯一 notification_id)处理重复
+
+---
+
+## 9. 项目结构
+
+```
+services/notificationService/
+├── main.go
+├── repository/
+│ ├── notification_repository.go -- 通知 CRUD
+│ └── notification_stats_repository.go -- 统计更新
+├── service/
+│ └── notification_service.go -- 业务逻辑 + 事务处理
+├── provider/
+│ └── notification_provider.go -- RPC 接口
+├── model/
+│ └── notification.go -- 数据模型
+└── client/
+ └── (供其他服务调用的客户端,如需要)
+```
+
+---
+
+## 10. 后续扩展
+
+- 支持评论通知 (type: "comment")
+- 支持 @提及通知 (type: "mention")
+- 支持推送功能(实时通知)
+
+---
+
+## 11. 确认点
+
+- [x] 统一通知表存储所有类型
+- [x] 独立 Notification Service
+- [x] 支持 star_id 数据隔离
+- [x] 支持今日/历史 Tab 查询
+- [x] 不合并点赞记录
+- [x] 支持未读数统计(事务保证一致性)
+- [x] 支持通知直达
+- [x] 支持批量操作
+- [x] INSERT ... ON CONFLICT DO UPDATE 处理首次创建统计
\ No newline at end of file
diff --git a/frontend/App.vue b/frontend/App.vue
index ec9a8e6..b660703 100644
--- a/frontend/App.vue
+++ b/frontend/App.vue
@@ -38,18 +38,18 @@ export default {
font-display: swap;
}
- /* 引入圆体字体 */
- @font-face {
- font-family: 'yt';
- src: url('/static/fonts/经典圆体简.ttf') format('truetype');
- font-weight: normal;
- font-style: normal;
- font-display: swap;
- }
+ /* 圆体 JDLTYuanTiJian.ttf 在部分 Android WebView 上报 OTS/cmap 解析失败,暂不 @font-face 加载,避免控制台告警与渲染异常 */
/* 全局字体设置 */
body {
- font-family: 'yt', sans-serif;
+ font-family:
+ -apple-system,
+ BlinkMacSystemFont,
+ 'PingFang SC',
+ 'Hiragino Sans GB',
+ 'Microsoft YaHei',
+ 'Noto Sans SC',
+ sans-serif;
}
/* App 容器 */
diff --git a/frontend/components/ConfirmModal.vue b/frontend/components/ConfirmModal.vue
index da309a6..1fb90e5 100644
--- a/frontend/components/ConfirmModal.vue
+++ b/frontend/components/ConfirmModal.vue
@@ -24,11 +24,13 @@
-
+
+
+
diff --git a/frontend/components/lenticular/LenticularCard.vue b/frontend/components/lenticular/LenticularCard.vue
new file mode 100644
index 0000000..9ee5c85
--- /dev/null
+++ b/frontend/components/lenticular/LenticularCard.vue
@@ -0,0 +1,352 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ↻
+ {{ tiltHintText }}
+ 叠化近似预览(倾斜或拖动体验光栅效果)
+
+
+
+
+
+
+
+
diff --git a/frontend/composables/useLenticularPreview.js b/frontend/composables/useLenticularPreview.js
new file mode 100644
index 0000000..69375a8
--- /dev/null
+++ b/frontend/composables/useLenticularPreview.js
@@ -0,0 +1,155 @@
+/**
+ * 光栅卡预览:触摸模拟倾斜 + LenticularEngine(不启动硬件传感器)
+ * @param {import('vue').Ref} layersRef 图层配置(reactive/ref 均可被 .value 读取时用 ref)
+ */
+import { reactive, ref, watch, onMounted, onUnmounted } from 'vue'
+import { LenticularEngine, DEFAULT_PHYSICS } from '@/utils/lenticular-engine.js'
+
+function clamp(v, min, max) {
+ return Math.max(min, Math.min(max, v))
+}
+
+export function useLenticularPreview(layersRef) {
+ const physics = reactive({ ...DEFAULT_PHYSICS })
+ physics.gyroSimEnabled = false
+
+ const sensorData = ref({ gamma: 0, beta: 0, timestamp: Date.now() })
+ const source = ref('simulation')
+
+ function simulate(x, _y) {
+ sensorData.value = {
+ gamma: clamp(x, -1, 1),
+ beta: 0,
+ timestamp: Date.now(),
+ }
+ }
+
+ function relax(factor = 0.85) {
+ sensorData.value = {
+ gamma: sensorData.value.gamma * factor,
+ beta: 0,
+ timestamp: Date.now(),
+ }
+ }
+
+ const engine = new LenticularEngine(physics)
+ const initial = layersRef.value || layersRef
+ engine.setLayers(Array.isArray(initial) ? initial : [])
+
+ const layerTransforms = ref({})
+
+ function rebuildTransformsFromLayers(ls) {
+ const prev = layerTransforms.value
+ const next = {}
+ for (const l of ls) {
+ const p = prev[l.id]
+ next[l.id] =
+ p != null ? p : { x: 0, y: 0, opacity: l.opacity }
+ }
+ layerTransforms.value = next
+ }
+
+ rebuildTransformsFromLayers(engine.layers.length ? engine.layers : layersRef.value || [])
+
+ const stripeRender = ref({
+ phaseShift: 0,
+ shares: [1 / 3, 1 / 3, 1 / 3],
+ pitchPx: DEFAULT_PHYSICS.lenticularPitchPx,
+ prevLayerGhost: null,
+ })
+
+ let rafId = null
+ const nextFrame =
+ typeof requestAnimationFrame === 'function'
+ ? (cb) => requestAnimationFrame(cb)
+ : (cb) => setTimeout(cb, 16)
+ const cancelFrame =
+ typeof cancelAnimationFrame === 'function'
+ ? (id) => cancelAnimationFrame(id)
+ : (id) => clearTimeout(id)
+
+ function getLayersArray() {
+ const v = layersRef.value !== undefined ? layersRef.value : layersRef
+ return Array.isArray(v) ? v : []
+ }
+
+ function tick() {
+ try {
+ const ls = getLayersArray()
+ const renderState = engine.feedSimulatedTilt(sensorData.value.gamma, sensorData.value.beta)
+ const next = {}
+ for (const layer of ls) {
+ const offset = renderState.layerOffsets.get(layer.id)
+ const opacity = renderState.layerOpacities.get(layer.id)
+ next[layer.id] = {
+ x: offset != null ? offset.x : 0,
+ y: offset != null ? offset.y : 0,
+ opacity: opacity != null ? opacity : layer.opacity,
+ }
+ }
+ layerTransforms.value = next
+ stripeRender.value = {
+ phaseShift: renderState.stripePhaseShift,
+ shares: [...renderState.stripShares],
+ pitchPx: renderState.lenticularPitchPx,
+ prevLayerGhost: renderState.prevLayerGhost,
+ }
+ } catch (e) {
+ console.error('[useLenticularPreview] tick failed', e)
+ }
+ rafId = nextFrame(tick)
+ }
+
+ function startRenderLoop() {
+ if (rafId != null) return
+ rafId = nextFrame(tick)
+ }
+
+ function stopRenderLoop() {
+ if (rafId != null) {
+ cancelFrame(rafId)
+ rafId = null
+ }
+ }
+
+ watch(
+ layersRef,
+ (ls) => {
+ const arr = Array.isArray(ls) ? ls : []
+ engine.setLayers(arr)
+ rebuildTransformsFromLayers(arr)
+ },
+ { deep: true, immediate: true }
+ )
+
+ onMounted(() => {
+ startRenderLoop()
+ })
+
+ onUnmounted(() => {
+ stopRenderLoop()
+ })
+
+ const gyro = {
+ sensorData,
+ source,
+ simulate,
+ relax,
+ start: () => {},
+ stop: () => {
+ source.value = 'simulation'
+ },
+ }
+
+ return {
+ physics,
+ layerTransforms,
+ stripeRender,
+ gyro,
+ simulate,
+ relax,
+ engine,
+ startRenderLoop,
+ stopRenderLoop,
+ }
+}
diff --git a/frontend/composables/useLenticularStudioTilt.js b/frontend/composables/useLenticularStudioTilt.js
new file mode 100644
index 0000000..2a0a636
--- /dev/null
+++ b/frontend/composables/useLenticularStudioTilt.js
@@ -0,0 +1,768 @@
+/**
+ * 光栅卡工作室倾斜驱动:App 优先 imengyu-UniAndroidGyro(DCloud 插件 id=6237),否则加速度计。
+ *
+ * 与插件约定对齐(官方说明):
+ * - 模块:`imengyu-UniAndroidGyro-GyroModule`,`uni.requireNativePlugin`。
+ * - `startGyro`:首包回调仅 success/errMsg/notSupport(**无 x/y/z 角度属正常**);持续数据需 **`getGyroValue` 轮询**(插件官方示例);勿把首包当「无参数」故障。
+ * - `startGyroWithCallback`:部分自定义基座 + HX SDK 组合下长期收不到角度帧;本实现以 **startGyro + 轮询** 为主,WithCallback 为兜底。
+ * - 轮询:插件若同时提供 `getGyroValue` 与 `getGyroValueSync`,**优先异步**(与官方示例一致);部分环境下 Sync 长期无 x/y/z,易触发看门狗回退。
+ * - 再次开启前须 `getGyroStarted`;若已在监听则先 `stopGyro`(不可重复开启)。
+ * - 页面进入后立即监听前宜延时再 start(官方示例约 100ms)。
+ * - 插件返回的 x/y/z 为**当前各轴角度(度)**,与「进入光栅卡时采样的基准角」做差得到相对姿态;离散档位应使用**与轴向切换无关**的标量(如 max(|Δx|,|Δy|,|Δz|)),避免「谁幅度大跟谁」在临界区来回跳轴导致画面乱切。
+ * - 原生陀螺看门狗:按「**连续无有效角度帧**」计时(每来一帧即重置),避免固定短超时与晚首包 / `getGyroStarted`→`stopGyro`→`kick` 慢链冲突;超时仍无帧再回退加速度计。
+ *
+ * @see https://ext.dcloud.net.cn/plugin?id=6237
+ */
+
+const NATIVE_PLUGIN_ID = 'imengyu-UniAndroidGyro-GyroModule'
+
+/** iOS 等环境下布尔可能为字符串 `'true'` */
+function isTruthyFlag(v) {
+ return v === true || v === 'true'
+}
+
+function hasAnglePayload(res) {
+ if (!res || typeof res !== 'object') return false
+ /** 插件首包常无 x/y/z;有数值(含 0)才视为角度帧 */
+ return [res.x, res.y, res.z].some((v) => Number.isFinite(Number(v)))
+}
+
+/** 无角度包时:仅明确失败才放弃原生;success 缺省不等同于失败(部分 ROM 首包字段不全) */
+function isExplicitGyroHandshakeFailure(res) {
+ if (!res || typeof res !== 'object') return false
+ if (isTruthyFlag(res.notSupport)) return true
+ if (res.success === false || res.success === 'false') return true
+ if (res.success === '') return true
+ return false
+}
+
+// #ifdef APP-PLUS
+function tryRequireImengyuGyro() {
+ try {
+ if (typeof uni === 'undefined' || typeof uni.requireNativePlugin !== 'function') return null
+ const mod = uni.requireNativePlugin(NATIVE_PLUGIN_ID)
+ const okPoll =
+ mod &&
+ typeof mod.startGyro === 'function' &&
+ typeof mod.stopGyro === 'function' &&
+ (typeof mod.getGyroValue === 'function' || typeof mod.getGyroValueSync === 'function')
+ const okCb =
+ mod &&
+ typeof mod.startGyroWithCallback === 'function' &&
+ typeof mod.stopGyro === 'function'
+ if (okPoll || okCb) {
+ return mod
+ }
+ } catch (e) {
+ console.warn('[useLenticularStudioTilt] requireNativePlugin failed', e)
+ }
+ return null
+}
+// #endif
+// #ifndef APP-PLUS
+function tryRequireImengyuGyro() {
+ return null
+}
+// #endif
+
+/** 基线帧数 */
+const ACCEL_BASELINE_FRAMES = 10
+const INSTANT_FULL_RAD = 0.32
+const MAX_STEP_RAD = 0.48
+const STEP_GAIN = 2.15
+const SIN_PHASE_SCALE = 1.18
+const BLEND_INSTANT = 0.38
+
+/** 原生插件:x/y/z 均为角度(度);进入页面后先采若干帧算**基准角**(与官方「先 start 再轮询 getGyroValue」一致) */
+const NATIVE_BASELINE_FRAMES = 12
+const NATIVE_FULL_DEG = 10
+
+/** 加速度计直连 + 离散档位:进入后多帧圆均值定「水平」基准,避免首帧抖动 */
+const STUDIO_ACCEL_BASELINE_FRAMES = 12
+
+/** 连续无有效 x/y/z 角度帧则回退加速度计(ms);覆盖 getGyroStarted→stopGyro→kick 慢链与晚首包 */
+const NATIVE_GYRO_STALL_FALLBACK_MS = 14000
+
+function deltaDeg(value, base) {
+ const v = Number(value)
+ const b = Number(base)
+ if (!Number.isFinite(v) || !Number.isFinite(b)) return 0
+ let d = v - b
+ while (d > 180) d -= 360
+ while (d < -180) d += 360
+ return d
+}
+
+/** 取与基线差最大的轴,避免握持方向不同导致「陀螺仪没反应」 */
+function pickDominantTiltDelta(dx, dy, dz) {
+ const ax = Math.abs(dx)
+ const ay = Math.abs(dy)
+ const az = Math.abs(dz)
+ if (ax >= ay && ax >= az) return dx
+ if (ay >= az) return dy
+ return dz
+}
+
+/** 相对基准的最大欧拉偏差(度),左右/多轴合成时仍单调,且不会在临界区因「换轴」突变符号 */
+function maxAbsTiltDeltaDeg(dx, dy, dz) {
+ return Math.max(Math.abs(dx), Math.abs(dy), Math.abs(dz))
+}
+
+/**
+ * 根据进入后采集的样本,选「抖动范围最大」的轴作为连续跟手的倾角轴(基线期手持微动时仍稳定)。
+ * @param {{ x: number, y: number, z: number }[]} samples
+ * @returns {0|1|2}
+ */
+function inferPrimaryTiltAxisFromSamples(samples) {
+ if (!samples || samples.length < 2) return 1
+ let bestAxis = 1
+ let bestSpread = -1
+ for (let axis = 0; axis < 3; axis++) {
+ const vals = samples.map((s) => [s.x, s.y, s.z][axis])
+ const mn = Math.min(...vals)
+ const mx = Math.max(...vals)
+ const spread = mx - mn
+ if (spread > bestSpread) {
+ bestSpread = spread
+ bestAxis = axis
+ }
+ }
+ /* 几乎完全静止:竖屏常见左右倾角在 y */
+ if (bestSpread < 0.35) return 1
+ return bestAxis
+}
+
+function circularMeanRad(rads) {
+ if (!rads.length) return 0
+ let sx = 0
+ let sy = 0
+ for (const r of rads) {
+ sx += Math.sin(r)
+ sy += Math.cos(r)
+ }
+ return Math.atan2(sx / rads.length, sy / rads.length)
+}
+
+function majorityPlane(votes) {
+ const cnt = { xz: 0, xy: 0, yz: 0 }
+ for (const v of votes) cnt[v] = (cnt[v] || 0) + 1
+ if (cnt.xz >= cnt.xy && cnt.xz >= cnt.yz) return 'xz'
+ if (cnt.xy >= cnt.yz) return 'xy'
+ return 'yz'
+}
+
+function detectGravityPlane(nx, ny, nz) {
+ const ax = Math.abs(nx)
+ const ay = Math.abs(ny)
+ const az = Math.abs(nz)
+ if (ay >= ax && ay >= az) return 'xz'
+ if (az >= ax && az >= ay) return 'xy'
+ return 'yz'
+}
+
+function uvForPlane(nx, ny, nz, mode) {
+ if (mode === 'xz') return { u: nx, v: nz }
+ if (mode === 'xy') return { u: nx, v: ny }
+ return { u: ny, v: nz }
+}
+
+/**
+ * @param {object} opts
+ * @param {(x: number, y: number) => void} opts.simulate
+ * @param {(tiltMagDeg: number) => void} [opts.simulateFromSignedDegrees] 若提供:用相对进入时基准的**倾角标量(度)**驱动预览(由页面做离散档位等);原生/加速度计侧会喂入非负幅度为主
+ * @param {import('vue').Ref} opts.gyroSourceLabel
+ * @param {boolean} [opts.useStudioAccelDirect] 光栅工作室:加速度计用重力投影直连(免原 warmup)
+ * @param {() => void} [opts.onTiltDriverFallback] 从原生陀螺失败/超时回退到加速度计时调用(用于重置离散档位等 UI 状态)
+ */
+export function useLenticularStudioTilt(opts) {
+ const {
+ simulate,
+ simulateFromSignedDegrees,
+ gyroSourceLabel,
+ useStudioAccelDirect = false,
+ onTiltDriverFallback,
+ } = opts
+
+ let mode = /** @type {'native'|'accel'} */ ('accel')
+ let gyroModule = null
+ let nativeStartTimer = null
+ let nativeWatchdogTimer = null
+ let gyroCbGuardTimer = null
+ let nativePollTimer = null
+ /** @type {{ x: number, y: number, z: number }[]} */
+ let nativeTrSamples = []
+ let nativeBase = { x: 0, y: 0, z: 0 }
+ let nativeBaselineReady = false
+ let nativeSmoothed = 0
+ /** 连续模式下与基线样本推断的主倾角轴(0=x,1=y,2=z),避免每帧在 x/y 间抢主导 */
+ let nativeLockedAxisIdx = 1
+ /** 递增以丢弃 stop 之后的原生回调 / 延时任务 */
+ let tiltGen = 0
+ /** 原生:每次收到角度帧时重置该计时器;超时仍无帧则回退加速度计 */
+ let scheduleNativeGyroStallFallback = /** @type {null | (() => void)} */ (null)
+
+ /** 加速度计直连:多帧采样缓冲,填满后取圆均值为基准角 */
+ let studioAccelBaselineRads = []
+ /** 加速度计直连模式下「校零」后的基准(弧度),仅与 simulateFromSignedDegrees 联用 */
+ let studioAccelBaseRad = null
+
+ let accelHandler = null
+ let accelSmoothed = 0
+ let rollAccum = 0
+ let prevCu = null
+ let prevCv = null
+
+ let tiltCal = {
+ lockedMode: null,
+ planeVotes: [],
+ sumU: 0,
+ sumV: 0,
+ count: 0,
+ ready: false,
+ bu: 1,
+ bv: 0,
+ }
+
+ function resetAccelCalibration() {
+ tiltCal = {
+ lockedMode: null,
+ planeVotes: [],
+ sumU: 0,
+ sumV: 0,
+ count: 0,
+ ready: false,
+ bu: 1,
+ bv: 0,
+ }
+ }
+
+ function resetNativeBaseline() {
+ nativeTrSamples = []
+ nativeBase = { x: 0, y: 0, z: 0 }
+ nativeBaselineReady = false
+ nativeSmoothed = 0
+ nativeLockedAxisIdx = 1
+ }
+
+ function resetStudioAccelBaseline() {
+ studioAccelBaseRad = null
+ studioAccelBaselineRads = []
+ }
+
+ function accelToRelativeTilt01(ax, ay, az) {
+ const gMag = Math.hypot(ax, ay, az)
+ if (gMag < 0.18) return null
+ const nx = ax / gMag
+ const ny = ay / gMag
+ const nz = az / gMag
+
+ if (!tiltCal.ready) {
+ const m = detectGravityPlane(nx, ny, nz)
+ if (tiltCal.planeVotes.length < 5) tiltCal.planeVotes.push(m)
+ if (tiltCal.planeVotes.length >= 5 && tiltCal.lockedMode == null) {
+ tiltCal.lockedMode = majorityPlane(tiltCal.planeVotes)
+ tiltCal.sumU = 0
+ tiltCal.sumV = 0
+ tiltCal.count = 0
+ }
+ if (tiltCal.lockedMode == null) return { warmup: true }
+ const { u, v } = uvForPlane(nx, ny, nz, tiltCal.lockedMode)
+ const h = Math.hypot(u, v)
+ if (h < 0.035) return { warmup: true }
+ tiltCal.sumU += u / h
+ tiltCal.sumV += v / h
+ tiltCal.count += 1
+ if (tiltCal.count >= ACCEL_BASELINE_FRAMES) {
+ const bh = Math.hypot(tiltCal.sumU, tiltCal.sumV) || 1
+ tiltCal.bu = tiltCal.sumU / bh
+ tiltCal.bv = tiltCal.sumV / bh
+ tiltCal.ready = true
+ }
+ return { warmup: true }
+ }
+
+ const { u, v } = uvForPlane(nx, ny, nz, tiltCal.lockedMode)
+ const h = Math.hypot(u, v)
+ if (h < 0.028) return null
+ const cu = u / h
+ const cv = v / h
+ const sinD = cu * tiltCal.bv - cv * tiltCal.bu
+ const cosD = cu * tiltCal.bu + cv * tiltCal.bv
+ const deltaRad = Math.atan2(sinD, cosD)
+ return { warmup: false, cu, cv, deltaRad }
+ }
+
+ function stopNative() {
+ tiltGen++
+ if (nativeWatchdogTimer != null) {
+ try {
+ clearTimeout(nativeWatchdogTimer)
+ } catch (e) {
+ /* noop */
+ }
+ nativeWatchdogTimer = null
+ }
+ if (gyroCbGuardTimer != null) {
+ try {
+ clearTimeout(gyroCbGuardTimer)
+ } catch (e) {
+ /* noop */
+ }
+ gyroCbGuardTimer = null
+ }
+ if (nativePollTimer != null) {
+ try {
+ clearInterval(nativePollTimer)
+ } catch (e) {
+ /* noop */
+ }
+ nativePollTimer = null
+ }
+ if (nativeStartTimer != null) {
+ try {
+ clearTimeout(nativeStartTimer)
+ } catch (e) {
+ /* noop */
+ }
+ nativeStartTimer = null
+ }
+ if (gyroModule && typeof gyroModule.stopGyro === 'function') {
+ try {
+ gyroModule.stopGyro(() => {})
+ } catch (e) {
+ /* noop */
+ }
+ }
+ gyroModule = null
+ mode = 'accel'
+ scheduleNativeGyroStallFallback = null
+ }
+
+ function stopAccel() {
+ try {
+ if (accelHandler && typeof uni.offAccelerometerChange === 'function') {
+ uni.offAccelerometerChange(accelHandler)
+ }
+ } catch (e) {
+ /* noop */
+ }
+ accelHandler = null
+ try {
+ uni.stopAccelerometer({})
+ } catch (e) {
+ /* noop */
+ }
+ }
+
+ function startAccelInternal(options = {}) {
+ const fromNativeFallback = options.fromNativeFallback === true
+ mode = 'accel'
+ stopAccel()
+ if (fromNativeFallback && typeof onTiltDriverFallback === 'function') {
+ try {
+ onTiltDriverFallback()
+ } catch (e) {
+ /* noop */
+ }
+ }
+ resetAccelCalibration()
+ accelSmoothed = 0
+ rollAccum = 0
+ prevCu = null
+ prevCv = null
+
+ if (useStudioAccelDirect) {
+ if (typeof simulateFromSignedDegrees === 'function') {
+ resetStudioAccelBaseline()
+ }
+ accelHandler = (res) => {
+ const ax = Number(res.x) || 0
+ const ay = Number(res.y) || 0
+ const az = Number(res.z) || 0
+ const gMag = Math.hypot(ax, ay, az)
+ if (gMag < 0.12) return
+ /* 竖屏常见握持:左右倾斜主要反映为 ax/az 与重力的关系(免 warmup,避免长期无输出) */
+ if (typeof simulateFromSignedDegrees === 'function') {
+ const tiltRad = Math.atan2(-ax, az)
+ if (studioAccelBaseRad == null) {
+ studioAccelBaselineRads.push(tiltRad)
+ if (studioAccelBaselineRads.length < STUDIO_ACCEL_BASELINE_FRAMES) {
+ simulate(0, 0)
+ return
+ }
+ studioAccelBaseRad = circularMeanRad(studioAccelBaselineRads)
+ studioAccelBaselineRads = []
+ }
+ let delta = tiltRad - studioAccelBaseRad
+ while (delta > Math.PI) delta -= 2 * Math.PI
+ while (delta < -Math.PI) delta += 2 * Math.PI
+ const signedDeg = delta * (180 / Math.PI)
+ simulateFromSignedDegrees(Math.abs(signedDeg))
+ } else {
+ const tiltRaw = Math.atan2(-ax, az) / (Math.PI / 5)
+ const tilt01 = Math.max(-1, Math.min(1, tiltRaw))
+ accelSmoothed += (tilt01 - accelSmoothed) * 0.58
+ simulate(Math.max(-1, Math.min(1, accelSmoothed)), 0)
+ }
+ gyroSourceLabel.value = 'accelerometer'
+ }
+ } else {
+ accelHandler = (res) => {
+ const ax = Number(res.x) || 0
+ const ay = Number(res.y) || 0
+ const az = Number(res.z) || 0
+ const out = accelToRelativeTilt01(ax, ay, az)
+ if (out == null) return
+ if (out.warmup) return
+ const { cu, cv, deltaRad } = out
+ if (prevCu != null && prevCv != null) {
+ let step = Math.atan2(cu * prevCv - cv * prevCu, cu * prevCu + cv * prevCv)
+ if (step > Math.PI * 0.92) step -= 2 * Math.PI
+ if (step < -Math.PI * 0.92) step += 2 * Math.PI
+ step = Math.max(-MAX_STEP_RAD, Math.min(MAX_STEP_RAD, step))
+ rollAccum += step * STEP_GAIN
+ }
+ prevCu = cu
+ prevCv = cv
+
+ const instant = Math.max(-1, Math.min(1, deltaRad / INSTANT_FULL_RAD))
+ const cyclic = Math.sin(rollAccum * SIN_PHASE_SCALE)
+ const target = BLEND_INSTANT * instant + (1 - BLEND_INSTANT) * cyclic
+
+ accelSmoothed += (target - accelSmoothed) * 0.74
+ simulate(Math.max(-1, Math.min(1, accelSmoothed)), 0)
+ gyroSourceLabel.value = 'accelerometer'
+ }
+ }
+ try {
+ uni.onAccelerometerChange(accelHandler)
+ const applyStart = (interval) => {
+ uni.startAccelerometer({
+ interval,
+ success: () => {
+ gyroSourceLabel.value = 'accelerometer'
+ },
+ fail: () => {
+ if (interval === 'game') {
+ applyStart('normal')
+ return
+ }
+ gyroSourceLabel.value = 'simulation'
+ },
+ })
+ }
+ applyStart('game')
+ } catch (e) {
+ gyroSourceLabel.value = 'simulation'
+ }
+ }
+
+ function onNativeAngleFrame(x, y, z) {
+ const vx = Number(x)
+ const vy = Number(y)
+ const vz = Number(z)
+ if (![vx, vy, vz].some(Number.isFinite)) return
+ /* 有角度帧即视为陀螺仪在工作,含基线采集期,避免看门狗误判「从未产出」而中途切加速度计 */
+ gyroSourceLabel.value = 'gyroscope'
+
+ if (!nativeBaselineReady) {
+ if ([vx, vy, vz].every(Number.isFinite)) {
+ nativeTrSamples.push({ x: vx, y: vy, z: vz })
+ }
+ if (nativeTrSamples.length >= NATIVE_BASELINE_FRAMES) {
+ const n = nativeTrSamples.length
+ nativeBase.x = nativeTrSamples.reduce((a, b) => a + b.x, 0) / n
+ nativeBase.y = nativeTrSamples.reduce((a, b) => a + b.y, 0) / n
+ nativeBase.z = nativeTrSamples.reduce((a, b) => a + b.z, 0) / n
+ nativeLockedAxisIdx = inferPrimaryTiltAxisFromSamples(nativeTrSamples)
+ nativeBaselineReady = true
+ }
+ simulate(0, 0)
+ if (typeof scheduleNativeGyroStallFallback === 'function') {
+ scheduleNativeGyroStallFallback()
+ }
+ return
+ }
+
+ const dx = deltaDeg(vx, nativeBase.x)
+ const dy = deltaDeg(vy, nativeBase.y)
+ const dz = deltaDeg(vz, nativeBase.z)
+ if (typeof simulateFromSignedDegrees === 'function') {
+ simulateFromSignedDegrees(maxAbsTiltDeltaDeg(dx, dy, dz))
+ } else {
+ const axisDeltas = [dx, dy, dz]
+ const chosen = axisDeltas[nativeLockedAxisIdx] ?? pickDominantTiltDelta(dx, dy, dz)
+ const tilt01 = Math.max(-1, Math.min(1, chosen / NATIVE_FULL_DEG))
+ /* 跟手:过低显钝;过高易抖。插件已做角度融合,此处略轻低通即可 */
+ nativeSmoothed += (tilt01 - nativeSmoothed) * 0.86
+ simulate(Math.max(-1, Math.min(1, nativeSmoothed)), 0)
+ }
+ if (typeof scheduleNativeGyroStallFallback === 'function') {
+ scheduleNativeGyroStallFallback()
+ }
+ }
+
+ function startNativeInternal() {
+ stopNative()
+ stopAccel()
+ resetNativeBaseline()
+ // #ifdef APP-PLUS
+ gyroModule = tryRequireImengyuGyro()
+ if (!gyroModule) {
+ mode = 'accel'
+ startAccelInternal()
+ return
+ }
+ mode = 'native'
+ const myGen = tiltGen
+ /** 与官方示例一致:normal / ui / game / fastest,game≈50Hz */
+ const startOpts = { interval: 'game' }
+
+ scheduleNativeGyroStallFallback = () => {
+ if (nativeWatchdogTimer != null) {
+ try {
+ clearTimeout(nativeWatchdogTimer)
+ } catch (e) {
+ /* noop */
+ }
+ nativeWatchdogTimer = null
+ }
+ nativeWatchdogTimer = setTimeout(() => {
+ nativeWatchdogTimer = null
+ if (myGen !== tiltGen) return
+ if (mode !== 'native') return
+ console.warn(
+ '[useLenticularStudioTilt] native gyro stalled (no angle frames for ' +
+ NATIVE_GYRO_STALL_FALLBACK_MS +
+ 'ms), falling back to accelerometer'
+ )
+ stopNative()
+ startAccelInternal({ fromNativeFallback: true })
+ }, NATIVE_GYRO_STALL_FALLBACK_MS)
+ }
+
+ function invokeStartNativeGyro() {
+ const mod = gyroModule
+ if (myGen !== tiltGen || !mod) return
+
+ function startNativePoll() {
+ if (nativePollTimer != null) {
+ try {
+ clearInterval(nativePollTimer)
+ } catch (e) {
+ /* noop */
+ }
+ nativePollTimer = null
+ }
+ /**
+ * 官方示例只用异步 getGyroValue;部分基座上 getGyroValueSync 长期无 x/y/z,
+ * 仅在没有异步接口时才用 Sync(见插件 id=6237 说明)。
+ */
+ const useSync =
+ typeof mod.getGyroValueSync === 'function' && typeof mod.getGyroValue !== 'function'
+ const intervalMs = useSync ? 20 : 28
+ const pollOnce = () => {
+ if (myGen !== tiltGen || !gyroModule) return
+ try {
+ if (useSync) {
+ const v = mod.getGyroValueSync()
+ if (v && hasAnglePayload(v)) {
+ onNativeAngleFrame(v.x, v.y, v.z)
+ }
+ return
+ }
+ mod.getGyroValue((v) => {
+ if (myGen !== tiltGen || !gyroModule || !v) return
+ if (hasAnglePayload(v)) {
+ onNativeAngleFrame(v.x, v.y, v.z)
+ }
+ })
+ } catch (e) {
+ /* noop */
+ }
+ }
+ pollOnce()
+ nativePollTimer = setInterval(pollOnce, intervalMs)
+ }
+
+ let kickOnce = false
+ const kick = () => {
+ if (kickOnce) return
+ if (myGen !== tiltGen || !gyroModule) return
+ kickOnce = true
+ if (gyroCbGuardTimer != null) {
+ try {
+ clearTimeout(gyroCbGuardTimer)
+ } catch (e) {
+ /* noop */
+ }
+ gyroCbGuardTimer = null
+ }
+
+ const usePoll =
+ typeof mod.startGyro === 'function' &&
+ (typeof mod.getGyroValue === 'function' || typeof mod.getGyroValueSync === 'function')
+
+ if (usePoll) {
+ try {
+ mod.startGyro(startOpts, (res) => {
+ if (myGen !== tiltGen || !gyroModule) return
+ /* 插件约定:首包只表示是否开启成功,不含持续角度;若回调无对象则无法进入官方要求的 getGyroValue 轮询 */
+ if (!res) {
+ console.warn(
+ '[useLenticularStudioTilt] startGyro callback received no argument (see plugin doc: first callback is handshake only)'
+ )
+ stopNative()
+ startAccelInternal({ fromNativeFallback: true })
+ return
+ }
+ if (isExplicitGyroHandshakeFailure(res)) {
+ console.warn('[useLenticularStudioTilt] startGyro handshake failed', res)
+ stopNative()
+ startAccelInternal({ fromNativeFallback: true })
+ return
+ }
+ /* 与官方示例一致:success 为真后再轮询;iOS 上常为字符串 'true' */
+ if (Object.prototype.hasOwnProperty.call(res, 'success') && !isTruthyFlag(res.success)) {
+ console.warn('[useLenticularStudioTilt] startGyro success=false', res)
+ stopNative()
+ startAccelInternal({ fromNativeFallback: true })
+ return
+ }
+ if (hasAnglePayload(res)) {
+ onNativeAngleFrame(res.x, res.y, res.z)
+ } else {
+ simulate(0, 0)
+ }
+ startNativePoll()
+ })
+ } catch (e) {
+ console.warn('[useLenticularStudioTilt] startGyro error', e)
+ stopNative()
+ startAccelInternal({ fromNativeFallback: true })
+ }
+ return
+ }
+
+ try {
+ mod.startGyroWithCallback(startOpts, (res) => {
+ if (myGen !== tiltGen || !gyroModule) return
+ if (!res) {
+ console.warn('[useLenticularStudioTilt] startGyroWithCallback: empty callback argument')
+ stopNative()
+ startAccelInternal({ fromNativeFallback: true })
+ return
+ }
+
+ if (!hasAnglePayload(res)) {
+ if (isExplicitGyroHandshakeFailure(res)) {
+ console.warn('[useLenticularStudioTilt] native gyro handshake failed', res)
+ stopNative()
+ startAccelInternal({ fromNativeFallback: true })
+ return
+ }
+ simulate(0, 0)
+ return
+ }
+
+ onNativeAngleFrame(res.x, res.y, res.z)
+ })
+ } catch (e) {
+ console.warn('[useLenticularStudioTilt] startGyroWithCallback error', e)
+ stopNative()
+ startAccelInternal({ fromNativeFallback: true })
+ }
+ }
+
+ if (typeof mod.getGyroStarted === 'function') {
+ gyroCbGuardTimer = setTimeout(() => {
+ gyroCbGuardTimer = null
+ if (myGen !== tiltGen || !gyroModule || kickOnce) return
+ console.warn('[useLenticularStudioTilt] getGyroStarted callback timeout, starting gyro')
+ kick()
+ }, 700)
+ mod.getGyroStarted((r) => {
+ if (gyroCbGuardTimer != null) {
+ try {
+ clearTimeout(gyroCbGuardTimer)
+ } catch (e) {
+ /* noop */
+ }
+ gyroCbGuardTimer = null
+ }
+ if (myGen !== tiltGen || !gyroModule) return
+ const started = r && isTruthyFlag(r.started)
+ if (started) {
+ let stopDone = false
+ const stopTimer = setTimeout(() => {
+ if (stopDone || myGen !== tiltGen || !gyroModule) return
+ stopDone = true
+ console.warn('[useLenticularStudioTilt] stopGyro callback timeout, continuing')
+ setTimeout(kick, 80)
+ }, 700)
+ mod.stopGyro(() => {
+ if (stopDone || myGen !== tiltGen || !gyroModule) return
+ stopDone = true
+ try {
+ clearTimeout(stopTimer)
+ } catch (e) {
+ /* noop */
+ }
+ setTimeout(kick, 80)
+ })
+ } else {
+ kick()
+ }
+ })
+ } else {
+ kick()
+ }
+ }
+
+ /* 官方:因 uni-app 原因,页面进入后需延时再 start(示例 100ms) */
+ nativeStartTimer = setTimeout(() => {
+ nativeStartTimer = null
+ if (myGen !== tiltGen || !gyroModule) return
+ invokeStartNativeGyro()
+ if (typeof scheduleNativeGyroStallFallback === 'function') {
+ scheduleNativeGyroStallFallback()
+ }
+ }, 100)
+ // #endif
+ // #ifndef APP-PLUS
+ mode = 'accel'
+ startAccelInternal()
+ // #endif
+ }
+
+ function start() {
+ // #ifdef APP-PLUS
+ startNativeInternal()
+ // #endif
+ // #ifndef APP-PLUS
+ startAccelInternal()
+ // #endif
+ }
+
+ function stop() {
+ stopNative()
+ stopAccel()
+ }
+
+ function recalibrate() {
+ resetAccelCalibration()
+ resetNativeBaseline()
+ resetStudioAccelBaseline()
+ accelSmoothed = 0
+ rollAccum = 0
+ prevCu = null
+ prevCv = null
+ simulate(0, 0)
+ }
+
+ return {
+ start,
+ stop,
+ recalibrate,
+ }
+}
diff --git a/frontend/main.js b/frontend/main.js
index 579a0cb..6867426 100644
--- a/frontend/main.js
+++ b/frontend/main.js
@@ -11,6 +11,10 @@ const app = new Vue({
app.$mount()
// #endif
+// #ifdef VUE3 && H5
+import './pages/castlove/create-laser-upload.css'
+// #endif
+
// #ifdef VUE3
import { createSSRApp } from 'vue'
import store from './store'
diff --git a/frontend/manifest.json b/frontend/manifest.json
index 2daf2ff..b446a4a 100644
--- a/frontend/manifest.json
+++ b/frontend/manifest.json
@@ -1,6 +1,6 @@
{
"name" : "TopFans",
- "appid" : "__UNI__F199FF4",
+ "appid" : "__UNI__8CBE431",
"description" : "",
"versionName" : "1.0.4",
"versionCode" : 105,
@@ -26,6 +26,9 @@
"Speech" : {},
"Push" : {}
},
+ "nativePlugins" : {
+ "imengyu-UniAndroidGyro" : {}
+ },
/* 应用发布信息 */
"distribute" : {
/* android打包配置 */
@@ -72,37 +75,38 @@
}
},
"push" : {},
- "statics" : {}
+ "statics" : {},
+ "ad" : {}
},
"icons" : {
"android" : {
- "hdpi" : "unpackage/res/icons/72x72.png",
- "xhdpi" : "unpackage/res/icons/96x96.png",
- "xxhdpi" : "unpackage/res/icons/144x144.png",
- "xxxhdpi" : "unpackage/res/icons/192x192.png"
+ "hdpi" : "static/app-icons/72x72.png",
+ "xhdpi" : "static/app-icons/96x96.png",
+ "xxhdpi" : "static/app-icons/144x144.png",
+ "xxxhdpi" : "static/app-icons/192x192.png"
},
"ios" : {
- "appstore" : "unpackage/res/icons/1024x1024.png",
+ "appstore" : "static/app-icons/1024x1024.png",
"ipad" : {
- "app" : "unpackage/res/icons/76x76.png",
- "app@2x" : "unpackage/res/icons/152x152.png",
- "notification" : "unpackage/res/icons/20x20.png",
- "notification@2x" : "unpackage/res/icons/40x40.png",
- "proapp@2x" : "unpackage/res/icons/167x167.png",
- "settings" : "unpackage/res/icons/29x29.png",
- "settings@2x" : "unpackage/res/icons/58x58.png",
- "spotlight" : "unpackage/res/icons/40x40.png",
- "spotlight@2x" : "unpackage/res/icons/80x80.png"
+ "app" : "static/app-icons/76x76.png",
+ "app@2x" : "static/app-icons/152x152.png",
+ "notification" : "static/app-icons/20x20.png",
+ "notification@2x" : "static/app-icons/40x40.png",
+ "proapp@2x" : "static/app-icons/167x167.png",
+ "settings" : "static/app-icons/29x29.png",
+ "settings@2x" : "static/app-icons/58x58.png",
+ "spotlight" : "static/app-icons/40x40.png",
+ "spotlight@2x" : "static/app-icons/80x80.png"
},
"iphone" : {
- "app@2x" : "unpackage/res/icons/120x120.png",
- "app@3x" : "unpackage/res/icons/180x180.png",
- "notification@2x" : "unpackage/res/icons/40x40.png",
- "notification@3x" : "unpackage/res/icons/60x60.png",
- "settings@2x" : "unpackage/res/icons/58x58.png",
- "settings@3x" : "unpackage/res/icons/87x87.png",
- "spotlight@2x" : "unpackage/res/icons/80x80.png",
- "spotlight@3x" : "unpackage/res/icons/120x120.png"
+ "app@2x" : "static/app-icons/120x120.png",
+ "app@3x" : "static/app-icons/180x180.png",
+ "notification@2x" : "static/app-icons/40x40.png",
+ "notification@3x" : "static/app-icons/60x60.png",
+ "settings@2x" : "static/app-icons/58x58.png",
+ "settings@3x" : "static/app-icons/87x87.png",
+ "spotlight@2x" : "static/app-icons/80x80.png",
+ "spotlight@3x" : "static/app-icons/120x120.png"
}
}
}
diff --git a/frontend/nativePlugins/imengyu-UniAndroidGyro/android/uni_android_gyro-release.aar b/frontend/nativePlugins/imengyu-UniAndroidGyro/android/uni_android_gyro-release.aar
new file mode 100644
index 0000000..0b76a8a
Binary files /dev/null and b/frontend/nativePlugins/imengyu-UniAndroidGyro/android/uni_android_gyro-release.aar differ
diff --git a/frontend/nativePlugins/imengyu-UniAndroidGyro/ios/GyroPlugin.framework/GyroPlugin b/frontend/nativePlugins/imengyu-UniAndroidGyro/ios/GyroPlugin.framework/GyroPlugin
new file mode 100644
index 0000000..867beb9
Binary files /dev/null and b/frontend/nativePlugins/imengyu-UniAndroidGyro/ios/GyroPlugin.framework/GyroPlugin differ
diff --git a/frontend/nativePlugins/imengyu-UniAndroidGyro/ios/GyroPlugin.framework/Info.plist b/frontend/nativePlugins/imengyu-UniAndroidGyro/ios/GyroPlugin.framework/Info.plist
new file mode 100644
index 0000000..f07f4fe
Binary files /dev/null and b/frontend/nativePlugins/imengyu-UniAndroidGyro/ios/GyroPlugin.framework/Info.plist differ
diff --git a/frontend/nativePlugins/imengyu-UniAndroidGyro/ios/GyroPlugin.framework/_CodeSignature/CodeDirectory b/frontend/nativePlugins/imengyu-UniAndroidGyro/ios/GyroPlugin.framework/_CodeSignature/CodeDirectory
new file mode 100644
index 0000000..5d27666
Binary files /dev/null and b/frontend/nativePlugins/imengyu-UniAndroidGyro/ios/GyroPlugin.framework/_CodeSignature/CodeDirectory differ
diff --git a/frontend/nativePlugins/imengyu-UniAndroidGyro/ios/GyroPlugin.framework/_CodeSignature/CodeRequirements b/frontend/nativePlugins/imengyu-UniAndroidGyro/ios/GyroPlugin.framework/_CodeSignature/CodeRequirements
new file mode 100644
index 0000000..6a34f37
Binary files /dev/null and b/frontend/nativePlugins/imengyu-UniAndroidGyro/ios/GyroPlugin.framework/_CodeSignature/CodeRequirements differ
diff --git a/frontend/nativePlugins/imengyu-UniAndroidGyro/ios/GyroPlugin.framework/_CodeSignature/CodeRequirements-1 b/frontend/nativePlugins/imengyu-UniAndroidGyro/ios/GyroPlugin.framework/_CodeSignature/CodeRequirements-1
new file mode 100644
index 0000000..8e7ff51
Binary files /dev/null and b/frontend/nativePlugins/imengyu-UniAndroidGyro/ios/GyroPlugin.framework/_CodeSignature/CodeRequirements-1 differ
diff --git a/frontend/nativePlugins/imengyu-UniAndroidGyro/ios/GyroPlugin.framework/_CodeSignature/CodeResources b/frontend/nativePlugins/imengyu-UniAndroidGyro/ios/GyroPlugin.framework/_CodeSignature/CodeResources
new file mode 100644
index 0000000..b552e06
--- /dev/null
+++ b/frontend/nativePlugins/imengyu-UniAndroidGyro/ios/GyroPlugin.framework/_CodeSignature/CodeResources
@@ -0,0 +1,101 @@
+
+
+
+
+ files
+
+ Info.plist
+
+ 8XOwyvu+OiYnMWBub0JdOOoIL9c=
+
+
+ files2
+
+ rules
+
+ ^.*
+
+ ^.*\.lproj/
+
+ optional
+
+ weight
+ 1000
+
+ ^.*\.lproj/locversion.plist$
+
+ omit
+
+ weight
+ 1100
+
+ ^Base\.lproj/
+
+ weight
+ 1010
+
+ ^version.plist$
+
+
+ rules2
+
+ .*\.dSYM($|/)
+
+ weight
+ 11
+
+ ^(.*/)?\.DS_Store$
+
+ omit
+
+ weight
+ 2000
+
+ ^.*
+
+ ^.*\.lproj/
+
+ optional
+
+ weight
+ 1000
+
+ ^.*\.lproj/locversion.plist$
+
+ omit
+
+ weight
+ 1100
+
+ ^Base\.lproj/
+
+ weight
+ 1010
+
+ ^Info\.plist$
+
+ omit
+
+ weight
+ 20
+
+ ^PkgInfo$
+
+ omit
+
+ weight
+ 20
+
+ ^embedded\.provisionprofile$
+
+ weight
+ 20
+
+ ^version\.plist$
+
+ weight
+ 20
+
+
+
+
diff --git a/frontend/nativePlugins/imengyu-UniAndroidGyro/ios/GyroPlugin.framework/_CodeSignature/CodeSignature b/frontend/nativePlugins/imengyu-UniAndroidGyro/ios/GyroPlugin.framework/_CodeSignature/CodeSignature
new file mode 100644
index 0000000..a676c08
Binary files /dev/null and b/frontend/nativePlugins/imengyu-UniAndroidGyro/ios/GyroPlugin.framework/_CodeSignature/CodeSignature differ
diff --git a/frontend/nativePlugins/imengyu-UniAndroidGyro/package.json b/frontend/nativePlugins/imengyu-UniAndroidGyro/package.json
new file mode 100644
index 0000000..ac0a30b
--- /dev/null
+++ b/frontend/nativePlugins/imengyu-UniAndroidGyro/package.json
@@ -0,0 +1,30 @@
+{
+ "name": "imengyu-UniAndroidGyro",
+ "id": "imengyu-UniAndroidGyro",
+ "version": "1.0.5",
+ "description": "APP端陀螺仪数据采集",
+ "_dp_type": "nativeplugin",
+ "_dp_nativeplugin": {
+ "android": {
+ "plugins": [
+ {
+ "type": "module",
+ "name": "imengyu-UniAndroidGyro-GyroModule",
+ "class": "uni.imengyu.gyro.GyroModule"
+ }
+ ],
+ "integrateType": "aar",
+ "minSdkVersion": "19"
+ },
+ "ios": {
+ "plugins": [{
+ "type": "module",
+ "name": "imengyu-UniAndroidGyro-GyroModule",
+ "class": "GyroModule"
+ }],
+ "frameworks": ["MapKit.framework"],
+ "integrateType": "framework",
+ "deploymentTarget": "9.0"
+ }
+ }
+}
\ No newline at end of file
diff --git a/frontend/pages.json b/frontend/pages.json
index a41389f..72bf6a0 100644
--- a/frontend/pages.json
+++ b/frontend/pages.json
@@ -134,6 +134,25 @@
}
}
},
+ {
+ "path": "pages/castlove/lenticular-studio",
+ "style": {
+ "navigationStyle": "custom",
+ "app-plus": {
+ "bounce": "none"
+ }
+ }
+ },
+ {
+ "path": "pages/castlove/laser-card-studio",
+ "style": {
+ "navigationStyle": "custom",
+ "backgroundColor": "#0a0a0a",
+ "app-plus": {
+ "bounce": "none"
+ }
+ }
+ },
{
"path": "pages/castlove/craft-select",
"style": {
@@ -210,5 +229,15 @@
"navigationBarBackgroundColor": "#F8F8F8",
"backgroundColor": "#F8F8F8"
},
- "uniIdRouter": {}
+ "uniIdRouter": {},
+ "condition": {
+ "current": 0,
+ "list": [
+ {
+ "name": "castlove-lenticular-create",
+ "path": "pages/castlove/create",
+ "query": "name=%E5%85%89%E6%A0%85%E5%8D%A1"
+ }
+ ]
+ }
}
\ No newline at end of file
diff --git a/frontend/pages/castlove/craft-select.vue b/frontend/pages/castlove/craft-select.vue
index c62b9f8..a1948e3 100644
--- a/frontend/pages/castlove/craft-select.vue
+++ b/frontend/pages/castlove/craft-select.vue
@@ -5,9 +5,12 @@
-
+ :class="{ 'card-selected': selectedIndex === index }" :style="getCardStyle(index)">
+
@@ -48,6 +51,18 @@
+
@@ -802,6 +1010,24 @@ onMounted(() => {
text-align: center;
}
+.lenticular-upload-row {
+ width: 100%;
+ display: flex;
+ flex-direction: row;
+ justify-content: center;
+ align-items: stretch;
+ gap: 20rpx;
+ padding: 0 4rpx;
+ box-sizing: border-box;
+}
+
+.upload-box--half {
+ flex: 1;
+ width: 0;
+ min-width: 0;
+ height: 280rpx;
+}
+
.uploaded-image {
width: 100%;
height: 100%;
@@ -1156,6 +1382,212 @@ onMounted(() => {
opacity: 0.9;
}
+/* ========== 镭射卡设计稿专用 ========== */
+.page-container--laser {
+ position: relative;
+}
+
+.laser-bg-veil {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ width: 100%;
+ height: 100%;
+ z-index: 0;
+ pointer-events: none;
+ background:
+ radial-gradient(2rpx 2rpx at 10% 18%, rgba(255, 255, 255, 0.55), transparent 55%),
+ radial-gradient(1.5rpx 1.5rpx at 78% 12%, rgba(255, 255, 255, 0.45), transparent 55%),
+ radial-gradient(2rpx 2rpx at 42% 8%, rgba(255, 255, 255, 0.4), transparent 50%),
+ radial-gradient(1.5rpx 1.5rpx at 88% 46%, rgba(255, 255, 255, 0.5), transparent 50%),
+ radial-gradient(2rpx 2rpx at 18% 62%, rgba(255, 255, 255, 0.35), transparent 50%),
+ radial-gradient(1.5rpx 1.5rpx at 64% 72%, rgba(255, 255, 255, 0.45), transparent 50%),
+ radial-gradient(2rpx 2rpx at 36% 88%, rgba(255, 255, 255, 0.4), transparent 50%),
+ radial-gradient(1.5rpx 1.5rpx at 92% 84%, rgba(255, 255, 255, 0.35), transparent 50%),
+ linear-gradient(
+ 165deg,
+ rgba(255, 182, 220, 0.16) 0%,
+ rgba(140, 100, 210, 0.22) 42%,
+ rgba(20, 40, 80, 0.2) 100%
+ );
+}
+
+.laser-close-hit {
+ position: fixed;
+ top: calc(16rpx + constant(safe-area-inset-top));
+ top: calc(16rpx + env(safe-area-inset-top));
+ left: 24rpx;
+ z-index: 20;
+ width: 72rpx;
+ height: 72rpx;
+ border-radius: 50%;
+ background: linear-gradient(
+ 135deg,
+ #ffb7e8 0%,
+ #c9a6ff 28%,
+ #8fd4ff 55%,
+ #fff3b0 78%,
+ #ffa8d8 100%
+ );
+ background-size: 180% 180%;
+ border: 2rpx solid rgba(255, 255, 255, 0.55);
+ box-shadow: 0 6rpx 22rpx rgba(120, 60, 160, 0.35);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.laser-close-x {
+ color: #ffffff;
+ font-size: 44rpx;
+ line-height: 1;
+ font-weight: 300;
+}
+
+.content-wrapper--laser {
+ padding-top: calc(112rpx + constant(safe-area-inset-top));
+ padding-top: calc(112rpx + env(safe-area-inset-top));
+}
+
+.upload-section--laser .upload-box-wrap {
+ position: relative;
+ width: 100%;
+ max-width: 640rpx;
+ margin: 0 auto;
+}
+
+.upload-section--laser .upload-hint {
+ color: rgba(255, 255, 255, 0.88);
+ font-size: 22rpx;
+ margin-top: 16rpx;
+}
+
+.upload-section--laser .upload-clear {
+ position: absolute;
+ top: 14rpx;
+ right: 14rpx;
+ z-index: 6;
+ width: 56rpx;
+ height: 56rpx;
+ border-radius: 50%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.upload-clear--laser {
+ background: rgba(255, 255, 255, 0.96);
+ border: 2rpx solid rgba(255, 255, 255, 0.95);
+ box-shadow: 0 6rpx 18rpx rgba(60, 30, 100, 0.22);
+}
+
+.upload-clear-x--laser {
+ color: #3d6fd8;
+ font-size: 34rpx;
+ font-weight: 400;
+}
+
+.form-section--laser {
+ gap: 36rpx;
+}
+
+.form-label--laser,
+.section-title--laser {
+ font-size: 28rpx;
+ padding: 10rpx 22rpx;
+ border-radius: 999rpx;
+ background: linear-gradient(95deg, #ff7ec8 0%, #ff9f7a 48%, #ffb35c 100%);
+ color: #ffffff;
+ text-shadow: 0 2rpx 6rpx rgba(160, 40, 80, 0.35);
+ box-shadow: 0 4rpx 14rpx rgba(255, 100, 140, 0.28);
+}
+
+.ai-section--laser {
+ gap: 20rpx;
+}
+
+.picker-display--laser {
+ background: rgba(255, 255, 255, 0.32);
+ backdrop-filter: blur(22rpx);
+ -webkit-backdrop-filter: blur(22rpx);
+ border: 2rpx solid rgba(255, 255, 255, 0.45);
+ box-shadow: 0 8rpx 28rpx rgba(50, 20, 90, 0.12);
+}
+
+.form-textarea--laser {
+ background: rgba(255, 255, 255, 0.32);
+ backdrop-filter: blur(22rpx);
+ -webkit-backdrop-filter: blur(22rpx);
+ border: 2rpx solid rgba(255, 255, 255, 0.42);
+ box-shadow: 0 8rpx 28rpx rgba(50, 20, 90, 0.1);
+}
+
+.picker-arrow.picker-arrow--laser {
+ transform: rotate(0deg);
+ font-size: 22rpx;
+ line-height: 1;
+ color: rgba(255, 255, 255, 0.88);
+ font-weight: 600;
+}
+
+.picker-arrow.picker-arrow--laser.picker-arrow-up {
+ transform: rotate(180deg);
+}
+
+.ai-input-wrapper--laser {
+ flex-direction: column;
+ min-height: 280rpx;
+ padding: 24rpx 28rpx 28rpx;
+ background: rgba(255, 255, 255, 0.32);
+ backdrop-filter: blur(22rpx);
+ -webkit-backdrop-filter: blur(22rpx);
+ border: 2rpx solid rgba(255, 255, 255, 0.45);
+ box-shadow: 0 8rpx 28rpx rgba(50, 20, 90, 0.1);
+}
+
+.ai-input-wrapper--laser .ai-input {
+ min-height: 220rpx;
+ width: 100%;
+}
+
+.button-section--laser {
+ gap: 24rpx;
+ margin-top: 48rpx;
+}
+
+.btn-secondary--laser {
+ background: rgba(255, 255, 255, 0.18);
+ border-color: rgba(255, 255, 255, 0.35);
+}
+
+.btn-confirm-laser {
+ flex: 1;
+ height: 96rpx;
+ line-height: 96rpx;
+ border-radius: 48rpx;
+ font-size: 36rpx;
+ font-family: 'yt', sans-serif;
+ font-weight: 600;
+ color: #ffffff;
+ border: none;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: linear-gradient(95deg, #ff8ec5 0%, #ff5a8c 38%, #ff2d6b 72%, #ff4d64 100%);
+ box-shadow: 0 10rpx 32rpx rgba(255, 50, 110, 0.42);
+ text-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.25);
+}
+
+.btn-confirm-laser::after {
+ border: none;
+}
+
+.btn-confirm-laser:active {
+ opacity: 0.92;
+}
+
.nav-mask {
position: fixed;
top: 0;
diff --git a/frontend/pages/castlove/laser-card-studio.vue b/frontend/pages/castlove/laser-card-studio.vue
new file mode 100644
index 0000000..17547ad
--- /dev/null
+++ b/frontend/pages/castlove/laser-card-studio.vue
@@ -0,0 +1,3428 @@
+
+
+
+
+
+
+
+
+
+
+
+
+ 换图
+
+
+ 全息镭射满铺预览 · 保存导出高清图
+
+
+
+
+ +
+
+ 拍照或上传
+ 支持相册选择或相机拍摄
+
+
+
+
+
+
+ 镭射底纹(人物后方)
+
+
+
+
+ 无
+
+
+ {{ opt.label }}
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/pages/castlove/lenticular-studio.vue b/frontend/pages/castlove/lenticular-studio.vue
new file mode 100644
index 0000000..5757e56
--- /dev/null
+++ b/frontend/pages/castlove/lenticular-studio.vue
@@ -0,0 +1,482 @@
+
+
+
+
+ ←
+
+
+ 光栅卡工作室
+ 倾斜手机 · 重力感应预览
+
+
+
+ 校零
+
+
+ ⋮
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+
+
+ {{ layer.label }}
+ {{ layer.src ? '已上传' : '点击上传' }}
+
+
+ ×
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/pages/components/HorizontalScroll.vue b/frontend/pages/components/HorizontalScroll.vue
index d60eed8..369d696 100644
--- a/frontend/pages/components/HorizontalScroll.vue
+++ b/frontend/pages/components/HorizontalScroll.vue
@@ -12,10 +12,10 @@
-