feat: 培训小程序(Taro+React,OPC AI 移动端)

- 页面:首页/活动/报名/测评/政策/流程/调研/我的
- 自定义 tabBar、骨架屏、后端驱动测评
This commit is contained in:
2026-08-23 22:32:39 +08:00
commit 1f65a1a7a8
51 changed files with 21830 additions and 0 deletions
+42
View File
@@ -0,0 +1,42 @@
import Taro from '@tarojs/taro';
import { cached } from './cache';
// API 基址
// - 开发:指向本地 web 后端(project.config.json 需 urlCheck:false
// - 生产:真实备案 HTTPS 域名(小程序要求)
const API_BASE = 'https://opc.pinesound.cn'; // 云超服 FastAPI 后端(备案 HTTPS 域名)
/**
* 统一请求封装(对齐 web 端 fetch 契约)
* 公开端点不鉴权;管理端点传 withAuth=true(带 token
*/
export function request(method, path, data = {}, withAuth = false) {
return new Promise((resolve, reject) => {
const header = { 'Content-Type': 'application/json' };
if (withAuth) {
const token = Taro.getStorageSync('pine_token');
if (token) header.Authorization = `Bearer ${token}`;
}
Taro.request({
url: `${API_BASE}${path}`,
method,
data,
header,
timeout: 10000, // 后端未启动时快速失败,避免界面一直"加载中"
success: (res) => {
const d = res.data || {};
if (res.statusCode >= 200 && res.statusCode < 300 && d.ok !== false) {
resolve(d);
} else {
reject(new Error(d.detail || d.error || '请求失败,请稍后重试'));
}
},
fail: () => reject(new Error('网络连接失败,请检查网络后重试'))
});
});
}
/** 带短缓存的请求:命中未过期直接返回(用于列表类接口,减少重复请求与"空→内容"跳变) */
export function cachedRequest(method, path, ttl = 30000, data = {}, withAuth = false) {
return cached(`req:${method}:${path}:${JSON.stringify(data)}`, ttl, () => request(method, path, data, withAuth));
}
+94
View File
@@ -0,0 +1,94 @@
import Taro from '@tarojs/taro';
import { request } from './api';
/* 用户端认证:手机号 + 短信验证码(与 web 端一致,见 小程序同步约束.md) */
export async function sendCode(phone) {
return request('POST', '/api/auth/send-code', { phone });
}
export async function phoneLogin(phone, code) {
const data = await request('POST', '/api/auth/phone-login', { phone, code });
if (data.token) {
Taro.setStorageSync('pine_token', data.token);
// 手机号登录账号即以手机号为账号,天然已绑定手机号(phoneBound=true),保持一致避免再次提示绑定
Taro.setStorageSync('pine_user', {
username: data.username,
name: data.name || data.username,
avatar: '',
phone: data.username,
phoneBound: true
});
}
return data;
}
export function isAuthed() {
return !!Taro.getStorageSync('pine_token');
}
export function getUser() {
return Taro.getStorageSync('pine_user') || null;
}
export function logout() {
Taro.removeStorageSync('pine_token');
Taro.removeStorageSync('pine_user');
}
/* ---------------- 微信登录 / 手机绑定 / 我的资料 ---------------- */
/** 微信一键登录:wx.login 拿 code(演示后端用 code 哈希做伪 openid),可携带昵称/头像 */
export async function wxLogin({ code, nickName, avatarUrl }) {
const data = await request('POST', '/api/auth/wx-login', { code, nickName, avatarUrl });
if (data.token) {
Taro.setStorageSync('pine_token', data.token);
Taro.setStorageSync('pine_user', {
username: data.username,
name: data.name || '微信用户',
avatar: data.avatar || '',
phone: '',
phoneBound: !!data.phoneBound
});
}
return data;
}
/** 绑定手机:验证码校验后把手机号挂到当前账号 */
export async function bindPhone(phone, code) {
const data = await request('POST', '/api/auth/bind-phone', { phone, code }, true);
if (data.ok) {
const u = getUser() || {};
Taro.setStorageSync('pine_user', { ...u, phone, phoneBound: true });
}
return data;
}
/** 拉取当前账号资料(刷新昵称/头像/手机绑定状态) */
export async function getMe() {
const data = await request('GET', '/api/auth/me', {}, true);
if (data.ok && data.user) {
const u = getUser() || {};
Taro.setStorageSync('pine_user', { ...u, ...data.user });
}
return data;
}
/** 微信官方手机号一键绑定(getPhoneNumber 回调 code */
export async function wxPhoneBind(code) {
const data = await request('POST', '/api/auth/wx-phone', { code }, true);
if (data.ok && data.phone) {
const u = getUser() || {};
Taro.setStorageSync('pine_user', { ...u, phone: data.phone, phoneBound: true });
}
return data;
}
/** 更新当前用户资料(昵称/头像 + 报名资料 status/topics/source */
export async function updateProfile(data) {
const r = await request('POST', '/api/auth/update-profile', data, true);
if (r.ok && r.user) {
const u = getUser() || {};
// 用后端返回的完整 user(含 nickname/avatar/phone/phoneBound/status/topics/source)覆盖内存资料
Taro.setStorageSync('pine_user', { ...u, ...r.user });
}
return r;
}
+72
View File
@@ -0,0 +1,72 @@
import { request, cachedRequest } from './api';
/* 报名字段契约(与 web 端 data/booking.js 保持一致,见 小程序同步约束.md) */
export const BK_STATUS_OPTIONS = ['上班族', '自由职业 / 副业', '创业者 / 小老板', '大学生 / 应届', '其他'];
export const BK_WANT_OPTIONS = ['公益课', '沙龙', '都可以'];
export const BK_TOPIC_OPTIONS = [
'副业 / 技能变现',
'民宿 / 文旅创业',
'咖啡 / 鲜花 / 农特产',
'AI 工具 / 智能体实操',
'数字游民',
'政策补贴 / 园区入驻',
'财税 / 注册合规',
'还没方向,想先了解'
];
export const BK_SOURCE_OPTIONS = ['公众号', '小红书', '短视频', '朋友推荐', '园区活动', '其他'];
/** 报名 = 需登录(Bearer token)+ 账号已绑手机号;报名信息由前端带 token 提交 */
export async function submitBooking(data) {
const r = await request('POST', '/api/bookings', data, true);
return r; // { id, username, name, auditStatus }
}
/** 可报名场次(公益课/沙龙) */
export async function getBookableEvents() {
return cachedRequest('GET', '/api/events?bookable=1');
}
/** 活动详情 */
export async function getEventDetail(id) {
return cachedRequest('GET', `/api/events/${id}`);
}
/** 我的报名列表(需登录) */
export async function getMyBookings() {
return request('GET', '/api/bookings/mine', {}, true);
}
/** 活动签到(需登录,同一报名只能签一次) */
export async function checkIn(bookingId) {
return request('POST', '/api/checkins', { bookingId }, true);
}
/* 手动解析 ISO 时间(iOS/微信对 new Date(ISO带时区) 兼容差,避免 Invalid Date */
export function parseIso(iso) {
if (!iso) return null;
const m = String(iso).match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2}))?(?:([+-])(\d{2}):(\d{2}))?/);
if (!m) {
const d = new Date(iso);
return isNaN(d.getTime()) ? null : d;
}
const [, Y, Mo, Da, H, Mi, S, sign, oh, om] = m;
const utc = Date.UTC(+Y, +Mo - 1, +Da, +H, +Mi, +(S || 0));
const offset = sign ? (sign === '+' ? 1 : -1) * (+(oh || 0) * 60 + +(om || 0)) * 60000 : 0;
return new Date(utc - offset);
}
export function fmtEvt(iso) {
const d = parseIso(iso);
if (!d) return '';
const p = (n) => String(n).padStart(2, '0');
const week = ['日', '一', '二', '三', '四', '五', '六'][d.getDay()];
return `${d.getMonth() + 1}${d.getDate()}日 周${week} ${p(d.getHours())}:${p(d.getMinutes())}`;
}
/** 查当前登录用户是否已报名某活动及其审核状态(详情页判断用) */
export async function getMyBookingForEvent(eventId) {
return request('GET', `/api/bookings/by-event/${eventId}`, {}, true);
}
+19
View File
@@ -0,0 +1,19 @@
/**
* 短缓存:内存 Map,按 key 缓存异步结果(TTL 毫秒)。
* 用于列表类接口(活动/题目/配置),减少重复请求与"空→内容"跳变。
* 仅前端缓存,不影响后端数据。
*/
const store = new Map();
export async function cached(key, ttl, fn) {
const hit = store.get(key);
if (hit && Date.now() - hit.t < ttl) return hit.data;
const data = await fn();
store.set(key, { t: Date.now(), data });
return data;
}
/** 清空全部缓存(登出/主动刷新时用) */
export function clearCache() {
store.clear();
}
+73
View File
@@ -0,0 +1,73 @@
/**
* home.js — 首页宣传页内容数据
* 与 web 端 website/src/data/home.js 保持同步(口径对齐营销总纲:
* 对外红线——公域只邀请到公益课/沙龙 + OPC 测评;付费课价格/交付物/效果卡数字不在首页出现)
*/
export const MK_HERO = {
badge: '一个人,也可以是一家公司',
headline: ['ONE PERSON', 'DESIGNED TO EVOLVE'],
subhead:
'我们落地在昆明市大学生创业园。每周有免费公益课和线下沙龙,讲透「一个人 + AI 怎么开一家公司」。先来听一场,交个朋友。',
ctaPrimary: { label: '预约公益课 / 沙龙', tab: '/pages/booking/index' },
ctaSecondary: { label: '先做 OPC 测评', tab: '/pages/test/index' }
};
export const MK_TRUST = [
{ value: '2', suffix: ' 场/周', label: '公益课 · 沙龙' },
{ value: '36', suffix: ' 期', label: '公益课主题库' },
{ value: '4', suffix: ' 方向', label: '沙龙小场深聊' },
{ value: '200', suffix: '+', label: '入驻创业者' }
];
/* MK_PRINCIPLE(痛点对比)已移除:首页不再展示。 */
export const MK_PRODUCTS = [
{
tag: '公益',
name: '公益课',
price: '免费',
period: '每周 1 场 · 主题轮换',
desc: '一场分享,带走一个值得做的方向,交个朋友。',
deliverables: ['6090 分钟主题认知', 'OPC典型案例分析', '扫码预约,无门槛'],
cta: '报名',
tab: '/pages/booking/index',
highlight: false
},
{
tag: '沙龙',
name: '交流沙龙',
price: '免费',
period: '每周 1 场 · 1230 人',
desc: '小场深度聊:主题深谈 + AI 现场演示 + 云南本地人脉面对面。',
deliverables: ['小场深度交流', 'AI 工具 / 智能体现场演示', '本地资源对接'],
cta: '预约',
tab: '/pages/booking/index',
highlight: true
}
];
export const MK_CORE = {
title: '我们怎么帮你:认识 → 方向 → 陪跑',
en: 'Know · Point · Run',
quote: '-·-·-·-',
items: [
{ step: '01', title: '认识', desc: '公益课 / 沙龙里看清「一个人 + AI 开公司」是怎么回事,做一次测评找到自己的倾向。', out: '得到:一个值得做的方向' },
{ step: '02', title: '方向', desc: '需要时,我们把你的资源、能力梳理成一条可执行的路(体系设计)。', out: '得到:一条可执行的路' },
{ step: '03', title: '陪跑', desc: '30 天每周复盘 + 答疑,陪你一起推进,落地到结果。', out: '得到:落地进展 / 结果' }
]
};
export const MK_OPENING = {
note: '免费公益课每周 1 场、沙龙每周 1 场,主题见公众号 / 社群每周预告。',
rows: [
{ tag: '公益', item: '公益课 · 每周 1 场', desc: '每周线下公益课,每月不定期线上主题交流' },
{ tag: '沙龙', item: '交流沙龙 · 每周 1 场', desc: '昆明 · 1230 人小场深聊' },
{ tag: '测评', item: 'OPC 创业测评 · 随时可测', desc: '5 分钟测出你的赛道 / 角色 / AI 协作方式' }
]
};
export const MK_SIGNUP = {
title: '下一步:从一次测评或一场公益课开始',
desc: '先花 5 分钟做 OPC 测评,或预约一场免费公益课 / 沙龙。'
};
+41
View File
@@ -0,0 +1,41 @@
/**
* tab.js — 自定义 tabBar 选中态同步
*
* 微信自定义 tabBar 时,每个 tab 页各自持有一个 custom-tab-bar 实例,
* 必须在页面显示时主动把选中下标同步给它。
*
* 注意:Taro.getTabBar(pageCtx) 需要传入「当前微信页面实例」
* (其内部调用 pageCtx.getTabBar().$taroInstances),无参调用会返回 undefined。
*/
import { useDidShow } from '@tarojs/taro';
import Taro from '@tarojs/taro';
function getTabBarInstance() {
const inst = Taro.getCurrentInstance();
const page = inst && inst.page;
if (page && typeof page.getTabBar === 'function') {
const bar = Taro.getTabBar(page);
return bar && (bar.then ? bar.then((b) => b) : bar);
}
return null;
}
export function useTabIndex(index) {
useDidShow(() => {
let tries = 0;
const sync = () => {
const bar = getTabBarInstance();
if (bar) {
if (bar.then) bar.then((b) => b && b.setState && b.setState({ selected: index }));
else if (bar.setState) bar.setState({ selected: index });
return;
}
// 首个 tab 页首次渲染时实例可能尚未就绪,重试几次
if (tries < 4) {
tries++;
Taro.nextTick(sync);
}
};
sync();
});
}