feat(miniapp): scaffold WeChat mini program MVP

This commit is contained in:
john
2026-07-11 08:46:08 +08:00
parent 32fb2cdeaf
commit 9e5a59b7d9
29 changed files with 948 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
node_modules/
miniprogram_npm/
project.private.config.json
*.local
+22
View File
@@ -0,0 +1,22 @@
# TKMind WeChat Mini Program
This directory contains the native WeChat Mini Program client. It is intentionally isolated from the H5/backend code so development can proceed without changing existing services.
## Current MVP
- Native WeChat login entry via `wx.login`.
- H5 account/password login fallback through `/auth/login`.
- Chat submission through `/api/agent/runs`.
- Agent run polling through `/api/agent/runs/:runId`.
- Session detail refresh through `/api/sessions/:sessionId`.
- Session list through `/api/sessions`.
- MindSpace page list through `/api/mindspace/v1/pages`.
- Page preview through Mini Program `web-view`.
## Backend Assumptions
The H5 APIs are reused as-is. Native Mini Program login posts the `wx.login` code to `MINIAPP_LOGIN_PATH` in `utils/config.js`; the current default is `/auth/wechat-miniapp/login`. If that backend endpoint is not available yet, use the account/password login fallback during local development.
## Isolation Rule
Mini Program work should stay inside `miniapp/`. Do not modify backend, H5, Memory, MindSpace, or production release files for this MVP unless explicitly approved.
+16
View File
@@ -0,0 +1,16 @@
const { getStoredSession, setApiBaseUrl } = require('./utils/api');
const { API_BASE_URL } = require('./utils/config');
App({
globalData: {
user: null,
},
onLaunch() {
setApiBaseUrl(API_BASE_URL);
const session = getStoredSession();
if (session?.user) {
this.globalData.user = session.user;
}
},
});
+36
View File
@@ -0,0 +1,36 @@
{
"pages": [
"pages/chat/index",
"pages/login/index",
"pages/sessions/index",
"pages/space/index",
"pages/webview/index"
],
"window": {
"navigationBarTitleText": "TKMind",
"navigationBarBackgroundColor": "#101820",
"navigationBarTextStyle": "white",
"backgroundColor": "#f5f7fb"
},
"tabBar": {
"color": "#64748b",
"selectedColor": "#0f766e",
"backgroundColor": "#ffffff",
"borderStyle": "black",
"list": [
{
"pagePath": "pages/chat/index",
"text": "聊天"
},
{
"pagePath": "pages/sessions/index",
"text": "会话"
},
{
"pagePath": "pages/space/index",
"text": "页面"
}
]
},
"sitemapLocation": "sitemap.json"
}
+45
View File
@@ -0,0 +1,45 @@
page {
min-height: 100%;
background: #f5f7fb;
color: #132028;
font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Helvetica Neue", sans-serif;
}
.page {
min-height: 100vh;
box-sizing: border-box;
padding: 28rpx;
}
.panel {
background: #ffffff;
border: 1rpx solid #dbe3eb;
border-radius: 16rpx;
box-shadow: 0 10rpx 30rpx rgba(15, 23, 42, 0.06);
}
.muted {
color: #64748b;
}
.primary-button {
height: 88rpx;
border-radius: 12rpx;
background: #0f766e;
color: #ffffff;
font-weight: 700;
line-height: 88rpx;
}
.secondary-button {
height: 78rpx;
border-radius: 12rpx;
background: #e7f3f1;
color: #0f766e;
font-weight: 700;
line-height: 78rpx;
}
.danger-text {
color: #b42318;
}
+97
View File
@@ -0,0 +1,97 @@
const {
checkAuth,
createAgentRun,
getAgentRun,
loadSession
} = require('../../utils/api');
const { AGENT_RUN_MAX_POLLS, AGENT_RUN_POLL_MS } = require('../../utils/config');
const { createUserMessage, normalizeMessages } = require('../../utils/messages');
function delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
Page({
data: {
sessionId: '',
messages: [],
draft: '',
loading: false,
scrollIntoView: ''
},
async onShow() {
try {
const status = await checkAuth();
if (!status?.authenticated) {
wx.redirectTo({ url: '/pages/login/index' });
}
} catch {
wx.redirectTo({ url: '/pages/login/index' });
}
},
handleInput(event) {
this.setData({ draft: event.detail.value });
},
scrollToBottom() {
const index = Math.max(0, this.data.messages.length - 1);
this.setData({ scrollIntoView: `msg-${index}` });
},
async sendMessage() {
const text = this.data.draft.trim();
if (!text || this.data.loading) return;
const userMessage = createUserMessage(text);
const nextMessages = [
...this.data.messages,
{ id: userMessage.id, role: 'user', text }
];
this.setData({ messages: nextMessages, draft: '', loading: true });
this.scrollToBottom();
try {
const payload = {
session_id: this.data.sessionId || null,
user_message: userMessage,
source: 'wechat-miniapp'
};
const run = await createAgentRun(payload);
await this.waitForRun(run?.id || run?.runId);
} catch (error) {
wx.showToast({ title: error?.message || '发送失败', icon: 'none' });
} finally {
this.setData({ loading: false });
this.scrollToBottom();
}
},
async waitForRun(runId) {
if (!runId) throw new Error('后台任务未返回 runId');
let sessionId = this.data.sessionId;
for (let i = 0; i < AGENT_RUN_MAX_POLLS; i += 1) {
const run = await getAgentRun(runId);
if (run?.sessionId && run.sessionId !== sessionId) {
sessionId = run.sessionId;
this.setData({ sessionId });
}
if (run?.status === 'succeeded') {
if (sessionId) await this.refreshSession(sessionId);
return;
}
if (run?.status === 'failed') {
throw new Error(run.error || '后台任务失败');
}
await delay(AGENT_RUN_POLL_MS);
}
throw new Error('任务耗时较长,请稍后在会话中查看');
},
async refreshSession(sessionId) {
const detail = await loadSession(sessionId);
const messages = normalizeMessages(detail?.messages || detail?.data?.messages || []);
this.setData({ messages });
}
});
+3
View File
@@ -0,0 +1,3 @@
{
"navigationBarTitleText": "TKMind 聊天"
}
+22
View File
@@ -0,0 +1,22 @@
<view class="page chat-page">
<scroll-view class="messages" scroll-y scroll-into-view="{{scrollIntoView}}">
<view wx:for="{{messages}}" wx:key="id" id="msg-{{index}}" class="message {{item.role}}">
<text>{{item.text}}</text>
</view>
<view wx:if="{{loading}}" class="message assistant">
<text>正在思考...</text>
</view>
</scroll-view>
<view class="composer panel">
<textarea
class="input"
value="{{draft}}"
placeholder="输入你的问题"
auto-height
maxlength="2000"
bindinput="handleInput"
/>
<button class="send" loading="{{loading}}" bindtap="sendMessage">发送</button>
</view>
</view>
+68
View File
@@ -0,0 +1,68 @@
.chat-page {
display: flex;
flex-direction: column;
height: 100vh;
padding: 0;
}
.messages {
flex: 1;
box-sizing: border-box;
padding: 28rpx;
}
.message {
max-width: 84%;
margin-bottom: 20rpx;
padding: 22rpx 24rpx;
border-radius: 16rpx;
font-size: 29rpx;
line-height: 1.55;
white-space: pre-wrap;
}
.message.user {
margin-left: auto;
background: #0f766e;
color: #ffffff;
}
.message.assistant {
margin-right: auto;
background: #ffffff;
color: #132028;
border: 1rpx solid #dbe3eb;
}
.composer {
display: flex;
align-items: flex-end;
gap: 16rpx;
padding: 18rpx;
border-left: 0;
border-right: 0;
border-bottom: 0;
border-radius: 0;
}
.input {
flex: 1;
min-height: 72rpx;
max-height: 220rpx;
padding: 18rpx 20rpx;
border-radius: 12rpx;
background: #f8fafc;
box-sizing: border-box;
font-size: 28rpx;
}
.send {
width: 136rpx;
height: 72rpx;
line-height: 72rpx;
border-radius: 12rpx;
background: #0f766e;
color: #fff;
font-weight: 700;
font-size: 28rpx;
}
+57
View File
@@ -0,0 +1,57 @@
const {
loginWithPassword,
loginWithWechatCode
} = require('../../utils/api');
const { MINIAPP_LOGIN_PATH } = require('../../utils/config');
Page({
data: {
username: '',
password: '',
error: '',
wechatLoading: false,
passwordLoading: false
},
handleUsername(event) {
this.setData({ username: event.detail.value, error: '' });
},
handlePassword(event) {
this.setData({ password: event.detail.value, error: '' });
},
async handleWechatLogin() {
this.setData({ wechatLoading: true, error: '' });
try {
await loginWithWechatCode(MINIAPP_LOGIN_PATH);
wx.switchTab({ url: '/pages/chat/index' });
} catch (error) {
this.setData({
error:
error?.message ||
'微信登录暂不可用。若后端尚未开放小程序登录接口,请先使用 H5 账号登录。'
});
} finally {
this.setData({ wechatLoading: false });
}
},
async handlePasswordLogin() {
const username = this.data.username.trim();
const password = this.data.password;
if (!username || !password) {
this.setData({ error: '请输入用户名和密码' });
return;
}
this.setData({ passwordLoading: true, error: '' });
try {
await loginWithPassword(username, password);
wx.switchTab({ url: '/pages/chat/index' });
} catch (error) {
this.setData({ error: error?.message || '登录失败,请重试' });
} finally {
this.setData({ passwordLoading: false });
}
}
});
+3
View File
@@ -0,0 +1,3 @@
{
"navigationBarTitleText": "登录 TKMind"
}
+22
View File
@@ -0,0 +1,22 @@
<view class="page login-page">
<view class="brand">
<text class="brand-title">TKMind</text>
<text class="brand-subtitle">微信小程序工作台</text>
</view>
<view class="panel login-card">
<button class="primary-button" loading="{{wechatLoading}}" bindtap="handleWechatLogin">
微信一键登录
</button>
<view class="divider"><text>或使用 H5 账号</text></view>
<input class="field" placeholder="用户名 / 邮箱" value="{{username}}" bindinput="handleUsername" />
<input class="field" placeholder="密码" password value="{{password}}" bindinput="handlePassword" />
<button class="secondary-button" loading="{{passwordLoading}}" bindtap="handlePasswordLogin">
账号登录
</button>
<text wx:if="{{error}}" class="error">{{error}}</text>
</view>
</view>
+52
View File
@@ -0,0 +1,52 @@
.login-page {
display: flex;
flex-direction: column;
gap: 36rpx;
padding-top: 96rpx;
}
.brand {
display: flex;
flex-direction: column;
gap: 12rpx;
}
.brand-title {
font-size: 68rpx;
font-weight: 800;
color: #101820;
}
.brand-subtitle {
font-size: 28rpx;
color: #5b6b7b;
}
.login-card {
display: flex;
flex-direction: column;
gap: 24rpx;
padding: 32rpx;
}
.divider {
display: flex;
justify-content: center;
color: #94a3b8;
font-size: 24rpx;
}
.field {
height: 84rpx;
padding: 0 24rpx;
border: 1rpx solid #ccd6e0;
border-radius: 12rpx;
background: #f8fafc;
box-sizing: border-box;
}
.error {
color: #b42318;
font-size: 24rpx;
line-height: 1.5;
}
+45
View File
@@ -0,0 +1,45 @@
const { listSessions } = require('../../utils/api');
function formatTime(value) {
const timestamp = Number(value || 0);
if (!timestamp) return '';
const date = new Date(timestamp < 10_000_000_000 ? timestamp * 1000 : timestamp);
return `${date.getMonth() + 1}/${date.getDate()} ${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`;
}
Page({
data: {
sessions: [],
loading: false,
error: ''
},
onShow() {
this.loadSessions();
},
async loadSessions() {
this.setData({ loading: true, error: '' });
try {
const result = await listSessions({ limit: 30 });
const rows = result?.sessions || result?.data || result?.items || [];
this.setData({
sessions: rows.map((item) => ({
...item,
id: item.id || item.sessionId,
updatedAtText: formatTime(item.updatedAt || item.updated_at || item.created || item.createdAt)
}))
});
} catch (error) {
this.setData({ error: error?.message || '会话加载失败' });
} finally {
this.setData({ loading: false });
}
},
openSession(event) {
const id = event.currentTarget.dataset.id;
if (!id) return;
wx.switchTab({ url: '/pages/chat/index' });
}
});
+3
View File
@@ -0,0 +1,3 @@
{
"navigationBarTitleText": "历史会话"
}
+17
View File
@@ -0,0 +1,17 @@
<view class="page">
<view class="toolbar">
<text class="title">历史会话</text>
<button class="reload" loading="{{loading}}" bindtap="loadSessions">刷新</button>
</view>
<view wx:if="{{error}}" class="error">{{error}}</view>
<view wx:if="{{sessions.length === 0 && !loading}}" class="empty panel">
<text>暂无会话</text>
</view>
<view wx:for="{{sessions}}" wx:key="id" class="session panel" bindtap="openSession" data-id="{{item.id}}">
<text class="session-title">{{item.title || item.name || '未命名会话'}}</text>
<text class="session-meta">{{item.updatedAtText || ''}}</text>
</view>
</view>
+50
View File
@@ -0,0 +1,50 @@
.toolbar {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 24rpx;
}
.title {
font-size: 42rpx;
font-weight: 800;
}
.reload {
width: 132rpx;
height: 64rpx;
line-height: 64rpx;
border-radius: 12rpx;
background: #e7f3f1;
color: #0f766e;
font-size: 26rpx;
}
.session {
display: flex;
flex-direction: column;
gap: 10rpx;
padding: 24rpx;
margin-bottom: 18rpx;
}
.session-title {
font-size: 30rpx;
font-weight: 700;
}
.session-meta {
color: #64748b;
font-size: 24rpx;
}
.empty {
padding: 40rpx;
text-align: center;
color: #64748b;
}
.error {
margin-bottom: 20rpx;
color: #b42318;
}
+37
View File
@@ -0,0 +1,37 @@
const { listMindSpacePages } = require('../../utils/api');
Page({
data: {
pages: [],
loading: false,
error: ''
},
onShow() {
this.loadPages();
},
async loadPages() {
this.setData({ loading: true, error: '' });
try {
const result = await listMindSpacePages({ limit: 30 });
const pages = result?.data || result?.pages || [];
this.setData({ pages });
} catch (error) {
this.setData({ error: error?.message || '页面加载失败' });
} finally {
this.setData({ loading: false });
}
},
openPage(event) {
const url = event.currentTarget.dataset.url;
if (!url) {
wx.showToast({ title: '该页面暂无可打开链接', icon: 'none' });
return;
}
wx.navigateTo({
url: `/pages/webview/index?url=${encodeURIComponent(url)}`
});
}
});
+3
View File
@@ -0,0 +1,3 @@
{
"navigationBarTitleText": "MindSpace 页面"
}
+17
View File
@@ -0,0 +1,17 @@
<view class="page">
<view class="toolbar">
<text class="title">MindSpace</text>
<button class="reload" loading="{{loading}}" bindtap="loadPages">刷新</button>
</view>
<view wx:if="{{error}}" class="error">{{error}}</view>
<view wx:if="{{pages.length === 0 && !loading}}" class="empty panel">
<text>暂无页面</text>
</view>
<view wx:for="{{pages}}" wx:key="id" class="page-card panel" bindtap="openPage" data-url="{{item.publicUrl || item.previewUrl}}">
<text class="page-title">{{item.title || '未命名页面'}}</text>
<text class="page-meta">{{item.status || item.visibility || ''}}</text>
</view>
</view>
+50
View File
@@ -0,0 +1,50 @@
.toolbar {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 24rpx;
}
.title {
font-size: 42rpx;
font-weight: 800;
}
.reload {
width: 132rpx;
height: 64rpx;
line-height: 64rpx;
border-radius: 12rpx;
background: #e7f3f1;
color: #0f766e;
font-size: 26rpx;
}
.page-card {
display: flex;
flex-direction: column;
gap: 10rpx;
padding: 24rpx;
margin-bottom: 18rpx;
}
.page-title {
font-size: 30rpx;
font-weight: 700;
}
.page-meta {
color: #64748b;
font-size: 24rpx;
}
.empty {
padding: 40rpx;
text-align: center;
color: #64748b;
}
.error {
margin-bottom: 20rpx;
color: #b42318;
}
+9
View File
@@ -0,0 +1,9 @@
Page({
data: {
url: ''
},
onLoad(query) {
this.setData({ url: decodeURIComponent(query.url || '') });
}
});
+3
View File
@@ -0,0 +1,3 @@
{
"navigationBarTitleText": "页面预览"
}
+1
View File
@@ -0,0 +1 @@
<web-view src="{{url}}" />
+23
View File
@@ -0,0 +1,23 @@
{
"description": "TKMind WeChat Mini Program",
"packOptions": {
"ignore": [
{
"type": "folder",
"value": "node_modules"
}
]
},
"setting": {
"urlCheck": true,
"es6": true,
"enhance": true,
"postcss": true,
"minified": true
},
"compileType": "miniprogram",
"libVersion": "3.8.8",
"appid": "touristappid",
"projectname": "tkmind-miniapp",
"condition": {}
}
+8
View File
@@ -0,0 +1,8 @@
{
"rules": [
{
"action": "disallow",
"page": "*"
}
]
}
+188
View File
@@ -0,0 +1,188 @@
const SESSION_KEY = 'tkmind_session';
let apiBaseUrl = '';
let cookie = '';
function setApiBaseUrl(value) {
apiBaseUrl = String(value || '').replace(/\/$/, '');
}
function getStoredSession() {
const session = wx.getStorageSync(SESSION_KEY);
if (session && typeof session === 'object') {
cookie = session.cookie || cookie;
return session;
}
return null;
}
function saveSession(session) {
cookie = session?.cookie || cookie;
wx.setStorageSync(SESSION_KEY, session || {});
}
function clearSession() {
cookie = '';
wx.removeStorageSync(SESSION_KEY);
}
function normalizeSetCookie(headers = {}) {
const raw = headers['Set-Cookie'] || headers['set-cookie'];
if (!raw) return '';
if (Array.isArray(raw)) {
return raw.map((item) => String(item).split(';')[0]).join('; ');
}
return String(raw)
.split(/,(?=\s*[^;,]+=)/)
.map((item) => item.trim().split(';')[0])
.filter(Boolean)
.join('; ');
}
function request(path, options = {}) {
const url = /^https?:\/\//i.test(path) ? path : `${apiBaseUrl}${path}`;
const header = {
Accept: 'application/json',
...(options.body ? { 'Content-Type': 'application/json' } : {}),
...(cookie ? { Cookie: cookie } : {}),
...(options.header || {})
};
return new Promise((resolve, reject) => {
wx.request({
url,
method: options.method || 'GET',
data: options.body || undefined,
header,
timeout: options.timeout || 20000,
success(res) {
const nextCookie = normalizeSetCookie(res.header);
if (nextCookie) {
cookie = nextCookie;
const previous = getStoredSession() || {};
saveSession({ ...previous, cookie });
}
const status = Number(res.statusCode || 0);
if (status >= 200 && status < 300) {
resolve(res.data);
return;
}
const message =
res.data?.message ||
res.data?.error?.message ||
`请求失败 (${status || 'network'})`;
const error = new Error(message);
error.status = status;
error.body = res.data;
reject(error);
},
fail(err) {
reject(new Error(err?.errMsg || '网络请求失败'));
}
});
});
}
function portalRequest(path, options = {}) {
return request(path, options);
}
function apiRequest(path, options = {}) {
return request(`/api${path}`, options);
}
async function checkAuth() {
return portalRequest('/auth/status');
}
async function getMe() {
return portalRequest('/auth/me');
}
async function loginWithPassword(username, password) {
const result = await portalRequest('/auth/login', {
method: 'POST',
body: { username, password }
});
saveSession({ cookie, user: result?.user || result || null, loginType: 'password' });
return result;
}
function wxLogin() {
return new Promise((resolve, reject) => {
wx.login({
success(res) {
if (res.code) resolve(res.code);
else reject(new Error('微信登录未返回 code'));
},
fail(err) {
reject(new Error(err?.errMsg || '微信登录失败'));
}
});
});
}
async function loginWithWechatCode(loginPath) {
const code = await wxLogin();
const result = await portalRequest(loginPath, {
method: 'POST',
body: { code }
});
saveSession({ cookie, user: result?.user || result || null, loginType: 'wechat' });
return result;
}
async function logout() {
try {
await portalRequest('/auth/logout', { method: 'POST' });
} finally {
clearSession();
}
}
async function listSessions(options = {}) {
const limit = options.limit || 30;
const cursor = options.cursor ? `&cursor=${encodeURIComponent(options.cursor)}` : '';
return apiRequest(`/sessions?limit=${encodeURIComponent(limit)}${cursor}`);
}
async function loadSession(sessionId) {
return apiRequest(`/sessions/${encodeURIComponent(sessionId)}`);
}
async function createAgentRun(input) {
return apiRequest('/agent/runs', {
method: 'POST',
body: input,
timeout: 60000
});
}
async function getAgentRun(runId) {
return apiRequest(`/agent/runs/${encodeURIComponent(runId)}`);
}
async function listMindSpacePages(options = {}) {
const limit = options.limit || 30;
const offset = options.offset || 0;
return apiRequest(
`/mindspace/v1/pages?limit=${encodeURIComponent(limit)}&offset=${encodeURIComponent(offset)}`
);
}
module.exports = {
setApiBaseUrl,
getStoredSession,
saveSession,
clearSession,
checkAuth,
getMe,
loginWithPassword,
loginWithWechatCode,
logout,
listSessions,
loadSession,
createAgentRun,
getAgentRun,
listMindSpacePages
};
+6
View File
@@ -0,0 +1,6 @@
module.exports = {
API_BASE_URL: 'https://h5.tkmind.cn',
MINIAPP_LOGIN_PATH: '/auth/wechat-miniapp/login',
AGENT_RUN_POLL_MS: 1200,
AGENT_RUN_MAX_POLLS: 120
};
+41
View File
@@ -0,0 +1,41 @@
function createUserMessage(text) {
return {
id: `mp_${Date.now()}_${Math.random().toString(16).slice(2)}`,
role: 'user',
created: Math.floor(Date.now() / 1000),
content: [{ type: 'text', text }],
metadata: {
source: 'wechat-miniapp',
userVisible: true,
agentVisible: true,
displayText: text
}
};
}
function messageText(message) {
if (!message) return '';
if (typeof message.text === 'string') return message.text;
if (Array.isArray(message.content)) {
return message.content
.filter((part) => part && part.type === 'text')
.map((part) => part.text || '')
.join('');
}
return '';
}
function normalizeMessages(messages = []) {
return messages.map((message) => ({
id: message.id || `${message.role}_${message.created || Date.now()}`,
role: message.role || 'assistant',
text: messageText(message),
created: message.created || 0
}));
}
module.exports = {
createUserMessage,
messageText,
normalizeMessages
};