重写后台前端入口

This commit is contained in:
2026-06-24 17:31:01 +08:00
parent 2f858b3bf0
commit 8e1086a1a0
4 changed files with 86 additions and 19 deletions

View File

@@ -5,7 +5,7 @@ WORKDIR /app
COPY package.json package-lock.json ./ COPY package.json package-lock.json ./
RUN npm ci RUN npm ci
COPY tsconfig.json config.example.js ./ COPY tsconfig.json tsconfig.web.json config.example.js ./
COPY src ./src COPY src ./src
COPY web ./web 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/package.json /app/package-lock.json ./
COPY --from=build --chown=node:node /app/node_modules ./node_modules COPY --from=build --chown=node:node /app/node_modules ./node_modules
COPY --from=build --chown=node:node /app/dist ./dist 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 EXPOSE 3000
ENTRYPOINT ["steam-chat-entrypoint"]
CMD ["node", "dist/src/index.js"] CMD ["node", "dist/src/index.js"]

View File

@@ -16,7 +16,7 @@ services:
STEAM_CHAT_PORT: 3000 STEAM_CHAT_PORT: 3000
STEAM_CHAT_WS_PATH: /ws STEAM_CHAT_WS_PATH: /ws
volumes: volumes:
- steam-chat-data:/app/data - ./data:/app/data
healthcheck: healthcheck:
test: test:
- CMD - CMD
@@ -27,6 +27,3 @@ services:
timeout: 5s timeout: 5s
retries: 3 retries: 3
start_period: 20s start_period: 20s
volumes:
steam-chat-data:

13
docker-entrypoint.sh Normal file
View File

@@ -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 "$@"

View File

@@ -106,7 +106,7 @@ const state: AppState = {
me: null, me: null,
needsSetup: false, needsSetup: false,
steam: defaultSteamStatus, steam: defaultSteamStatus,
view: (localStorage.getItem('steam-chat.view') as View) || 'steam', view: normalizeView(localStorage.getItem('steam-chat.view')),
users: [], users: [],
conversations: [], conversations: [],
friends: [], friends: [],
@@ -149,6 +149,11 @@ function clampLimit(value: unknown): number {
return Math.min(parsed, 500); 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[] { function asListEntries(value: unknown): ListEntry[] {
return Array.isArray(value) ? value.filter(isRecord).map((item) => ({ ...item, id: String(item.id || '') })) : []; 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'; 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) { function updateSteamStatus(status: SteamStatus) {
state.steam = { ...defaultSteamStatus, ...status }; state.steam = { ...defaultSteamStatus, ...status };
const badge = document.querySelector<HTMLElement>('#steamBadge'); const badge = document.querySelector<HTMLElement>('#steamBadge');
@@ -196,6 +213,8 @@ function updateSteamStatus(status: SteamStatus) {
} }
const hint = document.querySelector<HTMLElement>('#steamHint'); const hint = document.querySelector<HTMLElement>('#steamHint');
if (hint) hint.textContent = state.steam.error || (state.steam.steamId ? `SteamID ${state.steam.steamId}` : '后台服务已启动'); 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(); updateChatAvailability();
} }
@@ -323,7 +342,10 @@ function renderShell() {
const main = create('main', 'workspace'); const main = create('main', 'workspace');
const top = create('header', 'topbar'); const top = create('header', 'topbar');
const title = create('div'); 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 status = create('div', 'top-actions');
const badge = create('span', 'steam-badge', steamLabel()); const badge = create('span', 'steam-badge', steamLabel());
badge.id = 'steamBadge'; badge.id = 'steamBadge';
@@ -388,10 +410,15 @@ function renderSteamView() {
const guardText = state.steam.guardType === 'email' const guardText = state.steam.guardType === 'email'
? `邮箱验证码${state.steam.domain ? `${state.steam.domain}` : ''}` ? `邮箱验证码${state.steam.domain ? `${state.steam.domain}` : ''}`
: '手机 2FA 验证码'; : '手机 2FA 验证码';
guardForm.innerHTML = ` const codeLabel = create('label', '', guardText);
<label>${guardText}<input name="code" autocomplete="one-time-code" required></label> const codeInput = create('input') as HTMLInputElement;
<button type="submit">提交验证</button> 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', '上一次验证码被拒绝,请等待新的验证码后再提交。')); if (state.steam.lastCodeWrong) guardForm.prepend(create('p', 'warn-text', '上一次验证码被拒绝,请等待新的验证码后再提交。'));
guardForm.addEventListener('submit', async (event) => { guardForm.addEventListener('submit', async (event) => {
event.preventDefault(); event.preventDefault();
@@ -581,16 +608,22 @@ function renderChatView() {
const view = create('div', 'chat-layout'); const view = create('div', 'chat-layout');
const lists = create('aside', 'chat-lists'); const lists = create('aside', 'chat-lists');
const openForm = create('form', 'open-chat-form') as HTMLFormElement; const openForm = create('form', 'open-chat-form') as HTMLFormElement;
openForm.innerHTML = ` const targetInput = create('input') as HTMLInputElement;
<input name="target" inputmode="numeric" autocomplete="off" placeholder="SteamID64" value="${state.activeId}"> targetInput.name = 'target';
<button type="submit">打开</button> 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) => { openForm.addEventListener('submit', (event) => {
event.preventDefault(); event.preventDefault();
openConversation(formValue(openForm, 'target')); openConversation(formValue(openForm, 'target'));
}); });
const tabs = create('div', 'list-sections'); 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); lists.append(openForm, tabs);
const thread = create('section', 'thread'); const thread = create('section', 'thread');
@@ -609,6 +642,16 @@ function renderChatView() {
return view; 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<HTMLElement>('#chatListSections');
if (tabs) renderChatListSections(tabs);
}
function renderListSection(title: string, items: ListEntry[]) { function renderListSection(title: string, items: ListEntry[]) {
const section = create('section', 'list-section'); const section = create('section', 'list-section');
section.append(create('h2', '', title)); section.append(create('h2', '', title));
@@ -727,7 +770,10 @@ async function refreshChatData() {
state.emoticons = []; state.emoticons = [];
state.stickers = []; state.stickers = [];
} }
if (state.view === 'chat') renderShell(); if (state.view === 'chat') {
updateChatLists();
updateChatAvailability();
}
} catch (error) { } catch (error) {
setFeedback(errorMessage(error), 'error'); setFeedback(errorMessage(error), 'error');
} }
@@ -974,8 +1020,13 @@ function startStatusPolling() {
state.statusTimer = setInterval(async () => { state.statusTimer = setInterval(async () => {
try { try {
const status = await api('/api/steam/status'); const status = await api('/api/steam/status');
const previousSignature = steamStatusSignature();
const wasOnline = steamOnline(); const wasOnline = steamOnline();
updateSteamStatus(status as SteamStatus); updateSteamStatus(status as SteamStatus);
if (state.view === 'steam' && steamStatusSignature() !== previousSignature) {
renderShell();
return;
}
if (state.view === 'chat' && steamOnline() !== wasOnline) await refreshChatData(); if (state.view === 'chat' && steamOnline() !== wasOnline) await refreshChatData();
} catch (_) { } catch (_) {
// Authentication errors are handled by api(). // Authentication errors are handled by api().
@@ -1009,7 +1060,11 @@ async function bootstrap() {
renderLogin(); renderLogin();
return; 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; if (isRecord(config) && typeof config.wsPath === 'string') state.wsPath = config.wsPath;
renderShell(); renderShell();
} }