type Role = 'admin' | 'user'; type View = 'steam' | 'chat' | 'users' | 'steamAccounts' | 'audit' | 'account'; type Tone = 'muted' | 'ok' | 'warn' | 'error'; type ChatListTab = 'recent' | 'friends' | 'groups'; type ChatPanel = 'list' | 'thread'; type ChatIconName = 'arrow-left' | 'image' | 'link' | 'paperclip' | 'plus' | 'search' | 'send' | 'smile' | 'x'; type Permission = | 'user.manage' | 'session.manage' | 'audit.view' | 'steam.manage' | 'steam.account.manage' | 'chat.use' | 'self.password.change'; type User = { id: number; username: string; displayName: string; note: string; role: Role; disabled: boolean; forcePasswordChange: boolean; sessionVersion: number; createdAt: string; updatedAt: string; lastLoginAt: string | null; lastLoginIp: string | null; lastSeenAt: string | null; passwordChangedAt: string | null; failedLoginCount: number; lockedUntil: string | null; locked: boolean; steamAccountCount: number; }; type UserSession = { id: string; userId: number; createdAt: string; lastSeenAt: string; expiresAt: string; ip: string | null; userAgent: string | null; }; type SteamAccount = { id: number; steamId: string; label: string; accountNameHint: string; enabled: boolean; createdAt: string; updatedAt: string; lastLoginAt: string | null; lastActiveAt: string | null; refreshTokenUpdatedAt: string | null; authorizedUserCount: number; active: boolean; }; type AuditLog = { id: number; actorUserId: number | null; actorUsername: string | null; action: string; targetType: string; targetId: string; detail: Record; ip: string | null; createdAt: string; }; type SteamStatus = { status: string; requiresGuard: boolean; guardType: 'email' | 'device' | null; domain: string | null; lastCodeWrong: boolean; error: string | null; steamId: string | null; activeAccount?: Pick | null; accessAllowed?: boolean; }; type MeResponse = { needsSetup: boolean; user: User | null; permissions: Permission[]; steam: SteamStatus; }; type ListEntry = Record & { id: string; name?: string; avatar?: string; preview?: string; updatedAt?: string; gameName?: string; online?: boolean; clanId?: string; clanid?: string; }; type MessageItem = ListEntry & { type?: string; echo?: boolean; date?: string; sentAt?: string; message?: string; ordinal?: string | number | null; }; type OpenGraphPreview = { url: string; imageUrl: string; title: string; description: string; }; const supportedBbcodeTags = ['og', 'url', 'img', 'emoticon', 'sticker'] as const; type SupportedBbcodeTag = (typeof supportedBbcodeTags)[number]; const supportedBbcodeTagSet = new Set(supportedBbcodeTags); const supportedBbcodePattern = new RegExp(`\\[(${supportedBbcodeTags.join('|')})(?=[\\s=\\]])`, 'gi'); type SteamImagePreview = { sourceUrl: string; displayUrl: string; sourceSet: string; width: number; height: number; }; type InventoryItem = Record & { name?: string; use_count?: number | string; }; type WsPayload = Record & { type?: string; id?: string; name?: string; message?: string; ordinal?: string | number | null; echo?: boolean; date?: string; sentAt?: string; error?: string; }; type AppState = { me: User | null; needsSetup: boolean; permissions: Permission[]; steam: SteamStatus; view: View; users: User[]; userSessions: Record; userSteamAccounts: Record; steamAccounts: SteamAccount[]; auditLogs: AuditLog[]; conversations: ListEntry[]; friends: ListEntry[]; groups: ListEntry[]; emoticons: InventoryItem[]; stickers: InventoryItem[]; activeId: string; activeName: string; chatListTab: ChatListTab; chatQuery: string; chatPanel: ChatPanel; historyLimit: number; wsPath: string; ws: WebSocket | null; reconnectTimer: ReturnType | null; statusTimer: ReturnType | null; userQuery: string; userRole: string; userStatus: string; auditAction: string; auditTargetType: string; feedback: string; feedbackTone: Tone; }; const defaultSteamStatus: SteamStatus = { status: 'logged_out', requiresGuard: false, guardType: null, domain: null, lastCodeWrong: false, error: null, steamId: null, activeAccount: null, accessAllowed: false }; const root = document.querySelector('#app'); if (!root) throw new Error('Missing app root'); let feedbackTimer: ReturnType | null = null; const state: AppState = { me: null, needsSetup: false, permissions: [], steam: defaultSteamStatus, view: normalizeView(localStorage.getItem('steam-chat.view')), users: [], userSessions: {}, userSteamAccounts: {}, steamAccounts: [], auditLogs: [], conversations: [], friends: [], groups: [], emoticons: [], stickers: [], activeId: localStorage.getItem('steam-chat.target') || '', activeName: '', chatListTab: 'recent', chatQuery: '', chatPanel: 'list', historyLimit: clampLimit(localStorage.getItem('steam-chat.history-limit') || 100), wsPath: '/ws', ws: null, reconnectTimer: null, statusTimer: null, userQuery: '', userRole: '', userStatus: '', auditAction: '', auditTargetType: '', feedback: '就绪', feedbackTone: 'muted' }; function isRecord(value: unknown): value is Record { return value !== null && typeof value === 'object'; } function create(tag: K, className = '', text = ''): HTMLElementTagNameMap[K] { const node = document.createElement(tag); if (className) node.className = className; if (text) node.textContent = text; return node; } function chatIcon(name: ChatIconName) { const node = create('span', `ui-icon icon-${name}`); node.setAttribute('aria-hidden', 'true'); return node; } function chatIconButton(name: ChatIconName, label: string, className = 'icon-btn') { const button = create('button', className); button.type = 'button'; button.title = label; button.setAttribute('aria-label', label); button.append(chatIcon(name)); return button; } function clear(node: HTMLElement) { node.replaceChildren(); } function errorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error || ''); } function clampLimit(value: unknown): number { const parsed = Number.parseInt(String(value), 10); if (!Number.isFinite(parsed) || parsed <= 0) return 100; return Math.min(parsed, 500); } function normalizeView(value: unknown): View { const view = String(value || ''); return view === 'steam' || view === 'chat' || view === 'users' || view === 'steamAccounts' || view === 'audit' || view === 'account' ? view : 'steam'; } function asListEntries(value: unknown): ListEntry[] { return Array.isArray(value) ? value.filter(isRecord).map((item) => ({ ...item, id: String(item.id || '') })) : []; } function asMessages(value: unknown): MessageItem[] { return Array.isArray(value) ? value.filter(isRecord).map((item) => ({ ...item, id: String(item.id || '') })) : []; } function asInventory(value: unknown): InventoryItem[] { return Array.isArray(value) ? value.filter(isRecord) : []; } function setFeedback(text: string, tone: Tone = 'muted') { if (feedbackTimer) clearTimeout(feedbackTimer); feedbackTimer = null; state.feedback = text; state.feedbackTone = tone; const node = document.querySelector('#feedback'); if (node) { const content = node.querySelector('#feedbackText'); if (content) content.textContent = text; else node.textContent = text; node.dataset.tone = tone; node.setAttribute('aria-live', tone === 'error' ? 'assertive' : 'polite'); if (state.view === 'chat') presentChatFeedback(node, tone); } } function presentChatFeedback(node: HTMLElement, tone: Tone) { node.hidden = tone === 'muted'; if (tone === 'muted' || tone === 'error') return; feedbackTimer = setTimeout(() => dismissChatFeedback(node), tone === 'warn' ? 4000 : 2400); } function dismissChatFeedback(node = document.querySelector('#feedback')) { if (feedbackTimer) clearTimeout(feedbackTimer); feedbackTimer = null; state.feedback = '就绪'; state.feedbackTone = 'muted'; if (node) node.hidden = true; } function steamLabel(status = state.steam.status): string { const labels: Record = { logged_out: '未连接', logging_in: '登录中', waiting_guard: '等待验证', online: '在线', error: '异常', reconnecting: '重连中' }; return labels[status] || status; } function steamOnline() { return state.steam.status === 'online'; } function steamAccessAllowed() { return state.steam.accessAllowed !== false; } function hasPermission(permission: Permission) { return state.permissions.includes(permission); } function displayName(user: User) { return user.displayName || user.username; } function dateTime(value: unknown): string { if (!value) return '无'; const date = new Date(String(value)); if (Number.isNaN(date.getTime())) return String(value); return date.toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }); } function compactJson(value: unknown): string { if (!isRecord(value) || !Object.keys(value).length) return '{}'; return JSON.stringify(value); } function steamStatusSignature(status = state.steam) { return [ status.status, status.requiresGuard, status.guardType || '', status.domain || '', status.lastCodeWrong, status.error || '', status.steamId || '', status.activeAccount?.id || '', status.accessAllowed ].join('|'); } function updateSteamStatus(status: SteamStatus) { state.steam = { ...defaultSteamStatus, ...status }; const badge = document.querySelector('#steamBadge'); if (badge) { badge.textContent = steamLabel(); badge.dataset.status = state.steam.status; } const hint = document.querySelector('#steamHint'); if (hint) hint.textContent = state.steam.error || (state.steam.steamId ? `SteamID ${state.steam.steamId}` : '后台服务已启动'); const subtitle = document.querySelector('#pageSubtitle'); if (subtitle) subtitle.textContent = pageSubtitle(); updateChatAvailability(); } async function api(path: string, options: RequestInit = {}): Promise { const headers = new Headers(options.headers || {}); headers.set('Accept', 'application/json'); if (options.body && !headers.has('Content-Type')) headers.set('Content-Type', 'application/json'); const response = await fetch(path, { ...options, headers, credentials: 'same-origin' }); const payload: unknown = await response.json().catch((): null => null); if (!response.ok) { if (response.status === 401 && !path.startsWith('/api/auth/me') && !path.startsWith('/api/auth/login')) { stopWebSocket(); state.me = null; state.permissions = []; state.needsSetup = false; renderLogin(); } throw new Error(isRecord(payload) && typeof payload.error === 'string' ? payload.error : `HTTP ${response.status}`); } return payload; } function jsonBody(body: Record): RequestInit { return { method: 'POST', body: JSON.stringify(body) }; } function formValue(form: HTMLFormElement, name: string): string { const value = new FormData(form).get(name); return typeof value === 'string' ? value : ''; } function authPage(title: string, subtitle: string, form: HTMLElement) { stopStatusPolling(); stopWebSocket(); clear(root); const page = create('main', 'auth-page'); const panel = create('section', 'auth-panel'); panel.append(create('h1', '', title), create('p', 'muted', subtitle), form); page.append(panel); root.append(page); } function renderSetup() { const form = create('form', 'stack-form') as HTMLFormElement; form.innerHTML = `

`; form.addEventListener('submit', async (event) => { event.preventDefault(); const errorNode = form.querySelector('#setupError'); try { await api('/api/auth/setup', jsonBody({ username: formValue(form, 'username'), password: formValue(form, 'password') })); await bootstrap(); } catch (error) { if (errorNode) errorNode.textContent = errorMessage(error); } }); authPage('初始化 Steam Chat 后台', '创建第一个管理员后,再进入工作台连接 Steam。', form); } function renderLogin() { const form = create('form', 'stack-form') as HTMLFormElement; form.innerHTML = `

`; form.addEventListener('submit', async (event) => { event.preventDefault(); const errorNode = form.querySelector('#loginError'); try { await api('/api/auth/login', jsonBody({ username: formValue(form, 'username'), password: formValue(form, 'password') })); await bootstrap(); } catch (error) { if (errorNode) errorNode.textContent = errorMessage(error); } }); authPage('Steam Chat 后台登录', '登录后可使用已连接的 Steam 会话聊天,管理员可管理用户和 Steam 登录。', form); } function navButton(view: View, label: string) { const button = create('button', state.view === view ? 'is-active' : '', label); button.type = 'button'; button.addEventListener('click', () => { state.view = view; localStorage.setItem('steam-chat.view', view); renderShell(); }); return button; } function panel(title: string, className = '') { const section = create('section', `panel${className ? ` ${className}` : ''}`); section.append(create('h2', 'panel-title', title)); return section; } function renderShell() { if (!state.me) { renderLogin(); return; } if (state.me.role !== 'admin' && ['users', 'steamAccounts', 'audit'].includes(state.view)) state.view = 'steam'; clear(root); const shell = create('div', `admin-shell${state.view === 'chat' ? ' is-chat' : ''}`); const sidebar = create('aside', 'admin-sidebar'); const brand = create('div', 'brand'); brand.append(create('strong', '', 'Steam Chat'), create('span', '', state.me.username)); const nav = create('nav'); nav.append(navButton('steam', 'Steam 连接'), navButton('chat', '聊天')); if (hasPermission('user.manage')) nav.append(navButton('users', '用户管理')); if (hasPermission('steam.account.manage')) nav.append(navButton('steamAccounts', 'Steam 账户')); if (hasPermission('audit.view')) nav.append(navButton('audit', '审计日志')); nav.append(navButton('account', '账号')); sidebar.append(brand, nav); const main = create('main', 'workspace'); const top = create('header', 'topbar'); const title = create('div'); const heading = create('h1', '', pageTitle()); const subtitle = create('p', 'muted', pageSubtitle()); subtitle.id = 'pageSubtitle'; title.append(heading, subtitle); const status = create('div', 'top-actions'); const badge = create('span', 'steam-badge', steamLabel()); badge.id = 'steamBadge'; badge.dataset.status = state.steam.status; const hint = create('span', 'top-hint', state.steam.error || (state.steam.steamId ? `SteamID ${state.steam.steamId}` : '后台服务已启动')); hint.id = 'steamHint'; const logout = create('button', 'ghost-btn', '退出后台'); logout.type = 'button'; logout.addEventListener('click', () => logoutApp()); status.append(badge, hint, logout); top.append(title, status); const content = create('section', `content${state.view === 'chat' ? ' chat-content' : ''}`); content.append(renderCurrentView()); const feedback = create('div', `feedback${state.view === 'chat' ? ' chat-toast' : ''}`); feedback.id = 'feedback'; feedback.dataset.tone = state.feedbackTone; feedback.setAttribute('aria-live', state.feedbackTone === 'error' ? 'assertive' : 'polite'); const feedbackText = create('span', '', state.feedback); feedbackText.id = 'feedbackText'; feedback.append(feedbackText); if (state.view === 'chat') { const dismiss = chatIconButton('x', '关闭提示', 'toast-close'); dismiss.addEventListener('click', () => dismissChatFeedback(feedback)); feedback.append(dismiss); feedback.hidden = state.feedbackTone === 'muted'; } main.append(top, content, feedback); shell.append(sidebar, main); root.append(shell); if (state.view === 'chat') void refreshChatData(); if (state.view === 'users' && hasPermission('user.manage')) { void loadSteamAccounts(); void loadUsers(); } if (state.view === 'steamAccounts') void loadSteamAccounts(); if (state.view === 'audit') void loadAuditLogs(); ensureWebSocket(); startStatusPolling(); } function pageTitle() { if (state.view === 'chat') return '聊天'; if (state.view === 'users') return '用户管理'; if (state.view === 'steamAccounts') return 'Steam 账户'; if (state.view === 'audit') return '审计日志'; if (state.view === 'account') return '账号'; return 'Steam 连接'; } function pageSubtitle() { if (state.view === 'chat') { if (!steamAccessAllowed()) return '当前后台用户未被授权访问活动 Steam 账户'; return steamOnline() ? '好友、群组、历史和实时消息' : 'Steam 未在线,聊天操作已禁用'; } if (state.view === 'users') return '后台用户、会话、角色、资料和 Steam 授权'; if (state.view === 'steamAccounts') return 'Steam 账户资料、连接状态和授权计数'; if (state.view === 'audit') return '关键管理动作和敏感字段脱敏记录'; if (state.view === 'account') return '修改当前后台账号密码'; return state.me?.role === 'admin' ? '管理员在这里完成 Steam 登录和 Guard 验证' : '等待管理员连接 Steam'; } function renderCurrentView() { if (state.view === 'chat') return renderChatView(); if (state.view === 'users' && hasPermission('user.manage')) return renderUsersView(); if (state.view === 'steamAccounts' && hasPermission('steam.account.manage')) return renderSteamAccountsView(); if (state.view === 'audit' && hasPermission('audit.view')) return renderAuditView(); if (state.view === 'account') return renderAccountView(); return renderSteamView(); } function renderSteamView() { const view = create('div', 'steam-view'); const summary = create('section', 'status-panel'); const statusText = create('strong', '', steamLabel()); const activeLabel = state.steam.activeAccount ? `${state.steam.activeAccount.label || state.steam.activeAccount.steamId} · ${state.steam.activeAccount.steamId}` : state.steam.steamId ? `当前 SteamID:${state.steam.steamId}` : '当前没有可用的 Steam 会话'; const detail = create('p', 'muted', state.steam.error || activeLabel); summary.append(statusText, detail); view.append(summary); if (state.me?.role !== 'admin') { const access = panel('访问状态', 'status-note-panel'); access.append(create('p', state.steam.accessAllowed === false ? 'warn-text' : 'muted', state.steam.accessAllowed === false ? '当前账号未被授权访问活动 Steam 账户。' : '普通用户只能在 Steam 在线且被授权后使用聊天。')); view.append(access); return view; } if (state.steam.requiresGuard) { const guardPanel = panel('Steam Guard', 'form-panel'); const guardForm = create('form', 'inline-form') as HTMLFormElement; const guardText = state.steam.guardType === 'email' ? `邮箱验证码${state.steam.domain ? `:${state.steam.domain}` : ''}` : '手机 2FA 验证码'; const codeLabel = create('label', '', guardText); const codeInput = create('input') as HTMLInputElement; codeInput.name = 'code'; codeInput.autocomplete = 'one-time-code'; codeInput.required = true; const submit = create('button', '', '提交验证'); submit.type = 'submit'; codeLabel.append(codeInput); guardForm.append(codeLabel, submit); if (state.steam.lastCodeWrong) guardForm.prepend(create('p', 'warn-text', '上一次验证码被拒绝,请等待新的验证码后再提交。')); guardForm.addEventListener('submit', async (event) => { event.preventDefault(); try { const result = await api('/api/steam/guard', jsonBody({ code: formValue(guardForm, 'code') })); updateSteamStatus(result as SteamStatus); renderShell(); } catch (error) { setFeedback(errorMessage(error), 'error'); } }); guardPanel.append(guardForm); view.append(guardPanel); } else if (!['logging_in', 'online', 'reconnecting'].includes(state.steam.status)) { const loginPanel = panel('登录 Steam', 'form-panel'); const loginForm = create('form', 'stack-form narrow-form') as HTMLFormElement; loginForm.innerHTML = ` `; loginForm.addEventListener('submit', async (event) => { event.preventDefault(); const rawLogonID = formValue(loginForm, 'logonID').trim(); try { const result = await api('/api/steam/login', jsonBody({ accountName: formValue(loginForm, 'accountName'), password: formValue(loginForm, 'password'), label: formValue(loginForm, 'label'), ...(rawLogonID ? { logonID: Number(rawLogonID) } : {}) })); updateSteamStatus(result as SteamStatus); renderShell(); } catch (error) { setFeedback(errorMessage(error), 'error'); } }); loginPanel.append(loginForm); view.append(loginPanel); } const actions = create('div', 'page-actions'); const logout = create('button', 'danger-btn', '退出 Steam 并删除 token'); logout.type = 'button'; logout.disabled = state.steam.status === 'logged_out'; logout.addEventListener('click', async () => { try { const result = await api('/api/steam/logout', { method: 'POST' }); updateSteamStatus(result as SteamStatus); renderShell(); } catch (error) { setFeedback(errorMessage(error), 'error'); } }); actions.append(logout); view.append(actions); return view; } function renderUsersView() { const view = create('div', 'users-view'); const filterPanel = panel('筛选', 'toolbar-panel'); const filters = create('form', 'inline-form user-filters') as HTMLFormElement; filters.innerHTML = ` `; (filters.elements.namedItem('role') as HTMLSelectElement).value = state.userRole; (filters.elements.namedItem('status') as HTMLSelectElement).value = state.userStatus; filters.addEventListener('submit', async (event) => { event.preventDefault(); state.userQuery = formValue(filters, 'query'); state.userRole = formValue(filters, 'role'); state.userStatus = formValue(filters, 'status'); await loadUsers(); }); filterPanel.append(filters); const createPanel = panel('新增用户', 'toolbar-panel'); const form = create('form', 'inline-form user-create') as HTMLFormElement; form.innerHTML = ` `; form.addEventListener('submit', async (event) => { event.preventDefault(); try { await api('/api/users', jsonBody({ username: formValue(form, 'username'), displayName: formValue(form, 'displayName'), password: formValue(form, 'password'), role: formValue(form, 'role') || 'user', note: formValue(form, 'note') })); form.reset(); await loadUsers(); } catch (error) { setFeedback(errorMessage(error), 'error'); } }); createPanel.append(form); const listPanel = panel('用户列表', 'list-panel'); const table = create('div', 'user-table'); table.id = 'userTable'; listPanel.append(table); view.append(filterPanel, createPanel, listPanel); renderUserTable(table); return view; } function renderUserTable(container: HTMLElement) { clear(container); if (!state.users.length) { container.append(create('div', 'empty', '暂无用户')); return; } for (const user of state.users) { const row = create('article', 'user-row'); const info = create('div'); const status = user.locked ? '锁定' : user.disabled ? '已禁用' : '启用'; info.append( create('strong', '', `${displayName(user)} (${user.username})`), create('span', 'muted', `${user.role === 'admin' ? '管理员' : '普通用户'} · ${status} · 授权 ${user.steamAccountCount || 0} 个 Steam 账户`), create('span', 'muted', `最近登录 ${dateTime(user.lastLoginAt)} · IP ${user.lastLoginIp || '无'} · 活跃 ${dateTime(user.lastSeenAt)}`), create('span', 'muted', user.note ? `备注:${user.note}` : '无备注') ); const actions = create('div', 'row-actions'); const profile = create('button', 'ghost-btn', '编辑资料'); profile.type = 'button'; profile.addEventListener('click', () => editUserProfile(user)); const role = create('button', 'ghost-btn', user.role === 'admin' ? '降为用户' : '设为管理员'); role.type = 'button'; role.disabled = user.id === state.me?.id; role.addEventListener('click', () => patchUser(user.id, { role: user.role === 'admin' ? 'user' : 'admin' })); const disabled = create('button', 'ghost-btn', user.disabled ? '启用' : '禁用'); disabled.type = 'button'; disabled.disabled = user.id === state.me?.id; disabled.addEventListener('click', () => patchUser(user.id, { disabled: !user.disabled })); const password = create('button', 'ghost-btn', '重置密码'); password.type = 'button'; password.addEventListener('click', () => resetUserPassword(user.id)); const forcePassword = create('button', 'ghost-btn', user.forcePasswordChange ? '取消强制改密' : '要求改密'); forcePassword.type = 'button'; forcePassword.addEventListener('click', () => patchUser(user.id, { forcePasswordChange: !user.forcePasswordChange })); const sessions = create('button', 'ghost-btn', '会话'); sessions.type = 'button'; sessions.addEventListener('click', () => loadUserSessions(user.id)); const grants = create('button', 'ghost-btn', '授权'); grants.type = 'button'; grants.addEventListener('click', () => loadUserSteamAccounts(user.id)); const remove = create('button', 'danger-btn', '删除'); remove.type = 'button'; remove.disabled = user.id === state.me?.id; remove.addEventListener('click', () => deleteUser(user.id)); actions.append(profile, role, disabled, password, forcePassword, sessions, grants, remove); row.append(info, actions); const detail = renderUserDetail(user); if (detail) row.append(detail); container.append(row); } } function renderUserDetail(user: User): HTMLElement | null { const sessions = state.userSessions[user.id]; const grants = state.userSteamAccounts[user.id]; if (!sessions && !grants) return null; const detail = create('div', 'user-detail'); if (sessions) { const block = create('section', 'detail-block'); const revokeAll = create('button', 'danger-btn', '踢下线全部会话'); revokeAll.type = 'button'; revokeAll.addEventListener('click', () => revokeUserSessions(user.id)); block.append(create('h3', '', '会话'), revokeAll); if (!sessions.length) block.append(create('div', 'empty small', '暂无活动会话')); for (const session of sessions) { const row = create('div', 'session-row'); row.append(create('span', '', `${dateTime(session.lastSeenAt)} · ${session.ip || '未知 IP'}`), create('span', 'muted', session.userAgent || '无 User-Agent')); const revoke = create('button', 'danger-btn', '踢下线'); revoke.type = 'button'; revoke.addEventListener('click', () => revokeSession(user.id, session.id)); row.append(revoke); block.append(row); } detail.append(block); } if (grants) { const form = create('form', 'grant-grid') as HTMLFormElement; form.append(create('h3', '', 'Steam 授权')); if (!state.steamAccounts.length) form.append(create('div', 'empty small', '暂无 Steam 账户')); for (const account of state.steamAccounts) { const label = create('label', 'check-row'); const input = create('input') as HTMLInputElement; input.type = 'checkbox'; input.name = 'steamAccountIds'; input.value = String(account.id); input.checked = grants.some((item) => item.id === account.id); label.append(input, create('span', '', `${account.label || account.steamId} · ${account.enabled ? '启用' : '禁用'}`)); form.append(label); } const save = create('button', 'primary-btn', '保存授权'); save.type = 'submit'; form.append(save); form.addEventListener('submit', async (event) => { event.preventDefault(); const ids = [...form.querySelectorAll('input[name="steamAccountIds"]:checked')].map((input) => Number(input.value)); await saveUserSteamAccounts(user.id, ids); }); detail.append(form); } return detail; } async function loadUsers() { if (!hasPermission('user.manage')) return; try { const params = new URLSearchParams(); if (state.userQuery) params.set('query', state.userQuery); if (state.userRole) params.set('role', state.userRole); if (state.userStatus) params.set('status', state.userStatus); const payload = await api(`/api/users${params.size ? `?${params}` : ''}`); state.users = isRecord(payload) && Array.isArray(payload.users) ? payload.users as User[] : []; const table = document.querySelector('#userTable'); if (table) renderUserTable(table); } catch (error) { setFeedback(errorMessage(error), 'error'); } } async function editUserProfile(user: User) { const display = window.prompt('昵称', user.displayName); if (display === null) return; const note = window.prompt('备注', user.note); if (note === null) return; await patchUser(user.id, { displayName: display, note }); } async function patchUser(id: number, patch: Record) { try { await api(`/api/users/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(patch) }); await loadUsers(); } catch (error) { setFeedback(errorMessage(error), 'error'); } } async function resetUserPassword(id: number) { const password = window.prompt('输入新密码,至少 8 位'); if (!password) return; const forcePasswordChange = window.confirm('要求该用户下次登录后修改密码?'); try { await api(`/api/users/${id}/password`, jsonBody({ password, forcePasswordChange })); setFeedback('密码已重置', 'ok'); await loadUsers(); } catch (error) { setFeedback(errorMessage(error), 'error'); } } async function loadUserSessions(id: number) { try { const payload = await api(`/api/users/${id}/sessions`); state.userSessions[id] = isRecord(payload) && Array.isArray(payload.sessions) ? payload.sessions as UserSession[] : []; renderShell(); } catch (error) { setFeedback(errorMessage(error), 'error'); } } async function revokeSession(userId: number, sessionId: string) { try { await api(`/api/users/${userId}/sessions/${encodeURIComponent(sessionId)}`, { method: 'DELETE' }); await loadUserSessions(userId); } catch (error) { setFeedback(errorMessage(error), 'error'); } } async function revokeUserSessions(userId: number) { try { await api(`/api/users/${userId}/sessions`, { method: 'DELETE' }); await loadUserSessions(userId); } catch (error) { setFeedback(errorMessage(error), 'error'); } } async function loadUserSteamAccounts(id: number) { try { await loadSteamAccounts(); const payload = await api(`/api/users/${id}/steam-accounts`); state.userSteamAccounts[id] = isRecord(payload) && Array.isArray(payload.steamAccounts) ? payload.steamAccounts as SteamAccount[] : []; renderShell(); } catch (error) { setFeedback(errorMessage(error), 'error'); } } async function saveUserSteamAccounts(id: number, steamAccountIds: number[]) { try { const payload = await api(`/api/users/${id}/steam-accounts`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ steamAccountIds }) }); state.userSteamAccounts[id] = isRecord(payload) && Array.isArray(payload.steamAccounts) ? payload.steamAccounts as SteamAccount[] : []; setFeedback('授权已保存', 'ok'); await loadUsers(); } catch (error) { setFeedback(errorMessage(error), 'error'); } } async function deleteUser(id: number) { if (!window.confirm('确认删除该后台用户?')) return; try { await api(`/api/users/${id}`, { method: 'DELETE' }); await loadUsers(); } catch (error) { setFeedback(errorMessage(error), 'error'); } } function renderSteamAccountsView() { const view = create('div', 'steam-accounts-view'); if (hasPermission('steam.manage')) { const loginPanel = panel('登录新账户', 'toolbar-panel'); const loginForm = create('form', 'inline-form steam-account-login') as HTMLFormElement; loginForm.innerHTML = ` `; loginForm.addEventListener('submit', async (event) => { event.preventDefault(); const rawLogonID = formValue(loginForm, 'logonID').trim(); try { const status = await api('/api/steam/accounts/login', jsonBody({ label: formValue(loginForm, 'label'), accountName: formValue(loginForm, 'accountName'), password: formValue(loginForm, 'password'), ...(rawLogonID ? { logonID: Number(rawLogonID) } : {}) })); updateSteamStatus(status as SteamStatus); loginForm.reset(); await loadSteamAccounts(); } catch (error) { setFeedback(errorMessage(error), 'error'); } }); loginPanel.append(loginForm); view.append(loginPanel); } const listPanel = panel('账户列表', 'list-panel'); const list = create('div', 'account-table'); list.id = 'steamAccountTable'; listPanel.append(list); view.append(listPanel); renderSteamAccountTable(list); return view; } function renderSteamAccountTable(container: HTMLElement) { clear(container); if (!state.steamAccounts.length) { container.append(create('div', 'empty', '暂无 Steam 账户')); return; } for (const account of state.steamAccounts) { const row = create('article', 'account-row'); const info = create('div'); info.append( create('strong', '', account.label || account.steamId), create('span', 'muted', `${account.steamId} · ${account.active ? '当前活动' : '未连接'} · ${account.enabled ? '启用' : '禁用'} · 授权 ${account.authorizedUserCount || 0} 人`), create('span', 'muted', `最近登录 ${dateTime(account.lastLoginAt)} · 活跃 ${dateTime(account.lastActiveAt)} · token ${dateTime(account.refreshTokenUpdatedAt)}`), create('span', 'muted', account.accountNameHint ? `账号提示:${account.accountNameHint}` : '无账号提示') ); const actions = create('div', 'row-actions'); const connect = create('button', 'ghost-btn', '连接'); connect.type = 'button'; connect.disabled = account.active || !account.enabled; connect.addEventListener('click', () => connectSteamAccount(account.id)); const edit = create('button', 'ghost-btn', '编辑'); edit.type = 'button'; edit.addEventListener('click', () => editSteamAccount(account)); const toggle = create('button', 'ghost-btn', account.enabled ? '禁用' : '启用'); toggle.type = 'button'; toggle.addEventListener('click', () => patchSteamAccount(account.id, { enabled: !account.enabled })); const logout = create('button', 'ghost-btn', '退出'); logout.type = 'button'; logout.disabled = !account.active; logout.addEventListener('click', () => logoutSteamAccount(account.id)); const remove = create('button', 'danger-btn', '删除'); remove.type = 'button'; remove.addEventListener('click', () => deleteSteamAccount(account.id)); actions.append(connect, edit, toggle, logout, remove); row.append(info, actions); container.append(row); } } async function loadSteamAccounts() { try { const payload = await api('/api/steam/accounts'); state.steamAccounts = isRecord(payload) && Array.isArray(payload.steamAccounts) ? payload.steamAccounts as SteamAccount[] : []; const table = document.querySelector('#steamAccountTable'); if (table) renderSteamAccountTable(table); } catch (error) { setFeedback(errorMessage(error), 'error'); } } async function connectSteamAccount(id: number) { try { const status = await api(`/api/steam/accounts/${id}/connect`, { method: 'POST' }); updateSteamStatus(status as SteamStatus); await loadSteamAccounts(); } catch (error) { setFeedback(errorMessage(error), 'error'); } } async function editSteamAccount(account: SteamAccount) { const label = window.prompt('显示名称', account.label); if (label === null) return; const accountNameHint = window.prompt('账号提示', account.accountNameHint); if (accountNameHint === null) return; await patchSteamAccount(account.id, { label, accountNameHint }); } async function patchSteamAccount(id: number, patch: Record) { try { await api(`/api/steam/accounts/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(patch) }); await loadSteamAccounts(); } catch (error) { setFeedback(errorMessage(error), 'error'); } } async function logoutSteamAccount(id: number) { try { const status = await api(`/api/steam/accounts/${id}/logout`, { method: 'POST' }); updateSteamStatus(status as SteamStatus); await loadSteamAccounts(); } catch (error) { setFeedback(errorMessage(error), 'error'); } } async function deleteSteamAccount(id: number) { if (!window.confirm('确认删除该 Steam 账户资料和授权关系?')) return; try { await api(`/api/steam/accounts/${id}`, { method: 'DELETE' }); await loadSteamAccounts(); } catch (error) { setFeedback(errorMessage(error), 'error'); } } function renderAuditView() { const view = create('div', 'audit-view'); const filterPanel = panel('筛选', 'toolbar-panel'); const filters = create('form', 'inline-form audit-filters') as HTMLFormElement; filters.innerHTML = ` `; filters.addEventListener('submit', async (event) => { event.preventDefault(); state.auditAction = formValue(filters, 'action'); state.auditTargetType = formValue(filters, 'targetType'); await loadAuditLogs(); }); filterPanel.append(filters); const listPanel = panel('日志列表', 'list-panel'); const table = create('div', 'audit-table'); table.id = 'auditTable'; listPanel.append(table); view.append(filterPanel, listPanel); renderAuditTable(table); return view; } function renderAuditTable(container: HTMLElement) { clear(container); if (!state.auditLogs.length) { container.append(create('div', 'empty', '暂无审计日志')); return; } for (const item of state.auditLogs) { const row = create('article', 'audit-row'); row.append( create('strong', '', `${dateTime(item.createdAt)} · ${item.action}`), create('span', 'muted', `操作者 ${item.actorUsername || item.actorUserId || '系统'} · ${item.targetType}:${item.targetId} · ${item.ip || '无 IP'}`), create('code', '', compactJson(item.detail)) ); container.append(row); } } async function loadAuditLogs() { try { const params = new URLSearchParams(); if (state.auditAction) params.set('action', state.auditAction); if (state.auditTargetType) params.set('targetType', state.auditTargetType); const payload = await api(`/api/audit-logs${params.size ? `?${params}` : ''}`); state.auditLogs = isRecord(payload) && Array.isArray(payload.auditLogs) ? payload.auditLogs as AuditLog[] : []; const table = document.querySelector('#auditTable'); if (table) renderAuditTable(table); } catch (error) { setFeedback(errorMessage(error), 'error'); } } function renderAccountView() { const view = create('div', 'account-view'); const accountPanel = panel('修改密码', 'form-panel'); const form = create('form', 'stack-form narrow-form') as HTMLFormElement; form.innerHTML = ` `; form.addEventListener('submit', async (event) => { event.preventDefault(); try { await api('/api/auth/password', jsonBody({ oldPassword: formValue(form, 'oldPassword'), newPassword: formValue(form, 'newPassword') })); form.reset(); setFeedback('密码已修改', 'ok'); } catch (error) { setFeedback(errorMessage(error), 'error'); } }); accountPanel.append(form); view.append(accountPanel); return view; } function renderChatView() { const view = create('div', 'chat-layout'); view.id = 'chatLayout'; view.dataset.mobilePanel = state.chatPanel; const lists = create('aside', 'chat-lists'); lists.setAttribute('aria-label', '会话列表'); const listHead = create('header', 'chat-list-head'); const titleRow = create('div', 'chat-list-title'); titleRow.append(create('h2', '', '会话')); const newConversation = create('button', 'new-chat-btn'); newConversation.type = 'button'; newConversation.append(chatIcon('plus'), create('span', '', '新建')); titleRow.append(newConversation); const search = create('label', 'chat-search'); search.append(chatIcon('search')); const searchInput = create('input') as HTMLInputElement; searchInput.type = 'search'; searchInput.placeholder = '搜索会话'; searchInput.value = state.chatQuery; searchInput.setAttribute('aria-label', '搜索当前分类'); searchInput.addEventListener('input', () => { state.chatQuery = searchInput.value; updateChatLists(); }); search.append(searchInput); const tabList = create('div', 'chat-tabs'); tabList.setAttribute('role', 'tablist'); const tabLabels: Array<[ChatListTab, string]> = [['recent', '最近'], ['friends', '好友'], ['groups', '群组']]; for (const [tab, label] of tabLabels) { const button = create('button', state.chatListTab === tab ? 'is-active' : '', label); button.type = 'button'; button.dataset.chatTab = tab; button.setAttribute('role', 'tab'); button.setAttribute('aria-controls', 'chatListSections'); button.setAttribute('aria-selected', String(state.chatListTab === tab)); button.addEventListener('click', () => { state.chatListTab = tab; updateChatTabs(); updateChatLists(); }); tabList.append(button); } listHead.append(titleRow, search, tabList); const listBody = create('div', 'conversation-list'); listBody.id = 'chatListSections'; listBody.setAttribute('role', 'tabpanel'); renderChatListSections(listBody); const dialog = create('dialog', 'new-chat-dialog') as HTMLDialogElement; const targetInput = create('input') as HTMLInputElement; targetInput.name = 'target'; targetInput.inputMode = 'numeric'; targetInput.autocomplete = 'off'; targetInput.placeholder = 'SteamID64'; targetInput.pattern = '[0-9]+'; targetInput.required = true; const dialogHead = create('div', 'dialog-head'); dialogHead.append(create('h2', '', '新建会话')); const closeDialog = chatIconButton('x', '关闭', 'dialog-close'); closeDialog.addEventListener('click', () => dialog.close()); dialogHead.append(closeDialog); const openForm = create('form', 'new-chat-form') as HTMLFormElement; const targetLabel = create('label', '', 'SteamID64'); targetLabel.append(targetInput); const openButton = create('button', 'primary-btn', '打开会话'); openButton.type = 'submit'; openForm.append(targetLabel, openButton); openForm.addEventListener('submit', (event) => { event.preventDefault(); dialog.close(); openConversation(formValue(openForm, 'target')); }); dialog.append(dialogHead, openForm); dialog.addEventListener('click', (event) => { if (event.target === dialog) dialog.close(); }); newConversation.addEventListener('click', () => { targetInput.value = ''; dialog.showModal(); targetInput.focus(); }); lists.append(listHead, listBody, dialog); const thread = create('section', 'thread'); const head = create('header', 'thread-head'); head.id = 'threadHead'; renderThreadHeader(head); const messages = create('div', 'messages'); messages.id = 'messages'; messages.setAttribute('aria-live', 'polite'); const composer = renderComposer(); thread.append(head, messages, composer); view.append(lists, thread); setTimeout(() => { if (state.activeId) void loadHistory(); else renderHistory([]); updateChatAvailability(); }, 0); return view; } function renderChatListSections(container: HTMLElement) { clear(container); const items = filterChatEntries(chatEntriesForActiveTab(), state.chatQuery); if (!items.length) { container.append(create('div', 'conversation-empty', state.chatQuery.trim() ? '没有匹配的会话' : '暂无会话')); return; } for (const item of items) container.append(renderListItem(item)); } function filterChatEntries(items: ListEntry[], query: string): ListEntry[] { const normalized = query.trim().toLocaleLowerCase('zh-CN'); if (!normalized) return items; return items.filter((item) => [item.name, item.id, item.preview] .some((value) => String(value || '').toLocaleLowerCase('zh-CN').includes(normalized))); } function chatEntriesForActiveTab() { if (state.chatListTab === 'friends') return state.friends; if (state.chatListTab === 'groups') return state.groups; return state.conversations; } function updateChatTabs() { document.querySelectorAll('[data-chat-tab]').forEach((button) => { const active = button.dataset.chatTab === state.chatListTab; button.classList.toggle('is-active', active); button.setAttribute('aria-selected', String(active)); }); } function updateChatLists() { const list = document.querySelector('#chatListSections'); if (list) renderChatListSections(list); } function renderAvatar(item: ListEntry | null, name: string, className = 'avatar') { const avatar = create('span', className, String(name || '?').slice(0, 1).toUpperCase()); if (item?.avatar) { const image = document.createElement('img'); image.src = item.avatar; image.alt = ''; avatar.replaceChildren(image); } if (item?.online) avatar.append(create('span', 'online-dot')); return avatar; } function activeChatEntry() { return [...state.conversations, ...state.friends, ...state.groups].find((item) => item.id === state.activeId) || null; } function renderThreadHeader(container: HTMLElement) { clear(container); const back = chatIconButton('arrow-left', '返回会话列表', 'thread-back'); back.addEventListener('click', showChatList); const item = activeChatEntry(); const name = state.activeName || item?.name || state.activeId || '未选择会话'; const identity = create('div', 'thread-identity'); identity.append(create('strong', '', name), create('span', 'muted', state.activeId || 'Steam Chat')); container.append(back, renderAvatar(item, name, 'thread-avatar'), identity); } function formatConversationTime(value: unknown) { if (!value) return ''; const date = new Date(String(value)); if (Number.isNaN(date.getTime())) return ''; const now = new Date(); if (date.toDateString() === now.toDateString()) { return date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }); } return date.toLocaleDateString('zh-CN', { month: '2-digit', day: '2-digit' }); } function renderListItem(item: ListEntry) { const button = create('button', `list-item${item.id === state.activeId ? ' is-active' : ''}`); button.type = 'button'; button.setAttribute('aria-current', item.id === state.activeId ? 'true' : 'false'); const name = item.name || item.id; const body = create('span', 'item-body'); const title = create('span', 'item-title'); title.append(create('strong', '', name), create('time', '', formatConversationTime(item.updatedAt))); body.append(title, create('span', 'item-preview', item.preview || item.gameName || item.clanId || item.clanid || item.id)); button.append(renderAvatar(item, name), body); button.addEventListener('click', () => openConversation(item.id, item.name || item.id)); return button; } function renderComposer() { const shell = create('footer', 'composer'); const disabledNote = create('div', 'offline-note', 'Steam 未在线,聊天发送和素材操作不可用。'); disabledNote.id = 'offlineNote'; const picker = create('div', 'picker'); picker.id = 'picker'; picker.hidden = true; picker.setAttribute('role', 'dialog'); picker.setAttribute('aria-label', '表情和贴纸'); const row = create('div', 'compose-row'); const pickerButton = chatIconButton('smile', '表情和贴纸', 'composer-tool'); pickerButton.id = 'pickerToggle'; const pickerControls = bindFloatingPanel(pickerButton, picker); pickerButton.addEventListener('click', () => { attachmentControls.close(); if (picker.hidden) renderPicker(picker); pickerControls.toggle(); }); const attachment = create('div', 'attachment-shell'); const attachmentButton = chatIconButton('paperclip', '添加图片', 'composer-tool'); attachmentButton.id = 'attachmentToggle'; const attachmentMenu = create('div', 'attachment-menu'); attachmentMenu.id = 'attachmentMenu'; attachmentMenu.hidden = true; attachmentMenu.setAttribute('role', 'dialog'); attachmentMenu.setAttribute('aria-label', '添加图片'); const fileInput = create('input') as HTMLInputElement; fileInput.type = 'file'; fileInput.accept = 'image/*'; fileInput.multiple = true; fileInput.hidden = true; const uploadButton = create('button', 'attachment-action'); uploadButton.type = 'button'; uploadButton.append(chatIcon('image'), create('span', '', '上传图片')); const urlButton = create('button', 'attachment-action'); urlButton.type = 'button'; urlButton.append(chatIcon('link'), create('span', '', '图片 URL')); const imageForm = create('form', 'image-url-form') as HTMLFormElement; imageForm.hidden = true; imageForm.innerHTML = ''; const attachmentControls = bindFloatingPanel(attachmentButton, attachmentMenu); attachmentButton.addEventListener('click', () => { pickerControls.close(); if (attachmentMenu.hidden) imageForm.hidden = true; attachmentControls.toggle(); }); uploadButton.addEventListener('click', () => { attachmentControls.close(); fileInput.click(); }); urlButton.addEventListener('click', () => { imageForm.hidden = !imageForm.hidden; if (!imageForm.hidden) imageForm.querySelector('input')?.focus(); }); fileInput.addEventListener('change', () => { sendFiles(fileInput.files); fileInput.value = ''; }); imageForm.addEventListener('submit', (event) => { event.preventDefault(); const url = formValue(imageForm, 'url').trim(); if (!url) return; imageForm.reset(); attachmentControls.close(); void sendImage({ url }); }); attachmentMenu.append(uploadButton, urlButton, imageForm); attachment.append(attachmentButton, attachmentMenu, fileInput); const input = create('textarea') as HTMLTextAreaElement; input.id = 'messageInput'; input.rows = 1; input.placeholder = '发送消息'; input.setAttribute('aria-label', '消息'); input.addEventListener('input', () => resizeComposerInput(input)); input.addEventListener('keydown', (event) => { if (event.key === 'Enter' && !event.shiftKey) { event.preventDefault(); void sendText(); } }); const send = chatIconButton('send', '发送消息', 'send-btn'); send.id = 'sendButton'; send.addEventListener('click', () => sendText()); row.append(pickerButton, attachment, input, send); shell.append(disabledNote, picker, row); shell.querySelectorAll('button, input, textarea').forEach((node) => { node.dataset.chatControl = ''; }); return shell; } function bindFloatingPanel(trigger: HTMLButtonElement, panel: HTMLElement) { const close = () => { panel.hidden = true; trigger.setAttribute('aria-expanded', 'false'); document.removeEventListener('pointerdown', handlePointerDown); document.removeEventListener('keydown', handleKeyDown); }; const handlePointerDown = (event: PointerEvent) => { if (event.target instanceof Node && !panel.contains(event.target) && !trigger.contains(event.target)) close(); }; const handleKeyDown = (event: KeyboardEvent) => { if (event.key === 'Escape') { close(); trigger.focus(); } }; const toggle = () => { if (!panel.hidden) { close(); return; } panel.hidden = false; trigger.setAttribute('aria-expanded', 'true'); document.addEventListener('pointerdown', handlePointerDown); document.addEventListener('keydown', handleKeyDown); }; trigger.setAttribute('aria-haspopup', 'dialog'); trigger.setAttribute('aria-expanded', 'false'); return { close, toggle }; } function resizeComposerInput(input: HTMLTextAreaElement) { input.style.height = 'auto'; input.style.height = `${Math.min(input.scrollHeight, 160)}px`; } function updateChatAvailability() { const unavailable = !steamOnline() || !steamAccessAllowed(); const disabled = unavailable || !state.activeId; document.querySelectorAll('[data-chat-control]').forEach((node) => { node.disabled = disabled; }); const note = document.querySelector('#offlineNote'); if (note) { note.textContent = !steamAccessAllowed() ? '当前账号未被授权访问活动 Steam 账户。' : 'Steam 未在线,聊天发送和素材操作不可用。'; note.hidden = !unavailable; } } async function refreshChatData() { if (!state.me) return; if (!steamAccessAllowed()) { state.conversations = []; state.friends = []; state.groups = []; state.emoticons = []; state.stickers = []; updateChatLists(); updateChatAvailability(); return; } try { const conversations = await api(`/conversations?limit=${state.historyLimit}`); state.conversations = asListEntries(conversations); if (steamOnline()) { const [friends, groups, inventory] = await Promise.all([ api('/api/friends'), api('/api/groups'), api('/api/emoticons') ]); state.friends = asListEntries(friends); state.groups = asListEntries(groups); state.emoticons = isRecord(inventory) ? asInventory(inventory.emoticons) : []; state.stickers = isRecord(inventory) ? asInventory(inventory.stickers) : []; } else { state.friends = []; state.groups = []; state.emoticons = []; state.stickers = []; } if (state.view === 'chat') { updateChatLists(); if (!state.activeName) state.activeName = activeChatEntry()?.name || ''; const head = document.querySelector('#threadHead'); if (head) renderThreadHeader(head); updateChatAvailability(); } } catch (error) { setFeedback(errorMessage(error), 'error'); } } function openConversation(id: unknown, name = '') { const target = String(id || '').trim(); if (!target) { setFeedback('请输入 SteamID64', 'warn'); return; } const alreadyActive = target === state.activeId; state.activeId = target; state.activeName = name || target; state.chatPanel = 'thread'; localStorage.setItem('steam-chat.target', state.activeId); if (state.view !== 'chat') return; const layout = document.querySelector('#chatLayout'); if (!layout) { renderShell(); return; } syncChatPanel(); updateChatLists(); const head = document.querySelector('#threadHead'); if (head) renderThreadHeader(head); updateChatAvailability(); if (!alreadyActive) void loadHistory(); } function showChatList() { state.chatPanel = 'list'; syncChatPanel(); } function syncChatPanel() { const layout = document.querySelector('#chatLayout'); if (layout) layout.dataset.mobilePanel = state.chatPanel; } async function loadHistory() { const messages = document.querySelector('#messages'); if (!messages) return; if (!state.activeId) { renderHistory([]); return; } const target = state.activeId; try { const history = await api(`/history?id=${encodeURIComponent(target)}&limit=${state.historyLimit}`); if (target !== state.activeId) return; renderHistory(asMessages(history)); } catch (error) { if (target !== state.activeId) return; setFeedback(errorMessage(error), 'error'); } } function renderHistory(items: MessageItem[]) { const messages = document.querySelector('#messages'); if (!messages) return; clear(messages); if (!items.length) { const empty = create('div', 'thread-empty'); empty.append(create('strong', '', state.activeId ? '暂无消息' : '未选择会话')); messages.append(empty); return; } for (const item of items) messages.append(renderMessage(item)); messages.scrollTop = messages.scrollHeight; } function renderMessage(item: MessageItem) { const row = create('article', `msg-row${item.echo ? ' is-self' : ''}`); const bubble = create('div', 'bubble'); const meta = create('div', 'meta'); meta.append(create('span', '', item.name || (item.echo ? '我' : item.id)), create('span', '', formatTime(item.sentAt || item.date))); const content = create('div', 'message-content'); const message = item.message || ''; if (item.type === 'image') { if (isRemoteImageSource(message)) content.append(imageNode(message)); else appendMessageText(content, message || '[图片]'); } else { appendMessageText(content, message); } bubble.append(meta, content); row.append(bubble); return row; } function appendMessageText(container: HTMLElement, text: string) { const lowerText = text.toLowerCase(); let lastIndex = 0; while (lastIndex < text.length) { const block = findSupportedBbcodeStart(text, lastIndex); if (!block) break; const { start, tag } = block; if (start > lastIndex) appendInlineMessageText(container, text.slice(lastIndex, start)); const openEnd = findBbcodeTagEnd(text, start + tag.length + 1); if (openEnd < 0) { appendInlineMessageText(container, text.slice(start)); return; } const closeTag = `[/${tag}]`; const closeStart = lowerText.indexOf(closeTag, openEnd + 1); if (closeStart < 0) { appendInlineMessageText(container, text.slice(start)); return; } const blockEnd = closeStart + closeTag.length; const attributes = text.slice(start + tag.length + 1, openEnd); const body = text.slice(openEnd + 1, closeStart); if (!appendSupportedBbcode(container, tag, attributes, body)) { appendInlineMessageText(container, text.slice(start, blockEnd)); } lastIndex = blockEnd; } if (lastIndex < text.length) appendInlineMessageText(container, text.slice(lastIndex)); } function findSupportedBbcodeStart(text: string, fromIndex: number): { start: number; tag: SupportedBbcodeTag } | null { supportedBbcodePattern.lastIndex = fromIndex; const match = supportedBbcodePattern.exec(text); const tag = match?.[1].toLowerCase() || ''; return match && isSupportedBbcodeTag(tag) ? { start: match.index, tag } : null; } function isSupportedBbcodeTag(value: string): value is SupportedBbcodeTag { return supportedBbcodeTagSet.has(value); } function findBbcodeTagEnd(text: string, fromIndex: number): number { let quote = ''; for (let index = fromIndex; index < text.length; index += 1) { const character = text[index]; if (quote) { if (character === '\\' && index + 1 < text.length) index += 1; else if (character === quote) quote = ''; } else if (character === '"' || character === "'") { quote = character; } else if (character === ']') { return index; } } return -1; } function appendSupportedBbcode( container: HTMLElement, tag: SupportedBbcodeTag, attributes: string, body: string ): boolean { if (tag === 'og') { const preview = parseOpenGraphPreview(attributes); if (!preview) return false; container.append(openGraphNode(preview)); if (body.trim() !== preview.url) appendInlineMessageText(container, body); return true; } if (tag === 'url') { const url = parseUrlTarget(attributes, body); if (!url) return false; container.append(externalLink('', url, body.trim() ? body : url)); return true; } if (tag === 'emoticon') { const name = body.trim(); if (attributes.trim() || !/^[A-Za-z0-9_+\-.]+$/.test(name)) return false; container.append(emoticonNode(name)); return true; } if (tag === 'sticker') { const values = parseBbcodeAttributes(attributes, new Set(['type', 'limit'])); const type = values?.type?.trim() || ''; if (!values || !type || body.trim()) return false; container.append(stickerNode(type)); return true; } const preview = parseSteamImagePreview(attributes, body); if (!preview) return false; container.append(steamImageNode(preview)); return true; } function parseUrlTarget(attributes: string, body: string): string { if (!attributes.trim()) return httpUrl(body); const values = parseBbcodeAttributes(`href${attributes}`, new Set(['href'])); return values ? httpUrl(values.href) : ''; } function parseOpenGraphPreview(attributes: string): OpenGraphPreview | null { const values = parseBbcodeAttributes(attributes, new Set(['url', 'img', 'title', 'desc']), true); if (!values) return null; const url = httpUrl(values.url); if (!url) return null; return { url, imageUrl: httpUrl(values.img), title: values.title || '', description: values.desc || '' }; } function parseBbcodeAttributes(source: string, allowed: Set, requireQuoted = false): Record | null { const values: Record = {}; let index = 0; while (index < source.length) { while (index < source.length && /\s/.test(source[index])) index += 1; if (index >= source.length) break; if (!/[a-z_]/i.test(source[index])) return null; const nameStart = index; index += 1; while (index < source.length && /[a-z0-9_-]/i.test(source[index])) index += 1; const name = source.slice(nameStart, index).toLowerCase(); if (!allowed.has(name) || Object.hasOwn(values, name)) return null; while (index < source.length && /\s/.test(source[index])) index += 1; if (source[index] !== '=') return null; index += 1; while (index < source.length && /\s/.test(source[index])) index += 1; const quote = source[index]; if (requireQuoted && quote !== '"' && quote !== "'") return null; let value = ''; const quoted = quote === '"' || quote === "'"; if (quoted) { index += 1; let closed = false; while (index < source.length) { const character = source[index]; if (character === quote) { closed = true; index += 1; break; } if (character === '\\' && index + 1 < source.length && (source[index + 1] === quote || source[index + 1] === '\\')) { value += source[index + 1]; index += 2; } else { value += character; index += 1; } } if (!closed) return null; } else { const valueStart = index; while (index < source.length && !/\s/.test(source[index])) index += 1; value = source.slice(valueStart, index); } if (!value && !quoted) return null; values[name] = value; } return values; } function parseSteamImagePreview(attributes: string, body: string): SteamImagePreview | null { const values = parseBbcodeAttributes(attributes, new Set(['src', 'thumbnail_src', 'srcset', 'width', 'height'])); if (!values) return null; const sourceUrl = httpUrl(values.src); if (!sourceUrl || !imageBodyMatchesSource(body, sourceUrl)) return null; return { sourceUrl, displayUrl: httpUrl(values.thumbnail_src) || sourceUrl, sourceSet: proxyImageSourceSet(values.srcset), width: positiveInteger(values.width), height: positiveInteger(values.height) }; } function imageBodyMatchesSource(body: string, sourceUrl: string): boolean { const trimmed = body.trim(); if (!/^\[url(?=[=\]])/i.test(trimmed)) return false; const openEnd = findBbcodeTagEnd(trimmed, 4); if (openEnd < 0) return false; const closeStart = trimmed.toLowerCase().indexOf('[/url]', openEnd + 1); if (closeStart < 0 || closeStart + '[/url]'.length !== trimmed.length) return false; const linkBody = trimmed.slice(openEnd + 1, closeStart); const target = parseUrlTarget(trimmed.slice(4, openEnd), linkBody); return target === sourceUrl && linkBody.trim() === sourceUrl; } function positiveInteger(value: unknown): number { const source = typeof value === 'string' ? value.trim() : ''; if (!/^[1-9]\d*$/.test(source)) return 0; const parsed = Number(source); return Number.isSafeInteger(parsed) ? parsed : 0; } function proxyImageSourceSet(value: unknown): string { const source = typeof value === 'string' ? value.trim() : ''; if (!source) return ''; const candidates: string[] = []; for (const candidate of source.split(',')) { const parts = candidate.trim().split(/\s+/); if (!parts[0] || parts.length > 2) return ''; const url = httpUrl(parts[0]); const descriptor = parts[1] || ''; if (!url || (descriptor && !validSourceSetDescriptor(descriptor))) return ''; candidates.push(`${proxiedImageUrl(url)}${descriptor ? ` ${descriptor}` : ''}`); } return candidates.join(', '); } function validSourceSetDescriptor(value: string): boolean { if (/^[1-9]\d*w$/.test(value)) return true; const match = value.match(/^(\d+(?:\.\d+)?|\.\d+)x$/); return Boolean(match && Number(match[1]) > 0); } function httpUrl(value: unknown): string { const source = typeof value === 'string' ? value.trim() : ''; if (!source) return ''; try { const parsed = new URL(source); return parsed.protocol === 'http:' || parsed.protocol === 'https:' ? source : ''; } catch (_) { return ''; } } function proxiedImageUrl(url: string): string { return `/proxy/image?url=${encodeURIComponent(url)}`; } function externalLink(className: string, url: string, text = '') { const link = create('a', className, text); link.href = url; link.target = '_blank'; link.rel = 'noopener noreferrer'; return link; } function openGraphNode(preview: OpenGraphPreview) { const card = create('section', 'og-card'); const main = create('div', 'og-main'); if (preview.imageUrl) { const imageLink = externalLink('og-image-link', preview.url); const image = document.createElement('img'); image.className = 'og-image'; image.src = proxiedImageUrl(preview.imageUrl); image.alt = preview.title || '链接预览'; image.loading = 'lazy'; image.addEventListener('error', () => { imageLink.hidden = true; }); imageLink.append(image); main.append(imageLink); } const body = create('div', 'og-body'); if (preview.title) body.append(externalLink('og-title', preview.url, preview.title)); if (preview.description) body.append(create('p', 'og-description', preview.description)); main.append(body); const footer = create('div', 'og-footer'); const domain = externalLink('og-domain', preview.url, new URL(preview.url).hostname.replace(/^www\./i, '').toUpperCase()); domain.title = preview.url; const copyButton = create('button', 'og-copy-button'); copyButton.type = 'button'; copyButton.title = '复制链接'; copyButton.setAttribute('aria-label', '复制链接'); copyButton.append(create('span', 'og-copy-icon')); copyButton.addEventListener('click', async () => { try { await navigator.clipboard.writeText(preview.url); copyButton.title = '已复制'; setTimeout(() => { copyButton.title = '复制链接'; }, 1500); } catch (_) { setFeedback('复制链接失败', 'error'); } }); footer.append(domain, copyButton); card.append(main, footer); return card; } function emoticonNode(name: string) { const image = document.createElement('img'); image.className = 'emoticon'; image.src = `https://community.cloudflare.steamstatic.com/economy/emoticon/${encodeURIComponent(name)}`; image.alt = `:${name}:`; return image; } function stickerNode(type: string) { const image = document.createElement('img'); image.className = 'sticker'; image.src = `/proxy/sticker/${encodeURIComponent(type)}`; image.alt = type; return image; } function steamImageNode(preview: SteamImagePreview) { const shell = create('span', 'bbcode-image-shell'); const button = create('button', 'image-button bbcode-image-button'); button.type = 'button'; button.title = '查看大图'; button.setAttribute('aria-label', '查看大图'); if (preview.width && preview.height) { button.style.aspectRatio = `${preview.width} / ${preview.height}`; button.style.width = `${Math.max(1, Math.min(420, Math.floor((420 * preview.width) / preview.height)))}px`; } const image = document.createElement('img'); image.className = 'bbcode-image'; image.src = proxiedImageUrl(preview.displayUrl); if (preview.sourceSet) image.srcset = preview.sourceSet; image.alt = '图片'; image.loading = 'lazy'; image.addEventListener('error', () => { const fallback = externalLink('bbcode-image-fallback', preview.sourceUrl, preview.sourceUrl); fallback.title = '图片加载失败'; shell.replaceChildren(fallback); }); button.addEventListener('click', () => openLightbox(proxiedImageUrl(preview.sourceUrl))); button.append(image); shell.append(button); return shell; } function appendInlineMessageText(container: HTMLElement, text: string) { const pattern = /:([A-Za-z0-9_+\-.]+):|(https?:\/\/[^\s<>"'\[\]]+)/gi; let lastIndex = 0; for (const match of text.matchAll(pattern)) { if (match.index > lastIndex) container.append(document.createTextNode(text.slice(lastIndex, match.index))); if (match[1]) { container.append(emoticonNode(match[1])); } else if (match[2]) { container.append(externalLink('', match[2], match[2])); } lastIndex = match.index + match[0].length; } if (lastIndex < text.length) container.append(document.createTextNode(text.slice(lastIndex))); } function imageNode(sourceUrl: string) { const shell = create('button', 'image-button', '加载图片'); shell.type = 'button'; const image = document.createElement('img'); image.src = proxiedImageUrl(sourceUrl); image.alt = '图片'; image.onload = () => shell.replaceChildren(image); image.onerror = () => shell.textContent = '图片加载失败'; shell.addEventListener('click', () => openLightbox(image.src)); return shell; } function isRemoteImageSource(value: unknown): value is string { return typeof value === 'string' && /^https?:\/\//i.test(value); } function formatTime(value: unknown): string { if (!value) return ''; const date = new Date(String(value).replace(' ', 'T')); if (Number.isNaN(date.getTime())) return String(value); return date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }); } function activeIdOrWarn() { if (state.activeId) return state.activeId; setFeedback('请选择会话', 'warn'); return ''; } async function sendText() { const id = activeIdOrWarn(); const input = document.querySelector('#messageInput'); const msg = input?.value.trim() || ''; if (!id || !msg) return; try { await api('/message', jsonBody({ id, msg })); if (input) { input.value = ''; resizeComposerInput(input); } await loadHistory(); setFeedback('已发送', 'ok'); } catch (error) { setFeedback(errorMessage(error), 'error'); } } async function sendImage(payload: Record) { const id = activeIdOrWarn(); if (!id) return; try { await api('/image', jsonBody({ id, ...payload })); await loadHistory(); setFeedback('图片已发送', 'ok'); } catch (error) { setFeedback(errorMessage(error), 'error'); } } function sendFiles(files: FileList | null) { if (!files) return; [...files].filter((file) => file.type.startsWith('image/')).forEach((file) => { const reader = new FileReader(); reader.onload = () => { if (typeof reader.result === 'string') void sendImage({ img: reader.result }); }; reader.onerror = () => setFeedback('读取图片失败', 'error'); reader.readAsDataURL(file); }); } function renderPicker(container: HTMLElement) { clear(container); const tabs = create('div', 'picker-tabs'); const emoticons = create('button', '', '表情'); const stickers = create('button', '', '贴纸'); const grid = create('div', 'picker-grid'); function fill(type: 'emoticons' | 'stickers') { clear(grid); const source = (type === 'emoticons' ? state.emoticons : state.stickers).slice(0, 160); if (!source.length) { grid.append(create('div', 'empty small', '暂无素材')); return; } for (const item of source) { const name = String(item.name || ''); if (!name) continue; const button = create('button'); button.type = 'button'; const image = document.createElement('img'); image.src = type === 'emoticons' ? `https://community.cloudflare.steamstatic.com/economy/emoticon/${encodeURIComponent(name)}` : `/proxy/sticker/${encodeURIComponent(name)}`; image.alt = name; button.append(image, create('span', '', name)); button.addEventListener('click', () => { const input = document.querySelector('#messageInput'); if (!input) return; input.value += type === 'emoticons' ? `:${name}:` : `[sticker type="${name}" limit="0"][/sticker]`; input.focus(); }); grid.append(button); } } emoticons.type = 'button'; stickers.type = 'button'; emoticons.addEventListener('click', () => fill('emoticons')); stickers.addEventListener('click', () => fill('stickers')); tabs.append(emoticons, stickers); container.append(tabs, grid); fill('emoticons'); } function ensureWebSocket() { if (!state.me || !steamAccessAllowed() || state.ws || state.reconnectTimer) return; const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'; const ws = new WebSocket(`${protocol}//${location.host}${state.wsPath}`); state.ws = ws; ws.addEventListener('message', (event) => { let payload: WsPayload; try { const parsed: unknown = JSON.parse(String(event.data)); payload = isRecord(parsed) ? parsed : {}; } catch { return; } if (payload.type === 'message' || payload.type === 'image') { const item: MessageItem = { ...payload, id: String(payload.id || ''), name: typeof payload.name === 'string' ? payload.name : undefined, message: typeof payload.message === 'string' ? payload.message : undefined, ordinal: typeof payload.ordinal === 'string' || typeof payload.ordinal === 'number' ? payload.ordinal : 0, echo: Boolean(payload.echo), date: typeof payload.date === 'string' ? payload.date : undefined, sentAt: typeof payload.sentAt === 'string' ? payload.sentAt : undefined }; if (item.id === state.activeId) { const messages = document.querySelector('#messages'); messages?.querySelector('.thread-empty')?.remove(); messages?.append(renderMessage(item)); if (messages) messages.scrollTop = messages.scrollHeight; } } }); ws.addEventListener('close', () => { state.ws = null; if (state.me) { state.reconnectTimer = setTimeout(() => { state.reconnectTimer = null; ensureWebSocket(); }, 1500); } }); } function stopWebSocket() { if (state.reconnectTimer) clearTimeout(state.reconnectTimer); state.reconnectTimer = null; if (state.ws) state.ws.close(); state.ws = null; } function startStatusPolling() { if (state.statusTimer || !state.me) return; state.statusTimer = setInterval(async () => { try { const status = await api('/api/steam/status'); const previousSignature = steamStatusSignature(); const wasOnline = steamOnline(); updateSteamStatus(status as SteamStatus); if (state.view === 'steam' && steamStatusSignature() !== previousSignature) { renderShell(); return; } if (state.view === 'chat' && steamOnline() !== wasOnline) await refreshChatData(); } catch (_) { // Authentication errors are handled by api(). } }, 3000); } function stopStatusPolling() { if (state.statusTimer) clearInterval(state.statusTimer); state.statusTimer = null; } async function logoutApp() { await api('/api/auth/logout', { method: 'POST' }).catch((): null => null); stopStatusPolling(); stopWebSocket(); state.me = null; state.permissions = []; renderLogin(); } async function bootstrap() { const mePayload = await api('/api/auth/me') as MeResponse; state.needsSetup = Boolean(mePayload.needsSetup); state.me = mePayload.user || null; state.permissions = Array.isArray(mePayload.permissions) ? mePayload.permissions : []; updateSteamStatus(mePayload.steam || defaultSteamStatus); if (state.needsSetup) { renderSetup(); return; } if (!state.me) { renderLogin(); return; } const [status, config] = await Promise.all([ api('/api/steam/status').catch((): SteamStatus => state.steam), api('/api/config').catch(() => ({ wsPath: '/ws' })) ]); if (isRecord(status)) updateSteamStatus(status as SteamStatus); if (isRecord(config) && typeof config.wsPath === 'string') state.wsPath = config.wsPath; renderShell(); } function openLightbox(src: string) { const box = document.querySelector('#lightbox'); const image = document.querySelector('#lightboxImage'); if (!box || !image) return; image.src = src; box.hidden = false; } document.querySelector('#lightboxClose')?.addEventListener('click', () => { const box = document.querySelector('#lightbox'); const image = document.querySelector('#lightboxImage'); if (box) box.hidden = true; if (image) image.src = ''; }); document.addEventListener('dragover', (event) => { if (state.view === 'chat' && steamOnline() && steamAccessAllowed() && state.activeId) event.preventDefault(); }); document.addEventListener('drop', (event) => { if (state.view !== 'chat' || !steamOnline() || !steamAccessAllowed() || !state.activeId) return; const files = event.dataTransfer?.files || null; if (files && files.length) { event.preventDefault(); sendFiles(files); } }); bootstrap().catch((error) => { authPage('启动失败', errorMessage(error), create('div')); });