Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 04ef1c9105 | |||
| fe6d631ba1 | |||
| b17b2f18cb | |||
| 0fd34a5644 | |||
| 9e5a59b7d9 |
@@ -199,6 +199,10 @@ H5_ACCESS_PASSWORD=change-me
|
|||||||
# H5_WECHAT_OPEN_APP_ID=
|
# H5_WECHAT_OPEN_APP_ID=
|
||||||
# H5_WECHAT_OPEN_APP_SECRET=
|
# H5_WECHAT_OPEN_APP_SECRET=
|
||||||
|
|
||||||
|
# 微信小程序原生登录(wx.login → /auth/wechat-miniapp/login)
|
||||||
|
# H5_WECHAT_MINIAPP_APP_ID=wx...
|
||||||
|
# H5_WECHAT_MINIAPP_APP_SECRET=小程序AppSecret
|
||||||
|
|
||||||
# 本地开发:pnpm dev 会自动启动 server.mjs + vite
|
# 本地开发:pnpm dev 会自动启动 server.mjs + vite
|
||||||
# 若只跑前端:pnpm dev:vite(需另开 pnpm dev:server)
|
# 若只跑前端:pnpm dev:vite(需另开 pnpm dev:server)
|
||||||
# H5_DEV_PORTAL=http://127.0.0.1:8081
|
# H5_DEV_PORTAL=http://127.0.0.1:8081
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules/
|
||||||
|
miniprogram_npm/
|
||||||
|
project.private.config.json
|
||||||
|
*.local
|
||||||
@@ -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.
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
const {
|
||||||
|
checkAuth,
|
||||||
|
createAgentRun,
|
||||||
|
getAgentRun,
|
||||||
|
loadSession,
|
||||||
|
logout
|
||||||
|
} = require('../../utils/api');
|
||||||
|
const {
|
||||||
|
AGENT_RUN_MAX_POLLS,
|
||||||
|
AGENT_RUN_POLL_MS,
|
||||||
|
SELECTED_SESSION_KEY
|
||||||
|
} = 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,
|
||||||
|
error: '',
|
||||||
|
scrollIntoView: ''
|
||||||
|
},
|
||||||
|
|
||||||
|
async onShow() {
|
||||||
|
try {
|
||||||
|
const status = await checkAuth();
|
||||||
|
if (!status?.authenticated) {
|
||||||
|
wx.redirectTo({ url: '/pages/login/index' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const selectedSessionId = wx.getStorageSync(SELECTED_SESSION_KEY);
|
||||||
|
if (selectedSessionId) {
|
||||||
|
wx.removeStorageSync(SELECTED_SESSION_KEY);
|
||||||
|
if (selectedSessionId !== this.data.sessionId) {
|
||||||
|
await this.openSession(selectedSessionId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} 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, error: '' });
|
||||||
|
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) {
|
||||||
|
this.setData({ error: error?.message || '发送失败' });
|
||||||
|
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({ sessionId, messages, error: '' });
|
||||||
|
},
|
||||||
|
|
||||||
|
async openSession(sessionId) {
|
||||||
|
this.setData({ loading: true, error: '' });
|
||||||
|
try {
|
||||||
|
await this.refreshSession(sessionId);
|
||||||
|
} catch (error) {
|
||||||
|
this.setData({ error: error?.message || '会话加载失败' });
|
||||||
|
} finally {
|
||||||
|
this.setData({ loading: false });
|
||||||
|
this.scrollToBottom();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
startNewChat() {
|
||||||
|
wx.removeStorageSync(SELECTED_SESSION_KEY);
|
||||||
|
this.setData({ sessionId: '', messages: [], draft: '', error: '' });
|
||||||
|
},
|
||||||
|
|
||||||
|
async handleLogout() {
|
||||||
|
this.setData({ loading: true, error: '' });
|
||||||
|
try {
|
||||||
|
await logout();
|
||||||
|
wx.redirectTo({ url: '/pages/login/index' });
|
||||||
|
} catch (error) {
|
||||||
|
this.setData({ error: error?.message || '退出失败' });
|
||||||
|
} finally {
|
||||||
|
this.setData({ loading: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"navigationBarTitleText": "TKMind 聊天"
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<view class="page chat-page">
|
||||||
|
<view class="chat-header">
|
||||||
|
<view class="chat-title">
|
||||||
|
<text>{{sessionId ? '继续会话' : '新会话'}}</text>
|
||||||
|
<text wx:if="{{error}}" class="header-error">{{error}}</text>
|
||||||
|
</view>
|
||||||
|
<view class="header-actions">
|
||||||
|
<button class="header-button" bindtap="startNewChat">新聊天</button>
|
||||||
|
<button class="header-button" bindtap="handleLogout">退出</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<scroll-view class="messages" scroll-y scroll-into-view="{{scrollIntoView}}">
|
||||||
|
<view wx:if="{{messages.length === 0 && !loading}}" class="empty-state">
|
||||||
|
<text>发一条消息开始使用 TKMind。</text>
|
||||||
|
</view>
|
||||||
|
<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,119 @@
|
|||||||
|
.chat-page {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100vh;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16rpx;
|
||||||
|
padding: 20rpx 24rpx;
|
||||||
|
background: #ffffff;
|
||||||
|
border-bottom: 1rpx solid #dbe3eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-title {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4rpx;
|
||||||
|
min-width: 0;
|
||||||
|
font-size: 30rpx;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-error {
|
||||||
|
max-width: 420rpx;
|
||||||
|
color: #b42318;
|
||||||
|
font-size: 22rpx;
|
||||||
|
font-weight: 500;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 12rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-button {
|
||||||
|
width: 112rpx;
|
||||||
|
height: 58rpx;
|
||||||
|
line-height: 58rpx;
|
||||||
|
border-radius: 10rpx;
|
||||||
|
background: #eef6f5;
|
||||||
|
color: #0f766e;
|
||||||
|
font-size: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.messages {
|
||||||
|
flex: 1;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 28rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
margin: 160rpx auto 0;
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 28rpx;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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,69 @@
|
|||||||
|
const {
|
||||||
|
checkAuth,
|
||||||
|
loginWithPassword,
|
||||||
|
loginWithWechatCode
|
||||||
|
} = require('../../utils/api');
|
||||||
|
const { MINIAPP_LOGIN_PATH } = require('../../utils/config');
|
||||||
|
|
||||||
|
Page({
|
||||||
|
data: {
|
||||||
|
username: '',
|
||||||
|
password: '',
|
||||||
|
error: '',
|
||||||
|
wechatLoading: false,
|
||||||
|
passwordLoading: false
|
||||||
|
},
|
||||||
|
|
||||||
|
async onLoad() {
|
||||||
|
try {
|
||||||
|
const status = await checkAuth();
|
||||||
|
if (status?.authenticated) {
|
||||||
|
wx.switchTab({ url: '/pages/chat/index' });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Stay on the login page when the existing session cannot be verified.
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
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,60 @@
|
|||||||
|
const { checkAuth, listSessions } = require('../../utils/api');
|
||||||
|
const { SELECTED_SESSION_KEY } = require('../../utils/config');
|
||||||
|
|
||||||
|
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.ensureAndLoad();
|
||||||
|
},
|
||||||
|
|
||||||
|
async ensureAndLoad() {
|
||||||
|
try {
|
||||||
|
const status = await checkAuth();
|
||||||
|
if (!status?.authenticated) {
|
||||||
|
wx.redirectTo({ url: '/pages/login/index' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await this.loadSessions();
|
||||||
|
} catch {
|
||||||
|
wx.redirectTo({ url: '/pages/login/index' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
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.setStorageSync(SELECTED_SESSION_KEY, id);
|
||||||
|
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,50 @@
|
|||||||
|
const { checkAuth, listMindSpacePages } = require('../../utils/api');
|
||||||
|
|
||||||
|
Page({
|
||||||
|
data: {
|
||||||
|
pages: [],
|
||||||
|
loading: false,
|
||||||
|
error: ''
|
||||||
|
},
|
||||||
|
|
||||||
|
onShow() {
|
||||||
|
this.ensureAndLoad();
|
||||||
|
},
|
||||||
|
|
||||||
|
async ensureAndLoad() {
|
||||||
|
try {
|
||||||
|
const status = await checkAuth();
|
||||||
|
if (!status?.authenticated) {
|
||||||
|
wx.redirectTo({ url: '/pages/login/index' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await this.loadPages();
|
||||||
|
} catch {
|
||||||
|
wx.redirectTo({ url: '/pages/login/index' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
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}}" />
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"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",
|
||||||
|
"miniprogramRoot": "./",
|
||||||
|
"libVersion": "3.8.8",
|
||||||
|
"appid": "touristappid",
|
||||||
|
"projectname": "tkmind-miniapp",
|
||||||
|
"condition": {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"rules": [
|
||||||
|
{
|
||||||
|
"action": "disallow",
|
||||||
|
"page": "*"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
const SESSION_KEY = 'tkmind_session';
|
||||||
|
|
||||||
|
let apiBaseUrl = '';
|
||||||
|
let cookie = '';
|
||||||
|
|
||||||
|
function setApiBaseUrl(value) {
|
||||||
|
apiBaseUrl = String(value || '').replace(/\/$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMiniProgramAppId() {
|
||||||
|
try {
|
||||||
|
return wx.getAccountInfoSync?.()?.miniProgram?.appId || '';
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 === 405
|
||||||
|
? `接口不支持当前请求方法,请检查 API_BASE_URL 是否指向 Portal 服务:${url}`
|
||||||
|
: `请求失败 (${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 appId = getMiniProgramAppId();
|
||||||
|
if (!appId || appId === 'touristappid') {
|
||||||
|
throw new Error('当前是游客模式,微信一键登录需要在 project.config.json 配置真实小程序 AppID。');
|
||||||
|
}
|
||||||
|
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
|
||||||
|
};
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
module.exports = {
|
||||||
|
API_BASE_URL: 'https://m.tkmind.cn',
|
||||||
|
MINIAPP_LOGIN_PATH: '/auth/wechat-miniapp/login',
|
||||||
|
SELECTED_SESSION_KEY: 'tkmind_selected_session_id',
|
||||||
|
AGENT_RUN_POLL_MS: 1200,
|
||||||
|
AGENT_RUN_MAX_POLLS: 120
|
||||||
|
};
|
||||||
@@ -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
|
||||||
|
};
|
||||||
+54
@@ -172,6 +172,7 @@ import {
|
|||||||
WECHAT_NOTIFY_SUCCESS_V2,
|
WECHAT_NOTIFY_SUCCESS_V2,
|
||||||
} from './wechat-pay.mjs';
|
} from './wechat-pay.mjs';
|
||||||
import { createWechatOAuthService, isWechatUserAgent, loadWechatOAuthConfig } from './wechat-oauth.mjs';
|
import { createWechatOAuthService, isWechatUserAgent, loadWechatOAuthConfig } from './wechat-oauth.mjs';
|
||||||
|
import { exchangeMiniProgramCode, loadWechatMiniappConfig } from './wechat-miniapp.mjs';
|
||||||
import { loadWechatMpConfig } from './wechat-mp-config.mjs';
|
import { loadWechatMpConfig } from './wechat-mp-config.mjs';
|
||||||
import { loadWechatMpModule } from './wechat-mp-loader.mjs';
|
import { loadWechatMpModule } from './wechat-mp-loader.mjs';
|
||||||
import { validateWechatShareSignatureUrl } from './wechat-share.mjs';
|
import { validateWechatShareSignatureUrl } from './wechat-share.mjs';
|
||||||
@@ -262,6 +263,15 @@ app.use((req, res, next) => {
|
|||||||
next();
|
next();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function isWechatMiniProgramSource(value) {
|
||||||
|
if (!value) return false;
|
||||||
|
try {
|
||||||
|
return new URL(value).hostname.endsWith('servicewechat.com');
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function csrfOriginCheck(req, res, next) {
|
function csrfOriginCheck(req, res, next) {
|
||||||
if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) return next();
|
if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) return next();
|
||||||
const host = req.get('host');
|
const host = req.get('host');
|
||||||
@@ -269,6 +279,9 @@ function csrfOriginCheck(req, res, next) {
|
|||||||
const referer = req.get('referer');
|
const referer = req.get('referer');
|
||||||
if (!origin && !referer) return next();
|
if (!origin && !referer) return next();
|
||||||
const requestHostname = (host ?? '').split(':')[0];
|
const requestHostname = (host ?? '').split(':')[0];
|
||||||
|
if ([origin, referer].some(isWechatMiniProgramSource)) {
|
||||||
|
return next();
|
||||||
|
}
|
||||||
const allowed = [origin, referer].some((value) => {
|
const allowed = [origin, referer].some((value) => {
|
||||||
if (!value) return false;
|
if (!value) return false;
|
||||||
try {
|
try {
|
||||||
@@ -904,6 +917,47 @@ app.post('/auth/login', jsonBody, async (req, res) => {
|
|||||||
return res.json({ authenticated: true, mode: 'legacy' });
|
return res.json({ authenticated: true, mode: 'legacy' });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.post('/auth/wechat-miniapp/login', jsonBody, async (req, res) => {
|
||||||
|
await userAuthReady;
|
||||||
|
if (!userAuth) {
|
||||||
|
return res.status(503).json({ message: '未启用用户系统' });
|
||||||
|
}
|
||||||
|
const miniappConfig = loadWechatMiniappConfig();
|
||||||
|
if (!miniappConfig.enabled) {
|
||||||
|
return res.status(503).json({ message: '小程序登录未配置,请联系管理员' });
|
||||||
|
}
|
||||||
|
const code = typeof req.body?.code === 'string' ? req.body.code.trim() : '';
|
||||||
|
if (!code) {
|
||||||
|
return res.status(400).json({ message: '缺少微信登录 code' });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const session = await exchangeMiniProgramCode({
|
||||||
|
appId: miniappConfig.appId,
|
||||||
|
appSecret: miniappConfig.appSecret,
|
||||||
|
code,
|
||||||
|
});
|
||||||
|
const result = await userAuth.loginByWechatMiniProgram({
|
||||||
|
appId: miniappConfig.appId,
|
||||||
|
openid: session.openid,
|
||||||
|
unionid: session.unionid,
|
||||||
|
});
|
||||||
|
if (!result.ok) {
|
||||||
|
return res.status(401).json({ message: result.message || '微信登录失败' });
|
||||||
|
}
|
||||||
|
setUserLoginCookies(res, req, result.token);
|
||||||
|
return res.json({
|
||||||
|
authenticated: true,
|
||||||
|
user: result.user,
|
||||||
|
mode: 'user',
|
||||||
|
isNewUser: Boolean(result.isNewUser),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : '微信登录失败';
|
||||||
|
const status = err?.code === 'wechat_miniapp_code_failed' ? 401 : 400;
|
||||||
|
return res.status(status).json({ message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
app.post('/auth/register', jsonBody, async (req, res) => {
|
app.post('/auth/register', jsonBody, async (req, res) => {
|
||||||
await userAuthReady;
|
await userAuthReady;
|
||||||
if (!userAuth) {
|
if (!userAuth) {
|
||||||
|
|||||||
@@ -2865,11 +2865,52 @@ export function createUserAuth(pool, options = {}) {
|
|||||||
return result;
|
return result;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const loginByWechatMiniProgram = async ({
|
||||||
|
appId,
|
||||||
|
openid,
|
||||||
|
unionid,
|
||||||
|
now = Date.now(),
|
||||||
|
}) => {
|
||||||
|
const existing = await findWechatUserByOpenid(appId, openid);
|
||||||
|
if (existing) {
|
||||||
|
if (existing.status === 'disabled') {
|
||||||
|
return { ok: false, message: '账户已禁用,请联系管理员' };
|
||||||
|
}
|
||||||
|
return loginBoundWechatUser({
|
||||||
|
userId: existing.userId,
|
||||||
|
appId,
|
||||||
|
openid,
|
||||||
|
unionid,
|
||||||
|
nickname: existing.nickname,
|
||||||
|
avatarUrl: null,
|
||||||
|
now,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const registered = await registerViaWechat({
|
||||||
|
appId,
|
||||||
|
openid,
|
||||||
|
unionid,
|
||||||
|
nickname: '微信用户',
|
||||||
|
avatarUrl: null,
|
||||||
|
now,
|
||||||
|
});
|
||||||
|
if (!registered.ok) return registered;
|
||||||
|
const token = await issueUserSession(registered.user.id, registered.user.role, now);
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
token,
|
||||||
|
user: registered.user,
|
||||||
|
isNewUser: true,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
USER_COOKIE,
|
USER_COOKIE,
|
||||||
register,
|
register,
|
||||||
login,
|
login,
|
||||||
loginByWechat,
|
loginByWechat,
|
||||||
|
loginByWechatMiniProgram,
|
||||||
resolveWechatAuth,
|
resolveWechatAuth,
|
||||||
completeWechatRegister,
|
completeWechatRegister,
|
||||||
completeWechatBindAccount,
|
completeWechatBindAccount,
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { fetch } from 'undici';
|
||||||
|
|
||||||
|
const JSCODE2SESSION_URL = 'https://api.weixin.qq.com/sns/jscode2session';
|
||||||
|
|
||||||
|
export function loadWechatMiniappConfig(env = process.env) {
|
||||||
|
const appId =
|
||||||
|
env.H5_WECHAT_MINIAPP_APP_ID?.trim() ??
|
||||||
|
env.H5_WECHAT_APP_ID?.trim() ??
|
||||||
|
'';
|
||||||
|
const appSecret =
|
||||||
|
env.H5_WECHAT_MINIAPP_APP_SECRET?.trim() ??
|
||||||
|
env.H5_WECHAT_APP_SECRET?.trim() ??
|
||||||
|
'';
|
||||||
|
return {
|
||||||
|
enabled: Boolean(appId && appSecret),
|
||||||
|
appId,
|
||||||
|
appSecret,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function exchangeMiniProgramCode(
|
||||||
|
{ appId, appSecret, code, fetchImpl = fetch } = {},
|
||||||
|
) {
|
||||||
|
const jsCode = String(code ?? '').trim();
|
||||||
|
if (!appId || !appSecret) {
|
||||||
|
throw Object.assign(new Error('小程序登录未配置 AppID/AppSecret'), {
|
||||||
|
code: 'wechat_miniapp_not_configured',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!jsCode) {
|
||||||
|
throw Object.assign(new Error('缺少微信登录 code'), { code: 'invalid_code' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = new URL(JSCODE2SESSION_URL);
|
||||||
|
url.searchParams.set('appid', appId);
|
||||||
|
url.searchParams.set('secret', appSecret);
|
||||||
|
url.searchParams.set('js_code', jsCode);
|
||||||
|
url.searchParams.set('grant_type', 'authorization_code');
|
||||||
|
|
||||||
|
const response = await fetchImpl(url);
|
||||||
|
const payload = await response.json().catch(() => ({}));
|
||||||
|
if (!response.ok) {
|
||||||
|
throw Object.assign(new Error('微信登录服务暂不可用'), {
|
||||||
|
code: 'wechat_miniapp_http_failed',
|
||||||
|
status: response.status,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (payload.errcode) {
|
||||||
|
throw Object.assign(new Error(payload.errmsg || '微信 code 无效或已过期'), {
|
||||||
|
code: 'wechat_miniapp_code_failed',
|
||||||
|
errcode: payload.errcode,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const openid = String(payload.openid ?? '').trim();
|
||||||
|
if (!openid) {
|
||||||
|
throw Object.assign(new Error('微信未返回 openid'), { code: 'wechat_miniapp_openid_missing' });
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
openid,
|
||||||
|
sessionKey: payload.session_key ?? null,
|
||||||
|
unionid: payload.unionid ? String(payload.unionid).trim() : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { exchangeMiniProgramCode, loadWechatMiniappConfig } from './wechat-miniapp.mjs';
|
||||||
|
|
||||||
|
test('loadWechatMiniappConfig prefers miniapp-specific env vars', () => {
|
||||||
|
const config = loadWechatMiniappConfig({
|
||||||
|
H5_WECHAT_MINIAPP_APP_ID: 'wx-mini',
|
||||||
|
H5_WECHAT_MINIAPP_APP_SECRET: 'secret-mini',
|
||||||
|
H5_WECHAT_APP_ID: 'wx-other',
|
||||||
|
H5_WECHAT_APP_SECRET: 'secret-other',
|
||||||
|
});
|
||||||
|
assert.equal(config.enabled, true);
|
||||||
|
assert.equal(config.appId, 'wx-mini');
|
||||||
|
assert.equal(config.appSecret, 'secret-mini');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('exchangeMiniProgramCode calls jscode2session and returns openid', async () => {
|
||||||
|
const calls = [];
|
||||||
|
const result = await exchangeMiniProgramCode({
|
||||||
|
appId: 'wxtest',
|
||||||
|
appSecret: 'secret',
|
||||||
|
code: 'abc123',
|
||||||
|
fetchImpl: async (url) => {
|
||||||
|
calls.push(String(url));
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
async json() {
|
||||||
|
return { openid: 'openid-1', session_key: 'sk', unionid: 'union-1' };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.equal(result.openid, 'openid-1');
|
||||||
|
assert.equal(result.unionid, 'union-1');
|
||||||
|
assert.match(calls[0], /jscode2session\?/);
|
||||||
|
assert.match(calls[0], /js_code=abc123/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('exchangeMiniProgramCode surfaces WeChat errcode', async () => {
|
||||||
|
await assert.rejects(
|
||||||
|
() =>
|
||||||
|
exchangeMiniProgramCode({
|
||||||
|
appId: 'wxtest',
|
||||||
|
appSecret: 'secret',
|
||||||
|
code: 'bad',
|
||||||
|
fetchImpl: async () => ({
|
||||||
|
ok: true,
|
||||||
|
async json() {
|
||||||
|
return { errcode: 40029, errmsg: 'invalid code' };
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
(error) => {
|
||||||
|
assert.equal(error.code, 'wechat_miniapp_code_failed');
|
||||||
|
assert.match(error.message, /invalid code/);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user