Refine account modal entry flow and local web binding
Some checks failed
CI / verify (push) Has been cancelled
Some checks failed
CI / verify (push) Has been cancelled
This commit is contained in:
@@ -83,6 +83,7 @@
|
|||||||
8. 子面板返回按钮固定摆在面板右上角
|
8. 子面板返回按钮固定摆在面板右上角
|
||||||
9. 设置首页与各级子面板都必须定义单一滚动容器,列表内容必须可稳定滚动,禁止外层与内层同时争夺滚动
|
9. 设置首页与各级子面板都必须定义单一滚动容器,列表内容必须可稳定滚动,禁止外层与内层同时争夺滚动
|
||||||
10. 二级或三级面板打开后,下层内容必须进入不可交互状态,并把焦点主动转移到当前面板内;禁止对仍保留焦点的祖先节点使用 `aria-hidden`
|
10. 二级或三级面板打开后,下层内容必须进入不可交互状态,并把焦点主动转移到当前面板内;禁止对仍保留焦点的祖先节点使用 `aria-hidden`
|
||||||
|
11. 右上角头像、账号入口等身份入口直达“账号信息”时,只允许展示“账号信息”面板本身,不再同步弹出或保留“设置与账号安全”首页;只有“设置”入口才打开设置首页
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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` 复制到目标目录。
|
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` 覆盖发布包目标库。
|
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`。
|
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/<version>/spacetimedb-cli` 或 `$HOME/.local/share/spacetime/bin/<version>/spacetimedb-cli` 同步到 `.spacetimedb/bin/current/spacetimedb-cli`,当前线上 `spacetime` 入口为 `/usr/local/bin/spacetime`;启动参数为 `spacetime --root-dir ./.spacetimedb start --edition standalone --listen-addr <host>:<port>`,探活必须确认 `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/<version>/spacetimedb-cli` 或 `$HOME/.local/share/spacetime/bin/<version>/spacetimedb-cli` 同步到 `.spacetimedb/bin/current/spacetimedb-cli`,当前线上 `spacetime` 入口为 `/usr/local/bin/spacetime`;启动参数为 `spacetime --root-dir ./.spacetimedb start --edition standalone --listen-addr <host>:<port>`,探活必须确认 `server ping` 输出包含 `Server is online:`;普通启动先无清库发布,若 publish 输出可判定为 schema 冲突,则自动导出旧库、清库发布新 wasm、导入回灌;如果以 `--clear-database` 启动,则内部 `spacetime publish` 会追加 `-c=on-conflict`,代表人工确认清库,不触发自动回灌。
|
||||||
9. 默认执行 `scp -r -i ~\.ssh\dsk.pem build/<timestamp> ubuntu@82.157.175.59:/home/ubuntu/genarrative/` 上传发布包。
|
9. 默认执行 `scp -r -i ~\.ssh\dsk.pem build/<timestamp> 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` 都会提前拦截这类非法名称。
|
SpacetimeDB database 名称必须匹配 `^[a-z0-9]+(-[a-z0-9]+)*$`:只能使用小写字母、数字,并用单个短横线分隔;大写字母、点号、下划线、首尾短横线和连续短横线都会触发 `spacetime publish` 的 `invalid characters in database name`。发布包构建脚本和 `start.sh` 都会提前拦截这类非法名称。
|
||||||
@@ -174,6 +174,7 @@ build/<timestamp>/
|
|||||||
```bash
|
```bash
|
||||||
npm run build:rust:ubuntu -- --name 20260422-153000
|
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-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
|
npm run build:rust:ubuntu -- --skip-upload
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -191,7 +191,7 @@ BUILD_NAME="$(date +%Y%m%d-%H%M%S)"
|
|||||||
DATABASE="xushi-p4wfr"
|
DATABASE="xushi-p4wfr"
|
||||||
API_HOST="127.0.0.1"
|
API_HOST="127.0.0.1"
|
||||||
API_PORT="8082"
|
API_PORT="8082"
|
||||||
WEB_HOST="0.0.0.0"
|
WEB_HOST="127.0.0.1"
|
||||||
WEB_PORT="25001"
|
WEB_PORT="25001"
|
||||||
SPACETIME_HOST="127.0.0.1"
|
SPACETIME_HOST="127.0.0.1"
|
||||||
SPACETIME_PORT="3101"
|
SPACETIME_PORT="3101"
|
||||||
@@ -421,7 +421,7 @@ import {fileURLToPath} from 'node:url';
|
|||||||
|
|
||||||
const releaseDir = path.dirname(fileURLToPath(import.meta.url));
|
const releaseDir = path.dirname(fileURLToPath(import.meta.url));
|
||||||
const webRoot = path.join(releaseDir, 'web');
|
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 webPort = Number(process.env.GENARRATIVE_WEB_PORT || '3000');
|
||||||
const apiTarget = new URL(process.env.GENARRATIVE_API_TARGET || 'http://127.0.0.1:8082');
|
const apiTarget = new URL(process.env.GENARRATIVE_API_TARGET || 'http://127.0.0.1:8082');
|
||||||
const indexPath = path.join(webRoot, 'index.html');
|
const indexPath = path.join(webRoot, 'index.html');
|
||||||
@@ -1215,7 +1215,7 @@ cat >"${TARGET_DIR}/README.md" <<'EOF'
|
|||||||
|
|
||||||
- 启动时会先加载发布目录根部的 \`.env\` 与 \`.env.local\`,再回退到脚本内默认值。
|
- 启动时会先加载发布目录根部的 \`.env\` 与 \`.env.local\`,再回退到脚本内默认值。
|
||||||
- 环境文件复制进发布包时会移除 UTF-8 BOM 与 CRLF;启动时也会按 \`KEY=value\` 子集解析,跳过不合法行。
|
- 环境文件复制进发布包时会移除 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 颜色控制码写入日志文件;如确有需要可在启动前显式覆盖。
|
- 默认导出 \`NO_COLOR=1\` 与 \`CARGO_TERM_COLOR=never\`,避免 ANSI 颜色控制码写入日志文件;如确有需要可在启动前显式覆盖。
|
||||||
- \`GENARRATIVE_WEB_HOST\` / \`GENARRATIVE_WEB_PORT\`
|
- \`GENARRATIVE_WEB_HOST\` / \`GENARRATIVE_WEB_PORT\`
|
||||||
- \`GENARRATIVE_API_HOST\` / \`GENARRATIVE_API_PORT\` / \`GENARRATIVE_API_LOG\`
|
- \`GENARRATIVE_API_HOST\` / \`GENARRATIVE_API_PORT\` / \`GENARRATIVE_API_LOG\`
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ const baseUser: AuthUser = {
|
|||||||
|
|
||||||
function renderAccountModal(overrides?: {
|
function renderAccountModal(overrides?: {
|
||||||
user?: AuthUser;
|
user?: AuthUser;
|
||||||
|
entryMode?: 'settings' | 'account';
|
||||||
riskBlocks?: AuthRiskBlockSummary[];
|
riskBlocks?: AuthRiskBlockSummary[];
|
||||||
sessions?: AuthSessionSummary[];
|
sessions?: AuthSessionSummary[];
|
||||||
auditLogs?: AuthAuditLogEntry[];
|
auditLogs?: AuthAuditLogEntry[];
|
||||||
@@ -41,6 +42,7 @@ function renderAccountModal(overrides?: {
|
|||||||
<AccountModal
|
<AccountModal
|
||||||
user={overrides?.user ?? baseUser}
|
user={overrides?.user ?? baseUser}
|
||||||
isOpen
|
isOpen
|
||||||
|
entryMode={overrides?.entryMode ?? 'settings'}
|
||||||
initialSection={overrides?.initialSection ?? null}
|
initialSection={overrides?.initialSection ?? null}
|
||||||
platformTheme="light"
|
platformTheme="light"
|
||||||
riskBlocks={overrides?.riskBlocks ?? []}
|
riskBlocks={overrides?.riskBlocks ?? []}
|
||||||
@@ -91,6 +93,21 @@ test('settings header uses a generic title instead of the phone number', () => {
|
|||||||
expect(screen.queryByRole('button', { name: '退出全部设备' })).toBeNull();
|
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 () => {
|
test('account actions open in independent panels instead of inline expansion', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
|
|
||||||
@@ -131,9 +148,9 @@ test('nested settings panels keep back navigation without an extra close action'
|
|||||||
expect(
|
expect(
|
||||||
within(accountDialog).getByRole('button', { name: '返回' }),
|
within(accountDialog).getByRole('button', { name: '返回' }),
|
||||||
).toBeTruthy();
|
).toBeTruthy();
|
||||||
expect(
|
expect(accountHeader?.lastElementChild?.textContent?.includes('返回')).toBe(
|
||||||
accountHeader?.lastElementChild?.textContent?.includes('返回'),
|
true,
|
||||||
).toBe(true);
|
);
|
||||||
expect(
|
expect(
|
||||||
within(accountDialog).queryByRole('button', { name: '关闭' }),
|
within(accountDialog).queryByRole('button', { name: '关闭' }),
|
||||||
).toBeNull();
|
).toBeNull();
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { CaptchaChallengeField } from './CaptchaChallengeField';
|
|||||||
type AccountModalProps = {
|
type AccountModalProps = {
|
||||||
user: AuthUser;
|
user: AuthUser;
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
|
entryMode?: 'settings' | 'account';
|
||||||
initialSection?: PlatformSettingsSection | null;
|
initialSection?: PlatformSettingsSection | null;
|
||||||
platformTheme: PlatformTheme;
|
platformTheme: PlatformTheme;
|
||||||
riskBlocks: AuthRiskBlockSummary[];
|
riskBlocks: AuthRiskBlockSummary[];
|
||||||
@@ -159,6 +160,7 @@ function OverlayPanel({
|
|||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
action,
|
action,
|
||||||
|
standalone = false,
|
||||||
onBack,
|
onBack,
|
||||||
onClose,
|
onClose,
|
||||||
children,
|
children,
|
||||||
@@ -167,64 +169,73 @@ function OverlayPanel({
|
|||||||
title: string;
|
title: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
action?: ReactNode;
|
action?: ReactNode;
|
||||||
|
standalone?: boolean;
|
||||||
onBack?: () => void;
|
onBack?: () => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}) {
|
}) {
|
||||||
|
const panel = (
|
||||||
|
<div
|
||||||
|
className="platform-auth-card flex max-h-full w-full min-h-0 flex-col overflow-hidden rounded-[28px] p-5 sm:max-w-3xl sm:p-6"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={title}
|
||||||
|
style={{ maxHeight: ACCOUNT_MODAL_MAX_HEIGHT }}
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="text-xs uppercase tracking-[0.28em] text-[var(--platform-cool-text)]">
|
||||||
|
{eyebrow}
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 text-2xl font-semibold text-[var(--platform-text-strong)]">
|
||||||
|
{title}
|
||||||
|
</div>
|
||||||
|
{description ? (
|
||||||
|
<div className="mt-2 text-sm text-[var(--platform-text-base)]">
|
||||||
|
{description}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{action}
|
||||||
|
{onBack ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
autoFocus
|
||||||
|
className="platform-button platform-button--ghost min-h-0 rounded-full px-3 py-1.5 text-xs"
|
||||||
|
onClick={onBack}
|
||||||
|
>
|
||||||
|
返回
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="platform-button platform-button--ghost min-h-0 rounded-full px-3 py-1.5 text-xs"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
关闭
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-5 min-h-0 flex-1 overflow-y-auto overscroll-y-contain pr-1">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (standalone) {
|
||||||
|
return panel;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="absolute inset-0 z-10 flex items-end bg-black/20 backdrop-blur-[2px] sm:items-center sm:justify-center sm:p-4"
|
className="absolute inset-0 z-10 flex items-end bg-black/20 backdrop-blur-[2px] sm:items-center sm:justify-center sm:p-4"
|
||||||
onClick={onBack ?? onClose}
|
onClick={onBack ?? onClose}
|
||||||
>
|
>
|
||||||
<div
|
{panel}
|
||||||
className="platform-auth-card flex max-h-full w-full min-h-0 flex-col overflow-hidden rounded-[28px] p-5 sm:max-w-3xl sm:p-6"
|
|
||||||
role="dialog"
|
|
||||||
aria-modal="true"
|
|
||||||
aria-label={title}
|
|
||||||
style={{ maxHeight: ACCOUNT_MODAL_MAX_HEIGHT }}
|
|
||||||
onClick={(event) => event.stopPropagation()}
|
|
||||||
>
|
|
||||||
<div className="flex items-start justify-between gap-4">
|
|
||||||
<div className="min-w-0">
|
|
||||||
<div className="text-xs uppercase tracking-[0.28em] text-[var(--platform-cool-text)]">
|
|
||||||
{eyebrow}
|
|
||||||
</div>
|
|
||||||
<div className="mt-2 text-2xl font-semibold text-[var(--platform-text-strong)]">
|
|
||||||
{title}
|
|
||||||
</div>
|
|
||||||
{description ? (
|
|
||||||
<div className="mt-2 text-sm text-[var(--platform-text-base)]">
|
|
||||||
{description}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{action}
|
|
||||||
{onBack ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
autoFocus
|
|
||||||
className="platform-button platform-button--ghost min-h-0 rounded-full px-3 py-1.5 text-xs"
|
|
||||||
onClick={onBack}
|
|
||||||
>
|
|
||||||
返回
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="platform-button platform-button--ghost min-h-0 rounded-full px-3 py-1.5 text-xs"
|
|
||||||
onClick={onClose}
|
|
||||||
>
|
|
||||||
关闭
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-5 min-h-0 flex-1 overflow-y-auto overscroll-y-contain pr-1">
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -266,6 +277,7 @@ function ThemeOptionCard({
|
|||||||
export function AccountModal({
|
export function AccountModal({
|
||||||
user,
|
user,
|
||||||
isOpen,
|
isOpen,
|
||||||
|
entryMode = 'settings',
|
||||||
initialSection = null,
|
initialSection = null,
|
||||||
platformTheme,
|
platformTheme,
|
||||||
riskBlocks,
|
riskBlocks,
|
||||||
@@ -314,6 +326,7 @@ export function AccountModal({
|
|||||||
const sectionTriggerRef = useRef<HTMLButtonElement | null>(null);
|
const sectionTriggerRef = useRef<HTMLButtonElement | null>(null);
|
||||||
const changePhoneTriggerRef = useRef<HTMLButtonElement | null>(null);
|
const changePhoneTriggerRef = useRef<HTMLButtonElement | null>(null);
|
||||||
const passwordTriggerRef = useRef<HTMLButtonElement | null>(null);
|
const passwordTriggerRef = useRef<HTMLButtonElement | null>(null);
|
||||||
|
const isDirectAccountMode = entryMode === 'account';
|
||||||
|
|
||||||
const focusAfterNextPaint = useCallback((element: HTMLElement | null) => {
|
const focusAfterNextPaint = useCallback((element: HTMLElement | null) => {
|
||||||
if (!element) {
|
if (!element) {
|
||||||
@@ -347,7 +360,11 @@ export function AccountModal({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setActiveSection(normalizeSettingsSection(initialSection));
|
setActiveSection(
|
||||||
|
isDirectAccountMode
|
||||||
|
? 'account'
|
||||||
|
: normalizeSettingsSection(initialSection),
|
||||||
|
);
|
||||||
setIsChangePhonePanelOpen(false);
|
setIsChangePhonePanelOpen(false);
|
||||||
setIsPasswordPanelOpen(false);
|
setIsPasswordPanelOpen(false);
|
||||||
setAccountNotice('');
|
setAccountNotice('');
|
||||||
@@ -356,7 +373,13 @@ export function AccountModal({
|
|||||||
passwordTriggerRef.current = null;
|
passwordTriggerRef.current = null;
|
||||||
resetChangePhoneDraft();
|
resetChangePhoneDraft();
|
||||||
resetPasswordDraft();
|
resetPasswordDraft();
|
||||||
}, [initialSection, isOpen, resetChangePhoneDraft, resetPasswordDraft]);
|
}, [
|
||||||
|
initialSection,
|
||||||
|
isDirectAccountMode,
|
||||||
|
isOpen,
|
||||||
|
resetChangePhoneDraft,
|
||||||
|
resetPasswordDraft,
|
||||||
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const settingsHome = settingsHomeRef.current;
|
const settingsHome = settingsHomeRef.current;
|
||||||
@@ -446,47 +469,55 @@ export function AccountModal({
|
|||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="platform-auth-card relative flex h-[min(100%,calc(100vh-2rem))] w-full max-w-5xl min-h-0 flex-col overflow-hidden rounded-[28px] p-5 sm:p-6"
|
className={
|
||||||
role="dialog"
|
isDirectAccountMode
|
||||||
aria-modal="true"
|
? 'relative flex max-h-full w-full max-w-3xl min-h-0 flex-col overflow-hidden'
|
||||||
aria-label="设置与账号安全"
|
: 'platform-auth-card relative flex h-[min(100%,calc(100vh-2rem))] w-full max-w-5xl min-h-0 flex-col overflow-hidden rounded-[28px] p-5 sm:p-6'
|
||||||
|
}
|
||||||
|
role={isDirectAccountMode ? undefined : 'dialog'}
|
||||||
|
aria-modal={isDirectAccountMode ? undefined : true}
|
||||||
|
aria-label={isDirectAccountMode ? undefined : '设置与账号安全'}
|
||||||
style={{ maxHeight: ACCOUNT_MODAL_MAX_HEIGHT }}
|
style={{ maxHeight: ACCOUNT_MODAL_MAX_HEIGHT }}
|
||||||
onClick={(event) => event.stopPropagation()}
|
onClick={(event) => event.stopPropagation()}
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between gap-4">
|
{!isDirectAccountMode ? (
|
||||||
<div>
|
<div className="flex items-start justify-between gap-4">
|
||||||
<div className="text-2xl font-semibold text-[var(--platform-text-strong)]">
|
<div>
|
||||||
设置与账号安全
|
<div className="text-2xl font-semibold text-[var(--platform-text-strong)]">
|
||||||
|
设置与账号安全
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="platform-button platform-button--ghost min-h-0 rounded-full px-3 py-1.5 text-xs"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
关闭
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button
|
) : null}
|
||||||
type="button"
|
|
||||||
className="platform-button platform-button--ghost min-h-0 rounded-full px-3 py-1.5 text-xs"
|
|
||||||
onClick={onClose}
|
|
||||||
>
|
|
||||||
关闭
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-5 min-h-0 flex-1 overflow-y-auto overscroll-y-contain pr-1">
|
{!isDirectAccountMode ? (
|
||||||
<div ref={settingsHomeRef} className="flex min-h-0 flex-col gap-4">
|
<div className="mt-5 min-h-0 flex-1 overflow-y-auto overscroll-y-contain pr-1">
|
||||||
<div className="grid gap-3 sm:grid-cols-2">
|
<div ref={settingsHomeRef} className="flex min-h-0 flex-col gap-4">
|
||||||
{SETTINGS_SECTIONS.map((section) => (
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
<SettingsEntryCard
|
{SETTINGS_SECTIONS.map((section) => (
|
||||||
key={section.id}
|
<SettingsEntryCard
|
||||||
label={section.label}
|
key={section.id}
|
||||||
detail={section.detail}
|
label={section.label}
|
||||||
summary={sectionSummaries[section.id]}
|
detail={section.detail}
|
||||||
onClick={(trigger) => {
|
summary={sectionSummaries[section.id]}
|
||||||
sectionTriggerRef.current = trigger;
|
onClick={(trigger) => {
|
||||||
setAccountNotice('');
|
sectionTriggerRef.current = trigger;
|
||||||
setActiveSection(section.id);
|
setAccountNotice('');
|
||||||
}}
|
setActiveSection(section.id);
|
||||||
/>
|
}}
|
||||||
))}
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
) : null}
|
||||||
|
|
||||||
{activeSection === 'appearance' ? (
|
{activeSection === 'appearance' ? (
|
||||||
<OverlayPanel
|
<OverlayPanel
|
||||||
@@ -538,7 +569,8 @@ export function AccountModal({
|
|||||||
eyebrow="身份信息"
|
eyebrow="身份信息"
|
||||||
title="账号信息"
|
title="账号信息"
|
||||||
description="统一查看身份、安全状态、登录设备与最近操作。"
|
description="统一查看身份、安全状态、登录设备与最近操作。"
|
||||||
onBack={closeSectionPanel}
|
standalone={isDirectAccountMode}
|
||||||
|
onBack={isDirectAccountMode ? undefined : closeSectionPanel}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
>
|
>
|
||||||
<div className="flex min-h-0 flex-col gap-4">
|
<div className="flex min-h-0 flex-col gap-4">
|
||||||
@@ -671,7 +703,10 @@ export function AccountModal({
|
|||||||
<span>{block.title}</span>
|
<span>{block.title}</span>
|
||||||
<span className="text-xs">
|
<span className="text-xs">
|
||||||
剩余约{' '}
|
剩余约{' '}
|
||||||
{Math.max(1, Math.ceil(block.remainingSeconds / 60))}{' '}
|
{Math.max(
|
||||||
|
1,
|
||||||
|
Math.ceil(block.remainingSeconds / 60),
|
||||||
|
)}{' '}
|
||||||
分钟
|
分钟
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -965,7 +1000,9 @@ export function AccountModal({
|
|||||||
type="password"
|
type="password"
|
||||||
autoComplete="current-password"
|
autoComplete="current-password"
|
||||||
placeholder="首次设置可留空"
|
placeholder="首次设置可留空"
|
||||||
onChange={(event) => setCurrentPassword(event.target.value)}
|
onChange={(event) =>
|
||||||
|
setCurrentPassword(event.target.value)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label className="grid gap-2 text-sm text-[var(--platform-text-base)]">
|
<label className="grid gap-2 text-sm text-[var(--platform-text-base)]">
|
||||||
|
|||||||
@@ -84,6 +84,9 @@ export function AuthGate({ children }: AuthGateProps) {
|
|||||||
const [wechatLoading, setWechatLoading] = useState(false);
|
const [wechatLoading, setWechatLoading] = useState(false);
|
||||||
const [showLoginModal, setShowLoginModal] = useState(false);
|
const [showLoginModal, setShowLoginModal] = useState(false);
|
||||||
const [showSettingsModal, setShowSettingsModal] = useState(false);
|
const [showSettingsModal, setShowSettingsModal] = useState(false);
|
||||||
|
const [settingsEntryMode, setSettingsEntryMode] = useState<
|
||||||
|
'settings' | 'account'
|
||||||
|
>('settings');
|
||||||
const [initialSettingsSection, setInitialSettingsSection] =
|
const [initialSettingsSection, setInitialSettingsSection] =
|
||||||
useState<PlatformSettingsSection | null>(null);
|
useState<PlatformSettingsSection | null>(null);
|
||||||
const [sessions, setSessions] = useState<AuthSessionSummary[]>([]);
|
const [sessions, setSessions] = useState<AuthSessionSummary[]>([]);
|
||||||
@@ -126,6 +129,7 @@ export function AuthGate({ children }: AuthGateProps) {
|
|||||||
setStatus('unauthenticated');
|
setStatus('unauthenticated');
|
||||||
setShowLoginModal(false);
|
setShowLoginModal(false);
|
||||||
setShowSettingsModal(false);
|
setShowSettingsModal(false);
|
||||||
|
setSettingsEntryMode('settings');
|
||||||
setInitialSettingsSection(null);
|
setInitialSettingsSection(null);
|
||||||
setSessions([]);
|
setSessions([]);
|
||||||
setAuditLogs([]);
|
setAuditLogs([]);
|
||||||
@@ -169,6 +173,12 @@ export function AuthGate({ children }: AuthGateProps) {
|
|||||||
setError('');
|
setError('');
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const closeSettingsModal = useCallback(() => {
|
||||||
|
setShowSettingsModal(false);
|
||||||
|
setSettingsEntryMode('settings');
|
||||||
|
setInitialSettingsSection(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const openLoginModal = useCallback(
|
const openLoginModal = useCallback(
|
||||||
(postLoginAction?: (() => void) | null) => {
|
(postLoginAction?: (() => void) | null) => {
|
||||||
if (readyUser) {
|
if (readyUser) {
|
||||||
@@ -192,6 +202,7 @@ export function AuthGate({ children }: AuthGateProps) {
|
|||||||
const openSettingsModal = useCallback(
|
const openSettingsModal = useCallback(
|
||||||
(section?: PlatformSettingsSection) => {
|
(section?: PlatformSettingsSection) => {
|
||||||
if (readyUser) {
|
if (readyUser) {
|
||||||
|
setSettingsEntryMode('settings');
|
||||||
setInitialSettingsSection(section ?? null);
|
setInitialSettingsSection(section ?? null);
|
||||||
setShowSettingsModal(true);
|
setShowSettingsModal(true);
|
||||||
return;
|
return;
|
||||||
@@ -203,8 +214,15 @@ export function AuthGate({ children }: AuthGateProps) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const openAccountModal = useCallback(() => {
|
const openAccountModal = useCallback(() => {
|
||||||
openSettingsModal('account');
|
if (readyUser) {
|
||||||
}, [openSettingsModal]);
|
setSettingsEntryMode('account');
|
||||||
|
setInitialSettingsSection('account');
|
||||||
|
setShowSettingsModal(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
openLoginModal();
|
||||||
|
}, [openLoginModal, readyUser]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let isActive = true;
|
let isActive = true;
|
||||||
@@ -224,7 +242,7 @@ export function AuthGate({ children }: AuthGateProps) {
|
|||||||
|
|
||||||
const resolveGuestFallback = async () => {
|
const resolveGuestFallback = async () => {
|
||||||
try {
|
try {
|
||||||
const options = await loadLoginOptions();
|
await loadLoginOptions();
|
||||||
if (!isActive) {
|
if (!isActive) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -555,6 +573,7 @@ export function AuthGate({ children }: AuthGateProps) {
|
|||||||
<AccountModal
|
<AccountModal
|
||||||
user={readyUser}
|
user={readyUser}
|
||||||
isOpen={showSettingsModal}
|
isOpen={showSettingsModal}
|
||||||
|
entryMode={settingsEntryMode}
|
||||||
initialSection={initialSettingsSection}
|
initialSection={initialSettingsSection}
|
||||||
platformTheme={settings.platformTheme}
|
platformTheme={settings.platformTheme}
|
||||||
riskBlocks={riskBlocks}
|
riskBlocks={riskBlocks}
|
||||||
@@ -566,7 +585,7 @@ export function AuthGate({ children }: AuthGateProps) {
|
|||||||
isHydratingSettings={settings.isHydratingSettings}
|
isHydratingSettings={settings.isHydratingSettings}
|
||||||
isPersistingSettings={settings.isPersistingSettings}
|
isPersistingSettings={settings.isPersistingSettings}
|
||||||
settingsError={settings.settingsError}
|
settingsError={settings.settingsError}
|
||||||
onClose={() => setShowSettingsModal(false)}
|
onClose={closeSettingsModal}
|
||||||
onPlatformThemeChange={settings.setPlatformTheme}
|
onPlatformThemeChange={settings.setPlatformTheme}
|
||||||
onLogout={logoutCurrentSession}
|
onLogout={logoutCurrentSession}
|
||||||
onRefreshRiskBlocks={async () => {
|
onRefreshRiskBlocks={async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user