取名小程序开发

This commit is contained in:
cjd
2025-11-05 00:22:09 +08:00
parent 271d207611
commit 6268c82b9c
24 changed files with 1508 additions and 0 deletions

166
NamingAssistant/Spec.md Normal file
View File

@@ -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<void>`。
- 广告位 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。

9
NamingAssistant/app.js Normal file
View File

@@ -0,0 +1,9 @@
const { initStore } = require("./store/namingStore");
App({
onLaunch() {
initStore();
},
onShow() {},
globalData: {}
});

13
NamingAssistant/app.json Normal file
View File

@@ -0,0 +1,13 @@
{
"pages": [
"pages/home/index",
"pages/result/index",
"pages/favorites/index"
],
"window": {
"navigationBarTitleText": "取名小程序",
"navigationBarBackgroundColor": "#ffffff",
"navigationBarTextStyle": "black",
"backgroundTextStyle": "light"
}
}

37
NamingAssistant/app.ttss Normal file
View File

@@ -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;
}

View File

@@ -0,0 +1,7 @@
const config = {
apiBaseUrl: "http://127.0.0.1:9291",
maxDailyQuota: 10,
adUnitId: ""
};
module.exports = config;

View File

@@ -0,0 +1,7 @@
module.exports = {
INVALID_SURNAME: "请输入合法姓氏",
FORM_INCOMPLETE: "请完整填写必填项",
WATCH_AD_TO_CONTINUE: "观看完整广告后才能生成姓名",
QUOTA_EXCEEDED: "今日生成次数已用尽,请明日再试",
GENERATION_FAILED: "生成失败,请稍后再试"
};

View File

@@ -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();
}
});

View File

@@ -0,0 +1,3 @@
{
"navigationBarTitleText": "我的收藏"
}

View File

@@ -0,0 +1,37 @@
<view class="page">
<view class="glow-layer">
<view class="glow glow-one"></view>
<view class="glow glow-two"></view>
</view>
<scroll-view class="content" scroll-y="true">
<view class="headline">
<text class="title">珍藏阁</text>
<text class="subtitle">收录曾经触动心弦的灵感姓名,可随时回望取舍</text>
</view>
<view class="empty" tt:if="{{!favorites.length}}">
<text>尚未收藏姓名,去玄名殿占卜一卦吧。</text>
</view>
<block tt:for="{{favorites}}" tt:for-item="item" tt:for-index="index" tt:key="id">
<view class="favorite-card">
<view class="card-glow"></view>
<view class="favorite-header">
<text class="name">{{item.name}}</text>
<button
size="mini"
type="default"
data-id="{{item.id}}"
bindtap="handleDelete"
class="delete-button"
>
移除
</button>
</view>
<text class="meaning">{{item.meaning}}</text>
<text class="timestamp">收藏于 {{item.displayTime}}</text>
</view>
</block>
</scroll-view>
</view>

View File

@@ -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;
}

View File

@@ -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 });
});
}
});

View File

@@ -0,0 +1,3 @@
{
"navigationBarTitleText": "取名"
}

View File

@@ -0,0 +1,91 @@
<view class="page">
<view class="glow-layer">
<view class="glow glow-one"></view>
<view class="glow glow-two"></view>
<view class="glow glow-three"></view>
</view>
<scroll-view class="content" scroll-y="true">
<view class="title-block">
<text class="title">玄名殿</text>
<text class="subtitle">循星辰八卦,揽乾坤灵意,为你凝练天赐之名</text>
</view>
<view class="form-card">
<view class="form-item">
<text class="label">姓氏</text>
<input
class="input"
maxlength="2"
placeholder="请输入1-2个汉字"
placeholder-class="placeholder-text"
value="{{surname}}"
bindinput="handleSurnameInput"
bindblur="handleSurnameBlur"
/>
<text class="error-text" tt:if="{{surnameError}}">{{surnameError}}</text>
</view>
<view class="form-item">
<text class="label">性别</text>
<radio-group class="radio-group" bindchange="handleGenderChange">
<label class="radio-option {{gender === 'male' ? 'selected' : ''}}" hover-class="radio-hover">
<radio color="#ff6a6a" value="male" checked="{{gender === 'male'}}" />男
</label>
<label class="radio-option {{gender === 'female' ? 'selected' : ''}}" hover-class="radio-hover">
<radio color="#ff6a6a" value="female" checked="{{gender === 'female'}}" />女
</label>
</radio-group>
</view>
<view class="form-item dual">
<view class="dual-item">
<text class="label">出生日期</text>
<picker mode="date" value="{{birthDate}}" bindchange="handleBirthDateChange">
<view class="picker-value">{{birthDate}}</view>
</picker>
</view>
<view class="dual-item">
<text class="label">具体时间</text>
<picker mode="time" value="{{birthTime}}" bindchange="handleBirthTimeChange">
<view class="picker-value {{birthTime ? '' : 'placeholder'}}">
{{birthTime || '请选择(可选)'}}
</view>
</picker>
</view>
</view>
<view class="form-item">
<text class="label">名字字数</text>
<radio-group class="radio-group" bindchange="handleNameLengthChange">
<label class="radio-option {{nameLength === 'single' ? 'selected' : ''}}" hover-class="radio-hover">
<radio color="#ff6a6a" value="single" checked="{{nameLength === 'single'}}" />单名
</label>
<label class="radio-option {{nameLength === 'double' ? 'selected' : ''}}" hover-class="radio-hover">
<radio color="#ff6a6a" value="double" checked="{{nameLength === 'double'}}" />双名
</label>
</radio-group>
</view>
</view>
<!-- <view class="quota" tt:if="{{quotaRemaining !== null}}">
今日剩余次数:<text class="quota-number">{{quotaRemaining}}</text>
</view> -->
<view class="action-container">
<button
class="primary generate-button"
type="primary"
bindtap="handleGenerate"
loading="{{isSubmitting}}"
disabled="{{isSubmitting}}"
>
✨ 召唤天命之名 ✨
</button>
<button class="secondary favorites-button" bindtap="handleGoFavorites">
珍名阁
</button>
</view>
</scroll-view>
</view>

View File

@@ -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;
}

View File

@@ -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" });
}
}
});

View File

@@ -0,0 +1,3 @@
{
"navigationBarTitleText": "生成结果"
}

View File

@@ -0,0 +1,40 @@
<view class="page">
<view class="glow-layer">
<view class="glow glow-one"></view>
<view class="glow glow-two"></view>
</view>
<scroll-view class="content" scroll-y="true">
<view class="headline">
<text class="title">&#x7075;&#x7B7E;&#x63ED;&#x793A;</text>
<text class="subtitle">&#x4ECE;&#x661F;&#x5BBF;&#x751F;&#x8FB0;&#x63A8;&#x6F14;&#x7684;&#x4E94;&#x91CD;&#x5409;&#x540D;&#xFF0C;&#x62E9;&#x5176;&#x5FC3;&#x6709;&#x6240;&#x5411;</text>
</view>
<view class="empty" tt:if="{{!results.length}}">
<text>&#x6682;&#x65E0;&#x751F;&#x6210;&#x7ED3;&#x679C;&#xFF0C;&#x8BF7;&#x8FD4;&#x56DE;&#x4E3B;&#x9875;&#x91CD;&#x65B0;&#x53EC;&#x5524;&#x3002;</text>
<button class="primary back-button" bindtap="handleBack">&#x8FD4;&#x56DE;&#x4E3B;&#x9875;</button>
</view>
<view tt:if="{{results.length}}">
<view class="tip">&#x8F7B;&#x89E6;&#x6536;&#x85CF;&#xFF0C;&#x94ED;&#x8BB0;&#x5FC3;&#x4EEA;&#x4E4B;&#x540D;</view>
<block tt:for="{{results}}" tt:for-index="index" tt:for-item="item" tt:key="name">
<view class="result-card">
<view class="result-header">
<text class="name">{{item.name}}</text>
<button
class="collect-button"
size="mini"
bindtap="handleFavorite"
data-name="{{item.name}}"
data-meaning="{{item.meaning}}"
>
&#x6536;&#x85CF;
</button>
</view>
<text class="meaning">{{item.meaning}}</text>
</view>
</block>
<button class="primary back-button" bindtap="handleBack">&#x518D;&#x5360;&#x4E00;&#x5366;</button>
</view>
</scroll-view>
</view>

View File

@@ -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;
}

View File

@@ -0,0 +1 @@
{"miniprogramRoot":"./","projectname":"NamingAssistant","description":"取名小程序","appid":"tta82ade973724953d01","compileType":"miniprogram","setting":{"urlCheck":false,"es6":true,"postcss":true,"minified":true},"condition":{}}

View File

@@ -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
};

View File

@@ -0,0 +1,9 @@
{
"desc": "页面路径配置",
"rules": [
{
"action": "allow",
"page": "*"
}
]
}

View File

@@ -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
};

View File

@@ -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
};

View File

@@ -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
};