diff --git a/NamingAssistant/Spec.md b/NamingAssistant/Spec.md new file mode 100644 index 0000000..de7d54f --- /dev/null +++ b/NamingAssistant/Spec.md @@ -0,0 +1,166 @@ +# 取名小程序产品规格说明(NamingAssistant) + +## 1. 文档信息 +- **版本**:v0.1(初版) +- **创建日期**:2025-11-04 +- **适用范围**:`MiniProgram/NamingAssistant` 首个迭代(姓名生成与收藏) +- **维护人**:前端负责人、后端接口负责人、测试负责人共用 + +## 2. 背景与目标 +- 提供面向抖音生态的姓名生成服务,支持根据用户输入的姓氏、性别、出生日期与名字长度快速获取候选姓名。 +- 通过广告激励实现变现,确保用户在观看广告后获得生成结果。 +- 支撑用户在本地收藏心仪姓名,限制每日请求频率防止滥用。 + +## 3. 名词与角色 +- **用户**:使用抖音小程序的终端访客。 +- **AI 命名服务**:后端聚合接口,根据提示词返回 5 个姓名及释义。 +- **校验服务**:后端接口,校验姓氏是否合法。 +- **激励广告**:抖音小程序提供的激励视频广告组件。 + +## 4. 产品范围 +- **In Scope** + - 主页表单输入、校验与广告流程。 + - 结果页展示 5 个姓名与释义,支持收藏。 + - 收藏页展示本地收藏记录、删除、查看详情。 + - 本地记录每日调用次数,超过 10 次给出提示,次数可在后台配置。 +- **Out of Scope** + - 账户登录、云端收藏同步。 + - 历史生成记录、多端同步。 + - 自定义广告策略与多渠道投放。 + +## 5. 用户流程(KISS) +1. 用户打开主页填写表单:姓 → 性别 → 出生日期/时间 → 名字字数。 +2. 姓输入失焦触发校验接口;非法返回后提示并清空输入。 +3. 用户点击“生成姓名”,系统执行整体验证;失败则提示错误并阻止提交。 +4. 校验通过后展示激励广告;广告播放完成触发生成逻辑。 +5. 调用生成接口并显示“计算中”加载状态;成功后缓存结果并跳转结果页。 +6. 结果页展示 5 条姓名及含义,用户可选择任意一个收藏,收藏立即写入本地,数量可在后台配置。 +7. 用户可从结果页或主导航进入收藏页查看或删除收藏项。 + +## 6. 页面结构与职责(SOLID) +- **主页(`pages/home/index`)** + - 负责表单输入组件渲染与状态管理。 + - 调用校验服务、触发广告播放与生成流程。 + - 控制每日次数提示、跳转结果页。 +- **结果页(`pages/result/index`)** + - 展示当前生成结果,处理收藏交互。 + - 支持返回主页重新生成。 +- **收藏页(`pages/favorites/index`)** + - 读取本地收藏列表并渲染。 + - 提供单项删除与查看释义。 + +> 页面逻辑使用独立 `store`(如 `store/namingStore.ts`)管理:当前表单、生成结果、调用次数、收藏集合,避免重复状态(DRY)。 + +## 7. 数据模型(YAGNI:仅覆盖本迭代必要字段) +| 实体 | 字段 | 类型 | 说明 | +| --- | --- | --- | --- | +| `NamingForm` | `surname` | string | 1-2 个汉字,必填 | +| | `gender` | `'male' \| 'female'` | 单选,必填 | +| | `birthDate` | `string` | `YYYY-MM-DD`,默认当天,必填 | +| | `birthTime` | `string \| null` | `HH:mm`,默认空,选填 | +| | `nameLength` | `'single' \| 'double'` | 单名 or 双名,默认 `double` | +| `NamingResult` | `name` | string | 生成姓名 | +| | `meaning` | string | 含义说明 | +| | `score` | number? | 预留;本迭代不返回(保持可选,默认不展示) | +| `FavoriteItem` | `id` | string | `name + timestamp` 生成的唯一键 | +| | `name` | string | 收藏姓名 | +| | `meaning` | string | 对应释义 | +| | `createdAt` | string | ISO 时间戳 | + +## 8. 业务规则 +- 姓氏校验:输入长度 1-2,失焦时调用 `/validate-surname`,返回 `isValid=false` 时清空并 Toast“请输入合法姓氏”。 +- 表单必填项:姓氏、性别、出生日期、名字字数;出生时间选填(为空不纳入提示词)。 +- 提交前整体验证:若任一必填缺失或校验失败,阻止广告播放并给出错误提示。 +- 激励广告完成事件 `onClose` 返回 `isEnded=true` 才允许调用生成接口;否则提示“观看完整广告后才能生成姓名”。 +- 每日次数:基于本地缓存键 `naming:quota:YYYYMMDD` 记录已用次数;达到 10 次时 Toast“今日生成次数已用尽,请明日再试”。 +- 收藏:每个姓名可多次生成;收藏时如同名存在则更新 `createdAt`,避免重复条目。 +- 删除收藏:从本地存储移除对应 `id` 并刷新列表。 + +## 9. 接口契约(接口分离,依赖抽象而非具体实现) +- **POST `/api/naming/validate-surname`** + - 请求:`{ "surname": "李" }` + - 响应:`{ "isValid": true, "message": "" }` + - 错误码:`40001` 非法字符、`50000` 系统异常。 +- **POST `/api/naming/generate`** + - 请求: + ```json + { + "surname": "李", + "gender": "male", + "birthDate": "2025-11-04", + "birthTime": "", + "nameLength": "double" + } + ``` + - 服务端将上述信息组合成提示词,例如:`"以“李”为姓,男孩,出生于2025年11月4日,生成双名,提供5个寓意美好的中文名字"`。 + - 响应: + ```json + { + "results": [ + { "name": "李承泽", "meaning": "承载福泽,寓意富足" }, + { "name": "李昱辰", "meaning": "昱意为光辉,辰代表时光" }, + { "name": "李颢宇", "meaning": "广阔天空,胸怀大志" }, + { "name": "李景曜", "meaning": "景象辉煌,曜为光耀" }, + { "name": "李辰逸", "meaning": "自在悠然,辰意指时光" } + ] + } + ``` + - 异常: + - `42900` 请求超限(由后端或网关返回,与前端本地控制互补)。 + - `50000` 系统异常。 + +## 10. 本地存储策略 +- `tt.setStorageSync('naming:quota:YYYYMMDD', number)`:记录当日已使用次数。 +- `tt.setStorageSync('naming:favorites', FavoriteItem[])`:存储收藏列表。 +- 加载收藏页时若数据缺失则返回空数组,避免抛错。 + +## 11. 激励广告模块 +- 封装 `utils/adService.ts` 暴露 `showRewardedVideoAd(): Promise`。 +- 广告位 ID 通过配置注入,方便后续替换;模块负责加载、展示、清理事件监听。 +- 页面调用时捕获异常并提示“广告加载失败,请稍后重试”。 + +## 12. 错误与提示文案(DRY:集中在 `constants/messages.ts`) +- `INVALID_SURNAME`:`请输入合法姓氏` +- `FORM_INCOMPLETE`:`请完整填写必填项` +- `WATCH_AD_TO_CONTINUE`:`观看完整广告后才能生成姓名` +- `QUOTA_EXCEEDED`:`今日生成次数已用尽,请明日再试` +- `GENERATION_FAILED`:`生成失败,请稍后再试` + +## 13. 验收标准(测试覆盖) +- **表单校验**: + - 输入非法姓氏提示并清空。 + - 必填项缺失无法触发广告。 +- **广告流程**: + - 未完整观看广告不可调用生成接口。 + - 广告失败异常提示正常。 +- **生成结果**: + - 成功返回后跳转结果页,展示 5 条记录及释义。 + - “计算中” loading 在接口完成后消失。 +- **收藏功能**: + - 收藏后收藏页可见,删除后立即消失。 + - 重复收藏刷新时间戳不新增条目。 +- **请求限额**: + - 连续触发 10 次后出现限额提示,第 11 次被阻止。 + +## 14. 非功能需求 +- Loading 状态 300ms 内出现,避免空白。 +- API 网络失败时 2s 内给出提示。 +- 本地存储读写失败需兜底为内存态并告警(日志)。 + +## 15. 依赖与配置 +- 抖音小程序基础库 ≥ 3.0;需启用激励视频广告能力。 +- 后端提供校验与生成接口;约定响应时间 ≤ 3s。 +- 配置文件 `config/index.ts`:广告位 ID、接口基础地址、最大生成次数。 + +## 16. 待确认事项 +- 广告必须观看时长是否有具体数值要求(默认以 `isEnded` 判断)。 +- AI 接口返回释义字段是否稳定,若缺失由兜底。 +- 是否需要埋点统计广告完成率与收藏次数(暂未纳入:YAGNI)。 + +## 17. 里程碑 +- **M1**:完成项目脚手架、页面路由与表单控件。 +- **M2**:接入校验与生成接口,贯通广告流程。 +- **M3**:实现收藏、本地限额逻辑并完成联调、测试用例。 + +> 设计遵循 KISS(简洁流程)、DRY(统一状态与文案)、YAGNI(仅落地当前需求),页面与服务模块职责清晰满足 SOLID。 + diff --git a/NamingAssistant/app.js b/NamingAssistant/app.js new file mode 100644 index 0000000..98ad08d --- /dev/null +++ b/NamingAssistant/app.js @@ -0,0 +1,9 @@ +const { initStore } = require("./store/namingStore"); + +App({ + onLaunch() { + initStore(); + }, + onShow() {}, + globalData: {} +}); diff --git a/NamingAssistant/app.json b/NamingAssistant/app.json new file mode 100644 index 0000000..0f5e9bf --- /dev/null +++ b/NamingAssistant/app.json @@ -0,0 +1,13 @@ +{ + "pages": [ + "pages/home/index", + "pages/result/index", + "pages/favorites/index" + ], + "window": { + "navigationBarTitleText": "取名小程序", + "navigationBarBackgroundColor": "#ffffff", + "navigationBarTextStyle": "black", + "backgroundTextStyle": "light" + } +} diff --git a/NamingAssistant/app.ttss b/NamingAssistant/app.ttss new file mode 100644 index 0000000..24d831b --- /dev/null +++ b/NamingAssistant/app.ttss @@ -0,0 +1,37 @@ +page { + background: radial-gradient(120% 120% at 50% 0%, #1b2735 0%, #0b1023 55%, #05060f 100%); + color: #f5f0d7; + font-family: "PingFang SC", "Microsoft YaHei", "Helvetica Neue", Arial, sans-serif; + min-height: 100vh; +} + +text { + color: #f5f0d7; +} + +button.primary { + background: linear-gradient(135deg, #c474ff 0%, #5bc0f8 100%); + color: #0f1224; + border-radius: 28px; + font-weight: 600; + letter-spacing: 2px; +} + +button.primary:disabled { + background: rgba(255, 255, 255, 0.16); + color: rgba(255, 255, 255, 0.4); +} + +button.secondary { + background: rgba(255, 255, 255, 0.12); + color: #f7edd8; + border-radius: 999px; + border: 1px solid rgba(255, 255, 255, 0.2); + font-weight: 500; + letter-spacing: 2px; +} + +radio { + transform: scale(0.92); + color: #ff6a6a; +} diff --git a/NamingAssistant/config/index.js b/NamingAssistant/config/index.js new file mode 100644 index 0000000..150f52d --- /dev/null +++ b/NamingAssistant/config/index.js @@ -0,0 +1,7 @@ +const config = { + apiBaseUrl: "http://127.0.0.1:9291", + maxDailyQuota: 10, + adUnitId: "" +}; + +module.exports = config; diff --git a/NamingAssistant/constants/messages.js b/NamingAssistant/constants/messages.js new file mode 100644 index 0000000..4daae3e --- /dev/null +++ b/NamingAssistant/constants/messages.js @@ -0,0 +1,7 @@ +module.exports = { + INVALID_SURNAME: "请输入合法姓氏", + FORM_INCOMPLETE: "请完整填写必填项", + WATCH_AD_TO_CONTINUE: "观看完整广告后才能生成姓名", + QUOTA_EXCEEDED: "今日生成次数已用尽,请明日再试", + GENERATION_FAILED: "生成失败,请稍后再试" +}; diff --git a/NamingAssistant/pages/favorites/index.js b/NamingAssistant/pages/favorites/index.js new file mode 100644 index 0000000..57bf610 --- /dev/null +++ b/NamingAssistant/pages/favorites/index.js @@ -0,0 +1,38 @@ +const namingStore = require("../../store/namingStore"); + +function formatDisplayTime(isoString) { + if (!isoString) { + return ""; + } + try { + const date = new Date(isoString); + const yyyy = date.getFullYear(); + const mm = `${date.getMonth() + 1}`.padStart(2, "0"); + const dd = `${date.getDate()}`.padStart(2, "0"); + const hh = `${date.getHours()}`.padStart(2, "0"); + const mi = `${date.getMinutes()}`.padStart(2, "0"); + return `${yyyy}-${mm}-${dd} ${hh}:${mi}`; + } catch (error) { + return isoString; + } +} + +Page({ + data: { + favorites: [] + }, + onShow() { + namingStore.loadFavorites(); + const { favorites } = namingStore.getState(); + const enriched = favorites.map((item) => ({ + ...item, + displayTime: formatDisplayTime(item.createdAt) + })); + this.setData({ favorites: enriched }); + }, + handleDelete(event) { + const { id } = event.currentTarget.dataset; + namingStore.removeFavorite(id); + this.onShow(); + } +}); diff --git a/NamingAssistant/pages/favorites/index.json b/NamingAssistant/pages/favorites/index.json new file mode 100644 index 0000000..d8ae3a7 --- /dev/null +++ b/NamingAssistant/pages/favorites/index.json @@ -0,0 +1,3 @@ +{ + "navigationBarTitleText": "我的收藏" +} diff --git a/NamingAssistant/pages/favorites/index.ttml b/NamingAssistant/pages/favorites/index.ttml new file mode 100644 index 0000000..e83cbc9 --- /dev/null +++ b/NamingAssistant/pages/favorites/index.ttml @@ -0,0 +1,37 @@ + + + + + + + + + 珍藏阁 + 收录曾经触动心弦的灵感姓名,可随时回望取舍 + + + + 尚未收藏姓名,去玄名殿占卜一卦吧。 + + + + + + + {{item.name}} + + + {{item.meaning}} + 收藏于 {{item.displayTime}} + + + + diff --git a/NamingAssistant/pages/favorites/index.ttss b/NamingAssistant/pages/favorites/index.ttss new file mode 100644 index 0000000..f4ecd65 --- /dev/null +++ b/NamingAssistant/pages/favorites/index.ttss @@ -0,0 +1,146 @@ +.page { + position: relative; + min-height: 100vh; + overflow: hidden; +} + +.glow-layer { + position: absolute; + inset: 0; + pointer-events: none; +} + +.glow { + position: absolute; + border-radius: 50%; + filter: blur(90px); + opacity: 0.5; + animation: float 18s ease-in-out infinite; +} + +.glow-one { + width: 320px; + height: 320px; + top: -120px; + left: 10px; + background: radial-gradient(circle, rgba(255, 210, 160, 0.85), rgba(255, 210, 160, 0)); +} + +.glow-two { + width: 280px; + height: 280px; + bottom: -120px; + right: -40px; + background: radial-gradient(circle, rgba(125, 214, 255, 0.8), rgba(125, 214, 255, 0)); + animation-delay: 5s; +} + +@keyframes float { + 0% { + transform: translate3d(0, 0, 0); + } + 50% { + transform: translate3d(30px, -25px, 0); + } + 100% { + transform: translate3d(0, 0, 0); + } +} + +.content { + position: relative; + z-index: 2; + height: 100vh; + padding: 48px 32px 120px; + box-sizing: border-box; + display: flex; + flex-direction: column; + gap: 24px; +} + +.headline { + text-align: center; + display: flex; + flex-direction: column; + gap: 8px; +} + +.title { + font-size: 28px; + letter-spacing: 10px; + text-indent: 10px; + color: #fce8d2; + text-shadow: 0 0 16px rgba(255, 208, 145, 0.4); +} + +.subtitle { + font-size: 13px; + color: rgba(255, 244, 227, 0.75); + line-height: 1.6; +} + +.empty { + margin-top: 120px; + text-align: center; + color: rgba(255, 244, 227, 0.74); + font-size: 14px; + letter-spacing: 2px; +} + +.favorite-card { + position: relative; + background: linear-gradient(135deg, rgba(22, 28, 54, 0.85), rgba(32, 38, 68, 0.75)); + border-radius: 24px; + padding: 26px 24px; + border: 1px solid rgba(255, 255, 255, 0.08); + overflow: hidden; + box-shadow: 0 12px 28px rgba(10, 14, 28, 0.45); + display: flex; + flex-direction: column; + gap: 14px; +} + +.card-glow { + position: absolute; + inset: 0; + background: radial-gradient(circle at 20% 20%, rgba(255, 210, 160, 0.45), transparent 60%); + opacity: 0.6; + pointer-events: none; +} + +.favorite-header { + display: flex; + justify-content: space-between; + align-items: center; +} + +.name { + font-size: 22px; + font-weight: 600; + letter-spacing: 6px; + color: #fdf0da; + z-index: 1; +} + +.delete-button { + border-radius: 20px; + padding: 0 18px; + background: rgba(255, 255, 255, 0.12); + color: #fbe6ce; + border: 1px solid rgba(255, 255, 255, 0.18); + z-index: 1; +} + +.meaning { + font-size: 14px; + color: rgba(255, 244, 227, 0.8); + line-height: 1.8; + z-index: 1; +} + +.timestamp { + font-size: 12px; + color: rgba(255, 244, 227, 0.5); + letter-spacing: 2px; + z-index: 1; +} diff --git a/NamingAssistant/pages/home/index.js b/NamingAssistant/pages/home/index.js new file mode 100644 index 0000000..6d6f208 --- /dev/null +++ b/NamingAssistant/pages/home/index.js @@ -0,0 +1,182 @@ +const namingStore = require("../../store/namingStore"); +const messages = require("../../constants/messages"); +const { validateSurname, generateName } = require("../../services/namingService"); +const { showRewardedVideoAd } = require("../../utils/adService"); +const config = require("../../config/index"); + +function showToast(title) { + if (typeof tt === "undefined" || !tt.showToast) { + console.warn("Toast:", title); + return; + } + tt.showToast({ + title, + icon: "none", + duration: 2000 + }); +} + +Page({ + data: { + surname: "", + surnameError: "", + gender: "male", + birthDate: "", + birthTime: "", + nameLength: "double", + isSubmitting: false, + quotaRemaining: null + }, + onLoad() { + const { form, quota } = namingStore.getState(); + this.setData({ + surname: form.surname, + gender: form.gender, + birthDate: form.birthDate, + birthTime: form.birthTime, + nameLength: form.nameLength, + quotaRemaining: config.maxDailyQuota - quota.count + }); + }, + handleSurnameInput(event) { + const value = (event.detail.value || "").trim(); + this.setData({ + surname: value, + surnameError: "" + }); + namingStore.setForm({ surname: value }); + }, + handleSurnameBlur() { + const surname = this.data.surname; + if (!surname) { + return; + } + validateSurname(surname) + .then((response) => { + if (!response || response.isValid === false) { + showToast(messages.INVALID_SURNAME); + this.setData({ + surname: "", + surnameError: messages.INVALID_SURNAME + }); + namingStore.setForm({ surname: "" }); + } else { + this.setData({ surnameError: "" }); + } + }) + .catch(() => { + showToast(messages.INVALID_SURNAME); + this.setData({ + surname: "", + surnameError: messages.INVALID_SURNAME + }); + namingStore.setForm({ surname: "" }); + }); + }, + handleGenderChange(event) { + const value = event.detail.value; + this.setData({ gender: value }); + namingStore.setForm({ gender: value }); + }, + handleBirthDateChange(event) { + const value = event.detail.value; + this.setData({ birthDate: value }); + namingStore.setForm({ birthDate: value }); + }, + handleBirthTimeChange(event) { + const value = event.detail.value; + this.setData({ birthTime: value }); + namingStore.setForm({ birthTime: value }); + }, + handleNameLengthChange(event) { + const value = event.detail.value; + this.setData({ nameLength: value }); + namingStore.setForm({ nameLength: value }); + }, + handleGoFavorites() { + if (typeof tt !== "undefined" && tt.navigateTo) { + tt.navigateTo({ url: "/pages/favorites/index" }); + } + }, + validateForm() { + const { surname, gender, birthDate, nameLength } = this.data; + if (!surname || !gender || !birthDate || !nameLength) { + showToast(messages.FORM_INCOMPLETE); + return false; + } + if (!namingStore.canGenerateToday()) { + showToast(messages.QUOTA_EXCEEDED); + return false; + } + return true; + }, + updateQuotaState() { + const { quota } = namingStore.getState(); + this.setData({ + quotaRemaining: Math.max(config.maxDailyQuota - quota.count, 0) + }); + }, + handleGenerate() { + if (this.data.isSubmitting) { + return; + } + if (!this.validateForm()) { + return; + } + this.setData({ isSubmitting: true }); + showRewardedVideoAd() + .then(() => this.generateName()) + .catch((error) => { + if (error && error.code === "ad_not_completed") { + showToast(messages.WATCH_AD_TO_CONTINUE); + } else { + showToast("广告加载失败,请稍后重试"); + } + this.setData({ isSubmitting: false }); + }); + }, + generateName() { + const payload = { + surname: this.data.surname, + gender: this.data.gender, + birthDate: this.data.birthDate, + birthTime: this.data.birthTime || "", + nameLength: this.data.nameLength + }; + generateName(payload) + .then((response) => { + const results = response && Array.isArray(response.results) ? response.results : []; + if (!results.length) { + showToast(messages.GENERATION_FAILED); + return; + } + const normalizedResults = results.reduce((accumulator, item) => { + const name = item.name || item.Name || ""; + if (!name) { + return accumulator; + } + accumulator.push({ + name, + meaning: item.meaning || item.Meaning || "" + }); + return accumulator; + }, []); + if (!normalizedResults.length) { + showToast(messages.GENERATION_FAILED); + return; + } + namingStore.setResults(normalizedResults); + namingStore.incrementQuota(); + this.updateQuotaState(); + if (typeof tt !== "undefined" && tt.navigateTo) { + tt.navigateTo({ url: "/pages/result/index" }); + } + }) + .catch(() => { + showToast(messages.GENERATION_FAILED); + }) + .finally(() => { + this.setData({ isSubmitting: false }); + }); + } +}); diff --git a/NamingAssistant/pages/home/index.json b/NamingAssistant/pages/home/index.json new file mode 100644 index 0000000..47ae26a --- /dev/null +++ b/NamingAssistant/pages/home/index.json @@ -0,0 +1,3 @@ +{ + "navigationBarTitleText": "取名" +} diff --git a/NamingAssistant/pages/home/index.ttml b/NamingAssistant/pages/home/index.ttml new file mode 100644 index 0000000..152e33e --- /dev/null +++ b/NamingAssistant/pages/home/index.ttml @@ -0,0 +1,91 @@ + + + + + + + + + + 玄名殿 + 循星辰八卦,揽乾坤灵意,为你凝练天赐之名 + + + + + + 姓氏 + + {{surnameError}} + + + + 性别 + + + + + + + + + 出生日期 + + {{birthDate}} + + + + 具体时间 + + + {{birthTime || '请选择(可选)'}} + + + + + + + 名字字数 + + + + + + + + + + + + + + + diff --git a/NamingAssistant/pages/home/index.ttss b/NamingAssistant/pages/home/index.ttss new file mode 100644 index 0000000..9fa1e71 --- /dev/null +++ b/NamingAssistant/pages/home/index.ttss @@ -0,0 +1,223 @@ +.page { + position: relative; + min-height: 100vh; + overflow: hidden; +} + +.glow-layer { + position: absolute; + inset: 0; + pointer-events: none; +} + +.glow { + position: absolute; + border-radius: 50%; + filter: blur(110px); + opacity: 0.55; + animation: float 22s ease-in-out infinite; +} + +.glow-one { + width: 320px; + height: 320px; + top: -140px; + left: -80px; + background: radial-gradient(circle, rgba(255, 212, 164, 0.9), rgba(255, 198, 134, 0.12)); +} + +.glow-two { + width: 260px; + height: 260px; + bottom: 160px; + right: -100px; + background: radial-gradient(circle, rgba(122, 154, 255, 0.85), rgba(22, 36, 82, 0.05)); + animation-delay: 6s; +} + +.glow-three { + width: 280px; + height: 280px; + bottom: -160px; + left: 40px; + background: radial-gradient(circle, rgba(197, 134, 255, 0.85), rgba(52, 24, 80, 0.05)); + animation-delay: 12s; +} + +@keyframes float { + 0% { + transform: translate3d(0, 0, 0); + } + 50% { + transform: translate3d(42px, -30px, 0); + } + 100% { + transform: translate3d(0, 0, 0); + } +} + +.content { + position: relative; + z-index: 2; + height: 100vh; + padding: 18px 12px 10px; + box-sizing: border-box; + display: flex; + flex-direction: column; + gap: 20px; +} + +.title-block { + text-align: center; + display: flex; + flex-direction: column; + gap: 6px; +} + +.title { + font-size: 34px; + letter-spacing: 16px; + text-indent: 16px; + color: #fdf0dd; + text-shadow: 0 10px 26px rgba(255, 196, 132, 0.45); +} + +.subtitle { + font-size: 14px; + color: rgba(255, 244, 227, 0.76); + line-height: 1.7; +} + +.favorites-button { + margin: 10px auto 0; + padding: 0 28px; + width: 50%; + height: 30px; + line-height: 30px; + font-size: 12px; + background: linear-gradient(90deg, rgba(255, 230, 188, 0.22), rgba(143, 169, 255, 0.18)); + border: 1px solid rgba(255, 255, 255, 0.16); + box-shadow: 0 6px 14px rgba(16, 18, 40, 0.3); +} + +.form-card { + background: linear-gradient(180deg, rgba(25, 31, 58, 0.9) 0%, rgba(15, 21, 40, 0.74) 100%); + border-radius: 24px; + padding: 22px 22px; + border: 1px solid rgba(255, 255, 255, 0.06); + box-shadow: 0 18px 36px rgba(8, 12, 24, 0.38); + display: flex; + flex-direction: column; + gap: 20px; +} + +.form-item { + display: flex; + flex-direction: column; + gap: 12px; +} + +.dual { + flex-direction: row; + gap: 14px; +} + +.dual-item { + flex: 1; + display: flex; + flex-direction: column; + gap: 12px; +} + +.label { + font-size: 15px; + color: rgba(255, 244, 227, 0.9); + letter-spacing: 4px; +} + +.input, +.picker-value { + background: rgba(9, 15, 33, 0.82); + border-radius: 14px; + padding: 14px 16px; + font-size: 15px; + color: #f8f2dd; + border: 1px solid rgba(82, 110, 166, 0.34); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06); + transition: border 0.2s ease, box-shadow 0.2s ease; +} + +.input:focus, +.picker-value:active { + border-color: rgba(255, 193, 135, 0.65); + box-shadow: 0 0 18px rgba(255, 193, 135, 0.35); +} + +.placeholder-text, +.picker-value.placeholder { + color: rgba(255, 244, 227, 0.46); +} + +.radio-group { + display: flex; + gap: 14px; +} + +.radio-option { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 16px; + border-radius: 14px; + background: rgba(16, 24, 46, 0.7); + border: 1px solid rgba(255, 255, 255, 0.06); + color: rgba(255, 244, 227, 0.82); + transition: transform 0.2s ease, border 0.2s ease, background 0.2s ease; +} + +.radio-option.selected { + border-color: rgba(255, 124, 148, 0.65); + background: rgba(255, 118, 148, 0.16); + box-shadow: 0 12px 20px rgba(255, 118, 148, 0.2); + color: #ffe9e9; +} + +.radio-hover { + transform: translateY(-2px); +} + +.quota { + text-align: center; + font-size: 13px; + color: rgba(255, 244, 227, 0.65); + letter-spacing: 2px; +} + +.quota-number { + font-size: 18px; + font-weight: 600; + color: #ffcda9; + margin-left: 8px; +} + +.generate-button { + align-self: stretch; + height: 52px; + line-height: 52px; + font-size: 16px; + border-radius: 26px; + margin: 4px 6px 12px; + background: linear-gradient(135deg, #c374ff 0%, #6ba9ff 52%, #ff7bd1 100%); + box-shadow: 0 18px 32px rgba(120, 92, 255, 0.35); + letter-spacing: 3px; +} + +.action-container { + padding-bottom: 8px; +} + +.error-text { + color: #ff9e9b; + font-size: 12px; + letter-spacing: 1px; +} diff --git a/NamingAssistant/pages/result/index.js b/NamingAssistant/pages/result/index.js new file mode 100644 index 0000000..b4e7f3a --- /dev/null +++ b/NamingAssistant/pages/result/index.js @@ -0,0 +1,54 @@ +const namingStore = require("../../store/namingStore"); + +function showToast(title) { + if (typeof tt === "undefined" || !tt.showToast) { + console.warn("Toast:", title); + return; + } + tt.showToast({ + title, + icon: "none", + duration: 2000 + }); +} + +Page({ + data: { + results: [] + }, + onLoad() { + const { results } = namingStore.getState(); + if (!results || !results.length) { + showToast("\u6682\u65e0\u7ed3\u679c\uff0c\u8bf7\u5148\u751f\u6210\u59d3\u540d"); + setTimeout(() => { + if (typeof tt !== "undefined" && tt.navigateBack) { + tt.navigateBack(); + } + }, 1000); + return; + } + this.setData({ results }); + }, + handleFavorite(event) { + const { name, meaning } = event.currentTarget.dataset; + const item = { + name, + meaning + }; + const saved = namingStore.addFavorite(item); + if (saved) { + showToast("\u5df2\u6536\u85cf"); + } else { + showToast("\u6536\u85cf\u5931\u8d25"); + } + }, + handleBack() { + if (typeof tt !== "undefined" && tt.navigateBack) { + tt.navigateBack(); + return; + } + if (typeof tt !== "undefined" && tt.redirectTo) { + tt.redirectTo({ url: "/pages/home/index" }); + } + } +}); diff --git a/NamingAssistant/pages/result/index.json b/NamingAssistant/pages/result/index.json new file mode 100644 index 0000000..3a2269f --- /dev/null +++ b/NamingAssistant/pages/result/index.json @@ -0,0 +1,3 @@ +{ + "navigationBarTitleText": "生成结果" +} diff --git a/NamingAssistant/pages/result/index.ttml b/NamingAssistant/pages/result/index.ttml new file mode 100644 index 0000000..c73723e --- /dev/null +++ b/NamingAssistant/pages/result/index.ttml @@ -0,0 +1,40 @@ + + + + + + + + + 灵签揭示 + 从星宿生辰推演的五重吉名,择其心有所向 + + + + 暂无生成结果,请返回主页重新召唤。 + + + + + 轻触收藏,铭记心仪之名 + + + + {{item.name}} + + + {{item.meaning}} + + + + + + diff --git a/NamingAssistant/pages/result/index.ttss b/NamingAssistant/pages/result/index.ttss new file mode 100644 index 0000000..7bed4f8 --- /dev/null +++ b/NamingAssistant/pages/result/index.ttss @@ -0,0 +1,148 @@ +.page { + position: relative; + min-height: 100vh; + overflow: hidden; +} + +.glow-layer { + position: absolute; + inset: 0; + pointer-events: none; +} + +.glow { + position: absolute; + border-radius: 50%; + filter: blur(90px); + opacity: 0.5; + animation: float 20s ease-in-out infinite; +} + +.glow-one { + width: 260px; + height: 260px; + top: -80px; + right: -40px; + background: radial-gradient(circle, rgba(84, 198, 255, 0.8), rgba(84, 198, 255, 0)); +} + +.glow-two { + width: 300px; + height: 300px; + bottom: -140px; + left: -60px; + background: radial-gradient(circle, rgba(214, 139, 255, 0.8), rgba(214, 139, 255, 0)); + animation-delay: 6s; +} + +@keyframes float { + 0% { + transform: translate3d(0, 0, 0); + opacity: 0.4; + } + 50% { + transform: translate3d(40px, -30px, 0); + opacity: 0.7; + } + 100% { + transform: translate3d(0, 0, 0); + opacity: 0.4; + } +} +.content { + position: relative; + z-index: 2; + height: 100vh; + padding: 18px 12px 10px; + box-sizing: border-box; + display: flex; + flex-direction: column; + gap: 20px; +} +.headline { + text-align: center; + display: flex; + flex-direction: column; + gap: 10px; +} + +.title { + font-size: 28px; + letter-spacing: 8px; + text-indent: 8px; + color: #fbe6ce; + text-shadow: 0 0 16px rgba(255, 196, 120, 0.35); +} + +.subtitle { + font-size: 13px; + color: rgba(255, 244, 227, 0.78); + line-height: 1.6; +} + +.tip { + font-size: 14px; + color: rgba(255, 244, 227, 0.7); + letter-spacing: 4px; + text-align: center; + margin-bottom: 8px; +} + +.result-card { + display: flex; + flex-direction: column; + gap: 10px; + background: linear-gradient(135deg, rgba(16, 22, 46, 0.86), rgba(36, 44, 80, 0.76)); + border-radius: 18px; + padding: 18px 20px; + margin-bottom: 12px; + border: 1px solid rgba(255, 255, 255, 0.12); + box-shadow: 0 18px 32px rgba(8, 12, 24, 0.45); + backdrop-filter: blur(16px); +} + +.result-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.name { + font-size: 20px; + font-weight: 600; + color: #fdf0da; + flex: 1; +} + +.collect-button { + height: 32px; + line-height: 32px; + padding: 0 22px; + border-radius: 20px; + background: rgba(255, 255, 255, 0.12); + color: #fbe6ce; + border: 1px solid rgba(255, 255, 255, 0.18); +} + +.meaning { + font-size: 14px; + color: rgba(255, 244, 227, 0.78); + line-height: 1.8; +} + +.back-button { + width: 100%; + border-radius: 24px; + margin-top: 16px; +} + +.empty { + display: flex; + flex-direction: column; + align-items: center; + gap: 18px; + margin-top: 80px; + color: rgba(255, 244, 227, 0.8); + font-size: 14px; +} diff --git a/NamingAssistant/project.config.json b/NamingAssistant/project.config.json new file mode 100644 index 0000000..20aea0a --- /dev/null +++ b/NamingAssistant/project.config.json @@ -0,0 +1 @@ +{"miniprogramRoot":"./","projectname":"NamingAssistant","description":"取名小程序","appid":"tta82ade973724953d01","compileType":"miniprogram","setting":{"urlCheck":false,"es6":true,"postcss":true,"minified":true},"condition":{}} \ No newline at end of file diff --git a/NamingAssistant/services/namingService.js b/NamingAssistant/services/namingService.js new file mode 100644 index 0000000..80d2d29 --- /dev/null +++ b/NamingAssistant/services/namingService.js @@ -0,0 +1,43 @@ +const config = require("../config/index"); + +function post(endpoint, data) { + return new Promise((resolve, reject) => { + if (typeof tt === "undefined" || !tt.request) { + reject(new Error("request_api_unavailable")); + return; + } + tt.request({ + url: `${config.apiBaseUrl}${endpoint}`, + method: "POST", + data, + timeout: 5000, + success(response) { + const { statusCode, data: payload } = response; + if (statusCode >= 200 && statusCode < 300) { + resolve(payload); + return; + } + const error = new Error("request_failed"); + error.statusCode = statusCode; + error.data = payload; + reject(error); + }, + fail(error) { + reject(error); + } + }); + }); +} + +function validateSurname(surname) { + return post("/api/naming/validate-surname", { surname }); +} + +function generateName(payload) { + return post("/api/naming/generate", payload); +} + +module.exports = { + validateSurname, + generateName +}; diff --git a/NamingAssistant/sitemap.json b/NamingAssistant/sitemap.json new file mode 100644 index 0000000..2af63a9 --- /dev/null +++ b/NamingAssistant/sitemap.json @@ -0,0 +1,9 @@ +{ + "desc": "页面路径配置", + "rules": [ + { + "action": "allow", + "page": "*" + } + ] +} diff --git a/NamingAssistant/store/namingStore.js b/NamingAssistant/store/namingStore.js new file mode 100644 index 0000000..66b094c --- /dev/null +++ b/NamingAssistant/store/namingStore.js @@ -0,0 +1,166 @@ +const config = require("../config/index"); +const messages = require("../constants/messages"); +const { formatDate } = require("../utils/date"); + +const STORAGE_KEYS = { + favorites: "naming:favorites", + quotaPrefix: "naming:quota:" +}; + +function createDefaultForm() { + return { + surname: "", + gender: "male", + birthDate: formatDate(new Date()), + birthTime: "", + nameLength: "double" + }; +} + +const state = { + form: createDefaultForm(), + results: [], + favorites: [], + quota: { + date: "", + count: 0 + }, + lastError: "" +}; + +function getQuotaStorageKey(dateStr) { + return `${STORAGE_KEYS.quotaPrefix}${dateStr.replace(/-/g, "")}`; +} + +function safeGetStorage(key, fallback) { + if (typeof tt === "undefined" || !tt.getStorageSync) { + return fallback; + } + try { + const value = tt.getStorageSync(key); + return value === undefined || value === null ? fallback : value; + } catch (error) { + console.warn(`getStorageSync failed for key ${key}`, error); + return fallback; + } +} + +function safeSetStorage(key, value) { + if (typeof tt === "undefined" || !tt.setStorageSync) { + return; + } + try { + tt.setStorageSync(key, value); + } catch (error) { + console.warn(`setStorageSync failed for key ${key}`, error); + state.lastError = error.message || "storage_error"; + } +} + +function syncQuota() { + const today = formatDate(new Date()); + const storageKey = getQuotaStorageKey(today); + const storedCount = safeGetStorage(storageKey, 0); + const count = typeof storedCount === "number" ? storedCount : 0; + state.quota = { + date: today, + count + }; +} + +function incrementQuota() { + const today = formatDate(new Date()); + if (state.quota.date !== today) { + syncQuota(); + } + const nextCount = state.quota.count + 1; + state.quota = { + date: today, + count: nextCount + }; + safeSetStorage(getQuotaStorageKey(today), nextCount); +} + +function canGenerateToday() { + const today = formatDate(new Date()); + if (state.quota.date !== today) { + syncQuota(); + } + return state.quota.count < config.maxDailyQuota; +} + +function setForm(partial) { + state.form = Object.assign({}, state.form, partial); +} + +function resetForm() { + state.form = createDefaultForm(); +} + +function setResults(results) { + state.results = Array.isArray(results) ? results : []; +} + +function clearResults() { + state.results = []; +} + +function loadFavorites() { + const stored = safeGetStorage(STORAGE_KEYS.favorites, []); + state.favorites = Array.isArray(stored) ? stored : []; +} + +function saveFavorites() { + safeSetStorage(STORAGE_KEYS.favorites, state.favorites); +} + +function addFavorite(item) { + if (!item || !item.name) { + state.lastError = messages.GENERATION_FAILED; + return null; + } + const timestamp = Date.now(); + const newItem = { + id: `${item.name}_${timestamp}`, + name: item.name, + meaning: item.meaning || "", + createdAt: new Date(timestamp).toISOString() + }; + const existingIndex = state.favorites.findIndex((fav) => fav.name === item.name); + if (existingIndex >= 0) { + state.favorites.splice(existingIndex, 1, newItem); + } else { + state.favorites.unshift(newItem); + } + saveFavorites(); + return newItem; +} + +function removeFavorite(id) { + const nextList = state.favorites.filter((favorite) => favorite.id !== id); + state.favorites = nextList; + saveFavorites(); +} + +function getState() { + return state; +} + +function initStore() { + syncQuota(); + loadFavorites(); +} + +module.exports = { + initStore, + getState, + setForm, + resetForm, + setResults, + clearResults, + canGenerateToday, + incrementQuota, + addFavorite, + removeFavorite, + loadFavorites +}; diff --git a/NamingAssistant/utils/adService.js b/NamingAssistant/utils/adService.js new file mode 100644 index 0000000..aa54ffb --- /dev/null +++ b/NamingAssistant/utils/adService.js @@ -0,0 +1,61 @@ +const config = require("../config/index"); + +function showRewardedVideoAd() { + return new Promise((resolve, reject) => { + if (!config.adUnitId) { + console.warn("adUnitId 未配置,跳过广告流程(仅限开发环境)"); + resolve("ad_skipped"); + return; + } + if (typeof tt === "undefined" || !tt.createRewardedVideoAd) { + reject(new Error("rewarded_ad_unavailable")); + return; + } + const ad = tt.createRewardedVideoAd({ + adUnitId: config.adUnitId + }); + + const cleanup = () => { + if (!ad) { + return; + } + if (ad.offLoad) { + ad.offLoad(); + } + if (ad.offError) { + ad.offError(); + } + if (ad.offClose) { + ad.offClose(); + } + }; + + ad.onError((error) => { + cleanup(); + reject(error); + }); + + ad.onClose((result = {}) => { + cleanup(); + if (result.isEnded) { + resolve(); + } else { + const err = new Error("ad_not_completed"); + err.code = "ad_not_completed"; + reject(err); + } + }); + + ad + .load() + .then(() => ad.show()) + .catch((error) => { + cleanup(); + reject(error); + }); + }); +} + +module.exports = { + showRewardedVideoAd +}; diff --git a/NamingAssistant/utils/date.js b/NamingAssistant/utils/date.js new file mode 100644 index 0000000..166d3f5 --- /dev/null +++ b/NamingAssistant/utils/date.js @@ -0,0 +1,21 @@ +function pad(value) { + return value < 10 ? `0${value}` : `${value}`; +} + +function formatDate(date) { + const year = date.getFullYear(); + const month = pad(date.getMonth() + 1); + const day = pad(date.getDate()); + return `${year}-${month}-${day}`; +} + +function formatTime(date) { + const hours = pad(date.getHours()); + const minutes = pad(date.getMinutes()); + return `${hours}:${minutes}`; +} + +module.exports = { + formatDate, + formatTime +};