This commit is contained in:
2026-04-24 16:15:00 +08:00
parent f65177b147
commit b355568189
16 changed files with 495 additions and 217 deletions

View File

@@ -0,0 +1,26 @@
# 移动端创作页新建作品紧凑布局设计
## 目标
移动端创作页顶部的新建作品模块只承担快速进入创作模板的作用,不承担规则解释和长说明承载。模块在首屏中最多占用约 1/3 高度,把更多空间留给作品列表和筛选操作。
## 落地范围
- 入口组件:`src/components/custom-world-home/CustomWorldCreationStartCard.tsx`
- 外层页面:`src/components/custom-world-home/CustomWorldCreationHub.tsx`
- 模板元数据继续复用 `PLATFORM_CREATION_TYPES`,不新增前端业务逻辑。
## 移动端布局规则
1. 顶部标题行压缩成单行:左侧标题,右侧仅保留简短状态,不再显示说明段落。
2. 模板入口在手机端使用横向滚动胶囊卡片,每个卡片保持单行动作感,不堆叠成长列表。
3. 卡片高度控制在约 4rem 内,标题与状态信息并排组织,避免大留白。
4. 模块本体使用 `max-height: 33svh` 作为硬约束,内容超出时优先在模板入口行内横向滚动,不撑高页面。
5. 桌面端保持网格入口,但同步收紧内边距和卡片留白,避免移动端与桌面端表现割裂。
## 文案约束
- UI 不新增规则说明类文案。
- 原有“直接选择游戏创作模板,立刻进入对应的共创工作台。”说明在移动端隐藏,桌面端保留为辅助说明。
- 锁定、可创建、正在开启等状态继续来自既有模板元数据或忙碌状态。

View File

@@ -0,0 +1,64 @@
# 平台入口分类与创作 Tab 强化设计
## 1. 目标
在不新建平台入口系统的前提下,直接扩展现有 `RpgEntryHomeView` 主 Tab
- 新增“分类” Tab用作品标签聚合所有公开发布作品。
- 强化“创作” Tab 的导航视觉权重,让它在底部导航中居中并更醒目。
- 登录态底部导航顺序为:首页、分类、创作、存档、我的。
- 未登录态底部导航只保留:首页、创作、分类,其中创作保持居中。
## 2. 数据边界
本次只做前端展示重排,不新增后端接口:
- 分类数据来源使用现有 `latestEntries``featuredEntries` 的公开作品列表。
- 标签来源沿用 `buildPlatformWorldTags(entry)`,公开作品会映射为题材、角色数、地标数。
- 同一公开作品若同时出现在精选与最新中,按 `ownerUserId + profileId` 去重。
- 点击分类作品继续走现有 `onOpenGalleryDetail`,不改变详情页和登录拦截逻辑。
## 3. 交互规则
### 3.1 登录态
底部导航展示 5 个入口:
1. 首页
2. 分类
3. 创作
4. 存档
5. 我的
创作入口位于第三位,视觉上使用更大的图标壳、轻微上浮、渐变高亮和阴影,保证它是主行动入口。
### 3.2 未登录态
底部导航展示 3 个入口:
1. 首页
2. 创作
3. 分类
不展示“存档”和“我的”,避免未登录用户在底部导航看到必须登录后才有价值的入口。创作入口位于第二位,保持几何居中。
### 3.3 桌面端
桌面侧栏同步增加“分类”,但保持纵向导航,不强行做居中布局。创作入口仍使用强调样式。
## 4. 分类页布局
分类页为独立 Tab 面板,不在首页下方展开:
- 顶部展示标签胶囊,默认选中作品数量最多的标签。
- 标签切换后,下方网格展示该标签下所有公开作品。
- 无公开作品时展示现有空状态组件。
- 分类页不写玩法规则说明类长文案,只保留必要标题、短状态文案和作品卡片。
## 5. 验收点
- 登录态移动端底部导航顺序准确,创作在 5 个 Tab 中居中。
- 未登录态移动端底部导航只显示 3 个 Tab创作在中间。
- 分类 Tab 能按标签切换并展示公开作品。
- 创作 Tab 在移动端和桌面端都比普通 Tab 更醒目。
- 不修改 server-node不新增后端逻辑。

View File

@@ -9,6 +9,8 @@
- [CUSTOM_WORLD_CREATOR_PURE_AGENT_COMPARISON_AND_CONVERSION_DESIGN_2026-04-12.md](./CUSTOM_WORLD_CREATOR_PURE_AGENT_COMPARISON_AND_CONVERSION_DESIGN_2026-04-12.md):纯 Agent 式创作工具与结构化工作台方案的优缺点对比,以及转型设计。 - [CUSTOM_WORLD_CREATOR_PURE_AGENT_COMPARISON_AND_CONVERSION_DESIGN_2026-04-12.md](./CUSTOM_WORLD_CREATOR_PURE_AGENT_COMPARISON_AND_CONVERSION_DESIGN_2026-04-12.md):纯 Agent 式创作工具与结构化工作台方案的优缺点对比,以及转型设计。
- [CUSTOM_WORLD_TEMPLATE_DECOUPLING_AND_CROSS_GENRE_GENERALIZATION_DESIGN_2026-04-08.md](./CUSTOM_WORLD_TEMPLATE_DECOUPLING_AND_CROSS_GENRE_GENERALIZATION_DESIGN_2026-04-08.md):把自定义世界从武侠/仙侠模板依赖迁到跨题材通用设定层的优化设计。 - [CUSTOM_WORLD_TEMPLATE_DECOUPLING_AND_CROSS_GENRE_GENERALIZATION_DESIGN_2026-04-08.md](./CUSTOM_WORLD_TEMPLATE_DECOUPLING_AND_CROSS_GENRE_GENERALIZATION_DESIGN_2026-04-08.md):把自定义世界从武侠/仙侠模板依赖迁到跨题材通用设定层的优化设计。
- [CUSTOM_WORLD_SELF_OWNED_SETTING_LAYER_OPTIMIZATION_2026-04-08.md](./CUSTOM_WORLD_SELF_OWNED_SETTING_LAYER_OPTIMIZATION_2026-04-08.md):把模板依赖逐步迁成自定义世界自有设定层,并保证不破坏当前生成流程的优化方案。 - [CUSTOM_WORLD_SELF_OWNED_SETTING_LAYER_OPTIMIZATION_2026-04-08.md](./CUSTOM_WORLD_SELF_OWNED_SETTING_LAYER_OPTIMIZATION_2026-04-08.md):把模板依赖逐步迁成自定义世界自有设定层,并保证不破坏当前生成流程的优化方案。
- [MOBILE_CREATION_NEW_WORK_COMPACT_LAYOUT_2026-04-24.md](./MOBILE_CREATION_NEW_WORK_COMPACT_LAYOUT_2026-04-24.md):移动端创作页新建作品模块最多占用首屏约 1/3 高度的紧凑布局设计。
- [PLATFORM_CATEGORY_AND_CREATE_TAB_DESIGN_2026-04-24.md](./PLATFORM_CATEGORY_AND_CREATE_TAB_DESIGN_2026-04-24.md):平台入口新增分类 Tab、登录态导航裁剪与创作 Tab 视觉强化设计。
- [AI_NATIVE_RUNTIME_ITEM_SYSTEM_REDESIGN_2026-04-02.md](./AI_NATIVE_RUNTIME_ITEM_SYSTEM_REDESIGN_2026-04-02.md):运行时物品生成系统重设计。 - [AI_NATIVE_RUNTIME_ITEM_SYSTEM_REDESIGN_2026-04-02.md](./AI_NATIVE_RUNTIME_ITEM_SYSTEM_REDESIGN_2026-04-02.md):运行时物品生成系统重设计。
- [LEVEL_PROGRESS_AND_CHAPTER_NPC_AUTO_SCALING_DESIGN_2026-04-20.md](./LEVEL_PROGRESS_AND_CHAPTER_NPC_AUTO_SCALING_DESIGN_2026-04-20.md):等级成长、章节经验节奏与 NPC 自动定级设计。 - [LEVEL_PROGRESS_AND_CHAPTER_NPC_AUTO_SCALING_DESIGN_2026-04-20.md](./LEVEL_PROGRESS_AND_CHAPTER_NPC_AUTO_SCALING_DESIGN_2026-04-20.md):等级成长、章节经验节奏与 NPC 自动定级设计。
- [RPG_NARRATIVE_PLANNING_FULL_PIPELINE_WORKFLOW_2026-04-12.md](./RPG_NARRATIVE_PLANNING_FULL_PIPELINE_WORKFLOW_2026-04-12.md):专业剧情策划构建 RPG 游戏全剧情的工作流程与交付模板。 - [RPG_NARRATIVE_PLANNING_FULL_PIPELINE_WORKFLOW_2026-04-12.md](./RPG_NARRATIVE_PLANNING_FULL_PIPELINE_WORKFLOW_2026-04-12.md):专业剧情策划构建 RPG 游戏全剧情的工作流程与交付模板。

View File

@@ -1217,8 +1217,9 @@ Phase 4 本轮已完成以下主链接线:
- published works 明确输出 `canEnterWorld=true` - published works 明确输出 `canEnterWorld=true`
4. 前端 Agent 结果页已开始消费服务端 Phase4 状态: 4. 前端 Agent 结果页已开始消费服务端 Phase4 状态:
- 结果页在 Agent 草稿未发布时把主 CTA 改成“发布并进入世界” - 结果页在 Agent 草稿未发布时把主 CTA 改成“发布并进入世界”
- 结果页会展示服务端 preview source、publish blockers、warning 数量 - 结果页会消费服务端 gate 语义,但不再把 preview source 做成底部常驻提示
- blocker 时会禁用“发布并进入世界”按钮,不再让前端继续假装可以直接进入世界 - publish blockers 改为点击“发布并进入世界”时,通过独立面板提示
- warning 数量仍可作为非阻断摘要展示
5. `useRpgCreationEnterWorld.ts``RpgEntryFlowShellImpl.tsx` 已把 Agent 结果页进入世界主链改成: 5. `useRpgCreationEnterWorld.ts``RpgEntryFlowShellImpl.tsx` 已把 Agent 结果页进入世界主链改成:
-`sync_result_profile` -`sync_result_profile`
- 再执行后端 `publish_world` - 再执行后端 `publish_world`

View File

@@ -4,12 +4,13 @@
## 1. 目标边界 ## 1. 目标边界
本次迭代开放号密码登录、登录后修改密码、手机号验证码重置密码,但不开放密码注册 本次迭代开放手机号密码登录、登录后修改密码、手机号验证码重置密码,但不开放独立注册页面
1. 新用户只能通过手机号验证码完成注册与首次登录。 1. 新用户只能通过手机号验证码完成注册与首次登录。
2. 已有用户可以在登录后设置或修改密码。 2. 已有用户可以在登录后设置或修改密码。
3. 忘记密码时,只能通过已绑定手机号验证码重置密码。 3. 忘记密码时,只能通过已绑定手机号验证码重置密码。
4. 密码登录只校验已存在且已设置密码的账号,不自动创建新账号。 4. 密码登录只校验已存在且已设置密码的手机号账号,不自动创建新账号。
5. 登录面板本地缓存最近一次成功登录的手机号,只用于回填手机号输入框,不缓存密码或验证码。
## 2. 接口设计 ## 2. 接口设计
@@ -17,10 +18,11 @@
沿用现有 `POST /api/auth/entry` 沿用现有 `POST /api/auth/entry`
1. 请求字段`username``password` 1. 请求字段沿用 `username``password`,但前端固定把手机号填入 `username`
2. 用户不存在时返回 `401`,不创建账号 2. 后端优先按标准手机号归一化后查找账号,兼容历史用户名只作为开发游客兜底能力
3. 用户存在但未设置密码时返回 `401` 3. 手机号不存在时返回 `401`,不创建账号
4. 校验成功后签发 access token并写入 refresh cookie 4. 手机号存在但未设置密码时返回 `401`
5. 校验成功后签发 access token并写入 refresh cookie。
### 2.2 修改密码 ### 2.2 修改密码
@@ -46,14 +48,24 @@
复用 `POST /api/auth/phone/send-code``scene` 增加 `reset_password` 复用 `POST /api/auth/phone/send-code``scene` 增加 `reset_password`
### 2.5 验证码注册/登录
复用现有 `POST /api/auth/phone/login`
1. 请求字段:`phone``code`
2. 验证码校验成功后,若手机号已绑定账号,则直接完成登录。
3. 验证码校验成功后,若手机号没有账号信息,则后端自动创建手机号账号,再完成登录。
4. 自动创建账号默认不设置用户可用密码,用户后续可在账号设置或忘记密码流程设置密码。
## 3. 前端交互 ## 3. 前端交互
登录弹窗拆成两个页签: 登录弹窗不再拆独立注册页签:
1. `登录`:提供密码登录、手机号验证码登录、忘记密码入口 1. 面板直接展示手机号和密码输入,用于已设置密码账号登录
2. `注册`:只提供手机号验证码注册/登录,不提供账号密码注册 2. 登录按钮文本固定为 `注册/登录`,避免用户在登录和首次进入之间做页面切换
3. `忘记密码`:从登录页进入独立重置面板,不在当前表单下方展开。 3. 忘记密码入口显示在登录按钮右下侧,点击后仍进入独立重置面板,不在当前表单下方展开。
4. 账号设置面板提供密码修改入口;未设置密码的账号显示为设置密码 4. 同一面板保留手机号验证码注册/登录能力,用于新用户自动注册和已注册用户免密码登录
5. 账号设置面板提供密码修改入口;未设置密码的账号显示为设置密码。
## 4. 数据约束 ## 4. 数据约束
@@ -63,4 +75,4 @@
2. 微信待绑定账号默认没有用户可用密码。 2. 微信待绑定账号默认没有用户可用密码。
3. 只有用户显式修改或重置密码后,才允许密码登录。 3. 只有用户显式修改或重置密码后,才允许密码登录。
后续迁移到 SpacetimeDB 表时,保持同一语义:密码哈希字段允许为空,密码登录 reducer 不承担注册能力。 后续迁移到 SpacetimeDB 表时,保持同一语义:密码哈希字段允许为空,密码登录 reducer 不承担注册能力,验证码登录 reducer 承担“无账号则自动注册”的唯一注册入口

View File

@@ -14,6 +14,8 @@
已改为: 已改为:
补充修复:`RpgCreationResultViewImpl` 已补齐 `previewSourceLabel` props 解构,避免 Agent 结果页在渲染数据源提示时触发 `ReferenceError`
```text ```text
Agent 结果页点击新增场景角色 / 新增场景 Agent 结果页点击新增场景角色 / 新增场景
-> RpgCreationResultView.onGenerateEntity -> RpgCreationResultView.onGenerateEntity

View File

@@ -65,7 +65,7 @@ pub async fn password_entry(
fn map_password_entry_error(error: PasswordEntryError) -> AppError { fn map_password_entry_error(error: PasswordEntryError) -> AppError {
match error { match error {
PasswordEntryError::InvalidUsername => AppError::from_status(StatusCode::BAD_REQUEST) PasswordEntryError::InvalidUsername => AppError::from_status(StatusCode::BAD_REQUEST)
.with_message("用户名只允许 3 到 24 位字母、数字、下划线") .with_message("手机号格式不正确")
.with_details(json!({ .with_details(json!({
"field": "username", "field": "username",
})), })),
@@ -80,10 +80,10 @@ fn map_password_entry_error(error: PasswordEntryError) -> AppError {
"field": "username", "field": "username",
})), })),
PasswordEntryError::InvalidCredentials => { PasswordEntryError::InvalidCredentials => {
AppError::from_status(StatusCode::UNAUTHORIZED).with_message("用户名或密码错误") AppError::from_status(StatusCode::UNAUTHORIZED).with_message("手机号或密码错误")
} }
PasswordEntryError::UserNotFound => { PasswordEntryError::UserNotFound => {
AppError::from_status(StatusCode::UNAUTHORIZED).with_message("用户名或密码错误") AppError::from_status(StatusCode::UNAUTHORIZED).with_message("手机号或密码错误")
} }
PasswordEntryError::Store(_) | PasswordEntryError::PasswordHash(_) => { PasswordEntryError::Store(_) | PasswordEntryError::PasswordHash(_) => {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_message(error.to_string()) AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_message(error.to_string())

View File

@@ -475,24 +475,25 @@ impl PasswordEntryService {
&self, &self,
input: PasswordEntryInput, input: PasswordEntryInput,
) -> Result<PasswordEntryResult, PasswordEntryError> { ) -> Result<PasswordEntryResult, PasswordEntryError> {
let username = normalize_username(&input.username)?;
validate_password(&input.password)?; validate_password(&input.password)?;
if let Some(existing_user) = self.store.find_by_username(&username)? { // 登录面板现在固定使用手机号作为密码登录标识;先走手机号索引,
if !existing_user.password_login_enabled { // 再保留历史用户名路径给开发游客和旧测试数据使用。
if let Ok(normalized_phone) = normalize_mainland_china_phone_number(&input.username) {
let Some(existing_user) = self
.store
.find_by_phone_number_for_password(&normalized_phone.e164)?
else {
return Err(PasswordEntryError::InvalidCredentials); return Err(PasswordEntryError::InvalidCredentials);
} };
let is_valid = verify_password(&existing_user.password_hash, &input.password)
.await
.map_err(|error| PasswordEntryError::PasswordHash(error.to_string()))?;
if !is_valid {
return Err(PasswordEntryError::InvalidCredentials);
}
return Ok(PasswordEntryResult { return verify_stored_password_user(existing_user, &input.password).await;
user: existing_user.user, }
created: false,
}); let username = normalize_username(&input.username)?;
if let Some(existing_user) = self.store.find_by_username(&username)? {
return verify_stored_password_user(existing_user, &input.password).await;
} }
Err(PasswordEntryError::InvalidCredentials) Err(PasswordEntryError::InvalidCredentials)
@@ -1292,6 +1293,24 @@ impl InMemoryAuthStore {
.cloned()) .cloned())
} }
fn find_by_phone_number_for_password(
&self,
phone_number: &str,
) -> Result<Option<StoredPasswordUser>, PasswordEntryError> {
let state = self
.inner
.lock()
.map_err(|_| PasswordEntryError::Store("用户仓储锁已中毒".to_string()))?;
let Some(user_id) = state.phone_to_user_id.get(phone_number) else {
return Ok(None);
};
Ok(state
.users_by_username
.values()
.find(|stored_user| stored_user.user.id == *user_id)
.cloned())
}
fn create_phone_user( fn create_phone_user(
&self, &self,
phone_number: PhoneNumberSnapshot, phone_number: PhoneNumberSnapshot,
@@ -2220,6 +2239,27 @@ fn validate_password(password: &str) -> Result<(), PasswordEntryError> {
Ok(()) Ok(())
} }
async fn verify_stored_password_user(
existing_user: StoredPasswordUser,
password: &str,
) -> Result<PasswordEntryResult, PasswordEntryError> {
if !existing_user.password_login_enabled {
return Err(PasswordEntryError::InvalidCredentials);
}
let is_valid = verify_password(&existing_user.password_hash, password)
.await
.map_err(|error| PasswordEntryError::PasswordHash(error.to_string()))?;
if !is_valid {
return Err(PasswordEntryError::InvalidCredentials);
}
Ok(PasswordEntryResult {
user: existing_user.user,
created: false,
})
}
fn verify_sms_code_format(verify_code: &str) -> Result<(), PhoneAuthError> { fn verify_sms_code_format(verify_code: &str) -> Result<(), PhoneAuthError> {
let verify_code = verify_code.trim(); let verify_code = verify_code.trim();
if verify_code.len() != SMS_CODE_LENGTH if verify_code.len() != SMS_CODE_LENGTH

View File

@@ -15,11 +15,11 @@ import {
import { import {
type AuthAuditLogEntry, type AuthAuditLogEntry,
type AuthCaptchaChallenge, type AuthCaptchaChallenge,
authEntry,
type AuthLoginMethod, type AuthLoginMethod,
type AuthRiskBlockSummary, type AuthRiskBlockSummary,
type AuthSessionSummary, type AuthSessionSummary,
type AuthUser, type AuthUser,
authEntry,
bindWechatPhone, bindWechatPhone,
changePassword, changePassword,
changePhoneNumber, changePhoneNumber,
@@ -38,6 +38,7 @@ import {
resetPassword, resetPassword,
revokeAuthSession, revokeAuthSession,
sendPhoneLoginCode, sendPhoneLoginCode,
setStoredLastLoginPhone,
startWechatLogin, startWechatLogin,
} from '../../services/authService'; } from '../../services/authService';
import { AccountModal } from './AccountModal'; import { AccountModal } from './AccountModal';
@@ -694,6 +695,7 @@ export function AuthGate({ children }: AuthGateProps) {
setError(''); setError('');
try { try {
const nextUser = await loginWithPhoneCode(phone, code); const nextUser = await loginWithPhoneCode(phone, code);
setStoredLastLoginPhone(phone);
setLoginCaptchaChallenge(null); setLoginCaptchaChallenge(null);
activateReadyUser(nextUser); activateReadyUser(nextUser);
} catch (loginError) { } catch (loginError) {
@@ -711,6 +713,7 @@ export function AuthGate({ children }: AuthGateProps) {
setError(''); setError('');
try { try {
const nextUser = await authEntry(username, password); const nextUser = await authEntry(username, password);
setStoredLastLoginPhone(username);
activateReadyUser(nextUser); activateReadyUser(nextUser);
} catch (loginError) { } catch (loginError) {
setError( setError(
@@ -727,6 +730,7 @@ export function AuthGate({ children }: AuthGateProps) {
setError(''); setError('');
try { try {
const nextUser = await resetPassword(phone, code, newPassword); const nextUser = await resetPassword(phone, code, newPassword);
setStoredLastLoginPhone(phone);
activateReadyUser(nextUser); activateReadyUser(nextUser);
} catch (resetError) { } catch (resetError) {
setError( setError(

View File

@@ -6,6 +6,7 @@ import type {
AuthCaptchaChallenge, AuthCaptchaChallenge,
AuthLoginMethod, AuthLoginMethod,
} from '../../services/authService'; } from '../../services/authService';
import { getStoredLastLoginPhone } from '../../services/authService';
import { CaptchaChallengeField } from './CaptchaChallengeField'; import { CaptchaChallengeField } from './CaptchaChallengeField';
type SmsScene = 'login' | 'reset_password'; type SmsScene = 'login' | 'reset_password';
@@ -57,11 +58,9 @@ export function LoginScreen({
onResetPassword, onResetPassword,
onStartWechatLogin, onStartWechatLogin,
}: LoginScreenProps) { }: LoginScreenProps) {
const [activeTab, setActiveTab] = useState<'login' | 'register'>('login');
const [isResetPanelOpen, setIsResetPanelOpen] = useState(false); const [isResetPanelOpen, setIsResetPanelOpen] = useState(false);
const [username, setUsername] = useState(''); const [phone, setPhone] = useState(() => getStoredLastLoginPhone());
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [phone, setPhone] = useState('');
const [code, setCode] = useState(''); const [code, setCode] = useState('');
const [resetPhone, setResetPhone] = useState(''); const [resetPhone, setResetPhone] = useState('');
const [resetCode, setResetCode] = useState(''); const [resetCode, setResetCode] = useState('');
@@ -154,75 +153,55 @@ export function LoginScreen({
/> />
) : ( ) : (
<div className="flex flex-col gap-4 px-5 py-5"> <div className="flex flex-col gap-4 px-5 py-5">
<div className="grid grid-cols-2 gap-2 rounded-full bg-[var(--platform-subpanel-bg)] p-1"> {passwordLoginEnabled ? (
<TabButton
active={activeTab === 'login'}
label="登录"
onClick={() => setActiveTab('login')}
/>
<TabButton
active={activeTab === 'register'}
label="注册"
onClick={() => setActiveTab('register')}
/>
</div>
{activeTab === 'login' ? (
<form <form
className="flex flex-col gap-4" className="flex flex-col gap-4"
onSubmit={(event) => { onSubmit={(event) => {
event.preventDefault(); event.preventDefault();
if (!passwordLoginEnabled) { void onPasswordSubmit(phone, password);
return;
}
void onPasswordSubmit(username, password);
}} }}
> >
{passwordLoginEnabled ? ( <label className="grid gap-2 text-sm text-[var(--platform-text-base)]">
<> <span></span>
<label className="grid gap-2 text-sm text-[var(--platform-text-base)]"> <input
<span></span> className="platform-input"
<input autoComplete="tel"
className="platform-input" inputMode="numeric"
autoComplete="username" value={phone}
value={username} onChange={(event) => setPhone(event.target.value)}
onChange={(event) => setUsername(event.target.value)} placeholder="13800000000"
placeholder="用户名" />
/> </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)]"> <span></span>
<span></span> <input
<input className="platform-input"
className="platform-input" autoComplete="current-password"
autoComplete="current-password" type="password"
type="password" value={password}
value={password} onChange={(event) => setPassword(event.target.value)}
onChange={(event) => setPassword(event.target.value)} placeholder="输入密码"
placeholder="输入密码" />
/> </label>
</label>
</>
) : null}
{error ? <ErrorBanner message={error} /> : null} {error ? <ErrorBanner message={error} /> : null}
{passwordLoginEnabled ? ( <div className="flex flex-col gap-2">
<button <button
type="submit" type="submit"
disabled={submitDisabled || !username.trim() || !password.trim()} disabled={submitDisabled || !phone.trim() || !password.trim()}
className="platform-button platform-button--primary h-12 px-4 text-base disabled:cursor-not-allowed disabled:opacity-60" className="platform-button platform-button--primary h-12 px-4 text-base disabled:cursor-not-allowed disabled:opacity-60"
> >
{loggingIn ? '登录中' : '登录'} {loggingIn ? '登录中' : '注册/登录'}
</button> </button>
) : null} <button
type="button"
<button className="self-end text-sm text-[var(--platform-accent)]"
type="button" onClick={() => setIsResetPanelOpen(true)}
className="self-center text-sm text-[var(--platform-accent)]" >
onClick={() => setIsResetPanelOpen(true)}
> </button>
</div>
</button>
{wechatLoginEnabled ? ( {wechatLoginEnabled ? (
<WechatButton <WechatButton
@@ -232,7 +211,9 @@ export function LoginScreen({
/> />
) : null} ) : null}
</form> </form>
) : ( ) : null}
{phoneLoginEnabled ? (
<PhoneCodeForm <PhoneCodeForm
phone={phone} phone={phone}
code={code} code={code}
@@ -243,8 +224,9 @@ export function LoginScreen({
loggingIn={loggingIn} loggingIn={loggingIn}
error={error} error={error}
hint={hint} hint={hint}
submitLabel="注册登录" submitLabel="注册/登录"
enabled={phoneLoginEnabled} enabled={phoneLoginEnabled}
showPhoneField={!passwordLoginEnabled}
onPhoneChange={setPhone} onPhoneChange={setPhone}
onCodeChange={setCode} onCodeChange={setCode}
onCaptchaAnswerChange={setCaptchaAnswer} onCaptchaAnswerChange={setCaptchaAnswer}
@@ -262,7 +244,7 @@ export function LoginScreen({
}} }}
onSubmit={() => onPhoneSubmit(phone, code)} onSubmit={() => onPhoneSubmit(phone, code)}
/> />
)} ) : null}
{!passwordLoginEnabled && !phoneLoginEnabled && !wechatLoginEnabled ? ( {!passwordLoginEnabled && !phoneLoginEnabled && !wechatLoginEnabled ? (
<div className="platform-subpanel rounded-2xl px-4 py-4 text-sm text-[var(--platform-text-base)]"> <div className="platform-subpanel rounded-2xl px-4 py-4 text-sm text-[var(--platform-text-base)]">
@@ -276,30 +258,6 @@ export function LoginScreen({
); );
} }
function TabButton({
active,
label,
onClick,
}: {
active: boolean;
label: string;
onClick: () => void;
}) {
return (
<button
type="button"
className={`h-10 rounded-full text-sm font-medium transition ${
active
? 'bg-[var(--platform-panel-bg)] text-[var(--platform-text-strong)] shadow-sm'
: 'text-[var(--platform-text-muted)]'
}`}
onClick={onClick}
>
{label}
</button>
);
}
function PhoneCodeForm({ function PhoneCodeForm({
phone, phone,
code, code,
@@ -312,6 +270,7 @@ function PhoneCodeForm({
hint, hint,
submitLabel, submitLabel,
enabled, enabled,
showPhoneField,
onPhoneChange, onPhoneChange,
onCodeChange, onCodeChange,
onCaptchaAnswerChange, onCaptchaAnswerChange,
@@ -329,6 +288,7 @@ function PhoneCodeForm({
hint: string; hint: string;
submitLabel: string; submitLabel: string;
enabled: boolean; enabled: boolean;
showPhoneField: boolean;
onPhoneChange: (value: string) => void; onPhoneChange: (value: string) => void;
onCodeChange: (value: string) => void; onCodeChange: (value: string) => void;
onCaptchaAnswerChange: (value: string) => void; onCaptchaAnswerChange: (value: string) => void;
@@ -347,17 +307,19 @@ function PhoneCodeForm({
void onSubmit(); void onSubmit();
}} }}
> >
<label className="grid gap-2 text-sm text-[var(--platform-text-base)]"> {showPhoneField ? (
<span></span> <label className="grid gap-2 text-sm text-[var(--platform-text-base)]">
<input <span></span>
className="platform-input" <input
autoComplete="tel" className="platform-input"
inputMode="numeric" autoComplete="tel"
value={phone} inputMode="numeric"
onChange={(event) => onPhoneChange(event.target.value)} value={phone}
placeholder="13800000000" onChange={(event) => onPhoneChange(event.target.value)}
/> placeholder="13800000000"
</label> />
</label>
) : null}
<label className="grid gap-2 text-sm text-[var(--platform-text-base)]"> <label className="grid gap-2 text-sm text-[var(--platform-text-base)]">
<span></span> <span></span>

View File

@@ -17,19 +17,23 @@ export function CustomWorldCreationStartCard({
onCreateType, onCreateType,
}: CustomWorldCreationStartCardProps) { }: CustomWorldCreationStartCardProps) {
return ( return (
<div className="platform-surface platform-surface--hero relative overflow-hidden px-5 py-5"> // 移动端限制模块高度,模板入口改为横向滚动,避免挤占作品列表首屏空间。
<div className="platform-surface platform-surface--hero relative max-h-[33svh] overflow-hidden px-3 py-3 sm:max-h-none sm:px-5 sm:py-5">
<div className="absolute inset-0 bg-[var(--platform-hero-overlay-strong)]" /> <div className="absolute inset-0 bg-[var(--platform-hero-overlay-strong)]" />
<div className="relative z-10 space-y-4"> <div className="relative z-10 space-y-2.5 sm:space-y-4">
<div> <div className="flex items-center justify-between gap-3">
<div className="text-2xl font-black text-white sm:text-3xl"> <div className="text-xl font-black leading-none text-white sm:text-3xl">
</div> </div>
<div className="mt-2 text-sm leading-6 text-zinc-200/88"> <div className="hidden text-sm leading-6 text-zinc-200/88 sm:block">
</div> </div>
<span className="platform-pill platform-pill--neutral shrink-0 border-white/25 bg-white/14 px-2.5 text-xs text-white sm:hidden">
{busy ? '正在开启' : '选择模板'}
</span>
</div> </div>
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-5"> <div className="-mx-1 flex snap-x gap-2 overflow-x-auto px-1 pb-1 sm:mx-0 sm:grid sm:gap-3 sm:overflow-visible sm:px-0 sm:pb-0 sm:grid-cols-2 xl:grid-cols-5">
{PLATFORM_CREATION_TYPES.map((item) => { {PLATFORM_CREATION_TYPES.map((item) => {
const disabled = item.locked || busy; const disabled = item.locked || busy;
@@ -41,15 +45,15 @@ export function CustomWorldCreationStartCard({
onClick={() => { onClick={() => {
onCreateType(item.id); onCreateType(item.id);
}} }}
className={`platform-interactive-card relative overflow-hidden rounded-[1.5rem] border px-4 py-4 text-left transition ${ className={`platform-interactive-card relative min-h-[4rem] w-[11.25rem] shrink-0 snap-start overflow-hidden rounded-[1.15rem] border px-3 py-2.5 text-left transition sm:min-h-[8.5rem] sm:w-auto sm:rounded-[1.5rem] sm:px-4 sm:py-4 ${
item.locked item.locked
? 'cursor-not-allowed border-white/10 bg-white/8 text-zinc-300/70' ? 'cursor-not-allowed border-white/10 bg-white/8 text-zinc-300/70'
: 'border-white/18 bg-[radial-gradient(circle_at_top_left,rgba(255,255,255,0.24),transparent_36%),linear-gradient(135deg,rgba(255,255,255,0.18),rgba(255,255,255,0.08))] text-white' : 'border-white/18 bg-[radial-gradient(circle_at_top_left,rgba(255,255,255,0.24),transparent_36%),linear-gradient(135deg,rgba(255,255,255,0.18),rgba(255,255,255,0.08))] text-white'
} ${busy && !item.locked ? 'opacity-70' : ''}`} } ${busy && !item.locked ? 'opacity-70' : ''}`}
> >
<div className="flex items-start justify-between gap-3"> <div className="flex items-center justify-between gap-2 sm:items-start sm:gap-3">
<span <span
className={`platform-pill px-3 ${ className={`platform-pill px-2.5 text-xs sm:px-3 sm:text-sm ${
item.locked item.locked
? 'platform-pill--neutral text-[var(--platform-text-soft)]' ? 'platform-pill--neutral text-[var(--platform-text-soft)]'
: 'platform-pill--neutral border-white/30 bg-white/18 text-white' : 'platform-pill--neutral border-white/30 bg-white/18 text-white'
@@ -64,11 +68,11 @@ export function CustomWorldCreationStartCard({
)} )}
</div> </div>
<div className="mt-7 text-lg font-black leading-tight text-inherit"> <div className="mt-2.5 truncate text-base font-black leading-tight text-inherit sm:mt-7 sm:text-lg">
{item.title} {item.title}
</div> </div>
<div <div
className={`mt-2 text-sm ${ className={`mt-1 truncate text-xs sm:mt-2 sm:text-sm ${
item.locked ? 'text-zinc-400' : 'text-zinc-200/82' item.locked ? 'text-zinc-400' : 'text-zinc-200/82'
}`} }`}
> >
@@ -80,7 +84,7 @@ export function CustomWorldCreationStartCard({
</div> </div>
{error ? ( {error ? (
<div className="platform-banner platform-banner--danger rounded-[1.25rem] text-sm leading-6"> <div className="platform-banner platform-banner--danger rounded-[1rem] px-3 py-2 text-sm leading-5 sm:rounded-[1.25rem] sm:leading-6">
{error} {error}
</div> </div>
) : null} ) : null}

View File

@@ -201,23 +201,6 @@ export function RpgCreationResultView({
{error} {error}
</div> </div>
) : null} ) : null}
{!error && compactAgentResultMode && previewSourceLabel ? (
<div className="platform-banner platform-banner--info mt-3 rounded-2xl text-sm leading-6">
{previewSourceLabel}
</div>
) : null}
{!error && compactAgentResultMode && publishBlockers.length > 0 ? (
<div className="platform-banner platform-banner--warning mt-3 rounded-2xl text-sm leading-6">
{publishReady
? '当前世界已满足发布门槛。'
: `当前还有 ${publishBlockers.length} 个发布阻断项,请先补齐后再进入世界。`}
{!publishReady ? (
<div className="mt-2 text-xs text-[var(--platform-text-muted)]">
</div>
) : null}
</div>
) : null}
{!error && {!error &&
compactAgentResultMode && compactAgentResultMode &&
publishBlockers.length <= 0 && publishBlockers.length <= 0 &&

View File

@@ -1889,7 +1889,7 @@ test('agent result view does not keep legacy publish blockers when preview uses
expect((actionButton as HTMLButtonElement).disabled).toBe(false); expect((actionButton as HTMLButtonElement).disabled).toBe(false);
}); });
test('agent draft result back button returns to creation hub without redundant sync when session is already latest', async () => { test('agent draft result back button syncs result profile before returning to creation hub', async () => {
const user = userEvent.setup(); const user = userEvent.setup();
vi.mocked(executeRpgCreationAction).mockResolvedValue({ vi.mocked(executeRpgCreationAction).mockResolvedValue({
@@ -2076,7 +2076,7 @@ test('agent draft result back button returns to creation hub without redundant s
sessionId === 'custom-world-agent-session-1' && sessionId === 'custom-world-agent-session-1' &&
payload?.action === 'sync_result_profile', payload?.action === 'sync_result_profile',
), ),
).toBe(false); ).toBe(true);
expect(screen.queryByText('世界档案')).toBeNull(); expect(screen.queryByText('世界档案')).toBeNull();
}); });

View File

@@ -15,6 +15,7 @@ import {
Search, Search,
Settings, Settings,
Sparkles, Sparkles,
Tags,
Ticket, Ticket,
UserPlus, UserPlus,
UserRound, UserRound,
@@ -50,7 +51,7 @@ import {
resolvePlatformWorldLeadPortrait, resolvePlatformWorldLeadPortrait,
} from './rpgEntryWorldPresentation'; } from './rpgEntryWorldPresentation';
export type PlatformHomeTab = 'home' | 'create' | 'saves' | 'profile'; export type PlatformHomeTab = 'home' | 'category' | 'create' | 'saves' | 'profile';
export interface RpgEntryHomeViewProps { export interface RpgEntryHomeViewProps {
activeTab: PlatformHomeTab; activeTab: PlatformHomeTab;
onTabChange: (tab: PlatformHomeTab) => void; onTabChange: (tab: PlatformHomeTab) => void;
@@ -96,6 +97,7 @@ const DESKTOP_PAGE_STAGE_CLASS =
const DESKTOP_LAYOUT_QUERY = '(min-width: 1024px)'; const DESKTOP_LAYOUT_QUERY = '(min-width: 1024px)';
const PLATFORM_HOME_TABS: PlatformHomeTab[] = [ const PLATFORM_HOME_TABS: PlatformHomeTab[] = [
'home', 'home',
'category',
'create', 'create',
'saves', 'saves',
'profile', 'profile',
@@ -470,17 +472,19 @@ function PlatformTabButton({
label, label,
icon: Icon, icon: Icon,
onClick, onClick,
emphasized = false,
}: { }: {
active: boolean; active: boolean;
label: string; label: string;
icon: ComponentType<{ className?: string }>; icon: ComponentType<{ className?: string }>;
onClick: () => void; onClick: () => void;
emphasized?: boolean;
}) { }) {
return ( return (
<button <button
type="button" type="button"
onClick={onClick} onClick={onClick}
className={`platform-bottom-nav__button ${active ? 'platform-bottom-nav__button--active' : ''}`} className={`platform-bottom-nav__button ${emphasized ? 'platform-bottom-nav__button--primary' : ''} ${active ? 'platform-bottom-nav__button--active' : ''}`}
> >
<span className="platform-bottom-nav__button-content"> <span className="platform-bottom-nav__button-content">
<span className="platform-bottom-nav__icon-shell"> <span className="platform-bottom-nav__icon-shell">
@@ -497,17 +501,19 @@ function DesktopTabButton({
label, label,
icon: Icon, icon: Icon,
onClick, onClick,
emphasized = false,
}: { }: {
active: boolean; active: boolean;
label: string; label: string;
icon: ComponentType<{ className?: string }>; icon: ComponentType<{ className?: string }>;
onClick: () => void; onClick: () => void;
emphasized?: boolean;
}) { }) {
return ( return (
<button <button
type="button" type="button"
onClick={onClick} onClick={onClick}
className={`platform-desktop-rail__button ${active ? 'platform-desktop-rail__button--active' : ''}`} className={`platform-desktop-rail__button ${emphasized ? 'platform-desktop-rail__button--primary' : ''} ${active ? 'platform-desktop-rail__button--active' : ''}`}
> >
<span className="platform-desktop-rail__icon-shell"> <span className="platform-desktop-rail__icon-shell">
<Icon className="platform-desktop-rail__icon h-[1.1rem] w-[1.1rem]" /> <Icon className="platform-desktop-rail__icon h-[1.1rem] w-[1.1rem]" />
@@ -605,6 +611,41 @@ function DesktopTrendingItem({
); );
} }
function buildPublicCategoryGroups(
featuredEntries: CustomWorldGalleryCard[],
latestEntries: CustomWorldGalleryCard[],
) {
const publicEntryMap = new Map<string, CustomWorldGalleryCard>();
[...featuredEntries, ...latestEntries].forEach((entry) => {
publicEntryMap.set(`${entry.ownerUserId}:${entry.profileId}`, entry);
});
const categoryMap = new Map<string, CustomWorldGalleryCard[]>();
Array.from(publicEntryMap.values()).forEach((entry) => {
const tags = buildPlatformWorldTags(entry)
.map((tag) => tag.trim())
.filter(Boolean);
const normalizedTags = tags.length > 0 ? tags : ['回响'];
normalizedTags.forEach((tag) => {
const entries = categoryMap.get(tag) ?? [];
entries.push(entry);
categoryMap.set(tag, entries);
});
});
return Array.from(categoryMap.entries())
.map(([tag, entries]) => ({ tag, entries }))
.sort((left, right) => {
if (right.entries.length !== left.entries.length) {
return right.entries.length - left.entries.length;
}
return left.tag.localeCompare(right.tag, 'zh-CN');
});
}
function formatSnapshotTime(value: string | null | undefined) { function formatSnapshotTime(value: string | null | undefined) {
if (!value) { if (!value) {
return '刚刚保存'; return '刚刚保存';
@@ -814,12 +855,30 @@ export function RpgEntryHomeView({
}: RpgEntryHomeViewProps) { }: RpgEntryHomeViewProps) {
const authUi = useAuthUi(); const authUi = useAuthUi();
const [desktopSearchKeyword, setDesktopSearchKeyword] = useState(''); const [desktopSearchKeyword, setDesktopSearchKeyword] = useState('');
const [selectedCategoryTag, setSelectedCategoryTag] = useState<string | null>(
null,
);
const isAuthenticated = Boolean(authUi?.user); const isAuthenticated = Boolean(authUi?.user);
const isDesktopLayout = usePlatformDesktopLayout(); const isDesktopLayout = usePlatformDesktopLayout();
const featuredShelf = useMemo( const featuredShelf = useMemo(
() => featuredEntries.slice(0, 6), () => featuredEntries.slice(0, 6),
[featuredEntries], [featuredEntries],
); );
const categoryGroups = useMemo(
() => buildPublicCategoryGroups(featuredEntries, latestEntries),
[featuredEntries, latestEntries],
);
const activeCategoryGroup =
categoryGroups.find((group) => group.tag === selectedCategoryTag) ??
categoryGroups[0] ??
null;
const visibleTabs = useMemo<PlatformHomeTab[]>(
() =>
isAuthenticated
? ['home', 'category', 'create', 'saves', 'profile']
: ['home', 'create', 'category'],
[isAuthenticated],
);
const snapshotWorldName = const snapshotWorldName =
savedSnapshot?.gameState.customWorldProfile?.name ?? savedSnapshot?.gameState.customWorldProfile?.name ??
savedSnapshot?.gameState.currentScenePreset?.name ?? savedSnapshot?.gameState.currentScenePreset?.name ??
@@ -842,10 +901,39 @@ export function RpgEntryHomeView({
const playedWorkCount = profileDashboard?.playedWorldCount ?? 0; const playedWorkCount = profileDashboard?.playedWorldCount ?? 0;
const tabIcons = { const tabIcons = {
home: House, home: House,
category: Tags,
create: Sparkles, create: Sparkles,
saves: Archive, saves: Archive,
profile: UserRound, profile: UserRound,
} as const; } as const;
const tabLabels = {
home: '首页',
category: '分类',
create: '创作',
saves: '存档',
profile: '我的',
} as const;
useEffect(() => {
if (!visibleTabs.includes(activeTab)) {
onTabChange('home');
}
}, [activeTab, onTabChange, visibleTabs]);
useEffect(() => {
if (categoryGroups.length === 0) {
setSelectedCategoryTag(null);
return;
}
const firstCategoryGroup = categoryGroups[0];
if (
firstCategoryGroup &&
!categoryGroups.some((group) => group.tag === selectedCategoryTag)
) {
setSelectedCategoryTag(firstCategoryGroup.tag);
}
}, [categoryGroups, selectedCategoryTag]);
const openUserSurface = () => { const openUserSurface = () => {
if (authUi?.user) { if (authUi?.user) {
authUi.openAccountModal(); authUi.openAccountModal();
@@ -873,6 +961,9 @@ export function RpgEntryHomeView({
const desktopFeaturedGrid = featuredShelf.slice(0, 4); const desktopFeaturedGrid = featuredShelf.slice(0, 4);
const desktopReleaseGrid = latestEntries.slice(0, 6); const desktopReleaseGrid = latestEntries.slice(0, 6);
const desktopLibraryPreview = myEntries.slice(0, 2); const desktopLibraryPreview = myEntries.slice(0, 2);
const categoryPageClass = isDesktopLayout
? DESKTOP_PAGE_STAGE_CLASS
: MOBILE_PAGE_STAGE_CLASS;
const mobileHomeContent: ReactNode = ( const mobileHomeContent: ReactNode = (
<div className={`${MOBILE_PAGE_STAGE_CLASS} platform-mobile-home-stage`}> <div className={`${MOBILE_PAGE_STAGE_CLASS} platform-mobile-home-stage`}>
@@ -967,6 +1058,51 @@ export function RpgEntryHomeView({
</div> </div>
); );
const categoryContent: ReactNode = (
<div className={categoryPageClass}>
<section className={`${PANEL_SURFACE_CLASS} px-4 py-3.5`}>
<SectionHeader title="分类" detail="按标签浏览" />
{isLoadingPlatform ? (
<EmptyShelf text="正在读取公开作品..." />
) : categoryGroups.length > 0 && activeCategoryGroup ? (
<>
<div className="flex min-w-0 gap-2 overflow-x-auto pb-1 scrollbar-hide">
{categoryGroups.map((group) => {
const active = group.tag === activeCategoryGroup.tag;
return (
<button
key={group.tag}
type="button"
onClick={() => setSelectedCategoryTag(group.tag)}
className={`platform-pill shrink-0 px-3 py-1.5 ${active ? 'platform-pill--warm' : 'platform-pill--neutral'}`}
>
{group.tag} · {group.entries.length}
</button>
);
})}
</div>
<div className="mt-4 grid grid-cols-2 gap-2.5 sm:gap-3 lg:grid-cols-3 xl:grid-cols-4">
{activeCategoryGroup.entries.map((entry) => (
<WorldCard
key={`${entry.ownerUserId}:${entry.profileId}:category:${activeCategoryGroup.tag}`}
entry={entry}
badge={activeCategoryGroup.tag}
metaLabel={entry.authorDisplayName}
onClick={() => onOpenGalleryDetail(entry)}
className="h-[15rem] w-full min-w-0 sm:h-[16rem]"
/>
))}
</div>
</>
) : (
<EmptyShelf text="公开广场暂时还没有可分类的作品。" />
)}
</section>
</div>
);
const createContent: ReactNode = createTabContent ?? ( const createContent: ReactNode = createTabContent ?? (
<div className={MOBILE_PAGE_STAGE_CLASS}> <div className={MOBILE_PAGE_STAGE_CLASS}>
<button <button
@@ -1554,11 +1690,14 @@ export function RpgEntryHomeView({
const tabContentById = { const tabContentById = {
home: isDesktopLayout ? desktopHomeContent : mobileHomeContent, home: isDesktopLayout ? desktopHomeContent : mobileHomeContent,
category: categoryContent,
create: createContent, create: createContent,
saves: savesContent, saves: savesContent,
profile: profileContent, profile: profileContent,
} satisfies Record<PlatformHomeTab, ReactNode>; } satisfies Record<PlatformHomeTab, ReactNode>;
const tabPanels = PLATFORM_HOME_TABS.map((tab) => ( const tabPanels = PLATFORM_HOME_TABS.filter((tab) =>
visibleTabs.includes(tab),
).map((tab) => (
<PlatformTabPanel key={tab} tab={tab} activeTab={activeTab}> <PlatformTabPanel key={tab} tab={tab} activeTab={activeTab}>
{tabContentById[tab]} {tabContentById[tab]}
</PlatformTabPanel> </PlatformTabPanel>
@@ -1582,31 +1721,19 @@ export function RpgEntryHomeView({
paddingBottom: 'calc(env(safe-area-inset-bottom) + 0.2rem)', paddingBottom: 'calc(env(safe-area-inset-bottom) + 0.2rem)',
}} }}
> >
<div className="platform-bottom-nav grid grid-cols-4"> <div
<PlatformTabButton className={`platform-bottom-nav grid ${visibleTabs.length === 5 ? 'grid-cols-5' : 'grid-cols-3'}`}
active={activeTab === 'home'} >
label="首页" {visibleTabs.map((tab) => (
icon={tabIcons.home} <PlatformTabButton
onClick={() => onTabChange('home')} key={tab}
/> active={activeTab === tab}
<PlatformTabButton label={tabLabels[tab]}
active={activeTab === 'create'} icon={tabIcons[tab]}
label="创作" emphasized={tab === 'create'}
icon={tabIcons.create} onClick={() => onTabChange(tab)}
onClick={() => onTabChange('create')} />
/> ))}
<PlatformTabButton
active={activeTab === 'saves'}
label="存档"
icon={tabIcons.saves}
onClick={() => onTabChange('saves')}
/>
<PlatformTabButton
active={activeTab === 'profile'}
label="我的"
icon={tabIcons.profile}
onClick={() => onTabChange('profile')}
/>
</div> </div>
</div> </div>
</div> </div>
@@ -1686,30 +1813,16 @@ export function RpgEntryHomeView({
<div className="mt-5 flex min-h-0 gap-5"> <div className="mt-5 flex min-h-0 gap-5">
<aside className="platform-desktop-rail flex w-[5.8rem] shrink-0 flex-col gap-3 p-3"> <aside className="platform-desktop-rail flex w-[5.8rem] shrink-0 flex-col gap-3 p-3">
<DesktopTabButton {visibleTabs.map((tab) => (
active={activeTab === 'home'} <DesktopTabButton
label="首页" key={tab}
icon={tabIcons.home} active={activeTab === tab}
onClick={() => onTabChange('home')} label={tabLabels[tab]}
/> icon={tabIcons[tab]}
<DesktopTabButton emphasized={tab === 'create'}
active={activeTab === 'create'} onClick={() => onTabChange(tab)}
label="创作" />
icon={tabIcons.create} ))}
onClick={() => onTabChange('create')}
/>
<DesktopTabButton
active={activeTab === 'saves'}
label="存档"
icon={tabIcons.saves}
onClick={() => onTabChange('saves')}
/>
<DesktopTabButton
active={activeTab === 'profile'}
label="我的"
icon={tabIcons.profile}
onClick={() => onTabChange('profile')}
/>
</aside> </aside>
<div className="platform-tab-panel-stack min-w-0 flex-1"> <div className="platform-tab-panel-stack min-w-0 flex-1">

View File

@@ -941,6 +941,29 @@ body {
box-shadow: var(--platform-bottom-nav-active-shadow); box-shadow: var(--platform-bottom-nav-active-shadow);
} }
.platform-bottom-nav__button--primary {
transform: translateY(-0.18rem);
color: var(--platform-text-strong);
}
.platform-bottom-nav__button--primary .platform-bottom-nav__icon-shell {
width: calc(var(--platform-bottom-nav-icon-shell-size) + 0.58rem);
height: calc(var(--platform-bottom-nav-icon-shell-size) + 0.58rem);
background: var(--platform-nav-active-fill);
box-shadow: var(--platform-nav-icon-active-shadow);
}
.platform-bottom-nav__button--primary .platform-bottom-nav__icon {
width: calc(var(--platform-bottom-nav-icon-size) + 0.18rem);
height: calc(var(--platform-bottom-nav-icon-size) + 0.18rem);
color: var(--platform-nav-item-icon-active-text);
}
.platform-bottom-nav__button--primary .platform-bottom-nav__label {
color: var(--platform-nav-item-text-active);
font-weight: 800;
}
.platform-bottom-nav__icon-shell, .platform-bottom-nav__icon-shell,
.platform-desktop-rail__icon-shell { .platform-desktop-rail__icon-shell {
display: flex; display: flex;
@@ -1098,8 +1121,9 @@ body {
display: none; display: none;
} }
.platform-bottom-nav { .platform-bottom-nav__button--primary .platform-bottom-nav__icon-shell {
grid-template-columns: repeat(4, minmax(0, 1fr)) !important; width: calc(var(--platform-bottom-nav-icon-shell-size) + 0.48rem);
height: calc(var(--platform-bottom-nav-icon-shell-size) + 0.48rem);
} }
} }
@@ -1216,6 +1240,24 @@ body {
var(--platform-nav-active-shadow); var(--platform-nav-active-shadow);
} }
.platform-desktop-rail__button--primary {
min-height: 5.85rem;
border-color: var(--platform-nav-active-border);
background: var(--platform-nav-active-fill);
box-shadow: var(--platform-nav-active-shadow);
}
.platform-desktop-rail__button--primary .platform-desktop-rail__icon-shell {
transform: scale(1.1);
background: var(--platform-nav-item-icon-active-fill);
box-shadow: var(--platform-nav-icon-active-shadow);
}
.platform-desktop-rail__button--primary .platform-desktop-rail__icon,
.platform-desktop-rail__button--primary .platform-desktop-rail__label {
color: var(--platform-nav-item-text-active);
}
.platform-desktop-panel { .platform-desktop-panel {
position: relative; position: relative;
overflow: hidden; overflow: hidden;

View File

@@ -7,9 +7,9 @@ import type {
AuthLoginMethod, AuthLoginMethod,
AuthLoginOptionsResponse, AuthLoginOptionsResponse,
AuthLogoutAllResponse, AuthLogoutAllResponse,
AuthMeResponse,
AuthPasswordChangeResponse, AuthPasswordChangeResponse,
AuthPasswordResetResponse, AuthPasswordResetResponse,
AuthMeResponse,
AuthPhoneChangeResponse, AuthPhoneChangeResponse,
AuthPhoneLoginResponse, AuthPhoneLoginResponse,
AuthPhoneSendCodeResponse, AuthPhoneSendCodeResponse,
@@ -18,15 +18,15 @@ import type {
AuthRiskBlockSummary, AuthRiskBlockSummary,
AuthSessionsResponse, AuthSessionsResponse,
AuthSessionSummary, AuthSessionSummary,
PublicUserSearchResponse,
AuthUser, AuthUser,
AuthWechatBindPhoneResponse, AuthWechatBindPhoneResponse,
AuthWechatStartResponse, AuthWechatStartResponse,
LogoutResponse, LogoutResponse,
PublicUserSearchResponse,
} from '../../packages/shared/src/contracts/auth'; } from '../../packages/shared/src/contracts/auth';
import { import {
type ApiRequestOptions,
ApiClientError, ApiClientError,
type ApiRequestOptions,
clearStoredAccessToken, clearStoredAccessToken,
clearStoredAutoAuthCredentials, clearStoredAutoAuthCredentials,
emitAuthStateChange, emitAuthStateChange,
@@ -71,10 +71,33 @@ const PUBLIC_AUTH_REQUEST_OPTIONS = {
skipRefresh: true, skipRefresh: true,
} satisfies ApiRequestOptions; } satisfies ApiRequestOptions;
const LAST_LOGIN_PHONE_STORAGE_KEY = 'genarrative:last-login-phone';
export function normalizePhoneInput(phoneInput: string) { export function normalizePhoneInput(phoneInput: string) {
return phoneInput.replace(/[^\d+]/gu, '').trim(); return phoneInput.replace(/[^\d+]/gu, '').trim();
} }
export function getStoredLastLoginPhone() {
if (typeof window === 'undefined') {
return '';
}
return window.localStorage.getItem(LAST_LOGIN_PHONE_STORAGE_KEY) ?? '';
}
export function setStoredLastLoginPhone(phone: string) {
if (typeof window === 'undefined') {
return;
}
const normalizedPhone = normalizePhoneInput(phone);
if (!normalizedPhone) {
return;
}
window.localStorage.setItem(LAST_LOGIN_PHONE_STORAGE_KEY, normalizedPhone);
}
export function getCaptchaChallengeFromError( export function getCaptchaChallengeFromError(
error: unknown, error: unknown,
): AuthCaptchaChallenge | null { ): AuthCaptchaChallenge | null {