From 2aef81e51d2833c23a1b06c8c8c4a7fe4fdbd0eb Mon Sep 17 00:00:00 2001 From: kdletters Date: Thu, 30 Apr 2026 18:08:28 +0800 Subject: [PATCH 1/6] Refine account modal entry flow and local web binding --- ...AB_SETTINGS_AND_SECURITY_PRD_2026-04-16.md | 1 + ...ND_REMOTE_DEPLOYMENT_SCRIPTS_2026-04-22.md | 3 +- scripts/deploy-rust-remote.sh | 6 +- src/components/auth/AccountModal.test.tsx | 23 +- src/components/auth/AccountModal.tsx | 211 ++++++++++-------- src/components/auth/AuthGate.tsx | 27 ++- 6 files changed, 173 insertions(+), 98 deletions(-) diff --git a/docs/prd/MY_TAB_SETTINGS_AND_SECURITY_PRD_2026-04-16.md b/docs/prd/MY_TAB_SETTINGS_AND_SECURITY_PRD_2026-04-16.md index 7c46a5c3..168dd33b 100644 --- a/docs/prd/MY_TAB_SETTINGS_AND_SECURITY_PRD_2026-04-16.md +++ b/docs/prd/MY_TAB_SETTINGS_AND_SECURITY_PRD_2026-04-16.md @@ -83,6 +83,7 @@ 8. 子面板返回按钮固定摆在面板右上角 9. 设置首页与各级子面板都必须定义单一滚动容器,列表内容必须可稳定滚动,禁止外层与内层同时争夺滚动 10. 二级或三级面板打开后,下层内容必须进入不可交互状态,并把焦点主动转移到当前面板内;禁止对仍保留焦点的祖先节点使用 `aria-hidden` +11. 右上角头像、账号入口等身份入口直达“账号信息”时,只允许展示“账号信息”面板本身,不再同步弹出或保留“设置与账号安全”首页;只有“设置”入口才打开设置首页 --- diff --git a/docs/technical/RUST_LOCAL_AND_REMOTE_DEPLOYMENT_SCRIPTS_2026-04-22.md b/docs/technical/RUST_LOCAL_AND_REMOTE_DEPLOYMENT_SCRIPTS_2026-04-22.md index d8dff95f..2dbc5d62 100644 --- a/docs/technical/RUST_LOCAL_AND_REMOTE_DEPLOYMENT_SCRIPTS_2026-04-22.md +++ b/docs/technical/RUST_LOCAL_AND_REMOTE_DEPLOYMENT_SCRIPTS_2026-04-22.md @@ -142,7 +142,7 @@ npm run deploy:rust:remote 5. 执行 `cargo build -p spacetime-module --release --target wasm32-unknown-unknown --manifest-path server-rs/Cargo.toml`,并把 `spacetime_module.wasm` 复制到目标目录。 6. 把仓库根目录的 `.env` 与 `.env.local` 分别复制到目标目录根部和目标目录的 `web/` 下;复制后统一移除 UTF-8 BOM 与 CRLF,并把 `GENARRATIVE_SPACETIME_DATABASE` 覆盖为本次 `--database` 参数,避免 Jenkins 工作区里残留的旧 `.env.local` 覆盖发布包目标库。 7. 在目标目录写入 `web-server.mjs`,用于托管 `web/` 并把 `/api/*`、`/generated-*`、`/healthz` 反代到本包内的 `api-server`。 -8. 在目标目录写入 `start.sh` 与 `stop.sh`;`start.sh` 会先按 `KEY=value` 子集加载发布目录根部的 `.env`、`.env.local`,兼容 UTF-8 BOM 与 CRLF,再回退到构建时通过 `--database`、`--api-port`、`--web-port`、`--spacetime-host`、`--spacetime-port` 写入的默认值,并默认导出 `NO_COLOR=1` 与 `CARGO_TERM_COLOR=never`,避免 ANSI 控制码写入日志文件;同时按 Ubuntu 发布环境使用发布目录内 `.spacetimedb/` 作为 root-dir,不再额外设置 `--data-dir`,启动前先执行 `sync_ubuntu_spacetime_install`,优先从 `/usr/.local/share/spacetime/bin//spacetimedb-cli` 或 `$HOME/.local/share/spacetime/bin//spacetimedb-cli` 同步到 `.spacetimedb/bin/current/spacetimedb-cli`,当前线上 `spacetime` 入口为 `/usr/local/bin/spacetime`;启动参数为 `spacetime --root-dir ./.spacetimedb start --edition standalone --listen-addr :`,探活必须确认 `server ping` 输出包含 `Server is online:`;普通启动先无清库发布,若 publish 输出可判定为 schema 冲突,则自动导出旧库、清库发布新 wasm、导入回灌;如果以 `--clear-database` 启动,则内部 `spacetime publish` 会追加 `-c=on-conflict`,代表人工确认清库,不触发自动回灌。 +8. 在目标目录写入 `start.sh` 与 `stop.sh`;`start.sh` 会先按 `KEY=value` 子集加载发布目录根部的 `.env`、`.env.local`,兼容 UTF-8 BOM 与 CRLF,再回退到构建时通过 `--database`、`--api-port`、`--web-host`、`--web-port`、`--spacetime-host`、`--spacetime-port` 写入的默认值,其中 Web 默认只监听 `127.0.0.1`;并默认导出 `NO_COLOR=1` 与 `CARGO_TERM_COLOR=never`,避免 ANSI 控制码写入日志文件;同时按 Ubuntu 发布环境使用发布目录内 `.spacetimedb/` 作为 root-dir,不再额外设置 `--data-dir`,启动前先执行 `sync_ubuntu_spacetime_install`,优先从 `/usr/.local/share/spacetime/bin//spacetimedb-cli` 或 `$HOME/.local/share/spacetime/bin//spacetimedb-cli` 同步到 `.spacetimedb/bin/current/spacetimedb-cli`,当前线上 `spacetime` 入口为 `/usr/local/bin/spacetime`;启动参数为 `spacetime --root-dir ./.spacetimedb start --edition standalone --listen-addr :`,探活必须确认 `server ping` 输出包含 `Server is online:`;普通启动先无清库发布,若 publish 输出可判定为 schema 冲突,则自动导出旧库、清库发布新 wasm、导入回灌;如果以 `--clear-database` 启动,则内部 `spacetime publish` 会追加 `-c=on-conflict`,代表人工确认清库,不触发自动回灌。 9. 默认执行 `scp -r -i ~\.ssh\dsk.pem build/ ubuntu@82.157.175.59:/home/ubuntu/genarrative/` 上传发布包。 SpacetimeDB database 名称必须匹配 `^[a-z0-9]+(-[a-z0-9]+)*$`:只能使用小写字母、数字,并用单个短横线分隔;大写字母、点号、下划线、首尾短横线和连续短横线都会触发 `spacetime publish` 的 `invalid characters in database name`。发布包构建脚本和 `start.sh` 都会提前拦截这类非法名称。 @@ -174,6 +174,7 @@ build// ```bash npm run build:rust:ubuntu -- --name 20260422-153000 npm run build:rust:ubuntu -- --database genarrative-dev --web-port 3000 --api-port 8082 --spacetime-port 3101 +npm run build:rust:ubuntu -- --database genarrative-dev --web-host 127.0.0.1 --web-port 3000 npm run build:rust:ubuntu -- --skip-upload ``` diff --git a/scripts/deploy-rust-remote.sh b/scripts/deploy-rust-remote.sh index 5f65e158..22bd13d8 100644 --- a/scripts/deploy-rust-remote.sh +++ b/scripts/deploy-rust-remote.sh @@ -191,7 +191,7 @@ BUILD_NAME="$(date +%Y%m%d-%H%M%S)" DATABASE="xushi-p4wfr" API_HOST="127.0.0.1" API_PORT="8082" -WEB_HOST="0.0.0.0" +WEB_HOST="127.0.0.1" WEB_PORT="25001" SPACETIME_HOST="127.0.0.1" SPACETIME_PORT="3101" @@ -421,7 +421,7 @@ import {fileURLToPath} from 'node:url'; const releaseDir = path.dirname(fileURLToPath(import.meta.url)); const webRoot = path.join(releaseDir, 'web'); -const webHost = process.env.GENARRATIVE_WEB_HOST || '0.0.0.0'; +const webHost = process.env.GENARRATIVE_WEB_HOST || '127.0.0.1'; const webPort = Number(process.env.GENARRATIVE_WEB_PORT || '3000'); const apiTarget = new URL(process.env.GENARRATIVE_API_TARGET || 'http://127.0.0.1:8082'); const indexPath = path.join(webRoot, 'index.html'); @@ -1215,7 +1215,7 @@ cat >"${TARGET_DIR}/README.md" <<'EOF' - 启动时会先加载发布目录根部的 \`.env\` 与 \`.env.local\`,再回退到脚本内默认值。 - 环境文件复制进发布包时会移除 UTF-8 BOM 与 CRLF;启动时也会按 \`KEY=value\` 子集解析,跳过不合法行。 -- 脚本内默认值来自构建时的 `--database`、`--api-port`、`--web-port`、`--spacetime-host`、`--spacetime-port` 参数。 +- 脚本内默认值来自构建时的 `--database`、`--api-port`、`--web-host`、`--web-port`、`--spacetime-host`、`--spacetime-port` 参数;Web 默认只监听 `127.0.0.1`。 - 默认导出 \`NO_COLOR=1\` 与 \`CARGO_TERM_COLOR=never\`,避免 ANSI 颜色控制码写入日志文件;如确有需要可在启动前显式覆盖。 - \`GENARRATIVE_WEB_HOST\` / \`GENARRATIVE_WEB_PORT\` - \`GENARRATIVE_API_HOST\` / \`GENARRATIVE_API_PORT\` / \`GENARRATIVE_API_LOG\` diff --git a/src/components/auth/AccountModal.test.tsx b/src/components/auth/AccountModal.test.tsx index 223218b2..b6d4d66c 100644 --- a/src/components/auth/AccountModal.test.tsx +++ b/src/components/auth/AccountModal.test.tsx @@ -26,6 +26,7 @@ const baseUser: AuthUser = { function renderAccountModal(overrides?: { user?: AuthUser; + entryMode?: 'settings' | 'account'; riskBlocks?: AuthRiskBlockSummary[]; sessions?: AuthSessionSummary[]; auditLogs?: AuthAuditLogEntry[]; @@ -41,6 +42,7 @@ function renderAccountModal(overrides?: { { expect(screen.queryByRole('button', { name: '退出全部设备' })).toBeNull(); }); +test('direct account entry does not render the settings shell as another dialog', () => { + renderAccountModal({ entryMode: 'account' }); + + const accountDialog = screen.getByRole('dialog', { name: '账号信息' }); + expect(accountDialog).toBeTruthy(); + expect(screen.queryByRole('dialog', { name: '设置与账号安全' })).toBeNull(); + expect(screen.queryByText('设置与账号安全')).toBeNull(); + expect( + within(accountDialog).getByRole('button', { name: '关闭' }), + ).toBeTruthy(); + expect( + within(accountDialog).queryByRole('button', { name: '返回' }), + ).toBeNull(); +}); + test('account actions open in independent panels instead of inline expansion', async () => { const user = userEvent.setup(); @@ -131,9 +148,9 @@ test('nested settings panels keep back navigation without an extra close action' expect( within(accountDialog).getByRole('button', { name: '返回' }), ).toBeTruthy(); - expect( - accountHeader?.lastElementChild?.textContent?.includes('返回'), - ).toBe(true); + expect(accountHeader?.lastElementChild?.textContent?.includes('返回')).toBe( + true, + ); expect( within(accountDialog).queryByRole('button', { name: '关闭' }), ).toBeNull(); diff --git a/src/components/auth/AccountModal.tsx b/src/components/auth/AccountModal.tsx index 12cd18ba..5864c53b 100644 --- a/src/components/auth/AccountModal.tsx +++ b/src/components/auth/AccountModal.tsx @@ -20,6 +20,7 @@ import { CaptchaChallengeField } from './CaptchaChallengeField'; type AccountModalProps = { user: AuthUser; isOpen: boolean; + entryMode?: 'settings' | 'account'; initialSection?: PlatformSettingsSection | null; platformTheme: PlatformTheme; riskBlocks: AuthRiskBlockSummary[]; @@ -159,6 +160,7 @@ function OverlayPanel({ title, description, action, + standalone = false, onBack, onClose, children, @@ -167,64 +169,73 @@ function OverlayPanel({ title: string; description?: string; action?: ReactNode; + standalone?: boolean; onBack?: () => void; onClose: () => void; children: ReactNode; }) { + const panel = ( +
event.stopPropagation()} + > +
+
+
+ {eyebrow} +
+
+ {title} +
+ {description ? ( +
+ {description} +
+ ) : null} +
+
+ {action} + {onBack ? ( + + ) : ( + + )} +
+
+ +
+ {children} +
+
+ ); + + if (standalone) { + return panel; + } + return (
-
event.stopPropagation()} - > -
-
-
- {eyebrow} -
-
- {title} -
- {description ? ( -
- {description} -
- ) : null} -
-
- {action} - {onBack ? ( - - ) : ( - - )} -
-
- -
- {children} -
-
+ {panel}
); } @@ -266,6 +277,7 @@ function ThemeOptionCard({ export function AccountModal({ user, isOpen, + entryMode = 'settings', initialSection = null, platformTheme, riskBlocks, @@ -314,6 +326,7 @@ export function AccountModal({ const sectionTriggerRef = useRef(null); const changePhoneTriggerRef = useRef(null); const passwordTriggerRef = useRef(null); + const isDirectAccountMode = entryMode === 'account'; const focusAfterNextPaint = useCallback((element: HTMLElement | null) => { if (!element) { @@ -347,7 +360,11 @@ export function AccountModal({ return; } - setActiveSection(normalizeSettingsSection(initialSection)); + setActiveSection( + isDirectAccountMode + ? 'account' + : normalizeSettingsSection(initialSection), + ); setIsChangePhonePanelOpen(false); setIsPasswordPanelOpen(false); setAccountNotice(''); @@ -356,7 +373,13 @@ export function AccountModal({ passwordTriggerRef.current = null; resetChangePhoneDraft(); resetPasswordDraft(); - }, [initialSection, isOpen, resetChangePhoneDraft, resetPasswordDraft]); + }, [ + initialSection, + isDirectAccountMode, + isOpen, + resetChangePhoneDraft, + resetPasswordDraft, + ]); useEffect(() => { const settingsHome = settingsHomeRef.current; @@ -446,47 +469,55 @@ export function AccountModal({ onClick={onClose} >
event.stopPropagation()} > -
-
-
- 设置与账号安全 + {!isDirectAccountMode ? ( +
+
+
+ 设置与账号安全 +
+
- -
+ ) : null} -
-
-
- {SETTINGS_SECTIONS.map((section) => ( - { - sectionTriggerRef.current = trigger; - setAccountNotice(''); - setActiveSection(section.id); - }} - /> - ))} + {!isDirectAccountMode ? ( +
+
+
+ {SETTINGS_SECTIONS.map((section) => ( + { + sectionTriggerRef.current = trigger; + setAccountNotice(''); + setActiveSection(section.id); + }} + /> + ))} +
-
+ ) : null} {activeSection === 'appearance' ? (
@@ -671,7 +703,10 @@ export function AccountModal({ {block.title} 剩余约{' '} - {Math.max(1, Math.ceil(block.remainingSeconds / 60))}{' '} + {Math.max( + 1, + Math.ceil(block.remainingSeconds / 60), + )}{' '} 分钟
@@ -965,7 +1000,9 @@ export function AccountModal({ type="password" autoComplete="current-password" placeholder="首次设置可留空" - onChange={(event) => setCurrentPassword(event.target.value)} + onChange={(event) => + setCurrentPassword(event.target.value) + } />