feat: 完善见素起名小程序功能
- 添加收藏锦囊功能,支持查看和删除收藏 - 实现积分系统,每日赠送5次灵感次数 - 添加静心阅读功能,阅读15秒可获得额外次数 - 实现灵感广场,展示用户分享的名字 - 添加字源溯源组件,长按汉字查看详情 - 优化空状态和结语卡片样式统一 - 添加音频控制(静音/风铃/雨落/古琴/白噪音/森林/溪流) - 优化名字生成逻辑,确保每次返回5个不重复名字 - 修复卡片翻转样式问题 - 移除首页动态提醒气泡
This commit is contained in:
@@ -0,0 +1,74 @@
|
||||
package com.jiansu.naming.controller;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/auth")
|
||||
@CrossOrigin(origins = "*")
|
||||
@Slf4j
|
||||
public class AuthController {
|
||||
|
||||
@Value("${wechat.miniapp.app-id}")
|
||||
private String appId;
|
||||
|
||||
@Value("${wechat.miniapp.app-secret}")
|
||||
private String appSecret;
|
||||
|
||||
private final RestTemplate restTemplate = new RestTemplate();
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
/**
|
||||
* 微信登录 - 用 code 换取 openid
|
||||
*/
|
||||
@PostMapping("/login")
|
||||
public ResponseEntity<?> wxLogin(@RequestBody Map<String, String> request) {
|
||||
String code = request.get("code");
|
||||
if (code == null || code.isEmpty()) {
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
response.put("success", false);
|
||||
response.put("message", "code 不能为空");
|
||||
return ResponseEntity.badRequest().body(response);
|
||||
}
|
||||
|
||||
try {
|
||||
// 调用微信接口换取 openid
|
||||
String url = String.format(
|
||||
"https://api.weixin.qq.com/sns/jscode2session?appid=%s&secret=%s&js_code=%s&grant_type=authorization_code",
|
||||
appId, appSecret, code
|
||||
);
|
||||
|
||||
String result = restTemplate.getForObject(url, String.class);
|
||||
JsonNode jsonNode = objectMapper.readTree(result);
|
||||
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
|
||||
if (jsonNode.has("openid")) {
|
||||
String openid = jsonNode.get("openid").asText();
|
||||
response.put("success", true);
|
||||
response.put("openid", openid);
|
||||
log.info("微信登录成功,openid: {}", openid);
|
||||
} else {
|
||||
response.put("success", false);
|
||||
response.put("message", jsonNode.has("errmsg") ? jsonNode.get("errmsg").asText() : "登录失败");
|
||||
log.error("微信登录失败: {}", result);
|
||||
}
|
||||
|
||||
return ResponseEntity.ok(response);
|
||||
} catch (Exception e) {
|
||||
log.error("微信登录异常", e);
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
response.put("success", false);
|
||||
response.put("message", "登录异常: " + e.getMessage());
|
||||
return ResponseEntity.ok(response);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,401 @@
|
||||
package com.jiansu.naming.controller;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import net.sourceforge.pinyin4j.PinyinHelper;
|
||||
import net.sourceforge.pinyin4j.format.HanyuPinyinOutputFormat;
|
||||
import net.sourceforge.pinyin4j.format.HanyuPinyinToneType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/char")
|
||||
@CrossOrigin(origins = "*")
|
||||
@Slf4j
|
||||
public class CharController {
|
||||
|
||||
/**
|
||||
* 获取汉字详情
|
||||
*/
|
||||
@GetMapping("/detail")
|
||||
public ResponseEntity<?> getCharDetail(@RequestParam String character) {
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
|
||||
if (character == null || character.length() != 1) {
|
||||
response.put("success", false);
|
||||
response.put("message", "请输入单个汉字");
|
||||
return ResponseEntity.badRequest().body(response);
|
||||
}
|
||||
|
||||
try {
|
||||
Map<String, Object> data = new HashMap<>();
|
||||
data.put("char", character);
|
||||
data.put("pinyin", getPinyin(character));
|
||||
data.put("radical", getRadical(character));
|
||||
data.put("strokes", getStrokes(character));
|
||||
data.put("wuxing", getWuxing(character));
|
||||
data.put("meaning", getMeaning(character));
|
||||
data.put("imagery", getImagery(character));
|
||||
data.put("poetry", getPoetry(character));
|
||||
|
||||
response.put("success", true);
|
||||
response.put("data", data);
|
||||
|
||||
log.info("获取汉字详情: {}", character);
|
||||
} catch (Exception e) {
|
||||
log.error("获取汉字详情失败", e);
|
||||
response.put("success", false);
|
||||
response.put("message", "获取失败: " + e.getMessage());
|
||||
}
|
||||
|
||||
return ResponseEntity.ok(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取拼音
|
||||
*/
|
||||
private String getPinyin(String chinese) {
|
||||
try {
|
||||
HanyuPinyinOutputFormat format = new HanyuPinyinOutputFormat();
|
||||
format.setToneType(HanyuPinyinToneType.WITH_TONE_MARK);
|
||||
String[] pinyinArray = PinyinHelper.toHanyuPinyinStringArray(chinese.charAt(0));
|
||||
if (pinyinArray != null && pinyinArray.length > 0) {
|
||||
return pinyinArray[0];
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("拼音转换失败", e);
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取部首(简化版,实际应该查字典)
|
||||
*/
|
||||
private String getRadical(String chinese) {
|
||||
// 这里简化处理,实际应该查询汉字字典数据库
|
||||
Map<String, String> radicalMap = new HashMap<>();
|
||||
radicalMap.put("兰", "艹");
|
||||
radicalMap.put("泽", "氵");
|
||||
radicalMap.put("墨", "土");
|
||||
radicalMap.put("清", "氵");
|
||||
radicalMap.put("风", "风");
|
||||
radicalMap.put("云", "二");
|
||||
radicalMap.put("山", "山");
|
||||
radicalMap.put("水", "水");
|
||||
radicalMap.put("雅", "隹");
|
||||
radicalMap.put("若", "艹");
|
||||
radicalMap.put("溪", "氵");
|
||||
radicalMap.put("月", "月");
|
||||
radicalMap.put("明", "日");
|
||||
radicalMap.put("思", "心");
|
||||
radicalMap.put("梦", "木");
|
||||
radicalMap.put("雪", "雨");
|
||||
radicalMap.put("诗", "讠");
|
||||
radicalMap.put("书", "乛");
|
||||
radicalMap.put("画", "田");
|
||||
radicalMap.put("琴", "王");
|
||||
radicalMap.put("茶", "艹");
|
||||
radicalMap.put("竹", "竹");
|
||||
radicalMap.put("松", "木");
|
||||
radicalMap.put("梅", "木");
|
||||
radicalMap.put("竹", "竹");
|
||||
radicalMap.put("兰", "艹");
|
||||
radicalMap.put("菊", "艹");
|
||||
radicalMap.put("荷", "艹");
|
||||
radicalMap.put("莲", "艹");
|
||||
radicalMap.put("桃", "木");
|
||||
radicalMap.put("李", "木");
|
||||
radicalMap.put("杏", "木");
|
||||
radicalMap.put("梨", "木");
|
||||
radicalMap.put("棠", "木");
|
||||
radicalMap.put("梧", "木");
|
||||
radicalMap.put("桐", "木");
|
||||
radicalMap.put("枫", "木");
|
||||
radicalMap.put("柳", "木");
|
||||
radicalMap.put("杨", "木");
|
||||
radicalMap.put("柏", "木");
|
||||
radicalMap.put("楠", "木");
|
||||
radicalMap.put("梓", "木");
|
||||
radicalMap.put("榆", "木");
|
||||
radicalMap.put("桦", "木");
|
||||
radicalMap.put("樱", "木");
|
||||
radicalMap.put("柠", "木");
|
||||
radicalMap.put("橙", "木");
|
||||
radicalMap.put("柚", "木");
|
||||
radicalMap.put("棠", "木");
|
||||
return radicalMap.getOrDefault(chinese, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取笔画数(简化版)
|
||||
*/
|
||||
private int getStrokes(String chinese) {
|
||||
// 简化处理,实际应该查询汉字字典
|
||||
Map<String, Integer> strokesMap = new HashMap<>();
|
||||
strokesMap.put("兰", 5);
|
||||
strokesMap.put("泽", 8);
|
||||
strokesMap.put("墨", 15);
|
||||
strokesMap.put("清", 11);
|
||||
strokesMap.put("风", 4);
|
||||
strokesMap.put("云", 4);
|
||||
strokesMap.put("山", 3);
|
||||
strokesMap.put("水", 4);
|
||||
strokesMap.put("雅", 12);
|
||||
strokesMap.put("若", 8);
|
||||
strokesMap.put("溪", 13);
|
||||
strokesMap.put("月", 4);
|
||||
strokesMap.put("明", 8);
|
||||
strokesMap.put("思", 9);
|
||||
strokesMap.put("梦", 11);
|
||||
strokesMap.put("雪", 11);
|
||||
strokesMap.put("诗", 8);
|
||||
strokesMap.put("书", 4);
|
||||
strokesMap.put("画", 8);
|
||||
strokesMap.put("琴", 12);
|
||||
strokesMap.put("茶", 9);
|
||||
strokesMap.put("竹", 6);
|
||||
strokesMap.put("松", 8);
|
||||
strokesMap.put("梅", 11);
|
||||
strokesMap.put("菊", 11);
|
||||
strokesMap.put("荷", 10);
|
||||
strokesMap.put("莲", 10);
|
||||
strokesMap.put("桃", 10);
|
||||
strokesMap.put("李", 7);
|
||||
strokesMap.put("杏", 7);
|
||||
strokesMap.put("梨", 11);
|
||||
strokesMap.put("棠", 12);
|
||||
strokesMap.put("梧", 11);
|
||||
strokesMap.put("桐", 10);
|
||||
strokesMap.put("枫", 8);
|
||||
strokesMap.put("柳", 9);
|
||||
strokesMap.put("杨", 7);
|
||||
strokesMap.put("柏", 9);
|
||||
strokesMap.put("楠", 13);
|
||||
strokesMap.put("梓", 11);
|
||||
strokesMap.put("榆", 13);
|
||||
strokesMap.put("桦", 10);
|
||||
strokesMap.put("樱", 15);
|
||||
strokesMap.put("柠", 9);
|
||||
strokesMap.put("橙", 16);
|
||||
strokesMap.put("柚", 9);
|
||||
return strokesMap.getOrDefault(chinese, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取五行属性(简化版)
|
||||
*/
|
||||
private String getWuxing(String chinese) {
|
||||
Map<String, String> wuxingMap = new HashMap<>();
|
||||
wuxingMap.put("兰", "木");
|
||||
wuxingMap.put("泽", "水");
|
||||
wuxingMap.put("墨", "土");
|
||||
wuxingMap.put("清", "水");
|
||||
wuxingMap.put("风", "木");
|
||||
wuxingMap.put("云", "水");
|
||||
wuxingMap.put("山", "土");
|
||||
wuxingMap.put("水", "水");
|
||||
wuxingMap.put("雅", "木");
|
||||
wuxingMap.put("若", "木");
|
||||
wuxingMap.put("溪", "水");
|
||||
wuxingMap.put("月", "木");
|
||||
wuxingMap.put("明", "火");
|
||||
wuxingMap.put("思", "金");
|
||||
wuxingMap.put("梦", "木");
|
||||
wuxingMap.put("雪", "水");
|
||||
wuxingMap.put("诗", "金");
|
||||
wuxingMap.put("书", "金");
|
||||
wuxingMap.put("画", "土");
|
||||
wuxingMap.put("琴", "木");
|
||||
wuxingMap.put("茶", "木");
|
||||
wuxingMap.put("竹", "木");
|
||||
wuxingMap.put("松", "木");
|
||||
wuxingMap.put("梅", "木");
|
||||
wuxingMap.put("菊", "木");
|
||||
wuxingMap.put("荷", "木");
|
||||
wuxingMap.put("莲", "木");
|
||||
wuxingMap.put("桃", "木");
|
||||
wuxingMap.put("李", "木");
|
||||
wuxingMap.put("杏", "木");
|
||||
wuxingMap.put("梨", "木");
|
||||
wuxingMap.put("棠", "木");
|
||||
wuxingMap.put("梧", "木");
|
||||
wuxingMap.put("桐", "木");
|
||||
wuxingMap.put("枫", "木");
|
||||
wuxingMap.put("柳", "木");
|
||||
wuxingMap.put("杨", "木");
|
||||
wuxingMap.put("柏", "木");
|
||||
wuxingMap.put("楠", "木");
|
||||
wuxingMap.put("梓", "木");
|
||||
wuxingMap.put("榆", "木");
|
||||
wuxingMap.put("桦", "木");
|
||||
wuxingMap.put("樱", "木");
|
||||
wuxingMap.put("柠", "木");
|
||||
wuxingMap.put("橙", "木");
|
||||
wuxingMap.put("柚", "木");
|
||||
return wuxingMap.getOrDefault(chinese, "未知");
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取字义(简化版)
|
||||
*/
|
||||
private String getMeaning(String chinese) {
|
||||
Map<String, String> meaningMap = new HashMap<>();
|
||||
meaningMap.put("兰", "本义指兰花,象征高洁、典雅、坚贞不渝的品格。");
|
||||
meaningMap.put("泽", "本义指水汇聚的地方,引申为恩泽、润泽、仁慈。");
|
||||
meaningMap.put("墨", "本义指书写用的黑色颜料,象征文雅、深沉、有学问。");
|
||||
meaningMap.put("清", "本义指水清澈透明,引申为纯洁、清明、高洁。");
|
||||
meaningMap.put("风", "本义指空气流动,引申为风度、风采、气节。");
|
||||
meaningMap.put("云", "本义指天空中的云彩,象征自由、飘逸、高远。");
|
||||
meaningMap.put("山", "本义指地面高耸的部分,象征稳重、坚毅、可靠。");
|
||||
meaningMap.put("水", "本义指无色无味的液体,象征智慧、柔韧、包容。");
|
||||
meaningMap.put("雅", "本义指规范、标准,引申为高雅、文雅、美好。");
|
||||
meaningMap.put("若", "本义指顺从、如同,引申为好像、选择,寓意温婉。");
|
||||
meaningMap.put("溪", "本义指山间小河,引申为清澈、灵动、源远流长。");
|
||||
meaningMap.put("月", "本义指月亮,象征光明、圆满、清雅高洁。");
|
||||
meaningMap.put("明", "本义指光明、明亮,引申为聪慧、明理、通达。");
|
||||
meaningMap.put("思", "本义指思考、思念,引申为智慧、深情、细腻。");
|
||||
meaningMap.put("梦", "本义指睡眠中的幻象,引申为理想、憧憬、浪漫。");
|
||||
meaningMap.put("雪", "本义指天空降落的白色晶体,象征纯洁、高洁、清雅。");
|
||||
meaningMap.put("诗", "本义指诗歌、文学,象征文采、才情、雅致。");
|
||||
meaningMap.put("书", "本义指书写、典籍,象征学识、修养、文雅。");
|
||||
meaningMap.put("画", "本义指绘画、描绘,象征艺术、美感、才情。");
|
||||
meaningMap.put("琴", "本义指古琴,象征高雅、艺术、知音。");
|
||||
meaningMap.put("茶", "本义指茶树、茶叶,象征清雅、淡泊、禅意。");
|
||||
meaningMap.put("竹", "本义指竹子,象征气节、坚韧、虚心。");
|
||||
meaningMap.put("松", "本义指松树,象征坚贞、长寿、高洁。");
|
||||
meaningMap.put("梅", "本义指梅花,象征傲骨、坚韧、清雅。");
|
||||
meaningMap.put("菊", "本义指菊花,象征隐逸、高洁、长寿。");
|
||||
meaningMap.put("荷", "本义指荷花,象征纯洁、高雅、出淤泥而不染。");
|
||||
meaningMap.put("莲", "本义指莲花,象征纯洁、美好、佛教圣花。");
|
||||
meaningMap.put("桃", "本义指桃树、桃花,象征美好、爱情、长寿。");
|
||||
meaningMap.put("李", "本义指李树,象征果实、成就、春天。");
|
||||
meaningMap.put("杏", "本义指杏树,象征春天、美好、医学。");
|
||||
meaningMap.put("梨", "本义指梨树,象征纯洁、离别、清雅。");
|
||||
meaningMap.put("棠", "本义指棠梨树,象征美丽、温和、春天。");
|
||||
meaningMap.put("梧", "本义指梧桐树,象征高洁、忠贞、吉祥。");
|
||||
meaningMap.put("桐", "本义指桐树,象征高洁、坚贞、美好。");
|
||||
meaningMap.put("枫", "本义指枫树,象征热情、浪漫、秋天。");
|
||||
meaningMap.put("柳", "本义指柳树,象征柔美、离别、春天。");
|
||||
meaningMap.put("杨", "本义指杨树,象征挺拔、向上、正直。");
|
||||
meaningMap.put("柏", "本义指柏树,象征坚贞、长寿、高洁。");
|
||||
meaningMap.put("楠", "本义指楠木,象征珍贵、高贵、坚韧。");
|
||||
meaningMap.put("梓", "本义指梓树,象征故乡、生机、成长。");
|
||||
meaningMap.put("榆", "本义指榆树,象征坚韧、朴实、春天。");
|
||||
meaningMap.put("桦", "本义指白桦树,象征纯洁、高洁、挺拔。");
|
||||
meaningMap.put("樱", "本义指樱花树,象征美丽、浪漫、短暂。");
|
||||
meaningMap.put("柠", "本义指柠檬,象征清新、活力、阳光。");
|
||||
meaningMap.put("橙", "本义指橙子,象征丰收、喜悦、温暖。");
|
||||
meaningMap.put("柚", "本义指柚子,象征团圆、保佑、清新。");
|
||||
return meaningMap.getOrDefault(chinese, "暂无解析");
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取起名意象
|
||||
*/
|
||||
private String getImagery(String chinese) {
|
||||
Map<String, String> imageryMap = new HashMap<>();
|
||||
imageryMap.put("兰", "兰花幽香,寓意品格高洁、气质优雅,适合期望孩子有高尚情操的家庭。");
|
||||
imageryMap.put("泽", "泽被万物,寓意恩泽深厚、仁慈宽厚,适合期望孩子有博爱之心的家庭。");
|
||||
imageryMap.put("墨", "墨香书卷,寓意文采斐然、学识渊博,适合期望孩子有文学才华的家庭。");
|
||||
imageryMap.put("清", "清风明月,寓意心地纯净、志向高远,适合期望孩子有清正品格的家庭。");
|
||||
imageryMap.put("风", "风度翩翩,寓意气宇轩昂、潇洒不凡,适合期望孩子有出众气质的家庭。");
|
||||
imageryMap.put("云", "云游四方,寓意胸怀广阔、志向高远,适合期望孩子有远大理想的家庭。");
|
||||
imageryMap.put("山", "高山仰止,寓意稳重可靠、坚毅不拔,适合期望孩子有坚强品格的家庭。");
|
||||
imageryMap.put("水", "上善若水,寓意智慧灵动、柔韧包容,适合期望孩子有聪慧性情的家庭。");
|
||||
imageryMap.put("雅", "温文尔雅,寓意举止得体、品味高雅,如芝兰玉树,生于庭阶。");
|
||||
imageryMap.put("若", "虚怀若谷,寓意谦逊包容、从容自在,如兰若生春阳。");
|
||||
imageryMap.put("溪", "溪水潺潺,寓意灵动清澈、生生不息,如溪水绕石,柔中带刚。");
|
||||
imageryMap.put("月", "明月清风,寓意光明磊落、清雅高洁,如月出东山,照彻千里。");
|
||||
imageryMap.put("明", "明德惟馨,寓意聪慧明理、通达事理,如日月之光,普照万物。");
|
||||
imageryMap.put("思", "深思熟虑,寓意智慧深邃、情感细腻,如思接千载,视通万里。");
|
||||
imageryMap.put("梦", "梦寐以求,寓意理想远大、憧憬美好,如梦笔生花,才华横溢。");
|
||||
imageryMap.put("雪", "雪中送炭,寓意纯洁无瑕、高洁清雅,如冰雪聪明,晶莹剔透。");
|
||||
imageryMap.put("诗", "诗情画意,寓意文采斐然、才情横溢,如诗中有画,画中有诗。");
|
||||
imageryMap.put("书", "书香门第,寓意学识渊博、修养深厚,如博览群书,学富五车。");
|
||||
imageryMap.put("画", "画龙点睛,寓意艺术天赋、审美高雅,如画中有诗,意境深远。");
|
||||
imageryMap.put("琴", "琴瑟和鸣,寓意高雅艺术、知音难觅,如琴心剑胆,刚柔并济。");
|
||||
imageryMap.put("茶", "茶余饭后,寓意清雅淡泊、禅意悠然,如品茶悟道,静心养性。");
|
||||
imageryMap.put("竹", "竹报平安,寓意气节高尚、虚心向上,如竹影清风,高洁自持。");
|
||||
imageryMap.put("松", "松柏之志,寓意坚贞不屈、长寿安康,如松鹤延年,傲雪凌霜。");
|
||||
imageryMap.put("梅", "梅香自苦寒,寓意傲骨铮铮、坚韧不拔,如梅花三弄,清雅高洁。");
|
||||
imageryMap.put("菊", "菊残犹有傲霜枝,寓意隐逸高洁、淡泊明志,如采菊东篱,悠然自得。");
|
||||
imageryMap.put("荷", "出淤泥而不染,寓意纯洁高雅、不染尘俗,如荷风送香,竹露滴清。");
|
||||
imageryMap.put("莲", "莲开并蒂,寓意纯洁美好、百年好合,如步步生莲,清雅脱俗。");
|
||||
imageryMap.put("桃", "桃李满天下,寓意美好爱情、长寿安康,如人面桃花,相映成趣。");
|
||||
imageryMap.put("李", "李下不正冠,寓意果实累累、成就斐然,如投桃报李,知恩图报。");
|
||||
imageryMap.put("杏", "杏林春暖,寓意春天美好、医术高明,如红杏出墙,生机勃勃。");
|
||||
imageryMap.put("梨", "梨花带雨,寓意纯洁清雅、楚楚动人,如梨园弟子,才艺双全。");
|
||||
imageryMap.put("棠", "棠棣之花,寓意兄弟和睦、家庭美满,如海棠春睡,娇艳欲滴。");
|
||||
imageryMap.put("梧", "梧桐更兼细雨,寓意高洁忠贞、吉祥美好,如凤栖梧桐,人才荟萃。");
|
||||
imageryMap.put("桐", "桐花万里丹山路,寓意高洁坚贞、前程似锦,如桐叶封弟,仁义道德。");
|
||||
imageryMap.put("枫", "枫叶荻花秋瑟瑟,寓意热情浪漫、成熟稳重,如丹枫迎秋,层林尽染。");
|
||||
imageryMap.put("柳", "柳暗花明又一村,寓意柔美婉约、生机勃勃,如柳岸花明,春意盎然。");
|
||||
imageryMap.put("杨", "杨花落尽子规啼,寓意挺拔向上、正直不阿,如百步穿杨,技艺精湛。");
|
||||
imageryMap.put("柏", "柏舟之誓,寓意坚贞不渝、长寿安康,如松柏后凋,岁寒知友。");
|
||||
imageryMap.put("楠", "楠木参天,寓意珍贵高贵、坚韧不拔,如楠梓之材,栋梁之器。");
|
||||
imageryMap.put("梓", "梓里桑梓,寓意故乡情深、生机勃勃,如敬恭桑梓,不忘根本。");
|
||||
imageryMap.put("榆", "榆柳荫后檐,寓意坚韧朴实、春天希望,如榆火相传,文明延续。");
|
||||
imageryMap.put("桦", "白桦亭亭,寓意纯洁高洁、挺拔向上,如桦皮做纸,文化传承。");
|
||||
imageryMap.put("樱", "樱花烂漫,寓意美丽浪漫、珍惜当下,如樱唇一点,娇艳动人。");
|
||||
imageryMap.put("柠", "柠檬清香,寓意清新活力、阳光开朗,如柠月清风,沁人心脾。");
|
||||
imageryMap.put("橙", "橙黄橘绿,寓意丰收喜悦、温暖阳光,如橙香四溢,甜蜜幸福。");
|
||||
imageryMap.put("柚", "柚香满园,寓意团圆美满、保佑平安,如柚叶青青,清新怡人。");
|
||||
return imageryMap.getOrDefault(chinese, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取诗词典故
|
||||
*/
|
||||
private String getPoetry(String chinese) {
|
||||
Map<String, String> poetryMap = new HashMap<>();
|
||||
poetryMap.put("兰", "「兰之猗猗,扬扬其香。」——《诗经》");
|
||||
poetryMap.put("泽", "「芳与泽其杂糅兮,唯昭质其犹未亏。」——《离骚》");
|
||||
poetryMap.put("墨", "「墨池飞出北溟鱼,笔锋杀尽中山兔。」——李白");
|
||||
poetryMap.put("清", "「清风徐来,水波不兴。」——苏轼《赤壁赋》");
|
||||
poetryMap.put("风", "「随风潜入夜,润物细无声。」——杜甫");
|
||||
poetryMap.put("云", "「行到水穷处,坐看云起时。」——王维");
|
||||
poetryMap.put("山", "「采菊东篱下,悠然见南山。」——陶渊明");
|
||||
poetryMap.put("水", "「问君能有几多愁,恰似一江春水向东流。」——李煜");
|
||||
poetryMap.put("雅", "「雅步擢纤腰,巧笑发皓齿。」——曹植");
|
||||
poetryMap.put("若", "「若有人兮山之阿,被薜荔兮带女萝。」——屈原");
|
||||
poetryMap.put("溪", "「旧时茅店社林边,路转溪桥忽见。」——辛弃疾");
|
||||
poetryMap.put("月", "「举杯邀明月,对影成三人。」——李白");
|
||||
poetryMap.put("明", "「明月几时有,把酒问青天。」——苏轼");
|
||||
poetryMap.put("思", "「举头望明月,低头思故乡。」——李白");
|
||||
poetryMap.put("梦", "「庄生晓梦迷蝴蝶,望帝春心托杜鹃。」——李商隐");
|
||||
poetryMap.put("雪", "「忽如一夜春风来,千树万树梨花开。」——岑参");
|
||||
poetryMap.put("诗", "「诗中有画,画中有诗。」——苏轼评王维");
|
||||
poetryMap.put("书", "「书中自有黄金屋,书中自有颜如玉。」——赵恒");
|
||||
poetryMap.put("画", "「远看山有色,近听水无声。」——王维");
|
||||
poetryMap.put("琴", "「欲将心事付瑶琴,知音少,弦断有谁听。」——岳飞");
|
||||
poetryMap.put("茶", "「茶烟轻扬落花风,竹影半墙如画。」——苏轼");
|
||||
poetryMap.put("竹", "「宁可食无肉,不可居无竹。」——苏轼");
|
||||
poetryMap.put("松", "「明月松间照,清泉石上流。」——王维");
|
||||
poetryMap.put("梅", "「墙角数枝梅,凌寒独自开。」——王安石");
|
||||
poetryMap.put("菊", "「采菊东篱下,悠然见南山。」——陶渊明");
|
||||
poetryMap.put("荷", "「接天莲叶无穷碧,映日荷花别样红。」——杨万里");
|
||||
poetryMap.put("莲", "「出淤泥而不染,濯清涟而不妖。」——周敦颐");
|
||||
poetryMap.put("桃", "「人面不知何处去,桃花依旧笑春风。」——崔护");
|
||||
poetryMap.put("李", "「桃李不言,下自成蹊。」——《史记》");
|
||||
poetryMap.put("杏", "「春色满园关不住,一枝红杏出墙来。」——叶绍翁");
|
||||
poetryMap.put("梨", "「梨花院落溶溶月,柳絮池塘淡淡风。」——晏殊");
|
||||
poetryMap.put("棠", "「棠棣之华,鄂不韡韡。」——《诗经》");
|
||||
poetryMap.put("梧", "「梧桐更兼细雨,到黄昏、点点滴滴。」——李清照");
|
||||
poetryMap.put("桐", "「桐花万里丹山路,雏凤清于老凤声。」——李商隐");
|
||||
poetryMap.put("枫", "「停车坐爱枫林晚,霜叶红于二月花。」——杜牧");
|
||||
poetryMap.put("柳", "「碧玉妆成一树高,万条垂下绿丝绦。」——贺知章");
|
||||
poetryMap.put("杨", "「杨花落尽子规啼,闻道龙标过五溪。」——李白");
|
||||
poetryMap.put("柏", "「丞相祠堂何处寻,锦官城外柏森森。」——杜甫");
|
||||
poetryMap.put("楠", "「楠树色冥冥,江边一盖青。」——杜甫");
|
||||
poetryMap.put("梓", "「梓泽丘墟,姑苏台榭。」——王勃");
|
||||
poetryMap.put("榆", "「榆柳荫后檐,桃李罗堂前。」——陶渊明");
|
||||
poetryMap.put("桦", "「白桦亭亭立,清风徐自来。」——佚名");
|
||||
poetryMap.put("樱", "「樱花红陌上,柳叶绿池边。」——周恩来");
|
||||
poetryMap.put("柠", "「柠檬香气远,清风送爽来。」——佚名");
|
||||
poetryMap.put("橙", "「橙黄橘绿时,正是好风景。」——苏轼");
|
||||
poetryMap.put("柚", "「柚叶青青,秋实离离。」——佚名");
|
||||
return poetryMap.getOrDefault(chinese, "");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package com.jiansu.naming.controller;
|
||||
|
||||
import com.jiansu.naming.entity.UserCredits;
|
||||
import com.jiansu.naming.service.CreditsService;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/credits")
|
||||
@CrossOrigin(origins = "*")
|
||||
@Slf4j
|
||||
public class CreditsController {
|
||||
|
||||
@Autowired
|
||||
private CreditsService creditsService;
|
||||
|
||||
/**
|
||||
* 获取用户积分信息
|
||||
*/
|
||||
@GetMapping("/info")
|
||||
public ResponseEntity<?> getCreditsInfo(@RequestParam String openid) {
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
|
||||
if (openid == null || openid.isEmpty()) {
|
||||
response.put("success", false);
|
||||
response.put("message", "openid 不能为空");
|
||||
return ResponseEntity.badRequest().body(response);
|
||||
}
|
||||
|
||||
try {
|
||||
UserCredits credits = creditsService.getUserCredits(openid);
|
||||
|
||||
Map<String, Object> data = new HashMap<>();
|
||||
data.put("dailyCredits", credits.getDailyCredits());
|
||||
data.put("totalCredits", credits.getTotalCredits());
|
||||
data.put("watchedAdCount", credits.getWatchedAdCount());
|
||||
data.put("maxAdPerDay", 5);
|
||||
data.put("adReward", 3);
|
||||
|
||||
response.put("success", true);
|
||||
response.put("data", data);
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("获取积分信息失败", e);
|
||||
response.put("success", false);
|
||||
response.put("message", "获取失败: " + e.getMessage());
|
||||
}
|
||||
|
||||
return ResponseEntity.ok(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* 观看广告奖励
|
||||
*/
|
||||
@PostMapping("/reward")
|
||||
public ResponseEntity<?> rewardForAd(@RequestParam String openid) {
|
||||
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
|
||||
if (openid == null || openid.isEmpty()) {
|
||||
response.put("success", false);
|
||||
response.put("message", "openid 不能为空");
|
||||
return ResponseEntity.badRequest().body(response);
|
||||
}
|
||||
|
||||
try {
|
||||
boolean success = creditsService.rewardForWatchingAd(openid);
|
||||
|
||||
if (success) {
|
||||
UserCredits credits = creditsService.getUserCredits(openid);
|
||||
|
||||
response.put("success", true);
|
||||
response.put("message", "观看成功,获得 3 次生成机会");
|
||||
response.put("dailyCredits", credits.getDailyCredits());
|
||||
response.put("watchedAdCount", credits.getWatchedAdCount());
|
||||
} else {
|
||||
response.put("success", false);
|
||||
response.put("message", "今日观看次数已达上限");
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("奖励失败", e);
|
||||
response.put("success", false);
|
||||
response.put("message", "奖励失败: " + e.getMessage());
|
||||
}
|
||||
|
||||
return ResponseEntity.ok(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否可生成
|
||||
*/
|
||||
@GetMapping("/check")
|
||||
public ResponseEntity<?> checkCanGenerate(@RequestParam String openid) {
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
|
||||
if (openid == null || openid.isEmpty()) {
|
||||
response.put("success", false);
|
||||
response.put("message", "openid 不能为空");
|
||||
return ResponseEntity.badRequest().body(response);
|
||||
}
|
||||
|
||||
try {
|
||||
boolean canGenerate = creditsService.hasEnoughCredits(openid);
|
||||
int remaining = creditsService.getRemainingCredits(openid);
|
||||
|
||||
response.put("success", true);
|
||||
response.put("canGenerate", canGenerate);
|
||||
response.put("remainingCredits", remaining);
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("检查失败", e);
|
||||
response.put("success", false);
|
||||
response.put("message", "检查失败: " + e.getMessage());
|
||||
}
|
||||
|
||||
return ResponseEntity.ok(response);
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,18 @@
|
||||
package com.jiansu.naming.controller;
|
||||
|
||||
import com.jiansu.naming.model.NameCard;
|
||||
import com.jiansu.naming.service.CreditsService;
|
||||
import com.jiansu.naming.service.KimiService;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
|
||||
@RestController
|
||||
@@ -19,18 +24,71 @@ public class NamingController {
|
||||
@Autowired
|
||||
private KimiService kimiService;
|
||||
|
||||
@Autowired
|
||||
private CreditsService creditsService;
|
||||
|
||||
@GetMapping("/generate")
|
||||
public List<NameCard> generate(
|
||||
public ResponseEntity<?> generate(
|
||||
@RequestParam(defaultValue = "清冷") String keyword,
|
||||
@RequestParam(required = false) String mode,
|
||||
@RequestParam(required = false) String surname,
|
||||
@RequestParam(required = false, defaultValue = "3") Integer count,
|
||||
@RequestParam(required = false) String batch) {
|
||||
@RequestParam(required = false, defaultValue = "5") Integer count,
|
||||
@RequestParam(required = false) String batch,
|
||||
@RequestParam(required = false) String openid,
|
||||
@RequestParam(required = false) String excludeNames) {
|
||||
|
||||
log.info("生成名字请求 - 关键词: {}, 模式: {}, 批次: {}, 数量: {}, openid: {}", keyword, mode, batch, count, openid);
|
||||
|
||||
// 检查并消耗积分(原子操作)
|
||||
if (openid != null && !openid.isEmpty()) {
|
||||
boolean consumed = creditsService.consumeCredit(openid);
|
||||
if (!consumed) {
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
response.put("success", false);
|
||||
response.put("message", "今日灵感次数已用完,请前往个人中心静心阅读获取更多次数");
|
||||
return ResponseEntity.ok(response);
|
||||
}
|
||||
}
|
||||
|
||||
// 解析需要排除的名字列表
|
||||
List<String> excludeList = new ArrayList<String>();
|
||||
if (excludeNames != null && !excludeNames.isEmpty()) {
|
||||
excludeList = java.util.Arrays.asList(excludeNames.split(","));
|
||||
}
|
||||
|
||||
// 使用异步方法获取结果,确保5个不重复的名字
|
||||
CompletableFuture<List<NameCard>> future = kimiService.generateUniqueNamesAsync(keyword, mode, surname, 5, excludeList);
|
||||
List<NameCard> result = future.join();
|
||||
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
response.put("success", true);
|
||||
response.put("data", result);
|
||||
return ResponseEntity.ok(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* AI 深度解析名字
|
||||
*/
|
||||
@PostMapping("/explain")
|
||||
public ResponseEntity<?> explainName(@RequestBody Map<String, String> request) {
|
||||
String name = request.get("name");
|
||||
String context = request.get("context"); // 如:从事艺术行业
|
||||
|
||||
log.info("生成名字请求 - 关键词: {}, 模式: {}, 批次: {}, 数量: {}", keyword, mode, batch, count);
|
||||
if (name == null || name.isEmpty()) {
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
response.put("success", false);
|
||||
response.put("message", "名字不能为空");
|
||||
return ResponseEntity.badRequest().body(response);
|
||||
}
|
||||
|
||||
// 使用异步方法获取结果
|
||||
CompletableFuture<List<NameCard>> future = kimiService.generateNamesAsync(keyword, mode, surname, count);
|
||||
return future.join();
|
||||
log.info("AI 解析名字: {}, 语境: {}", name, context);
|
||||
|
||||
String explanation = kimiService.explainName(name, context);
|
||||
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
response.put("success", true);
|
||||
response.put("data", explanation);
|
||||
|
||||
return ResponseEntity.ok(response);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
package com.jiansu.naming.controller;
|
||||
|
||||
import com.jiansu.naming.entity.SquarePost;
|
||||
import com.jiansu.naming.service.SquareService;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/square")
|
||||
@CrossOrigin(origins = "*")
|
||||
@Slf4j
|
||||
public class SquareController {
|
||||
|
||||
@Autowired
|
||||
private SquareService squareService;
|
||||
|
||||
/**
|
||||
* 获取广场帖子列表
|
||||
*/
|
||||
@GetMapping("/posts")
|
||||
public ResponseEntity<?> getPosts(
|
||||
@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "20") int size,
|
||||
@RequestParam(required = false) String tag,
|
||||
@RequestParam(required = false) String keyword,
|
||||
@RequestParam(defaultValue = "latest") String sort) {
|
||||
|
||||
Page<SquarePost> posts;
|
||||
|
||||
if (tag != null && !tag.isEmpty()) {
|
||||
posts = squareService.getPostsByTag(tag, page, size);
|
||||
} else if (keyword != null && !keyword.isEmpty()) {
|
||||
posts = squareService.getPostsByKeyword(keyword, page, size);
|
||||
} else if ("hot".equals(sort)) {
|
||||
posts = squareService.getHotPosts(page, size);
|
||||
} else {
|
||||
posts = squareService.getPosts(page, size);
|
||||
}
|
||||
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
response.put("success", true);
|
||||
response.put("data", posts.getContent());
|
||||
response.put("total", posts.getTotalElements());
|
||||
response.put("hasMore", posts.hasNext());
|
||||
|
||||
return ResponseEntity.ok(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* 发布帖子
|
||||
*/
|
||||
@PostMapping("/posts")
|
||||
public ResponseEntity<?> createPost(
|
||||
@RequestParam String name,
|
||||
@RequestParam String origin,
|
||||
@RequestParam String description,
|
||||
@RequestParam String keyword,
|
||||
@RequestParam String mode,
|
||||
@RequestParam String openid,
|
||||
@RequestParam(required = false) String imageUrl) {
|
||||
|
||||
log.info("发布帖子: {}", name);
|
||||
|
||||
SquarePost post = new SquarePost();
|
||||
post.setName(name);
|
||||
post.setOrigin(origin);
|
||||
post.setDescription(description);
|
||||
post.setKeyword(keyword);
|
||||
post.setMode(mode);
|
||||
post.setOpenid(openid);
|
||||
post.setImageUrl(imageUrl);
|
||||
post.setIsPublic(true);
|
||||
post.setLikeCount(0);
|
||||
|
||||
SquarePost saved = squareService.createPost(post);
|
||||
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
response.put("success", true);
|
||||
response.put("data", saved);
|
||||
response.put("message", "发布成功");
|
||||
|
||||
return ResponseEntity.ok(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* 点赞
|
||||
*/
|
||||
@PostMapping("/posts/{id}/like")
|
||||
public ResponseEntity<?> likePost(@PathVariable Long id) {
|
||||
squareService.likePost(id);
|
||||
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
response.put("success", true);
|
||||
response.put("message", "点赞成功");
|
||||
|
||||
return ResponseEntity.ok(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取帖子详情
|
||||
*/
|
||||
@GetMapping("/posts/{id}")
|
||||
public ResponseEntity<?> getPostDetail(@PathVariable Long id) {
|
||||
SquarePost post = squareService.getPostById(id).orElse(null);
|
||||
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
if (post != null) {
|
||||
response.put("success", true);
|
||||
response.put("data", post);
|
||||
} else {
|
||||
response.put("success", false);
|
||||
response.put("message", "帖子不存在");
|
||||
}
|
||||
|
||||
return ResponseEntity.ok(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除帖子
|
||||
*/
|
||||
@DeleteMapping("/posts/{id}")
|
||||
public ResponseEntity<?> deletePost(@PathVariable Long id, @RequestParam String openid) {
|
||||
squareService.deletePost(id, openid);
|
||||
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
response.put("success", true);
|
||||
response.put("message", "删除成功");
|
||||
|
||||
return ResponseEntity.ok(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户的帖子
|
||||
*/
|
||||
@GetMapping("/user/posts")
|
||||
public ResponseEntity<?> getUserPosts(@RequestParam String openid) {
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
response.put("success", true);
|
||||
response.put("data", squareService.getUserPosts(openid));
|
||||
|
||||
return ResponseEntity.ok(response);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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(如果已分享)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package com.jiansu.naming.repository;
|
||||
|
||||
import com.jiansu.naming.entity.SquarePost;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Modifying;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Repository
|
||||
public interface SquarePostRepository extends JpaRepository<SquarePost, Long> {
|
||||
|
||||
/**
|
||||
* 查询公开的帖子
|
||||
*/
|
||||
Page<SquarePost> findByIsPublicTrueOrderByCreatedAtDesc(Pageable pageable);
|
||||
|
||||
/**
|
||||
* 根据标签查询
|
||||
*/
|
||||
@Query("SELECT s FROM SquarePost s WHERE s.isPublic = true AND s.tags LIKE %:tag% ORDER BY s.createdAt DESC")
|
||||
Page<SquarePost> findByTag(@Param("tag") String tag, Pageable pageable);
|
||||
|
||||
/**
|
||||
* 根据关键词查询
|
||||
*/
|
||||
@Query("SELECT s FROM SquarePost s WHERE s.isPublic = true AND s.keyword = :keyword ORDER BY s.createdAt DESC")
|
||||
Page<SquarePost> findByKeyword(@Param("keyword") String keyword, Pageable pageable);
|
||||
|
||||
/**
|
||||
* 增加点赞数
|
||||
*/
|
||||
@Modifying
|
||||
@Query("UPDATE SquarePost s SET s.likeCount = s.likeCount + 1 WHERE s.id = :id")
|
||||
void incrementLikeCount(@Param("id") Long id);
|
||||
|
||||
/**
|
||||
* 查询用户的帖子
|
||||
*/
|
||||
List<SquarePost> findByOpenidOrderByCreatedAtDesc(String openid);
|
||||
|
||||
/**
|
||||
* 查询热门帖子
|
||||
*/
|
||||
@Query("SELECT s FROM SquarePost s WHERE s.isPublic = true ORDER BY s.likeCount DESC, s.createdAt DESC")
|
||||
Page<SquarePost> findHotPosts(Pageable pageable);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.jiansu.naming.repository;
|
||||
|
||||
import com.jiansu.naming.entity.UserCredits;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
@Repository
|
||||
public interface UserCreditsRepository extends JpaRepository<UserCredits, Long> {
|
||||
|
||||
Optional<UserCredits> findByOpenid(String openid);
|
||||
|
||||
boolean existsByOpenid(String openid);
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package com.jiansu.naming.service;
|
||||
|
||||
import com.jiansu.naming.entity.UserCredits;
|
||||
import com.jiansu.naming.repository.UserCreditsRepository;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.Optional;
|
||||
|
||||
@Service
|
||||
@Slf4j
|
||||
public class CreditsService {
|
||||
|
||||
private static final int DAILY_FREE_CREDITS = 5; // 每日免费次数
|
||||
private static final int AD_REWARD_CREDITS = 3; // 观看广告奖励次数
|
||||
private static final int MAX_AD_PER_DAY = 5; // 每日最多观看广告次数
|
||||
|
||||
@Autowired
|
||||
private UserCreditsRepository userCreditsRepository;
|
||||
|
||||
/**
|
||||
* 获取或创建用户积分记录
|
||||
*/
|
||||
@Transactional
|
||||
public UserCredits getOrCreateUserCredits(String openid) {
|
||||
Optional<UserCredits> optional = userCreditsRepository.findByOpenid(openid);
|
||||
|
||||
if (optional.isPresent()) {
|
||||
UserCredits credits = optional.get();
|
||||
// 检查是否需要刷新每日次数
|
||||
if (credits.getLastRefreshDate() == null ||
|
||||
!credits.getLastRefreshDate().equals(LocalDate.now())) {
|
||||
credits.setDailyCredits(DAILY_FREE_CREDITS);
|
||||
credits.setWatchedAdCount(0);
|
||||
credits.setLastRefreshDate(LocalDate.now());
|
||||
userCreditsRepository.save(credits);
|
||||
log.info("刷新用户每日次数: {}", openid);
|
||||
}
|
||||
return credits;
|
||||
} else {
|
||||
// 创建新记录
|
||||
UserCredits credits = new UserCredits();
|
||||
credits.setOpenid(openid);
|
||||
credits.setTotalCredits(0);
|
||||
credits.setDailyCredits(DAILY_FREE_CREDITS);
|
||||
credits.setLastRefreshDate(LocalDate.now());
|
||||
credits.setWatchedAdCount(0);
|
||||
userCreditsRepository.save(credits);
|
||||
log.info("创建用户积分记录: {}", openid);
|
||||
return credits;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否有足够的次数
|
||||
*/
|
||||
public boolean hasEnoughCredits(String openid) {
|
||||
UserCredits credits = getOrCreateUserCredits(openid);
|
||||
int remaining = credits.getDailyCredits();
|
||||
log.info("检查用户 {} 的剩余次数: {}", openid, remaining);
|
||||
return remaining > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 消耗一次生成次数(包含检查和消耗,原子操作)
|
||||
*/
|
||||
@Transactional
|
||||
public boolean consumeCredit(String openid) {
|
||||
UserCredits credits = getOrCreateUserCredits(openid);
|
||||
int remaining = credits.getDailyCredits();
|
||||
|
||||
log.info("用户 {} 尝试消耗次数,当前剩余: {}", openid, remaining);
|
||||
|
||||
if (remaining > 0) {
|
||||
credits.setDailyCredits(remaining - 1);
|
||||
credits.setTotalCredits(credits.getTotalCredits() + 1);
|
||||
userCreditsRepository.save(credits);
|
||||
log.info("用户 {} 成功消耗一次生成次数,剩余: {}", openid, credits.getDailyCredits());
|
||||
return true;
|
||||
}
|
||||
|
||||
log.warn("用户 {} 次数不足,无法消耗", openid);
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 观看广告奖励次数
|
||||
*/
|
||||
@Transactional
|
||||
public boolean rewardForWatchingAd(String openid) {
|
||||
UserCredits credits = getOrCreateUserCredits(openid);
|
||||
|
||||
if (credits.getWatchedAdCount() >= MAX_AD_PER_DAY) {
|
||||
log.info("用户 {} 今日广告观看次数已达上限", openid);
|
||||
return false;
|
||||
}
|
||||
|
||||
credits.setDailyCredits(credits.getDailyCredits() + AD_REWARD_CREDITS);
|
||||
credits.setWatchedAdCount(credits.getWatchedAdCount() + 1);
|
||||
userCreditsRepository.save(credits);
|
||||
|
||||
log.info("用户 {} 观看广告获得 {} 次机会,今日已观看: {}",
|
||||
openid, AD_REWARD_CREDITS, credits.getWatchedAdCount());
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户积分信息
|
||||
*/
|
||||
public UserCredits getUserCredits(String openid) {
|
||||
return getOrCreateUserCredits(openid);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取剩余次数
|
||||
*/
|
||||
public int getRemainingCredits(String openid) {
|
||||
UserCredits credits = getOrCreateUserCredits(openid);
|
||||
return credits.getDailyCredits();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -34,6 +34,11 @@ public class KimiService {
|
||||
|
||||
@Value("${kimi.api-url}")
|
||||
private String apiUrl;
|
||||
|
||||
// 模型策略配置
|
||||
private static final String MODEL_STANDARD = "moonshot-v1-8k";
|
||||
private static final String MODEL_PREMIUM = "moonshot-v1-32k";
|
||||
private static final double PREMIUM_THRESHOLD = 9.5; // 高分名字使用高级模型
|
||||
|
||||
private final ToneAnalysisService toneAnalysisService;
|
||||
|
||||
@@ -124,49 +129,91 @@ public class KimiService {
|
||||
* @return CompletableFuture<List<NameCard>>
|
||||
*/
|
||||
public CompletableFuture<List<NameCard>> generateNamesAsync(String keyword, String mode, String surname, int count) {
|
||||
return generateUniqueNamesAsync(keyword, mode, surname, count, new ArrayList<>());
|
||||
}
|
||||
|
||||
/**
|
||||
* 异步生成指定数量的不重复名字
|
||||
* @param keyword 关键词
|
||||
* @param mode 模式
|
||||
* @param surname 姓氏
|
||||
* @param targetCount 目标数量(默认5个)
|
||||
* @param excludeNames 需要排除的名字列表
|
||||
* @return CompletableFuture<List<NameCard>>
|
||||
*/
|
||||
public CompletableFuture<List<NameCard>> generateUniqueNamesAsync(String keyword, String mode, String surname, int targetCount, List<String> excludeNames) {
|
||||
long startTime = System.currentTimeMillis();
|
||||
log.info("开始异步生成 {} 个名字,模式: {}", count, mode);
|
||||
|
||||
log.info("开始异步生成 {} 个不重复名字,模式: {},排除: {}", targetCount, mode, excludeNames);
|
||||
|
||||
// 后门:当关键词为 "test" 时,返回固定的测试数据
|
||||
if ("test".equalsIgnoreCase(keyword)) {
|
||||
log.info("Keyword is 'test', returning hardcoded test data.");
|
||||
return CompletableFuture.completedFuture(createTestData());
|
||||
}
|
||||
|
||||
|
||||
// 默认使用 classic 模式
|
||||
final String finalMode = (mode == null || mode.isEmpty()) ? "classic" : mode;
|
||||
|
||||
// 创建 count 个并行任务
|
||||
List<CompletableFuture<NameCard>> futures = new ArrayList<>();
|
||||
for (int i = 0; i < count; i++) {
|
||||
final int index = i;
|
||||
CompletableFuture<NameCard> future = CompletableFuture.supplyAsync(() -> {
|
||||
try {
|
||||
return callAiToGenerateSingleName(keyword, finalMode, surname, index);
|
||||
} catch (Exception e) {
|
||||
log.error("第 {} 个名字生成失败: {}", index, e.getMessage());
|
||||
|
||||
// 使用 Set 存储已生成的名字,确保唯一性
|
||||
java.util.Set<String> generatedNames = new java.util.HashSet<>(excludeNames);
|
||||
java.util.List<NameCard> results = new ArrayList<>();
|
||||
|
||||
// 最多尝试 3 轮,每轮生成 targetCount 个
|
||||
int maxRounds = 3;
|
||||
int round = 0;
|
||||
|
||||
while (results.size() < targetCount && round < maxRounds) {
|
||||
round++;
|
||||
int needCount = targetCount - results.size();
|
||||
log.info("第 {} 轮生成,需要 {} 个名字", round, needCount);
|
||||
|
||||
// 创建本轮并行任务
|
||||
List<CompletableFuture<NameCard>> futures = new ArrayList<>();
|
||||
for (int i = 0; i < needCount; i++) {
|
||||
final int index = i + (round - 1) * targetCount;
|
||||
CompletableFuture<NameCard> future = CompletableFuture.supplyAsync(() -> {
|
||||
try {
|
||||
return callAiToGenerateSingleName(keyword, finalMode, surname, index);
|
||||
} catch (Exception e) {
|
||||
log.error("第 {} 个名字生成失败: {}", index, e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}, jianSuAiExecutor).exceptionally(ex -> {
|
||||
log.error("第 {} 个任务异常: {}", index, ex.getMessage());
|
||||
return null;
|
||||
});
|
||||
futures.add(future);
|
||||
}
|
||||
|
||||
// 等待本轮任务完成
|
||||
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
|
||||
|
||||
// 收集本轮结果,去重
|
||||
for (CompletableFuture<NameCard> future : futures) {
|
||||
NameCard card = future.join();
|
||||
if (card != null && card.getName() != null) {
|
||||
String nameKey = card.getName().trim();
|
||||
// 检查是否与已生成的重复
|
||||
if (!generatedNames.contains(nameKey)) {
|
||||
generatedNames.add(nameKey);
|
||||
results.add(card);
|
||||
log.debug("添加名字: {}", nameKey);
|
||||
|
||||
// 如果已经收集够,提前退出
|
||||
if (results.size() >= targetCount) {
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
log.debug("跳过重复名字: {}", nameKey);
|
||||
}
|
||||
}
|
||||
}, jianSuAiExecutor).exceptionally(ex -> {
|
||||
log.error("第 {} 个任务异常: {}", index, ex.getMessage());
|
||||
return null;
|
||||
});
|
||||
futures.add(future);
|
||||
}
|
||||
}
|
||||
|
||||
// 等待所有任务完成并合并结果
|
||||
return CompletableFuture.allOf(futures.toArray(new CompletableFuture[0]))
|
||||
.thenApply(v -> {
|
||||
List<NameCard> results = futures.stream()
|
||||
.map(CompletableFuture::join)
|
||||
.filter(card -> card != null)
|
||||
.collect(Collectors.toList());
|
||||
|
||||
long duration = System.currentTimeMillis() - startTime;
|
||||
log.info("异步生成完成,成功 {} 个,耗时 {}ms", results.size(), duration);
|
||||
|
||||
return results;
|
||||
});
|
||||
|
||||
long duration = System.currentTimeMillis() - startTime;
|
||||
log.info("异步生成完成,成功 {} 个不重复名字,耗时 {}ms,共进行 {} 轮", results.size(), duration, round);
|
||||
|
||||
return CompletableFuture.completedFuture(results);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -174,16 +221,20 @@ public class KimiService {
|
||||
*/
|
||||
private NameCard callAiToGenerateSingleName(String keyword, String mode, String surname, int seed) {
|
||||
String promptTemplate = PROMPT_TEMPLATES.getOrDefault(mode, PROMPT_TEMPLATES.get("classic"));
|
||||
|
||||
|
||||
// 动态构建 Prompt,加入 seed 使每次请求略有不同
|
||||
String systemPrompt = promptTemplate
|
||||
.replace("{keyword}", keyword != null ? keyword : "")
|
||||
.replace("{surname}", surname != null ? surname : "")
|
||||
+ " 请确保创意独特,避免与之前生成的名字重复。";
|
||||
|
||||
// 模型选择策略:前2个使用标准模型,第3个及以后使用高级模型
|
||||
String model = seed < 2 ? MODEL_STANDARD : MODEL_PREMIUM;
|
||||
log.info("使用模型 {} 生成第 {} 个名字", model, seed + 1);
|
||||
|
||||
// 构建 Kimi API 请求体 (OpenAI 兼容格式)
|
||||
JSONObject jsonBody = new JSONObject();
|
||||
jsonBody.put("model", "moonshot-v1-8k");
|
||||
jsonBody.put("model", model);
|
||||
jsonBody.put("temperature", 0.7);
|
||||
// 使用随机种子确保每次生成结果不同
|
||||
jsonBody.put("seed", (int) (Math.random() * 1000000) + seed * 1000);
|
||||
@@ -311,4 +362,189 @@ public class KimiService {
|
||||
|
||||
return testData;
|
||||
}
|
||||
|
||||
/**
|
||||
* AI 深度解析名字
|
||||
*/
|
||||
public String explainName(String name, String context) {
|
||||
String prompt = buildExplainPrompt(name, context);
|
||||
|
||||
Map<String, Object> jsonBody = new HashMap<>();
|
||||
jsonBody.put("model", "moonshot-v1-8k");
|
||||
jsonBody.put("temperature", 0.7);
|
||||
|
||||
List<Map<String, String>> messages = new ArrayList<>();
|
||||
|
||||
Map<String, String> systemMsg = new HashMap<>();
|
||||
systemMsg.put("role", "system");
|
||||
systemMsg.put("content", "你是一位精通中国传统文化、姓名学、诗词典故的起名专家。请用优美、富有诗意的语言解析名字。");
|
||||
messages.add(systemMsg);
|
||||
|
||||
Map<String, String> userMsg = new HashMap<>();
|
||||
userMsg.put("role", "user");
|
||||
userMsg.put("content", prompt);
|
||||
messages.add(userMsg);
|
||||
|
||||
jsonBody.put("messages", messages);
|
||||
|
||||
RequestBody body = RequestBody.create(
|
||||
JSON.toJSONString(jsonBody),
|
||||
MediaType.parse("application/json; charset=utf-8")
|
||||
);
|
||||
|
||||
Request request = new Request.Builder()
|
||||
.url(apiUrl)
|
||||
.post(body)
|
||||
.addHeader("Authorization", "Bearer " + apiKey)
|
||||
.addHeader("Content-Type", "application/json")
|
||||
.build();
|
||||
|
||||
try (Response response = client.newCall(request).execute()) {
|
||||
if (response.isSuccessful() && response.body() != null) {
|
||||
String responseBody = response.body().string();
|
||||
return parseExplanation(responseBody);
|
||||
} else {
|
||||
String errorMsg = response.body() != null ? response.body().string() : response.message();
|
||||
log.error("Kimi API Error: {} - {}", response.code(), errorMsg);
|
||||
return "抱歉,AI 解析暂时不可用,请稍后重试。";
|
||||
}
|
||||
} catch (IOException e) {
|
||||
log.error("API Error: ", e);
|
||||
return "网络请求失败,请检查网络连接。";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建解析 Prompt
|
||||
*/
|
||||
private String buildExplainPrompt(String name, String context) {
|
||||
StringBuilder prompt = new StringBuilder();
|
||||
prompt.append("请深度解析名字\"").append(name).append("\"");
|
||||
|
||||
if (context != null && !context.isEmpty()) {
|
||||
prompt.append(",针对\"").append(context).append("\"这个背景");
|
||||
}
|
||||
|
||||
prompt.append("。请从以下几个方面分析:\n\n" +
|
||||
"1. 字面意境:每个字的含义和组合后的整体意象\n" +
|
||||
"2. 诗词出处:是否有相关的诗词典故\n" +
|
||||
"3. 性格暗示:这个名字给人的感觉和性格特质\n" +
|
||||
"4. 适用场景:适合什么样的人或场合\n\n" +
|
||||
"请用优美、富有诗意的语言回答,控制在300字以内。"
|
||||
);
|
||||
|
||||
return prompt.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 AI 回复
|
||||
*/
|
||||
private String parseExplanation(String responseBody) {
|
||||
try {
|
||||
JSONObject root = JSON.parseObject(responseBody);
|
||||
|
||||
JSONObject error = root.getJSONObject("error");
|
||||
if (error != null) {
|
||||
String errorMsg = error.getString("message");
|
||||
log.error("Kimi API error: {}", errorMsg);
|
||||
return "解析失败,请稍后重试。";
|
||||
}
|
||||
|
||||
JSONArray choices = root.getJSONArray("choices");
|
||||
if (choices == null || choices.isEmpty()) {
|
||||
return "暂无解析内容。";
|
||||
}
|
||||
|
||||
JSONObject firstChoice = choices.getJSONObject(0);
|
||||
JSONObject message = firstChoice.getJSONObject("message");
|
||||
return message.getString("content");
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("解析 AI 回复失败", e);
|
||||
return "解析失败,请稍后重试。";
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 模型策略方法 ====================
|
||||
|
||||
/**
|
||||
* 根据分数选择模型
|
||||
* 高分名字使用高级模型以获得更好的质量
|
||||
*/
|
||||
private String selectModelByScore(double score) {
|
||||
return score >= PREMIUM_THRESHOLD ? MODEL_PREMIUM : MODEL_STANDARD;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据请求类型选择模型
|
||||
*/
|
||||
private String selectModelByRequestType(String requestType) {
|
||||
switch (requestType) {
|
||||
case "deep_explain": // 深度解析
|
||||
case "premium": // 高级生成
|
||||
return MODEL_PREMIUM;
|
||||
case "standard":
|
||||
default:
|
||||
return MODEL_STANDARD;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用高级模型生成名字(用于高质量需求)
|
||||
*/
|
||||
public NameCard generatePremiumName(String keyword, String mode, String surname) {
|
||||
log.info("使用高级模型生成名字 - 关键词: {}, 模式: {}", keyword, mode);
|
||||
|
||||
String promptTemplate = PROMPT_TEMPLATES.getOrDefault(mode, PROMPT_TEMPLATES.get("classic"));
|
||||
String prompt = promptTemplate
|
||||
.replace("{keyword}", keyword != null ? keyword : "")
|
||||
.replace("{surname}", surname != null ? surname : "");
|
||||
|
||||
Map<String, Object> jsonBody = new HashMap<>();
|
||||
jsonBody.put("model", MODEL_PREMIUM);
|
||||
jsonBody.put("temperature", 0.7);
|
||||
|
||||
List<Map<String, String>> messages = new ArrayList<>();
|
||||
|
||||
Map<String, String> systemMsg = new HashMap<>();
|
||||
systemMsg.put("role", "system");
|
||||
systemMsg.put("content", "你是「见素」的首席起名大师,精通中国传统文化、诗词典故、音韵学。请创作极具意境和美感的名字。");
|
||||
messages.add(systemMsg);
|
||||
|
||||
Map<String, String> userMsg = new HashMap<>();
|
||||
userMsg.put("role", "user");
|
||||
userMsg.put("content", prompt);
|
||||
messages.add(userMsg);
|
||||
|
||||
jsonBody.put("messages", messages);
|
||||
|
||||
RequestBody body = RequestBody.create(
|
||||
JSON.toJSONString(jsonBody),
|
||||
MediaType.parse("application/json; charset=utf-8")
|
||||
);
|
||||
|
||||
Request request = new Request.Builder()
|
||||
.url(apiUrl)
|
||||
.post(body)
|
||||
.addHeader("Authorization", "Bearer " + apiKey)
|
||||
.addHeader("Content-Type", "application/json")
|
||||
.build();
|
||||
|
||||
try (Response response = client.newCall(request).execute()) {
|
||||
if (response.isSuccessful() && response.body() != null) {
|
||||
String responseBody = response.body().string();
|
||||
NameCard card = parseSingleNameCard(responseBody);
|
||||
// 高级模型生成的名字给予更高评分
|
||||
card.setScore(Math.min(10.0, card.getScore() + 0.3));
|
||||
return card;
|
||||
} else {
|
||||
String errorMsg = response.body() != null ? response.body().string() : response.message();
|
||||
log.error("Kimi Premium API Error: {} - {}", response.code(), errorMsg);
|
||||
throw new RuntimeException("高级模型调用失败");
|
||||
}
|
||||
} catch (IOException e) {
|
||||
log.error("Premium API Error: ", e);
|
||||
throw new RuntimeException("网络请求失败");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
package com.jiansu.naming.service;
|
||||
|
||||
import com.jiansu.naming.entity.SquarePost;
|
||||
import com.jiansu.naming.repository.SquarePostRepository;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
@Service
|
||||
@Slf4j
|
||||
public class SquareService {
|
||||
|
||||
@Autowired
|
||||
private SquarePostRepository squarePostRepository;
|
||||
|
||||
/**
|
||||
* 发布帖子
|
||||
*/
|
||||
@Transactional
|
||||
public SquarePost createPost(SquarePost post) {
|
||||
log.info("发布广场帖子: {}", post.getName());
|
||||
return squarePostRepository.save(post);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取帖子列表(分页)
|
||||
*/
|
||||
public Page<SquarePost> getPosts(int page, int size) {
|
||||
Pageable pageable = PageRequest.of(page, size);
|
||||
return squarePostRepository.findByIsPublicTrueOrderByCreatedAtDesc(pageable);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取热门帖子
|
||||
*/
|
||||
public Page<SquarePost> getHotPosts(int page, int size) {
|
||||
Pageable pageable = PageRequest.of(page, size);
|
||||
return squarePostRepository.findHotPosts(pageable);
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据标签查询
|
||||
*/
|
||||
public Page<SquarePost> getPostsByTag(String tag, int page, int size) {
|
||||
Pageable pageable = PageRequest.of(page, size);
|
||||
return squarePostRepository.findByTag(tag, pageable);
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据关键词查询
|
||||
*/
|
||||
public Page<SquarePost> getPostsByKeyword(String keyword, int page, int size) {
|
||||
Pageable pageable = PageRequest.of(page, size);
|
||||
return squarePostRepository.findByKeyword(keyword, pageable);
|
||||
}
|
||||
|
||||
/**
|
||||
* 点赞
|
||||
*/
|
||||
@Transactional
|
||||
public void likePost(Long postId) {
|
||||
log.info("帖子点赞: {}", postId);
|
||||
squarePostRepository.incrementLikeCount(postId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取帖子详情
|
||||
*/
|
||||
public Optional<SquarePost> getPostById(Long id) {
|
||||
return squarePostRepository.findById(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除帖子
|
||||
*/
|
||||
@Transactional
|
||||
public void deletePost(Long id, String openid) {
|
||||
Optional<SquarePost> post = squarePostRepository.findById(id);
|
||||
if (post.isPresent() && post.get().getOpenid().equals(openid)) {
|
||||
squarePostRepository.deleteById(id);
|
||||
log.info("删除帖子: {}", id);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户的帖子
|
||||
*/
|
||||
public List<SquarePost> getUserPosts(String openid) {
|
||||
return squarePostRepository.findByOpenidOrderByCreatedAtDesc(openid);
|
||||
}
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
backend/target/classes/com/jiansu/naming/entity/SquarePost.class
Normal file
BIN
backend/target/classes/com/jiansu/naming/entity/SquarePost.class
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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'
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
108
miniprogram/components/charDetail/charDetail.js
Normal file
108
miniprogram/components/charDetail/charDetail.js
Normal file
@@ -0,0 +1,108 @@
|
||||
// 字源溯源弹窗组件
|
||||
Component({
|
||||
properties: {
|
||||
visible: {
|
||||
type: Boolean,
|
||||
value: false
|
||||
},
|
||||
char: {
|
||||
type: String,
|
||||
value: ''
|
||||
}
|
||||
},
|
||||
|
||||
data: {
|
||||
pinyin: '',
|
||||
radical: '',
|
||||
strokes: '',
|
||||
wuxing: '',
|
||||
meaning: '',
|
||||
imagery: '',
|
||||
poetry: '',
|
||||
loading: false
|
||||
},
|
||||
|
||||
observers: {
|
||||
'visible, char': function(visible, char) {
|
||||
if (visible && char) {
|
||||
this.loadCharDetail(char);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
// 加载汉字详情
|
||||
loadCharDetail(char) {
|
||||
this.setData({ loading: true });
|
||||
|
||||
// 先从本地缓存查找
|
||||
const cache = wx.getStorageSync(`char_${char}`);
|
||||
if (cache) {
|
||||
this.setData({ ...cache, loading: false });
|
||||
return;
|
||||
}
|
||||
|
||||
// 调用后端接口获取
|
||||
const apiBaseUrl = getApp().globalData.apiBaseUrl;
|
||||
wx.request({
|
||||
url: `${apiBaseUrl}/api/char/detail`,
|
||||
data: { char },
|
||||
success: (res) => {
|
||||
if (res.data && res.data.success) {
|
||||
const data = res.data.data;
|
||||
this.setData({ ...data, loading: false });
|
||||
// 缓存结果
|
||||
wx.setStorageSync(`char_${char}`, data);
|
||||
} else {
|
||||
// 使用默认数据
|
||||
this.setDefaultData(char);
|
||||
}
|
||||
},
|
||||
fail: () => {
|
||||
this.setDefaultData(char);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
// 设置默认数据
|
||||
setDefaultData(char) {
|
||||
// 简单的拼音映射(实际应该从后端获取)
|
||||
const pinyinMap = {
|
||||
'兰': 'lán', '泽': 'zé', '芳': 'fāng', '风': 'fēng',
|
||||
'墨': 'mò', '染': 'rǎn', '清': 'qīng', '欢': 'huān',
|
||||
'素': 'sù', '简': 'jiǎn', '雅': 'yǎ', '致': 'zhì'
|
||||
};
|
||||
|
||||
this.setData({
|
||||
pinyin: pinyinMap[char] || '',
|
||||
radical: '',
|
||||
strokes: '',
|
||||
wuxing: '',
|
||||
meaning: '暂无详细解析,点击"问问 AI"获取深度分析',
|
||||
imagery: '',
|
||||
poetry: '',
|
||||
loading: false
|
||||
});
|
||||
},
|
||||
|
||||
// 点击遮罩关闭
|
||||
onMaskTap() {
|
||||
this.triggerEvent('close');
|
||||
},
|
||||
|
||||
// 阻止冒泡
|
||||
onContainerTap() {
|
||||
// 什么都不做,只是阻止冒泡
|
||||
},
|
||||
|
||||
// 点击关闭按钮
|
||||
onClose() {
|
||||
this.triggerEvent('close');
|
||||
},
|
||||
|
||||
// 问问 AI
|
||||
onAskAI() {
|
||||
this.triggerEvent('askAI', { char: this.data.char });
|
||||
}
|
||||
}
|
||||
});
|
||||
4
miniprogram/components/charDetail/charDetail.json
Normal file
4
miniprogram/components/charDetail/charDetail.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"component": true,
|
||||
"usingComponents": {}
|
||||
}
|
||||
59
miniprogram/components/charDetail/charDetail.wxml
Normal file
59
miniprogram/components/charDetail/charDetail.wxml
Normal file
@@ -0,0 +1,59 @@
|
||||
<!-- 字源溯源弹窗组件 -->
|
||||
<view class="char-detail-mask {{visible ? 'show' : ''}}" bindtap="onMaskTap">
|
||||
<view class="char-detail-container {{visible ? 'show' : ''}}" catchtap="onContainerTap">
|
||||
<!-- 磨砂玻璃背景 -->
|
||||
<view class="glass-bg"></view>
|
||||
|
||||
<!-- 关闭按钮 -->
|
||||
<view class="close-btn" bindtap="onClose">×</view>
|
||||
|
||||
<!-- 大字展示 -->
|
||||
<view class="char-display">
|
||||
<text class="char-text">{{char}}</text>
|
||||
<view class="char-pinyin">{{pinyin}}</view>
|
||||
</view>
|
||||
|
||||
<!-- 字源解析 -->
|
||||
<scroll-view class="char-content" scroll-y>
|
||||
<!-- 基础信息 -->
|
||||
<view class="info-section">
|
||||
<view class="info-item">
|
||||
<text class="label">部首</text>
|
||||
<text class="value">{{radical || '未知'}}</text>
|
||||
</view>
|
||||
<view class="info-item">
|
||||
<text class="label">笔画</text>
|
||||
<text class="value">{{strokes || '未知'}}画</text>
|
||||
</view>
|
||||
<view class="info-item">
|
||||
<text class="label">五行</text>
|
||||
<text class="value">{{wuxing || '未知'}}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 字义解析 -->
|
||||
<view class="meaning-section">
|
||||
<view class="section-title">本义</view>
|
||||
<text class="meaning-text">{{meaning || '暂无解析'}}</text>
|
||||
</view>
|
||||
|
||||
<!-- 意象分析 -->
|
||||
<view class="imagery-section">
|
||||
<view class="section-title">起名意象</view>
|
||||
<text class="imagery-text">{{imagery || '暂无解析'}}</text>
|
||||
</view>
|
||||
|
||||
<!-- 诗词典故 -->
|
||||
<view class="poetry-section" wx:if="{{poetry}}">
|
||||
<view class="section-title">诗词典故</view>
|
||||
<text class="poetry-text">{{poetry}}</text>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<!-- AI 解析按钮 -->
|
||||
<view class="ai-btn" bindtap="onAskAI">
|
||||
<text class="ai-icon">✦</text>
|
||||
<text class="ai-text">问问 AI</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
178
miniprogram/components/charDetail/charDetail.wxss
Normal file
178
miniprogram/components/charDetail/charDetail.wxss
Normal file
@@ -0,0 +1,178 @@
|
||||
/* 字源溯源弹窗组件 - 磨砂玻璃风格 */
|
||||
|
||||
.char-detail-mask {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: all 0.3s ease;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.char-detail-mask.show {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.char-detail-container {
|
||||
position: relative;
|
||||
width: 600rpx;
|
||||
max-height: 800rpx;
|
||||
border-radius: 24rpx;
|
||||
overflow: hidden;
|
||||
transform: scale(0.9) translateY(20rpx);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.char-detail-container.show {
|
||||
transform: scale(1) translateY(0);
|
||||
}
|
||||
|
||||
/* 磨砂玻璃背景 */
|
||||
.glass-bg {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(255, 255, 255, 0.85);
|
||||
backdrop-filter: blur(20rpx);
|
||||
-webkit-backdrop-filter: blur(20rpx);
|
||||
}
|
||||
|
||||
/* 关闭按钮 */
|
||||
.close-btn {
|
||||
position: absolute;
|
||||
top: 20rpx;
|
||||
right: 20rpx;
|
||||
width: 48rpx;
|
||||
height: 48rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 36rpx;
|
||||
color: #666;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
/* 大字展示 */
|
||||
.char-display {
|
||||
position: relative;
|
||||
padding: 60rpx 0 40rpx;
|
||||
text-align: center;
|
||||
border-bottom: 1rpx solid rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.char-text {
|
||||
font-size: 120rpx;
|
||||
font-family: 'KaiTi', 'STKaiti', serif;
|
||||
color: #2D2D2D;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.char-pinyin {
|
||||
margin-top: 16rpx;
|
||||
font-size: 28rpx;
|
||||
color: #666;
|
||||
letter-spacing: 4rpx;
|
||||
}
|
||||
|
||||
/* 内容区域 */
|
||||
.char-content {
|
||||
position: relative;
|
||||
max-height: 400rpx;
|
||||
padding: 30rpx 40rpx;
|
||||
}
|
||||
|
||||
/* 基础信息 */
|
||||
.info-section {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
padding: 20rpx 0;
|
||||
margin-bottom: 30rpx;
|
||||
border-bottom: 1rpx solid rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.info-item {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.info-item .label {
|
||||
display: block;
|
||||
font-size: 22rpx;
|
||||
color: #999;
|
||||
margin-bottom: 8rpx;
|
||||
}
|
||||
|
||||
.info-item .value {
|
||||
display: block;
|
||||
font-size: 28rpx;
|
||||
color: #333;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 章节标题 */
|
||||
.section-title {
|
||||
font-size: 26rpx;
|
||||
color: #2D2D2D;
|
||||
font-weight: 600;
|
||||
margin-bottom: 16rpx;
|
||||
padding-left: 16rpx;
|
||||
border-left: 4rpx solid #B22222;
|
||||
}
|
||||
|
||||
/* 内容文本 */
|
||||
.meaning-section,
|
||||
.imagery-section,
|
||||
.poetry-section {
|
||||
margin-bottom: 30rpx;
|
||||
}
|
||||
|
||||
.meaning-text,
|
||||
.imagery-text,
|
||||
.poetry-text {
|
||||
font-size: 28rpx;
|
||||
color: #555;
|
||||
line-height: 1.8;
|
||||
text-align: justify;
|
||||
}
|
||||
|
||||
.poetry-text {
|
||||
font-style: italic;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
/* AI 按钮 */
|
||||
.ai-btn {
|
||||
position: relative;
|
||||
margin: 0 40rpx 40rpx;
|
||||
padding: 24rpx 0;
|
||||
background: linear-gradient(135deg, #2D2D2D 0%, #444 100%);
|
||||
border-radius: 40rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12rpx;
|
||||
}
|
||||
|
||||
.ai-btn:active {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.ai-icon {
|
||||
font-size: 28rpx;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.ai-text {
|
||||
font-size: 28rpx;
|
||||
color: #fff;
|
||||
letter-spacing: 4rpx;
|
||||
}
|
||||
@@ -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
|
||||
});
|
||||
|
||||
@@ -52,6 +52,12 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 灵感广场入口 -->
|
||||
<view class="square-entry {{isGenerating ? 'fade-out' : ''}}" bindtap="goToSquare">
|
||||
<text class="entry-text">入林搜寻灵感</text>
|
||||
<view class="entry-arrow"></view>
|
||||
</view>
|
||||
|
||||
<view class="footer {{isGenerating ? 'fade-out' : ''}}">
|
||||
<text>© 见素 · 审美溢价</text>
|
||||
</view>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
3
miniprogram/pages/index/index.json
Normal file
3
miniprogram/pages/index/index.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"usingComponents": {}
|
||||
}
|
||||
@@ -18,7 +18,7 @@
|
||||
<view class="card-stack" wx:if="{{!isLoading && nameList.length > 0}}">
|
||||
<view
|
||||
class="card-container"
|
||||
style="transform: translateX({{translateX}}px) rotate({{rotate}}deg); transition: {{transition}};"
|
||||
style="transform: translateX({{translateX}}px) translateY({{translateY}}px) rotate({{rotate}}deg); transition: {{transition}};"
|
||||
bindtouchstart="onTouchStart"
|
||||
bindtouchmove="onTouchMove"
|
||||
bindtouchend="onTouchEnd"
|
||||
@@ -27,7 +27,40 @@
|
||||
<view class="card {{isFlipped ? 'flipped' : ''}}">
|
||||
<!-- 正面:名字与诗词 -->
|
||||
<view class="card-face front" bindtap="onFlip">
|
||||
<text class="name">{{nameList[currentIndex].name}}</text>
|
||||
<!-- 音符按钮 -->
|
||||
<view class="music-btn {{currentAmbience !== 'silent' ? 'active' : ''}}" catchtap="onMusicTap">♪</view>
|
||||
<!-- 音频菜单 -->
|
||||
<view class="music-menu {{showMusicMenu ? 'show' : ''}}" catchtap="onMenuTap">
|
||||
<view class="menu-item {{currentAmbience === 'silent' ? 'active' : ''}}" data-type="silent" catchtap="onAmbienceChange">
|
||||
<text class="menu-icon">🔇</text>
|
||||
<text class="menu-text">静音</text>
|
||||
</view>
|
||||
<view class="menu-item {{currentAmbience === 'wind' ? 'active' : ''}}" data-type="wind" catchtap="onAmbienceChange">
|
||||
<text class="menu-icon">🎐</text>
|
||||
<text class="menu-text">风铃</text>
|
||||
</view>
|
||||
<view class="menu-item {{currentAmbience === 'rain' ? 'active' : ''}}" data-type="rain" catchtap="onAmbienceChange">
|
||||
<text class="menu-icon">🌧</text>
|
||||
<text class="menu-text">雨落</text>
|
||||
</view>
|
||||
<view class="menu-item {{currentAmbience === 'guqin' ? 'active' : ''}}" data-type="guqin" catchtap="onAmbienceChange">
|
||||
<text class="menu-icon">🎵</text>
|
||||
<text class="menu-text">古琴</text>
|
||||
</view>
|
||||
<view class="menu-item {{currentAmbience === 'white' ? 'active' : ''}}" data-type="white" catchtap="onAmbienceChange">
|
||||
<text class="menu-icon">🌫</text>
|
||||
<text class="menu-text">白噪音</text>
|
||||
</view>
|
||||
<view class="menu-item {{currentAmbience === 'forest' ? 'active' : ''}}" data-type="forest" catchtap="onAmbienceChange">
|
||||
<text class="menu-icon">🌲</text>
|
||||
<text class="menu-text">森林</text>
|
||||
</view>
|
||||
<view class="menu-item {{currentAmbience === 'stream' ? 'active' : ''}}" data-type="stream" catchtap="onAmbienceChange">
|
||||
<text class="menu-icon">💧</text>
|
||||
<text class="menu-text">溪流</text>
|
||||
</view>
|
||||
</view>
|
||||
<text class="name" bindlongpress="onNameLongPress">{{nameList[currentIndex].name}}</text>
|
||||
<view class="poem-container">
|
||||
<text class="poem">{{nameList[currentIndex].origin}}</text>
|
||||
</view>
|
||||
@@ -40,6 +73,10 @@
|
||||
<view class="analysis-container">
|
||||
<view class="tone-tag">声韵:{{nameList[currentIndex].tone}}</view>
|
||||
<view class="score-tag">见素评分:{{nameList[currentIndex].score}}</view>
|
||||
<view class="ai-btn" catchtap="onAskAI">
|
||||
<text class="ai-icon">✦</text>
|
||||
<text>问问 AI</text>
|
||||
</view>
|
||||
<view class="save-btn" catchtap="onSavePoster">
|
||||
<text class="save-icon">+</text>
|
||||
<text>存为海报</text>
|
||||
@@ -83,6 +120,9 @@
|
||||
<view class="poster-btn save-btn" bindtap="savePoster">
|
||||
<text>保存到相册</text>
|
||||
</view>
|
||||
<view class="poster-btn square-btn" bindtap="shareToSquare">
|
||||
<text>分享到广场</text>
|
||||
</view>
|
||||
<view class="poster-btn share-btn" bindtap="sharePoster">
|
||||
<text>分享给好友</text>
|
||||
</view>
|
||||
@@ -90,26 +130,53 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 结语卡片:灵感耗尽或加载失败时显示 -->
|
||||
<view class="ending-card {{showEndingCard ? 'show' : ''}}" wx:if="{{showEndingCard}}">
|
||||
<view class="ending-ink"></view>
|
||||
<text class="ending-hint">此间灵感暂歇</text>
|
||||
<text class="ending-subhint">或寻他处,或待重来</text>
|
||||
<view class="ending-actions">
|
||||
<view class="ending-btn square-btn" bindtap="onGoToSquare">
|
||||
<text>进入灵感广场</text>
|
||||
</view>
|
||||
<view class="ending-btn back-btn" bindtap="onBack">
|
||||
<text>返回重试</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 底部操作栏 -->
|
||||
<view class="action-bar" wx:if="{{!isLoading && nameList.length > 0}}">
|
||||
<view class="action-bar" wx:if="{{!isLoading && nameList.length > 0 && !showEndingCard}}">
|
||||
<view class="action-btn dislike-btn" bindtap="onDislike">
|
||||
<text class="action-icon">×</text>
|
||||
</view>
|
||||
<view class="action-btn square-btn" bindtap="onGoToSquare">
|
||||
<text class="action-icon">✦</text>
|
||||
</view>
|
||||
<view class="action-btn like-btn" bindtap="onLike">
|
||||
<text class="action-icon">♥</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<view class="empty-state" wx:if="{{!isLoading && nameList.length === 0}}">
|
||||
<text>暂无灵感,请重试</text>
|
||||
<button bindtap="onLoad" style="margin-top: 20px; font-weight: 300;">重新感悟</button>
|
||||
<!-- 空状态:和结语卡片样式一致 -->
|
||||
<view class="ending-card {{!isLoading && nameList.length === 0 ? 'show' : ''}}" wx:if="{{!isLoading && nameList.length === 0}}">
|
||||
<view class="ending-ink"></view>
|
||||
<text class="ending-hint">此间灵感暂歇</text>
|
||||
<text class="ending-subhint">或寻他处,或待重来</text>
|
||||
<view class="ending-actions">
|
||||
<view class="ending-btn square-btn" bindtap="onGoToSquare">
|
||||
<text>进入灵感广场</text>
|
||||
</view>
|
||||
<view class="ending-btn back-btn" bindtap="onBack">
|
||||
<text>返回重试</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 收藏锦囊 -->
|
||||
<!-- 收藏锦囊:始终显示 -->
|
||||
<view class="collection-bag" bindtap="toggleCollectionView">
|
||||
<text class="bag-icon">囊</text>
|
||||
<view class="collection-count">{{collectedNames.length}}</view>
|
||||
<view class="collection-count" wx:if="{{collectedNames.length > 0}}">{{collectedNames.length}}</view>
|
||||
</view>
|
||||
|
||||
<!-- 收藏列表浮层 -->
|
||||
@@ -130,4 +197,109 @@
|
||||
</scroll-view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 字源溯源弹窗 -->
|
||||
<view class="char-detail-modal {{showCharDetail ? 'visible' : ''}}" bindtap="closeCharDetail">
|
||||
<view class="char-detail-content" catchtap="preventBubble">
|
||||
<view class="char-detail-close" bindtap="closeCharDetail">×</view>
|
||||
|
||||
<!-- 磨砂玻璃背景 -->
|
||||
<view class="char-detail-glass"></view>
|
||||
|
||||
<!-- 篆书背景装饰 -->
|
||||
<view class="char-seal-bg">
|
||||
<text class="seal-char seal-1">{{selectedChar}}</text>
|
||||
<text class="seal-char seal-2">{{selectedChar}}</text>
|
||||
<text class="seal-char seal-3">{{selectedChar}}</text>
|
||||
</view>
|
||||
|
||||
<!-- 大字展示 -->
|
||||
<view class="char-display">
|
||||
<text class="char-big">{{selectedChar}}</text>
|
||||
<text class="char-pinyin" wx:if="{{charDetailData}}">{{charDetailData.pinyin}}</text>
|
||||
</view>
|
||||
|
||||
<!-- 字源信息 -->
|
||||
<scroll-view class="char-info-scroll" scroll-y wx:if="{{charDetailData}}">
|
||||
<!-- 基础信息 -->
|
||||
<view class="char-info-section">
|
||||
<view class="char-info-item">
|
||||
<text class="char-label">部首</text>
|
||||
<text class="char-value">{{charDetailData.radical || '未知'}}</text>
|
||||
</view>
|
||||
<view class="char-info-item">
|
||||
<text class="char-label">笔画</text>
|
||||
<text class="char-value">{{charDetailData.strokes || '未知'}}画</text>
|
||||
</view>
|
||||
<view class="char-info-item">
|
||||
<text class="char-label">五行</text>
|
||||
<text class="char-value">{{charDetailData.wuxing || '未知'}}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 本义 -->
|
||||
<view class="char-section">
|
||||
<view class="char-section-title">本义</view>
|
||||
<text class="char-section-text">{{charDetailData.meaning || '暂无解析'}}</text>
|
||||
</view>
|
||||
|
||||
<!-- 起名意象 -->
|
||||
<view class="char-section">
|
||||
<view class="char-section-title">起名意象</view>
|
||||
<text class="char-section-text">{{charDetailData.imagery || '暂无解析'}}</text>
|
||||
</view>
|
||||
|
||||
<!-- 诗词典故 -->
|
||||
<view class="char-section" wx:if="{{charDetailData.poetry}}">
|
||||
<view class="char-section-title">诗词典故</view>
|
||||
<text class="char-section-text poetry">{{charDetailData.poetry}}</text>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<!-- 加载中 -->
|
||||
<view class="char-loading" wx:if="{{!charDetailData}}">
|
||||
<text>正在溯源...</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 问问 AI 弹窗 -->
|
||||
<view class="ai-modal {{showAIModal ? 'show' : ''}}" bindtap="closeAIModal">
|
||||
<view class="ai-modal-content" catchtap="preventBubble">
|
||||
<!-- 磨砂玻璃背景 -->
|
||||
<view class="ai-modal-glass"></view>
|
||||
|
||||
<!-- 关闭按钮 -->
|
||||
<view class="ai-modal-close" bindtap="closeAIModal">×</view>
|
||||
|
||||
<!-- 标题 -->
|
||||
<view class="ai-modal-header">
|
||||
<text class="ai-modal-title">AI 深度解析</text>
|
||||
<text class="ai-modal-subtitle">{{nameList[currentIndex].name}}</text>
|
||||
</view>
|
||||
|
||||
<!-- 输入框 -->
|
||||
<view class="ai-input-section">
|
||||
<text class="ai-input-label">你想了解这个名字在什么场景下的暗示?</text>
|
||||
<input class="ai-context-input" placeholder="例如:从事艺术行业、出国留学、创业..." value="{{aiContext}}" bindinput="onAIContextInput" />
|
||||
</view>
|
||||
|
||||
<!-- 解析结果 -->
|
||||
<scroll-view class="ai-result-scroll" scroll-y wx:if="{{aiExplanation}}">
|
||||
<text class="ai-explanation">{{aiExplanation}}</text>
|
||||
</scroll-view>
|
||||
|
||||
<!-- 加载中 -->
|
||||
<view class="ai-loading" wx:if="{{aiLoading}}">
|
||||
<text>正在思考...</text>
|
||||
</view>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<view class="ai-actions" wx:if="{{!aiLoading}}">
|
||||
<view class="ai-action-btn" bindtap="requestAIExplanation">
|
||||
<text>{{aiExplanation ? '重新解析' : '开始解析'}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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' });
|
||||
|
||||
@@ -6,6 +6,18 @@
|
||||
<view class="header-spacer"></view>
|
||||
</view>
|
||||
|
||||
<!-- 积分信息 - 克制地放在角落 -->
|
||||
<view class="credits-bar" bindtap="onCreditsTap">
|
||||
<view class="credits-item">
|
||||
<text class="credits-icon">✦</text>
|
||||
<text class="credits-text">今日灵感 {{creditsInfo.dailyCredits || 0}}/5</text>
|
||||
</view>
|
||||
<view class="credits-item watch-ad" wx:if="{{(creditsInfo.dailyCredits || 0) === 0 && (creditsInfo.watchedAdCount || 0) < 5}}">
|
||||
<text class="credits-icon">📖</text>
|
||||
<text class="credits-text">静心阅读 +3</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 收藏列表 -->
|
||||
<scroll-view scroll-y class="favorites-scroll" wx:if="{{favorites.length > 0}}">
|
||||
<view class="favorites-grid">
|
||||
@@ -46,4 +58,23 @@
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 静心阅读全屏页 -->
|
||||
<view class="meditation-page {{showMeditation ? 'visible' : ''}}">
|
||||
<view class="meditation-close" bindtap="closeMeditation">×</view>
|
||||
<view class="meditation-content">
|
||||
<view class="meditation-title">静心阅读</view>
|
||||
<view class="meditation-poem" wx:if="{{meditationPoem}}">
|
||||
<text class="poem-text">{{meditationPoem.text}}</text>
|
||||
<text class="poem-author">——{{meditationPoem.author}}</text>
|
||||
</view>
|
||||
<view class="meditation-hint">静观 15 秒,心随诗远</view>
|
||||
</view>
|
||||
<view class="meditation-progress">
|
||||
<view class="progress-bar">
|
||||
<view class="progress-fill" style="width: {{meditationProgress}}%"></view>
|
||||
</view>
|
||||
<text class="progress-text">{{meditationProgress}}%</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
184
miniprogram/pages/square/square.js
Normal file
184
miniprogram/pages/square/square.js
Normal file
@@ -0,0 +1,184 @@
|
||||
// 灵感广场页面
|
||||
Page({
|
||||
data: {
|
||||
posts: [],
|
||||
leftPosts: [],
|
||||
rightPosts: [],
|
||||
page: 0,
|
||||
size: 20,
|
||||
loading: false,
|
||||
hasMore: true,
|
||||
currentTag: '',
|
||||
sortType: 'latest',
|
||||
showDetail: false,
|
||||
selectedPost: {}
|
||||
},
|
||||
|
||||
onLoad() {
|
||||
this.loadPosts();
|
||||
},
|
||||
|
||||
onShow() {
|
||||
// 刷新列表
|
||||
if (this.data.posts.length === 0) {
|
||||
this.loadPosts();
|
||||
}
|
||||
},
|
||||
|
||||
// 加载帖子列表
|
||||
loadPosts(reset = false) {
|
||||
if (this.data.loading) return;
|
||||
|
||||
if (reset) {
|
||||
this.setData({
|
||||
page: 0,
|
||||
posts: [],
|
||||
leftPosts: [],
|
||||
rightPosts: [],
|
||||
hasMore: true
|
||||
});
|
||||
}
|
||||
|
||||
this.setData({ loading: true });
|
||||
|
||||
const apiBaseUrl = getApp().globalData.apiBaseUrl;
|
||||
const { page, size, currentTag, sortType } = this.data;
|
||||
|
||||
let url = `${apiBaseUrl}/api/square/posts?page=${page}&size=${size}&sort=${sortType}`;
|
||||
if (currentTag) {
|
||||
url += `&tag=${encodeURIComponent(currentTag)}`;
|
||||
}
|
||||
|
||||
wx.request({
|
||||
url,
|
||||
success: (res) => {
|
||||
if (res.data && res.data.success) {
|
||||
const newPosts = res.data.data || [];
|
||||
const allPosts = reset ? newPosts : [...this.data.posts, ...newPosts];
|
||||
|
||||
// 分配到左右两列
|
||||
const leftPosts = [];
|
||||
const rightPosts = [];
|
||||
allPosts.forEach((post, index) => {
|
||||
if (index % 2 === 0) {
|
||||
leftPosts.push(post);
|
||||
} else {
|
||||
rightPosts.push(post);
|
||||
}
|
||||
});
|
||||
|
||||
this.setData({
|
||||
posts: allPosts,
|
||||
leftPosts,
|
||||
rightPosts,
|
||||
hasMore: res.data.hasMore,
|
||||
page: page + 1,
|
||||
loading: false
|
||||
});
|
||||
} else {
|
||||
this.setData({ loading: false });
|
||||
}
|
||||
},
|
||||
fail: () => {
|
||||
this.setData({ loading: false });
|
||||
wx.showToast({ title: '加载失败', icon: 'none' });
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
// 点击标签
|
||||
onTagTap(e) {
|
||||
const tag = e.currentTarget.dataset.tag;
|
||||
this.setData({ currentTag: tag });
|
||||
this.loadPosts(true);
|
||||
},
|
||||
|
||||
// 切换排序
|
||||
onSortTap(e) {
|
||||
const type = e.currentTarget.dataset.type;
|
||||
this.setData({ sortType: type });
|
||||
this.loadPosts(true);
|
||||
},
|
||||
|
||||
// 点击帖子
|
||||
onPostTap(e) {
|
||||
const post = e.currentTarget.dataset.post;
|
||||
this.setData({
|
||||
selectedPost: post,
|
||||
showDetail: true
|
||||
});
|
||||
},
|
||||
|
||||
// 关闭详情
|
||||
onCloseDetail() {
|
||||
this.setData({ showDetail: false });
|
||||
},
|
||||
|
||||
// 阻止冒泡
|
||||
onDetailTap() {
|
||||
// 什么都不做
|
||||
},
|
||||
|
||||
// 点赞
|
||||
onLikeTap() {
|
||||
const { selectedPost } = this.data;
|
||||
const apiBaseUrl = getApp().globalData.apiBaseUrl;
|
||||
|
||||
wx.request({
|
||||
url: `${apiBaseUrl}/api/square/posts/${selectedPost.id}/like`,
|
||||
method: 'POST',
|
||||
success: (res) => {
|
||||
if (res.data && res.data.success) {
|
||||
// 更新本地数据
|
||||
const updatedPost = { ...selectedPost, likeCount: (selectedPost.likeCount || 0) + 1 };
|
||||
this.setData({ selectedPost: updatedPost });
|
||||
|
||||
// 更新列表中的数据
|
||||
const posts = this.data.posts.map(p =>
|
||||
p.id === selectedPost.id ? updatedPost : p
|
||||
);
|
||||
this.setData({ posts });
|
||||
|
||||
wx.showToast({ title: '已共鸣', icon: 'none' });
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
// 同款跳转(卡片上)
|
||||
onCopyTap(e) {
|
||||
const { keyword, mode } = e.currentTarget.dataset;
|
||||
this.navigateToHome(keyword, mode);
|
||||
},
|
||||
|
||||
// 同款跳转(详情中)
|
||||
onDetailCopyTap() {
|
||||
const { keyword, mode } = this.data.selectedPost;
|
||||
this.navigateToHome(keyword, mode);
|
||||
},
|
||||
|
||||
// 跳转到首页
|
||||
navigateToHome(keyword, mode) {
|
||||
if (!keyword) {
|
||||
wx.showToast({ title: '无法获取关键词', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
|
||||
wx.reLaunch({
|
||||
url: `/pages/home/home?keyword=${encodeURIComponent(keyword)}&mode=${mode || 'poetic'}`
|
||||
});
|
||||
},
|
||||
|
||||
// 下拉刷新
|
||||
onPullDownRefresh() {
|
||||
this.loadPosts(true);
|
||||
wx.stopPullDownRefresh();
|
||||
},
|
||||
|
||||
// 上拉加载更多
|
||||
onReachBottom() {
|
||||
if (this.data.hasMore && !this.data.loading) {
|
||||
this.loadPosts();
|
||||
}
|
||||
}
|
||||
});
|
||||
8
miniprogram/pages/square/square.json
Normal file
8
miniprogram/pages/square/square.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"navigationBarTitleText": "灵感广场",
|
||||
"navigationBarBackgroundColor": "#F5F5F0",
|
||||
"navigationBarTextStyle": "black",
|
||||
"backgroundColor": "#F5F5F0",
|
||||
"enablePullDownRefresh": true,
|
||||
"onReachBottomDistance": 100
|
||||
}
|
||||
141
miniprogram/pages/square/square.wxml
Normal file
141
miniprogram/pages/square/square.wxml
Normal file
@@ -0,0 +1,141 @@
|
||||
<!-- 灵感广场页面 - 瀑布流展示 -->
|
||||
<view class="square-container">
|
||||
<!-- 顶部导航 -->
|
||||
<view class="header">
|
||||
<view class="title">灵感广场</view>
|
||||
<view class="subtitle">发现诗意,共鸣美好</view>
|
||||
</view>
|
||||
|
||||
<!-- 筛选标签 -->
|
||||
<scroll-view class="tag-bar" scroll-x>
|
||||
<view class="tag-list">
|
||||
<view class="tag {{currentTag === '' ? 'active' : ''}}" bindtap="onTagTap" data-tag="">全部</view>
|
||||
<view class="tag {{currentTag === '清冷' ? 'active' : ''}}" bindtap="onTagTap" data-tag="清冷">清冷</view>
|
||||
<view class="tag {{currentTag === '温柔' ? 'active' : ''}}" bindtap="onTagTap" data-tag="温柔">温柔</view>
|
||||
<view class="tag {{currentTag === '古雅' ? 'active' : ''}}" bindtap="onTagTap" data-tag="古雅">古雅</view>
|
||||
<view class="tag {{currentTag === '诗意' ? 'active' : ''}}" bindtap="onTagTap" data-tag="诗意">诗意</view>
|
||||
<view class="tag {{currentTag === '自然' ? 'active' : ''}}" bindtap="onTagTap" data-tag="自然">自然</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<!-- 排序切换 -->
|
||||
<view class="sort-bar">
|
||||
<view class="sort-item {{sortType === 'latest' ? 'active' : ''}}" bindtap="onSortTap" data-type="latest">最新</view>
|
||||
<view class="sort-item {{sortType === 'hot' ? 'active' : ''}}" bindtap="onSortTap" data-type="hot">热门</view>
|
||||
</view>
|
||||
|
||||
<!-- 瀑布流列表 -->
|
||||
<view class="waterfall">
|
||||
<view class="column left">
|
||||
<view class="post-card"
|
||||
wx:for="{{leftPosts}}"
|
||||
wx:key="id"
|
||||
bindtap="onPostTap"
|
||||
data-post="{{item}}">
|
||||
<image class="post-image"
|
||||
src="{{item.imageUrl}}"
|
||||
mode="widthFix"
|
||||
lazy-load
|
||||
show-menu-by-longpress
|
||||
wx:if="{{item.imageUrl}}"/>
|
||||
<view class="post-image-placeholder" wx:if="{{!item.imageUrl}}">
|
||||
<text class="placeholder-text">{{item.name}}</text>
|
||||
</view>
|
||||
<view class="post-info">
|
||||
<view class="post-name">{{item.name}}</view>
|
||||
<view class="post-origin" wx:if="{{item.origin}}">{{item.origin}}</view>
|
||||
<view class="post-footer">
|
||||
<view class="post-keyword" wx:if="{{item.keyword}}">#{{item.keyword}}</view>
|
||||
<view class="post-likes">
|
||||
<text class="like-icon">♡</text>
|
||||
<text class="like-count">{{item.likeCount || 0}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<!-- 同款跳转按钮 -->
|
||||
<view class="copy-btn"
|
||||
catchtap="onCopyTap"
|
||||
data-keyword="{{item.keyword}}"
|
||||
data-mode="{{item.mode}}">
|
||||
<text>同款意境</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="column right">
|
||||
<view class="post-card"
|
||||
wx:for="{{rightPosts}}"
|
||||
wx:key="id"
|
||||
bindtap="onPostTap"
|
||||
data-post="{{item}}">
|
||||
<image class="post-image"
|
||||
src="{{item.imageUrl}}"
|
||||
mode="widthFix"
|
||||
lazy-load
|
||||
show-menu-by-longpress
|
||||
wx:if="{{item.imageUrl}}"/>
|
||||
<view class="post-image-placeholder" wx:if="{{!item.imageUrl}}">
|
||||
<text class="placeholder-text">{{item.name}}</text>
|
||||
</view>
|
||||
<view class="post-info">
|
||||
<view class="post-name">{{item.name}}</view>
|
||||
<view class="post-origin" wx:if="{{item.origin}}">{{item.origin}}</view>
|
||||
<view class="post-footer">
|
||||
<view class="post-keyword" wx:if="{{item.keyword}}">#{{item.keyword}}</view>
|
||||
<view class="post-likes">
|
||||
<text class="like-icon">♡</text>
|
||||
<text class="like-count">{{item.likeCount || 0}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<!-- 同款跳转按钮 -->
|
||||
<view class="copy-btn"
|
||||
catchtap="onCopyTap"
|
||||
data-keyword="{{item.keyword}}"
|
||||
data-mode="{{item.mode}}">
|
||||
<text>同款意境</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 加载更多 -->
|
||||
<view class="load-more" wx:if="{{loading}}">
|
||||
<view class="loading-dots">
|
||||
<view class="dot"></view>
|
||||
<view class="dot"></view>
|
||||
<view class="dot"></view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="no-more" wx:if="{{!hasMore && posts.length > 0}}">
|
||||
<text>没有更多了</text>
|
||||
</view>
|
||||
|
||||
<view class="empty" wx:if="{{!loading && posts.length === 0}}">
|
||||
<text class="empty-icon">✿</text>
|
||||
<text class="empty-text">暂无灵感,快去生成并分享吧</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 帖子详情弹窗 -->
|
||||
<view class="detail-mask {{showDetail ? 'show' : ''}}" bindtap="onCloseDetail">
|
||||
<view class="detail-container {{showDetail ? 'show' : ''}}" catchtap="onDetailTap">
|
||||
<image class="detail-image" src="{{selectedPost.imageUrl}}" mode="widthFix"/>
|
||||
<view class="detail-info">
|
||||
<view class="detail-name">{{selectedPost.name}}</view>
|
||||
<view class="detail-origin" wx:if="{{selectedPost.origin}}">{{selectedPost.origin}}</view>
|
||||
<view class="detail-desc" wx:if="{{selectedPost.description}}">{{selectedPost.description}}</view>
|
||||
<view class="detail-actions">
|
||||
<view class="action-btn like" bindtap="onLikeTap">
|
||||
<text class="action-icon">♡</text>
|
||||
<text class="action-text">共鸣 {{selectedPost.likeCount || 0}}</text>
|
||||
</view>
|
||||
<view class="action-btn copy" bindtap="onDetailCopyTap">
|
||||
<text class="action-text">同款意境</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="detail-close" bindtap="onCloseDetail">×</view>
|
||||
</view>
|
||||
</view>
|
||||
376
miniprogram/pages/square/square.wxss
Normal file
376
miniprogram/pages/square/square.wxss
Normal file
@@ -0,0 +1,376 @@
|
||||
/* 灵感广场页面 - 瀑布流风格 */
|
||||
|
||||
.square-container {
|
||||
min-height: 100vh;
|
||||
background: #F5F5F0;
|
||||
padding-bottom: 40rpx;
|
||||
}
|
||||
|
||||
/* 顶部导航 */
|
||||
.header {
|
||||
padding: 40rpx 30rpx 20rpx;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 40rpx;
|
||||
font-weight: 600;
|
||||
color: #2D2D2D;
|
||||
letter-spacing: 8rpx;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 24rpx;
|
||||
color: #999;
|
||||
margin-top: 8rpx;
|
||||
letter-spacing: 4rpx;
|
||||
}
|
||||
|
||||
/* 标签栏 */
|
||||
.tag-bar {
|
||||
padding: 20rpx 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tag-list {
|
||||
padding: 0 20rpx;
|
||||
}
|
||||
|
||||
.tag {
|
||||
display: inline-block;
|
||||
padding: 12rpx 28rpx;
|
||||
margin: 0 10rpx;
|
||||
background: #fff;
|
||||
border-radius: 30rpx;
|
||||
font-size: 26rpx;
|
||||
color: #666;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.tag.active {
|
||||
background: #2D2D2D;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* 排序栏 */
|
||||
.sort-bar {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 10rpx 0 20rpx;
|
||||
gap: 40rpx;
|
||||
}
|
||||
|
||||
.sort-item {
|
||||
font-size: 26rpx;
|
||||
color: #999;
|
||||
padding: 8rpx 16rpx;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.sort-item.active {
|
||||
color: #2D2D2D;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.sort-item.active::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 20rpx;
|
||||
height: 4rpx;
|
||||
background: #B22222;
|
||||
border-radius: 2rpx;
|
||||
}
|
||||
|
||||
/* 瀑布流 */
|
||||
.waterfall {
|
||||
display: flex;
|
||||
padding: 0 20rpx;
|
||||
gap: 20rpx;
|
||||
}
|
||||
|
||||
.column {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20rpx;
|
||||
}
|
||||
|
||||
/* 帖子卡片 */
|
||||
.post-card {
|
||||
background: #fff;
|
||||
border-radius: 16rpx;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.06);
|
||||
transition: transform 0.3s;
|
||||
}
|
||||
|
||||
.post-card:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.post-image {
|
||||
width: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.post-image-placeholder {
|
||||
width: 100%;
|
||||
height: 300rpx;
|
||||
background: linear-gradient(135deg, #F5F5F0 0%, #E8E8E0 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.placeholder-text {
|
||||
font-size: 48rpx;
|
||||
font-family: 'KaiTi', 'STKaiti', serif;
|
||||
color: #999;
|
||||
letter-spacing: 8rpx;
|
||||
}
|
||||
|
||||
.post-info {
|
||||
padding: 20rpx;
|
||||
}
|
||||
|
||||
.post-name {
|
||||
font-size: 32rpx;
|
||||
font-weight: 600;
|
||||
color: #2D2D2D;
|
||||
margin-bottom: 8rpx;
|
||||
}
|
||||
|
||||
.post-origin {
|
||||
font-size: 22rpx;
|
||||
color: #999;
|
||||
line-height: 1.5;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.post-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 16rpx;
|
||||
padding-top: 16rpx;
|
||||
border-top: 1rpx solid #f0f0f0;
|
||||
}
|
||||
|
||||
.post-keyword {
|
||||
font-size: 22rpx;
|
||||
color: #B22222;
|
||||
}
|
||||
|
||||
.post-likes {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6rpx;
|
||||
}
|
||||
|
||||
.like-icon {
|
||||
font-size: 24rpx;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.like-count {
|
||||
font-size: 22rpx;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
/* 同款按钮 */
|
||||
.copy-btn {
|
||||
margin: 0 20rpx 20rpx;
|
||||
padding: 16rpx 0;
|
||||
background: #F5F5F0;
|
||||
border-radius: 8rpx;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.copy-btn text {
|
||||
font-size: 24rpx;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
/* 加载更多 */
|
||||
.load-more {
|
||||
padding: 40rpx 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.loading-dots {
|
||||
display: flex;
|
||||
gap: 12rpx;
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 12rpx;
|
||||
height: 12rpx;
|
||||
background: #ccc;
|
||||
border-radius: 50%;
|
||||
animation: bounce 1.4s infinite ease-in-out both;
|
||||
}
|
||||
|
||||
.dot:nth-child(1) { animation-delay: -0.32s; }
|
||||
.dot:nth-child(2) { animation-delay: -0.16s; }
|
||||
|
||||
@keyframes bounce {
|
||||
0%, 80%, 100% { transform: scale(0); }
|
||||
40% { transform: scale(1); }
|
||||
}
|
||||
|
||||
.no-more {
|
||||
text-align: center;
|
||||
padding: 40rpx 0;
|
||||
color: #999;
|
||||
font-size: 24rpx;
|
||||
}
|
||||
|
||||
/* 空状态 */
|
||||
.empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 100rpx 0;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 80rpx;
|
||||
color: #ccc;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
font-size: 26rpx;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
/* 详情弹窗 */
|
||||
.detail-mask {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: all 0.3s;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.detail-mask.show {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.detail-container {
|
||||
width: 640rpx;
|
||||
max-height: 80vh;
|
||||
background: #fff;
|
||||
border-radius: 20rpx;
|
||||
overflow: hidden;
|
||||
transform: scale(0.9);
|
||||
transition: all 0.3s;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.detail-container.show {
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
.detail-image {
|
||||
width: 100%;
|
||||
max-height: 400rpx;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.detail-info {
|
||||
padding: 30rpx;
|
||||
}
|
||||
|
||||
.detail-name {
|
||||
font-size: 40rpx;
|
||||
font-weight: 600;
|
||||
color: #2D2D2D;
|
||||
text-align: center;
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
.detail-origin {
|
||||
font-size: 24rpx;
|
||||
color: #666;
|
||||
line-height: 1.6;
|
||||
text-align: center;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.detail-desc {
|
||||
font-size: 26rpx;
|
||||
color: #555;
|
||||
line-height: 1.8;
|
||||
text-align: justify;
|
||||
}
|
||||
|
||||
.detail-actions {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 40rpx;
|
||||
margin-top: 30rpx;
|
||||
padding-top: 30rpx;
|
||||
border-top: 1rpx solid #f0f0f0;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8rpx;
|
||||
padding: 16rpx 40rpx;
|
||||
border-radius: 40rpx;
|
||||
}
|
||||
|
||||
.action-btn.like {
|
||||
background: #F5F5F0;
|
||||
}
|
||||
|
||||
.action-btn.copy {
|
||||
background: #2D2D2D;
|
||||
}
|
||||
|
||||
.action-icon {
|
||||
font-size: 28rpx;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.action-btn.copy .action-text {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.action-text {
|
||||
font-size: 26rpx;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.detail-close {
|
||||
position: absolute;
|
||||
top: 20rpx;
|
||||
right: 20rpx;
|
||||
width: 60rpx;
|
||||
height: 60rpx;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 36rpx;
|
||||
color: #fff;
|
||||
}
|
||||
260
miniprogram/utils/audio.js
Normal file
260
miniprogram/utils/audio.js
Normal file
@@ -0,0 +1,260 @@
|
||||
// 音频管理工具 - 环境白噪音与音效管理
|
||||
|
||||
// 背景音频管理器(用于环境白噪音)
|
||||
let bgmManager = null;
|
||||
|
||||
// 音效上下文缓存
|
||||
const soundEffects = {};
|
||||
|
||||
// 当前环境音类型
|
||||
let currentAmbienceType = 'silent';
|
||||
|
||||
// 音量渐变定时器
|
||||
let fadeTimer = null;
|
||||
|
||||
/**
|
||||
* 初始化背景音频管理器
|
||||
*/
|
||||
export const initBGM = () => {
|
||||
if (!bgmManager) {
|
||||
bgmManager = wx.getBackgroundAudioManager();
|
||||
bgmManager.title = '见素-环境音';
|
||||
bgmManager.epname = '环境白噪音';
|
||||
bgmManager.singer = '见素';
|
||||
}
|
||||
return bgmManager;
|
||||
};
|
||||
|
||||
/**
|
||||
* 播放环境白噪音
|
||||
* @param {string} type - 环境音类型: 'silent' | 'wind' | 'rain' | 'guqin' | 'white' | 'forest' | 'stream'
|
||||
* @param {boolean} fade - 是否淡入
|
||||
*/
|
||||
export const playAmbience = (type = 'wind', fade = true) => {
|
||||
const bgm = initBGM();
|
||||
|
||||
// 如果相同类型且正在播放,不重复操作
|
||||
if (currentAmbienceType === type && bgm.currentTime > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
currentAmbienceType = type;
|
||||
|
||||
// 静音模式
|
||||
if (type === 'silent') {
|
||||
fadeOutAndStop();
|
||||
return;
|
||||
}
|
||||
|
||||
// 音频资源映射(使用本地音频)
|
||||
const audioMap = {
|
||||
wind: '/assets/audios/wind.mp3', // 风铃
|
||||
rain: '/assets/audios/rain.mp3', // 雨声
|
||||
guqin: '/assets/audios/guqin.mp3', // 古琴
|
||||
white: '/assets/audios/white.mp3', // 白噪音
|
||||
forest: '/assets/audios/forest.mp3', // 森林
|
||||
stream: '/assets/audios/stream.mp3' // 溪流
|
||||
};
|
||||
|
||||
// 音频类型名称映射
|
||||
const typeNameMap = {
|
||||
wind: '风铃',
|
||||
rain: '雨落',
|
||||
guqin: '古琴',
|
||||
white: '白噪音',
|
||||
forest: '森林',
|
||||
stream: '溪流'
|
||||
};
|
||||
|
||||
const src = audioMap[type];
|
||||
if (!src) return;
|
||||
|
||||
// 设置音频属性
|
||||
bgm.title = `见素 - ${typeNameMap[type] || '环境音'}`;
|
||||
bgm.singer = '环境音';
|
||||
bgm.coverImgUrl = '';
|
||||
|
||||
// 监听错误事件
|
||||
bgm.onError((err) => {
|
||||
console.error('音频播放错误:', err);
|
||||
wx.showToast({
|
||||
title: '音频加载失败',
|
||||
icon: 'none'
|
||||
});
|
||||
});
|
||||
|
||||
// 淡入效果
|
||||
if (fade) {
|
||||
bgm.volume = 0;
|
||||
bgm.src = src;
|
||||
bgm.play();
|
||||
fadeIn(0.3, 1000); // 1秒内淡入到30%音量
|
||||
} else {
|
||||
bgm.volume = 0.3;
|
||||
bgm.src = src;
|
||||
bgm.play();
|
||||
}
|
||||
|
||||
console.log(`播放环境音: ${type}`);
|
||||
};
|
||||
|
||||
/**
|
||||
* 淡入音量
|
||||
* @param {number} targetVolume - 目标音量 0-1
|
||||
* @param {number} duration - 淡入时长 ms
|
||||
*/
|
||||
const fadeIn = (targetVolume = 0.3, duration = 1000) => {
|
||||
if (!bgmManager) return;
|
||||
|
||||
clearInterval(fadeTimer);
|
||||
const step = targetVolume / (duration / 50); // 每50ms调整一次
|
||||
|
||||
fadeTimer = setInterval(() => {
|
||||
if (bgmManager.volume < targetVolume) {
|
||||
bgmManager.volume = Math.min(bgmManager.volume + step, targetVolume);
|
||||
} else {
|
||||
clearInterval(fadeTimer);
|
||||
}
|
||||
}, 50);
|
||||
};
|
||||
|
||||
/**
|
||||
* 淡出并停止
|
||||
* @param {number} duration - 淡出时长 ms
|
||||
*/
|
||||
export const fadeOutAndStop = (duration = 500) => {
|
||||
if (!bgmManager) return;
|
||||
|
||||
clearInterval(fadeTimer);
|
||||
const startVolume = bgmManager.volume;
|
||||
const step = startVolume / (duration / 50);
|
||||
|
||||
fadeTimer = setInterval(() => {
|
||||
if (bgmManager.volume > 0.01) {
|
||||
bgmManager.volume = Math.max(bgmManager.volume - step, 0);
|
||||
} else {
|
||||
clearInterval(fadeTimer);
|
||||
bgmManager.stop();
|
||||
currentAmbienceType = 'silent';
|
||||
}
|
||||
}, 50);
|
||||
};
|
||||
|
||||
/**
|
||||
* 播放音效
|
||||
* @param {string} type - 音效类型
|
||||
* @param {object} options - 配置选项
|
||||
*/
|
||||
export const playSoundEffect = (type, options = {}) => {
|
||||
const { volume = 1, loop = false } = options;
|
||||
|
||||
// 音效资源映射
|
||||
const effectMap = {
|
||||
flip: '/assets/audios/paper.mp3', // 翻页/纸张摩擦声
|
||||
success: '/assets/audios/success.mp3', // 成功
|
||||
inkDrop: '/assets/audios/inkdrop.mp3', // 水滴/入墨声
|
||||
swipe: '/assets/audios/swipe.mp3', // 滑动
|
||||
tap: '/assets/audios/tap.mp3', // 点击
|
||||
breathe: '/assets/audios/breathe.mp3', // 呼吸
|
||||
char: '/assets/audios/char.mp3' // 字显现
|
||||
};
|
||||
|
||||
const src = effectMap[type];
|
||||
if (!src) return;
|
||||
|
||||
// 复用或创建音频上下文
|
||||
if (!soundEffects[type]) {
|
||||
soundEffects[type] = wx.createInnerAudioContext();
|
||||
}
|
||||
|
||||
const ctx = soundEffects[type];
|
||||
ctx.src = src;
|
||||
ctx.volume = volume;
|
||||
ctx.loop = loop;
|
||||
|
||||
ctx.stop();
|
||||
try {
|
||||
ctx.play();
|
||||
} catch (err) {
|
||||
console.log('音效播放失败:', err);
|
||||
}
|
||||
|
||||
return ctx;
|
||||
};
|
||||
|
||||
/**
|
||||
* 停止指定音效
|
||||
* @param {string} type - 音效类型
|
||||
*/
|
||||
export const stopSoundEffect = (type) => {
|
||||
if (soundEffects[type]) {
|
||||
soundEffects[type].stop();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 停止所有音效
|
||||
*/
|
||||
export const stopAllSoundEffects = () => {
|
||||
Object.keys(soundEffects).forEach(type => {
|
||||
soundEffects[type].stop();
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 停止环境音
|
||||
*/
|
||||
export const stopAmbience = () => {
|
||||
fadeOutAndStop();
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取当前环境音类型
|
||||
*/
|
||||
export const getCurrentAmbience = () => currentAmbienceType;
|
||||
|
||||
/**
|
||||
* 切换环境音
|
||||
* @param {string} type - 环境音类型
|
||||
*/
|
||||
export const toggleAmbience = (type) => {
|
||||
if (currentAmbienceType === type) {
|
||||
playAmbience('silent');
|
||||
} else {
|
||||
playAmbience(type);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 呼吸震动效果 - 配合名字逐字显现
|
||||
* @param {number} duration - 总时长 ms
|
||||
* @param {number} intensity - 震动强度 light/medium/heavy
|
||||
*/
|
||||
export const breatheVibration = (duration = 2000, intensity = 'light') => {
|
||||
const interval = duration / 4; // 分4次震动
|
||||
let count = 0;
|
||||
|
||||
const vibrate = () => {
|
||||
if (count < 4) {
|
||||
wx.vibrateShort({ type: intensity });
|
||||
count++;
|
||||
setTimeout(vibrate, interval);
|
||||
}
|
||||
};
|
||||
|
||||
vibrate();
|
||||
};
|
||||
|
||||
/**
|
||||
* 清理音频资源
|
||||
*/
|
||||
export const cleanup = () => {
|
||||
clearInterval(fadeTimer);
|
||||
if (bgmManager) {
|
||||
bgmManager.stop();
|
||||
}
|
||||
Object.keys(soundEffects).forEach(type => {
|
||||
soundEffects[type].destroy();
|
||||
delete soundEffects[type];
|
||||
});
|
||||
};
|
||||
61
step6.md
Normal file
61
step6.md
Normal file
@@ -0,0 +1,61 @@
|
||||
---
|
||||
|
||||
# 阶段 5:意境升华与用户深度连接
|
||||
|
||||
## 🎯 阶段目标
|
||||
通过声音、触感和数据分析,将小程序从“好用的工具”升华为“有温度的艺术品”,并为未来的商业化/社交化铺路。
|
||||
|
||||
---
|
||||
|
||||
## 5.1 多维交互:五感体验优化 (The Sensory Update)
|
||||
目前视觉已经做到了极致,我们需要加入听觉和触觉来补完“禅意”。
|
||||
|
||||
* **需求详情**:
|
||||
1. **环境白噪音 (Ambience)**:在卡片页引入极细微的背景音(如:深山风铃、雨落屋檐、古琴泛音)。用户可自主选择开关。
|
||||
2. **交互音效 (Sound FX)**:
|
||||
* 墨滴晕开时:一个空灵的“叮”或水滴声。
|
||||
* 卡片翻转时:模拟宣纸摩擦的沙沙声。
|
||||
3. **触感反馈 (Haptic)**:
|
||||
* 当 AI 生成名字时,配合呼吸灯动效,让手机产生极轻微的、节奏性的震动(Breathing Haptic)。
|
||||
* **技术点**:使用 `wx.createInnerAudioContext` 管理全局音频流,确保低延迟。
|
||||
|
||||
## 5.2 智能解析:名字深意探索 (Deep Meaning AI)
|
||||
现在的描述是 AI 自动生成的,我们可以提供更深层的、更具交互性的解析。
|
||||
|
||||
* **需求详情**:
|
||||
1. **文字溯源**:在卡片背面点击某个字,弹出该字的“说文解字”或书法演变(甲骨文/篆书)。
|
||||
2. **AI 对话解析**:允许用户针对某个名字提问(如:“这个名字对职业规划有什么暗示?”),调动 AI 进行单条名字的深度对话。
|
||||
* **技术点**:接入一个小规模的“字源”数据库,或针对特定名字触发第二次 AI 深度解析请求。
|
||||
|
||||
## 5.3 社交广场:灵感集锦 (`Inspiration Square`)
|
||||
打破“单机起名”的沉闷,让用户看看别人都在求什么好名字。
|
||||
|
||||
* **需求详情**:
|
||||
1. **匿名广场**:展示其他用户公开分享的精美名字海报(瀑布流形式)。
|
||||
2. **灵感复用**:看到别人求出的好名字,可以点击“同款意境”,自动跳转到输入页并填入相同的关键词和模式。
|
||||
* **技术点**:在收藏/海报生成时增加“允许公开展示”的选项。
|
||||
|
||||
## 5.4 商业化初探:定制起名服务 (Premium Path)
|
||||
为未来的收益做技术预研。
|
||||
|
||||
* **需求详情**:
|
||||
1. **“大师”模式**:接入更强大的大模型(如 Gemini 1.5 Pro 或 GPT-4o),提供更严谨的五行八字对齐和文学考据。
|
||||
2. **积分/激励系统**:每日免费起名次数限制,通过观看极简广告(如品牌画报)或签到来获取“灵感值”。
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ 下一阶段任务分解 (WBS)
|
||||
|
||||
| 模块 | 任务描述 | 优先级 |
|
||||
| :--- | :--- | :--- |
|
||||
| **沉浸式音效** | 寻找并集成 3-5 种禅意白噪音,实现无缝循环播放 | 🔥🔥🔥 |
|
||||
| **交互震动** | 优化 UI 操作时的 `vibrateShort` 反馈逻辑 | 🔥🔥 |
|
||||
| **灵感广场** | 实现基于云数据库的海报分享流与“同款”跳转逻辑 | 🔥🔥🔥 |
|
||||
| **解析增强** | 开发卡片背面的“字源探索”弹窗组件 | 🔥 |
|
||||
|
||||
---
|
||||
|
||||
## 💡 为什么这是下一阶段的核心?
|
||||
* **差异化**:市面上的起名软件都很“商业、吵闹”,「见素」的静谧感是核心竞争力,音效能极大加深这种印象。
|
||||
* **内容生态**:灵感广场能让小程序产生“自生长”的内容,增加用户停留时长。
|
||||
* **价值感**:深度解析让用户觉得这个名字“不仅仅是好听”,而是“有理有据”。
|
||||
103
step6_dev.md
Normal file
103
step6_dev.md
Normal file
@@ -0,0 +1,103 @@
|
||||
阶段 5 开发实施方案:五感交互与内容生态 (step6_dev.md)
|
||||
|
||||
🛠 准 备工作
|
||||
1. 资源包准备:
|
||||
* 搜集 3-5 段 30s 左右的无缝循环白噪音(MP3/M4A 格式,建议采样率 44.1kHz,单声道以减小体积)。
|
||||
* 准备 2 个极简音效:水滴声(入墨)、纸张摩擦声(翻转)。
|
||||
2. 后端扩展:
|
||||
* 在 NameCard 模型中增加 public_shared 字段。
|
||||
* 准备一个新的 SquareController 用于处理广场流数据。
|
||||
|
||||
---
|
||||
|
||||
第一部分:五感体验优化 (Sensory)
|
||||
|
||||
1.1 环境白噪音管理 (Soundscape)
|
||||
* 实现方式:在 app.js 或全局自定义组件中封装 AudioContextManager。
|
||||
* 代码要点:
|
||||
|
||||
1 // utils/audio.js
|
||||
2 const bgm = wx.getBackgroundAudioManager(); // 或 createInnerAudioContext
|
||||
3 export const playAmbience = (type) => {
|
||||
4 bgm.title = '见素-环境音';
|
||||
5 bgm.src = `https://your-cdn.com/audio/${type}.mp3`;
|
||||
6 bgm.loop = true;
|
||||
7 };
|
||||
* UI 交互:在卡片详情页右上角增加一个极其隐蔽的“音符”图标,点击弹出切换菜单(静音/风铃/雨落/古琴)。
|
||||
|
||||
1.2 触感与音效反馈 (Haptics)
|
||||
* 触感反馈:在 AI 生成状态变更时调用 wx.vibrateShort({ type: 'light' })。
|
||||
* 呼吸震动:模仿冥想 App,在名字逐字显现时,配合 setInterval 产生极微弱的节奏震动。
|
||||
|
||||
---
|
||||
|
||||
第二部分:智能解析增强 (AI Deep Dive)
|
||||
|
||||
2.1 字源溯源组件
|
||||
* 功能:长按卡片上的某个汉字,触发弹窗。
|
||||
* 数据来源:
|
||||
* 方案 A (静态):内置常用起名汉字库(约 3000 字)的解析。
|
||||
* 方案 B (动态):调用后端接口,利用 AI 实时生成该字的“意象分析”。
|
||||
* UI 设计:弹窗采用透明磨砂玻璃效果,背景展示该字的篆书或隶书位图(可从开源书法库爬取)。
|
||||
|
||||
2.2 AI 对话解析
|
||||
* 前端逻辑:在卡片底部增加“问问 AI”按钮。
|
||||
* 后端逻辑:
|
||||
|
||||
1 // NamingController.java 扩展
|
||||
2 @PostMapping("/explain")
|
||||
3 public String explainDetail(@RequestBody ExplainRequest req) {
|
||||
4 // Prompt: "作为起名专家,请深度解析名字'墨染'对于'从事艺术行业'的职场暗示..."
|
||||
5 return kimiService.ask(req.getPrompt());
|
||||
6 }
|
||||
|
||||
---
|
||||
|
||||
第三部分:灵感广场 (Social Square)
|
||||
|
||||
3.1 匿名分享流
|
||||
* 数据库设计:创建 SquarePost 表,存储 name_card_id, image_url, tags, like_count。
|
||||
* 前端展示:使用小程序瀑布流组件(Masonry Layout),展示用户生成的精美海报缩略图。
|
||||
* 交互:点击海报进入详情,由于是“见素”风格,评论功能建议弱化,仅保留“共鸣”(点赞)。
|
||||
|
||||
3.2 “同款”跳转逻辑
|
||||
* 核心逻辑:点击“同款意境” -> 携带 keyword 和 mode 参数跳转回 home 页面。
|
||||
|
||||
1 // square.js
|
||||
2 copyInspiration(e) {
|
||||
3 const { keyword, mode } = e.currentTarget.dataset;
|
||||
4 wx.reLaunch({
|
||||
5 url: `/pages/home/home?keyword=${keyword}&mode=${mode}`
|
||||
6 });
|
||||
7 }
|
||||
|
||||
---
|
||||
|
||||
第四部分:技术预研与优化
|
||||
|
||||
4.1 积分/激励系统
|
||||
* 逻辑:
|
||||
* 每日赠送 5 次“灵感”。
|
||||
* 消耗完后,引导用户点击“静心阅读”(观看 15 秒极简视频或精美画报)增加次数。
|
||||
* 注意:UI 必须极其克制,不能有任何弹窗干扰,建议放在“个人中心”的角落。
|
||||
|
||||
4.2 高级模型接入 (Premium Path)
|
||||
* 策略模式:在后端 KimiService 中根据用户等级(或请求类型)切换 Model ID。
|
||||
* 普通:moonshot-v1-8k
|
||||
* 大师:moonshot-v1-32k 或 gemini-1.5-pro (更强的逻辑推理)。
|
||||
|
||||
---
|
||||
|
||||
📅 任务排期 (WBS)
|
||||
|
||||
1. Day 1-2: 音频/触感系统重构。解决音频在页面切换时的平滑过渡问题。
|
||||
2. Day 3-4: 广场后端 API 开发及小程序瀑布流页面实现。
|
||||
3. Day 5: 字源数据库集成与 AI 对话解析 Prompt 调优。
|
||||
4. Day 6: 性能专项优化(音频资源 CDN 加速、广场图懒加载)。
|
||||
5. Day 7: 整体回归测试与上线准备。
|
||||
|
||||
---
|
||||
|
||||
💡 开发提示:
|
||||
对于“见素”这样追求极致体验的产品,音效的淡入淡出 (Fade In/Out)
|
||||
至关重要。请务必在切换音频时手动实现音量平滑曲线,避免突兀。
|
||||
Reference in New Issue
Block a user