feat: polish intake detail navigation #13

Merged
WuYongJie merged 1 commits from codex/xuxiake-mainline into main 2026-04-28 10:51:05 +08:00
7 changed files with 156 additions and 58 deletions

View File

@ -6,6 +6,7 @@ import type { ReactNode } from 'react'
import IntakeClient from './IntakeClient'
import {
createDefaultDocumentWorkspaceConfirmations,
getNonIntegratedDocumentWorkspaceCategoryLabel,
NON_INTEGRATED_BRAND_PREFIX_MAP,
NON_INTEGRATED_DOCUMENT_WORKSPACE_CATEGORIES,
NON_INTEGRATED_STORAGE_KEY,
@ -544,11 +545,18 @@ describe('IntakeClient', () => {
expect(pendingRow).not.toBeNull()
expect(within(checkedRow as HTMLElement).getByText('已检查')).toBeInTheDocument()
expect(within(pendingRow as HTMLElement).getByText('待确认')).toBeInTheDocument()
expect(screen.getByRole('columnheader', { name: '产品实拍' })).toBeInTheDocument()
expect(screen.getByRole('columnheader', { name: '产品资料' })).toBeInTheDocument()
expect(screen.getByRole('columnheader', { name: '仓库抽检' })).toBeInTheDocument()
expect(
within(checkedRow as HTMLElement).getByLabelText(`${readyCategory}已检查且有数据`),
within(checkedRow as HTMLElement).getByLabelText(
`${getNonIntegratedDocumentWorkspaceCategoryLabel(readyCategory)}已检查且有数据`,
),
).toBeInTheDocument()
expect(
within(pendingRow as HTMLElement).getByLabelText(`${pendingCategory}未检查或无数据`),
within(pendingRow as HTMLElement).getByLabelText(
`${getNonIntegratedDocumentWorkspaceCategoryLabel(pendingCategory)}未检查或无数据`,
),
).toBeInTheDocument()
})

View File

@ -4,6 +4,7 @@ import Link from "next/link";
import { useRouter } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import {
Check,
Copy,
Eye,
Layers3,
@ -13,6 +14,7 @@ import {
Pencil,
Printer,
Trash2,
X as XIcon,
} from "lucide-react";
import MaterialLibraryClient from "@/app/asset-center/material-library/MaterialLibraryClient";
import AccessoryLibraryPanel from "@/app/intake/AccessoryLibraryPanel";
@ -35,6 +37,7 @@ import {
NON_INTEGRATED_BRAND_PREFIX_MAP,
NON_INTEGRATED_DOCUMENT_WORKSPACE_CATEGORIES,
NON_INTEGRATED_PRODUCTS_UPDATED,
getNonIntegratedDocumentWorkspaceCategoryLabel,
saveNonIntegratedProduct,
type NonIntegratedProductRecord,
} from "./non-integrated/nonIntegratedStorage";
@ -148,6 +151,31 @@ function SpuLabel() {
);
}
function DocumentStatusMark({
category,
ready,
}: {
category: NonIntegratedDocumentStatusCategory;
ready: boolean;
}) {
const Icon = ready ? Check : XIcon;
const label = getNonIntegratedDocumentWorkspaceCategoryLabel(category);
return (
<span
aria-label={`${label}${ready ? "已检查且有数据" : "未检查或无数据"}`}
title={ready ? "已检查且有数据" : "未检查或无数据"}
className={`inline-flex h-6 w-6 items-center justify-center rounded-md border transition-colors ${
ready
? "border-zinc-950 bg-zinc-950 text-white shadow-[inset_0_1px_0_rgba(255,255,255,0.18)]"
: "border-zinc-200 bg-zinc-50 text-zinc-400"
}`}
>
<Icon className="h-3.5 w-3.5" strokeWidth={2.4} aria-hidden="true" />
</span>
);
}
type NonIntegratedDocumentStatusCategory =
(typeof NON_INTEGRATED_DOCUMENT_WORKSPACE_CATEGORIES)[number];
@ -156,6 +184,10 @@ const NON_INTEGRATED_DOCUMENT_STATUS_COLUMNS = [
] as NonIntegratedDocumentStatusCategory[];
const PENDING_PAGE_SIZE = 25;
const TABLE_ACTION_BUTTON_BASE =
"!h-8 !min-h-8 !w-8 !rounded-md !px-0 !py-0 !shadow-none";
const TABLE_ACTION_BUTTON_SECONDARY = `${TABLE_ACTION_BUTTON_BASE} !border-zinc-200 !bg-white !text-zinc-500 hover:!border-zinc-300 hover:!bg-zinc-50 hover:!text-zinc-950 focus:!border-zinc-400 disabled:!border-zinc-100 disabled:!bg-zinc-50 disabled:!text-zinc-300`;
const TABLE_ACTION_BUTTON_PRIMARY = `${TABLE_ACTION_BUTTON_BASE} !border-zinc-950 !bg-zinc-950 !text-white hover:!border-zinc-800 hover:!bg-zinc-800 focus:!border-zinc-600 disabled:!border-zinc-200 disabled:!bg-zinc-200 disabled:!text-zinc-400`;
const BOM_LIBRARY_OPTIONS: {
key: BomLibraryKey;
@ -1179,7 +1211,7 @@ function NonIntegratedPendingDetail({
key={category}
className="min-w-[88px] px-3 py-2.5 text-center font-apple-medium whitespace-nowrap"
>
{category}
{getNonIntegratedDocumentWorkspaceCategoryLabel(category)}
</th>
))}
<th className="sticky right-0 z-20 min-w-[152px] border-l border-apple-separator bg-apple-bg-primary px-2.5 py-2.5 text-center font-apple-medium whitespace-nowrap shadow-[-10px_0_14px_-14px_rgba(15,23,42,0.28)]">
@ -1280,16 +1312,10 @@ function NonIntegratedPendingDetail({
key={category}
className="px-2 py-3 text-center align-middle"
>
<span
aria-label={`${category}${isReady ? "已检查且有数据" : "未检查或无数据"}`}
className={`inline-flex h-7 w-7 items-center justify-center rounded-full text-sm font-apple-semibold ${
isReady
? "bg-emerald-100 text-emerald-700"
: "bg-rose-100 text-rose-500"
}`}
>
{isReady ? "√" : "×"}
</span>
<DocumentStatusMark
category={category}
ready={isReady}
/>
</td>
);
},
@ -1304,7 +1330,7 @@ function NonIntegratedPendingDetail({
<Button
variant="plain"
size="small"
className="!h-8 !min-h-8 !w-8 !rounded-apple-lg !px-0 !py-0"
className={TABLE_ACTION_BUTTON_SECONDARY}
>
<Eye className="h-4 w-4" aria-hidden="true" />
<span className="sr-only">查看</span>
@ -1316,8 +1342,9 @@ function NonIntegratedPendingDetail({
title="修改"
>
<Button
variant="plain"
size="small"
className="!h-8 !min-h-8 !w-8 !rounded-apple-lg !px-0 !py-0"
className={TABLE_ACTION_BUTTON_PRIMARY}
>
<Pencil
className="h-4 w-4"
@ -1331,7 +1358,7 @@ function NonIntegratedPendingDetail({
size="small"
aria-label="复制"
title="复制"
className="!h-8 !min-h-8 !w-8 !rounded-apple-lg !px-0 !py-0"
className={TABLE_ACTION_BUTTON_SECONDARY}
onClick={() => openCopyDialog(product)}
disabled={
getAvailableCopyBrands(product).length === 0
@ -1345,7 +1372,7 @@ function NonIntegratedPendingDetail({
size="small"
aria-label="删除"
title="删除"
className="!h-8 !min-h-8 !w-8 !rounded-apple-lg !px-0 !py-0"
className={TABLE_ACTION_BUTTON_SECONDARY}
onClick={() => handleDelete(product)}
>
<Trash2 className="h-4 w-4" aria-hidden="true" />

View File

@ -460,6 +460,19 @@ describe("NonIntegratedDetailClient", () => {
);
const sidebar = screen.getByTestId("document-workspace-sidebar");
expect(
sidebar.querySelector<HTMLButtonElement>(
'button[data-testid="document-workspace-basic-info-trigger"]',
),
).not.toBeNull();
expect(
Array.from(
sidebar.querySelectorAll<HTMLButtonElement>(
'button[data-testid^="document-workspace"]',
),
)[0]?.getAttribute("data-testid"),
).toBe("document-workspace-basic-info-trigger");
const triggerIds = Array.from(
sidebar.querySelectorAll<HTMLButtonElement>(
'button[data-testid^="document-workspace-trigger-"]',
@ -1120,14 +1133,16 @@ describe("NonIntegratedDetailClient", () => {
await actor.click(screen.getByTestId("document-workspace-trigger-产品实拍图"));
expect(screen.getByRole("button", { name: "返回详情" })).toBeInTheDocument();
expect(
screen.getByTestId("document-workspace-basic-info-trigger"),
).toHaveAttribute("data-active", "false");
await actor.click(screen.getByRole("button", { name: "返回详情" }));
await actor.click(screen.getByTestId("document-workspace-basic-info-trigger"));
expect(screen.getByText(/产品开发明细/)).toBeInTheDocument();
expect(
screen.queryByRole("button", { name: "返回详情" }),
).not.toBeInTheDocument();
screen.getByTestId("document-workspace-basic-info-trigger"),
).toHaveAttribute("data-active", "true");
});
it("marks copied document categories as pending until the current category is confirmed", async () => {

View File

@ -100,6 +100,7 @@ import {
NON_INTEGRATED_BRAND_PREFIX_MAP,
NON_INTEGRATED_DUPLICATE_SPU_MESSAGE,
NON_INTEGRATED_DOCUMENT_WORKSPACE_CATEGORIES,
getNonIntegratedDocumentWorkspaceCategoryLabel,
getNonIntegratedProductById,
hasPendingDocumentWorkspaceConfirmation,
replaceNonIntegratedProducts,
@ -1083,19 +1084,7 @@ const DOCUMENT_WORKSPACE_COPY: Record<
function getDocumentWorkspaceCategoryLabel(
category: DocumentWorkspaceCategory,
) {
if (category === PRODUCT_PHOTO_CATEGORY) {
return "产品实拍";
}
if (category === DETECTION_WORKSPACE_CATEGORY) {
return "仓库抽检";
}
if (category === PRODUCT_DATA_WORKSPACE_CATEGORY) {
return "产品资料";
}
return category;
return getNonIntegratedDocumentWorkspaceCategoryLabel(category);
}
function getUploadCategoryOptionLabel(category: UploadCategoryKey | string) {
@ -15763,27 +15752,15 @@ export default function NonIntegratedDetailClient({
图片与文档
</p>
<div className="flex shrink-0 flex-wrap items-center justify-end gap-1">
{!isDocumentWorkspaceMode ? (
<Link href="/intake">
<Button
variant="plain"
size="small"
className="!min-h-7 !rounded-apple-md !px-2.5 !py-1 !text-xs"
>
返回列表
</Button>
</Link>
) : null}
{isDocumentWorkspaceMode ? (
<Link href="/intake">
<Button
variant="plain"
size="small"
className="!min-h-7 !rounded-apple-md !px-2.5 !py-1 !text-xs"
onClick={() => setDocumentWorkspaceView("detail")}
>
返回详情
返回列表
</Button>
) : null}
</Link>
</div>
</div>
<p className="mt-1 text-[11px] text-apple-label-secondary">
@ -15791,6 +15768,34 @@ export default function NonIntegratedDetailClient({
</p>
</div>
<div className="flex flex-col gap-2 p-2">
<button
type="button"
data-testid="document-workspace-basic-info-trigger"
data-active={
documentWorkspaceView === "detail" ? "true" : "false"
}
onClick={() => setDocumentWorkspaceView("detail")}
className={`rounded-apple-lg border px-3 py-2 text-left transition ${
documentWorkspaceView === "detail"
? "border-apple-label-primary bg-apple-label-primary text-white"
: "border-apple-separator bg-apple-bg-primary text-apple-label-primary hover:bg-apple-bg-secondary/40"
}`}
>
<div className="flex items-center justify-between gap-2">
<p className="text-[15px] font-apple-medium leading-5">
基础信息
</p>
<span
className={`rounded-full px-2 py-0.5 text-[11px] ${
documentWorkspaceView === "detail"
? "bg-white/20 text-white"
: "bg-apple-bg-secondary text-apple-label-secondary"
}`}
>
{skuItems.length}
</span>
</div>
</button>
{groupedUploadFiles.map(({ category, files }) => {
const isActive =
documentWorkspaceView === "document" &&

View File

@ -47,6 +47,15 @@ export const NON_INTEGRATED_DOCUMENT_WORKSPACE_CATEGORIES = [
export type NonIntegratedDocumentWorkspaceCategory =
(typeof NON_INTEGRATED_DOCUMENT_WORKSPACE_CATEGORIES)[number];
export function getNonIntegratedDocumentWorkspaceCategoryLabel(
category: NonIntegratedDocumentWorkspaceCategory | string,
) {
if (category === "产品实拍图") return "产品实拍";
if (category === "产品模型") return "产品资料";
if (category === "检测报告") return "仓库抽检";
return category;
}
export type NonIntegratedDocumentWorkspaceConfirmationMap = Record<
NonIntegratedDocumentWorkspaceCategory,
boolean
@ -78,6 +87,7 @@ const NON_INTEGRATED_UPLOAD_CATEGORY_MAP: Record<string, string> = {
产品核价: "产品核价",
工厂质检: "工厂质检",
仓库质检: "工厂质检",
仓库抽检: "检测报告",
产品资料: "产品模型",
产品实拍: "产品实拍图",
安装资料: "安装方式",

View File

@ -44,10 +44,18 @@ export function AppShell({ children, user }: AppShellProps) {
pathname === '/asset-center' || pathname.startsWith('/asset-center/')
const isAssistedAssemblyRoute =
pathname === '/assisted-assembly' || pathname.startsWith('/assisted-assembly/')
const isDraftGroupsRoute = pathname === '/draft-groups' || pathname.startsWith('/draft-groups/')
const isIntakeRoute = pathname === '/intake' || pathname.startsWith('/intake/')
const isWarehouseRoute = pathname === '/warehouse' || pathname.startsWith('/warehouse/')
const isWorkbenchShellRoute =
isHomeRoute ||
isAssetCenterRoute ||
isAssistedAssemblyRoute ||
isDraftGroupsRoute ||
isIntakeRoute ||
isWarehouseRoute
const shouldLockMainViewport =
isHomeRoute || isAssetCenterRoute || isAssistedAssemblyRoute || isIntakeRoute || isWarehouseRoute
isWorkbenchShellRoute
useEffect(() => {
if (!shouldLockMainViewport || typeof document === 'undefined') {
@ -82,12 +90,12 @@ export function AppShell({ children, user }: AppShellProps) {
<div className="min-h-screen" style={{ backgroundColor: 'var(--color-surface)' }}>
<AppTopNav
user={user}
variant={isHomeRoute || isAssetCenterRoute || isAssistedAssemblyRoute ? 'workbench' : 'default'}
variant={isWorkbenchShellRoute ? 'workbench' : 'default'}
/>
{!isHomeRoute && !isAssetCenterRoute && !isAssistedAssemblyRoute && <AppSideNav />}
{!isWorkbenchShellRoute && <AppSideNav />}
<main
className={
isHomeRoute || isAssetCenterRoute || isAssistedAssemblyRoute
isWorkbenchShellRoute
? 'mt-16 h-[calc(100dvh-64px)] min-h-[calc(100dvh-64px)] overflow-hidden overscroll-none bg-[#f8f9fa]'
: shouldLockMainViewport
? 'ml-[var(--layout-app-sidebar-width)] mt-14 h-[calc(100vh-56px)] overflow-hidden overscroll-none bg-white'

View File

@ -52,17 +52,17 @@ describe('AppShell', () => {
})
it('新平台主链页面渲染全局壳子', () => {
usePathnameMock.mockReturnValue('/intake')
usePathnameMock.mockReturnValue('/draft-groups')
render(
<AppShell>
<div>intake content</div>
<div>draft groups content</div>
</AppShell>,
)
expect(screen.getByTestId('app-top-nav')).toBeInTheDocument()
expect(screen.getByTestId('app-side-nav')).toBeInTheDocument()
expect(screen.getByText('intake content')).toBeInTheDocument()
expect(screen.getByTestId('app-top-nav')).toHaveAttribute('data-variant', 'workbench')
expect(screen.queryByTestId('app-side-nav')).not.toBeInTheDocument()
expect(screen.getByText('draft groups content')).toBeInTheDocument()
})
it('首页使用工作台壳子且不再渲染左侧旧导航', () => {
@ -92,4 +92,29 @@ describe('AppShell', () => {
expect(screen.queryByTestId('app-side-nav')).not.toBeInTheDocument()
expect(screen.getByText('assisted assembly content')).toBeInTheDocument()
})
it('正式产品和仓库管理使用顶部工作台导航', () => {
usePathnameMock.mockReturnValue('/intake')
const { rerender } = render(
<AppShell>
<div>intake content</div>
</AppShell>,
)
expect(screen.getByTestId('app-top-nav')).toHaveAttribute('data-variant', 'workbench')
expect(screen.queryByTestId('app-side-nav')).not.toBeInTheDocument()
expect(screen.getByText('intake content')).toBeInTheDocument()
usePathnameMock.mockReturnValue('/warehouse')
rerender(
<AppShell>
<div>warehouse content</div>
</AppShell>,
)
expect(screen.getByTestId('app-top-nav')).toHaveAttribute('data-variant', 'workbench')
expect(screen.queryByTestId('app-side-nav')).not.toBeInTheDocument()
expect(screen.getByText('warehouse content')).toBeInTheDocument()
})
})