feat: 完善见素起名小程序功能

- 添加收藏锦囊功能,支持查看和删除收藏
- 实现积分系统,每日赠送5次灵感次数
- 添加静心阅读功能,阅读15秒可获得额外次数
- 实现灵感广场,展示用户分享的名字
- 添加字源溯源组件,长按汉字查看详情
- 优化空状态和结语卡片样式统一
- 添加音频控制(静音/风铃/雨落/古琴/白噪音/森林/溪流)
- 优化名字生成逻辑,确保每次返回5个不重复名字
- 修复卡片翻转样式问题
- 移除首页动态提醒气泡
This commit is contained in:
王鹏
2026-04-18 16:56:31 +08:00
parent be1f5722ab
commit 2c47fb8f65
59 changed files with 4643 additions and 145 deletions

View File

@@ -0,0 +1,74 @@
package com.jiansu.naming.controller;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.client.RestTemplate;
import java.util.HashMap;
import java.util.Map;
@RestController
@RequestMapping("/api/auth")
@CrossOrigin(origins = "*")
@Slf4j
public class AuthController {
@Value("${wechat.miniapp.app-id}")
private String appId;
@Value("${wechat.miniapp.app-secret}")
private String appSecret;
private final RestTemplate restTemplate = new RestTemplate();
private final ObjectMapper objectMapper = new ObjectMapper();
/**
* 微信登录 - 用 code 换取 openid
*/
@PostMapping("/login")
public ResponseEntity<?> wxLogin(@RequestBody Map<String, String> request) {
String code = request.get("code");
if (code == null || code.isEmpty()) {
Map<String, Object> response = new HashMap<>();
response.put("success", false);
response.put("message", "code 不能为空");
return ResponseEntity.badRequest().body(response);
}
try {
// 调用微信接口换取 openid
String url = String.format(
"https://api.weixin.qq.com/sns/jscode2session?appid=%s&secret=%s&js_code=%s&grant_type=authorization_code",
appId, appSecret, code
);
String result = restTemplate.getForObject(url, String.class);
JsonNode jsonNode = objectMapper.readTree(result);
Map<String, Object> response = new HashMap<>();
if (jsonNode.has("openid")) {
String openid = jsonNode.get("openid").asText();
response.put("success", true);
response.put("openid", openid);
log.info("微信登录成功openid: {}", openid);
} else {
response.put("success", false);
response.put("message", jsonNode.has("errmsg") ? jsonNode.get("errmsg").asText() : "登录失败");
log.error("微信登录失败: {}", result);
}
return ResponseEntity.ok(response);
} catch (Exception e) {
log.error("微信登录异常", e);
Map<String, Object> response = new HashMap<>();
response.put("success", false);
response.put("message", "登录异常: " + e.getMessage());
return ResponseEntity.ok(response);
}
}
}

View File

@@ -0,0 +1,401 @@
package com.jiansu.naming.controller;
import lombok.extern.slf4j.Slf4j;
import net.sourceforge.pinyin4j.PinyinHelper;
import net.sourceforge.pinyin4j.format.HanyuPinyinOutputFormat;
import net.sourceforge.pinyin4j.format.HanyuPinyinToneType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
import java.util.Map;
@RestController
@RequestMapping("/api/char")
@CrossOrigin(origins = "*")
@Slf4j
public class CharController {
/**
* 获取汉字详情
*/
@GetMapping("/detail")
public ResponseEntity<?> getCharDetail(@RequestParam String character) {
Map<String, Object> response = new HashMap<>();
if (character == null || character.length() != 1) {
response.put("success", false);
response.put("message", "请输入单个汉字");
return ResponseEntity.badRequest().body(response);
}
try {
Map<String, Object> data = new HashMap<>();
data.put("char", character);
data.put("pinyin", getPinyin(character));
data.put("radical", getRadical(character));
data.put("strokes", getStrokes(character));
data.put("wuxing", getWuxing(character));
data.put("meaning", getMeaning(character));
data.put("imagery", getImagery(character));
data.put("poetry", getPoetry(character));
response.put("success", true);
response.put("data", data);
log.info("获取汉字详情: {}", character);
} catch (Exception e) {
log.error("获取汉字详情失败", e);
response.put("success", false);
response.put("message", "获取失败: " + e.getMessage());
}
return ResponseEntity.ok(response);
}
/**
* 获取拼音
*/
private String getPinyin(String chinese) {
try {
HanyuPinyinOutputFormat format = new HanyuPinyinOutputFormat();
format.setToneType(HanyuPinyinToneType.WITH_TONE_MARK);
String[] pinyinArray = PinyinHelper.toHanyuPinyinStringArray(chinese.charAt(0));
if (pinyinArray != null && pinyinArray.length > 0) {
return pinyinArray[0];
}
} catch (Exception e) {
log.error("拼音转换失败", e);
}
return "";
}
/**
* 获取部首(简化版,实际应该查字典)
*/
private String getRadical(String chinese) {
// 这里简化处理,实际应该查询汉字字典数据库
Map<String, String> radicalMap = new HashMap<>();
radicalMap.put("", "");
radicalMap.put("", "");
radicalMap.put("", "");
radicalMap.put("", "");
radicalMap.put("", "");
radicalMap.put("", "");
radicalMap.put("", "");
radicalMap.put("", "");
radicalMap.put("", "");
radicalMap.put("", "");
radicalMap.put("", "");
radicalMap.put("", "");
radicalMap.put("", "");
radicalMap.put("", "");
radicalMap.put("", "");
radicalMap.put("", "");
radicalMap.put("", "");
radicalMap.put("", "");
radicalMap.put("", "");
radicalMap.put("", "");
radicalMap.put("", "");
radicalMap.put("", "");
radicalMap.put("", "");
radicalMap.put("", "");
radicalMap.put("", "");
radicalMap.put("", "");
radicalMap.put("", "");
radicalMap.put("", "");
radicalMap.put("", "");
radicalMap.put("", "");
radicalMap.put("", "");
radicalMap.put("", "");
radicalMap.put("", "");
radicalMap.put("", "");
radicalMap.put("", "");
radicalMap.put("", "");
radicalMap.put("", "");
radicalMap.put("", "");
radicalMap.put("", "");
radicalMap.put("", "");
radicalMap.put("", "");
radicalMap.put("", "");
radicalMap.put("", "");
radicalMap.put("", "");
radicalMap.put("", "");
radicalMap.put("", "");
radicalMap.put("", "");
radicalMap.put("", "");
radicalMap.put("", "");
return radicalMap.getOrDefault(chinese, "");
}
/**
* 获取笔画数(简化版)
*/
private int getStrokes(String chinese) {
// 简化处理,实际应该查询汉字字典
Map<String, Integer> strokesMap = new HashMap<>();
strokesMap.put("", 5);
strokesMap.put("", 8);
strokesMap.put("", 15);
strokesMap.put("", 11);
strokesMap.put("", 4);
strokesMap.put("", 4);
strokesMap.put("", 3);
strokesMap.put("", 4);
strokesMap.put("", 12);
strokesMap.put("", 8);
strokesMap.put("", 13);
strokesMap.put("", 4);
strokesMap.put("", 8);
strokesMap.put("", 9);
strokesMap.put("", 11);
strokesMap.put("", 11);
strokesMap.put("", 8);
strokesMap.put("", 4);
strokesMap.put("", 8);
strokesMap.put("", 12);
strokesMap.put("", 9);
strokesMap.put("", 6);
strokesMap.put("", 8);
strokesMap.put("", 11);
strokesMap.put("", 11);
strokesMap.put("", 10);
strokesMap.put("", 10);
strokesMap.put("", 10);
strokesMap.put("", 7);
strokesMap.put("", 7);
strokesMap.put("", 11);
strokesMap.put("", 12);
strokesMap.put("", 11);
strokesMap.put("", 10);
strokesMap.put("", 8);
strokesMap.put("", 9);
strokesMap.put("", 7);
strokesMap.put("", 9);
strokesMap.put("", 13);
strokesMap.put("", 11);
strokesMap.put("", 13);
strokesMap.put("", 10);
strokesMap.put("", 15);
strokesMap.put("", 9);
strokesMap.put("", 16);
strokesMap.put("", 9);
return strokesMap.getOrDefault(chinese, 0);
}
/**
* 获取五行属性(简化版)
*/
private String getWuxing(String chinese) {
Map<String, String> wuxingMap = new HashMap<>();
wuxingMap.put("", "");
wuxingMap.put("", "");
wuxingMap.put("", "");
wuxingMap.put("", "");
wuxingMap.put("", "");
wuxingMap.put("", "");
wuxingMap.put("", "");
wuxingMap.put("", "");
wuxingMap.put("", "");
wuxingMap.put("", "");
wuxingMap.put("", "");
wuxingMap.put("", "");
wuxingMap.put("", "");
wuxingMap.put("", "");
wuxingMap.put("", "");
wuxingMap.put("", "");
wuxingMap.put("", "");
wuxingMap.put("", "");
wuxingMap.put("", "");
wuxingMap.put("", "");
wuxingMap.put("", "");
wuxingMap.put("", "");
wuxingMap.put("", "");
wuxingMap.put("", "");
wuxingMap.put("", "");
wuxingMap.put("", "");
wuxingMap.put("", "");
wuxingMap.put("", "");
wuxingMap.put("", "");
wuxingMap.put("", "");
wuxingMap.put("", "");
wuxingMap.put("", "");
wuxingMap.put("", "");
wuxingMap.put("", "");
wuxingMap.put("", "");
wuxingMap.put("", "");
wuxingMap.put("", "");
wuxingMap.put("", "");
wuxingMap.put("", "");
wuxingMap.put("", "");
wuxingMap.put("", "");
wuxingMap.put("", "");
wuxingMap.put("", "");
wuxingMap.put("", "");
wuxingMap.put("", "");
wuxingMap.put("", "");
return wuxingMap.getOrDefault(chinese, "未知");
}
/**
* 获取字义(简化版)
*/
private String getMeaning(String chinese) {
Map<String, String> meaningMap = new HashMap<>();
meaningMap.put("", "本义指兰花,象征高洁、典雅、坚贞不渝的品格。");
meaningMap.put("", "本义指水汇聚的地方,引申为恩泽、润泽、仁慈。");
meaningMap.put("", "本义指书写用的黑色颜料,象征文雅、深沉、有学问。");
meaningMap.put("", "本义指水清澈透明,引申为纯洁、清明、高洁。");
meaningMap.put("", "本义指空气流动,引申为风度、风采、气节。");
meaningMap.put("", "本义指天空中的云彩,象征自由、飘逸、高远。");
meaningMap.put("", "本义指地面高耸的部分,象征稳重、坚毅、可靠。");
meaningMap.put("", "本义指无色无味的液体,象征智慧、柔韧、包容。");
meaningMap.put("", "本义指规范、标准,引申为高雅、文雅、美好。");
meaningMap.put("", "本义指顺从、如同,引申为好像、选择,寓意温婉。");
meaningMap.put("", "本义指山间小河,引申为清澈、灵动、源远流长。");
meaningMap.put("", "本义指月亮,象征光明、圆满、清雅高洁。");
meaningMap.put("", "本义指光明、明亮,引申为聪慧、明理、通达。");
meaningMap.put("", "本义指思考、思念,引申为智慧、深情、细腻。");
meaningMap.put("", "本义指睡眠中的幻象,引申为理想、憧憬、浪漫。");
meaningMap.put("", "本义指天空降落的白色晶体,象征纯洁、高洁、清雅。");
meaningMap.put("", "本义指诗歌、文学,象征文采、才情、雅致。");
meaningMap.put("", "本义指书写、典籍,象征学识、修养、文雅。");
meaningMap.put("", "本义指绘画、描绘,象征艺术、美感、才情。");
meaningMap.put("", "本义指古琴,象征高雅、艺术、知音。");
meaningMap.put("", "本义指茶树、茶叶,象征清雅、淡泊、禅意。");
meaningMap.put("", "本义指竹子,象征气节、坚韧、虚心。");
meaningMap.put("", "本义指松树,象征坚贞、长寿、高洁。");
meaningMap.put("", "本义指梅花,象征傲骨、坚韧、清雅。");
meaningMap.put("", "本义指菊花,象征隐逸、高洁、长寿。");
meaningMap.put("", "本义指荷花,象征纯洁、高雅、出淤泥而不染。");
meaningMap.put("", "本义指莲花,象征纯洁、美好、佛教圣花。");
meaningMap.put("", "本义指桃树、桃花,象征美好、爱情、长寿。");
meaningMap.put("", "本义指李树,象征果实、成就、春天。");
meaningMap.put("", "本义指杏树,象征春天、美好、医学。");
meaningMap.put("", "本义指梨树,象征纯洁、离别、清雅。");
meaningMap.put("", "本义指棠梨树,象征美丽、温和、春天。");
meaningMap.put("", "本义指梧桐树,象征高洁、忠贞、吉祥。");
meaningMap.put("", "本义指桐树,象征高洁、坚贞、美好。");
meaningMap.put("", "本义指枫树,象征热情、浪漫、秋天。");
meaningMap.put("", "本义指柳树,象征柔美、离别、春天。");
meaningMap.put("", "本义指杨树,象征挺拔、向上、正直。");
meaningMap.put("", "本义指柏树,象征坚贞、长寿、高洁。");
meaningMap.put("", "本义指楠木,象征珍贵、高贵、坚韧。");
meaningMap.put("", "本义指梓树,象征故乡、生机、成长。");
meaningMap.put("", "本义指榆树,象征坚韧、朴实、春天。");
meaningMap.put("", "本义指白桦树,象征纯洁、高洁、挺拔。");
meaningMap.put("", "本义指樱花树,象征美丽、浪漫、短暂。");
meaningMap.put("", "本义指柠檬,象征清新、活力、阳光。");
meaningMap.put("", "本义指橙子,象征丰收、喜悦、温暖。");
meaningMap.put("", "本义指柚子,象征团圆、保佑、清新。");
return meaningMap.getOrDefault(chinese, "暂无解析");
}
/**
* 获取起名意象
*/
private String getImagery(String chinese) {
Map<String, String> imageryMap = new HashMap<>();
imageryMap.put("", "兰花幽香,寓意品格高洁、气质优雅,适合期望孩子有高尚情操的家庭。");
imageryMap.put("", "泽被万物,寓意恩泽深厚、仁慈宽厚,适合期望孩子有博爱之心的家庭。");
imageryMap.put("", "墨香书卷,寓意文采斐然、学识渊博,适合期望孩子有文学才华的家庭。");
imageryMap.put("", "清风明月,寓意心地纯净、志向高远,适合期望孩子有清正品格的家庭。");
imageryMap.put("", "风度翩翩,寓意气宇轩昂、潇洒不凡,适合期望孩子有出众气质的家庭。");
imageryMap.put("", "云游四方,寓意胸怀广阔、志向高远,适合期望孩子有远大理想的家庭。");
imageryMap.put("", "高山仰止,寓意稳重可靠、坚毅不拔,适合期望孩子有坚强品格的家庭。");
imageryMap.put("", "上善若水,寓意智慧灵动、柔韧包容,适合期望孩子有聪慧性情的家庭。");
imageryMap.put("", "温文尔雅,寓意举止得体、品味高雅,如芝兰玉树,生于庭阶。");
imageryMap.put("", "虚怀若谷,寓意谦逊包容、从容自在,如兰若生春阳。");
imageryMap.put("", "溪水潺潺,寓意灵动清澈、生生不息,如溪水绕石,柔中带刚。");
imageryMap.put("", "明月清风,寓意光明磊落、清雅高洁,如月出东山,照彻千里。");
imageryMap.put("", "明德惟馨,寓意聪慧明理、通达事理,如日月之光,普照万物。");
imageryMap.put("", "深思熟虑,寓意智慧深邃、情感细腻,如思接千载,视通万里。");
imageryMap.put("", "梦寐以求,寓意理想远大、憧憬美好,如梦笔生花,才华横溢。");
imageryMap.put("", "雪中送炭,寓意纯洁无瑕、高洁清雅,如冰雪聪明,晶莹剔透。");
imageryMap.put("", "诗情画意,寓意文采斐然、才情横溢,如诗中有画,画中有诗。");
imageryMap.put("", "书香门第,寓意学识渊博、修养深厚,如博览群书,学富五车。");
imageryMap.put("", "画龙点睛,寓意艺术天赋、审美高雅,如画中有诗,意境深远。");
imageryMap.put("", "琴瑟和鸣,寓意高雅艺术、知音难觅,如琴心剑胆,刚柔并济。");
imageryMap.put("", "茶余饭后,寓意清雅淡泊、禅意悠然,如品茶悟道,静心养性。");
imageryMap.put("", "竹报平安,寓意气节高尚、虚心向上,如竹影清风,高洁自持。");
imageryMap.put("", "松柏之志,寓意坚贞不屈、长寿安康,如松鹤延年,傲雪凌霜。");
imageryMap.put("", "梅香自苦寒,寓意傲骨铮铮、坚韧不拔,如梅花三弄,清雅高洁。");
imageryMap.put("", "菊残犹有傲霜枝,寓意隐逸高洁、淡泊明志,如采菊东篱,悠然自得。");
imageryMap.put("", "出淤泥而不染,寓意纯洁高雅、不染尘俗,如荷风送香,竹露滴清。");
imageryMap.put("", "莲开并蒂,寓意纯洁美好、百年好合,如步步生莲,清雅脱俗。");
imageryMap.put("", "桃李满天下,寓意美好爱情、长寿安康,如人面桃花,相映成趣。");
imageryMap.put("", "李下不正冠,寓意果实累累、成就斐然,如投桃报李,知恩图报。");
imageryMap.put("", "杏林春暖,寓意春天美好、医术高明,如红杏出墙,生机勃勃。");
imageryMap.put("", "梨花带雨,寓意纯洁清雅、楚楚动人,如梨园弟子,才艺双全。");
imageryMap.put("", "棠棣之花,寓意兄弟和睦、家庭美满,如海棠春睡,娇艳欲滴。");
imageryMap.put("", "梧桐更兼细雨,寓意高洁忠贞、吉祥美好,如凤栖梧桐,人才荟萃。");
imageryMap.put("", "桐花万里丹山路,寓意高洁坚贞、前程似锦,如桐叶封弟,仁义道德。");
imageryMap.put("", "枫叶荻花秋瑟瑟,寓意热情浪漫、成熟稳重,如丹枫迎秋,层林尽染。");
imageryMap.put("", "柳暗花明又一村,寓意柔美婉约、生机勃勃,如柳岸花明,春意盎然。");
imageryMap.put("", "杨花落尽子规啼,寓意挺拔向上、正直不阿,如百步穿杨,技艺精湛。");
imageryMap.put("", "柏舟之誓,寓意坚贞不渝、长寿安康,如松柏后凋,岁寒知友。");
imageryMap.put("", "楠木参天,寓意珍贵高贵、坚韧不拔,如楠梓之材,栋梁之器。");
imageryMap.put("", "梓里桑梓,寓意故乡情深、生机勃勃,如敬恭桑梓,不忘根本。");
imageryMap.put("", "榆柳荫后檐,寓意坚韧朴实、春天希望,如榆火相传,文明延续。");
imageryMap.put("", "白桦亭亭,寓意纯洁高洁、挺拔向上,如桦皮做纸,文化传承。");
imageryMap.put("", "樱花烂漫,寓意美丽浪漫、珍惜当下,如樱唇一点,娇艳动人。");
imageryMap.put("", "柠檬清香,寓意清新活力、阳光开朗,如柠月清风,沁人心脾。");
imageryMap.put("", "橙黄橘绿,寓意丰收喜悦、温暖阳光,如橙香四溢,甜蜜幸福。");
imageryMap.put("", "柚香满园,寓意团圆美满、保佑平安,如柚叶青青,清新怡人。");
return imageryMap.getOrDefault(chinese, "");
}
/**
* 获取诗词典故
*/
private String getPoetry(String chinese) {
Map<String, String> poetryMap = new HashMap<>();
poetryMap.put("", "「兰之猗猗,扬扬其香。」——《诗经》");
poetryMap.put("", "「芳与泽其杂糅兮,唯昭质其犹未亏。」——《离骚》");
poetryMap.put("", "「墨池飞出北溟鱼,笔锋杀尽中山兔。」——李白");
poetryMap.put("", "「清风徐来,水波不兴。」——苏轼《赤壁赋》");
poetryMap.put("", "「随风潜入夜,润物细无声。」——杜甫");
poetryMap.put("", "「行到水穷处,坐看云起时。」——王维");
poetryMap.put("", "「采菊东篱下,悠然见南山。」——陶渊明");
poetryMap.put("", "「问君能有几多愁,恰似一江春水向东流。」——李煜");
poetryMap.put("", "「雅步擢纤腰,巧笑发皓齿。」——曹植");
poetryMap.put("", "「若有人兮山之阿,被薜荔兮带女萝。」——屈原");
poetryMap.put("", "「旧时茅店社林边,路转溪桥忽见。」——辛弃疾");
poetryMap.put("", "「举杯邀明月,对影成三人。」——李白");
poetryMap.put("", "「明月几时有,把酒问青天。」——苏轼");
poetryMap.put("", "「举头望明月,低头思故乡。」——李白");
poetryMap.put("", "「庄生晓梦迷蝴蝶,望帝春心托杜鹃。」——李商隐");
poetryMap.put("", "「忽如一夜春风来,千树万树梨花开。」——岑参");
poetryMap.put("", "「诗中有画,画中有诗。」——苏轼评王维");
poetryMap.put("", "「书中自有黄金屋,书中自有颜如玉。」——赵恒");
poetryMap.put("", "「远看山有色,近听水无声。」——王维");
poetryMap.put("", "「欲将心事付瑶琴,知音少,弦断有谁听。」——岳飞");
poetryMap.put("", "「茶烟轻扬落花风,竹影半墙如画。」——苏轼");
poetryMap.put("", "「宁可食无肉,不可居无竹。」——苏轼");
poetryMap.put("", "「明月松间照,清泉石上流。」——王维");
poetryMap.put("", "「墙角数枝梅,凌寒独自开。」——王安石");
poetryMap.put("", "「采菊东篱下,悠然见南山。」——陶渊明");
poetryMap.put("", "「接天莲叶无穷碧,映日荷花别样红。」——杨万里");
poetryMap.put("", "「出淤泥而不染,濯清涟而不妖。」——周敦颐");
poetryMap.put("", "「人面不知何处去,桃花依旧笑春风。」——崔护");
poetryMap.put("", "「桃李不言,下自成蹊。」——《史记》");
poetryMap.put("", "「春色满园关不住,一枝红杏出墙来。」——叶绍翁");
poetryMap.put("", "「梨花院落溶溶月,柳絮池塘淡淡风。」——晏殊");
poetryMap.put("", "「棠棣之华,鄂不韡韡。」——《诗经》");
poetryMap.put("", "「梧桐更兼细雨,到黄昏、点点滴滴。」——李清照");
poetryMap.put("", "「桐花万里丹山路,雏凤清于老凤声。」——李商隐");
poetryMap.put("", "「停车坐爱枫林晚,霜叶红于二月花。」——杜牧");
poetryMap.put("", "「碧玉妆成一树高,万条垂下绿丝绦。」——贺知章");
poetryMap.put("", "「杨花落尽子规啼,闻道龙标过五溪。」——李白");
poetryMap.put("", "「丞相祠堂何处寻,锦官城外柏森森。」——杜甫");
poetryMap.put("", "「楠树色冥冥,江边一盖青。」——杜甫");
poetryMap.put("", "「梓泽丘墟,姑苏台榭。」——王勃");
poetryMap.put("", "「榆柳荫后檐,桃李罗堂前。」——陶渊明");
poetryMap.put("", "「白桦亭亭立,清风徐自来。」——佚名");
poetryMap.put("", "「樱花红陌上,柳叶绿池边。」——周恩来");
poetryMap.put("", "「柠檬香气远,清风送爽来。」——佚名");
poetryMap.put("", "「橙黄橘绿时,正是好风景。」——苏轼");
poetryMap.put("", "「柚叶青青,秋实离离。」——佚名");
return poetryMap.getOrDefault(chinese, "");
}
}

View File

@@ -0,0 +1,124 @@
package com.jiansu.naming.controller;
import com.jiansu.naming.entity.UserCredits;
import com.jiansu.naming.service.CreditsService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
import java.util.Map;
@RestController
@RequestMapping("/api/credits")
@CrossOrigin(origins = "*")
@Slf4j
public class CreditsController {
@Autowired
private CreditsService creditsService;
/**
* 获取用户积分信息
*/
@GetMapping("/info")
public ResponseEntity<?> getCreditsInfo(@RequestParam String openid) {
Map<String, Object> response = new HashMap<>();
if (openid == null || openid.isEmpty()) {
response.put("success", false);
response.put("message", "openid 不能为空");
return ResponseEntity.badRequest().body(response);
}
try {
UserCredits credits = creditsService.getUserCredits(openid);
Map<String, Object> data = new HashMap<>();
data.put("dailyCredits", credits.getDailyCredits());
data.put("totalCredits", credits.getTotalCredits());
data.put("watchedAdCount", credits.getWatchedAdCount());
data.put("maxAdPerDay", 5);
data.put("adReward", 3);
response.put("success", true);
response.put("data", data);
} catch (Exception e) {
log.error("获取积分信息失败", e);
response.put("success", false);
response.put("message", "获取失败: " + e.getMessage());
}
return ResponseEntity.ok(response);
}
/**
* 观看广告奖励
*/
@PostMapping("/reward")
public ResponseEntity<?> rewardForAd(@RequestParam String openid) {
Map<String, Object> response = new HashMap<>();
if (openid == null || openid.isEmpty()) {
response.put("success", false);
response.put("message", "openid 不能为空");
return ResponseEntity.badRequest().body(response);
}
try {
boolean success = creditsService.rewardForWatchingAd(openid);
if (success) {
UserCredits credits = creditsService.getUserCredits(openid);
response.put("success", true);
response.put("message", "观看成功,获得 3 次生成机会");
response.put("dailyCredits", credits.getDailyCredits());
response.put("watchedAdCount", credits.getWatchedAdCount());
} else {
response.put("success", false);
response.put("message", "今日观看次数已达上限");
}
} catch (Exception e) {
log.error("奖励失败", e);
response.put("success", false);
response.put("message", "奖励失败: " + e.getMessage());
}
return ResponseEntity.ok(response);
}
/**
* 检查是否可生成
*/
@GetMapping("/check")
public ResponseEntity<?> checkCanGenerate(@RequestParam String openid) {
Map<String, Object> response = new HashMap<>();
if (openid == null || openid.isEmpty()) {
response.put("success", false);
response.put("message", "openid 不能为空");
return ResponseEntity.badRequest().body(response);
}
try {
boolean canGenerate = creditsService.hasEnoughCredits(openid);
int remaining = creditsService.getRemainingCredits(openid);
response.put("success", true);
response.put("canGenerate", canGenerate);
response.put("remainingCredits", remaining);
} catch (Exception e) {
log.error("检查失败", e);
response.put("success", false);
response.put("message", "检查失败: " + e.getMessage());
}
return ResponseEntity.ok(response);
}
}

View File

@@ -1,13 +1,18 @@
package com.jiansu.naming.controller;
import com.jiansu.naming.model.NameCard;
import com.jiansu.naming.service.CreditsService;
import com.jiansu.naming.service.KimiService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
@RestController
@@ -19,18 +24,71 @@ public class NamingController {
@Autowired
private KimiService kimiService;
@Autowired
private CreditsService creditsService;
@GetMapping("/generate")
public List<NameCard> generate(
public ResponseEntity<?> generate(
@RequestParam(defaultValue = "清冷") String keyword,
@RequestParam(required = false) String mode,
@RequestParam(required = false) String surname,
@RequestParam(required = false, defaultValue = "3") Integer count,
@RequestParam(required = false) String batch) {
@RequestParam(required = false, defaultValue = "5") Integer count,
@RequestParam(required = false) String batch,
@RequestParam(required = false) String openid,
@RequestParam(required = false) String excludeNames) {
log.info("生成名字请求 - 关键词: {}, 模式: {}, 批次: {}, 数量: {}, openid: {}", keyword, mode, batch, count, openid);
// 检查并消耗积分(原子操作)
if (openid != null && !openid.isEmpty()) {
boolean consumed = creditsService.consumeCredit(openid);
if (!consumed) {
Map<String, Object> response = new HashMap<>();
response.put("success", false);
response.put("message", "今日灵感次数已用完,请前往个人中心静心阅读获取更多次数");
return ResponseEntity.ok(response);
}
}
// 解析需要排除的名字列表
List<String> excludeList = new ArrayList<String>();
if (excludeNames != null && !excludeNames.isEmpty()) {
excludeList = java.util.Arrays.asList(excludeNames.split(","));
}
// 使用异步方法获取结果确保5个不重复的名字
CompletableFuture<List<NameCard>> future = kimiService.generateUniqueNamesAsync(keyword, mode, surname, 5, excludeList);
List<NameCard> result = future.join();
Map<String, Object> response = new HashMap<>();
response.put("success", true);
response.put("data", result);
return ResponseEntity.ok(response);
}
/**
* AI 深度解析名字
*/
@PostMapping("/explain")
public ResponseEntity<?> explainName(@RequestBody Map<String, String> request) {
String name = request.get("name");
String context = request.get("context"); // 如:从事艺术行业
log.info("生成名字请求 - 关键词: {}, 模式: {}, 批次: {}, 数量: {}", keyword, mode, batch, count);
if (name == null || name.isEmpty()) {
Map<String, Object> response = new HashMap<>();
response.put("success", false);
response.put("message", "名字不能为空");
return ResponseEntity.badRequest().body(response);
}
// 使用异步方法获取结果
CompletableFuture<List<NameCard>> future = kimiService.generateNamesAsync(keyword, mode, surname, count);
return future.join();
log.info("AI 解析名字: {}, 语境: {}", name, context);
String explanation = kimiService.explainName(name, context);
Map<String, Object> response = new HashMap<>();
response.put("success", true);
response.put("data", explanation);
return ResponseEntity.ok(response);
}
}

View File

@@ -0,0 +1,149 @@
package com.jiansu.naming.controller;
import com.jiansu.naming.entity.SquarePost;
import com.jiansu.naming.service.SquareService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.Page;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
import java.util.Map;
@RestController
@RequestMapping("/api/square")
@CrossOrigin(origins = "*")
@Slf4j
public class SquareController {
@Autowired
private SquareService squareService;
/**
* 获取广场帖子列表
*/
@GetMapping("/posts")
public ResponseEntity<?> getPosts(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size,
@RequestParam(required = false) String tag,
@RequestParam(required = false) String keyword,
@RequestParam(defaultValue = "latest") String sort) {
Page<SquarePost> posts;
if (tag != null && !tag.isEmpty()) {
posts = squareService.getPostsByTag(tag, page, size);
} else if (keyword != null && !keyword.isEmpty()) {
posts = squareService.getPostsByKeyword(keyword, page, size);
} else if ("hot".equals(sort)) {
posts = squareService.getHotPosts(page, size);
} else {
posts = squareService.getPosts(page, size);
}
Map<String, Object> response = new HashMap<>();
response.put("success", true);
response.put("data", posts.getContent());
response.put("total", posts.getTotalElements());
response.put("hasMore", posts.hasNext());
return ResponseEntity.ok(response);
}
/**
* 发布帖子
*/
@PostMapping("/posts")
public ResponseEntity<?> createPost(
@RequestParam String name,
@RequestParam String origin,
@RequestParam String description,
@RequestParam String keyword,
@RequestParam String mode,
@RequestParam String openid,
@RequestParam(required = false) String imageUrl) {
log.info("发布帖子: {}", name);
SquarePost post = new SquarePost();
post.setName(name);
post.setOrigin(origin);
post.setDescription(description);
post.setKeyword(keyword);
post.setMode(mode);
post.setOpenid(openid);
post.setImageUrl(imageUrl);
post.setIsPublic(true);
post.setLikeCount(0);
SquarePost saved = squareService.createPost(post);
Map<String, Object> response = new HashMap<>();
response.put("success", true);
response.put("data", saved);
response.put("message", "发布成功");
return ResponseEntity.ok(response);
}
/**
* 点赞
*/
@PostMapping("/posts/{id}/like")
public ResponseEntity<?> likePost(@PathVariable Long id) {
squareService.likePost(id);
Map<String, Object> response = new HashMap<>();
response.put("success", true);
response.put("message", "点赞成功");
return ResponseEntity.ok(response);
}
/**
* 获取帖子详情
*/
@GetMapping("/posts/{id}")
public ResponseEntity<?> getPostDetail(@PathVariable Long id) {
SquarePost post = squareService.getPostById(id).orElse(null);
Map<String, Object> response = new HashMap<>();
if (post != null) {
response.put("success", true);
response.put("data", post);
} else {
response.put("success", false);
response.put("message", "帖子不存在");
}
return ResponseEntity.ok(response);
}
/**
* 删除帖子
*/
@DeleteMapping("/posts/{id}")
public ResponseEntity<?> deletePost(@PathVariable Long id, @RequestParam String openid) {
squareService.deletePost(id, openid);
Map<String, Object> response = new HashMap<>();
response.put("success", true);
response.put("message", "删除成功");
return ResponseEntity.ok(response);
}
/**
* 获取用户的帖子
*/
@GetMapping("/user/posts")
public ResponseEntity<?> getUserPosts(@RequestParam String openid) {
Map<String, Object> response = new HashMap<>();
response.put("success", true);
response.put("data", squareService.getUserPosts(openid));
return ResponseEntity.ok(response);
}
}

View File

@@ -0,0 +1,54 @@
package com.jiansu.naming.entity;
import lombok.Data;
import org.hibernate.annotations.CreationTimestamp;
import javax.persistence.*;
import java.time.LocalDateTime;
@Entity
@Table(name = "square_posts")
@Data
public class SquarePost {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "name_card_id")
private Long nameCardId;
@Column(name = "name", length = 50)
private String name;
@Column(name = "origin", length = 500)
private String origin;
@Column(name = "description", length = 1000)
private String description;
@Column(name = "image_url", length = 500)
private String imageUrl;
@Column(name = "tags", length = 200)
private String tags;
@Column(name = "keyword", length = 50)
private String keyword;
@Column(name = "mode", length = 20)
private String mode;
@Column(name = "like_count")
private Integer likeCount = 0;
@Column(name = "is_public")
private Boolean isPublic = true;
@Column(name = "openid", length = 100)
private String openid;
@CreationTimestamp
@Column(name = "created_at")
private LocalDateTime createdAt;
}

View File

@@ -0,0 +1,42 @@
package com.jiansu.naming.entity;
import lombok.Data;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.UpdateTimestamp;
import javax.persistence.*;
import java.time.LocalDate;
import java.time.LocalDateTime;
@Entity
@Table(name = "user_credits")
@Data
public class UserCredits {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "openid", length = 100, unique = true, nullable = false)
private String openid;
@Column(name = "total_credits")
private Integer totalCredits = 0;
@Column(name = "daily_credits")
private Integer dailyCredits = 5; // 每日赠送次数
@Column(name = "last_refresh_date")
private LocalDate lastRefreshDate;
@Column(name = "watched_ad_count")
private Integer watchedAdCount = 0; // 今日观看广告次数
@CreationTimestamp
@Column(name = "created_at")
private LocalDateTime createdAt;
@UpdateTimestamp
@Column(name = "updated_at")
private LocalDateTime updatedAt;
}

View File

@@ -12,7 +12,9 @@ import lombok.NoArgsConstructor;
public class NameCard {
private String name; // 名字
private String origin; // 出处/诗句
private String description; // 通感叙事描述
private String description; // "通感"叙事描述
private Double score; // 声韵评分(后端计算)
private String tone; // 平仄分析 (如:平仄)
private Boolean publicShared; // 是否已分享到广场
private Long squarePostId; // 广场帖子ID如果已分享
}

View File

@@ -0,0 +1,51 @@
package com.jiansu.naming.repository;
import com.jiansu.naming.entity.SquarePost;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public interface SquarePostRepository extends JpaRepository<SquarePost, Long> {
/**
* 查询公开的帖子
*/
Page<SquarePost> findByIsPublicTrueOrderByCreatedAtDesc(Pageable pageable);
/**
* 根据标签查询
*/
@Query("SELECT s FROM SquarePost s WHERE s.isPublic = true AND s.tags LIKE %:tag% ORDER BY s.createdAt DESC")
Page<SquarePost> findByTag(@Param("tag") String tag, Pageable pageable);
/**
* 根据关键词查询
*/
@Query("SELECT s FROM SquarePost s WHERE s.isPublic = true AND s.keyword = :keyword ORDER BY s.createdAt DESC")
Page<SquarePost> findByKeyword(@Param("keyword") String keyword, Pageable pageable);
/**
* 增加点赞数
*/
@Modifying
@Query("UPDATE SquarePost s SET s.likeCount = s.likeCount + 1 WHERE s.id = :id")
void incrementLikeCount(@Param("id") Long id);
/**
* 查询用户的帖子
*/
List<SquarePost> findByOpenidOrderByCreatedAtDesc(String openid);
/**
* 查询热门帖子
*/
@Query("SELECT s FROM SquarePost s WHERE s.isPublic = true ORDER BY s.likeCount DESC, s.createdAt DESC")
Page<SquarePost> findHotPosts(Pageable pageable);
}

View File

@@ -0,0 +1,15 @@
package com.jiansu.naming.repository;
import com.jiansu.naming.entity.UserCredits;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.Optional;
@Repository
public interface UserCreditsRepository extends JpaRepository<UserCredits, Long> {
Optional<UserCredits> findByOpenid(String openid);
boolean existsByOpenid(String openid);
}

View File

@@ -0,0 +1,124 @@
package com.jiansu.naming.service;
import com.jiansu.naming.entity.UserCredits;
import com.jiansu.naming.repository.UserCreditsRepository;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDate;
import java.util.Optional;
@Service
@Slf4j
public class CreditsService {
private static final int DAILY_FREE_CREDITS = 5; // 每日免费次数
private static final int AD_REWARD_CREDITS = 3; // 观看广告奖励次数
private static final int MAX_AD_PER_DAY = 5; // 每日最多观看广告次数
@Autowired
private UserCreditsRepository userCreditsRepository;
/**
* 获取或创建用户积分记录
*/
@Transactional
public UserCredits getOrCreateUserCredits(String openid) {
Optional<UserCredits> optional = userCreditsRepository.findByOpenid(openid);
if (optional.isPresent()) {
UserCredits credits = optional.get();
// 检查是否需要刷新每日次数
if (credits.getLastRefreshDate() == null ||
!credits.getLastRefreshDate().equals(LocalDate.now())) {
credits.setDailyCredits(DAILY_FREE_CREDITS);
credits.setWatchedAdCount(0);
credits.setLastRefreshDate(LocalDate.now());
userCreditsRepository.save(credits);
log.info("刷新用户每日次数: {}", openid);
}
return credits;
} else {
// 创建新记录
UserCredits credits = new UserCredits();
credits.setOpenid(openid);
credits.setTotalCredits(0);
credits.setDailyCredits(DAILY_FREE_CREDITS);
credits.setLastRefreshDate(LocalDate.now());
credits.setWatchedAdCount(0);
userCreditsRepository.save(credits);
log.info("创建用户积分记录: {}", openid);
return credits;
}
}
/**
* 检查是否有足够的次数
*/
public boolean hasEnoughCredits(String openid) {
UserCredits credits = getOrCreateUserCredits(openid);
int remaining = credits.getDailyCredits();
log.info("检查用户 {} 的剩余次数: {}", openid, remaining);
return remaining > 0;
}
/**
* 消耗一次生成次数(包含检查和消耗,原子操作)
*/
@Transactional
public boolean consumeCredit(String openid) {
UserCredits credits = getOrCreateUserCredits(openid);
int remaining = credits.getDailyCredits();
log.info("用户 {} 尝试消耗次数,当前剩余: {}", openid, remaining);
if (remaining > 0) {
credits.setDailyCredits(remaining - 1);
credits.setTotalCredits(credits.getTotalCredits() + 1);
userCreditsRepository.save(credits);
log.info("用户 {} 成功消耗一次生成次数,剩余: {}", openid, credits.getDailyCredits());
return true;
}
log.warn("用户 {} 次数不足,无法消耗", openid);
return false;
}
/**
* 观看广告奖励次数
*/
@Transactional
public boolean rewardForWatchingAd(String openid) {
UserCredits credits = getOrCreateUserCredits(openid);
if (credits.getWatchedAdCount() >= MAX_AD_PER_DAY) {
log.info("用户 {} 今日广告观看次数已达上限", openid);
return false;
}
credits.setDailyCredits(credits.getDailyCredits() + AD_REWARD_CREDITS);
credits.setWatchedAdCount(credits.getWatchedAdCount() + 1);
userCreditsRepository.save(credits);
log.info("用户 {} 观看广告获得 {} 次机会,今日已观看: {}",
openid, AD_REWARD_CREDITS, credits.getWatchedAdCount());
return true;
}
/**
* 获取用户积分信息
*/
public UserCredits getUserCredits(String openid) {
return getOrCreateUserCredits(openid);
}
/**
* 获取剩余次数
*/
public int getRemainingCredits(String openid) {
UserCredits credits = getOrCreateUserCredits(openid);
return credits.getDailyCredits();
}
}

View File

@@ -6,6 +6,7 @@ import com.jiansu.naming.repository.UserFavoriteRepository;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.stream.Collectors;
@@ -62,6 +63,7 @@ public class FavoriteService {
/**
* 取消收藏
*/
@Transactional
public void removeFavorite(String openid, String name) {
userFavoriteRepository.deleteByOpenidAndName(openid, name);
log.info("用户 {} 取消收藏名字 {}", openid, name);

View File

@@ -34,6 +34,11 @@ public class KimiService {
@Value("${kimi.api-url}")
private String apiUrl;
// 模型策略配置
private static final String MODEL_STANDARD = "moonshot-v1-8k";
private static final String MODEL_PREMIUM = "moonshot-v1-32k";
private static final double PREMIUM_THRESHOLD = 9.5; // 高分名字使用高级模型
private final ToneAnalysisService toneAnalysisService;
@@ -124,49 +129,91 @@ public class KimiService {
* @return CompletableFuture<List<NameCard>>
*/
public CompletableFuture<List<NameCard>> generateNamesAsync(String keyword, String mode, String surname, int count) {
return generateUniqueNamesAsync(keyword, mode, surname, count, new ArrayList<>());
}
/**
* 异步生成指定数量的不重复名字
* @param keyword 关键词
* @param mode 模式
* @param surname 姓氏
* @param targetCount 目标数量默认5个
* @param excludeNames 需要排除的名字列表
* @return CompletableFuture<List<NameCard>>
*/
public CompletableFuture<List<NameCard>> generateUniqueNamesAsync(String keyword, String mode, String surname, int targetCount, List<String> excludeNames) {
long startTime = System.currentTimeMillis();
log.info("开始异步生成 {} 个名字,模式: {}", count, mode);
log.info("开始异步生成 {} 个不重复名字,模式: {},排除: {}", targetCount, mode, excludeNames);
// 后门:当关键词为 "test" 时,返回固定的测试数据
if ("test".equalsIgnoreCase(keyword)) {
log.info("Keyword is 'test', returning hardcoded test data.");
return CompletableFuture.completedFuture(createTestData());
}
// 默认使用 classic 模式
final String finalMode = (mode == null || mode.isEmpty()) ? "classic" : mode;
// 创建 count 个并行任务
List<CompletableFuture<NameCard>> futures = new ArrayList<>();
for (int i = 0; i < count; i++) {
final int index = i;
CompletableFuture<NameCard> future = CompletableFuture.supplyAsync(() -> {
try {
return callAiToGenerateSingleName(keyword, finalMode, surname, index);
} catch (Exception e) {
log.error("第 {} 个名字生成失败: {}", index, e.getMessage());
// 使用 Set 存储已生成的名字,确保唯一性
java.util.Set<String> generatedNames = new java.util.HashSet<>(excludeNames);
java.util.List<NameCard> results = new ArrayList<>();
// 最多尝试 3 轮,每轮生成 targetCount 个
int maxRounds = 3;
int round = 0;
while (results.size() < targetCount && round < maxRounds) {
round++;
int needCount = targetCount - results.size();
log.info("第 {} 轮生成,需要 {} 个名字", round, needCount);
// 创建本轮并行任务
List<CompletableFuture<NameCard>> futures = new ArrayList<>();
for (int i = 0; i < needCount; i++) {
final int index = i + (round - 1) * targetCount;
CompletableFuture<NameCard> future = CompletableFuture.supplyAsync(() -> {
try {
return callAiToGenerateSingleName(keyword, finalMode, surname, index);
} catch (Exception e) {
log.error("第 {} 个名字生成失败: {}", index, e.getMessage());
return null;
}
}, jianSuAiExecutor).exceptionally(ex -> {
log.error("第 {} 个任务异常: {}", index, ex.getMessage());
return null;
});
futures.add(future);
}
// 等待本轮任务完成
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
// 收集本轮结果,去重
for (CompletableFuture<NameCard> future : futures) {
NameCard card = future.join();
if (card != null && card.getName() != null) {
String nameKey = card.getName().trim();
// 检查是否与已生成的重复
if (!generatedNames.contains(nameKey)) {
generatedNames.add(nameKey);
results.add(card);
log.debug("添加名字: {}", nameKey);
// 如果已经收集够,提前退出
if (results.size() >= targetCount) {
break;
}
} else {
log.debug("跳过重复名字: {}", nameKey);
}
}
}, jianSuAiExecutor).exceptionally(ex -> {
log.error("第 {} 个任务异常: {}", index, ex.getMessage());
return null;
});
futures.add(future);
}
}
// 等待所有任务完成并合并结果
return CompletableFuture.allOf(futures.toArray(new CompletableFuture[0]))
.thenApply(v -> {
List<NameCard> results = futures.stream()
.map(CompletableFuture::join)
.filter(card -> card != null)
.collect(Collectors.toList());
long duration = System.currentTimeMillis() - startTime;
log.info("异步生成完成,成功 {} 个,耗时 {}ms", results.size(), duration);
return results;
});
long duration = System.currentTimeMillis() - startTime;
log.info("异步生成完成,成功 {} 个不重复名字,耗时 {}ms共进行 {} 轮", results.size(), duration, round);
return CompletableFuture.completedFuture(results);
}
/**
@@ -174,16 +221,20 @@ public class KimiService {
*/
private NameCard callAiToGenerateSingleName(String keyword, String mode, String surname, int seed) {
String promptTemplate = PROMPT_TEMPLATES.getOrDefault(mode, PROMPT_TEMPLATES.get("classic"));
// 动态构建 Prompt加入 seed 使每次请求略有不同
String systemPrompt = promptTemplate
.replace("{keyword}", keyword != null ? keyword : "")
.replace("{surname}", surname != null ? surname : "")
+ " 请确保创意独特,避免与之前生成的名字重复。";
// 模型选择策略前2个使用标准模型第3个及以后使用高级模型
String model = seed < 2 ? MODEL_STANDARD : MODEL_PREMIUM;
log.info("使用模型 {} 生成第 {} 个名字", model, seed + 1);
// 构建 Kimi API 请求体 (OpenAI 兼容格式)
JSONObject jsonBody = new JSONObject();
jsonBody.put("model", "moonshot-v1-8k");
jsonBody.put("model", model);
jsonBody.put("temperature", 0.7);
// 使用随机种子确保每次生成结果不同
jsonBody.put("seed", (int) (Math.random() * 1000000) + seed * 1000);
@@ -311,4 +362,189 @@ public class KimiService {
return testData;
}
/**
* AI 深度解析名字
*/
public String explainName(String name, String context) {
String prompt = buildExplainPrompt(name, context);
Map<String, Object> jsonBody = new HashMap<>();
jsonBody.put("model", "moonshot-v1-8k");
jsonBody.put("temperature", 0.7);
List<Map<String, String>> messages = new ArrayList<>();
Map<String, String> systemMsg = new HashMap<>();
systemMsg.put("role", "system");
systemMsg.put("content", "你是一位精通中国传统文化、姓名学、诗词典故的起名专家。请用优美、富有诗意的语言解析名字。");
messages.add(systemMsg);
Map<String, String> userMsg = new HashMap<>();
userMsg.put("role", "user");
userMsg.put("content", prompt);
messages.add(userMsg);
jsonBody.put("messages", messages);
RequestBody body = RequestBody.create(
JSON.toJSONString(jsonBody),
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();
return parseExplanation(responseBody);
} else {
String errorMsg = response.body() != null ? response.body().string() : response.message();
log.error("Kimi API Error: {} - {}", response.code(), errorMsg);
return "抱歉AI 解析暂时不可用,请稍后重试。";
}
} catch (IOException e) {
log.error("API Error: ", e);
return "网络请求失败,请检查网络连接。";
}
}
/**
* 构建解析 Prompt
*/
private String buildExplainPrompt(String name, String context) {
StringBuilder prompt = new StringBuilder();
prompt.append("请深度解析名字\"").append(name).append("\"");
if (context != null && !context.isEmpty()) {
prompt.append(",针对\"").append(context).append("\"这个背景");
}
prompt.append("。请从以下几个方面分析:\n\n" +
"1. 字面意境:每个字的含义和组合后的整体意象\n" +
"2. 诗词出处:是否有相关的诗词典故\n" +
"3. 性格暗示:这个名字给人的感觉和性格特质\n" +
"4. 适用场景:适合什么样的人或场合\n\n" +
"请用优美、富有诗意的语言回答控制在300字以内。"
);
return prompt.toString();
}
/**
* 解析 AI 回复
*/
private String parseExplanation(String responseBody) {
try {
JSONObject root = JSON.parseObject(responseBody);
JSONObject error = root.getJSONObject("error");
if (error != null) {
String errorMsg = error.getString("message");
log.error("Kimi API error: {}", errorMsg);
return "解析失败,请稍后重试。";
}
JSONArray choices = root.getJSONArray("choices");
if (choices == null || choices.isEmpty()) {
return "暂无解析内容。";
}
JSONObject firstChoice = choices.getJSONObject(0);
JSONObject message = firstChoice.getJSONObject("message");
return message.getString("content");
} catch (Exception e) {
log.error("解析 AI 回复失败", e);
return "解析失败,请稍后重试。";
}
}
// ==================== 模型策略方法 ====================
/**
* 根据分数选择模型
* 高分名字使用高级模型以获得更好的质量
*/
private String selectModelByScore(double score) {
return score >= PREMIUM_THRESHOLD ? MODEL_PREMIUM : MODEL_STANDARD;
}
/**
* 根据请求类型选择模型
*/
private String selectModelByRequestType(String requestType) {
switch (requestType) {
case "deep_explain": // 深度解析
case "premium": // 高级生成
return MODEL_PREMIUM;
case "standard":
default:
return MODEL_STANDARD;
}
}
/**
* 使用高级模型生成名字(用于高质量需求)
*/
public NameCard generatePremiumName(String keyword, String mode, String surname) {
log.info("使用高级模型生成名字 - 关键词: {}, 模式: {}", keyword, mode);
String promptTemplate = PROMPT_TEMPLATES.getOrDefault(mode, PROMPT_TEMPLATES.get("classic"));
String prompt = promptTemplate
.replace("{keyword}", keyword != null ? keyword : "")
.replace("{surname}", surname != null ? surname : "");
Map<String, Object> jsonBody = new HashMap<>();
jsonBody.put("model", MODEL_PREMIUM);
jsonBody.put("temperature", 0.7);
List<Map<String, String>> messages = new ArrayList<>();
Map<String, String> systemMsg = new HashMap<>();
systemMsg.put("role", "system");
systemMsg.put("content", "你是「见素」的首席起名大师,精通中国传统文化、诗词典故、音韵学。请创作极具意境和美感的名字。");
messages.add(systemMsg);
Map<String, String> userMsg = new HashMap<>();
userMsg.put("role", "user");
userMsg.put("content", prompt);
messages.add(userMsg);
jsonBody.put("messages", messages);
RequestBody body = RequestBody.create(
JSON.toJSONString(jsonBody),
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();
NameCard card = parseSingleNameCard(responseBody);
// 高级模型生成的名字给予更高评分
card.setScore(Math.min(10.0, card.getScore() + 0.3));
return card;
} else {
String errorMsg = response.body() != null ? response.body().string() : response.message();
log.error("Kimi Premium API Error: {} - {}", response.code(), errorMsg);
throw new RuntimeException("高级模型调用失败");
}
} catch (IOException e) {
log.error("Premium API Error: ", e);
throw new RuntimeException("网络请求失败");
}
}
}

View File

@@ -0,0 +1,98 @@
package com.jiansu.naming.service;
import com.jiansu.naming.entity.SquarePost;
import com.jiansu.naming.repository.SquarePostRepository;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Optional;
@Service
@Slf4j
public class SquareService {
@Autowired
private SquarePostRepository squarePostRepository;
/**
* 发布帖子
*/
@Transactional
public SquarePost createPost(SquarePost post) {
log.info("发布广场帖子: {}", post.getName());
return squarePostRepository.save(post);
}
/**
* 获取帖子列表(分页)
*/
public Page<SquarePost> getPosts(int page, int size) {
Pageable pageable = PageRequest.of(page, size);
return squarePostRepository.findByIsPublicTrueOrderByCreatedAtDesc(pageable);
}
/**
* 获取热门帖子
*/
public Page<SquarePost> getHotPosts(int page, int size) {
Pageable pageable = PageRequest.of(page, size);
return squarePostRepository.findHotPosts(pageable);
}
/**
* 根据标签查询
*/
public Page<SquarePost> getPostsByTag(String tag, int page, int size) {
Pageable pageable = PageRequest.of(page, size);
return squarePostRepository.findByTag(tag, pageable);
}
/**
* 根据关键词查询
*/
public Page<SquarePost> getPostsByKeyword(String keyword, int page, int size) {
Pageable pageable = PageRequest.of(page, size);
return squarePostRepository.findByKeyword(keyword, pageable);
}
/**
* 点赞
*/
@Transactional
public void likePost(Long postId) {
log.info("帖子点赞: {}", postId);
squarePostRepository.incrementLikeCount(postId);
}
/**
* 获取帖子详情
*/
public Optional<SquarePost> getPostById(Long id) {
return squarePostRepository.findById(id);
}
/**
* 删除帖子
*/
@Transactional
public void deletePost(Long id, String openid) {
Optional<SquarePost> post = squarePostRepository.findById(id);
if (post.isPresent() && post.get().getOpenid().equals(openid)) {
squarePostRepository.deleteById(id);
log.info("删除帖子: {}", id);
}
}
/**
* 获取用户的帖子
*/
public List<SquarePost> getUserPosts(String openid) {
return squarePostRepository.findByOpenidOrderByCreatedAtDesc(openid);
}
}

View File

@@ -38,6 +38,12 @@ kimi:
api-key: ${KIMI_API_KEY:sk-EORjVwYTlXMTIFmelkt6ebWlOOLk9qCkm2PR0tvKXdkAnSdd}
api-url: https://api.moonshot.cn/v1/chat/completions
# 微信小程序配置
wechat:
miniapp:
app-id: wx4793dc8fe6f34d2d
app-secret: d6514ef9115f2fa5224da98800a7bd8d
# 日志配置
logging:
level:

View File

@@ -38,6 +38,12 @@ kimi:
api-key: ${KIMI_API_KEY:sk-EORjVwYTlXMTIFmelkt6ebWlOOLk9qCkm2PR0tvKXdkAnSdd}
api-url: https://api.moonshot.cn/v1/chat/completions
# 微信小程序配置
wechat:
miniapp:
app-id: wx4793dc8fe6f34d2d
app-secret: d6514ef9115f2fa5224da98800a7bd8d
# 日志配置
logging:
level:

View File

@@ -8,32 +8,72 @@ App({
swipe: wx.createInnerAudioContext() // 滑动切换
};
// 预设音效资源
// 预设音效资源(本地文件)
// 翻页声 - 纸张摩擦
this.globalData.audioContexts.flip.src = 'https://assets.mixkit.co/active_storage/sfx/2571/2571-preview.mp3';
this.globalData.audioContexts.flip.src = '/assets/audio/flip.mp3';
// 收藏成功 - 清脆铃声
this.globalData.audioContexts.success.src = 'https://assets.mixkit.co/active_storage/sfx/2000/2000-preview.mp3';
this.globalData.audioContexts.success.src = '/assets/audio/success.mp3';
// 水滴声 - 水墨滴落
this.globalData.audioContexts.inkDrop.src = 'https://assets.mixkit.co/active_storage/sfx/2578/2578-preview.mp3';
this.globalData.audioContexts.inkDrop.src = '/assets/audio/inkDrop.mp3';
// 滑动声
this.globalData.audioContexts.swipe.src = 'https://assets.mixkit.co/active_storage/sfx/2571/2571-preview.mp3';
this.globalData.audioContexts.swipe.src = '/assets/audio/swipe.mp3';
// 微信登录获取 openid
this.wxLogin();
},
// 微信登录
wxLogin() {
wx.login({
success: (res) => {
if (res.code) {
// 发送 code 到后端换取 openid
wx.request({
url: `${this.globalData.apiBaseUrl}/api/auth/login`,
method: 'POST',
data: { code: res.code },
success: (response) => {
if (response.data && response.data.success) {
this.globalData.openid = response.data.openid;
console.log('登录成功openid:', response.data.openid);
} else {
console.error('登录失败:', response.data);
}
},
fail: (err) => {
console.error('登录请求失败:', err);
}
});
} else {
console.error('登录失败:', res.errMsg);
}
}
});
},
// 获取 openid如果还未获取到则返回 null
getOpenid() {
return this.globalData.openid;
},
playAudio(type) {
const ctx = this.globalData.audioContexts[type];
if (ctx) {
ctx.stop();
ctx.play().catch(err => {
try {
ctx.play();
} catch (err) {
console.log('音效播放失败:', err);
});
}
}
},
globalData: {
audioContexts: {},
openid: null,
// API 基础地址 - 修改这里即可切换环境
// apiBaseUrl: 'http://localhost:8080'
apiBaseUrl: 'https://feast.yidaima.cn/jsu'
apiBaseUrl: 'http://localhost:8080'
// apiBaseUrl: 'https://feast.yidaima.cn/jsu'
// 生产环境:'https://api.yourdomain.com'
}
});

View File

@@ -2,7 +2,8 @@
"pages": [
"pages/home/home",
"pages/index/index",
"pages/profile/profile"
"pages/profile/profile",
"pages/square/square"
],
"window": {
"backgroundColor": "#FFFFFF",
@@ -11,6 +12,7 @@
"navigationBarTitleText": "见素",
"navigationBarTextStyle": "black"
},
"requiredBackgroundModes": ["audio"],
"sitemapLocation": "sitemap.json",
"lazyCodeLoading": "requiredComponents"
}

View File

@@ -0,0 +1,108 @@
// 字源溯源弹窗组件
Component({
properties: {
visible: {
type: Boolean,
value: false
},
char: {
type: String,
value: ''
}
},
data: {
pinyin: '',
radical: '',
strokes: '',
wuxing: '',
meaning: '',
imagery: '',
poetry: '',
loading: false
},
observers: {
'visible, char': function(visible, char) {
if (visible && char) {
this.loadCharDetail(char);
}
}
},
methods: {
// 加载汉字详情
loadCharDetail(char) {
this.setData({ loading: true });
// 先从本地缓存查找
const cache = wx.getStorageSync(`char_${char}`);
if (cache) {
this.setData({ ...cache, loading: false });
return;
}
// 调用后端接口获取
const apiBaseUrl = getApp().globalData.apiBaseUrl;
wx.request({
url: `${apiBaseUrl}/api/char/detail`,
data: { char },
success: (res) => {
if (res.data && res.data.success) {
const data = res.data.data;
this.setData({ ...data, loading: false });
// 缓存结果
wx.setStorageSync(`char_${char}`, data);
} else {
// 使用默认数据
this.setDefaultData(char);
}
},
fail: () => {
this.setDefaultData(char);
}
});
},
// 设置默认数据
setDefaultData(char) {
// 简单的拼音映射(实际应该从后端获取)
const pinyinMap = {
'兰': 'lán', '泽': 'zé', '芳': 'fāng', '风': 'fēng',
'墨': 'mò', '染': 'rǎn', '清': 'qīng', '欢': 'huān',
'素': 'sù', '简': 'jiǎn', '雅': 'yǎ', '致': 'zhì'
};
this.setData({
pinyin: pinyinMap[char] || '',
radical: '',
strokes: '',
wuxing: '',
meaning: '暂无详细解析,点击"问问 AI"获取深度分析',
imagery: '',
poetry: '',
loading: false
});
},
// 点击遮罩关闭
onMaskTap() {
this.triggerEvent('close');
},
// 阻止冒泡
onContainerTap() {
// 什么都不做,只是阻止冒泡
},
// 点击关闭按钮
onClose() {
this.triggerEvent('close');
},
// 问问 AI
onAskAI() {
this.triggerEvent('askAI', { char: this.data.char });
}
}
});

View File

@@ -0,0 +1,4 @@
{
"component": true,
"usingComponents": {}
}

View File

@@ -0,0 +1,59 @@
<!-- 字源溯源弹窗组件 -->
<view class="char-detail-mask {{visible ? 'show' : ''}}" bindtap="onMaskTap">
<view class="char-detail-container {{visible ? 'show' : ''}}" catchtap="onContainerTap">
<!-- 磨砂玻璃背景 -->
<view class="glass-bg"></view>
<!-- 关闭按钮 -->
<view class="close-btn" bindtap="onClose">×</view>
<!-- 大字展示 -->
<view class="char-display">
<text class="char-text">{{char}}</text>
<view class="char-pinyin">{{pinyin}}</view>
</view>
<!-- 字源解析 -->
<scroll-view class="char-content" scroll-y>
<!-- 基础信息 -->
<view class="info-section">
<view class="info-item">
<text class="label">部首</text>
<text class="value">{{radical || '未知'}}</text>
</view>
<view class="info-item">
<text class="label">笔画</text>
<text class="value">{{strokes || '未知'}}画</text>
</view>
<view class="info-item">
<text class="label">五行</text>
<text class="value">{{wuxing || '未知'}}</text>
</view>
</view>
<!-- 字义解析 -->
<view class="meaning-section">
<view class="section-title">本义</view>
<text class="meaning-text">{{meaning || '暂无解析'}}</text>
</view>
<!-- 意象分析 -->
<view class="imagery-section">
<view class="section-title">起名意象</view>
<text class="imagery-text">{{imagery || '暂无解析'}}</text>
</view>
<!-- 诗词典故 -->
<view class="poetry-section" wx:if="{{poetry}}">
<view class="section-title">诗词典故</view>
<text class="poetry-text">{{poetry}}</text>
</view>
</scroll-view>
<!-- AI 解析按钮 -->
<view class="ai-btn" bindtap="onAskAI">
<text class="ai-icon">✦</text>
<text class="ai-text">问问 AI</text>
</view>
</view>
</view>

View File

@@ -0,0 +1,178 @@
/* 字源溯源弹窗组件 - 磨砂玻璃风格 */
.char-detail-mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
visibility: hidden;
transition: all 0.3s ease;
z-index: 1000;
}
.char-detail-mask.show {
opacity: 1;
visibility: visible;
}
.char-detail-container {
position: relative;
width: 600rpx;
max-height: 800rpx;
border-radius: 24rpx;
overflow: hidden;
transform: scale(0.9) translateY(20rpx);
transition: all 0.3s ease;
}
.char-detail-container.show {
transform: scale(1) translateY(0);
}
/* 磨砂玻璃背景 */
.glass-bg {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(255, 255, 255, 0.85);
backdrop-filter: blur(20rpx);
-webkit-backdrop-filter: blur(20rpx);
}
/* 关闭按钮 */
.close-btn {
position: absolute;
top: 20rpx;
right: 20rpx;
width: 48rpx;
height: 48rpx;
display: flex;
align-items: center;
justify-content: center;
font-size: 36rpx;
color: #666;
z-index: 10;
}
/* 大字展示 */
.char-display {
position: relative;
padding: 60rpx 0 40rpx;
text-align: center;
border-bottom: 1rpx solid rgba(0, 0, 0, 0.05);
}
.char-text {
font-size: 120rpx;
font-family: 'KaiTi', 'STKaiti', serif;
color: #2D2D2D;
line-height: 1;
}
.char-pinyin {
margin-top: 16rpx;
font-size: 28rpx;
color: #666;
letter-spacing: 4rpx;
}
/* 内容区域 */
.char-content {
position: relative;
max-height: 400rpx;
padding: 30rpx 40rpx;
}
/* 基础信息 */
.info-section {
display: flex;
justify-content: space-around;
padding: 20rpx 0;
margin-bottom: 30rpx;
border-bottom: 1rpx solid rgba(0, 0, 0, 0.05);
}
.info-item {
text-align: center;
}
.info-item .label {
display: block;
font-size: 22rpx;
color: #999;
margin-bottom: 8rpx;
}
.info-item .value {
display: block;
font-size: 28rpx;
color: #333;
font-weight: 500;
}
/* 章节标题 */
.section-title {
font-size: 26rpx;
color: #2D2D2D;
font-weight: 600;
margin-bottom: 16rpx;
padding-left: 16rpx;
border-left: 4rpx solid #B22222;
}
/* 内容文本 */
.meaning-section,
.imagery-section,
.poetry-section {
margin-bottom: 30rpx;
}
.meaning-text,
.imagery-text,
.poetry-text {
font-size: 28rpx;
color: #555;
line-height: 1.8;
text-align: justify;
}
.poetry-text {
font-style: italic;
color: #666;
}
/* AI 按钮 */
.ai-btn {
position: relative;
margin: 0 40rpx 40rpx;
padding: 24rpx 0;
background: linear-gradient(135deg, #2D2D2D 0%, #444 100%);
border-radius: 40rpx;
display: flex;
align-items: center;
justify-content: center;
gap: 12rpx;
}
.ai-btn:active {
opacity: 0.9;
}
.ai-icon {
font-size: 28rpx;
color: #fff;
}
.ai-text {
font-size: 28rpx;
color: #fff;
letter-spacing: 4rpx;
}

View File

@@ -11,8 +11,19 @@ Page({
]
},
onLoad() {
onLoad(options) {
// 页面加载时检查是否需要重置状态
// 处理从其他页面传入的参数
if (options.keyword) {
this.setData({
keyword: decodeURIComponent(options.keyword)
});
}
if (options.mode) {
this.setData({
activeMode: options.mode
});
}
},
onShow() {
@@ -22,6 +33,13 @@ Page({
});
},
// 跳转到灵感广场
goToSquare() {
wx.navigateTo({
url: '/pages/square/square'
});
},
// 选择模式
selectMode(e) {
const mode = e.currentTarget.dataset.mode;
@@ -89,7 +107,7 @@ Page({
if (activeMode === 'baby' && surname) {
url += `&surname=${encodeURIComponent(surname)}`;
}
wx.navigateTo({
url: url
});

View File

@@ -52,6 +52,12 @@
</view>
</view>
<!-- 灵感广场入口 -->
<view class="square-entry {{isGenerating ? 'fade-out' : ''}}" bindtap="goToSquare">
<text class="entry-text">入林搜寻灵感</text>
<view class="entry-arrow"></view>
</view>
<view class="footer {{isGenerating ? 'fade-out' : ''}}">
<text>© 见素 · 审美溢价</text>
</view>

View File

@@ -231,3 +231,42 @@ page {
70% { opacity: 0; }
100% { opacity: 1; }
}
/* 灵感广场入口 */
.square-entry {
position: fixed;
bottom: 140rpx;
left: 0;
right: 0;
display: flex;
align-items: center;
justify-content: center;
padding: 20rpx 40rpx;
opacity: 0.5;
transition: opacity 0.3s ease;
z-index: 10;
}
.square-entry:active {
opacity: 0.8;
}
.square-entry.fade-out {
opacity: 0;
}
.entry-text {
font-size: 24rpx;
color: #888888;
letter-spacing: 4rpx;
font-family: "Noto Serif SC", serif;
}
.entry-arrow {
width: 10rpx;
height: 10rpx;
border-right: 1rpx solid #888888;
border-top: 1rpx solid #888888;
transform: rotate(45deg);
margin-left: 10rpx;
}

View File

@@ -35,6 +35,7 @@ Page({
// 动画控制
translateX: 0,
translateY: 0,
rotate: 0,
opacity: 1,
transition: 'none',
@@ -45,7 +46,25 @@ Page({
// 海报相关
showPoster: false,
posterNameChars: []
posterNameChars: [],
// 音频相关
showMusicMenu: false,
currentAmbience: 'silent',
// 字源弹窗
showCharDetail: false,
selectedChar: '',
charDetailData: null,
// AI 解析弹窗
showAIModal: false,
aiContext: '',
aiExplanation: '',
aiLoading: false,
// 结语卡片
showEndingCard: false
},
onLoad(options) {
@@ -91,7 +110,11 @@ Page({
// 从后端加载收藏列表
loadCollectedNames() {
const openid = 'test_openid'; // 实际应从登录获取
const openid = getApp().getOpenid();
if (!openid) {
console.log('openid 未获取到,跳过加载收藏');
return;
}
const apiBaseUrl = getApp().globalData.apiBaseUrl;
wx.request({
url: `${apiBaseUrl}/api/favorites/list`,
@@ -119,38 +142,55 @@ Page({
this.fetchFirstBatch(keyword, mode, surname, 2);
},
// 第一波:快速获取前2个名字
// 第一波:快速获取前5个名字
fetchFirstBatch(keyword, mode, surname, count) {
const apiBaseUrl = getApp().globalData.apiBaseUrl;
const requestData = {
keyword,
count: count,
const openid = getApp().getOpenid();
// 获取已生成的名字列表(用于去重)
const existingNames = this.data.nameList.map(card => card.name).join(',');
const requestData = {
keyword,
count: 5,
batch: 'first'
};
if (mode) requestData.mode = mode;
if (surname) requestData.surname = surname;
if (openid) requestData.openid = openid;
if (existingNames) requestData.excludeNames = existingNames;
wx.request({
url: `${apiBaseUrl}/api/names/generate`,
data: requestData,
success: (res) => {
if (res.statusCode === 200 && res.data && res.data.length > 0) {
// 检查是否积分不足
if (res.data && !res.data.success) {
wx.showToast({ title: res.data.message || '次数已用完', icon: 'none' });
this.setData({ isLoading: false });
return;
}
const nameList = res.data && res.data.data ? res.data.data : res.data;
if (res.statusCode === 200 && nameList && nameList.length > 0) {
// 清除定时器
if (this.loadingQuoteTimer) {
clearInterval(this.loadingQuoteTimer);
this.loadingQuoteTimer = null;
}
// 立即展示第一波结果
this.setData({
nameList: res.data,
currentIndex: 0,
isLoading: false,
cardKey: this.data.cardKey + 1
// 立即展示结果5个名字
this.setData({
nameList: nameList,
currentIndex: 0,
isLoading: false,
cardKey: this.data.cardKey + 1
});
// 静默加载剩余3个名字
this.fetchSecondBatch(keyword, mode, surname, 3);
// 呼吸震动效果 - 名字逐字显现时的节奏震动
this.breatheVibrate(nameList[0].name);
console.log('已加载 5 个不重复名字:', nameList.map(n => n.name).join(', '));
} else {
this.handleFetchError('意境未达,请重试');
}
@@ -161,43 +201,16 @@ Page({
});
},
// 第二波:静默加载剩余名字
fetchSecondBatch(keyword, mode, surname, count) {
const apiBaseUrl = getApp().globalData.apiBaseUrl;
const requestData = {
keyword,
count: count,
batch: 'second'
};
if (mode) requestData.mode = mode;
if (surname) requestData.surname = surname;
wx.request({
url: `${apiBaseUrl}/api/names/generate`,
data: requestData,
success: (res) => {
if (res.statusCode === 200 && res.data && res.data.length > 0) {
// 平滑合并数据
const currentList = this.data.nameList;
const newList = [...currentList, ...res.data];
this.setData({ nameList: newList });
console.log('第二波数据已静默加载,当前共', newList.length, '个名字');
}
},
fail: (err) => {
console.log('第二波加载失败(不影响已展示内容):', err);
}
});
},
// 处理加载错误
// 处理加载错误 - 显示结语卡片
handleFetchError(message) {
if (this.loadingQuoteTimer) {
clearInterval(this.loadingQuoteTimer);
this.loadingQuoteTimer = null;
}
this.setData({ isLoading: false });
wx.showToast({ title: message, icon: 'none' });
this.setData({
isLoading: false,
showEndingCard: true
});
},
// 返回首页
@@ -258,6 +271,20 @@ Page({
if (this.isExiting) return;
const deltaX = e.touches[0].clientX - this.startX;
const deltaY = e.touches[0].clientY - this.startY;
// 检测上划进入广场在Y轴移动超过X轴且向上滑动
if (Math.abs(deltaY) > Math.abs(deltaX) && deltaY < -50 && !this.isDragging) {
this.isDragging = true;
this.isSwipingUp = true;
this.setData({
translateY: deltaY,
opacity: 1 - Math.abs(deltaY) / 400,
transition: 'none'
});
return;
}
// 只有移动超过5px才真正判定为"拖拽"
if (Math.abs(deltaX) > 5) {
this.isDragging = true;
@@ -283,8 +310,47 @@ Page({
if (!this.isDragging) {
return;
}
// 以下是拖拽结束后的逻辑
// 处理上划进入广场
if (this.isSwipingUp) {
const deltaY = this.data.translateY;
if (Math.abs(deltaY) > 150) {
// 上划距离足够,进入广场
this.isExiting = true;
this.setData({
translateY: -800,
opacity: 0,
transition: 'all 0.4s cubic-bezier(0.6, -0.28, 0.735, 0.045)'
});
// 延迟后跳转
setTimeout(() => {
this.onGoToSquare();
// 重置状态
this.setData({
translateY: 0,
opacity: 1,
transition: 'none'
});
this.isExiting = false;
this.isDragging = false;
this.isSwipingUp = false;
}, 400);
} else {
// 上划距离不够,回弹
this.setData({
translateY: 0,
opacity: 1,
transition: 'all 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275)'
});
setTimeout(() => {
this.isDragging = false;
this.isSwipingUp = false;
}, 100);
}
return;
}
// 以下是拖拽结束后的逻辑(左右滑动)
const deltaX = this.lastX - this.startX;
const deltaTime = Date.now() - this.startTime;
const velocity = deltaX / deltaTime;
@@ -294,12 +360,12 @@ Page({
this.isExiting = true;
const direction = deltaX > 0 ? 1 : -1;
const targetX = direction * 500;
// 播放滑动音效
getApp().playAudio('swipe');
// 触觉反馈
wx.vibrateShort({ type: 'light' });
this.setData({
translateX: targetX,
opacity: 0,
@@ -313,7 +379,7 @@ Page({
transition: 'all 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275)'
});
}
// 延迟重置 isDragging 标志位。
// 这是为了确保在系统触发 tap 事件时isDragging 标志仍然为 true
// 从而让 onFlip 方法可以正确地忽略掉这次由拖拽产生的 tap。
@@ -349,7 +415,11 @@ Page({
// 同步收藏到后端
syncFavoriteToBackend(card) {
const openid = 'test_openid'; // 实际应从登录获取
const openid = getApp().getOpenid();
if (!openid) {
console.log('openid 未获取到,无法同步收藏');
return;
}
const apiBaseUrl = getApp().globalData.apiBaseUrl;
wx.request({
url: `${apiBaseUrl}/api/favorites/add?openid=${openid}&mode=${this.data.mode}&keyword=${encodeURIComponent(this.data.keyword)}`,
@@ -390,26 +460,49 @@ Page({
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.setData({ isLoading: true });
this.startLoadingQuoteRotation();
this.fetchNamesBatch(this.data.keyword, this.data.mode, this.data.surname);
} else {
this.onBack();
}
}
});
// 检查是否还有剩余次数
this.checkAndLoadMore();
}
this.isExiting = false;
});
});
},
// 检查是否还有次数,有则自动加载,无则显示结语卡片
checkAndLoadMore() {
const openid = getApp().getOpenid();
if (!openid) {
this.setData({ showEndingCard: true });
return;
}
const apiBaseUrl = getApp().globalData.apiBaseUrl;
wx.request({
url: `${apiBaseUrl}/api/credits/info`,
data: { openid },
success: (res) => {
if (res.data && res.data.success) {
const creditsInfo = res.data.data;
if (creditsInfo.dailyCredits > 0) {
// 还有次数,自动加载新的一批
console.log('还有次数,自动加载新的一批');
this.setData({ isLoading: true });
this.startLoadingQuoteRotation();
this.fetchFirstBatch(this.data.keyword, this.data.mode, this.data.surname, 5);
} else {
// 次数用完,显示结语卡片
this.setData({ showEndingCard: true });
}
} else {
this.setData({ showEndingCard: true });
}
},
fail: () => {
this.setData({ showEndingCard: true });
}
});
},
toggleCollectionView() {
// 跳转到 profile 页面
wx.navigateTo({
@@ -417,6 +510,13 @@ Page({
});
},
// 跳转到灵感广场
onGoToSquare() {
wx.navigateTo({
url: '/pages/square/square'
});
},
onDeleteCollected(e) {
const nameToDelete = e.currentTarget.dataset.name;
this.setData({
@@ -493,8 +593,302 @@ Page({
});
},
// 分享到灵感广场
shareToSquare() {
const card = this.data.nameList[this.data.currentIndex];
const openid = getApp().getOpenid();
if (!openid) {
wx.showToast({ title: '请先登录', icon: 'none' });
return;
}
wx.showLoading({ title: '分享中...', mask: true });
const apiBaseUrl = getApp().globalData.apiBaseUrl;
// 直接提交到灵感广场(不上传图片,使用默认图片)
// 将数据转换为 URL 编码格式
const formData = `name=${encodeURIComponent(card.name)}&origin=${encodeURIComponent(card.origin)}&description=${encodeURIComponent(card.description)}&keyword=${encodeURIComponent(this.data.keyword)}&mode=${encodeURIComponent(this.data.mode)}&openid=${encodeURIComponent(openid)}&imageUrl=`;
wx.request({
url: `${apiBaseUrl}/api/square/posts`,
method: 'POST',
header: {
'Content-Type': 'application/x-www-form-urlencoded'
},
data: formData,
success: (res) => {
wx.hideLoading();
if (res.data && res.data.success) {
wx.showToast({ title: '分享成功', icon: 'success' });
this.setData({ showPoster: false });
} else {
wx.showToast({ title: res.data.message || '分享失败', icon: 'none' });
}
},
fail: () => {
wx.hideLoading();
wx.showToast({ title: '分享失败', icon: 'none' });
}
});
},
// 阻止事件冒泡
preventBubble() {
// 什么都不做,只是阻止事件冒泡
},
// 点击音符按钮
onMusicTap() {
this.setData({
showMusicMenu: !this.data.showMusicMenu
});
},
// 阻止菜单冒泡
onMenuTap() {
// 什么都不做,只是阻止事件冒泡
},
// 切换环境音
onAmbienceChange(e) {
const type = e.currentTarget.dataset.type;
const { currentAmbience } = this.data;
// 如果点击当前正在播放的类型,则切换到静音
const newType = (type === currentAmbience) ? 'silent' : type;
this.setData({
currentAmbience: newType,
showMusicMenu: false
});
// 播放或停止环境音
const { playAmbience, fadeOutAndStop } = require('../../utils/audio.js');
if (newType === 'silent') {
fadeOutAndStop();
wx.showToast({ title: '已静音', icon: 'none' });
} else {
playAmbience(newType, true);
const typeName = { wind: '风铃', rain: '雨落', guqin: '古琴' }[newType];
wx.showToast({ title: `正在播放:${typeName}`, icon: 'none' });
}
},
// 长按名字显示字源弹窗
onNameLongPress() {
const currentName = this.data.nameList[this.data.currentIndex];
if (!currentName || !currentName.name) return;
// 默认显示名字的第一个字
const firstChar = currentName.name.charAt(0);
this.showCharDetail(firstChar);
// 触觉反馈
wx.vibrateLong();
},
// 显示字源详情
showCharDetail(char) {
// 播放水滴声(入墨效果)
const { playSoundEffect } = require('../../utils/audio.js');
playSoundEffect('inkDrop', { volume: 0.5 });
this.setData({
showCharDetail: true,
selectedChar: char,
charDetailData: null
});
// 加载字源数据
this.loadCharDetail(char);
},
// 加载字源数据
loadCharDetail(char) {
const apiBaseUrl = getApp().globalData.apiBaseUrl;
wx.request({
url: `${apiBaseUrl}/api/char/detail`,
data: { char },
success: (res) => {
if (res.data && res.data.success) {
this.setData({ charDetailData: res.data.data });
}
},
fail: () => {
// 使用本地静态数据作为备选
this.setData({ charDetailData: this.getLocalCharData(char) });
}
});
},
// 本地静态字源数据(备选)
getLocalCharData(char) {
const charDB = {
'清': {
char: '清',
pinyin: 'qīng',
radical: '氵',
strokes: 11,
wuxing: '水',
meaning: '水清澈透明,引申为纯净、高洁、明白',
imagery: '清澈如泉,心境澄明。寓意纯净通透、高洁自持,如清水出芙蓉,天然去雕饰。',
poetry: '「清水出芙蓉,天然去雕饰」——李白'
},
'雅': {
char: '雅',
pinyin: 'yǎ',
radical: '隹',
strokes: 12,
wuxing: '木',
meaning: '正也,规范、美好、高尚',
imagery: '温文尔雅,气度不凡。寓意举止得体、品味高雅,如芝兰玉树,生于庭阶。',
poetry: '「雅步擢纤腰,巧笑发皓齿」——曹植'
},
'若': {
char: '若',
pinyin: 'ruò',
radical: '艹',
strokes: 8,
wuxing: '木',
meaning: '如、像,引申为顺从、选择',
imagery: '虚怀若谷,温润如玉。寓意谦逊包容、从容自在,如兰若生春阳。',
poetry: '「若有人兮山之阿,被薜荔兮带女萝」——屈原'
},
'溪': {
char: '溪',
pinyin: 'xī',
radical: '氵',
strokes: 13,
wuxing: '水',
meaning: '山间小河,水流清澈',
imagery: '溪水潺潺,源远流长。寓意灵动清澈、生生不息,如溪水绕石,柔中带刚。',
poetry: '「旧时茅店社林边,路转溪桥忽见」——辛弃疾'
},
'云': {
char: '云',
pinyin: 'yún',
radical: '二',
strokes: 4,
wuxing: '水',
meaning: '水气上升凝结,引申为说、众多',
imagery: '云卷云舒,自在飘逸。寓意超然物外、洒脱不羁,如行云流水,任意西东。',
poetry: '「行到水穷处,坐看云起时」——王维'
},
'月': {
char: '月',
pinyin: 'yuè',
radical: '月',
strokes: 4,
wuxing: '木',
meaning: '月亮,引申为月份、光明',
imagery: '明月清风,皎洁无瑕。寓意光明磊落、清雅高洁,如月出东山,照彻千里。',
poetry: '「举杯邀明月,对影成三人」——李白'
}
};
return charDB[char] || {
char: char,
pinyin: '',
radical: '',
strokes: '',
wuxing: '',
meaning: '暂无详细解析',
imagery: '暂无意象分析',
poetry: ''
};
},
// 关闭字源弹窗
closeCharDetail() {
this.setData({
showCharDetail: false,
selectedChar: '',
charDetailData: null
});
},
// 呼吸震动 - 名字逐字显现时的节奏震动
breatheVibrate(name) {
if (!name || name.length === 0) return;
const chars = name.split('');
let index = 0;
// 每 300ms 震动一次,模拟呼吸节奏
const vibrateInterval = setInterval(() => {
if (index >= chars.length) {
clearInterval(vibrateInterval);
return;
}
// 极微弱的震动,营造意境
wx.vibrateShort({ type: 'light' });
// 播放字显现音效
const { playSoundEffect } = require('../../utils/audio.js');
playSoundEffect('char', { volume: 0.3 });
index++;
}, 300);
},
// 打开 AI 解析弹窗
onAskAI(e) {
e.stopPropagation();
this.setData({
showAIModal: true,
aiContext: '',
aiExplanation: ''
});
},
// 关闭 AI 解析弹窗
closeAIModal() {
this.setData({
showAIModal: false,
aiContext: '',
aiExplanation: ''
});
},
// AI 上下文输入
onAIContextInput(e) {
this.setData({ aiContext: e.detail.value });
},
// 请求 AI 解析
requestAIExplanation() {
const currentName = this.data.nameList[this.data.currentIndex];
if (!currentName || !currentName.name) return;
this.setData({ aiLoading: true });
const apiBaseUrl = getApp().globalData.apiBaseUrl;
wx.request({
url: `${apiBaseUrl}/api/names/explain`,
method: 'POST',
header: { 'Content-Type': 'application/json' },
data: {
name: currentName.name,
context: this.data.aiContext || '一般场景'
},
success: (res) => {
if (res.data && res.data.success) {
this.setData({
aiExplanation: res.data.data,
aiLoading: false
});
} else {
wx.showToast({ title: '解析失败', icon: 'none' });
this.setData({ aiLoading: false });
}
},
fail: () => {
wx.showToast({ title: '网络错误', icon: 'none' });
this.setData({ aiLoading: false });
}
});
}
});

View File

@@ -0,0 +1,3 @@
{
"usingComponents": {}
}

View File

@@ -18,7 +18,7 @@
<view class="card-stack" wx:if="{{!isLoading && nameList.length > 0}}">
<view
class="card-container"
style="transform: translateX({{translateX}}px) rotate({{rotate}}deg); transition: {{transition}};"
style="transform: translateX({{translateX}}px) translateY({{translateY}}px) rotate({{rotate}}deg); transition: {{transition}};"
bindtouchstart="onTouchStart"
bindtouchmove="onTouchMove"
bindtouchend="onTouchEnd"
@@ -27,7 +27,40 @@
<view class="card {{isFlipped ? 'flipped' : ''}}">
<!-- 正面:名字与诗词 -->
<view class="card-face front" bindtap="onFlip">
<text class="name">{{nameList[currentIndex].name}}</text>
<!-- 音符按钮 -->
<view class="music-btn {{currentAmbience !== 'silent' ? 'active' : ''}}" catchtap="onMusicTap">♪</view>
<!-- 音频菜单 -->
<view class="music-menu {{showMusicMenu ? 'show' : ''}}" catchtap="onMenuTap">
<view class="menu-item {{currentAmbience === 'silent' ? 'active' : ''}}" data-type="silent" catchtap="onAmbienceChange">
<text class="menu-icon">🔇</text>
<text class="menu-text">静音</text>
</view>
<view class="menu-item {{currentAmbience === 'wind' ? 'active' : ''}}" data-type="wind" catchtap="onAmbienceChange">
<text class="menu-icon">🎐</text>
<text class="menu-text">风铃</text>
</view>
<view class="menu-item {{currentAmbience === 'rain' ? 'active' : ''}}" data-type="rain" catchtap="onAmbienceChange">
<text class="menu-icon">🌧</text>
<text class="menu-text">雨落</text>
</view>
<view class="menu-item {{currentAmbience === 'guqin' ? 'active' : ''}}" data-type="guqin" catchtap="onAmbienceChange">
<text class="menu-icon">🎵</text>
<text class="menu-text">古琴</text>
</view>
<view class="menu-item {{currentAmbience === 'white' ? 'active' : ''}}" data-type="white" catchtap="onAmbienceChange">
<text class="menu-icon">🌫</text>
<text class="menu-text">白噪音</text>
</view>
<view class="menu-item {{currentAmbience === 'forest' ? 'active' : ''}}" data-type="forest" catchtap="onAmbienceChange">
<text class="menu-icon">🌲</text>
<text class="menu-text">森林</text>
</view>
<view class="menu-item {{currentAmbience === 'stream' ? 'active' : ''}}" data-type="stream" catchtap="onAmbienceChange">
<text class="menu-icon">💧</text>
<text class="menu-text">溪流</text>
</view>
</view>
<text class="name" bindlongpress="onNameLongPress">{{nameList[currentIndex].name}}</text>
<view class="poem-container">
<text class="poem">{{nameList[currentIndex].origin}}</text>
</view>
@@ -40,6 +73,10 @@
<view class="analysis-container">
<view class="tone-tag">声韵:{{nameList[currentIndex].tone}}</view>
<view class="score-tag">见素评分:{{nameList[currentIndex].score}}</view>
<view class="ai-btn" catchtap="onAskAI">
<text class="ai-icon">✦</text>
<text>问问 AI</text>
</view>
<view class="save-btn" catchtap="onSavePoster">
<text class="save-icon"></text>
<text>存为海报</text>
@@ -83,6 +120,9 @@
<view class="poster-btn save-btn" bindtap="savePoster">
<text>保存到相册</text>
</view>
<view class="poster-btn square-btn" bindtap="shareToSquare">
<text>分享到广场</text>
</view>
<view class="poster-btn share-btn" bindtap="sharePoster">
<text>分享给好友</text>
</view>
@@ -90,26 +130,53 @@
</view>
</view>
<!-- 结语卡片:灵感耗尽或加载失败时显示 -->
<view class="ending-card {{showEndingCard ? 'show' : ''}}" wx:if="{{showEndingCard}}">
<view class="ending-ink"></view>
<text class="ending-hint">此间灵感暂歇</text>
<text class="ending-subhint">或寻他处,或待重来</text>
<view class="ending-actions">
<view class="ending-btn square-btn" bindtap="onGoToSquare">
<text>进入灵感广场</text>
</view>
<view class="ending-btn back-btn" bindtap="onBack">
<text>返回重试</text>
</view>
</view>
</view>
<!-- 底部操作栏 -->
<view class="action-bar" wx:if="{{!isLoading && nameList.length > 0}}">
<view class="action-bar" wx:if="{{!isLoading && nameList.length > 0 && !showEndingCard}}">
<view class="action-btn dislike-btn" bindtap="onDislike">
<text class="action-icon">×</text>
</view>
<view class="action-btn square-btn" bindtap="onGoToSquare">
<text class="action-icon">✦</text>
</view>
<view class="action-btn like-btn" bindtap="onLike">
<text class="action-icon">♥</text>
</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 class="ending-card {{!isLoading && nameList.length === 0 ? 'show' : ''}}" wx:if="{{!isLoading && nameList.length === 0}}">
<view class="ending-ink"></view>
<text class="ending-hint">此间灵感暂歇</text>
<text class="ending-subhint">或寻他处,或待重来</text>
<view class="ending-actions">
<view class="ending-btn square-btn" bindtap="onGoToSquare">
<text>进入灵感广场</text>
</view>
<view class="ending-btn back-btn" bindtap="onBack">
<text>返回重试</text>
</view>
</view>
</view>
<!-- 收藏锦囊 -->
<!-- 收藏锦囊:始终显示 -->
<view class="collection-bag" bindtap="toggleCollectionView">
<text class="bag-icon">囊</text>
<view class="collection-count">{{collectedNames.length}}</view>
<view class="collection-count" wx:if="{{collectedNames.length > 0}}">{{collectedNames.length}}</view>
</view>
<!-- 收藏列表浮层 -->
@@ -130,4 +197,109 @@
</scroll-view>
</view>
</view>
<!-- 字源溯源弹窗 -->
<view class="char-detail-modal {{showCharDetail ? 'visible' : ''}}" bindtap="closeCharDetail">
<view class="char-detail-content" catchtap="preventBubble">
<view class="char-detail-close" bindtap="closeCharDetail">×</view>
<!-- 磨砂玻璃背景 -->
<view class="char-detail-glass"></view>
<!-- 篆书背景装饰 -->
<view class="char-seal-bg">
<text class="seal-char seal-1">{{selectedChar}}</text>
<text class="seal-char seal-2">{{selectedChar}}</text>
<text class="seal-char seal-3">{{selectedChar}}</text>
</view>
<!-- 大字展示 -->
<view class="char-display">
<text class="char-big">{{selectedChar}}</text>
<text class="char-pinyin" wx:if="{{charDetailData}}">{{charDetailData.pinyin}}</text>
</view>
<!-- 字源信息 -->
<scroll-view class="char-info-scroll" scroll-y wx:if="{{charDetailData}}">
<!-- 基础信息 -->
<view class="char-info-section">
<view class="char-info-item">
<text class="char-label">部首</text>
<text class="char-value">{{charDetailData.radical || '未知'}}</text>
</view>
<view class="char-info-item">
<text class="char-label">笔画</text>
<text class="char-value">{{charDetailData.strokes || '未知'}}画</text>
</view>
<view class="char-info-item">
<text class="char-label">五行</text>
<text class="char-value">{{charDetailData.wuxing || '未知'}}</text>
</view>
</view>
<!-- 本义 -->
<view class="char-section">
<view class="char-section-title">本义</view>
<text class="char-section-text">{{charDetailData.meaning || '暂无解析'}}</text>
</view>
<!-- 起名意象 -->
<view class="char-section">
<view class="char-section-title">起名意象</view>
<text class="char-section-text">{{charDetailData.imagery || '暂无解析'}}</text>
</view>
<!-- 诗词典故 -->
<view class="char-section" wx:if="{{charDetailData.poetry}}">
<view class="char-section-title">诗词典故</view>
<text class="char-section-text poetry">{{charDetailData.poetry}}</text>
</view>
</scroll-view>
<!-- 加载中 -->
<view class="char-loading" wx:if="{{!charDetailData}}">
<text>正在溯源...</text>
</view>
</view>
</view>
<!-- 问问 AI 弹窗 -->
<view class="ai-modal {{showAIModal ? 'show' : ''}}" bindtap="closeAIModal">
<view class="ai-modal-content" catchtap="preventBubble">
<!-- 磨砂玻璃背景 -->
<view class="ai-modal-glass"></view>
<!-- 关闭按钮 -->
<view class="ai-modal-close" bindtap="closeAIModal">×</view>
<!-- 标题 -->
<view class="ai-modal-header">
<text class="ai-modal-title">AI 深度解析</text>
<text class="ai-modal-subtitle">{{nameList[currentIndex].name}}</text>
</view>
<!-- 输入框 -->
<view class="ai-input-section">
<text class="ai-input-label">你想了解这个名字在什么场景下的暗示?</text>
<input class="ai-context-input" placeholder="例如:从事艺术行业、出国留学、创业..." value="{{aiContext}}" bindinput="onAIContextInput" />
</view>
<!-- 解析结果 -->
<scroll-view class="ai-result-scroll" scroll-y wx:if="{{aiExplanation}}">
<text class="ai-explanation">{{aiExplanation}}</text>
</scroll-view>
<!-- 加载中 -->
<view class="ai-loading" wx:if="{{aiLoading}}">
<text>正在思考...</text>
</view>
<!-- 操作按钮 -->
<view class="ai-actions" wx:if="{{!aiLoading}}">
<view class="ai-action-btn" bindtap="requestAIExplanation">
<text>{{aiExplanation ? '重新解析' : '开始解析'}}</text>
</view>
</view>
</view>
</view>
</view>

View File

@@ -129,9 +129,10 @@
.card-face {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
backface-visibility: hidden;
background: #FFFFFF;
border-radius: 24rpx;
box-shadow: 0 10rpx 40rpx rgba(0, 0, 0, 0.08);
@@ -139,16 +140,101 @@
flex-direction: column;
padding: 60rpx;
box-sizing: border-box;
-webkit-backface-visibility: hidden;
backface-visibility: hidden;
}
.card-face.front {
align-items: center;
justify-content: center;
-webkit-backface-visibility: hidden;
backface-visibility: hidden;
}
.card-face.back {
transform: rotateY(180deg);
justify-content: space-between;
-webkit-backface-visibility: hidden;
backface-visibility: hidden;
}
/* 音符按钮 */
.music-btn {
position: absolute;
top: 30rpx;
right: 30rpx;
width: 56rpx;
height: 56rpx;
border-radius: 50%;
background: rgba(255, 255, 255, 0.8);
display: flex;
align-items: center;
justify-content: center;
font-size: 32rpx;
color: #999;
opacity: 0.6;
transition: all 0.3s;
z-index: 10;
}
.music-btn:active {
opacity: 1;
transform: scale(0.9);
}
.music-btn.active {
color: #B22222;
opacity: 1;
}
/* 音频菜单 */
.music-menu {
position: absolute;
top: 100rpx;
right: 20rpx;
background: rgba(255, 255, 255, 0.95);
border-radius: 16rpx;
padding: 16rpx;
box-shadow: 0 8rpx 32rpx rgba(0, 0, 0, 0.1);
opacity: 0;
visibility: hidden;
transform: translateY(-10rpx);
transition: all 0.3s;
z-index: 20;
max-height: 400rpx;
overflow-y: auto;
}
.music-menu.show {
opacity: 1;
visibility: visible;
transform: translateY(0);
}
.menu-item {
display: flex;
align-items: center;
gap: 16rpx;
padding: 16rpx 24rpx;
border-radius: 12rpx;
transition: all 0.2s;
}
.menu-item:active {
background: #F5F5F0;
}
.menu-item.active {
background: #F5F5F0;
}
.menu-icon {
font-size: 36rpx;
}
.menu-text {
font-size: 28rpx;
color: #333;
}
.name {
@@ -200,7 +286,7 @@
}
.save-btn {
margin-top: 20rpx;
margin-top: 16rpx;
padding: 16rpx 40rpx;
background: #F5F5F5;
border-radius: 30rpx;
@@ -220,6 +306,28 @@
font-size: 28rpx;
}
/* 问问 AI 按钮 */
.ai-btn {
margin-top: 16rpx;
padding: 16rpx 40rpx;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 30rpx;
display: flex;
align-items: center;
gap: 8rpx;
font-size: 24rpx;
color: #FFFFFF;
transition: all 0.2s;
}
.ai-btn:active {
opacity: 0.8;
}
.ai-icon {
font-size: 28rpx;
}
/* 底部操作栏 */
.action-bar {
display: flex;
@@ -251,6 +359,12 @@
color: #FFFFFF;
}
.action-btn.square-btn {
background: #F5F5F0;
color: #B22222;
font-size: 40rpx;
}
.action-btn:active {
transform: scale(0.9);
}
@@ -284,20 +398,106 @@
border: none !important;
}
/* 结语卡片 */
.ending-card {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: #FFFFFF;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
z-index: 50;
opacity: 0;
visibility: hidden;
transition: all 0.5s ease;
}
.ending-card.show {
opacity: 1;
visibility: visible;
}
.ending-ink {
width: 120rpx;
height: 120rpx;
background: radial-gradient(circle, rgba(45,45,45,0.1) 0%, transparent 70%);
border-radius: 50%;
margin-bottom: 60rpx;
animation: inkSpread 2s ease-out;
}
@keyframes inkSpread {
from { transform: scale(0); opacity: 0; }
to { transform: scale(1); opacity: 1; }
}
.ending-hint {
font-family: "KaiTi", "STKaiti", serif;
font-size: 40rpx;
color: #2D2D2D;
letter-spacing: 8rpx;
margin-bottom: 20rpx;
}
.ending-subhint {
font-size: 28rpx;
color: #888888;
letter-spacing: 4rpx;
margin-bottom: 80rpx;
}
.ending-actions {
display: flex;
flex-direction: column;
gap: 24rpx;
align-items: center;
}
.ending-btn {
padding: 24rpx 80rpx;
border-radius: 40rpx;
font-size: 28rpx;
letter-spacing: 4rpx;
transition: all 0.2s;
}
.ending-btn.square-btn {
background: #2D2D2D;
color: #FFFFFF;
}
.ending-btn.square-btn:active {
background: #1a1a1a;
}
.ending-btn.back-btn {
background: transparent;
color: #888888;
border: 1rpx solid #E0E0E0;
}
.ending-btn.back-btn:active {
background: #F5F5F5;
}
/* 收藏锦囊 */
.collection-bag {
position: fixed;
right: 40rpx;
bottom: 200rpx;
bottom: 160rpx;
width: 100rpx;
height: 100rpx;
background: #FFFFFF;
border-radius: 50%;
box-shadow: 0 8rpx 30rpx rgba(0, 0, 0, 0.1);
box-shadow: 0 8rpx 30rpx rgba(0, 0, 0, 0.15);
display: flex;
align-items: center;
justify-content: center;
z-index: 10;
z-index: 100;
transition: transform 0.2s;
}
@@ -614,6 +814,367 @@
border: 2rpx solid rgba(255, 255, 255, 0.5);
}
.poster-btn.square-btn {
background: #B22222;
color: #FFFFFF;
}
.poster-btn.square-btn:active {
background: #8B1A1A;
}
.poster-btn.share-btn:active {
background: rgba(255, 255, 255, 0.1);
}
/* 字源溯源弹窗 */
.char-detail-modal {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.4);
backdrop-filter: blur(10px);
z-index: 200;
opacity: 0;
visibility: hidden;
transition: all 0.3s ease;
display: flex;
align-items: center;
justify-content: center;
}
.char-detail-modal.visible {
opacity: 1;
visibility: visible;
}
.char-detail-content {
width: 85%;
max-width: 600rpx;
max-height: 80vh;
background: rgba(255, 255, 255, 0.95);
border-radius: 24rpx;
position: relative;
overflow: hidden;
transform: scale(0.9);
transition: transform 0.3s ease;
}
.char-detail-modal.visible .char-detail-content {
transform: scale(1);
}
.char-detail-close {
position: absolute;
top: 20rpx;
right: 20rpx;
width: 60rpx;
height: 60rpx;
display: flex;
align-items: center;
justify-content: center;
font-size: 40rpx;
color: #999;
z-index: 10;
}
.char-detail-close:active {
color: #666;
}
.char-detail-glass {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(135deg, rgba(255,255,255,0.4) 0%, rgba(255,255,255,0.1) 100%);
backdrop-filter: blur(20px);
z-index: 0;
}
/* 篆书背景装饰 */
.char-seal-bg {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
overflow: hidden;
z-index: 0;
opacity: 0.08;
}
.seal-char {
position: absolute;
font-family: "KaiTi", "STKaiti", "SimSun", serif;
font-size: 200rpx;
color: #8B4513;
font-weight: bold;
letter-spacing: 20rpx;
}
.seal-1 {
top: 10%;
left: -10%;
transform: rotate(-15deg);
}
.seal-2 {
top: 40%;
right: -15%;
transform: rotate(10deg);
}
.seal-3 {
bottom: 5%;
left: 20%;
transform: rotate(-5deg);
}
.char-display {
position: relative;
z-index: 1;
padding: 60rpx 40rpx 40rpx;
text-align: center;
background: linear-gradient(180deg, #F5F5F0 0%, rgba(245,245,240,0) 100%);
}
.char-big {
font-family: "KaiTi", "STKaiti", "Noto Serif SC", serif;
font-size: 120rpx;
color: #2D2D2D;
letter-spacing: 8rpx;
text-shadow: 2rpx 2rpx 4rpx rgba(0,0,0,0.1);
}
.char-pinyin {
font-size: 28rpx;
color: #888;
margin-top: 16rpx;
letter-spacing: 4rpx;
}
.char-info-scroll {
max-height: 50vh;
padding: 0 40rpx 40rpx;
position: relative;
z-index: 1;
}
.char-info-section {
display: flex;
justify-content: space-around;
padding: 30rpx 0;
border-bottom: 1rpx solid #E8E8E0;
margin-bottom: 30rpx;
}
.char-info-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 12rpx;
}
.char-label {
font-size: 22rpx;
color: #999;
letter-spacing: 2rpx;
}
.char-value {
font-size: 28rpx;
color: #333;
font-weight: 500;
}
.char-section {
margin-bottom: 30rpx;
}
.char-section-title {
font-size: 24rpx;
color: #B22222;
letter-spacing: 4rpx;
margin-bottom: 16rpx;
font-weight: 500;
}
.char-section-text {
font-size: 26rpx;
color: #555;
line-height: 1.8;
letter-spacing: 2rpx;
}
.char-section-text.poetry {
font-family: "KaiTi", "STKaiti", serif;
color: #666;
font-style: italic;
}
.char-loading {
padding: 60rpx;
text-align: center;
color: #999;
font-size: 26rpx;
letter-spacing: 4rpx;
}
/* 问问 AI 弹窗 */
.ai-modal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 100;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
visibility: hidden;
transition: all 0.3s ease;
}
.ai-modal.show {
opacity: 1;
visibility: visible;
}
.ai-modal-content {
position: relative;
width: 80%;
max-width: 600rpx;
max-height: 70vh;
border-radius: 24rpx;
overflow: hidden;
transform: scale(0.9);
transition: transform 0.3s ease;
}
.ai-modal.show .ai-modal-content {
transform: scale(1);
}
.ai-modal-glass {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(135deg, rgba(255,255,255,0.95) 0%, rgba(250,250,250,0.9) 100%);
backdrop-filter: blur(20px);
z-index: 0;
}
.ai-modal-close {
position: absolute;
top: 20rpx;
right: 20rpx;
width: 60rpx;
height: 60rpx;
display: flex;
align-items: center;
justify-content: center;
font-size: 40rpx;
color: #999;
z-index: 2;
}
.ai-modal-header {
position: relative;
z-index: 1;
padding: 40rpx 40rpx 20rpx;
text-align: center;
border-bottom: 1rpx solid rgba(0,0,0,0.05);
}
.ai-modal-title {
font-size: 32rpx;
font-weight: 500;
color: #2D2D2D;
letter-spacing: 4rpx;
}
.ai-modal-subtitle {
display: block;
font-size: 48rpx;
color: #667eea;
margin-top: 12rpx;
font-family: "KaiTi", "STKaiti", serif;
}
.ai-input-section {
position: relative;
z-index: 1;
padding: 30rpx 40rpx;
}
.ai-input-label {
display: block;
font-size: 26rpx;
color: #666;
margin-bottom: 16rpx;
letter-spacing: 2rpx;
}
.ai-context-input {
width: 100%;
height: 80rpx;
padding: 0 24rpx;
background: #F5F5F5;
border-radius: 12rpx;
font-size: 26rpx;
color: #333;
box-sizing: border-box;
}
.ai-result-scroll {
position: relative;
z-index: 1;
max-height: 300rpx;
padding: 0 40rpx;
margin-bottom: 20rpx;
}
.ai-explanation {
font-size: 28rpx;
color: #444;
line-height: 1.8;
letter-spacing: 2rpx;
text-align: justify;
}
.ai-loading {
padding: 40rpx;
text-align: center;
color: #999;
font-size: 26rpx;
letter-spacing: 4rpx;
}
.ai-actions {
position: relative;
z-index: 1;
padding: 20rpx 40rpx 40rpx;
display: flex;
justify-content: center;
}
.ai-action-btn {
padding: 20rpx 60rpx;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 40rpx;
font-size: 28rpx;
color: #FFFFFF;
letter-spacing: 4rpx;
transition: opacity 0.2s;
}
.ai-action-btn:active {
opacity: 0.8;
}

View File

@@ -4,20 +4,47 @@ Page({
showDetail: false,
selectedItem: {},
selectedIndex: -1,
openid: 'test_openid' // 实际应从登录获取
openid: null,
creditsInfo: {
dailyCredits: 5,
totalCredits: 0,
watchedAdCount: 0
},
// 静心阅读
showMeditation: false,
meditationPoem: null,
meditationProgress: 0
},
onLoad() {
this.loadFavorites();
this.setOpenid();
},
onShow() {
// 每次显示页面时刷新列表
this.loadFavorites();
// 加载积分信息
this.loadCreditsInfo();
},
// 设置 openid
setOpenid() {
const openid = getApp().getOpenid();
if (openid) {
this.setData({ openid });
this.loadFavorites();
} else {
// openid 还未获取到,延迟重试
setTimeout(() => this.setOpenid(), 500);
}
},
// 加载收藏列表
loadFavorites() {
if (!this.data.openid) {
console.log('openid 未获取到,跳过加载收藏');
return;
}
const apiBaseUrl = getApp().globalData.apiBaseUrl;
wx.request({
url: `${apiBaseUrl}/api/favorites/list`,
@@ -38,6 +65,140 @@ Page({
wx.navigateBack();
},
// 加载积分信息
loadCreditsInfo() {
const openid = getApp().getOpenid();
if (!openid) {
console.log('openid 未获取到,跳过加载积分');
return;
}
const apiBaseUrl = getApp().globalData.apiBaseUrl;
wx.request({
url: `${apiBaseUrl}/api/credits/info`,
data: { openid },
success: (res) => {
if (res.data && res.data.success) {
this.setData({ creditsInfo: res.data.data });
}
}
});
},
// 点击积分区域
onCreditsTap() {
const { creditsInfo } = this.data;
// 如果还有剩余次数,显示提示
if (creditsInfo.dailyCredits > 0) {
wx.showToast({
title: `今日还有 ${creditsInfo.dailyCredits} 次灵感`,
icon: 'none'
});
return;
}
// 如果次数用完且还可以看广告
if (creditsInfo.watchedAdCount < 5) {
wx.showModal({
title: '静心阅读',
content: '观看 15 秒静心画报,即可获得 3 次灵感',
confirmText: '开始阅读',
cancelText: '取消',
success: (res) => {
if (res.confirm) {
this.watchAd();
}
}
});
} else {
wx.showToast({
title: '今日次数已达上限,明日再来',
icon: 'none'
});
}
},
// 静心阅读 - 模拟 15 秒阅读体验
watchAd() {
const openid = getApp().getOpenid();
if (!openid) {
wx.showToast({ title: '请先登录', icon: 'none' });
return;
}
// 随机选择一首诗词
const poems = [
{ text: '「采菊东篱下,悠然见南山」', author: '陶渊明《饮酒》' },
{ text: '「行到水穷处,坐看云起时」', author: '王维《终南别业》' },
{ text: '「人闲桂花落,夜静春山空」', author: '王维《鸟鸣涧》' },
{ text: '「松风吹解带,山月照弹琴」', author: '王维《酬张少府》' },
{ text: '「曲径通幽处,禅房花木深」', author: '常建《题破山寺后禅院》' },
{ text: '「明月松间照,清泉石上流」', author: '王维《山居秋暝》' }
];
const poem = poems[Math.floor(Math.random() * poems.length)];
// 显示静心阅读页面
this.setData({
showMeditation: true,
meditationPoem: poem,
meditationProgress: 0
});
// 15 秒倒计时 - 每 150ms 增加 1%,总共 150 * 100 = 15000ms = 15秒
let progress = 0;
const timer = setInterval(() => {
progress += 1;
this.setData({ meditationProgress: progress });
if (progress >= 100) {
clearInterval(timer);
this.meditationTimer = null;
// 延迟一下再关闭,让用户看到完成状态
setTimeout(() => {
this.setData({ showMeditation: false });
// 领取奖励
this.rewardAd(openid);
}, 500);
}
}, 150);
// 保存 timer 以便可以提前关闭
this.meditationTimer = timer;
},
// 提前关闭静心阅读
closeMeditation() {
if (this.meditationTimer) {
clearInterval(this.meditationTimer);
this.meditationTimer = null;
}
this.setData({ showMeditation: false });
},
// 领取广告奖励
rewardAd(openid) {
const apiBaseUrl = getApp().globalData.apiBaseUrl;
wx.request({
url: `${apiBaseUrl}/api/credits/reward`,
method: 'POST',
header: {
'Content-Type': 'application/x-www-form-urlencoded'
},
data: `openid=${encodeURIComponent(openid)}`,
success: (res) => {
if (res.data && res.data.success) {
wx.showToast({ title: '获得 3 次灵感', icon: 'success' });
this.loadCreditsInfo();
} else {
wx.showToast({ title: res.data.message || '领取失败', icon: 'none' });
}
},
fail: () => {
wx.showToast({ title: '领取失败', icon: 'none' });
}
});
},
// 点击收藏项
onItemTap(e) {
const index = e.currentTarget.dataset.index;
@@ -79,12 +240,8 @@ Page({
if (res.confirm) {
const apiBaseUrl = getApp().globalData.apiBaseUrl;
wx.request({
url: `${apiBaseUrl}/api/favorites/remove`,
url: `${apiBaseUrl}/api/favorites/remove?openid=${this.data.openid}&name=${encodeURIComponent(item.name)}`,
method: 'POST',
data: {
openid: this.data.openid,
name: item.name
},
success: (res) => {
if (res.data && res.data.success) {
wx.showToast({ title: '已移除', icon: 'success' });

View File

@@ -6,6 +6,18 @@
<view class="header-spacer"></view>
</view>
<!-- 积分信息 - 克制地放在角落 -->
<view class="credits-bar" bindtap="onCreditsTap">
<view class="credits-item">
<text class="credits-icon">✦</text>
<text class="credits-text">今日灵感 {{creditsInfo.dailyCredits || 0}}/5</text>
</view>
<view class="credits-item watch-ad" wx:if="{{(creditsInfo.dailyCredits || 0) === 0 && (creditsInfo.watchedAdCount || 0) < 5}}">
<text class="credits-icon">📖</text>
<text class="credits-text">静心阅读 +3</text>
</view>
</view>
<!-- 收藏列表 -->
<scroll-view scroll-y class="favorites-scroll" wx:if="{{favorites.length > 0}}">
<view class="favorites-grid">
@@ -46,4 +58,23 @@
</view>
</view>
</view>
<!-- 静心阅读全屏页 -->
<view class="meditation-page {{showMeditation ? 'visible' : ''}}">
<view class="meditation-close" bindtap="closeMeditation">×</view>
<view class="meditation-content">
<view class="meditation-title">静心阅读</view>
<view class="meditation-poem" wx:if="{{meditationPoem}}">
<text class="poem-text">{{meditationPoem.text}}</text>
<text class="poem-author">——{{meditationPoem.author}}</text>
</view>
<view class="meditation-hint">静观 15 秒,心随诗远</view>
</view>
<view class="meditation-progress">
<view class="progress-bar">
<view class="progress-fill" style="width: {{meditationProgress}}%"></view>
</view>
<text class="progress-text">{{meditationProgress}}%</text>
</view>
</view>
</view>

View File

@@ -36,6 +36,47 @@ page {
width: 76rpx;
}
/* 积分信息 - 克制地放在角落 */
.credits-bar {
display: flex;
justify-content: flex-end;
align-items: center;
gap: 20rpx;
margin-bottom: 30rpx;
padding: 0 10rpx;
}
.credits-item {
display: flex;
align-items: center;
gap: 8rpx;
padding: 12rpx 20rpx;
background: rgba(255, 255, 255, 0.8);
border-radius: 30rpx;
opacity: 0.7;
transition: all 0.3s;
}
.credits-item:active {
opacity: 1;
transform: scale(0.98);
}
.credits-item.watch-ad {
background: #F5F5F0;
opacity: 1;
}
.credits-icon {
font-size: 24rpx;
}
.credits-text {
font-size: 22rpx;
color: #666;
letter-spacing: 2rpx;
}
/* 收藏列表 */
.favorites-scroll {
height: calc(100vh - 200rpx);
@@ -215,3 +256,113 @@ page {
.remove-btn:active {
background: #E8E8E8;
}
/* 静心阅读全屏页 */
.meditation-page {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(180deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%);
z-index: 1000;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
opacity: 0;
visibility: hidden;
transition: all 0.5s ease;
}
.meditation-page.visible {
opacity: 1;
visibility: visible;
}
.meditation-close {
position: absolute;
top: 60rpx;
right: 40rpx;
width: 80rpx;
height: 80rpx;
display: flex;
align-items: center;
justify-content: center;
font-size: 48rpx;
color: rgba(255, 255, 255, 0.6);
z-index: 10;
}
.meditation-content {
text-align: center;
padding: 60rpx;
}
.meditation-title {
font-size: 32rpx;
color: rgba(255, 255, 255, 0.5);
letter-spacing: 8rpx;
margin-bottom: 80rpx;
}
.meditation-poem {
margin-bottom: 60rpx;
}
.poem-text {
display: block;
font-family: "KaiTi", "STKaiti", serif;
font-size: 56rpx;
color: #ffffff;
line-height: 1.8;
letter-spacing: 8rpx;
margin-bottom: 40rpx;
text-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.3);
}
.poem-author {
display: block;
font-size: 28rpx;
color: rgba(255, 255, 255, 0.6);
letter-spacing: 4rpx;
}
.meditation-hint {
font-size: 24rpx;
color: rgba(255, 255, 255, 0.4);
letter-spacing: 4rpx;
margin-top: 60rpx;
}
.meditation-progress {
position: absolute;
bottom: 100rpx;
left: 80rpx;
right: 80rpx;
display: flex;
align-items: center;
gap: 20rpx;
}
.progress-bar {
flex: 1;
height: 4rpx;
background: rgba(255, 255, 255, 0.2);
border-radius: 2rpx;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, #667eea 0%, #764ba2 100%);
border-radius: 2rpx;
transition: width 0.15s linear;
}
.progress-text {
font-size: 24rpx;
color: rgba(255, 255, 255, 0.6);
min-width: 60rpx;
text-align: right;
}

View File

@@ -0,0 +1,184 @@
// 灵感广场页面
Page({
data: {
posts: [],
leftPosts: [],
rightPosts: [],
page: 0,
size: 20,
loading: false,
hasMore: true,
currentTag: '',
sortType: 'latest',
showDetail: false,
selectedPost: {}
},
onLoad() {
this.loadPosts();
},
onShow() {
// 刷新列表
if (this.data.posts.length === 0) {
this.loadPosts();
}
},
// 加载帖子列表
loadPosts(reset = false) {
if (this.data.loading) return;
if (reset) {
this.setData({
page: 0,
posts: [],
leftPosts: [],
rightPosts: [],
hasMore: true
});
}
this.setData({ loading: true });
const apiBaseUrl = getApp().globalData.apiBaseUrl;
const { page, size, currentTag, sortType } = this.data;
let url = `${apiBaseUrl}/api/square/posts?page=${page}&size=${size}&sort=${sortType}`;
if (currentTag) {
url += `&tag=${encodeURIComponent(currentTag)}`;
}
wx.request({
url,
success: (res) => {
if (res.data && res.data.success) {
const newPosts = res.data.data || [];
const allPosts = reset ? newPosts : [...this.data.posts, ...newPosts];
// 分配到左右两列
const leftPosts = [];
const rightPosts = [];
allPosts.forEach((post, index) => {
if (index % 2 === 0) {
leftPosts.push(post);
} else {
rightPosts.push(post);
}
});
this.setData({
posts: allPosts,
leftPosts,
rightPosts,
hasMore: res.data.hasMore,
page: page + 1,
loading: false
});
} else {
this.setData({ loading: false });
}
},
fail: () => {
this.setData({ loading: false });
wx.showToast({ title: '加载失败', icon: 'none' });
}
});
},
// 点击标签
onTagTap(e) {
const tag = e.currentTarget.dataset.tag;
this.setData({ currentTag: tag });
this.loadPosts(true);
},
// 切换排序
onSortTap(e) {
const type = e.currentTarget.dataset.type;
this.setData({ sortType: type });
this.loadPosts(true);
},
// 点击帖子
onPostTap(e) {
const post = e.currentTarget.dataset.post;
this.setData({
selectedPost: post,
showDetail: true
});
},
// 关闭详情
onCloseDetail() {
this.setData({ showDetail: false });
},
// 阻止冒泡
onDetailTap() {
// 什么都不做
},
// 点赞
onLikeTap() {
const { selectedPost } = this.data;
const apiBaseUrl = getApp().globalData.apiBaseUrl;
wx.request({
url: `${apiBaseUrl}/api/square/posts/${selectedPost.id}/like`,
method: 'POST',
success: (res) => {
if (res.data && res.data.success) {
// 更新本地数据
const updatedPost = { ...selectedPost, likeCount: (selectedPost.likeCount || 0) + 1 };
this.setData({ selectedPost: updatedPost });
// 更新列表中的数据
const posts = this.data.posts.map(p =>
p.id === selectedPost.id ? updatedPost : p
);
this.setData({ posts });
wx.showToast({ title: '已共鸣', icon: 'none' });
}
}
});
},
// 同款跳转(卡片上)
onCopyTap(e) {
const { keyword, mode } = e.currentTarget.dataset;
this.navigateToHome(keyword, mode);
},
// 同款跳转(详情中)
onDetailCopyTap() {
const { keyword, mode } = this.data.selectedPost;
this.navigateToHome(keyword, mode);
},
// 跳转到首页
navigateToHome(keyword, mode) {
if (!keyword) {
wx.showToast({ title: '无法获取关键词', icon: 'none' });
return;
}
wx.reLaunch({
url: `/pages/home/home?keyword=${encodeURIComponent(keyword)}&mode=${mode || 'poetic'}`
});
},
// 下拉刷新
onPullDownRefresh() {
this.loadPosts(true);
wx.stopPullDownRefresh();
},
// 上拉加载更多
onReachBottom() {
if (this.data.hasMore && !this.data.loading) {
this.loadPosts();
}
}
});

View File

@@ -0,0 +1,8 @@
{
"navigationBarTitleText": "灵感广场",
"navigationBarBackgroundColor": "#F5F5F0",
"navigationBarTextStyle": "black",
"backgroundColor": "#F5F5F0",
"enablePullDownRefresh": true,
"onReachBottomDistance": 100
}

View File

@@ -0,0 +1,141 @@
<!-- 灵感广场页面 - 瀑布流展示 -->
<view class="square-container">
<!-- 顶部导航 -->
<view class="header">
<view class="title">灵感广场</view>
<view class="subtitle">发现诗意,共鸣美好</view>
</view>
<!-- 筛选标签 -->
<scroll-view class="tag-bar" scroll-x>
<view class="tag-list">
<view class="tag {{currentTag === '' ? 'active' : ''}}" bindtap="onTagTap" data-tag="">全部</view>
<view class="tag {{currentTag === '清冷' ? 'active' : ''}}" bindtap="onTagTap" data-tag="清冷">清冷</view>
<view class="tag {{currentTag === '温柔' ? 'active' : ''}}" bindtap="onTagTap" data-tag="温柔">温柔</view>
<view class="tag {{currentTag === '古雅' ? 'active' : ''}}" bindtap="onTagTap" data-tag="古雅">古雅</view>
<view class="tag {{currentTag === '诗意' ? 'active' : ''}}" bindtap="onTagTap" data-tag="诗意">诗意</view>
<view class="tag {{currentTag === '自然' ? 'active' : ''}}" bindtap="onTagTap" data-tag="自然">自然</view>
</view>
</scroll-view>
<!-- 排序切换 -->
<view class="sort-bar">
<view class="sort-item {{sortType === 'latest' ? 'active' : ''}}" bindtap="onSortTap" data-type="latest">最新</view>
<view class="sort-item {{sortType === 'hot' ? 'active' : ''}}" bindtap="onSortTap" data-type="hot">热门</view>
</view>
<!-- 瀑布流列表 -->
<view class="waterfall">
<view class="column left">
<view class="post-card"
wx:for="{{leftPosts}}"
wx:key="id"
bindtap="onPostTap"
data-post="{{item}}">
<image class="post-image"
src="{{item.imageUrl}}"
mode="widthFix"
lazy-load
show-menu-by-longpress
wx:if="{{item.imageUrl}}"/>
<view class="post-image-placeholder" wx:if="{{!item.imageUrl}}">
<text class="placeholder-text">{{item.name}}</text>
</view>
<view class="post-info">
<view class="post-name">{{item.name}}</view>
<view class="post-origin" wx:if="{{item.origin}}">{{item.origin}}</view>
<view class="post-footer">
<view class="post-keyword" wx:if="{{item.keyword}}">#{{item.keyword}}</view>
<view class="post-likes">
<text class="like-icon">♡</text>
<text class="like-count">{{item.likeCount || 0}}</text>
</view>
</view>
</view>
<!-- 同款跳转按钮 -->
<view class="copy-btn"
catchtap="onCopyTap"
data-keyword="{{item.keyword}}"
data-mode="{{item.mode}}">
<text>同款意境</text>
</view>
</view>
</view>
<view class="column right">
<view class="post-card"
wx:for="{{rightPosts}}"
wx:key="id"
bindtap="onPostTap"
data-post="{{item}}">
<image class="post-image"
src="{{item.imageUrl}}"
mode="widthFix"
lazy-load
show-menu-by-longpress
wx:if="{{item.imageUrl}}"/>
<view class="post-image-placeholder" wx:if="{{!item.imageUrl}}">
<text class="placeholder-text">{{item.name}}</text>
</view>
<view class="post-info">
<view class="post-name">{{item.name}}</view>
<view class="post-origin" wx:if="{{item.origin}}">{{item.origin}}</view>
<view class="post-footer">
<view class="post-keyword" wx:if="{{item.keyword}}">#{{item.keyword}}</view>
<view class="post-likes">
<text class="like-icon">♡</text>
<text class="like-count">{{item.likeCount || 0}}</text>
</view>
</view>
</view>
<!-- 同款跳转按钮 -->
<view class="copy-btn"
catchtap="onCopyTap"
data-keyword="{{item.keyword}}"
data-mode="{{item.mode}}">
<text>同款意境</text>
</view>
</view>
</view>
</view>
<!-- 加载更多 -->
<view class="load-more" wx:if="{{loading}}">
<view class="loading-dots">
<view class="dot"></view>
<view class="dot"></view>
<view class="dot"></view>
</view>
</view>
<view class="no-more" wx:if="{{!hasMore && posts.length > 0}}">
<text>没有更多了</text>
</view>
<view class="empty" wx:if="{{!loading && posts.length === 0}}">
<text class="empty-icon">✿</text>
<text class="empty-text">暂无灵感,快去生成并分享吧</text>
</view>
</view>
<!-- 帖子详情弹窗 -->
<view class="detail-mask {{showDetail ? 'show' : ''}}" bindtap="onCloseDetail">
<view class="detail-container {{showDetail ? 'show' : ''}}" catchtap="onDetailTap">
<image class="detail-image" src="{{selectedPost.imageUrl}}" mode="widthFix"/>
<view class="detail-info">
<view class="detail-name">{{selectedPost.name}}</view>
<view class="detail-origin" wx:if="{{selectedPost.origin}}">{{selectedPost.origin}}</view>
<view class="detail-desc" wx:if="{{selectedPost.description}}">{{selectedPost.description}}</view>
<view class="detail-actions">
<view class="action-btn like" bindtap="onLikeTap">
<text class="action-icon">♡</text>
<text class="action-text">共鸣 {{selectedPost.likeCount || 0}}</text>
</view>
<view class="action-btn copy" bindtap="onDetailCopyTap">
<text class="action-text">同款意境</text>
</view>
</view>
</view>
<view class="detail-close" bindtap="onCloseDetail">×</view>
</view>
</view>

View File

@@ -0,0 +1,376 @@
/* 灵感广场页面 - 瀑布流风格 */
.square-container {
min-height: 100vh;
background: #F5F5F0;
padding-bottom: 40rpx;
}
/* 顶部导航 */
.header {
padding: 40rpx 30rpx 20rpx;
text-align: center;
}
.title {
font-size: 40rpx;
font-weight: 600;
color: #2D2D2D;
letter-spacing: 8rpx;
}
.subtitle {
font-size: 24rpx;
color: #999;
margin-top: 8rpx;
letter-spacing: 4rpx;
}
/* 标签栏 */
.tag-bar {
padding: 20rpx 0;
white-space: nowrap;
}
.tag-list {
padding: 0 20rpx;
}
.tag {
display: inline-block;
padding: 12rpx 28rpx;
margin: 0 10rpx;
background: #fff;
border-radius: 30rpx;
font-size: 26rpx;
color: #666;
transition: all 0.3s;
}
.tag.active {
background: #2D2D2D;
color: #fff;
}
/* 排序栏 */
.sort-bar {
display: flex;
justify-content: center;
padding: 10rpx 0 20rpx;
gap: 40rpx;
}
.sort-item {
font-size: 26rpx;
color: #999;
padding: 8rpx 16rpx;
position: relative;
}
.sort-item.active {
color: #2D2D2D;
font-weight: 500;
}
.sort-item.active::after {
content: '';
position: absolute;
bottom: 0;
left: 50%;
transform: translateX(-50%);
width: 20rpx;
height: 4rpx;
background: #B22222;
border-radius: 2rpx;
}
/* 瀑布流 */
.waterfall {
display: flex;
padding: 0 20rpx;
gap: 20rpx;
}
.column {
flex: 1;
display: flex;
flex-direction: column;
gap: 20rpx;
}
/* 帖子卡片 */
.post-card {
background: #fff;
border-radius: 16rpx;
overflow: hidden;
box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.06);
transition: transform 0.3s;
}
.post-card:active {
transform: scale(0.98);
}
.post-image {
width: 100%;
display: block;
}
.post-image-placeholder {
width: 100%;
height: 300rpx;
background: linear-gradient(135deg, #F5F5F0 0%, #E8E8E0 100%);
display: flex;
align-items: center;
justify-content: center;
}
.placeholder-text {
font-size: 48rpx;
font-family: 'KaiTi', 'STKaiti', serif;
color: #999;
letter-spacing: 8rpx;
}
.post-info {
padding: 20rpx;
}
.post-name {
font-size: 32rpx;
font-weight: 600;
color: #2D2D2D;
margin-bottom: 8rpx;
}
.post-origin {
font-size: 22rpx;
color: #999;
line-height: 1.5;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.post-footer {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 16rpx;
padding-top: 16rpx;
border-top: 1rpx solid #f0f0f0;
}
.post-keyword {
font-size: 22rpx;
color: #B22222;
}
.post-likes {
display: flex;
align-items: center;
gap: 6rpx;
}
.like-icon {
font-size: 24rpx;
color: #999;
}
.like-count {
font-size: 22rpx;
color: #999;
}
/* 同款按钮 */
.copy-btn {
margin: 0 20rpx 20rpx;
padding: 16rpx 0;
background: #F5F5F0;
border-radius: 8rpx;
text-align: center;
}
.copy-btn text {
font-size: 24rpx;
color: #666;
}
/* 加载更多 */
.load-more {
padding: 40rpx 0;
display: flex;
justify-content: center;
}
.loading-dots {
display: flex;
gap: 12rpx;
}
.dot {
width: 12rpx;
height: 12rpx;
background: #ccc;
border-radius: 50%;
animation: bounce 1.4s infinite ease-in-out both;
}
.dot:nth-child(1) { animation-delay: -0.32s; }
.dot:nth-child(2) { animation-delay: -0.16s; }
@keyframes bounce {
0%, 80%, 100% { transform: scale(0); }
40% { transform: scale(1); }
}
.no-more {
text-align: center;
padding: 40rpx 0;
color: #999;
font-size: 24rpx;
}
/* 空状态 */
.empty {
display: flex;
flex-direction: column;
align-items: center;
padding: 100rpx 0;
}
.empty-icon {
font-size: 80rpx;
color: #ccc;
margin-bottom: 20rpx;
}
.empty-text {
font-size: 26rpx;
color: #999;
}
/* 详情弹窗 */
.detail-mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.8);
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
visibility: hidden;
transition: all 0.3s;
z-index: 1000;
}
.detail-mask.show {
opacity: 1;
visibility: visible;
}
.detail-container {
width: 640rpx;
max-height: 80vh;
background: #fff;
border-radius: 20rpx;
overflow: hidden;
transform: scale(0.9);
transition: all 0.3s;
position: relative;
}
.detail-container.show {
transform: scale(1);
}
.detail-image {
width: 100%;
max-height: 400rpx;
object-fit: cover;
}
.detail-info {
padding: 30rpx;
}
.detail-name {
font-size: 40rpx;
font-weight: 600;
color: #2D2D2D;
text-align: center;
margin-bottom: 16rpx;
}
.detail-origin {
font-size: 24rpx;
color: #666;
line-height: 1.6;
text-align: center;
margin-bottom: 20rpx;
}
.detail-desc {
font-size: 26rpx;
color: #555;
line-height: 1.8;
text-align: justify;
}
.detail-actions {
display: flex;
justify-content: center;
gap: 40rpx;
margin-top: 30rpx;
padding-top: 30rpx;
border-top: 1rpx solid #f0f0f0;
}
.action-btn {
display: flex;
align-items: center;
gap: 8rpx;
padding: 16rpx 40rpx;
border-radius: 40rpx;
}
.action-btn.like {
background: #F5F5F0;
}
.action-btn.copy {
background: #2D2D2D;
}
.action-icon {
font-size: 28rpx;
color: #999;
}
.action-btn.copy .action-text {
color: #fff;
}
.action-text {
font-size: 26rpx;
color: #666;
}
.detail-close {
position: absolute;
top: 20rpx;
right: 20rpx;
width: 60rpx;
height: 60rpx;
background: rgba(0, 0, 0, 0.3);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 36rpx;
color: #fff;
}

260
miniprogram/utils/audio.js Normal file
View File

@@ -0,0 +1,260 @@
// 音频管理工具 - 环境白噪音与音效管理
// 背景音频管理器(用于环境白噪音)
let bgmManager = null;
// 音效上下文缓存
const soundEffects = {};
// 当前环境音类型
let currentAmbienceType = 'silent';
// 音量渐变定时器
let fadeTimer = null;
/**
* 初始化背景音频管理器
*/
export const initBGM = () => {
if (!bgmManager) {
bgmManager = wx.getBackgroundAudioManager();
bgmManager.title = '见素-环境音';
bgmManager.epname = '环境白噪音';
bgmManager.singer = '见素';
}
return bgmManager;
};
/**
* 播放环境白噪音
* @param {string} type - 环境音类型: 'silent' | 'wind' | 'rain' | 'guqin' | 'white' | 'forest' | 'stream'
* @param {boolean} fade - 是否淡入
*/
export const playAmbience = (type = 'wind', fade = true) => {
const bgm = initBGM();
// 如果相同类型且正在播放,不重复操作
if (currentAmbienceType === type && bgm.currentTime > 0) {
return;
}
currentAmbienceType = type;
// 静音模式
if (type === 'silent') {
fadeOutAndStop();
return;
}
// 音频资源映射(使用本地音频)
const audioMap = {
wind: '/assets/audios/wind.mp3', // 风铃
rain: '/assets/audios/rain.mp3', // 雨声
guqin: '/assets/audios/guqin.mp3', // 古琴
white: '/assets/audios/white.mp3', // 白噪音
forest: '/assets/audios/forest.mp3', // 森林
stream: '/assets/audios/stream.mp3' // 溪流
};
// 音频类型名称映射
const typeNameMap = {
wind: '风铃',
rain: '雨落',
guqin: '古琴',
white: '白噪音',
forest: '森林',
stream: '溪流'
};
const src = audioMap[type];
if (!src) return;
// 设置音频属性
bgm.title = `见素 - ${typeNameMap[type] || '环境音'}`;
bgm.singer = '环境音';
bgm.coverImgUrl = '';
// 监听错误事件
bgm.onError((err) => {
console.error('音频播放错误:', err);
wx.showToast({
title: '音频加载失败',
icon: 'none'
});
});
// 淡入效果
if (fade) {
bgm.volume = 0;
bgm.src = src;
bgm.play();
fadeIn(0.3, 1000); // 1秒内淡入到30%音量
} else {
bgm.volume = 0.3;
bgm.src = src;
bgm.play();
}
console.log(`播放环境音: ${type}`);
};
/**
* 淡入音量
* @param {number} targetVolume - 目标音量 0-1
* @param {number} duration - 淡入时长 ms
*/
const fadeIn = (targetVolume = 0.3, duration = 1000) => {
if (!bgmManager) return;
clearInterval(fadeTimer);
const step = targetVolume / (duration / 50); // 每50ms调整一次
fadeTimer = setInterval(() => {
if (bgmManager.volume < targetVolume) {
bgmManager.volume = Math.min(bgmManager.volume + step, targetVolume);
} else {
clearInterval(fadeTimer);
}
}, 50);
};
/**
* 淡出并停止
* @param {number} duration - 淡出时长 ms
*/
export const fadeOutAndStop = (duration = 500) => {
if (!bgmManager) return;
clearInterval(fadeTimer);
const startVolume = bgmManager.volume;
const step = startVolume / (duration / 50);
fadeTimer = setInterval(() => {
if (bgmManager.volume > 0.01) {
bgmManager.volume = Math.max(bgmManager.volume - step, 0);
} else {
clearInterval(fadeTimer);
bgmManager.stop();
currentAmbienceType = 'silent';
}
}, 50);
};
/**
* 播放音效
* @param {string} type - 音效类型
* @param {object} options - 配置选项
*/
export const playSoundEffect = (type, options = {}) => {
const { volume = 1, loop = false } = options;
// 音效资源映射
const effectMap = {
flip: '/assets/audios/paper.mp3', // 翻页/纸张摩擦声
success: '/assets/audios/success.mp3', // 成功
inkDrop: '/assets/audios/inkdrop.mp3', // 水滴/入墨声
swipe: '/assets/audios/swipe.mp3', // 滑动
tap: '/assets/audios/tap.mp3', // 点击
breathe: '/assets/audios/breathe.mp3', // 呼吸
char: '/assets/audios/char.mp3' // 字显现
};
const src = effectMap[type];
if (!src) return;
// 复用或创建音频上下文
if (!soundEffects[type]) {
soundEffects[type] = wx.createInnerAudioContext();
}
const ctx = soundEffects[type];
ctx.src = src;
ctx.volume = volume;
ctx.loop = loop;
ctx.stop();
try {
ctx.play();
} catch (err) {
console.log('音效播放失败:', err);
}
return ctx;
};
/**
* 停止指定音效
* @param {string} type - 音效类型
*/
export const stopSoundEffect = (type) => {
if (soundEffects[type]) {
soundEffects[type].stop();
}
};
/**
* 停止所有音效
*/
export const stopAllSoundEffects = () => {
Object.keys(soundEffects).forEach(type => {
soundEffects[type].stop();
});
};
/**
* 停止环境音
*/
export const stopAmbience = () => {
fadeOutAndStop();
};
/**
* 获取当前环境音类型
*/
export const getCurrentAmbience = () => currentAmbienceType;
/**
* 切换环境音
* @param {string} type - 环境音类型
*/
export const toggleAmbience = (type) => {
if (currentAmbienceType === type) {
playAmbience('silent');
} else {
playAmbience(type);
}
};
/**
* 呼吸震动效果 - 配合名字逐字显现
* @param {number} duration - 总时长 ms
* @param {number} intensity - 震动强度 light/medium/heavy
*/
export const breatheVibration = (duration = 2000, intensity = 'light') => {
const interval = duration / 4; // 分4次震动
let count = 0;
const vibrate = () => {
if (count < 4) {
wx.vibrateShort({ type: intensity });
count++;
setTimeout(vibrate, interval);
}
};
vibrate();
};
/**
* 清理音频资源
*/
export const cleanup = () => {
clearInterval(fadeTimer);
if (bgmManager) {
bgmManager.stop();
}
Object.keys(soundEffects).forEach(type => {
soundEffects[type].destroy();
delete soundEffects[type];
});
};

View File

61
step6.md Normal file
View File

@@ -0,0 +1,61 @@
---
# 阶段 5意境升华与用户深度连接
## 🎯 阶段目标
通过声音、触感和数据分析,将小程序从“好用的工具”升华为“有温度的艺术品”,并为未来的商业化/社交化铺路。
---
## 5.1 多维交互:五感体验优化 (The Sensory Update)
目前视觉已经做到了极致,我们需要加入听觉和触觉来补完“禅意”。
* **需求详情**
1. **环境白噪音 (Ambience)**:在卡片页引入极细微的背景音(如:深山风铃、雨落屋檐、古琴泛音)。用户可自主选择开关。
2. **交互音效 (Sound FX)**
* 墨滴晕开时:一个空灵的“叮”或水滴声。
* 卡片翻转时:模拟宣纸摩擦的沙沙声。
3. **触感反馈 (Haptic)**
* 当 AI 生成名字时配合呼吸灯动效让手机产生极轻微的、节奏性的震动Breathing Haptic
* **技术点**:使用 `wx.createInnerAudioContext` 管理全局音频流,确保低延迟。
## 5.2 智能解析:名字深意探索 (Deep Meaning AI)
现在的描述是 AI 自动生成的,我们可以提供更深层的、更具交互性的解析。
* **需求详情**
1. **文字溯源**:在卡片背面点击某个字,弹出该字的“说文解字”或书法演变(甲骨文/篆书)。
2. **AI 对话解析**:允许用户针对某个名字提问(如:“这个名字对职业规划有什么暗示?”),调动 AI 进行单条名字的深度对话。
* **技术点**:接入一个小规模的“字源”数据库,或针对特定名字触发第二次 AI 深度解析请求。
## 5.3 社交广场:灵感集锦 (`Inspiration Square`)
打破“单机起名”的沉闷,让用户看看别人都在求什么好名字。
* **需求详情**
1. **匿名广场**:展示其他用户公开分享的精美名字海报(瀑布流形式)。
2. **灵感复用**:看到别人求出的好名字,可以点击“同款意境”,自动跳转到输入页并填入相同的关键词和模式。
* **技术点**:在收藏/海报生成时增加“允许公开展示”的选项。
## 5.4 商业化初探:定制起名服务 (Premium Path)
为未来的收益做技术预研。
* **需求详情**
1. **“大师”模式**:接入更强大的大模型(如 Gemini 1.5 Pro 或 GPT-4o提供更严谨的五行八字对齐和文学考据。
2. **积分/激励系统**:每日免费起名次数限制,通过观看极简广告(如品牌画报)或签到来获取“灵感值”。
---
## 🛠️ 下一阶段任务分解 (WBS)
| 模块 | 任务描述 | 优先级 |
| :--- | :--- | :--- |
| **沉浸式音效** | 寻找并集成 3-5 种禅意白噪音,实现无缝循环播放 | 🔥🔥🔥 |
| **交互震动** | 优化 UI 操作时的 `vibrateShort` 反馈逻辑 | 🔥🔥 |
| **灵感广场** | 实现基于云数据库的海报分享流与“同款”跳转逻辑 | 🔥🔥🔥 |
| **解析增强** | 开发卡片背面的“字源探索”弹窗组件 | 🔥 |
---
## 💡 为什么这是下一阶段的核心?
* **差异化**:市面上的起名软件都很“商业、吵闹”,「见素」的静谧感是核心竞争力,音效能极大加深这种印象。
* **内容生态**:灵感广场能让小程序产生“自生长”的内容,增加用户停留时长。
* **价值感**:深度解析让用户觉得这个名字“不仅仅是好听”,而是“有理有据”。

103
step6_dev.md Normal file
View File

@@ -0,0 +1,103 @@
阶段 5 开发实施方案:五感交互与内容生态 (step6_dev.md)
🛠 准 备工作
1. 资源包准备:
* 搜集 3-5 段 30s 左右的无缝循环白噪音MP3/M4A 格式,建议采样率 44.1kHz,单声道以减小体积)。
* 准备 2 个极简音效:水滴声(入墨)、纸张摩擦声(翻转)。
2. 后端扩展:
* 在 NameCard 模型中增加 public_shared 字段。
* 准备一个新的 SquareController 用于处理广场流数据。
---
第一部分:五感体验优化 (Sensory)
1.1 环境白噪音管理 (Soundscape)
* 实现方式:在 app.js 或全局自定义组件中封装 AudioContextManager。
* 代码要点:
1 // utils/audio.js
2 const bgm = wx.getBackgroundAudioManager(); // 或 createInnerAudioContext
3 export const playAmbience = (type) => {
4 bgm.title = '见素-环境音';
5 bgm.src = `https://your-cdn.com/audio/${type}.mp3`;
6 bgm.loop = true;
7 };
* UI 交互:在卡片详情页右上角增加一个极其隐蔽的“音符”图标,点击弹出切换菜单(静音/风铃/雨落/古琴)。
1.2 触感与音效反馈 (Haptics)
* 触感反馈:在 AI 生成状态变更时调用 wx.vibrateShort({ type: 'light' })。
* 呼吸震动:模仿冥想 App在名字逐字显现时配合 setInterval 产生极微弱的节奏震动。
---
第二部分:智能解析增强 (AI Deep Dive)
2.1 字源溯源组件
* 功能:长按卡片上的某个汉字,触发弹窗。
* 数据来源:
* 方案 A (静态):内置常用起名汉字库(约 3000 字)的解析。
* 方案 B (动态):调用后端接口,利用 AI 实时生成该字的“意象分析”。
* UI 设计:弹窗采用透明磨砂玻璃效果,背景展示该字的篆书或隶书位图(可从开源书法库爬取)。
2.2 AI 对话解析
* 前端逻辑:在卡片底部增加“问问 AI”按钮。
* 后端逻辑:
1 // NamingController.java 扩展
2 @PostMapping("/explain")
3 public String explainDetail(@RequestBody ExplainRequest req) {
4 // Prompt: "作为起名专家,请深度解析名字'墨染'对于'从事艺术行业'的职场暗示..."
5 return kimiService.ask(req.getPrompt());
6 }
---
第三部分:灵感广场 (Social Square)
3.1 匿名分享流
* 数据库设计:创建 SquarePost 表,存储 name_card_id, image_url, tags, like_count。
* 前端展示使用小程序瀑布流组件Masonry Layout展示用户生成的精美海报缩略图。
* 交互:点击海报进入详情,由于是“见素”风格,评论功能建议弱化,仅保留“共鸣”(点赞)。
3.2 “同款”跳转逻辑
* 核心逻辑:点击“同款意境” -> 携带 keyword 和 mode 参数跳转回 home 页面。
1 // square.js
2 copyInspiration(e) {
3 const { keyword, mode } = e.currentTarget.dataset;
4 wx.reLaunch({
5 url: `/pages/home/home?keyword=${keyword}&mode=${mode}`
6 });
7 }
---
第四部分:技术预研与优化
4.1 积分/激励系统
* 逻辑:
* 每日赠送 5 次“灵感”。
* 消耗完后,引导用户点击“静心阅读”(观看 15 秒极简视频或精美画报)增加次数。
* 注意UI 必须极其克制,不能有任何弹窗干扰,建议放在“个人中心”的角落。
4.2 高级模型接入 (Premium Path)
* 策略模式:在后端 KimiService 中根据用户等级(或请求类型)切换 Model ID。
* 普通moonshot-v1-8k
* 大师moonshot-v1-32k 或 gemini-1.5-pro (更强的逻辑推理)。
---
📅 任务排期 (WBS)
1. Day 1-2: 音频/触感系统重构。解决音频在页面切换时的平滑过渡问题。
2. Day 3-4: 广场后端 API 开发及小程序瀑布流页面实现。
3. Day 5: 字源数据库集成与 AI 对话解析 Prompt 调优。
4. Day 6: 性能专项优化(音频资源 CDN 加速、广场图懒加载)。
5. Day 7: 整体回归测试与上线准备。
---
💡 开发提示:
对于“见素”这样追求极致体验的产品,音效的淡入淡出 (Fade In/Out)
至关重要。请务必在切换音频时手动实现音量平滑曲线,避免突兀。