diff --git a/backend/src/main/java/com/jiansu/naming/controller/AuthController.java b/backend/src/main/java/com/jiansu/naming/controller/AuthController.java new file mode 100644 index 0000000..e04e0d3 --- /dev/null +++ b/backend/src/main/java/com/jiansu/naming/controller/AuthController.java @@ -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 request) { + String code = request.get("code"); + if (code == null || code.isEmpty()) { + Map 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 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 response = new HashMap<>(); + response.put("success", false); + response.put("message", "登录异常: " + e.getMessage()); + return ResponseEntity.ok(response); + } + } +} diff --git a/backend/src/main/java/com/jiansu/naming/controller/CharController.java b/backend/src/main/java/com/jiansu/naming/controller/CharController.java new file mode 100644 index 0000000..31dbddc --- /dev/null +++ b/backend/src/main/java/com/jiansu/naming/controller/CharController.java @@ -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 response = new HashMap<>(); + + if (character == null || character.length() != 1) { + response.put("success", false); + response.put("message", "请输入单个汉字"); + return ResponseEntity.badRequest().body(response); + } + + try { + Map 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 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 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 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 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 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 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, ""); + } +} diff --git a/backend/src/main/java/com/jiansu/naming/controller/CreditsController.java b/backend/src/main/java/com/jiansu/naming/controller/CreditsController.java new file mode 100644 index 0000000..e5c18b7 --- /dev/null +++ b/backend/src/main/java/com/jiansu/naming/controller/CreditsController.java @@ -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 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 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 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 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); + } +} diff --git a/backend/src/main/java/com/jiansu/naming/controller/NamingController.java b/backend/src/main/java/com/jiansu/naming/controller/NamingController.java index a532059..b9504d8 100644 --- a/backend/src/main/java/com/jiansu/naming/controller/NamingController.java +++ b/backend/src/main/java/com/jiansu/naming/controller/NamingController.java @@ -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 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 response = new HashMap<>(); + response.put("success", false); + response.put("message", "今日灵感次数已用完,请前往个人中心静心阅读获取更多次数"); + return ResponseEntity.ok(response); + } + } + + // 解析需要排除的名字列表 + List excludeList = new ArrayList(); + if (excludeNames != null && !excludeNames.isEmpty()) { + excludeList = java.util.Arrays.asList(excludeNames.split(",")); + } + + // 使用异步方法获取结果,确保5个不重复的名字 + CompletableFuture> future = kimiService.generateUniqueNamesAsync(keyword, mode, surname, 5, excludeList); + List result = future.join(); + + Map response = new HashMap<>(); + response.put("success", true); + response.put("data", result); + return ResponseEntity.ok(response); + } + + /** + * AI 深度解析名字 + */ + @PostMapping("/explain") + public ResponseEntity explainName(@RequestBody Map request) { + String name = request.get("name"); + String context = request.get("context"); // 如:从事艺术行业 - log.info("生成名字请求 - 关键词: {}, 模式: {}, 批次: {}, 数量: {}", keyword, mode, batch, count); + if (name == null || name.isEmpty()) { + Map response = new HashMap<>(); + response.put("success", false); + response.put("message", "名字不能为空"); + return ResponseEntity.badRequest().body(response); + } - // 使用异步方法获取结果 - CompletableFuture> future = kimiService.generateNamesAsync(keyword, mode, surname, count); - return future.join(); + log.info("AI 解析名字: {}, 语境: {}", name, context); + + String explanation = kimiService.explainName(name, context); + + Map response = new HashMap<>(); + response.put("success", true); + response.put("data", explanation); + + return ResponseEntity.ok(response); } } diff --git a/backend/src/main/java/com/jiansu/naming/controller/SquareController.java b/backend/src/main/java/com/jiansu/naming/controller/SquareController.java new file mode 100644 index 0000000..45638b4 --- /dev/null +++ b/backend/src/main/java/com/jiansu/naming/controller/SquareController.java @@ -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 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 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 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 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 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 response = new HashMap<>(); + response.put("success", true); + response.put("message", "删除成功"); + + return ResponseEntity.ok(response); + } + + /** + * 获取用户的帖子 + */ + @GetMapping("/user/posts") + public ResponseEntity getUserPosts(@RequestParam String openid) { + Map response = new HashMap<>(); + response.put("success", true); + response.put("data", squareService.getUserPosts(openid)); + + return ResponseEntity.ok(response); + } +} diff --git a/backend/src/main/java/com/jiansu/naming/entity/SquarePost.java b/backend/src/main/java/com/jiansu/naming/entity/SquarePost.java new file mode 100644 index 0000000..d325ab2 --- /dev/null +++ b/backend/src/main/java/com/jiansu/naming/entity/SquarePost.java @@ -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; +} diff --git a/backend/src/main/java/com/jiansu/naming/entity/UserCredits.java b/backend/src/main/java/com/jiansu/naming/entity/UserCredits.java new file mode 100644 index 0000000..272c199 --- /dev/null +++ b/backend/src/main/java/com/jiansu/naming/entity/UserCredits.java @@ -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; +} diff --git a/backend/src/main/java/com/jiansu/naming/model/NameCard.java b/backend/src/main/java/com/jiansu/naming/model/NameCard.java index a267ad8..42310e8 100644 --- a/backend/src/main/java/com/jiansu/naming/model/NameCard.java +++ b/backend/src/main/java/com/jiansu/naming/model/NameCard.java @@ -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(如果已分享) } \ No newline at end of file diff --git a/backend/src/main/java/com/jiansu/naming/repository/SquarePostRepository.java b/backend/src/main/java/com/jiansu/naming/repository/SquarePostRepository.java new file mode 100644 index 0000000..01f63c8 --- /dev/null +++ b/backend/src/main/java/com/jiansu/naming/repository/SquarePostRepository.java @@ -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 { + + /** + * 查询公开的帖子 + */ + Page findByIsPublicTrueOrderByCreatedAtDesc(Pageable pageable); + + /** + * 根据标签查询 + */ + @Query("SELECT s FROM SquarePost s WHERE s.isPublic = true AND s.tags LIKE %:tag% ORDER BY s.createdAt DESC") + Page 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 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 findByOpenidOrderByCreatedAtDesc(String openid); + + /** + * 查询热门帖子 + */ + @Query("SELECT s FROM SquarePost s WHERE s.isPublic = true ORDER BY s.likeCount DESC, s.createdAt DESC") + Page findHotPosts(Pageable pageable); +} diff --git a/backend/src/main/java/com/jiansu/naming/repository/UserCreditsRepository.java b/backend/src/main/java/com/jiansu/naming/repository/UserCreditsRepository.java new file mode 100644 index 0000000..9619552 --- /dev/null +++ b/backend/src/main/java/com/jiansu/naming/repository/UserCreditsRepository.java @@ -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 { + + Optional findByOpenid(String openid); + + boolean existsByOpenid(String openid); +} diff --git a/backend/src/main/java/com/jiansu/naming/service/CreditsService.java b/backend/src/main/java/com/jiansu/naming/service/CreditsService.java new file mode 100644 index 0000000..55a3239 --- /dev/null +++ b/backend/src/main/java/com/jiansu/naming/service/CreditsService.java @@ -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 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(); + } +} diff --git a/backend/src/main/java/com/jiansu/naming/service/FavoriteService.java b/backend/src/main/java/com/jiansu/naming/service/FavoriteService.java index ba8c6ac..a8458bf 100644 --- a/backend/src/main/java/com/jiansu/naming/service/FavoriteService.java +++ b/backend/src/main/java/com/jiansu/naming/service/FavoriteService.java @@ -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); diff --git a/backend/src/main/java/com/jiansu/naming/service/KimiService.java b/backend/src/main/java/com/jiansu/naming/service/KimiService.java index 607e228..70e5b1d 100644 --- a/backend/src/main/java/com/jiansu/naming/service/KimiService.java +++ b/backend/src/main/java/com/jiansu/naming/service/KimiService.java @@ -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> */ public CompletableFuture> 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> + */ + public CompletableFuture> generateUniqueNamesAsync(String keyword, String mode, String surname, int targetCount, List 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> futures = new ArrayList<>(); - for (int i = 0; i < count; i++) { - final int index = i; - CompletableFuture future = CompletableFuture.supplyAsync(() -> { - try { - return callAiToGenerateSingleName(keyword, finalMode, surname, index); - } catch (Exception e) { - log.error("第 {} 个名字生成失败: {}", index, e.getMessage()); + + // 使用 Set 存储已生成的名字,确保唯一性 + java.util.Set generatedNames = new java.util.HashSet<>(excludeNames); + java.util.List 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> futures = new ArrayList<>(); + for (int i = 0; i < needCount; i++) { + final int index = i + (round - 1) * targetCount; + CompletableFuture 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 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 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 jsonBody = new HashMap<>(); + jsonBody.put("model", "moonshot-v1-8k"); + jsonBody.put("temperature", 0.7); + + List> messages = new ArrayList<>(); + + Map systemMsg = new HashMap<>(); + systemMsg.put("role", "system"); + systemMsg.put("content", "你是一位精通中国传统文化、姓名学、诗词典故的起名专家。请用优美、富有诗意的语言解析名字。"); + messages.add(systemMsg); + + Map 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 jsonBody = new HashMap<>(); + jsonBody.put("model", MODEL_PREMIUM); + jsonBody.put("temperature", 0.7); + + List> messages = new ArrayList<>(); + + Map systemMsg = new HashMap<>(); + systemMsg.put("role", "system"); + systemMsg.put("content", "你是「见素」的首席起名大师,精通中国传统文化、诗词典故、音韵学。请创作极具意境和美感的名字。"); + messages.add(systemMsg); + + Map 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("网络请求失败"); + } + } } diff --git a/backend/src/main/java/com/jiansu/naming/service/SquareService.java b/backend/src/main/java/com/jiansu/naming/service/SquareService.java new file mode 100644 index 0000000..d8b7c78 --- /dev/null +++ b/backend/src/main/java/com/jiansu/naming/service/SquareService.java @@ -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 getPosts(int page, int size) { + Pageable pageable = PageRequest.of(page, size); + return squarePostRepository.findByIsPublicTrueOrderByCreatedAtDesc(pageable); + } + + /** + * 获取热门帖子 + */ + public Page getHotPosts(int page, int size) { + Pageable pageable = PageRequest.of(page, size); + return squarePostRepository.findHotPosts(pageable); + } + + /** + * 根据标签查询 + */ + public Page getPostsByTag(String tag, int page, int size) { + Pageable pageable = PageRequest.of(page, size); + return squarePostRepository.findByTag(tag, pageable); + } + + /** + * 根据关键词查询 + */ + public Page 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 getPostById(Long id) { + return squarePostRepository.findById(id); + } + + /** + * 删除帖子 + */ + @Transactional + public void deletePost(Long id, String openid) { + Optional post = squarePostRepository.findById(id); + if (post.isPresent() && post.get().getOpenid().equals(openid)) { + squarePostRepository.deleteById(id); + log.info("删除帖子: {}", id); + } + } + + /** + * 获取用户的帖子 + */ + public List getUserPosts(String openid) { + return squarePostRepository.findByOpenidOrderByCreatedAtDesc(openid); + } +} diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index a269a06..8b2f554 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -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: diff --git a/backend/target/classes/application.yml b/backend/target/classes/application.yml index a269a06..8b2f554 100644 --- a/backend/target/classes/application.yml +++ b/backend/target/classes/application.yml @@ -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: diff --git a/backend/target/classes/com/jiansu/naming/controller/AuthController.class b/backend/target/classes/com/jiansu/naming/controller/AuthController.class new file mode 100644 index 0000000..fabe38e Binary files /dev/null and b/backend/target/classes/com/jiansu/naming/controller/AuthController.class differ diff --git a/backend/target/classes/com/jiansu/naming/controller/CharController.class b/backend/target/classes/com/jiansu/naming/controller/CharController.class new file mode 100644 index 0000000..9014591 Binary files /dev/null and b/backend/target/classes/com/jiansu/naming/controller/CharController.class differ diff --git a/backend/target/classes/com/jiansu/naming/controller/CreditsController.class b/backend/target/classes/com/jiansu/naming/controller/CreditsController.class new file mode 100644 index 0000000..9b3624e Binary files /dev/null and b/backend/target/classes/com/jiansu/naming/controller/CreditsController.class differ diff --git a/backend/target/classes/com/jiansu/naming/controller/NamingController.class b/backend/target/classes/com/jiansu/naming/controller/NamingController.class index f66f822..1ed9b84 100644 Binary files a/backend/target/classes/com/jiansu/naming/controller/NamingController.class and b/backend/target/classes/com/jiansu/naming/controller/NamingController.class differ diff --git a/backend/target/classes/com/jiansu/naming/controller/SquareController.class b/backend/target/classes/com/jiansu/naming/controller/SquareController.class new file mode 100644 index 0000000..8c2f65c Binary files /dev/null and b/backend/target/classes/com/jiansu/naming/controller/SquareController.class differ diff --git a/backend/target/classes/com/jiansu/naming/entity/SquarePost.class b/backend/target/classes/com/jiansu/naming/entity/SquarePost.class new file mode 100644 index 0000000..dbaa746 Binary files /dev/null and b/backend/target/classes/com/jiansu/naming/entity/SquarePost.class differ diff --git a/backend/target/classes/com/jiansu/naming/entity/UserCredits.class b/backend/target/classes/com/jiansu/naming/entity/UserCredits.class new file mode 100644 index 0000000..a0da1ab Binary files /dev/null and b/backend/target/classes/com/jiansu/naming/entity/UserCredits.class differ diff --git a/backend/target/classes/com/jiansu/naming/model/NameCard$NameCardBuilder.class b/backend/target/classes/com/jiansu/naming/model/NameCard$NameCardBuilder.class index 5ce0f94..05ffca4 100644 Binary files a/backend/target/classes/com/jiansu/naming/model/NameCard$NameCardBuilder.class and b/backend/target/classes/com/jiansu/naming/model/NameCard$NameCardBuilder.class differ diff --git a/backend/target/classes/com/jiansu/naming/model/NameCard.class b/backend/target/classes/com/jiansu/naming/model/NameCard.class index ae998b0..4802833 100644 Binary files a/backend/target/classes/com/jiansu/naming/model/NameCard.class and b/backend/target/classes/com/jiansu/naming/model/NameCard.class differ diff --git a/backend/target/classes/com/jiansu/naming/repository/SquarePostRepository.class b/backend/target/classes/com/jiansu/naming/repository/SquarePostRepository.class new file mode 100644 index 0000000..6ae538e Binary files /dev/null and b/backend/target/classes/com/jiansu/naming/repository/SquarePostRepository.class differ diff --git a/backend/target/classes/com/jiansu/naming/repository/UserCreditsRepository.class b/backend/target/classes/com/jiansu/naming/repository/UserCreditsRepository.class new file mode 100644 index 0000000..d3402fd Binary files /dev/null and b/backend/target/classes/com/jiansu/naming/repository/UserCreditsRepository.class differ diff --git a/backend/target/classes/com/jiansu/naming/service/CreditsService.class b/backend/target/classes/com/jiansu/naming/service/CreditsService.class new file mode 100644 index 0000000..67ba1da Binary files /dev/null and b/backend/target/classes/com/jiansu/naming/service/CreditsService.class differ diff --git a/backend/target/classes/com/jiansu/naming/service/FavoriteService.class b/backend/target/classes/com/jiansu/naming/service/FavoriteService.class index a2a84e2..980883e 100644 Binary files a/backend/target/classes/com/jiansu/naming/service/FavoriteService.class and b/backend/target/classes/com/jiansu/naming/service/FavoriteService.class differ diff --git a/backend/target/classes/com/jiansu/naming/service/KimiService$1.class b/backend/target/classes/com/jiansu/naming/service/KimiService$1.class index 5d23c10..409160f 100644 Binary files a/backend/target/classes/com/jiansu/naming/service/KimiService$1.class and b/backend/target/classes/com/jiansu/naming/service/KimiService$1.class differ diff --git a/backend/target/classes/com/jiansu/naming/service/KimiService.class b/backend/target/classes/com/jiansu/naming/service/KimiService.class index 871fdfd..c295610 100644 Binary files a/backend/target/classes/com/jiansu/naming/service/KimiService.class and b/backend/target/classes/com/jiansu/naming/service/KimiService.class differ diff --git a/backend/target/classes/com/jiansu/naming/service/SquareService.class b/backend/target/classes/com/jiansu/naming/service/SquareService.class new file mode 100644 index 0000000..eebfd81 Binary files /dev/null and b/backend/target/classes/com/jiansu/naming/service/SquareService.class differ diff --git a/backend/target/naming-0.0.1-SNAPSHOT.war b/backend/target/naming-0.0.1-SNAPSHOT.war index a51a013..c89686f 100644 Binary files a/backend/target/naming-0.0.1-SNAPSHOT.war and b/backend/target/naming-0.0.1-SNAPSHOT.war differ diff --git a/backend/target/naming-0.0.1-SNAPSHOT.war.original b/backend/target/naming-0.0.1-SNAPSHOT.war.original index d4c9735..70558eb 100644 Binary files a/backend/target/naming-0.0.1-SNAPSHOT.war.original and b/backend/target/naming-0.0.1-SNAPSHOT.war.original differ diff --git a/backend/target/naming-0.0.1-SNAPSHOT/WEB-INF/classes/com/jiansu/naming/service/FavoriteService.class b/backend/target/naming-0.0.1-SNAPSHOT/WEB-INF/classes/com/jiansu/naming/service/FavoriteService.class index a2a84e2..980883e 100644 Binary files a/backend/target/naming-0.0.1-SNAPSHOT/WEB-INF/classes/com/jiansu/naming/service/FavoriteService.class and b/backend/target/naming-0.0.1-SNAPSHOT/WEB-INF/classes/com/jiansu/naming/service/FavoriteService.class differ diff --git a/miniprogram/app.js b/miniprogram/app.js index ff18ea7..739d62d 100644 --- a/miniprogram/app.js +++ b/miniprogram/app.js @@ -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' } }); diff --git a/miniprogram/app.json b/miniprogram/app.json index ebc013e..10023c8 100644 --- a/miniprogram/app.json +++ b/miniprogram/app.json @@ -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" } \ No newline at end of file diff --git a/miniprogram/components/charDetail/charDetail.js b/miniprogram/components/charDetail/charDetail.js new file mode 100644 index 0000000..672d841 --- /dev/null +++ b/miniprogram/components/charDetail/charDetail.js @@ -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 }); + } + } +}); diff --git a/miniprogram/components/charDetail/charDetail.json b/miniprogram/components/charDetail/charDetail.json new file mode 100644 index 0000000..a89ef4d --- /dev/null +++ b/miniprogram/components/charDetail/charDetail.json @@ -0,0 +1,4 @@ +{ + "component": true, + "usingComponents": {} +} diff --git a/miniprogram/components/charDetail/charDetail.wxml b/miniprogram/components/charDetail/charDetail.wxml new file mode 100644 index 0000000..c243770 --- /dev/null +++ b/miniprogram/components/charDetail/charDetail.wxml @@ -0,0 +1,59 @@ + + + + + + + + × + + + + {{char}} + {{pinyin}} + + + + + + + + 部首 + {{radical || '未知'}} + + + 笔画 + {{strokes || '未知'}}画 + + + 五行 + {{wuxing || '未知'}} + + + + + + 本义 + {{meaning || '暂无解析'}} + + + + + 起名意象 + {{imagery || '暂无解析'}} + + + + + 诗词典故 + {{poetry}} + + + + + + + 问问 AI + + + diff --git a/miniprogram/components/charDetail/charDetail.wxss b/miniprogram/components/charDetail/charDetail.wxss new file mode 100644 index 0000000..6c74361 --- /dev/null +++ b/miniprogram/components/charDetail/charDetail.wxss @@ -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; +} diff --git a/miniprogram/pages/home/home.js b/miniprogram/pages/home/home.js index 33b9456..8c7acf0 100644 --- a/miniprogram/pages/home/home.js +++ b/miniprogram/pages/home/home.js @@ -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 }); diff --git a/miniprogram/pages/home/home.wxml b/miniprogram/pages/home/home.wxml index 82308b6..7c8dea0 100644 --- a/miniprogram/pages/home/home.wxml +++ b/miniprogram/pages/home/home.wxml @@ -52,6 +52,12 @@ + + + 入林搜寻灵感 + + + © 见素 · 审美溢价 diff --git a/miniprogram/pages/home/home.wxss b/miniprogram/pages/home/home.wxss index 655ac80..cedd726 100644 --- a/miniprogram/pages/home/home.wxss +++ b/miniprogram/pages/home/home.wxss @@ -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; +} diff --git a/miniprogram/pages/index/index.js b/miniprogram/pages/index/index.js index cd75797..99e63a0 100644 --- a/miniprogram/pages/index/index.js +++ b/miniprogram/pages/index/index.js @@ -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 }); + } + }); } }); diff --git a/miniprogram/pages/index/index.json b/miniprogram/pages/index/index.json new file mode 100644 index 0000000..a97367d --- /dev/null +++ b/miniprogram/pages/index/index.json @@ -0,0 +1,3 @@ +{ + "usingComponents": {} +} diff --git a/miniprogram/pages/index/index.wxml b/miniprogram/pages/index/index.wxml index 30b22ae..5d14999 100644 --- a/miniprogram/pages/index/index.wxml +++ b/miniprogram/pages/index/index.wxml @@ -18,7 +18,7 @@ - {{nameList[currentIndex].name}} + + + + + + 🔇 + 静音 + + + 🎐 + 风铃 + + + 🌧 + 雨落 + + + 🎵 + 古琴 + + + 🌫 + 白噪音 + + + 🌲 + 森林 + + + 💧 + 溪流 + + + {{nameList[currentIndex].name}} {{nameList[currentIndex].origin}} @@ -40,6 +73,10 @@ 声韵:{{nameList[currentIndex].tone}} 见素评分:{{nameList[currentIndex].score}} + + + 问问 AI + 存为海报 @@ -83,6 +120,9 @@ 保存到相册 + + 分享到广场 + @@ -90,26 +130,53 @@ + + + + 此间灵感暂歇 + 或寻他处,或待重来 + + + 进入灵感广场 + + + 返回重试 + + + + - + × + + + - - - 暂无灵感,请重试 - + + + + 此间灵感暂歇 + 或寻他处,或待重来 + + + 进入灵感广场 + + + 返回重试 + + - + - {{collectedNames.length}} + {{collectedNames.length}} @@ -130,4 +197,109 @@ + + + + + × + + + + + + + {{selectedChar}} + {{selectedChar}} + {{selectedChar}} + + + + + {{selectedChar}} + {{charDetailData.pinyin}} + + + + + + + + 部首 + {{charDetailData.radical || '未知'}} + + + 笔画 + {{charDetailData.strokes || '未知'}}画 + + + 五行 + {{charDetailData.wuxing || '未知'}} + + + + + + 本义 + {{charDetailData.meaning || '暂无解析'}} + + + + + 起名意象 + {{charDetailData.imagery || '暂无解析'}} + + + + + 诗词典故 + {{charDetailData.poetry}} + + + + + + 正在溯源... + + + + + + + + + + + + × + + + + AI 深度解析 + {{nameList[currentIndex].name}} + + + + + 你想了解这个名字在什么场景下的暗示? + + + + + + {{aiExplanation}} + + + + + 正在思考... + + + + + + {{aiExplanation ? '重新解析' : '开始解析'}} + + + + diff --git a/miniprogram/pages/index/index.wxss b/miniprogram/pages/index/index.wxss index fdc526e..19d54ff 100644 --- a/miniprogram/pages/index/index.wxss +++ b/miniprogram/pages/index/index.wxss @@ -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; +} diff --git a/miniprogram/pages/profile/profile.js b/miniprogram/pages/profile/profile.js index 1b34d32..3a27b7f 100644 --- a/miniprogram/pages/profile/profile.js +++ b/miniprogram/pages/profile/profile.js @@ -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' }); diff --git a/miniprogram/pages/profile/profile.wxml b/miniprogram/pages/profile/profile.wxml index 2630043..da6da34 100644 --- a/miniprogram/pages/profile/profile.wxml +++ b/miniprogram/pages/profile/profile.wxml @@ -6,6 +6,18 @@ + + + + + 今日灵感 {{creditsInfo.dailyCredits || 0}}/5 + + + 📖 + 静心阅读 +3 + + + @@ -46,4 +58,23 @@ + + + + × + + 静心阅读 + + {{meditationPoem.text}} + ——{{meditationPoem.author}} + + 静观 15 秒,心随诗远 + + + + + + {{meditationProgress}}% + + diff --git a/miniprogram/pages/profile/profile.wxss b/miniprogram/pages/profile/profile.wxss index 95acfbb..810fcd3 100644 --- a/miniprogram/pages/profile/profile.wxss +++ b/miniprogram/pages/profile/profile.wxss @@ -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; +} diff --git a/miniprogram/pages/square/square.js b/miniprogram/pages/square/square.js new file mode 100644 index 0000000..58b0670 --- /dev/null +++ b/miniprogram/pages/square/square.js @@ -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(); + } + } +}); diff --git a/miniprogram/pages/square/square.json b/miniprogram/pages/square/square.json new file mode 100644 index 0000000..2c0ee94 --- /dev/null +++ b/miniprogram/pages/square/square.json @@ -0,0 +1,8 @@ +{ + "navigationBarTitleText": "灵感广场", + "navigationBarBackgroundColor": "#F5F5F0", + "navigationBarTextStyle": "black", + "backgroundColor": "#F5F5F0", + "enablePullDownRefresh": true, + "onReachBottomDistance": 100 +} diff --git a/miniprogram/pages/square/square.wxml b/miniprogram/pages/square/square.wxml new file mode 100644 index 0000000..e238e1a --- /dev/null +++ b/miniprogram/pages/square/square.wxml @@ -0,0 +1,141 @@ + + + + + 灵感广场 + 发现诗意,共鸣美好 + + + + + + 全部 + 清冷 + 温柔 + 古雅 + 诗意 + 自然 + + + + + + 最新 + 热门 + + + + + + + + + {{item.name}} + + + + + 同款意境 + + + + + + + + + {{item.name}} + + + + + 同款意境 + + + + + + + + + + + + + + + + 没有更多了 + + + + + 暂无灵感,快去生成并分享吧 + + + + + + + + + {{selectedPost.name}} + {{selectedPost.origin}} + {{selectedPost.description}} + + + + 同款意境 + + + + × + + diff --git a/miniprogram/pages/square/square.wxss b/miniprogram/pages/square/square.wxss new file mode 100644 index 0000000..44a8442 --- /dev/null +++ b/miniprogram/pages/square/square.wxss @@ -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; +} diff --git a/miniprogram/utils/audio.js b/miniprogram/utils/audio.js new file mode 100644 index 0000000..c0abf1a --- /dev/null +++ b/miniprogram/utils/audio.js @@ -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]; + }); +}; diff --git a/step5.mc b/step5.md similarity index 100% rename from step5.mc rename to step5.md diff --git a/step6.md b/step6.md new file mode 100644 index 0000000..cb508d9 --- /dev/null +++ b/step6.md @@ -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` 反馈逻辑 | 🔥🔥 | +| **灵感广场** | 实现基于云数据库的海报分享流与“同款”跳转逻辑 | 🔥🔥🔥 | +| **解析增强** | 开发卡片背面的“字源探索”弹窗组件 | 🔥 | + +--- + +## 💡 为什么这是下一阶段的核心? +* **差异化**:市面上的起名软件都很“商业、吵闹”,「见素」的静谧感是核心竞争力,音效能极大加深这种印象。 +* **内容生态**:灵感广场能让小程序产生“自生长”的内容,增加用户停留时长。 +* **价值感**:深度解析让用户觉得这个名字“不仅仅是好听”,而是“有理有据”。 diff --git a/step6_dev.md b/step6_dev.md new file mode 100644 index 0000000..db91e4d --- /dev/null +++ b/step6_dev.md @@ -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) + 至关重要。请务必在切换音频时手动实现音量平滑曲线,避免突兀。 \ No newline at end of file