重写后台前端入口
This commit is contained in:
@@ -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"]
|
||||||
|
|||||||
@@ -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
13
docker-entrypoint.sh
Normal 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 "$@"
|
||||||
81
web/app.ts
81
web/app.ts
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user