Fix Issue 549 #573
@@ -1075,7 +1075,7 @@ export interface AdminRechargeRefundActionResponse {
|
||||
export interface AdminWalletRestrictionResponse {
|
||||
wallet: AdminProfileWalletPayload;
|
||||
}
|
||||
export type AdminAgcAgentMode = 'codex' | 'cc';
|
||||
export type AdminAgcAgentMode = 'codex' | 'cc' | 'anthropic';
|
||||
|
||||
export interface AdminAgcModel {
|
||||
id: string;
|
||||
|
||||
@@ -226,6 +226,7 @@ export function AdminAgcModelsPage({
|
||||
>
|
||||
<option value="codex">Codex</option>
|
||||
<option value="cc">Claude Code</option>
|
||||
<option value="anthropic">Anthropic 协议</option>
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
|
||||
@@ -140,6 +140,7 @@ const allowedUncalledTauriCommands = [
|
||||
// 里以字面量出现,静态扫描仍按共享注册表核验。
|
||||
'read_profile_recharge_center',
|
||||
'read_profile_wallet_ledger',
|
||||
'read_profile_referral_invite_center',
|
||||
'create_profile_recharge_order',
|
||||
'confirm_wechat_profile_recharge_order',
|
||||
'redeem_profile_reward_code',
|
||||
|
||||
@@ -8,10 +8,12 @@ use crate::http_client::agc_main_site_client_builder;
|
||||
use crate::platform_session::{current_platform_session, PlatformSessionSnapshot};
|
||||
use reqwest::{Method, StatusCode};
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
use shared_contracts::runtime::{
|
||||
ConfirmWechatProfileRechargeOrderResponse, CreateProfileRechargeOrderResponse,
|
||||
ProfileRechargeCenterResponse, ProfileWalletLedgerResponse, RedeemProfileRewardCodeResponse,
|
||||
ProfileRechargeCenterResponse, ProfileReferralInviteCenterResponse,
|
||||
ProfileWalletLedgerResponse, RedeemProfileRewardCodeResponse,
|
||||
};
|
||||
use std::time::Duration;
|
||||
use url::Url;
|
||||
@@ -103,6 +105,29 @@ fn unwrap_envelope(body: &str, fallback: &str) -> Result<Value, String> {
|
||||
Ok(value.get("data").cloned().unwrap_or(value))
|
||||
}
|
||||
|
||||
/// 客户端邀请码弹层需要的完整数据。
|
||||
///
|
||||
/// 后端只返回站内相对路径(`inviteLinkPath`),客户端要拿它当分享链接用,
|
||||
/// 所以这里用当前登录站点 origin 补全成完整 URL 再交给渲染层。
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct ClientProfileReferralInviteCenter {
|
||||
pub(crate) center: ProfileReferralInviteCenterResponse,
|
||||
pub(crate) invite_url: String,
|
||||
}
|
||||
|
||||
fn resolve_invite_url(api_base_url: &str, invite_link_path: &str) -> String {
|
||||
if invite_link_path.trim().is_empty() {
|
||||
return api_base_url.trim_end_matches('/').to_string();
|
||||
}
|
||||
match Url::parse(&format!("{}/", api_base_url.trim_end_matches('/')))
|
||||
.and_then(|base| base.join(invite_link_path))
|
||||
{
|
||||
Ok(url) => url.to_string(),
|
||||
Err(_) => invite_link_path.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn request_json<T: DeserializeOwned>(
|
||||
client: &reqwest::Client,
|
||||
snapshot: &PlatformSessionSnapshot,
|
||||
@@ -171,6 +196,24 @@ pub(crate) async fn read_profile_wallet_ledger() -> Result<ProfileWalletLedgerRe
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn read_profile_referral_invite_center(
|
||||
) -> Result<ClientProfileReferralInviteCenter, String> {
|
||||
let snapshot = require_session()?;
|
||||
let client = build_client()?;
|
||||
let center: ProfileReferralInviteCenterResponse = request_json(
|
||||
&client,
|
||||
&snapshot,
|
||||
Method::GET,
|
||||
&["api", "profile", "referrals", "invite-center"],
|
||||
None,
|
||||
"读取邀请码失败",
|
||||
)
|
||||
.await?;
|
||||
let invite_url = resolve_invite_url(&snapshot.api_base_url, ¢er.invite_link_path);
|
||||
Ok(ClientProfileReferralInviteCenter { center, invite_url })
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn create_profile_recharge_order(
|
||||
product_id: String,
|
||||
@@ -314,4 +357,20 @@ mod tests {
|
||||
assert!(bounded_business_id(&"x".repeat(MAX_BUSINESS_ID_CHARS + 1), "订单").is_err());
|
||||
assert!(bounded_business_id("bad\nid", "订单").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invite_link_resolves_against_login_origin() {
|
||||
assert_eq!(
|
||||
resolve_invite_url("https://dev.genarrative.world", "/?inviteCode=SY12345678"),
|
||||
"https://dev.genarrative.world/?inviteCode=SY12345678"
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_invite_url("https://dev.genarrative.world/", "invite/SY12345678"),
|
||||
"https://dev.genarrative.world/invite/SY12345678"
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_invite_url("https://dev.genarrative.world", ""),
|
||||
"https://dev.genarrative.world"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -352,7 +352,21 @@ fn copy_env(command: &mut tokio::process::Command, name: &str) {
|
||||
fn claude_base_url(llm: Option<&GameCreatorLlmConfig>) -> Option<String> {
|
||||
std::env::var("ANTHROPIC_BASE_URL")
|
||||
.ok()
|
||||
.or_else(|| llm.map(|value| value.base_url.trim_end_matches('/').to_string()))
|
||||
.or_else(|| {
|
||||
let llm = llm?;
|
||||
// Official models never talk to the Router directly: the account
|
||||
// Router credential lives server-side, so the Claude sidecar goes
|
||||
// through the platform gateway and appends `/v1/messages` itself.
|
||||
if !llm.custom_enabled {
|
||||
if let Some(session) = crate::platform_session::current_platform_session() {
|
||||
return Some(format!(
|
||||
"{}/api/llm/anthropic",
|
||||
session.api_base_url.trim_end_matches('/')
|
||||
));
|
||||
}
|
||||
}
|
||||
Some(llm.base_url.trim_end_matches('/').to_string())
|
||||
})
|
||||
.map(|value| {
|
||||
value
|
||||
.trim_end_matches('/')
|
||||
|
||||
@@ -2351,7 +2351,7 @@ impl CodexAppServerConnection {
|
||||
)
|
||||
})?;
|
||||
effective_llm.base_url =
|
||||
format!("{}/api/llm", session.api_base_url.trim_end_matches('/'));
|
||||
format!("{}/api/llm/v1", session.api_base_url.trim_end_matches('/'));
|
||||
effective_llm.api_key.clear();
|
||||
// Only the platform catalog identifier reaches Codex. api-server
|
||||
// validates it and resolves the actual upstream model.
|
||||
@@ -2619,7 +2619,7 @@ impl CodexAppServerConnection {
|
||||
..
|
||||
} => (
|
||||
Some((
|
||||
format!("{}/api/llm", api_base_url.trim_end_matches('/')),
|
||||
format!("{}/api/llm/v1", api_base_url.trim_end_matches('/')),
|
||||
access_token.clone(),
|
||||
)),
|
||||
true,
|
||||
|
||||
@@ -1098,7 +1098,8 @@ fn select_game_creator_model_inner(
|
||||
if let Some(agent_mode) = catalog_agent_mode {
|
||||
config.agent_mode = match agent_mode.trim() {
|
||||
"codex" => GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER.to_string(),
|
||||
"cc" => GAME_CREATOR_AGENT_MODE_CLAUDE_CODE_CLI.to_string(),
|
||||
// `cc` 是 Claude 执行器的历史别名,`anthropic` 是同一执行器的显式协议值。
|
||||
"cc" | "anthropic" => GAME_CREATOR_AGENT_MODE_CLAUDE_CODE_CLI.to_string(),
|
||||
value => return Err(format!("模型 Agent 模式无效:{value}")),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -222,7 +222,8 @@ fn build_game_creator_official_platform_llm_config(
|
||||
if api_base_url.is_empty() {
|
||||
return Err("登录态缺少 API Server 地址".to_string());
|
||||
}
|
||||
let proxy_base_url = format!("{api_base_url}/api/llm");
|
||||
// baseUrl 不带路由与版本段,OpenAI 协议由 platform-llm 自己拼 `v1/<op>`。
|
||||
let proxy_base_url = format!("{api_base_url}/api/llm/v1");
|
||||
LlmConfig::new(
|
||||
LlmProvider::OpenAiCompatible,
|
||||
proxy_base_url,
|
||||
|
||||
@@ -638,6 +638,7 @@ pub(super) fn run() {
|
||||
set_client_server_selection,
|
||||
read_profile_recharge_center,
|
||||
read_profile_wallet_ledger,
|
||||
read_profile_referral_invite_center,
|
||||
create_profile_recharge_order,
|
||||
confirm_wechat_profile_recharge_order,
|
||||
redeem_profile_reward_code,
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { PlatformProfileReferralModal } from '../../../../../packages/shared/src/components/PlatformProfileReferralModal';
|
||||
import type {
|
||||
AccountReferralController,
|
||||
AccountReferralPanel,
|
||||
} from './useAccountReferral';
|
||||
|
||||
/** 客户端账号菜单的入口文案是「邀请码 / 玩家社区」,弹层标题跟着入口走。 */
|
||||
const CLIENT_REFERRAL_PANEL_TITLE: Record<AccountReferralPanel, string> = {
|
||||
invite: '邀请码',
|
||||
community: '玩家社区',
|
||||
};
|
||||
|
||||
/**
|
||||
* 账号菜单的邀请码 / 玩家社区弹层。
|
||||
*
|
||||
* 弹层实现与主站「我的」页签共用 `packages/shared` 里的同一份
|
||||
* `PlatformProfileReferralModal`(与充值、泥点账单同口径);这里只做宿主适配:
|
||||
* 把 Tauri typed command 读到的邀请中心、会话 origin 补全后的邀请链接、宿主剪贴板
|
||||
* 复制回显和面板开关接到共享组件上,不再另写一份客户端专属弹层。
|
||||
*/
|
||||
export function AccountReferralDialogs({
|
||||
controller,
|
||||
}: {
|
||||
controller: AccountReferralController;
|
||||
}) {
|
||||
if (!controller.panel) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<PlatformProfileReferralModal
|
||||
panel={controller.panel}
|
||||
title={CLIENT_REFERRAL_PANEL_TITLE[controller.panel]}
|
||||
center={controller.center}
|
||||
inviteLinkUrl={controller.inviteUrl}
|
||||
isLoading={controller.loading}
|
||||
isSubmittingRedeem={false}
|
||||
redeemCode=""
|
||||
copyInviteLinkState={controller.copyInviteLinkState}
|
||||
copyInviteCodeState={controller.copyInviteCodeState}
|
||||
error={controller.error}
|
||||
success={null}
|
||||
onClose={controller.closePanel}
|
||||
onCopyInviteLink={controller.copyInviteLink}
|
||||
onCopyInviteCode={controller.copyInviteCode}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -41,6 +41,7 @@ import { useDirectActiveTurns } from '../agent-runtime/directActiveTurns';
|
||||
import { projectPathsMatchForInvalidation } from '../project-summary/projectPath';
|
||||
import { RuntimeConfigDialog } from '../runtime-config/RuntimeConfigDialog';
|
||||
import { useTemplateLibrary } from '../template-library/useTemplateLibrary';
|
||||
import { AccountReferralDialogs } from './AccountReferral';
|
||||
import { AccountWalletBar, AccountWalletDialogs } from './AccountWallet';
|
||||
import {
|
||||
type LauncherNotice,
|
||||
@@ -52,6 +53,7 @@ import type {
|
||||
WorkspaceLauncherShellProps,
|
||||
} from './model';
|
||||
import { NonEmptyProjectDialog, ProjectsPage } from './ProjectCreation';
|
||||
import { useAccountReferral } from './useAccountReferral';
|
||||
import { useAccountWallet } from './useAccountWallet';
|
||||
import {
|
||||
DESIGN_ARTIFACTS_BUILD_PROMPT,
|
||||
@@ -73,6 +75,7 @@ export function WorkspaceLauncherShell({
|
||||
setActiveProjectRuns,
|
||||
} = useWindowChrome();
|
||||
const accountWallet = useAccountWallet(currentUser.id);
|
||||
const accountReferral = useAccountReferral(currentUser.id);
|
||||
const [status, setStatus] = useState('');
|
||||
const recentProjects = useRecentProjects(setStatus);
|
||||
const { recentProjectRows, rememberRecentWorkspace } = recentProjects;
|
||||
@@ -741,6 +744,11 @@ export function WorkspaceLauncherShell({
|
||||
}}
|
||||
onNoticeRequest={showLauncherNotice}
|
||||
onRechargeRequest={accountWallet.openRecharge}
|
||||
onReferralRequest={(panel) =>
|
||||
panel === 'invite'
|
||||
? accountReferral.openInvite()
|
||||
: accountReferral.openCommunity()
|
||||
}
|
||||
onRuntimeConfigOpen={() => setRuntimeConfigOpen(true)}
|
||||
onViewChange={navigateLauncher}
|
||||
/>
|
||||
@@ -963,6 +971,7 @@ export function WorkspaceLauncherShell({
|
||||
/>
|
||||
) : null}
|
||||
<AccountWalletDialogs controller={accountWallet} />
|
||||
<AccountReferralDialogs controller={accountReferral} />
|
||||
{walletSlot ? (
|
||||
createPortal(
|
||||
<AccountWalletBar controller={accountWallet} />,
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
import { writeText } from '@tauri-apps/plugin-clipboard-manager';
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
|
||||
import type { PlatformProfileReferralCopyState } from '../../../../../packages/shared/src/components/PlatformProfileReferralModal';
|
||||
import type { ProfileReferralInviteCenterResponse } from '../../../../../packages/shared/src/contracts/runtime';
|
||||
import { getClientProfileReferralInviteCenter } from '../../services/accountHost';
|
||||
|
||||
export type AccountReferralPanel = 'invite' | 'community';
|
||||
|
||||
/**
|
||||
* 客户端账号菜单的邀请码 / 玩家社区弹层控制器。
|
||||
*
|
||||
* 邀请码是账号级数据,和泥点余额一样按登录账号隔离:切换账号就丢弃上一位用户的
|
||||
* 邀请中心和复制回显;读取过程中换账号时,晚到的响应直接作废。
|
||||
*/
|
||||
export function useAccountReferral(currentUserId: string) {
|
||||
const [panel, setPanel] = useState<AccountReferralPanel | null>(null);
|
||||
const [center, setCenter] =
|
||||
useState<ProfileReferralInviteCenterResponse | null>(null);
|
||||
const [inviteUrl, setInviteUrl] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [copyInviteLinkState, setCopyInviteLinkState] =
|
||||
useState<PlatformProfileReferralCopyState>('idle');
|
||||
const [copyInviteCodeState, setCopyInviteCodeState] =
|
||||
useState<PlatformProfileReferralCopyState>('idle');
|
||||
const lifecycleRef = useRef(0);
|
||||
const currentUserIdRef = useRef(currentUserId);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
currentUserIdRef.current = currentUserId;
|
||||
lifecycleRef.current += 1;
|
||||
}, [currentUserId]);
|
||||
|
||||
useEffect(() => {
|
||||
lifecycleRef.current += 1;
|
||||
setPanel(null);
|
||||
setCenter(null);
|
||||
setInviteUrl(null);
|
||||
setLoading(false);
|
||||
setError(null);
|
||||
setCopyInviteLinkState('idle');
|
||||
setCopyInviteCodeState('idle');
|
||||
}, [currentUserId]);
|
||||
|
||||
async function loadInviteCenter(lifecycle: number, owner: string) {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await getClientProfileReferralInviteCenter();
|
||||
if (
|
||||
lifecycleRef.current !== lifecycle ||
|
||||
currentUserIdRef.current !== owner
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setCenter(response.center);
|
||||
setInviteUrl(response.inviteUrl);
|
||||
} catch (loadError) {
|
||||
if (
|
||||
lifecycleRef.current !== lifecycle ||
|
||||
currentUserIdRef.current !== owner
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setCenter(null);
|
||||
setInviteUrl(null);
|
||||
setError(
|
||||
loadError instanceof Error ? loadError.message : '读取邀请码失败',
|
||||
);
|
||||
} finally {
|
||||
if (
|
||||
lifecycleRef.current === lifecycle &&
|
||||
currentUserIdRef.current === owner
|
||||
) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function openPanel(nextPanel: AccountReferralPanel) {
|
||||
const lifecycle = ++lifecycleRef.current;
|
||||
setPanel(nextPanel);
|
||||
setError(null);
|
||||
setCopyInviteLinkState('idle');
|
||||
setCopyInviteCodeState('idle');
|
||||
if (nextPanel !== 'invite') {
|
||||
return;
|
||||
}
|
||||
void loadInviteCenter(lifecycle, currentUserId);
|
||||
}
|
||||
|
||||
function closePanel() {
|
||||
lifecycleRef.current += 1;
|
||||
setPanel(null);
|
||||
setLoading(false);
|
||||
setCopyInviteLinkState('idle');
|
||||
setCopyInviteCodeState('idle');
|
||||
}
|
||||
|
||||
async function copyValue(
|
||||
applyState: (state: PlatformProfileReferralCopyState) => void,
|
||||
value: string,
|
||||
) {
|
||||
try {
|
||||
await writeText(value);
|
||||
applyState('copied');
|
||||
} catch {
|
||||
applyState('failed');
|
||||
}
|
||||
}
|
||||
|
||||
function copyInviteCode() {
|
||||
if (!center?.inviteCode) {
|
||||
return;
|
||||
}
|
||||
void copyValue(setCopyInviteCodeState, center.inviteCode);
|
||||
}
|
||||
|
||||
function copyInviteLink() {
|
||||
if (!inviteUrl) {
|
||||
return;
|
||||
}
|
||||
void copyValue(setCopyInviteLinkState, inviteUrl);
|
||||
}
|
||||
|
||||
return {
|
||||
center,
|
||||
copyInviteCodeState,
|
||||
copyInviteCode,
|
||||
copyInviteLink,
|
||||
copyInviteLinkState,
|
||||
error,
|
||||
inviteUrl,
|
||||
loading,
|
||||
panel,
|
||||
openInvite: () => openPanel('invite'),
|
||||
openCommunity: () => openPanel('community'),
|
||||
closePanel,
|
||||
};
|
||||
}
|
||||
|
||||
export type AccountReferralController = ReturnType<typeof useAccountReferral>;
|
||||
@@ -2,6 +2,7 @@ import type {
|
||||
ConfirmWechatProfileRechargeOrderResponse,
|
||||
CreateProfileRechargeOrderResponse,
|
||||
ProfileRechargeCenterResponse,
|
||||
ProfileReferralInviteCenterResponse,
|
||||
ProfileWalletLedgerResponse,
|
||||
RedeemProfileRewardCodeResponse,
|
||||
} from '../../../../packages/shared/src/contracts/runtime';
|
||||
@@ -40,6 +41,17 @@ export function getClientProfileWalletLedger() {
|
||||
);
|
||||
}
|
||||
|
||||
export type ClientProfileReferralInviteCenterResponse = {
|
||||
center: ProfileReferralInviteCenterResponse;
|
||||
inviteUrl: string;
|
||||
};
|
||||
|
||||
export function getClientProfileReferralInviteCenter() {
|
||||
return invokeAccount<ClientProfileReferralInviteCenterResponse>(
|
||||
'read_profile_referral_invite_center',
|
||||
);
|
||||
}
|
||||
|
||||
export function createClientProfileRechargeOrder(productId: string) {
|
||||
return invokeAccount<CreateProfileRechargeOrderResponse>(
|
||||
'create_profile_recharge_order',
|
||||
|
||||
@@ -3,7 +3,7 @@ import { resolveTauriInvoke } from '../app/tauri';
|
||||
export type ClientLlmModel = {
|
||||
displayName: string;
|
||||
id: string;
|
||||
agentMode: 'codex' | 'cc';
|
||||
agentMode: 'codex' | 'cc' | 'anthropic';
|
||||
};
|
||||
|
||||
export type ClientLlmModelCatalog = {
|
||||
|
||||
@@ -46,6 +46,7 @@ type LauncherSidebarProps = {
|
||||
onViewChange: (view: LauncherView) => void;
|
||||
onNoticeRequest: (title: string) => void;
|
||||
onRechargeRequest: () => void;
|
||||
onReferralRequest: (panel: 'invite' | 'community') => void;
|
||||
onRuntimeConfigOpen: () => void;
|
||||
onLogout: () => void;
|
||||
};
|
||||
@@ -56,6 +57,7 @@ type SidebarAccountMenuProps = {
|
||||
onLogout: () => void;
|
||||
onNoticeRequest: (title: string) => void;
|
||||
onRechargeRequest: () => void;
|
||||
onReferralRequest: (panel: 'invite' | 'community') => void;
|
||||
};
|
||||
|
||||
function formatMudPoints(value: number | null | undefined) {
|
||||
@@ -71,6 +73,7 @@ function SidebarAccountMenu({
|
||||
onLogout,
|
||||
onNoticeRequest,
|
||||
onRechargeRequest,
|
||||
onReferralRequest,
|
||||
}: SidebarAccountMenuProps) {
|
||||
const walletBalance = useWalletStore(
|
||||
(state) => state.mudPointBalance?.totalPoints ?? null,
|
||||
@@ -164,6 +167,28 @@ function SidebarAccountMenu({
|
||||
>
|
||||
使用指南
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className={sidebarMenuItemClass}
|
||||
onClick={() => {
|
||||
onClose();
|
||||
onReferralRequest('invite');
|
||||
}}
|
||||
>
|
||||
邀请码
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className={sidebarMenuItemClass}
|
||||
onClick={() => {
|
||||
onClose();
|
||||
onReferralRequest('community');
|
||||
}}
|
||||
>
|
||||
玩家社区
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
@@ -198,6 +223,7 @@ export function Sidebar({
|
||||
onViewChange,
|
||||
onNoticeRequest,
|
||||
onRechargeRequest,
|
||||
onReferralRequest,
|
||||
onRuntimeConfigOpen,
|
||||
onLogout,
|
||||
}: LauncherSidebarProps) {
|
||||
@@ -324,6 +350,7 @@ export function Sidebar({
|
||||
onLogout={onLogout}
|
||||
onNoticeRequest={onNoticeRequest}
|
||||
onRechargeRequest={onRechargeRequest}
|
||||
onReferralRequest={onReferralRequest}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
confirmClientWechatProfileRechargeOrder,
|
||||
createClientProfileRechargeOrder,
|
||||
getClientProfileRechargeCenter,
|
||||
getClientProfileReferralInviteCenter,
|
||||
getClientProfileWalletLedger,
|
||||
redeemClientProfileRewardCode,
|
||||
} from '../src/services/accountHost';
|
||||
@@ -47,6 +48,7 @@ test('账户读取只提交结构化参数,不携带 token 或远端地址', a
|
||||
|
||||
await getClientProfileRechargeCenter();
|
||||
await getClientProfileWalletLedger();
|
||||
await getClientProfileReferralInviteCenter();
|
||||
await createClientProfileRechargeOrder('product-1');
|
||||
await confirmClientWechatProfileRechargeOrder('order-1');
|
||||
await redeemClientProfileRewardCode('CODE-1');
|
||||
@@ -54,6 +56,7 @@ test('账户读取只提交结构化参数,不携带 token 或远端地址', a
|
||||
expect(invoke.mock.calls).toEqual([
|
||||
['read_profile_recharge_center', undefined],
|
||||
['read_profile_wallet_ledger', undefined],
|
||||
['read_profile_referral_invite_center', undefined],
|
||||
['create_profile_recharge_order', { productId: 'product-1' }],
|
||||
['confirm_wechat_profile_recharge_order', { orderId: 'order-1' }],
|
||||
['redeem_profile_reward_code', { code: 'CODE-1' }],
|
||||
|
||||
@@ -120,6 +120,9 @@ vi.mock('@tauri-apps/plugin-clipboard-manager', () => ({
|
||||
throw new Error('no native clipboard image');
|
||||
}),
|
||||
readText: vi.fn(async () => nativeClipboardMock.text),
|
||||
writeText: vi.fn(async (text: string) => {
|
||||
nativeClipboardMock.text = text;
|
||||
}),
|
||||
}));
|
||||
|
||||
import type { AuthUser } from '../../../../packages/shared/src/contracts/auth';
|
||||
|
||||
@@ -1076,6 +1076,8 @@ export function registerClientHomeTests() {
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '我的' }));
|
||||
expect(screen.getByRole('menuitem', { name: '使用指南' })).not.toBeNull();
|
||||
expect(screen.getByRole('menuitem', { name: '邀请码' })).not.toBeNull();
|
||||
expect(screen.getByRole('menuitem', { name: '玩家社区' })).not.toBeNull();
|
||||
expect(screen.getByRole('menuitem', { name: '反馈联系' })).not.toBeNull();
|
||||
expect(screen.getByRole('menuitem', { name: '退出登录' })).not.toBeNull();
|
||||
fireEvent.click(screen.getByRole('button', { name: '帮助' }));
|
||||
@@ -1090,6 +1092,74 @@ export function registerClientHomeTests() {
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '使用指南' }));
|
||||
expect(screen.getByRole('dialog', { name: '使用指南' })).not.toBeNull();
|
||||
});
|
||||
|
||||
it('opens the invite code and community panels from the account menu', async () => {
|
||||
const inviteCenter = {
|
||||
inviteCode: 'SY12345678',
|
||||
inviteLinkPath: '/?inviteCode=SY12345678',
|
||||
invitedCount: 2,
|
||||
rewardedInviteCount: 2,
|
||||
todayInviterRewardCount: 1,
|
||||
todayInviterRewardRemaining: 9,
|
||||
rewardPoints: 30,
|
||||
invitedUsers: [],
|
||||
hasRedeemedCode: false,
|
||||
boundInviterUserId: null,
|
||||
boundAt: null,
|
||||
updatedAt: '2026-10-01T00:00:00.000Z',
|
||||
};
|
||||
const inviteLink = 'https://dev.genarrative.world/?inviteCode=SY12345678';
|
||||
const invoke = vi.fn(async (command: string) => {
|
||||
if (command === 'read_profile_referral_invite_center') {
|
||||
return { center: inviteCenter, inviteUrl: inviteLink };
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
window.__TAURI__ = { core: { invoke } };
|
||||
renderLauncherAt('/?launcher');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '我的' }));
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '邀请码' }));
|
||||
|
||||
const inviteDialog = await screen.findByRole('dialog', { name: '邀请码' });
|
||||
expect(invoke).toHaveBeenCalledWith(
|
||||
'read_profile_referral_invite_center',
|
||||
undefined,
|
||||
);
|
||||
expect(within(inviteDialog).getByText('SY12345678')).not.toBeNull();
|
||||
expect(within(inviteDialog).getByText(inviteLink)).not.toBeNull();
|
||||
|
||||
fireEvent.click(
|
||||
within(inviteDialog).getByRole('button', { name: '复制邀请链接' }),
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(nativeClipboardMock.text).toBe(inviteLink);
|
||||
});
|
||||
|
||||
fireEvent.click(
|
||||
within(inviteDialog).getByRole('button', { name: '复制邀请码' }),
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(nativeClipboardMock.text).toBe('SY12345678');
|
||||
});
|
||||
|
||||
fireEvent.click(
|
||||
within(inviteDialog).getByRole('button', { name: '关闭邀请码' }),
|
||||
);
|
||||
expect(screen.queryByRole('dialog', { name: '邀请码' })).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '我的' }));
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '玩家社区' }));
|
||||
const communityDialog = await screen.findByRole('dialog', {
|
||||
name: '玩家社区',
|
||||
});
|
||||
expect(
|
||||
within(communityDialog).getByAltText('玩家社区微信群二维码'),
|
||||
).not.toBeNull();
|
||||
expect(
|
||||
within(communityDialog).getByAltText('玩家社区 QQ 群二维码'),
|
||||
).not.toBeNull();
|
||||
});
|
||||
}
|
||||
|
||||
export function registerHomeProjectCreationTests() {
|
||||
|
||||
@@ -59,6 +59,18 @@
|
||||
> 维护:阶段过程、分支合并和当轮测试数字由 Git 追溯;实现依据以当前代码和最新专题文档为准。
|
||||
> 格式:参见[决策记录格式](../README.md#决策记录格式),按需填写。
|
||||
|
||||
## 2026-10-01 邀请好友入口回到“我的”页,客户端账号菜单接入邀请码与玩家社区
|
||||
|
||||
- 背景:主站“我的”页签的常用功能宫格只剩四项,`邀请好友` 入口在平台个人页恢复时漏了;同一份邀请码在 AGC 客户端里没有任何入口,玩家社区二维码也只存在主站弹层里。
|
||||
- 决策(主站):宫格按文档口径恢复五项——泥点充值、邀请好友、兑换码、玩家社区、反馈与建议,`grid-cols-5` 与 `.platform-profile-shortcut-grid` 的五列规则保持一致;邀请好友复用邀请弹层的 `invite` 面板,弹层展示邀请码与完整邀请链接,并拆成“复制邀请码 / 复制邀请链接”两个动作。邀请链接仍由后端 `inviteLinkPath` 加宿主 origin 补全,不在共享组件里拼参数。
|
||||
- 决策(跨端复用,重要):邀请弹层下沉到 `packages/shared/src/components/PlatformProfileReferralModal/`(含 `model.ts`、`index.css`、`index.test.tsx` 与 `assets/` 微信群 / QQ 群二维码),与 `PlatformProfileRechargeModal`、`PlatformProfileWalletLedgerModal` 同口径,由主站 `PlatformEntryActiveFlowShell` 和 AGC 客户端 `AccountReferralDialogs` 共用一份实现。共享组件只吃 props(邀请中心事实、补全后的邀请链接、两个复制回显、宿主剪贴板粘贴能力),自己不发请求、不写剪贴板;宿主能力(网页 HostBridge / 客户端 Tauri clipboard-manager)留在各自宿主。客户端弹层标题由宿主覆盖为“邀请码 / 玩家社区”,与账号菜单入口文案保持一致;主站沿用“邀请好友 / 填邀请码 / 玩家社区”。原地删除 `src/components/platform-entry/PlatformProfileReferralModal.tsx` 与客户端专用弹层实现(含 `launcher-referral-*` 样式)。
|
||||
- 决策(客户端取数):AGC 侧栏账号菜单在“使用指南”之后新增“邀请码”和“玩家社区”两项。Tauri 侧新增 `read_profile_referral_invite_center`,用当前登录 origin 把 `inviteLinkPath` 补全成完整链接后交给渲染层;客户端只展示邀请码 / 邀请链接和复制动作,不本地另存邀请码、不发放奖励。命令名同时登记进 `scripts/check-config.mjs` 的 native-only 白名单(该门禁要求 `generate_handler!` 里的命令必须被前端调用或显式白名单)。客户端邀请中心按登录账号隔离(`useAccountReferral` 按 `currentUserId` 重置并作废在飞响应)。
|
||||
- 决策(奖励说明收进右上角帮助按钮):邀请面板正文不再常驻那段黄色说明,改由标题右侧的帮助按钮(`?`,仅 `invite` 面板)承载悬浮说明;鼠标 `pointerenter` 展开、离开收起,触屏 / 键盘用点击切换(按 `pointerType` 区分,两套触发互不干扰),文案随 `role="note"` 可被无障碍读取。面板正文因此只保留邀请码、邀请链接和成功邀请三段内容。
|
||||
- 决策(每日上限文案改为数据驱动):说明文案不再写死“每日最多获得十次”(旧文案只在网页弹层里硬编码,客户端完全没有这句)。共享弹层用后端字段推导:每日上限 = `todayInviterRewardCount + todayInviterRewardRemaining`(当前 10),说明为“邀请一位好友注册,好友每次都能获得 N 泥点 / 好友完成注册后你也可以获得 N 泥点;每天最多 L 次,今日还剩 R 次”,两端自动一致。后端事实:`PROFILE_REFERRAL_REWARD_POINTS = 30`、`PROFILE_REFERRAL_DAILY_INVITER_REWARD_LIMIT = 10` 在 `module-runtime` 域内强制;被邀请人每次都拿满 30,上限只压邀请人一侧,超限后邀请关系仍成立。
|
||||
- 已知口径问题(未改,待产品确认):邀请上限的“当日”用的是 `runtime_profile_day_start_micros`(UTC 00:00 边界,即北京时间 08:00 重置),而每日任务 / 每日免费泥点用的是 `runtime_profile_beijing_day_key`(北京时间 00:00)。同一个仓库里两套“每日”口径并存,跨零点前后的奖励计数会与用户直觉不一致。
|
||||
- 影响范围:`packages/shared/src/components/PlatformProfileReferralModal/**`、`src/components/platform-entry/PlatformActiveProfileView.tsx`、`PlatformEntryActiveFlowShell.tsx`、`usePlatformProfileCenterController.ts`、`vite.config.ts`(移除已删除文件的 Tailwind `@source`)、`apps/ai-game-creator-shell/src/view/layout.tsx`、`src/features/app-shell/AccountReferral.tsx`、`useAccountReferral.ts`、`WorkspaceLauncher.tsx`、`src/styles.css`、`src-tauri/src/account_api.rs`、`scripts/check-config.mjs`、`src/services/accountHost.ts`、`media/social-media-group/*`(移动到共享组件 `assets/`)、`docs/【项目基线】当前产品与工程约束-2026-05-15.md`。
|
||||
- 验证方式:`npx vitest run packages/shared/src/components/PlatformProfileReferralModal/index.test.tsx src/components/platform-entry apps/ai-game-creator-shell/tests/appSurface.test.ts apps/ai-game-creator-shell/tests/accountHost.test.ts`、`npm run typecheck`(主站,既有 6 处历史报错不变)、`apps/ai-game-creator-shell` 内 `npm run typecheck`(含 check-config 门禁)、`cargo check --tests` 与 `cargo test --bin … account_api::tests`、`cargo fmt --check`、ESLint / Prettier 改动文件、`npm run check:encoding`、`git diff --check`;并在真实 dev 栈上用浏览器与 CDP 附着客户端 WebView2 复截两端弹层,确认同一份实现渲染一致。
|
||||
|
||||
## 2026-09-29 外壳状态栏退役:`status` 一行改由浮层承载
|
||||
|
||||
- 背景:首页输入框下方、项目组页面头部那一行由 `WorkspaceLauncher` 的 `status` 承接,写入的内容很杂——进行中进度(`正在创建工作区`、`正在选择项目`)、“已取消 / 已创建项目 / 已打开项目目录”这类回显、失败结论(`创建未完成,请重试`、工作区看门狗文案、Provider 报错)以及项目组页面的 `正在检查项目状态`。这一行常驻占页面,用户明确要求整行改成浮层提示,而不是只把「已取消」挑出来。
|
||||
|
||||
@@ -6205,6 +6205,14 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/`
|
||||
- **验证**:真机尺寸下用 `document.elementFromPoint(按钮中心)` 断言命中的是按钮自身而不是底部菜单(`overlay.parentElement === document.body`);顶栏断点矩阵(320–768)断言单行且无横向溢出。
|
||||
- **关联**:`src/components/common/SquareImageCropModal.tsx`、`src/components/platform-entry/PlatformProfileModalShell.tsx`、`src/components/platform-entry/PlatformEntryActiveFlowShell.tsx`、`src/index.css`。
|
||||
|
||||
## 2026-10-01 cc/Anthropic 必须走平台网关,客户端不得直连 Router
|
||||
|
||||
- **现象**:目录里把 `claude-opus-5-5` 标成 `agentMode=cc` 后,选它发消息必然失败:先是 Claude CLI 自己打印 `Not logged in · Please run <path>`,后来卡满 `requestTimeoutMs=180000`。
|
||||
- **根因**:`claude_code_cli.rs` 用配置里残留的 `llm.baseUrl`(`https://router.genarrative.world/v1`)当 `ANTHROPIC_BASE_URL`,再把平台会话 token 塞成 `ANTHROPIC_AUTH_TOKEN`。平台凭据边界是「客户端只出示平台 access token,账号的 Router key 由 api-server 解析、绝不下发」(见 `api-server/src/llm/mod.rs`)。实测 `POST https://router.genarrative.world/v1/messages` 用平台 token 返回 `401 Invalid token (new_api_error)`。
|
||||
- **现行口径**:baseUrl 不带路由与版本段,统一由协议自己拼 `v1/<op>`(服务端 `router_protocol_url` 会先把历史凭据末尾的 `/v1` 归一化掉)。平台侧 Anthropic 是独立路由 `/api/llm/anthropic/{*path}`,客户端 `ANTHROPIC_BASE_URL` 设成 `{apiBaseUrl}/api/llm/anthropic`;OpenAI 侧是 `/api/llm/v1/responses` 与 `/api/llm/v1/chat/completions`,旧的无 `v1` 路径保留为已发布客户端的兼容别名。
|
||||
- **注意**:Claude Agent SDK 固定请求 `{ANTHROPIC_BASE_URL}/v1/messages?beta=true`(外加一次 `HEAD /api/hello` 探测),网关不要自己再补 `v1`,用通配段承接客户端协议路径。
|
||||
- **后台协议选项**:`AgcAgentMode` 增加 `anthropic`(显式 Anthropic 协议),`cc` 保留为同一执行器的历史别名;新增目录项应直接写 `anthropic`。
|
||||
|
||||
## 2026-10-01 AGC 官方 Claude 模型缺少账号令牌会被误报为通道断开
|
||||
|
||||
- **现象**:模型目录把回合路由到 `cc`,本地 `game-creator.config.json` 的 `llm.apiKey` 为空时,Claude Agent SDK 返回失败终态;界面只显示“执行通道未能建立或已断开”。
|
||||
|
||||
@@ -55,7 +55,8 @@
|
||||
|
||||
## 模型绑定 Agent 执行模式
|
||||
|
||||
- 后台 AGC 模型目录每项新增 `agentMode`,只接受 `codex` 与 `cc`;缺少该字段的历史目录按 `codex` 解释,后台新增模型默认 `codex`。
|
||||
- 后台 AGC 模型目录每项新增 `agentMode`,接受 `codex`、`cc` 与 `anthropic`;缺少该字段的历史目录按 `codex` 解释,后台新增模型默认 `codex`。`anthropic` 是 2026-10-01 新增的显式 Anthropic Messages 协议值,`cc` 保留为同一 Claude 执行器的历史别名(两者都映射到 `claude_code_cli`),新目录项应直接写 `anthropic`。
|
||||
- 2026-10-01 补充:`cc`/`anthropic` 一律经平台 LLM 网关的独立 Anthropic 路由 `POST /api/llm/anthropic/{*path}`,客户端 `ANTHROPIC_BASE_URL` 为 `{apiBaseUrl}/api/llm/anthropic`,不得再用配置里的 `llm.baseUrl`(那是 Router 地址,平台不会把账号 Router key 下发给客户端)。Claude Agent SDK 自己拼 `{base}/v1/messages` 并带 `authorization: bearer` 与 `anthropic-version`,网关据此转发上游 `{routerBase}/v1/messages`。
|
||||
- `codex` 仍表示现有 AGC Codex app-server 执行链路;`cc` 表示 AGC 客户端启动随包的 Claude Agent SDK sidecar,模型的 `modelId` 原样作为 Claude Agent SDK 的模型标识,不把 Claude 协议伪装成 OpenAI Responses。sidecar 随 AGC 安装包携带 SDK 及匹配平台的 Claude Code runtime,用户不需要预先安装 `claude` 命令。
|
||||
- `/api/llm/models` 的启用模型摘要返回 `agentMode`,后台完整目录、管理 DTO 和客户端目录保持同一绑定快照。客户端在选择模型时同时持久化模型 ID 与执行模式;默认模型变化、模型被停用或目录刷新回退时一并更新执行模式。
|
||||
- 客户端设置保存不得把后台模型绑定覆盖回 Codex。旧客户端配置缺少执行模式时继续按 Codex 运行;旧后台响应缺少 `agentMode` 时客户端按 Codex 兼容。
|
||||
|
||||
@@ -78,6 +78,7 @@ npm run check:server-rs-ddd
|
||||
- 认证与账号:`/api/auth/*`、`/api/profile/me`,包括短信、密码、微信、refresh session、多端会话和登出。
|
||||
- 个人中心:`/api/profile/*`,包括钱包流水、任务、领奖、充值、反馈、邀请和兑换等账号侧能力。
|
||||
- 平台基础能力:`/api/llm/*`、`/api/speech/volcengine/*`,只保留通用 LLM 和语音代理。
|
||||
- 2026-10-01 路由约定:LLM 网关 baseUrl 不带路由与版本段,路径由协议自己拼。OpenAI 侧是 `POST /api/llm/v1/responses` 与 `POST /api/llm/v1/chat/completions`;Anthropic 是独立路由 `POST /api/llm/anthropic/{*path}`(Claude Agent SDK 固定请求 `{ANTHROPIC_BASE_URL}/v1/messages`,网关不自己补 `v1`)。旧的无 `v1` 路径 `/api/llm/responses`、`/api/llm/chat/completions` 保留为已发布客户端的兼容别名。三条路径共用同一凭据边界:客户端只出示平台 access token,账号 Router key 由 api-server 解析且不下发,模型名由目录解析后替换。
|
||||
- 资产基础能力:`/api/assets/direct-upload-tickets`、`/api/assets/sts-upload-credentials`、`/api/assets/objects/*`、`/api/assets/read-url`、`/api/assets/read-bytes`,负责直传、确认、绑定和读取。两个读取入口共用同一授权函数,并通过受 runtime service identity 限制的 procedure 在同一事务快照内按配置 bucket 与精确 key 权威查询 `asset_object`、计算现役编辑器精选素材派生授权;不得把任意连接的订阅 cache miss 或命中解释为当前授权真相。一旦存在 metadata,即使 key 命中 legacy 前缀,也必须按 `PublicRead`、当前登录 owner,或同 owner 且已通过、已展示、返还完成的 `editor_showcase_asset` 顶层媒体 / 冻结角色动作帧精确授权读取;动作帧只按快照中的 `assetObjectId` / `objectKey` 逐对象授权,不从 `imageSrc` 或 `generated-*` 前缀推导宽泛权限;动作快照损坏、隐藏、拒绝或不再满足返还条件时不形成帧授权。只有同 bucket / key 的权威查询确认未登记时,才允许显式 `legacyPublicPath` 命中 `platform_oss::LEGACY_PUBLIC_PREFIXES` curated 白名单后匿名兼容。已登记资产继续保持 `private`,公开精选只获得与正式展示快照生命周期一致的精确派生读授权,不得批量改为 `PublicRead` 或放开 `generated-*` 前缀。任意未登记 `objectKey`、跨 owner 和未获授权的匿名私有读取统一返回不存在,`read-bytes` 不得成为绕过 `read-url` 授权的同源代理;精选派生授权、`PublicRead` 和 legacy 兼容读取签发的 URL 统一限制为最长 600 秒,owner / admin 读取保持原有有效期口径。
|
||||
- 外部 OpenAPI:`/api/external/v1/openapi.json`、`/api/external/v1/assets/direct-upload-tickets`、`/api/external/v1/assets/objects/confirm`、`/api/external/v1/assets/read-url`、`/api/external/v1/editor/*`,使用 Bearer API Key 鉴权;API Key 管理仍在登录态 `/api/profile/api-keys`,不进入外部 OpenAPI JSON。主站和 External 的 asset object confirm 都必须从已认证主体派生 owner,不能信任请求体 owner;同 bucket / key 已登记后不得改变 owner。
|
||||
- 编辑器与素材生成:`/api/editor/projects*`、`/api/editor/assets*`、`/api/editor/showcase/*`、`/api/editor/*/generations`、`/api/editor/images/*`、`/api/editor/icon-spritesheets/*`、`/api/editor/ui-designs/*`,以及编辑器 Agent 会话路由。通用任务与素材支撑另保留 `/api/ai/tasks*`、`/api/assets/history`、`/api/assets/character-visual/*`、`/api/assets/character-animation/*`、`/api/assets/character-workflow-cache*` 和 `/api/assets/hyper3d/*`。
|
||||
|
||||
@@ -98,7 +98,7 @@ server-rs + Axum + SpacetimeDB
|
||||
7. 主站入口已锁定移动端页面级缩放;单个游戏页面不要再重复实现整页缩放锁定。
|
||||
8. 图像输入通用 UI 统一走 `src/components/common/CreativeImageInputPanel.tsx`。外层页面持有业务状态,组件只承担上传卡、预览、参考图缩略图、AI 重绘开关、错误展示和提交按钮。
|
||||
9. 现役项目、素材等列表的筛选必须打开独立 dialog / drawer / modal;筛选结果为空时显示空状态,不把筛选内容展开在当前列表下方。
|
||||
10. 移动端“我的”页顶部品牌行承载扫码和设置入口,正文按参考图顺序组织为头像 / 昵称 / 陶泥号、三张统计卡、五项常用功能宫格、通用设置入口和法律信息;`media/profile/` 中的陶泥素材作为该页图形资产。常用功能宫格固定承载泥点充值、邀请好友、兑换码、玩家社区、反馈与建议;当前只展示四项常驻入口时必须按四列铺满整行,不保留五列网格导致左对齐空位。页面不再提供会员购买 / 升级横幅、每日任务卡片或任务中心入口,也不提供独立存档按钮入口,不在底部保留旧的填邀请码次级入口;主题设置、账号与安全只作为通用设置弹窗下一级入口,不在“我的”页外层单独占行。填邀请码只由邀请链接 query 或其它明确引导打开独立弹窗,不作为“我的”页常驻按钮。
|
||||
10. 移动端“我的”页顶部品牌行承载扫码和设置入口,正文按参考图顺序组织为头像 / 昵称 / 陶泥号、三张统计卡、五项常用功能宫格、通用设置入口和法律信息;`media/profile/` 中的陶泥素材作为该页图形资产。常用功能宫格固定承载泥点充值、邀请好友、兑换码、玩家社区、反馈与建议五项,按五列铺满整行;入口收缩到四项时按四列铺满整行,不保留五列网格导致左对齐空位。邀请弹层(主站「我的」页签与 AGC 客户端账号菜单共用 `packages/shared` 的 `PlatformProfileReferralModal`)展示邀请码和完整邀请链接,提供“复制邀请码 / 复制邀请链接”两个动作;奖励说明必须从后端 `rewardPoints` 与 `todayInviterRewardCount + todayInviterRewardRemaining` 推导(当前为 30 泥点、每日 10 次、今日剩余 N 次),不在前端写死次数或金额。同一份邀请数据在网页和客户端显示一致,客户端只允许覆盖弹层标题以匹配各自入口文案。页面不再提供会员购买 / 升级横幅、每日任务卡片或任务中心入口,也不提供独立存档按钮入口,不在底部保留旧的填邀请码次级入口;主题设置、账号与安全只作为通用设置弹窗下一级入口,不在“我的”页外层单独占行。填邀请码只由邀请链接 query 或其它明确引导打开独立弹窗,不作为“我的”页常驻按钮。
|
||||
11. 每日免费泥点由后端独立余额桶承载,基础额度固定为 `20`,按北京时间每日 `00:00` 重置。跨业务日退款时,原消费中的每日免费泥点部分叠加到退款当日每日免费桶,当日余额允许超过 `20`;到下一业务日仍统一失效并重置为 `20`。主站不得把已隐藏的每日任务入口或 `daily_task_reward` 文案继续当作每日免费泥点入口。
|
||||
12. “我的”页泥点余额、累计游玩、已玩游戏三张统计卡只展示各自标签和值,三个统计 icon 使用小尺寸普通 UI 档位,内容不换行,不在统计区底部展示“更新于”时间;移动端昵称、常用功能和法律信息也应保持 `10px` 到 `14px` 的普通 UI 字号区间,避免展示级字号挤压内容。
|
||||
13. 移动端“我的”页需要兼容窄屏:头像 / 昵称 / 陶泥号、三张统计卡、五项常用功能和法律信息都必须能在底部固定 TabBar 上方完整滚动露出,不得与底部 dock、刘海 safe-area 或相邻 UI 元素遮挡重叠。
|
||||
|
||||
|
Before Width: | Height: | Size: 296 KiB After Width: | Height: | Size: 296 KiB |
|
Before Width: | Height: | Size: 86 KiB After Width: | Height: | Size: 86 KiB |
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user