Initial commit
This commit is contained in:
7
.claude/settings.json
Normal file
7
.claude/settings.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Read(//c/Users/南音/.m2/repository/com/squareup/okhttp3/okhttp/**)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
66
DEVELOPMENT.md
Normal file
66
DEVELOPMENT.md
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
# 「见素」开发文档 (JianSu Naming Dev Doc)
|
||||||
|
|
||||||
|
## 1. 项目概述与核心理念
|
||||||
|
「见素」是一款专注于“留白”与“故事化”体验的起名应用。其核心理念在于摒弃繁杂,回归名字本身的诗意与文化底蕴。应用旨在通过极简的视觉设计、带有物理反馈的微交互,以及基于 AI 的深度叙事解读,为用户提供一种“审美溢价”的起名体验。
|
||||||
|
|
||||||
|
## 2. 视觉与 UI 架构
|
||||||
|
为了对齐“潮汐风格”,UI 架构需严格遵循 **0.618 黄金比例**的布局,强调极致的“留白”。
|
||||||
|
|
||||||
|
### 2.1 色彩系统
|
||||||
|
* **主背景**:`#FFFFFF`(纯白),营造空灵感。
|
||||||
|
* **文字**:`#2D2D2D`(带有温度的深灰,而非纯黑),减轻视觉疲劳,增加温润感。
|
||||||
|
* **辅助色**:`#E0E0E0`(用于极细的分割线),保持界面的通透。
|
||||||
|
|
||||||
|
### 2.2 字体栈 (Typography)
|
||||||
|
* **标题/名字展示**:优先调用 `Noto Serif SC` (思源宋体),展现汉字的古典韵味与雕刻感。
|
||||||
|
* **说明文字/正文**:使用 `PingFang SC` (苹方),保证移动端的清晰易读性。
|
||||||
|
|
||||||
|
## 3. 交互设计 (Interaction)
|
||||||
|
摒弃传统的简单 Swiper 效果,为滑动增加“摩擦力”和“阻尼感”,打造“见素”时刻。
|
||||||
|
|
||||||
|
### 3.1 卡片设计
|
||||||
|
* **正面 (Front)**:极致极简,只放置一个巨大的、居中的名字,下方配以一行极小的出处诗句。
|
||||||
|
* **反面 (Back)**:点击卡片翻转,展示名字背后的“叙事性解读”(通感描述)。
|
||||||
|
|
||||||
|
### 3.2 交互与微反馈
|
||||||
|
* **左滑 (Dislike/Skip)**:无感交互,卡片像烟雾一样逐渐变淡消失。
|
||||||
|
* **右滑 (Like/Save)**:触发轻微的 Haptic Feedback(触觉震动),名字化作一个墨点落入底部的“收藏”图标中。
|
||||||
|
|
||||||
|
## 4. 技术核心
|
||||||
|
结合 Gemini API 与声韵学算法,打造有灵魂的起名引擎。
|
||||||
|
|
||||||
|
### 4.1 AI 故事化 Prompt 策略
|
||||||
|
**目标**:拒绝生硬的 JSON 数据堆砌,让 AI 扮演一位“隐居的诗人”。
|
||||||
|
**System Prompt 示例**:
|
||||||
|
> “你是「见素」的灵魂导师。当用户输入期待时,请从《诗经》、《楚辞》或宋词中提取意象,生成3个名字。每个名字必须配有一段 50 字以内的‘通感’描述,包含气味、光线或声音的描写,拒绝说教。”
|
||||||
|
|
||||||
|
### 4.2 技术栈与核心逻辑 (Tech Stack)
|
||||||
|
* **后端**:Java SpringBoot,提供起名接口、声韵分析及 API 转发。
|
||||||
|
* **小程序端**:原生微信小程序 (Native Mini Program),利用 WXS 与动画库实现阻尼感滑动。
|
||||||
|
|
||||||
|
**声韵分析逻辑 (Algorithm)**:
|
||||||
|
在后端 Service 层实现姓名声韵评估:
|
||||||
|
1. **平仄匹配**:检测姓氏(平/仄)与名字(平/仄)的组合。
|
||||||
|
2. **优选模式**:推荐“平仄平”、“仄平仄”等有起伏感、抑扬顿挫的组合。
|
||||||
|
3. **叠音/开口度检查**:避免连续的闭口音(如 iao, iu, in 等连续出现),确保名字喊出来时是响亮、大气的。
|
||||||
|
|
||||||
|
## 5. 开发路线图 (MVP Roadmap)
|
||||||
|
从基础骨架到最终的美化,分为四个阶段进行:
|
||||||
|
|
||||||
|
* **Phase 1 (骨架)**:
|
||||||
|
* **后端**:搭建 SpringBoot 基础环境,配置跨域与 API 基础接口。
|
||||||
|
* **小程序**:使用原生小程序框架,实现带阻尼感的卡片滑动组件与翻转动效。
|
||||||
|
* **Phase 2 (大脑)**:后端集成 Gemini API,调试“故事化起名”的 Prompt,确保输出文本的 Vibe 符合“见素”的调性。
|
||||||
|
* **Phase 3 (细节)**:加入微音效(如类似翻书声或清脆的铃声)和后端的声韵评分逻辑算法。
|
||||||
|
* **Phase 4 (美化)**:优化字体渲染,增加 Canvas 名字海报生成功能。
|
||||||
|
|
||||||
|
## 6. 商业化预留 (暂不实现,仅做架构预留)
|
||||||
|
商业化路线拒绝弹窗广告,主打“审美溢价”。
|
||||||
|
|
||||||
|
### 6.1 名字壁纸生成 (高级感变现)
|
||||||
|
* 利用 Canvas 生成极简海报:居中的大字 + 用户的姓氏印章 + 独一无二的编号(如:见素第 8921 号灵感)。
|
||||||
|
* **变现点**:基础版免费,高清无水印版(可设置价格梯度如 ¥1.9 - ¥9.9)。
|
||||||
|
|
||||||
|
### 6.2 “见素”锦囊 (深度定制)
|
||||||
|
* 为难以刷到满意名字的用户提供“深度定制”入口。
|
||||||
|
* 调用更高级别的模型(如 Claude 3.5 Sonnet 或 Gemini Ultra)进行 1v1 生成,按次收费。
|
||||||
65
README.md
Normal file
65
README.md
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
1. 视觉底座:极致的“留白”
|
||||||
|
为了对齐“潮汐风格”,你的 UI 架构需要遵循 0.618 黄金比例 的布局。
|
||||||
|
|
||||||
|
色彩系统: * 主背景:#FFFFFF(纯白)。
|
||||||
|
|
||||||
|
文字:#2D2D2D(带有温度的深灰,而非纯黑)。
|
||||||
|
|
||||||
|
辅助色:#E0E0E0(极细的分割线)。
|
||||||
|
|
||||||
|
字体栈: * 标题/名字展示:优先调用 Noto Serif SC (思源宋体),展现古典韵味。
|
||||||
|
|
||||||
|
说明文字:使用 PingFang SC,保证移动端的易读性。
|
||||||
|
|
||||||
|
2. 交互灵魂:卡片滑动与“见素”时刻
|
||||||
|
不要简单的 Swiper,要给滑动增加“摩擦力”和“阻尼感”。
|
||||||
|
|
||||||
|
卡片设计:
|
||||||
|
|
||||||
|
正面:只放一个巨大的、居中的名字,下方一行极小的出处诗句。
|
||||||
|
|
||||||
|
反面(点击翻转):展示**“叙事性解读”**。
|
||||||
|
|
||||||
|
反馈微交互:
|
||||||
|
|
||||||
|
左滑(无感): 卡片像烟雾一样变淡消失。
|
||||||
|
|
||||||
|
右滑(收藏): 触发轻微的 Haptic Feedback(震动),名字化作一个墨点落入底部的“收藏”图标中。
|
||||||
|
|
||||||
|
3. 技术核心:AI 故事化与声韵算法
|
||||||
|
既然你有 Gemini API 的开发经验,这里可以做得很硬核:
|
||||||
|
|
||||||
|
AI 故事化 Prompt 策略
|
||||||
|
不要让 AI 只返回 JSON,要让它扮演一个“隐居的诗人”。
|
||||||
|
|
||||||
|
System Prompt 示例:
|
||||||
|
“你是「见素」的灵魂导师。当用户输入期待时,请从《诗经》、《楚辞》或宋词中提取意象,生成3个名字。每个名字必须配有一段 50 字以内的‘通感’描述,包含气味、光线或声音的描写,拒绝说教。”
|
||||||
|
|
||||||
|
声韵分析逻辑 (Algorithm)
|
||||||
|
你可以写一个简单的 Python 或 Node.js 脚本来处理:
|
||||||
|
|
||||||
|
平仄匹配: 检测姓氏(平/仄)与名字(平/仄)的组合。
|
||||||
|
|
||||||
|
优选模式: 平仄平、仄平仄(有起伏感)。
|
||||||
|
|
||||||
|
叠音/开口度检查: 避免连续的闭口音,确保名字喊出来时是响亮的。
|
||||||
|
|
||||||
|
4. 商业化与衍生:高级感变现(先不实现)
|
||||||
|
拒绝弹窗广告,我们走**“审美溢价”**路线。
|
||||||
|
|
||||||
|
名字壁纸生成: * 利用 Canvas 生成一张极简海报:居中的大字 + 用户的姓氏印章 + 独一无二的编号(如:见素第 8921 号灵感)。
|
||||||
|
|
||||||
|
变现点: 基础版免费,高清无水印版 ¥1.9 - ¥9.9。
|
||||||
|
|
||||||
|
“见素”锦囊:
|
||||||
|
|
||||||
|
如果用户一直刷不到满意的,提供一个“深度定制”入口,调用更高级别的模型(如 Claude 3.5 Sonnet 或 Gemini Ultra)进行 1v1 生成,按次收费。
|
||||||
|
|
||||||
|
5. 开发者视角的 MVP 路径 (Roadmap)
|
||||||
|
Phase 1 (骨架): 搭建基础的 Next.js 或 Uni-app 框架,实现卡片滑动组件。
|
||||||
|
|
||||||
|
Phase 2 (大脑): 接入 Gemini API,调试“故事化起名”的提示词,确保输出的 Vibe 符合“见素”。
|
||||||
|
|
||||||
|
Phase 3 (细节): 加入音效(类似翻书声或清脆的铃声)和声韵评分逻辑。
|
||||||
|
|
||||||
|
Phase 4 (美化): 优化字体渲染,增加 Canvas 海报生成功能。
|
||||||
8
backend/.idea/.gitignore
generated
vendored
Normal file
8
backend/.idea/.gitignore
generated
vendored
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
# Default ignored files
|
||||||
|
/shelf/
|
||||||
|
/workspace.xml
|
||||||
|
# Datasource local storage ignored files
|
||||||
|
/dataSources/
|
||||||
|
/dataSources.local.xml
|
||||||
|
# Editor-based HTTP Client requests
|
||||||
|
/httpRequests/
|
||||||
19
backend/.idea/compiler.xml
generated
Normal file
19
backend/.idea/compiler.xml
generated
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="CompilerConfiguration">
|
||||||
|
<annotationProcessing>
|
||||||
|
<profile default="true" name="Default" enabled="true" />
|
||||||
|
<profile name="Maven default annotation processors profile" enabled="true">
|
||||||
|
<sourceOutputDir name="target/generated-sources/annotations" />
|
||||||
|
<sourceTestOutputDir name="target/generated-test-sources/test-annotations" />
|
||||||
|
<outputRelativeToContentRoot value="true" />
|
||||||
|
<module name="naming" />
|
||||||
|
</profile>
|
||||||
|
</annotationProcessing>
|
||||||
|
</component>
|
||||||
|
<component name="JavacSettings">
|
||||||
|
<option name="ADDITIONAL_OPTIONS_OVERRIDE">
|
||||||
|
<module name="naming" options="-parameters" />
|
||||||
|
</option>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
6
backend/.idea/encodings.xml
generated
Normal file
6
backend/.idea/encodings.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="Encoding">
|
||||||
|
<file url="file://$PROJECT_DIR$/src/main/java" charset="UTF-8" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
20
backend/.idea/jarRepositories.xml
generated
Normal file
20
backend/.idea/jarRepositories.xml
generated
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="RemoteRepositoriesConfiguration">
|
||||||
|
<remote-repository>
|
||||||
|
<option name="id" value="central" />
|
||||||
|
<option name="name" value="Maven Central repository" />
|
||||||
|
<option name="url" value="https://repo1.maven.org/maven2" />
|
||||||
|
</remote-repository>
|
||||||
|
<remote-repository>
|
||||||
|
<option name="id" value="jboss.community" />
|
||||||
|
<option name="name" value="JBoss Community repository" />
|
||||||
|
<option name="url" value="https://repository.jboss.org/nexus/content/repositories/public/" />
|
||||||
|
</remote-repository>
|
||||||
|
<remote-repository>
|
||||||
|
<option name="id" value="central" />
|
||||||
|
<option name="name" value="Central Repository" />
|
||||||
|
<option name="url" value="https://maven.aliyun.com/repository/central" />
|
||||||
|
</remote-repository>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
12
backend/.idea/misc.xml
generated
Normal file
12
backend/.idea/misc.xml
generated
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="ExternalStorageConfigurationManager" enabled="true" />
|
||||||
|
<component name="MavenProjectsManager">
|
||||||
|
<option name="originalFiles">
|
||||||
|
<list>
|
||||||
|
<option value="$PROJECT_DIR$/pom.xml" />
|
||||||
|
</list>
|
||||||
|
</option>
|
||||||
|
</component>
|
||||||
|
<component name="ProjectRootManager" version="2" languageLevel="JDK_1_8" default="true" project-jdk-name="1.8" project-jdk-type="JavaSDK" />
|
||||||
|
</project>
|
||||||
10
backend/.idea/runConfigurations.xml
generated
Normal file
10
backend/.idea/runConfigurations.xml
generated
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="RunConfigurationProducerService">
|
||||||
|
<option name="ignoredProducers">
|
||||||
|
<set>
|
||||||
|
<option value="com.android.tools.idea.compose.preview.runconfiguration.ComposePreviewRunConfigurationProducer" />
|
||||||
|
</set>
|
||||||
|
</option>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
67
backend/pom.xml
Normal file
67
backend/pom.xml
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||||
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
<parent>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-parent</artifactId>
|
||||||
|
<version>2.7.18</version>
|
||||||
|
<relativePath/>
|
||||||
|
</parent>
|
||||||
|
<groupId>com.jiansu</groupId>
|
||||||
|
<artifactId>naming</artifactId>
|
||||||
|
<version>0.0.1-SNAPSHOT</version>
|
||||||
|
<name>jiansu-naming</name>
|
||||||
|
<description>JianSu Naming AI Backend</description>
|
||||||
|
<properties>
|
||||||
|
<java.version>1.8</java.version>
|
||||||
|
</properties>
|
||||||
|
<dependencies>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-web</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.projectlombok</groupId>
|
||||||
|
<artifactId>lombok</artifactId>
|
||||||
|
<optional>true</optional>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.alibaba</groupId>
|
||||||
|
<artifactId>fastjson</artifactId>
|
||||||
|
<version>1.2.83</version>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.squareup.okhttp3</groupId>
|
||||||
|
<artifactId>okhttp</artifactId>
|
||||||
|
<version>4.12.0</version>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.belerweb</groupId>
|
||||||
|
<artifactId>pinyin4j</artifactId>
|
||||||
|
<version>2.5.1</version>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-test</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
|
||||||
|
<build>
|
||||||
|
<plugins>
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||||
|
<configuration>
|
||||||
|
<excludes>
|
||||||
|
<exclude>
|
||||||
|
<groupId>org.projectlombok</groupId>
|
||||||
|
<artifactId>lombok</artifactId>
|
||||||
|
</exclude>
|
||||||
|
</excludes>
|
||||||
|
</configuration>
|
||||||
|
</plugin>
|
||||||
|
</plugins>
|
||||||
|
</build>
|
||||||
|
</project>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package com.jiansu.naming;
|
||||||
|
|
||||||
|
import org.springframework.boot.SpringApplication;
|
||||||
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
|
|
||||||
|
@SpringBootApplication
|
||||||
|
public class NamingApplication {
|
||||||
|
public static void main(String[] args) {
|
||||||
|
SpringApplication.run(NamingApplication.class, args);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package com.jiansu.naming.controller;
|
||||||
|
|
||||||
|
import com.jiansu.naming.model.NameCard;
|
||||||
|
import com.jiansu.naming.service.MiniMaxService;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/names")
|
||||||
|
@CrossOrigin(origins = "*")
|
||||||
|
public class NamingController {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private MiniMaxService miniMaxService;
|
||||||
|
|
||||||
|
@GetMapping("/generate")
|
||||||
|
public List<NameCard> generate(@RequestParam(defaultValue = "清冷") String keyword) {
|
||||||
|
return miniMaxService.generateNames(keyword);
|
||||||
|
}
|
||||||
|
}
|
||||||
18
backend/src/main/java/com/jiansu/naming/model/NameCard.java
Normal file
18
backend/src/main/java/com/jiansu/naming/model/NameCard.java
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
package com.jiansu.naming.model;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class NameCard {
|
||||||
|
private String name; // 名字
|
||||||
|
private String origin; // 出处/诗句
|
||||||
|
private String description; // “通感”叙事描述
|
||||||
|
private Double score; // 声韵评分(后端计算)
|
||||||
|
private String tone; // 平仄分析 (如:平仄)
|
||||||
|
}
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
package com.jiansu.naming.service;
|
||||||
|
|
||||||
|
import com.alibaba.fastjson.JSON;
|
||||||
|
import com.alibaba.fastjson.JSONArray;
|
||||||
|
import com.alibaba.fastjson.JSONObject;
|
||||||
|
import com.jiansu.naming.model.NameCard;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import okhttp3.*;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
public class MiniMaxService {
|
||||||
|
|
||||||
|
@Value("${minimax.api-key}")
|
||||||
|
private String apiKey;
|
||||||
|
|
||||||
|
@Value("${minimax.api-url}")
|
||||||
|
private String apiUrl;
|
||||||
|
|
||||||
|
private final ToneAnalysisService toneAnalysisService;
|
||||||
|
|
||||||
|
public MiniMaxService(ToneAnalysisService toneAnalysisService) {
|
||||||
|
this.toneAnalysisService = toneAnalysisService;
|
||||||
|
}
|
||||||
|
|
||||||
|
private final OkHttpClient client = new OkHttpClient.Builder()
|
||||||
|
.connectTimeout(30, TimeUnit.SECONDS)
|
||||||
|
.writeTimeout(30, TimeUnit.SECONDS)
|
||||||
|
.readTimeout(60, TimeUnit.SECONDS)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
private static final String SYSTEM_PROMPT =
|
||||||
|
"你是「见素」的灵魂导师。当用户输入期待或关键词时,请从《诗经》、《楚辞》或宋词中提取意象,生成3个极具美感的名字。" +
|
||||||
|
"每个名字必须配有一段 50 字以内的'通感'描述,包含气味、光线或声音的描写,拒绝说教。" +
|
||||||
|
"请直接以 JSON 数组格式返回,JSON 对象包含三个字段:name (名字), origin (诗词出处), description (通感描述)。不要包含任何 Markdown 格式。";
|
||||||
|
|
||||||
|
public List<NameCard> generateNames(String keyword) {
|
||||||
|
// 后门:当关键词为 "test" 时,返回固定的测试数据
|
||||||
|
if ("test".equalsIgnoreCase(keyword)) {
|
||||||
|
log.info("Keyword is 'test', returning hardcoded test data.");
|
||||||
|
return createTestData();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 构建 MiniMax Token Plan (OpenAI 兼容) 请求体
|
||||||
|
JSONObject jsonBody = new JSONObject();
|
||||||
|
jsonBody.put("model", "MiniMax-M2.7");
|
||||||
|
|
||||||
|
JSONArray messages = new JSONArray();
|
||||||
|
|
||||||
|
JSONObject systemMsg = new JSONObject();
|
||||||
|
systemMsg.put("role", "system");
|
||||||
|
systemMsg.put("content", SYSTEM_PROMPT);
|
||||||
|
messages.add(systemMsg);
|
||||||
|
|
||||||
|
JSONObject userMsg = new JSONObject();
|
||||||
|
userMsg.put("role", "user");
|
||||||
|
userMsg.put("content", "用户期待/关键词是:" + keyword);
|
||||||
|
messages.add(userMsg);
|
||||||
|
|
||||||
|
jsonBody.put("messages", messages);
|
||||||
|
|
||||||
|
RequestBody body = RequestBody.create(
|
||||||
|
jsonBody.toJSONString(),
|
||||||
|
MediaType.parse("application/json; charset=utf-8")
|
||||||
|
);
|
||||||
|
|
||||||
|
Request request = new Request.Builder()
|
||||||
|
.url(apiUrl)
|
||||||
|
.post(body)
|
||||||
|
.addHeader("Authorization", "Bearer " + apiKey)
|
||||||
|
.addHeader("Content-Type", "application/json")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
try (Response response = client.newCall(request).execute()) {
|
||||||
|
if (response.isSuccessful() && response.body() != null) {
|
||||||
|
String responseBody = response.body().string();
|
||||||
|
log.info("MiniMax raw response: {}", responseBody);
|
||||||
|
return parseMiniMaxResponse(responseBody);
|
||||||
|
} else {
|
||||||
|
String errorMsg = response.body() != null ? response.body().string() : response.message();
|
||||||
|
log.error("MiniMax API Error: {} - {}", response.code(), errorMsg);
|
||||||
|
throw new RuntimeException("MiniMax API 调用失败: " + errorMsg);
|
||||||
|
}
|
||||||
|
} catch (IOException e) {
|
||||||
|
log.error("API Error: ", e);
|
||||||
|
throw new RuntimeException("网络请求失败");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<NameCard> parseMiniMaxResponse(String responseBody) {
|
||||||
|
List<NameCard> nameCards = new ArrayList<>();
|
||||||
|
try {
|
||||||
|
JSONObject root = JSON.parseObject(responseBody);
|
||||||
|
|
||||||
|
// 检查 API 错误
|
||||||
|
JSONObject baseResp = root.getJSONObject("base_resp");
|
||||||
|
if (baseResp != null) {
|
||||||
|
int statusCode = baseResp.getIntValue("status_code");
|
||||||
|
if (statusCode != 0) {
|
||||||
|
String statusMsg = baseResp.getString("status_msg");
|
||||||
|
log.error("MiniMax API error: {} - {}", statusCode, statusMsg);
|
||||||
|
throw new RuntimeException("MiniMax API 错误: " + statusMsg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
JSONArray choices = root.getJSONArray("choices");
|
||||||
|
if (choices == null || choices.isEmpty()) {
|
||||||
|
throw new RuntimeException("MiniMax 返回内容为空");
|
||||||
|
}
|
||||||
|
|
||||||
|
JSONObject firstChoice = choices.getJSONObject(0);
|
||||||
|
JSONObject message = firstChoice.getJSONObject("message");
|
||||||
|
String aiText = message.getString("content");
|
||||||
|
|
||||||
|
if (aiText == null || aiText.isEmpty()) {
|
||||||
|
throw new RuntimeException("MiniMax 返回内容为空");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清理可能存在的 Markdown 代码块标记
|
||||||
|
aiText = aiText.replaceAll("```json", "").replaceAll("```", "").trim();
|
||||||
|
|
||||||
|
JSONArray jsonArray = JSON.parseArray(aiText);
|
||||||
|
for (int i = 0; i < jsonArray.size(); i++) {
|
||||||
|
JSONObject obj = jsonArray.getJSONObject(i);
|
||||||
|
String name = obj.getString("name");
|
||||||
|
|
||||||
|
// 进行声韵分析
|
||||||
|
ToneAnalysisService.ToneResult tr = toneAnalysisService.analyze(name);
|
||||||
|
|
||||||
|
NameCard card = NameCard.builder()
|
||||||
|
.name(name)
|
||||||
|
.origin(obj.getString("origin"))
|
||||||
|
.description(obj.getString("description"))
|
||||||
|
.score(tr.score)
|
||||||
|
.tone(tr.tonePattern)
|
||||||
|
.build();
|
||||||
|
nameCards.add(card);
|
||||||
|
}
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
throw e;
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Parse Error: ", e);
|
||||||
|
throw new RuntimeException("数据解析失败");
|
||||||
|
}
|
||||||
|
return nameCards;
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<NameCard> createTestData() {
|
||||||
|
List<NameCard> testData = new ArrayList<>();
|
||||||
|
|
||||||
|
testData.add(NameCard.builder()
|
||||||
|
.name("月白")
|
||||||
|
.origin("《月出》:月出皎兮,佼人僚兮。")
|
||||||
|
.description("像月光洒在雪地,清冷又干净,带着一丝不易察觉的、风吹过竹林的微响。")
|
||||||
|
.score(9.8)
|
||||||
|
.tone("仄仄")
|
||||||
|
.build());
|
||||||
|
|
||||||
|
testData.add(NameCard.builder()
|
||||||
|
.name("南絮")
|
||||||
|
.origin("晏几道《御街行》:街南绿树春饶絮。")
|
||||||
|
.description("春天街巷里柔软的柳絮,随风飘浮,带着南方阳光的暖意和青草的气息。")
|
||||||
|
.score(9.5)
|
||||||
|
.tone("平仄")
|
||||||
|
.build());
|
||||||
|
|
||||||
|
testData.add(NameCard.builder()
|
||||||
|
.name("疏影")
|
||||||
|
.origin("林逋《山园小梅》:疏影横斜水清浅,暗香浮动月黄昏。")
|
||||||
|
.description("黄昏月下,梅枝稀疏的影子落在清浅的水面,空气中浮动着若有似无的冷香。")
|
||||||
|
.score(9.7)
|
||||||
|
.tone("平仄")
|
||||||
|
.build());
|
||||||
|
|
||||||
|
return testData;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
package com.jiansu.naming.service;
|
||||||
|
|
||||||
|
import net.sourceforge.pinyin4j.PinyinHelper;
|
||||||
|
import net.sourceforge.pinyin4j.format.HanyuPinyinOutputFormat;
|
||||||
|
import net.sourceforge.pinyin4j.format.HanyuPinyinToneType;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 见素声韵分析逻辑
|
||||||
|
* 1. 平仄匹配:1,2声为平,3,4声为仄
|
||||||
|
* 2. 优选模式:平仄平、仄平仄、平平仄、仄仄平
|
||||||
|
* 3. 连读检查:避免连续闭口音
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class ToneAnalysisService {
|
||||||
|
|
||||||
|
private final HanyuPinyinOutputFormat format;
|
||||||
|
|
||||||
|
public ToneAnalysisService() {
|
||||||
|
format = new HanyuPinyinOutputFormat();
|
||||||
|
format.setToneType(HanyuPinyinToneType.WITH_TONE_NUMBER);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class ToneResult {
|
||||||
|
public double score;
|
||||||
|
public String tonePattern;
|
||||||
|
public String msg;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ToneResult analyze(String name) {
|
||||||
|
ToneResult result = new ToneResult();
|
||||||
|
if (name == null || name.isEmpty()) {
|
||||||
|
result.score = 0;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Integer> tones = new ArrayList<>();
|
||||||
|
List<String> pinyins = new ArrayList<>();
|
||||||
|
|
||||||
|
for (char c : name.toCharArray()) {
|
||||||
|
try {
|
||||||
|
String[] pinyinArray = PinyinHelper.toHanyuPinyinStringArray(c, format);
|
||||||
|
if (pinyinArray != null && pinyinArray.length > 0) {
|
||||||
|
String p = pinyinArray[0];
|
||||||
|
pinyins.add(p);
|
||||||
|
// 取最后一位数字作为声调
|
||||||
|
int tone = Character.getNumericValue(p.charAt(p.length() - 1));
|
||||||
|
tones.add(tone);
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
// 忽略无法识别的字符
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 构建平仄模式
|
||||||
|
StringBuilder pattern = new StringBuilder();
|
||||||
|
for (int tone : tones) {
|
||||||
|
if (tone == 1 || tone == 2) pattern.append("平");
|
||||||
|
else if (tone == 3 || tone == 4) pattern.append("仄");
|
||||||
|
}
|
||||||
|
result.tonePattern = pattern.toString();
|
||||||
|
|
||||||
|
// 基础分 8.0
|
||||||
|
double score = 8.0;
|
||||||
|
|
||||||
|
// 1. 平仄起伏检查
|
||||||
|
if (pattern.length() >= 2) {
|
||||||
|
boolean hasChange = false;
|
||||||
|
for (int i = 0; i < pattern.length() - 1; i++) {
|
||||||
|
if (pattern.charAt(i) != pattern.charAt(i + 1)) {
|
||||||
|
hasChange = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (hasChange) score += 1.0; // 有起伏感 +1
|
||||||
|
else score -= 0.5; // 全平或全仄 -0.5
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 优选模式加分
|
||||||
|
String pStr = pattern.toString();
|
||||||
|
if (pStr.equals("平仄平") || pStr.equals("仄平仄") || pStr.equals("平平仄") || pStr.equals("仄仄平")) {
|
||||||
|
score += 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 开口度简单检查 (韵母 a, e, o 结尾通常更响亮)
|
||||||
|
int openCount = 0;
|
||||||
|
for (String p : pinyins) {
|
||||||
|
String yumu = p.substring(0, p.length() - 1);
|
||||||
|
if (yumu.endsWith("a") || yumu.endsWith("e") || yumu.endsWith("o") || yumu.endsWith("ang") || yumu.endsWith("ong")) {
|
||||||
|
openCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (openCount >= 1) score += 0.3;
|
||||||
|
|
||||||
|
// 限制最高分 9.9
|
||||||
|
result.score = Math.min(score, 9.9);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
11
backend/src/main/resources/application.yml
Normal file
11
backend/src/main/resources/application.yml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
server:
|
||||||
|
port: 8080
|
||||||
|
|
||||||
|
spring:
|
||||||
|
application:
|
||||||
|
name: jiansu-naming
|
||||||
|
|
||||||
|
# MiniMax API 配置
|
||||||
|
minimax:
|
||||||
|
api-key: sk-cp-n0eCZgH5s-NpduAVPo8rpWM9eUBsMOBnIroISIaH6y8eFIpT0VSrCMttzE4bVDbQ-loiMR1b8ZpIsgotQ_yqQRk8_fcUxKHsbhtLfN70oCVaV6-94ZC9Wjk
|
||||||
|
api-url: https://api.minimax.chat/v1/text/chatcompletion_v2
|
||||||
11
backend/target/classes/application.yml
Normal file
11
backend/target/classes/application.yml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
server:
|
||||||
|
port: 8080
|
||||||
|
|
||||||
|
spring:
|
||||||
|
application:
|
||||||
|
name: jiansu-naming
|
||||||
|
|
||||||
|
# MiniMax API 配置
|
||||||
|
minimax:
|
||||||
|
api-key: sk-cp-n0eCZgH5s-NpduAVPo8rpWM9eUBsMOBnIroISIaH6y8eFIpT0VSrCMttzE4bVDbQ-loiMR1b8ZpIsgotQ_yqQRk8_fcUxKHsbhtLfN70oCVaV6-94ZC9Wjk
|
||||||
|
api-url: https://api.minimax.chat/v1/text/chatcompletion_v2
|
||||||
BIN
backend/target/classes/com/jiansu/naming/NamingApplication.class
Normal file
BIN
backend/target/classes/com/jiansu/naming/NamingApplication.class
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
backend/target/classes/com/jiansu/naming/model/NameCard.class
Normal file
BIN
backend/target/classes/com/jiansu/naming/model/NameCard.class
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,5 @@
|
|||||||
|
com\jiansu\naming\model\NameCard$NameCardBuilder.class
|
||||||
|
com\jiansu\naming\model\NameCard.class
|
||||||
|
com\jiansu\naming\controller\NamingController.class
|
||||||
|
com\jiansu\naming\service\MiniMaxService.class
|
||||||
|
com\jiansu\naming\NamingApplication.class
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
C:\Users\<5C><><EFBFBD><EFBFBD>\Desktop\JianSu-Naming\backend\src\main\java\com\jiansu\naming\controller\NamingController.java
|
||||||
|
C:\Users\<5C><><EFBFBD><EFBFBD>\Desktop\JianSu-Naming\backend\src\main\java\com\jiansu\naming\model\NameCard.java
|
||||||
|
C:\Users\<5C><><EFBFBD><EFBFBD>\Desktop\JianSu-Naming\backend\src\main\java\com\jiansu\naming\service\GeminiService.java
|
||||||
|
C:\Users\<5C><><EFBFBD><EFBFBD>\Desktop\JianSu-Naming\backend\src\main\java\com\jiansu\naming\NamingApplication.java
|
||||||
26
miniprogram/app.js
Normal file
26
miniprogram/app.js
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
App({
|
||||||
|
onLaunch() {
|
||||||
|
// 初始化音效上下文
|
||||||
|
this.globalData.audioContexts = {
|
||||||
|
flip: wx.createInnerAudioContext(),
|
||||||
|
success: wx.createInnerAudioContext()
|
||||||
|
};
|
||||||
|
|
||||||
|
// 预设音效资源 (用户可替换为本地或云端 URL)
|
||||||
|
// 示例使用了一些开源的基础音效,仅作占位参考
|
||||||
|
this.globalData.audioContexts.flip.src = 'https://assets.mixkit.co/active_storage/sfx/2571/2571-preview.mp3'; // 类似翻页声
|
||||||
|
this.globalData.audioContexts.success.src = 'https://assets.mixkit.co/active_storage/sfx/2000/2000-preview.mp3'; // 类似轻微铃声
|
||||||
|
},
|
||||||
|
|
||||||
|
playAudio(type) {
|
||||||
|
const ctx = this.globalData.audioContexts[type];
|
||||||
|
if (ctx) {
|
||||||
|
ctx.stop();
|
||||||
|
ctx.play();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
globalData: {
|
||||||
|
audioContexts: {}
|
||||||
|
}
|
||||||
|
});
|
||||||
15
miniprogram/app.json
Normal file
15
miniprogram/app.json
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"pages": [
|
||||||
|
"pages/home/home",
|
||||||
|
"pages/index/index"
|
||||||
|
],
|
||||||
|
"window": {
|
||||||
|
"backgroundColor": "#FFFFFF",
|
||||||
|
"backgroundTextStyle": "light",
|
||||||
|
"navigationBarBackgroundColor": "#FFFFFF",
|
||||||
|
"navigationBarTitleText": "见素",
|
||||||
|
"navigationBarTextStyle": "black"
|
||||||
|
},
|
||||||
|
"sitemapLocation": "sitemap.json",
|
||||||
|
"lazyCodeLoading": "requiredComponents"
|
||||||
|
}
|
||||||
25
miniprogram/pages/home/home.js
Normal file
25
miniprogram/pages/home/home.js
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
Page({
|
||||||
|
data: {
|
||||||
|
keyword: ''
|
||||||
|
},
|
||||||
|
|
||||||
|
onInput(e) {
|
||||||
|
this.setData({
|
||||||
|
keyword: e.detail.value
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
startNaming() {
|
||||||
|
if (!this.data.keyword.trim()) {
|
||||||
|
wx.showToast({
|
||||||
|
title: '请输入一抹期待',
|
||||||
|
icon: 'none'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
wx.navigateTo({
|
||||||
|
url: `/pages/index/index?keyword=${encodeURIComponent(this.data.keyword)}`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
3
miniprogram/pages/home/home.json
Normal file
3
miniprogram/pages/home/home.json
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"usingComponents": {}
|
||||||
|
}
|
||||||
25
miniprogram/pages/home/home.wxml
Normal file
25
miniprogram/pages/home/home.wxml
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
<view class="container">
|
||||||
|
<view class="content">
|
||||||
|
<view class="title">见素</view>
|
||||||
|
<view class="subtitle">回归名字的诗意与留白</view>
|
||||||
|
|
||||||
|
<view class="input-section">
|
||||||
|
<input
|
||||||
|
class="keyword-input"
|
||||||
|
placeholder="输入关键词,如:自由、清冷"
|
||||||
|
placeholder-class="placeholder"
|
||||||
|
bindinput="onInput"
|
||||||
|
value="{{keyword}}"
|
||||||
|
/>
|
||||||
|
<view class="line"></view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="action-section">
|
||||||
|
<button class="generate-btn" bindtap="startNaming">感悟名字</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="footer">
|
||||||
|
<text>© 见素 · 审美溢价</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
123
miniprogram/pages/home/home.wxss
Normal file
123
miniprogram/pages/home/home.wxss
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
page {
|
||||||
|
background-color: #FFFFFF;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 0 80rpx;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
margin-top: 35vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
animation: contentFadeIn 1.5s cubic-bezier(0.19, 1, 0.22, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-family: "Noto Serif SC", serif;
|
||||||
|
font-size: 80rpx;
|
||||||
|
color: #2D2D2D;
|
||||||
|
letter-spacing: 24rpx;
|
||||||
|
font-weight: 300;
|
||||||
|
margin-bottom: 20rpx;
|
||||||
|
padding-left: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: #D0D0D0;
|
||||||
|
letter-spacing: 6rpx;
|
||||||
|
margin-bottom: 150rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-section {
|
||||||
|
width: 100%;
|
||||||
|
margin-bottom: 80rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.keyword-input {
|
||||||
|
width: 100%;
|
||||||
|
height: 80rpx;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 30rpx;
|
||||||
|
color: #4A4A4A;
|
||||||
|
letter-spacing: 2rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.placeholder {
|
||||||
|
color: #E0E0E0;
|
||||||
|
font-weight: 200;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line {
|
||||||
|
width: 40rpx;
|
||||||
|
height: 1rpx;
|
||||||
|
background-color: #F0F0F0;
|
||||||
|
margin: 10rpx auto 0;
|
||||||
|
transition: width 0.6s cubic-bezier(0.165, 0.84, 0.44, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-section:focus-within .line {
|
||||||
|
width: 80rpx;
|
||||||
|
background-color: #2D2D2D;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-section {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
margin-top: 40rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.generate-btn {
|
||||||
|
background: none !important;
|
||||||
|
color: #A0A0A0 !important;
|
||||||
|
font-size: 24rpx !important;
|
||||||
|
font-weight: 200 !important;
|
||||||
|
padding: 20rpx 60rpx !important;
|
||||||
|
letter-spacing: 12rpx !important;
|
||||||
|
text-indent: 12rpx !important;
|
||||||
|
transition: all 0.3s;
|
||||||
|
border: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.generate-btn::after {
|
||||||
|
border: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.generate-btn:active {
|
||||||
|
color: #2D2D2D !important;
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 80rpx;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
text-align: center;
|
||||||
|
animation: footerFadeIn 3s ease-in;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer text {
|
||||||
|
font-size: 16rpx;
|
||||||
|
color: #F0F0F0;
|
||||||
|
letter-spacing: 4rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes contentFadeIn {
|
||||||
|
from { opacity: 0; transform: translateY(20rpx); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes footerFadeIn {
|
||||||
|
0% { opacity: 0; }
|
||||||
|
70% { opacity: 0; }
|
||||||
|
100% { opacity: 1; }
|
||||||
|
}
|
||||||
242
miniprogram/pages/index/index.js
Normal file
242
miniprogram/pages/index/index.js
Normal file
@@ -0,0 +1,242 @@
|
|||||||
|
const EXIT_THRESHOLD = 80;
|
||||||
|
const FAST_SWIPE_VELOCITY = 0.5;
|
||||||
|
|
||||||
|
Page({
|
||||||
|
isDragging: false,
|
||||||
|
isExiting: false,
|
||||||
|
startTime: 0,
|
||||||
|
startX: 0,
|
||||||
|
startY: 0,
|
||||||
|
lastX: 0,
|
||||||
|
|
||||||
|
data: {
|
||||||
|
nameList: [],
|
||||||
|
currentIndex: 0,
|
||||||
|
isLoading: true,
|
||||||
|
isFlipped: false,
|
||||||
|
keyword: '清冷',
|
||||||
|
|
||||||
|
// 动画控制
|
||||||
|
translateX: 0,
|
||||||
|
rotate: 0,
|
||||||
|
opacity: 1,
|
||||||
|
transition: 'none',
|
||||||
|
|
||||||
|
cardKey: 0,
|
||||||
|
collectedNames: [],
|
||||||
|
showCollection: false
|
||||||
|
},
|
||||||
|
|
||||||
|
onLoad(options) {
|
||||||
|
const keyword = options.keyword || this.data.keyword;
|
||||||
|
this.setData({ keyword });
|
||||||
|
this.fetchNames(keyword);
|
||||||
|
},
|
||||||
|
|
||||||
|
fetchNames(keyword) {
|
||||||
|
this.setData({ isLoading: true });
|
||||||
|
wx.showLoading({ title: '见素正在感悟...', mask: true });
|
||||||
|
wx.request({
|
||||||
|
url: 'http://localhost:8080/api/names/generate',
|
||||||
|
data: { keyword },
|
||||||
|
success: (res) => {
|
||||||
|
if (res.statusCode === 200 && res.data && res.data.length > 0) {
|
||||||
|
this.setData({ nameList: res.data, currentIndex: 0, isLoading: false, cardKey: this.data.cardKey + 1 });
|
||||||
|
} else {
|
||||||
|
wx.showToast({ title: '意境未达,请重试', icon: 'none' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
fail: () => wx.showToast({ title: '网络疏离,请检查后端', icon: 'none' }),
|
||||||
|
complete: () => { this.setData({ isLoading: false }); wx.hideLoading(); }
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
onFlip() {
|
||||||
|
// 守卫:如果这是一次拖拽,isDragging 会为 true,则不执行翻转
|
||||||
|
if (this.isDragging || this.isExiting) return;
|
||||||
|
|
||||||
|
getApp().playAudio('flip');
|
||||||
|
this.setData({ isFlipped: !this.data.isFlipped });
|
||||||
|
},
|
||||||
|
|
||||||
|
onTouchStart(e) {
|
||||||
|
if (this.isExiting) return;
|
||||||
|
|
||||||
|
this.isDragging = false; // 每次开始触摸时,都假定为点击,而非拖拽
|
||||||
|
this.startX = e.touches[0].clientX;
|
||||||
|
this.lastX = this.startX;
|
||||||
|
this.startY = e.touches[0].clientY;
|
||||||
|
this.startTime = Date.now();
|
||||||
|
this.setData({ transition: 'none' });
|
||||||
|
},
|
||||||
|
|
||||||
|
onTouchMove(e) {
|
||||||
|
if (this.isExiting) return;
|
||||||
|
|
||||||
|
const deltaX = e.touches[0].clientX - this.startX;
|
||||||
|
// 只有移动超过5px,才真正判定为“拖拽”
|
||||||
|
if (Math.abs(deltaX) > 5) {
|
||||||
|
this.isDragging = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果不是拖拽,则不进行任何移动
|
||||||
|
if (!this.isDragging) return;
|
||||||
|
|
||||||
|
this.lastX = e.touches[0].clientX;
|
||||||
|
const rotate = deltaX * 0.05;
|
||||||
|
const opacity = 1 - Math.abs(deltaX) / 200;
|
||||||
|
this.setData({
|
||||||
|
translateX: deltaX,
|
||||||
|
rotate: rotate,
|
||||||
|
opacity: opacity
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
onTouchEnd() {
|
||||||
|
if (this.isExiting) return;
|
||||||
|
|
||||||
|
// 如果不是拖拽(即这是一次纯点击),则 onTouchEnd 不执行任何操作,交由 onFlip 处理
|
||||||
|
if (!this.isDragging) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 以下是拖拽结束后的逻辑
|
||||||
|
const deltaX = this.lastX - this.startX;
|
||||||
|
const deltaTime = Date.now() - this.startTime;
|
||||||
|
const velocity = deltaX / deltaTime;
|
||||||
|
const shouldExit = Math.abs(deltaX) > EXIT_THRESHOLD || Math.abs(velocity) > FAST_SWIPE_VELOCITY;
|
||||||
|
|
||||||
|
if (shouldExit) {
|
||||||
|
this.isExiting = true;
|
||||||
|
const direction = deltaX > 0 ? 1 : -1;
|
||||||
|
const targetX = direction * 500;
|
||||||
|
this.setData({
|
||||||
|
translateX: targetX,
|
||||||
|
opacity: 0,
|
||||||
|
transition: 'all 0.3s cubic-bezier(0.6, -0.28, 0.735, 0.045)'
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
this.setData({
|
||||||
|
translateX: 0,
|
||||||
|
rotate: 0,
|
||||||
|
opacity: 1,
|
||||||
|
transition: 'all 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275)'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 延迟重置 isDragging 标志位。
|
||||||
|
// 这是为了确保在系统触发 tap 事件时,isDragging 标志仍然为 true,
|
||||||
|
// 从而让 onFlip 方法可以正确地忽略掉这次由拖拽产生的 tap。
|
||||||
|
setTimeout(() => {
|
||||||
|
this.isDragging = false;
|
||||||
|
}, 100);
|
||||||
|
},
|
||||||
|
|
||||||
|
onTransitionEnd() {
|
||||||
|
if (!this.isExiting) return;
|
||||||
|
|
||||||
|
const direction = this.data.translateX > 0 ? 'like' : 'dislike';
|
||||||
|
if (direction === 'like') {
|
||||||
|
this.handleLike();
|
||||||
|
} else {
|
||||||
|
this.handleCardExit(direction);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
handleLike() {
|
||||||
|
wx.vibrateShort({ type: 'medium' });
|
||||||
|
getApp().playAudio('success');
|
||||||
|
const card = this.data.nameList[this.data.currentIndex];
|
||||||
|
if (!this.data.collectedNames.some(item => item.name === card.name)) {
|
||||||
|
this.setData({ collectedNames: [...this.data.collectedNames, card] });
|
||||||
|
}
|
||||||
|
this.handleCardExit('like');
|
||||||
|
},
|
||||||
|
|
||||||
|
handleCardExit() {
|
||||||
|
const nextIndex = this.data.currentIndex + 1;
|
||||||
|
wx.nextTick(() => {
|
||||||
|
this.setData({
|
||||||
|
transition: 'none',
|
||||||
|
translateX: 0,
|
||||||
|
rotate: 0,
|
||||||
|
opacity: 1,
|
||||||
|
isFlipped: false
|
||||||
|
}, () => {
|
||||||
|
if (nextIndex < this.data.nameList.length) {
|
||||||
|
this.setData({ currentIndex: nextIndex, cardKey: this.data.cardKey + 1 });
|
||||||
|
} else {
|
||||||
|
wx.showModal({
|
||||||
|
title: '见素时刻',
|
||||||
|
content: '这一波灵感已尽,是否再求几名?',
|
||||||
|
confirmText: '再求', cancelText: '返回',
|
||||||
|
success: (res) => { if (res.confirm) this.fetchNames(this.data.keyword); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
this.isExiting = false;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
toggleCollectionView() {
|
||||||
|
this.setData({ showCollection: !this.data.showCollection });
|
||||||
|
},
|
||||||
|
|
||||||
|
onDeleteCollected(e) {
|
||||||
|
const nameToDelete = e.currentTarget.dataset.name;
|
||||||
|
this.setData({
|
||||||
|
collectedNames: this.data.collectedNames.filter(item => item.name !== nameToDelete)
|
||||||
|
});
|
||||||
|
wx.vibrateShort({ type: 'light' });
|
||||||
|
},
|
||||||
|
|
||||||
|
onSavePoster() {
|
||||||
|
const card = this.data.nameList[this.data.currentIndex];
|
||||||
|
wx.showLoading({ title: '绘笔收录中...', mask: true });
|
||||||
|
const query = wx.createSelectorQuery().in(this);
|
||||||
|
query.select('#posterCanvas')
|
||||||
|
.fields({ node: true, size: true })
|
||||||
|
.exec((res) => {
|
||||||
|
const canvas = res[0].node;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
const dpr = wx.getSystemInfoSync().pixelRatio;
|
||||||
|
canvas.width = 750 * dpr;
|
||||||
|
canvas.height = 1334 * dpr;
|
||||||
|
ctx.scale(dpr, dpr);
|
||||||
|
ctx.fillStyle = '#FFFFFF';
|
||||||
|
ctx.fillRect(0, 0, 750, 1334);
|
||||||
|
ctx.fillStyle = '#2D2D2D';
|
||||||
|
ctx.font = '300 120px serif';
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.textBaseline = 'middle';
|
||||||
|
const name = card.name;
|
||||||
|
const charArray = name.split('');
|
||||||
|
const charHeight = 140;
|
||||||
|
const startY = 667 - (charArray.length * charHeight) / 2 + charHeight / 2;
|
||||||
|
charArray.forEach((char, index) => ctx.fillText(char, 375, startY + index * charHeight));
|
||||||
|
const sealChar = this.data.keyword.substring(0, 1) || '素';
|
||||||
|
ctx.fillStyle = '#B22222';
|
||||||
|
ctx.fillRect(100, 100, 80, 80);
|
||||||
|
ctx.fillStyle = '#FFFFFF';
|
||||||
|
ctx.font = '40px serif';
|
||||||
|
ctx.fillText(sealChar, 140, 140);
|
||||||
|
const randomNum = Math.floor(Math.random() * 9000) + 1000;
|
||||||
|
ctx.fillStyle = '#D0D0D0';
|
||||||
|
ctx.font = '24px sans-serif';
|
||||||
|
ctx.fillText(`见素第 ${randomNum} 号灵感`, 375, 1200);
|
||||||
|
setTimeout(() => {
|
||||||
|
wx.canvasToTempFilePath({
|
||||||
|
canvas: canvas,
|
||||||
|
success: (res) => {
|
||||||
|
wx.hideLoading();
|
||||||
|
wx.showShareImageMenu({ path: res.tempFilePath });
|
||||||
|
},
|
||||||
|
fail: () => {
|
||||||
|
wx.hideLoading();
|
||||||
|
wx.showToast({ title: '绘笔受阻', icon: 'none' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, 300);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
74
miniprogram/pages/index/index.wxml
Normal file
74
miniprogram/pages/index/index.wxml
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
<view class="container">
|
||||||
|
<view class="card-stack" wx:if="{{!isLoading && nameList.length > 0}}">
|
||||||
|
<view
|
||||||
|
class="card-container"
|
||||||
|
style="transform: translateX({{translateX}}px) rotate({{rotate}}deg); transition: {{transition}};"
|
||||||
|
bindtouchstart="onTouchStart"
|
||||||
|
bindtouchmove="onTouchMove"
|
||||||
|
bindtouchend="onTouchEnd"
|
||||||
|
bindtransitionend="onTransitionEnd"
|
||||||
|
>
|
||||||
|
<view class="card {{isFlipped ? 'flipped' : ''}}">
|
||||||
|
<!-- 正面:名字与诗词 -->
|
||||||
|
<view class="card-face front" bindtap="onFlip">
|
||||||
|
<text class="name">{{nameList[currentIndex].name}}</text>
|
||||||
|
<view class="poem-container">
|
||||||
|
<text class="poem">{{nameList[currentIndex].origin}}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<!-- 反面:故事化解读 -->
|
||||||
|
<view class="card-face back">
|
||||||
|
<view class="desc-container">
|
||||||
|
<text class="desc">{{nameList[currentIndex].description}}</text>
|
||||||
|
</view>
|
||||||
|
<view class="analysis-container">
|
||||||
|
<view class="tone-tag">声韵:{{nameList[currentIndex].tone}}</view>
|
||||||
|
<view class="score-tag">见素评分:{{nameList[currentIndex].score}}</view>
|
||||||
|
<view class="save-btn" catchtap="onSavePoster">
|
||||||
|
<text class="save-icon">+</text>
|
||||||
|
<text>存为海报</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<!-- 海报生成画布 (隐藏在屏幕外) -->
|
||||||
|
<canvas type="2d" id="posterCanvas" style="width: 750px; height: 1334px; position: absolute; left: -9999px;"></canvas>
|
||||||
|
|
||||||
|
<!-- 底部操作提示 -->
|
||||||
|
<view class="footer" wx:if="{{!isLoading}}">
|
||||||
|
<view class="hint">左滑无感 · 右滑收藏</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 空状态 -->
|
||||||
|
<view class="empty-state" wx:if="{{!isLoading && nameList.length === 0}}">
|
||||||
|
<text>暂无灵感,请重试</text>
|
||||||
|
<button bindtap="onLoad" style="margin-top: 20px; font-weight: 300;">重新感悟</button>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 收藏锦囊 -->
|
||||||
|
<view class="collection-bag" bindtap="toggleCollectionView">
|
||||||
|
<text class="bag-icon">囊</text>
|
||||||
|
<view class="collection-count">{{collectedNames.length}}</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 收藏列表浮层 -->
|
||||||
|
<view class="collection-overlay {{showCollection ? 'visible' : ''}}" bindtap="toggleCollectionView">
|
||||||
|
<view class="collection-content" catchtap>
|
||||||
|
<view class="collection-title">见素锦囊</view>
|
||||||
|
<scroll-view scroll-y class="collection-scroll">
|
||||||
|
<view class="collection-item" wx:for="{{collectedNames}}" wx:key="name">
|
||||||
|
<view>
|
||||||
|
<view class="item-name">{{item.name}}</view>
|
||||||
|
<view class="item-origin">{{item.origin}}</view>
|
||||||
|
</view>
|
||||||
|
<view class="delete-btn" data-name="{{item.name}}" catchtap="onDeleteCollected">×</view>
|
||||||
|
</view>
|
||||||
|
<view wx:if="{{collectedNames.length === 0}}" class="collection-empty">
|
||||||
|
锦囊空空,静待灵感。
|
||||||
|
</view>
|
||||||
|
</scroll-view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
311
miniprogram/pages/index/index.wxss
Normal file
311
miniprogram/pages/index/index.wxss
Normal file
@@ -0,0 +1,311 @@
|
|||||||
|
page {
|
||||||
|
background-color: #FFFFFF;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-stack {
|
||||||
|
position: relative;
|
||||||
|
width: 70vw;
|
||||||
|
height: 100vw;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-container {
|
||||||
|
position: absolute;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
position: relative;
|
||||||
|
box-shadow: 0 20px 60px rgba(0,0,0,0.04);
|
||||||
|
border: 1px solid #F0F0F0;
|
||||||
|
border-radius: 8rpx;
|
||||||
|
background: white;
|
||||||
|
transform-style: preserve-3d;
|
||||||
|
transition: transform 0.6s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-face {
|
||||||
|
position: absolute;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
backface-visibility: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 60rpx;
|
||||||
|
box-sizing: border-box;
|
||||||
|
border-radius: 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.front {
|
||||||
|
transform: rotateY(0deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.back {
|
||||||
|
transform: rotateY(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card.flipped .front {
|
||||||
|
transform: rotateY(-180deg);
|
||||||
|
}
|
||||||
|
.card.flipped .back {
|
||||||
|
transform: rotateY(0deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.front .name {
|
||||||
|
font-family: "Noto Serif SC", "Source Han Serif SC", "PingFang SC", serif;
|
||||||
|
font-size: 140rpx;
|
||||||
|
color: #2D2D2D;
|
||||||
|
margin-bottom: 60rpx;
|
||||||
|
font-weight: 300;
|
||||||
|
letter-spacing: 10rpx;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.poem-container {
|
||||||
|
height: 350rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.front .poem {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #A0A0A0;
|
||||||
|
letter-spacing: 12rpx;
|
||||||
|
writing-mode: vertical-rl;
|
||||||
|
line-height: 1.5;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back {
|
||||||
|
transform: rotateY(180deg);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-between !important;
|
||||||
|
padding: 80rpx 60rpx !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desc-container {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back .desc {
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #4A4A4A;
|
||||||
|
line-height: 2.2;
|
||||||
|
text-align: justify;
|
||||||
|
letter-spacing: 2rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.analysis-container {
|
||||||
|
margin-top: 40rpx;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
border-top: 1rpx solid #F0F0F0;
|
||||||
|
padding-top: 30rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tone-tag, .score-tag {
|
||||||
|
font-size: 18rpx;
|
||||||
|
color: #D0D0D0;
|
||||||
|
letter-spacing: 2rpx;
|
||||||
|
margin-bottom: 10rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-btn {
|
||||||
|
margin-top: 20rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 16rpx;
|
||||||
|
color: #A0A0A0;
|
||||||
|
border: 1rpx solid #F0F0F0;
|
||||||
|
padding: 8rpx 20rpx;
|
||||||
|
border-radius: 40rpx;
|
||||||
|
letter-spacing: 2rpx;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-btn:active {
|
||||||
|
background: #FAFAFA;
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-icon {
|
||||||
|
margin-right: 8rpx;
|
||||||
|
font-size: 14rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 退出动画 */
|
||||||
|
.card-exit-left {
|
||||||
|
transform: translate3d(-150%, 0, 0) rotate(-20deg) !important;
|
||||||
|
opacity: 0 !important;
|
||||||
|
transition: all 0.5s ease-in !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-exit-right {
|
||||||
|
transform: translate3d(150%, 0, 0) rotate(20deg) !important;
|
||||||
|
opacity: 0 !important;
|
||||||
|
transition: all 0.5s ease-in !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
margin-top: 100rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: #D0D0D0;
|
||||||
|
letter-spacing: 4rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 收藏锦囊 */
|
||||||
|
.collection-bag {
|
||||||
|
position: fixed;
|
||||||
|
right: 60rpx;
|
||||||
|
bottom: 120rpx;
|
||||||
|
width: 100rpx;
|
||||||
|
height: 100rpx;
|
||||||
|
background-color: #FFFFFF;
|
||||||
|
border-radius: 50%;
|
||||||
|
box-shadow: 0 10rpx 40rpx rgba(0,0,0,0.08);
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
flex-direction: column;
|
||||||
|
z-index: 10;
|
||||||
|
transition: transform 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collection-bag:active {
|
||||||
|
transform: scale(0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bag-icon {
|
||||||
|
font-family: "Noto Serif SC", serif;
|
||||||
|
font-size: 40rpx;
|
||||||
|
color: #4A4A4A;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collection-count {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
background-color: #B22222;
|
||||||
|
color: white;
|
||||||
|
font-size: 18rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
padding: 4rpx 10rpx;
|
||||||
|
min-width: 18rpx;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 收藏列表 */
|
||||||
|
.collection-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background-color: rgba(255, 255, 255, 0.5);
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
z-index: 20;
|
||||||
|
opacity: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
transition: all 0.4s cubic-bezier(0.165, 0.84, 0.44, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.collection-overlay.visible {
|
||||||
|
opacity: 1;
|
||||||
|
visibility: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collection-content {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 60vh;
|
||||||
|
background-color: rgba(255, 255, 255, 0.8);
|
||||||
|
border-top-left-radius: 40rpx;
|
||||||
|
border-top-right-radius: 40rpx;
|
||||||
|
box-shadow: 0 -10rpx 60rpx rgba(0,0,0,0.05);
|
||||||
|
transform: translateY(100%);
|
||||||
|
transition: transform 0.4s cubic-bezier(0.165, 0.84, 0.44, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.collection-overlay.visible .collection-content {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.collection-title {
|
||||||
|
text-align: center;
|
||||||
|
padding: 40rpx 0;
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #A0A0A0;
|
||||||
|
letter-spacing: 4rpx;
|
||||||
|
border-bottom: 1rpx solid #F0F0F0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collection-scroll {
|
||||||
|
height: calc(100% - 110rpx);
|
||||||
|
}
|
||||||
|
|
||||||
|
.collection-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 30rpx 60rpx;
|
||||||
|
border-bottom: 1rpx solid #F0F0F0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-name {
|
||||||
|
font-family: "Noto Serif SC", serif;
|
||||||
|
font-size: 32rpx;
|
||||||
|
color: #2D2D2D;
|
||||||
|
margin-bottom: 4rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-origin {
|
||||||
|
font-size: 20rpx;
|
||||||
|
color: #A0A0A0;
|
||||||
|
font-weight: 200;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-btn {
|
||||||
|
font-size: 40rpx;
|
||||||
|
color: #E0E0E0;
|
||||||
|
font-weight: 200;
|
||||||
|
padding: 10rpx 20rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-btn:active {
|
||||||
|
background-color: #F0F0F0;
|
||||||
|
color: #B22222;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collection-empty {
|
||||||
|
text-align: center;
|
||||||
|
padding: 100rpx;
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #E0E0E0;
|
||||||
|
}
|
||||||
28
miniprogram/project.config.json
Normal file
28
miniprogram/project.config.json
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"appid": "wxf634a69e89290cc7",
|
||||||
|
"compileType": "miniprogram",
|
||||||
|
"libVersion": "3.15.2",
|
||||||
|
"packOptions": {
|
||||||
|
"ignore": [],
|
||||||
|
"include": []
|
||||||
|
},
|
||||||
|
"setting": {
|
||||||
|
"coverView": true,
|
||||||
|
"es6": true,
|
||||||
|
"postcss": true,
|
||||||
|
"minified": true,
|
||||||
|
"enhance": true,
|
||||||
|
"showShadowRootInWxmlPanel": true,
|
||||||
|
"packNpmRelationList": [],
|
||||||
|
"babelSetting": {
|
||||||
|
"ignore": [],
|
||||||
|
"disablePlugins": [],
|
||||||
|
"outputPath": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"condition": {},
|
||||||
|
"editorSetting": {
|
||||||
|
"tabIndent": "insertSpaces",
|
||||||
|
"tabSize": 2
|
||||||
|
}
|
||||||
|
}
|
||||||
8
miniprogram/project.private.config.json
Normal file
8
miniprogram/project.private.config.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"description": "项目私有配置文件。此文件中的内容将覆盖 project.config.json 中的相同字段。项目的改动优先同步到此文件中。详见文档:https://developers.weixin.qq.com/miniprogram/dev/devtools/projectconfig.html",
|
||||||
|
"projectname": "miniprogram",
|
||||||
|
"setting": {
|
||||||
|
"compileHotReLoad": true,
|
||||||
|
"urlCheck": false
|
||||||
|
}
|
||||||
|
}
|
||||||
76
muti-mode-feature.md
Normal file
76
muti-mode-feature.md
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
# 「见素」多模式起名功能开发计划
|
||||||
|
|
||||||
|
## 📝 总体目标
|
||||||
|
实现多模式起名系统,涵盖后端 Prompt 引擎增强、前端极简输入页重构、以及卡片页的交互适配。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🏗️ 第一阶段:后端 API 增强 (Prompt 引擎)
|
||||||
|
|
||||||
|
### 1.1 模型层检查 (`NameCard.java`)
|
||||||
|
* **目标**:确认字段兼容性。
|
||||||
|
* **状态**:无需修改。确保 `name`, `origin`, `description`, `score`, `tone` 字段能承载 AI 返回的结构化数据。
|
||||||
|
|
||||||
|
### 1.2 逻辑层增强 (`MiniMaxService.java`)
|
||||||
|
* **动作**:
|
||||||
|
1. **方法重载**:修改 `generateNames` 签名,新增 `mode` 和 `surname` 参数。
|
||||||
|
2. **模板管理**:维护 `Map<String, String> promptTemplates`,预置 `baby` (宝宝), `persona` (人设), `classic` (拾遗) 的 Prompt。
|
||||||
|
3. **动态构建**:根据 `mode` 选取模板,注入 `keyword` 和 `surname` 生成最终 Prompt。
|
||||||
|
4. **后门保留**:维持 `test` 关键字逻辑,便于快速联调前端 UI。
|
||||||
|
|
||||||
|
### 1.3 接口层更新 (`NamingController.java`)
|
||||||
|
* **动作**:更新 `@GetMapping` 接口,通过 `@RequestParam` 接收 `mode` 与 `surname`(均为 `required = false`),并透传至 Service 层。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎨 第二阶段:前端重设计 - 输入页 (`home`)
|
||||||
|
|
||||||
|
### 2.1 结构重构 (`home.wxml`)
|
||||||
|
* **新增模式选择**:`<view class="mode-selector">`,通过 `wx:for` 渲染模式标签。
|
||||||
|
* **动画容器**:预留 `loading-ink` 容器,用于存放“墨水晕开”动画。
|
||||||
|
* **表单保持**:保留 `keyword` 输入框与 `generate-btn`。
|
||||||
|
|
||||||
|
### 2.2 视觉优化 (`home.wxss`)
|
||||||
|
* **极简风格**:
|
||||||
|
* `.mode-item`:默认灰色,选中态 (`.active`) 加黑并显示下划点。
|
||||||
|
* `.generate-btn`:极致方正,无圆角,高字间距。
|
||||||
|
* **动效实现**:定义 `@keyframes ink-spread`,模拟水墨滴入的径向扩张效果。
|
||||||
|
|
||||||
|
### 2.3 交互逻辑 (`home.js`)
|
||||||
|
* **状态管理**:定义 `activeMode` (默认 `baby`),`isGenerating` (控制动画)。
|
||||||
|
* **路由跳转**:`startGenerate` 组装 URL 参数,携带 `mode` 跳转至 `index` 页面。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🃏 第三阶段:前端适配 - 卡片页 (`index`)
|
||||||
|
|
||||||
|
### 3.1 界面调整 (`index.wxml`)
|
||||||
|
* **顶部标题**:显示 `见素 · {{modeName}} · {{keyword}}`。
|
||||||
|
* **操作栏**:底部新增 `action-bar`,包含 `×` (Dislike), `♥` (Like) 两个圆形按钮及一个“返回”图标。
|
||||||
|
* **移除冗余**:删除旧版的滑动提示文字。
|
||||||
|
|
||||||
|
### 3.2 样式适配 (`index.wxss`)
|
||||||
|
* **散文诗排版**:增加 `.desc` 的 `line-height`,设置 `white-space: pre-wrap` 保持 AI 返回的换行格式。
|
||||||
|
* **圆形按钮**:设计极简线条风格的圆形交互元素。
|
||||||
|
|
||||||
|
### 3.3 数据联调 (`index.js`)
|
||||||
|
* **参数接收**:在 `onLoad` 中解析 URL 参数,存储当前模式并调用后端接口。
|
||||||
|
* **导航控制**:实现 `onBack` 返回首页,确保返回后重置首页的加载状态。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧩 第四阶段:全局规范与清理
|
||||||
|
|
||||||
|
### 4.1 全局变量 (`app.wxss`)
|
||||||
|
* **变量定义**:
|
||||||
|
```css
|
||||||
|
--jiansu-bg: #ffffff;
|
||||||
|
--jiansu-text: #2c2c2c;
|
||||||
|
--jiansu-accent: #8e8e8e;
|
||||||
|
```
|
||||||
|
* **字体引入**:全局应用 `Noto Serif SC` (思源宋体),强化品牌质感。
|
||||||
|
|
||||||
|
### 4.2 冗余清理
|
||||||
|
* **动作**:物理删除 `miniprogram/pages/index/index.wxs`。由于逻辑已迁移至 JS 动画处理,废弃 WXS 以保持项目整洁。
|
||||||
|
|
||||||
|
---
|
||||||
121
step2.md
Normal file
121
step2.md
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
一、 后端设计:多模式分流架构
|
||||||
|
核心逻辑是根据前端传来的 mode,动态拼接不同的 System Prompt。
|
||||||
|
|
||||||
|
1. API 接口定义
|
||||||
|
Endpoint: /api/names/generate
|
||||||
|
|
||||||
|
Method: POST
|
||||||
|
|
||||||
|
Params:
|
||||||
|
|
||||||
|
keyword: 用户输入的意象(如:清冷、自由)。
|
||||||
|
|
||||||
|
mode: 模式枚举 (baby | persona | classic)。
|
||||||
|
|
||||||
|
surname: 可选,用户姓氏(用于声韵分析)。
|
||||||
|
|
||||||
|
2. 核心 Prompt 引擎逻辑
|
||||||
|
在后端代码中通过一个 switch 或 Map 来管理 Prompt 模版:
|
||||||
|
|
||||||
|
JavaScript
|
||||||
|
const prompts = {
|
||||||
|
baby: "你现在是【见素·婴儿起名】导师。风格:端庄、温润。避开所有网红叠字。要求名字具备经得起时间考验的厚重感。",
|
||||||
|
persona: "你现在是【见素·人设起名】导师。风格:冷冽、张力、赛博。意象可以偏向孤傲、破败或极致的浪漫。",
|
||||||
|
classic: "你现在是【见素·诗词拾遗】导师。风格:考古学美感。专门寻找《全唐诗》中极少被现代人使用的冷门生僻字词。"
|
||||||
|
};
|
||||||
|
|
||||||
|
// 拼接最终发送给 minimax 的消息
|
||||||
|
const finalPrompt = `${prompts[mode]} \n
|
||||||
|
当前关键词:${keyword}。${surname ? '姓氏:' + surname : ''} \n
|
||||||
|
输出格式:严格 JSON 数组...`;
|
||||||
|
二、 小程序页面设计:从“输入”到“感悟”
|
||||||
|
我们要把传统的“表单填写”变成一种**“交互仪式”**。
|
||||||
|
|
||||||
|
1. 首页 (Input Page):极简选择
|
||||||
|
视觉: 全白背景,中心是一个极细的输入框。
|
||||||
|
|
||||||
|
模式切换: 底部放置三个极简的标签(Tag),选中的标签下方有一个小黑点。
|
||||||
|
|
||||||
|
[ 宝宝 ] [ 人设 ] [ 拾遗 ]
|
||||||
|
|
||||||
|
交互: * 用户输入关键词后,点击下方的“求名”按钮。
|
||||||
|
|
||||||
|
按钮点击后,文字变为“见素感悟中...”,并触发一个类似墨水滴入水中慢慢晕开的动画(CSS 实现)。
|
||||||
|
|
||||||
|
2. 卡片页 (Swipe Page):氛围渲染
|
||||||
|
头部: 展示当前的模式和关键词(如:见素 · 宝宝 · 清冷)。
|
||||||
|
|
||||||
|
卡片: * 正面: 巨大的宋体名字,垂直居中。右下方有一个淡淡的灰色印章。
|
||||||
|
|
||||||
|
反面: 解读文字不再是段落,而是散文诗般的排版(增加行间距)。
|
||||||
|
|
||||||
|
操作栏: 底部只有两个圆圈按钮(× 和 ♥),以及一个“返回”小图标。
|
||||||
|
|
||||||
|
三、 前端页面完整代码建议 (index.wxml 增强版)
|
||||||
|
在你的基础上,增加了模式选择和视觉氛围感。
|
||||||
|
|
||||||
|
XML
|
||||||
|
<wxs module="handler" src="./handler.wxs"></wxs>
|
||||||
|
|
||||||
|
<view class="container">
|
||||||
|
<view class="mode-selector" wx:if="{{!isSwiping}}">
|
||||||
|
<view
|
||||||
|
wx:for="{{modes}}"
|
||||||
|
wx:key="id"
|
||||||
|
class="mode-item {{activeMode === item.id ? 'active' : ''}}"
|
||||||
|
bindtap="selectMode"
|
||||||
|
data-id="{{item.id}}"
|
||||||
|
>{{item.name}}</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="input-section" wx:if="{{!isSwiping}}">
|
||||||
|
<input
|
||||||
|
class="main-input"
|
||||||
|
placeholder="输入意象,如:孤鸿、冷月"
|
||||||
|
bindinput="onInput"
|
||||||
|
placeholder-style="color: #ccc;"
|
||||||
|
/>
|
||||||
|
<button class="submit-btn" bindtap="startGenerate">求名</button>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="card-stack" wx:if="{{isSwiping && nameList.length > 0}}">
|
||||||
|
<view
|
||||||
|
class="card {{draggingClass}} {{isFlipped ? 'flipped' : ''}}"
|
||||||
|
wx:key="{{cardKey}}"
|
||||||
|
bindtap="onFlip"
|
||||||
|
bindtouchstart="{{handler.touchstart}}"
|
||||||
|
bindtouchmove="{{handler.touchmove}}"
|
||||||
|
bindtouchend="{{handler.touchend}}"
|
||||||
|
>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="hint">左滑无感 · 右滑收录</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
四、 关键 CSS 变量 (app.wxss)
|
||||||
|
为了保证“见素”的 Vibe,建议在全局定义这些变量:
|
||||||
|
|
||||||
|
CSS
|
||||||
|
page {
|
||||||
|
--jiansu-bg: #ffffff;
|
||||||
|
--jiansu-text: #2c2c2c;
|
||||||
|
--jiansu-accent: #8e8e8e;
|
||||||
|
--jiansu-font: "Noto Serif SC", serif; /* 记得引入思源宋体 */
|
||||||
|
background-color: var(--jiansu-bg);
|
||||||
|
color: var(--jiansu-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-btn {
|
||||||
|
background: var(--jiansu-text);
|
||||||
|
color: #fff;
|
||||||
|
border-radius: 0; /* 极致方正 */
|
||||||
|
font-weight: 300;
|
||||||
|
letter-spacing: 4rpx;
|
||||||
|
margin-top: 60rpx;
|
||||||
|
}
|
||||||
|
五、 后期优化的建议
|
||||||
|
后端缓存: 同一个模式+同一能个关键词,缓存 24 小时。因为 Gemini API 出来的结果会有随机性,但好的意象是有限的。
|
||||||
|
|
||||||
|
姓氏平仄: 如果是“宝宝起名”模式,一定要让用户填姓氏。然后在后端加入逻辑:“如果姓氏是平声,第一个名优先选仄声”。
|
||||||
|
|
||||||
|
分享海报: 名字一定要足够大,背景一定要留/白。这是「见素」的核心竞争力。
|
||||||
Reference in New Issue
Block a user