您好!您遇到的问题——在非账户设置页面(例如 /user/help)点击顶部栏的“私信”按钮时,私信界面可能无法正确加载并卡在“正在加载对话…”——我分析后认为主要原因如下:
- JavaScript 文件缺失:在
/user/help这样的独立页面上,专门处理私信逻辑的 JavaScript 文件 (ui-messaging.js) 可能没有被加载。目前您的generateHelpPageHtml函数在生成页面时,并未包含所有UI脚本。 - HTML 结构缺失:私信界面的 HTML 结构(包括显示对话列表的容器)是作为
/user/account页面内的一个“标签页” (tab pane) 生成的。当您在/user/help页面时,这部分 HTML 结构不存在于当前页面,因此即使相关脚本被加载,也无法找到并操作对应的界面元素。
您提出的将用户中心的各个主要功能模块(如 API 密钥、个人设置、安全设置、我的应用以及私信)拆分成独立的页面,而不是全部聚合在 /user/account 下的标签页中,这是一个非常好的建议。这种方式更符合多页面应用(MPA)的思路,可以有效地解决上述问题。为每个功能区创建专门的页面路由和 HTML,能确保在访问该功能时,所有必需的资源(HTML、CSS、JavaScript)都能被正确加载和初始化。
针对私信功能,我们可以将其改造成一个独立的页面,例如 /user/messaging。这将涉及以下修改:
1. my/src/worker.js (Cloudflare Worker 入口文件)
- 需要为新的私信页面添加路由。
- 当用户访问
/user/messaging时,调用一个新的 HTML 生成函数。
export class ConversationDurableObject {
constructor(state, env) {
this.state = state;
this.env = env;
this.sessions = [];
this.messages = [];
this.conversationId = null;
this.participants = [];
this.initialized = false;
this.state.blockConcurrencyWhile(async () => {
let stored = await this.state.storage.get(["conversationId", "participants", "messages"]);
this.conversationId = stored.get("conversationId");
this.participants = stored.get("participants") || [];
this.messages = stored.get("messages") || [];
if (this.conversationId && this.participants.length > 0) {
this.initialized = true;
}
});
}
async initialize(conversationId, participant1Email, participant2Email) {
if (this.initialized) return;
this.conversationId = conversationId;
this.participants = [participant1Email, participant2Email].sort();
if (this.messages.length === 0 && this.env.DB && this.conversationId) {
try {
const { results } = await this.env.DB.prepare(
`SELECT m.message_id, m.conversation_id, m.sender_email, u_sender.username as sender_username, m.content, m.sent_at, m.is_read
FROM messages m
JOIN users u_sender ON m.sender_email = u_sender.email
WHERE m.conversation_id = ?
ORDER BY m.sent_at ASC`
).bind(this.conversationId).all();
if (results) {
this.messages = results;
}
} catch (e) {
}
}
await this.state.storage.put({
"conversationId": this.conversationId,
"participants": this.participants,
"messages": this.messages
});
this.initialized = true;
}
static getConversationDOName(user1Email, user2Email) {
const participants = [user1Email, user2Email].sort();
return `conv-${participants[0]}-${participants[1]}`;
}
async fetch(request) {
const url = new URL(request.url);
const userEmail = request.headers.get("X-User-Email");
const requestD1ConversationId = request.headers.get("X-Conversation-D1-Id");
const requestP1 = request.headers.get("X-Participant1-Email");
const requestP2 = request.headers.get("X-Participant2-Email");
if (!this.initialized && requestD1ConversationId && requestP1 && requestP2) {
await this.initialize(requestD1ConversationId, requestP1, requestP2);
}
if (!this.initialized) {
return new Response("DO 未初始化。请调用 /initialize 或提供头部信息。", { status: 500 });
}
if (!userEmail) {
return new Response("需要 X-User-Email 头部信息。", { status: 400 });
}
if (!this.participants.includes(userEmail)) {
return new Response("禁止访问:用户不是参与者。", { status: 403 });
}
if (url.pathname === "/websocket") {
if (request.headers.get("Upgrade") !== "websocket") {
return new Response("期望 WebSocket 升级", { status: 426 });
}
const pair = new WebSocketPair();
const [client, server] = Object.values(pair);
server.accept();
this.sessions.push({ ws: server, userEmail: userEmail });
const initialBatchSize = 10;
const startIndex = Math.max(0, this.messages.length - initialBatchSize);
const initialMessages = this.messages.slice(startIndex);
initialMessages.forEach(msg => {
server.send(JSON.stringify({ type: "HISTORICAL_MESSAGE", data: msg }));
});
server.send(JSON.stringify({
type: "INITIAL_MESSAGES_LOADED",
data: {
count: initialMessages.length,
totalMessagesInConversation: this.messages.length,
hasMore: this.messages.length > initialBatchSize
}
}));
server.send(JSON.stringify({ type: "CONNECTION_ESTABLISHED", data: { conversationId: this.conversationId, participants: this.participants }}));
server.addEventListener("message", async event => {
try {
const messageData = JSON.parse(event.data);
if (messageData.type === "NEW_MESSAGE") {
const { content } = messageData.data;
const senderEmail = userEmail;
const receiverEmail = this.participants.find(p => p !== senderEmail);
if (!receiverEmail) {
server.send(JSON.stringify({ type: "ERROR", data: "无法确定接收者。"}));
return;
}
const now = Date.now();
const messageId = crypto.randomUUID();
let senderUsername = senderEmail.split('@')[0];
try {
const userDetails = await this.env.DB.prepare("SELECT username FROM users WHERE email = ?").bind(senderEmail).first();
if (userDetails && userDetails.username) senderUsername = userDetails.username;
} catch (e) { }
const newMessage = {
message_id: messageId,
conversation_id: this.conversationId,
sender_email: senderEmail,
sender_username: senderUsername,
content: content,
sent_at: now,
is_read: 0,
};
if (this.env.DB) {
try {
await this.env.DB.prepare(
"INSERT INTO messages (message_id, conversation_id, sender_email, receiver_email, content, sent_at, is_read) VALUES (?, ?, ?, ?, ?, ?, 0)"
).bind(messageId, this.conversationId, senderEmail, receiverEmail, content, now).run();
await this.env.DB.prepare("UPDATE conversations SET last_message_at = ? WHERE conversation_id = ?")
.bind(now, this.conversationId).run();
} catch (e) {
server.send(JSON.stringify({ type: "ERROR", data: "保存消息失败。" }));
return;
}
}
this.messages.push(newMessage);
await this.state.storage.put("messages", this.messages);
this.broadcast({ type: "NEW_MESSAGE", data: newMessage });
if (this.env.USER_PRESENCE_DO) {
try {
const presenceDOId = this.env.USER_PRESENCE_DO.idFromName(`user-${receiverEmail}`);
const presenceStub = this.env.USER_PRESENCE_DO.get(presenceDOId);
await presenceStub.fetch(new Request(`https://do-internal/updateConversationState`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
conversationId: this.conversationId,
action: 'newMessage',
senderEmail: senderEmail,
messageTimestamp: now,
messageContent: content,
otherParticipantEmail: senderEmail,
otherParticipantUsername: senderUsername
})
}));
} catch(e) { }
try {
const senderPresenceDOId = this.env.USER_PRESENCE_DO.idFromName(`user-${senderEmail}`);
const senderPresenceStub = this.env.USER_PRESENCE_DO.get(senderPresenceDOId);
await senderPresenceStub.fetch(new Request(`https://do-internal/updateConversationState`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
conversationId: this.conversationId,
action: 'messageSent',
receiverEmail: receiverEmail,
messageTimestamp: now,
messageContent: content,
})
}));
} catch(e) { }
}
} else if (messageData.type === "MESSAGE_SEEN") {
const { message_id: seenMessageId } = messageData.data;
if (this.env.DB && this.conversationId) {
const messagesRead = await this.env.DB.prepare(
"UPDATE messages SET is_read = 1 WHERE conversation_id = ? AND receiver_email = ? AND is_read = 0"
).bind(this.conversationId, userEmail).run();
this.messages.forEach(msg => {
if (msg.receiver_email === userEmail && msg.conversation_id === this.conversationId && !msg.is_read) {
msg.is_read = 1;
}
});
await this.state.storage.put("messages", this.messages);
this.broadcast({ type: "MESSAGES_READ", data: { reader: userEmail, conversationId: this.conversationId, count: messagesRead.meta.changes }});
if (this.env.USER_PRESENCE_DO) {
try {
const presenceDOId = this.env.USER_PRESENCE_DO.idFromName(`user-${userEmail}`);
const presenceStub = this.env.USER_PRESENCE_DO.get(presenceDOId);
await presenceStub.fetch(new Request(`https://do-internal/updateConversationState`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ conversationId: this.conversationId, action: 'markRead' })
}));
} catch(e) { }
}
}
} else if (messageData.type === "LOAD_MORE_MESSAGES") {
const { currentlyLoadedCount } = messageData.data;
const batchSize = 10;
const remainingMessagesCount = this.messages.length - currentlyLoadedCount;
const endIndexForSlice = this.messages.length - currentlyLoadedCount;
const startIndexForSlice = Math.max(0, endIndexForSlice - batchSize);
const olderMessages = this.messages.slice(startIndexForSlice, endIndexForSlice);
server.send(JSON.stringify({
type: "OLDER_MESSAGES_BATCH",
data: {
messages: olderMessages,
hasMore: startIndexForSlice > 0
}
}));
}
} catch (e) {
server.send(JSON.stringify({ type: "ERROR", data: "无效的消息格式或处理错误。" }));
}
});
server.addEventListener("close", () => {
this.sessions = this.sessions.filter(s => s.ws !== server);
});
server.addEventListener("error", (err) => {
this.sessions = this.sessions.filter(s => s.ws !== server);
});
return new Response(null, { status: 101, webSocket: client });
} else if (url.pathname === "/initialize" && request.method === "POST") {
try {
const { conversationId, participant1Email, participant2Email } = await request.json();
if (conversationId && participant1Email && participant2Email) {
if (!this.initialized) {
await this.initialize(conversationId, participant1Email, participant2Email);
}
return new Response("DO 已初始化或之前已初始化", { status: 200 });
}
return new Response("DO初始化缺少数据", { status: 400 });
} catch (e) {
return new Response(`DO初始化错误: ${e.message}`, { status: 500 });
}
}
return new Response("在ConversationDurableObject中未找到", { status: 404 });
}
broadcast(message) {
const serializedMessage = JSON.stringify(message);
this.sessions = this.sessions.filter(session => {
try {
if (session.ws.readyState === WebSocket.OPEN) {
session.ws.send(serializedMessage);
return true;
} else {
return false;
}
} catch (e) {
return false;
}
});
}
}
export class UserPresenceDurableObject {
constructor(state, env) {
this.state = state;
this.env = env;
this.userEmail = null;
this.webSocket = null;
this.conversationsSummary = {};
this.initialized = false;
this.state.blockConcurrencyWhile(async () => {
const storedUserEmail = await this.state.storage.get("userEmail");
if (storedUserEmail) {
this.userEmail = storedUserEmail;
const storedSummary = await this.state.storage.get("conversationsSummary");
if (storedSummary) {
this.conversationsSummary = storedSummary;
}
this.initialized = true;
}
});
}
async initialize(userEmail) {
if (this.initialized && this.userEmail === userEmail) return;
this.userEmail = userEmail;
await this.state.storage.put("userEmail", this.userEmail);
await this.fetchConversationsSummaryFromD1();
this.initialized = true;
}
async fetchConversationsSummaryFromD1() {
if (!this.userEmail || !this.env.DB) return;
try {
const convResults = await this.env.DB.prepare(
`SELECT
c.conversation_id, c.last_message_at, c.participant1_email, c.participant2_email,
u1.username as p1_username, u2.username as p2_username,
(SELECT COUNT(*) FROM messages m WHERE m.conversation_id = c.conversation_id AND m.receiver_email = ?1 AND m.is_read = 0) as unread_count,
(SELECT content FROM messages m WHERE m.conversation_id = c.conversation_id ORDER BY m.sent_at DESC LIMIT 1) as last_message_content,
(SELECT sender_email FROM messages m WHERE m.conversation_id = c.conversation_id ORDER BY m.sent_at DESC LIMIT 1) as last_message_sender
FROM conversations c
LEFT JOIN users u1 ON c.participant1_email = u1.email
LEFT JOIN users u2 ON c.participant2_email = u2.email
WHERE c.participant1_email = ?1 OR c.participant2_email = ?1
ORDER BY c.last_message_at DESC`
).bind(this.userEmail).all();
const newSummary = {};
let totalUnread = 0;
if (convResults && convResults.results) {
convResults.results.forEach(conv => {
const otherParticipantEmail = conv.participant1_email === this.userEmail ? conv.participant2_email : conv.participant1_email;
const otherParticipantUsername = conv.participant1_email === this.userEmail ? conv.p2_username : conv.p1_username;
newSummary[conv.conversation_id] = {
conversation_id: conv.conversation_id,
unread_count: conv.unread_count || 0,
last_message_at: conv.last_message_at,
last_message_content: conv.last_message_content,
last_message_sender: conv.last_message_sender,
other_participant_email: otherParticipantEmail,
other_participant_username: otherParticipantUsername || otherParticipantEmail.split('@')[0],
};
totalUnread += (conv.unread_count || 0);
});
}
this.conversationsSummary = newSummary;
await this.state.storage.put("conversationsSummary", this.conversationsSummary);
if (this.webSocket && this.webSocket.readyState === WebSocket.OPEN) {
this.webSocket.send(JSON.stringify({ type: "CONVERSATIONS_LIST", data: Object.values(this.conversationsSummary) }));
this.webSocket.send(JSON.stringify({ type: "UNREAD_COUNT_TOTAL", data: { unread_count: totalUnread } }));
}
} catch (e) {
if (this.webSocket && this.webSocket.readyState === WebSocket.OPEN) {
this.webSocket.send(JSON.stringify({ type: "ERROR", data: "无法加载对话列表。" }));
this.webSocket.send(JSON.stringify({ type: "CONVERSATIONS_LIST", data: [] }));
}
}
}
async fetch(request) {
const url = new URL(request.url);
const requestUserEmail = request.headers.get("X-User-Email");
if (!this.initialized && requestUserEmail) {
await this.initialize(requestUserEmail);
} else if (!this.initialized && this.state.id && this.state.id.name && this.state.id.name.startsWith("user-")) {
await this.initialize(this.state.id.name.substring(5));
}
if (!this.initialized || !this.userEmail) {
return new Response("UserPresenceDO 未正确初始化。", { status: 500 });
}
if (url.pathname === "/websocket") {
if (request.headers.get("Upgrade") !== "websocket") {
return new Response("期望 WebSocket 升级", { status: 426 });
}
if (this.webSocket && this.webSocket.readyState === WebSocket.OPEN) {
this.webSocket.close(1000, "新连接已建立");
}
const pair = new WebSocketPair();
const [client, server] = Object.values(pair);
this.webSocket = server;
this.webSocket.accept();
this.webSocket.addEventListener("message", async event => {
try {
const messageData = JSON.parse(event.data);
if (messageData.type === "REQUEST_INITIAL_STATE" || messageData.type === "REQUEST_CONVERSATIONS_LIST") {
await this.fetchConversationsSummaryFromD1();
}
} catch(e) {
}
});
this.webSocket.addEventListener("close", () => { this.webSocket = null; });
this.webSocket.addEventListener("error", (err) => {
this.webSocket = null;
});
return new Response(null, { status: 101, webSocket: client });
} else if (url.pathname === "/updateConversationState" && request.method === "POST") {
const updateData = await request.json();
const { conversationId, action, senderEmail, messageTimestamp, messageContent, otherParticipantEmail, otherParticipantUsername, receiverEmail } = updateData;
let changed = false;
if (!this.conversationsSummary[conversationId]) {
this.conversationsSummary[conversationId] = {
conversation_id: conversationId,
unread_count: 0,
last_message_at: 0,
last_message_content: "",
last_message_sender: "",
other_participant_email: action === 'newMessage' ? (otherParticipantEmail || senderEmail) : (action === 'messageSent' ? receiverEmail : ""),
other_participant_username: action === 'newMessage' ? (otherParticipantUsername || (otherParticipantEmail || senderEmail || "").split('@')[0]) : (action === 'messageSent' ? (receiverEmail || "").split('@')[0] : "")
};
changed = true;
}
const currentConv = this.conversationsSummary[conversationId];
if (action === 'newMessage') {
currentConv.unread_count = (currentConv.unread_count || 0) + 1;
currentConv.last_message_at = messageTimestamp;
currentConv.last_message_content = messageContent;
currentConv.last_message_sender = senderEmail;
if (!currentConv.other_participant_email && senderEmail) {
currentConv.other_participant_email = senderEmail;
currentConv.other_participant_username = otherParticipantUsername || senderEmail.split('@')[0];
}
changed = true;
} else if (action === 'markRead') {
if (currentConv.unread_count > 0) {
currentConv.unread_count = 0;
changed = true;
}
} else if (action === 'messageSent') {
currentConv.last_message_at = messageTimestamp;
currentConv.last_message_content = messageContent;
currentConv.last_message_sender = this.userEmail;
if (!currentConv.other_participant_email && receiverEmail) {
currentConv.other_participant_email = receiverEmail;
currentConv.other_participant_username = receiverEmail.split('@')[0];
}
changed = true;
}
if (changed) {
await this.state.storage.put("conversationsSummary", this.conversationsSummary);
let totalUnread = 0;
Object.values(this.conversationsSummary).forEach(conv => totalUnread += (conv.unread_count || 0));
if (this.webSocket && this.webSocket.readyState === WebSocket.OPEN) {
const sortedSummary = Object.values(this.conversationsSummary).sort((a,b) => (b.last_message_at || 0) - (a.last_message_at || 0));
this.webSocket.send(JSON.stringify({ type: "CONVERSATIONS_LIST", data: sortedSummary }));
this.webSocket.send(JSON.stringify({ type: "UNREAD_COUNT_TOTAL", data: { unread_count: totalUnread } }));
}
}
return new Response("用户状态已更新", { status: 200 });
} else if (url.pathname === "/getConversationsList" && request.method === "GET") {
await this.fetchConversationsSummaryFromD1();
const sortedSummary = Object.values(this.conversationsSummary).sort((a,b) => (b.last_message_at || 0) - (a.last_message_at || 0));
return new Response(JSON.stringify(sortedSummary), { headers: { 'Content-Type': 'application/json'}});
} else if (url.pathname === "/getTotalUnreadCount" && request.method === "GET") {
await this.fetchConversationsSummaryFromD1();
let totalUnread = 0;
Object.values(this.conversationsSummary).forEach(conv => totalUnread += (conv.unread_count || 0));
return new Response(JSON.stringify({ unread_count: totalUnread }), { headers: { 'Content-Type': 'application/json'}});
}
return new Response("在UserPresenceDurableObject中未找到", { status: 404 });
}
}
import {
authenticateApiRequest,
handleGetMe,
handleRegister,
handleLogin,
handleLogin2FAVerify,
handleChangePassword,
handleUpdateProfile,
handleLogout,
handle2FAGenerateSecret,
handle2FAEnable,
handle2FADisable,
handleCreatePasteApiKey,
handleGetCloudPcKey,
handleCreateCloudPcKey,
handleGetGreenHubKeys,
} from './api-handlers.js';
import {
handleOpenIDConfiguration,
handleJwks,
handleOAuthAuthorize,
handleOAuthToken,
handleOAuthUserInfo,
handleRegisterOauthClient,
handleListOauthClients,
handleUpdateOauthClient,
handleDeleteOauthClient
} from './oauth-server.js';
import { generateHtmlUi, generateErrorPageHtml, generateHelpPageHtml, generateMessagingPageHtml } from './html-ui.js';
import { jsonResponse, OAUTH_ISSUER_URL } from './helpers.js';
export default {
async fetch(request, env, ctx) {
const url = new URL(request.url);
const path = url.pathname;
const method = request.method;
const rayId = request.headers.get('cf-ray') || crypto.randomUUID();
let userEmailFromSession = null;
const cookieHeader = request.headers.get('Cookie');
let sessionIdFromCookie = null;
if (cookieHeader) {
const authCookieString = cookieHeader.split(';').find(row => row.trim().startsWith('AuthToken='));
if (authCookieString) sessionIdFromCookie = authCookieString.split('=')[1]?.trim();
}
if (sessionIdFromCookie && env.DB) {
userEmailFromSession = await authenticateApiRequest(sessionIdFromCookie, env);
}
try {
if (method === 'GET' && path === '/.well-known/openid-configuration') {
return handleOpenIDConfiguration(request, env);
}
if (method === 'GET' && path === '/.well-known/jwks.json') {
return handleJwks(request, env);
}
if (path.startsWith('/oauth/')) {
if (path === '/oauth/authorize') {
return handleOAuthAuthorize(request, env);
}
if (path === '/oauth/token' && method === 'POST') {
return handleOAuthToken(request, env);
}
if (path === '/oauth/userinfo' && (method === 'GET' || method === 'POST')) {
return handleOAuthUserInfo(request, env);
}
return jsonResponse({ error: 'OAuth/OIDC 端点未找到或方法不允许' }, 404);
}
if (path.startsWith('/api/ws/user')) {
if (!userEmailFromSession) return jsonResponse({ error: 'WebSocket 需要身份验证' }, 401);
const doId = env.USER_PRESENCE_DO.idFromName(`user-${userEmailFromSession}`);
const stub = env.USER_PRESENCE_DO.get(doId);
const forwardRequestHeaders = new Headers(request.headers);
forwardRequestHeaders.set('X-User-Email', userEmailFromSession);
return stub.fetch(new Request(`${url.origin}/websocket`, new Request(request, {headers: forwardRequestHeaders})));
}
const wsConversationMatch = path.match(/^\/api\/ws\/conversation\/([a-fA-F0-9-]+)$/);
if (wsConversationMatch) {
if (!userEmailFromSession) return jsonResponse({ error: 'WebSocket 需要身份验证' }, 401);
const conversationD1Id = wsConversationMatch[1];
const convDetails = await env.DB.prepare(
"SELECT participant1_email, participant2_email FROM conversations WHERE conversation_id = ?"
).bind(conversationD1Id).first();
if (!convDetails) return jsonResponse({ error: '对话未找到' }, 404);
if (userEmailFromSession !== convDetails.participant1_email && userEmailFromSession !== convDetails.participant2_email) {
return jsonResponse({ error: '禁止访问' }, 403);
}
const doName = ConversationDurableObject.getConversationDOName(convDetails.participant1_email, convDetails.participant2_email);
const doId = env.CONVERSATION_DO.idFromName(doName);
const stub = env.CONVERSATION_DO.get(doId);
const forwardRequestHeaders = new Headers(request.headers);
forwardRequestHeaders.set('X-User-Email', userEmailFromSession);
forwardRequestHeaders.set('X-Conversation-D1-Id', conversationD1Id);
forwardRequestHeaders.set('X-Participant1-Email', convDetails.participant1_email);
forwardRequestHeaders.set('X-Participant2-Email', convDetails.participant2_email);
return stub.fetch(new Request(`${url.origin}/websocket`, new Request(request, {headers: forwardRequestHeaders})));
}
if (path.startsWith('/api/')) {
if (path === '/api/config' && method === 'GET') {
return jsonResponse({ turnstileSiteKey: env.TURNSTILE_SITE_KEY });
}
if (method === 'POST' && path === '/api/logout') {
return handleLogout(request, env, sessionIdFromCookie, url.hostname);
}
const authenticatedRoutes = [
'/api/me', '/api/change-password', '/api/update-profile',
'/api/2fa/',
'/api/paste-keys', '/api/cloudpc-key',
'/api/greenhub-keys', '/api/oauth/clients',
'/api/messages', '/api/conversations', '/api/messages/unread-count'
];
const oauthClientSpecificPathMatchForAuth = path.match(/^\/api\/oauth\/clients\/([\w-]+)$/);
let needsAuth = authenticatedRoutes.some(p => path.startsWith(p) || (p.endsWith('/') && path.startsWith(p.slice(0,-1))) || path === p );
if (oauthClientSpecificPathMatchForAuth && (method === 'PUT' || method === 'DELETE')) {
needsAuth = true;
}
if (needsAuth && !userEmailFromSession) {
return jsonResponse({ error: '未授权或会话无效' }, 401);
}
if (method === 'POST' && path === '/api/register') {
return handleRegister(request, env, request.headers.get('CF-Connecting-IP'));
}
if (method === 'POST' && path === '/api/login') {
return handleLogin(request, env, url.protocol, url.hostname, request.headers.get('CF-Connecting-IP'));
}
if (method === 'POST' && path === '/api/login/2fa-verify') {
return handleLogin2FAVerify(request, env, url.protocol, url.hostname);
}
if (path === '/api/me' && method === 'GET') {
return handleGetMe(request, env, userEmailFromSession);
}
if (path === '/api/change-password' && method === 'POST') {
return handleChangePassword(request, env, userEmailFromSession);
}
if (path === '/api/update-profile' && method === 'POST') {
return handleUpdateProfile(request, env, userEmailFromSession);
}
if (path.startsWith('/api/2fa/')) {
if (path === '/api/2fa/generate-secret' && method === 'GET') {
return handle2FAGenerateSecret(request, env, userEmailFromSession);
}
if (path === '/api/2fa/enable' && method === 'POST') {
return handle2FAEnable(request, env, userEmailFromSession);
}
if (path === '/api/2fa/disable' && method === 'POST') {
return handle2FADisable(request, env, userEmailFromSession);
}
}
if (path === '/api/paste-keys' && method === 'POST') {
return handleCreatePasteApiKey(request, env, userEmailFromSession, request.headers.get('CF-Connecting-IP'));
}
if (path.startsWith('/api/cloudpc-key')) {
if (method === 'GET') {
return handleGetCloudPcKey(request, env, userEmailFromSession);
}
if (method === 'POST') {
return handleCreateCloudPcKey(request, env, userEmailFromSession, request.headers.get('CF-Connecting-IP'));
}
}
if (path === '/api/greenhub-keys' && method === 'GET') {
return handleGetGreenHubKeys(request, env, userEmailFromSession);
}
if (path === '/api/oauth/clients') {
if (method === 'POST') {
return handleRegisterOauthClient(request, env, userEmailFromSession);
}
if (method === 'GET') {
return handleListOauthClients(request, env, userEmailFromSession);
}
}
if (oauthClientSpecificPathMatchForAuth) {
const clientIdFromPath = oauthClientSpecificPathMatchForAuth[1];
if (method === 'PUT') {
return handleUpdateOauthClient(request, env, userEmailFromSession, clientIdFromPath);
}
if (method === 'DELETE') {
return handleDeleteOauthClient(request, env, userEmailFromSession, clientIdFromPath);
}
}
if (path === '/api/messages' && method === 'POST') {
if (!userEmailFromSession) return jsonResponse({ error: '用户未认证' }, 401);
const reqBody = await request.json();
const { receiverEmail, content } = reqBody;
if (!receiverEmail || !content) return jsonResponse({ error: '接收者和内容为必填项' }, 400);
if (receiverEmail === userEmailFromSession) return jsonResponse({ error: '不能给自己发送消息' }, 400);
const receiverUser = await env.DB.prepare("SELECT email FROM users WHERE email = ?").bind(receiverEmail).first();
if (!receiverUser) return jsonResponse({ error: '接收用户不存在' }, 404);
const p1Sorted = [userEmailFromSession, receiverEmail].sort()[0];
const p2Sorted = [userEmailFromSession, receiverEmail].sort()[1];
let conversation = await env.DB.prepare(
"SELECT conversation_id FROM conversations WHERE participant1_email = ? AND participant2_email = ?"
).bind(p1Sorted, p2Sorted).first();
let conversationD1Id;
if (!conversation) {
conversationD1Id = crypto.randomUUID();
const nowDb = Date.now();
await env.DB.prepare(
"INSERT INTO conversations (conversation_id, participant1_email, participant2_email, last_message_at, created_at) VALUES (?, ?, ?, ?, ?)"
).bind(conversationD1Id, p1Sorted, p2Sorted, nowDb, nowDb).run();
} else {
conversationD1Id = conversation.conversation_id;
}
const doName = ConversationDurableObject.getConversationDOName(p1Sorted, p2Sorted);
const doId = env.CONVERSATION_DO.idFromName(doName);
const stub = env.CONVERSATION_DO.get(doId);
const initResponse = await stub.fetch(new Request(`${url.origin}/initialize`, {
method: 'POST',
headers: {'Content-Type': 'application/json', 'X-User-Email': userEmailFromSession },
body: JSON.stringify({
conversationId: conversationD1Id,
participant1Email: p1Sorted,
participant2Email: p2Sorted
})
}));
return jsonResponse({
success: true,
message: '对话已就绪。请通过WebSocket发送实际消息。',
conversationId: conversationD1Id
});
}
if (path === '/api/conversations' && method === 'GET') {
if (!userEmailFromSession) return jsonResponse({ error: '用户未认证' }, 401);
const doId = env.USER_PRESENCE_DO.idFromName(`user-${userEmailFromSession}`);
const stub = env.USER_PRESENCE_DO.get(doId);
const forwardRequestHeaders = new Headers(request.headers);
forwardRequestHeaders.set('X-User-Email', userEmailFromSession);
return stub.fetch(new Request(`${url.origin}/getConversationsList`, new Request(request, {headers: forwardRequestHeaders})));
}
if (path === '/api/messages/unread-count' && method === 'GET') {
if (!userEmailFromSession) return jsonResponse({ error: '用户未认证' }, 401);
const doId = env.USER_PRESENCE_DO.idFromName(`user-${userEmailFromSession}`);
const stub = env.USER_PRESENCE_DO.get(doId);
const forwardRequestHeaders = new Headers(request.headers);
forwardRequestHeaders.set('X-User-Email', userEmailFromSession);
return stub.fetch(new Request(`${url.origin}/getTotalUnreadCount`, new Request(request, {headers: forwardRequestHeaders})));
}
return jsonResponse({ error: 'API 端点未找到或请求方法不允许' }, 404);
}
if (method === 'GET' && path === '/user/help') {
return new Response(generateHelpPageHtml(env), { headers: { 'Content-Type': 'text/html;charset=UTF-8' } });
}
if (method === 'GET' && path === '/user/messaging') {
if (!userEmailFromSession) { // Redirect to login if not authenticated
const loginUrl = new URL(`${OAUTH_ISSUER_URL(env, request)}/user/login`);
loginUrl.searchParams.set('return_to', path); // Return to messaging page after login
return Response.redirect(loginUrl.toString(), 302);
}
return new Response(generateMessagingPageHtml(env), { headers: { 'Content-Type': 'text/html;charset=UTF-8' } });
}
if (method === 'GET' && (path === '/' || path === '/user/login' || path === '/user/register' || path === '/user/account')) {
return new Response(generateHtmlUi(path, env), { headers: { 'Content-Type': 'text/html;charset=UTF-8' } });
}
let errorPageIssuerUrl = '/';
try { errorPageIssuerUrl = OAUTH_ISSUER_URL(env, request); } catch(e) { }
return new Response(generateErrorPageHtml({title: "页面未找到", message: `请求的资源 ${path} 不存在。`, issuerUrl: errorPageIssuerUrl, env}), { status: 404, headers: { 'Content-Type': 'text/html;charset=UTF-8' }});
} catch (error) {
let errorPageIssuerUrl = '/';
try { errorPageIssuerUrl = OAUTH_ISSUER_URL(env, request); } catch (e) { }
return new Response(generateErrorPageHtml({title: "服务器内部错误", message: "处理您的请求时发生意外错误。", issuerUrl: errorPageIssuerUrl, env}), { status: 500, headers: { 'Content-Type': 'text/html;charset=UTF-8' }});
}
},
};
2. my/src/html-ui.js
- 创建一个新的导出函数
generateMessagingPageHtml(env)。 - 这个函数将负责生成私信页面的完整 HTML。其内容将基于原先账户设置页面中私信标签页的 HTML 结构。
- 从
_generateAccountPanesHtml函数中移除原有的私信 (tab-content-messaging) 部分。 - 确保
generateMessagingPageHtml包含所有必要的 JavaScript 文件,特别是main.js和ui-messaging.js。 generateHelpPageHtml中_generateScripts的最后一个参数从false改为true,以确保在帮助页面也能加载所有UI脚本,这有助于顶部栏按钮(如主题切换、未来的用户菜单等)在所有页面行为一致。
function _escapeHtml(unsafe) {
if (typeof unsafe !== 'string') return '';
return unsafe.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
}
function _getIssuerUrl(env, req) {
let baseUrl = "https://qmwneb946.dpdns.org";
if (req && req.url) {
try {
const urlObj = new URL(req.url);
baseUrl = `${urlObj.protocol}//${urlObj.hostname}`;
if (baseUrl.endsWith("my.qmwneb946.dpdns.org")) {
baseUrl = baseUrl.replace("my.qmwneb946.dpdns.org", "qmwneb946.dpdns.org");
}
} catch (e) { }
} else if (env && env.EXPECTED_ISSUER_URL) {
baseUrl = env.EXPECTED_ISSUER_URL;
}
return baseUrl;
};
function _generateIcons() {
return {
menuIcon: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="h-6 w-6"><path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" /></svg>`,
userIcon: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="h-5 w-5 mr-1"><path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-5.5-2.5a2.5 2.5 0 11-5 0 2.5 2.5 0 015 0zM10 12a5.99 5.99 0 00-4.793 2.39A6.483 6.483 0 0010 16.5a6.483 6.483 0 004.793-2.11A5.99 5.99 0 0010 12z" clip-rule="evenodd" /></svg>`,
sunIcon: `<svg id="theme-toggle-light-icon" class="h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M12 3v1m0 16v1m8.66-12.66l-.707.707M5.05 5.05l-.707.707M21 12h-1M4 12H3m15.66 8.66l-.707-.707M6.757 17.243l-.707-.707M12 6a6 6 0 100 12 6 6 0 000-12z"></path></svg>`,
moonIcon: `<svg id="theme-toggle-dark-icon" class="h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"></path></svg>`,
messageIcon: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="h-5 w-5"><path stroke-linecap="round" stroke-linejoin="round" d="M2.25 12.76c0 1.6 1.123 2.994 2.707 3.227 1.087.16 2.185.283 3.293.369V21l4.076-4.076a1.526 1.526 0 011.037-.443 48.282 48.282 0 005.68-.494c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0012 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018z" /></svg>`,
warningIconLarge: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-16 h-16 text-yellow-500"><path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.008v.008H12v-.008z" /></svg>`,
mailIcon: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="h-6 w-6 mr-2"><path stroke-linecap="round" stroke-linejoin="round" d="M21.75 6.75v10.5a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75" /></svg>`
};
}
function _generateHead(title, cdnBaseUrl, faviconDataUri, pageSpecificStyles = '', loadMonacoEditor = false) {
let monacoLoaderScript = loadMonacoEditor ? `<script src="https://cdn.jsdelivr.net/npm/monaco-editor@0.47.0/min/vs/loader.js"></script>` : '';
return `
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${_escapeHtml(title)}</title>
<link rel="icon" href="${_escapeHtml(faviconDataUri)}">
<link rel="stylesheet" href="${_escapeHtml(cdnBaseUrl)}/css/style.css">
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
<script src="https://cdn.jsdelivr.net/npm/qrcodejs@1.0.0/qrcode.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js" defer></script>
<script src="https://cdn.jsdelivr.net/npm/dompurify/dist/purify.min.js" defer></script>
${monacoLoaderScript}
<style>
.hidden { display: none !important; }
.modal {
position: fixed; top: 0; left: 0; width: 100%; height: 100%;
background-color: rgba(0,0,0,0.6); display: flex;
justify-content: center; align-items: center; z-index: 2000;
opacity: 0; visibility: hidden; transition: opacity 0.3s ease, visibility 0s linear 0.3s;
}
.modal.active { opacity: 1; visibility: visible; transition: opacity 0.3s ease; }
.modal-content {
background-color: var(--current-surface-color); color: var(--current-text-color);
padding: 25px 30px; border-radius: var(--border-radius);
box-shadow: 0 5px 15px rgba(0,0,0,0.3); width: 90%; max-width: 500px;
transform: translateY(-20px); transition: transform 0.3s ease;
}
.modal.active .modal-content { transform: translateY(0); }
.modal-content h4 { margin-top: 0; text-align: left; font-size: 1.4em; color: var(--current-heading-color); }
.modal-buttons { margin-top: 25px; text-align: right; }
.modal-buttons button { margin-left: 10px; }
.license-code-list { list-style-type: none; padding-left: 0; max-height: 200px; overflow-y: auto; border: 1px solid var(--current-border-color); border-radius: var(--border-radius); padding: 10px; background-color: var(--current-bg-color); }
.license-code-list li { padding: 5px 0; border-bottom: 1px dashed var(--current-border-color); font-family: var(--font-family-mono); font-size: 0.9em;}
.license-code-list li:last-child { border-bottom: none; }
${pageSpecificStyles}
</style>
</head>`;
}
function _generateScripts(cdnBaseUrl, pageSpecificScripts = '', includeAllUiScripts = true) {
let scripts = '';
if (includeAllUiScripts) {
scripts += `
<script src="${_escapeHtml(cdnBaseUrl)}/js/main.js" defer></script>
<script src="${_escapeHtml(cdnBaseUrl)}/js/ui-personal-info.js" defer></script>
<script src="${_escapeHtml(cdnBaseUrl)}/js/ui-security-settings.js" defer></script>
<script src="${_escapeHtml(cdnBaseUrl)}/js/ui-api-keys.js" defer></script>
<script src="${_escapeHtml(cdnBaseUrl)}/js/ui-oauth-apps.js" defer></script>
<script src="${_escapeHtml(cdnBaseUrl)}/js/ui-messaging.js" defer></script>`;
} else {
scripts += `
<script src="${_escapeHtml(cdnBaseUrl)}/js/main.js" defer></script>`;
}
if (pageSpecificScripts) {
scripts += `
<script>
${pageSpecificScripts}
</script>`;
}
return scripts;
}
function _generateTopBarHtml(siteName, icons, isHelpPageLayout = false, isMessagingPageLayout = false) {
let siteTitleLink = `<span class="site-title">${_escapeHtml(siteName)}</span>`;
if (isHelpPageLayout) {
siteTitleLink = `<a href="/" class="site-title" style="margin-left:0;">${_escapeHtml(siteName)}</a>`;
} else if (isMessagingPageLayout) {
siteTitleLink = `<a href="/user/account" class="site-title">${_escapeHtml(siteName)}</a>`;
}
return `
<header id="top-bar" class="top-bar">
<div class="top-bar-left">
<button id="sidebar-toggle" class="sidebar-toggle-button" aria-label="切换导航菜单">
${icons.menuIcon}
</button>
${siteTitleLink}
</div>
<div class="top-bar-right">
<button type="button" id="theme-toggle-button" aria-label="切换主题" class="theme-toggle-button">
${icons.sunIcon}
${icons.moonIcon}
</button>
<button type="button" id="top-bar-messaging-button" aria-label="私信" class="messaging-button hidden">
${icons.messageIcon}
<span id="unread-messages-indicator" class="unread-badge hidden"></span>
</button>
<div id="top-bar-auth-buttons" class="auth-actions hidden">
<a href="/user/register" class="button secondary small">注册</a>
<a href="/user/login" class="button primary small">登录</a>
</div>
<div id="top-bar-user-info" class="user-info-dropdown hidden">
<button id="user-menu-button" class="user-menu-button">
${icons.userIcon}
<span class="username-text" id="top-bar-user-username"></span>
</button>
<div id="user-dropdown-menu" class="dropdown-menu hidden">
<div class="dropdown-user-email" id="top-bar-user-email"></div>
<hr class="dropdown-divider">
<a href="/user/account" id="top-bar-account-link" class="dropdown-item">账户设置</a>
<a href="/user/help" class="dropdown-item">帮助与API示例</a>
<button type="button" class="dropdown-item logout" id="top-bar-logout-button">安全登出</button>
</div>
</div>
</div>
</header>`;
}
function _generateSidebarHtml() {
return `
<aside id="sidebar" class="sidebar">
<nav class="sidebar-nav">
<ul id="account-tabs">
<li><a href="/user/account#tab-personal-info" id="tab-personal-info" class="sidebar-link" data-pane-id="tab-content-personal-info">个人信息</a></li>
<li><a href="/user/account#tab-security-settings" id="tab-security-settings" class="sidebar-link" data-pane-id="tab-content-security-settings">安全设置</a></li>
<li><a href="/user/account#tab-api-keys" id="tab-api-keys" class="sidebar-link" data-pane-id="tab-content-api-keys">获取/查看密钥</a></li>
<li><a href="/user/account#tab-my-applications" id="tab-my-applications" class="sidebar-link" data-pane-id="tab-content-my-applications">我的应用</a></li>
</ul>
</nav>
</aside>`;
}
function _generateAuthFormsHtml(showLogin, showRegister, env, authSectionVisible) {
const turnstileSiteKey = env.TURNSTILE_SITE_KEY || '1x00000000000000000000AA';
return `
<div id="auth-section" class="auth-section ${authSectionVisible ? '' : 'hidden'}">
<div id="login-form" class="form-container ${showLogin ? '' : 'hidden'}">
<h2>用户登录</h2>
<form id="login-form-el">
<div class="form-group"><label for="login-identifier">邮箱或用户名</label><input type="text" id="login-identifier" name="identifier" required autocomplete="username email" placeholder="请输入您的邮箱或用户名"></div>
<div class="form-group"><label for="login-password">密码</label><input type="password" id="login-password" name="password" required autocomplete="current-password" placeholder="请输入密码"></div>
<div id="login-2fa-section" class="form-group hidden"><label for="login-totp-code">两步验证码</label><input type="text" id="login-totp-code" name="totpCode" pattern="\\d{6}" maxlength="6" placeholder="请输入6位验证码"></div>
<div class="cf-turnstile" data-sitekey="${_escapeHtml(turnstileSiteKey)}" data-callback="turnstileCallbackLogin"></div>
<button type="submit" class="button primary full-width">登录</button>
</form>
<div class="toggle-link">还没有账户? <a href="/user/register">立即注册</a></div>
</div>
<div id="register-form" class="form-container ${showRegister ? '' : 'hidden'}">
<h2>新用户注册</h2>
<form id="register-form-el">
<div class="form-group"><label for="register-username">用户名</label><input type="text" id="register-username" name="username" required minlength="3" maxlength="30" placeholder="3-30位字符,可包含字母、数字、_-"></div>
<div class="form-group"><label for="register-email">邮箱地址</label><input type="email" id="register-email" name="email" required autocomplete="email" placeholder="例如:user@example.com"></div>
<div class="form-group"><label for="register-phone">手机号码 (可选)</label><input type="tel" id="register-phone" name="phoneNumber" autocomplete="tel" placeholder="例如:+8613800138000"></div>
<div class="form-group"><label for="register-password">设置密码 (至少6位)</label><input type="password" id="register-password" name="password" required minlength="6" autocomplete="new-password" placeholder="请输入至少6位密码"></div>
<div class="form-group"><label for="register-confirm-password">确认密码</label><input type="password" id="register-confirm-password" name="confirmPassword" required minlength="6" autocomplete="new-password" placeholder="请再次输入密码"></div>
<div class="cf-turnstile" data-sitekey="${_escapeHtml(turnstileSiteKey)}" data-callback="turnstileCallbackRegister"></div>
<button type="submit" class="button primary full-width">创建账户</button>
</form>
<div class="toggle-link">已经有账户了? <a href="/user/login">返回登录</a></div>
</div>
</div>`;
}
function _generateAccountPanesHtml(env, icons) {
const turnstileSiteKey = env.TURNSTILE_SITE_KEY || '1x00000000000000000000AA';
return `
<div id="tab-content-personal-info" class="tab-pane">
<h3>个人信息修改</h3>
<form id="update-profile-form" class="content-form">
<div class="form-group"><label for="profile-username">用户名</label><input type="text" id="profile-username" name="username" required minlength="3" maxlength="30" placeholder="3-30位字符"></div>
<div class="form-group"><label for="profile-phone">手机号码 (可选)</label><input type="tel" id="profile-phone" name="phoneNumber" placeholder="例如:+8613800138000"></div>
<div class="form-group"><label for="profile-email">邮箱地址 (不可修改)</label><input type="email" id="profile-email" name="email" readonly disabled></div>
<button type="submit" class="button primary">保存个人信息</button>
</form>
</div>
<div id="tab-content-security-settings" class="tab-pane hidden">
<h3>安全设置</h3>
<ul class="security-settings-list">
<li class="security-setting-item">
<div class="setting-entry" data-target="change-password-content-panel">
<span class="entry-title">修改密码</span>
<span class="entry-arrow">▼</span>
</div>
<div id="change-password-content-panel" class="setting-content-panel hidden">
<form id="change-password-form" class="content-form">
<div class="form-group"><label for="current-password">当前密码</label><input type="password" id="current-password" name="currentPassword" required autocomplete="current-password" placeholder="请输入您当前的密码"></div>
<div class="form-group"><label for="new-password">新密码 (至少6位)</label><input type="password" id="new-password" name="newPassword" required minlength="6" autocomplete="new-password" placeholder="请输入新的密码"></div>
<button type="submit" class="button secondary">确认修改密码</button>
</form>
</div>
</li>
<li class="security-setting-item">
<div class="setting-entry" data-target="2fa-content-panel">
<span class="entry-title">两步验证 (2FA)</span>
<span class="entry-status" id="2fa-entry-status">(未知)</span>
<span class="entry-arrow">▼</span>
</div>
<div id="2fa-content-panel" class="setting-content-panel hidden">
<p class="description-text">使用两步验证码可以帮您双重保护账户安全。</p>
<div id="2fa-status-section" class="status-display">当前状态: <span id="2fa-current-status">未知</span></div>
<div id="2fa-controls" style="margin-bottom: 15px;">
<button type="button" id="btn-init-enable-2fa" class="button success small hidden">启用两步验证</button>
<button type="button" id="btn-disable-2fa" class="button danger small hidden">禁用两步验证</button>
</div>
<div id="2fa-setup-section" class="hidden" style="margin-top:20px;">
<p>1. 使用您的身份验证器应用扫描二维码或手动输入密钥。</p>
<div style="text-align: center; margin: 15px 0;"><div id="qrcode-display"></div></div>
<p>密钥链接: <code id="otpauth-uri-text-display" class="otpauth-uri-text"></code></p>
<input type="hidden" id="2fa-temp-secret">
<div class="form-group" style="margin-top:10px;"><label for="2fa-setup-code">6位验证码</label><input type="text" id="2fa-setup-code" name="totpCode" pattern="\\d{6}" maxlength="6" placeholder="请输入验证码"></div>
<div class="form-actions">
<button type="button" id="btn-complete-enable-2fa" class="button success">验证并启用</button>
<button type="button" id="btn-cancel-2fa-setup" class="button secondary">取消</button>
</div>
</div>
</div>
</li>
</ul>
</div>
<div id="tab-content-api-keys" class="tab-pane hidden">
<h3>获取/查看密钥</h3>
<ul class="security-settings-list">
<li class="security-setting-item">
<div class="setting-entry" data-target="paste-api-key-content-panel">
<span class="entry-title">云剪贴板 API 密钥</span>
<span class="entry-arrow">▼</span>
</div>
<div id="paste-api-key-content-panel" class="setting-content-panel hidden">
<p class="description-text">API 密钥名称将自动生成,仅拥有文本和文件权限。</p>
<p class="description-text">访问云剪贴板服务:<a href="http://go.qmwneb946.dpdns.org/?LinkId=37" target="_blank" rel="noopener noreferrer" class="external-link">http://go.qmwneb946.dpdns.org/?LinkId=37</a></p>
<form id="create-paste-api-key-form" class="content-form" style="margin-top:15px;">
<div class="cf-turnstile" data-sitekey="${_escapeHtml(turnstileSiteKey)}" data-callback="turnstileCallbackPasteApi"></div>
<button type="submit" class="button success">创建云剪贴板 API 密钥</button>
</form>
<div id="newly-created-api-key-display" class="hidden api-key-display" style="margin-top:15px;">
<h5>新创建的云剪贴板 API 密钥:</h5>
<div class="api-key-value-container">
<input type="text" id="new-api-key-value" readonly><button type="button" class="button small secondary" onclick="copyToClipboard(document.getElementById('new-api-key-value').value, '云剪贴板 API 密钥')">复制</button>
</div>
</div>
</div>
</li>
<li class="security-setting-item">
<div class="setting-entry" data-target="cloud-pc-key-content-panel">
<span class="entry-title">Cloud PC 密钥</span>
<span class="entry-status" id="cloud-pc-key-entry-status">(点击展开查看状态)</span>
<span class="entry-arrow">▼</span>
</div>
<div id="cloud-pc-key-content-panel" class="setting-content-panel hidden">
<div id="cloud-pc-key-status-area" class="status-display">正在加载 Cloud PC 密钥状态...</div>
<p class="description-text">访问 Cloud PC 服务:<a href="http://go.qmwneb946.dpdns.org/?LinkId=17" target="_blank" rel="noopener noreferrer" class="external-link">http://go.qmwneb946.dpdns.org/?LinkId=17</a></p>
<form id="create-cloud-pc-key-form" class="content-form hidden" style="margin-top:15px;">
<p class="description-text">每位用户仅可创建一次,获得 1 次使用次数。</p>
<div class="cf-turnstile" data-sitekey="${_escapeHtml(turnstileSiteKey)}" data-callback="turnstileCallbackCloudPc"></div>
<button type="submit" class="button success">创建 Cloud PC 密钥</button>
</form>
<div id="existing-cloud-pc-key-display" class="hidden api-key-display" style="margin-top:15px;">
<h5>您的 Cloud PC 密钥:</h5>
<div class="api-key-value-container">
<input type="text" id="cloud-pc-api-key-value" readonly><button type="button" class="button small secondary" onclick="copyToClipboard(document.getElementById('cloud-pc-api-key-value').value, 'Cloud PC API 密钥')">复制</button>
</div>
<p>剩余使用次数: <strong id="cloud-pc-usage-count"></strong></p>
</div>
</div>
</li>
<li class="security-setting-item">
<div class="setting-entry" data-target="greenhub-key-content-panel">
<span class="entry-title">GreenHub 激活码</span>
<span class="entry-arrow">▼</span>
</div>
<div id="greenhub-key-content-panel" class="setting-content-panel hidden">
<p class="description-text">查看您已获取的 GreenHub 激活码。</p>
<button type="button" id="btn-fetch-greenhub-keys" class="button primary">获取 GreenHub 激活码</button>
<div id="greenhub-codes-display" style="margin-top:15px;">
<p class="placeholder-text">点击按钮获取激活码。</p>
</div>
</div>
</li>
</ul>
</div>
<div id="tab-content-my-applications" class="tab-pane hidden">
<h3>我的 OAuth 应用</h3>
<div class="setting-block">
<h4>注册新应用</h4>
<form id="register-oauth-client-form" class="content-form">
<div class="form-group"><label for="oauth-client-name">应用名称</label><input type="text" id="oauth-client-name" name="clientName" required maxlength="50" placeholder="例如:我的博客评论系统"></div>
<div class="form-group"><label for="oauth-client-website">应用主页 (可选)</label><input type="url" id="oauth-client-website" name="clientWebsite" maxlength="200" placeholder="例如:https://myblog.com"></div>
<div class="form-group"><label for="oauth-client-description">应用描述 (可选)</label><input type="text" id="oauth-client-description" name="clientDescription" maxlength="200" placeholder="简要描述您的应用"></div>
<div class="form-group"><label for="oauth-client-redirect-uri">回调地址 (Redirect URI)</label><input type="url" id="oauth-client-redirect-uri" name="redirectUri" required placeholder="例如:https://myblog.com/oauth/callback"><p class="input-hint">必须是 HTTPS 地址。</p></div>
<div class="cf-turnstile" data-sitekey="${_escapeHtml(turnstileSiteKey)}" data-callback="turnstileCallbackOauthClient"></div>
<button type="submit" class="button success">注册应用</button>
</form>
<div id="new-oauth-client-credentials" class="hidden" style="margin-top: 20px;">
<h5 style="color: var(--primary-color);">应用注册成功!</h5>
<p class="description-text">请妥善保管您的应用凭据,特别是 <strong>客户端密钥 (Client Secret)</strong>,它将仅显示这一次。</p>
<div class="application-card">
<p><strong>客户端 ID:</strong> <code id="new-client-id-display"></code> <button type="button" class="button small secondary" onclick="copyToClipboard(document.getElementById('new-client-id-display').textContent, '客户端 ID')">复制</button></p>
<p><strong>客户端密钥:</strong> <code id="new-client-secret-display"></code> <button type="button" class="button small secondary" onclick="copyToClipboard(document.getElementById('new-client-secret-display').textContent, '客户端密钥')">复制</button></p>
</div>
<div class="new-client-secret-warning"><strong>重要提示:</strong> 客户端密钥非常敏感,请立即复制并安全存储。</div>
</div>
</div>
<hr class="section-divider">
<h4>已注册的应用</h4>
<div id="registered-oauth-clients-list"><p>正在加载应用列表...</p></div>
</div>`;
}
function _generateModalsHtml() {
return `
<div id="edit-oauth-client-modal" class="modal hidden">
<div class="modal-content">
<h4 id="edit-oauth-client-modal-title">编辑应用信息</h4>
<form id="edit-oauth-client-form">
<input type="hidden" id="edit-client-id" name="clientId">
<div class="form-group"><label for="edit-oauth-client-name">应用名称</label><input type="text" id="edit-oauth-client-name" name="clientName" required maxlength="50"></div>
<div class="form-group"><label for="edit-oauth-client-website">应用主页 (可选)</label><input type="url" id="edit-oauth-client-website" name="clientWebsite" maxlength="200"></div>
<div class="form-group"><label for="edit-oauth-client-description">应用描述 (可选)</label><input type="text" id="edit-oauth-client-description" name="clientDescription" maxlength="200"></div>
<div class="form-group"><label for="edit-oauth-client-redirect-uri">回调地址</label><input type="url" id="edit-oauth-client-redirect-uri" name="redirectUri" required><p class="input-hint">必须是 HTTPS 地址。</p></div>
<div class="modal-buttons">
<button type="button" id="btn-cancel-edit-oauth-client" class="button secondary">取消</button>
<button type="submit" class="button success">保存更改</button>
</div>
</form>
</div>
</div>`;
}
export function generateHtmlUi(currentPath = '/', env = {}) {
const icons = _generateIcons();
const cdnBaseUrl = env.CDN_BASE_URL || "https://cdn.qmwneb946.dpdns.org";
const siteName = "用户中心";
const faviconDataUri = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Ctext y='.9em' font-size='90'%3E👤%3C/text%3E%3C/svg%3E";
const showLogin = currentPath === '/' || currentPath === '/user/login';
const showRegister = currentPath === '/user/register';
const showAccount = currentPath === '/user/account';
const authSectionVisible = showLogin || showRegister;
const appWrapperClass = authSectionVisible ? 'logged-out-layout' : (showAccount ? 'logged-in-layout' : '');
return `
<!DOCTYPE html>
<html lang="zh-CN">
${_generateHead(siteName, cdnBaseUrl, faviconDataUri)}
<body class="font-sans antialiased">
<div id="app-wrapper" class="app-wrapper ${appWrapperClass}">
${_generateTopBarHtml(siteName, icons)}
${_generateSidebarHtml()}
<div id="sidebar-overlay" class="sidebar-overlay hidden"></div>
<main id="main-content" class="main-content">
<div class="container">
<div id="message-area" class="message hidden" role="alert"></div>
${_generateAuthFormsHtml(showLogin, showRegister, env, authSectionVisible)}
<div id="logged-in-section" class="account-content ${showAccount ? '' : 'hidden'}">
${_generateAccountPanesHtml(env, icons)}
</div>
</div>
</main>
</div>
${_generateModalsHtml()}
${_generateScripts(cdnBaseUrl, '', true)}
</body>
</html>`;
}
export function generateMessagingPageHtml(env = {}) {
const icons = _generateIcons();
const cdnBaseUrl = env.CDN_BASE_URL || "https://cdn.qmwneb946.dpdns.org";
const siteName = "用户中心";
const pageTitle = "个人私信 - " + siteName;
const faviconDataUri = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Ctext y='.9em' font-size='90'%3E👤%3C/text%3E%3C/svg%3E";
const messagingPageStyles = `
.app-wrapper.messaging-page-layout .sidebar { display: none; } /* Or manage display via main.js based on screen size */
.app-wrapper.messaging-page-layout .main-content { margin-left: 0; }
.app-wrapper.messaging-page-layout .main-content .container { padding: 0; max-width: 100%; background-color: var(--current-bg-color); }
.messaging-page-container { display: flex; flex-direction: column; height: calc(100vh - var(--top-bar-height)); }
`;
const messagingPageSpecificScripts = `
document.addEventListener('DOMContentLoaded', () => {
if (typeof window.checkLoginStatus === 'function') {
window.checkLoginStatus().then(() => {
if (window.currentUserData && typeof window.loadMessagingTabData === 'function') {
window.loadMessagingTabData();
} else if (!window.currentUserData) {
window.location.href = '/user/login?return_to=/user/messaging';
}
});
}
// Adjust layout for messaging page, sidebar might be hidden by default on this page or handled by main.js
const appWrapper = document.getElementById('app-wrapper');
if(appWrapper) appWrapper.classList.add('messaging-page-layout');
const sidebarToggle = document.getElementById('sidebar-toggle');
if(sidebarToggle) sidebarToggle.classList.add('hidden'); // Hide sidebar toggle on dedicated messaging page
});
`;
return `
<!DOCTYPE html>
<html lang="zh-CN">
${_generateHead(pageTitle, cdnBaseUrl, faviconDataUri, messagingPageStyles)}
<body class="font-sans antialiased">
<div id="app-wrapper" class="app-wrapper logged-in-layout messaging-page-layout">
${_generateTopBarHtml(siteName, icons, false, true)}
<aside id="sidebar" class="sidebar hidden"></aside> {/* Sidebar can be empty or hidden by CSS/JS */}
<div id="sidebar-overlay" class="sidebar-overlay hidden"></div>
<main id="main-content" class="main-content">
<div class="container">
<div id="message-area" class="message hidden" role="alert"></div>
<div class="messaging-page-container"> {/* Wrapper for messaging content */}
<div class="messaging-tab-header" style="padding: 15px 20px 10px; margin-bottom:0;">
${icons.mailIcon}
<h3 style="margin-bottom:0; border-bottom:none; padding-bottom:0; font-size: 1.75rem;">个人私信</h3>
</div>
<div class="new-conversation-trigger" id="new-conversation-area-messaging" style="padding: 0px 20px 15px; border-bottom: 1px solid var(--current-border-color);">
<input type="email" id="new-conversation-email" placeholder="输入对方邮箱开始新对话...">
<button type="button" id="btn-start-new-conversation" class="button primary small">开始</button>
</div>
<div class="messaging-layout-new" style="height: calc(100% - 130px); border:none; box-shadow:none; border-radius:0;"> {/* Adjusted height and remove borders */}
<div class="messaging-contacts-panel">
<div class="contact-search-bar">
<input type="search" id="contact-search-input" placeholder="搜索联系人...">
</div>
<h4 class="recent-contacts-title">最近联系</h4>
<ul id="conversations-list" class="contact-list">
<p class="placeholder-text">正在加载对话...</p>
</ul>
</div>
<div id="messages-area" class="message-display-panel">
<div id="messages-list-wrapper">
<div id="message-loader" class="message-loader hidden">
<div class="spinner"></div>
</div>
<div id="messages-list" class="messages-list">
<div class="empty-messages-placeholder">
<p>选择一个联系人开始聊天</p>
<span>或通过上方搜索框发起新的对话。</span>
</div>
</div>
</div>
<div id="message-input-area" class="message-input-area hidden">
<textarea id="message-input" placeholder="输入消息..." rows="1"></textarea>
<button type="button" id="btn-send-message" class="button primary">发送</button>
</div>
</div>
</div>
</div>
</div>
</main>
</div>
${_generateScripts(cdnBaseUrl, messagingPageSpecificScripts, true)}
</body>
</html>`;
}
export function generateHelpPageHtml(env = {}) {
const icons = _generateIcons();
const siteName = "用户中心";
const faviconDataUri = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Ctext y='.9em' font-size='90'%3E👤%3C/text%3E%3C/svg%3E";
const cdnBaseUrl = env.CDN_BASE_URL || "https://cdn.qmwneb946.dpdns.org";
const currentIssuerUrl = _getIssuerUrl(env, { url: (typeof window !== 'undefined' && window.location.href) ? window.location.href : 'https://my.qmwneb946.dpdns.org' });
const oauthRedirectUrlExample = `${currentIssuerUrl}/oauth/authorize?response_type=code&client_id=YOUR_CLIENT_ID&redirect_uri=YOUR_REGISTERED_REDIRECT_URI&scope=openid%20profile%20email&state=YOUR_OPAQUE_STATE_VALUE&nonce=YOUR_UNIQUE_NONCE_VALUE`;
const curlTokenExample = `curl -X POST "${currentIssuerUrl}/oauth/token" \\
-H "Content-Type: application/x-www-form-urlencoded" \\
-d "grant_type=authorization_code" \\
-d "code=THE_AUTHORIZATION_CODE_YOU_RECEIVED" \\
-d "redirect_uri=YOUR_REGISTERED_REDIRECT_URI" \\
-d "client_id=YOUR_CLIENT_ID" \\
-d "client_secret=YOUR_CLIENT_SECRET"`;
const curlUserInfoExample = `curl -X GET "${currentIssuerUrl}/oauth/userinfo" \\
-H "Authorization: Bearer YOUR_ACCESS_TOKEN"`;
const helpPageStyles = `
.help-content { padding: 20px; }
.monaco-editor-container {
border: 1px solid var(--current-border-color);
margin-bottom: 1em;
overflow: hidden;
}
.api-usage-section :not(pre) > code {
background-color: color-mix(in srgb, var(--current-text-color) 10%, transparent);
padding: 2px 5px;
border-radius: 3px;
font-family: 'Monaco', var(--font-family-mono);
}
hr.section-divider { border: none; border-top: 1px solid var(--current-border-color); margin: 30px 0; }
.app-wrapper.help-page-layout .sidebar { display: none; }
.app-wrapper.help-page-layout .main-content { margin-left: 0; }
.input-hint { font-size: 0.8rem; color: var(--current-text-muted-color); margin-top: 4px; }
.external-link { color: var(--accent-color); font-weight: 500; }
.external-link:hover { color: color-mix(in srgb, var(--accent-color) 80%, black); }`;
const helpPageScripts = `
document.addEventListener('DOMContentLoaded', function () {
const isDarkMode = document.body.classList.contains('dark-mode');
const editorTheme = isDarkMode ? 'vs-dark' : 'vs';
require.config({ paths: { 'vs': 'https://cdn.jsdelivr.net/npm/monaco-editor@0.47.0/min/vs' }});
require(['vs/editor/editor.main'], function () {
const editorContainers = document.querySelectorAll('.monaco-editor-container');
editorContainers.forEach(container => {
const codeContent = container.dataset.content || '';
let language = container.dataset.language || 'plaintext';
if (language === 'uri') language = 'plaintext';
if (language === 'bash') language = 'shell';
const lines = codeContent.split('\\\\n').length; // Use escaped newline for split
const lineHeight = 19;
let editorHeight = Math.max(60, Math.min(400, lines * lineHeight + 20));
if (lines === 1 && codeContent.length > 80) {
editorHeight = Math.max(40, Math.min(120, Math.ceil(codeContent.length / 80) * lineHeight + 10) );
} else if (lines === 1) {
editorHeight = 40;
}
container.style.height = editorHeight + 'px';
monaco.editor.create(container, {
value: codeContent,
language: language,
readOnly: true,
theme: editorTheme,
minimap: { enabled: false },
automaticLayout: true,
scrollBeyondLastLine: false,
wordWrap: 'on',
renderWhitespace: "boundary",
fontSize: 13,
fontFamily: "'Monaco', Consolas, 'Courier New', monospace",
scrollbar: {
verticalScrollbarSize: 8,
horizontalScrollbarSize: 8,
alwaysConsumeMouseWheel: false
},
lineNumbers: (lines > 1) ? 'on' : 'off',
overviewRulerLanes: (lines > 1) ? 2 : 0,
hideCursorInOverviewRuler: true,
renderLineHighlight: 'none'
});
});
});
const themeToggleButton = document.getElementById('theme-toggle-button');
if (themeToggleButton) {
let currentIsDarkMode = document.body.classList.contains('dark-mode');
function applyEditorTheme(dark) {
const newEditorTheme = dark ? 'vs-dark' : 'vs';
if (typeof monaco !== 'undefined' && monaco.editor && monaco.editor.getEditors) {
const editors = monaco.editor.getEditors();
editors.forEach(editor => {
monaco.editor.setTheme(newEditorTheme);
});
}
}
if (typeof monaco !== 'undefined' && monaco.editor) {
applyEditorTheme(currentIsDarkMode);
}
themeToggleButton.addEventListener('click', () => {
currentIsDarkMode = document.body.classList.contains('dark-mode');
if (typeof monaco !== 'undefined' && monaco.editor) {
applyEditorTheme(currentIsDarkMode);
}
});
}
const observer = new MutationObserver(mutationsList => {
for (let mutation of mutationsList) {
if (mutation.type === 'attributes' && mutation.attributeName === 'class') {
const currentIsDarkModeForObserver = document.body.classList.contains('dark-mode');
if (typeof monaco !== 'undefined' && monaco.editor && monaco.editor.getEditors && monaco.editor.getEditors().length > 0) {
const editors = monaco.editor.getEditors();
const newEditorTheme = currentIsDarkModeForObserver ? 'vs-dark' : 'vs';
if (editors[0] && editors[0].getOptions().get(monaco.editor.EditorOption.theme) !== newEditorTheme) {
monaco.editor.setTheme(newEditorTheme);
}
}
}
}
});
observer.observe(document.body, { attributes: true });
});`;
return `
<!DOCTYPE html>
<html lang="zh-CN">
${_generateHead(`帮助与API示例 - ${siteName}`, cdnBaseUrl, faviconDataUri, helpPageStyles, true)}
<body class="font-sans antialiased">
<div id="app-wrapper" class="app-wrapper help-page-layout">
${_generateTopBarHtml(siteName, icons, true)}
<aside id="sidebar" class="sidebar hidden">
<nav class="sidebar-nav">
<ul id="account-tabs">
<li><a href="/user/account#tab-personal-info" class="sidebar-link">返回账户</a></li>
</ul>
</nav>
</aside>
<div id="sidebar-overlay" class="sidebar-overlay hidden"></div>
<main id="main-content" class="main-content">
<div class="container">
<div id="message-area" class="message hidden" role="alert"></div>
<div id="help-page-content" class="help-content api-usage-section">
<h3>欢迎来到 API 使用帮助中心</h3>
<p>本页面提供关于如何使用本用户中心提供的各种 API 和服务的示例与说明。</p>
<hr class="section-divider">
<h3>OAuth 2.0 / OpenID Connect (OIDC)</h3>
<p>本用户中心支持作为 OAuth 2.0 和 OpenID Connect 的身份提供方 (IdP)。开发者可以使用它来为自己的应用添加用户认证和授权功能。</p>
<h4>1. 获取授权码 (Authorization Code)</h4>
<p>首先,您的应用需要将用户重定向到本用户中心的授权端点 (<code>${_escapeHtml(currentIssuerUrl)}/oauth/authorize</code>)。用户登录并同意授权后,本系统会将用户重定向回您应用注册的回调地址 (Redirect URI),并附带一个授权码。</p>
<p>例如,重定向 URL 可能如下:</p>
<div class="monaco-editor-container" data-language="uri" data-content="${_escapeHtml(oauthRedirectUrlExample.replace(/\n/g, '\\n'))}"></div>
<p class="input-hint">请确保替换上述 URL 中的占位符 (<code>YOUR_CLIENT_ID</code>, <code>YOUR_REGISTERED_REDIRECT_URI</code>, <code>YOUR_OPAQUE_STATE_VALUE</code>, <code>YOUR_UNIQUE_NONCE_VALUE</code>) 为您的实际值。</p>
<h4>2. 使用授权码交换令牌 (Access Token & ID Token)</h4>
<p>获得授权码后,您的应用后端服务需要向令牌端点 (<code>${_escapeHtml(currentIssuerUrl)}/oauth/token</code>) 发起 POST 请求,以授权码交换访问令牌 (Access Token) 和身份令牌 (ID Token)。</p>
<div class="monaco-editor-container" data-language="shell" data-content="${_escapeHtml(curlTokenExample.replace(/\n/g, '\\n'))}"></div>
<p>成功的响应将是一个 JSON 对象,其中包含 <code>access_token</code>, <code>id_token</code>, <code>token_type</code>, <code>expires_in</code>, 以及可能的 <code>refresh_token</code> (如果请求了 <code>offline_access</code> 范围并且客户端允许)。</p>
<h4>3. 使用访问令牌获取用户信息</h4>
<p>获得访问令牌后,您的应用可以使用它向用户信息端点 (<code>${_escapeHtml(currentIssuerUrl)}/oauth/userinfo</code>) 请求已授权的用户信息。</p>
<div class="monaco-editor-container" data-language="shell" data-content="${_escapeHtml(curlUserInfoExample.replace(/\n/g, '\\n'))}"></div>
<p>响应内容将是一个 JSON 对象,包含用户的相关信息,具体取决于授权时请求的范围 (scopes),例如 <code>sub</code> (用户唯一标识), <code>email</code>, <code>name</code>, <code>username</code> 等。</p>
<p class="input-hint">有关 OAuth 和 OIDC 的更多详细信息,请参阅相关的官方规范文档。您也可以在本站的 <code>/.well-known/openid-configuration</code> 路径查看 OpenID Connect 发现文档。</p>
</div>
</div>
</main>
</div>
${_generateScripts(cdnBaseUrl, helpPageScripts, true)}
</body>
</html>`;
}
export function generateConsentScreenHtml(data) {
const { clientName, requestedScopes, user, formAction, clientId, redirectUri, scope, state, nonce, responseType, issuerUrl: consentIssuerUrl, cdnBaseUrl: consentCdnBaseUrl, env = {} } = data;
const finalCdnBaseUrl = consentCdnBaseUrl || env.CDN_BASE_URL || "https://cdn.qmwneb946.dpdns.org";
const siteName = "用户中心授权";
const faviconDataUri = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Ctext y='.9em' font-size='90'%3E👤%3C/text%3E%3C/svg%3E";
const scopesHtml = requestedScopes.map(s => `<li>${_escapeHtml(s)}</li>`).join('');
const consentScreenStyles = `
body { display: flex; justify-content: center; align-items: center; min-height: 100vh; padding: 20px; background-color: var(--current-bg-color); color: var(--current-text-color); }
.consent-container { background-color: var(--current-surface-color); padding: 30px 40px; border-radius: var(--border-radius); box-shadow: var(--box-shadow-md); max-width: 480px; width:100%; text-align: center; }
.consent-container h2 { margin-top: 0; color: var(--current-heading-color); font-size: 1.8em; margin-bottom: 20px; }
.consent-container p { margin-bottom: 15px; line-height: 1.6; font-size: 1.05em; }
.client-name { font-weight: bold; color: var(--primary-color); }
.user-identifier { font-weight: bold; }
.scopes-list { list-style: inside disc; text-align: left; margin: 20px auto; padding-left: 25px; max-width: fit-content; }
.scopes-list li { margin-bottom: 8px; }
.consent-buttons { margin-top: 30px; display: flex; justify-content: space-between; gap: 15px; }
.consent-buttons button { flex-grow: 1; }`;
const consentScreenScripts = `
if (localStorage.getItem('theme') === 'dark' || (!localStorage.getItem('theme') && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
document.body.classList.add('dark-mode');
}`;
return `
<!DOCTYPE html>
<html lang="zh-CN">
${_generateHead(`授权请求 - ${siteName}`, finalCdnBaseUrl, faviconDataUri, consentScreenStyles)}
<body class="font-sans antialiased">
<div class="consent-container">
<h2>授权请求</h2>
<p>应用 <strong class="client-name">${_escapeHtml(clientName)}</strong> (${_escapeHtml(clientId)})</p>
<p>正在请求访问您 <strong class="user-identifier">(${_escapeHtml(user.username || user.email)})</strong> 的以下信息:</p>
<ul class="scopes-list">${scopesHtml}</ul>
<p>您是否允许此应用访问?</p>
<form method="POST" action="${_escapeHtml(formAction)}">
<input type="hidden" name="client_id" value="${_escapeHtml(clientId)}">
<input type="hidden" name="redirect_uri" value="${_escapeHtml(redirectUri)}">
<input type="hidden" name="scope" value="${_escapeHtml(scope)}">
<input type="hidden" name="state" value="${_escapeHtml(state || '')}">
<input type="hidden" name="nonce" value="${_escapeHtml(nonce || '')}">
<input type="hidden" name="response_type" value="${_escapeHtml(responseType)}">
<div class="consent-buttons">
<button type="submit" name="decision" value="deny" class="button secondary">拒绝</button>
<button type="submit" name="decision" value="allow" class="button primary">允许</button>
</div>
</form>
</div>
${_generateScripts(finalCdnBaseUrl, consentScreenScripts, true)}
</body>
</html>`;
}
export function generateErrorPageHtml(data) {
const { title, message, issuerUrl, cdnBaseUrl: dataCdnBaseUrl, env = {} } = data;
const cdnBaseUrl = dataCdnBaseUrl || env.CDN_BASE_URL || "https://cdn.qmwneb946.dpdns.org";
const siteName = "用户中心";
const faviconDataUri = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Ctext y='.9em' font-size='90'%3E👤%3C/text%3E%3C/svg%3E";
const finalIssuerUrl = issuerUrl || _getIssuerUrl(env, { url: (typeof window !== 'undefined' && window.location.href) ? window.location.href : 'https://my.qmwneb946.dpdns.org' });
const errorPageStyles = `
body { display: flex; justify-content: center; align-items: center; min-height: 100vh; padding: 20px; background-color: var(--current-bg-color); color: var(--current-text-color); }
.error-container { background-color: var(--current-surface-color); padding: 30px 40px; border-radius: var(--border-radius); box-shadow: var(--box-shadow-md); max-width: 450px; width: 100%; text-align: center;}
.error-container h1 { color: var(--danger-color); margin-top: 0; font-size: 2em; margin-bottom: 15px;}
.error-container p { font-size: 1.1em; margin-bottom: 25px; }
.error-container a { color: var(--primary-color); text-decoration: none; font-weight: 500; }
.error-container a:hover { text-decoration: underline; }`;
const errorPageScripts = `
if (localStorage.getItem('theme') === 'dark' || (!localStorage.getItem('theme') && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
document.body.classList.add('dark-mode');
}`;
return `
<!DOCTYPE html>
<html lang="zh-CN">
${_generateHead(`错误 - ${siteName}`, cdnBaseUrl, faviconDataUri, errorPageStyles)}
<body class="font-sans antialiased">
<div class="error-container">
<h1>${_escapeHtml(title)}</h1>
<p>${_escapeHtml(message)}</p>
<p><a href="${_escapeHtml(finalIssuerUrl || '/user/login')}">返回登录或首页</a></p>
</div>
${_generateScripts(cdnBaseUrl, errorPageScripts, true)}
</body>
</html>`;
}
3. cdn/js/main.js
- 修改顶部私信按钮 (
topBarMessagingButton) 的事件监听器。之前它调用activateTab,现在应该直接导航到/user/messaging。 - 由于私信功能现在是独立页面,原先在
activateTab中专门为tab-content-messaging加载数据的逻辑 (if (pane.id === 'tab-content-messaging' && typeof window.loadMessagingTabData === 'function')) 可以移除或调整。 loadMessagingTabData函数(在ui-messaging.js中)现在将作为/user/messaging页面的主要初始化函数。它应该在DOMContentLoaded事件后,并且在checkLoginStatus确认用户已登录后被调用。
let TURNSTILE_SITE_KEY = '1x00000000000000000000AA';
const activeTurnstileWidgets = new Map();
let loginEmailFor2FA = null;
let currentUserData = null;
let messageArea, authSection, loggedInSection, loginFormEl, registerFormEl;
let topBarUserEmailEl, topBarUserUsernameEl, topBarUserInfoEl, topBarAuthButtonsEl, topBarLogoutButtonEl, userMenuButtonEl, userDropdownMenuEl, topBarAccountLinkEl;
let sidebarEl, sidebarToggleEl, mainContentContainerEl, sidebarOverlayEl;
let accountTabLinks = [];
let tabPanes = [];
let themeToggleButton, themeToggleDarkIcon, themeToggleLightIcon;
let unreadMessagesIndicator;
let appWrapper;
let topBarMessagingButton;
let userPresenceSocket = null;
function renderTurnstile(containerElement) {
if (!containerElement || !window.turnstile || typeof window.turnstile.render !== 'function') return;
if (!TURNSTILE_SITE_KEY) { return; }
if (activeTurnstileWidgets.has(containerElement)) {
try { turnstile.remove(activeTurnstileWidgets.get(containerElement)); } catch (e) { }
activeTurnstileWidgets.delete(containerElement);
}
containerElement.innerHTML = '';
try {
const widgetId = turnstile.render(containerElement, {
sitekey: TURNSTILE_SITE_KEY,
callback: (token) => {
const specificCallbackName = containerElement.getAttribute('data-callback');
if (specificCallbackName && typeof window[specificCallbackName] === 'function') {
window[specificCallbackName](token);
}
}
});
if (widgetId) activeTurnstileWidgets.set(containerElement, widgetId);
} catch (e) { }
}
function removeTurnstile(containerElement) {
if (containerElement && activeTurnstileWidgets.has(containerElement)) {
try { turnstile.remove(activeTurnstileWidgets.get(containerElement)); } catch (e) { }
activeTurnstileWidgets.delete(containerElement);
}
}
function resetTurnstileInContainer(containerElement) {
if (containerElement && activeTurnstileWidgets.has(containerElement)) {
try { turnstile.reset(activeTurnstileWidgets.get(containerElement)); } catch (e) { }
} else if (containerElement) {
renderTurnstile(containerElement);
}
}
function clearMessages() { if (messageArea) { messageArea.textContent = ''; messageArea.className = 'message hidden'; } }
function showMessage(text, type = 'error', isHtml = false) {
if (messageArea) {
if (isHtml) { messageArea.innerHTML = text; } else { messageArea.textContent = text; }
messageArea.className = 'message ' + type; messageArea.classList.remove('hidden');
const mainContent = document.getElementById('main-content');
if (mainContent) {
mainContent.scrollTo({ top: 0, behavior: 'smooth' });
} else {
window.scrollTo({ top: 0, behavior: 'smooth' });
}
}
}
async function apiCall(endpoint, method = 'GET', body = null) {
const options = { method, headers: {}, credentials: 'include' };
if (body) { options.headers['Content-Type'] = 'application/json'; options.body = JSON.stringify(body); }
try {
const response = await fetch(endpoint, options);
let resultData = {};
const contentType = response.headers.get("content-type");
if (contentType && contentType.includes("application/json") && response.status !== 204) {
try { resultData = await response.json(); } catch (e) { }
}
return { ok: response.ok, status: response.status, data: resultData };
} catch (e) {
showMessage('发生网络或服务器错误,请稍后重试。', 'error');
return { ok: false, status: 0, data: { error: '网络错误' } };
}
}
function updateUnreadMessagesIndicatorUI(count) {
const localUnreadIndicator = document.getElementById('unread-messages-indicator');
const localMessagingButton = document.getElementById('top-bar-messaging-button');
if (!localUnreadIndicator || !localMessagingButton) return;
if (count > 0) {
localUnreadIndicator.textContent = count;
localUnreadIndicator.classList.remove('hidden');
localMessagingButton.classList.add('active');
} else {
localUnreadIndicator.textContent = '';
localUnreadIndicator.classList.add('hidden');
localMessagingButton.classList.remove('active');
}
}
function connectUserPresenceWebSocket() {
if (userPresenceSocket && (userPresenceSocket.readyState === WebSocket.OPEN || userPresenceSocket.readyState === WebSocket.CONNECTING)) {
return;
}
if (!currentUserData || !currentUserData.email) {
return;
}
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}/api/ws/user`;
userPresenceSocket = new WebSocket(wsUrl);
userPresenceSocket.onopen = () => {
if (userPresenceSocket && userPresenceSocket.readyState === WebSocket.OPEN) {
userPresenceSocket.send(JSON.stringify({type: "REQUEST_INITIAL_STATE"}));
}
};
userPresenceSocket.onmessage = (event) => {
try {
const message = JSON.parse(event.data);
if (message.type === "CONVERSATIONS_LIST") {
if (typeof window.handleConversationsListUpdate === 'function') {
window.handleConversationsListUpdate(message.data);
}
} else if (message.type === "UNREAD_COUNT_TOTAL") {
updateUnreadMessagesIndicatorUI(message.data.unread_count);
} else if (message.type === "CONVERSATION_UPDATE") {
if (typeof window.handleSingleConversationUpdate === 'function') {
window.handleSingleConversationUpdate(message.data);
}
} else if (message.type === "ERROR") {
if (typeof window.showMessage === 'function') {
window.showMessage(message.data || '从服务器收到错误消息。', 'error');
}
if (typeof window.handleConversationsListUpdate === 'function') {
window.handleConversationsListUpdate([]);
}
}
} catch (e) {
}
};
userPresenceSocket.onclose = (event) => {
userPresenceSocket = null;
if (currentUserData && currentUserData.email) {
setTimeout(connectUserPresenceWebSocket, 5000);
}
};
userPresenceSocket.onerror = (error) => {
};
}
function applyTheme(isDark) {
document.body.classList.toggle('dark-mode', isDark);
if (themeToggleDarkIcon) themeToggleDarkIcon.style.display = isDark ? 'block' : 'none';
if (themeToggleLightIcon) themeToggleLightIcon.style.display = isDark ? 'none' : 'block';
const qrCodeDisplay = document.getElementById('qrcode-display');
const otpAuthUriTextDisplay = document.getElementById('otpauth-uri-text-display');
if (qrCodeDisplay && typeof QRCode !== 'undefined' && otpAuthUriTextDisplay) {
const otpauthUri = otpAuthUriTextDisplay.textContent;
if (otpauthUri && qrCodeDisplay.innerHTML.includes('canvas')) {
qrCodeDisplay.innerHTML = '';
new QRCode(qrCodeDisplay, {
text: otpauthUri, width: 180, height: 180,
colorDark: isDark ? "#e2e8f0" : "#000000",
colorLight: "#ffffff",
correctLevel: QRCode.CorrectLevel.H
});
}
}
}
function toggleSidebar() {
if (sidebarEl && sidebarOverlayEl && appWrapper) {
const isOpen = sidebarEl.classList.toggle('open');
sidebarOverlayEl.classList.toggle('hidden', !isOpen);
appWrapper.classList.toggle('sidebar-open-app', isOpen);
}
}
function activateTab(tabLinkToActivate) {
if (!tabLinkToActivate) {
return;
}
let paneIdToActivate = tabLinkToActivate.dataset.paneId;
if (accountTabLinks) {
accountTabLinks.forEach(link => link.classList.remove('selected'));
}
tabLinkToActivate.classList.add('selected');
if (!mainContentContainerEl) {
return;
}
tabPanes.forEach(pane => {
const turnstileDivsInPane = pane.querySelectorAll('.cf-turnstile');
if (pane.id === paneIdToActivate) {
pane.classList.remove('hidden');
turnstileDivsInPane.forEach(div => renderTurnstile(div));
if (pane.id === 'tab-content-api-keys' && typeof window.initializeApiKeysTab === 'function') window.initializeApiKeysTab();
if (pane.id === 'tab-content-my-applications' && typeof window.loadOauthAppsTabData === 'function') window.loadOauthAppsTabData();
if (pane.id === 'tab-content-security-settings' && typeof window.initializeSecuritySettings === 'function') {
if (currentUserData) {
window.initializeSecuritySettings(currentUserData);
} else {
apiCall('/api/me').then(response => {
if (response.ok && response.data) {
currentUserData = response.data;
window.initializeSecuritySettings(currentUserData);
}
});
}
}
} else {
turnstileDivsInPane.forEach(div => removeTurnstile(div));
pane.classList.add('hidden');
}
});
clearMessages();
const newlyCreatedApiKeyDisplayDiv = document.getElementById('newly-created-api-key-display');
const newOauthClientCredentialsDiv = document.getElementById('new-oauth-client-credentials');
if (newlyCreatedApiKeyDisplayDiv) newlyCreatedApiKeyDisplayDiv.classList.add('hidden');
if (newOauthClientCredentialsDiv) newOauthClientCredentialsDiv.classList.add('hidden');
if (window.innerWidth < 769 && sidebarEl && sidebarEl.classList.contains('open')) {
toggleSidebar();
}
}
function displayCorrectView(userData) {
clearMessages();
currentUserData = userData;
const isLoggedIn = !!userData?.email;
document.querySelectorAll('.cf-turnstile').forEach(div => removeTurnstile(div));
if (topBarUserInfoEl) topBarUserInfoEl.classList.toggle('hidden', !isLoggedIn);
if (isLoggedIn && topBarUserEmailEl) topBarUserEmailEl.textContent = userData.email || '未知邮箱';
if (isLoggedIn && topBarUserUsernameEl) topBarUserUsernameEl.textContent = userData.username || '用户';
if (topBarAuthButtonsEl) topBarAuthButtonsEl.classList.toggle('hidden', isLoggedIn);
if (topBarMessagingButton) {
topBarMessagingButton.classList.toggle('hidden', !isLoggedIn);
if(isLoggedIn) topBarMessagingButton.classList.remove('active');
}
const currentPath = window.location.pathname;
if (isLoggedIn) {
if (currentPath !== '/user/messaging') { // Only show sidebar for non-messaging logged-in pages
if (sidebarEl) sidebarEl.classList.remove('hidden');
} else {
if (sidebarEl) sidebarEl.classList.add('hidden');
}
if (appWrapper) appWrapper.classList.add('logged-in-layout');
if (appWrapper) appWrapper.classList.remove('logged-out-layout');
if (['/', '/user/login', '/user/register'].includes(currentPath)) { window.location.pathname = '/user/account'; return; }
if(authSection) authSection.classList.add('hidden');
if(loggedInSection && currentPath === '/user/account') {
loggedInSection.classList.remove('hidden');
if (typeof window.initializePersonalInfoForm === 'function') window.initializePersonalInfoForm(userData);
const defaultTabId = 'tab-personal-info';
let tabToActivateId = defaultTabId;
if (window.location.hash) {
const hashTabId = window.location.hash.substring(1);
const potentialTabLink = document.getElementById(hashTabId);
if (potentialTabLink && potentialTabLink.classList.contains('sidebar-link') && hashTabId !== 'tab-messaging') {
tabToActivateId = hashTabId;
}
}
const tabLinkToActivate = document.getElementById(tabToActivateId) || document.getElementById(defaultTabId);
if (tabLinkToActivate) activateTab(tabLinkToActivate);
} else if (loggedInSection && currentPath !== '/user/account' && currentPath !== '/user/messaging') {
loggedInSection.classList.add('hidden'); // Hide if on other logged-in pages like /user/help
}
connectUserPresenceWebSocket();
} else { // Not logged in
if (userPresenceSocket && userPresenceSocket.readyState === WebSocket.OPEN) {
userPresenceSocket.close();
userPresenceSocket = null;
}
if (sidebarEl) sidebarEl.classList.add('hidden');
if (appWrapper) appWrapper.classList.remove('logged-in-layout');
if (appWrapper) appWrapper.classList.add('logged-out-layout');
if (mainContentContainerEl && window.location.pathname !== '/user/messaging') mainContentContainerEl.classList.remove('messaging-active');
if (currentPath === '/user/account' || currentPath === '/user/messaging' || currentPath === '/user/help') {
const returnTo = encodeURIComponent(currentPath + window.location.hash + window.location.search);
window.location.pathname = `/user/login?return_to=${returnTo}`;
return;
}
if(loggedInSection) loggedInSection.classList.add('hidden');
if(authSection) authSection.classList.remove('hidden');
const login2FASection = document.getElementById('login-2fa-section');
const loginFormContainer = document.getElementById('login-form');
const registerFormContainer = document.getElementById('register-form');
if (currentPath === '/' || currentPath === '/user/login') {
if(loginFormContainer) { loginFormContainer.classList.remove('hidden'); if(loginFormEl) loginFormEl.reset(); renderTurnstile(loginFormContainer.querySelector('.cf-turnstile')); }
if(registerFormContainer) registerFormContainer.classList.add('hidden');
} else if (currentPath === '/user/register') {
if(loginFormContainer) loginFormContainer.classList.add('hidden');
if(registerFormContainer) { registerFormContainer.classList.remove('hidden'); if(registerFormEl) registerFormEl.reset(); renderTurnstile(registerFormContainer.querySelector('.cf-turnstile'));}
}
if(login2FASection) login2FASection.classList.add('hidden');
loginEmailFor2FA = null;
updateUnreadMessagesIndicatorUI(0);
}
}
async function fetchAppConfigAndInitialize() {
try {
const response = await apiCall('/api/config');
if (response.ok && response.data.turnstileSiteKey) {
TURNSTILE_SITE_KEY = response.data.turnstileSiteKey;
}
} catch (error) { }
await checkLoginStatus();
}
async function checkLoginStatus() {
const { ok, status, data } = await apiCall('/api/me');
displayCorrectView(ok && data.email ? data : null);
const urlParams = new URLSearchParams(window.location.search);
if (urlParams.has('registered') && (window.location.pathname === '/user/login' || window.location.pathname === '/')) {
showMessage('注册成功!请使用您的邮箱或用户名登录。', 'success');
const newUrl = new URL(window.location);
newUrl.searchParams.delete('registered');
window.history.replaceState({}, document.title, newUrl.toString());
}
if (urlParams.has('return_to') && window.location.pathname === '/user/login' && ok && data.email) {
const returnToPath = decodeURIComponent(urlParams.get('return_to'));
const newUrl = new URL(window.location);
newUrl.searchParams.delete('return_to');
window.history.replaceState({}, document.title, newUrl.pathname + newUrl.search); // clean up URL first
window.location.href = returnToPath; // then redirect
}
}
document.addEventListener('DOMContentLoaded', () => {
messageArea = document.getElementById('message-area');
authSection = document.getElementById('auth-section');
loggedInSection = document.getElementById('logged-in-section');
loginFormEl = document.getElementById('login-form-el');
registerFormEl = document.getElementById('register-form-el');
appWrapper = document.getElementById('app-wrapper');
topBarUserEmailEl = document.getElementById('top-bar-user-email');
topBarUserUsernameEl = document.getElementById('top-bar-user-username');
topBarUserInfoEl = document.getElementById('top-bar-user-info');
topBarAuthButtonsEl = document.getElementById('top-bar-auth-buttons');
topBarLogoutButtonEl = document.getElementById('top-bar-logout-button');
userMenuButtonEl = document.getElementById('user-menu-button');
userDropdownMenuEl = document.getElementById('user-dropdown-menu');
topBarAccountLinkEl = document.getElementById('top-bar-account-link');
topBarMessagingButton = document.getElementById('top-bar-messaging-button');
sidebarEl = document.getElementById('sidebar');
sidebarToggleEl = document.getElementById('sidebar-toggle');
const mainContent = document.getElementById('main-content');
if (mainContent) {
mainContentContainerEl = mainContent.querySelector('.container');
}
sidebarOverlayEl = document.getElementById('sidebar-overlay');
if (document.getElementById('account-tabs')) {
accountTabLinks = Array.from(document.querySelectorAll('#account-tabs .sidebar-link'));
}
if (mainContentContainerEl) {
tabPanes = Array.from(mainContentContainerEl.querySelectorAll('.tab-pane'));
}
themeToggleButton = document.getElementById('theme-toggle-button');
themeToggleDarkIcon = document.getElementById('theme-toggle-dark-icon');
themeToggleLightIcon = document.getElementById('theme-toggle-light-icon');
unreadMessagesIndicator = document.getElementById('unread-messages-indicator');
let isDarkMode = localStorage.getItem('theme') === 'dark' || (!localStorage.getItem('theme') && window.matchMedia('(prefers-color-scheme: dark)').matches);
applyTheme(isDarkMode);
if (themeToggleButton) {
themeToggleButton.addEventListener('click', () => {
isDarkMode = !isDarkMode;
localStorage.setItem('theme', isDarkMode ? 'dark' : 'light');
applyTheme(isDarkMode);
});
}
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', e => {
if (localStorage.getItem('theme') === null) {
isDarkMode = e.matches;
applyTheme(isDarkMode);
}
});
accountTabLinks.forEach(link => {
link.addEventListener('click', (event) => {
event.preventDefault();
activateTab(event.currentTarget);
window.location.hash = event.currentTarget.id;
});
});
if (topBarMessagingButton) {
topBarMessagingButton.addEventListener('click', () => {
if (window.location.pathname === '/user/messaging') {
if (typeof window.loadMessagingTabData === 'function') {
window.loadMessagingTabData(); // Reload data if already on page
}
} else {
window.location.href = '/user/messaging';
}
});
}
if (sidebarToggleEl) {
sidebarToggleEl.addEventListener('click', toggleSidebar);
if (window.location.pathname === '/user/messaging') { // Hide toggle on dedicated messaging page
sidebarToggleEl.classList.add('hidden');
}
}
if (sidebarOverlayEl) sidebarOverlayEl.addEventListener('click', toggleSidebar);
if (userMenuButtonEl && userDropdownMenuEl) {
userMenuButtonEl.addEventListener('click', (event) => {
event.stopPropagation();
userDropdownMenuEl.classList.toggle('hidden');
});
document.addEventListener('click', (event) => {
if (!userDropdownMenuEl.classList.contains('hidden') && !userMenuButtonEl.contains(event.target) && !userDropdownMenuEl.contains(event.target)) {
userDropdownMenuEl.classList.add('hidden');
}
});
}
if (topBarAccountLinkEl) {
topBarAccountLinkEl.addEventListener('click', (e) => {
e.preventDefault();
if (window.location.pathname !== '/user/account') {
window.location.href = '/user/account#tab-personal-info';
} else {
const personalInfoTabLink = document.getElementById('tab-personal-info');
if (personalInfoTabLink) activateTab(personalInfoTabLink);
window.location.hash = 'tab-personal-info';
}
if (userDropdownMenuEl) userDropdownMenuEl.classList.add('hidden');
});
}
if (loginFormEl) loginFormEl.addEventListener('submit', (event) => handleAuth(event, 'login'));
if (registerFormEl) registerFormEl.addEventListener('submit', (event) => handleAuth(event, 'register'));
if (topBarLogoutButtonEl) topBarLogoutButtonEl.addEventListener('click', handleLogout);
fetchAppConfigAndInitialize();
window.addEventListener('hashchange', () => {
if (window.location.pathname === '/user/account') {
const hash = window.location.hash.substring(1);
if (hash && hash !== 'tab-messaging') { // Ignore messaging hash for account page
const tabLinkToActivateByHash = document.getElementById(hash);
if (tabLinkToActivateByHash && tabLinkToActivateByHash.classList.contains('sidebar-link')) {
activateTab(tabLinkToActivateByHash);
} else {
const defaultTabLink = document.getElementById('tab-personal-info');
if (defaultTabLink) activateTab(defaultTabLink);
}
}
}
});
});
window.handleAuth = async function(event, type) {
event.preventDefault(); clearMessages();
const form = event.target;
const turnstileContainer = form.querySelector('.cf-turnstile');
const turnstileToken = form.querySelector('[name="cf-turnstile-response"]')?.value;
const login2FASection = document.getElementById('login-2fa-section');
const loginTotpCodeInput = document.getElementById('login-totp-code');
if (!turnstileToken && turnstileContainer) {
showMessage('人机验证失败,请刷新页面或稍后重试。', 'error');
if (turnstileContainer) resetTurnstileInContainer(turnstileContainer);
return;
}
let endpoint = '', requestBody = {};
if (type === 'login') {
const identifier = form.elements['identifier'].value, password = form.elements['password'].value;
const totpCode = loginTotpCodeInput ? loginTotpCodeInput.value : '';
if (!identifier || !password) { showMessage('邮箱/用户名和密码不能为空。'); return; }
if (loginEmailFor2FA && totpCode) { endpoint = '/api/login/2fa-verify'; requestBody = { email: loginEmailFor2FA, totpCode }; }
else { endpoint = '/api/login'; requestBody = { identifier, password, turnstileToken }; }
} else {
endpoint = '/api/register';
const {email, username, password, confirmPassword, phoneNumber} = Object.fromEntries(new FormData(form));
if (password !== confirmPassword) { showMessage('两次输入的密码不一致。'); return; }
if (!email || !username || !password) { showMessage('邮箱、用户名和密码为必填项。'); return; }
if (password.length < 6) { showMessage('密码至少需要6个字符。'); return; }
requestBody = { email, username, password, confirmPassword, phoneNumber, turnstileToken };
}
const { ok, status, data } = await apiCall(endpoint, 'POST', requestBody);
if (turnstileContainer) resetTurnstileInContainer(turnstileContainer);
if (ok && data.success) {
if (data.twoFactorRequired && data.email) {
showMessage('需要两步验证。请输入验证码。', 'info'); loginEmailFor2FA = data.email;
if(login2FASection) login2FASection.classList.remove('hidden'); if(loginTotpCodeInput) loginTotpCodeInput.focus();
} else {
form.reset();
if(login2FASection) login2FASection.classList.add('hidden'); loginEmailFor2FA = null;
const urlParams = new URLSearchParams(window.location.search);
const returnTo = urlParams.get('return_to');
if (type === 'login' || (type === 'login' && loginEmailFor2FA) || (data.twoFactorRequired === undefined)) {
if (returnTo) {
window.location.href = decodeURIComponent(returnTo);
} else {
window.location.href = '/user/account#tab-personal-info';
}
} else { // register
window.location.href = '/user/login?registered=true' + (returnTo ? '&return_to=' + encodeURIComponent(returnTo) : '');
}
}
} else {
showMessage(data.error || ('操作失败 (' + status + ')'), 'error', data.details ? true : false);
if (type === 'login' && loginEmailFor2FA && status !== 401 && login2FASection) { login2FASection.classList.remove('hidden'); }
else if (status === 401 && data.error === '两步验证码无效' && login2FASection) {
login2FASection.classList.remove('hidden'); if(loginTotpCodeInput) { loginTotpCodeInput.value = ''; loginTotpCodeInput.focus(); }
} else if (login2FASection) { login2FASection.classList.add('hidden'); loginEmailFor2FA = null; }
}
};
window.handleLogout = async function() {
clearMessages();
if (userPresenceSocket && userPresenceSocket.readyState === WebSocket.OPEN) {
userPresenceSocket.close();
}
if (typeof window.closeActiveConversationSocket === 'function') {
window.closeActiveConversationSocket();
}
await apiCall('/api/logout', 'POST');
currentUserData = null;
window.location.href = '/user/login';
};
window.turnstileCallbackLogin = function(token) { };
window.turnstileCallbackRegister = function(token) { };
window.turnstileCallbackPasteApi = function(token) { };
window.turnstileCallbackCloudPc = function(token) { };
window.turnstileCallbackOauthClient = function(token) { };
window.copyToClipboard = function(text, itemNameToCopy = '内容') {
if (!text) { showMessage('没有可复制的'+itemNameToCopy+'。', 'warning'); return; }
navigator.clipboard.writeText(text).then(() => {
showMessage(itemNameToCopy + '已复制到剪贴板!', 'success');
}).catch(err => {
showMessage('复制失败: ' + err, 'error');
});
};
window.escapeHtml = function(unsafe) {
if (unsafe === null || typeof unsafe === 'undefined') return '';
return String(unsafe)
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
};
window.apiCall = apiCall;
window.showMessage = showMessage;
window.clearMessages = clearMessages;
window.renderTurnstile = renderTurnstile;
window.removeTurnstile = removeTurnstile;
window.resetTurnstileInContainer = resetTurnstileInContainer;
window.checkLoginStatus = checkLoginStatus;
window.isValidEmail = function(email) {
if (typeof email !== 'string') return false;
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
};
window.updateUnreadMessagesIndicatorUI = updateUnreadMessagesIndicatorUI;
window.connectUserPresenceWebSocket = connectUserPresenceWebSocket;
4. cdn/js/ui-messaging.js
loadMessagingTabData应该仍然是主要的入口点,但它现在是为/user/messaging页面加载时调用的。它可以直接在DOMContentLoaded(或在checkLoginStatus成功后) 被触发,而不是通过activateTab。
let contactSearchInput, btnStartNewConversation;
let conversationsListUl, messagesAreaDiv, messagesListDiv, messageInputAreaDiv, messageInputTextarea, btnSendMessage;
let emptyMessagesPlaceholder;
let newConversationEmailInput;
let currentActiveConversationD1Id = null;
let currentUserEmail = null;
let allConversationsCache = [];
let currentConversationMessages = [];
let displayedMessagesCount = 0;
let conversationSocket = null;
let messageIntersectionObserver = null;
let notificationPermissionGranted = false;
let messagesListWrapper, messageLoader;
let isLoadingMoreMessages = false;
let hasMoreMessagesToLoad = true;
function formatMillisecondsTimestamp(timestamp) {
const date = new Date(timestamp);
const year = date.getFullYear();
const month = (date.getMonth() + 1).toString().padStart(2, '0');
const day = date.getDate().toString().padStart(2, '0');
const hours = date.getHours().toString().padStart(2, '0');
const minutes = date.getMinutes().toString().padStart(2, '0');
const seconds = date.getSeconds().toString().padStart(2, '0');
return `${year}/${month}/${day} ${hours}:${minutes}:${seconds}`;
}
async function requestNotificationPermission() {
if (!('Notification' in window)) {
return;
}
if (Notification.permission === 'granted') {
notificationPermissionGranted = true;
return;
}
if (Notification.permission !== 'denied') {
const permission = await Notification.requestPermission();
if (permission === 'granted') {
notificationPermissionGranted = true;
}
}
}
function showDesktopNotification(title, options, conversationIdToOpen) {
if (!notificationPermissionGranted || document.hasFocus()) {
return;
}
const notification = new Notification(title, options);
notification.onclick = () => {
window.focus();
if (window.location.pathname !== '/user/messaging') {
window.location.href = '/user/messaging';
}
const convElement = conversationsListUl?.querySelector(`li[data-conversation-id="${conversationIdToOpen}"]`);
if (convElement) {
convElement.click();
}
notification.close();
};
}
function initializeMessageObserver() {
if (messageIntersectionObserver) {
messageIntersectionObserver.disconnect();
}
messageIntersectionObserver = new IntersectionObserver((entries, observer) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const messageElement = entry.target;
const messageId = messageElement.dataset.messageId;
const isUnreadForCurrentUser = messageElement.classList.contains('unread-for-current-user');
if (messageId && isUnreadForCurrentUser && conversationSocket && conversationSocket.readyState === WebSocket.OPEN) {
conversationSocket.send(JSON.stringify({
type: "MESSAGE_SEEN",
data: { message_id: messageId }
}));
messageElement.classList.remove('unread-for-current-user');
observer.unobserve(messageElement);
}
}
});
}, { threshold: 0.8 });
}
function observeMessageElement(element) {
if (messageIntersectionObserver && element) {
messageIntersectionObserver.observe(element);
}
}
function initializeMessagingTab() {
contactSearchInput = document.getElementById('contact-search-input');
btnStartNewConversation = document.getElementById('btn-start-new-conversation');
newConversationEmailInput = document.getElementById('new-conversation-email');
conversationsListUl = document.getElementById('conversations-list');
messagesAreaDiv = document.getElementById('messages-area');
messagesListWrapper = document.getElementById('messages-list-wrapper');
messageLoader = document.getElementById('message-loader');
messagesListDiv = document.getElementById('messages-list');
messageInputAreaDiv = document.getElementById('message-input-area');
messageInputTextarea = document.getElementById('message-input');
btnSendMessage = document.getElementById('btn-send-message');
emptyMessagesPlaceholder = messagesListDiv?.querySelector('.empty-messages-placeholder');
if (btnStartNewConversation && newConversationEmailInput) {
btnStartNewConversation.removeEventListener('click', handleNewConversationButtonClick);
btnStartNewConversation.addEventListener('click', handleNewConversationButtonClick);
newConversationEmailInput.removeEventListener('keypress', handleNewConversationInputKeypress);
newConversationEmailInput.addEventListener('keypress', handleNewConversationInputKeypress);
}
if(contactSearchInput) {
contactSearchInput.removeEventListener('input', handleContactSearch);
contactSearchInput.addEventListener('input', handleContactSearch);
}
if (btnSendMessage) {
btnSendMessage.removeEventListener('click', handleSendMessageClick);
btnSendMessage.addEventListener('click', handleSendMessageClick);
}
if (messageInputTextarea) {
messageInputTextarea.removeEventListener('keypress', handleMessageInputKeypress);
messageInputTextarea.addEventListener('keypress', handleMessageInputKeypress);
messageInputTextarea.removeEventListener('input', handleMessageInputAutosize);
messageInputTextarea.addEventListener('input', handleMessageInputAutosize);
}
if (messagesListWrapper) {
messagesListWrapper.removeEventListener('scroll', handleMessageScroll);
messagesListWrapper.addEventListener('scroll', handleMessageScroll);
}
initializeMessageObserver();
requestNotificationPermission();
}
function handleMessageScroll() {
if (messagesListWrapper.scrollTop === 0 && hasMoreMessagesToLoad && !isLoadingMoreMessages && conversationSocket && conversationSocket.readyState === WebSocket.OPEN) {
loadMoreMessages();
}
}
async function loadMoreMessages() {
if (isLoadingMoreMessages || !hasMoreMessagesToLoad) return;
isLoadingMoreMessages = true;
if (messageLoader) messageLoader.classList.remove('hidden');
conversationSocket.send(JSON.stringify({
type: "LOAD_MORE_MESSAGES",
data: { currentlyLoadedCount: displayedMessagesCount }
}));
}
function handleNewConversationButtonClick() {
newConversationEmailInput = newConversationEmailInput || document.getElementById('new-conversation-email');
if (newConversationEmailInput) {
const emailValue = newConversationEmailInput.value.trim();
handleStartNewConversation(emailValue);
}
}
function handleNewConversationInputKeypress(event) {
if (event.key === 'Enter') {
newConversationEmailInput = newConversationEmailInput || document.getElementById('new-conversation-email');
if (newConversationEmailInput) {
event.preventDefault();
const emailValue = newConversationEmailInput.value.trim();
handleStartNewConversation(emailValue);
}
}
}
function handleMessageInputKeypress(event) {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
handleSendMessageClick();
}
}
function handleMessageInputAutosize() {
this.style.height = 'auto';
this.style.height = (this.scrollHeight) + 'px';
}
function closeActiveConversationSocket() {
if (messageIntersectionObserver) {
messageIntersectionObserver.disconnect();
}
if (conversationSocket && (conversationSocket.readyState === WebSocket.OPEN || conversationSocket.readyState === WebSocket.CONNECTING)) {
conversationSocket.close();
}
conversationSocket = null;
currentActiveConversationD1Id = null;
currentConversationMessages = [];
displayedMessagesCount = 0;
hasMoreMessagesToLoad = true;
isLoadingMoreMessages = false;
if (messageLoader) messageLoader.classList.add('hidden');
}
window.closeActiveConversationSocket = closeActiveConversationSocket;
async function loadMessagingTabData() {
initializeMessagingTab();
if (typeof window.clearMessages === 'function') window.clearMessages();
conversationsListUl = conversationsListUl || document.getElementById('conversations-list');
messagesListDiv = messagesListDiv || document.getElementById('messages-list');
emptyMessagesPlaceholder = emptyMessagesPlaceholder || messagesListDiv?.querySelector('.empty-messages-placeholder');
messageInputAreaDiv = messageInputAreaDiv || document.getElementById('message-input-area');
messageLoader = messageLoader || document.getElementById('message-loader');
if (conversationsListUl) {
conversationsListUl.innerHTML = '<p class="placeholder-text">正在加载对话...</p>';
}
if (window.currentUserData && window.currentUserData.email) {
currentUserEmail = window.currentUserData.email;
} else {
const { ok, data } = await window.apiCall('/api/me');
if (ok && data && data.email) {
currentUserEmail = data.email;
window.currentUserData = data;
} else {
if (conversationsListUl) conversationsListUl.innerHTML = '<p class="placeholder-text" style="color: var(--danger-color);">无法加载用户信息,请重新登录以查看私信。</p>';
resetActiveConversationUIOnly();
if(typeof window.showMessage === 'function') window.showMessage("用户未登录或会话已过期,请重新登录。", "error");
setTimeout(() => { window.location.href = '/user/login?return_to=/user/messaging'; }, 2000);
return;
}
}
if (!currentUserEmail) {
if (conversationsListUl) conversationsListUl.innerHTML = '<p class="placeholder-text" style="color: var(--danger-color);">用户信息最终未能确认,无法加载对话。</p>';
resetActiveConversationUIOnly();
return;
}
if (currentActiveConversationD1Id) {
const activeConv = allConversationsCache.find(c => c.conversation_id === currentActiveConversationD1Id);
if (activeConv) {
await handleConversationClick(currentActiveConversationD1Id, activeConv.other_participant_email);
} else {
resetActiveConversationUIOnly();
}
} else {
resetActiveConversationUIOnly();
}
let wasSocketAlreadyOpen = false;
if (window.userPresenceSocket && window.userPresenceSocket.readyState === WebSocket.OPEN) {
wasSocketAlreadyOpen = true;
}
if (typeof window.connectUserPresenceWebSocket === 'function') {
window.connectUserPresenceWebSocket();
} else {
if (conversationsListUl) conversationsListUl.innerHTML = '<p class="placeholder-text" style="color: var(--danger-color);">消息服务连接功能不可用。</p>';
return;
}
if (wasSocketAlreadyOpen) {
if (window.userPresenceSocket && window.userPresenceSocket.readyState === WebSocket.OPEN) {
window.userPresenceSocket.send(JSON.stringify({type: "REQUEST_INITIAL_STATE"}));
}
}
}
function displayConversations(conversations) {
if (typeof window.escapeHtml !== 'function') {
window.escapeHtml = (unsafe) => {
if (typeof unsafe !== 'string') return '';
return unsafe.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
};
}
conversationsListUl = conversationsListUl || document.getElementById('conversations-list');
if (!conversationsListUl) return;
if (!currentUserEmail) {
if (window.currentUserData && window.currentUserData.email) {
currentUserEmail = window.currentUserData.email;
} else {
conversationsListUl.innerHTML = '<p class="placeholder-text" style="color: var(--text-color-muted);">当前用户信息不可用,无法显示对话列表。</p>';
return;
}
}
const currentScrollTop = conversationsListUl.scrollTop;
conversationsListUl.innerHTML = '';
const sortedConversations = conversations.sort((a,b) => (b.last_message_at || 0) - (a.last_message_at || 0));
if (sortedConversations.length === 0) {
let emptyMessage = '<p class="placeholder-text" style="color: var(--text-color-muted);">没有对话记录。尝试发起新对话吧!</p>';
if (contactSearchInput && contactSearchInput.value.trim() !== '') {
emptyMessage = '<p class="placeholder-text" style="color: var(--text-color-muted);">未找到相关联系人。</p>';
}
conversationsListUl.innerHTML = emptyMessage;
return;
}
let html = '';
try {
sortedConversations.forEach(conv => {
const otherParticipantDisplay = window.escapeHtml(conv.other_participant_username || conv.other_participant_email);
let lastMessagePreview = conv.last_message_content ? conv.last_message_content : '<i>开始聊天吧!</i>';
if (typeof window.marked === 'function' && conv.last_message_content) {
lastMessagePreview = window.marked.parse(conv.last_message_content, { sanitize: true, breaks: true }).replace(/<[^>]*>?/gm, '');
}
lastMessagePreview = window.escapeHtml(lastMessagePreview);
if (lastMessagePreview.length > 25) lastMessagePreview = lastMessagePreview.substring(0, 22) + "...";
const lastMessageTimeRaw = conv.last_message_at;
let lastMessageTimeFormatted = '';
if (lastMessageTimeRaw) {
try {
const date = new Date(lastMessageTimeRaw);
const today = new Date();
const yesterday = new Date(today);
yesterday.setDate(today.getDate() - 1);
if (date.toDateString() === today.toDateString()) {
lastMessageTimeFormatted = date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
} else if (date.toDateString() === yesterday.toDateString()) {
lastMessageTimeFormatted = '昨天';
} else {
lastMessageTimeFormatted = date.toLocaleDateString([], { month: '2-digit', day: '2-digit' });
}
} catch (e) { lastMessageTimeFormatted = ''; }
}
const unreadCount = conv.unread_count > 0 ? `<span class="unread-badge">${conv.unread_count}</span>` : '';
const isActive = conv.conversation_id === currentActiveConversationD1Id ? 'selected' : '';
const avatarInitial = otherParticipantDisplay.charAt(0).toUpperCase();
html += `
<li data-conversation-id="${conv.conversation_id}" data-other-participant-email="${window.escapeHtml(conv.other_participant_email)}" class="${isActive}" title="与 ${otherParticipantDisplay} 的对话">
<div class="contact-avatar">${avatarInitial}</div>
<div class="contact-info">
<span class="contact-name">${otherParticipantDisplay}</span>
<span class="contact-last-message">${conv.last_message_sender === currentUserEmail ? '你: ' : ''}${lastMessagePreview}</span>
</div>
<div class="contact-meta">
<span class="contact-time">${lastMessageTimeFormatted}</span>
${unreadCount}
</div>
</li>`;
});
} catch (e) {
conversationsListUl.innerHTML = '<p class="placeholder-text" style="color: var(--danger-color);">渲染对话列表时出错。</p>';
return;
}
conversationsListUl.innerHTML = html;
conversationsListUl.scrollTop = currentScrollTop;
conversationsListUl.querySelectorAll('li').forEach(li => {
li.removeEventListener('click', handleConversationLiClick);
li.addEventListener('click', handleConversationLiClick);
});
}
function handleConversationLiClick(event) {
const li = event.currentTarget;
const convId = li.dataset.conversationId;
const otherUserEmail = li.dataset.otherParticipantEmail;
handleConversationClick(convId, otherUserEmail);
}
function handleContactSearch() {
contactSearchInput = contactSearchInput || document.getElementById('contact-search-input');
if (!contactSearchInput) return;
const searchTerm = contactSearchInput.value.toLowerCase().trim();
if (!allConversationsCache || !Array.isArray(allConversationsCache)) {
if(conversationsListUl) conversationsListUl.innerHTML = '<p class="placeholder-text" style="color: var(--text-color-muted);">对话缓存未准备好,无法搜索。</p>';
return;
}
if (!searchTerm) {
displayConversations(allConversationsCache);
return;
}
const filteredConversations = allConversationsCache.filter(conv => {
const otherUserUsername = conv.other_participant_username ? String(conv.other_participant_username).toLowerCase() : '';
const otherUserEmail = conv.other_participant_email ? String(conv.other_participant_email).toLowerCase() : '';
return otherUserUsername.includes(searchTerm) || otherUserEmail.includes(searchTerm);
});
displayConversations(filteredConversations);
}
async function handleConversationClick(conversationD1Id, otherParticipantEmail) {
if (!conversationD1Id) return;
closeActiveConversationSocket();
if (conversationsListUl) {
conversationsListUl.querySelectorAll('li').forEach(li => {
li.classList.toggle('selected', li.dataset.conversationId === conversationD1Id);
});
}
if(messageInputTextarea) messageInputTextarea.dataset.receiverEmail = otherParticipantEmail;
if (messageLoader) messageLoader.classList.add('hidden');
await connectConversationWebSocket(conversationD1Id);
}
function appendSingleMessageToUI(msg, prepend = false) {
if (!messagesListDiv || !currentUserEmail) return;
const isSent = msg.sender_email === currentUserEmail;
const senderDisplayName = isSent ? '你' : (window.escapeHtml(msg.sender_username || msg.sender_email));
const messageTime = formatMillisecondsTimestamp(msg.sent_at);
let messageHtmlContent = '';
if (typeof window.marked === 'function' && typeof DOMPurify === 'object' && DOMPurify.sanitize) {
messageHtmlContent = DOMPurify.sanitize(window.marked.parse(msg.content || '', { breaks: true, gfm: true }));
} else {
messageHtmlContent = window.escapeHtml(msg.content || '').replace(/\n/g, '<br>');
}
const messageItemDiv = document.createElement('div');
messageItemDiv.className = `message-item ${isSent ? 'sent' : 'received'}`;
messageItemDiv.dataset.messageId = msg.message_id;
if (msg.sender_email !== currentUserEmail && msg.is_read === 0) {
messageItemDiv.classList.add('unread-for-current-user');
}
messageItemDiv.innerHTML = `
<span class="message-sender">${senderDisplayName}</span>
<div class="message-content">${messageHtmlContent}</div>
<span class="message-time">${messageTime}</span>`;
const oldScrollHeight = messagesListWrapper.scrollHeight;
const oldScrollTop = messagesListWrapper.scrollTop;
if (prepend) {
messagesListDiv.insertBefore(messageItemDiv, messagesListDiv.firstChild);
messagesListWrapper.scrollTop = oldScrollTop + (messagesListWrapper.scrollHeight - oldScrollHeight);
} else {
messagesListDiv.appendChild(messageItemDiv);
messagesListWrapper.scrollTop = messagesListWrapper.scrollHeight;
}
if (msg.sender_email !== currentUserEmail && msg.is_read === 0) {
observeMessageElement(messageItemDiv);
}
}
function connectConversationWebSocket(conversationD1Id) {
return new Promise((resolve, reject) => {
if (conversationSocket && (conversationSocket.readyState === WebSocket.OPEN || conversationSocket.readyState === WebSocket.CONNECTING)) {
if (currentActiveConversationD1Id === conversationD1Id) {
resolve();
return;
}
closeActiveConversationSocket();
}
initializeMessageObserver();
currentConversationMessages = [];
displayedMessagesCount = 0;
hasMoreMessagesToLoad = true;
isLoadingMoreMessages = false;
if (!window.currentUserData || !window.currentUserData.email) {
reject(new Error("User not authenticated for conversation WebSocket."));
return;
}
currentActiveConversationD1Id = conversationD1Id;
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsConvUrl = `${protocol}//${window.location.host}/api/ws/conversation/${conversationD1Id}`;
conversationSocket = new WebSocket(wsConvUrl);
conversationSocket.onopen = () => {
if (messagesListDiv) messagesListDiv.innerHTML = '';
if (messageInputAreaDiv) messageInputAreaDiv.classList.remove('hidden');
if (emptyMessagesPlaceholder) emptyMessagesPlaceholder.classList.add('hidden');
if (messageLoader) messageLoader.classList.add('hidden');
resolve();
};
conversationSocket.onmessage = (event) => {
try {
const message = JSON.parse(event.data);
if (message.type === "HISTORICAL_MESSAGE") {
currentConversationMessages.unshift(message.data);
} else if (message.type === "INITIAL_MESSAGES_LOADED") {
currentConversationMessages.sort((a, b) => a.sent_at - b.sent_at);
currentConversationMessages.forEach(msg => appendSingleMessageToUI(msg, false));
displayedMessagesCount = currentConversationMessages.length;
hasMoreMessagesToLoad = message.data.hasMore;
if (!hasMoreMessagesToLoad && messageLoader) messageLoader.classList.add('hidden');
messagesListWrapper.scrollTop = messagesListWrapper.scrollHeight;
} else if (message.type === "OLDER_MESSAGES_BATCH") {
const olderBatch = message.data.messages.sort((a, b) => a.sent_at - b.sent_at);
olderBatch.forEach(msg => {
currentConversationMessages.unshift(msg);
appendSingleMessageToUI(msg, true);
});
displayedMessagesCount += olderBatch.length;
hasMoreMessagesToLoad = message.data.hasMore;
isLoadingMoreMessages = false;
if (messageLoader) messageLoader.classList.add('hidden');
if (!hasMoreMessagesToLoad && messageLoader) messageLoader.classList.add('hidden');
} else if (message.type === "NEW_MESSAGE") {
currentConversationMessages.push(message.data);
appendSingleMessageToUI(message.data, false);
displayedMessagesCount++;
if (message.data.sender_email !== currentUserEmail && window.userPresenceSocket && window.userPresenceSocket.readyState === WebSocket.OPEN) {
window.userPresenceSocket.send(JSON.stringify({type: "REQUEST_CONVERSATIONS_LIST"}));
}
} else if (message.type === "CONNECTION_ESTABLISHED"){
} else if (message.type === "MESSAGES_READ"){
} else if (message.type === "ERROR") {
if (typeof window.showMessage === 'function') window.showMessage(`对话错误: ${message.data}`, 'error');
}
} catch (e) { }
};
conversationSocket.onclose = (event) => {
if (currentActiveConversationD1Id === conversationD1Id) {
conversationSocket = null;
currentActiveConversationD1Id = null;
resetActiveConversationUIOnly();
}
};
conversationSocket.onerror = (error) => {
if (currentActiveConversationD1Id === conversationD1Id) {
resetActiveConversationUIOnly();
}
reject(error);
};
});
}
function resetActiveConversationUIOnly() {
closeActiveConversationSocket();
if (messageInputTextarea) {
messageInputTextarea.value = '';
messageInputTextarea.removeAttribute('data-receiver-email');
}
if (messageInputAreaDiv) messageInputAreaDiv.classList.add('hidden');
if (messagesListDiv) messagesListDiv.innerHTML = '';
if (messageLoader) messageLoader.classList.add('hidden');
if (emptyMessagesPlaceholder && messagesListDiv) {
emptyMessagesPlaceholder.querySelector('p').textContent = '选择一个联系人开始聊天';
emptyMessagesPlaceholder.querySelector('span').textContent = '或通过上方输入框发起新的对话。';
if (messagesListDiv.firstChild !== emptyMessagesPlaceholder) {
messagesListDiv.appendChild(emptyMessagesPlaceholder);
}
emptyMessagesPlaceholder.classList.remove('hidden');
}
if (conversationsListUl) {
conversationsListUl.querySelectorAll('li.selected').forEach(li => li.classList.remove('selected'));
}
}
function handleSendMessageClick() {
if (!conversationSocket || conversationSocket.readyState !== WebSocket.OPEN) {
if(typeof window.showMessage === 'function') window.showMessage('对话连接未建立。', 'error');
return;
}
const content = messageInputTextarea.value.trim();
if (!content) {
if(typeof window.showMessage === 'function') window.showMessage('消息内容不能为空。', 'warning');
return;
}
conversationSocket.send(JSON.stringify({
type: "NEW_MESSAGE",
data: { content: content }
}));
messageInputTextarea.value = '';
messageInputTextarea.style.height = 'auto';
messageInputTextarea.focus();
}
async function handleStartNewConversation(receiverEmailFromInput) {
const localReceiverEmail = receiverEmailFromInput.trim();
if (!localReceiverEmail) {
if (typeof window.showMessage === 'function') window.showMessage('请输入对方的邮箱地址。', 'warning');
return;
}
if (!currentUserEmail) {
if (typeof window.showMessage === 'function') window.showMessage('当前用户信息获取失败。', 'error');
return;
}
if (localReceiverEmail === currentUserEmail) {
if (typeof window.showMessage === 'function') window.showMessage('不能与自己开始对话。', 'warning');
return;
}
if (typeof window.isValidEmail === 'function' && !window.isValidEmail(localReceiverEmail)) {
if (typeof window.showMessage === 'function') window.showMessage('请输入有效的邮箱地址。', 'error');
return;
}
const existingConv = allConversationsCache.find(c => c.other_participant_email === localReceiverEmail);
if (existingConv && existingConv.conversation_id) {
await handleConversationClick(existingConv.conversation_id, localReceiverEmail);
if (newConversationEmailInput) newConversationEmailInput.value = '';
if (typeof window.showMessage === 'function') window.showMessage(`已切换到与 ${window.escapeHtml(localReceiverEmail)} 的对话。`, 'info');
return;
}
if (typeof window.apiCall === 'function') {
const { ok, data, status } = await window.apiCall('/api/messages', 'POST', {
receiverEmail: localReceiverEmail,
content: `与 ${currentUserEmail.split('@')[0]} 的对话已开始。` // This initial message might be optional
});
if (ok && data.success && data.conversationId) {
if (newConversationEmailInput) newConversationEmailInput.value = '';
if (contactSearchInput) contactSearchInput.value = '';
if (window.userPresenceSocket && window.userPresenceSocket.readyState === WebSocket.OPEN) {
window.userPresenceSocket.send(JSON.stringify({type: "REQUEST_CONVERSATIONS_LIST"}));
}
setTimeout(async () => { // Give a moment for the conversations list to potentially update via UserPresenceDO
const newlyCreatedConv = allConversationsCache.find(c => c.other_participant_email === localReceiverEmail && c.conversation_id === data.conversationId);
if (newlyCreatedConv) { // if list updated, click it
await handleConversationClick(data.conversationId, localReceiverEmail);
} else { // if not found in cache, force open with provided data
await handleConversationClick(data.conversationId, localReceiverEmail);
}
}, 500);
if (typeof window.showMessage === 'function') window.showMessage(`与 ${window.escapeHtml(localReceiverEmail)} 的对话已开始。`, 'success');
} else if (data.error === '接收者用户不存在' || status === 404) {
if (typeof window.showMessage === 'function') window.showMessage(`无法开始对话:用户 ${window.escapeHtml(localReceiverEmail)} 不存在。`, 'error');
}
else {
if (typeof window.showMessage === 'function') window.showMessage(`无法与 ${window.escapeHtml(localReceiverEmail)} 开始对话: ${ (data && (data.error || data.message)) ? window.escapeHtml(data.error || data.message) : '未知错误, 状态: ' + status}`, 'error');
}
}
}
window.handleConversationsListUpdate = function(conversationsData) {
if (!currentUserEmail && window.currentUserData && window.currentUserData.email) {
currentUserEmail = window.currentUserData.email;
}
const oldConversationsSummary = { ...allConversationsCache.reduce((acc, conv) => { acc[conv.conversation_id] = conv; return acc; }, {}) };
allConversationsCache = conversationsData.map(conv => ({
conversation_id: conv.conversation_id,
other_participant_username: conv.other_participant_username,
other_participant_email: conv.other_participant_email,
last_message_content: conv.last_message_content,
last_message_sender: conv.last_message_sender,
last_message_at: conv.last_message_at,
unread_count: conv.unread_count,
}));
displayConversations(allConversationsCache);
allConversationsCache.forEach(newConv => {
const oldConv = oldConversationsSummary[newConv.conversation_id];
if (newConv.last_message_sender && newConv.last_message_sender !== currentUserEmail && newConv.unread_count > 0) {
if (!oldConv || newConv.last_message_at > (oldConv.last_message_at || 0)) {
const isCurrentConversationActive = newConv.conversation_id === currentActiveConversationD1Id;
if (!document.hasFocus() || !isCurrentConversationActive) { // Only notify if not focused or not the active conversation
showDesktopNotification(
`来自 ${window.escapeHtml(newConv.other_participant_username || newConv.other_participant_email)} 的新消息`,
{
body: window.escapeHtml(newConv.last_message_content.substring(0, 50) + (newConv.last_message_content.length > 50 ? "..." : "")),
icon: '/favicon.ico',
tag: `conversation-${newConv.conversation_id}`
},
newConv.conversation_id
);
}
}
}
});
if (currentActiveConversationD1Id) {
const activeConvStillExists = allConversationsCache.some(c => c.conversation_id === currentActiveConversationD1Id);
if (!activeConvStillExists) {
resetActiveConversationUIOnly();
} else {
const selectedLi = conversationsListUl?.querySelector(`li[data-conversation-id="${currentActiveConversationD1Id}"]`);
if (selectedLi) selectedLi.classList.add('selected');
}
}
};
window.handleSingleConversationUpdate = function(updatedConvData) {
if (!currentUserEmail && window.currentUserData && window.currentUserData.email) {
currentUserEmail = window.currentUserData.email;
}
const index = allConversationsCache.findIndex(c => c.conversation_id === updatedConvData.conversation_id);
const oldConvData = index > -1 ? { ...allConversationsCache[index] } : null;
const mappedData = {
conversation_id: updatedConvData.conversation_id,
other_participant_username: updatedConvData.other_participant_username,
other_participant_email: updatedConvData.other_participant_email,
last_message_content: updatedConvData.last_message_content,
last_message_sender: updatedConvData.last_message_sender,
last_message_at: updatedConvData.last_message_at,
unread_count: updatedConvData.unread_count,
};
if (index > -1) {
allConversationsCache[index] = { ...allConversationsCache[index], ...mappedData };
} else {
allConversationsCache.unshift(mappedData);
}
displayConversations(allConversationsCache);
if (mappedData.last_message_sender && mappedData.last_message_sender !== currentUserEmail && mappedData.unread_count > 0) {
if (!oldConvData || mappedData.last_message_at > (oldConvData.last_message_at || 0)) {
const isCurrentConversationActive = mappedData.conversation_id === currentActiveConversationD1Id;
if (!document.hasFocus() || !isCurrentConversationActive) {
showDesktopNotification(
`来自 ${window.escapeHtml(mappedData.other_participant_username || mappedData.other_participant_email)} 的新消息`,
{
body: window.escapeHtml(mappedData.last_message_content.substring(0, 50) + (mappedData.last_message_content.length > 50 ? "..." : "")),
icon: '/favicon.ico',
tag: `conversation-${mappedData.conversation_id}`
},
mappedData.conversation_id
);
}
}
}
if (currentActiveConversationD1Id === updatedConvData.conversation_id) {
const selectedLi = conversationsListUl?.querySelector(`li[data-conversation-id="${currentActiveConversationD1Id}"]`);
if (selectedLi) selectedLi.classList.add('selected');
}
};
window.initializeMessagingTab = initializeMessagingTab;
window.loadMessagingTabData = loadMessagingTabData;
// If this is the dedicated messaging page, initialize it after user check.
if (window.location.pathname === '/user/messaging') {
document.addEventListener('DOMContentLoaded', () => {
if (typeof window.checkLoginStatus === 'function') {
window.checkLoginStatus().then(() => { // checkLoginStatus in main.js sets currentUserData
if (window.currentUserData && typeof loadMessagingTabData === 'function') {
loadMessagingTabData();
} else if (!window.currentUserData) { // If checkLoginStatus determined not logged in
// main.js's displayCorrectView should handle redirect to login
}
});
}
});
}
总结与后续步骤
这些更改将私信功能迁移到一个独立的 /user/messaging 页面。当用户点击顶部导航栏的私信按钮时,会直接跳转到这个新页面,确保所有相关的 HTML 和 JavaScript 都被正确加载。这应该能解决从其他页面(如 /user/help)访问私信时遇到的加载问题。
您可以按照同样的模式,将账户设置中的其他部分(如API密钥管理、我的应用等)也拆分为独立的页面,以进一步模块化您的用户中心。
请注意,上述代码修改仅针对分离私信功能。main.js 中的 activateTab 函数以及相关的标签页逻辑仍然对 /user/account 页面内的其他标签页有效。如果未来您将所有标签页都改为独立页面,那么 activateTab 的大部分逻辑可以被移除,侧边栏链接也将变为直接的页面跳转。