1
This commit is contained in:
@@ -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 不新增规则说明类文案。
|
||||
- 原有“直接选择游戏创作模板,立刻进入对应的共创工作台。”说明在移动端隐藏,桌面端保留为辅助说明。
|
||||
- 锁定、可创建、正在开启等状态继续来自既有模板元数据或忙碌状态。
|
||||
|
||||
@@ -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,不新增后端逻辑。
|
||||
@@ -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_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):把模板依赖逐步迁成自定义世界自有设定层,并保证不破坏当前生成流程的优化方案。
|
||||
- [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):运行时物品生成系统重设计。
|
||||
- [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 游戏全剧情的工作流程与交付模板。
|
||||
|
||||
@@ -1217,8 +1217,9 @@ Phase 4 本轮已完成以下主链接线:
|
||||
- published works 明确输出 `canEnterWorld=true`
|
||||
4. 前端 Agent 结果页已开始消费服务端 Phase4 状态:
|
||||
- 结果页在 Agent 草稿未发布时把主 CTA 改成“发布并进入世界”
|
||||
- 结果页会展示服务端 preview source、publish blockers、warning 数量
|
||||
- 有 blocker 时会禁用“发布并进入世界”按钮,不再让前端继续假装可以直接进入世界
|
||||
- 结果页会消费服务端 gate 语义,但不再把 preview source 做成底部常驻提示
|
||||
- publish blockers 改为点击“发布并进入世界”时,通过独立面板提示
|
||||
- warning 数量仍可作为非阻断摘要展示
|
||||
5. `useRpgCreationEnterWorld.ts` 与 `RpgEntryFlowShellImpl.tsx` 已把 Agent 结果页进入世界主链改成:
|
||||
- 先 `sync_result_profile`
|
||||
- 再执行后端 `publish_world`
|
||||
|
||||
@@ -4,12 +4,13 @@
|
||||
|
||||
## 1. 目标边界
|
||||
|
||||
本次迭代开放账号密码登录、登录后修改密码、手机号验证码重置密码,但不开放密码注册。
|
||||
本次迭代开放手机号密码登录、登录后修改密码、手机号验证码重置密码,但不开放独立注册页面。
|
||||
|
||||
1. 新用户只能通过手机号验证码完成注册与首次登录。
|
||||
2. 已有用户可以在登录后设置或修改密码。
|
||||
3. 忘记密码时,只能通过已绑定手机号验证码重置密码。
|
||||
4. 密码登录只校验已存在且已设置密码的账号,不自动创建新账号。
|
||||
4. 密码登录只校验已存在且已设置密码的手机号账号,不自动创建新账号。
|
||||
5. 登录面板本地缓存最近一次成功登录的手机号,只用于回填手机号输入框,不缓存密码或验证码。
|
||||
|
||||
## 2. 接口设计
|
||||
|
||||
@@ -17,10 +18,11 @@
|
||||
|
||||
沿用现有 `POST /api/auth/entry`:
|
||||
|
||||
1. 请求字段:`username`、`password`。
|
||||
2. 用户不存在时返回 `401`,不创建账号。
|
||||
3. 用户存在但未设置密码时返回 `401`。
|
||||
4. 校验成功后签发 access token,并写入 refresh cookie。
|
||||
1. 请求字段沿用 `username`、`password`,但前端固定把手机号填入 `username`。
|
||||
2. 后端优先按标准手机号归一化后查找账号,兼容历史用户名只作为开发游客兜底能力。
|
||||
3. 手机号不存在时返回 `401`,不创建账号。
|
||||
4. 手机号存在但未设置密码时返回 `401`。
|
||||
5. 校验成功后签发 access token,并写入 refresh cookie。
|
||||
|
||||
### 2.2 修改密码
|
||||
|
||||
@@ -46,14 +48,24 @@
|
||||
|
||||
复用 `POST /api/auth/phone/send-code`,`scene` 增加 `reset_password`。
|
||||
|
||||
### 2.5 验证码注册/登录
|
||||
|
||||
复用现有 `POST /api/auth/phone/login`:
|
||||
|
||||
1. 请求字段:`phone`、`code`。
|
||||
2. 验证码校验成功后,若手机号已绑定账号,则直接完成登录。
|
||||
3. 验证码校验成功后,若手机号没有账号信息,则后端自动创建手机号账号,再完成登录。
|
||||
4. 自动创建账号默认不设置用户可用密码,用户后续可在账号设置或忘记密码流程设置密码。
|
||||
|
||||
## 3. 前端交互
|
||||
|
||||
登录弹窗拆成两个页签:
|
||||
登录弹窗不再拆独立注册页签:
|
||||
|
||||
1. `登录`:提供密码登录、手机号验证码登录、忘记密码入口。
|
||||
2. `注册`:只提供手机号验证码注册/登录,不提供账号密码注册。
|
||||
3. `忘记密码`:从登录页进入独立重置面板,不在当前表单下方展开。
|
||||
4. 账号设置面板提供密码修改入口;未设置密码的账号显示为设置密码。
|
||||
1. 面板直接展示手机号和密码输入,用于已设置密码账号登录。
|
||||
2. 登录按钮文本固定为 `注册/登录`,避免用户在登录和首次进入之间做页面切换。
|
||||
3. 忘记密码入口显示在登录按钮右下侧,点击后仍进入独立重置面板,不在当前表单下方展开。
|
||||
4. 同一面板保留手机号验证码注册/登录能力,用于新用户自动注册和已注册用户免密码登录。
|
||||
5. 账号设置面板提供密码修改入口;未设置密码的账号显示为设置密码。
|
||||
|
||||
## 4. 数据约束
|
||||
|
||||
@@ -63,4 +75,4 @@
|
||||
2. 微信待绑定账号默认没有用户可用密码。
|
||||
3. 只有用户显式修改或重置密码后,才允许密码登录。
|
||||
|
||||
后续迁移到 SpacetimeDB 表时,保持同一语义:密码哈希字段允许为空,密码登录 reducer 不承担注册能力。
|
||||
后续迁移到 SpacetimeDB 表时,保持同一语义:密码哈希字段允许为空,密码登录 reducer 不承担注册能力,验证码登录 reducer 承担“无账号则自动注册”的唯一注册入口。
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
|
||||
已改为:
|
||||
|
||||
补充修复:`RpgCreationResultViewImpl` 已补齐 `previewSourceLabel` props 解构,避免 Agent 结果页在渲染数据源提示时触发 `ReferenceError`。
|
||||
|
||||
```text
|
||||
Agent 结果页点击新增场景角色 / 新增场景
|
||||
-> RpgCreationResultView.onGenerateEntity
|
||||
|
||||
@@ -65,7 +65,7 @@ pub async fn password_entry(
|
||||
fn map_password_entry_error(error: PasswordEntryError) -> AppError {
|
||||
match error {
|
||||
PasswordEntryError::InvalidUsername => AppError::from_status(StatusCode::BAD_REQUEST)
|
||||
.with_message("用户名只允许 3 到 24 位字母、数字、下划线")
|
||||
.with_message("手机号格式不正确")
|
||||
.with_details(json!({
|
||||
"field": "username",
|
||||
})),
|
||||
@@ -80,10 +80,10 @@ fn map_password_entry_error(error: PasswordEntryError) -> AppError {
|
||||
"field": "username",
|
||||
})),
|
||||
PasswordEntryError::InvalidCredentials => {
|
||||
AppError::from_status(StatusCode::UNAUTHORIZED).with_message("用户名或密码错误")
|
||||
AppError::from_status(StatusCode::UNAUTHORIZED).with_message("手机号或密码错误")
|
||||
}
|
||||
PasswordEntryError::UserNotFound => {
|
||||
AppError::from_status(StatusCode::UNAUTHORIZED).with_message("用户名或密码错误")
|
||||
AppError::from_status(StatusCode::UNAUTHORIZED).with_message("手机号或密码错误")
|
||||
}
|
||||
PasswordEntryError::Store(_) | PasswordEntryError::PasswordHash(_) => {
|
||||
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_message(error.to_string())
|
||||
|
||||
@@ -475,24 +475,25 @@ impl PasswordEntryService {
|
||||
&self,
|
||||
input: PasswordEntryInput,
|
||||
) -> Result<PasswordEntryResult, PasswordEntryError> {
|
||||
let username = normalize_username(&input.username)?;
|
||||
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);
|
||||
}
|
||||
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 {
|
||||
user: existing_user.user,
|
||||
created: false,
|
||||
});
|
||||
return verify_stored_password_user(existing_user, &input.password).await;
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -1292,6 +1293,24 @@ impl InMemoryAuthStore {
|
||||
.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(
|
||||
&self,
|
||||
phone_number: PhoneNumberSnapshot,
|
||||
@@ -2220,6 +2239,27 @@ fn validate_password(password: &str) -> Result<(), PasswordEntryError> {
|
||||
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> {
|
||||
let verify_code = verify_code.trim();
|
||||
if verify_code.len() != SMS_CODE_LENGTH
|
||||
|
||||
@@ -15,11 +15,11 @@ import {
|
||||
import {
|
||||
type AuthAuditLogEntry,
|
||||
type AuthCaptchaChallenge,
|
||||
authEntry,
|
||||
type AuthLoginMethod,
|
||||
type AuthRiskBlockSummary,
|
||||
type AuthSessionSummary,
|
||||
type AuthUser,
|
||||
authEntry,
|
||||
bindWechatPhone,
|
||||
changePassword,
|
||||
changePhoneNumber,
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
resetPassword,
|
||||
revokeAuthSession,
|
||||
sendPhoneLoginCode,
|
||||
setStoredLastLoginPhone,
|
||||
startWechatLogin,
|
||||
} from '../../services/authService';
|
||||
import { AccountModal } from './AccountModal';
|
||||
@@ -694,6 +695,7 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
setError('');
|
||||
try {
|
||||
const nextUser = await loginWithPhoneCode(phone, code);
|
||||
setStoredLastLoginPhone(phone);
|
||||
setLoginCaptchaChallenge(null);
|
||||
activateReadyUser(nextUser);
|
||||
} catch (loginError) {
|
||||
@@ -711,6 +713,7 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
setError('');
|
||||
try {
|
||||
const nextUser = await authEntry(username, password);
|
||||
setStoredLastLoginPhone(username);
|
||||
activateReadyUser(nextUser);
|
||||
} catch (loginError) {
|
||||
setError(
|
||||
@@ -727,6 +730,7 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
setError('');
|
||||
try {
|
||||
const nextUser = await resetPassword(phone, code, newPassword);
|
||||
setStoredLastLoginPhone(phone);
|
||||
activateReadyUser(nextUser);
|
||||
} catch (resetError) {
|
||||
setError(
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
AuthCaptchaChallenge,
|
||||
AuthLoginMethod,
|
||||
} from '../../services/authService';
|
||||
import { getStoredLastLoginPhone } from '../../services/authService';
|
||||
import { CaptchaChallengeField } from './CaptchaChallengeField';
|
||||
|
||||
type SmsScene = 'login' | 'reset_password';
|
||||
@@ -57,11 +58,9 @@ export function LoginScreen({
|
||||
onResetPassword,
|
||||
onStartWechatLogin,
|
||||
}: LoginScreenProps) {
|
||||
const [activeTab, setActiveTab] = useState<'login' | 'register'>('login');
|
||||
const [isResetPanelOpen, setIsResetPanelOpen] = useState(false);
|
||||
const [username, setUsername] = useState('');
|
||||
const [phone, setPhone] = useState(() => getStoredLastLoginPhone());
|
||||
const [password, setPassword] = useState('');
|
||||
const [phone, setPhone] = useState('');
|
||||
const [code, setCode] = useState('');
|
||||
const [resetPhone, setResetPhone] = 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="grid grid-cols-2 gap-2 rounded-full bg-[var(--platform-subpanel-bg)] p-1">
|
||||
<TabButton
|
||||
active={activeTab === 'login'}
|
||||
label="登录"
|
||||
onClick={() => setActiveTab('login')}
|
||||
/>
|
||||
<TabButton
|
||||
active={activeTab === 'register'}
|
||||
label="注册"
|
||||
onClick={() => setActiveTab('register')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{activeTab === 'login' ? (
|
||||
{passwordLoginEnabled ? (
|
||||
<form
|
||||
className="flex flex-col gap-4"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
if (!passwordLoginEnabled) {
|
||||
return;
|
||||
}
|
||||
void onPasswordSubmit(username, password);
|
||||
void onPasswordSubmit(phone, password);
|
||||
}}
|
||||
>
|
||||
{passwordLoginEnabled ? (
|
||||
<>
|
||||
<label className="grid gap-2 text-sm text-[var(--platform-text-base)]">
|
||||
<span>账号</span>
|
||||
<input
|
||||
className="platform-input"
|
||||
autoComplete="username"
|
||||
value={username}
|
||||
onChange={(event) => setUsername(event.target.value)}
|
||||
placeholder="用户名"
|
||||
/>
|
||||
</label>
|
||||
<label className="grid gap-2 text-sm text-[var(--platform-text-base)]">
|
||||
<span>密码</span>
|
||||
<input
|
||||
className="platform-input"
|
||||
autoComplete="current-password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
placeholder="输入密码"
|
||||
/>
|
||||
</label>
|
||||
</>
|
||||
) : null}
|
||||
<label className="grid gap-2 text-sm text-[var(--platform-text-base)]">
|
||||
<span>手机号</span>
|
||||
<input
|
||||
className="platform-input"
|
||||
autoComplete="tel"
|
||||
inputMode="numeric"
|
||||
value={phone}
|
||||
onChange={(event) => setPhone(event.target.value)}
|
||||
placeholder="13800000000"
|
||||
/>
|
||||
</label>
|
||||
<label className="grid gap-2 text-sm text-[var(--platform-text-base)]">
|
||||
<span>密码</span>
|
||||
<input
|
||||
className="platform-input"
|
||||
autoComplete="current-password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
placeholder="输入密码"
|
||||
/>
|
||||
</label>
|
||||
|
||||
{error ? <ErrorBanner message={error} /> : null}
|
||||
|
||||
{passwordLoginEnabled ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
<button
|
||||
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"
|
||||
>
|
||||
{loggingIn ? '登录中' : '登录'}
|
||||
{loggingIn ? '登录中' : '注册/登录'}
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="self-center text-sm text-[var(--platform-accent)]"
|
||||
onClick={() => setIsResetPanelOpen(true)}
|
||||
>
|
||||
忘记密码
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="self-end text-sm text-[var(--platform-accent)]"
|
||||
onClick={() => setIsResetPanelOpen(true)}
|
||||
>
|
||||
忘记密码
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{wechatLoginEnabled ? (
|
||||
<WechatButton
|
||||
@@ -232,7 +211,9 @@ export function LoginScreen({
|
||||
/>
|
||||
) : null}
|
||||
</form>
|
||||
) : (
|
||||
) : null}
|
||||
|
||||
{phoneLoginEnabled ? (
|
||||
<PhoneCodeForm
|
||||
phone={phone}
|
||||
code={code}
|
||||
@@ -243,8 +224,9 @@ export function LoginScreen({
|
||||
loggingIn={loggingIn}
|
||||
error={error}
|
||||
hint={hint}
|
||||
submitLabel="注册并登录"
|
||||
submitLabel="注册/登录"
|
||||
enabled={phoneLoginEnabled}
|
||||
showPhoneField={!passwordLoginEnabled}
|
||||
onPhoneChange={setPhone}
|
||||
onCodeChange={setCode}
|
||||
onCaptchaAnswerChange={setCaptchaAnswer}
|
||||
@@ -262,7 +244,7 @@ export function LoginScreen({
|
||||
}}
|
||||
onSubmit={() => onPhoneSubmit(phone, code)}
|
||||
/>
|
||||
)}
|
||||
) : null}
|
||||
|
||||
{!passwordLoginEnabled && !phoneLoginEnabled && !wechatLoginEnabled ? (
|
||||
<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({
|
||||
phone,
|
||||
code,
|
||||
@@ -312,6 +270,7 @@ function PhoneCodeForm({
|
||||
hint,
|
||||
submitLabel,
|
||||
enabled,
|
||||
showPhoneField,
|
||||
onPhoneChange,
|
||||
onCodeChange,
|
||||
onCaptchaAnswerChange,
|
||||
@@ -329,6 +288,7 @@ function PhoneCodeForm({
|
||||
hint: string;
|
||||
submitLabel: string;
|
||||
enabled: boolean;
|
||||
showPhoneField: boolean;
|
||||
onPhoneChange: (value: string) => void;
|
||||
onCodeChange: (value: string) => void;
|
||||
onCaptchaAnswerChange: (value: string) => void;
|
||||
@@ -347,17 +307,19 @@ function PhoneCodeForm({
|
||||
void onSubmit();
|
||||
}}
|
||||
>
|
||||
<label className="grid gap-2 text-sm text-[var(--platform-text-base)]">
|
||||
<span>手机号</span>
|
||||
<input
|
||||
className="platform-input"
|
||||
autoComplete="tel"
|
||||
inputMode="numeric"
|
||||
value={phone}
|
||||
onChange={(event) => onPhoneChange(event.target.value)}
|
||||
placeholder="13800000000"
|
||||
/>
|
||||
</label>
|
||||
{showPhoneField ? (
|
||||
<label className="grid gap-2 text-sm text-[var(--platform-text-base)]">
|
||||
<span>手机号</span>
|
||||
<input
|
||||
className="platform-input"
|
||||
autoComplete="tel"
|
||||
inputMode="numeric"
|
||||
value={phone}
|
||||
onChange={(event) => onPhoneChange(event.target.value)}
|
||||
placeholder="13800000000"
|
||||
/>
|
||||
</label>
|
||||
) : null}
|
||||
|
||||
<label className="grid gap-2 text-sm text-[var(--platform-text-base)]">
|
||||
<span>验证码</span>
|
||||
|
||||
@@ -17,19 +17,23 @@ export function CustomWorldCreationStartCard({
|
||||
onCreateType,
|
||||
}: CustomWorldCreationStartCardProps) {
|
||||
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="relative z-10 space-y-4">
|
||||
<div>
|
||||
<div className="text-2xl font-black text-white sm:text-3xl">
|
||||
<div className="relative z-10 space-y-2.5 sm:space-y-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="text-xl font-black leading-none text-white sm:text-3xl">
|
||||
新建作品
|
||||
</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>
|
||||
<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 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) => {
|
||||
const disabled = item.locked || busy;
|
||||
|
||||
@@ -41,15 +45,15 @@ export function CustomWorldCreationStartCard({
|
||||
onClick={() => {
|
||||
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
|
||||
? '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'
|
||||
} ${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
|
||||
className={`platform-pill px-3 ${
|
||||
className={`platform-pill px-2.5 text-xs sm:px-3 sm:text-sm ${
|
||||
item.locked
|
||||
? 'platform-pill--neutral text-[var(--platform-text-soft)]'
|
||||
: 'platform-pill--neutral border-white/30 bg-white/18 text-white'
|
||||
@@ -64,11 +68,11 @@ export function CustomWorldCreationStartCard({
|
||||
)}
|
||||
</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}
|
||||
</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'
|
||||
}`}
|
||||
>
|
||||
@@ -80,7 +84,7 @@ export function CustomWorldCreationStartCard({
|
||||
</div>
|
||||
|
||||
{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}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -201,23 +201,6 @@ export function RpgCreationResultView({
|
||||
{error}
|
||||
</div>
|
||||
) : 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 &&
|
||||
compactAgentResultMode &&
|
||||
publishBlockers.length <= 0 &&
|
||||
|
||||
@@ -1889,7 +1889,7 @@ test('agent result view does not keep legacy publish blockers when preview uses
|
||||
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();
|
||||
|
||||
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' &&
|
||||
payload?.action === 'sync_result_profile',
|
||||
),
|
||||
).toBe(false);
|
||||
).toBe(true);
|
||||
expect(screen.queryByText('世界档案')).toBeNull();
|
||||
});
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
Search,
|
||||
Settings,
|
||||
Sparkles,
|
||||
Tags,
|
||||
Ticket,
|
||||
UserPlus,
|
||||
UserRound,
|
||||
@@ -50,7 +51,7 @@ import {
|
||||
resolvePlatformWorldLeadPortrait,
|
||||
} from './rpgEntryWorldPresentation';
|
||||
|
||||
export type PlatformHomeTab = 'home' | 'create' | 'saves' | 'profile';
|
||||
export type PlatformHomeTab = 'home' | 'category' | 'create' | 'saves' | 'profile';
|
||||
export interface RpgEntryHomeViewProps {
|
||||
activeTab: PlatformHomeTab;
|
||||
onTabChange: (tab: PlatformHomeTab) => void;
|
||||
@@ -96,6 +97,7 @@ const DESKTOP_PAGE_STAGE_CLASS =
|
||||
const DESKTOP_LAYOUT_QUERY = '(min-width: 1024px)';
|
||||
const PLATFORM_HOME_TABS: PlatformHomeTab[] = [
|
||||
'home',
|
||||
'category',
|
||||
'create',
|
||||
'saves',
|
||||
'profile',
|
||||
@@ -470,17 +472,19 @@ function PlatformTabButton({
|
||||
label,
|
||||
icon: Icon,
|
||||
onClick,
|
||||
emphasized = false,
|
||||
}: {
|
||||
active: boolean;
|
||||
label: string;
|
||||
icon: ComponentType<{ className?: string }>;
|
||||
onClick: () => void;
|
||||
emphasized?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
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__icon-shell">
|
||||
@@ -497,17 +501,19 @@ function DesktopTabButton({
|
||||
label,
|
||||
icon: Icon,
|
||||
onClick,
|
||||
emphasized = false,
|
||||
}: {
|
||||
active: boolean;
|
||||
label: string;
|
||||
icon: ComponentType<{ className?: string }>;
|
||||
onClick: () => void;
|
||||
emphasized?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
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">
|
||||
<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) {
|
||||
if (!value) {
|
||||
return '刚刚保存';
|
||||
@@ -814,12 +855,30 @@ export function RpgEntryHomeView({
|
||||
}: RpgEntryHomeViewProps) {
|
||||
const authUi = useAuthUi();
|
||||
const [desktopSearchKeyword, setDesktopSearchKeyword] = useState('');
|
||||
const [selectedCategoryTag, setSelectedCategoryTag] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const isAuthenticated = Boolean(authUi?.user);
|
||||
const isDesktopLayout = usePlatformDesktopLayout();
|
||||
const featuredShelf = useMemo(
|
||||
() => featuredEntries.slice(0, 6),
|
||||
[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 =
|
||||
savedSnapshot?.gameState.customWorldProfile?.name ??
|
||||
savedSnapshot?.gameState.currentScenePreset?.name ??
|
||||
@@ -842,10 +901,39 @@ export function RpgEntryHomeView({
|
||||
const playedWorkCount = profileDashboard?.playedWorldCount ?? 0;
|
||||
const tabIcons = {
|
||||
home: House,
|
||||
category: Tags,
|
||||
create: Sparkles,
|
||||
saves: Archive,
|
||||
profile: UserRound,
|
||||
} 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 = () => {
|
||||
if (authUi?.user) {
|
||||
authUi.openAccountModal();
|
||||
@@ -873,6 +961,9 @@ export function RpgEntryHomeView({
|
||||
const desktopFeaturedGrid = featuredShelf.slice(0, 4);
|
||||
const desktopReleaseGrid = latestEntries.slice(0, 6);
|
||||
const desktopLibraryPreview = myEntries.slice(0, 2);
|
||||
const categoryPageClass = isDesktopLayout
|
||||
? DESKTOP_PAGE_STAGE_CLASS
|
||||
: MOBILE_PAGE_STAGE_CLASS;
|
||||
|
||||
const mobileHomeContent: ReactNode = (
|
||||
<div className={`${MOBILE_PAGE_STAGE_CLASS} platform-mobile-home-stage`}>
|
||||
@@ -967,6 +1058,51 @@ export function RpgEntryHomeView({
|
||||
</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 ?? (
|
||||
<div className={MOBILE_PAGE_STAGE_CLASS}>
|
||||
<button
|
||||
@@ -1554,11 +1690,14 @@ export function RpgEntryHomeView({
|
||||
|
||||
const tabContentById = {
|
||||
home: isDesktopLayout ? desktopHomeContent : mobileHomeContent,
|
||||
category: categoryContent,
|
||||
create: createContent,
|
||||
saves: savesContent,
|
||||
profile: profileContent,
|
||||
} 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}>
|
||||
{tabContentById[tab]}
|
||||
</PlatformTabPanel>
|
||||
@@ -1582,31 +1721,19 @@ export function RpgEntryHomeView({
|
||||
paddingBottom: 'calc(env(safe-area-inset-bottom) + 0.2rem)',
|
||||
}}
|
||||
>
|
||||
<div className="platform-bottom-nav grid grid-cols-4">
|
||||
<PlatformTabButton
|
||||
active={activeTab === 'home'}
|
||||
label="首页"
|
||||
icon={tabIcons.home}
|
||||
onClick={() => onTabChange('home')}
|
||||
/>
|
||||
<PlatformTabButton
|
||||
active={activeTab === 'create'}
|
||||
label="创作"
|
||||
icon={tabIcons.create}
|
||||
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
|
||||
className={`platform-bottom-nav grid ${visibleTabs.length === 5 ? 'grid-cols-5' : 'grid-cols-3'}`}
|
||||
>
|
||||
{visibleTabs.map((tab) => (
|
||||
<PlatformTabButton
|
||||
key={tab}
|
||||
active={activeTab === tab}
|
||||
label={tabLabels[tab]}
|
||||
icon={tabIcons[tab]}
|
||||
emphasized={tab === 'create'}
|
||||
onClick={() => onTabChange(tab)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1686,30 +1813,16 @@ export function RpgEntryHomeView({
|
||||
|
||||
<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">
|
||||
<DesktopTabButton
|
||||
active={activeTab === 'home'}
|
||||
label="首页"
|
||||
icon={tabIcons.home}
|
||||
onClick={() => onTabChange('home')}
|
||||
/>
|
||||
<DesktopTabButton
|
||||
active={activeTab === 'create'}
|
||||
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')}
|
||||
/>
|
||||
{visibleTabs.map((tab) => (
|
||||
<DesktopTabButton
|
||||
key={tab}
|
||||
active={activeTab === tab}
|
||||
label={tabLabels[tab]}
|
||||
icon={tabIcons[tab]}
|
||||
emphasized={tab === 'create'}
|
||||
onClick={() => onTabChange(tab)}
|
||||
/>
|
||||
))}
|
||||
</aside>
|
||||
|
||||
<div className="platform-tab-panel-stack min-w-0 flex-1">
|
||||
|
||||
@@ -941,6 +941,29 @@ body {
|
||||
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-desktop-rail__icon-shell {
|
||||
display: flex;
|
||||
@@ -1098,8 +1121,9 @@ body {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.platform-bottom-nav {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr)) !important;
|
||||
.platform-bottom-nav__button--primary .platform-bottom-nav__icon-shell {
|
||||
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);
|
||||
}
|
||||
|
||||
.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 {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
@@ -7,9 +7,9 @@ import type {
|
||||
AuthLoginMethod,
|
||||
AuthLoginOptionsResponse,
|
||||
AuthLogoutAllResponse,
|
||||
AuthMeResponse,
|
||||
AuthPasswordChangeResponse,
|
||||
AuthPasswordResetResponse,
|
||||
AuthMeResponse,
|
||||
AuthPhoneChangeResponse,
|
||||
AuthPhoneLoginResponse,
|
||||
AuthPhoneSendCodeResponse,
|
||||
@@ -18,15 +18,15 @@ import type {
|
||||
AuthRiskBlockSummary,
|
||||
AuthSessionsResponse,
|
||||
AuthSessionSummary,
|
||||
PublicUserSearchResponse,
|
||||
AuthUser,
|
||||
AuthWechatBindPhoneResponse,
|
||||
AuthWechatStartResponse,
|
||||
LogoutResponse,
|
||||
PublicUserSearchResponse,
|
||||
} from '../../packages/shared/src/contracts/auth';
|
||||
import {
|
||||
type ApiRequestOptions,
|
||||
ApiClientError,
|
||||
type ApiRequestOptions,
|
||||
clearStoredAccessToken,
|
||||
clearStoredAutoAuthCredentials,
|
||||
emitAuthStateChange,
|
||||
@@ -71,10 +71,33 @@ const PUBLIC_AUTH_REQUEST_OPTIONS = {
|
||||
skipRefresh: true,
|
||||
} satisfies ApiRequestOptions;
|
||||
|
||||
const LAST_LOGIN_PHONE_STORAGE_KEY = 'genarrative:last-login-phone';
|
||||
|
||||
export function normalizePhoneInput(phoneInput: string) {
|
||||
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(
|
||||
error: unknown,
|
||||
): AuthCaptchaChallenge | null {
|
||||
|
||||
Reference in New Issue
Block a user