2264 lines
81 KiB
TypeScript
2264 lines
81 KiB
TypeScript
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<string, unknown>;
|
||
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<SteamAccount, 'id' | 'steamId' | 'label'> | null;
|
||
accessAllowed?: boolean;
|
||
};
|
||
|
||
type MeResponse = {
|
||
needsSetup: boolean;
|
||
user: User | null;
|
||
permissions: Permission[];
|
||
steam: SteamStatus;
|
||
};
|
||
|
||
type ListEntry = Record<string, unknown> & {
|
||
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<string>(supportedBbcodeTags);
|
||
const supportedBbcodePattern = new RegExp(`\\[(${supportedBbcodeTags.join('|')})(?=[\\s=\\]])`, 'gi');
|
||
|
||
type SteamImagePreview = {
|
||
sourceUrl: string;
|
||
displayUrl: string;
|
||
sourceSet: string;
|
||
width: number;
|
||
height: number;
|
||
};
|
||
|
||
type InventoryItem = Record<string, unknown> & {
|
||
name?: string;
|
||
use_count?: number | string;
|
||
};
|
||
|
||
type WsPayload = Record<string, unknown> & {
|
||
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<number, UserSession[]>;
|
||
userSteamAccounts: Record<number, SteamAccount[]>;
|
||
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<typeof setTimeout> | null;
|
||
statusTimer: ReturnType<typeof setInterval> | 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<HTMLElement>('#app');
|
||
if (!root) throw new Error('Missing app root');
|
||
let feedbackTimer: ReturnType<typeof setTimeout> | 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<string, unknown> {
|
||
return value !== null && typeof value === 'object';
|
||
}
|
||
|
||
function create<K extends keyof HTMLElementTagNameMap>(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<HTMLElement>('#feedback');
|
||
if (node) {
|
||
const content = node.querySelector<HTMLElement>('#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<HTMLElement>('#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<string, string> = {
|
||
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<HTMLElement>('#steamBadge');
|
||
if (badge) {
|
||
badge.textContent = steamLabel();
|
||
badge.dataset.status = state.steam.status;
|
||
}
|
||
const hint = document.querySelector<HTMLElement>('#steamHint');
|
||
if (hint) hint.textContent = state.steam.error || (state.steam.steamId ? `SteamID ${state.steam.steamId}` : '后台服务已启动');
|
||
const subtitle = document.querySelector<HTMLElement>('#pageSubtitle');
|
||
if (subtitle) subtitle.textContent = pageSubtitle();
|
||
updateChatAvailability();
|
||
}
|
||
|
||
async function api(path: string, options: RequestInit = {}): Promise<unknown> {
|
||
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<string, unknown>): 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 = `
|
||
<label>管理员账号<input name="username" autocomplete="username" minlength="3" maxlength="64" required></label>
|
||
<label>后台密码<input name="password" type="password" autocomplete="new-password" minlength="8" required></label>
|
||
<button type="submit">初始化后台</button>
|
||
<p class="form-error" id="setupError"></p>
|
||
`;
|
||
form.addEventListener('submit', async (event) => {
|
||
event.preventDefault();
|
||
const errorNode = form.querySelector<HTMLElement>('#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 = `
|
||
<label>账号<input name="username" autocomplete="username" required></label>
|
||
<label>密码<input name="password" type="password" autocomplete="current-password" required></label>
|
||
<button type="submit">登录后台</button>
|
||
<p class="form-error" id="loginError"></p>
|
||
`;
|
||
form.addEventListener('submit', async (event) => {
|
||
event.preventDefault();
|
||
const errorNode = form.querySelector<HTMLElement>('#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 = `
|
||
<label>显示名称<input name="label" autocomplete="off" placeholder="例如:客服一号"></label>
|
||
<label>Steam 账号<input name="accountName" autocomplete="username" required></label>
|
||
<label>Steam 密码<input name="password" type="password" autocomplete="current-password" required></label>
|
||
<label>Logon ID<input name="logonID" inputmode="numeric" placeholder="留空使用后台固定值"></label>
|
||
<button type="submit">登录 Steam</button>
|
||
`;
|
||
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 = `
|
||
<label>搜索<input name="query" value="${state.userQuery}" placeholder="账号、昵称、备注"></label>
|
||
<label>角色<select name="role"><option value="">全部</option><option value="admin">管理员</option><option value="user">普通用户</option></select></label>
|
||
<label>状态<select name="status"><option value="">全部</option><option value="enabled">启用</option><option value="disabled">禁用</option><option value="locked">锁定</option></select></label>
|
||
<button type="submit">筛选</button>
|
||
`;
|
||
(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 = `
|
||
<label>账号<input name="username" minlength="3" maxlength="64" required></label>
|
||
<label>昵称<input name="displayName" maxlength="80"></label>
|
||
<label>密码<input name="password" type="password" minlength="8" required></label>
|
||
<label>角色<select name="role"><option value="user">普通用户</option><option value="admin">管理员</option></select></label>
|
||
<label>备注<input name="note" maxlength="500"></label>
|
||
<button type="submit">新增用户</button>
|
||
`;
|
||
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<HTMLInputElement>('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<HTMLElement>('#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<string, unknown>) {
|
||
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 = `
|
||
<label>显示名称<input name="label" maxlength="80"></label>
|
||
<label>Steam 账号<input name="accountName" autocomplete="username" required></label>
|
||
<label>Steam 密码<input name="password" type="password" autocomplete="current-password" required></label>
|
||
<label>Logon ID<input name="logonID" inputmode="numeric"></label>
|
||
<button type="submit">登录新账户</button>
|
||
`;
|
||
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<HTMLElement>('#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<string, unknown>) {
|
||
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 = `
|
||
<label>动作<input name="action" value="${state.auditAction}" placeholder="例如 user.update"></label>
|
||
<label>目标类型<input name="targetType" value="${state.auditTargetType}" placeholder="user / steam_account"></label>
|
||
<button type="submit">筛选</button>
|
||
`;
|
||
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<HTMLElement>('#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 = `
|
||
<label>当前密码<input name="oldPassword" type="password" autocomplete="current-password" required></label>
|
||
<label>新密码<input name="newPassword" type="password" autocomplete="new-password" minlength="8" required></label>
|
||
<button type="submit">修改密码</button>
|
||
`;
|
||
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<HTMLButtonElement>('[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<HTMLElement>('#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 = '<input name="url" type="url" placeholder="https://..." aria-label="图片 URL" required><button type="submit">发送</button>';
|
||
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<HTMLInputElement>('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<HTMLInputElement | HTMLButtonElement | HTMLTextAreaElement>('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<HTMLInputElement | HTMLButtonElement | HTMLTextAreaElement>('[data-chat-control]').forEach((node) => {
|
||
node.disabled = disabled;
|
||
});
|
||
const note = document.querySelector<HTMLElement>('#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<HTMLElement>('#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<HTMLElement>('#chatLayout');
|
||
if (!layout) {
|
||
renderShell();
|
||
return;
|
||
}
|
||
syncChatPanel();
|
||
updateChatLists();
|
||
const head = document.querySelector<HTMLElement>('#threadHead');
|
||
if (head) renderThreadHeader(head);
|
||
updateChatAvailability();
|
||
if (!alreadyActive) void loadHistory();
|
||
}
|
||
|
||
function showChatList() {
|
||
state.chatPanel = 'list';
|
||
syncChatPanel();
|
||
}
|
||
|
||
function syncChatPanel() {
|
||
const layout = document.querySelector<HTMLElement>('#chatLayout');
|
||
if (layout) layout.dataset.mobilePanel = state.chatPanel;
|
||
}
|
||
|
||
async function loadHistory() {
|
||
const messages = document.querySelector<HTMLElement>('#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<HTMLElement>('#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<string>, requireQuoted = false): Record<string, string> | null {
|
||
const values: Record<string, string> = {};
|
||
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<HTMLTextAreaElement>('#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<string, string>) {
|
||
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<HTMLTextAreaElement>('#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<HTMLElement>('#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<HTMLElement>('#lightbox');
|
||
const image = document.querySelector<HTMLImageElement>('#lightboxImage');
|
||
if (!box || !image) return;
|
||
image.src = src;
|
||
box.hidden = false;
|
||
}
|
||
|
||
document.querySelector('#lightboxClose')?.addEventListener('click', () => {
|
||
const box = document.querySelector<HTMLElement>('#lightbox');
|
||
const image = document.querySelector<HTMLImageElement>('#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'));
|
||
});
|