重写后台前端入口

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 ./
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"]

View File

@@ -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:

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,
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<HTMLElement>('#steamBadge');
@@ -196,6 +213,8 @@ function updateSteamStatus(status: SteamStatus) {
}
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();
}
@@ -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 = `
<label>${guardText}<input name="code" autocomplete="one-time-code" required></label>
<button type="submit">提交验证</button>
`;
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 = `
<input name="target" inputmode="numeric" autocomplete="off" placeholder="SteamID64" value="${state.activeId}">
<button type="submit">打开</button>
`;
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<HTMLElement>('#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();
}