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:
lili 2026-06-21 09:11:28 -07:00
parent c76cdec60a
commit cc57c595d8
8 changed files with 254 additions and 1 deletions

View 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 });
});

View File

@ -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,
},
});
}),
];

View File

@ -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();
});
});

View File

@ -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>
);
}

View File

@ -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');
});
});

View File

@ -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,
});
}

View File

@ -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

View File

@ -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'];