Merge branch 'cesia' into dev

This commit is contained in:
zheng020 2026-05-15 11:30:59 +08:00
commit f0b0df2ebf
59 changed files with 8425 additions and 272 deletions

View 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;

View 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 处理首次创建统计

View File

@ -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 容器 */

View File

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

View 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>

View 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 + unicreateSelectorQuery().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>

View 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,
}
}

View File

@ -0,0 +1,768 @@
/**
* 光栅卡工作室倾斜驱动App 优先 imengyu-UniAndroidGyroDCloud 插件 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 / fastestgame≈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,
}
}

View File

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

View File

@ -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"
}
}
}

View File

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

View 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"
}
}
}

View File

@ -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"
}
]
}
}

View File

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

View 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);
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View 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>

View File

@ -12,10 +12,10 @@
</scroll-view>
</template>
<script setup>
defineOptions({
name: 'HorizontalScroll'
});
<script>
export default {
name: 'HorizontalScroll',
};
</script>
<style scoped>

View File

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

View File

@ -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 // 使reforder_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

View 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()

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 794 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 990 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 206 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 237 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 377 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 386 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 462 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 497 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 525 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 607 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 658 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

View File

@ -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 策略将本地临时文件直传 OSSApp / 小程序等
* @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====================
// 获取铸造活动列表

View 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 }
}

View 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,
}
}

View 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
}

View File

@ -0,0 +1,557 @@
/**
* uni-app App 端可用OSS Post/Put + 签名 GET URL直连抠图默认 IVPD SegmentImage另有 imageseg SegmentHDBodyPOP不依赖 ali-oss / Node http
* 签名使用 Web CryptoHMAC-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• IVPDhttps://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 SignatureDoesNotMatchAccessKeySecret 与当前 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-dateOSS 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
* Endpointivpd.{region}.aliyuncs.comVersion2019-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
}

View File

@ -0,0 +1,48 @@
/**
* 智能抠图方案一OSS + IVPD与方案二HTTP 后端契约见 server/index.js
*
* SEGMENT_TRANSPORTauto | backend | direct详见 segmentApi.example.js
*
* 方案一 · 推荐生产ALIYUN_STS_URL仅下发临时密钥的 HTTPSFC 不写主账号 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 = ''

View 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 SegmentImageUrl 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_BASEHTTP/后端抠图需要填写根地址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 SegmentImagebackend=HTTP 后端契约同 server/index.jsauto=能直连则直连否则走后端
*/
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 网络权限'
)
}

View 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
View 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从而 405ResourceType: 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()]
})