Fix Issue 549 #573

Merged
lhk229 merged 5 commits from fix/issue-549 into master 2026-10-01 20:55:28 +08:00
42 changed files with 1954 additions and 551 deletions
Showing only changes of commit bb9397258e - Show all commits
+1 -1
View File
@@ -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, &center.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