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 @@ + + + + + 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 @@ + + + + + + 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 @@ + + + + + + + + + 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 @@ -