Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ jobs:
- name: Sensitive scan
run: node ./scripts/check-no-sensitive.mjs

- name: Smoke (10 pages + 6 AI labels + integer fen)
- name: Smoke (11 pages incl. V0.8 PR-1 week-plan + 6 AI labels + integer fen)
run: node ./scripts/smoke-weapp.mjs

# lint / build:weapp / test 需要真实安装 @rockcent/platform 与 Taro 才能跑;
Expand Down
35 changes: 35 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,38 @@
- 真实第三方商品 API
- 真实朋友圈 / 视频号发布
- 礼有方 Pro / 礼有方云 / 开放平台

## v0.8.0-pr1 · 2026-07-06

礼有方(GiftPilot)微信小程序 V0.8 AI 经营版 / PR-1:周经营计划 + 节日机会。

### Features
- **周经营计划**:新增 `pages/week-plan/`,7 天格子 + KPI Bar + 今日任务高亮 + 进度统计
- **节日机会**:AI 对话首页"今日节日机会"卡片 + 新组件 `components/festival-card/`
- 6 个节日 mock 数据(夏日打卡 / 建军 / 父亲 / 七夕 / 中秋 / 双十一)按天数过滤 [0, 30] 范围内
- 7 天 × 21 任务 mock 数据(含 done / in_progress / pending / 跳过 4 状态,4 种场景类型)

### Engineering
- `ai-store` 增 `weekPlan` + `festivalOpportunities` 字段 + `loadWeekPlan` + `loadFestivalOpportunities` action
- 2 个 mock service(`services/ai/plan.ts` + `services/ai/festival.ts`)— 接口形态严格 = ProviderCallResult
- 类型扩展(`WeekPlan` / `WeekPlanDay` / `DayTask` / `SceneKind` / `FestivalOpportunity` / `FestivalRelation` + `computeDaysAway()`)
- 路由注册:`app.config.ts` 新增 `pages/week-plan/index`
- smoke 加第 11 页(10 旧 + 1 新 V0.8)

### Testing
- 13 个新增 jest 单测:
- `__tests__/services/plan.test.ts`(7 tests 含 `computeDaysAway`)
- `__tests__/services/festival.test.ts`(3 tests)
- `__tests__/stores/ai-store-v08.test.ts`(4 tests 含 reset 验证)
- 总测试数:14 (旧) + 13 (新) = **27 tests in 7 suites 全部 PASS**

### Documentation
- `.specify/features/v0-8-ai-operation/{spec.md, plan.md, tasks.md}` 路线图三件套(commit `3d90045`)
- `README.md` 新增 V0.8 路线图段(PR-1 / PR-2 / PR-3 / PR-4)
- 本 CHANGELOG

### Out of scope(本 PR-1 不做)
- 风格学习(PR-2)
- 发布时间优化 + 多平台内容(PR-3)
- 商品替换提醒(PR-4)
- 真实微信云开发 / 真实 LLM / 真实商品库(阶段二)
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,21 @@ pnpm run check:no-sensitive # 敏感信息扫描
- ✅ 4 个 smoke 脚本(平台 pin / 敏感扫描 / 整数金额 / 6 大 AI 标签)
- ✅ GitHub Actions 最小 CI
- ✅ H5 视觉预览(10 张截图,见 `dist-screenshots/README.md`)

## V0.8 路线图 AI 经营版(.specify/features/v0-8-ai-operation/)

- ✅ **PR-1** 周经营计划 + 节日机会(commit `a1c9962` 起;本页 11 张截图)
- 新页面 `pages/week-plan/`:7 天格子 + KPI Bar + 今日高亮 + 节日卡
- 新组件 `components/festival-card/`:emoji + 倒计时 + 推荐语 + 关系 chips
- `ai-store` 增 `weekPlan` + `festivalOpportunities` 字段 + 2 个 `load*` action
- 2 个 mock service(接口形态 = 真实 ProviderCallResult)
- 2 个 mock 数据(7 天 21 任务 + 6 个节日 0–128 天分布)
- index 页加 2 张 V0.8 卡片
- 13 个新增 jest 单测(plan / festival / store-v08)
- ⏳ PR-2 风格学习
- ⏳ PR-3 发布时间优化 + 多平台内容
- ⏳ PR-4 商品替换提醒

- ⏳ 阶段二:接通真实微信云开发后端 + 真实 LLM(OpenAI 兼容)+ 真实商品库
- ⏳ 阶段三:礼有方 Pro
- ⏳ 阶段四:礼有方云(SaaS)
Expand Down
43 changes: 43 additions & 0 deletions __tests__/services/festival.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { festivalMock } from '../../src/services/ai/festival';

describe('services/ai/festival (V0.8 PR-1)', () => {
test('returns only festivals within [0, 30] days, sorted asc by daysAway', async () => {
const today = new Date('2026-07-06T08:00:00.000Z');
const res = await festivalMock.fetchFestivalOpportunities(today);
expect(res.ok).toBe(true);
expect(res.error).toBeNull();
const list = res.data!;
expect(Array.isArray(list)).toBe(true);
expect(list.length).toBeGreaterThan(0);
for (const f of list) {
expect(f.daysAway).toBeGreaterThanOrEqual(0);
expect(f.daysAway).toBeLessThanOrEqual(30);
}
for (let i = 1; i < list.length; i += 1) {
expect(list[i].daysAway).toBeGreaterThanOrEqual(list[i - 1].daysAway);
}
});

test('excludes festivals more than 30 days away and any past dates', async () => {
const today = new Date('2026-07-06T08:00:00.000Z');
const res = await festivalMock.fetchFestivalOpportunities(today);
const list = res.data!;
// mid-autumn (10-03) = 89 days, must be excluded
// double-eleven (11-11) = 128 days, must be excluded
expect(list.find((f) => f.id === 'mid-autumn-2026')).toBeUndefined();
expect(list.find((f) => f.id === 'double-eleven-2026')).toBeUndefined();
});

test('each festival has emoji + name + aiPitch + recommendedRelations + hotGifts', async () => {
const today = new Date('2026-07-06T08:00:00.000Z');
const res = await festivalMock.fetchFestivalOpportunities(today);
for (const f of res.data!) {
expect(typeof f.emoji === 'string' && f.emoji.length > 0).toBe(true);
expect(typeof f.name === 'string' && f.name.length > 0).toBe(true);
expect(typeof f.aiPitch === 'string' && f.aiPitch.length > 0).toBe(true);
expect(Array.isArray(f.recommendedRelations)).toBe(true);
expect(f.recommendedRelations.length).toBeGreaterThan(0);
expect(Array.isArray(f.hotGifts)).toBe(true);
}
});
});
58 changes: 58 additions & 0 deletions __tests__/services/plan.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { planMock } from '../../src/services/ai/plan';
import { computeDaysAway } from '../../src/types';

describe('services/ai/plan (V0.8 PR-1)', () => {
test('fetchWeekPlan returns ok with valid 7-day plan', async () => {
const res = await planMock.fetchWeekPlan();
expect(res.ok).toBe(true);
expect(res.error).toBeNull();
expect(res.trace_id).toMatch(/^tr_/);
const wp = res.data!;
expect(wp.weekId).toMatch(/^\d{4}-W\d{2}$/);
expect(wp.days).toHaveLength(7);
expect(wp.weeklyKpi.publishTarget).toBeGreaterThan(0);
expect(Number.isInteger(wp.weeklyKpi.commissionTargetFen)).toBe(true);
});

test('every day has at least one task, total ≥ 7', async () => {
const res = await planMock.fetchWeekPlan();
const wp = res.data!;
for (const day of wp.days) {
expect(day.tasks.length).toBeGreaterThanOrEqual(1);
}
const total = wp.days.reduce((acc, d) => acc + d.tasks.length, 0);
expect(total).toBeGreaterThanOrEqual(7);
});

test('task status is one of allowed values, scene is allowed', async () => {
const res = await planMock.fetchWeekPlan();
const scenes: Array<string> = [];
const statuses: Array<string> = [];
for (const day of res.data!.days) for (const t of day.tasks) {
scenes.push(t.scene);
statuses.push(t.status);
}
for (const s of scenes) {
expect(['birthday', 'festival', 'campaign', 'routine']).toContain(s);
}
for (const st of statuses) {
expect(['pending', 'in_progress', 'done', 'skipped']).toContain(st);
}
});
});

describe('computeDaysAway (V0.8 PR-1 utils)', () => {
test('returns 0 for today', () => {
const today = new Date('2026-07-06T08:00:00.000Z');
expect(computeDaysAway('2026-07-06T00:00:00.000Z', today)).toBe(0);
});
test('returns positive for future dates', () => {
const today = new Date('2026-07-06T08:00:00.000Z');
expect(computeDaysAway('2026-08-08T00:00:00.000Z', today)).toBe(33);
expect(computeDaysAway('2026-07-12T00:00:00.000Z', today)).toBe(6);
});
test('returns negative for past dates', () => {
const today = new Date('2026-07-06T08:00:00.000Z');
expect(computeDaysAway('2026-05-10T00:00:00.000Z', today)).toBeLessThan(0);
});
});
40 changes: 40 additions & 0 deletions __tests__/stores/ai-store-v08.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { useAIStore } from '../../src/stores/ai-store';

describe('stores/ai-store V0.8 PR-1 fields', () => {
beforeEach(() => useAIStore.getState().reset());

test('initial state for V0.8 fields: weekPlan=null, festivals=[]', () => {
const s = useAIStore.getState();
expect(s.weekPlan).toBeNull();
expect(s.festivalOpportunities).toEqual([]);
});

test('loadWeekPlan populates weekPlan from mock', async () => {
const res = await useAIStore.getState().loadWeekPlan();
expect(res.ok).toBe(true);
const wp = useAIStore.getState().weekPlan;
expect(wp).not.toBeNull();
expect(wp!.days).toHaveLength(7);
});

test('loadFestivalOpportunities populates festivals sorted by daysAway', async () => {
const res = await useAIStore.getState().loadFestivalOpportunities();
expect(res.ok).toBe(true);
const list = useAIStore.getState().festivalOpportunities;
expect(list.length).toBeGreaterThan(0);
for (let i = 1; i < list.length; i += 1) {
expect(list[i].daysAway).toBeGreaterThanOrEqual(list[i - 1].daysAway);
}
});

test('reset() clears V0.8 fields without touching legacy fields', () => {
// first populate
useAIStore.setState({
weekPlan: { weekId: '2026-W99', startDate: '2099-01-01', days: [], weeklyKpi: { publishTarget: 0, commissionTargetFen: 0 } },
festivalOpportunities: [{ id: 'x', name: 'X', emoji: 'x', startDate: '2099-01-01', endDate: '2099-01-01', daysAway: 0, recommendedRelations: ['mother'], hotGifts: [], aiPitch: 'x' }]
});
useAIStore.getState().reset();
expect(useAIStore.getState().weekPlan).toBeNull();
expect(useAIStore.getState().festivalOpportunities).toEqual([]);
});
});
Binary file modified dist-screenshots/home.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added dist-screenshots/week-plan.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 2 additions & 1 deletion scripts/screenshot-h5.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@ const PAGES = [
{ name: 'orders', path: 'pages/orders/index' },
{ name: 'review', path: 'pages/review/index' },
{ name: 'next-plan', path: 'pages/next-plan/index' },
{ name: 'memory', path: 'pages/memory/index' }
{ name: 'memory', path: 'pages/memory/index' },
{ name: 'week-plan', path: 'pages/week-plan/index' }
];

async function main() {
Expand Down
5 changes: 3 additions & 2 deletions scripts/smoke-weapp.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -67,12 +67,13 @@ const pages = [
'src/pages/orders/index.tsx',
'src/pages/review/index.tsx',
'src/pages/next-plan/index.tsx',
'src/pages/memory/index.tsx'
'src/pages/memory/index.tsx',
'src/pages/week-plan/index.tsx'
];
for (const p of pages) {
if (!fs.existsSync(path.join(ROOT, p))) bad('missing page entry: ' + p);
}
if (!errs.some((e) => e.startsWith('missing page'))) ok('all 10 page entries present');
if (!errs.some((e) => e.startsWith('missing page'))) ok('all 11 page entries present (V0.6×10 + V0.8 PR-1 week-plan)');

const sixLabels = ['选对礼', '谈得好', '做得美', '发得准', '看得清', '改得好'];
const bannerPath = path.join(ROOT, 'src/components/ai-six-banner/ai-six-banner.tsx');
Expand Down
3 changes: 2 additions & 1 deletion src/app.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ export default defineAppConfig({
'pages/orders/index',
'pages/review/index',
'pages/next-plan/index',
'pages/memory/index'
'pages/memory/index',
'pages/week-plan/index'
],
window: {
backgroundTextStyle: 'dark',
Expand Down
51 changes: 51 additions & 0 deletions src/components/festival-card/festival-card.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
.gp-festival-card {
background: var(--brand-bg-card);
border-radius: var(--radius-card);
padding: var(--sp-5);
box-shadow: var(--shadow-card);
border: 1px solid rgba(35, 178, 109, 0.15);
display: flex;
flex-direction: column;
gap: var(--sp-3);
}

.gp-festival-card__head {
display: flex;
align-items: center;
gap: var(--sp-3);
}

.gp-festival-card__emoji {
font-size: 32px;
line-height: 1;
}

.gp-festival-card__title {
display: flex;
flex-direction: column;
gap: var(--sp-1);
}

.gp-festival-card__name {
font-size: var(--fs-title);
font-weight: 600;
color: var(--brand-ink);
}

.gp-festival-card__when {
font-size: var(--fs-caption);
color: var(--brand-green);
font-weight: 500;
}

.gp-festival-card__pitch {
font-size: var(--fs-body);
color: var(--brand-muted);
line-height: 1.5;
}

.gp-festival-card__rel-row {
display: flex;
flex-wrap: wrap;
gap: var(--sp-1);
}
47 changes: 47 additions & 0 deletions src/components/festival-card/festival-card.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { View, Text } from '@tarojs/components';
import type { FestivalOpportunity } from '../../types';
import './festival-card.scss';

export interface FestivalCardProps {
festival: FestivalOpportunity;
onPick?: (festival: FestivalOpportunity) => void;
}

const RELATION_LABEL: Record<string, string> = {
mother: '妈妈',
wife: '老婆',
grandma: '奶奶',
father: '爸爸',
friend: '朋友',
colleague: '同事',
kids: '孩子',
partner: '伴侣'
};

export function FestivalCard({ festival, onPick }: FestivalCardProps) {
const handleClick = () => {
if (onPick) onPick(festival);
};

return (
<View className="gp-festival-card" onClick={handleClick}>
<View className="gp-festival-card__head">
<Text className="gp-festival-card__emoji">{festival.emoji}</Text>
<View className="gp-festival-card__title">
<Text className="gp-festival-card__name">{festival.name}</Text>
<Text className="gp-festival-card__when">
{festival.daysAway === 0 ? '就今天' : `还差 ${festival.daysAway} 天`}
</Text>
</View>
</View>
<Text className="gp-festival-card__pitch">{festival.aiPitch}</Text>
<View className="gp-festival-card__rel-row">
{festival.recommendedRelations.slice(0, 3).map((r) => (
<View key={r} className="gp-pill gp-pill--outline">
<Text>{RELATION_LABEL[r] ?? r}</Text>
</View>
))}
</View>
</View>
);
}
Loading
Loading