feat(studio): E 来源归因查看 - 数据层+UI+e2e 真跑[P1]
studio 缺 UI 端面补齐(第 2 个,可验证缺口)。openapi export SourceAttribution;useSourceAttribution hook(只读);content mock source-attribution handler;SourceAttributionPanel(来源类型/贡献中文标签+许可+谱系);WorkspacePage 右侧加「来源」Tab(block 级,需 activeBlock)。 验证:useSourceAttribution 2/2 + SourceAttributionPanel 2/2 + 全套 vitest 77/77(无回归) + tsc 0 + eslint 0 + playwright source-attribution 1 passed(真后端 user_original→用户原创)。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
c76cdec60a
commit
cc57c595d8
36
muse-studio/e2e/source-attribution.spec.ts
Normal file
36
muse-studio/e2e/source-attribution.spec.ts
Normal file
@ -0,0 +1,36 @@
|
||||
import { expect, test, type Page } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Block 来源归因查看端到端(真实后端,MSW off)。
|
||||
*
|
||||
* 覆盖 E 链路:进 work1 workspace → 右侧「来源」Tab → 真实 GET source-attribution →
|
||||
* 展示来源(work1 block 真后端含 user_original → 渲染「用户原创」)。纯读,无副作用,幂等。
|
||||
*/
|
||||
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('用户查看章节正文来源归因(真实后端)', async ({ page }) => {
|
||||
await seedToken(page);
|
||||
|
||||
await page.goto('/workspace/1');
|
||||
await expect(page.getByRole('heading', { name: /正在创作/ })).toBeVisible({ timeout: 15_000 });
|
||||
// 等章节结构条出现(activeBlock 已加载,来源 Tab 才有目标 Block)。
|
||||
await expect(page.getByText(/章节结构/)).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
// 切到「来源」Tab,捕获真实 source-attribution 请求。
|
||||
const attrResp = page.waitForResponse(
|
||||
(r) => /\/blocks\/[^/]+\/source-attribution/.test(r.url()) && r.request().method() === 'GET'
|
||||
);
|
||||
await page.getByRole('button', { name: '来源' }).click();
|
||||
expect((await attrResp).status()).toBe(200);
|
||||
|
||||
// 面板渲染来源(真后端 user_original → 用户原创)。
|
||||
await expect(page.getByText('正文来源归因')).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.getByText('用户原创')).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
@ -617,4 +617,34 @@ export const contentHandlers = [
|
||||
},
|
||||
});
|
||||
}),
|
||||
|
||||
// GET /app-api/muse/works/:workId/blocks/:blockId/source-attribution — 查看 Block 来源归因
|
||||
http.get('/app-api/muse/works/:workId/blocks/:blockId/source-attribution', ({ params }) => {
|
||||
const { workId, blockId } = params;
|
||||
const chapter = mockChapters.find((ch) => ch.blockId === blockId && ch.workId === workId);
|
||||
|
||||
if (!chapter) {
|
||||
return HttpResponse.json({ code: 40403, msg: 'Block 不存在', data: null }, { status: 404 });
|
||||
}
|
||||
return HttpResponse.json({
|
||||
code: 0,
|
||||
msg: 'success',
|
||||
data: {
|
||||
blockId,
|
||||
revision: chapter.blockVersion,
|
||||
sources: [
|
||||
{
|
||||
sourceType: 'user_original',
|
||||
sourceId: blockId,
|
||||
sourceName: null,
|
||||
contributionType: 'full',
|
||||
authorizationSnapshotId: null,
|
||||
sourceVersion: null,
|
||||
licenseRestrictions: [],
|
||||
},
|
||||
],
|
||||
lineage: null,
|
||||
},
|
||||
});
|
||||
}),
|
||||
];
|
||||
|
||||
@ -0,0 +1,34 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { server } from '@/test/setup';
|
||||
import SourceAttributionPanel from './SourceAttributionPanel';
|
||||
import { resetMockDb } from '@/api/mocks/handlers/content';
|
||||
import React from 'react';
|
||||
|
||||
const renderWithClient = (ui: React.ReactElement) => {
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } });
|
||||
return render(<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>);
|
||||
};
|
||||
|
||||
describe('SourceAttributionPanel 组件', () => {
|
||||
beforeEach(() => resetMockDb());
|
||||
|
||||
it('应展示 Block 来源(user_original→用户原创、full→全文)', async () => {
|
||||
renderWithClient(<SourceAttributionPanel workId="1" blockId="block-1-1-main" />);
|
||||
expect(await screen.findByText('正文来源归因')).toBeInTheDocument();
|
||||
expect(await screen.findByText('用户原创')).toBeInTheDocument();
|
||||
expect(screen.getByText('全文')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('无来源时显示空状态', async () => {
|
||||
server.use(
|
||||
http.get('/app-api/muse/works/:workId/blocks/:blockId/source-attribution', () =>
|
||||
HttpResponse.json({ code: 0, msg: 'success', data: { blockId: 'x', revision: 1, sources: [], lineage: null } })
|
||||
)
|
||||
);
|
||||
renderWithClient(<SourceAttributionPanel workId="1" blockId="block-1-1-main" />);
|
||||
expect(await screen.findByText('暂无来源记录。')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,87 @@
|
||||
import { useSourceAttribution } from '../hooks/useSourceAttribution';
|
||||
|
||||
/** 来源类型 → 中文标签。 */
|
||||
const SOURCE_TYPE_LABEL: Record<string, string> = {
|
||||
user_original: '用户原创',
|
||||
ai_suggestion: 'AI 生成',
|
||||
knowledge_reference: '知识引用',
|
||||
market_asset: '市场素材',
|
||||
imported_content: '导入内容',
|
||||
};
|
||||
|
||||
/** 贡献类型 → 中文标签。 */
|
||||
const CONTRIBUTION_LABEL: Record<string, string> = {
|
||||
full: '全文',
|
||||
partial: '部分',
|
||||
reference: '引用',
|
||||
context: '上下文',
|
||||
};
|
||||
|
||||
/** 采纳方式 → 中文标签。 */
|
||||
const ACCEPT_MODE_LABEL: Record<string, string> = {
|
||||
accept_as_is: '原样采纳',
|
||||
merge_after_edit: '修改后合并',
|
||||
};
|
||||
|
||||
interface SourceAttributionPanelProps {
|
||||
workId: string;
|
||||
blockId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Block 来源归因面板:展示当前 Block 正文由哪些来源构成(创作来源透明度)。
|
||||
*
|
||||
* 只读;每个来源显示类型(用户原创/AI 生成/知识引用/市场素材/导入内容)、贡献程度、名称、许可限制;
|
||||
* 有谱系(lineage)时显示来自哪个 AI 候选及采纳方式。
|
||||
*/
|
||||
export default function SourceAttributionPanel({ workId, blockId }: SourceAttributionPanelProps) {
|
||||
const { data, isLoading, isError } = useSourceAttribution(workId, blockId);
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="p-6 text-sm text-slate-400">加载来源归因…</div>;
|
||||
}
|
||||
if (isError) {
|
||||
return <div className="p-6 text-sm text-red-500">来源归因加载失败,请刷新重试。</div>;
|
||||
}
|
||||
|
||||
const sources = data?.sources ?? [];
|
||||
|
||||
return (
|
||||
<div className="p-4 overflow-y-auto h-full">
|
||||
<h3 className="text-sm font-semibold text-slate-700 mb-1">正文来源归因</h3>
|
||||
<p className="text-xs text-slate-400 mb-3">当前小节 revision {data?.revision ?? '—'}</p>
|
||||
|
||||
{sources.length === 0 ? (
|
||||
<div className="text-sm text-slate-400">暂无来源记录。</div>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-2">
|
||||
{sources.map((s, idx) => (
|
||||
<li key={`${s.sourceId}-${idx}`} className="rounded-lg border border-slate-200 bg-white px-3 py-2">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-xs font-semibold px-2 py-0.5 rounded bg-violet-100 text-violet-700">
|
||||
{SOURCE_TYPE_LABEL[s.sourceType] ?? s.sourceType}
|
||||
</span>
|
||||
<span className="text-xs text-slate-500">
|
||||
{CONTRIBUTION_LABEL[s.contributionType] ?? s.contributionType}
|
||||
</span>
|
||||
</div>
|
||||
{s.sourceName && <div className="text-sm text-slate-700">{s.sourceName}</div>}
|
||||
{s.licenseRestrictions && s.licenseRestrictions.length > 0 && (
|
||||
<div className="text-xs text-amber-600 mt-1">许可限制: {s.licenseRestrictions.join('、')}</div>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{data?.lineage?.parentSuggestionId && (
|
||||
<div className="mt-3 text-xs text-slate-500 border-t border-slate-100 pt-2">
|
||||
<div>谱系: 来自 AI 候选 {data.lineage.parentSuggestionId}</div>
|
||||
{data.lineage.acceptMode && (
|
||||
<div>采纳方式: {ACCEPT_MODE_LABEL[data.lineage.acceptMode] ?? data.lineage.acceptMode}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,31 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { renderHook, waitFor } from '@testing-library/react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { useSourceAttribution } from './useSourceAttribution';
|
||||
import { resetMockDb } from '@/api/mocks/handlers/content';
|
||||
import React from 'react';
|
||||
|
||||
const createTestQueryClient = () =>
|
||||
new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } });
|
||||
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => {
|
||||
const queryClient = createTestQueryClient();
|
||||
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
|
||||
};
|
||||
|
||||
describe('useSourceAttribution hook 单元测试', () => {
|
||||
beforeEach(() => resetMockDb());
|
||||
|
||||
it('应拉取 Block 来源归因', async () => {
|
||||
const { result } = renderHook(() => useSourceAttribution('1', 'block-1-1-main'), { wrapper });
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||
expect(result.current.data?.blockId).toBe('block-1-1-main');
|
||||
expect(result.current.data?.sources?.[0]?.sourceType).toBe('user_original');
|
||||
expect(result.current.data?.sources?.[0]?.contributionType).toBe('full');
|
||||
});
|
||||
|
||||
it('blockId 缺失时不发起请求', () => {
|
||||
const { result } = renderHook(() => useSourceAttribution('1', undefined), { wrapper });
|
||||
expect(result.current.fetchStatus).toBe('idle');
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,17 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { api } from '@/api/client';
|
||||
import type { SourceAttribution } from '@/types/openapi';
|
||||
|
||||
/**
|
||||
* 查看当前 Block revision 的来源归因(创作来源透明度)。
|
||||
*
|
||||
* 只读:展示该 Block 正文由哪些来源构成(user_original/ai_suggestion/knowledge_reference/...)及谱系。
|
||||
* queryKey 绑定 blockId,切换 Block 自动重拉;blockId 缺失时不发起。
|
||||
*/
|
||||
export function useSourceAttribution(workId: string, blockId: string | undefined) {
|
||||
return useQuery({
|
||||
queryKey: ['sourceAttribution', blockId],
|
||||
queryFn: () => api.get<SourceAttribution>(`/works/${workId}/blocks/${blockId}/source-attribution`),
|
||||
enabled: !!workId && !!blockId,
|
||||
});
|
||||
}
|
||||
@ -13,6 +13,7 @@ import AIPanel from '../features/editor/components/AIPanel';
|
||||
import CandidatePanel from '../features/editor/components/CandidatePanel';
|
||||
import PlanningEditorPanel from '../features/editor/components/PlanningEditorPanel';
|
||||
import BlockStructureBar from '../features/editor/components/BlockStructureBar';
|
||||
import SourceAttributionPanel from '../features/editor/components/SourceAttributionPanel';
|
||||
import type { WorkVO } from '@/types/openapi';
|
||||
|
||||
/**
|
||||
@ -61,7 +62,7 @@ export default function WorkspacePage() {
|
||||
const [originalText, setOriginalText] = useState('');
|
||||
const [showCompare, setShowCompare] = useState(false);
|
||||
// 右侧面板 Tab:AI 创作助手 / 作品规划(planning 为 work 级,不依赖 activeBlock,故面板始终挂载)。
|
||||
const [rightTab, setRightTab] = useState<'ai' | 'planning'>('ai');
|
||||
const [rightTab, setRightTab] = useState<'ai' | 'planning' | 'source'>('ai');
|
||||
// 本地 revision 覆盖:采纳成功后后端返回 newRevision,先用它即时校准本地乐观锁,避免连续采纳因 revision 滞后误触 409。
|
||||
const [revisionOverride, setRevisionOverride] = useState<number | null>(null);
|
||||
|
||||
@ -274,11 +275,26 @@ export default function WorkspacePage() {
|
||||
>
|
||||
作品规划
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRightTab('source')}
|
||||
className={`px-3 py-1.5 text-xs font-semibold rounded-lg transition ${
|
||||
rightTab === 'source' ? 'bg-violet-600 text-white' : 'text-slate-600 hover:bg-slate-100'
|
||||
}`}
|
||||
>
|
||||
来源
|
||||
</button>
|
||||
</div>
|
||||
{/* Tab 内容区:planning 始终可用;AI 须有激活 Block(生成与采纳都需要目标 Block)。 */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{rightTab === 'planning' ? (
|
||||
<PlanningEditorPanel workId={workId} />
|
||||
) : rightTab === 'source' ? (
|
||||
activeBlock ? (
|
||||
<SourceAttributionPanel workId={workId} blockId={activeBlock.id} />
|
||||
) : (
|
||||
<div className="p-6 text-sm text-slate-400">请在左侧大纲选择章节,查看正文来源归因。</div>
|
||||
)
|
||||
) : activeBlock ? (
|
||||
showCompare ? (
|
||||
<CandidatePanel
|
||||
|
||||
@ -58,3 +58,5 @@ export type DynamicFieldValidationResult = ContentComponents['schemas']['Dynamic
|
||||
// (后端 response schema 注解缺失),故 Result 在 useBlockStructure 内手写(对齐后端 SplitBlockRespVO/MergeBlockRespVO)。
|
||||
export type SplitBlockRequest = ContentComponents['schemas']['SplitBlockRequest'];
|
||||
export type MergeBlockRequest = ContentComponents['schemas']['MergeBlockRequest'];
|
||||
// Block 来源归因查看(创作来源透明度:user_original/ai_suggestion/knowledge_reference/market_asset/imported_content)。
|
||||
export type SourceAttribution = ContentComponents['schemas']['SourceAttribution'];
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user