feat:添加光栅卡的选择
This commit is contained in:
parent
cf8671b5bb
commit
45d3adc373
28
.claude/settings.json
Normal file
28
.claude/settings.json
Normal file
@ -0,0 +1,28 @@
|
||||
{
|
||||
"hooks": {
|
||||
"PostToolUse": [
|
||||
{
|
||||
"matcher": "Edit|Write|Bash",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "git rev-parse --git-dir >/dev/null 2>&1 && code-review-graph update --skip-flows --repo \"/Users/liulujian/Documents/code/TopFansByGithub\" || true",
|
||||
"timeout": 30
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"SessionStart": [
|
||||
{
|
||||
"matcher": "",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "git rev-parse --git-dir >/dev/null 2>&1 && code-review-graph status --repo \"/Users/liulujian/Documents/code/TopFansByGithub\" || echo 'Not a git repo, skipping'",
|
||||
"timeout": 10
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@ -4,7 +4,12 @@
|
||||
"Skill(superpowers:subagent-driven-development)",
|
||||
"Skill(superpowers:subagent-driven-development:*)",
|
||||
"Bash(go build:*)",
|
||||
"Bash(go vet:*)"
|
||||
"Bash(go vet:*)",
|
||||
"mcp__code-review-graph__semantic_search_nodes_tool"
|
||||
]
|
||||
}
|
||||
},
|
||||
"enableAllProjectMcpServers": true,
|
||||
"enabledMcpjsonServers": [
|
||||
"code-review-graph"
|
||||
]
|
||||
}
|
||||
|
||||
27
.claude/skills/debug-issue/skill.md
Normal file
27
.claude/skills/debug-issue/skill.md
Normal file
@ -0,0 +1,27 @@
|
||||
---
|
||||
name: Debug Issue
|
||||
description: Systematically debug issues using graph-powered code navigation
|
||||
---
|
||||
|
||||
## Debug Issue
|
||||
|
||||
Use the knowledge graph to systematically trace and debug issues.
|
||||
|
||||
### Steps
|
||||
|
||||
1. Use `semantic_search_nodes` to find code related to the issue.
|
||||
2. Use `query_graph` with `callers_of` and `callees_of` to trace call chains.
|
||||
3. Use `get_flow` to see full execution paths through suspected areas.
|
||||
4. Run `detect_changes` to check if recent changes caused the issue.
|
||||
5. Use `get_impact_radius` on suspected files to see what else is affected.
|
||||
|
||||
### Tips
|
||||
|
||||
- Check both callers and callees to understand the full context.
|
||||
- Look at affected flows to find the entry point that triggers the bug.
|
||||
- Recent changes are the most common source of new issues.
|
||||
|
||||
## Token Efficiency Rules
|
||||
- ALWAYS start with `get_minimal_context(task="<your task>")` before any other graph tool.
|
||||
- Use `detail_level="minimal"` on all calls. Only escalate to "standard" when minimal is insufficient.
|
||||
- Target: complete any review/debug/refactor task in ≤5 tool calls and ≤800 total output tokens.
|
||||
28
.claude/skills/explore-codebase/skill.md
Normal file
28
.claude/skills/explore-codebase/skill.md
Normal file
@ -0,0 +1,28 @@
|
||||
---
|
||||
name: Explore Codebase
|
||||
description: Navigate and understand codebase structure using the knowledge graph
|
||||
---
|
||||
|
||||
## Explore Codebase
|
||||
|
||||
Use the code-review-graph MCP tools to explore and understand the codebase.
|
||||
|
||||
### Steps
|
||||
|
||||
1. Run `list_graph_stats` to see overall codebase metrics.
|
||||
2. Run `get_architecture_overview` for high-level community structure.
|
||||
3. Use `list_communities` to find major modules, then `get_community` for details.
|
||||
4. Use `semantic_search_nodes` to find specific functions or classes.
|
||||
5. Use `query_graph` with patterns like `callers_of`, `callees_of`, `imports_of` to trace relationships.
|
||||
6. Use `list_flows` and `get_flow` to understand execution paths.
|
||||
|
||||
### Tips
|
||||
|
||||
- Start broad (stats, architecture) then narrow down to specific areas.
|
||||
- Use `children_of` on a file to see all its functions and classes.
|
||||
- Use `find_large_functions` to identify complex code.
|
||||
|
||||
## Token Efficiency Rules
|
||||
- ALWAYS start with `get_minimal_context(task="<your task>")` before any other graph tool.
|
||||
- Use `detail_level="minimal"` on all calls. Only escalate to "standard" when minimal is insufficient.
|
||||
- Target: complete any review/debug/refactor task in ≤5 tool calls and ≤800 total output tokens.
|
||||
28
.claude/skills/refactor-safely/skill.md
Normal file
28
.claude/skills/refactor-safely/skill.md
Normal file
@ -0,0 +1,28 @@
|
||||
---
|
||||
name: Refactor Safely
|
||||
description: Plan and execute safe refactoring using dependency analysis
|
||||
---
|
||||
|
||||
## Refactor Safely
|
||||
|
||||
Use the knowledge graph to plan and execute refactoring with confidence.
|
||||
|
||||
### Steps
|
||||
|
||||
1. Use `refactor_tool` with mode="suggest" for community-driven refactoring suggestions.
|
||||
2. Use `refactor_tool` with mode="dead_code" to find unreferenced code.
|
||||
3. For renames, use `refactor_tool` with mode="rename" to preview all affected locations.
|
||||
4. Use `apply_refactor_tool` with the refactor_id to apply renames.
|
||||
5. After changes, run `detect_changes` to verify the refactoring impact.
|
||||
|
||||
### Safety Checks
|
||||
|
||||
- Always preview before applying (rename mode gives you an edit list).
|
||||
- Check `get_impact_radius` before major refactors.
|
||||
- Use `get_affected_flows` to ensure no critical paths are broken.
|
||||
- Run `find_large_functions` to identify decomposition targets.
|
||||
|
||||
## Token Efficiency Rules
|
||||
- ALWAYS start with `get_minimal_context(task="<your task>")` before any other graph tool.
|
||||
- Use `detail_level="minimal"` on all calls. Only escalate to "standard" when minimal is insufficient.
|
||||
- Target: complete any review/debug/refactor task in ≤5 tool calls and ≤800 total output tokens.
|
||||
29
.claude/skills/review-changes/skill.md
Normal file
29
.claude/skills/review-changes/skill.md
Normal file
@ -0,0 +1,29 @@
|
||||
---
|
||||
name: Review Changes
|
||||
description: Perform a structured code review using change detection and impact
|
||||
---
|
||||
|
||||
## Review Changes
|
||||
|
||||
Perform a thorough, risk-aware code review using the knowledge graph.
|
||||
|
||||
### Steps
|
||||
|
||||
1. Run `detect_changes` to get risk-scored change analysis.
|
||||
2. Run `get_affected_flows` to find impacted execution paths.
|
||||
3. For each high-risk function, run `query_graph` with pattern="tests_for" to check test coverage.
|
||||
4. Run `get_impact_radius` to understand the blast radius.
|
||||
5. For any untested changes, suggest specific test cases.
|
||||
|
||||
### Output Format
|
||||
|
||||
Provide findings grouped by risk level (high/medium/low) with:
|
||||
- What changed and why it matters
|
||||
- Test coverage status
|
||||
- Suggested improvements
|
||||
- Overall merge recommendation
|
||||
|
||||
## Token Efficiency Rules
|
||||
- ALWAYS start with `get_minimal_context(task="<your task>")` before any other graph tool.
|
||||
- Use `detail_level="minimal"` on all calls. Only escalate to "standard" when minimal is insufficient.
|
||||
- Target: complete any review/debug/refactor task in ≤5 tool calls and ≤800 total output tokens.
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@ -7,4 +7,6 @@ frontend/.hbuilderx/launch.json
|
||||
.idea
|
||||
|
||||
node_modules
|
||||
package-lock.json
|
||||
package-lock.json
|
||||
# Added by code-review-graph
|
||||
.code-review-graph/
|
||||
|
||||
13
.mcp.json
Normal file
13
.mcp.json
Normal file
@ -0,0 +1,13 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"code-review-graph": {
|
||||
"command": "uvx",
|
||||
"args": [
|
||||
"code-review-graph",
|
||||
"serve"
|
||||
],
|
||||
"cwd": "/Users/liulujian/Documents/code/TopFansByGithub",
|
||||
"type": "stdio"
|
||||
}
|
||||
}
|
||||
}
|
||||
38
CLAUDE.md
Normal file
38
CLAUDE.md
Normal file
@ -0,0 +1,38 @@
|
||||
<!-- code-review-graph MCP tools -->
|
||||
## MCP Tools: code-review-graph
|
||||
|
||||
**IMPORTANT: This project has a knowledge graph. ALWAYS use the
|
||||
code-review-graph MCP tools BEFORE using Grep/Glob/Read to explore
|
||||
the codebase.** The graph is faster, cheaper (fewer tokens), and gives
|
||||
you structural context (callers, dependents, test coverage) that file
|
||||
scanning cannot.
|
||||
|
||||
### When to use graph tools FIRST
|
||||
|
||||
- **Exploring code**: `semantic_search_nodes` or `query_graph` instead of Grep
|
||||
- **Understanding impact**: `get_impact_radius` instead of manually tracing imports
|
||||
- **Code review**: `detect_changes` + `get_review_context` instead of reading entire files
|
||||
- **Finding relationships**: `query_graph` with callers_of/callees_of/imports_of/tests_for
|
||||
- **Architecture questions**: `get_architecture_overview` + `list_communities`
|
||||
|
||||
Fall back to Grep/Glob/Read **only** when the graph doesn't cover what you need.
|
||||
|
||||
### Key Tools
|
||||
|
||||
| Tool | Use when |
|
||||
| ------ | ---------- |
|
||||
| `detect_changes` | Reviewing code changes — gives risk-scored analysis |
|
||||
| `get_review_context` | Need source snippets for review — token-efficient |
|
||||
| `get_impact_radius` | Understanding blast radius of a change |
|
||||
| `get_affected_flows` | Finding which execution paths are impacted |
|
||||
| `query_graph` | Tracing callers, callees, imports, tests, dependencies |
|
||||
| `semantic_search_nodes` | Finding functions/classes by name or keyword |
|
||||
| `get_architecture_overview` | Understanding high-level codebase structure |
|
||||
| `refactor_tool` | Planning renames, finding dead code |
|
||||
|
||||
### Workflow
|
||||
|
||||
1. The graph auto-updates on file changes (via hooks).
|
||||
2. Use `detect_changes` for code review.
|
||||
3. Use `get_affected_flows` to understand impact.
|
||||
4. Use `query_graph` pattern="tests_for" to check coverage.
|
||||
119
backend/dev.sh
119
backend/dev.sh
@ -219,20 +219,26 @@ restart_service() {
|
||||
}
|
||||
|
||||
# 启动文件监听器
|
||||
# 用法: start_watcher name dir binary port use_db
|
||||
# 用法: start_watcher name dir1:dir2:dir3 binary port use_db
|
||||
# 注意: 多个目录用冒号分隔
|
||||
start_watcher() {
|
||||
local name=$1
|
||||
local dir=$2
|
||||
local dirs=$2
|
||||
local binary=$3
|
||||
local port=$4
|
||||
local use_db=$5
|
||||
local watch_path="$SCRIPT_DIR/$dir"
|
||||
local watch_paths=()
|
||||
local watch_path=""
|
||||
local restart_marker="/tmp/dev_sh_${name}_restart"
|
||||
|
||||
if [ ! -d "$watch_path" ]; then
|
||||
echo -e "${RED}❌ 监听目录不存在: $watch_path${NC}"
|
||||
return 1
|
||||
fi
|
||||
# 分割多个目录
|
||||
IFS=':' read -ra watch_paths <<< "$dirs"
|
||||
for d in "${watch_paths[@]}"; do
|
||||
if [ ! -d "$SCRIPT_DIR/$d" ]; then
|
||||
echo -e "${RED}❌ 监听目录不存在: $SCRIPT_DIR/$d${NC}"
|
||||
return 1
|
||||
fi
|
||||
done
|
||||
|
||||
# 清理可能遗留的重启标记
|
||||
rm -f "$restart_marker"
|
||||
@ -240,20 +246,31 @@ start_watcher() {
|
||||
(
|
||||
if [[ "$(uname)" == "Darwin" ]]; then
|
||||
# 排除: .git目录, 测试文件, 二进制文件(无扩展名), .exe, bin/目录
|
||||
fswatch -r "$watch_path" \
|
||||
--exclude='\.git' \
|
||||
--exclude='_test\.go$' \
|
||||
--exclude='\.exe$' \
|
||||
--exclude='gateway$' \
|
||||
--exclude='userService$' \
|
||||
--exclude='assetService$' \
|
||||
--exclude='socialService$' \
|
||||
--exclude='galleryService$' \
|
||||
--exclude='activityService$' \
|
||||
--exclude='taskService$' \
|
||||
--exclude='starbookService$'
|
||||
local exclude_args=""
|
||||
for d in "${watch_paths[@]}"; do
|
||||
if [[ "$d" == "pkg/proto"* ]] || [[ "$d" == "proto"* ]]; then
|
||||
# proto 目录监听所有 proto 文件变化
|
||||
fswatch -r "$SCRIPT_DIR/$d" \
|
||||
--exclude='\.git' \
|
||||
--exclude='_test\.go$' \
|
||||
--exclude='\.exe$' &
|
||||
else
|
||||
fswatch -r "$SCRIPT_DIR/$d" \
|
||||
--exclude='\.git' \
|
||||
--exclude='_test\.go$' \
|
||||
--exclude='\.exe$' \
|
||||
--exclude='gateway$' \
|
||||
--exclude='userService$' \
|
||||
--exclude='assetService$' \
|
||||
--exclude='socialService$' \
|
||||
--exclude='galleryService$' \
|
||||
--exclude='activityService$' \
|
||||
--exclude='taskService$' \
|
||||
--exclude='starbookService$' &
|
||||
fi
|
||||
done
|
||||
else
|
||||
inotifywait -r -m -e modify,create,write "$watch_path" \
|
||||
inotifywait -r -m -e modify,create,write "${watch_paths[@]}" \
|
||||
--exclude='\.git' \
|
||||
--exclude='_test\.go$' \
|
||||
--exclude='\.exe$' \
|
||||
@ -264,16 +281,9 @@ start_watcher() {
|
||||
--exclude='galleryService$' \
|
||||
--exclude='activityService$' \
|
||||
--exclude='taskService$' \
|
||||
--exclude='starbookService$'
|
||||
fi | while read event; do
|
||||
# 时间戳防抖:每次事件更新标记文件
|
||||
# Darwin/Windows 不支持 date +%s%N,使用 python 获取纳秒时间戳
|
||||
if [[ "$(uname)" == "Darwin" ]] || [[ "$(uname)" =~ ^MINGW ]] || [[ "$(uname)" =~ ^MSYS ]] || [[ "$(uname)" =~ ^CYGWIN ]]; then
|
||||
python3 -c 'import time; print(int(time.time()*1e9))' > "$restart_marker"
|
||||
else
|
||||
date +%s%N > "$restart_marker"
|
||||
fi
|
||||
done
|
||||
--exclude='starbookService$' &
|
||||
fi
|
||||
wait
|
||||
) &
|
||||
local watcher_pid=$!
|
||||
echo "$watcher_pid" >> /tmp/dev_sh_watchers.tmp
|
||||
@ -297,7 +307,25 @@ start_watcher() {
|
||||
if (( elapsed >= 500000000 )); then
|
||||
# 距上次事件已过 300ms,执行重启
|
||||
rm -f "$restart_marker"
|
||||
restart_service "$name" "$dir" "$binary" "$port" "$use_db"
|
||||
|
||||
# 检查是否包含 proto 目录变化,包含则先重新编译 proto
|
||||
local proto_changed=false
|
||||
for d in "${watch_paths[@]}"; do
|
||||
if [[ "$d" == "pkg/proto"* ]] || [[ "$d" == "proto"* ]]; then
|
||||
proto_changed=true
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$proto_changed" = true ]; then
|
||||
echo -e "${YELLOW}🔄 [Proto 文件变化] 重新编译 Proto...${NC}"
|
||||
build_proto
|
||||
# proto 变化时重启 gateway(gateway 是 proto 客户端的调用方)
|
||||
echo -e "${YELLOW}🔄 [Proto 变化] 重启 gateway...${NC}"
|
||||
restart_service "gateway" "gateway" "gateway/gateway" "8080" "0"
|
||||
fi
|
||||
|
||||
restart_service "$name" "${watch_paths[0]}" "$binary" "$port" "$use_db"
|
||||
fi
|
||||
done
|
||||
) &
|
||||
@ -328,6 +356,21 @@ for service in gateway userService socialService assetService galleryService act
|
||||
done
|
||||
sleep 1
|
||||
|
||||
# 编译 proto 文件
|
||||
build_proto() {
|
||||
echo -e "${YELLOW}📦 编译 Proto 文件...${NC}"
|
||||
if [ -f "$SCRIPT_DIR/scripts/compile-proto.sh" ]; then
|
||||
bash "$SCRIPT_DIR/scripts/compile-proto.sh"
|
||||
else
|
||||
echo -e "${RED}❌ compile-proto.sh 不存在,跳过${NC}"
|
||||
fi
|
||||
}
|
||||
|
||||
# 先编译 proto 文件
|
||||
echo ""
|
||||
echo -e "${YELLOW}🔨 预编译 Proto 文件...${NC}"
|
||||
build_proto
|
||||
|
||||
# 先构建所有服务
|
||||
echo ""
|
||||
echo -e "${YELLOW}🔨 预编译所有服务...${NC}"
|
||||
@ -361,13 +404,13 @@ start_service "gateway" "gateway/gateway" 8080
|
||||
# 启动所有文件监听器
|
||||
echo ""
|
||||
echo -e "${YELLOW}👁️ 启动所有文件监听器...${NC}"
|
||||
start_watcher "gateway" "gateway" "gateway/gateway" 8080 0
|
||||
start_watcher "userService" "services/userService" "services/userService/userService" 20000 1
|
||||
start_watcher "assetService" "services/assetService" "services/assetService/assetService" 20003 1
|
||||
start_watcher "socialService" "services/socialService" "services/socialService/socialService" 20002 1
|
||||
start_watcher "activityService" "services/activityService" "services/activityService/activityService" 20005 1
|
||||
start_watcher "taskService" "services/taskService" "services/taskService/taskService" 20006 1
|
||||
start_watcher "starbookService" "services/starbookService" "services/starbookService/starbookService" 20007 1
|
||||
start_watcher "gateway" "gateway:pkg/proto" "gateway/gateway" 8080 0
|
||||
start_watcher "userService" "services/userService" "services/userService/userService" 20000 1
|
||||
start_watcher "assetService" "services/assetService:pkg/proto/asset" "services/assetService/assetService" 20003 1
|
||||
start_watcher "socialService" "services/socialService" "services/socialService/socialService" 20002 1
|
||||
start_watcher "activityService" "services/activityService" "services/activityService/activityService" 20005 1
|
||||
start_watcher "taskService" "services/taskService" "services/taskService/taskService" 20006 1
|
||||
start_watcher "starbookService" "services/starbookService" "services/starbookService/starbookService" 20007 1
|
||||
|
||||
echo ""
|
||||
echo -e "${GREEN}========================================${NC}"
|
||||
|
||||
@ -228,6 +228,51 @@ func (ctrl *AssetController) PreCreateMintOrder(c *gin.Context) {
|
||||
response.Success(c, data)
|
||||
}
|
||||
|
||||
// EstimateMintCost 估算铸造费用
|
||||
// @Summary 估算铸造费用
|
||||
// @Description 在用户确认铸造前,显示本次铸造消耗水晶数和当前余额
|
||||
// @Tags assets
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Success 200 {object} response.Response
|
||||
// @Router /api/v1/assets/mints/cost-estimate [post]
|
||||
func (ctrl *AssetController) EstimateMintCost(c *gin.Context) {
|
||||
userID, exists := c.Get("user_id")
|
||||
if !exists {
|
||||
response.Error(c, http.StatusUnauthorized, "未授权")
|
||||
return
|
||||
}
|
||||
starID, exists := c.Get("star_id")
|
||||
if !exists {
|
||||
response.Error(c, http.StatusUnauthorized, "未授权")
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
ctx = context.WithValue(ctx, constant.AttachmentKey, map[string]interface{}{
|
||||
"user_id": strconv.FormatInt(userID.(int64), 10),
|
||||
"star_id": strconv.FormatInt(starID.(int64), 10),
|
||||
})
|
||||
|
||||
resp, err := ctrl.assetService.EstimateMintCost(ctx, &pbAsset.EstimateMintCostRequest{})
|
||||
if err != nil {
|
||||
code, msg := parseRPCError(err)
|
||||
response.ErrorWithCode(c, code, msg)
|
||||
return
|
||||
}
|
||||
|
||||
data := map[string]interface{}{
|
||||
"cost_crystal": resp.CostCrystal,
|
||||
"current_balance": resp.CurrentBalance,
|
||||
"balance_after": resp.BalanceAfter,
|
||||
"mint_count": resp.MintCount,
|
||||
"next_tier_hint": resp.NextTierHint,
|
||||
}
|
||||
response.Success(c, data)
|
||||
}
|
||||
|
||||
// CreateMintOrder 创建铸造订单
|
||||
// @Summary 创建铸造订单
|
||||
// @Description 创建一个新的数字藏品铸造订单
|
||||
@ -1619,9 +1664,18 @@ func (ctrl *AssetController) GetAssetMaterials(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
userID, _ := c.Get("user_id")
|
||||
starID, _ := c.Get("star_id")
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// 设置 Dubbo attachments
|
||||
ctx = context.WithValue(ctx, constant.AttachmentKey, map[string]interface{}{
|
||||
"user_id": strconv.FormatInt(userID.(int64), 10),
|
||||
"star_id": strconv.FormatInt(starID.(int64), 10),
|
||||
})
|
||||
|
||||
resp, err := ctrl.assetService.GetAssetMaterials(ctx, &pbAsset.GetAssetMaterialsRequest{
|
||||
AssetId: assetID,
|
||||
})
|
||||
|
||||
@ -187,6 +187,7 @@ func SetupRouter(userClient *client.Client, socialClient *client.Client, assetCl
|
||||
assets.GET("/oss/batch-presigned-urls", assetCtrl.GetOSSBatchPresignedURLs) // 批量获取 OSS 预签名URL(用于读取目录下的图片)
|
||||
assets.POST("/upload", assetCtrl.UploadImage) // 上传图片
|
||||
assets.POST("/mints/precreate", assetCtrl.PreCreateMintOrder) // 阶段一:预创建铸造订单(返回 order_id)
|
||||
assets.POST("/mints/cost-estimate", assetCtrl.EstimateMintCost) // 估算铸造费用(显示消耗和余额)
|
||||
assets.POST("/mints", assetCtrl.CreateMintOrder) // 创建铸造订单
|
||||
assets.GET("/mints/:order_id", assetCtrl.GetMintOrder) // 查询铸造订单状态
|
||||
assets.DELETE("/mints/:order_id", assetCtrl.CancelMintOrder) // 取消铸造订单
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -91,6 +91,7 @@ type AssetService interface {
|
||||
GetAssetMaterials(ctx context.Context, req *GetAssetMaterialsRequest, opts ...client.CallOption) (*GetAssetMaterialsResponse, error)
|
||||
UpdateMaterialLayerOrder(ctx context.Context, req *UpdateMaterialLayerOrderRequest, opts ...client.CallOption) (*UpdateMaterialLayerOrderResponse, error)
|
||||
UnbindAssetMaterial(ctx context.Context, req *UnbindAssetMaterialRequest, opts ...client.CallOption) (*UnbindAssetMaterialResponse, error)
|
||||
EstimateMintCost(ctx context.Context, req *EstimateMintCostRequest, opts ...client.CallOption) (*EstimateMintCostResponse, error)
|
||||
}
|
||||
|
||||
// NewAssetService constructs a client for the asset.AssetService service.
|
||||
@ -265,9 +266,17 @@ func (c *AssetServiceImpl) UnbindAssetMaterial(ctx context.Context, req *UnbindA
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (c *AssetServiceImpl) EstimateMintCost(ctx context.Context, req *EstimateMintCostRequest, opts ...client.CallOption) (*EstimateMintCostResponse, error) {
|
||||
resp := new(EstimateMintCostResponse)
|
||||
if err := c.conn.CallUnary(ctx, []interface{}{req}, resp, "EstimateMintCost", opts...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
var AssetService_ClientInfo = client.ClientInfo{
|
||||
InterfaceName: "topfans.asset.AssetService",
|
||||
MethodNames: []string{"InitMintOrder", "PreCreateMintOrder", "CreateMintOrder", "GetMyAssets", "GetAsset", "GetAssetStatus", "GetMintOrder", "CancelMintOrder", "GetAssetForRPC", "LikeAsset", "UnlikeAsset", "CheckAssetLike", "GetAssetLikes", "ClearAssetLikeRecords", "UploadMaterial", "BindAssetMaterials", "GetAssetMaterials", "UpdateMaterialLayerOrder", "UnbindAssetMaterial"},
|
||||
MethodNames: []string{"InitMintOrder", "PreCreateMintOrder", "CreateMintOrder", "GetMyAssets", "GetAsset", "GetAssetStatus", "GetMintOrder", "CancelMintOrder", "GetAssetForRPC", "LikeAsset", "UnlikeAsset", "CheckAssetLike", "GetAssetLikes", "ClearAssetLikeRecords", "UploadMaterial", "BindAssetMaterials", "GetAssetMaterials", "UpdateMaterialLayerOrder", "UnbindAssetMaterial", "EstimateMintCost"},
|
||||
ConnectionInjectFunc: func(dubboCliRaw interface{}, conn *client.Connection) {
|
||||
dubboCli := dubboCliRaw.(*AssetServiceImpl)
|
||||
dubboCli.conn = conn
|
||||
@ -295,6 +304,7 @@ type AssetServiceHandler interface {
|
||||
GetAssetMaterials(context.Context, *GetAssetMaterialsRequest) (*GetAssetMaterialsResponse, error)
|
||||
UpdateMaterialLayerOrder(context.Context, *UpdateMaterialLayerOrderRequest) (*UpdateMaterialLayerOrderResponse, error)
|
||||
UnbindAssetMaterial(context.Context, *UnbindAssetMaterialRequest) (*UnbindAssetMaterialResponse, error)
|
||||
EstimateMintCost(context.Context, *EstimateMintCostRequest) (*EstimateMintCostResponse, error)
|
||||
}
|
||||
|
||||
func RegisterAssetServiceHandler(srv *server.Server, hdlr AssetServiceHandler, opts ...server.ServiceOption) error {
|
||||
@ -594,5 +604,20 @@ var AssetService_ServiceInfo = server.ServiceInfo{
|
||||
return triple_protocol.NewResponse(res), nil
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "EstimateMintCost",
|
||||
Type: constant.CallUnary,
|
||||
ReqInitFunc: func() interface{} {
|
||||
return new(EstimateMintCostRequest)
|
||||
},
|
||||
MethodFunc: func(ctx context.Context, args []interface{}, handler interface{}) (interface{}, error) {
|
||||
req := args[0].(*EstimateMintCostRequest)
|
||||
res, err := handler.(AssetServiceHandler).EstimateMintCost(ctx, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return triple_protocol.NewResponse(res), nil
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@ -112,6 +112,23 @@ message CreateMintOrderResponse {
|
||||
topfans.common.BaseResponse base = 1;
|
||||
MintOrder order = 2; // 创建的订单信息
|
||||
Asset asset = 3; // 创建的资产信息
|
||||
int64 cost_crystal = 4; // 本次铸造消耗水晶
|
||||
int64 balance_after = 5; // 铸造后余额
|
||||
}
|
||||
|
||||
// 估算铸造费用请求
|
||||
message EstimateMintCostRequest {
|
||||
string trace_id = 1; // 追踪ID(可选,用于调试)
|
||||
}
|
||||
|
||||
// 估算铸造费用响应
|
||||
message EstimateMintCostResponse {
|
||||
topfans.common.BaseResponse base = 1;
|
||||
int64 cost_crystal = 2; // 本次铸造消耗水晶
|
||||
int64 current_balance = 3; // 当前余额(铸造前)
|
||||
int64 balance_after = 4; // 铸造后余额
|
||||
int32 mint_count = 5; // 本次是第几次铸造
|
||||
string next_tier_hint = 6; // 下一阶梯提示
|
||||
}
|
||||
|
||||
// ==================== 资产查询相关消息 ====================
|
||||
@ -428,6 +445,14 @@ service AssetService {
|
||||
};
|
||||
}
|
||||
|
||||
// 估算铸造费用(在确认铸造前显示消耗和余额)
|
||||
rpc EstimateMintCost(EstimateMintCostRequest) returns (EstimateMintCostResponse) {
|
||||
option (google.api.http) = {
|
||||
post: "/api/v1/assets/mints/cost-estimate"
|
||||
body: "*"
|
||||
};
|
||||
}
|
||||
|
||||
// 获取我的藏品列表
|
||||
rpc GetMyAssets(GetMyAssetsRequest) returns (GetMyAssetsResponse) {
|
||||
option (google.api.http) = {
|
||||
|
||||
@ -816,3 +816,43 @@ func (p *AssetProvider) CancelMintOrder(ctx context.Context, req *pb.CancelMintO
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// EstimateMintCost 估算铸造费用
|
||||
func (p *AssetProvider) EstimateMintCost(ctx context.Context, req *pb.EstimateMintCostRequest) (*pb.EstimateMintCostResponse, error) {
|
||||
userID, starID, err := extractUserInfoFromDubboAttachments(ctx)
|
||||
if err != nil {
|
||||
return &pb.EstimateMintCostResponse{
|
||||
Base: &pbCommon.BaseResponse{
|
||||
Code: pbCommon.StatusCode_STATUS_UNAUTHORIZED,
|
||||
Message: "user authentication required",
|
||||
},
|
||||
}, err
|
||||
}
|
||||
|
||||
estimate, err := p.mintService.EstimateMintCost(userID, starID)
|
||||
if err != nil {
|
||||
logger.Logger.Error("EstimateMintCost failed",
|
||||
zap.Int64("user_id", userID),
|
||||
zap.Int64("star_id", starID),
|
||||
zap.Error(err),
|
||||
)
|
||||
return &pb.EstimateMintCostResponse{
|
||||
Base: &pbCommon.BaseResponse{
|
||||
Code: appErrors.ToStatusCode(err),
|
||||
Message: err.Error(),
|
||||
},
|
||||
}, err
|
||||
}
|
||||
|
||||
return &pb.EstimateMintCostResponse{
|
||||
Base: &pbCommon.BaseResponse{
|
||||
Code: pbCommon.StatusCode_STATUS_OK,
|
||||
Message: "ok",
|
||||
},
|
||||
CostCrystal: estimate.CostCrystal,
|
||||
CurrentBalance: estimate.CurrentBalance,
|
||||
BalanceAfter: estimate.AfterBalance,
|
||||
MintCount: estimate.MintCount,
|
||||
NextTierHint: estimate.NextTierHint,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@ -2,7 +2,13 @@ package provider
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/aliyun/aliyun-oss-go-sdk/oss"
|
||||
"github.com/aliyun/credentials-go/credentials"
|
||||
pb "github.com/topfans/backend/pkg/proto/asset"
|
||||
pbCommon "github.com/topfans/backend/pkg/proto/common"
|
||||
"github.com/topfans/backend/pkg/models"
|
||||
@ -121,6 +127,13 @@ func (p *AssetProvider) GetAssetMaterials(ctx context.Context, req *pb.GetAssetM
|
||||
MaterialType: rel.MaterialType,
|
||||
LayerOrder: int32(rel.LayerOrder),
|
||||
}
|
||||
// 如果关联的 Material 有 OssKey,生成预签名 URL
|
||||
if rel.Material.OssKey != "" {
|
||||
signedURL, err := generatePresignedURL(rel.Material.OssKey, 3600)
|
||||
if err == nil {
|
||||
pbRel.MaterialUrlSigned = signedURL
|
||||
}
|
||||
}
|
||||
if rel.PosX != nil {
|
||||
pbRel.PosX = *rel.PosX
|
||||
}
|
||||
@ -197,3 +210,75 @@ func doublePtr(v float64) *float64 {
|
||||
}
|
||||
return &v
|
||||
}
|
||||
|
||||
// generatePresignedURL 生成预签名 URL
|
||||
func generatePresignedURL(filePath string, expiresInSeconds int64) (string, error) {
|
||||
region := os.Getenv("OSS_REGION")
|
||||
bucketName := os.Getenv("OSS_BUCKET_NAME")
|
||||
roleArn := os.Getenv("OSS_STS_ROLE_ARN")
|
||||
accessKeyID := os.Getenv("OSS_ACCESS_KEY_ID")
|
||||
accessKeySecret := os.Getenv("OSS_ACCESS_KEY_SECRET")
|
||||
|
||||
if region == "" || bucketName == "" || roleArn == "" || accessKeyID == "" || accessKeySecret == "" {
|
||||
return "", fmt.Errorf("OSS 配置不完整")
|
||||
}
|
||||
|
||||
credConfig := new(credentials.Config).
|
||||
SetType("ram_role_arn").
|
||||
SetAccessKeyId(accessKeyID).
|
||||
SetAccessKeySecret(accessKeySecret).
|
||||
SetRoleArn(roleArn).
|
||||
SetRoleSessionName("topfans-download-session").
|
||||
SetPolicy("").
|
||||
SetRoleSessionExpiration(int(expiresInSeconds))
|
||||
|
||||
provider, err := credentials.NewCredential(credConfig)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("创建凭证提供器失败: %w", err)
|
||||
}
|
||||
|
||||
cred, err := provider.GetCredential()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("获取临时凭证失败: %w", err)
|
||||
}
|
||||
|
||||
endpoint := fmt.Sprintf("https://oss-%s.aliyuncs.com", region)
|
||||
client, err := oss.New(endpoint, *cred.AccessKeyId, *cred.AccessKeySecret,
|
||||
oss.SecurityToken(*cred.SecurityToken))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("创建OSS客户端失败: %w", err)
|
||||
}
|
||||
|
||||
bucket, err := client.Bucket(bucketName)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("获取Bucket失败: %w", err)
|
||||
}
|
||||
|
||||
ossKey := filePath
|
||||
if strings.HasPrefix(filePath, "https://") {
|
||||
parts := strings.SplitN(filePath, ".oss-", 2)
|
||||
if len(parts) == 2 {
|
||||
keyParts := strings.SplitN(parts[1], "/", 2)
|
||||
if len(keyParts) == 2 {
|
||||
ossKey = keyParts[1]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
signedURL, err := bucket.SignURL(ossKey, oss.HTTPGet, expiresInSeconds)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("生成预签名URL失败: %w", err)
|
||||
}
|
||||
|
||||
if idx := strings.Index(signedURL, "?"); idx >= 0 {
|
||||
signedURL = strings.ReplaceAll(signedURL[:idx], "%2F", "/") + signedURL[idx:]
|
||||
} else {
|
||||
signedURL = strings.ReplaceAll(signedURL, "%2F", "/")
|
||||
}
|
||||
|
||||
if !strings.Contains(signedURL, "security-token") && cred.SecurityToken != nil && *cred.SecurityToken != "" {
|
||||
signedURL = signedURL + "&security-token=" + url.QueryEscape(*cred.SecurityToken)
|
||||
}
|
||||
|
||||
return signedURL, nil
|
||||
}
|
||||
|
||||
@ -50,6 +50,9 @@ type MintService interface {
|
||||
// GetUserMintCount 获取用户累计铸爱次数
|
||||
GetUserMintCount(userID, starID int64) (int32, error)
|
||||
|
||||
// EstimateMintCost 估算铸造费用(不实际扣款)
|
||||
EstimateMintCost(userID, starID int64) (*MintCostEstimate, error)
|
||||
|
||||
// UpdateMintCountAndBoost 更新铸爱次数和收益提升
|
||||
UpdateMintCountAndBoost(ctx context.Context, tx *gorm.DB, userID, starID int64, boostBps int32) error
|
||||
}
|
||||
@ -62,7 +65,7 @@ type mintService struct {
|
||||
db *gorm.DB
|
||||
config *config.AssetConfig
|
||||
registryRepo starbookRepo.AssetRegistryRepository // 资产索引仓库(用于星册体系)
|
||||
mintCostRepo repository.MintCostRepository // 铸造消耗配置仓库
|
||||
localMintCostRepo repository.MintCostRepository // 铸造消耗配置仓库
|
||||
userMintCountRepo repository.UserMintCountRepository // 用户铸爱累计仓库
|
||||
}
|
||||
|
||||
@ -74,7 +77,7 @@ func NewMintService(
|
||||
db *gorm.DB,
|
||||
cfg *config.AssetConfig,
|
||||
registryRepo starbookRepo.AssetRegistryRepository,
|
||||
mintCostRepo repository.MintCostRepository,
|
||||
localMintCostRepo repository.MintCostRepository,
|
||||
userMintCountRepo repository.UserMintCountRepository,
|
||||
) MintService {
|
||||
return &mintService{
|
||||
@ -84,7 +87,7 @@ func NewMintService(
|
||||
db: db,
|
||||
config: cfg,
|
||||
registryRepo: registryRepo,
|
||||
mintCostRepo: mintCostRepo,
|
||||
localMintCostRepo: localMintCostRepo,
|
||||
userMintCountRepo: userMintCountRepo,
|
||||
}
|
||||
}
|
||||
@ -230,7 +233,12 @@ func (s *mintService) CreateMintOrder(req *pb.CreateMintOrderRequest, userID, st
|
||||
// 3. 使用事务创建铸造订单(或将阶段一订单推进到 PROCESSING)
|
||||
var mintOrder *models.MintOrder
|
||||
var asset *models.Asset
|
||||
var newBalance int64
|
||||
var capturedCostCrystal int64 // 捕获铸造消耗
|
||||
err = s.db.Transaction(func(tx *gorm.DB) error {
|
||||
// 用局部变量捕获事务内获取的 newBalance
|
||||
var capturedBalance int64
|
||||
defer func() { newBalance = capturedBalance }()
|
||||
// 3.0 取出阶段一订单,并校验状态/所有者
|
||||
logger.Logger.Info("[MintOrder] Step 3.0: 获取订单", zap.String("order_id", req.OrderId), zap.Int64("user_id", userID), zap.Int64("star_id", starID))
|
||||
existing, err := s.mintOrderRepo.GetByOrderIDAndUser(req.OrderId, userID, starID)
|
||||
@ -281,19 +289,21 @@ func (s *mintService) CreateMintOrder(req *pb.CreateMintOrderRequest, userID, st
|
||||
// 3.1 获取铸造消耗配置(阶梯计价)
|
||||
// 本次铸造是第 currentMintCount+1 次
|
||||
logger.Logger.Info("[MintOrder] Step 3.1: 获取铸造配置", zap.Int32("current_mint_count", currentMintCount))
|
||||
mintCost, err := s.GetMintCost(currentMintCount + 1)
|
||||
var localMintCost *models.MintCostConfig
|
||||
localMintCost, err = s.GetMintCost(currentMintCount + 1)
|
||||
if err != nil {
|
||||
logger.Logger.Error("[MintOrder] Step 3.1 失败", zap.Error(err))
|
||||
return fmt.Errorf("获取铸造消耗配置失败: %w", err)
|
||||
}
|
||||
logger.Logger.Info("[MintOrder] Step 3.1 完成", zap.Int64("cost_crystal", mintCost.CostCrystal))
|
||||
capturedCostCrystal = localMintCost.CostCrystal // 捕获铸造消耗
|
||||
logger.Logger.Info("[MintOrder] Step 3.1 完成", zap.Int64("cost_crystal", localMintCost.CostCrystal))
|
||||
|
||||
// 3.2 扣除水晶余额(调用 User Service RPC)
|
||||
logger.Logger.Info("[MintOrder] Step 3.2: 扣除水晶", zap.Int64("user_id", userID), zap.Int64("star_id", starID), zap.Int64("delta", -mintCost.CostCrystal))
|
||||
logger.Logger.Info("[MintOrder] Step 3.2: 扣除水晶", zap.Int64("user_id", userID), zap.Int64("star_id", starID), zap.Int64("delta", -localMintCost.CostCrystal))
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
newBalance, err := s.userClient.UpdateCrystalBalance(ctx, userID, starID, -mintCost.CostCrystal,
|
||||
capturedBalance, err = s.userClient.UpdateCrystalBalance(ctx, userID, starID, -localMintCost.CostCrystal,
|
||||
"mint_cost", req.OrderId, fmt.Sprintf("铸造藏品 #%s", req.OrderId))
|
||||
if err != nil {
|
||||
logger.Logger.Error("[MintOrder] Step 3.2 失败: 扣除水晶错误", zap.Error(err))
|
||||
@ -304,17 +314,17 @@ func (s *mintService) CreateMintOrder(req *pb.CreateMintOrderRequest, userID, st
|
||||
|
||||
// 3.3 检查是否触发保底(概率触发)
|
||||
var boostBps int32 = 0
|
||||
if mintCost.Probability > 0 && mintCost.RewardValue > 0 {
|
||||
if localMintCost.Probability > 0 && localMintCost.RewardValue > 0 {
|
||||
// 随机判断是否触发
|
||||
randomValue := time.Now().UnixNano() % 100
|
||||
if randomValue < mintCost.Probability {
|
||||
boostBps = int32(mintCost.RewardValue) // reward_value 单位是 bps
|
||||
if randomValue < localMintCost.Probability {
|
||||
boostBps = int32(localMintCost.RewardValue) // reward_value 单位是 bps
|
||||
logger.Logger.Info("Mint guarantee triggered",
|
||||
zap.Int64("user_id", userID),
|
||||
zap.Int64("star_id", starID),
|
||||
zap.Int32("mint_count", currentMintCount+1),
|
||||
zap.Int32("boost_bps", boostBps),
|
||||
zap.Int64("probability", mintCost.Probability))
|
||||
zap.Int64("probability", localMintCost.Probability))
|
||||
}
|
||||
}
|
||||
|
||||
@ -397,7 +407,7 @@ func (s *mintService) CreateMintOrder(req *pb.CreateMintOrderRequest, userID, st
|
||||
updates := map[string]interface{}{
|
||||
"asset_id": asset.ID,
|
||||
"status": models.MintOrderStatusSuccess, // 直接设为成功,无需异步处理
|
||||
"cost_crystal": mintCost.CostCrystal,
|
||||
"cost_crystal": localMintCost.CostCrystal,
|
||||
"error_message": nil,
|
||||
"material_url": getStringValue(mintOrder.MaterialURL),
|
||||
"name": getStringValue(mintOrder.Name),
|
||||
@ -415,7 +425,7 @@ func (s *mintService) CreateMintOrder(req *pb.CreateMintOrderRequest, userID, st
|
||||
assetID := asset.ID
|
||||
mintOrder.AssetID = &assetID
|
||||
mintOrder.Status = models.MintOrderStatusSuccess
|
||||
mintOrder.CostCrystal = mintCost.CostCrystal
|
||||
mintOrder.CostCrystal = localMintCost.CostCrystal
|
||||
|
||||
logger.Logger.Info("Mint order created",
|
||||
zap.String("order_id", mintOrder.OrderID),
|
||||
@ -446,20 +456,25 @@ func (s *mintService) CreateMintOrder(req *pb.CreateMintOrderRequest, userID, st
|
||||
}
|
||||
|
||||
// 6. 构建响应
|
||||
// 获取本次铸造消耗(从事务内捕获的值)
|
||||
response := &pb.CreateMintOrderResponse{
|
||||
Base: &pbCommon.BaseResponse{
|
||||
Code: pbCommon.StatusCode_STATUS_OK,
|
||||
Message: "",
|
||||
Timestamp: time.Now().UnixMilli(),
|
||||
},
|
||||
Order: ModelToProtoMintOrder(mintOrder),
|
||||
Asset: ModelToProtoAssetDetail(asset, ownerNickname, false, 0, 0, 0), // 新创建的资产,is_liked 为 false,display_status 默认为 0,earnings 和 exhibitionExpireAt 为 0
|
||||
Order: ModelToProtoMintOrder(mintOrder),
|
||||
Asset: ModelToProtoAssetDetail(asset, ownerNickname, false, 0, 0, 0), // 新创建的资产,is_liked 为 false,display_status 默认为 0,earnings 和 exhibitionExpireAt 为 0
|
||||
CostCrystal: capturedCostCrystal,
|
||||
BalanceAfter: newBalance,
|
||||
}
|
||||
|
||||
logger.Logger.Info("Create mint order successful",
|
||||
zap.String("order_id", mintOrder.OrderID),
|
||||
zap.Int64("asset_id", asset.ID),
|
||||
zap.Int64("user_id", userID),
|
||||
zap.Int64("cost_crystal", capturedCostCrystal),
|
||||
zap.Int64("balance_after", newBalance),
|
||||
)
|
||||
|
||||
return response, nil
|
||||
@ -785,7 +800,7 @@ func (s *mintService) GetMintCost(mintCount int32) (*models.MintCostConfig, erro
|
||||
mintCount = 10
|
||||
}
|
||||
|
||||
config, err := s.mintCostRepo.GetByMintCount(mintCount)
|
||||
config, err := s.localMintCostRepo.GetByMintCount(mintCount)
|
||||
if err != nil {
|
||||
logger.Logger.Error("Failed to get mint cost config",
|
||||
zap.Int32("mint_count", mintCount),
|
||||
@ -805,6 +820,70 @@ func (s *mintService) GetUserMintCount(userID, starID int64) (int32, error) {
|
||||
return record.MintCount, nil
|
||||
}
|
||||
|
||||
// EstimateMintCost 估算铸造费用(不实际扣款)
|
||||
func (s *mintService) EstimateMintCost(userID, starID int64) (*MintCostEstimate, error) {
|
||||
// 获取当前累计铸爱次数
|
||||
currentMintCount, err := s.GetUserMintCount(userID, starID)
|
||||
if err != nil {
|
||||
logger.Logger.Warn("Failed to get user mint count, using 0",
|
||||
zap.Int64("user_id", userID),
|
||||
zap.Int64("star_id", starID),
|
||||
zap.Error(err))
|
||||
currentMintCount = 0
|
||||
}
|
||||
|
||||
// 获取铸造消耗配置(本次铸造是第 currentMintCount+1 次)
|
||||
localMintCost, err := s.GetMintCost(currentMintCount + 1)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取铸造消耗配置失败: %w", err)
|
||||
}
|
||||
|
||||
// 获取当前水晶余额
|
||||
profile, err := s.userClient.GetFanProfile(context.Background(), userID, starID)
|
||||
var currentBalance int64 = 0
|
||||
if err != nil {
|
||||
logger.Logger.Warn("Failed to get fan profile, balance will be 0",
|
||||
zap.Int64("user_id", userID),
|
||||
zap.Int64("star_id", starID),
|
||||
zap.Error(err))
|
||||
} else {
|
||||
currentBalance = profile.CrystalBalance
|
||||
}
|
||||
|
||||
// 计算铸造后余额
|
||||
afterBalance := currentBalance - localMintCost.CostCrystal
|
||||
if afterBalance < 0 {
|
||||
afterBalance = 0
|
||||
}
|
||||
|
||||
// 下一阶梯费用提示
|
||||
var nextTierHint string
|
||||
nextMintCount := currentMintCount + 2 // 下一次铸造的次数
|
||||
if nextMintCount <= 10 {
|
||||
nextCost, err := s.GetMintCost(nextMintCount)
|
||||
if err == nil {
|
||||
nextTierHint = fmt.Sprintf("下一阶梯 %d 水晶", nextCost.CostCrystal)
|
||||
}
|
||||
}
|
||||
|
||||
return &MintCostEstimate{
|
||||
CostCrystal: localMintCost.CostCrystal,
|
||||
CurrentBalance: currentBalance,
|
||||
AfterBalance: afterBalance,
|
||||
MintCount: currentMintCount + 1, // 本次将是第几次铸造
|
||||
NextTierHint: nextTierHint,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// MintCostEstimate 铸造费用估算结果
|
||||
type MintCostEstimate struct {
|
||||
CostCrystal int64 // 本次铸造消耗水晶
|
||||
CurrentBalance int64 // 当前余额(铸造前)
|
||||
AfterBalance int64 // 铸造后余额
|
||||
MintCount int32 // 本次铸造是第几次
|
||||
NextTierHint string // 下一阶梯提示
|
||||
}
|
||||
|
||||
// UpdateMintCountAndBoost 更新铸爱次数和收益提升
|
||||
// 在事务内调用,tx 为nil时会创建新事务
|
||||
func (s *mintService) UpdateMintCountAndBoost(ctx context.Context, tx *gorm.DB, userID, starID int64, boostBps int32) error {
|
||||
|
||||
@ -0,0 +1,315 @@
|
||||
# 光栅卡铸造后写入 asset_registry 方案
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 确保光栅卡铸造完成后:1) 两张图片(main 主图 + bg 背景图)都注册到 `materials` 表;2) `asset_registry` 有记录;3) `asset_material_relations` 正确绑定两组图片
|
||||
|
||||
**Architecture:**
|
||||
- 前端在 `lenticular-result.vue` 的 `selectAsset` 中调用 `submitCraftMintFromPath` 完成上传和铸造
|
||||
- `submitCraftMintFromPath` 已经支持:上传主图(main) + 背景图(bg)、注册素材、创建订单、绑定多素材
|
||||
- 问题:`lenticular-result.vue` 引用了未定义的 `isCraftLenticular` 和 `craftCoverUrl`,导致铸造流程断裂
|
||||
|
||||
**Tech Stack:** Vue3 + uni-app 前端,Go 后端,Supabase/PostgreSQL
|
||||
|
||||
---
|
||||
|
||||
## 数据库设计解析
|
||||
|
||||
### 核心表关系
|
||||
|
||||
```
|
||||
asset_registry ← 资产索引表(用户有哪些藏品)
|
||||
│ asset_id
|
||||
▼
|
||||
assets ← 资产表(名称、封面、描述)
|
||||
│ id
|
||||
▼
|
||||
asset_material_relations ← 资产-素材关联表(同一资产有哪些图片)
|
||||
│ asset_id, material_id
|
||||
▼
|
||||
materials ← 素材表(OSS key、MIME、尺寸)
|
||||
```
|
||||
|
||||
### `asset_registry` 表结构(不存图片)
|
||||
|
||||
```sql
|
||||
CREATE TABLE public.asset_registry (
|
||||
id bigint,
|
||||
asset_id bigint, -- 关联 assets.ID
|
||||
asset_type varchar(20), -- 'regular'|'collection'|'activity'
|
||||
owner_uid bigint,
|
||||
star_id bigint,
|
||||
grade integer,
|
||||
status integer, -- 0=待处理, 1=已激活
|
||||
like_count integer,
|
||||
display_status integer,
|
||||
created_at bigint,
|
||||
updated_at bigint
|
||||
);
|
||||
```
|
||||
|
||||
**注意:** `asset_registry` 只存储资产索引和元数据,**不存储任何图片 URL**。
|
||||
|
||||
### 图片通过 `asset_material_relations` 关联
|
||||
|
||||
光栅卡有**两张图片**(main 主图 + bg 背景图),通过 `asset_material_relations` 表实现多对一关联:
|
||||
|
||||
```sql
|
||||
-- 同一个 asset_id = 123 的光栅卡,有两条素材记录
|
||||
INSERT INTO asset_material_relations (asset_id, material_id, material_type, layer_order) VALUES
|
||||
(123, 456, 'main', 0), -- 主图(人物层)
|
||||
(123, 789, 'bg', 1); -- 背景图
|
||||
|
||||
-- materials 表存储实际图片信息
|
||||
INSERT INTO materials (id, oss_key, original_name, file_size, mime_type, hash, ...) VALUES
|
||||
(456, 'asset/7/88/main/xxx.jpg', 'subject.jpg', 102400, 'image/jpeg', 'abc123', ...),
|
||||
(789, 'asset/7/88/bg/xxx.jpg', 'bg.png', 204800, 'image/png', 'def456', ...);
|
||||
```
|
||||
|
||||
### 封面图来源
|
||||
|
||||
铸造时,前端传入的 `material_url`(main 素材的 oss_key)会被设为 `assets.CoverURL`,用于列表页展示:
|
||||
|
||||
```
|
||||
craftMintSubmit.js → createMintOrderApi({ material_url: main_oss_key })
|
||||
↓
|
||||
mint_service.go:339 assets.CoverURL = materialURLValue
|
||||
↓
|
||||
前端列表页通过 assets.CoverURL 获取封面图
|
||||
```
|
||||
|
||||
### 完整数据流
|
||||
|
||||
```
|
||||
submitCraftMintFromPath({ imagePath, bgImagePath, formData })
|
||||
│
|
||||
├─ uploadImageAndRegisterMaterial(imagePath, 'main') → materials.id=456
|
||||
├─ uploadImageAndRegisterMaterial(bgImagePath, 'bg') → materials.id=789
|
||||
│
|
||||
├─ createMintOrderApi({ material_url: main_oss_key })
|
||||
│ ↓
|
||||
│ mint_service.go
|
||||
│ ├── tx.Create(asset) → CoverURL = main_oss_key
|
||||
│ ├── tx.Create(registry) → asset_registry (status=1)
|
||||
│ └── 更新订单状态为 SUCCESS
|
||||
│
|
||||
└─ bindAssetMaterialsApi(asset_id, [
|
||||
{ material_id: 456, material_type: 'main', layer_order: 0 },
|
||||
{ material_id: 789, material_type: 'bg', layer_order: 1 }
|
||||
])
|
||||
↓
|
||||
asset_material_relations 写入两条记录
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 问题分析
|
||||
|
||||
### 当前代码问题(`lenticular-result.vue` 第 596-623 行)
|
||||
|
||||
```javascript
|
||||
const selectAsset = async () => {
|
||||
// line 596-625 是完整代码块
|
||||
const imagePath =
|
||||
isCraftLenticular.value // ❌ isCraftLenticular 未定义
|
||||
? lenticularLayers.value.find((l) => l.id === 'mid')?.src || craftCoverUrl.value
|
||||
: craftCoverUrl.value; // ❌ craftCoverUrl 未定义
|
||||
if (!imagePath) { ... }
|
||||
|
||||
const bgImagePath = isCraftLenticular.value // ❌ isCraftLenticular 未定义
|
||||
? lenticularLayers.value.find((l) => l.id === 'base')?.src || ''
|
||||
: undefined;
|
||||
|
||||
try {
|
||||
await submitCraftMintFromPath({ imagePath, bgImagePath, formData: craftFormData.value });
|
||||
uni.navigateTo({ url: '/pages/castlove/success' });
|
||||
return; // line 623 return 后不会执行
|
||||
} catch (e) { ... }
|
||||
|
||||
// 以下代码 dead code(return 后的逻辑)
|
||||
if (!isLenticularDisplay.value && selectedIndex.value === -1) { ... }
|
||||
if (isUploading.value) { ... }
|
||||
// ... 上传到 OSS 并创建订单的旧代码 ...
|
||||
};
|
||||
```
|
||||
|
||||
### 现有正确流程(`craftMintSubmit.js` 的 `submitCraftMintFromPath`)
|
||||
|
||||
```javascript
|
||||
// 第 187-306 行
|
||||
export async function submitCraftMintFromPath({ imagePath, bgImagePath, formData }) {
|
||||
// 1. 获取 OSS 签名
|
||||
// 2. 上传主图并注册为 'main' 素材
|
||||
// 3. 如果是光栅卡,上传背景图并注册为 'bg' 素材
|
||||
// 4. 创建铸造订单(后端自动写入 asset_registry)
|
||||
// 5. 绑定多素材到资产(main + bg)
|
||||
// 6. 返回
|
||||
}
|
||||
```
|
||||
|
||||
铸造流程本身是正确的,问题在前端调用方。
|
||||
|
||||
---
|
||||
|
||||
## 文件结构
|
||||
|
||||
| 文件 | 修改类型 | 职责 |
|
||||
|------|----------|------|
|
||||
| `frontend/pages/castlove/lenticular/lenticular-result.vue` | 修改 | 修复 `selectAsset` 函数,添加缺失的 computed |
|
||||
| `frontend/utils/craftMintSubmit.js` | 已有 | 铸造流程(已正确,无需修改) |
|
||||
|
||||
---
|
||||
|
||||
## Task 1: 修复 `lenticular-result.vue` 的 `selectAsset` 函数
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/pages/castlove/lenticular/lenticular-result.vue:595-625`
|
||||
|
||||
- [ ] **Step 1: 确认缺失的 import 并添加**
|
||||
|
||||
在 `<script setup>` 块顶部已有:
|
||||
```javascript
|
||||
import { submitCraftMintFromPath } from '@/utils/craftMintSubmit.js';
|
||||
import { CASTLOVE_FORM_KEY, GENERATED_IMAGES_KEY, ... } from '@/utils/castloveGenerationFlow.js';
|
||||
```
|
||||
|
||||
需要检查并补充以下 import(参考 `asset-detail.vue` 第 371 行):
|
||||
```javascript
|
||||
import { CRAFT_LENTICULAR_CN, CRAFT_TAG_LENTICULAR } from '@/utils/castloveMintForm.js';
|
||||
import { STUDIO_LENTICULAR } from '@/utils/castloveGenerationFlow.js';
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 添加缺失的 computed 属性**
|
||||
|
||||
在 `craftFormData` 之后添加:
|
||||
|
||||
```javascript
|
||||
// 光栅卡类型判断
|
||||
const isCraftLenticular = computed(() =>
|
||||
craftFormData.value?.studio_kind === STUDIO_LENTICULAR
|
||||
);
|
||||
|
||||
// 光栅封面 URL(优先取中间层)
|
||||
const craftCoverUrl = computed(() =>
|
||||
lenticularLayers.value.find((l) => l.id === 'mid')?.src || ''
|
||||
);
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 清理 dead code(line 625-719)**
|
||||
|
||||
`selectAsset` 函数的 return 后存在大量永不执行的代码(旧的上传 + 创建订单流程),需要删除:
|
||||
- 删除 `if (!isLenticularDisplay.value && selectedIndex.value === -1)` 分支
|
||||
- 删除 `if (isUploading.value)` 分支
|
||||
- 删除 `uni.getStorageSync(CASTLOVE_FORM_KEY)` 及后续所有旧代码
|
||||
- 保留 try-catch 和错误处理
|
||||
|
||||
修复后 `selectAsset` 函数结构:
|
||||
|
||||
```javascript
|
||||
const selectAsset = async () => {
|
||||
// 1. 获取主图路径
|
||||
const imagePath =
|
||||
isCraftLenticular.value
|
||||
? craftCoverUrl.value
|
||||
: craftCoverUrl.value;
|
||||
if (!imagePath) {
|
||||
uni.showToast({ title: '缺少作品图', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 获取背景图路径(仅光栅卡)
|
||||
const bgImagePath = isCraftLenticular.value
|
||||
? lenticularLayers.value.find((l) => l.id === 'base')?.src || ''
|
||||
: undefined;
|
||||
|
||||
console.log('[lenticular-result] handleCraftMint', {
|
||||
isCraftLenticular: isCraftLenticular.value,
|
||||
imagePath: imagePath?.substring(0, 50),
|
||||
bgImagePath: bgImagePath?.substring(0, 50),
|
||||
});
|
||||
|
||||
// 3. 调用铸造流程
|
||||
uni.showLoading({ title: '铸造中…', mask: true });
|
||||
try {
|
||||
await submitCraftMintFromPath({ imagePath, bgImagePath, formData: craftFormData.value });
|
||||
uni.hideLoading();
|
||||
uni.navigateTo({ url: '/pages/castlove/success' });
|
||||
} catch (e) {
|
||||
uni.hideLoading();
|
||||
uni.showToast({ title: e.message || '铸造失败', icon: 'none' });
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 运行验证**
|
||||
|
||||
检查 `selectAsset` 函数:
|
||||
1. `isCraftLenticular` 和 `craftCoverUrl` 已正确定义
|
||||
2. `bgImagePath` 正确取 `base` 层图片
|
||||
3. `submitCraftMintFromPath` 调用参数正确
|
||||
4. dead code 已删除
|
||||
|
||||
---
|
||||
|
||||
## Task 2: 验证铸造全链路
|
||||
|
||||
**Files:**
|
||||
- Test: 手动测试 `lenticular-result.vue` 的铸造流程
|
||||
|
||||
- [ ] **Step 1: 检查后端是否自动写入 asset_registry**
|
||||
|
||||
后端 `mint_service.go` 第 369-387 行在创建订单时已自动写入 `asset_registry`:
|
||||
```go
|
||||
registry := &models.AssetRegistry{
|
||||
AssetID: asset.ID,
|
||||
AssetType: models.AssetTypeRegular,
|
||||
OwnerUID: userID,
|
||||
StarID: starID,
|
||||
Grade: &grade,
|
||||
Status: models.AssetRegistryStatusActive,
|
||||
// ...
|
||||
}
|
||||
tx.Create(registry)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 确认 bindAssetMaterialsApi 调用后素材正确绑定**
|
||||
|
||||
`craftMintSubmit.js` 第 268-286 行已调用 `bindAssetMaterialsApi`:
|
||||
```javascript
|
||||
const materialsToBind = [
|
||||
{ material_id: mainResult.materialId, material_type: 'main', layer_order: 0 }
|
||||
]
|
||||
if (bgMaterialId) {
|
||||
materialsToBind.push({ material_id: bgMaterialId, material_type: 'bg', layer_order: 1 })
|
||||
}
|
||||
bindAssetMaterialsApi(assetId, materialsToBind)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: 检查清单确认
|
||||
|
||||
- [ ] `asset_registry` 有记录(asset_type = 'regular',status = 1)
|
||||
- [ ] `assets.CoverURL` = main 素材的 oss_key(用于列表封面)
|
||||
- [ ] `materials` 表有两条记录(material_type = 'main' 和 'bg')
|
||||
- [ ] `asset_material_relations` 有两条绑定记录(同一个 asset_id,layer_order 0 和 1)
|
||||
- [ ] 前端 `selectAsset` 无编译错误(isCraftLenticular、craftCoverUrl 已定义)
|
||||
- [ ] 铸造后跳转成功页 `/pages/castlove/success`
|
||||
|
||||
---
|
||||
|
||||
## 总结
|
||||
|
||||
**核心理解:**
|
||||
- `asset_registry` 是**索引表**,不存图片 — 它通过 `asset_id` 关联到 `assets`,再通过 `asset_material_relations` 关联到 `materials`
|
||||
- **同一组光栅卡图片**靠 `asset_material_relations.asset_id` 相同 + `material_type` 不同('main'/'bg')来区分
|
||||
- **封面图**来自 `assets.CoverURL`(即 main 素材的 oss_key)
|
||||
|
||||
**问题根因:** `lenticular-result.vue` 第 596-623 行的 `selectAsset` 函数引用了未定义的 `isCraftLenticular` 和 `craftCoverUrl`,且 return 后存在大量 dead code。
|
||||
|
||||
**修复方案:**
|
||||
1. 添加 `isCraftLenticular` 和 `craftCoverUrl` computed
|
||||
2. 清理 dead code(return 后的旧上传/订单代码)
|
||||
3. 确保 `bgImagePath` 传入 `base` 层图片给 `submitCraftMintFromPath`
|
||||
|
||||
**已正确部分:** `submitCraftMintFromPath` 已完整实现:上传主图 + 背景图 → 注册素材 → 创建订单 → 绑定多素材,后端自动写 `asset_registry`。
|
||||
@ -5,9 +5,35 @@
|
||||
<view v-if="title" class="modal-title">
|
||||
<text>{{ title }}</text>
|
||||
</view>
|
||||
<view v-else class="modal-title">
|
||||
<text class="modal-title-text">铸造确认</text>
|
||||
</view>
|
||||
|
||||
<!-- 铸造费用信息 -->
|
||||
<view v-if="costCrystal > 0 || currentBalance > 0" class="cost-info">
|
||||
<view class="cost-row">
|
||||
<text class="cost-label">本次消耗</text>
|
||||
<text class="cost-value cost-crystal">{{ costCrystal }} 水晶</text>
|
||||
</view>
|
||||
<view class="cost-row">
|
||||
<text class="cost-label">当前余额</text>
|
||||
<text class="cost-value">{{ currentBalance }} 水晶</text>
|
||||
</view>
|
||||
<view class="cost-row">
|
||||
<text class="cost-label">铸造后余额</text>
|
||||
<text class="cost-value balance-after">{{ balanceAfter }} 水晶</text>
|
||||
</view>
|
||||
<view v-if="mintCount > 0" class="cost-row">
|
||||
<text class="cost-label">铸造次数</text>
|
||||
<text class="cost-value">第 {{ mintCount }} 次</text>
|
||||
</view>
|
||||
<view v-if="nextTierHint" class="next-tier-hint">
|
||||
<text>{{ nextTierHint }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 内容 -->
|
||||
<view class="modal-body">
|
||||
<view v-else-if="content" class="modal-body">
|
||||
<text class="modal-content">{{ content }}</text>
|
||||
</view>
|
||||
|
||||
@ -55,6 +81,27 @@ const props = defineProps({
|
||||
showCancel: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
// 铸造费用信息
|
||||
costCrystal: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
currentBalance: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
balanceAfter: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
mintCount: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
nextTierHint: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
})
|
||||
|
||||
@ -90,9 +137,12 @@ function handleCancel() {
|
||||
|
||||
.modal-box {
|
||||
width: 560rpx;
|
||||
background: #fff;
|
||||
/* background: #fff; */
|
||||
background-image: url('/static/starbookcontent/beijing.png');
|
||||
background-size: cover;
|
||||
background-position: center bottom;
|
||||
border-radius: 24rpx;
|
||||
overflow: hidden;
|
||||
overflow: visible;
|
||||
padding: 48rpx 40rpx 32rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@ -102,10 +152,66 @@ function handleCancel() {
|
||||
.modal-title {
|
||||
font-size: 34rpx;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
color: #fff;
|
||||
margin-bottom: 32rpx;
|
||||
}
|
||||
|
||||
.cost-info {
|
||||
width: 100%;
|
||||
margin-bottom: 32rpx;
|
||||
}
|
||||
|
||||
.cost-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12rpx 0;
|
||||
/* border-bottom: 1rpx solid rgba(0, 0, 0, 0.05); */
|
||||
}
|
||||
|
||||
.cost-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.cost-label {
|
||||
font-size: 28rpx;
|
||||
color: #fff;
|
||||
text-shadow: 0 2px 8px rgba(0, 0, 0, 0.7);
|
||||
|
||||
}
|
||||
|
||||
.cost-value {
|
||||
font-size: 28rpx;
|
||||
color: #fff;
|
||||
font-weight: 500;
|
||||
text-shadow: 0 2px 8px rgba(0, 0, 0, 0.7);
|
||||
|
||||
}
|
||||
|
||||
.cost-crystal {
|
||||
color: #fff;
|
||||
font-weight: bold;
|
||||
text-shadow: 0 2px 8px rgba(0, 0, 0, 0.7);
|
||||
|
||||
}
|
||||
|
||||
.balance-after {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.next-tier-hint {
|
||||
margin-top: 16rpx;
|
||||
padding: 12rpx 16rpx;
|
||||
background: rgba(255, 107, 157, 0.1);
|
||||
border-radius: 12rpx;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.next-tier-hint text {
|
||||
font-size: 24rpx;
|
||||
color: #FF6B9D;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@ -20,6 +20,8 @@
|
||||
class="card-layer-img"
|
||||
:src="layer.src"
|
||||
mode="aspectFill"
|
||||
@error="onImageError"
|
||||
@load="onImageLoad"
|
||||
/>
|
||||
<template v-else>
|
||||
<view
|
||||
@ -60,13 +62,20 @@ const props = defineProps({
|
||||
shimmerMidOpacity: { type: Number, default: 0.1 },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['simulate'])
|
||||
/** Vue3 + uni:createSelectorQuery().in() 必须传组件 public 实例(proxy),传 internal instance 会运行时报错导致整页白屏 */
|
||||
const pageProxy = getCurrentInstance()?.proxy
|
||||
|
||||
const showHint = ref(true)
|
||||
const cardId = `lcard-${Math.random().toString(36).slice(2, 9)}`
|
||||
const cardRect = ref(null)
|
||||
/** Vue3 + uni:createSelectorQuery().in() 必须传组件 public 实例(proxy),传 internal instance 会运行时报错导致整页白屏 */
|
||||
const pageProxy = getCurrentInstance()?.proxy
|
||||
|
||||
function onImageError(e) {
|
||||
// console.log('[LenticularCard] image error:', e)
|
||||
}
|
||||
|
||||
function onImageLoad(e) {
|
||||
// console.log('[LenticularCard] image loaded')
|
||||
}
|
||||
|
||||
const MOTION_HIDE_PX = 0.45
|
||||
|
||||
@ -100,7 +109,7 @@ const cardRotateStyle = computed(() => {
|
||||
const x = tiltVisualX.value
|
||||
const rotateY = (x / 120) * 12
|
||||
return {
|
||||
transform: `perspective(1000px) rotateY(${rotateY}deg) rotateX(0deg)`,
|
||||
// transform: `perspective(1000px) rotateY(${rotateY}deg) rotateX(0deg)`,
|
||||
}
|
||||
})
|
||||
|
||||
@ -109,7 +118,7 @@ const shimmerStyle = computed(() => {
|
||||
const angle = 135 + (x / 120) * 60
|
||||
const a = Math.max(0, Math.min(0.35, Number(props.shimmerMidOpacity) || 0.1))
|
||||
return {
|
||||
background: `linear-gradient(${angle}deg, transparent 30%, rgba(221,183,255,${a}) 50%, transparent 70%)`,
|
||||
// background: `linear-gradient(${angle}deg, transparent 30%, rgba(221,183,255,${a}) 50%, transparent 70%)`,
|
||||
}
|
||||
})
|
||||
|
||||
@ -118,11 +127,11 @@ function getLayerStyle(layer) {
|
||||
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',
|
||||
}
|
||||
// return {
|
||||
// opacity: baseOpacity,
|
||||
// transform: `translate3d(${x}px, 0, 0)`,
|
||||
// background: layer.background || 'transparent',
|
||||
// }
|
||||
}
|
||||
|
||||
function getDotStyle(dot) {
|
||||
@ -217,22 +226,21 @@ onMounted(() => {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
perspective: 1000px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.card-frame {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
max-height: 100%;
|
||||
aspect-ratio: 3 / 4;
|
||||
width: auto;
|
||||
max-width: min(420px, 94vw);
|
||||
width: 100%;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 24px;
|
||||
overflow: hidden;
|
||||
/* 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);
|
||||
@ -242,10 +250,10 @@ onMounted(() => {
|
||||
|
||||
.card-layer {
|
||||
position: absolute;
|
||||
top: -6%;
|
||||
left: -6%;
|
||||
width: 112%;
|
||||
height: 112%;
|
||||
/* top: -6%;
|
||||
left: -6%; */
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
will-change: transform, opacity;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
{
|
||||
"name" : "TopFans",
|
||||
"appid" : "__UNI__8CBE431",
|
||||
"appid" : "__UNI__F199FF4",
|
||||
"description" : "",
|
||||
"versionName" : "1.0.4",
|
||||
"versionCode" : 105,
|
||||
"versionName" : "1.1.0",
|
||||
"versionCode" : 100,
|
||||
"transformPx" : false,
|
||||
/* 5+App特有相关 */
|
||||
"app-plus" : {
|
||||
|
||||
@ -162,6 +162,33 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/castlove/lenticular/lenticular-create",
|
||||
"style": {
|
||||
"navigationStyle": "custom",
|
||||
"app-plus": {
|
||||
"bounce": "none"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/castlove/lenticular/lenticular-thinking",
|
||||
"style": {
|
||||
"navigationStyle": "custom",
|
||||
"app-plus": {
|
||||
"bounce": "none"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/castlove/lenticular/lenticular-result",
|
||||
"style": {
|
||||
"navigationStyle": "custom",
|
||||
"app-plus": {
|
||||
"bounce": "none"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/castlove/mall",
|
||||
"style": {
|
||||
|
||||
@ -180,7 +180,7 @@
|
||||
<!-- 收益 -->
|
||||
<view class="earnings-area">
|
||||
<image class="crystal-icon" src="/static/icon/crystal.png" mode="aspectFit"></image>
|
||||
<text class="earnings-text">{{ assetData.earnings || 0 }}/时</text>
|
||||
<text class="earnings-text">{{ assetData.earnings || 5 }}/时</text>
|
||||
</view>
|
||||
|
||||
<!-- 倒计时(如有) -->
|
||||
@ -318,7 +318,7 @@
|
||||
<script setup>
|
||||
import { ref, computed, onUnmounted } from 'vue';
|
||||
import { onLoad, onShow, onHide } from '@dcloudio/uni-app';
|
||||
import { getAssetDetailApi, getMintOrderDetailApi, likeAssetApi, unlikeAssetApi } from '@/utils/api.js';
|
||||
import { getAssetDetailApi, getMintOrderDetailApi, likeAssetApi, unlikeAssetApi, getAssetMaterialsApi } from '@/utils/api.js';
|
||||
import { getAssetCoverRealUrl } from '@/utils/assetImageHelper.js';
|
||||
import LikeUsersModal from '@/pages/components/LikeUsersModal.vue';
|
||||
import LenticularCard from '@/components/lenticular/LenticularCard.vue';
|
||||
@ -628,7 +628,7 @@ const loadData = async () => {
|
||||
isLiked.value = res.data.asset.is_liked || res.data.is_liked || false;
|
||||
likeCount.value = asset.like_count || 0;
|
||||
|
||||
// 加载贴纸素材
|
||||
// 加载贴纸素材(从 material_relations 加载)
|
||||
if (asset.material_relations || asset.materials) {
|
||||
loadStickersForAsset(asset.material_relations || asset.materials)
|
||||
}
|
||||
@ -637,20 +637,35 @@ const loadData = async () => {
|
||||
startCountdown();
|
||||
}
|
||||
console.log(res.data)
|
||||
|
||||
// 异步加载封面图片,不阻塞页面渲染
|
||||
console.log('开始加载封面图片:', asset.cover_url);
|
||||
getAssetCoverRealUrl(asset.cover_url).then(url => {
|
||||
getAssetCoverRealUrl(asset.cover_url).then(async (url) => {
|
||||
console.log('封面图片加载成功:', url);
|
||||
coverUrl.value = url;
|
||||
if (isLenticularAsset.value) {
|
||||
const materialUrl = asset.material_url || url
|
||||
// 参考 success.vue 逻辑:通过 bindAssetMaterialsApi 获取素材列表
|
||||
let subjectUrl = url
|
||||
let bgUrl = ''
|
||||
// 调用素材接口获取完整的素材列表
|
||||
try {
|
||||
const parsed = JSON.parse(materialUrl)
|
||||
if (parsed.main) subjectUrl = parsed.main
|
||||
if (parsed.bg) bgUrl = parsed.bg
|
||||
} catch (e) { /* 非 JSON 格式,按单 URL 处理 */ }
|
||||
const materialsRes = await getAssetMaterialsApi(assetId)
|
||||
console.log('getAssetMaterialsApi 响应:', materialsRes)
|
||||
if (materialsRes.code === 200 && materialsRes.data) {
|
||||
const materialsList = Array.isArray(materialsRes.data) ? materialsRes.data : materialsRes.data.materials || []
|
||||
// 找到 bg 和 main 素材
|
||||
for (const mat of materialsList) {
|
||||
if (mat.material_type === 'main' && mat.material_url_signed) {
|
||||
subjectUrl = mat.material_url_signed
|
||||
}
|
||||
if (mat.material_type === 'bg' && mat.material_url_signed) {
|
||||
bgUrl = mat.material_url_signed
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[asset-detail] 获取素材列表失败:', e)
|
||||
}
|
||||
lenticularLayers.value = bgUrl
|
||||
? buildLenticularLayersTwo(bgUrl, subjectUrl)
|
||||
: buildLenticularLayers(subjectUrl)
|
||||
@ -848,7 +863,15 @@ onUnmounted(() => {
|
||||
/* Header */
|
||||
.header-bar {
|
||||
position: fixed;
|
||||
top: 32rpx;
|
||||
/* top: 80rpx; */
|
||||
/* #ifdef APP-PLUS */
|
||||
/* App 通用(兜底) */
|
||||
top: 96rpx;
|
||||
/* #endif */
|
||||
/* #ifdef H5 */
|
||||
/* H5 使用普通高度 */
|
||||
top: 16rpx;
|
||||
/* #endif */
|
||||
left: 32rpx;
|
||||
z-index: 100;
|
||||
}
|
||||
@ -1011,7 +1034,7 @@ onUnmounted(() => {
|
||||
|
||||
.content-wrapper {
|
||||
min-height: 90%;
|
||||
padding: 104rpx 40rpx 80rpx;
|
||||
padding: 200rpx 40rpx 80rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
@ -1044,9 +1067,11 @@ onUnmounted(() => {
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
width: 78%;
|
||||
height: 82%;
|
||||
height: 96%;
|
||||
border-radius: 64rpx;
|
||||
transform: translate(-50%, -50%) rotate(-10deg);
|
||||
z-index: 2;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.detail-lenticular-card {
|
||||
|
||||
@ -68,7 +68,7 @@ export default {
|
||||
selectedIndex: 2,
|
||||
// 工艺名称 → 页面路由映射,方便扩展
|
||||
cardRoutes: {
|
||||
'光栅卡': '/pages/castlove/create',
|
||||
'光栅卡': '/pages/castlove/lenticular/lenticular-create',
|
||||
'拍立得': '/pages/castlove/create',
|
||||
'镭射卡': '/pages/castlove/create',
|
||||
'撕拉片': '/pages/castlove/create',
|
||||
@ -133,27 +133,25 @@ export default {
|
||||
*/
|
||||
onCardFrameTap(index) {
|
||||
const card = this.cardList[index];
|
||||
if (!card || card.comingSoon) {
|
||||
if (!card) {
|
||||
return;
|
||||
}
|
||||
const pos = this.getCardStackPosition(index);
|
||||
// 只有中间位置的卡片点击才会进入创建页
|
||||
// 其他位置点击只是切换选中(把卡片移到中间),再次点击中间卡片才进入
|
||||
if (pos === 2) {
|
||||
if (card.name === '撕拉片') {
|
||||
const route = this.cardRoutes[card.name];
|
||||
if (route) {
|
||||
uni.navigateTo({
|
||||
url: '/pages/castlove/mint/tear-card',
|
||||
});
|
||||
}
|
||||
} else {
|
||||
if (card.name === '光栅卡') {
|
||||
const route = this.cardRoutes[card.name];
|
||||
if (route) {
|
||||
uni.navigateTo({
|
||||
url: `${route}?name=${encodeURIComponent(card.name)}`,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
uni.showToast({
|
||||
title: '激情开发中',
|
||||
icon: 'none',
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@ -0,0 +1 @@
|
||||
/* 已移除单图上传相关样式 */
|
||||
1090
frontend/pages/castlove/lenticular/lenticular-create.vue
Normal file
1090
frontend/pages/castlove/lenticular/lenticular-create.vue
Normal file
File diff suppressed because it is too large
Load Diff
1294
frontend/pages/castlove/lenticular/lenticular-result.vue
Normal file
1294
frontend/pages/castlove/lenticular/lenticular-result.vue
Normal file
File diff suppressed because it is too large
Load Diff
361
frontend/pages/castlove/lenticular/lenticular-thinking.vue
Normal file
361
frontend/pages/castlove/lenticular/lenticular-thinking.vue
Normal file
@ -0,0 +1,361 @@
|
||||
<template>
|
||||
<view class="generation-loading">
|
||||
<image class="background-image" src="/static/background/exhibitionSuccess.png" mode="aspectFill" />
|
||||
|
||||
<view class="gift-box">
|
||||
<image class="gift-image" src="/static/nft/lihe.png" mode="aspectFit" />
|
||||
</view>
|
||||
|
||||
<view class="progress-container">
|
||||
<view class="progress-bar-wrapper">
|
||||
<view class="progress-bar">
|
||||
<view class="progress-fill" :style="{ width: progress + '%' }"></view>
|
||||
</view>
|
||||
<view class="progress-icon" :style="{ left: progress + '%' }">
|
||||
<view class="icon-circle"></view>
|
||||
</view>
|
||||
</view>
|
||||
<text class="progress-text">{{ Math.round(progress) }}%</text>
|
||||
<text class="loading-text">Thinking</text>
|
||||
</view>
|
||||
|
||||
<!-- 镭射批量导出(离屏) -->
|
||||
<canvas
|
||||
canvas-id="laserBatchCanvas"
|
||||
class="laser-batch-canvas"
|
||||
:style="{ width: laserCanvasW + 'px', height: laserCanvasH + 'px' }"
|
||||
/>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { imageGenerationApi } from '@/utils/api.js';
|
||||
import {
|
||||
consumeGenerationFlowPayload,
|
||||
FLOW_MODE_PREFILLED,
|
||||
FLOW_MODE_LENTICULAR,
|
||||
FLOW_MODE_LASER,
|
||||
GENERATED_IMAGES_KEY,
|
||||
GENERATION_REQUEST_KEY,
|
||||
CASTLOVE_FORM_KEY,
|
||||
persistLenticularPreviewMeta,
|
||||
persistLaserPreviewImages,
|
||||
} from '@/utils/castloveGenerationFlow.js';
|
||||
import {
|
||||
generateLaserVariantBatch,
|
||||
LASER_BATCH_CANVAS_SIZE,
|
||||
} from '@/utils/laser-card/laserBatchExport.js';
|
||||
|
||||
const progress = ref(0);
|
||||
const laserCanvasW = LASER_BATCH_CANVAS_SIZE.w;
|
||||
const laserCanvasH = LASER_BATCH_CANVAS_SIZE.h;
|
||||
let progressTimer = null;
|
||||
let generationData = null;
|
||||
let flowStartedAt = 0;
|
||||
|
||||
// 模拟进度增长
|
||||
const simulateProgress = () => {
|
||||
progressTimer = setInterval(() => {
|
||||
if (progress.value < 90) {
|
||||
// 进度到90%前缓慢增长
|
||||
const increment = Math.random() * 5 + 2;
|
||||
progress.value = Math.min(90, progress.value + increment);
|
||||
}
|
||||
}, 500);
|
||||
};
|
||||
|
||||
// 停止进度模拟
|
||||
const stopProgress = () => {
|
||||
if (progressTimer) {
|
||||
clearInterval(progressTimer);
|
||||
progressTimer = null;
|
||||
}
|
||||
};
|
||||
|
||||
// 完成进度
|
||||
const completeProgress = () => {
|
||||
stopProgress();
|
||||
const finalInterval = setInterval(() => {
|
||||
if (progress.value < 100) {
|
||||
progress.value = Math.min(100, progress.value + 5);
|
||||
} else {
|
||||
clearInterval(finalInterval);
|
||||
// 进度完成后延迟跳转
|
||||
setTimeout(() => {
|
||||
handleSuccess();
|
||||
}, 500);
|
||||
}
|
||||
}, 50);
|
||||
};
|
||||
|
||||
// 回退进度
|
||||
const revertProgress = () => {
|
||||
stopProgress();
|
||||
const revertInterval = setInterval(() => {
|
||||
if (progress.value > 0) {
|
||||
progress.value = Math.max(0, progress.value - 10);
|
||||
} else {
|
||||
clearInterval(revertInterval);
|
||||
// 回退完成后返回上一页
|
||||
setTimeout(() => {
|
||||
uni.navigateBack();
|
||||
}, 500);
|
||||
}
|
||||
}, 100);
|
||||
};
|
||||
|
||||
const waitMinDuration = async (minMs) => {
|
||||
if (!minMs || minMs <= 0) return;
|
||||
const elapsed = Date.now() - flowStartedAt;
|
||||
if (elapsed < minMs) {
|
||||
await new Promise((r) => setTimeout(r, minMs - elapsed));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSuccess = () => {
|
||||
uni.showToast({ title: '生成成功', icon: 'success', duration: 1200 });
|
||||
setTimeout(() => {
|
||||
uni.navigateTo({ url: '/pages/castlove/lenticular/lenticular-result' });
|
||||
}, 1200);
|
||||
};
|
||||
|
||||
const runPrefilledFlow = async (flow) => {
|
||||
try {
|
||||
const images = flow.images || [];
|
||||
if (!images.length) throw new Error('无候选图');
|
||||
await waitMinDuration(flow.minDurationMs ?? 1400);
|
||||
uni.setStorageSync(GENERATED_IMAGES_KEY, JSON.stringify(images));
|
||||
completeProgress();
|
||||
} catch (err) {
|
||||
console.error('[GenerationLoading] prefilled', err);
|
||||
uni.showToast({ title: err.message || '加载失败', icon: 'none' });
|
||||
revertProgress();
|
||||
}
|
||||
};
|
||||
|
||||
const runLenticularFlow = async (flow) => {
|
||||
try {
|
||||
const formStr = uni.getStorageSync(CASTLOVE_FORM_KEY);
|
||||
if (!formStr) throw new Error('缺少表单数据');
|
||||
const formData = JSON.parse(formStr);
|
||||
await waitMinDuration(flow.minDurationMs ?? 1600);
|
||||
persistLenticularPreviewMeta(formData);
|
||||
completeProgress();
|
||||
} catch (err) {
|
||||
console.error('[GenerationLoading] lenticular', err);
|
||||
uni.showToast({ title: err.message || '光栅预览失败', icon: 'none' });
|
||||
revertProgress();
|
||||
}
|
||||
};
|
||||
|
||||
const runLaserFlow = async (flow) => {
|
||||
try {
|
||||
const formStr = uni.getStorageSync(CASTLOVE_FORM_KEY);
|
||||
if (!formStr) throw new Error('缺少表单数据');
|
||||
const formData = JSON.parse(formStr);
|
||||
const imagePath = formData.image || formData.uploadedImage || '';
|
||||
if (!imagePath) throw new Error('缺少上传图片');
|
||||
const paths = await generateLaserVariantBatch(imagePath, 'laserBatchCanvas');
|
||||
await waitMinDuration(flow.minDurationMs ?? 1600);
|
||||
persistLaserPreviewImages(paths);
|
||||
completeProgress();
|
||||
} catch (err) {
|
||||
console.error('[GenerationLoading] laser', err);
|
||||
uni.showToast({ title: err.message || '镭射生成失败', icon: 'none' });
|
||||
revertProgress();
|
||||
}
|
||||
};
|
||||
|
||||
const callImageGeneration = async () => {
|
||||
try {
|
||||
const res = await imageGenerationApi(generationData);
|
||||
if (res.data?.images?.length > 0) {
|
||||
uni.setStorageSync(GENERATED_IMAGES_KEY, JSON.stringify(res.data.images));
|
||||
completeProgress();
|
||||
} else {
|
||||
uni.showToast({ title: '未生成图片', icon: 'none' });
|
||||
revertProgress();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[GenerationLoading] API', err);
|
||||
uni.showToast({ title: err.message || '生成失败', icon: 'none' });
|
||||
revertProgress();
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
flowStartedAt = Date.now();
|
||||
simulateProgress();
|
||||
try {
|
||||
const flow = consumeGenerationFlowPayload();
|
||||
|
||||
if (flow?.mode === FLOW_MODE_LENTICULAR) {
|
||||
runLenticularFlow(flow);
|
||||
return;
|
||||
}
|
||||
if (flow?.mode === FLOW_MODE_LASER) {
|
||||
runLaserFlow(flow);
|
||||
return;
|
||||
}
|
||||
if (flow?.mode === FLOW_MODE_PREFILLED) {
|
||||
runPrefilledFlow(flow);
|
||||
return;
|
||||
}
|
||||
|
||||
const requestDataStr = uni.getStorageSync(GENERATION_REQUEST_KEY);
|
||||
if (!requestDataStr) {
|
||||
uni.showToast({ title: '数据错误', icon: 'none' });
|
||||
setTimeout(() => uni.navigateBack(), 1500);
|
||||
return;
|
||||
}
|
||||
generationData = JSON.parse(requestDataStr);
|
||||
const castloveDataStr = uni.getStorageSync(CASTLOVE_FORM_KEY);
|
||||
if (castloveDataStr) {
|
||||
const castloveData = JSON.parse(castloveDataStr);
|
||||
if (generationData.subject_reference?.[0]) {
|
||||
generationData.subject_reference[0].image_file =
|
||||
castloveData.imageBase64 ||
|
||||
castloveData.lenticularSubjectBase64 ||
|
||||
'';
|
||||
}
|
||||
}
|
||||
uni.removeStorageSync(GENERATION_REQUEST_KEY);
|
||||
callImageGeneration();
|
||||
} catch (e) {
|
||||
console.error('[GenerationLoading] mount', e);
|
||||
uni.showToast({ title: '数据错误', icon: 'none' });
|
||||
setTimeout(() => uni.navigateBack(), 1500);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.generation-loading {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.laser-batch-canvas {
|
||||
position: fixed;
|
||||
left: -9999px;
|
||||
top: -9999px;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.background-image {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.gift-box {
|
||||
position: absolute;
|
||||
top: 25%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 480rpx;
|
||||
height: 480rpx;
|
||||
z-index: 2;
|
||||
animation: float 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes float {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateX(-50%) translateY(0) rotate(0deg);
|
||||
}
|
||||
50% {
|
||||
transform: translateX(-50%) translateY(-20rpx) rotate(2deg);
|
||||
}
|
||||
}
|
||||
|
||||
.gift-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.progress-container {
|
||||
position: absolute;
|
||||
bottom: 25%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 600rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
.progress-bar-wrapper {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 40rpx;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
width: 100%;
|
||||
height: 40rpx;
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
border-radius: 20rpx;
|
||||
overflow: hidden;
|
||||
border: 2rpx solid rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #ff6b9d 0%, #ffa8c5 50%, #ffb6d9 100%);
|
||||
border-radius: 20rpx;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.progress-icon {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 60rpx;
|
||||
height: 60rpx;
|
||||
transition: left 0.3s ease;
|
||||
}
|
||||
|
||||
.icon-circle {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(135deg, #ffb6d9 0%, #ff6b9d 100%);
|
||||
border-radius: 50%;
|
||||
border: 4rpx solid #ffffff;
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%,
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
}
|
||||
|
||||
.progress-text {
|
||||
font-size: 56rpx;
|
||||
font-weight: bold;
|
||||
color: #ffffff;
|
||||
margin-bottom: 10rpx;
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
font-size: 36rpx;
|
||||
color: #ffffff;
|
||||
letter-spacing: 6rpx;
|
||||
font-weight: 500;
|
||||
}
|
||||
</style>
|
||||
@ -7,7 +7,7 @@
|
||||
<view class="background-overlay"></view>
|
||||
|
||||
<!-- Header组件 -->
|
||||
<Header :showBack="true" :showGuideIcon="false" :showTaskIcon="false" :showStarActivityIcon="false" backIconColor="#e6e6e6" />
|
||||
<!-- <Header :showBack="true" :showGuideIcon="false" :showTaskIcon="false" :showStarActivityIcon="false" backIconColor="#e6e6e6" /> -->
|
||||
|
||||
<!-- 内容区域 -->
|
||||
<view class="content-wrapper">
|
||||
@ -18,12 +18,31 @@
|
||||
|
||||
<!-- 藏品展示区域 -->
|
||||
<view class="nft-display-section">
|
||||
<NftCard
|
||||
:cover-image="nftData.image"
|
||||
:width="280"
|
||||
:height="280"
|
||||
:custom-style="nftCardStyle"
|
||||
/>
|
||||
<!-- 光栅卡:单张工作台预览(陀螺仪) -->
|
||||
<view v-if="isLenticular" class="lenticular-result-wrap">
|
||||
<view class="lenticular-result-card">
|
||||
<view class="card-wrapper craft-card-wrapper">
|
||||
<image
|
||||
class="card-frame"
|
||||
src="/static/square/gerenzhongxincangpinkuang.png"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
<view class="craft-lenticular-slot">
|
||||
<LenticularCard
|
||||
v-if="lenticularLayers.length > 0"
|
||||
class="craft-lenticular-card"
|
||||
:layers="lenticularLayers"
|
||||
:transforms="layerTransforms"
|
||||
:gyro-source="gyroSourceLabel"
|
||||
:skip-built-in-touch="true"
|
||||
tilt-hint-text="晃动查看"
|
||||
:shimmer-mid-opacity="0.16"
|
||||
@simulate="simulate"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 铸造成功角色图片 -->
|
||||
@ -48,6 +67,9 @@ import { ref, onMounted } from 'vue';
|
||||
import { onUnload } from '@dcloudio/uni-app';
|
||||
import Header from '../components/Header.vue';
|
||||
import NftCard from '../components/NftCard.vue';
|
||||
import LenticularCard from '@/components/lenticular/LenticularCard.vue';
|
||||
import { useLenticularCraftTiltPreview } from '@/composables/useLenticularCraftTiltPreview.js';
|
||||
import { buildLenticularLayersTwo } from '@/utils/castloveMintForm.js';
|
||||
|
||||
// 藏品数据
|
||||
const nftData = ref({
|
||||
@ -55,9 +77,24 @@ const nftData = ref({
|
||||
name: '',
|
||||
event: '',
|
||||
remark: '',
|
||||
materialType: ''
|
||||
materialType: '',
|
||||
is_lenticular: false,
|
||||
bg_image: '',
|
||||
});
|
||||
|
||||
// 是否显示为光栅卡
|
||||
const isLenticular = ref(false)
|
||||
|
||||
// 光栅卡图层
|
||||
const lenticularLayers = ref([])
|
||||
const {
|
||||
layerTransforms,
|
||||
simulate,
|
||||
gyroSourceLabel,
|
||||
scheduleTiltStart,
|
||||
stopTiltPreview,
|
||||
} = useLenticularCraftTiltPreview(lenticularLayers);
|
||||
|
||||
// NftCard 样式
|
||||
const nftCardStyle = {
|
||||
position: 'relative',
|
||||
@ -77,8 +114,16 @@ onMounted(() => {
|
||||
name: data.name || '未命名藏品',
|
||||
event: data.event || '',
|
||||
remark: data.remark || '',
|
||||
materialType: data.materialType || ''
|
||||
materialType: data.materialType || '',
|
||||
is_lenticular: data.is_lenticular || false,
|
||||
bg_image: data.bg_image || '',
|
||||
};
|
||||
// 如果是光栅卡,构建图层
|
||||
if (data.is_lenticular && data.image && data.bg_image) {
|
||||
isLenticular.value = true
|
||||
lenticularLayers.value = buildLenticularLayersTwo(data.bg_image, data.image)
|
||||
scheduleTiltStart()
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('读取藏品数据失败:', e);
|
||||
@ -89,6 +134,15 @@ onMounted(() => {
|
||||
}
|
||||
});
|
||||
|
||||
onUnload(() => {
|
||||
stopTiltPreview()
|
||||
try {
|
||||
uni.removeStorageSync('temp_nft_data');
|
||||
} catch (e) {
|
||||
/* noop */
|
||||
}
|
||||
});
|
||||
|
||||
// 查看详情(保留 temp_nft_data,避免第二次点击时 order_id 丢失)
|
||||
const handleViewDetails = () => {
|
||||
let orderId = '';
|
||||
@ -212,6 +266,20 @@ onUnload(() => {
|
||||
animation: zoomIn 0.8s ease-out 0.2s both;
|
||||
}
|
||||
|
||||
/* 光栅卡容器 */
|
||||
.lenticular-card-wrap {
|
||||
width: 420rpx;
|
||||
height: 560rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.lenticular-preview {
|
||||
width: 420rpx;
|
||||
height: 560rpx;
|
||||
}
|
||||
|
||||
@keyframes zoomIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
@ -296,4 +364,54 @@ onUnload(() => {
|
||||
opacity: 0.9;
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
/* 光栅卡样式 */
|
||||
.lenticular-result-wrap {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
z-index: 8;
|
||||
}
|
||||
|
||||
.lenticular-result-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.lenticular-result-card .card-wrapper {
|
||||
position: relative;
|
||||
width: 352rpx;
|
||||
height: 520rpx;
|
||||
}
|
||||
|
||||
.lenticular-result-card .craft-card-wrapper {
|
||||
margin-bottom: 32rpx;
|
||||
}
|
||||
|
||||
.lenticular-result-card .card-frame {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 2;
|
||||
transform: rotate(-10deg);
|
||||
}
|
||||
|
||||
.lenticular-result-card .craft-lenticular-slot {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
width: 87%;
|
||||
height: 96%;
|
||||
border-radius: 48rpx;
|
||||
transform: translate(-50%, -50%) rotate(-10deg);
|
||||
z-index: 2;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.lenticular-result-card .craft-lenticular-card {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -558,6 +558,14 @@ export function getMintOrderDetailApi(orderId) {
|
||||
})
|
||||
}
|
||||
|
||||
// 估算铸造费用(显示消耗和余额)
|
||||
export function estimateMintCostApi() {
|
||||
return request({
|
||||
url: '/api/v1/assets/mints/cost-estimate',
|
||||
method: 'POST'
|
||||
})
|
||||
}
|
||||
|
||||
// 上传素材(返回 material_id)
|
||||
export function uploadMaterialApi(data) {
|
||||
return request({
|
||||
@ -576,6 +584,14 @@ export function bindAssetMaterialsApi(assetId, materials) {
|
||||
})
|
||||
}
|
||||
|
||||
// 获取资产素材列表
|
||||
export function getAssetMaterialsApi(assetId) {
|
||||
return request({
|
||||
url: `/api/v1/assets/${assetId}/materials`,
|
||||
method: 'GET'
|
||||
})
|
||||
}
|
||||
|
||||
// 图生图
|
||||
export function imageGenerationApi(params) {
|
||||
return request({
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
import { getOssPresignedUrlApi } from '@/utils/api.js'
|
||||
|
||||
/**
|
||||
* 从URL提取文件名
|
||||
* @param {String} url - URL字符串
|
||||
@ -64,8 +66,8 @@ export function extractOssObjectPath(url) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取藏品封面的真实URL - 直接使用后端返回的URL
|
||||
* @param {String} coverUrl - 后端返回的cover_url
|
||||
* 获取藏品封面的真实URL - 使用 OSS 预签名 URL
|
||||
* @param {String} coverUrl - 后端返回的cover_url(相对路径或完整URL)
|
||||
* @returns {Promise<String>} 真实可访问的URL
|
||||
*/
|
||||
export async function getAssetCoverRealUrl(coverUrl) {
|
||||
@ -77,13 +79,27 @@ export async function getAssetCoverRealUrl(coverUrl) {
|
||||
return coverUrl || DEFAULT_IMAGE;
|
||||
}
|
||||
|
||||
// 直接使用后端返回的 URL
|
||||
return coverUrl;
|
||||
// 如果是完整的绝对URL(包含 ://),直接返回
|
||||
if (coverUrl.includes('://')) {
|
||||
return coverUrl;
|
||||
}
|
||||
|
||||
// 相对路径:调用预签名接口获取可访问的URL
|
||||
try {
|
||||
const res = await getOssPresignedUrlApi(coverUrl, 3600, 'asset');
|
||||
if (res && res.data && res.data.url) {
|
||||
return res.data.url;
|
||||
}
|
||||
return coverUrl;
|
||||
} catch (e) {
|
||||
console.error('[assetImageHelper] get presigned url failed:', e);
|
||||
return coverUrl;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取好友头像的真实URL - 直接使用后端返回的URL
|
||||
* @param {String} avatarUrl - 后端返回的avatar_url(完整URL)
|
||||
* 获取好友头像的真实URL - 使用 OSS 预签名 URL
|
||||
* @param {String} avatarUrl - 后端返回的avatar_url
|
||||
* @returns {Promise<String>} 真实可访问的URL,失败返回空字符串
|
||||
*/
|
||||
export async function getFriendAvatarRealUrl(avatarUrl) {
|
||||
@ -92,6 +108,21 @@ export async function getFriendAvatarRealUrl(avatarUrl) {
|
||||
return avatarUrl || '';
|
||||
}
|
||||
|
||||
// 直接使用后端返回的 URL
|
||||
return avatarUrl;
|
||||
}
|
||||
// 如果是完整的绝对URL(包含 :://),直接返回
|
||||
if (avatarUrl.includes('://')) {
|
||||
return avatarUrl;
|
||||
}
|
||||
|
||||
// 相对路径:调用预签名接口获取可访问的URL
|
||||
try {
|
||||
const res = await getOssPresignedUrlApi(avatarUrl, 3600, 'avatar');
|
||||
if (res && res.data && res.data.url) {
|
||||
return res.data.url;
|
||||
}
|
||||
// 预签名失败,返回原始路径
|
||||
return avatarUrl;
|
||||
} catch (e) {
|
||||
console.error('[assetImageHelper] get avatar presigned url failed:', e);
|
||||
return avatarUrl;
|
||||
}
|
||||
}
|
||||
@ -1,5 +1,8 @@
|
||||
/**
|
||||
* 铸爱 — 统一「Thinking → 选择 → 详情确认 → 铸造」路由与 Storage
|
||||
*
|
||||
* 本模块管理铸爱(castlove)生成流程的页面跳转、数据持久化和状态管理。
|
||||
* 支持四种模式:API生成、预填充选择、光栅工作室、镭射工作室
|
||||
*/
|
||||
|
||||
import {
|
||||
@ -7,29 +10,61 @@ import {
|
||||
CASTLOVE_LASER_ENTRY_KEY,
|
||||
} from '@/utils/castloveMintForm.js'
|
||||
|
||||
// ========== Storage Keys ==========
|
||||
/** 生成流程的完整Payload(包含mode、images等) */
|
||||
export const GENERATION_FLOW_KEY = 'generation_flow_payload'
|
||||
/** AI生成请求的数据(prompt、model、aspect_ratio等) */
|
||||
export const GENERATION_REQUEST_KEY = 'generation_request_data'
|
||||
/** 已生成的图片列表(预填充或AI生成的结果) */
|
||||
export const GENERATED_IMAGES_KEY = 'generated_images'
|
||||
/** 生成结果的元数据(displayMode、imageCount) */
|
||||
export const GENERATION_RESULT_META_KEY = 'generation_result_meta'
|
||||
/** 铸爱表单数据(craft_name、typeName等) */
|
||||
export const CASTLOVE_FORM_KEY = 'castlove_form_data'
|
||||
/** 用户在选择界面选中的图片 */
|
||||
export const CRAFT_SELECTED_IMAGE_KEY = 'craft_selected_image'
|
||||
/** 用户在选择界面选中的图片索引 */
|
||||
export const CRAFT_SELECTED_INDEX_KEY = 'craft_selected_index'
|
||||
|
||||
// ========== Flow Modes ==========
|
||||
/** 模式:API生成(AI生成四图) */
|
||||
export const FLOW_MODE_API = 'api'
|
||||
/** 模式:预填充(用户已有所需图片,直接进入选择) */
|
||||
export const FLOW_MODE_PREFILLED = 'prefilled'
|
||||
/** 模式:光栅工作室(工作台生成,不走AI四图) */
|
||||
export const FLOW_MODE_LENTICULAR = 'lenticular'
|
||||
/** 模式:镭射工作室(工作台生成,不走AI四图) */
|
||||
export const FLOW_MODE_LASER = 'laser'
|
||||
|
||||
// ========== After Select Actions ==========
|
||||
/** 选择后的动作:直接进入铸造 */
|
||||
export const AFTER_SELECT_MINT = 'mint'
|
||||
/** 选择后的动作:进入详情确认 */
|
||||
export const AFTER_SELECT_DETAIL = 'detail'
|
||||
|
||||
// ========== Studio Types ==========
|
||||
/** 工作室类型:光栅 */
|
||||
export const STUDIO_LENTICULAR = 'lenticular'
|
||||
/** 工作室类型:镭射 */
|
||||
export const STUDIO_LASER = 'laser'
|
||||
|
||||
// ========== Page URLs ==========
|
||||
const LOADING_URL = '/pages/discover/generation-loading'
|
||||
const RESULT_URL = '/pages/discover/generation-result'
|
||||
const ASSET_DETAIL_URL = '/pages/asset-detail/asset-detail'
|
||||
const LENTICULAR_THINKING_URL = '/pages/castlove/lenticular/lenticular-thinking'
|
||||
// const ASSET_DETAIL_URL = '/pages/asset-detail/asset-detail'
|
||||
|
||||
// ========== Core Functions ==========
|
||||
|
||||
/**
|
||||
* 将图片数组补足到最少指定数量
|
||||
* - 如果图片数量已达到最低要求,直接返回前minCount张
|
||||
* - 否则循环追加图片直到达到最低数量(用于保持UI四宫格布局)
|
||||
*
|
||||
* @param {Array} images - 图片数组
|
||||
* @param {number} minCount - 最少图片数量,默认4
|
||||
* @returns {Array} 补足后的图片数组
|
||||
*/
|
||||
export function padImagesForSelection(images, minCount = 4) {
|
||||
if (!Array.isArray(images) || images.length === 0) {
|
||||
return []
|
||||
@ -39,17 +74,55 @@ export function padImagesForSelection(images, minCount = 4) {
|
||||
}
|
||||
const out = [...images]
|
||||
while (out.length < minCount) {
|
||||
// 循环追加:用已有图片填充空白位置
|
||||
out.push(images[out.length % images.length])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* 将表单数据持久化到Storage
|
||||
* 用于在页面跳转过程中保存用户填写的表单信息
|
||||
*
|
||||
* @param {Object|null} formData - 表单数据对象
|
||||
*/
|
||||
function persistFormData(formData) {
|
||||
if (formData != null) {
|
||||
uni.setStorageSync(CASTLOVE_FORM_KEY, JSON.stringify(formData))
|
||||
try {
|
||||
uni.setStorageSync(CASTLOVE_FORM_KEY, JSON.stringify(formData))
|
||||
} catch (e) {
|
||||
console.error('persistFormData failed:', e)
|
||||
if (e.name === 'QuotaExceededError' || e.message?.includes('quota')) {
|
||||
return {
|
||||
success: false,
|
||||
error: {
|
||||
title: '存储空间不足',
|
||||
content: '图片数据过大,请尝试压缩图片后重新提交'
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
success: false,
|
||||
error: {
|
||||
title: '存储失败',
|
||||
content: '表单数据保存失败,请重试'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
/**
|
||||
* enrich(丰富)表单数据,添加流程控制字段
|
||||
* - generation_after: 选择后的动作(mint/detail)
|
||||
* - studio_kind: 工作室类型(lenticular/laser)
|
||||
*
|
||||
* @param {Object|null} formData - 原始表单数据
|
||||
* @param {Object} options - { afterSelect, studioKind }
|
||||
* @returns {Object} 丰富后的表单数据
|
||||
*/
|
||||
function enrichFormData(formData, { afterSelect, studioKind } = {}) {
|
||||
const next = { ...(formData || {}) }
|
||||
if (afterSelect) {
|
||||
@ -61,14 +134,26 @@ function enrichFormData(formData, { afterSelect, studioKind } = {}) {
|
||||
return next
|
||||
}
|
||||
|
||||
/**
|
||||
* 将图片引用转换为可用的本地路径或Base64
|
||||
* 支持三种形式:
|
||||
* 1. data:URL格式 → 直接返回base64
|
||||
* 2. http/https URL → 下载到本地临时文件
|
||||
* 3. 本地路径 → 直接返回path
|
||||
*
|
||||
* @param {string} src - 图片引用(URL、路径或base64)
|
||||
* @returns {Promise<{path: string, base64: string}>}
|
||||
*/
|
||||
export function materializeImageRef(src) {
|
||||
const s = String(src || '').trim()
|
||||
if (!s) {
|
||||
return Promise.resolve({ path: '', base64: '' })
|
||||
}
|
||||
// data:URL 格式(如 data:image/png;base64,XXXX)
|
||||
if (s.startsWith('data:')) {
|
||||
return Promise.resolve({ path: '', base64: s })
|
||||
}
|
||||
// 网络URL,需要下载
|
||||
if (s.startsWith('http://') || s.startsWith('https://')) {
|
||||
return new Promise((resolve, reject) => {
|
||||
uni.downloadFile({
|
||||
@ -84,13 +169,33 @@ export function materializeImageRef(src) {
|
||||
})
|
||||
})
|
||||
}
|
||||
// 本地路径
|
||||
return Promise.resolve({ path: s, base64: '' })
|
||||
}
|
||||
|
||||
/**
|
||||
* 导航到加载页面(生成中动画)
|
||||
*/
|
||||
function navigateToLoading() {
|
||||
uni.navigateTo({ url: LOADING_URL })
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动AI图片生成流程
|
||||
* 1. 构建生成请求数据(prompt、model、aspect_ratio等)
|
||||
* 2. 保存表单数据和请求数据到Storage
|
||||
* 3. 设置流程模式为API模式
|
||||
* 4. 跳转到加载页面
|
||||
*
|
||||
* @param {Object} options
|
||||
* @param {string} options.prompt - 生成提示词
|
||||
* @param {Object} options.formData - 表单数据(craft_name等)
|
||||
* @param {number} options.n - 生成数量,默认4
|
||||
* @param {string} options.model - 模型,默认'image-01'
|
||||
* @param {string} options.aspectRatio - 宽高比,默认'16:9'
|
||||
* @param {string} options.afterSelect - 选择后动作,默认mint
|
||||
* @param {string} options.studioKind - 工作室类型
|
||||
*/
|
||||
export function startAiImageGenerationFlow({
|
||||
prompt,
|
||||
formData,
|
||||
@ -122,14 +227,36 @@ export function startAiImageGenerationFlow({
|
||||
navigateToLoading()
|
||||
}
|
||||
|
||||
/** 光栅 / 镭射:工作台生成预览,不走通用 AI 四图 */
|
||||
/**
|
||||
* 导航到对应类型的加载页面(生成中动画)
|
||||
* @param {string} studioKind - 工作室类型(lenticular/laser)
|
||||
*/
|
||||
function navigateToStudioLoading(studioKind) {
|
||||
if (studioKind === STUDIO_LENTICULAR) {
|
||||
uni.navigateTo({ url: LENTICULAR_THINKING_URL })
|
||||
} else {
|
||||
uni.navigateTo({ url: LOADING_URL })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动工作台生成流程(光栅/镭射)
|
||||
* 与AI生成流程不同,工作台生成不走通用AI四图,而是直接在工作台生成预览
|
||||
*
|
||||
* @param {Object} options
|
||||
* @param {Object} options.formData - 表单数据
|
||||
* @param {string} options.studioKind - 工作室类型(lenticular/laser)
|
||||
*/
|
||||
export function startCraftGenerationFlow({ formData, studioKind }) {
|
||||
const merged = enrichFormData(formData, {
|
||||
afterSelect: AFTER_SELECT_DETAIL,
|
||||
afterSelect: AFTER_SELECT_DETAIL, // 工作台模式默认选择后进入详情
|
||||
studioKind,
|
||||
})
|
||||
persistFormData(merged)
|
||||
uni.removeStorageSync(GENERATION_REQUEST_KEY)
|
||||
const result = persistFormData(merged)
|
||||
if (!result.success) {
|
||||
return result // { success: false, error: { title, content } }
|
||||
}
|
||||
uni.removeStorageSync(GENERATION_REQUEST_KEY) // 不需要AI请求数据
|
||||
const mode =
|
||||
studioKind === STUDIO_LENTICULAR ? FLOW_MODE_LENTICULAR : FLOW_MODE_LASER
|
||||
uni.setStorageSync(
|
||||
@ -139,12 +266,24 @@ export function startCraftGenerationFlow({ formData, studioKind }) {
|
||||
studioKind,
|
||||
afterSelect: AFTER_SELECT_DETAIL,
|
||||
craft: merged?.craft_name || merged?.typeName || '',
|
||||
minDurationMs: 1600,
|
||||
minDurationMs: 1600, // 至少展示1600ms的加载动画
|
||||
})
|
||||
)
|
||||
navigateToLoading()
|
||||
navigateToStudioLoading(studioKind)
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动预填充选择流程(用户已有所需图片)
|
||||
* 用户从相册或历史记录选择图片,直接进入选择界面
|
||||
*
|
||||
* @param {Object} options
|
||||
* @param {Array} options.images - 图片数组
|
||||
* @param {Object} options.formData - 表单数据
|
||||
* @param {number} options.minDurationMs - 加载动画最少展示时间,默认1400ms
|
||||
* @param {boolean} options.padToFour - 是否补足到4张,默认true
|
||||
* @param {string} options.afterSelect - 选择后动作,默认mint
|
||||
* @param {string} options.studioKind - 工作室类型
|
||||
*/
|
||||
export function startPrefilledSelectionFlow({
|
||||
images,
|
||||
formData,
|
||||
@ -158,7 +297,10 @@ export function startPrefilledSelectionFlow({
|
||||
throw new Error('startPrefilledSelectionFlow: images 不能为空')
|
||||
}
|
||||
const merged = enrichFormData(formData, { afterSelect, studioKind })
|
||||
persistFormData(merged)
|
||||
const result = persistFormData(merged)
|
||||
if (!result.success) {
|
||||
return result
|
||||
}
|
||||
uni.removeStorageSync(GENERATION_REQUEST_KEY)
|
||||
uni.setStorageSync(
|
||||
GENERATION_FLOW_KEY,
|
||||
@ -174,6 +316,12 @@ export function startPrefilledSelectionFlow({
|
||||
navigateToLoading()
|
||||
}
|
||||
|
||||
/**
|
||||
* 持久化光栅工作室预览元数据
|
||||
* 在进入光栅结果页面前调用,保存背景图、主体图、材质等信息
|
||||
*
|
||||
* @param {Object} formData - 包含lenticularBgImage、lenticularSubjectImage等字段
|
||||
*/
|
||||
export function persistLenticularPreviewMeta(formData) {
|
||||
uni.setStorageSync(
|
||||
LENTICULAR_STUDIO_STORAGE_KEY,
|
||||
@ -194,6 +342,12 @@ export function persistLenticularPreviewMeta(formData) {
|
||||
uni.setStorageSync(GENERATED_IMAGES_KEY, JSON.stringify([{ type: 'lenticular' }]))
|
||||
}
|
||||
|
||||
/**
|
||||
* 持久化镭射工作室预览图片
|
||||
* 在进入镭射结果页面前调用,保存生成的图片路径列表
|
||||
*
|
||||
* @param {Array} paths - 镭射生成的图片路径数组
|
||||
*/
|
||||
export function persistLaserPreviewImages(paths) {
|
||||
uni.setStorageSync(
|
||||
GENERATION_RESULT_META_KEY,
|
||||
@ -202,6 +356,18 @@ export function persistLaserPreviewImages(paths) {
|
||||
uni.setStorageSync(GENERATED_IMAGES_KEY, JSON.stringify(paths))
|
||||
}
|
||||
|
||||
/**
|
||||
* 完成图片选择并跳转到详情页
|
||||
* 1. 将选中图片转换为可用的本地引用
|
||||
* 2. 保存选中图片和索引到Storage
|
||||
* 3. 根据工作室类型保存特定的元数据
|
||||
* 4. 跳转到资产详情页
|
||||
*
|
||||
* @param {Object} options
|
||||
* @param {string} options.selectedImage - 用户选中的图片
|
||||
* @param {number} options.selectedIndex - 用户选中的图片索引
|
||||
* @param {Object} options.formData - 表单数据(包含studio_kind等)
|
||||
*/
|
||||
export async function completeSelectionAndOpenDetail({
|
||||
selectedImage,
|
||||
selectedIndex,
|
||||
@ -212,6 +378,7 @@ export async function completeSelectionAndOpenDetail({
|
||||
uni.setStorageSync(CRAFT_SELECTED_IMAGE_KEY, storedImage)
|
||||
uni.setStorageSync(CRAFT_SELECTED_INDEX_KEY, String(selectedIndex ?? 0))
|
||||
|
||||
// 根据工作室类型保存特定的预览元数据
|
||||
if (formData?.studio_kind === STUDIO_LENTICULAR) {
|
||||
const subjectRef = storedImage
|
||||
uni.setStorageSync(
|
||||
@ -241,23 +408,48 @@ export async function completeSelectionAndOpenDetail({
|
||||
}
|
||||
|
||||
const kind = formData?.studio_kind || ''
|
||||
uni.navigateTo({
|
||||
url: `${ASSET_DETAIL_URL}?from=craft_confirm&studio_kind=${encodeURIComponent(kind)}`,
|
||||
})
|
||||
// uni.navigateTo({
|
||||
// url: `${ASSET_DETAIL_URL}?from=craft_confirm&studio_kind=${encodeURIComponent(kind)}`,
|
||||
// })
|
||||
}
|
||||
|
||||
// ========== Query Helpers ==========
|
||||
|
||||
/**
|
||||
* 判断选择后的动作是否为"详情确认"
|
||||
* @param {Object} formData - 表单数据
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isDetailAfterSelect(formData) {
|
||||
return formData?.generation_after === AFTER_SELECT_DETAIL
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为光栅工作室类型
|
||||
* @param {Object} formData - 表单数据
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isLenticularKind(formData) {
|
||||
return formData?.studio_kind === STUDIO_LENTICULAR
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为镭射工作室类型
|
||||
* @param {Object} formData - 表单数据
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isLaserKind(formData) {
|
||||
return formData?.studio_kind === STUDIO_LASER
|
||||
}
|
||||
|
||||
// ========== Payload Consumer ==========
|
||||
|
||||
/**
|
||||
* 消费(读取并清除)生成流程的Payload
|
||||
* 在目标页面读取后自动清除,保证一次性使用
|
||||
*
|
||||
* @returns {Object|null} 流程Payload,包含mode、images、craft等
|
||||
*/
|
||||
export function consumeGenerationFlowPayload() {
|
||||
try {
|
||||
const raw = uni.getStorageSync(GENERATION_FLOW_KEY)
|
||||
@ -272,4 +464,5 @@ export function consumeGenerationFlowPayload() {
|
||||
}
|
||||
}
|
||||
|
||||
export { RESULT_URL }
|
||||
// ========== Exports ==========
|
||||
export { RESULT_URL }
|
||||
@ -32,13 +32,14 @@ function uploadFileToOss(tempFilePath, ossData) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传文件到 OSS 并返回文件大小(用于注册素材)
|
||||
* 上传文件到 OSS 并返回文件大小和 hash(用于注册素材)
|
||||
*/
|
||||
function uploadFileToOssWithInfo(tempFilePath, ossData) {
|
||||
return new Promise((resolve, reject) => {
|
||||
console.log('[craftMintSubmit] uploadFileToOssWithInfo tempFilePath:', tempFilePath)
|
||||
const fileName = `${Date.now()}.jpg`
|
||||
uni.uploadFile({
|
||||
url: ossData.host,
|
||||
url: resolveH5OssPostUrl(ossData.host),
|
||||
filePath: tempFilePath,
|
||||
name: 'file',
|
||||
formData: {
|
||||
@ -52,6 +53,7 @@ function uploadFileToOssWithInfo(tempFilePath, ossData) {
|
||||
'x-oss-signature-version': ossData.x_oss_signature_version,
|
||||
},
|
||||
success: async (res) => {
|
||||
console.log('[craftMintSubmit] uploadFileToOssWithInfo statusCode:', res.statusCode)
|
||||
if (res.statusCode === 200 || res.statusCode === 204) {
|
||||
const url = `${ossData.host}/${ossData.dir}${fileName}`
|
||||
// 获取文件大小
|
||||
@ -68,30 +70,264 @@ function uploadFileToOssWithInfo(tempFilePath, ossData) {
|
||||
} catch (e) {
|
||||
console.warn('[craftMintSubmit] getFileInfo failed', e)
|
||||
}
|
||||
resolve({ url, size, hash: '' })
|
||||
// 计算文件 hash
|
||||
const hash = await computeHashFromPath(tempFilePath)
|
||||
console.log('[craftMintSubmit] uploadFileToOssWithInfo hash:', hash ? hash.substring(0, 10) + '...' : 'empty')
|
||||
resolve({ url, size, hash })
|
||||
} else {
|
||||
reject(new Error(`上传失败 ${res.statusCode}`))
|
||||
}
|
||||
},
|
||||
fail: reject,
|
||||
fail: (err) => {
|
||||
console.error('[craftMintSubmit] uploadFileToOssWithInfo fail:', err)
|
||||
reject(err)
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 纯 JavaScript SHA256 实现(用于 App 等不支持 crypto.subtle 的环境)
|
||||
*/
|
||||
function sha256Sync(str) {
|
||||
// SHA256 实现
|
||||
const rotateRight = (n, s) => (n >>> s) | ((n << (32 - s)) >>> 0)
|
||||
const choice = (x, y, z) => (x & y) ^ (~x & z)
|
||||
const majority = (x, y, z) => (x & y) ^ (x & z) ^ (y & z)
|
||||
const maj = (x, y, z) => (x & y) ^ (x & z) ^ (y & z)
|
||||
const ch = (x, y, z) => (x & y) ^ (~x & z)
|
||||
|
||||
const K = [
|
||||
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
|
||||
0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
|
||||
0x49b40821, 0xfef9e05c, 0x4fd9c4a7, 0x5b9cca4f, 0x682e6ff3, 0x748f82ee, 0x78a5636f, 0x84c87814,
|
||||
0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2
|
||||
]
|
||||
|
||||
// 转换为 bytes
|
||||
const msgBytes = []
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
const code = str.charCodeAt(i)
|
||||
if (code < 0x80) {
|
||||
msgBytes.push(code)
|
||||
} else if (code < 0x800) {
|
||||
msgBytes.push(0xc0 | (code >> 6))
|
||||
msgBytes.push(0x80 | (code & 0x3f))
|
||||
} else if (code < 0xd800 || code >= 0xe000) {
|
||||
msgBytes.push(0xe0 | (code >> 12))
|
||||
msgBytes.push(0x80 | ((code >> 6) & 0x3f))
|
||||
msgBytes.push(0x80 | (code & 0x3f))
|
||||
} else {
|
||||
const cp = 0x10000 + ((code - 0xd800) << 10) | (str.charCodeAt(++i) - 0xdc00)
|
||||
msgBytes.push(0xf0 | (cp >> 18))
|
||||
msgBytes.push(0x80 | ((cp >> 12) & 0x3f))
|
||||
msgBytes.push(0x80 | ((cp >> 6) & 0x3f))
|
||||
msgBytes.push(0x80 | (cp & 0x3f))
|
||||
}
|
||||
}
|
||||
|
||||
// Padding
|
||||
const msgLen = msgBytes.length
|
||||
const bitLen = msgLen * 8
|
||||
msgBytes.push(0x80)
|
||||
while ((msgBytes.length % 64) !== 56) msgBytes.push(0)
|
||||
for (let i = 7; i >= 0; i--) msgBytes.push((bitLen >>> (i * 8)) & 0xff)
|
||||
|
||||
// Initialize hash values
|
||||
let h0 = 0x6a09e667, h1 = 0xbb67ae85, h2 = 0x3c6ef372, h3 = 0xa54ff53a
|
||||
let h4 = 0x510e527f, h5 = 0x9b05688c, h6 = 0x1f83d9ab, h7 = 0x5be0cd19
|
||||
|
||||
// Process blocks
|
||||
for (let chunk = 0; chunk < msgBytes.length / 64; chunk++) {
|
||||
const w = new Array(64)
|
||||
for (let j = 0; j < 16; j++) {
|
||||
w[j] = (msgBytes[chunk * 64 + j * 4] << 24) | (msgBytes[chunk * 64 + j * 4 + 1] << 16) |
|
||||
(msgBytes[chunk * 64 + j * 4 + 2] << 8) | msgBytes[chunk * 64 + j * 4 + 3]
|
||||
}
|
||||
for (let j = 16; j < 64; j++) {
|
||||
const s0 = rotateRight(w[j - 15], 7) ^ rotateRight(w[j - 15], 18) ^ (w[j - 15] >>> 3)
|
||||
const s1 = rotateRight(w[j - 2], 17) ^ rotateRight(w[j - 2], 19) ^ (w[j - 2] >>> 10)
|
||||
w[j] = (w[j - 16] + s0 + w[j - 7] + s1) >>> 0
|
||||
}
|
||||
let [ah0, ah1, ah2, ah3, ah4, ah5, ah6, ah7] = [h0, h1, h2, h3, h4, h5, h6, h7]
|
||||
for (let j = 0; j < 64; j++) {
|
||||
const S1 = rotateRight(ah6, 6) ^ rotateRight(ah6, 11) ^ rotateRight(ah6, 25)
|
||||
const ch2 = ch(ah6, ah5, ah4)
|
||||
const temp1 = (ah7 + S1 + ch2 + K[j] + w[j]) >>> 0
|
||||
const S0 = rotateRight(ah0, 2) ^ rotateRight(ah0, 13) ^ rotateRight(ah0, 22)
|
||||
const maj2 = maj(ah0, ah1, ah2)
|
||||
const temp2 = (S0 + maj2) >>> 0
|
||||
ah7 = (ah6 + temp1) >>> 0
|
||||
ah6 = (ah5 + temp1) >>> 0
|
||||
ah5 = (ah4 + temp1) >>> 0
|
||||
ah4 = (ah3 + temp1) >>> 0
|
||||
ah3 = (ah2 + temp1) >>> 0
|
||||
ah2 = (ah1 + temp1) >>> 0
|
||||
ah1 = (ah0 + temp1) >>> 0
|
||||
ah0 = (temp1 + temp2) >>> 0
|
||||
}
|
||||
h0 = (h0 + ah0) >>> 0; h1 = (h1 + ah1) >>> 0; h2 = (h2 + ah2) >>> 0; h3 = (h3 + ah3) >>> 0
|
||||
h4 = (h4 + ah4) >>> 0; h5 = (h5 + ah5) >>> 0; h6 = (h6 + ah6) >>> 0; h7 = (h7 + ah7) >>> 0
|
||||
}
|
||||
|
||||
const toHex = (n) => n.toString(16).padStart(8, '0')
|
||||
return toHex(h0) + toHex(h1) + toHex(h2) + toHex(h3) + toHex(h4) + toHex(h5) + toHex(h6) + toHex(h7)
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算文件 SHA256 哈希(使用 Web Crypto API)
|
||||
*/
|
||||
async function computeFileHash(file) {
|
||||
if (typeof crypto !== 'undefined' && crypto.subtle) {
|
||||
const buffer = await file.arrayBuffer()
|
||||
const hashBuffer = await crypto.subtle.digest('SHA-256', buffer)
|
||||
const hashArray = Array.from(new Uint8Array(hashBuffer))
|
||||
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('')
|
||||
console.log('[craftMintSubmit] computeFileHash start, file type:', typeof file, file instanceof Blob ? `Blob(size=${file.size})` : '')
|
||||
try {
|
||||
if (typeof crypto !== 'undefined' && crypto.subtle) {
|
||||
const buffer = await file.arrayBuffer()
|
||||
console.log('[craftMintSubmit] computeFileHash arrayBuffer length:', buffer.byteLength)
|
||||
const hashBuffer = await crypto.subtle.digest('SHA-256', buffer)
|
||||
const hashArray = Array.from(new Uint8Array(hashBuffer))
|
||||
const hash = hashArray.map(b => b.toString(16).padStart(2, '0')).join('')
|
||||
console.log('[craftMintSubmit] computeFileHash result:', hash.substring(0, 20) + '...')
|
||||
return hash
|
||||
} else {
|
||||
console.warn('[craftMintSubmit] computeFileHash: crypto.subtle not available')
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[craftMintSubmit] computeFileHash failed:', e)
|
||||
}
|
||||
// Fallback: 返回空字符串,后端会重新计算
|
||||
return ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 从文件路径读取内容并计算 hash(支持各端)
|
||||
*/
|
||||
async function computeHashFromPath(filePath) {
|
||||
console.log('[craftMintSubmit] computeHashFromPath start, path:', filePath ? filePath.substring(0, 80) : 'empty')
|
||||
try {
|
||||
// 处理 data:URL 格式
|
||||
if (filePath && filePath.startsWith('data:')) {
|
||||
console.log('[craftMintSubmit] computeHashFromPath data:URL format')
|
||||
try {
|
||||
const base64Data = filePath.split(',')[1] || ''
|
||||
const binaryStr = atob(base64Data)
|
||||
// 优先用 crypto.subtle,失败则用纯JS
|
||||
if (typeof crypto !== 'undefined' && crypto.subtle) {
|
||||
const bytes = new Uint8Array(binaryStr.length)
|
||||
for (let i = 0; i < binaryStr.length; i++) {
|
||||
bytes[i] = binaryStr.charCodeAt(i)
|
||||
}
|
||||
const hashBuffer = await crypto.subtle.digest('SHA-256', bytes)
|
||||
const hashArray = Array.from(new Uint8Array(hashBuffer))
|
||||
const hash = hashArray.map(b => b.toString(16).padStart(2, '0')).join('')
|
||||
console.log('[craftMintSubmit] computeHashFromPath data:URL hash:', hash.substring(0, 20) + '...')
|
||||
return hash
|
||||
} else {
|
||||
// Fallback 纯JS SHA256
|
||||
const hash = sha256Sync(binaryStr)
|
||||
console.log('[craftMintSubmit] computeHashFromPath data:URL sha256Sync hash:', hash.substring(0, 20) + '...')
|
||||
return hash
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[craftMintSubmit] data:URL hash failed:', e)
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
let content = ''
|
||||
|
||||
// #ifdef MP-WEIXIN || MP-ALIPAY || MP-BAIDU || MP-TOUTIAO || MP-QQ
|
||||
console.log('[craftMintSubmit] computeHashFromPath in miniprogram')
|
||||
try {
|
||||
const fs = uni.getFileSystemManager()
|
||||
content = await new Promise((resolve, reject) => {
|
||||
fs.readFile({
|
||||
filePath: filePath,
|
||||
encoding: 'base64',
|
||||
success: (res) => {
|
||||
console.log('[craftMintSubmit] fs.readFile success, data length:', res.data?.length)
|
||||
resolve(res.data)
|
||||
},
|
||||
fail: (err) => {
|
||||
console.error('[craftMintSubmit] fs.readFile fail:', err)
|
||||
reject(err)
|
||||
}
|
||||
})
|
||||
})
|
||||
} catch (e) {
|
||||
console.error('[craftMintSubmit] 小程序读取文件失败:', e)
|
||||
}
|
||||
// #endif
|
||||
|
||||
// #ifdef APP-PLUS
|
||||
if (!content) {
|
||||
console.log('[craftMintSubmit] computeHashFromPath in app-plus')
|
||||
try {
|
||||
content = await new Promise((resolve, reject) => {
|
||||
plus.io.resolveLocalFileSystemURL(filePath, (entry) => {
|
||||
entry.file((file) => {
|
||||
const reader = new plus.io.FileReader()
|
||||
reader.onloadend = (e) => {
|
||||
console.log('[craftMintSubmit] FileReader onloadend, result length:', e.target.result?.length)
|
||||
resolve(e.target.result.split(',')[1] || '')
|
||||
}
|
||||
reader.onerror = (e) => {
|
||||
console.error('[craftMintSubmit] FileReader error:', e)
|
||||
reject(e)
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
}, (err) => {
|
||||
console.error('[craftMintSubmit] getFile error:', err)
|
||||
reject(err)
|
||||
})
|
||||
}, (err) => {
|
||||
console.error('[craftMintSubmit] resolveLocalFileSystemURL error:', err)
|
||||
reject(err)
|
||||
})
|
||||
})
|
||||
} catch (e) {
|
||||
console.error('[craftMintSubmit] App读取文件失败:', e)
|
||||
}
|
||||
}
|
||||
// #endif
|
||||
|
||||
console.log('[craftMintSubmit] computeHashFromPath content length:', content?.length || 0)
|
||||
console.log('[craftMintSubmit] computeHashFromPath crypto:', typeof crypto, crypto ? 'exists' : 'null', ', subtle:', crypto?.subtle ? 'exists' : 'null')
|
||||
if (content && typeof crypto !== 'undefined' && crypto && crypto.subtle) {
|
||||
try {
|
||||
// 将 base64 转换为 ArrayBuffer 并计算 hash
|
||||
const binary = atob(content)
|
||||
const bytes = new Uint8Array(binary.length)
|
||||
for (let i = 0; i < binary.length; i++) {
|
||||
bytes[i] = binary.charCodeAt(i)
|
||||
}
|
||||
const hashBuffer = await crypto.subtle.digest('SHA-256', bytes)
|
||||
const hashArray = Array.from(new Uint8Array(hashBuffer))
|
||||
const hash = hashArray.map(b => b.toString(16).padStart(2, '0')).join('')
|
||||
console.log('[craftMintSubmit] computeHashFromPath hash:', hash.substring(0, 20) + '...')
|
||||
return hash
|
||||
} catch (e) {
|
||||
console.error('[craftMintSubmit] hash计算失败:', e)
|
||||
}
|
||||
} else {
|
||||
// Fallback: 使用纯JS SHA256 实现 (App 环境 crypto.subtle 可能不可用)
|
||||
try {
|
||||
console.log('[craftMintSubmit] computeHashFromPath 使用纯JS SHA256 fallback, content length:', content.length)
|
||||
// content 是 base64 字符串,需要先解码为 binary string
|
||||
const binaryStr = atob(content)
|
||||
const hash = sha256Sync(binaryStr)
|
||||
console.log('[craftMintSubmit] computeHashFromPath sha256Sync hash:', hash.substring(0, 20) + '...')
|
||||
return hash
|
||||
} catch (e) {
|
||||
console.error('[craftMintSubmit] SHA256 fallback failed:', e)
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[craftMintSubmit] computeHashFromPath failed:', e)
|
||||
}
|
||||
console.log('[craftMintSubmit] computeHashFromPath return empty')
|
||||
return ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文件 MIME 类型
|
||||
*/
|
||||
@ -119,9 +355,17 @@ async function uploadImageAndRegisterMaterial(imagePath, ossData, originalName,
|
||||
// #ifdef H5
|
||||
if (imagePath.startsWith('data:')) {
|
||||
console.log('[craftMintSubmit] H5 base64 upload')
|
||||
const blob = await fetch(imagePath).then((r) => r.blob())
|
||||
// H5: data:URL 需要手动转换 为 Blob
|
||||
const base64Data = imagePath.split(',')[1] || ''
|
||||
const binaryStr = atob(base64Data)
|
||||
const bytes = new Uint8Array(binaryStr.length)
|
||||
for (let i = 0; i < binaryStr.length; i++) {
|
||||
bytes[i] = binaryStr.charCodeAt(i)
|
||||
}
|
||||
const blob = new Blob([bytes], { type: 'image/jpeg' })
|
||||
uploadFileSize = blob.size
|
||||
uploadHash = await computeFileHash(blob)
|
||||
console.log('[craftMintSubmit] H5 base64 hash computed:', uploadHash.substring(0, 20) + '...')
|
||||
const fd = new FormData()
|
||||
const fileName = `${Date.now()}.jpg`
|
||||
fd.append('key', ossData.dir + fileName)
|
||||
@ -139,6 +383,7 @@ async function uploadImageAndRegisterMaterial(imagePath, ossData, originalName,
|
||||
throw new Error('上传失败')
|
||||
}
|
||||
imageUrl = `${ossData.host}/${ossData.dir}${fileName}`
|
||||
console.log('[craftMintSubmit] H5 base64 upload complete, hash:', uploadHash.substring(0, 20) + '...')
|
||||
} else {
|
||||
console.log('[craftMintSubmit] 小程序/非H5上传')
|
||||
const uploadResult = await uploadFileToOssWithInfo(imagePath, ossData)
|
||||
@ -163,6 +408,7 @@ async function uploadImageAndRegisterMaterial(imagePath, ossData, originalName,
|
||||
const mimeType = getFileMimeType(imagePath)
|
||||
|
||||
// 4. 注册素材到后端
|
||||
console.log('[craftMintSubmit] uploadMaterialApi ossKey:', ossKey, 'mimeType:', mimeType, 'fileSize:', uploadFileSize)
|
||||
const materialRes = await uploadMaterialApi({
|
||||
oss_key: ossKey,
|
||||
original_name: originalName || `${materialType}.jpg`,
|
||||
@ -171,6 +417,7 @@ async function uploadImageAndRegisterMaterial(imagePath, ossData, originalName,
|
||||
hash: uploadHash,
|
||||
material_type: materialType,
|
||||
})
|
||||
console.log('[craftMintSubmit] uploadMaterialApi response:', materialRes)
|
||||
|
||||
if (!materialRes || materialRes.code !== 200 || !materialRes.data) {
|
||||
throw new Error(materialRes?.message || '素材注册失败')
|
||||
@ -287,7 +534,7 @@ export async function submitCraftMintFromPath({ imagePath, bgImagePath, formData
|
||||
|
||||
// 7. 构建 NFT 数据并跳转
|
||||
const nftData = {
|
||||
image: mainResult.ossKey, // oss_key
|
||||
image: `${ossData.host}/${mainResult.ossKey}`, // 完整OSS URL
|
||||
name: snap.name,
|
||||
description: snap.description || '',
|
||||
material_type: snap.material_type,
|
||||
@ -296,7 +543,11 @@ export async function submitCraftMintFromPath({ imagePath, bgImagePath, formData
|
||||
asset_id: assetId,
|
||||
info: snap.info,
|
||||
event: snap.info,
|
||||
...(bgMaterialId ? { bg_material_id: bgMaterialId } : {}),
|
||||
}
|
||||
// 如果是光栅卡,存储背景图路径供 success 页面展示
|
||||
if (isLenticular && bgImagePath) {
|
||||
nftData.bg_image = bgImagePath
|
||||
nftData.is_lenticular = true
|
||||
}
|
||||
uni.setStorageSync('temp_nft_data', JSON.stringify(nftData))
|
||||
uni.removeStorageSync('castlove_form_data')
|
||||
|
||||
Loading…
Reference in New Issue
Block a user