From 8e1086a1a02c441ede6a7bc32b2a9785f2ab08c0 Mon Sep 17 00:00:00 2001 From: tursom Date: Wed, 24 Jun 2026 17:31:01 +0800 Subject: [PATCH] =?UTF-8?q?=E9=87=8D=E5=86=99=E5=90=8E=E5=8F=B0=E5=89=8D?= =?UTF-8?q?=E7=AB=AF=E5=85=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Dockerfile | 6 ++- compose.yaml => docker-compose.yaml | 5 +- docker-entrypoint.sh | 13 +++++ web/app.ts | 81 ++++++++++++++++++++++++----- 4 files changed, 86 insertions(+), 19 deletions(-) rename compose.yaml => docker-compose.yaml (92%) create mode 100644 docker-entrypoint.sh diff --git a/Dockerfile b/Dockerfile index 148938e..9ff7756 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,7 +5,7 @@ WORKDIR /app COPY package.json package-lock.json ./ RUN npm ci -COPY tsconfig.json config.example.js ./ +COPY tsconfig.json tsconfig.web.json config.example.js ./ COPY src ./src COPY web ./web @@ -26,9 +26,11 @@ RUN mkdir -p /app/data \ COPY --from=build --chown=node:node /app/package.json /app/package-lock.json ./ COPY --from=build --chown=node:node /app/node_modules ./node_modules COPY --from=build --chown=node:node /app/dist ./dist +COPY docker-entrypoint.sh /usr/local/bin/steam-chat-entrypoint -USER node +RUN chmod +x /usr/local/bin/steam-chat-entrypoint EXPOSE 3000 +ENTRYPOINT ["steam-chat-entrypoint"] CMD ["node", "dist/src/index.js"] diff --git a/compose.yaml b/docker-compose.yaml similarity index 92% rename from compose.yaml rename to docker-compose.yaml index 2de187b..d49f884 100644 --- a/compose.yaml +++ b/docker-compose.yaml @@ -16,7 +16,7 @@ services: STEAM_CHAT_PORT: 3000 STEAM_CHAT_WS_PATH: /ws volumes: - - steam-chat-data:/app/data + - ./data:/app/data healthcheck: test: - CMD @@ -27,6 +27,3 @@ services: timeout: 5s retries: 3 start_period: 20s - -volumes: - steam-chat-data: diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh new file mode 100644 index 0000000..8684c2f --- /dev/null +++ b/docker-entrypoint.sh @@ -0,0 +1,13 @@ +#!/bin/sh +set -eu + +data_dir="${STEAM_CHAT_DATA_DIR:-/app/data}" + +mkdir -p "$data_dir" + +if [ "$(id -u)" = "0" ]; then + chown -R node:node "$data_dir" + exec setpriv --reuid=node --regid=node --init-groups "$@" +fi + +exec "$@" diff --git a/web/app.ts b/web/app.ts index 7e72e8e..706177d 100644 --- a/web/app.ts +++ b/web/app.ts @@ -106,7 +106,7 @@ const state: AppState = { me: null, needsSetup: false, steam: defaultSteamStatus, - view: (localStorage.getItem('steam-chat.view') as View) || 'steam', + view: normalizeView(localStorage.getItem('steam-chat.view')), users: [], conversations: [], friends: [], @@ -149,6 +149,11 @@ function clampLimit(value: unknown): number { return Math.min(parsed, 500); } +function normalizeView(value: unknown): View { + const view = String(value || ''); + return view === 'steam' || view === 'chat' || view === 'users' || view === 'account' ? view : 'steam'; +} + function asListEntries(value: unknown): ListEntry[] { return Array.isArray(value) ? value.filter(isRecord).map((item) => ({ ...item, id: String(item.id || '') })) : []; } @@ -187,6 +192,18 @@ function steamOnline() { return state.steam.status === 'online'; } +function steamStatusSignature(status = state.steam) { + return [ + status.status, + status.requiresGuard, + status.guardType || '', + status.domain || '', + status.lastCodeWrong, + status.error || '', + status.steamId || '' + ].join('|'); +} + function updateSteamStatus(status: SteamStatus) { state.steam = { ...defaultSteamStatus, ...status }; const badge = document.querySelector('#steamBadge'); @@ -196,6 +213,8 @@ function updateSteamStatus(status: SteamStatus) { } 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(); } @@ -323,7 +342,10 @@ function renderShell() { const main = create('main', 'workspace'); const top = create('header', 'topbar'); const title = create('div'); - title.append(create('h1', '', pageTitle()), create('p', 'muted', pageSubtitle())); + 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'; @@ -388,10 +410,15 @@ function renderSteamView() { const guardText = state.steam.guardType === 'email' ? `邮箱验证码${state.steam.domain ? `:${state.steam.domain}` : ''}` : '手机 2FA 验证码'; - guardForm.innerHTML = ` - - - `; + 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(); @@ -581,16 +608,22 @@ function renderChatView() { const view = create('div', 'chat-layout'); const lists = create('aside', 'chat-lists'); const openForm = create('form', 'open-chat-form') as HTMLFormElement; - openForm.innerHTML = ` - - - `; + const targetInput = create('input') as HTMLInputElement; + targetInput.name = 'target'; + targetInput.inputMode = 'numeric'; + targetInput.autocomplete = 'off'; + targetInput.placeholder = 'SteamID64'; + targetInput.value = state.activeId; + const openButton = create('button', '', '打开'); + openButton.type = 'submit'; + openForm.append(targetInput, openButton); openForm.addEventListener('submit', (event) => { event.preventDefault(); openConversation(formValue(openForm, 'target')); }); const tabs = create('div', 'list-sections'); - tabs.append(renderListSection('最近', state.conversations), renderListSection('好友', state.friends), renderListSection('群组', state.groups)); + tabs.id = 'chatListSections'; + renderChatListSections(tabs); lists.append(openForm, tabs); const thread = create('section', 'thread'); @@ -609,6 +642,16 @@ function renderChatView() { return view; } +function renderChatListSections(container: HTMLElement) { + clear(container); + container.append(renderListSection('最近', state.conversations), renderListSection('好友', state.friends), renderListSection('群组', state.groups)); +} + +function updateChatLists() { + const tabs = document.querySelector('#chatListSections'); + if (tabs) renderChatListSections(tabs); +} + function renderListSection(title: string, items: ListEntry[]) { const section = create('section', 'list-section'); section.append(create('h2', '', title)); @@ -727,7 +770,10 @@ async function refreshChatData() { state.emoticons = []; state.stickers = []; } - if (state.view === 'chat') renderShell(); + if (state.view === 'chat') { + updateChatLists(); + updateChatAvailability(); + } } catch (error) { setFeedback(errorMessage(error), 'error'); } @@ -974,8 +1020,13 @@ function startStatusPolling() { 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(). @@ -1009,7 +1060,11 @@ async function bootstrap() { renderLogin(); return; } - const config = await api('/api/config').catch(() => ({ wsPath: '/ws' })); + 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(); }