feat(studio): 市场推荐位接入真后端 recommendations(A 类:接已就绪后端)

A 类(缺口盘点:后端已就绪、前端 0 消费):02F GET /marketplace/recommendations 后端就绪
(curl code:0 + 真实推荐资产 + recommendationReason),但 studio 从不调用、推荐理由仅 mock
造、UI 未渲染(盘点 C3)。接入:
- useMarketRecommendations hook(返回 MarketAssetCard[],推荐失败不阻断主列表)
- MarketBrowse「为你推荐」区:渲染推荐资产 + recommendationReason,点击进详情
- mock handler + vitest 推荐契约;market-recommendations.spec.ts 真后端 e2e
- 连带修 live-read market 断言加 .first()(推荐位与列表可同时渲染同名资产→strict mode)

验证:全量 e2e 44→45/0;vitest 79→80;tsc/eslint 绿。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
lili 2026-06-22 07:29:14 -07:00
parent 1686843e71
commit cac3b8837f
6 changed files with 101 additions and 1 deletions

View File

@ -28,7 +28,8 @@ test.describe('活体只读旅程(真实后端,MSW off)', () => {
test('市场浏览渲染真实资产(market)', async ({ page }) => {
await seedToken(page);
await page.goto('/market');
await expect(page.getByText('活体市场资产·测试')).toBeVisible({ timeout: 15_000 });
// .first():推荐位与资产列表可能同时渲染同一资产(推荐区新增),strict 模式需唯一匹配;渲染即证真实市场数据已加载。
await expect(page.getByText('活体市场资产·测试').first()).toBeVisible({ timeout: 15_000 });
});
test('个人中心渲染真实账户(account)', async ({ page }) => {

View File

@ -0,0 +1,42 @@
import { expect, test, type Page } from '@playwright/test';
/**
* 市场推荐位 e2e(真实后端,MSW off)——A 类切片:后端 GET /marketplace/recommendations 就绪、此前 studio 零调用。
*
* 反假绿:盘点(02F C3)确认后端推荐接口就绪(curl code:0 + 真实推荐资产 + recommendationReason),
* 但前端从不调用、推荐理由仅 mock 造、UI 未渲染。本测进市场发现页 → 等真实 GET /marketplace/recommendations
* → 验响应 code:0(证真后端就绪被 UI 消费)+「为你推荐」区按真实数据渲染。
* 设计:不硬编码推荐条数/内容(后端推荐算法可变),依真实响应数据断言——有推荐则验区块 + 首个推荐名渲染,
* 空推荐则不阻断(契合"推荐失败不阻断主列表"语义)。
*/
const TOKEN = 'test1';
async function seedToken(page: Page): Promise<void> {
await page.addInitScript((t) => {
window.localStorage.setItem('accessToken', t);
window.localStorage.setItem('tenantId', '1');
}, TOKEN);
}
test('市场发现页渲染个性化推荐位(真实后端 recommendations)', async ({ page }) => {
await seedToken(page);
// 进市场页 → 捕获真实 GET /marketplace/recommendations(随首屏发出)。
const recoResp = page.waitForResponse(
(r) =>
/\/marketplace\/recommendations$/.test(new URL(r.url()).pathname) &&
r.request().method() === 'GET',
{ timeout: 15_000 }
);
await page.goto('/market');
const resp = await recoResp;
expect(resp.status()).toBe(200);
const body = await resp.json();
expect(body.code).toBe(0); // 后端推荐接口就绪(非 fail-closed)。
// 依真实响应断言:有推荐资产时「为你推荐」区渲染 + 首个推荐名可见(证真后端数据被 UI 消费)。
if (Array.isArray(body.data) && body.data.length > 0) {
await expect(page.getByRole('heading', { name: '为你推荐' })).toBeVisible({ timeout: 10_000 });
await expect(page.getByText(String(body.data[0].name)).first()).toBeVisible({ timeout: 10_000 });
}
});

View File

@ -134,6 +134,11 @@ function toAssetCard(asset: MarketAssetDetail): MarketAssetCard {
}
export const marketHandlers = [
// GET /app-api/muse/marketplace/recommendations — 个性化推荐(含推荐理由),返回 MarketAssetCard 数组
http.get('/app-api/muse/marketplace/recommendations', () => {
const list = mockAssets.filter((asset) => asset.assetType === 'agent').map(toAssetCard);
return HttpResponse.json({ code: 0, msg: 'success', data: list });
}),
// GET /app-api/muse/marketplace/assets — 市场资产发现列表
http.get('/app-api/muse/marketplace/assets', ({ request }) => {
const url = new URL(request.url);

View File

@ -4,6 +4,7 @@ import {
useAcquireMarketAsset,
useInstallMarketAsset,
useMarketplaceCategories,
useMarketRecommendations,
useMarketAssets,
type MarketAssetType,
} from '../hooks/useMarket';
@ -26,6 +27,8 @@ export default function MarketBrowse() {
const [keyword, setKeyword] = useState('');
const { data, isLoading, isError } = useMarketAssets(assetType, keyword);
const { data: categories } = useMarketplaceCategories();
// 个性化推荐(含推荐理由);推荐失败不阻断主列表——仅在有数据时渲染推荐区。
const { data: recommendations } = useMarketRecommendations();
const acquireAsset = useAcquireMarketAsset();
const installAsset = useInstallMarketAsset();
@ -89,6 +92,30 @@ export default function MarketBrowse() {
</div>
)}
{/* 个性化推荐位:展示后端推荐资产 + 推荐理由(recommendationReason),点击进资产详情。后端就绪、此前 UI 未渲染。 */}
{recommendations && recommendations.length > 0 && (
<section className="mt-6" aria-label="为你推荐">
<h2 className="text-sm font-bold text-slate-700">为你推荐</h2>
<div className="mt-3 grid grid-cols-1 gap-3 md:grid-cols-3">
{recommendations.slice(0, 3).map((rec) => (
<Link
key={rec.assetId}
to={`/market/assets/${rec.assetId}`}
className="rounded-2xl border border-indigo-100 bg-indigo-50/40 p-4 shadow-sm transition hover:border-indigo-300"
>
<div className="text-sm font-bold text-slate-800">{rec.name}</div>
{rec.recommendationReason && (
<div className="mt-1 text-xs font-medium text-indigo-600">荐 · {rec.recommendationReason}</div>
)}
<div className="mt-1 text-xs text-slate-400">
{getAssetTypeLabel(rec.assetType)} · {rec.publisherName}
</div>
</Link>
))}
</div>
</section>
)}
<div className="mt-8">
{isLoading ? (
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3">

View File

@ -12,6 +12,7 @@ import {
useInstallMarketAsset,
useMarketAssetDetail,
useMarketAssets,
useMarketRecommendations,
} from './useMarket';
const createTestQueryClient = () =>
@ -43,6 +44,17 @@ describe('useMarket API Hooks 单元测试', () => {
expect(result.current.data?.list[0]?.assetType).toBe('agent');
});
it('应能获取个性化推荐资产(含推荐理由 recommendationReason)', async () => {
const { result } = renderHook(() => useMarketRecommendations(), { wrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
// mock recommendations 返回 agent 资产;toAssetCard 给 agent 设 recommendationReason(证推荐理由被解出)。
expect(Array.isArray(result.current.data)).toBe(true);
expect(result.current.data?.length).toBeGreaterThan(0);
expect(result.current.data?.[0]?.recommendationReason).toBe('适合长篇结构检查');
});
it('应能获取授权后安装智能体资产', async () => {
const assetId = '20000000-0000-4000-8000-000000000002';
const { result: acquireHook } = renderHook(() => useAcquireMarketAsset(), { wrapper });

View File

@ -42,6 +42,19 @@ export function useMarketplaceCategories() {
});
}
/**
* 查询市场个性化推荐资产(含推荐理由 recommendationReason),用于发现页推荐位。
* 后端 listMarketplaceRecommendations 返回 MarketAssetCard 数组;推荐加载失败不阻断主列表
* (调用方仅在有数据时渲染推荐区,hook 错误态不影响 useMarketAssets)。此前后端就绪、studio 零调用。
*/
export function useMarketRecommendations() {
return useQuery<MarketAssetCard[], Error>({
queryKey: ['marketRecommendations'],
queryFn: () => api.get<MarketAssetCard[]>('/marketplace/recommendations'),
staleTime: 60_000,
});
}
/** 获取市场资产授权。授权不等于安装或绑定。 */
export function useAcquireMarketAsset() {
const queryClient = useQueryClient();