Merge branch 'cesia' into dev
53
docker/sql/migrations/V5__mint_cost_config.sql
Normal file
@ -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;
|
||||
345
docs/specs/2026-05-15-notification-system-design.md
Normal file
@ -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 处理首次创建统计
|
||||
@ -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 容器 */
|
||||
|
||||
@ -24,11 +24,13 @@
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineOptions({
|
||||
inheritAttrs: false
|
||||
})
|
||||
<script>
|
||||
export default {
|
||||
inheritAttrs: false,
|
||||
};
|
||||
</script>
|
||||
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
visible: {
|
||||
type: Boolean,
|
||||
|
||||
55
frontend/components/lenticular/CastloveLenticularPreview.vue
Normal file
@ -0,0 +1,55 @@
|
||||
<template>
|
||||
<view class="lc-wrap">
|
||||
<view class="lc-frame">
|
||||
<LenticularCard
|
||||
:layers="lenticularLayers"
|
||||
:transforms="layerTransforms"
|
||||
gyro-source="simulation"
|
||||
:approximate-preview="true"
|
||||
tilt-hint-text="滑动卡片预览光栅效果"
|
||||
@simulate="simulate"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
import LenticularCard from './LenticularCard.vue'
|
||||
import { useLenticularPreview } from '@/composables/useLenticularPreview.js'
|
||||
import { buildLenticularLayers } from '@/utils/castloveMintForm.js'
|
||||
|
||||
const props = defineProps({
|
||||
/** 用户上传图:作为光栅主体层 */
|
||||
imageSrc: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
})
|
||||
|
||||
const lenticularLayers = ref(buildLenticularLayers(props.imageSrc || ''))
|
||||
|
||||
watch(
|
||||
() => props.imageSrc,
|
||||
(s) => {
|
||||
lenticularLayers.value = buildLenticularLayers(s || '')
|
||||
}
|
||||
)
|
||||
|
||||
const { layerTransforms, simulate } = useLenticularPreview(lenticularLayers)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.lc-wrap {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 16rpx 0 32rpx;
|
||||
}
|
||||
|
||||
.lc-frame {
|
||||
width: 100%;
|
||||
height: 520rpx;
|
||||
max-height: 70vh;
|
||||
}
|
||||
</style>
|
||||
352
frontend/components/lenticular/LenticularCard.vue
Normal file
@ -0,0 +1,352 @@
|
||||
<template>
|
||||
<view class="card-container">
|
||||
<view
|
||||
:id="cardId"
|
||||
class="card-frame"
|
||||
@touchstart.stop="onFrameTouchStart"
|
||||
@touchmove.stop.prevent="onFrameTouchMove"
|
||||
@touchend.stop="onFrameTouchEnd"
|
||||
@touchcancel.stop="onFrameTouchEnd"
|
||||
>
|
||||
<view class="card-body" :style="cardRotateStyle">
|
||||
<view
|
||||
v-for="layer in layers"
|
||||
:key="layer.id"
|
||||
class="card-layer"
|
||||
:style="getLayerStyle(layer)"
|
||||
>
|
||||
<image
|
||||
v-if="layer.src"
|
||||
class="card-layer-img"
|
||||
:src="layer.src"
|
||||
mode="aspectFill"
|
||||
/>
|
||||
<template v-else>
|
||||
<view
|
||||
v-for="(dot, i) in layer.dots || []"
|
||||
:key="i"
|
||||
class="card-layer-dot"
|
||||
:style="getDotStyle(dot)"
|
||||
/>
|
||||
</template>
|
||||
</view>
|
||||
|
||||
<view class="lenticular-shimmer" :style="shimmerStyle" />
|
||||
<view class="lenticular-tint" />
|
||||
<view class="glass-rim" />
|
||||
<view class="vignette" />
|
||||
</view>
|
||||
|
||||
<view v-if="showHint && tiltHintText" class="tilt-hint">
|
||||
<text class="tilt-hint-icon">↻</text>
|
||||
<text class="tilt-hint-text">{{ tiltHintText }}</text>
|
||||
<text v-if="approximatePreview" class="tilt-hint-sub">叠化近似预览(倾斜或拖动体验光栅效果)</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, getCurrentInstance, onMounted, ref, watch } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
layers: { type: Array, required: true },
|
||||
transforms: { type: Object, required: true },
|
||||
gyroSource: { type: String, default: 'simulation' },
|
||||
approximatePreview: { type: Boolean, default: true },
|
||||
skipBuiltInTouch: { type: Boolean, default: false },
|
||||
tiltHintText: { type: String, default: '倾斜手机预览' },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['simulate'])
|
||||
|
||||
const showHint = ref(true)
|
||||
const cardId = `lcard-${Math.random().toString(36).slice(2, 9)}`
|
||||
const cardRect = ref(null)
|
||||
/** Vue3 + uni:createSelectorQuery().in() 必须传组件 public 实例(proxy),传 internal instance 会运行时报错导致整页白屏 */
|
||||
const pageProxy = getCurrentInstance()?.proxy
|
||||
|
||||
const MOTION_HIDE_PX = 0.45
|
||||
|
||||
function shouldHideTiltHint() {
|
||||
if (
|
||||
props.gyroSource === 'accelerometer' ||
|
||||
props.gyroSource === 'gyroscope' ||
|
||||
props.gyroSource === 'orientation'
|
||||
) {
|
||||
return true
|
||||
}
|
||||
return Object.values(props.transforms || {}).some((t) => Math.abs(t.x) > MOTION_HIDE_PX)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.gyroSource,
|
||||
() => {
|
||||
if (shouldHideTiltHint()) showHint.value = false
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.transforms,
|
||||
() => {
|
||||
if (shouldHideTiltHint()) showHint.value = false
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
const tiltVisualX = computed(() => {
|
||||
let num = 0
|
||||
let den = 0
|
||||
for (const layer of props.layers) {
|
||||
const t = props.transforms[layer.id]
|
||||
if (!t) continue
|
||||
const w = Math.max(0.06, t.opacity)
|
||||
num += t.x * w
|
||||
den += w
|
||||
}
|
||||
return den > 1e-6 ? num / den : 0
|
||||
})
|
||||
|
||||
const cardRotateStyle = computed(() => {
|
||||
const x = tiltVisualX.value
|
||||
const rotateY = (x / 120) * 12
|
||||
return {
|
||||
transform: `perspective(1000px) rotateY(${rotateY}deg) rotateX(0deg)`,
|
||||
}
|
||||
})
|
||||
|
||||
const shimmerStyle = computed(() => {
|
||||
const x = tiltVisualX.value
|
||||
const angle = 135 + (x / 120) * 60
|
||||
return {
|
||||
background: `linear-gradient(${angle}deg, transparent 30%, rgba(221,183,255,0.10) 50%, transparent 70%)`,
|
||||
}
|
||||
})
|
||||
|
||||
function getLayerStyle(layer) {
|
||||
const t = props.transforms[layer.id]
|
||||
const baseOpacity = t ? t.opacity : layer.opacity
|
||||
const x = t ? t.x : 0
|
||||
// 不在行内样式写 mix-blend-mode:部分小程序 / WebView 对非 normal 支持差,可能引发渲染异常
|
||||
return {
|
||||
opacity: baseOpacity,
|
||||
transform: `translate3d(${x}px, 0, 0)`,
|
||||
background: layer.background || 'transparent',
|
||||
}
|
||||
}
|
||||
|
||||
function getDotStyle(dot) {
|
||||
const color = dot.color || '#dae2fd'
|
||||
return {
|
||||
left: `${dot.x}%`,
|
||||
top: `${dot.y}%`,
|
||||
width: `${dot.r * 2}px`,
|
||||
height: `${dot.r * 2}px`,
|
||||
background: color,
|
||||
opacity: dot.opacity != null ? dot.opacity : 1,
|
||||
boxShadow: `0 0 ${dot.r * 3}px ${color}`,
|
||||
}
|
||||
}
|
||||
|
||||
function refreshRect() {
|
||||
return new Promise((resolve) => {
|
||||
try {
|
||||
const query = pageProxy ? uni.createSelectorQuery().in(pageProxy) : uni.createSelectorQuery()
|
||||
query
|
||||
.select(`#${cardId}`)
|
||||
.boundingClientRect((rect) => {
|
||||
if (rect && rect.width) {
|
||||
cardRect.value = {
|
||||
left: rect.left,
|
||||
top: rect.top,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
}
|
||||
}
|
||||
resolve()
|
||||
})
|
||||
.exec()
|
||||
} catch (e) {
|
||||
console.warn('[LenticularCard] refreshRect failed', e)
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function dispatch(clientX, clientY) {
|
||||
const rect = cardRect.value
|
||||
if (!rect || !rect.width || !rect.height) {
|
||||
void refreshRect()
|
||||
return
|
||||
}
|
||||
const cx = rect.left + rect.width / 2
|
||||
const cy = rect.top + rect.height / 2
|
||||
const nx = ((clientX - cx) / (rect.width / 2)) * 1.1
|
||||
const ny = ((clientY - cy) / (rect.height / 2)) * 1.1
|
||||
emit(
|
||||
'simulate',
|
||||
Math.max(-1, Math.min(1, nx)),
|
||||
Math.max(-1, Math.min(1, ny))
|
||||
)
|
||||
}
|
||||
|
||||
function onTouchMove(e) {
|
||||
if (props.skipBuiltInTouch) return
|
||||
const touch = e.touches && e.touches[0] ? e.touches[0] : e.changedTouches && e.changedTouches[0] ? e.changedTouches[0] : null
|
||||
if (!touch) return
|
||||
dispatch(touch.clientX, touch.clientY)
|
||||
}
|
||||
|
||||
function onFrameTouchStart(e) {
|
||||
if (props.skipBuiltInTouch) return
|
||||
onTouchMove(e)
|
||||
}
|
||||
|
||||
function onFrameTouchMove(e) {
|
||||
if (props.skipBuiltInTouch) return
|
||||
onTouchMove(e)
|
||||
}
|
||||
|
||||
function onFrameTouchEnd() {
|
||||
if (props.skipBuiltInTouch) return
|
||||
emit('simulate', 0, 0)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
setTimeout(() => {
|
||||
void refreshRect()
|
||||
}, 0)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.card-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
perspective: 1000px;
|
||||
}
|
||||
|
||||
.card-frame {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
max-height: 100%;
|
||||
aspect-ratio: 3 / 4;
|
||||
width: auto;
|
||||
max-width: min(420px, 94vw);
|
||||
}
|
||||
|
||||
.card-body {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 24px;
|
||||
overflow: hidden;
|
||||
transform-style: preserve-3d;
|
||||
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.55);
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
background-color: #060e20;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.card-layer {
|
||||
position: absolute;
|
||||
top: -6%;
|
||||
left: -6%;
|
||||
width: 112%;
|
||||
height: 112%;
|
||||
will-change: transform, opacity;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
}
|
||||
|
||||
.card-layer-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.card-layer-dot {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
.lenticular-shimmer {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.lenticular-tint {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(221, 183, 255, 0.1) 0%,
|
||||
transparent 35%,
|
||||
transparent 65%,
|
||||
rgba(76, 215, 246, 0.1) 100%
|
||||
);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.glass-rim {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 24px;
|
||||
border-top: 1px solid rgba(221, 183, 255, 0.42);
|
||||
box-shadow: inset 0 0 40px rgba(255, 255, 255, 0.05);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.vignette {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(to top, rgba(6, 14, 32, 0.82), transparent 50%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.tilt-hint {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: rgba(45, 52, 73, 0.6);
|
||||
backdrop-filter: blur(12px);
|
||||
padding: 14px 22px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.tilt-hint-icon {
|
||||
font-size: 28px;
|
||||
line-height: 1;
|
||||
color: #ddb7ff;
|
||||
}
|
||||
|
||||
.tilt-hint-text {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
color: #dae2fd;
|
||||
}
|
||||
|
||||
.tilt-hint-sub {
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.04em;
|
||||
color: rgba(207, 194, 214, 0.75);
|
||||
text-align: center;
|
||||
max-width: 200px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
</style>
|
||||
155
frontend/composables/useLenticularPreview.js
Normal file
@ -0,0 +1,155 @@
|
||||
/**
|
||||
* 光栅卡预览:触摸模拟倾斜 + LenticularEngine(不启动硬件传感器)
|
||||
* @param {import('vue').Ref<Array>} 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,
|
||||
}
|
||||
}
|
||||
768
frontend/composables/useLenticularStudioTilt.js
Normal file
@ -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<string>} 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,
|
||||
}
|
||||
}
|
||||
@ -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'
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,101 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>files</key>
|
||||
<dict>
|
||||
<key>Info.plist</key>
|
||||
<data>
|
||||
8XOwyvu+OiYnMWBub0JdOOoIL9c=
|
||||
</data>
|
||||
</dict>
|
||||
<key>files2</key>
|
||||
<dict/>
|
||||
<key>rules</key>
|
||||
<dict>
|
||||
<key>^.*</key>
|
||||
<true/>
|
||||
<key>^.*\.lproj/</key>
|
||||
<dict>
|
||||
<key>optional</key>
|
||||
<true/>
|
||||
<key>weight</key>
|
||||
<real>1000</real>
|
||||
</dict>
|
||||
<key>^.*\.lproj/locversion.plist$</key>
|
||||
<dict>
|
||||
<key>omit</key>
|
||||
<true/>
|
||||
<key>weight</key>
|
||||
<real>1100</real>
|
||||
</dict>
|
||||
<key>^Base\.lproj/</key>
|
||||
<dict>
|
||||
<key>weight</key>
|
||||
<real>1010</real>
|
||||
</dict>
|
||||
<key>^version.plist$</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<key>rules2</key>
|
||||
<dict>
|
||||
<key>.*\.dSYM($|/)</key>
|
||||
<dict>
|
||||
<key>weight</key>
|
||||
<real>11</real>
|
||||
</dict>
|
||||
<key>^(.*/)?\.DS_Store$</key>
|
||||
<dict>
|
||||
<key>omit</key>
|
||||
<true/>
|
||||
<key>weight</key>
|
||||
<real>2000</real>
|
||||
</dict>
|
||||
<key>^.*</key>
|
||||
<true/>
|
||||
<key>^.*\.lproj/</key>
|
||||
<dict>
|
||||
<key>optional</key>
|
||||
<true/>
|
||||
<key>weight</key>
|
||||
<real>1000</real>
|
||||
</dict>
|
||||
<key>^.*\.lproj/locversion.plist$</key>
|
||||
<dict>
|
||||
<key>omit</key>
|
||||
<true/>
|
||||
<key>weight</key>
|
||||
<real>1100</real>
|
||||
</dict>
|
||||
<key>^Base\.lproj/</key>
|
||||
<dict>
|
||||
<key>weight</key>
|
||||
<real>1010</real>
|
||||
</dict>
|
||||
<key>^Info\.plist$</key>
|
||||
<dict>
|
||||
<key>omit</key>
|
||||
<true/>
|
||||
<key>weight</key>
|
||||
<real>20</real>
|
||||
</dict>
|
||||
<key>^PkgInfo$</key>
|
||||
<dict>
|
||||
<key>omit</key>
|
||||
<true/>
|
||||
<key>weight</key>
|
||||
<real>20</real>
|
||||
</dict>
|
||||
<key>^embedded\.provisionprofile$</key>
|
||||
<dict>
|
||||
<key>weight</key>
|
||||
<real>20</real>
|
||||
</dict>
|
||||
<key>^version\.plist$</key>
|
||||
<dict>
|
||||
<key>weight</key>
|
||||
<real>20</real>
|
||||
</dict>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
30
frontend/nativePlugins/imengyu-UniAndroidGyro/package.json
Normal file
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@ -5,9 +5,12 @@
|
||||
<!-- 左侧图片卡片区域 - 半圆弧形布局 -->
|
||||
<view class="cards-container">
|
||||
<view v-for="(card, index) in cardList" :key="index" class="card-item"
|
||||
:class="{ 'card-selected': selectedIndex === index }" :style="getCardStyle(index)"
|
||||
@click="selectCard(index)">
|
||||
<view class="card-frame" :class="{ 'no-border': card.comingSoon }">
|
||||
:class="{ 'card-selected': selectedIndex === index }" :style="getCardStyle(index)">
|
||||
<view
|
||||
class="card-frame"
|
||||
:class="{ 'no-border': card.comingSoon, 'card-frame--tappable': !card.comingSoon }"
|
||||
@tap.stop="onCardFrameTap(index)"
|
||||
>
|
||||
<image class="card-image" :src="card.image" mode="aspectFill" />
|
||||
<image v-if="card.comingSoon" class="coming-soon-badge" src="/static/castlove/jinqingqidai.png" mode="aspectFit" />
|
||||
</view>
|
||||
@ -48,6 +51,18 @@
|
||||
|
||||
<script>
|
||||
export default {
|
||||
onShow() {
|
||||
try {
|
||||
uni.hideToast();
|
||||
} catch (e) {
|
||||
/* noop */
|
||||
}
|
||||
try {
|
||||
uni.hideLoading();
|
||||
} catch (e) {
|
||||
/* noop */
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
selectedIndex: 2, // 初始选中光栅卡
|
||||
@ -99,6 +114,35 @@ export default {
|
||||
selectCard(index) {
|
||||
this.selectedIndex = index;
|
||||
},
|
||||
/** 当前叠卡在弧形布局中的槽位:2=正中主图(最大),1=中上「第二张」叠层,0最上… */
|
||||
getCardStackPosition(index) {
|
||||
return (index - this.selectedIndex + 2 + 5) % 5;
|
||||
},
|
||||
/**
|
||||
* 点击卡图区域:
|
||||
* - 正中主图(槽位 2)→ 进入对应工艺创建页(光栅卡即进入已接入预览的 create)
|
||||
* - 中上叠层(槽位 1,常为拍立得示意)在选中光栅时 → 同样进入光栅卡创建(与设计稿「点第二张进光栅」一致)
|
||||
* - 其余叠层 → 仅切换选中
|
||||
*/
|
||||
onCardFrameTap(index) {
|
||||
const card = this.cardList[index];
|
||||
if (!card || card.comingSoon) {
|
||||
return;
|
||||
}
|
||||
const pos = this.getCardStackPosition(index);
|
||||
const LENTICULAR_INDEX = 2;
|
||||
const goLenticular =
|
||||
this.selectedIndex === LENTICULAR_INDEX &&
|
||||
(pos === 2 || pos === 1);
|
||||
const targetName = goLenticular ? '光栅卡' : card.name;
|
||||
if (pos === 2 || goLenticular) {
|
||||
uni.navigateTo({
|
||||
url: `/pages/castlove/create?name=${encodeURIComponent(targetName)}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
this.selectCard(index);
|
||||
},
|
||||
handleBack() {
|
||||
uni.navigateBack()
|
||||
},
|
||||
@ -115,13 +159,14 @@ export default {
|
||||
}
|
||||
},
|
||||
handleSkip() {
|
||||
// 跳过,选择默认卡片或第一张
|
||||
const defaultIndex = this.cardList.findIndex(card => !card.comingSoon);
|
||||
if (defaultIndex !== -1) {
|
||||
uni.navigateTo({
|
||||
url: `/pages/castlove/create?name=${encodeURIComponent(this.cardList[defaultIndex].name)}`
|
||||
});
|
||||
const card = this.cardList[this.selectedIndex]
|
||||
if (!card || card.comingSoon) {
|
||||
uni.showToast({ title: '请选择已开放的工艺', icon: 'none' })
|
||||
return
|
||||
}
|
||||
uni.navigateTo({
|
||||
url: `/pages/castlove/create?name=${encodeURIComponent(card.name)}`
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -156,8 +201,10 @@ export default {
|
||||
width: 344rpx;
|
||||
height: 344rpx;
|
||||
transition: all 0.6s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.card-frame--tappable {
|
||||
cursor: pointer;
|
||||
|
||||
}
|
||||
|
||||
.card-frame {
|
||||
|
||||
117
frontend/pages/castlove/create-laser-upload.css
Normal file
@ -0,0 +1,117 @@
|
||||
/**
|
||||
* 铸爱 create 页 — 镭射卡上传区样式(独立 CSS,由 create.vue import,确保各端构建必进包)
|
||||
* 根类 castlove-laser-upload-root 全局唯一,避免 scoped / 第二 style 块在部分端未生效
|
||||
*/
|
||||
.castlove-laser-upload-root {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
/* 略偏竖版小卡比例,接近设计稿「大卡 + 内虚线」观感 */
|
||||
height: 520rpx;
|
||||
box-sizing: border-box;
|
||||
border-radius: 32rpx;
|
||||
border: 1rpx solid rgba(255, 255, 255, 0.38);
|
||||
background: rgba(255, 255, 255, 0.18);
|
||||
backdrop-filter: blur(22rpx);
|
||||
-webkit-backdrop-filter: blur(22rpx);
|
||||
overflow: hidden;
|
||||
box-shadow:
|
||||
0 22rpx 56rpx rgba(40, 22, 80, 0.36),
|
||||
inset 0 0 0 1rpx rgba(255, 255, 255, 0.22);
|
||||
}
|
||||
|
||||
.castlove-laser-upload-root .uploaded-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 24rpx;
|
||||
box-shadow: inset 0 0 0 1rpx rgba(255, 255, 255, 0.22);
|
||||
}
|
||||
|
||||
.castlove-laser-upload-root .upload-laser-stack {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* 与设计稿一致:卡内淡色「卡面 / 人像」底图(与工艺选择同款素材) */
|
||||
.castlove-laser-upload-root .upload-laser-photo-bg {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 0;
|
||||
opacity: 0.22;
|
||||
pointer-events: none;
|
||||
filter: saturate(1.15) contrast(0.92);
|
||||
}
|
||||
|
||||
.castlove-laser-upload-root .upload-laser-watermark {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 1;
|
||||
opacity: 1;
|
||||
pointer-events: none;
|
||||
/* 叠在底图上:压暗边缘 + 轻微幻彩,避免只剩「灰蒙一块」 */
|
||||
background:
|
||||
linear-gradient(
|
||||
118deg,
|
||||
rgba(255, 170, 220, 0.12) 0%,
|
||||
transparent 40%,
|
||||
rgba(170, 210, 255, 0.1) 62%,
|
||||
transparent 82%,
|
||||
rgba(255, 210, 190, 0.08) 100%
|
||||
),
|
||||
radial-gradient(ellipse 55% 75% at 50% 100%, rgba(35, 22, 70, 0.45), transparent 58%),
|
||||
radial-gradient(ellipse 40% 48% at 50% 32%, rgba(100, 75, 150, 0.28), transparent 55%),
|
||||
linear-gradient(185deg, rgba(255, 220, 245, 0.08) 0%, rgba(55, 35, 95, 0.25) 100%);
|
||||
}
|
||||
|
||||
.castlove-laser-upload-root .upload-laser-inset {
|
||||
position: absolute;
|
||||
/* 收紧留白:虚线框更贴外框,接近设计稿「整卡即相框」 */
|
||||
left: 14rpx;
|
||||
right: 14rpx;
|
||||
top: 14rpx;
|
||||
bottom: 14rpx;
|
||||
z-index: 2;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.castlove-laser-upload-root .upload-laser-dashed-inner {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
border: 2rpx dashed rgba(255, 255, 255, 0.95);
|
||||
border-radius: 26rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 20rpx;
|
||||
padding: 24rpx 16rpx;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
|
||||
.castlove-laser-upload-root .upload-plus--laser {
|
||||
font-size: 96rpx;
|
||||
line-height: 0.88;
|
||||
color: #ffffff;
|
||||
font-weight: 100;
|
||||
text-shadow: 0 2rpx 18rpx rgba(60, 30, 100, 0.35);
|
||||
}
|
||||
|
||||
.castlove-laser-upload-root .upload-text--laser {
|
||||
font-size: 28rpx;
|
||||
color: rgba(255, 255, 255, 0.96);
|
||||
font-weight: 500;
|
||||
letter-spacing: 1rpx;
|
||||
text-shadow: 0 2rpx 12rpx rgba(40, 20, 80, 0.4);
|
||||
}
|
||||
3428
frontend/pages/castlove/laser-card-studio.vue
Normal file
482
frontend/pages/castlove/lenticular-studio.vue
Normal file
@ -0,0 +1,482 @@
|
||||
<template>
|
||||
<view class="physics-page">
|
||||
<view class="top-bar">
|
||||
<view class="top-bar-btn" @tap="goBack">
|
||||
<text class="nav-glyph">←</text>
|
||||
</view>
|
||||
<view class="top-bar-title">
|
||||
<text class="top-bar-title-text">光栅卡工作室</text>
|
||||
<text class="top-bar-subtitle">倾斜手机 · 重力感应预览</text>
|
||||
</view>
|
||||
<view class="top-bar-actions">
|
||||
<view class="top-bar-chip" @tap.stop="onRecalibrate">
|
||||
<text class="top-bar-chip-text">校零</text>
|
||||
</view>
|
||||
<view class="top-bar-btn" @tap="onMore">
|
||||
<text class="nav-glyph">⋮</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="canvas-wrap">
|
||||
<view class="canvas-bg" />
|
||||
<LenticularCard
|
||||
class="preview-stack"
|
||||
:layers="layers"
|
||||
:transforms="layerTransforms"
|
||||
:gyro-source="gyroSourceLabel"
|
||||
:skip-built-in-touch="true"
|
||||
:tilt-hint-text="''"
|
||||
@simulate="simulate"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<view class="tool-dock">
|
||||
<view class="dock-rim" />
|
||||
<view class="layer-strip">
|
||||
<view class="layer-strip-header">
|
||||
<text class="layer-strip-title">图层素材</text>
|
||||
<text class="layer-strip-hint">点击替换 · 长按重置</text>
|
||||
</view>
|
||||
<view class="layer-slots">
|
||||
<view
|
||||
v-for="layer in layers"
|
||||
:key="layer.id"
|
||||
class="layer-slot"
|
||||
:class="{ 'layer-slot--filled': !!layer.src }"
|
||||
@tap="pickImage(layer.id)"
|
||||
@longpress="resetLayer(layer.id)"
|
||||
>
|
||||
<image v-if="layer.src" class="layer-slot-img" :src="layer.src" mode="aspectFill" />
|
||||
<view v-else class="layer-slot-placeholder" :style="placeholderStyle(layer)">
|
||||
<text class="layer-slot-icon">+</text>
|
||||
</view>
|
||||
<view class="layer-slot-meta">
|
||||
<text class="layer-slot-name">{{ layer.label }}</text>
|
||||
<text class="layer-slot-tag">{{ layer.src ? '已上传' : '点击上传' }}</text>
|
||||
</view>
|
||||
<view v-if="layer.src" class="layer-slot-badge" @tap.stop="resetLayer(layer.id)">
|
||||
<text class="layer-slot-badge-icon">×</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
inheritAttrs: false,
|
||||
};
|
||||
</script>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted, nextTick } from 'vue'
|
||||
import LenticularCard from '@/components/lenticular/LenticularCard.vue'
|
||||
import { useLenticularPreview } from '@/composables/useLenticularPreview.js'
|
||||
import { useLenticularStudioTilt } from '@/composables/useLenticularStudioTilt.js'
|
||||
import {
|
||||
buildLenticularLayersTwo,
|
||||
LENTICULAR_STUDIO_STORAGE_KEY,
|
||||
} from '@/utils/castloveMintForm.js'
|
||||
|
||||
const layers = ref([])
|
||||
const gyroSourceLabel = ref('simulation')
|
||||
|
||||
const { physics, layerTransforms, simulate } = useLenticularPreview(layers)
|
||||
|
||||
/** 相对进入时基准的倾角标量(度),每满 15° 一档;左右倾斜等价 */
|
||||
const STUDIO_DISCRETE_STEP_DEG = 15
|
||||
/** 档位施密特迟滞(度),减轻在档位边界来回跳 */
|
||||
const DISCRETE_STEP_HYST_DEG = 4
|
||||
|
||||
const discreteStableStep = ref(0)
|
||||
|
||||
/**
|
||||
* 将相对基准的倾角标量(度,非负)映射为 LenticularEngine 的 gamma。
|
||||
* @param {number} tiltMagDeg 相对校零的倾角幅度(度);由 composable 已做低通时可为非负标量
|
||||
*/
|
||||
function simulateFromSignedDegrees(tiltMagDeg) {
|
||||
const ls = layers.value || []
|
||||
const n = Math.max(1, ls.length)
|
||||
const absDeg = Math.abs(Number(tiltMagDeg) || 0)
|
||||
const STEP = STUDIO_DISCRETE_STEP_DEG
|
||||
const MARGIN = DISCRETE_STEP_HYST_DEG
|
||||
let s = discreteStableStep.value
|
||||
while (absDeg >= (s + 1) * STEP + MARGIN) s++
|
||||
while (s > 0 && absDeg <= s * STEP - MARGIN) s--
|
||||
discreteStableStep.value = s
|
||||
const idx = s % n
|
||||
const sens = physics.tiltSensitivity / 100
|
||||
const mul = 0.44 + sens * 0.52
|
||||
const u = (idx + 0.5) / n
|
||||
const gPick = Math.max(-1, Math.min(1, 2 * u - 1))
|
||||
const gamma = Math.max(-1, Math.min(1, gPick / mul))
|
||||
simulate(gamma, 0)
|
||||
}
|
||||
|
||||
const { start: startTilt, stop: stopTilt, recalibrate: recalibrateTilt } = useLenticularStudioTilt({
|
||||
simulate,
|
||||
simulateFromSignedDegrees,
|
||||
gyroSourceLabel,
|
||||
useStudioAccelDirect: true,
|
||||
onTiltDriverFallback: () => {
|
||||
discreteStableStep.value = 0
|
||||
},
|
||||
})
|
||||
|
||||
function goBack() {
|
||||
uni.navigateBack({
|
||||
delta: 1,
|
||||
fail: () => {},
|
||||
})
|
||||
}
|
||||
|
||||
function onMore() {
|
||||
uni.showActionSheet({
|
||||
itemList: ['恢复为进入工作室时的图片'],
|
||||
success: (res) => {
|
||||
if (res.tapIndex === 0) {
|
||||
const raw = uni.getStorageSync(LENTICULAR_STUDIO_STORAGE_KEY)
|
||||
if (!raw) return
|
||||
try {
|
||||
const p = typeof raw === 'string' ? JSON.parse(raw) : raw
|
||||
layers.value = buildLenticularLayersTwo(p.bgPath || '', p.subjectPath || '')
|
||||
uni.showToast({ title: '已恢复', icon: 'none' })
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function placeholderStyle(layer) {
|
||||
if (layer.background) {
|
||||
return { background: layer.background }
|
||||
}
|
||||
return { background: 'rgba(6,14,32,0.55)' }
|
||||
}
|
||||
|
||||
function pickImage(layerId) {
|
||||
uni.chooseImage({
|
||||
count: 1,
|
||||
sizeType: ['compressed'],
|
||||
sourceType: ['album', 'camera'],
|
||||
success: (res) => {
|
||||
const path = res.tempFilePaths && res.tempFilePaths[0]
|
||||
if (!path) return
|
||||
const idx = layers.value.findIndex((l) => l.id === layerId)
|
||||
if (idx < 0) return
|
||||
const cur = layers.value[idx]
|
||||
const next = { ...cur, src: path, background: undefined }
|
||||
if (layerId === 'base') next.dots = undefined
|
||||
const copy = [...layers.value]
|
||||
copy[idx] = next
|
||||
layers.value = copy
|
||||
},
|
||||
fail: (err) => {
|
||||
const msg = (err && err.errMsg) || ''
|
||||
if (!msg.includes('cancel')) {
|
||||
uni.showToast({ title: '选择图片失败', icon: 'none' })
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function resetLayer(layerId) {
|
||||
const fresh = buildLenticularLayersTwo('', '')
|
||||
const defLayer = fresh.find((l) => l.id === layerId)
|
||||
const idx = layers.value.findIndex((l) => l.id === layerId)
|
||||
if (idx < 0 || !defLayer) return
|
||||
const copy = [...layers.value]
|
||||
copy[idx] = { ...defLayer }
|
||||
layers.value = copy
|
||||
}
|
||||
|
||||
function onRecalibrate() {
|
||||
discreteStableStep.value = 0
|
||||
recalibrateTilt()
|
||||
uni.showToast({ title: '已重置水平基准', icon: 'none' })
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
try {
|
||||
const raw = uni.getStorageSync(LENTICULAR_STUDIO_STORAGE_KEY)
|
||||
if (!raw) {
|
||||
uni.showToast({ title: '缺少素材数据,请返回上一步', icon: 'none' })
|
||||
setTimeout(() => goBack(), 1600)
|
||||
return
|
||||
}
|
||||
const p = typeof raw === 'string' ? JSON.parse(raw) : raw
|
||||
layers.value = buildLenticularLayersTwo(p.bgPath || '', p.subjectPath || '')
|
||||
} catch (e) {
|
||||
console.error('[lenticular-studio] load payload', e)
|
||||
uni.showToast({ title: '数据无效', icon: 'none' })
|
||||
setTimeout(() => goBack(), 1600)
|
||||
return
|
||||
}
|
||||
nextTick(() => {
|
||||
/* 离散换图:略降过渡混合,档位切换更利落 */
|
||||
physics.angleStability = 6
|
||||
physics.transitionSmoothness = 12
|
||||
physics.tiltSensitivity = 96
|
||||
physics.sensorDeadzoneStrength = 0
|
||||
startTilt()
|
||||
})
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
stopTilt()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.nav-glyph {
|
||||
font-size: 22px;
|
||||
line-height: 1;
|
||||
font-weight: 600;
|
||||
color: inherit;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style scoped>
|
||||
.physics-page {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #0b1326;
|
||||
color: #dae2fd;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.top-bar {
|
||||
position: relative;
|
||||
z-index: 50;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0 16px;
|
||||
height: 56px;
|
||||
padding-top: calc(env(safe-area-inset-top) + 4px);
|
||||
background: rgba(11, 19, 38, 0.88);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.top-bar-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.top-bar-chip {
|
||||
padding: 6px 12px;
|
||||
border-radius: 999px;
|
||||
background: rgba(45, 52, 73, 0.75);
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
.top-bar-chip-text {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #ddb7ff;
|
||||
}
|
||||
|
||||
.top-bar-btn {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #ddb7ff;
|
||||
}
|
||||
|
||||
.top-bar-title {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.top-bar-title-text {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #dae2fd;
|
||||
}
|
||||
|
||||
.top-bar-subtitle {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.16em;
|
||||
color: #4cd7f6;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.canvas-wrap {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 8px 10px 4px;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.preview-stack {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.canvas-bg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background:
|
||||
radial-gradient(ellipse at 50% 35%, rgba(132, 43, 210, 0.18), transparent 55%),
|
||||
radial-gradient(ellipse at 50% 85%, rgba(76, 215, 246, 0.1), transparent 60%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.tool-dock {
|
||||
position: relative;
|
||||
z-index: 20;
|
||||
margin: 0 10px calc(10px + env(safe-area-inset-bottom));
|
||||
max-width: 460px;
|
||||
align-self: center;
|
||||
width: calc(100% - 20px);
|
||||
background: rgba(34, 42, 61, 0.88);
|
||||
border: 1px solid rgba(77, 67, 84, 0.45);
|
||||
border-radius: 20px;
|
||||
padding: 12px 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
box-shadow: 0 18px 50px rgba(0, 0, 0, 0.4);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.dock-rim {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 1px;
|
||||
background: linear-gradient(to right, transparent, rgba(221, 183, 255, 0.45), transparent);
|
||||
}
|
||||
|
||||
.layer-strip {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.layer-strip-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0 2px;
|
||||
}
|
||||
|
||||
.layer-strip-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #dae2fd;
|
||||
}
|
||||
|
||||
.layer-strip-hint {
|
||||
font-size: 11px;
|
||||
color: rgba(207, 194, 214, 0.6);
|
||||
}
|
||||
|
||||
.layer-slots {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.layer-slot {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
background: rgba(6, 14, 32, 0.55);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.layer-slot--filled {
|
||||
border-color: rgba(76, 215, 246, 0.5);
|
||||
box-shadow: 0 0 14px rgba(76, 215, 246, 0.15);
|
||||
}
|
||||
|
||||
.layer-slot-img {
|
||||
width: 100%;
|
||||
height: 56px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.layer-slot-placeholder {
|
||||
width: 100%;
|
||||
height: 56px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
}
|
||||
|
||||
.layer-slot-icon {
|
||||
font-size: 22px;
|
||||
color: rgba(221, 183, 255, 0.85);
|
||||
}
|
||||
|
||||
.layer-slot-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 6px 8px 8px;
|
||||
gap: 2px;
|
||||
background: rgba(6, 14, 32, 0.55);
|
||||
}
|
||||
|
||||
.layer-slot-name {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #dae2fd;
|
||||
}
|
||||
|
||||
.layer-slot-tag {
|
||||
font-size: 10px;
|
||||
color: rgba(207, 194, 214, 0.7);
|
||||
}
|
||||
|
||||
.layer-slot--filled .layer-slot-tag {
|
||||
color: #4cd7f6;
|
||||
}
|
||||
|
||||
.layer-slot-badge {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
}
|
||||
|
||||
.layer-slot-badge-icon {
|
||||
font-size: 14px;
|
||||
color: #dae2fd;
|
||||
}
|
||||
</style>
|
||||
@ -12,10 +12,10 @@
|
||||
</scroll-view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineOptions({
|
||||
name: 'HorizontalScroll'
|
||||
});
|
||||
<script>
|
||||
export default {
|
||||
name: 'HorizontalScroll',
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@ -45,6 +45,7 @@ import DiscoverFeed from './discover-feed.vue';
|
||||
import CreateFeed from './create-feed.vue';
|
||||
// 导入API
|
||||
import { getOssSignatureApi, createMintOrderApi } from '@/utils/api.js';
|
||||
import { resolveH5OssPostUrl } from '@/utils/h5OssPostUrl.js';
|
||||
|
||||
const activeTab = ref(0);
|
||||
const formData = ref(null);
|
||||
@ -97,7 +98,7 @@ const uploadImageToOss = async (base64Image, ossData) => {
|
||||
formData.append('x-oss-signature-version', ossData.x_oss_signature_version);
|
||||
formData.append('file', blob, fileName);
|
||||
|
||||
const response = await fetch(ossData.host, {
|
||||
const response = await fetch(resolveH5OssPostUrl(ossData.host), {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
@ -203,16 +204,16 @@ const handleSkip = async () => {
|
||||
// 更新加载提示
|
||||
uni.showLoading({ title: '创建订单中...', mask: true });
|
||||
|
||||
// 构建订单数据
|
||||
// 构建订单数据(对齐 CreateMintOrderRequestDTO)
|
||||
const orderData = {
|
||||
name: orderValue.name,
|
||||
event: orderValue.event,
|
||||
description: orderValue.remark || '',
|
||||
material_type: orderValue.materialType || 'image',
|
||||
order_id: orderId,
|
||||
name: orderValue.name || '',
|
||||
material_url: imageUrl,
|
||||
rarity: 0,
|
||||
tags: [],
|
||||
order_id: orderId
|
||||
description: orderValue.description || '',
|
||||
grade: orderValue.grade ?? 0,
|
||||
tags: Array.isArray(orderValue.tags) ? orderValue.tags : [],
|
||||
material_type: orderValue.material_type || orderValue.materialType || '',
|
||||
info: orderValue.info || orderValue.event || '',
|
||||
};
|
||||
|
||||
// 调用创建铸造订单API
|
||||
@ -229,11 +230,13 @@ const handleSkip = async () => {
|
||||
// 构建藏品数据,存储到temp_nft_data
|
||||
const nftData = {
|
||||
image: imageUrl,
|
||||
name: orderValue.name,
|
||||
event: orderValue.event,
|
||||
description: orderValue.remark || '',
|
||||
material_type: orderValue.materialType || 'image',
|
||||
order_id: orderId
|
||||
name: orderValue.name || '',
|
||||
description: orderValue.description || orderValue.remark || '',
|
||||
material_type: orderValue.material_type || orderValue.materialType || '',
|
||||
tags: Array.isArray(orderValue.tags) ? orderValue.tags : [],
|
||||
order_id: orderId,
|
||||
info: orderValue.info || '',
|
||||
event: orderValue.info || orderValue.event || '',
|
||||
};
|
||||
|
||||
// 存储到storage
|
||||
|
||||
@ -75,6 +75,7 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { getOssSignatureApi, createMintOrderApi } from '@/utils/api.js';
|
||||
import { resolveH5OssPostUrl } from '@/utils/h5OssPostUrl.js';
|
||||
|
||||
const generatedImages = ref([]);
|
||||
const selectedIndex = ref(-1);
|
||||
@ -422,12 +423,12 @@ const uploadToOssH5 = async (base64Image, fileName, ossData) => {
|
||||
formData.append('file', blob, fileName);
|
||||
|
||||
// 使用fetch上传
|
||||
fetch(ossData.host, {
|
||||
fetch(resolveH5OssPostUrl(ossData.host), {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
.then(response => {
|
||||
if (response.ok) {
|
||||
if (response.ok || response.status === 204) {
|
||||
const imageUrl = `${ossData.host}/${ossData.dir}${fileName}`;
|
||||
uni.hideLoading();
|
||||
uni.showToast({
|
||||
@ -656,17 +657,16 @@ const selectAsset = async () => {
|
||||
const formDataStr = uni.getStorageSync('castlove_form_data');
|
||||
const orderValue = formDataStr ? JSON.parse(formDataStr) : {};
|
||||
|
||||
// 构建订单数据
|
||||
// 构建订单数据(对齐 CreateMintOrderRequestDTO)
|
||||
const orderData = {
|
||||
name: orderValue.name,
|
||||
event: orderValue.event,
|
||||
description: orderValue.description,
|
||||
material_type: orderValue.material_type,
|
||||
material_url: imageUrl,
|
||||
rarity: 0,
|
||||
tags: [],
|
||||
order_id: currentOrderId.value,
|
||||
info: orderValue.info || '' // 添加info字段
|
||||
name: orderValue.name || '',
|
||||
material_url: imageUrl,
|
||||
description: orderValue.description || '',
|
||||
grade: orderValue.grade ?? 0,
|
||||
tags: Array.isArray(orderValue.tags) ? orderValue.tags : [],
|
||||
material_type: orderValue.material_type || orderValue.materialType || '',
|
||||
info: orderValue.info || orderValue.event || '',
|
||||
};
|
||||
|
||||
// 调用创建铸造订单API
|
||||
@ -679,12 +679,14 @@ const selectAsset = async () => {
|
||||
uni.removeStorageSync('castlove_form_data');
|
||||
// 构建藏品数据,存储到temp_nft_data
|
||||
const nftData = {
|
||||
image: imageUrl, // 使用OSS URL
|
||||
name: orderValue.name,
|
||||
event: orderValue.event,
|
||||
description: orderValue.description,
|
||||
material_type: orderValue.material_type,
|
||||
order_id: currentOrderId.value // 使用ref保存的order_id
|
||||
image: imageUrl,
|
||||
name: orderValue.name || '',
|
||||
description: orderValue.description || '',
|
||||
material_type: orderValue.material_type || orderValue.materialType || '',
|
||||
tags: Array.isArray(orderValue.tags) ? orderValue.tags : [],
|
||||
order_id: currentOrderId.value,
|
||||
info: orderValue.info || '',
|
||||
event: orderValue.info || orderValue.event || '',
|
||||
};
|
||||
|
||||
// 存储到storage
|
||||
|
||||
55
frontend/scripts/generate_app_icons.py
Normal file
@ -0,0 +1,55 @@
|
||||
"""One-off: generate PNGs under unpackage/res/icons for manifest.json paths."""
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
# 勿放 unpackage/(.gitignore),否则云端/他人拉代码后 HBuilder 仍报图标不存在
|
||||
OUT = ROOT / "static" / "app-icons"
|
||||
|
||||
BG = (0x4A, 0x6B, 0xCF)
|
||||
ACCENT = (0xFF, 0xFF, 0xFF)
|
||||
|
||||
SIZES = {
|
||||
"72x72.png": 72,
|
||||
"96x96.png": 96,
|
||||
"144x144.png": 144,
|
||||
"192x192.png": 192,
|
||||
"1024x1024.png": 1024,
|
||||
"76x76.png": 76,
|
||||
"152x152.png": 152,
|
||||
"20x20.png": 20,
|
||||
"40x40.png": 40,
|
||||
"167x167.png": 167,
|
||||
"29x29.png": 29,
|
||||
"58x58.png": 58,
|
||||
"80x80.png": 80,
|
||||
"120x120.png": 120,
|
||||
"180x180.png": 180,
|
||||
"60x60.png": 60,
|
||||
"87x87.png": 87,
|
||||
}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
OUT.mkdir(parents=True, exist_ok=True)
|
||||
for fname, w in SIZES.items():
|
||||
h = w
|
||||
im = Image.new("RGB", (w, h), BG)
|
||||
draw = ImageDraw.Draw(im)
|
||||
margin = max(1, w // 8)
|
||||
draw.ellipse(
|
||||
[margin, margin, w - margin, h - margin],
|
||||
outline=ACCENT,
|
||||
width=max(1, w // 32),
|
||||
)
|
||||
if w >= 48:
|
||||
r = w // 5
|
||||
cx, cy = w // 2, h // 2
|
||||
draw.ellipse([cx - r, cy - r, cx + r, cy + r], fill=ACCENT)
|
||||
im.save(OUT / fname, format="PNG", optimize=True)
|
||||
print("wrote", len(SIZES), "icons to", OUT)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
BIN
frontend/static/app-icons/1024x1024.png
Normal file
|
After Width: | Height: | Size: 8.6 KiB |
BIN
frontend/static/app-icons/120x120.png
Normal file
|
After Width: | Height: | Size: 794 B |
BIN
frontend/static/app-icons/144x144.png
Normal file
|
After Width: | Height: | Size: 990 B |
BIN
frontend/static/app-icons/152x152.png
Normal file
|
After Width: | Height: | Size: 1.0 KiB |
BIN
frontend/static/app-icons/167x167.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
frontend/static/app-icons/180x180.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
frontend/static/app-icons/192x192.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
frontend/static/app-icons/20x20.png
Normal file
|
After Width: | Height: | Size: 168 B |
BIN
frontend/static/app-icons/29x29.png
Normal file
|
After Width: | Height: | Size: 206 B |
BIN
frontend/static/app-icons/40x40.png
Normal file
|
After Width: | Height: | Size: 237 B |
BIN
frontend/static/app-icons/58x58.png
Normal file
|
After Width: | Height: | Size: 377 B |
BIN
frontend/static/app-icons/60x60.png
Normal file
|
After Width: | Height: | Size: 386 B |
BIN
frontend/static/app-icons/72x72.png
Normal file
|
After Width: | Height: | Size: 462 B |
BIN
frontend/static/app-icons/76x76.png
Normal file
|
After Width: | Height: | Size: 497 B |
BIN
frontend/static/app-icons/80x80.png
Normal file
|
After Width: | Height: | Size: 525 B |
BIN
frontend/static/app-icons/87x87.png
Normal file
|
After Width: | Height: | Size: 607 B |
BIN
frontend/static/app-icons/96x96.png
Normal file
|
After Width: | Height: | Size: 658 B |
BIN
frontend/static/castlove/laser-bg/laser-bg-1.png
Normal file
|
After Width: | Height: | Size: 55 KiB |
BIN
frontend/static/castlove/laser-bg/laser-bg-2.png
Normal file
|
After Width: | Height: | Size: 69 KiB |
BIN
frontend/static/castlove/laser-bg/laser-bg-3.png
Normal file
|
After Width: | Height: | Size: 69 KiB |
@ -4,23 +4,28 @@
|
||||
|
||||
// #ifdef H5
|
||||
// const baseURL = 'http://localhost:8080' // H5 开发用本机
|
||||
const baseURL = 'http://192.168.110.60:8080' // H5 开发用本机
|
||||
const baseURL = 'http://101.132.250.62:8080' // H5 开发用本机
|
||||
// #endif
|
||||
|
||||
// #ifdef APP-PLUS
|
||||
// 开发调试:手机和电脑同一WiFi时用这个(改成你电脑IP)
|
||||
// 上线后:改成实际服务器地址
|
||||
const baseURL = 'http://192.168.110.60:8080'
|
||||
// const baseURL = 'http://192.168.110.60:8080'
|
||||
// #endif
|
||||
|
||||
// 服务器地址(正式上线用)
|
||||
// #ifdef APP-PLUS
|
||||
// const baseURL = 'http://101.132.250.62:8080'
|
||||
const baseURL = 'http://101.132.250.62:8080'
|
||||
// #endif
|
||||
|
||||
// 是否使用模拟数据(开发调试时设为 true,后端API准备好后改为 false)
|
||||
const USE_MOCK_API = false
|
||||
|
||||
/** 网关根地址(供 uni.uploadFile 等无法走 request 封装的场景拼接完整 URL) */
|
||||
export function getApiBaseUrl() {
|
||||
return String(baseURL).replace(/\/+$/, '')
|
||||
}
|
||||
|
||||
// 模拟网络延迟
|
||||
function mockDelay(ms = 800) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms))
|
||||
@ -414,15 +419,30 @@ export function removeAssetFromGalleryApi(slotId) {
|
||||
})
|
||||
}
|
||||
|
||||
// 获取OSS上传签名
|
||||
export function getOssSignatureApi(type) {
|
||||
/**
|
||||
* 获取 OSS 浏览器直传凭证(PostObject 表单字段)
|
||||
* 对应网关:GET /api/v1/assets/oss/upload-signature(与 /oss/signature 同一处理逻辑,此处走阶段一推荐路径)
|
||||
* @param {'avatar'|'asset'} [type='asset'] 上传目录类型
|
||||
* @param {string} [orderId] 可选;传入则与后续 POST /api/v1/assets/mints 使用同一 order_id(后端 InitMintOrder)
|
||||
*/
|
||||
export function getOssSignatureApi(type = 'asset', orderId = '') {
|
||||
let url = `/api/v1/assets/oss/upload-signature?type=${encodeURIComponent(type)}`
|
||||
if (orderId) {
|
||||
url += `&order_id=${encodeURIComponent(orderId)}`
|
||||
}
|
||||
return request({
|
||||
url: `/api/v1/assets/oss/signature?type=${type}`,
|
||||
url,
|
||||
method: 'GET'
|
||||
})
|
||||
}
|
||||
|
||||
// 获取OSS预签名URL(用于读取图片)
|
||||
/** 兼容旧调用名:与 getOssSignatureApi 相同 */
|
||||
export const getOssUploadSignatureApi = getOssSignatureApi
|
||||
|
||||
/**
|
||||
* 获取 OSS 预签名 GET URL(私有桶读图)
|
||||
* 对应网关:GET /api/v1/assets/oss/presigned-url
|
||||
*/
|
||||
export function getOssPresignedUrlApi(fileName, expires = 3600, type = 'avatar') {
|
||||
return request({
|
||||
url: `/api/v1/assets/oss/presigned-url?file_name=${encodeURIComponent(fileName)}&expires=${expires}&type=${type}`,
|
||||
@ -430,6 +450,76 @@ export function getOssPresignedUrlApi(fileName, expires = 3600, type = 'avatar')
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量预签名读 URL
|
||||
* 对应网关:GET /api/v1/assets/oss/batch-presigned-urls
|
||||
*/
|
||||
export function getBatchOssPresignedUrlsApi(files, expires = 3600, type = 'asset') {
|
||||
return request({
|
||||
url: `/api/v1/assets/oss/batch-presigned-urls?files=${encodeURIComponent(JSON.stringify(files))}&expires=${expires}&type=${type}`,
|
||||
method: 'GET'
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用网关下发的 Post 策略将本地临时文件直传 OSS(App / 小程序等)
|
||||
* @param {string} filePath uni.chooseImage 等返回的 tempFilePaths 项
|
||||
* @param {{ type?: 'avatar'|'asset', orderId?: string, fileName?: string }} [options]
|
||||
* @returns {Promise<{ imageUrl: string, orderId?: string, data: object }>}
|
||||
*/
|
||||
export function uploadLocalFileToOss(filePath, options = {}) {
|
||||
const { type = 'asset', orderId = '', fileName } = options
|
||||
const objectName = fileName || `${Date.now()}.jpg`
|
||||
return new Promise((resolve, reject) => {
|
||||
getOssSignatureApi(type, orderId)
|
||||
.then((signRes) => {
|
||||
if (!signRes || signRes.code !== 200 || !signRes.data) {
|
||||
reject(new Error((signRes && signRes.message) || '获取 OSS 签名失败'))
|
||||
return
|
||||
}
|
||||
const d = signRes.data
|
||||
const host = d.host
|
||||
const dir = d.dir || ''
|
||||
if (!host) {
|
||||
reject(new Error('签名数据缺少 host'))
|
||||
return
|
||||
}
|
||||
uni.uploadFile({
|
||||
url: host,
|
||||
filePath,
|
||||
name: 'file',
|
||||
formData: {
|
||||
key: dir + objectName,
|
||||
policy: d.policy,
|
||||
success_action_status: '200',
|
||||
'x-oss-credential': d.x_oss_credential,
|
||||
'x-oss-date': d.x_oss_date,
|
||||
'x-oss-security-token': d.security_token,
|
||||
'x-oss-signature': d.signature,
|
||||
'x-oss-signature-version': d.x_oss_signature_version
|
||||
},
|
||||
success: (uploadRes) => {
|
||||
const ok = uploadRes.statusCode === 200 || uploadRes.statusCode === 204
|
||||
if (!ok) {
|
||||
reject(new Error(`OSS 上传失败 HTTP ${uploadRes.statusCode}`))
|
||||
return
|
||||
}
|
||||
const imageUrl = `${host.replace(/\/+$/, '')}/${dir}${objectName}`
|
||||
resolve({
|
||||
imageUrl,
|
||||
orderId: d.order_id,
|
||||
data: d
|
||||
})
|
||||
},
|
||||
fail: (e) => {
|
||||
reject(new Error(e.errMsg || 'OSS 上传失败'))
|
||||
}
|
||||
})
|
||||
})
|
||||
.catch(reject)
|
||||
})
|
||||
}
|
||||
|
||||
// 更新用户头像
|
||||
export function updateAvatarApi(avatarUrl) {
|
||||
return request({
|
||||
@ -654,14 +744,6 @@ export function getInspirationFlowApi(params) {
|
||||
})
|
||||
}
|
||||
|
||||
// 批量获取OSS预签名URL(用于读取目录下的图片)
|
||||
export function getBatchOssPresignedUrlsApi(files, expires = 3600, type = 'asset') {
|
||||
return request({
|
||||
url: `/api/v1/assets/oss/batch-presigned-urls?files=${encodeURIComponent(JSON.stringify(files))}&expires=${expires}&type=${type}`,
|
||||
method: 'GET'
|
||||
})
|
||||
}
|
||||
|
||||
// ==================== 铸造活动相关接口(运营banner)====================
|
||||
|
||||
// 获取铸造活动列表
|
||||
|
||||
179
frontend/utils/castloveAfterLaserMint.js
Normal file
@ -0,0 +1,179 @@
|
||||
/**
|
||||
* 镭射工坊导出图后:OSS 上传 + 创建铸造订单(与 create.vue 原「跳过」链路一致)
|
||||
*/
|
||||
import { getOssSignatureApi, createMintOrderApi } from '@/utils/api.js'
|
||||
import { resolveH5OssPostUrl } from '@/utils/h5OssPostUrl.js'
|
||||
import { buildCastloveFormSnapshot } from '@/utils/castloveMintForm.js'
|
||||
|
||||
/**
|
||||
* @param {string} base64Data data:image/jpeg;base64,... 或 data:image/png;base64,...
|
||||
* @param {object} ossData getOssSignatureApi 返回的 data
|
||||
* @returns {Promise<string>} material_url
|
||||
*/
|
||||
function uploadDataUrlToOss(base64Data, ossData) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const fileName = `${Date.now()}.jpg`
|
||||
|
||||
// #ifdef H5
|
||||
fetch(base64Data)
|
||||
.then((res) => res.blob())
|
||||
.then((blob) => {
|
||||
const formData = new FormData()
|
||||
formData.append('key', ossData.dir + fileName)
|
||||
formData.append('policy', ossData.policy)
|
||||
formData.append('success_action_status', '200')
|
||||
formData.append('x-oss-credential', ossData.x_oss_credential)
|
||||
formData.append('x-oss-date', ossData.x_oss_date)
|
||||
formData.append('x-oss-security-token', ossData.security_token)
|
||||
formData.append('x-oss-signature', ossData.signature)
|
||||
formData.append('x-oss-signature-version', ossData.x_oss_signature_version)
|
||||
formData.append('file', blob, fileName)
|
||||
return fetch(resolveH5OssPostUrl(ossData.host), {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
})
|
||||
.then((response) => {
|
||||
if (response.ok || response.status === 204) {
|
||||
resolve(`${ossData.host}/${ossData.dir}${fileName}`)
|
||||
} else {
|
||||
reject(new Error('上传失败'))
|
||||
}
|
||||
})
|
||||
.catch(reject)
|
||||
// #endif
|
||||
|
||||
// #ifdef MP-WEIXIN || MP-ALIPAY || MP-BAIDU || MP-TOUTIAO || MP-QQ
|
||||
const base64Content = base64Data.split(',')[1]
|
||||
const fp = `${wx.env.USER_DATA_PATH}/${fileName}`
|
||||
uni.getFileSystemManager().writeFile({
|
||||
filePath: fp,
|
||||
data: base64Content,
|
||||
encoding: 'base64',
|
||||
success: () => {
|
||||
uni.uploadFile({
|
||||
url: ossData.host,
|
||||
filePath: fp,
|
||||
name: 'file',
|
||||
formData: {
|
||||
key: ossData.dir + fileName,
|
||||
policy: ossData.policy,
|
||||
success_action_status: '200',
|
||||
'x-oss-credential': ossData.x_oss_credential,
|
||||
'x-oss-date': ossData.x_oss_date,
|
||||
'x-oss-security-token': ossData.security_token,
|
||||
'x-oss-signature': ossData.signature,
|
||||
'x-oss-signature-version': ossData.x_oss_signature_version
|
||||
},
|
||||
success: (uploadRes) => {
|
||||
if (uploadRes.statusCode === 200 || uploadRes.statusCode === 204) {
|
||||
resolve(`${ossData.host}/${ossData.dir}${fileName}`)
|
||||
} else {
|
||||
reject(new Error(`上传失败 HTTP ${uploadRes.statusCode}`))
|
||||
}
|
||||
},
|
||||
fail: reject
|
||||
})
|
||||
},
|
||||
fail: reject
|
||||
})
|
||||
// #endif
|
||||
|
||||
// #ifdef APP-PLUS
|
||||
const bitmap = new plus.nativeObj.Bitmap('castlove_laser_export')
|
||||
bitmap.loadBase64Data(base64Data, () => {
|
||||
const tempFilePath = `_doc/${fileName}`
|
||||
bitmap.save(tempFilePath, {}, () => {
|
||||
bitmap.clear()
|
||||
uni.uploadFile({
|
||||
url: ossData.host,
|
||||
filePath: tempFilePath,
|
||||
name: 'file',
|
||||
formData: {
|
||||
key: ossData.dir + fileName,
|
||||
policy: ossData.policy,
|
||||
success_action_status: '200',
|
||||
'x-oss-credential': ossData.x_oss_credential,
|
||||
'x-oss-date': ossData.x_oss_date,
|
||||
'x-oss-security-token': ossData.security_token,
|
||||
'x-oss-signature': ossData.signature,
|
||||
'x-oss-signature-version': ossData.x_oss_signature_version
|
||||
},
|
||||
success: (uploadRes) => {
|
||||
if (uploadRes.statusCode === 200 || uploadRes.statusCode === 204) {
|
||||
resolve(`${ossData.host}/${ossData.dir}${fileName}`)
|
||||
} else {
|
||||
reject(new Error(`上传失败 HTTP ${uploadRes.statusCode}`))
|
||||
}
|
||||
},
|
||||
fail: reject
|
||||
})
|
||||
}, () => {
|
||||
bitmap.clear()
|
||||
reject(new Error('保存临时文件失败'))
|
||||
})
|
||||
}, () => {
|
||||
bitmap.clear()
|
||||
reject(new Error('加载导出图失败'))
|
||||
})
|
||||
// #endif
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} imageDataUrl 导出后的 data URL
|
||||
* @param {object} entry create 页写入的 CASTLOVE_LASER_ENTRY_KEY 解析结果
|
||||
*/
|
||||
export async function submitCastloveAfterLaserExport(imageDataUrl, entry) {
|
||||
if (!entry || !imageDataUrl) {
|
||||
throw new Error('缺少铸爱表单或导出图')
|
||||
}
|
||||
const {
|
||||
nftInfo,
|
||||
materialTypes,
|
||||
materialTypeIndex,
|
||||
pageName,
|
||||
uploadedImage,
|
||||
uploadedImageBase64
|
||||
} = entry
|
||||
const signRes = await getOssSignatureApi('asset')
|
||||
if (!signRes || signRes.code !== 200 || !signRes.data) {
|
||||
throw new Error((signRes && signRes.message) || '获取签名失败')
|
||||
}
|
||||
const orderId = signRes.data.order_id || ''
|
||||
const imageUrl = await uploadDataUrlToOss(imageDataUrl, signRes.data)
|
||||
const snap = buildCastloveFormSnapshot({
|
||||
nftInfo,
|
||||
materialTypes,
|
||||
materialTypeIndex,
|
||||
pageName,
|
||||
uploadedImage: uploadedImage || '',
|
||||
uploadedImageBase64: uploadedImageBase64 || ''
|
||||
})
|
||||
const orderData = {
|
||||
order_id: orderId,
|
||||
name: snap.name,
|
||||
material_url: imageUrl,
|
||||
description: snap.description,
|
||||
grade: snap.grade,
|
||||
tags: snap.tags,
|
||||
material_type: snap.material_type,
|
||||
info: snap.info
|
||||
}
|
||||
const response = await createMintOrderApi(orderData)
|
||||
if (!response || response.code !== 200) {
|
||||
throw new Error((response && response.message) || '创建订单失败')
|
||||
}
|
||||
const nftData = {
|
||||
image: imageUrl,
|
||||
name: snap.name,
|
||||
description: snap.description,
|
||||
material_type: snap.material_type,
|
||||
tags: snap.tags,
|
||||
order_id: orderId,
|
||||
info: snap.info,
|
||||
event: snap.info
|
||||
}
|
||||
uni.setStorageSync('temp_nft_data', JSON.stringify(nftData))
|
||||
return { imageUrl, orderId }
|
||||
}
|
||||
138
frontend/utils/castloveMintForm.js
Normal file
@ -0,0 +1,138 @@
|
||||
/** 铸爱 — 与网关 CreateMintOrderRequestDTO 对齐的本地表单快照(见 gateway/dto/asset_dto.go) */
|
||||
|
||||
export const CRAFT_LENTICULAR_CN = '光栅卡'
|
||||
/** 工艺名:与 craft-select 卡片「镭射卡」一致 */
|
||||
export const CRAFT_LASER_CARD_CN = '镭射卡'
|
||||
/** 工艺标签:写入 tags[],便于检索与后端扩展 */
|
||||
export const CRAFT_TAG_LENTICULAR = 'craft:lenticular'
|
||||
|
||||
const defaultBgBase =
|
||||
'radial-gradient(ellipse at 50% 40%, #1a0a3b 0%, #0a0418 55%, #000 100%)'
|
||||
const defaultBgMid =
|
||||
'radial-gradient(ellipse 38% 28% at 50% 45%, rgba(255,212,255,0.9) 0%, rgba(183,109,255,0.55) 50%, rgba(26,10,59,0) 100%)'
|
||||
|
||||
export function defaultLenticularDots() {
|
||||
return [
|
||||
{ x: 18, y: 22, r: 1.4 },
|
||||
{ x: 78, y: 30, r: 1.6 },
|
||||
{ x: 28, y: 72, r: 1.3 },
|
||||
{ x: 86, y: 78, r: 1.2 },
|
||||
{ x: 58, y: 12, r: 1.1 },
|
||||
{ x: 8, y: 50, r: 1.0 },
|
||||
{ x: 92, y: 50, r: 1.2 },
|
||||
{ x: 50, y: 88, r: 1.1 },
|
||||
]
|
||||
}
|
||||
|
||||
/** 进入光栅工作室前写入的临时数据(create → lenticular-studio) */
|
||||
export const LENTICULAR_STUDIO_STORAGE_KEY = 'lenticular_studio_payload'
|
||||
|
||||
/** 单图工艺:create → 镭射工坊(Laser-Card 页)入口载荷 */
|
||||
export const CASTLOVE_LASER_ENTRY_KEY = 'castlove_laser_entry_payload'
|
||||
|
||||
/**
|
||||
* 仅背景 + 主体两图层(无高光层),与光栅卡工作室一致
|
||||
* @param {string} bgSrc 背景图本地路径或 URL
|
||||
* @param {string} subjectSrc 主体图本地路径或 URL
|
||||
*/
|
||||
export function buildLenticularLayersTwo(bgSrc, subjectSrc) {
|
||||
return [
|
||||
{
|
||||
id: 'base',
|
||||
label: '背景',
|
||||
depth: 0,
|
||||
opacity: 1,
|
||||
blendMode: 'normal',
|
||||
parallaxFactor: 0.35,
|
||||
background: bgSrc ? undefined : defaultBgBase,
|
||||
dots: bgSrc ? undefined : defaultLenticularDots(),
|
||||
src: bgSrc || undefined,
|
||||
},
|
||||
{
|
||||
id: 'mid',
|
||||
label: '主体',
|
||||
depth: 0.55,
|
||||
opacity: 0.92,
|
||||
blendMode: 'screen',
|
||||
parallaxFactor: 0.7,
|
||||
background: subjectSrc ? undefined : defaultBgMid,
|
||||
src: subjectSrc || undefined,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
/** 兼容旧预览:单图仅贴在主体层,仍带高光层 */
|
||||
export function buildLenticularLayers(uploadSrc) {
|
||||
const mid = {
|
||||
id: 'mid',
|
||||
label: '主体',
|
||||
depth: 0.55,
|
||||
opacity: 0.92,
|
||||
blendMode: 'screen',
|
||||
parallaxFactor: 0.7,
|
||||
background: uploadSrc ? undefined : defaultBgMid,
|
||||
src: uploadSrc || undefined,
|
||||
}
|
||||
return [
|
||||
{
|
||||
id: 'base',
|
||||
label: '背景',
|
||||
depth: 0.0,
|
||||
opacity: 1,
|
||||
blendMode: 'normal',
|
||||
parallaxFactor: 0.35,
|
||||
background: defaultBgBase,
|
||||
dots: defaultLenticularDots(),
|
||||
},
|
||||
mid,
|
||||
{
|
||||
id: 'fx',
|
||||
label: '高光',
|
||||
depth: 0.9,
|
||||
opacity: 0.85,
|
||||
blendMode: 'lighten',
|
||||
parallaxFactor: 1.15,
|
||||
background: 'transparent',
|
||||
dots: [
|
||||
{ x: 20, y: 16, r: 3, color: '#ffffff' },
|
||||
{ x: 82, y: 22, r: 2.4, color: '#ffffff' },
|
||||
{ x: 14, y: 78, r: 3.2, color: '#4cd7f6' },
|
||||
],
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建与 POST /api/v1/assets/mints 一致的字段子集 + 本地预览所需字段
|
||||
* DTO: order_id, name, material_url, description, grade, tags, material_type, info
|
||||
*/
|
||||
export function buildCastloveFormSnapshot({
|
||||
nftInfo,
|
||||
materialTypes,
|
||||
materialTypeIndex,
|
||||
pageName,
|
||||
uploadedImage,
|
||||
uploadedImageBase64,
|
||||
}) {
|
||||
const info = (nftInfo || '').trim()
|
||||
const material_type = materialTypes[materialTypeIndex] || '粉丝自制'
|
||||
const lines = info.split(/\n/).map((l) => l.trim()).filter(Boolean)
|
||||
const name =
|
||||
lines.length > 0
|
||||
? lines[0].slice(0, 80)
|
||||
: pageName === CRAFT_LENTICULAR_CN
|
||||
? '光栅卡作品'
|
||||
: pageName || '藏品'
|
||||
const tags = pageName === CRAFT_LENTICULAR_CN ? [CRAFT_TAG_LENTICULAR] : []
|
||||
return {
|
||||
image: uploadedImage,
|
||||
imageBase64: uploadedImageBase64,
|
||||
info,
|
||||
material_type,
|
||||
name,
|
||||
description: '',
|
||||
tags,
|
||||
craft_name: pageName || '',
|
||||
grade: 0,
|
||||
}
|
||||
}
|
||||
26
frontend/utils/h5OssPostUrl.js
Normal file
@ -0,0 +1,26 @@
|
||||
/**
|
||||
* H5 本地开发:将 OSS Post 地址换为同源 /dev-oss-proxy,由 vite.config.js 中中间件转发到 OSS 桶根 POST /,
|
||||
* 避免浏览器从 localhost / 局域网端口直连 *.aliyuncs.com 触发 CORS。
|
||||
*
|
||||
* 正式 H5 部署到业务域名时仍直连 ossData.host;若遇 CORS,请在 OSS 控制台配置 CORS 或使用与站点同域的反代。
|
||||
*
|
||||
* @param {string} ossHost 签名接口返回的 host,如 https://bucket.oss-cn-shanghai.aliyuncs.com
|
||||
* @returns {string} 实际用于 fetch POST 的 URL(开发态为同源代理前缀)
|
||||
*/
|
||||
export function resolveH5OssPostUrl(ossHost) {
|
||||
if (!ossHost || typeof location === 'undefined') {
|
||||
return ossHost
|
||||
}
|
||||
const port = String(location.port || '')
|
||||
const hn = location.hostname || ''
|
||||
const devPortOk = ['5173', '5174', '8080', '9528'].includes(port)
|
||||
const devHostOk =
|
||||
hn === 'localhost' ||
|
||||
hn === '127.0.0.1' ||
|
||||
/^192\.168\./.test(hn) ||
|
||||
/^10\./.test(hn)
|
||||
if (devPortOk && devHostOk) {
|
||||
return `${location.protocol}//${location.host}/dev-oss-proxy`
|
||||
}
|
||||
return ossHost
|
||||
}
|
||||
557
frontend/utils/laser-card/aliyunPortraitUni.js
Normal file
@ -0,0 +1,557 @@
|
||||
/**
|
||||
* uni-app App 端可用:OSS Post/Put + 签名 GET URL;直连抠图默认 IVPD SegmentImage,另有 imageseg SegmentHDBody(POP,不依赖 ali-oss / Node http)
|
||||
* 签名使用 Web Crypto(HMAC-SHA1),避免额外依赖 crypto-js;需安全上下文(localhost / HTTPS)且存在 globalThis.crypto.subtle。
|
||||
*/
|
||||
const _textEnc = typeof TextEncoder !== 'undefined' ? new TextEncoder() : null
|
||||
|
||||
function utf8StringToBase64(str) {
|
||||
if (_textEnc) {
|
||||
const bytes = _textEnc.encode(str)
|
||||
let bin = ''
|
||||
for (let i = 0; i < bytes.byteLength; i++) {
|
||||
bin += String.fromCharCode(bytes[i])
|
||||
}
|
||||
return btoa(bin)
|
||||
}
|
||||
return btoa(unescape(encodeURIComponent(str)))
|
||||
}
|
||||
|
||||
async function hmacSha1Base64(message, secretKey) {
|
||||
const subtle = globalThis.crypto && globalThis.crypto.subtle
|
||||
if (!subtle || !_textEnc) {
|
||||
throw new Error(
|
||||
'当前环境无法计算 OSS/阿里云签名:需要支持 Web Crypto(请在 localhost 或 HTTPS 下打开,并确保非禁用 crypto.subtle 的 WebView)'
|
||||
)
|
||||
}
|
||||
const key = await subtle.importKey(
|
||||
'raw',
|
||||
_textEnc.encode(secretKey),
|
||||
{ name: 'HMAC', hash: 'SHA-1' },
|
||||
false,
|
||||
['sign']
|
||||
)
|
||||
const sig = await subtle.sign('HMAC', key, _textEnc.encode(message))
|
||||
const bytes = new Uint8Array(sig)
|
||||
let bin = ''
|
||||
for (let i = 0; i < bytes.byteLength; i++) {
|
||||
bin += String.fromCharCode(bytes[i])
|
||||
}
|
||||
return btoa(bin)
|
||||
}
|
||||
|
||||
/** RAM 密钥常见复制问题:首尾空格、BOM、零宽字符、Windows 多余 \\r */
|
||||
function normalizeAccessKeyPart(s) {
|
||||
return String(s || '')
|
||||
.trim()
|
||||
.replace(/^\uFEFF/, '')
|
||||
.replace(/[\u200B-\u200D\uFEFF]/g, '')
|
||||
.replace(/\r\n/g, '\n')
|
||||
.replace(/\r/g, '')
|
||||
.trim()
|
||||
}
|
||||
|
||||
function percentEncode(str) {
|
||||
if (str === undefined || str === null) {
|
||||
return ''
|
||||
}
|
||||
return encodeURIComponent(String(str))
|
||||
.replace(/!/g, '%21')
|
||||
.replace(/'/g, '%27')
|
||||
.replace(/\(/g, '%28')
|
||||
.replace(/\)/g, '%29')
|
||||
.replace(/\*/g, '%2A')
|
||||
}
|
||||
|
||||
function pickHttpImageUrl(obj, depth = 0) {
|
||||
if (!obj || depth > 10) return null
|
||||
if (typeof obj === 'string' && /^https?:\/\//i.test(obj) && /\.(png|jpg|jpeg|webp)/i.test(obj)) {
|
||||
return obj
|
||||
}
|
||||
if (typeof obj !== 'object') return null
|
||||
for (const v of Object.values(obj)) {
|
||||
const u = pickHttpImageUrl(v, depth + 1)
|
||||
if (u) return u
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** 递归查找 oss://bucket/object 形式 URI */
|
||||
function pickOssUriString(obj, depth = 0) {
|
||||
if (!obj || depth > 10) return null
|
||||
if (typeof obj === 'string' && /^oss:\/\//i.test(obj)) {
|
||||
if (parseOssUri(obj)) {
|
||||
return obj
|
||||
}
|
||||
}
|
||||
if (typeof obj !== 'object') return null
|
||||
for (const v of Object.values(obj)) {
|
||||
const u = pickOssUriString(v, depth + 1)
|
||||
if (u) return u
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function parseOssUri(uri) {
|
||||
const s = String(uri || '').trim()
|
||||
const m = /^oss:\/\/([^/]+)\/(.+)$/i.exec(s)
|
||||
if (!m) return null
|
||||
return { bucket: m[1], objectKey: m[2] }
|
||||
}
|
||||
|
||||
/** PostObject 的目标地址(仅桶根、无 object 路径);失败 errMsg 常只有这一串 URL */
|
||||
export function looksLikeBareOssEndpointUrl(s) {
|
||||
const t = String(s || '').trim()
|
||||
if (!/^https?:\/\/[^/\s]+\.oss-[a-z0-9-]+\.aliyuncs\.com\/?$/i.test(t)) {
|
||||
return false
|
||||
}
|
||||
try {
|
||||
const u = new URL(t)
|
||||
if (!/^https?:$/i.test(u.protocol)) return false
|
||||
const segs = u.pathname.split('/').filter(Boolean)
|
||||
return segs.length === 0
|
||||
} catch {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
/** 仅一行 OSS 根域名错误时,转成可操作的说明(依赖 manifest domainWhiteList) */
|
||||
export function humanizeIfBareOssUploadErr(msg) {
|
||||
const t = String(msg || '').trim()
|
||||
if (!looksLikeBareOssEndpointUrl(t)) {
|
||||
return String(msg || '')
|
||||
}
|
||||
const hostOnly = t.replace(/^https?:\/\//i, '').replace(/\/$/, '')
|
||||
return (
|
||||
`OSS 上传失败(uni-app 常因未配置「域名白名单」拦截 uploadFile,控制台只显示请求 URL)。\n\n请在 manifest.json → app-plus → networkSecurity → domainWhiteList 中加入(注意 https):\n• https://${hostOnly}\n• IVPD:https://ivpd.<地域>.aliyuncs.com(与 IMM_REGION 一致,如 cn-shanghai)\n以及抠图结果下载域名(若与上面不同)。\n保存后必须重新制作自定义调试基座再运行。\n\n原文:${t}`
|
||||
)
|
||||
}
|
||||
|
||||
/** 排除仅桶根域名、无对象路径的「伪 URL」(IMM 偶发只回 Host) */
|
||||
function isLikelyObjectHttpUrl(url) {
|
||||
try {
|
||||
const u = new URL(String(url).trim())
|
||||
if (!/^https?:$/i.test(u.protocol)) {
|
||||
return false
|
||||
}
|
||||
const parts = u.pathname.split('/').filter(Boolean)
|
||||
return parts.length >= 1
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function ossHost(bucket, ossRegionId) {
|
||||
const r = ossRegionId.replace(/^oss-/, '')
|
||||
return `${bucket}.oss-${r}.aliyuncs.com`
|
||||
}
|
||||
|
||||
/** objectKey 含路径时用分段编码路径 */
|
||||
function ossObjectPath(objectKey) {
|
||||
return objectKey
|
||||
.split('/')
|
||||
.map((seg) => encodeURIComponent(seg))
|
||||
.join('/')
|
||||
}
|
||||
|
||||
/** 私有读:签名 GET URL(与 Put 后给分割接口用的 URL 一致) */
|
||||
export async function ossPresignedObjectUrl({
|
||||
bucket,
|
||||
ossRegionId,
|
||||
accessKeyId,
|
||||
accessKeySecret,
|
||||
securityToken,
|
||||
objectKey,
|
||||
expiresSec = 3600
|
||||
}) {
|
||||
const ak = normalizeAccessKeyPart(accessKeyId)
|
||||
const sk = normalizeAccessKeyPart(accessKeySecret)
|
||||
const host = ossHost(bucket, ossRegionId)
|
||||
const path = ossObjectPath(objectKey)
|
||||
const resource = `/${bucket}/${objectKey}`
|
||||
const expires = Math.floor(Date.now() / 1000) + expiresSec
|
||||
const stringToSignGet = `GET\n\n\n${expires}\n${resource}`
|
||||
const sigGet = await hmacSha1Base64(stringToSignGet, sk)
|
||||
let qs = `OSSAccessKeyId=${percentEncode(ak)}&Expires=${expires}&Signature=${percentEncode(sigGet)}`
|
||||
if (securityToken) {
|
||||
qs += `&security-token=${percentEncode(securityToken)}`
|
||||
}
|
||||
return `https://${host}/${path}?${qs}`
|
||||
}
|
||||
|
||||
/**
|
||||
* PostObject + policy:用 uni.uploadFile 直传本地路径,不经 JS 读 ArrayBuffer(调试基座读图失败时的主路径)。
|
||||
*/
|
||||
export async function ossPostObjectFromLocalFilePath({
|
||||
bucket,
|
||||
ossRegionId,
|
||||
accessKeyId,
|
||||
accessKeySecret,
|
||||
securityToken,
|
||||
objectKey,
|
||||
filePath
|
||||
}) {
|
||||
const ak = normalizeAccessKeyPart(accessKeyId)
|
||||
const sk = normalizeAccessKeyPart(accessKeySecret)
|
||||
if (!ak || !sk) {
|
||||
return Promise.reject(new Error('OSS 缺少 AccessKeyId 或 AccessKeySecret'))
|
||||
}
|
||||
|
||||
const expiration = new Date(Date.now() + 55 * 60 * 1000).toISOString()
|
||||
const conditions = [
|
||||
['content-length-range', 0, 52428800],
|
||||
{ bucket },
|
||||
['eq', '$key', objectKey]
|
||||
]
|
||||
if (securityToken) {
|
||||
conditions.push({ 'x-oss-security-token': securityToken })
|
||||
}
|
||||
|
||||
const policyJson = JSON.stringify({ expiration, conditions })
|
||||
const policyB64 = utf8StringToBase64(policyJson)
|
||||
const signature = await hmacSha1Base64(policyB64, sk)
|
||||
|
||||
const host = ossHost(bucket, ossRegionId)
|
||||
const url = `https://${host}/`
|
||||
|
||||
const formData = {
|
||||
key: objectKey,
|
||||
policy: policyB64,
|
||||
OSSAccessKeyId: ak,
|
||||
Signature: signature,
|
||||
success_action_status: '200'
|
||||
}
|
||||
if (securityToken) {
|
||||
formData['x-oss-security-token'] = securityToken
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
uni.uploadFile({
|
||||
url,
|
||||
filePath,
|
||||
name: 'file',
|
||||
formData,
|
||||
timeout: 180000,
|
||||
success: (res) => {
|
||||
const sc = res.statusCode
|
||||
if (sc >= 200 && sc < 300) {
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
const body = typeof res.data === 'string' ? res.data : JSON.stringify(res.data || '')
|
||||
if (typeof body === 'string' && body.includes('SignatureDoesNotMatch')) {
|
||||
reject(
|
||||
new Error(
|
||||
'OSS SignatureDoesNotMatch:AccessKeySecret 与当前 OSSAccessKeyId 不匹配,或密钥含隐形字符/换行。请到 RAM 用户页「创建 AccessKey」重新复制 Secret 写入 config/segmentApi.js;若曾泄露请删除旧密钥并轮换。'
|
||||
)
|
||||
)
|
||||
return
|
||||
}
|
||||
reject(new Error(body || `OSS PostObject 失败 HTTP ${sc}`))
|
||||
},
|
||||
fail: (e) => {
|
||||
const raw = e.errMsg || 'uploadFile 失败'
|
||||
if (
|
||||
looksLikeBareOssEndpointUrl(raw) ||
|
||||
(/request:fail/i.test(raw) && /\.oss-[a-z0-9-]+\.aliyuncs\.com/i.test(raw))
|
||||
) {
|
||||
reject(
|
||||
new Error(
|
||||
humanizeIfBareOssUploadErr(raw)
|
||||
)
|
||||
)
|
||||
return
|
||||
}
|
||||
reject(new Error(raw))
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function uniRequestRaw(options) {
|
||||
const timeout = options.timeout != null ? options.timeout : 120000
|
||||
return new Promise((resolve, reject) => {
|
||||
uni.request({
|
||||
...options,
|
||||
timeout,
|
||||
success: (r) => {
|
||||
if (r.statusCode >= 200 && r.statusCode < 300) {
|
||||
resolve(r)
|
||||
} else {
|
||||
const msg =
|
||||
(typeof r.data === 'string' ? r.data : '') ||
|
||||
(r.data && r.data.Message) ||
|
||||
`HTTP ${r.statusCode}`
|
||||
reject(new Error(msg))
|
||||
}
|
||||
},
|
||||
fail: (e) => reject(new Error(e.errMsg || '请求失败'))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT 上传后生成 OSS 签名 GET URL(私有 Bucket 时分割接口需可访问该 URL)
|
||||
*/
|
||||
export async function ossPutAndSignedGetUrl({
|
||||
bucket,
|
||||
ossRegionId,
|
||||
accessKeyId,
|
||||
accessKeySecret,
|
||||
securityToken,
|
||||
objectKey,
|
||||
bodyBuffer,
|
||||
contentType = 'image/jpeg'
|
||||
}) {
|
||||
const ak = normalizeAccessKeyPart(accessKeyId)
|
||||
const sk = normalizeAccessKeyPart(accessKeySecret)
|
||||
if (!ak || !sk) {
|
||||
throw new Error('OSS 签名缺少 AccessKeyId 或 AccessKeySecret')
|
||||
}
|
||||
|
||||
const host = ossHost(bucket, ossRegionId)
|
||||
const path = ossObjectPath(objectKey)
|
||||
const urlPut = `https://${host}/${path}`
|
||||
|
||||
const dateGmt = new Date().toUTCString()
|
||||
/**
|
||||
* uni.request 会带 Date;若再发 x-oss-date,OSS 的 StringToSign 为:Date 行 + 规范头里的 x-oss-date(与错误 XML 一致),不能签成 Date 为空。
|
||||
*/
|
||||
const ossHeaderPairs = []
|
||||
ossHeaderPairs.push(['x-oss-date', dateGmt])
|
||||
if (securityToken) {
|
||||
ossHeaderPairs.push(['x-oss-security-token', securityToken])
|
||||
}
|
||||
ossHeaderPairs.sort((a, b) => a[0].localeCompare(b[0]))
|
||||
const canonHeaders = ossHeaderPairs.map(([k, v]) => `${k}:${v}\n`).join('')
|
||||
|
||||
const resource = `/${bucket}/${objectKey}`
|
||||
const stringToSignPut = `PUT\n\n${contentType}\n${dateGmt}\n${canonHeaders}${resource}`
|
||||
const sigPut = await hmacSha1Base64(stringToSignPut, sk)
|
||||
|
||||
const headersPut = {
|
||||
Date: dateGmt,
|
||||
'Content-Type': contentType,
|
||||
'x-oss-date': dateGmt,
|
||||
Authorization: `OSS ${ak}:${sigPut}`
|
||||
}
|
||||
if (securityToken) {
|
||||
headersPut['x-oss-security-token'] = securityToken
|
||||
}
|
||||
|
||||
await uniRequestRaw({
|
||||
url: urlPut,
|
||||
method: 'PUT',
|
||||
header: headersPut,
|
||||
data: bodyBuffer,
|
||||
/** 大图上传(Bitmap 兜底常为 PNG)易慢;超时后 fail 才会结束 loading */
|
||||
timeout: 180000
|
||||
})
|
||||
|
||||
return await ossPresignedObjectUrl({
|
||||
bucket,
|
||||
ossRegionId,
|
||||
accessKeyId: ak,
|
||||
accessKeySecret: sk,
|
||||
securityToken,
|
||||
objectKey,
|
||||
expiresSec: 3600
|
||||
})
|
||||
}
|
||||
|
||||
/** IVPD 常见英文报错 → 中文说明(控制台需开通「智能视觉生产」等产品) */
|
||||
function mapIvpdUserMessage(raw) {
|
||||
const s = String(raw || '')
|
||||
if (/please\s+open\s+service/i.test(s)) {
|
||||
return (
|
||||
'IVPD(智能视觉生产)服务尚未开通:请用阿里云主账号或有权限的子账号登录控制台,搜索「智能视觉生产」或「视觉智能开放平台」,进入产品页点击「立即开通」后再试抠图。' +
|
||||
(s.length <= 60 ? `(接口原文:${s})` : '')
|
||||
)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
/**
|
||||
* 智能视觉生产 IVPD「通用分割 / 抠图」SegmentImage。
|
||||
* Endpoint:ivpd.{region}.aliyuncs.com,Version:2019-06-25,参数 Url 为可访问的图片 HTTP(S) URL(如 OSS 签名 GET)。
|
||||
*/
|
||||
export async function rpcIvpdSegmentImage({
|
||||
imageUrl,
|
||||
accessKeyId,
|
||||
accessKeySecret,
|
||||
securityToken,
|
||||
regionId
|
||||
}) {
|
||||
const ak = normalizeAccessKeyPart(accessKeyId)
|
||||
const sk = normalizeAccessKeyPart(accessKeySecret)
|
||||
const u = String(imageUrl || '').trim()
|
||||
if (!/^https?:\/\//i.test(u)) {
|
||||
throw new Error('IVPD SegmentImage 需要可访问的 HTTP(S) 图片 URL(一般为 OSS 私有桶签名 GET)')
|
||||
}
|
||||
|
||||
const params = {
|
||||
Format: 'JSON',
|
||||
Version: '2019-06-25',
|
||||
AccessKeyId: ak,
|
||||
SignatureMethod: 'HMAC-SHA1',
|
||||
Timestamp: new Date().toISOString().replace(/\.\d{3}Z$/, 'Z'),
|
||||
SignatureVersion: '1.0',
|
||||
SignatureNonce: `${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||
Action: 'SegmentImage',
|
||||
RegionId: regionId,
|
||||
Url: u
|
||||
}
|
||||
if (securityToken) {
|
||||
params.SecurityToken = securityToken
|
||||
}
|
||||
|
||||
const sortedKeys = Object.keys(params).sort()
|
||||
const canonicalized = sortedKeys.map((k) => `${percentEncode(k)}=${percentEncode(params[k])}`).join('&')
|
||||
const stringToSign = `POST&${percentEncode('/')}&${percentEncode(canonicalized)}`
|
||||
const signature = await hmacSha1Base64(stringToSign, `${sk}&`)
|
||||
|
||||
const body = `${canonicalized}&Signature=${percentEncode(signature)}`
|
||||
const host = `ivpd.${regionId}.aliyuncs.com`
|
||||
|
||||
let res
|
||||
try {
|
||||
res = await uniRequestRaw({
|
||||
url: `https://${host}/`,
|
||||
method: 'POST',
|
||||
header: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
},
|
||||
data: body,
|
||||
timeout: 120000
|
||||
})
|
||||
} catch (e) {
|
||||
const em = e && e.message ? String(e.message) : ''
|
||||
if (/please\s+open\s+service/i.test(em)) {
|
||||
throw new Error(mapIvpdUserMessage(em))
|
||||
}
|
||||
try {
|
||||
const j = JSON.parse(em)
|
||||
const mm = j.Message || j.message || ''
|
||||
if (/please\s+open\s+service/i.test(String(mm))) {
|
||||
throw new Error(mapIvpdUserMessage(mm))
|
||||
}
|
||||
} catch (inner) {
|
||||
if (inner && inner.message && /IVPD(/.test(inner.message)) {
|
||||
throw inner
|
||||
}
|
||||
}
|
||||
throw e
|
||||
}
|
||||
|
||||
let data = res.data
|
||||
if (typeof data === 'string') {
|
||||
try {
|
||||
data = JSON.parse(data)
|
||||
} catch (e) {
|
||||
throw new Error((data && data.slice(0, 240)) || 'IVPD 返回非 JSON')
|
||||
}
|
||||
}
|
||||
|
||||
const flat = data
|
||||
const bizCode = flat.code ?? flat.Code
|
||||
const bizMsg = flat.message ?? flat.Message
|
||||
if (bizCode != null && String(bizCode) !== '0') {
|
||||
throw new Error(mapIvpdUserMessage(bizMsg ? `${bizCode}: ${bizMsg}` : String(bizCode)))
|
||||
}
|
||||
|
||||
const aliPopMsg = flat.Message || flat.Code
|
||||
if (typeof aliPopMsg === 'string' && /please\s+open\s+service/i.test(aliPopMsg)) {
|
||||
throw new Error(mapIvpdUserMessage(aliPopMsg))
|
||||
}
|
||||
|
||||
const result = flat.result ?? flat.Result
|
||||
const outUrl =
|
||||
(result && (result.url || result.URL)) ||
|
||||
flat.url ||
|
||||
flat.URL ||
|
||||
pickHttpImageUrl(flat)
|
||||
|
||||
if (outUrl && isLikelyObjectHttpUrl(outUrl)) {
|
||||
return outUrl
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
bizMsg
|
||||
? mapIvpdUserMessage(bizMsg)
|
||||
: `IVPD 未返回有效结果图 URL。摘录:${JSON.stringify(flat).slice(0, 600)}`
|
||||
)
|
||||
}
|
||||
|
||||
/** 人像高清抠图(imageseg SegmentHDBody);自建代理 server/index.js 等可使用。 */
|
||||
export async function rpcSegmentHDBody({
|
||||
imageURL,
|
||||
accessKeyId,
|
||||
accessKeySecret,
|
||||
securityToken,
|
||||
regionId
|
||||
}) {
|
||||
const ak = normalizeAccessKeyPart(accessKeyId)
|
||||
const sk = normalizeAccessKeyPart(accessKeySecret)
|
||||
const params = {
|
||||
Format: 'JSON',
|
||||
Version: '2019-12-30',
|
||||
AccessKeyId: ak,
|
||||
SignatureMethod: 'HMAC-SHA1',
|
||||
Timestamp: new Date().toISOString().replace(/\.\d{3}Z$/, 'Z'),
|
||||
SignatureVersion: '1.0',
|
||||
SignatureNonce: `${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||
Action: 'SegmentHDBody',
|
||||
ImageURL: imageURL
|
||||
}
|
||||
if (securityToken) {
|
||||
params.SecurityToken = securityToken
|
||||
}
|
||||
|
||||
const sortedKeys = Object.keys(params).sort()
|
||||
const canonicalized = sortedKeys.map((k) => `${percentEncode(k)}=${percentEncode(params[k])}`).join('&')
|
||||
const stringToSign = `POST&${percentEncode('/')}&${percentEncode(canonicalized)}`
|
||||
const signature = await hmacSha1Base64(stringToSign, `${sk}&`)
|
||||
|
||||
const body = `${canonicalized}&Signature=${percentEncode(signature)}`
|
||||
const host = `imageseg.${regionId}.aliyuncs.com`
|
||||
|
||||
const res = await uniRequestRaw({
|
||||
url: `https://${host}/`,
|
||||
method: 'POST',
|
||||
header: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
},
|
||||
data: body,
|
||||
timeout: 90000
|
||||
})
|
||||
|
||||
let data = res.data
|
||||
if (typeof data === 'string') {
|
||||
try {
|
||||
data = JSON.parse(data)
|
||||
} catch (e) {
|
||||
throw new Error((data && data.slice(0, 240)) || '分割接口返回非 JSON')
|
||||
}
|
||||
}
|
||||
|
||||
let flat = data
|
||||
if (flat && typeof flat.Data === 'string') {
|
||||
try {
|
||||
const inner = JSON.parse(flat.Data)
|
||||
flat = { ...flat, ...inner }
|
||||
} catch (_) {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
const outUrl =
|
||||
flat?.ImageURL ||
|
||||
flat?.imageURL ||
|
||||
flat?.Data?.ImageURL ||
|
||||
pickHttpImageUrl(flat)
|
||||
|
||||
if (!outUrl) {
|
||||
const msg = (flat && (flat.Message || flat.Code)) || JSON.stringify(flat).slice(0, 200)
|
||||
throw new Error(msg || '分割接口未返回结果图 URL')
|
||||
}
|
||||
return outUrl
|
||||
}
|
||||
48
frontend/utils/laser-card/segmentApi.js
Normal file
@ -0,0 +1,48 @@
|
||||
/**
|
||||
* 智能抠图:方案一(OSS + IVPD)与方案二(HTTP 后端,契约见 server/index.js)
|
||||
*
|
||||
* SEGMENT_TRANSPORT:auto | backend | direct(详见 segmentApi.example.js)
|
||||
*
|
||||
* 【方案一 · 推荐生产】ALIYUN_STS_URL:仅下发临时密钥的 HTTPS(FC 等),不写主账号 AK。
|
||||
* 【方案一 · 仅开发】ALIYUN_ACCESS_KEY_ID / ALIYUN_ACCESS_KEY_SECRET:直配 RAM 子账号 AK(有泄露风险,上架前务必删除并改用 STS)。
|
||||
*/
|
||||
|
||||
/** 仅开发:RAM 子账号 AccessKeyId(勿提交仓库、勿用于正式包) */
|
||||
export const ALIYUN_ACCESS_KEY_ID = ''
|
||||
|
||||
/** 仅开发:RAM 子账号 AccessKeySecret */
|
||||
export const ALIYUN_ACCESS_KEY_SECRET = ''
|
||||
|
||||
/** 可选:使用 STS 临时凭证时填写 SecurityToken;长期 AK 一般留空 */
|
||||
export const ALIYUN_SECURITY_TOKEN = ''
|
||||
|
||||
/** 生产:STS 获取地址(GET,返回 JSON)。若上面已填 AK/SK,则不再请求此地址 */
|
||||
export const ALIYUN_STS_URL = ''
|
||||
|
||||
/** OSS Bucket 名(不含域名) */
|
||||
export const OSS_BUCKET = ''
|
||||
|
||||
/** OSS region,如 oss-cn-shanghai */
|
||||
export const OSS_REGION = 'oss-cn-shanghai'
|
||||
|
||||
/** 上传目录前缀 */
|
||||
export const OSS_KEY_PREFIX = 'laser-card-segment/tmp/'
|
||||
|
||||
/** imageseg 地域,如 cn-shanghai */
|
||||
export const IMAGESEG_REGION = 'cn-shanghai'
|
||||
|
||||
/** 预留;当前直连抠图走 IVPD,不使用 IMM 项目名称 */
|
||||
export const IMM_PROJECT_NAME = 'laser-card'
|
||||
|
||||
/** IVPD 与 OSS 同地域 id,如 cn-shanghai */
|
||||
export const IMM_REGION = 'cn-shanghai'
|
||||
|
||||
/**
|
||||
* auto | backend | direct — 详见 segmentApi.example.js 注释;接后端时改为 backend 并填 SEGMENT_API_BASE。
|
||||
*/
|
||||
export const SEGMENT_TRANSPORT = 'auto'
|
||||
|
||||
/** 方案二:自建代理根地址(无末尾斜杠);与方案一互斥:有 OSS 且(AK 或 STS)时不会走代理 */
|
||||
export const SEGMENT_API_BASE = ''
|
||||
|
||||
export const SEGMENT_API_TOKEN = ''
|
||||
320
frontend/utils/laser-card/segmentationCloud.js
Normal file
@ -0,0 +1,320 @@
|
||||
import {
|
||||
ALIYUN_ACCESS_KEY_ID,
|
||||
ALIYUN_ACCESS_KEY_SECRET,
|
||||
ALIYUN_SECURITY_TOKEN,
|
||||
ALIYUN_STS_URL,
|
||||
IMM_REGION,
|
||||
OSS_BUCKET,
|
||||
OSS_KEY_PREFIX,
|
||||
OSS_REGION,
|
||||
SEGMENT_API_BASE,
|
||||
SEGMENT_API_TOKEN,
|
||||
SEGMENT_TRANSPORT
|
||||
} from './segmentApi.js'
|
||||
import {
|
||||
ossPostObjectFromLocalFilePath,
|
||||
ossPresignedObjectUrl,
|
||||
rpcIvpdSegmentImage,
|
||||
looksLikeBareOssEndpointUrl
|
||||
} from './aliyunPortraitUni.js'
|
||||
|
||||
function uniRequest(options) {
|
||||
return new Promise((resolve, reject) => {
|
||||
uni.request({
|
||||
...options,
|
||||
success: (r) => {
|
||||
if (r.statusCode >= 200 && r.statusCode < 300) {
|
||||
resolve(r)
|
||||
} else {
|
||||
const msg =
|
||||
(typeof r.data === 'object' && r.data && (r.data.Message || r.data.message)) ||
|
||||
(typeof r.data === 'string' ? r.data : '') ||
|
||||
`HTTP ${r.statusCode}`
|
||||
reject(new Error(msg))
|
||||
}
|
||||
},
|
||||
fail: (e) => reject(new Error(e.errMsg || '网络请求失败'))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/** 内层 Promise 长期无回调时强制失败(避免一直「正在识别」) */
|
||||
function raceWithTimeout(promise, ms, errMsg) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const t = setTimeout(() => reject(new Error(errMsg)), ms)
|
||||
Promise.resolve(promise).then(
|
||||
(v) => {
|
||||
clearTimeout(t)
|
||||
resolve(v)
|
||||
},
|
||||
(e) => {
|
||||
clearTimeout(t)
|
||||
reject(e)
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function normalizeStsPayload(raw) {
|
||||
if (!raw || typeof raw !== 'object') {
|
||||
return null
|
||||
}
|
||||
if (raw.Credentials) {
|
||||
const c = raw.Credentials
|
||||
return {
|
||||
accessKeyId: c.AccessKeyId,
|
||||
accessKeySecret: c.AccessKeySecret,
|
||||
securityToken: c.SecurityToken || undefined,
|
||||
expiration: c.Expiration
|
||||
}
|
||||
}
|
||||
if (raw.accessKeyId && raw.accessKeySecret) {
|
||||
return {
|
||||
accessKeyId: raw.accessKeyId,
|
||||
accessKeySecret: raw.accessKeySecret,
|
||||
securityToken: raw.securityToken || undefined,
|
||||
expiration: raw.expiration
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
async function fetchStsCredentials() {
|
||||
const url = (ALIYUN_STS_URL || '').trim()
|
||||
if (!url) {
|
||||
return null
|
||||
}
|
||||
const res = await uniRequest({ url, method: 'GET', timeout: 30000 })
|
||||
const data = typeof res.data === 'string' ? JSON.parse(res.data) : res.data
|
||||
const cred = normalizeStsPayload(data)
|
||||
if (!cred || !cred.accessKeyId) {
|
||||
throw new Error('STS 地址返回格式不正确(需 Credentials 或 accessKeyId/accessKeySecret)')
|
||||
}
|
||||
return cred
|
||||
}
|
||||
|
||||
/** 开发直配 AK,或 STS 拉取 */
|
||||
function getCredentialsFromConfig() {
|
||||
const id = (ALIYUN_ACCESS_KEY_ID || '').trim()
|
||||
const sec = (ALIYUN_ACCESS_KEY_SECRET || '').trim()
|
||||
const token = (ALIYUN_SECURITY_TOKEN || '').trim()
|
||||
if (id && sec) {
|
||||
return {
|
||||
accessKeyId: id,
|
||||
accessKeySecret: sec,
|
||||
securityToken: token || undefined
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
async function resolveCredentials() {
|
||||
const fromFile = getCredentialsFromConfig()
|
||||
if (fromFile) {
|
||||
return fromFile
|
||||
}
|
||||
const url = (ALIYUN_STS_URL || '').trim()
|
||||
if (!url) {
|
||||
throw new Error('请配置 ALIYUN_ACCESS_KEY_ID/SECRET(开发)或 ALIYUN_STS_URL(生产)')
|
||||
}
|
||||
return fetchStsCredentials()
|
||||
}
|
||||
|
||||
function canUseDirectAliyunClient() {
|
||||
const bucket = (OSS_BUCKET || '').trim()
|
||||
const hasAk =
|
||||
!!(ALIYUN_ACCESS_KEY_ID || '').trim() && !!(ALIYUN_ACCESS_KEY_SECRET || '').trim()
|
||||
const hasStsUrl = !!(ALIYUN_STS_URL || '').trim()
|
||||
return !!(bucket && (hasAk || hasStsUrl))
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {'direct' | 'backend'}
|
||||
*/
|
||||
function resolveSegmentTransport() {
|
||||
const raw = String(SEGMENT_TRANSPORT == null ? 'auto' : SEGMENT_TRANSPORT)
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
if (raw === 'backend' || raw === 'proxy' || raw === 'server') {
|
||||
return 'backend'
|
||||
}
|
||||
if (raw === 'direct' || raw === 'aliyun' || raw === 'client' || raw === 'oss') {
|
||||
if (!canUseDirectAliyunClient()) {
|
||||
throw new Error(
|
||||
'SEGMENT_TRANSPORT 为 direct/aliyun/client/oss 时需配置 OSS_BUCKET 以及(ALIYUN_ACCESS_KEY_ID+SECRET 或 ALIYUN_STS_URL)'
|
||||
)
|
||||
}
|
||||
return 'direct'
|
||||
}
|
||||
return canUseDirectAliyunClient() ? 'direct' : 'backend'
|
||||
}
|
||||
|
||||
/**
|
||||
* 凭证 → OSS PostObject → 签名 GET URL → IVPD SegmentImage(Url)→ downloadFile
|
||||
*/
|
||||
async function segmentViaOssIvpd(filePath) {
|
||||
const bucket = (OSS_BUCKET || '').trim()
|
||||
if (!bucket) {
|
||||
throw new Error('请在 config/segmentApi.js 配置 OSS_BUCKET')
|
||||
}
|
||||
|
||||
const cred = await resolveCredentials()
|
||||
|
||||
const ext = /\.png$/i.test(String(filePath || '')) ? 'png' : 'jpg'
|
||||
const base = `${Date.now()}_${Math.random().toString(36).slice(2, 10)}`
|
||||
const prefix = OSS_KEY_PREFIX.replace(/^\/+/, '')
|
||||
const objectKeyIn = `${prefix}${base}.${ext}`
|
||||
|
||||
await ossPostObjectFromLocalFilePath({
|
||||
bucket,
|
||||
ossRegionId: OSS_REGION,
|
||||
accessKeyId: cred.accessKeyId,
|
||||
accessKeySecret: cred.accessKeySecret,
|
||||
securityToken: cred.securityToken,
|
||||
objectKey: objectKeyIn,
|
||||
filePath
|
||||
})
|
||||
|
||||
const imageUrlForSeg = await ossPresignedObjectUrl({
|
||||
bucket,
|
||||
ossRegionId: OSS_REGION,
|
||||
accessKeyId: cred.accessKeyId,
|
||||
accessKeySecret: cred.accessKeySecret,
|
||||
securityToken: cred.securityToken,
|
||||
objectKey: objectKeyIn,
|
||||
expiresSec: 3600
|
||||
})
|
||||
|
||||
const ivpdRegion = (IMM_REGION || '').trim() || String(OSS_REGION || '').replace(/^oss-/i, '')
|
||||
|
||||
const outUrl = await rpcIvpdSegmentImage({
|
||||
imageUrl: imageUrlForSeg,
|
||||
accessKeyId: cred.accessKeyId,
|
||||
accessKeySecret: cred.accessKeySecret,
|
||||
securityToken: cred.securityToken,
|
||||
regionId: ivpdRegion
|
||||
})
|
||||
|
||||
return raceWithTimeout(
|
||||
new Promise((res, rej) => {
|
||||
uni.downloadFile({
|
||||
url: outUrl,
|
||||
timeout: 120000,
|
||||
success: (dr) => {
|
||||
if (dr.statusCode !== 200 || !dr.tempFilePath) {
|
||||
rej(new Error('下载分割结果失败'))
|
||||
return
|
||||
}
|
||||
res({ kind: 'cutout', localPath: dr.tempFilePath })
|
||||
},
|
||||
fail: (e) => {
|
||||
const raw = e.errMsg || '未知错误'
|
||||
const needDomainHint =
|
||||
looksLikeBareOssEndpointUrl(raw) ||
|
||||
(/^https?:\/\//i.test(String(raw).trim()) && String(raw).includes('aliyuncs'))
|
||||
const extra = needDomainHint
|
||||
? '(若仅为 URL:请在 manifest → networkSecurity.domainWhiteList 中加入下载域名并重编译。)'
|
||||
: ''
|
||||
rej(new Error(`下载分割结果失败:${raw}${extra}`))
|
||||
}
|
||||
})
|
||||
}),
|
||||
125000,
|
||||
'下载分割结果超时'
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 方案二:自建 Node 代理(server/index.js)
|
||||
*/
|
||||
function segmentViaProxy(filePath) {
|
||||
const base = (SEGMENT_API_BASE || '').trim().replace(/\/+$/, '')
|
||||
if (!base) {
|
||||
return Promise.reject(
|
||||
new Error(
|
||||
'缺少 SEGMENT_API_BASE:HTTP/后端抠图需要填写根地址(POST {BASE}/segment)。若使用客户端直连阿里云,请配置 OSS_BUCKET 与 AK 或 STS,并将 SEGMENT_TRANSPORT 设为 auto 或 direct。'
|
||||
)
|
||||
)
|
||||
}
|
||||
const url = `${base}/segment`
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
uni.uploadFile({
|
||||
url,
|
||||
filePath,
|
||||
name: 'image',
|
||||
timeout: 120000,
|
||||
header: SEGMENT_API_TOKEN ? { 'x-laser-token': SEGMENT_API_TOKEN } : {},
|
||||
success: (res) => {
|
||||
if (res.statusCode !== 200) {
|
||||
reject(new Error(typeof res.data === 'string' ? res.data : `HTTP ${res.statusCode}`))
|
||||
return
|
||||
}
|
||||
let body
|
||||
try {
|
||||
body = typeof res.data === 'string' ? JSON.parse(res.data) : res.data
|
||||
} catch (e) {
|
||||
reject(new Error('代理返回非 JSON'))
|
||||
return
|
||||
}
|
||||
if (!body || body.ok !== true || !body.imageUrl) {
|
||||
reject(new Error((body && body.error) || '抠图失败'))
|
||||
return
|
||||
}
|
||||
uni.downloadFile({
|
||||
url: body.imageUrl,
|
||||
timeout: 120000,
|
||||
success: (dr) => {
|
||||
if (dr.statusCode !== 200 || !dr.tempFilePath) {
|
||||
reject(new Error('下载分割结果失败'))
|
||||
return
|
||||
}
|
||||
resolve({ kind: 'cutout', localPath: dr.tempFilePath })
|
||||
},
|
||||
fail: (e) => reject(new Error(e.errMsg || 'downloadFile 失败'))
|
||||
})
|
||||
},
|
||||
fail: (e) => reject(new Error(e.errMsg || 'uploadFile 失败'))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/** 防止某一步网络/原生无回调导致一直转圈 */
|
||||
function withTimeout(promise, ms, message) {
|
||||
let timer = null
|
||||
return new Promise((resolve, reject) => {
|
||||
timer = setTimeout(() => {
|
||||
timer = null
|
||||
reject(new Error(message))
|
||||
}, ms)
|
||||
promise.then(
|
||||
(v) => {
|
||||
if (timer != null) {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
resolve(v)
|
||||
},
|
||||
(e) => {
|
||||
if (timer != null) {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
reject(e)
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传本地图并获取抠图结果本地路径。
|
||||
* 链路由 config/segmentApi.js 的 SEGMENT_TRANSPORT 决定:direct=客户端 OSS + IVPD SegmentImage;backend=HTTP 后端(契约同 server/index.js);auto=能直连则直连否则走后端。
|
||||
*/
|
||||
export function segmentPortraitToLocal(filePath) {
|
||||
const transport = resolveSegmentTransport()
|
||||
const inner =
|
||||
transport === 'direct' ? segmentViaOssIvpd(filePath) : segmentViaProxy(filePath)
|
||||
return withTimeout(
|
||||
inner,
|
||||
240000,
|
||||
'智能抠图超时:请检查手机网络、OSS、IVPD 地域(IMM_REGION,如 cn-shanghai)、RAM 权限及 manifest 网络权限'
|
||||
)
|
||||
}
|
||||
256
frontend/utils/lenticular-engine.js
Normal file
@ -0,0 +1,256 @@
|
||||
/**
|
||||
* 光栅叠化引擎(自 Raster-Card-L 移植为纯 JS,无 DOM 依赖)
|
||||
*/
|
||||
|
||||
export const PREV_LAYER_GHOST_MIN = 0.065
|
||||
|
||||
const NON_DOMINANT_RESIDUAL_MIN = 0.055
|
||||
const PARALLAX_UNIFY_GAP_LO = 0.065
|
||||
const PARALLAX_UNIFY_GAP_HI = 0.2
|
||||
const OFFSET_X_EMA_BASE = 0.16
|
||||
const OFFSET_X_EMA_STAB_SCALE = 0.26
|
||||
const PARALLAX_BLEND_T_EMA_BASE = 0.14
|
||||
const PARALLAX_BLEND_T_EMA_STAB_SCALE = 0.24
|
||||
|
||||
export const DEFAULT_PHYSICS = {
|
||||
tiltSensitivity: 72,
|
||||
transitionSmoothness: 66,
|
||||
parallaxDepth: 0,
|
||||
gyroSimEnabled: true,
|
||||
backgroundAnglePercent: 30,
|
||||
angleStability: 88,
|
||||
lenticularPitchPx: 16,
|
||||
/** 1 = 默认;0 = 关闭 displayGamma 近零软衰减(硬件倾斜起步更跟手) */
|
||||
sensorDeadzoneStrength: 1,
|
||||
}
|
||||
|
||||
function lerp(a, b, t) {
|
||||
return a + (b - a) * t
|
||||
}
|
||||
|
||||
function clamp(v, min, max) {
|
||||
return Math.max(min, Math.min(max, v))
|
||||
}
|
||||
|
||||
function smoothstep(edge0, edge1, x) {
|
||||
const t = clamp((x - edge0) / (edge1 - edge0), 0, 1)
|
||||
return t * t * (3 - 2 * t)
|
||||
}
|
||||
|
||||
function softAttenuateNearZero(g, db) {
|
||||
if (db <= 1e-9) return g
|
||||
const a = Math.abs(g)
|
||||
if (a >= db) return g
|
||||
const k = smoothstep(0, db, a)
|
||||
return (g >= 0 ? 1 : -1) * a * k
|
||||
}
|
||||
|
||||
export class LenticularEngine {
|
||||
constructor(physics) {
|
||||
this.layers = []
|
||||
this.physics = { ...physics }
|
||||
this.smoothedSensor = { gamma: 0, beta: 0, timestamp: 0 }
|
||||
this.displayGamma = 0
|
||||
this.layerOffsetXSmoothed = new Map()
|
||||
this.parallaxBlendTSmoothed = 0
|
||||
this.renderState = {
|
||||
layerOffsets: new Map(),
|
||||
layerOpacities: new Map(),
|
||||
stripePhaseShift: 0,
|
||||
stripShares: [1 / 3, 1 / 3, 1 / 3],
|
||||
lenticularPitchPx: 14,
|
||||
prevLayerGhost: null,
|
||||
}
|
||||
}
|
||||
|
||||
setLayers(layers) {
|
||||
this.layers = [...layers]
|
||||
this.layerOffsetXSmoothed.clear()
|
||||
this.parallaxBlendTSmoothed = 0
|
||||
for (const layer of this.layers) {
|
||||
this.renderState.layerOffsets.set(layer.id, { x: 0, y: 0 })
|
||||
this.renderState.layerOpacities.set(layer.id, layer.opacity)
|
||||
}
|
||||
}
|
||||
|
||||
updatePhysics(config) {
|
||||
Object.assign(this.physics, config)
|
||||
}
|
||||
|
||||
getPhysics() {
|
||||
return { ...this.physics }
|
||||
}
|
||||
|
||||
feedSensor(raw) {
|
||||
const alpha = this.calcSmoothingAlpha()
|
||||
this.smoothedSensor = {
|
||||
gamma: lerp(this.smoothedSensor.gamma, raw.gamma, alpha),
|
||||
beta: 0,
|
||||
timestamp: raw.timestamp,
|
||||
}
|
||||
this.updateDisplayStable()
|
||||
return this.computeRenderState()
|
||||
}
|
||||
|
||||
feedSimulatedTilt(normalizedX, _normalizedY) {
|
||||
return this.feedSensor({
|
||||
gamma: clamp(normalizedX, -1, 1),
|
||||
beta: 0,
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
}
|
||||
|
||||
computeRenderState() {
|
||||
const sensitivity = this.physics.tiltSensitivity / 100
|
||||
const maxDisplacement = this.physics.parallaxDepth
|
||||
const gamma = this.displayGamma
|
||||
const N = this.layers.length
|
||||
|
||||
const gPick = clamp(gamma * (0.44 + sensitivity * 0.52), -1, 1)
|
||||
const u = (gPick + 1) / 2
|
||||
|
||||
const stability = this.physics.angleStability / 100
|
||||
const smooth = this.physics.transitionSmoothness / 100
|
||||
const blendBase = 0.04 + smooth * 0.13 + stability * 0.15
|
||||
|
||||
const shares = this.computeStripShares(N)
|
||||
const centers = []
|
||||
let cum = 0
|
||||
for (let i = 0; i < N; i++) {
|
||||
centers.push(cum + shares[i] / 2)
|
||||
cum += shares[i]
|
||||
}
|
||||
|
||||
const rawWeights = []
|
||||
for (let i = 0; i < N; i++) {
|
||||
const half = shares[i] / 2 + blendBase
|
||||
const d = Math.abs(u - centers[i])
|
||||
rawWeights.push(Math.max(0, 1 - d / half))
|
||||
}
|
||||
const sumW = rawWeights.reduce((a, b) => a + b, 0) || 1
|
||||
|
||||
const smoothedW = []
|
||||
for (let i = 0; i < N; i++) {
|
||||
let weight = rawWeights[i] / sumW
|
||||
weight = weight * weight * (3 - 2 * weight)
|
||||
smoothedW.push(weight)
|
||||
}
|
||||
|
||||
let dominant = 0
|
||||
let bestScore = -1
|
||||
for (let i = 0; i < N; i++) {
|
||||
const layer = this.layers[i]
|
||||
const score = layer.opacity * smoothedW[i]
|
||||
if (score > bestScore) {
|
||||
bestScore = score
|
||||
dominant = i
|
||||
}
|
||||
}
|
||||
|
||||
this.renderState.prevLayerGhost = null
|
||||
|
||||
const parallaxScale = 0.42
|
||||
const sortedW = [...smoothedW].sort((a, b) => b - a)
|
||||
const weightGap = N >= 2 ? sortedW[0] - sortedW[1] : 1
|
||||
const parallaxLayerBlendTRaw = smoothstep(PARALLAX_UNIFY_GAP_LO, PARALLAX_UNIFY_GAP_HI, weightGap)
|
||||
const stabForEma = clamp(stability, 0, 1)
|
||||
const blendTEmaMix = PARALLAX_BLEND_T_EMA_BASE + stabForEma * PARALLAX_BLEND_T_EMA_STAB_SCALE
|
||||
this.parallaxBlendTSmoothed = lerp(this.parallaxBlendTSmoothed, parallaxLayerBlendTRaw, blendTEmaMix)
|
||||
const parallaxLayerBlendT = this.parallaxBlendTSmoothed
|
||||
|
||||
const swSum = smoothedW.reduce((a, b) => a + b, 0) || 1
|
||||
let unifiedParallaxFactor = 0
|
||||
for (let i = 0; i < N; i++) {
|
||||
unifiedParallaxFactor += (smoothedW[i] / swSum) * this.layers[i].parallaxFactor
|
||||
}
|
||||
const unifiedEffective = maxDisplacement * sensitivity * unifiedParallaxFactor * parallaxScale
|
||||
const unifiedOffsetX = gamma * unifiedEffective
|
||||
|
||||
for (let i = 0; i < N; i++) {
|
||||
const layer = this.layers[i]
|
||||
|
||||
const specificEffective = maxDisplacement * sensitivity * layer.parallaxFactor * parallaxScale
|
||||
const specificOffsetX = gamma * specificEffective
|
||||
const offsetXRaw = lerp(unifiedOffsetX, specificOffsetX, parallaxLayerBlendT)
|
||||
const offsetEmaMix = OFFSET_X_EMA_BASE + stabForEma * OFFSET_X_EMA_STAB_SCALE
|
||||
const prevX = this.layerOffsetXSmoothed.get(layer.id)
|
||||
const offsetX = prevX === undefined ? offsetXRaw : lerp(prevX, offsetXRaw, offsetEmaMix)
|
||||
this.layerOffsetXSmoothed.set(layer.id, offsetX)
|
||||
this.renderState.layerOffsets.set(layer.id, { x: offsetX, y: 0 })
|
||||
|
||||
const weight = smoothedW[i]
|
||||
const isAnchor = i === 0
|
||||
const floor = isAnchor && N > 1 ? 0.18 : 0
|
||||
const finalOpacity = clamp(Math.max(floor, layer.opacity * weight), 0, 1)
|
||||
this.renderState.layerOpacities.set(layer.id, finalOpacity)
|
||||
}
|
||||
|
||||
if (dominant >= 1) {
|
||||
const prev = this.layers[dominant - 1]
|
||||
const minGhost = PREV_LAYER_GHOST_MIN * prev.opacity
|
||||
const cur = this.renderState.layerOpacities.get(prev.id) != null ? this.renderState.layerOpacities.get(prev.id) : 0
|
||||
const boosted = clamp(Math.max(cur, minGhost), 0, 1)
|
||||
this.renderState.layerOpacities.set(prev.id, boosted)
|
||||
this.renderState.prevLayerGhost = { layerId: prev.id, alpha: minGhost }
|
||||
}
|
||||
|
||||
if (N >= 2) {
|
||||
for (let i = 0; i < N; i++) {
|
||||
if (i === dominant) continue
|
||||
const layer = this.layers[i]
|
||||
const minRes = NON_DOMINANT_RESIDUAL_MIN * layer.opacity
|
||||
const cur = this.renderState.layerOpacities.get(layer.id) != null ? this.renderState.layerOpacities.get(layer.id) : 0
|
||||
this.renderState.layerOpacities.set(layer.id, clamp(Math.max(cur, minRes), 0, 1))
|
||||
}
|
||||
}
|
||||
|
||||
this.renderState.stripShares = [...shares]
|
||||
this.renderState.stripePhaseShift = gamma * (0.38 + 0.52 * sensitivity)
|
||||
this.renderState.lenticularPitchPx = clamp(
|
||||
Number.isFinite(this.physics.lenticularPitchPx) ? this.physics.lenticularPitchPx : 14,
|
||||
8,
|
||||
64
|
||||
)
|
||||
|
||||
return this.renderState
|
||||
}
|
||||
|
||||
computeStripShares(N) {
|
||||
if (N <= 0) return []
|
||||
if (N === 1) return [1]
|
||||
if (N !== 3) {
|
||||
const w = 1 / N
|
||||
return Array.from({ length: N }, () => w)
|
||||
}
|
||||
const bg = clamp(this.physics.backgroundAnglePercent / 100, 0.1, 0.55)
|
||||
const rest = (1 - bg) / 2
|
||||
return [bg, rest, rest]
|
||||
}
|
||||
|
||||
updateDisplayStable() {
|
||||
const stab = clamp(this.physics.angleStability / 100, 0, 1)
|
||||
const k = 0.006 + (1 - stab) * 0.095
|
||||
let g = lerp(this.displayGamma, this.smoothedSensor.gamma, k)
|
||||
const dead =
|
||||
this.physics.sensorDeadzoneStrength != null ? Number(this.physics.sensorDeadzoneStrength) : 1
|
||||
if (!Number.isFinite(dead) || dead <= 1e-6) {
|
||||
this.displayGamma = clamp(g, -1, 1)
|
||||
return
|
||||
}
|
||||
const db = (0.016 + (1 - stab) * 0.034) * dead
|
||||
this.displayGamma = softAttenuateNearZero(g, db)
|
||||
}
|
||||
|
||||
calcSmoothingAlpha() {
|
||||
const s = this.physics.transitionSmoothness / 100
|
||||
return 1 - s * 0.9
|
||||
}
|
||||
|
||||
getRenderState() {
|
||||
return this.renderState
|
||||
}
|
||||
|
||||
getSmoothedSensor() {
|
||||
return { ...this.smoothedSensor }
|
||||
}
|
||||
}
|
||||
83
frontend/vite.config.js
Normal file
@ -0,0 +1,83 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import https from 'node:https'
|
||||
import uni from '@dcloudio/vite-plugin-uni'
|
||||
|
||||
/** 与 upload-signature 返回的 OSS 虚拟域名一致;换桶时请同步 */
|
||||
const OSS_DEV_HOST = 'top-fans-test.oss-cn-shanghai.aliyuncs.com'
|
||||
|
||||
/**
|
||||
* 部分环境下 Vite 内置 server.proxy 的 rewrite 对 POST 未生效,OSS 仍收到路径
|
||||
* /dev-oss-proxy,从而 405(ResourceType: Object)。此处用中间件固定转发到桶根 POST /。
|
||||
*/
|
||||
function ossDevPostProxyPlugin() {
|
||||
return {
|
||||
name: 'oss-dev-post-proxy',
|
||||
configureServer(server) {
|
||||
server.middlewares.use((req, res, next) => {
|
||||
const raw = req.url || ''
|
||||
if (!raw.startsWith('/dev-oss-proxy')) {
|
||||
return next()
|
||||
}
|
||||
if (req.method === 'OPTIONS') {
|
||||
res.statusCode = 204
|
||||
res.setHeader('Access-Control-Allow-Origin', '*')
|
||||
res.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS')
|
||||
res.setHeader(
|
||||
'Access-Control-Allow-Headers',
|
||||
String(req.headers['access-control-request-headers'] || '*')
|
||||
)
|
||||
res.end()
|
||||
return
|
||||
}
|
||||
if (req.method !== 'POST') {
|
||||
res.statusCode = 405
|
||||
res.setHeader('Content-Type', 'text/plain; charset=utf-8')
|
||||
res.end('OSS dev proxy only allows POST')
|
||||
return
|
||||
}
|
||||
const hop = new Set([
|
||||
'connection',
|
||||
'keep-alive',
|
||||
'proxy-authenticate',
|
||||
'proxy-authorization',
|
||||
'te',
|
||||
'trailers',
|
||||
'transfer-encoding',
|
||||
'upgrade'
|
||||
])
|
||||
/** @type {import('node:http').OutgoingHttpHeaders} */
|
||||
const headers = {}
|
||||
for (const [k, v] of Object.entries(req.headers)) {
|
||||
if (v === undefined || hop.has(k.toLowerCase())) continue
|
||||
headers[k] = v
|
||||
}
|
||||
headers.host = OSS_DEV_HOST
|
||||
const proxyReq = https.request(
|
||||
{
|
||||
hostname: OSS_DEV_HOST,
|
||||
port: 443,
|
||||
method: 'POST',
|
||||
path: '/',
|
||||
headers
|
||||
},
|
||||
(proxyRes) => {
|
||||
res.writeHead(proxyRes.statusCode || 502, proxyRes.headers)
|
||||
proxyRes.pipe(res)
|
||||
}
|
||||
)
|
||||
proxyReq.on('error', (err) => {
|
||||
if (!res.headersSent) {
|
||||
res.statusCode = 502
|
||||
res.setHeader('Content-Type', 'text/plain; charset=utf-8')
|
||||
}
|
||||
res.end(err.message)
|
||||
})
|
||||
req.pipe(proxyReq)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [ossDevPostProxyPlugin(), uni()]
|
||||
})
|
||||