diff --git a/packages/shared/src/components/PlatformFilterPanel.test.tsx b/packages/shared/src/components/PlatformFilterPanel.test.tsx
new file mode 100644
index 000000000..7165d0ca6
--- /dev/null
+++ b/packages/shared/src/components/PlatformFilterPanel.test.tsx
@@ -0,0 +1,90 @@
+/* @vitest-environment jsdom */
+
+import { fireEvent, render, screen } from '@testing-library/react';
+import { describe, expect, test, vi } from 'vitest';
+
+import {
+ PlatformFilterPanel,
+ PlatformFilterPanelField,
+} from './PlatformFilterPanel';
+
+describe('PlatformFilterPanel', () => {
+ test('renders a titled dialog shell and forwards close', () => {
+ const onClose = vi.fn();
+ render(
+
+
+ 字段控件
+
+ ,
+ );
+
+ expect(screen.getByRole('dialog', { name: '筛选资源' })).toBeTruthy();
+ expect(screen.getByRole('heading', { name: '筛选' })).toBeTruthy();
+ expect(screen.getByText('所在区域')).toBeTruthy();
+
+ fireEvent.click(screen.getByRole('button', { name: '关闭筛选' }));
+ expect(onClose).toHaveBeenCalledTimes(1);
+ });
+
+ test('renders every field passed in, in order, and no field of its own', () => {
+ render(
+ {}}>
+
+
+
+
+
+
+
+ 标签区
+
+ ,
+ );
+
+ // 外壳自己不知道任何字段语义:字段名只可能来自宿主传入的 children。
+ expect(screen.getByText('查找素材')).toBeTruthy();
+ expect(screen.getByText('所在区域')).toBeTruthy();
+ expect(screen.getByText('自定义标签')).toBeTruthy();
+ expect(screen.getByLabelText('关键词')).toBeTruthy();
+ expect(screen.getByLabelText('区域')).toBeTruthy();
+ // 「状态」不在本产品范围内(系统没有资源状态事实源),外壳不得自带占位字段。
+ expect(screen.queryByText('状态')).toBeNull();
+ expect(screen.queryByText('全部状态')).toBeNull();
+ });
+
+ test('associates a field label with its control id when provided', () => {
+ render(
+ {}}>
+
+
+
+ ,
+ );
+
+ expect(screen.getByLabelText('所在区域')).toBe(
+ screen.getByLabelText('区域'),
+ );
+ });
+
+ test('lets the host own positioning and extra classes', () => {
+ render(
+ {}}
+ className="host-position-class"
+ >
+
+ 字段控件
+
+ ,
+ );
+
+ const panel = screen.getByRole('dialog', { name: '筛选资源' });
+ expect(panel.className).toContain('host-position-class');
+ // 组件不自带 position:定位必须由宿主注入,避免共享层猜宿主布局。
+ expect(panel.className).not.toContain('absolute');
+ expect(panel.className).not.toContain('fixed');
+ });
+});
diff --git a/packages/shared/src/components/PlatformFilterPanel.tsx b/packages/shared/src/components/PlatformFilterPanel.tsx
new file mode 100644
index 000000000..27c82707b
--- /dev/null
+++ b/packages/shared/src/components/PlatformFilterPanel.tsx
@@ -0,0 +1,90 @@
+import { X } from 'lucide-react';
+import type { ReactNode } from 'react';
+
+import { PlatformFieldLabel } from './PlatformFieldLabel';
+
+export type PlatformFilterPanelFieldProps = {
+ /** 字段名,例如「所在区域」。 */
+ label: string;
+ /** 字段控件;由宿主持有取值与回调,本组件不解释任何筛选语义。 */
+ children: ReactNode;
+ /** 控件 id,用于把字段名与控件关联起来。不传则只渲染视觉字段名。 */
+ controlId?: string;
+};
+
+/**
+ * 筛选面板里的一个竖向字段。
+ *
+ * 只负责「字段名在上、控件在下」这一条排布约定,字段名与控件都由宿主给出。
+ */
+export function PlatformFilterPanelField({
+ label,
+ children,
+ controlId,
+}: PlatformFilterPanelFieldProps) {
+ return (
+
+
+ {children}
+
+ );
+}
+
+export type PlatformFilterPanelProps = {
+ /** 浮层无障碍名称,例如「筛选资源」。 */
+ ariaLabel: string;
+ /** 浮层标题。 */
+ title: string;
+ /** 右上角关闭键的无障碍名称。 */
+ closeLabel?: string;
+ onClose: () => void;
+ /** 竖向排布的字段,通常由若干 `PlatformFilterPanelField` 组成。 */
+ children: ReactNode;
+ className?: string;
+};
+
+/**
+ * 按需弹出的筛选浮层外壳。
+ *
+ * 只承载通用表现:竖向字段容器、右上角关闭键与 `dialog` 语义。字段有哪些、每个字段
+ * 取什么值、筛选怎么算,全部由宿主持有——因此同一个外壳可以在不同页面各自装一套
+ * 字段,不把任何领域规则下沉到共享层。
+ *
+ * 定位交给宿主:调用方通过 `className` 给出绝对定位与层级,组件本身不自带 position。
+ */
+export function PlatformFilterPanel({
+ ariaLabel,
+ title,
+ closeLabel = '关闭筛选',
+ onClose,
+ children,
+ className,
+}: PlatformFilterPanelProps) {
+ return (
+
+ );
+}