点赞和改造开关加入后台配置
This commit is contained in:
@@ -16,6 +16,14 @@
|
||||
|
||||
---
|
||||
|
||||
## 2026-06-10 公开作品互动能力进入后台全局配置
|
||||
|
||||
- 背景:作品详情页的点赞和改造能力原本由前端和各玩法 handler 的硬编码能力矩阵决定,后台无法临时关闭某类公开作品的互动入口,直接关闭创作入口又会误伤已有作品读取和游玩。
|
||||
- 决策:公开作品点赞 / 改造能力作为 `creation_entry_config.public_work_interactions_json` 的全局矩阵保存,不进入单个 `creation_entry_type_config`。`GET /api/creation-entry/config` 下发 `publicWorkInteractions`;后台通过 `/admin/api/creation-entry/config/interactions` 按 `sourceType` 保存点赞、改造开关和关闭提示;api-server 只对已经接入后端动作的 RPG / custom-world、大鱼吃小鱼和拼图 like / remix 路由做同源熔断,公开列表、详情读取、已发布作品启动和运行态请求不受影响。
|
||||
- 影响范围:`CreationEntryConfigResponse`、`AdminCreationEntryConfigResponse`、`module-runtime` 默认矩阵、`spacetime-module` 表字段和 procedure、`spacetime-client` 绑定、后台入口开关页、平台作品详情点赞 / 改造意图解析。
|
||||
- 验证方式:`npm run spacetime:generate`、`npm run check:spacetime-schema`、`cargo test -p module-runtime public_work_interaction_config_defaults_and_overrides --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server public_work_interactions --manifest-path server-rs/Cargo.toml`、后台和前台作品详情互动相关前端测试。
|
||||
- 关联文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
|
||||
|
||||
## 2026-06-10 dev Gitea 提供内网 HTTP 入口
|
||||
|
||||
- 背景:release / dev 目标 agent 需要从 dev 自托管 Gitea 拉取仓库;继续走 `https://git.genarrative.world/...` 会绕公网链路,`10.2.0.10:3000` 又受云侧端口策略影响不能作为稳定入口。
|
||||
|
||||
@@ -1135,6 +1135,8 @@
|
||||
- 现象:刷新网页后,用户明明有本地 access token,却回到未登录状态。
|
||||
- 原因:`AuthGate` hydrate 曾先强制调用 `refreshStoredAccessToken()`;当 refresh cookie 临时失效、代理错配或后端返回 `401` 时,该方法会先清空本地 access token,随后 `/api/auth/me` 只能恢复成未登录。
|
||||
- 处理:`refreshStoredAccessToken()` 增加 `clearOnFailure` 选项;`AuthGate` 在已有本地 access token 时先用 `/api/auth/me` 确认用户,确认成功后再后台 refresh 续期与写每日登录埋点,后台 refresh 失败不清 token。
|
||||
- 追加处理:`/api/auth/refresh` 只有明确返回 `401` / `403` 时才代表登录态权威失效,可以清本地 access token 并触发全局 auth 变化;服务器重启、Nginx 502/503/504、浏览器 `Failed to fetch` 或 refresh 响应契约异常都属于暂时不可用,不能把已有本地 token 清掉,否则重启窗口会把所有打开页面踢成未登录。
|
||||
- 契约:`/api/auth/refresh` 成功响应按共享契约 `RefreshSessionResponse { token }` 解析;测试 mock 不要额外塞 `{ ok: true, token }` 遮住真实恢复路径。
|
||||
- 验证:`npm run test -- src/services/apiClient.test.ts src/components/auth/AuthGate.test.tsx -t "explicit refresh opts out|auth gate keeps a valid local token login"`。
|
||||
- 关联:`src/services/apiClient.ts`、`src/components/auth/AuthGate.tsx`、`docs/technical/AUTH_RESTORE_AND_RECOMMEND_LOADING_FIX_2026-05-09.md`。
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ import type {
|
||||
AdminUpsertProfileRechargeProductRequest,
|
||||
AdminUpsertProfileRedeemCodeRequest,
|
||||
AdminUpsertProfileTaskConfigRequest,
|
||||
AdminUpsertPublicWorkInteractionConfigRequest,
|
||||
AdminWorkVisibilityListResponse,
|
||||
ApiErrorEnvelope,
|
||||
ApiMeta,
|
||||
@@ -129,16 +130,16 @@ export async function request<T>(
|
||||
export function loginAdmin(username: string, password: string) {
|
||||
return request<AdminLoginResponse>('/admin/api/login', {
|
||||
method: 'POST',
|
||||
body: {username, password},
|
||||
body: { username, password },
|
||||
});
|
||||
}
|
||||
|
||||
export function getAdminMe(token: string) {
|
||||
return request<AdminMeResponse>('/admin/api/me', {token});
|
||||
return request<AdminMeResponse>('/admin/api/me', { token });
|
||||
}
|
||||
|
||||
export function getAdminOverview(token: string) {
|
||||
return request<AdminOverviewResponse>('/admin/api/overview', {token});
|
||||
return request<AdminOverviewResponse>('/admin/api/overview', { token });
|
||||
}
|
||||
|
||||
export function getAdminDatabaseTables(token: string) {
|
||||
@@ -154,7 +155,7 @@ export function getAdminDatabaseTableRows(
|
||||
) {
|
||||
return request<AdminDatabaseTableRowsResponse>(
|
||||
`/admin/api/database/tables/${encodeURIComponent(tableName)}/rows${buildDatabaseTableRowsQuery(query)}`,
|
||||
{token},
|
||||
{ token },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -172,15 +173,14 @@ export function listAdminTrackingEvents(
|
||||
) {
|
||||
return request<AdminTrackingEventListResponse>(
|
||||
`/admin/api/tracking/events${buildQueryString(query)}`,
|
||||
{token},
|
||||
{ token },
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
export function getAdminCreationEntryConfig(token: string) {
|
||||
return request<AdminCreationEntryConfigResponse>(
|
||||
'/admin/api/creation-entry/config',
|
||||
{token},
|
||||
{ token },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -213,10 +213,25 @@ export function upsertAdminCreationEntryBanners(
|
||||
);
|
||||
}
|
||||
|
||||
/** 保存公开作品详情页点赞 / 改造能力配置。 */
|
||||
export function upsertAdminPublicWorkInteractions(
|
||||
token: string,
|
||||
payload: AdminUpsertPublicWorkInteractionConfigRequest,
|
||||
) {
|
||||
return request<AdminCreationEntryConfigResponse>(
|
||||
'/admin/api/creation-entry/config/interactions',
|
||||
{
|
||||
method: 'POST',
|
||||
token,
|
||||
body: payload,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function listAdminWorkVisibility(token: string) {
|
||||
return request<AdminWorkVisibilityListResponse>(
|
||||
'/admin/api/works/visibility',
|
||||
{token},
|
||||
{ token },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -237,7 +252,7 @@ export function updateAdminWorkVisibility(
|
||||
export function listProfileRedeemCodes(token: string) {
|
||||
return request<ProfileRedeemCodeAdminListResponse>(
|
||||
'/admin/api/profile/redeem-codes',
|
||||
{token},
|
||||
{ token },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -258,7 +273,7 @@ export function upsertProfileRedeemCode(
|
||||
export function listProfileInviteCodes(token: string) {
|
||||
return request<ProfileInviteCodeAdminListResponse>(
|
||||
'/admin/api/profile/invite-codes',
|
||||
{token},
|
||||
{ token },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -293,7 +308,7 @@ export function disableProfileRedeemCode(
|
||||
export function listProfileTaskConfigs(token: string) {
|
||||
return request<ProfileTaskConfigAdminListResponse>(
|
||||
'/admin/api/profile/tasks',
|
||||
{token},
|
||||
{ token },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -325,7 +340,7 @@ export function disableProfileTaskConfig(
|
||||
export function listProfileRechargeProducts(token: string) {
|
||||
return request<ProfileRechargeProductConfigAdminListResponse>(
|
||||
'/admin/api/profile/recharge-products',
|
||||
{token},
|
||||
{ token },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -414,13 +429,13 @@ function buildAdminApiError(
|
||||
) {
|
||||
const envelope = isRecord(payload) ? (payload as ApiErrorEnvelope) : null;
|
||||
const errorPayload = envelope?.error;
|
||||
const details = isRecord(errorPayload?.details)
|
||||
? errorPayload.details
|
||||
: null;
|
||||
const details = isRecord(errorPayload?.details) ? errorPayload.details : null;
|
||||
const detailsMessage =
|
||||
typeof details?.message === 'string' ? details.message.trim() : '';
|
||||
const payloadMessage =
|
||||
typeof errorPayload?.message === 'string' ? errorPayload.message.trim() : '';
|
||||
typeof errorPayload?.message === 'string'
|
||||
? errorPayload.message.trim()
|
||||
: '';
|
||||
const topLevelMessage =
|
||||
typeof envelope?.message === 'string' ? envelope.message.trim() : '';
|
||||
const message =
|
||||
|
||||
@@ -107,12 +107,7 @@ export interface AdminDebugHeaderInput {
|
||||
value: string;
|
||||
}
|
||||
|
||||
export type AdminDebugHttpMethod =
|
||||
| 'GET'
|
||||
| 'POST'
|
||||
| 'PUT'
|
||||
| 'PATCH'
|
||||
| 'DELETE';
|
||||
export type AdminDebugHttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
|
||||
|
||||
export interface AdminDebugHttpRequest {
|
||||
method: AdminDebugHttpMethod;
|
||||
@@ -143,11 +138,11 @@ export interface AdminTrackingEventListQuery {
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
|
||||
/** 后台创作入口配置响应,同时包含模板入口和独立公告配置。 */
|
||||
export interface AdminCreationEntryConfigResponse {
|
||||
entries: AdminCreationEntryTypeConfigPayload[];
|
||||
eventBanners: AdminCreationEntryEventBannerPayload[];
|
||||
publicWorkInteractions: PublicWorkInteractionConfigPayload[];
|
||||
}
|
||||
|
||||
/** 后台创作入口公告位配置项;旧结构化 banner 字段仅保留兼容。 */
|
||||
@@ -201,6 +196,20 @@ export interface AdminUpsertCreationEntryEventBannersRequest {
|
||||
eventBannersJson: string;
|
||||
}
|
||||
|
||||
/** 后台公开作品详情页互动能力配置项。 */
|
||||
export interface PublicWorkInteractionConfigPayload {
|
||||
sourceType: string;
|
||||
likeEnabled: boolean;
|
||||
remixEnabled: boolean;
|
||||
likeDisabledMessage: string;
|
||||
remixDisabledMessage: string;
|
||||
}
|
||||
|
||||
/** 后台保存公开作品点赞 / 改造能力配置请求体。 */
|
||||
export interface AdminUpsertPublicWorkInteractionConfigRequest {
|
||||
publicWorkInteractions: PublicWorkInteractionConfigPayload[];
|
||||
}
|
||||
|
||||
/** 后台统一创作工作台契约表单的传输结构。 */
|
||||
export interface UnifiedCreationSpecPayload {
|
||||
playId: string;
|
||||
|
||||
@@ -1,19 +1,26 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import {fireEvent, render, screen, waitFor, within} from '@testing-library/react';
|
||||
import {
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
within,
|
||||
} from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import {beforeEach, expect, test, vi} from 'vitest';
|
||||
import { beforeEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
getAdminCreationEntryConfig,
|
||||
upsertAdminCreationEntryBanners,
|
||||
upsertAdminCreationEntryConfig,
|
||||
upsertAdminPublicWorkInteractions,
|
||||
} from '../api/adminApiClient';
|
||||
import type {
|
||||
AdminCreationEntryConfigResponse,
|
||||
UnifiedCreationSpecPayload,
|
||||
} from '../api/adminApiTypes';
|
||||
import {AdminCreationEntrySwitchPage} from './AdminCreationEntrySwitchPage';
|
||||
import { AdminCreationEntrySwitchPage } from './AdminCreationEntrySwitchPage';
|
||||
|
||||
vi.mock('../api/adminApiClient', () => ({
|
||||
formatAdminApiError: vi.fn((error: unknown) =>
|
||||
@@ -23,6 +30,7 @@ vi.mock('../api/adminApiClient', () => ({
|
||||
isAdminApiError: vi.fn(() => false),
|
||||
upsertAdminCreationEntryBanners: vi.fn(),
|
||||
upsertAdminCreationEntryConfig: vi.fn(),
|
||||
upsertAdminPublicWorkInteractions: vi.fn(),
|
||||
}));
|
||||
|
||||
const puzzleSpec: UnifiedCreationSpecPayload = {
|
||||
@@ -55,6 +63,15 @@ const configResponse: AdminCreationEntryConfigResponse = {
|
||||
htmlCode: '<section>后台公告</section>',
|
||||
},
|
||||
],
|
||||
publicWorkInteractions: [
|
||||
{
|
||||
sourceType: 'puzzle',
|
||||
likeEnabled: true,
|
||||
remixEnabled: true,
|
||||
likeDisabledMessage: '拼图点赞暂不可用。',
|
||||
remixDisabledMessage: '拼图作品改造暂不可用。',
|
||||
},
|
||||
],
|
||||
entries: [
|
||||
{
|
||||
id: 'puzzle',
|
||||
@@ -79,16 +96,24 @@ beforeEach(() => {
|
||||
vi.mocked(getAdminCreationEntryConfig).mockResolvedValue(configResponse);
|
||||
vi.mocked(upsertAdminCreationEntryBanners).mockResolvedValue(configResponse);
|
||||
vi.mocked(upsertAdminCreationEntryConfig).mockResolvedValue(configResponse);
|
||||
vi.mocked(upsertAdminPublicWorkInteractions).mockResolvedValue(
|
||||
configResponse,
|
||||
);
|
||||
});
|
||||
|
||||
test('创作入口后台展示并保存统一创作契约', async () => {
|
||||
const user = userEvent.setup();
|
||||
const {container} = render(
|
||||
<AdminCreationEntrySwitchPage token="admin-token" onUnauthorized={vi.fn()} />,
|
||||
const { container } = render(
|
||||
<AdminCreationEntrySwitchPage
|
||||
token="admin-token"
|
||||
onUnauthorized={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
await screen.findByText('pictureDescription');
|
||||
expect(container.querySelector('.admin-subsection .admin-info-list')).not.toBeNull();
|
||||
expect(
|
||||
container.querySelector('.admin-subsection .admin-info-list'),
|
||||
).not.toBeNull();
|
||||
expect(
|
||||
container.querySelector('.admin-subsection .admin-info-list')?.textContent,
|
||||
).toContain('拼图');
|
||||
@@ -97,22 +122,22 @@ test('创作入口后台展示并保存统一创作契约', async () => {
|
||||
expect(screen.queryByLabelText('契约 JSON')).toBeNull();
|
||||
expect(screen.queryByText('puzzle-generating')).toBeNull();
|
||||
|
||||
await user.click(screen.getByRole('button', {name: '修改契约'}));
|
||||
const dialog = screen.getByRole('dialog', {name: '统一创作契约'});
|
||||
await user.click(screen.getByRole('button', { name: '修改契约' }));
|
||||
const dialog = screen.getByRole('dialog', { name: '统一创作契约' });
|
||||
expect(within(dialog).queryByLabelText('玩法 ID')).toBeNull();
|
||||
expect(within(dialog).queryByLabelText('工作台阶段')).toBeNull();
|
||||
expect(within(dialog).queryByLabelText('生成阶段')).toBeNull();
|
||||
expect(within(dialog).queryByLabelText('结果阶段')).toBeNull();
|
||||
fireEvent.change(within(dialog).getByLabelText('泥点消耗'), {
|
||||
target: {value: '12'},
|
||||
target: { value: '12' },
|
||||
});
|
||||
await user.click(within(dialog).getByRole('button', {name: '应用修改'}));
|
||||
await user.click(within(dialog).getByRole('button', { name: '应用修改' }));
|
||||
|
||||
expect(screen.queryByRole('dialog', {name: '统一创作契约'})).toBeNull();
|
||||
expect(screen.queryByRole('dialog', { name: '统一创作契约' })).toBeNull();
|
||||
expect(screen.getByText('12泥点数')).toBeTruthy();
|
||||
|
||||
await user.click(screen.getByRole('button', {name: '保存入库'}));
|
||||
await user.click(screen.getByRole('button', {name: '确认'}));
|
||||
await user.click(screen.getByRole('button', { name: '保存入库' }));
|
||||
await user.click(screen.getByRole('button', { name: '确认' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(upsertAdminCreationEntryConfig).toHaveBeenCalledWith(
|
||||
@@ -150,9 +175,11 @@ test('创作入口后台拒绝 playId 不一致的统一创作契约', async ()
|
||||
);
|
||||
|
||||
await screen.findByText('pictureDescription');
|
||||
await user.click(screen.getByRole('button', {name: '保存入库'}));
|
||||
await user.click(screen.getByRole('button', { name: '保存入库' }));
|
||||
|
||||
expect(await screen.findByText('统一创作契约 playId 必须与入口 ID 一致')).toBeTruthy();
|
||||
expect(
|
||||
await screen.findByText('统一创作契约 playId 必须与入口 ID 一致'),
|
||||
).toBeTruthy();
|
||||
expect(upsertAdminCreationEntryConfig).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -166,23 +193,25 @@ test('创作入口后台用表单保存公告配置', async () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(await screen.findAllByRole('heading', {name: '创作入口公告'})).toHaveLength(2);
|
||||
expect(
|
||||
await screen.findAllByRole('heading', { name: '创作入口公告' }),
|
||||
).toHaveLength(2);
|
||||
expect(screen.queryByLabelText('公告代码 JSON')).toBeNull();
|
||||
fireEvent.change(await screen.findByLabelText('公告 1 标题'), {
|
||||
target: {value: '周末创作赛'},
|
||||
target: { value: '周末创作赛' },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText('公告 1 HTML'), {
|
||||
target: {value: '<section>新的入口公告</section>'},
|
||||
target: { value: '<section>新的入口公告</section>' },
|
||||
});
|
||||
await user.click(screen.getByRole('button', {name: '新增公告'}));
|
||||
await user.click(screen.getByRole('button', { name: '新增公告' }));
|
||||
fireEvent.change(screen.getByLabelText('公告 2 标题'), {
|
||||
target: {value: '第二条公告'},
|
||||
target: { value: '第二条公告' },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText('公告 2 HTML'), {
|
||||
target: {value: '<section>轮播第二条</section>'},
|
||||
target: { value: '<section>轮播第二条</section>' },
|
||||
});
|
||||
await user.click(screen.getByRole('button', {name: '保存公告'}));
|
||||
await user.click(screen.getByRole('button', {name: '确认'}));
|
||||
await user.click(screen.getByRole('button', { name: '保存公告' }));
|
||||
await user.click(screen.getByRole('button', { name: '确认' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(upsertAdminCreationEntryBanners).toHaveBeenCalled();
|
||||
@@ -206,6 +235,42 @@ test('创作入口后台用表单保存公告配置', async () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('创作入口后台用表单保存作品互动配置', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<AdminCreationEntrySwitchPage
|
||||
token="admin-token"
|
||||
onUnauthorized={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
await screen.findByText('作品互动');
|
||||
const likeToggle = screen.getAllByRole('checkbox')[0]!;
|
||||
await user.click(likeToggle);
|
||||
fireEvent.change(screen.getByLabelText('拼图 / puzzle 点赞关闭提示'), {
|
||||
target: { value: '拼图点赞维护中。' },
|
||||
});
|
||||
await user.click(screen.getByRole('button', { name: '保存作品互动' }));
|
||||
await user.click(screen.getByRole('button', { name: '确认' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(upsertAdminPublicWorkInteractions).toHaveBeenCalledWith(
|
||||
'admin-token',
|
||||
{
|
||||
publicWorkInteractions: [
|
||||
{
|
||||
sourceType: 'puzzle',
|
||||
likeEnabled: false,
|
||||
remixEnabled: true,
|
||||
likeDisabledMessage: '拼图点赞维护中。',
|
||||
remixDisabledMessage: '拼图作品改造暂不可用。',
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('创作入口后台把旧结构化公告回显成 HTML 表单', async () => {
|
||||
vi.mocked(getAdminCreationEntryConfig).mockResolvedValueOnce({
|
||||
...configResponse,
|
||||
@@ -251,12 +316,12 @@ test('创作入口后台拒绝空公告表单', async () => {
|
||||
);
|
||||
|
||||
fireEvent.change(await screen.findByLabelText('公告 1 标题'), {
|
||||
target: {value: ''},
|
||||
target: { value: '' },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText('公告 1 HTML'), {
|
||||
target: {value: ''},
|
||||
target: { value: '' },
|
||||
});
|
||||
await user.click(screen.getByRole('button', {name: '保存公告'}));
|
||||
await user.click(screen.getByRole('button', { name: '保存公告' }));
|
||||
|
||||
expect(await screen.findByText('公告 1 标题和 HTML 都不能为空')).toBeTruthy();
|
||||
expect(upsertAdminCreationEntryBanners).not.toHaveBeenCalled();
|
||||
|
||||
@@ -5,10 +5,12 @@ import {
|
||||
getAdminCreationEntryConfig,
|
||||
upsertAdminCreationEntryBanners,
|
||||
upsertAdminCreationEntryConfig,
|
||||
upsertAdminPublicWorkInteractions,
|
||||
} from '../api/adminApiClient';
|
||||
import type {
|
||||
AdminCreationEntryEventBannerPayload,
|
||||
AdminCreationEntryTypeConfigPayload,
|
||||
PublicWorkInteractionConfigPayload,
|
||||
UnifiedCreationFieldPayload,
|
||||
UnifiedCreationSpecPayload,
|
||||
} from '../api/adminApiTypes';
|
||||
@@ -129,14 +131,28 @@ const DEFAULT_UNIFIED_CREATION_STAGE_MAP: Record<
|
||||
let announcementFormItemSequence = 0;
|
||||
let unifiedCreationSpecFieldSequence = 0;
|
||||
|
||||
const PUBLIC_WORK_SOURCE_LABELS: Record<string, string> = {
|
||||
'custom-world': 'RPG',
|
||||
'big-fish': '摸鱼',
|
||||
puzzle: '拼图',
|
||||
'puzzle-clear': '拼消消',
|
||||
'jump-hop': '跳一跳',
|
||||
'wooden-fish': '敲木鱼',
|
||||
match3d: '抓大鹅',
|
||||
'square-hole': '方洞挑战',
|
||||
'visual-novel': '视觉小说',
|
||||
'bark-battle': '汪汪声浪',
|
||||
edutainment: '宝贝识物',
|
||||
};
|
||||
|
||||
export function AdminCreationEntrySwitchPage({
|
||||
token,
|
||||
onUnauthorized,
|
||||
mode = 'switches',
|
||||
}: AdminCreationEntrySwitchPageProps) {
|
||||
const [entries, setEntries] = useState<
|
||||
AdminCreationEntryTypeConfigPayload[]
|
||||
>([]);
|
||||
const [entries, setEntries] = useState<AdminCreationEntryTypeConfigPayload[]>(
|
||||
[],
|
||||
);
|
||||
const [selectedId, setSelectedId] = useState('puzzle');
|
||||
const [title, setTitle] = useState('');
|
||||
const [subtitle, setSubtitle] = useState('');
|
||||
@@ -157,12 +173,17 @@ export function AdminCreationEntrySwitchPage({
|
||||
const [announcementItems, setAnnouncementItems] = useState<
|
||||
AnnouncementFormItem[]
|
||||
>([]);
|
||||
const [publicWorkInteractions, setPublicWorkInteractions] = useState<
|
||||
PublicWorkInteractionConfigPayload[]
|
||||
>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [isSavingBanners, setIsSavingBanners] = useState(false);
|
||||
const [isSavingInteractions, setIsSavingInteractions] = useState(false);
|
||||
const [listErrorMessage, setListErrorMessage] = useState('');
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
const [bannerErrorMessage, setBannerErrorMessage] = useState('');
|
||||
const [interactionErrorMessage, setInteractionErrorMessage] = useState('');
|
||||
const { confirmWrite, confirmDialog } = useAdminWriteConfirm();
|
||||
const isAnnouncementMode = mode === 'announcements';
|
||||
|
||||
@@ -179,6 +200,7 @@ export function AdminCreationEntrySwitchPage({
|
||||
const nextEntries = sortEntries(response.entries);
|
||||
setEntries(nextEntries);
|
||||
setAnnouncementItems(formatEventBannersFormItems(response.eventBanners));
|
||||
setPublicWorkInteractions(response.publicWorkInteractions ?? []);
|
||||
fillForm(
|
||||
nextEntries.find((entry) => entry.id === selectedId) ??
|
||||
nextEntries[0] ??
|
||||
@@ -234,6 +256,7 @@ export function AdminCreationEntrySwitchPage({
|
||||
const nextEntries = sortEntries(response.entries);
|
||||
setEntries(nextEntries);
|
||||
setAnnouncementItems(formatEventBannersFormItems(response.eventBanners));
|
||||
setPublicWorkInteractions(response.publicWorkInteractions ?? []);
|
||||
fillForm(nextEntries.find((entry) => entry.id === targetId) ?? null);
|
||||
} catch (error: unknown) {
|
||||
handlePageError(error, onUnauthorized, setErrorMessage);
|
||||
@@ -269,6 +292,7 @@ export function AdminCreationEntrySwitchPage({
|
||||
});
|
||||
setEntries(sortEntries(response.entries));
|
||||
setAnnouncementItems(formatEventBannersFormItems(response.eventBanners));
|
||||
setPublicWorkInteractions(response.publicWorkInteractions ?? []);
|
||||
} catch (error: unknown) {
|
||||
handlePageError(error, onUnauthorized, setBannerErrorMessage);
|
||||
} finally {
|
||||
@@ -276,6 +300,41 @@ export function AdminCreationEntrySwitchPage({
|
||||
}
|
||||
}
|
||||
|
||||
/** 保存公开作品详情页点赞 / 改造能力开关。 */
|
||||
async function handleSavePublicWorkInteractions() {
|
||||
if (isSavingInteractions) {
|
||||
return;
|
||||
}
|
||||
|
||||
setInteractionErrorMessage('');
|
||||
const confirmed = await confirmWrite({
|
||||
action: '保存作品互动配置',
|
||||
target: 'public-work-interactions',
|
||||
});
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSavingInteractions(true);
|
||||
try {
|
||||
const response = await upsertAdminPublicWorkInteractions(token, {
|
||||
publicWorkInteractions: publicWorkInteractions.map((item) => ({
|
||||
...item,
|
||||
sourceType: item.sourceType.trim(),
|
||||
likeDisabledMessage: item.likeDisabledMessage.trim(),
|
||||
remixDisabledMessage: item.remixDisabledMessage.trim(),
|
||||
})),
|
||||
});
|
||||
setEntries(sortEntries(response.entries));
|
||||
setAnnouncementItems(formatEventBannersFormItems(response.eventBanners));
|
||||
setPublicWorkInteractions(response.publicWorkInteractions ?? []);
|
||||
} catch (error: unknown) {
|
||||
handlePageError(error, onUnauthorized, setInteractionErrorMessage);
|
||||
} finally {
|
||||
setIsSavingInteractions(false);
|
||||
}
|
||||
}
|
||||
|
||||
function fillForm(entry: AdminCreationEntryTypeConfigPayload | null) {
|
||||
if (!entry) {
|
||||
return;
|
||||
@@ -361,7 +420,10 @@ export function AdminCreationEntrySwitchPage({
|
||||
currentForm
|
||||
? {
|
||||
...currentForm,
|
||||
fields: [...currentForm.fields, createUnifiedCreationSpecFieldFormItem()],
|
||||
fields: [
|
||||
...currentForm.fields,
|
||||
createUnifiedCreationSpecFieldFormItem(),
|
||||
],
|
||||
}
|
||||
: currentForm,
|
||||
);
|
||||
@@ -377,7 +439,10 @@ export function AdminCreationEntrySwitchPage({
|
||||
);
|
||||
return {
|
||||
...currentForm,
|
||||
fields: fields.length > 0 ? fields : [createUnifiedCreationSpecFieldFormItem()],
|
||||
fields:
|
||||
fields.length > 0
|
||||
? fields
|
||||
: [createUnifiedCreationSpecFieldFormItem()],
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -414,6 +479,26 @@ export function AdminCreationEntrySwitchPage({
|
||||
});
|
||||
}
|
||||
|
||||
/** 更新单条公开作品互动配置。 */
|
||||
function updatePublicWorkInteraction(
|
||||
index: number,
|
||||
patch: Partial<
|
||||
Pick<
|
||||
PublicWorkInteractionConfigPayload,
|
||||
| 'likeEnabled'
|
||||
| 'remixEnabled'
|
||||
| 'likeDisabledMessage'
|
||||
| 'remixDisabledMessage'
|
||||
>
|
||||
>,
|
||||
) {
|
||||
setPublicWorkInteractions((currentItems) =>
|
||||
currentItems.map((item, itemIndex) =>
|
||||
itemIndex === index ? { ...item, ...patch } : item,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="admin-page">
|
||||
<div className="admin-page-heading">
|
||||
@@ -515,191 +600,288 @@ export function AdminCreationEntrySwitchPage({
|
||||
) : null}
|
||||
|
||||
{!isAnnouncementMode ? (
|
||||
<div className="admin-two-column admin-two-column-wide">
|
||||
<form className="admin-panel admin-form" onSubmit={handleSave}>
|
||||
<div className="admin-form-row">
|
||||
<label className="admin-field admin-field-fill">
|
||||
<span>入口 ID</span>
|
||||
<input
|
||||
value={selectedId}
|
||||
onChange={(event) => setSelectedId(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="admin-switch-field">
|
||||
<input
|
||||
checked={visible}
|
||||
type="checkbox"
|
||||
onChange={(event) => setVisible(event.target.checked)}
|
||||
/>
|
||||
<span>展示</span>
|
||||
</label>
|
||||
<label className="admin-switch-field">
|
||||
<input
|
||||
checked={open}
|
||||
type="checkbox"
|
||||
onChange={(event) => setOpen(event.target.checked)}
|
||||
/>
|
||||
<span>开放</span>
|
||||
</label>
|
||||
<>
|
||||
<section className="admin-panel admin-form">
|
||||
<div className="admin-subsection-heading">
|
||||
<h3>作品互动</h3>
|
||||
<span>{`${publicWorkInteractions.length} 类`}</span>
|
||||
</div>
|
||||
|
||||
<div className="admin-form-row">
|
||||
<label className="admin-field">
|
||||
<span>标题</span>
|
||||
<input
|
||||
value={title}
|
||||
onChange={(event) => setTitle(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="admin-field">
|
||||
<span>角标</span>
|
||||
<input
|
||||
value={badge}
|
||||
onChange={(event) => setBadge(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label className="admin-field">
|
||||
<span>副标题</span>
|
||||
<input
|
||||
value={subtitle}
|
||||
onChange={(event) => setSubtitle(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="admin-field">
|
||||
<span>图片路径</span>
|
||||
<input
|
||||
value={imageSrc}
|
||||
onChange={(event) => setImageSrc(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="admin-field">
|
||||
<span>排序</span>
|
||||
<input
|
||||
inputMode="numeric"
|
||||
value={sortOrder}
|
||||
onChange={(event) => setSortOrder(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="admin-form-row">
|
||||
<label className="admin-field">
|
||||
<span>分类 ID</span>
|
||||
<input
|
||||
value={categoryId}
|
||||
onChange={(event) => setCategoryId(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="admin-field">
|
||||
<span>分类名称</span>
|
||||
<input
|
||||
value={categoryLabel}
|
||||
onChange={(event) => setCategoryLabel(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label className="admin-field">
|
||||
<span>分类排序</span>
|
||||
<input
|
||||
inputMode="numeric"
|
||||
value={categorySortOrder}
|
||||
onChange={(event) => setCategorySortOrder(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<section className="admin-subsection">
|
||||
<div className="admin-subsection-heading">
|
||||
<span>统一创作契约</span>
|
||||
<span>
|
||||
{unifiedCreationSpec ? '已配置' : '未配置'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="admin-form-actions">
|
||||
<button
|
||||
className="admin-secondary-button"
|
||||
type="button"
|
||||
onClick={openUnifiedCreationSpecForm}
|
||||
>
|
||||
<Pencil size={17} aria-hidden="true" />
|
||||
<span>{unifiedCreationSpec ? '修改契约' : '新增契约'}</span>
|
||||
</button>
|
||||
{unifiedCreationSpec ? (
|
||||
<button
|
||||
className="admin-secondary-button"
|
||||
type="button"
|
||||
onClick={() => setUnifiedCreationSpec(null)}
|
||||
>
|
||||
<Trash2 size={17} aria-hidden="true" />
|
||||
<span>清空契约</span>
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
{unifiedCreationSpec ? (
|
||||
<UnifiedCreationSpecCard spec={unifiedCreationSpec} />
|
||||
) : (
|
||||
<div className="admin-muted-text">未配置统一创作页契约</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{errorMessage ? (
|
||||
<div className="admin-alert" role="status">
|
||||
{errorMessage}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="admin-form-actions">
|
||||
<button
|
||||
className="admin-primary-button"
|
||||
disabled={isSaving}
|
||||
type="submit"
|
||||
>
|
||||
<Save size={17} aria-hidden="true" />
|
||||
<span>{isSaving ? '保存中' : '保存入库'}</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<section className="admin-panel">
|
||||
<div className="admin-table-wrap">
|
||||
<table className="admin-table admin-table-compact">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>入口</th>
|
||||
<th>展示</th>
|
||||
<th>开放</th>
|
||||
<th>统一契约</th>
|
||||
<th>分类</th>
|
||||
<th>排序</th>
|
||||
<th>作品类型</th>
|
||||
<th>点赞</th>
|
||||
<th>点赞关闭提示</th>
|
||||
<th>改造</th>
|
||||
<th>改造关闭提示</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{entries.map((entry) => (
|
||||
<tr key={entry.id}>
|
||||
{publicWorkInteractions.map((item, index) => (
|
||||
<tr key={item.sourceType}>
|
||||
<td>{formatPublicWorkSourceLabel(item.sourceType)}</td>
|
||||
<td>
|
||||
<button
|
||||
className="admin-link-button"
|
||||
type="button"
|
||||
onClick={() => fillForm(entry)}
|
||||
>
|
||||
{entry.title || entry.id}
|
||||
</button>
|
||||
<label className="admin-switch-field">
|
||||
<input
|
||||
checked={item.likeEnabled}
|
||||
type="checkbox"
|
||||
onChange={(event) =>
|
||||
updatePublicWorkInteraction(index, {
|
||||
likeEnabled: event.target.checked,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<span>{item.likeEnabled ? '开' : '关'}</span>
|
||||
</label>
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
aria-label={`${formatPublicWorkSourceLabel(
|
||||
item.sourceType,
|
||||
)} 点赞关闭提示`}
|
||||
value={item.likeDisabledMessage}
|
||||
onChange={(event) =>
|
||||
updatePublicWorkInteraction(index, {
|
||||
likeDisabledMessage: event.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<label className="admin-switch-field">
|
||||
<input
|
||||
checked={item.remixEnabled}
|
||||
type="checkbox"
|
||||
onChange={(event) =>
|
||||
updatePublicWorkInteraction(index, {
|
||||
remixEnabled: event.target.checked,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<span>{item.remixEnabled ? '开' : '关'}</span>
|
||||
</label>
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
aria-label={`${formatPublicWorkSourceLabel(
|
||||
item.sourceType,
|
||||
)} 改造关闭提示`}
|
||||
value={item.remixDisabledMessage}
|
||||
onChange={(event) =>
|
||||
updatePublicWorkInteraction(index, {
|
||||
remixDisabledMessage: event.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</td>
|
||||
<td>{entry.visible ? '是' : '否'}</td>
|
||||
<td>{entry.open ? '是' : '否'}</td>
|
||||
<td>{entry.unifiedCreationSpec ? '是' : '否'}</td>
|
||||
<td>{entry.categoryLabel || entry.categoryId}</td>
|
||||
<td>{entry.sortOrder}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{interactionErrorMessage ? (
|
||||
<div className="admin-alert" role="status">
|
||||
{interactionErrorMessage}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="admin-form-actions">
|
||||
<button
|
||||
className="admin-secondary-button"
|
||||
disabled={isSavingInteractions}
|
||||
type="button"
|
||||
onClick={handleSavePublicWorkInteractions}
|
||||
>
|
||||
<Save size={17} aria-hidden="true" />
|
||||
<span>{isSavingInteractions ? '保存中' : '保存作品互动'}</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className="admin-two-column admin-two-column-wide">
|
||||
<form className="admin-panel admin-form" onSubmit={handleSave}>
|
||||
<div className="admin-form-row">
|
||||
<label className="admin-field admin-field-fill">
|
||||
<span>入口 ID</span>
|
||||
<input
|
||||
value={selectedId}
|
||||
onChange={(event) => setSelectedId(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="admin-switch-field">
|
||||
<input
|
||||
checked={visible}
|
||||
type="checkbox"
|
||||
onChange={(event) => setVisible(event.target.checked)}
|
||||
/>
|
||||
<span>展示</span>
|
||||
</label>
|
||||
<label className="admin-switch-field">
|
||||
<input
|
||||
checked={open}
|
||||
type="checkbox"
|
||||
onChange={(event) => setOpen(event.target.checked)}
|
||||
/>
|
||||
<span>开放</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="admin-form-row">
|
||||
<label className="admin-field">
|
||||
<span>标题</span>
|
||||
<input
|
||||
value={title}
|
||||
onChange={(event) => setTitle(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="admin-field">
|
||||
<span>角标</span>
|
||||
<input
|
||||
value={badge}
|
||||
onChange={(event) => setBadge(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label className="admin-field">
|
||||
<span>副标题</span>
|
||||
<input
|
||||
value={subtitle}
|
||||
onChange={(event) => setSubtitle(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="admin-field">
|
||||
<span>图片路径</span>
|
||||
<input
|
||||
value={imageSrc}
|
||||
onChange={(event) => setImageSrc(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="admin-field">
|
||||
<span>排序</span>
|
||||
<input
|
||||
inputMode="numeric"
|
||||
value={sortOrder}
|
||||
onChange={(event) => setSortOrder(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="admin-form-row">
|
||||
<label className="admin-field">
|
||||
<span>分类 ID</span>
|
||||
<input
|
||||
value={categoryId}
|
||||
onChange={(event) => setCategoryId(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="admin-field">
|
||||
<span>分类名称</span>
|
||||
<input
|
||||
value={categoryLabel}
|
||||
onChange={(event) => setCategoryLabel(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label className="admin-field">
|
||||
<span>分类排序</span>
|
||||
<input
|
||||
inputMode="numeric"
|
||||
value={categorySortOrder}
|
||||
onChange={(event) => setCategorySortOrder(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<section className="admin-subsection">
|
||||
<div className="admin-subsection-heading">
|
||||
<span>统一创作契约</span>
|
||||
<span>{unifiedCreationSpec ? '已配置' : '未配置'}</span>
|
||||
</div>
|
||||
<div className="admin-form-actions">
|
||||
<button
|
||||
className="admin-secondary-button"
|
||||
type="button"
|
||||
onClick={openUnifiedCreationSpecForm}
|
||||
>
|
||||
<Pencil size={17} aria-hidden="true" />
|
||||
<span>{unifiedCreationSpec ? '修改契约' : '新增契约'}</span>
|
||||
</button>
|
||||
{unifiedCreationSpec ? (
|
||||
<button
|
||||
className="admin-secondary-button"
|
||||
type="button"
|
||||
onClick={() => setUnifiedCreationSpec(null)}
|
||||
>
|
||||
<Trash2 size={17} aria-hidden="true" />
|
||||
<span>清空契约</span>
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
{unifiedCreationSpec ? (
|
||||
<UnifiedCreationSpecCard spec={unifiedCreationSpec} />
|
||||
) : (
|
||||
<div className="admin-muted-text">未配置统一创作页契约</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{errorMessage ? (
|
||||
<div className="admin-alert" role="status">
|
||||
{errorMessage}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="admin-form-actions">
|
||||
<button
|
||||
className="admin-primary-button"
|
||||
disabled={isSaving}
|
||||
type="submit"
|
||||
>
|
||||
<Save size={17} aria-hidden="true" />
|
||||
<span>{isSaving ? '保存中' : '保存入库'}</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<section className="admin-panel">
|
||||
<div className="admin-table-wrap">
|
||||
<table className="admin-table admin-table-compact">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>入口</th>
|
||||
<th>展示</th>
|
||||
<th>开放</th>
|
||||
<th>统一契约</th>
|
||||
<th>分类</th>
|
||||
<th>排序</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{entries.map((entry) => (
|
||||
<tr key={entry.id}>
|
||||
<td>
|
||||
<button
|
||||
className="admin-link-button"
|
||||
type="button"
|
||||
onClick={() => fillForm(entry)}
|
||||
>
|
||||
{entry.title || entry.id}
|
||||
</button>
|
||||
</td>
|
||||
<td>{entry.visible ? '是' : '否'}</td>
|
||||
<td>{entry.open ? '是' : '否'}</td>
|
||||
<td>{entry.unifiedCreationSpec ? '是' : '否'}</td>
|
||||
<td>{entry.categoryLabel || entry.categoryId}</td>
|
||||
<td>{entry.sortOrder}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{confirmDialog}
|
||||
@@ -776,7 +958,10 @@ export function AdminCreationEntrySwitchPage({
|
||||
</div>
|
||||
<div className="admin-contract-field-editor-list">
|
||||
{unifiedCreationSpecForm.fields.map((field, index) => (
|
||||
<section className="admin-contract-field-editor" key={field.id}>
|
||||
<section
|
||||
className="admin-contract-field-editor"
|
||||
key={field.id}
|
||||
>
|
||||
<div className="admin-subsection-heading">
|
||||
<span>{`字段 ${index + 1}`}</span>
|
||||
<button
|
||||
@@ -881,6 +1066,11 @@ function sortEntries(entries: AdminCreationEntryTypeConfigPayload[]) {
|
||||
});
|
||||
}
|
||||
|
||||
function formatPublicWorkSourceLabel(sourceType: string) {
|
||||
const label = PUBLIC_WORK_SOURCE_LABELS[sourceType];
|
||||
return label ? `${label} / ${sourceType}` : sourceType;
|
||||
}
|
||||
|
||||
function parseInteger(value: string) {
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
if (!Number.isFinite(parsed)) {
|
||||
@@ -933,7 +1123,7 @@ function buildEventBannersJsonFromForm(
|
||||
htmlCode: item.htmlCode.trim(),
|
||||
}));
|
||||
if (banners.length === 0) {
|
||||
return {ok: false as const, message: '至少需要一条公告'};
|
||||
return { ok: false as const, message: '至少需要一条公告' };
|
||||
}
|
||||
const emptyIndex = banners.findIndex(
|
||||
(banner) => !banner.title || !banner.htmlCode,
|
||||
@@ -945,7 +1135,7 @@ function buildEventBannersJsonFromForm(
|
||||
};
|
||||
}
|
||||
|
||||
return {ok: true as const, json: JSON.stringify(banners, null, 2)};
|
||||
return { ok: true as const, json: JSON.stringify(banners, null, 2) };
|
||||
}
|
||||
|
||||
/** 将旧结构化公告字段转成可编辑 HTML,避免后台表单丢失历史公告内容。 */
|
||||
@@ -995,9 +1185,9 @@ function buildUnifiedCreationSpecForm(
|
||||
title: spec?.title ?? entryTitle,
|
||||
mudPointCost: String(normalizeMudPointCost(spec?.mudPointCost)),
|
||||
...stages,
|
||||
fields:
|
||||
spec?.fields.map((field) => createUnifiedCreationSpecFieldFormItem(field)) ??
|
||||
[createUnifiedCreationSpecFieldFormItem()],
|
||||
fields: spec?.fields.map((field) =>
|
||||
createUnifiedCreationSpecFieldFormItem(field),
|
||||
) ?? [createUnifiedCreationSpecFieldFormItem()],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1044,10 +1234,13 @@ function validateUnifiedCreationSpecForEntry(
|
||||
spec: UnifiedCreationSpecPayload | null,
|
||||
) {
|
||||
if (!spec) {
|
||||
return {ok: true as const, spec: null};
|
||||
return { ok: true as const, spec: null };
|
||||
}
|
||||
if (spec.playId !== entryId) {
|
||||
return {ok: false as const, message: '统一创作契约 playId 必须与入口 ID 一致'};
|
||||
return {
|
||||
ok: false as const,
|
||||
message: '统一创作契约 playId 必须与入口 ID 一致',
|
||||
};
|
||||
}
|
||||
return validateUnifiedCreationSpec(spec);
|
||||
}
|
||||
@@ -1064,12 +1257,12 @@ function formatMudPointCostText(value: number | null | undefined) {
|
||||
|
||||
function validateUnifiedCreationSpec(value: unknown) {
|
||||
if (!isRecord(value)) {
|
||||
return {ok: false as const, message: '统一创作契约必须是对象'};
|
||||
return { ok: false as const, message: '统一创作契约必须是对象' };
|
||||
}
|
||||
|
||||
const playId = readRequiredString(value, 'playId');
|
||||
if (!playId) {
|
||||
return {ok: false as const, message: '统一创作契约 playId 不能为空'};
|
||||
return { ok: false as const, message: '统一创作契约 playId 不能为空' };
|
||||
}
|
||||
|
||||
const title = readRequiredString(value, 'title');
|
||||
@@ -1078,42 +1271,57 @@ function validateUnifiedCreationSpec(value: unknown) {
|
||||
const generationStage = readRequiredString(value, 'generationStage');
|
||||
const resultStage = readRequiredString(value, 'resultStage');
|
||||
if (!title) {
|
||||
return {ok: false as const, message: '统一创作契约标题不能为空'};
|
||||
return { ok: false as const, message: '统一创作契约标题不能为空' };
|
||||
}
|
||||
if (!workspaceStage || !generationStage || !resultStage) {
|
||||
return {ok: false as const, message: '该玩法缺少默认阶段配置,请先由开发接入'};
|
||||
return {
|
||||
ok: false as const,
|
||||
message: '该玩法缺少默认阶段配置,请先由开发接入',
|
||||
};
|
||||
}
|
||||
if (mudPointCost === null) {
|
||||
return {ok: false as const, message: '统一创作契约泥点消耗数量必须是大于 0 的整数'};
|
||||
return {
|
||||
ok: false as const,
|
||||
message: '统一创作契约泥点消耗数量必须是大于 0 的整数',
|
||||
};
|
||||
}
|
||||
if (new Set([workspaceStage, generationStage, resultStage]).size !== 3) {
|
||||
return {ok: false as const, message: '统一创作契约阶段不能重复'};
|
||||
return { ok: false as const, message: '统一创作契约阶段不能重复' };
|
||||
}
|
||||
|
||||
if (!Array.isArray(value.fields) || value.fields.length === 0) {
|
||||
return {ok: false as const, message: '统一创作契约 fields 不能为空'};
|
||||
return { ok: false as const, message: '统一创作契约 fields 不能为空' };
|
||||
}
|
||||
|
||||
const fieldIds = new Set<string>();
|
||||
const fields: UnifiedCreationFieldPayload[] = [];
|
||||
for (const item of value.fields) {
|
||||
if (!isRecord(item)) {
|
||||
return {ok: false as const, message: '统一创作契约字段必须是对象'};
|
||||
return { ok: false as const, message: '统一创作契约字段必须是对象' };
|
||||
}
|
||||
const id = readRequiredString(item, 'id');
|
||||
const label = readRequiredString(item, 'label');
|
||||
if (!id || !label) {
|
||||
return {ok: false as const, message: '统一创作契约字段 id 和 label 不能为空'};
|
||||
return {
|
||||
ok: false as const,
|
||||
message: '统一创作契约字段 id 和 label 不能为空',
|
||||
};
|
||||
}
|
||||
if (fieldIds.has(id)) {
|
||||
return {ok: false as const, message: `统一创作契约字段 id 重复:${id}`};
|
||||
return { ok: false as const, message: `统一创作契约字段 id 重复:${id}` };
|
||||
}
|
||||
fieldIds.add(id);
|
||||
if (!isUnifiedCreationFieldKind(item.kind)) {
|
||||
return {ok: false as const, message: `统一创作契约字段 kind 非法:${id}`};
|
||||
return {
|
||||
ok: false as const,
|
||||
message: `统一创作契约字段 kind 非法:${id}`,
|
||||
};
|
||||
}
|
||||
if (typeof item.required !== 'boolean') {
|
||||
return {ok: false as const, message: `统一创作契约字段 required 非法:${id}`};
|
||||
return {
|
||||
ok: false as const,
|
||||
message: `统一创作契约字段 required 非法:${id}`,
|
||||
};
|
||||
}
|
||||
fields.push({
|
||||
id,
|
||||
@@ -1137,7 +1345,11 @@ function validateUnifiedCreationSpec(value: unknown) {
|
||||
};
|
||||
}
|
||||
|
||||
function UnifiedCreationSpecCard({spec}: {spec: UnifiedCreationSpecPayload}) {
|
||||
function UnifiedCreationSpecCard({
|
||||
spec,
|
||||
}: {
|
||||
spec: UnifiedCreationSpecPayload;
|
||||
}) {
|
||||
return (
|
||||
<div className="admin-contract-card">
|
||||
<dl className="admin-info-list">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# server-rs 与 SpacetimeDB 数据契约
|
||||
|
||||
更新时间:`2026-05-15`
|
||||
更新时间:`2026-06-10`
|
||||
|
||||
## 后端主线
|
||||
|
||||
@@ -54,13 +54,13 @@ npm run check:server-rs-ddd
|
||||
路由树由 `server-rs/crates/api-server/src/app.rs` 统一构造。当前主要分组:
|
||||
|
||||
- 健康检查:`GET /healthz`。
|
||||
- 后台管理:`/admin/api/*`,包括登录、概览、HTTP debug、埋点、表查询、创作入口开关、作品可见性、兑换码、邀请码、任务配置和充值商品配置。
|
||||
- 后台管理:`/admin/api/*`,包括登录、概览、HTTP debug、埋点、表查询、创作入口开关、作品互动配置、作品可见性、兑换码、邀请码、任务配置和充值商品配置。
|
||||
- 认证与账号:`/api/auth/*`、`/api/profile/me`,包括短信、密码、微信、refresh session、多端会话和登出。
|
||||
- 个人中心:`/api/profile/*`,包括钱包流水、任务、领奖、充值、反馈、邀请和兑换等账号侧能力。
|
||||
- 平台基础能力:`/api/llm/*`、`/api/speech/volcengine/*`,只保留通用 LLM 和语音代理。
|
||||
- 资产基础能力:`/api/assets/direct-upload-tickets`、`/api/assets/sts-upload-credentials`、`/api/assets/objects/*`、`/api/assets/read-*`,负责直传、确认、绑定和读取。
|
||||
- 创作 / 游玩支撑能力:`/api/creation-entry/config`、`/api/ai/tasks*`、`/api/runtime/chat/*`、`/api/runtime/settings`、`/api/runtime/save/snapshot`、`/api/profile/browse-history`、`/api/profile/save-archives*`、`/api/profile/play-stats`、`/api/assets/history`、`/api/assets/character-visual/*`、`/api/assets/character-animation/*`、`/api/assets/character-workflow-cache*`、`/api/assets/hyper3d/*`、`/api/runtime/custom-world/asset-studio/*`。
|
||||
- 后台入口配置:`/admin/api/creation-entry/config` 和 `/admin/api/creation-entry/config/banners`。
|
||||
- 后台入口配置:`/admin/api/creation-entry/config`、`/admin/api/creation-entry/config/banners` 和 `/admin/api/creation-entry/config/interactions`。
|
||||
- 自定义世界 / RPG:`/api/runtime/custom-world*`、`/api/story/*`、`/api/runtime/chat/*`。
|
||||
- 拼图:`/api/runtime/puzzle/*`。
|
||||
- 抓大鹅 Match3D:`/api/creation/match3d/*`、`/api/runtime/match3d/*`。
|
||||
@@ -356,8 +356,8 @@ npm run check:server-rs-ddd
|
||||
|
||||
- Rust 结构体:`CreationEntryConfig`
|
||||
- 源码:`server-rs/crates/spacetime-module/src/runtime/creation_entry_config.rs`
|
||||
- 字段:`config_id`、`start_title`、`start_description`、`start_idle_badge`、`start_busy_badge`、`modal_title`、`modal_description`、`updated_at`、`event_title`、`event_description`、`event_cover_image_src`、`event_prize_pool_mud_points`、`event_starts_at_text`、`event_ends_at_text`、`event_banners_json`。
|
||||
- 迁移兼容:旧迁移包缺少活动横幅字段时,由 `migration.rs` 写入 `None` / `58000` 默认值;旧库缺少 `event_banners_json` 时写入 `None`,运行态读取层再按 `module-runtime` 默认公告数组归一,不覆盖后台已保存配置,也不把旧结构化 `eventBanner` 升格为前端优先数组。HTTP 响应同时返回 `eventBanners` 数组和旧 `eventBanner` 单条兼容字段,前端优先消费数组;后台新配置主格式为 HTML 公告字符串数组或 `{title, htmlCode}` 对象数组,旧结构化 banner 字段仅保留兼容。默认公告背景和旧结构化默认 `coverImageSrc` 必须引用 `public/` 下真实存在的静态资源,当前为 `/creation-type-references/puzzle.webp`。
|
||||
- 字段:`config_id`、`start_title`、`start_description`、`start_idle_badge`、`start_busy_badge`、`modal_title`、`modal_description`、`updated_at`、`event_title`、`event_description`、`event_cover_image_src`、`event_prize_pool_mud_points`、`event_starts_at_text`、`event_ends_at_text`、`event_banners_json`、`public_work_interactions_json`。
|
||||
- 迁移兼容:旧迁移包缺少活动横幅字段时,由 `migration.rs` 写入 `None` / `58000` 默认值;旧库缺少 `event_banners_json` 时写入 `None`,运行态读取层再按 `module-runtime` 默认公告数组归一,不覆盖后台已保存配置,也不把旧结构化 `eventBanner` 升格为前端优先数组。旧库缺少 `public_work_interactions_json` 时写入 `None`,读取层按 `module-runtime` 默认作品互动矩阵补齐 `publicWorkInteractions`,不覆盖后台已保存开关。HTTP 响应同时返回 `eventBanners` 数组、旧 `eventBanner` 单条兼容字段和 `publicWorkInteractions` 互动矩阵;前端优先消费数组与矩阵。后台新公告配置主格式为 HTML 公告字符串数组或 `{title, htmlCode}` 对象数组,旧结构化 banner 字段仅保留兼容。默认公告背景和旧结构化默认 `coverImageSrc` 必须引用 `public/` 下真实存在的静态资源,当前为 `/creation-type-references/puzzle.webp`。
|
||||
|
||||
### `creation_entry_type_config`
|
||||
|
||||
@@ -749,8 +749,8 @@ npm run check:server-rs-ddd
|
||||
|
||||
跨玩法公开作品列表 / 详情主读模型是 `public_work_gallery_entry` 与 `public_work_detail_entry`。拼图、自定义世界等旧玩法公开列表 HTTP 路由保留原响应 shape,由 BFF mapper 从统一 public cache 映射回当前 DTO;旧 `*_gallery_card_view` / `*_gallery_view` / `custom_world_gallery_entry` 继续作为 source view 和兼容缓存。各玩法的个人作品列表、详情、发布、点赞、游玩记录、Remix 和其它需要鉴权或写入副作用的路径继续走 procedure / reducer;不要为了公开列表性能把这些 owner-specific 或 mutation 语义混进 public view。
|
||||
|
||||
`GET /api/creation-entry/config` 和入口熔断优先从订阅 cache 读取创作入口配置;cache 缺失时使用最近一次成功读取的内存快照,再兜底调用 `get_creation_entry_config` procedure 完成空库种子或旧库兼容。
|
||||
入口配置快照包含 start card、类型弹窗、公告位兼容字段和入口类型列表;入口类型列表新增 `category_id`、`category_label`、`category_sort_order` 后,后台 upsert、`shared-contracts`、`module-runtime` 和 `spacetime-client` binding 必须同步,旧迁移 JSON 通过 `migration.rs` 默认值兼容。
|
||||
`GET /api/creation-entry/config`、入口熔断和公开作品互动熔断优先从订阅 cache 读取创作入口配置;cache 缺失时使用最近一次成功读取的内存快照,再兜底调用 `get_creation_entry_config` procedure 完成空库种子或旧库兼容。
|
||||
入口配置快照包含 start card、类型弹窗、公告位兼容字段、入口类型列表和 `publicWorkInteractions` 作品互动矩阵;入口类型列表新增 `category_id`、`category_label`、`category_sort_order` 后,后台 upsert、`shared-contracts`、`module-runtime` 和 `spacetime-client` binding 必须同步,旧迁移 JSON 通过 `migration.rs` 默认值兼容。作品互动矩阵是全局公开作品详情能力配置,不属于单个 `creation_entry_type_config`;后台通过 `/admin/api/creation-entry/config/interactions` 保存,前端据此隐藏或拦截已接入的点赞 / Remix 入口,api-server 同时对已接入后端动作执行 `public_work_interaction_disabled` 熔断。
|
||||
|
||||
RPG 创作入口的配置 ID 是 `rpg`,当前 `visible=true`、`open=true`;历史 `custom-world` 路由仍是 RPG 的工程域与运行态源类型。入口熔断把 `/api/runtime/custom-world*`、`/api/story/*` 和 `/api/runtime/chat/*` 统一映射到 `rpg`,不要新增平行 `airp` 路由或用 `airp` 接管当前文字冒险链路。
|
||||
|
||||
|
||||
@@ -465,6 +465,8 @@ journalctl -u genarrative-api.service --since '30 seconds ago' --no-pager | grep
|
||||
|
||||
`Genarrative-Server-Provision` 和 `Genarrative-Api-Deploy` 会在保留旧 `/etc/genarrative/api-server.env` 的前提下补齐缺失的 tracking outbox 运行态路径,并确保 `/var/lib/genarrative/tracking-outbox` 归属 `genarrative:genarrative`。用户认证真相源只允许在 SpacetimeDB 正式认证表(`user_account` / `auth_identity` / `refresh_session`)恢复;不要再配置或依赖 `GENARRATIVE_AUTH_STORE_PATH` / `auth-store.json`,`module-auth` 也不再维护本地文件持久化;`auth_store_snapshot` 只保留行级记录,不再保存为单行 `default` 聚合快照,且旧 `get_auth_store_snapshot` / `upsert_auth_store_snapshot` / `import_auth_store_snapshot` 入口已经删除。如果 `api-server` 启动时连不上 SpacetimeDB,会持续重试启动恢复,直到认证工作集从 SpacetimeDB 正式表恢复成功后才开始监听 HTTP,以避免用空本地状态或旧快照覆盖认证表。
|
||||
|
||||
前端登录态恢复只把 `/api/auth/refresh` 的 `401` / `403` 当成权威失效信号;服务器重启窗口里的 `502` / `503` / `504`、浏览器 `Failed to fetch` 或 refresh 响应契约异常都必须保留已有本地 access token,不触发全局 auth 变化。refresh 成功响应以共享契约 `RefreshSessionResponse { token }` 为准,前端不要额外要求业务 `ok` 字段。排查“重启后用户都掉线”时,先区分前端是否被暂时不可用清掉本地 token,再检查 SpacetimeDB 正式认证表是否缺 `user_account` / `refresh_session` 数据。
|
||||
|
||||
常用检查思路:
|
||||
|
||||
```sql
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
# 平台入口与玩法链路
|
||||
|
||||
更新时间:`2026-06-04`
|
||||
更新时间:`2026-06-10`
|
||||
|
||||
## 平台创作入口
|
||||
|
||||
创作入口配置事实源在 SpacetimeDB,通过 `GET /api/creation-entry/config` 下发;后台通过 `/admin/api/creation-entry/config` 管理。前端只在展示层派生可见卡片和入口状态,`api-server` 路由熔断也使用同一份配置。不要恢复前端硬编码入口配置文件。
|
||||
创作入口配置事实源在 SpacetimeDB,通过 `GET /api/creation-entry/config` 下发;后台通过 `/admin/api/creation-entry/config` 管理入口开关,通过 `/admin/api/creation-entry/config/interactions` 管理公开作品点赞 / 改造能力矩阵。前端只在展示层派生可见卡片、入口状态和作品详情互动状态,`api-server` 路由熔断也使用同一份配置。不要恢复前端硬编码入口配置文件。
|
||||
|
||||
当前点击底部加号进入的创作入口页承载后台公告位、创作入口页签和两列模板卡;页签中只有真实后端作品架摘要存在时才展示“最近创作”,其余为玩法模板分类。点击模板卡后直接进入对应玩法已有的入口创作表单 stage,不再经过空白占位页,也不把旧表单嵌进创作入口页;模板点击的占位 no-op、隐藏模板拦截、未知入口 no-op 和工作台启动目标统一由 `platformCreationLaunchModel.ts` 判定,壳层只执行启动前准备、错误提示和受保护动作。移动端创作入口页顶栏在 `陶泥儿` 品牌同一行显示真实账户泥点数,数据来自 `profileDashboard.walletBalance`,不得再把公告内容或活动奖池当作账号余额展示。创作入口页公告位数据优先读取 `GET /api/creation-entry/config` 的 `eventBanners` 数组,多条配置时前端自动轮播;旧 `eventBanner` 只保留字段回显与旧客户端兼容,不再作为前端公告数组的兜底来源。后台公告配置面向表单:每条公告包含标题和 HTML 内容,后台保存时序列化为后端 `eventBannersJson` 传输字段,由前端空权限沙箱 iframe 展示;旧结构化 banner 字段仅保留回显兼容,不再作为后台公告配置主格式;不得执行 JSX 或把后台代码直接注入 DOM。玩法列表不再套外部边框卡片,移动端需要压缩横向边距和两列间距;玩法卡统一按“上图、左上状态标签(仅非开放态显示)、封面右下显示 `creationTypes[].unifiedCreationSpec.mudPointCost` 经前端格式化后的泥点消耗、下方白底标题/描述”结构展示,旧契约缺少该字段时兜底 `10` 并由前端显示为 `10泥点数`,卡片高度保持紧凑但标题、描述和预估消耗点数都必须可见。创作入口页根容器不再使用 `platform-page-stage` 这类全局内容卡片壳,但继续保留 `platform-remap-surface` 作为主题和输入框样式命中钩子。创作入口页字号需要对齐平台普通 UI 档位:顶栏泥点组件、公告正文、分类 Tab 和玩法卡标题 / 副标题 / 消耗说明优先使用 `11px` 到 `14px`,不使用 `text-lg`、`text-xl` 或更大的展示级字号。草稿 Tab 继续承接作品架;底部加号入口页的“最近创作”只用 7 天内的真实后端作品架摘要判断是否展示,并从摘要里推导最近使用过的模板 ID,页面必须展示“仅显示最近7天内使用过的模板”提示,列表内容必须复用其它页签里的模板卡样式、文案和点击行为,不展示具体作品名称、摘要或生成状态,也不新增独立最近创作卡组件。RPG、RPG 之外的各玩法入口分别落到既有的 `agent-workspace`、`big-fish-agent-workspace`、`match3d-agent-workspace`、`square-hole-agent-workspace`、`jump-hop-workspace`、`wooden-fish-workspace`、`puzzle-agent-workspace`、`bark-battle-workspace`、`visual-novel-agent-workspace`、`baby-object-match-workspace`,这些入口继续承接各玩法自己的表单、草稿恢复和后续编排,不作为创作入口页内容。
|
||||
|
||||
@@ -40,6 +40,8 @@ RPG Agent 结果页发布门禁展示由 `platformRpgAgentResultPreviewModel.ts`
|
||||
|
||||
入口配置中的 `open=false` 表示关闭新建创作入口,不表示下架已有草稿、私有作品或公开作品。api-server 的入口熔断只允许拦截新建创作、新建草稿、首次生成入口和 Remix 成草稿等会产生新创作的请求;公开广场列表、公开详情、点赞、已发布作品启动、运行态过程请求、存档 / 浏览记录和已有作品回读不能因为创作入口关闭而返回 `creation_entry_disabled`。平台首页如果遇到旧服务端返回的 `creation_entry_disabled`,只能降级为空列表或隐藏入口,不弹平台级错误弹窗。
|
||||
|
||||
公开作品点赞 / 改造是否开放不跟随入口 `open` 字段,而是读取 `GET /api/creation-entry/config` 的 `publicWorkInteractions`。后台可以按 `sourceType` 分别关闭点赞或改造并维护关闭提示;前端只据此关闭已接入的作品详情动作,尚未接入后端动作的玩法仍按实际能力矩阵返回不可用提示。api-server 对已接入的 RPG、自定义世界兼容路径、大鱼吃小鱼和拼图点赞 / 改造接口做同源熔断,关闭时返回 `public_work_interaction_disabled`,但公开列表、公开详情、已发布作品启动和运行态过程请求不受影响。
|
||||
|
||||
创作入口页的关闭态卡片必须有明显差异:卡片禁用点击,展示后台配置的关闭态 badge 或 `暂未开放`,不再显示泥点消耗这类可创建成本提示;开放态卡片仍不显示普通 `可创建 / 可创作` badge。
|
||||
|
||||
`PlatformEntryFlowShellImpl.tsx` 仍是平台入口编排壳,后续维护时应优先把独立 UI 片段、公开作品映射、草稿生成 notice 和运行态状态 helper 拆到 `src/components/platform-entry/PlatformEntryFlowShellImpl/` 或同目录紧邻 helper 文件。拆分只允许改变文件组织,不改变入口配置事实源、默认导出、props、页面阶段、UI 文案或现有交互;其中拼图首访 onboarding 已拆为 `PlatformEntryFlowShellImpl/PuzzleOnboardingView.tsx`。
|
||||
|
||||
@@ -156,8 +156,7 @@ export type AuthPhoneChangeResponse = {
|
||||
};
|
||||
|
||||
export type AuthRefreshResponse = {
|
||||
ok: true;
|
||||
token?: string;
|
||||
token: string;
|
||||
};
|
||||
|
||||
export type AuthSessionSummary = {
|
||||
|
||||
@@ -26,7 +26,8 @@ use shared_contracts::admin::{
|
||||
AdminSessionPayload, AdminTrackingEventEntryPayload, AdminTrackingEventListQuery,
|
||||
AdminTrackingEventListResponse, AdminUpdateWorkVisibilityRequest,
|
||||
AdminUpdateWorkVisibilityResponse, AdminUpsertCreationEntryEventBannersRequest,
|
||||
AdminUpsertCreationEntryTypeConfigRequest, AdminWorkVisibilityListResponse,
|
||||
AdminUpsertCreationEntryTypeConfigRequest, AdminUpsertPublicWorkInteractionConfigRequest,
|
||||
AdminWorkVisibilityListResponse,
|
||||
};
|
||||
use shared_contracts::creation_entry_config::{
|
||||
encode_unified_creation_spec_response, validate_unified_creation_spec_for_play,
|
||||
@@ -212,14 +213,7 @@ pub async fn admin_get_creation_entry_config(
|
||||
.map_err(map_admin_spacetime_error)?;
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
AdminCreationEntryConfigResponse {
|
||||
event_banners: config.event_banners,
|
||||
entries: config
|
||||
.creation_types
|
||||
.into_iter()
|
||||
.map(map_admin_creation_entry_type_config)
|
||||
.collect(),
|
||||
},
|
||||
build_admin_creation_entry_config_response(config),
|
||||
))
|
||||
}
|
||||
|
||||
@@ -237,14 +231,7 @@ pub async fn admin_upsert_creation_entry_config(
|
||||
.map_err(map_admin_spacetime_error)?;
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
AdminCreationEntryConfigResponse {
|
||||
event_banners: config.event_banners,
|
||||
entries: config
|
||||
.creation_types
|
||||
.into_iter()
|
||||
.map(map_admin_creation_entry_type_config)
|
||||
.collect(),
|
||||
},
|
||||
build_admin_creation_entry_config_response(config),
|
||||
))
|
||||
}
|
||||
|
||||
@@ -268,14 +255,45 @@ pub async fn admin_upsert_creation_entry_event_banners_config(
|
||||
.map_err(map_admin_spacetime_error)?;
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
AdminCreationEntryConfigResponse {
|
||||
event_banners: config.event_banners,
|
||||
entries: config
|
||||
.creation_types
|
||||
.into_iter()
|
||||
.map(map_admin_creation_entry_type_config)
|
||||
.collect(),
|
||||
},
|
||||
build_admin_creation_entry_config_response(config),
|
||||
))
|
||||
}
|
||||
|
||||
/// 保存公开作品详情页点赞 / 改造能力配置。
|
||||
pub async fn admin_upsert_public_work_interaction_config(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(_admin): Extension<AuthenticatedAdmin>,
|
||||
Json(payload): Json<AdminUpsertPublicWorkInteractionConfigRequest>,
|
||||
) -> Result<Json<Value>, AppError> {
|
||||
let snapshots = payload
|
||||
.public_work_interactions
|
||||
.into_iter()
|
||||
.map(
|
||||
|entry| module_runtime::PublicWorkInteractionConfigSnapshot {
|
||||
source_type: entry.source_type,
|
||||
like_enabled: entry.like_enabled,
|
||||
remix_enabled: entry.remix_enabled,
|
||||
like_disabled_message: entry.like_disabled_message,
|
||||
remix_disabled_message: entry.remix_disabled_message,
|
||||
},
|
||||
)
|
||||
.collect::<Vec<_>>();
|
||||
let public_work_interactions_json =
|
||||
module_runtime::encode_public_work_interaction_config_snapshots(&snapshots)
|
||||
.and_then(|json| module_runtime::normalize_public_work_interaction_config_json(&json))
|
||||
.map_err(|error| AppError::from_status(StatusCode::BAD_REQUEST).with_message(error))?;
|
||||
let config = state
|
||||
.upsert_public_work_interaction_config(
|
||||
module_runtime::PublicWorkInteractionConfigAdminUpsertInput {
|
||||
public_work_interactions_json,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(map_admin_spacetime_error)?;
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
build_admin_creation_entry_config_response(config),
|
||||
))
|
||||
}
|
||||
|
||||
@@ -313,6 +331,20 @@ pub async fn admin_update_work_visibility(
|
||||
))
|
||||
}
|
||||
|
||||
fn build_admin_creation_entry_config_response(
|
||||
config: shared_contracts::creation_entry_config::CreationEntryConfigResponse,
|
||||
) -> AdminCreationEntryConfigResponse {
|
||||
AdminCreationEntryConfigResponse {
|
||||
event_banners: config.event_banners,
|
||||
public_work_interactions: config.public_work_interactions,
|
||||
entries: config
|
||||
.creation_types
|
||||
.into_iter()
|
||||
.map(map_admin_creation_entry_type_config)
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn map_admin_creation_entry_type_config(
|
||||
entry: shared_contracts::creation_entry_config::CreationEntryTypeResponse,
|
||||
) -> AdminCreationEntryTypeConfigPayload {
|
||||
|
||||
@@ -17,7 +17,9 @@ use tracing::{Level, Span, error, info_span};
|
||||
use crate::{
|
||||
auth::AuthenticatedAccessToken,
|
||||
backpressure::limit_concurrent_requests,
|
||||
creation_entry_config::require_creation_entry_route_enabled,
|
||||
creation_entry_config::{
|
||||
require_creation_entry_route_enabled, require_public_work_interaction_enabled,
|
||||
},
|
||||
error_middleware::normalize_error_response,
|
||||
http_error::AppError,
|
||||
modules,
|
||||
@@ -57,6 +59,10 @@ pub fn build_router(state: AppState) -> Router {
|
||||
state.clone(),
|
||||
require_creation_entry_route_enabled,
|
||||
))
|
||||
.layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_public_work_interaction_enabled,
|
||||
))
|
||||
// HTTP 背压在业务路由外侧快拒绝,避免过载请求继续占用 SpacetimeDB facade 与业务执行资源。
|
||||
.layer(middleware::from_fn_with_state(
|
||||
BackpressureState::from_ref(&state),
|
||||
@@ -590,6 +596,37 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn disabled_public_work_like_returns_service_unavailable() {
|
||||
let state = AppState::new(AppConfig::default()).expect("state should build");
|
||||
state.set_test_public_work_interaction_enabled(
|
||||
"puzzle",
|
||||
crate::creation_entry_config::PublicWorkInteractionAction::Like,
|
||||
false,
|
||||
);
|
||||
let app = build_router(state);
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/runtime/puzzle/gallery/profile-1/like")
|
||||
.body(Body::empty())
|
||||
.expect("request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
|
||||
let body = read_json_response(response).await;
|
||||
assert_eq!(
|
||||
body["error"]["details"]["reason"],
|
||||
"public_work_interaction_disabled"
|
||||
);
|
||||
assert_eq!(body["error"]["details"]["sourceType"], "puzzle");
|
||||
assert_eq!(body["error"]["details"]["action"], "like");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn disabled_visual_novel_creation_route_returns_service_unavailable() {
|
||||
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
|
||||
@@ -4228,6 +4265,62 @@ mod tests {
|
||||
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
/// 中文注释:验证后台作品互动配置保存后回到同一份入口配置响应。
|
||||
#[tokio::test]
|
||||
async fn admin_public_work_interactions_route_saves_form_payload() {
|
||||
let mut config = AppConfig::default();
|
||||
config.admin_username = Some("root".to_string());
|
||||
config.admin_password = Some("secret123".to_string());
|
||||
let app = build_router(AppState::new(config).expect("state should build"));
|
||||
let admin_token = read_admin_access_token(app.clone()).await;
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/admin/api/creation-entry/config/interactions")
|
||||
.header("authorization", format!("Bearer {admin_token}"))
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
serde_json::json!({
|
||||
"publicWorkInteractions": [
|
||||
{
|
||||
"sourceType": "puzzle",
|
||||
"likeEnabled": false,
|
||||
"remixEnabled": true,
|
||||
"likeDisabledMessage": "拼图点赞维护中。",
|
||||
"remixDisabledMessage": "拼图作品改造暂不可用。"
|
||||
}
|
||||
]
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("interactions request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("interactions request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = response
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.expect("interactions body should collect")
|
||||
.to_bytes();
|
||||
let payload: Value =
|
||||
serde_json::from_slice(&body).expect("interactions payload should be json");
|
||||
let puzzle = payload["publicWorkInteractions"]
|
||||
.as_array()
|
||||
.expect("interactions should be array")
|
||||
.iter()
|
||||
.find(|item| item["sourceType"] == "puzzle")
|
||||
.expect("puzzle interaction should exist");
|
||||
|
||||
assert_eq!(puzzle["likeEnabled"], false);
|
||||
assert_eq!(puzzle["remixEnabled"], true);
|
||||
assert_eq!(puzzle["likeDisabledMessage"], "拼图点赞维护中。");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn admin_debug_http_can_probe_healthz_when_authenticated() {
|
||||
let mut config = AppConfig::default();
|
||||
|
||||
@@ -17,6 +17,21 @@ use crate::{
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum PublicWorkInteractionAction {
|
||||
Like,
|
||||
Remix,
|
||||
}
|
||||
|
||||
impl PublicWorkInteractionAction {
|
||||
fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Like => "like",
|
||||
Self::Remix => "remix",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 中文注释:入口配置由 SpacetimeDB 表提供;api-server 只负责读取同一份配置并熔断运行态路由。
|
||||
pub async fn get_creation_entry_config_handler(
|
||||
State(state): State<AppState>,
|
||||
@@ -71,6 +86,68 @@ pub async fn require_creation_entry_route_enabled(
|
||||
next.run(request).await
|
||||
}
|
||||
|
||||
/// 中文注释:公开作品互动配置只拦点赞 / 改造动作,不影响作品详情读取和正式游玩。
|
||||
pub async fn require_public_work_interaction_enabled(
|
||||
State(state): State<AppState>,
|
||||
request: Request<Body>,
|
||||
next: Next,
|
||||
) -> Response {
|
||||
let path = request.uri().path();
|
||||
if let Some((source_type, action)) = resolve_public_work_interaction_route(path) {
|
||||
match state
|
||||
.is_public_work_interaction_enabled(source_type, action)
|
||||
.await
|
||||
{
|
||||
Ok(true) => {}
|
||||
Ok(false) => {
|
||||
return AppError::from_status(StatusCode::SERVICE_UNAVAILABLE)
|
||||
.with_message("该作品互动暂不可用")
|
||||
.with_details(json!({
|
||||
"reason": "public_work_interaction_disabled",
|
||||
"sourceType": source_type,
|
||||
"action": action.as_str(),
|
||||
}))
|
||||
.into();
|
||||
}
|
||||
Err(error) => {
|
||||
return AppError::from_status(StatusCode::BAD_GATEWAY)
|
||||
.with_message("读取作品互动配置失败")
|
||||
.with_details(json!({
|
||||
"provider": "spacetimedb",
|
||||
"message": error.to_string(),
|
||||
}))
|
||||
.into();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
next.run(request).await
|
||||
}
|
||||
|
||||
pub(crate) fn resolve_public_work_interaction_route(
|
||||
path: &str,
|
||||
) -> Option<(&'static str, PublicWorkInteractionAction)> {
|
||||
let action = if path.ends_with("/like") {
|
||||
PublicWorkInteractionAction::Like
|
||||
} else if path.ends_with("/remix") {
|
||||
PublicWorkInteractionAction::Remix
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
|
||||
if path.starts_with("/api/runtime/custom-world-gallery/") {
|
||||
return Some(("custom-world", action));
|
||||
}
|
||||
if path.starts_with("/api/runtime/big-fish/gallery/") {
|
||||
return Some(("big-fish", action));
|
||||
}
|
||||
if path.starts_with("/api/runtime/puzzle/gallery/") {
|
||||
return Some(("puzzle", action));
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
pub(crate) fn resolve_creation_entry_mud_point_cost_from_config(
|
||||
config: &CreationEntryConfigResponse,
|
||||
creation_type_id: &str,
|
||||
@@ -142,6 +219,9 @@ pub(crate) fn default_creation_entry_config_response() -> CreationEntryConfigRes
|
||||
event_banners_json: Some(module_runtime::default_creation_entry_event_banners_json()),
|
||||
creation_types: module_runtime::default_creation_entry_type_snapshots(0),
|
||||
updated_at_micros: 0,
|
||||
public_work_interactions_json: Some(
|
||||
module_runtime::default_public_work_interaction_config_json(),
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -242,6 +322,28 @@ mod tests {
|
||||
assert_eq!(resolve_creation_entry_route_id("/healthz"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_public_work_interaction_routes() {
|
||||
assert_eq!(
|
||||
resolve_public_work_interaction_route("/api/runtime/puzzle/gallery/profile-1/like"),
|
||||
Some(("puzzle", PublicWorkInteractionAction::Like)),
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_public_work_interaction_route(
|
||||
"/api/runtime/custom-world-gallery/user-1/profile-1/remix"
|
||||
),
|
||||
Some(("custom-world", PublicWorkInteractionAction::Remix)),
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_public_work_interaction_route("/api/runtime/puzzle/gallery/profile-1"),
|
||||
None,
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_public_work_interaction_route("/api/runtime/wooden-fish/runs/run-1"),
|
||||
None,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_mud_point_cost_from_unified_creation_spec() {
|
||||
let mut config = test_creation_entry_config_response();
|
||||
|
||||
@@ -9,7 +9,7 @@ use crate::{
|
||||
admin_list_database_tables, admin_list_tracking_events, admin_list_work_visibility,
|
||||
admin_login, admin_me, admin_overview, admin_update_work_visibility,
|
||||
admin_upsert_creation_entry_config, admin_upsert_creation_entry_event_banners_config,
|
||||
require_admin_auth,
|
||||
admin_upsert_public_work_interaction_config, require_admin_auth,
|
||||
},
|
||||
runtime_profile::{
|
||||
admin_disable_profile_redeem_code, admin_disable_profile_task_config,
|
||||
@@ -81,6 +81,12 @@ pub fn router(state: AppState) -> Router<AppState> {
|
||||
middleware::from_fn_with_state(state.clone(), require_admin_auth),
|
||||
),
|
||||
)
|
||||
.route(
|
||||
"/admin/api/creation-entry/config/interactions",
|
||||
post(admin_upsert_public_work_interaction_config).route_layer(
|
||||
middleware::from_fn_with_state(state.clone(), require_admin_auth),
|
||||
),
|
||||
)
|
||||
.route(
|
||||
"/admin/api/works/visibility",
|
||||
get(admin_list_work_visibility)
|
||||
|
||||
@@ -559,6 +559,44 @@ impl AppState {
|
||||
}
|
||||
}
|
||||
|
||||
/// 通过 SpacetimeDB 保存公开作品互动配置,并同步测试缓存。
|
||||
pub async fn upsert_public_work_interaction_config(
|
||||
&self,
|
||||
input: module_runtime::PublicWorkInteractionConfigAdminUpsertInput,
|
||||
) -> Result<CreationEntryConfigResponse, SpacetimeClientError> {
|
||||
#[cfg(test)]
|
||||
let test_interactions_json = input.public_work_interactions_json.clone();
|
||||
match self
|
||||
.spacetime_client
|
||||
.upsert_public_work_interaction_config(input)
|
||||
.await
|
||||
{
|
||||
Ok(config) => {
|
||||
#[cfg(test)]
|
||||
self.cache_test_creation_entry_config(config.clone());
|
||||
Ok(config)
|
||||
}
|
||||
#[cfg(test)]
|
||||
Err(_) => {
|
||||
let mut config = self.read_test_creation_entry_config();
|
||||
if let Ok(interactions) =
|
||||
module_runtime::decode_public_work_interaction_config_snapshots(
|
||||
test_interactions_json.as_str(),
|
||||
)
|
||||
{
|
||||
config.public_work_interactions = interactions
|
||||
.into_iter()
|
||||
.map(module_runtime::build_public_work_interaction_config_response)
|
||||
.collect();
|
||||
self.cache_test_creation_entry_config(config.clone());
|
||||
}
|
||||
Ok(config)
|
||||
}
|
||||
#[cfg(not(test))]
|
||||
Err(error) => Err(error),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_creation_entry_config(
|
||||
&self,
|
||||
) -> Result<CreationEntryConfigResponse, SpacetimeClientError> {
|
||||
@@ -619,6 +657,53 @@ impl AppState {
|
||||
.unwrap_or(true))
|
||||
}
|
||||
|
||||
pub async fn is_public_work_interaction_enabled(
|
||||
&self,
|
||||
source_type: &str,
|
||||
action: crate::creation_entry_config::PublicWorkInteractionAction,
|
||||
) -> Result<bool, SpacetimeClientError> {
|
||||
let config = self.get_creation_entry_config().await?;
|
||||
Ok(config
|
||||
.public_work_interactions
|
||||
.iter()
|
||||
.find(|item| item.source_type == source_type)
|
||||
.map(|item| match action {
|
||||
crate::creation_entry_config::PublicWorkInteractionAction::Like => {
|
||||
item.like_enabled
|
||||
}
|
||||
crate::creation_entry_config::PublicWorkInteractionAction::Remix => {
|
||||
item.remix_enabled
|
||||
}
|
||||
})
|
||||
.unwrap_or(true))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn set_test_public_work_interaction_enabled(
|
||||
&self,
|
||||
source_type: impl AsRef<str>,
|
||||
action: crate::creation_entry_config::PublicWorkInteractionAction,
|
||||
enabled: bool,
|
||||
) {
|
||||
let source_type = source_type.as_ref();
|
||||
let mut config = self.read_test_creation_entry_config();
|
||||
if let Some(item) = config
|
||||
.public_work_interactions
|
||||
.iter_mut()
|
||||
.find(|item| item.source_type == source_type)
|
||||
{
|
||||
match action {
|
||||
crate::creation_entry_config::PublicWorkInteractionAction::Like => {
|
||||
item.like_enabled = enabled;
|
||||
}
|
||||
crate::creation_entry_config::PublicWorkInteractionAction::Remix => {
|
||||
item.remix_enabled = enabled;
|
||||
}
|
||||
}
|
||||
}
|
||||
self.cache_test_creation_entry_config(config);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn set_test_creation_entry_route_enabled(
|
||||
&self,
|
||||
|
||||
@@ -11,7 +11,7 @@ use crate::errors::RuntimeProfileFieldError;
|
||||
use crate::format_utc_micros;
|
||||
use shared_contracts::creation_entry_config::{
|
||||
CreationEntryConfigResponse, CreationEntryEventBannerResponse, CreationEntryStartCardResponse,
|
||||
CreationEntryTypeModalResponse, CreationEntryTypeResponse,
|
||||
CreationEntryTypeModalResponse, CreationEntryTypeResponse, PublicWorkInteractionConfigResponse,
|
||||
encode_unified_creation_spec_response, resolve_unified_creation_spec_response,
|
||||
};
|
||||
|
||||
@@ -27,6 +27,9 @@ pub fn build_creation_entry_config_response(
|
||||
.first()
|
||||
.cloned()
|
||||
.unwrap_or_else(|| build_creation_entry_event_banner_response(snapshot.event_banner));
|
||||
let public_work_interactions = resolve_public_work_interaction_config_responses(
|
||||
snapshot.public_work_interactions_json.as_deref(),
|
||||
);
|
||||
|
||||
CreationEntryConfigResponse {
|
||||
start_card: CreationEntryStartCardResponse {
|
||||
@@ -41,6 +44,7 @@ pub fn build_creation_entry_config_response(
|
||||
},
|
||||
event_banner,
|
||||
event_banners,
|
||||
public_work_interactions,
|
||||
creation_types: snapshot
|
||||
.creation_types
|
||||
.into_iter()
|
||||
@@ -69,6 +73,223 @@ pub fn build_creation_entry_config_response(
|
||||
}
|
||||
}
|
||||
|
||||
/// 返回公开作品点赞 / 改造默认矩阵,保持历史前端硬编码能力不变。
|
||||
pub fn default_public_work_interaction_config_snapshots() -> Vec<PublicWorkInteractionConfigSnapshot>
|
||||
{
|
||||
vec![
|
||||
public_work_interaction_config(
|
||||
"custom-world",
|
||||
true,
|
||||
true,
|
||||
"RPG 作品暂不支持点赞。",
|
||||
"RPG 作品暂不支持改造。",
|
||||
),
|
||||
public_work_interaction_config(
|
||||
"big-fish",
|
||||
true,
|
||||
true,
|
||||
"摸鱼点赞暂不可用。",
|
||||
"摸鱼作品改造暂不可用。",
|
||||
),
|
||||
public_work_interaction_config(
|
||||
"puzzle",
|
||||
true,
|
||||
true,
|
||||
"拼图点赞暂不可用。",
|
||||
"拼图作品改造暂不可用。",
|
||||
),
|
||||
public_work_interaction_config(
|
||||
"puzzle-clear",
|
||||
false,
|
||||
false,
|
||||
"拼消消点赞将在后续版本开放。",
|
||||
"拼消消作品改造将在后续版本开放。",
|
||||
),
|
||||
public_work_interaction_config(
|
||||
"jump-hop",
|
||||
false,
|
||||
false,
|
||||
"作品类型 jump-hop 暂不支持点赞。",
|
||||
"跳一跳作品改造将在后续版本开放。",
|
||||
),
|
||||
public_work_interaction_config(
|
||||
"wooden-fish",
|
||||
false,
|
||||
false,
|
||||
"作品类型 wooden-fish 暂不支持点赞。",
|
||||
"敲木鱼作品改造将在后续版本开放。",
|
||||
),
|
||||
public_work_interaction_config(
|
||||
"match3d",
|
||||
false,
|
||||
false,
|
||||
"作品类型 match3d 暂不支持点赞。",
|
||||
"抓大鹅作品改造将在后续版本开放。",
|
||||
),
|
||||
public_work_interaction_config(
|
||||
"square-hole",
|
||||
false,
|
||||
false,
|
||||
"方洞挑战点赞将在后续版本开放。",
|
||||
"方洞挑战作品改造将在后续版本开放。",
|
||||
),
|
||||
public_work_interaction_config(
|
||||
"visual-novel",
|
||||
false,
|
||||
false,
|
||||
"视觉小说点赞将在后续版本开放。",
|
||||
"视觉小说作品改造将在后续版本开放。",
|
||||
),
|
||||
public_work_interaction_config(
|
||||
"bark-battle",
|
||||
false,
|
||||
false,
|
||||
"汪汪声浪点赞将在后续版本开放。",
|
||||
"汪汪声浪作品改造将在后续版本开放。",
|
||||
),
|
||||
public_work_interaction_config(
|
||||
"edutainment",
|
||||
false,
|
||||
false,
|
||||
"宝贝识物点赞将在后续版本开放。",
|
||||
"宝贝识物作品改造将在创作链路接入后开放。",
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
fn public_work_interaction_config(
|
||||
source_type: &str,
|
||||
like_enabled: bool,
|
||||
remix_enabled: bool,
|
||||
like_disabled_message: &str,
|
||||
remix_disabled_message: &str,
|
||||
) -> PublicWorkInteractionConfigSnapshot {
|
||||
PublicWorkInteractionConfigSnapshot {
|
||||
source_type: source_type.to_string(),
|
||||
like_enabled,
|
||||
remix_enabled,
|
||||
like_disabled_message: like_disabled_message.to_string(),
|
||||
remix_disabled_message: remix_disabled_message.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 生成默认公开作品互动配置 JSON,供 SpacetimeDB 表字段持久化。
|
||||
pub fn default_public_work_interaction_config_json() -> String {
|
||||
encode_public_work_interaction_config_snapshots(
|
||||
&default_public_work_interaction_config_snapshots(),
|
||||
)
|
||||
.unwrap_or_else(|_| "[]".to_string())
|
||||
}
|
||||
|
||||
/// 校验并归一后台公开作品互动配置 JSON。
|
||||
pub fn normalize_public_work_interaction_config_json(input: &str) -> Result<String, String> {
|
||||
let trimmed = input.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Ok(default_public_work_interaction_config_json());
|
||||
}
|
||||
|
||||
let configs = decode_public_work_interaction_config_snapshots(trimmed)?;
|
||||
encode_public_work_interaction_config_snapshots(&configs)
|
||||
}
|
||||
|
||||
/// 解析公开作品互动配置 JSON,并补齐缺失 sourceType 的默认项。
|
||||
pub fn decode_public_work_interaction_config_snapshots(
|
||||
input: &str,
|
||||
) -> Result<Vec<PublicWorkInteractionConfigSnapshot>, String> {
|
||||
let raw_entries = serde_json::from_str::<Vec<PublicWorkInteractionConfigResponse>>(input)
|
||||
.map_err(|error| format!("作品互动配置 JSON 非法:{error}"))?;
|
||||
if raw_entries.len() > PUBLIC_WORK_INTERACTION_CONFIG_MAX_COUNT {
|
||||
return Err(format!(
|
||||
"作品互动配置最多允许 {} 条",
|
||||
PUBLIC_WORK_INTERACTION_CONFIG_MAX_COUNT
|
||||
));
|
||||
}
|
||||
|
||||
let defaults = default_public_work_interaction_config_snapshots();
|
||||
let default_by_source = defaults
|
||||
.iter()
|
||||
.map(|item| (item.source_type.clone(), item.clone()))
|
||||
.collect::<BTreeMap<_, _>>();
|
||||
let mut overrides = BTreeMap::<String, PublicWorkInteractionConfigSnapshot>::new();
|
||||
|
||||
for (index, entry) in raw_entries.into_iter().enumerate() {
|
||||
let source_type = entry.source_type.trim().to_string();
|
||||
let Some(default_entry) = default_by_source.get(&source_type) else {
|
||||
return Err(format!("第 {} 条作品类型非法:{}", index + 1, source_type));
|
||||
};
|
||||
if overrides.contains_key(&source_type) {
|
||||
return Err(format!("作品互动配置 sourceType 重复:{source_type}"));
|
||||
}
|
||||
overrides.insert(
|
||||
source_type.clone(),
|
||||
PublicWorkInteractionConfigSnapshot {
|
||||
source_type,
|
||||
like_enabled: entry.like_enabled,
|
||||
remix_enabled: entry.remix_enabled,
|
||||
like_disabled_message: normalize_interaction_message(
|
||||
entry.like_disabled_message,
|
||||
&default_entry.like_disabled_message,
|
||||
),
|
||||
remix_disabled_message: normalize_interaction_message(
|
||||
entry.remix_disabled_message,
|
||||
&default_entry.remix_disabled_message,
|
||||
),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Ok(defaults
|
||||
.into_iter()
|
||||
.map(|item| overrides.remove(&item.source_type).unwrap_or(item))
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn normalize_interaction_message(value: String, fallback: &str) -> String {
|
||||
let trimmed = value.trim();
|
||||
if trimmed.is_empty() {
|
||||
fallback.to_string()
|
||||
} else {
|
||||
trimmed.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// 把公开作品互动领域快照编码为稳定 JSON。
|
||||
pub fn encode_public_work_interaction_config_snapshots(
|
||||
configs: &[PublicWorkInteractionConfigSnapshot],
|
||||
) -> Result<String, String> {
|
||||
let responses = configs
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(build_public_work_interaction_config_response)
|
||||
.collect::<Vec<_>>();
|
||||
serde_json::to_string_pretty(&responses)
|
||||
.map_err(|error| format!("作品互动配置 JSON 序列化失败:{error}"))
|
||||
}
|
||||
|
||||
/// 根据持久化 JSON 得到前台可消费的公开作品互动矩阵。
|
||||
pub fn resolve_public_work_interaction_config_responses(
|
||||
public_work_interactions_json: Option<&str>,
|
||||
) -> Vec<PublicWorkInteractionConfigResponse> {
|
||||
public_work_interactions_json
|
||||
.and_then(|raw| decode_public_work_interaction_config_snapshots(raw).ok())
|
||||
.unwrap_or_else(default_public_work_interaction_config_snapshots)
|
||||
.into_iter()
|
||||
.map(build_public_work_interaction_config_response)
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn build_public_work_interaction_config_response(
|
||||
config: PublicWorkInteractionConfigSnapshot,
|
||||
) -> PublicWorkInteractionConfigResponse {
|
||||
PublicWorkInteractionConfigResponse {
|
||||
source_type: config.source_type,
|
||||
like_enabled: config.like_enabled,
|
||||
remix_enabled: config.remix_enabled,
|
||||
like_disabled_message: config.like_disabled_message,
|
||||
remix_disabled_message: config.remix_disabled_message,
|
||||
}
|
||||
}
|
||||
|
||||
/// 返回平台默认公告配置,用于空库种子和旧库兜底。
|
||||
pub fn default_creation_entry_event_banner_snapshots() -> Vec<CreationEntryEventBannerSnapshot> {
|
||||
vec![CreationEntryEventBannerSnapshot {
|
||||
|
||||
@@ -65,6 +65,8 @@ pub const DEFAULT_CREATION_ENTRY_EVENT_ENDS_AT_TEXT: &str = "2024.11.20 23:59";
|
||||
pub const CREATION_ENTRY_EVENT_BANNERS_MAX_COUNT: usize = 8;
|
||||
/// 单条 HTML 公告的代码大小上限,避免后台误贴超大片段拖慢入口页。
|
||||
pub const CREATION_ENTRY_EVENT_BANNER_HTML_CODE_MAX_BYTES: usize = 12_000;
|
||||
/// 公开作品互动配置最多允许覆盖的 sourceType 数量。
|
||||
pub const PUBLIC_WORK_INTERACTION_CONFIG_MAX_COUNT: usize = 32;
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
@@ -96,6 +98,17 @@ pub struct CreationEntryEventBannerSnapshot {
|
||||
pub html_code: Option<String>,
|
||||
}
|
||||
|
||||
/// 单类公开作品互动配置,控制作品详情页点赞 / 改造入口与后端动作熔断。
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct PublicWorkInteractionConfigSnapshot {
|
||||
pub source_type: String,
|
||||
pub like_enabled: bool,
|
||||
pub remix_enabled: bool,
|
||||
pub like_disabled_message: String,
|
||||
pub remix_disabled_message: String,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct CreationEntryTypeSnapshot {
|
||||
@@ -126,6 +139,8 @@ pub struct CreationEntryConfigSnapshot {
|
||||
pub event_banners_json: Option<String>,
|
||||
pub creation_types: Vec<CreationEntryTypeSnapshot>,
|
||||
pub updated_at_micros: i64,
|
||||
/// 公开作品点赞 / 改造能力 JSON 配置;旧库为空时由应用层兜底。
|
||||
pub public_work_interactions_json: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
@@ -153,6 +168,14 @@ pub struct CreationEntryEventBannersAdminUpsertInput {
|
||||
pub event_banners_json: String,
|
||||
}
|
||||
|
||||
/// 后台保存公开作品互动能力表单序列化结果的领域输入。
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct PublicWorkInteractionConfigAdminUpsertInput {
|
||||
/// 持久化字段沿用 JSON 字符串,内容由后台表单生成。
|
||||
pub public_work_interactions_json: String,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct CreationEntryConfigProcedureResult {
|
||||
|
||||
@@ -531,6 +531,7 @@ mod tests {
|
||||
),
|
||||
}],
|
||||
updated_at_micros: 1,
|
||||
public_work_interactions_json: Some(default_public_work_interaction_config_json()),
|
||||
});
|
||||
let puzzle = response
|
||||
.creation_types
|
||||
@@ -547,6 +548,37 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn public_work_interaction_config_defaults_and_overrides() {
|
||||
let defaults = resolve_public_work_interaction_config_responses(None);
|
||||
let puzzle = defaults
|
||||
.iter()
|
||||
.find(|item| item.source_type == "puzzle")
|
||||
.expect("puzzle interaction should exist");
|
||||
assert!(puzzle.like_enabled);
|
||||
assert!(puzzle.remix_enabled);
|
||||
|
||||
let normalized = normalize_public_work_interaction_config_json(
|
||||
r#"[{
|
||||
"sourceType": "puzzle",
|
||||
"likeEnabled": false,
|
||||
"remixEnabled": true,
|
||||
"likeDisabledMessage": "拼图点赞维护中。",
|
||||
"remixDisabledMessage": ""
|
||||
}]"#,
|
||||
)
|
||||
.expect("interaction config should normalize");
|
||||
let resolved = resolve_public_work_interaction_config_responses(Some(&normalized));
|
||||
let puzzle = resolved
|
||||
.iter()
|
||||
.find(|item| item.source_type == "puzzle")
|
||||
.expect("puzzle interaction should exist");
|
||||
|
||||
assert!(!puzzle.like_enabled);
|
||||
assert_eq!(puzzle.like_disabled_message, "拼图点赞维护中。");
|
||||
assert_eq!(puzzle.remix_disabled_message, "拼图作品改造暂不可用。");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalized_clamps_music_volume_into_valid_range() {
|
||||
let low = RuntimeSettings::normalized(-1.0, RuntimePlatformTheme::Light);
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::creation_entry_config::{CreationEntryEventBannerResponse, UnifiedCreationSpecResponse};
|
||||
use crate::creation_entry_config::{
|
||||
CreationEntryEventBannerResponse, PublicWorkInteractionConfigResponse,
|
||||
UnifiedCreationSpecResponse,
|
||||
};
|
||||
|
||||
// 管理后台协议统一收口在 shared-contracts,避免页面脚本和 Rust handler 各自手拼字段。
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
@@ -20,6 +23,8 @@ pub struct AdminCreationEntryConfigResponse {
|
||||
pub entries: Vec<AdminCreationEntryTypeConfigPayload>,
|
||||
/// 底部加号创作入口页的后台公告列表。
|
||||
pub event_banners: Vec<CreationEntryEventBannerResponse>,
|
||||
/// 公开作品详情页点赞 / 改造能力配置。
|
||||
pub public_work_interactions: Vec<PublicWorkInteractionConfigResponse>,
|
||||
}
|
||||
|
||||
/// 后台单个创作入口开关配置。
|
||||
@@ -69,6 +74,13 @@ pub struct AdminUpsertCreationEntryEventBannersRequest {
|
||||
pub event_banners_json: String,
|
||||
}
|
||||
|
||||
/// 后台保存公开作品点赞 / 改造能力配置请求。
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AdminUpsertPublicWorkInteractionConfigRequest {
|
||||
pub public_work_interactions: Vec<PublicWorkInteractionConfigResponse>,
|
||||
}
|
||||
|
||||
/// 后台作品可见性列表项。
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
|
||||
@@ -12,6 +12,7 @@ pub struct CreationEntryConfigResponse {
|
||||
pub type_modal: CreationEntryTypeModalResponse,
|
||||
pub event_banner: CreationEntryEventBannerResponse,
|
||||
pub event_banners: Vec<CreationEntryEventBannerResponse>,
|
||||
pub public_work_interactions: Vec<PublicWorkInteractionConfigResponse>,
|
||||
pub creation_types: Vec<CreationEntryTypeResponse>,
|
||||
}
|
||||
|
||||
@@ -57,6 +58,20 @@ pub fn default_creation_entry_event_banner_render_mode() -> String {
|
||||
"structured".to_string()
|
||||
}
|
||||
|
||||
/// 单类公开作品互动能力配置。
|
||||
///
|
||||
/// 后台可以关闭已接入的点赞 / 改造能力;未接入后端动作的玩法即使误开,
|
||||
/// 前端仍会按实际能力矩阵返回不可用提示。
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PublicWorkInteractionConfigResponse {
|
||||
pub source_type: String,
|
||||
pub like_enabled: bool,
|
||||
pub remix_enabled: bool,
|
||||
pub like_disabled_message: String,
|
||||
pub remix_disabled_message: String,
|
||||
}
|
||||
|
||||
/// 单个创作模板入口配置,决定底部加号入口中的分类、排序和开放状态。
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -547,6 +562,13 @@ mod tests {
|
||||
render_mode: "html".to_string(),
|
||||
html_code: Some("<section>ok</section>".to_string()),
|
||||
}],
|
||||
public_work_interactions: vec![PublicWorkInteractionConfigResponse {
|
||||
source_type: "puzzle".to_string(),
|
||||
like_enabled: true,
|
||||
remix_enabled: true,
|
||||
like_disabled_message: "拼图点赞暂不可用。".to_string(),
|
||||
remix_disabled_message: "拼图作品改造暂不可用。".to_string(),
|
||||
}],
|
||||
creation_types: Vec::new(),
|
||||
};
|
||||
let value = serde_json::to_value(response).expect("response should serialize");
|
||||
@@ -558,5 +580,6 @@ mod tests {
|
||||
);
|
||||
assert!(value.get("event_banner").is_none());
|
||||
assert!(value.get("eventBanner").is_some());
|
||||
assert_eq!(value["publicWorkInteractions"][0]["sourceType"], "puzzle");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,17 @@ impl From<module_runtime::CreationEntryEventBannersAdminUpsertInput>
|
||||
}
|
||||
}
|
||||
|
||||
/// 将业务层公开作品互动配置保存输入转换为 SpacetimeDB 生成绑定类型。
|
||||
impl From<module_runtime::PublicWorkInteractionConfigAdminUpsertInput>
|
||||
for PublicWorkInteractionConfigAdminUpsertInput
|
||||
{
|
||||
fn from(input: module_runtime::PublicWorkInteractionConfigAdminUpsertInput) -> Self {
|
||||
Self {
|
||||
public_work_interactions_json: input.public_work_interactions_json,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<module_runtime::AdminWorkVisibilityListInput> for AdminWorkVisibilityListInput {
|
||||
fn from(input: module_runtime::AdminWorkVisibilityListInput) -> Self {
|
||||
Self {
|
||||
@@ -323,6 +334,7 @@ pub(crate) fn build_creation_entry_config_record_from_rows(
|
||||
})
|
||||
.collect(),
|
||||
updated_at_micros: header.updated_at.to_micros_since_unix_epoch(),
|
||||
public_work_interactions_json: header.public_work_interactions_json,
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -376,6 +388,7 @@ fn map_creation_entry_config_snapshot(
|
||||
})
|
||||
.collect(),
|
||||
updated_at_micros: snapshot.updated_at_micros,
|
||||
public_work_interactions_json: snapshot.public_work_interactions_json,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -432,6 +445,7 @@ mod tests {
|
||||
event_starts_at_text: None,
|
||||
event_ends_at_text: None,
|
||||
event_banners_json: None,
|
||||
public_work_interactions_json: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -514,6 +528,7 @@ mod tests {
|
||||
unified_creation_spec_json: None,
|
||||
}],
|
||||
updated_at_micros: 1_000_000,
|
||||
public_work_interactions_json: None,
|
||||
});
|
||||
|
||||
let jump_hop = record
|
||||
|
||||
@@ -591,6 +591,7 @@ pub mod public_work_detail_entry_table;
|
||||
pub mod public_work_detail_entry_type;
|
||||
pub mod public_work_gallery_entry_table;
|
||||
pub mod public_work_gallery_entry_type;
|
||||
pub mod public_work_interaction_config_admin_upsert_input_type;
|
||||
pub mod public_work_like_table;
|
||||
pub mod public_work_like_type;
|
||||
pub mod public_work_play_daily_stat_table;
|
||||
@@ -1022,6 +1023,7 @@ pub mod upsert_custom_world_profile_reducer;
|
||||
pub mod upsert_npc_state_and_return_procedure;
|
||||
pub mod upsert_npc_state_reducer;
|
||||
pub mod upsert_platform_browse_history_and_return_procedure;
|
||||
pub mod upsert_public_work_interaction_config_procedure;
|
||||
pub mod upsert_runtime_setting_and_return_procedure;
|
||||
pub mod upsert_runtime_snapshot_and_return_procedure;
|
||||
pub mod upsert_visual_novel_run_snapshot_procedure;
|
||||
@@ -1697,6 +1699,7 @@ pub use public_work_detail_entry_table::*;
|
||||
pub use public_work_detail_entry_type::PublicWorkDetailEntry;
|
||||
pub use public_work_gallery_entry_table::*;
|
||||
pub use public_work_gallery_entry_type::PublicWorkGalleryEntry;
|
||||
pub use public_work_interaction_config_admin_upsert_input_type::PublicWorkInteractionConfigAdminUpsertInput;
|
||||
pub use public_work_like_table::*;
|
||||
pub use public_work_like_type::PublicWorkLike;
|
||||
pub use public_work_play_daily_stat_table::*;
|
||||
@@ -2128,6 +2131,7 @@ pub use upsert_custom_world_profile_reducer::upsert_custom_world_profile;
|
||||
pub use upsert_npc_state_and_return_procedure::upsert_npc_state_and_return;
|
||||
pub use upsert_npc_state_reducer::upsert_npc_state;
|
||||
pub use upsert_platform_browse_history_and_return_procedure::upsert_platform_browse_history_and_return;
|
||||
pub use upsert_public_work_interaction_config_procedure::upsert_public_work_interaction_config;
|
||||
pub use upsert_runtime_setting_and_return_procedure::upsert_runtime_setting_and_return;
|
||||
pub use upsert_runtime_snapshot_and_return_procedure::upsert_runtime_snapshot_and_return;
|
||||
pub use upsert_visual_novel_run_snapshot_procedure::upsert_visual_novel_run_snapshot;
|
||||
|
||||
@@ -19,6 +19,7 @@ pub struct CreationEntryConfigSnapshot {
|
||||
pub event_banners_json: Option<String>,
|
||||
pub creation_types: Vec<CreationEntryTypeSnapshot>,
|
||||
pub updated_at_micros: i64,
|
||||
pub public_work_interactions_json: Option<String>,
|
||||
}
|
||||
|
||||
impl __sdk::InModule for CreationEntryConfigSnapshot {
|
||||
|
||||
@@ -22,6 +22,7 @@ pub struct CreationEntryConfig {
|
||||
pub event_starts_at_text: Option<String>,
|
||||
pub event_ends_at_text: Option<String>,
|
||||
pub event_banners_json: Option<String>,
|
||||
pub public_work_interactions_json: Option<String>,
|
||||
}
|
||||
|
||||
impl __sdk::InModule for CreationEntryConfig {
|
||||
@@ -47,6 +48,8 @@ pub struct CreationEntryConfigCols {
|
||||
pub event_starts_at_text: __sdk::__query_builder::Col<CreationEntryConfig, Option<String>>,
|
||||
pub event_ends_at_text: __sdk::__query_builder::Col<CreationEntryConfig, Option<String>>,
|
||||
pub event_banners_json: __sdk::__query_builder::Col<CreationEntryConfig, Option<String>>,
|
||||
pub public_work_interactions_json:
|
||||
__sdk::__query_builder::Col<CreationEntryConfig, Option<String>>,
|
||||
}
|
||||
|
||||
impl __sdk::__query_builder::HasCols for CreationEntryConfig {
|
||||
@@ -77,6 +80,10 @@ impl __sdk::__query_builder::HasCols for CreationEntryConfig {
|
||||
),
|
||||
event_ends_at_text: __sdk::__query_builder::Col::new(table_name, "event_ends_at_text"),
|
||||
event_banners_json: __sdk::__query_builder::Col::new(table_name, "event_banners_json"),
|
||||
public_work_interactions_json: __sdk::__query_builder::Col::new(
|
||||
table_name,
|
||||
"public_work_interactions_json",
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,6 +115,34 @@ impl SpacetimeClient {
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
/// 调用 SpacetimeDB procedure 保存公开作品互动配置并刷新缓存。
|
||||
pub async fn upsert_public_work_interaction_config(
|
||||
&self,
|
||||
input: module_runtime::PublicWorkInteractionConfigAdminUpsertInput,
|
||||
) -> Result<CreationEntryConfigRecord, SpacetimeClientError> {
|
||||
let procedure_input: PublicWorkInteractionConfigAdminUpsertInput = input.into();
|
||||
let config = self
|
||||
.call_after_connect(
|
||||
"upsert_public_work_interaction_config",
|
||||
move |connection, sender| {
|
||||
connection
|
||||
.procedures()
|
||||
.upsert_public_work_interaction_config_then(
|
||||
procedure_input,
|
||||
move |_, result| {
|
||||
let mapped = result
|
||||
.map_err(SpacetimeClientError::from_sdk_error)
|
||||
.and_then(map_creation_entry_config_procedure_result);
|
||||
send_once(&sender, mapped);
|
||||
},
|
||||
);
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
self.cache_creation_entry_config(config.clone()).await;
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
pub async fn admin_list_work_visibility(
|
||||
&self,
|
||||
admin_user_id: String,
|
||||
|
||||
@@ -1193,6 +1193,9 @@ fn normalize_migration_row(table_name: &str, value: &serde_json::Value) -> serde
|
||||
object
|
||||
.entry("event_banners_json".to_string())
|
||||
.or_insert(serde_json::Value::Null);
|
||||
object
|
||||
.entry("public_work_interactions_json".to_string())
|
||||
.or_insert(serde_json::Value::Null);
|
||||
}
|
||||
}
|
||||
if table_name == "creation_entry_type_config" {
|
||||
|
||||
@@ -26,6 +26,9 @@ pub struct CreationEntryConfig {
|
||||
/// 底部加号创作入口页的多 banner JSON 配置,旧单条字段仅用于兼容。
|
||||
#[default(None::<String>)]
|
||||
pub(crate) event_banners_json: Option<String>,
|
||||
/// 公开作品点赞 / 改造能力配置,旧库为空时由读取层按默认矩阵兜底。
|
||||
#[default(None::<String>)]
|
||||
pub(crate) public_work_interactions_json: Option<String>,
|
||||
}
|
||||
|
||||
#[spacetimedb::table(
|
||||
@@ -109,6 +112,26 @@ pub fn upsert_creation_entry_event_banners_config(
|
||||
}
|
||||
}
|
||||
|
||||
#[spacetimedb::procedure]
|
||||
/// 后台保存公开作品点赞 / 改造能力配置的过程入口。
|
||||
pub fn upsert_public_work_interaction_config(
|
||||
ctx: &mut ProcedureContext,
|
||||
input: PublicWorkInteractionConfigAdminUpsertInput,
|
||||
) -> CreationEntryConfigProcedureResult {
|
||||
match ctx.try_with_tx(|tx| upsert_public_work_interaction_config_in_tx(tx, input.clone())) {
|
||||
Ok(record) => CreationEntryConfigProcedureResult {
|
||||
ok: true,
|
||||
record: Some(record),
|
||||
error_message: None,
|
||||
},
|
||||
Err(message) => CreationEntryConfigProcedureResult {
|
||||
ok: false,
|
||||
record: None,
|
||||
error_message: Some(message),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn upsert_creation_entry_type_config_in_tx(
|
||||
ctx: &ReducerContext,
|
||||
input: CreationEntryTypeAdminUpsertInput,
|
||||
@@ -171,6 +194,33 @@ fn upsert_creation_entry_event_banners_config_in_tx(
|
||||
get_or_seed_creation_entry_config_snapshot(ctx)
|
||||
}
|
||||
|
||||
/// 在事务内归一化公开作品互动配置 JSON 并更新全局入口配置表头。
|
||||
fn upsert_public_work_interaction_config_in_tx(
|
||||
ctx: &ReducerContext,
|
||||
input: PublicWorkInteractionConfigAdminUpsertInput,
|
||||
) -> Result<CreationEntryConfigSnapshot, String> {
|
||||
seed_creation_entry_config_if_missing(ctx);
|
||||
let now = ctx.timestamp;
|
||||
let config_id = CREATION_ENTRY_CONFIG_GLOBAL_ID.to_string();
|
||||
let Some(header) = ctx.db.creation_entry_config().config_id().find(&config_id) else {
|
||||
return Err("创作入口配置初始化失败".to_string());
|
||||
};
|
||||
let public_work_interactions_json =
|
||||
module_runtime::normalize_public_work_interaction_config_json(
|
||||
&input.public_work_interactions_json,
|
||||
)?;
|
||||
|
||||
ctx.db
|
||||
.creation_entry_config()
|
||||
.config_id()
|
||||
.update(CreationEntryConfig {
|
||||
updated_at: now,
|
||||
public_work_interactions_json: Some(public_work_interactions_json),
|
||||
..header
|
||||
});
|
||||
get_or_seed_creation_entry_config_snapshot(ctx)
|
||||
}
|
||||
|
||||
fn get_or_seed_creation_entry_config_snapshot(
|
||||
ctx: &ReducerContext,
|
||||
) -> Result<CreationEntryConfigSnapshot, String> {
|
||||
@@ -247,6 +297,7 @@ fn get_or_seed_creation_entry_config_snapshot(
|
||||
event_banners_json: header.event_banners_json,
|
||||
creation_types,
|
||||
updated_at_micros: header.updated_at.to_micros_since_unix_epoch(),
|
||||
public_work_interactions_json: header.public_work_interactions_json,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -276,6 +327,9 @@ fn seed_creation_entry_config_if_missing(ctx: &ReducerContext) {
|
||||
event_starts_at_text: Some(DEFAULT_CREATION_ENTRY_EVENT_STARTS_AT_TEXT.to_string()),
|
||||
event_ends_at_text: Some(DEFAULT_CREATION_ENTRY_EVENT_ENDS_AT_TEXT.to_string()),
|
||||
event_banners_json: Some(module_runtime::default_creation_entry_event_banners_json()),
|
||||
public_work_interactions_json: Some(
|
||||
module_runtime::default_public_work_interaction_config_json(),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -372,9 +372,7 @@ import {
|
||||
type CreationWorkShelfItem,
|
||||
isPersistedBarkBattleDraftGenerating,
|
||||
} from '../custom-world-home/creationWorkShelf';
|
||||
import {
|
||||
selectAdjacentPlatformRecommendEntry,
|
||||
} from '../rpg-entry/rpgEntryPublicGalleryViewModel';
|
||||
import { selectAdjacentPlatformRecommendEntry } from '../rpg-entry/rpgEntryPublicGalleryViewModel';
|
||||
import {
|
||||
isBigFishGalleryEntry,
|
||||
isEdutainmentGalleryEntry,
|
||||
@@ -1194,7 +1192,9 @@ const PuzzleClearResultView = lazy(async () => {
|
||||
});
|
||||
|
||||
const PuzzleClearRuntimeShell = lazy(async () => {
|
||||
const module = await import('../puzzle-clear-runtime/PuzzleClearRuntimeShell');
|
||||
const module = await import(
|
||||
'../puzzle-clear-runtime/PuzzleClearRuntimeShell'
|
||||
);
|
||||
return {
|
||||
default: module.PuzzleClearRuntimeShell,
|
||||
};
|
||||
@@ -1712,6 +1712,10 @@ export function PlatformEntryFlowShellImpl({
|
||||
const entries = creationEntryConfig?.creationTypes ?? [];
|
||||
return new Map(entries.map((entry) => [entry.id, entry]));
|
||||
}, [creationEntryConfig]);
|
||||
const publicWorkInteractions = useMemo(
|
||||
() => creationEntryConfig?.publicWorkInteractions ?? [],
|
||||
[creationEntryConfig],
|
||||
);
|
||||
const getUnifiedSpec = useCallback(
|
||||
(playId: UnifiedCreationPlayId) =>
|
||||
getUnifiedCreationSpec(playId, unifiedCreationConfigById.get(playId)),
|
||||
@@ -2900,8 +2904,10 @@ export function PlatformEntryFlowShellImpl({
|
||||
woodenFishGalleryEntries,
|
||||
],
|
||||
);
|
||||
const { featuredEntries: featuredGalleryEntries, latestEntries: latestGalleryEntries } =
|
||||
publicGalleryFeeds;
|
||||
const {
|
||||
featuredEntries: featuredGalleryEntries,
|
||||
latestEntries: latestGalleryEntries,
|
||||
} = publicGalleryFeeds;
|
||||
const recommendRuntimeEntries = useMemo(
|
||||
() =>
|
||||
buildPlatformRecommendedEntries({
|
||||
@@ -3084,23 +3090,22 @@ export function PlatformEntryFlowShellImpl({
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const progressTickDecision =
|
||||
resolvePlatformGenerationProgressTickDecision({
|
||||
selectionStage,
|
||||
miniGameStates: {
|
||||
puzzle: puzzleGenerationState,
|
||||
match3d: match3dGenerationState,
|
||||
'big-fish': bigFishGenerationState,
|
||||
'square-hole': squareHoleGenerationState,
|
||||
'jump-hop': jumpHopGenerationState,
|
||||
'wooden-fish': woodenFishGenerationState,
|
||||
'baby-object-match': babyObjectMatchGenerationState,
|
||||
},
|
||||
visualNovel: {
|
||||
startedAtMs: visualNovelGenerationStartedAtMs,
|
||||
phase: visualNovelGenerationPhase,
|
||||
},
|
||||
});
|
||||
const progressTickDecision = resolvePlatformGenerationProgressTickDecision({
|
||||
selectionStage,
|
||||
miniGameStates: {
|
||||
puzzle: puzzleGenerationState,
|
||||
match3d: match3dGenerationState,
|
||||
'big-fish': bigFishGenerationState,
|
||||
'square-hole': squareHoleGenerationState,
|
||||
'jump-hop': jumpHopGenerationState,
|
||||
'wooden-fish': woodenFishGenerationState,
|
||||
'baby-object-match': babyObjectMatchGenerationState,
|
||||
},
|
||||
visualNovel: {
|
||||
startedAtMs: visualNovelGenerationStartedAtMs,
|
||||
phase: visualNovelGenerationPhase,
|
||||
},
|
||||
});
|
||||
|
||||
if (!progressTickDecision.shouldTick) {
|
||||
return undefined;
|
||||
@@ -3603,7 +3608,11 @@ export function PlatformEntryFlowShellImpl({
|
||||
markPendingDraftFailed('match3d', session.sessionId);
|
||||
markDraftFailed(
|
||||
'match3d',
|
||||
[session.draft?.profileId, session.publishedProfileId, session.sessionId],
|
||||
[
|
||||
session.draft?.profileId,
|
||||
session.publishedProfileId,
|
||||
session.sessionId,
|
||||
],
|
||||
errorMessage,
|
||||
);
|
||||
try {
|
||||
@@ -3885,7 +3894,11 @@ export function PlatformEntryFlowShellImpl({
|
||||
markPendingDraftFailed('square-hole', session.sessionId);
|
||||
markDraftFailed(
|
||||
'square-hole',
|
||||
[session.draft?.profileId, session.publishedProfileId, session.sessionId],
|
||||
[
|
||||
session.draft?.profileId,
|
||||
session.publishedProfileId,
|
||||
session.sessionId,
|
||||
],
|
||||
errorMessage,
|
||||
);
|
||||
void refreshSquareHoleShelf().catch(() => undefined);
|
||||
@@ -3965,15 +3978,18 @@ export function PlatformEntryFlowShellImpl({
|
||||
if (!isPuzzleCompileActionReady(response.session)) {
|
||||
const nextPayload =
|
||||
formPayload ?? buildPuzzleFormPayloadFromSession(response.session);
|
||||
const fallbackGenerationState = createPuzzleDraftGenerationStateFromPayload(
|
||||
nextPayload,
|
||||
response.session,
|
||||
);
|
||||
const nextGenerationState = mergePuzzleSessionProgressIntoGenerationState(
|
||||
puzzleGenerationState ?? fallbackGenerationState,
|
||||
response.session,
|
||||
);
|
||||
activePuzzleGenerationSessionIdRef.current = response.session.sessionId;
|
||||
const fallbackGenerationState =
|
||||
createPuzzleDraftGenerationStateFromPayload(
|
||||
nextPayload,
|
||||
response.session,
|
||||
);
|
||||
const nextGenerationState =
|
||||
mergePuzzleSessionProgressIntoGenerationState(
|
||||
puzzleGenerationState ?? fallbackGenerationState,
|
||||
response.session,
|
||||
);
|
||||
activePuzzleGenerationSessionIdRef.current =
|
||||
response.session.sessionId;
|
||||
setSelectionStage('puzzle-generating');
|
||||
markDraftGenerating('puzzle', [
|
||||
response.session.sessionId,
|
||||
@@ -7726,8 +7742,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
...current.filter(
|
||||
(item) =>
|
||||
item.workId !== response.work!.summary.workId &&
|
||||
item.sourceSessionId !==
|
||||
response.work!.summary.sourceSessionId,
|
||||
item.sourceSessionId !== response.work!.summary.sourceSessionId,
|
||||
),
|
||||
]);
|
||||
markPendingDraftReady(
|
||||
@@ -7849,7 +7864,8 @@ export function PlatformEntryFlowShellImpl({
|
||||
workTitle: puzzleClearSession.draft?.workTitle,
|
||||
workDescription: puzzleClearSession.draft?.workDescription,
|
||||
themePrompt: puzzleClearSession.draft?.themePrompt,
|
||||
boardBackgroundPrompt: puzzleClearSession.draft?.boardBackgroundPrompt,
|
||||
boardBackgroundPrompt:
|
||||
puzzleClearSession.draft?.boardBackgroundPrompt,
|
||||
generateBoardBackground:
|
||||
puzzleClearSession.draft?.generateBoardBackground,
|
||||
boardBackgroundAsset: puzzleClearSession.draft?.boardBackgroundAsset,
|
||||
@@ -7874,11 +7890,9 @@ export function PlatformEntryFlowShellImpl({
|
||||
);
|
||||
setPuzzleClearError(errorMessage);
|
||||
setPuzzleClearGenerationState(
|
||||
resolveFinishedMiniGameDraftGenerationState(
|
||||
generationState,
|
||||
'failed',
|
||||
{ error: errorMessage },
|
||||
),
|
||||
resolveFinishedMiniGameDraftGenerationState(generationState, 'failed', {
|
||||
error: errorMessage,
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
setIsPuzzleClearBusy(false);
|
||||
@@ -7905,7 +7919,9 @@ export function PlatformEntryFlowShellImpl({
|
||||
setPuzzleClearWork(response.item);
|
||||
setPuzzleClearWorks((current) => [
|
||||
response.item.summary,
|
||||
...current.filter((item) => item.workId !== response.item.summary.workId),
|
||||
...current.filter(
|
||||
(item) => item.workId !== response.item.summary.workId,
|
||||
),
|
||||
]);
|
||||
void refreshPuzzleClearShelf();
|
||||
void refreshPuzzleClearGallery();
|
||||
@@ -7918,7 +7934,9 @@ export function PlatformEntryFlowShellImpl({
|
||||
setPublicWorkDetailError(null);
|
||||
selectionStageRef.current = 'work-detail';
|
||||
setSelectionStage('work-detail');
|
||||
pushAppHistoryPath(buildPublicWorkStagePath('work-detail', publicWorkCode));
|
||||
pushAppHistoryPath(
|
||||
buildPublicWorkStagePath('work-detail', publicWorkCode),
|
||||
);
|
||||
openPublishShareModal({
|
||||
title: response.item.summary.workTitle || '拼消消',
|
||||
publicWorkCode,
|
||||
@@ -8020,7 +8038,9 @@ export function PlatformEntryFlowShellImpl({
|
||||
return;
|
||||
}
|
||||
setPuzzleClearError(null);
|
||||
setPuzzleClearRun(retryPuzzleClearLocalLevel(puzzleClearRun, puzzleClearWork));
|
||||
setPuzzleClearRun(
|
||||
retryPuzzleClearLocalLevel(puzzleClearRun, puzzleClearWork),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -10653,7 +10673,10 @@ export function PlatformEntryFlowShellImpl({
|
||||
setIsPublicWorkDetailBusy(true);
|
||||
setPublicWorkDetailError(null);
|
||||
|
||||
const intent = resolvePlatformPublicWorkLikeIntent(entry);
|
||||
const intent = resolvePlatformPublicWorkLikeIntent(
|
||||
entry,
|
||||
publicWorkInteractions,
|
||||
);
|
||||
|
||||
if (intent.type === 'like-big-fish') {
|
||||
void likeBigFishGalleryWork(intent.profileId)
|
||||
@@ -10763,6 +10786,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
[
|
||||
isPublicWorkDetailBusy,
|
||||
platformBootstrap,
|
||||
publicWorkInteractions,
|
||||
resolveBigFishErrorMessage,
|
||||
resolvePuzzleErrorMessage,
|
||||
runProtectedAction,
|
||||
@@ -11049,7 +11073,8 @@ export function PlatformEntryFlowShellImpl({
|
||||
notices: draftGenerationNotices,
|
||||
generation: {
|
||||
activeSessionId: jumpHopSession?.sessionId,
|
||||
hasActiveGenerationFailure: jumpHopGenerationState?.phase === 'failed',
|
||||
hasActiveGenerationFailure:
|
||||
jumpHopGenerationState?.phase === 'failed',
|
||||
},
|
||||
});
|
||||
markDraftNoticeSeen(openIntent.noticeKeys);
|
||||
@@ -11133,7 +11158,9 @@ export function PlatformEntryFlowShellImpl({
|
||||
try {
|
||||
const detail = await puzzleClearClient.getRuntimeWorkDetail(profileId);
|
||||
setPuzzleClearWork(detail.item);
|
||||
openPublicWorkDetail(mapPuzzleClearWorkToPlatformGalleryCard(detail.item));
|
||||
openPublicWorkDetail(
|
||||
mapPuzzleClearWorkToPlatformGalleryCard(detail.item),
|
||||
);
|
||||
} catch (error) {
|
||||
setPublicWorkDetailError(
|
||||
resolveRpgCreationErrorMessage(error, '读取拼消消详情失败。'),
|
||||
@@ -11435,8 +11462,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
notices: draftGenerationNotices,
|
||||
generation: {
|
||||
activeSessionId: puzzleSession?.sessionId,
|
||||
hasActiveGenerationFailure:
|
||||
activeGenerationState?.phase === 'failed',
|
||||
hasActiveGenerationFailure: activeGenerationState?.phase === 'failed',
|
||||
hasActiveGenerationRunning: isMiniGameDraftGenerating(
|
||||
activeGenerationState ?? null,
|
||||
),
|
||||
@@ -11483,9 +11509,8 @@ export function PlatformEntryFlowShellImpl({
|
||||
const failedError = backgroundTask?.error ?? openIntent.errorMessage;
|
||||
if (!failedSession) {
|
||||
try {
|
||||
const { session: latestSession } = await getPuzzleAgentSession(
|
||||
sourceSessionId,
|
||||
);
|
||||
const { session: latestSession } =
|
||||
await getPuzzleAgentSession(sourceSessionId);
|
||||
failedSession = latestSession;
|
||||
failedPayload = buildPuzzleFormPayloadFromSession(latestSession);
|
||||
} catch {
|
||||
@@ -11568,9 +11593,8 @@ export function PlatformEntryFlowShellImpl({
|
||||
|
||||
if (openIntent.type === 'restore-generating') {
|
||||
try {
|
||||
const { session: latestSession } = await getPuzzleAgentSession(
|
||||
sourceSessionId,
|
||||
);
|
||||
const { session: latestSession } =
|
||||
await getPuzzleAgentSession(sourceSessionId);
|
||||
const payload = buildPuzzleFormPayloadFromSession(latestSession);
|
||||
const startedAtMs = resolveMiniGameDraftGenerationStartedAtMs(
|
||||
latestSession.updatedAt,
|
||||
@@ -11619,9 +11643,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
|
||||
markDraftNoticeSeen(noticeKeys);
|
||||
|
||||
const restoredSession = await puzzleFlow.restoreDraft(
|
||||
sourceSessionId,
|
||||
);
|
||||
const restoredSession = await puzzleFlow.restoreDraft(sourceSessionId);
|
||||
if (!restoredSession) {
|
||||
await refreshPuzzleShelf().catch(() => undefined);
|
||||
return;
|
||||
@@ -11669,8 +11691,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
forceDraft: options.forceDraft,
|
||||
generation: {
|
||||
activeSessionId: match3dSession?.sessionId,
|
||||
hasActiveGenerationFailure:
|
||||
activeGenerationState?.phase === 'failed',
|
||||
hasActiveGenerationFailure: activeGenerationState?.phase === 'failed',
|
||||
hasActiveGenerationRunning: isMiniGameDraftGenerating(
|
||||
activeGenerationState ?? null,
|
||||
),
|
||||
@@ -11865,9 +11886,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
|
||||
markDraftNoticeSeen(noticeKeys);
|
||||
|
||||
const restoredSession = await match3dFlow.restoreDraft(
|
||||
sourceSessionId,
|
||||
);
|
||||
const restoredSession = await match3dFlow.restoreDraft(sourceSessionId);
|
||||
if (!restoredSession) {
|
||||
await refreshMatch3DShelf().catch(() => undefined);
|
||||
return;
|
||||
@@ -13316,8 +13335,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
activeRecommendEntryKey && !isDesktopLayout
|
||||
? (recommendRuntimeEntries.find(
|
||||
(entry) =>
|
||||
getPlatformPublicGalleryEntryKey(entry) ===
|
||||
activeRecommendEntryKey,
|
||||
getPlatformPublicGalleryEntryKey(entry) === activeRecommendEntryKey,
|
||||
) ?? null)
|
||||
: null;
|
||||
const isActiveRecommendRuntimeReady =
|
||||
@@ -13333,7 +13351,8 @@ export function PlatformEntryFlowShellImpl({
|
||||
hasVisualNovelRun: Boolean(visualNovelRun),
|
||||
hasWoodenFishRun: Boolean(woodenFishRun),
|
||||
puzzleRunEntryProfileId: puzzleRun?.entryProfileId ?? null,
|
||||
puzzleRunCurrentLevelProfileId: puzzleRun?.currentLevel?.profileId ?? null,
|
||||
puzzleRunCurrentLevelProfileId:
|
||||
puzzleRun?.currentLevel?.profileId ?? null,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
@@ -13407,7 +13426,10 @@ export function PlatformEntryFlowShellImpl({
|
||||
setIsPublicWorkDetailBusy(true);
|
||||
setPublicWorkDetailError(null);
|
||||
|
||||
const intent = resolvePlatformPublicWorkRemixIntent(entry);
|
||||
const intent = resolvePlatformPublicWorkRemixIntent(
|
||||
entry,
|
||||
publicWorkInteractions,
|
||||
);
|
||||
|
||||
if (intent.type === 'remix-big-fish') {
|
||||
void remixBigFishGalleryWork(intent.profileId)
|
||||
@@ -13482,6 +13504,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
isPublicWorkDetailBusy,
|
||||
platformBootstrap,
|
||||
puzzleFlow,
|
||||
publicWorkInteractions,
|
||||
resetRecommendRuntimeSelection,
|
||||
resolveBigFishErrorMessage,
|
||||
resolvePuzzleErrorMessage,
|
||||
@@ -13698,10 +13721,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
const detailEntry = mapPuzzleClearWorkToPlatformGalleryCard(entry);
|
||||
return (
|
||||
canExposePublicWork(detailEntry) &&
|
||||
isSamePuzzleClearPublicWorkCode(
|
||||
normalizedKeyword,
|
||||
entry.profileId,
|
||||
)
|
||||
isSamePuzzleClearPublicWorkCode(normalizedKeyword, entry.profileId)
|
||||
);
|
||||
});
|
||||
|
||||
@@ -14126,7 +14146,9 @@ export function PlatformEntryFlowShellImpl({
|
||||
jumpHopItems: isJumpHopCreationVisible ? jumpHopShelfItems : [],
|
||||
woodenFishItems: woodenFishShelfItems,
|
||||
match3dItems: match3dShelfItems,
|
||||
squareHoleItems: isSquareHoleCreationVisible ? squareHoleShelfItems : [],
|
||||
squareHoleItems: isSquareHoleCreationVisible
|
||||
? squareHoleShelfItems
|
||||
: [],
|
||||
puzzleItems: puzzleShelfItems,
|
||||
babyObjectMatchItems: isBabyObjectMatchVisible
|
||||
? babyObjectMatchDrafts
|
||||
@@ -14358,7 +14380,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
puzzleShelfError ??
|
||||
puzzleError ??
|
||||
(isVisualNovelCreationOpen ? visualNovelError : null) ??
|
||||
babyObjectMatchError ??
|
||||
babyObjectMatchError ??
|
||||
puzzleClearError ??
|
||||
barkBattleError)
|
||||
}
|
||||
@@ -15766,7 +15788,9 @@ export function PlatformEntryFlowShellImpl({
|
||||
profile={jumpHopWork}
|
||||
isBusy={isJumpHopBusy}
|
||||
error={jumpHopError}
|
||||
runtimeRequestOptions={jumpHopRuntimeRequestOptions ?? undefined}
|
||||
runtimeRequestOptions={
|
||||
jumpHopRuntimeRequestOptions ?? undefined
|
||||
}
|
||||
onBack={() => {
|
||||
setSelectionStage(jumpHopRuntimeReturnStage);
|
||||
}}
|
||||
@@ -16226,7 +16250,9 @@ export function PlatformEntryFlowShellImpl({
|
||||
<UnifiedCreationPage
|
||||
spec={getUnifiedSpec('visual-novel')}
|
||||
onBack={leaveVisualNovelFlow}
|
||||
isBackDisabled={isVisualNovelBusy || isVisualNovelStreamingReply}
|
||||
isBackDisabled={
|
||||
isVisualNovelBusy || isVisualNovelStreamingReply
|
||||
}
|
||||
>
|
||||
<VisualNovelAgentWorkspace
|
||||
session={visualNovelSession}
|
||||
|
||||
@@ -899,6 +899,23 @@ test('platform public work detail flow resolves like intent', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('platform public work detail flow respects configured like disable', () => {
|
||||
expect(
|
||||
resolvePlatformPublicWorkLikeIntent(buildTypedEntry('puzzle'), [
|
||||
{
|
||||
sourceType: 'puzzle',
|
||||
likeEnabled: false,
|
||||
remixEnabled: true,
|
||||
likeDisabledMessage: '拼图点赞维护中。',
|
||||
remixDisabledMessage: '拼图改造维护中。',
|
||||
},
|
||||
]),
|
||||
).toEqual({
|
||||
type: 'unsupported',
|
||||
errorMessage: '拼图点赞维护中。',
|
||||
});
|
||||
});
|
||||
|
||||
test('platform public work detail flow resolves remix intent', () => {
|
||||
expect(
|
||||
resolvePlatformPublicWorkRemixIntent(buildTypedEntry('big-fish')),
|
||||
@@ -969,13 +986,31 @@ test('platform public work detail flow resolves remix intent', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('platform public work detail flow respects configured remix disable', () => {
|
||||
expect(
|
||||
resolvePlatformPublicWorkRemixIntent(buildRpgEntry(), [
|
||||
{
|
||||
sourceType: 'custom-world',
|
||||
likeEnabled: true,
|
||||
remixEnabled: false,
|
||||
likeDisabledMessage: 'RPG 点赞维护中。',
|
||||
remixDisabledMessage: 'RPG 改造维护中。',
|
||||
},
|
||||
]),
|
||||
).toEqual({
|
||||
type: 'unsupported',
|
||||
errorMessage: 'RPG 改造维护中。',
|
||||
});
|
||||
});
|
||||
|
||||
test('platform public work detail flow resolves edit intent for draft-backed works', () => {
|
||||
const bigFishEntry = buildTypedEntry('big-fish');
|
||||
expect(resolvePlatformPublicWorkEditIntent(bigFishEntry, buildEditIntentDeps()))
|
||||
.toEqual({
|
||||
type: 'edit-big-fish',
|
||||
work: mapPublicWorkDetailToBigFishWork(bigFishEntry),
|
||||
});
|
||||
expect(
|
||||
resolvePlatformPublicWorkEditIntent(bigFishEntry, buildEditIntentDeps()),
|
||||
).toEqual({
|
||||
type: 'edit-big-fish',
|
||||
work: mapPublicWorkDetailToBigFishWork(bigFishEntry),
|
||||
});
|
||||
|
||||
const selectedPuzzleDetail = buildPuzzleWork({
|
||||
profileId: 'puzzle-profile',
|
||||
@@ -1153,7 +1188,10 @@ test('platform public work detail flow resolves edit intent for unsupported and
|
||||
|
||||
const edutainmentEntry = buildTypedEntry('edutainment');
|
||||
expect(
|
||||
resolvePlatformPublicWorkEditIntent(edutainmentEntry, buildEditIntentDeps()),
|
||||
resolvePlatformPublicWorkEditIntent(
|
||||
edutainmentEntry,
|
||||
buildEditIntentDeps(),
|
||||
),
|
||||
).toEqual({
|
||||
type: 'resolve-edutainment-draft',
|
||||
entry: edutainmentEntry,
|
||||
|
||||
@@ -97,6 +97,14 @@ export type PlatformPublicWorkDetailOpenStrategy =
|
||||
|
||||
export type PlatformPublicWorkActionMode = 'edit' | 'remix';
|
||||
|
||||
export type PlatformPublicWorkInteractionConfig = {
|
||||
sourceType: string;
|
||||
likeEnabled: boolean;
|
||||
remixEnabled: boolean;
|
||||
likeDisabledMessage: string;
|
||||
remixDisabledMessage: string;
|
||||
};
|
||||
|
||||
export type PlatformPublicWorkLikeIntent =
|
||||
| {
|
||||
type: 'like-big-fish';
|
||||
@@ -678,9 +686,55 @@ export function resolvePlatformPublicWorkActionMode(
|
||||
: 'remix';
|
||||
}
|
||||
|
||||
export function getPlatformPublicWorkInteractionSourceType(
|
||||
entry: PlatformPublicGalleryCard,
|
||||
) {
|
||||
return 'sourceType' in entry ? entry.sourceType : 'custom-world';
|
||||
}
|
||||
|
||||
function resolveConfiguredPublicWorkInteractionBlock(
|
||||
entry: PlatformPublicGalleryCard,
|
||||
configs: readonly PlatformPublicWorkInteractionConfig[] | null | undefined,
|
||||
action: 'like' | 'remix',
|
||||
): PlatformPublicWorkLikeIntent | PlatformPublicWorkRemixIntent | null {
|
||||
const sourceType = getPlatformPublicWorkInteractionSourceType(entry);
|
||||
const config = configs?.find((item) => item.sourceType === sourceType);
|
||||
if (!config) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (action === 'like' && !config.likeEnabled) {
|
||||
return {
|
||||
type: 'unsupported',
|
||||
errorMessage:
|
||||
config.likeDisabledMessage.trim() || '该作品类型暂不支持点赞。',
|
||||
};
|
||||
}
|
||||
|
||||
if (action === 'remix' && !config.remixEnabled) {
|
||||
return {
|
||||
type: 'unsupported',
|
||||
errorMessage:
|
||||
config.remixDisabledMessage.trim() || '该作品类型暂不支持改造。',
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function resolvePlatformPublicWorkLikeIntent(
|
||||
entry: PlatformPublicGalleryCard,
|
||||
configs?: readonly PlatformPublicWorkInteractionConfig[] | null,
|
||||
): PlatformPublicWorkLikeIntent {
|
||||
const configuredBlock = resolveConfiguredPublicWorkInteractionBlock(
|
||||
entry,
|
||||
configs,
|
||||
'like',
|
||||
);
|
||||
if (configuredBlock) {
|
||||
return configuredBlock as PlatformPublicWorkLikeIntent;
|
||||
}
|
||||
|
||||
if (isBigFishGalleryEntry(entry)) {
|
||||
return {
|
||||
type: 'like-big-fish',
|
||||
@@ -760,7 +814,17 @@ export function resolvePlatformPublicWorkLikeIntent(
|
||||
|
||||
export function resolvePlatformPublicWorkRemixIntent(
|
||||
entry: PlatformPublicGalleryCard,
|
||||
configs?: readonly PlatformPublicWorkInteractionConfig[] | null,
|
||||
): PlatformPublicWorkRemixIntent {
|
||||
const configuredBlock = resolveConfiguredPublicWorkInteractionBlock(
|
||||
entry,
|
||||
configs,
|
||||
'remix',
|
||||
);
|
||||
if (configuredBlock) {
|
||||
return configuredBlock as PlatformPublicWorkRemixIntent;
|
||||
}
|
||||
|
||||
if (isBigFishGalleryEntry(entry)) {
|
||||
return {
|
||||
type: 'remix-big-fish',
|
||||
@@ -933,8 +997,9 @@ export function resolvePlatformPublicWorkEditIntent(
|
||||
|
||||
if (isVisualNovelGalleryEntry(entry)) {
|
||||
const work =
|
||||
deps.visualNovelWorks?.find((item) => item.profileId === entry.profileId) ??
|
||||
null;
|
||||
deps.visualNovelWorks?.find(
|
||||
(item) => item.profileId === entry.profileId,
|
||||
) ?? null;
|
||||
if (!work) {
|
||||
return {
|
||||
type: 'blocked',
|
||||
|
||||
@@ -81,7 +81,6 @@ describe('apiClient', () => {
|
||||
body: JSON.stringify({
|
||||
ok: true,
|
||||
data: {
|
||||
ok: true,
|
||||
token: 'fresh-token',
|
||||
},
|
||||
error: null,
|
||||
@@ -156,7 +155,6 @@ describe('apiClient', () => {
|
||||
body: JSON.stringify({
|
||||
ok: true,
|
||||
data: {
|
||||
ok: true,
|
||||
token: 'fresh-token',
|
||||
},
|
||||
error: null,
|
||||
@@ -334,6 +332,64 @@ describe('apiClient', () => {
|
||||
expect(getStoredAccessToken()).toBe('usable-local-token');
|
||||
});
|
||||
|
||||
it('keeps local token when refresh fails with transient server unavailable', async () => {
|
||||
setStoredAccessToken('usable-local-token', { emit: false });
|
||||
fetchMock.mockResolvedValueOnce(createResponseMock({ status: 503 }));
|
||||
|
||||
await expect(refreshStoredAccessToken()).rejects.toMatchObject({
|
||||
status: 503,
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'/api/auth/refresh',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
}),
|
||||
);
|
||||
expect(dispatchEventMock).not.toHaveBeenCalled();
|
||||
expect(getStoredAccessToken()).toBe('usable-local-token');
|
||||
});
|
||||
|
||||
it('keeps local token when refresh cannot reach the restarting server', async () => {
|
||||
setStoredAccessToken('usable-local-token', { emit: false });
|
||||
fetchMock.mockRejectedValueOnce(new TypeError('Failed to fetch'));
|
||||
|
||||
await expect(refreshStoredAccessToken()).rejects.toBeInstanceOf(TypeError);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(dispatchEventMock).not.toHaveBeenCalled();
|
||||
expect(getStoredAccessToken()).toBe('usable-local-token');
|
||||
});
|
||||
|
||||
it('clears local token when refresh confirms the session is unauthorized', async () => {
|
||||
setStoredAccessToken('expired-local-token', { emit: false });
|
||||
fetchMock.mockResolvedValueOnce(createResponseMock({ status: 401 }));
|
||||
|
||||
await expect(refreshStoredAccessToken()).rejects.toMatchObject({
|
||||
status: 401,
|
||||
});
|
||||
|
||||
expect(dispatchEventMock).not.toHaveBeenCalled();
|
||||
expect(getStoredAccessToken()).toBe('');
|
||||
});
|
||||
|
||||
it('does not clear auth when protected request refresh fails transiently', async () => {
|
||||
setStoredAccessToken('expired-token-during-restart', { emit: false });
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(createResponseMock({ status: 401 }))
|
||||
.mockResolvedValueOnce(createResponseMock({ status: 503 }));
|
||||
|
||||
const response = await fetchWithApiAuth('/api/runtime/protected', {
|
||||
method: 'GET',
|
||||
});
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(dispatchEventMock).not.toHaveBeenCalled();
|
||||
expect(getStoredAccessToken()).toBe('expired-token-during-restart');
|
||||
});
|
||||
|
||||
it('keeps the refreshed token when the retried protected request is still unauthorized', async () => {
|
||||
setStoredAccessToken('expired-token', { emit: false });
|
||||
fetchMock
|
||||
@@ -344,7 +400,6 @@ describe('apiClient', () => {
|
||||
body: JSON.stringify({
|
||||
ok: true,
|
||||
data: {
|
||||
ok: true,
|
||||
token: 'fresh-token',
|
||||
},
|
||||
error: null,
|
||||
@@ -366,7 +421,7 @@ describe('apiClient', () => {
|
||||
expect(dispatchEventMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects refresh responses that do not return a renewed bearer token', async () => {
|
||||
it('rejects malformed refresh responses without treating them as logout', async () => {
|
||||
setStoredAccessToken('expired-token', { emit: false });
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(createResponseMock({ status: 401 }))
|
||||
@@ -397,8 +452,8 @@ describe('apiClient', () => {
|
||||
message: '读取受保护数据失败',
|
||||
});
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(getStoredAccessToken()).toBe('');
|
||||
expect(dispatchEventMock).toHaveBeenCalledTimes(1);
|
||||
expect(getStoredAccessToken()).toBe('expired-token');
|
||||
expect(dispatchEventMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps the current access token when a public request explicitly skips auth', async () => {
|
||||
|
||||
@@ -497,6 +497,13 @@ function withAuthorizationHeaders(
|
||||
|
||||
let refreshAccessTokenPromise: Promise<string> | null = null;
|
||||
|
||||
function shouldClearAuthAfterRefreshFailure(error: unknown) {
|
||||
return (
|
||||
error instanceof ApiClientError &&
|
||||
(error.status === 401 || error.status === 403)
|
||||
);
|
||||
}
|
||||
|
||||
async function refreshAccessToken() {
|
||||
if (refreshAccessTokenPromise) {
|
||||
return refreshAccessTokenPromise;
|
||||
@@ -522,11 +529,11 @@ async function refreshAccessToken() {
|
||||
)
|
||||
: null;
|
||||
|
||||
if (payload?.ok !== true || !payload.token?.trim()) {
|
||||
const nextToken = payload?.token?.trim();
|
||||
if (!nextToken) {
|
||||
throw new Error('刷新登录状态失败');
|
||||
}
|
||||
|
||||
const nextToken = payload.token.trim();
|
||||
setStoredAccessToken(nextToken, { emit: false });
|
||||
return nextToken;
|
||||
})();
|
||||
@@ -556,7 +563,10 @@ export async function refreshStoredAccessToken(
|
||||
try {
|
||||
return await refreshAccessToken();
|
||||
} catch (error) {
|
||||
if (options.clearOnFailure !== false) {
|
||||
if (
|
||||
options.clearOnFailure !== false &&
|
||||
shouldClearAuthAfterRefreshFailure(error)
|
||||
) {
|
||||
clearStoredAccessToken({ emit: false });
|
||||
}
|
||||
throw error;
|
||||
@@ -629,11 +639,15 @@ export async function fetchWithApiAuth(
|
||||
// 不能把当前业务请求的首次 401 直接放大成全局鉴权变更,
|
||||
// 否则像 Puzzle works 这类受保护列表会把单接口失败放大成整个平台重复 hydrate。
|
||||
continue;
|
||||
} catch {
|
||||
if (hasAuthHeader && authFailurePolicy.clearAuthOnUnauthorized) {
|
||||
} catch (refreshError) {
|
||||
const shouldClearAuth =
|
||||
hasAuthHeader &&
|
||||
authFailurePolicy.clearAuthOnUnauthorized &&
|
||||
shouldClearAuthAfterRefreshFailure(refreshError);
|
||||
if (shouldClearAuth) {
|
||||
clearStoredAccessToken({ emit: false });
|
||||
}
|
||||
if (authFailurePolicy.notifyAuthStateChange) {
|
||||
if (shouldClearAuth && authFailurePolicy.notifyAuthStateChange) {
|
||||
emitAuthStateChange();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,6 +51,15 @@ export type CreationEntryEventBannerConfig = {
|
||||
htmlCode?: string | null;
|
||||
};
|
||||
|
||||
/** 公开作品详情页互动能力配置,前端只据此关闭已接入动作。 */
|
||||
export type PublicWorkInteractionConfig = {
|
||||
sourceType: string;
|
||||
likeEnabled: boolean;
|
||||
remixEnabled: boolean;
|
||||
likeDisabledMessage: string;
|
||||
remixDisabledMessage: string;
|
||||
};
|
||||
|
||||
/** 创作入口页完整配置;前端只展示后端事实源,不内置入口默认值。 */
|
||||
export type CreationEntryConfig = {
|
||||
startCard: {
|
||||
@@ -67,6 +76,8 @@ export type CreationEntryConfig = {
|
||||
eventBanner: CreationEntryEventBannerConfig;
|
||||
/** 底部加号创作入口页的多公告轮播配置。 */
|
||||
eventBanners?: CreationEntryEventBannerConfig[];
|
||||
/** 公开作品详情页点赞 / 改造能力矩阵。 */
|
||||
publicWorkInteractions?: PublicWorkInteractionConfig[];
|
||||
creationTypes: CreationEntryTypeConfig[];
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user