feat(miniapp): scaffold WeChat mini program MVP
This commit is contained in:
@@ -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 });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"navigationBarTitleText": "TKMind 聊天"
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"navigationBarTitleText": "登录 TKMind"
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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' });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"navigationBarTitleText": "历史会话"
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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)}`
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"navigationBarTitleText": "MindSpace 页面"
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
Page({
|
||||
data: {
|
||||
url: ''
|
||||
},
|
||||
|
||||
onLoad(query) {
|
||||
this.setData({ url: decodeURIComponent(query.url || '') });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"navigationBarTitleText": "页面预览"
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<web-view src="{{url}}" />
|
||||
Reference in New Issue
Block a user