# MRO Data Viewer 数据查询页面实现计划

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** 实现两个数据查询页面（FA 固定资产明细、CRM 客户联系人变更）+ NextAuth 管理员鉴权 + CSV 导出 + 统计汇总

**Architecture:** NextAuth v5 Credentials Provider 管理员鉴权 + Server Actions 直连 PostgreSQL（pg 连接池）+ shadcn/ui 数据表格组件。两张表结构相似，抽象共享查询逻辑。

**Tech Stack:** Next.js 16, NextAuth v5, pg, shadcn/ui (radix-nova style), Tailwind CSS 4, Server Actions

---

## File Structure

```
新增/修改文件清单：

lib/db.ts                   — pg 连接池，单例 Pool
lib/auth.ts                 — NextAuth v5 配置（Credentials Provider）
lib/query.ts                — 共享查询逻辑（queryTable + exportTable）
lib/export.ts               — CSV 导出工具函数
middleware.ts               — 路由保护中间件
app/api/auth/[...nextauth]/route.ts — NextAuth API 路由
app/login/page.tsx          — 登录页面
app/fa-details/page.tsx     — FA 查询页面（客户端组件）
app/fa-details/actions.ts   — FA Server Actions
app/crm-contacts/page.tsx   — CRM 查询页面（客户端组件）
app/crm-contacts/actions.ts — CRM Server Actions
app/layout.tsx              — 修改：添加 SessionProvider + NavBar
app/page.tsx                — 修改：首页导航入口
components/nav-bar.tsx      — 顶部导航栏（含登出）
components/data-filter.tsx  — 通用筛选栏组件
components/data-table.tsx   — 通用数据表格组件（分页）
components/data-summary.tsx — 统计汇总栏组件
.env.local                  — 环境变量配置
```

---

### Task 1: 安装依赖 + 环境变量 + shadcn/ui 组件

**Files:**
- Create: `.env.local`
- Modify: `package.json` (新增依赖)

- [ ] **Step 1: 安装核心依赖**

```bash
pnpm add next-auth@beta pg
pnpm add -D @types/pg
```

- [ ] **Step 2: 安装 shadcn/ui 组件**

```bash
pnpm dlx shadcn@latest add input label select card table badge separator dropdown-menu dialog
```

- [ ] **Step 3: 创建 `.env.local`**

```env
DATABASE_URL=postgresql://foldspace_dev:yRt4QoFwbmaB2E6sDdk9@frpdx.xhyonline.com:15281/foldspace_dev
NEXTAUTH_SECRET=dev-secret-change-in-production-mro2026
NEXTAUTH_URL=http://localhost:3000
ADMIN_USERNAME=admin
ADMIN_PASSWORD=admin123
```

- [ ] **Step 4: 验证依赖安装**

```bash
pnpm install
```

- [ ] **Step 5: Commit**

```bash
git add .env.local package.json pnpm-lock.yaml
git commit -m "chore: add next-auth, pg, and shadcn/ui dependencies"
```

---

### Task 2: 数据库连接池 + 共享查询逻辑

**Files:**
- Create: `lib/db.ts`
- Create: `lib/query.ts`
- Create: `lib/export.ts`

- [ ] **Step 1: 创建 `lib/db.ts` — pg 连接池**

```typescript
import pg from "pg";

let pool: pg.Pool | null = null;

export function getPool(): pg.Pool {
  if (!pool) {
    pool = new pg.Pool({
      connectionString: process.env.DATABASE_URL,
      max: 10,
      idleTimeoutMillis: 30000,
      connectionTimeoutMillis: 5000,
    });
  }
  return pool;
}

export async function query(text: string, params: unknown[]) {
  const pool = getPool();
  const start = Date.now();
  const result = await pool.query(text, params);
  const duration = Date.now() - start;
  console.log("executed query", { text, duration, rows: result.rowCount });
  return result;
}
```

- [ ] **Step 2: 创建 `lib/query.ts` — 共享查询逻辑**

```typescript
import { query } from "./db";

export interface TableFilters {
  documentNumber?: string;
  vendorName?: string;
  startDate?: string;
  endDate?: string;
  status?: string;
}

export interface QueryResult<T> {
  rows: T[];
  total: number;
  summary: {
    totalAmount: number;
    taxAmount: number;
    grandTotal: number;
    count: number;
  };
}

export interface ExportResult {
  csv: string;
  filename: string;
}

// FA 表行类型
export interface FaDetailRow {
  id: number;
  document_number: string | null;
  document_date: string | null;
  vendor_code: string | null;
  vendor_name: string | null;
  material_code: string | null;
  material_name: string | null;
  material_spec: string | null;
  quantity: number | null;
  unit_price: number | null;
  amount: number | null;
  tax_rate: number | null;
  tax_amount: number | null;
  total_amount: number | null;
  status: string | null;
  approver: string | null;
}

// CRM 表行类型
export interface CrmContactRow {
  id: number;
  document_number: string | null;
  document_date: string | null;
  vendor_code: string | null;
  vendor_name: string | null;
  description: string | null;
  amount: number | null;
  total_amount: number | null;
  status: string | null;
  approver: string | null;
}

function buildWhereClause(filters: TableFilters): { clause: string; params: unknown[] } {
  const conditions: string[] = [];
  const params: unknown[] = [];
  let paramIndex = 1;

  if (filters.documentNumber) {
    conditions.push(`document_number ILIKE $${paramIndex++}`);
    params.push(`%${filters.documentNumber}%`);
  }
  if (filters.vendorName) {
    conditions.push(`vendor_name ILIKE $${paramIndex++}`);
    params.push(`%${filters.vendorName}%`);
  }
  if (filters.startDate) {
    conditions.push(`document_date >= $${paramIndex++}`);
    params.push(filters.startDate);
  }
  if (filters.endDate) {
    conditions.push(`document_date <= $${paramIndex++}`);
    params.push(filters.endDate);
  }
  if (filters.status) {
    conditions.push(`status = $${paramIndex++}`);
    params.push(filters.status);
  }

  const clause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
  return { clause, params };
}

export async function queryTable<T>(
  tableName: string,
  filters: TableFilters,
  page: number,
  pageSize: number
): Promise<QueryResult<T>> {
  const { clause, params } = buildWhereClause(filters);
  const offset = (page - 1) * pageSize;

  // 主查询
  const dataSql = `
    SELECT * FROM ${tableName}
    ${clause}
    ORDER BY document_date DESC NULLS LAST, id DESC
    LIMIT $${params.length + 1} OFFSET $${params.length + 2}
  `;
  const dataParams = [...params, pageSize, offset];
  const dataResult = await query(dataSql, dataParams);

  // 总数查询
  const countSql = `SELECT COUNT(*) as total FROM ${tableName} ${clause}`;
  const countResult = await query(countSql, params);

  // 统计汇总查询
  const summarySql = `
    SELECT
      COALESCE(SUM(amount), 0) as total_amount,
      COALESCE(SUM(tax_amount), 0) as tax_amount,
      COALESCE(SUM(total_amount), 0) as grand_total,
      COUNT(*) as count
    FROM ${tableName} ${clause}
  `;
  const summaryResult = await query(summarySql, params);

  return {
    rows: dataResult.rows as T[],
    total: countResult.rows[0].total,
    summary: {
      totalAmount: summaryResult.rows[0].total_amount,
      taxAmount: summaryResult.rows[0].tax_amount,
      grandTotal: summaryResult.rows[0].grand_total,
      count: summaryResult.rows[0].count,
    },
  };
}

export async function exportTable(
  tableName: string,
  columns: { key: string; label: string }[],
  filters: TableFilters
): Promise<ExportResult> {
  const { clause, params } = buildWhereClause(filters);

  const selectCols = columns.map((c) => c.key).join(", ");
  const sql = `
    SELECT ${selectCols} FROM ${tableName}
    ${clause}
    ORDER BY document_date DESC NULLS LAST, id DESC
  `;
  const result = await query(sql, params);

  // 构建 CSV
  const header = columns.map((c) => c.label).join(",");
  const rows = result.rows.map((row) =>
    columns
      .map((c) => {
        const val = row[c.key];
        if (val === null || val === undefined) return "";
        const str = String(val);
        // CSV 中需要转义含逗号、引号、换行的值
        if (str.includes(",") || str.includes('"') || str.includes("\n")) {
          return `"${str.replace(/"/g, '""')}"`;
        }
        return str;
      })
      .join(",")
  );
  const csv = [header, ...rows].join("\n");

  return {
    csv,
    filename: `${tableName}_${new Date().toISOString().slice(0, 10)}.csv`,
  };
}
```

- [ ] **Step 3: 创建 `lib/export.ts` — CSV 下载辅助函数**

```typescript
import { exportTable } from "./query";

export async function downloadCsv(
  tableName: string,
  columns: { key: string; label: string }[],
  filters: {
    documentNumber?: string;
    vendorName?: string;
    startDate?: string;
    endDate?: string;
    status?: string;
  }
): Promise<{ csv: string; filename: string }> {
  return exportTable(tableName, columns, filters);
}
```

- [ ] **Step 4: 验证编译通过**

```bash
pnpm exec tsc --noEmit
```

- [ ] **Step 5: Commit**

```bash
git add lib/db.ts lib/query.ts lib/export.ts
git commit -m "feat: add database connection pool and shared query logic"
```

---

### Task 3: NextAuth v5 鉴权配置

**Files:**
- Create: `lib/auth.ts`
- Create: `app/api/auth/[...nextauth]/route.ts`
- Create: `middleware.ts`

- [ ] **Step 1: 创建 `lib/auth.ts` — NextAuth 配置**

```typescript
import NextAuth from "next-auth";
import Credentials from "next-auth/providers/credentials";

export const { handlers, signIn, signOut, auth } = NextAuth({
  providers: [
    Credentials({
      name: "credentials",
      credentials: {
        username: { label: "用户名", type: "text" },
        password: { label: "密码", type: "password" },
      },
      async authorize(credentials) {
        const username = credentials?.username as string;
        const password = credentials?.password as string;

        if (
          username === process.env.ADMIN_USERNAME &&
          password === process.env.ADMIN_PASSWORD
        ) {
          return { id: "1", name: "管理员", username };
        }
        return null;
      },
    }),
  ],
  pages: {
    signIn: "/login",
  },
  session: {
    strategy: "jwt",
  },
});

declare module "next-auth" {
  interface Session {
    user: {
      id: string;
      name: string;
      username: string;
    };
  }
}
```

- [ ] **Step 2: 创建 `app/api/auth/[...nextauth]/route.ts`**

```typescript
import { handlers } from "@/lib/auth";

export const { GET, POST } = handlers;
```

- [ ] **Step 3: 创建 `middleware.ts` — 路由保护**

```typescript
import { auth } from "@/lib/auth";

export default auth((req) => {
  const isLoggedIn = !!req.auth;
  const isOnLoginPage = req.nextUrl.pathname.startsWith("/login");

  if (isLoggedIn && isOnLoginPage) {
    return Response.redirect(new URL("/", req.url));
  }

  if (!isLoggedIn && !isOnLoginPage) {
    return Response.redirect(new URL("/login", req.url));
  }

  return undefined;
});

export const config = {
  matcher: ["/((?!api/auth|_next/static|_next/image|favicon.ico).*)"],
};
```

- [ ] **Step 4: 验证编译通过**

```bash
pnpm exec tsc --noEmit
```

- [ ] **Step 5: Commit**

```bash
git add lib/auth.ts app/api/auth/[...nextauth]/route.ts middleware.ts
git commit -m "feat: add NextAuth credentials provider and route middleware"
```

---

### Task 4: 登录页面

**Files:**
- Create: `app/login/page.tsx`

- [ ] **Step 1: 创建 `app/login/page.tsx`**

```typescript
"use client";

import { signIn } from "next-auth/react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";

export default function LoginPage() {
  const [username, setUsername] = useState("");
  const [password, setPassword] = useState("");
  const [error, setError] = useState("");
  const [loading, setLoading] = useState(false);

  async function handleSubmit(e: React.FormEvent) {
    e.preventDefault();
    setError("");
    setLoading(true);

    const result = await signIn("credentials", {
      username,
      password,
      redirect: false,
    });

    setLoading(false);

    if (result?.error) {
      setError("用户名或密码错误");
    } else {
      window.location.href = "/";
    }
  }

  return (
    <div className="flex min-h-screen items-center justify-center bg-zinc-50 dark:bg-black">
      <Card className="w-full max-w-sm">
        <CardHeader>
          <CardTitle>MRO 数据查询系统</CardTitle>
          <CardDescription>请输入管理员账号登录</CardDescription>
        </CardHeader>
        <CardContent>
          <form onSubmit={handleSubmit} className="space-y-4">
            <div className="space-y-2">
              <Label htmlFor="username">用户名</Label>
              <Input
                id="username"
                value={username}
                onChange={(e) => setUsername(e.target.value)}
                placeholder="请输入用户名"
                required
              />
            </div>
            <div className="space-y-2">
              <Label htmlFor="password">密码</Label>
              <Input
                id="password"
                type="password"
                value={password}
                onChange={(e) => setPassword(e.target.value)}
                placeholder="请输入密码"
                required
              />
            </div>
            {error && (
              <p className="text-sm text-red-500">{error}</p>
            )}
            <Button type="submit" className="w-full" disabled={loading}>
              {loading ? "登录中..." : "登录"}
            </Button>
          </form>
        </CardContent>
      </Card>
    </div>
  );
}
```

- [ ] **Step 2: 验证编译通过**

```bash
pnpm exec tsc --noEmit
```

- [ ] **Step 3: Commit**

```bash
git add app/login/page.tsx
git commit -m "feat: add login page with credentials form"
```

---

### Task 5: 导航栏 + Layout 更新 + 首页

**Files:**
- Create: `components/nav-bar.tsx`
- Modify: `app/layout.tsx`
- Modify: `app/page.tsx`

- [ ] **Step 1: 创建 `components/nav-bar.tsx`**

```typescript
"use client";

import Link from "next/link";
import { signOut, useSession } from "next-auth/react";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";

export function NavBar() {
  const { data: session } = useSession();

  return (
    <header className="sticky top-0 z-50 border-b bg-white dark:bg-zinc-950">
      <div className="flex h-14 items-center px-6 gap-6">
        <Link href="/" className="font-semibold text-lg">
          MRO 数据查询
        </Link>
        <Separator orientation="vertical" className="h-6" />
        {session && (
          <nav className="flex items-center gap-4">
            <Link
              href="/fa-details"
              className="text-sm text-zinc-600 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-50"
            >
              FA 固定资产明细
            </Link>
            <Link
              href="/crm-contacts"
              className="text-sm text-zinc-600 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-50"
            >
              CRM 客户联系人变更
            </Link>
          </nav>
        )}
        <div className="flex-1" />
        {session && (
          <div className="flex items-center gap-4">
            <span className="text-sm text-zinc-500">{session.user?.name}</span>
            <Button
              variant="outline"
              size="sm"
              onClick={() => signOut({ callbackUrl: "/login" })}
            >
              登出
            </Button>
          </div>
        )}
      </div>
    </header>
  );
}
```

- [ ] **Step 2: 修改 `app/layout.tsx` — 添加 SessionProvider + NavBar**

```typescript
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import { SessionProvider } from "next-auth/react";
import { NavBar } from "@/components/nav-bar";
import "./globals.css";

const geistSans = Geist({
  variable: "--font-geist-sans",
  subsets: ["latin"],
});

const geistMono = Geist_Mono({
  variable: "--font-geist-mono",
  subsets: ["latin"],
});

export const metadata: Metadata = {
  title: "MRO 数据查询系统",
  description: "固定资产明细与客户联系人变更数据查询",
};

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html
      lang="zh-CN"
      className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
    >
      <body className="min-h-full flex flex-col">
        <SessionProvider>
          <NavBar />
          <main className="flex-1">{children}</main>
        </SessionProvider>
      </body>
    </html>
  );
}
```

- [ ] **Step 3: 修改 `app/page.tsx` — 首页导航入口**

```typescript
import Link from "next/link";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";

export default function HomePage() {
  return (
    <div className="flex flex-1 items-center justify-center p-8">
      <div className="grid gap-6 max-w-lg w-full">
        <Card>
          <CardHeader>
            <CardTitle>FA 固定资产明细</CardTitle>
          </CardHeader>
          <CardContent>
            <p className="text-sm text-zinc-500 mb-4">
              查询固定资产明细数据，支持筛选、统计汇总和导出
            </p>
            <Link
              href="/fa-details"
              className="inline-flex h-9 items-center justify-center rounded-md bg-zinc-900 px-4 text-sm font-medium text-white hover:bg-zinc-800 dark:bg-zinc-50 dark:text-zinc-900 dark:hover:bg-zinc-200"
            >
              进入查询
            </Link>
          </CardContent>
        </Card>
        <Card>
          <CardHeader>
            <CardTitle>CRM 客户联系人变更</CardTitle>
          </CardHeader>
          <CardContent>
            <p className="text-sm text-zinc-500 mb-4">
              查询客户联系人变更数据，支持筛选、统计汇总和导出
            </p>
            <Link
              href="/crm-contacts"
              className="inline-flex h-9 items-center justify-center rounded-md bg-zinc-900 px-4 text-sm font-medium text-white hover:bg-zinc-800 dark:bg-zinc-50 dark:text-zinc-900 dark:hover:bg-zinc-200"
            >
              进入查询
            </Link>
          </CardContent>
        </Card>
      </div>
    </div>
  );
}
```

- [ ] **Step 4: 验证编译通过**

```bash
pnpm exec tsc --noEmit
```

- [ ] **Step 5: Commit**

```bash
git add components/nav-bar.tsx app/layout.tsx app/page.tsx
git commit -m "feat: add navigation bar, session provider, and home page"
```

---

### Task 6: 通用筛选栏组件

**Files:**
- Create: `components/data-filter.tsx`

- [ ] **Step 1: 创建 `components/data-filter.tsx`**

```typescript
"use client";

import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";

export interface FilterValues {
  documentNumber: string;
  vendorName: string;
  startDate: string;
  endDate: string;
  status: string;
}

interface DataFilterProps {
  filters: FilterValues;
  onFiltersChange: (filters: FilterValues) => void;
  statusOptions: { value: string; label: string }[];
}

export function DataFilter({ filters, onFiltersChange, statusOptions }: DataFilterProps) {
  function updateFilter(key: keyof FilterValues, value: string) {
    onFiltersChange({ ...filters, [key]: value });
  }

  function resetFilters() {
    onFiltersChange({
      documentNumber: "",
      vendorName: "",
      startDate: "",
      endDate: "",
      status: "",
    });
  }

  return (
    <div className="rounded-lg border bg-white p-4 dark:bg-zinc-950">
      <div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-5 gap-4">
        <div className="space-y-1">
          <Label htmlFor="documentNumber" className="text-xs">单据号</Label>
          <Input
            id="documentNumber"
            placeholder="模糊搜索"
            value={filters.documentNumber}
            onChange={(e) => updateFilter("documentNumber", e.target.value)}
          />
        </div>
        <div className="space-y-1">
          <Label htmlFor="vendorName" className="text-xs">供应商名称</Label>
          <Input
            id="vendorName"
            placeholder="模糊搜索"
            value={filters.vendorName}
            onChange={(e) => updateFilter("vendorName", e.target.value)}
          />
        </div>
        <div className="space-y-1">
          <Label htmlFor="startDate" className="text-xs">起始日期</Label>
          <Input
            id="startDate"
            type="date"
            value={filters.startDate}
            onChange={(e) => updateFilter("startDate", e.target.value)}
          />
        </div>
        <div className="space-y-1">
          <Label htmlFor="endDate" className="text-xs">截止日期</Label>
          <Input
            id="endDate"
            type="date"
            value={filters.endDate}
            onChange={(e) => updateFilter("endDate", e.target.value)}
          />
        </div>
        <div className="space-y-1">
          <Label htmlFor="status" className="text-xs">状态</Label>
          <Select
            value={filters.status}
            onValueChange={(v) => updateFilter("status", v)}
          >
            <SelectTrigger id="status">
              <SelectValue placeholder="全部状态" />
            </SelectTrigger>
            <SelectContent>
              {statusOptions.map((opt) => (
                <SelectItem key={opt.value} value={opt.value}>
                  {opt.label}
                </SelectItem>
              ))}
            </SelectContent>
          </Select>
        </div>
      </div>
      <div className="mt-3 flex justify-end">
        <Button variant="outline" size="sm" onClick={resetFilters}>
          重置筛选
        </Button>
      </div>
    </div>
  );
}
```

- [ ] **Step 2: 验证编译通过**

```bash
pnpm exec tsc --noEmit
```

- [ ] **Step 3: Commit**

```bash
git add components/data-filter.tsx
git commit -m "feat: add generic data filter component"
```

---

### Task 7: 统计汇总栏组件

**Files:**
- Create: `components/data-summary.tsx`

- [ ] **Step 1: 创建 `components/data-summary.tsx`**

```typescript
interface SummaryData {
  count: number;
  totalAmount: number;
  taxAmount: number;
  grandTotal: number;
}

interface DataSummaryProps {
  summary: SummaryData;
}

export function DataSummary({ summary }: DataSummaryProps) {
  return (
    <div className="grid grid-cols-2 md:grid-cols-4 gap-4 rounded-lg border bg-white p-4 dark:bg-zinc-950">
      <div>
        <p className="text-xs text-zinc-500">记录总数</p>
        <p className="text-lg font-semibold">{summary.count.toLocaleString()}</p>
      </div>
      <div>
        <p className="text-xs text-zinc-500">金额合计</p>
        <p className="text-lg font-semibold">
          ¥{Number(summary.totalAmount).toLocaleString("zh-CN", { minimumFractionDigits: 2 })}
        </p>
      </div>
      <div>
        <p className="text-xs text-zinc-500">税额合计</p>
        <p className="text-lg font-semibold">
          ¥{Number(summary.taxAmount).toLocaleString("zh-CN", { minimumFractionDigits: 2 })}
        </p>
      </div>
      <div>
        <p className="text-xs text-zinc-500">总金额合计</p>
        <p className="text-lg font-semibold">
          ¥{Number(summary.grandTotal).toLocaleString("zh-CN", { minimumFractionDigits: 2 })}
        </p>
      </div>
    </div>
  );
}
```

- [ ] **Step 2: 验证编译通过**

```bash
pnpm exec tsc --noEmit
```

- [ ] **Step 3: Commit**

```bash
git add components/data-summary.tsx
git commit -m "feat: add data summary component with stats display"
```

---

### Task 8: 通用数据表格组件

**Files:**
- Create: `components/data-table.tsx`

- [ ] **Step 1: 创建 `components/data-table.tsx`**

```typescript
"use client";

import {
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableHeader,
  TableRow,
} from "@/components/ui/table";
import { Button } from "@/components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";

export interface ColumnDef<T> {
  key: string;
  header: string;
  render?: (row: T) => React.ReactNode;
  className?: string;
}

interface DataTableProps<T> {
  columns: ColumnDef<T>[];
  data: T[];
  total: number;
  page: number;
  pageSize: number;
  onPageChange: (page: number) => void;
  onPageSizeChange: (size: number) => void;
}

export function DataTable<T>({
  columns,
  data,
  total,
  page,
  pageSize,
  onPageChange,
  onPageSizeChange,
}: DataTableProps<T>) {
  const totalPages = Math.ceil(total / pageSize);
  const startRow = (page - 1) * pageSize + 1;
  const endRow = Math.min(page * pageSize, total);

  return (
    <div className="space-y-3">
      <div className="rounded-lg border overflow-hidden">
        <Table>
          <TableHeader>
            <TableRow>
              {columns.map((col) => (
                <TableHead key={col.key} className={col.className}>
                  {col.header}
                </TableHead>
              ))}
            </TableRow>
          </TableHeader>
          <TableBody>
            {data.length === 0 ? (
              <TableRow>
                <TableCell colSpan={columns.length} className="text-center text-zinc-500 py-8">
                  暂无数据
                </TableCell>
              </TableRow>
            ) : (
              data.map((row, i) => (
                <TableRow key={i}>
                  {columns.map((col) => (
                    <TableCell key={col.key} className={col.className}>
                      {col.render
                        ? col.render(row)
                        : String(row[col.key as keyof T] ?? "")}
                    </TableCell>
                  ))}
                </TableRow>
              ))
            )}
          </TableBody>
        </Table>
      </div>

      <div className="flex items-center justify-between">
        <div className="text-sm text-zinc-500">
          显示 {startRow}-{endRow} 条，共 {total.toLocaleString()} 条
        </div>
        <div className="flex items-center gap-4">
          <Select
            value={String(pageSize)}
            onValueChange={(v) => onPageSizeChange(Number(v))}
          >
            <SelectTrigger className="w-[80px]">
              <SelectValue />
            </SelectTrigger>
            <SelectContent>
              <SelectItem value="20">20</SelectItem>
              <SelectItem value="50">50</SelectItem>
            </SelectContent>
          </Select>
          <div className="flex items-center gap-2">
            <Button
              variant="outline"
              size="sm"
              disabled={page <= 1}
              onClick={() => onPageChange(page - 1)}
            >
              上一页
            </Button>
            <span className="text-sm">
              {page} / {totalPages || 1}
            </span>
            <Button
              variant="outline"
              size="sm"
              disabled={page >= totalPages}
              onClick={() => onPageChange(page + 1)}
            >
              下一页
            </Button>
          </div>
        </div>
      </div>
    </div>
  );
}
```

- [ ] **Step 2: 验证编译通过**

```bash
pnpm exec tsc --noEmit
```

- [ ] **Step 3: Commit**

```bash
git add components/data-table.tsx
git commit -m "feat: add generic data table component with pagination"
```

---

### Task 9: FA 固定资产明细 Server Actions + 页面

**Files:**
- Create: `app/fa-details/actions.ts`
- Create: `app/fa-details/page.tsx`

- [ ] **Step 1: 创建 `app/fa-details/actions.ts`**

```typescript
"use server";

import { queryTable, exportTable, type FaDetailRow, type TableFilters } from "@/lib/query";

const FA_COLUMNS = [
  { key: "document_number", label: "单据号" },
  { key: "document_date", label: "单据日期" },
  { key: "vendor_code", label: "供应商编码" },
  { key: "vendor_name", label: "供应商名称" },
  { key: "material_code", label: "物料编码" },
  { key: "material_name", label: "物料名称" },
  { key: "material_spec", label: "物料规格" },
  { key: "quantity", label: "数量" },
  { key: "unit_price", label: "单价" },
  { key: "amount", label: "金额" },
  { key: "tax_rate", label: "税率" },
  { key: "tax_amount", label: "税额" },
  { key: "total_amount", label: "总金额" },
  { key: "status", label: "状态" },
  { key: "approver", label: "审批人" },
];

export async function fetchFaDetails(filters: TableFilters, page: number, pageSize: number) {
  return queryTable<FaDetailRow>("yhdmro_fa_fa_details", filters, page, pageSize);
}

export async function exportFaDetails(filters: TableFilters) {
  return exportTable("yhdmro_fa_fa_details", FA_COLUMNS, filters);
}

export { FA_COLUMNS };
```

- [ ] **Step 2: 创建 `app/fa-details/page.tsx`**

```typescript
"use client";

import { useState, useEffect, useCallback } from "react";
import { Button } from "@/components/ui/button";
import { DataFilter, type FilterValues } from "@/components/data-filter";
import { DataTable, type ColumnDef } from "@/components/data-table";
import { DataSummary } from "@/components/data-summary";
import { fetchFaDetails, exportFaDetails, FA_COLUMNS } from "./actions";
import type { FaDetailRow } from "@/lib/query";
import { Download } from "lucide-react";

const STATUS_OPTIONS = [
  { value: "3", label: "3" },
  { value: "6", label: "6" },
  { value: "10", label: "10" },
  { value: "13", label: "13" },
  { value: "17", label: "17" },
  { value: "20", label: "20" },
  { value: "24", label: "24" },
  { value: "27", label: "27" },
  { value: "29", label: "29" },
  { value: "31", label: "31" },
];

const TABLE_COLUMNS: ColumnDef<FaDetailRow>[] = FA_COLUMNS.map((col) => ({
  key: col.key,
  header: col.label,
  className: col.key === "vendor_name" || col.key === "material_name" || col.key === "material_spec"
    ? "min-w-[150px]"
    : col.key === "amount" || col.key === "total_amount" || col.key === "tax_amount" || col.key === "unit_price"
      ? "text-right min-w-[100px]"
      : undefined,
  render: col.key === "amount" || col.key === "total_amount" || col.key === "tax_amount" || col.key === "unit_price"
    ? (row: FaDetailRow) => {
        const val = row[col.key as keyof FaDetailRow];
        return val !== null ? `¥${Number(val).toLocaleString("zh-CN", { minimumFractionDigits: 2 })}` : "";
      }
    : undefined,
}));

export default function FaDetailsPage() {
  const [filters, setFilters] = useState<FilterValues>({
    documentNumber: "",
    vendorName: "",
    startDate: "",
    endDate: "",
    status: "",
  });
  const [page, setPage] = useState(1);
  const [pageSize, setPageSize] = useState(20);
  const [data, setData] = useState<FaDetailRow[]>([]);
  const [total, setTotal] = useState(0);
  const [summary, setSummary] = useState({ count: 0, totalAmount: 0, taxAmount: 0, grandTotal: 0 });
  const [loading, setLoading] = useState(false);
  const [exporting, setExporting] = useState(false);

  const loadData = useCallback(async () => {
    setLoading(true);
    try {
      const result = await fetchFaDetails(filters, page, pageSize);
      setData(result.rows);
      setTotal(result.total);
      setSummary(result.summary);
    } catch (err) {
      console.error("查询失败:", err);
    } finally {
      setLoading(false);
    }
  }, [filters, page, pageSize]);

  useEffect(() => {
    loadData();
  }, [loadData]);

  function handleSearch() {
    setPage(1);
    loadData();
  }

  function handlePageSizeChange(size: number) {
    setPageSize(size);
    setPage(1);
  }

  async function handleExport() {
    setExporting(true);
    try {
      const result = await exportFaDetails(filters);
      const blob = new Blob([result.csv], { type: "text/csv;charset=utf-8;" });
      const url = URL.createObjectURL(blob);
      const a = document.createElement("a");
      a.href = url;
      a.download = result.filename;
      a.click();
      URL.revokeObjectURL(url);
    } catch (err) {
      console.error("导出失败:", err);
    } finally {
      setExporting(false);
    }
  }

  return (
    <div className="flex-1 p-6 space-y-4">
      <div className="flex items-center justify-between">
        <h1 className="text-xl font-semibold">FA 固定资产明细</h1>
        <Button onClick={handleExport} disabled={exporting} size="sm">
          <Download className="h-4 w-4 mr-1" />
          {exporting ? "导出中..." : "导出 CSV"}
        </Button>
      </div>

      <DataFilter
        filters={filters}
        onFiltersChange={setFilters}
        statusOptions={STATUS_OPTIONS}
      />

      <Button onClick={handleSearch} disabled={loading} size="sm" className="w-full md:w-auto">
        {loading ? "查询中..." : "查询"}
      </Button>

      <DataSummary summary={summary} />

      <DataTable
        columns={TABLE_COLUMNS}
        data={data}
        total={total}
        page={page}
        pageSize={pageSize}
        onPageChange={setPage}
        onPageSizeChange={handlePageSizeChange}
      />
    </div>
  );
}
```

- [ ] **Step 3: 验证编译通过**

```bash
pnpm exec tsc --noEmit
```

- [ ] **Step 4: Commit**

```bash
git add app/fa-details/actions.ts app/fa-details/page.tsx
git commit -m "feat: add FA details query page with filter, summary, and export"
```

---

### Task 10: CRM 客户联系人变更 Server Actions + 页面

**Files:**
- Create: `app/crm-contacts/actions.ts`
- Create: `app/crm-contacts/page.tsx`

- [ ] **Step 1: 创建 `app/crm-contacts/actions.ts`**

```typescript
"use server";

import { queryTable, exportTable, type CrmContactRow, type TableFilters } from "@/lib/query";

const CRM_COLUMNS = [
  { key: "document_number", label: "单据号" },
  { key: "document_date", label: "单据日期" },
  { key: "vendor_code", label: "供应商编码" },
  { key: "vendor_name", label: "供应商名称" },
  { key: "description", label: "描述" },
  { key: "amount", label: "金额" },
  { key: "total_amount", label: "总金额" },
  { key: "status", label: "状态" },
  { key: "approver", label: "审批人" },
];

export async function fetchCrmContacts(filters: TableFilters, page: number, pageSize: number) {
  return queryTable<CrmContactRow>("yhdmro_crm_cust_contact_change", filters, page, pageSize);
}

export async function exportCrmContacts(filters: TableFilters) {
  return exportTable("yhdmro_crm_cust_contact_change", CRM_COLUMNS, filters);
}

export { CRM_COLUMNS };
```

- [ ] **Step 2: 创建 `app/crm-contacts/page.tsx`**

```typescript
"use client";

import { useState, useEffect, useCallback } from "react";
import { Button } from "@/components/ui/button";
import { DataFilter, type FilterValues } from "@/components/data-filter";
import { DataTable, type ColumnDef } from "@/components/data-table";
import { DataSummary } from "@/components/data-summary";
import { fetchCrmContacts, exportCrmContacts, CRM_COLUMNS } from "./actions";
import type { CrmContactRow } from "@/lib/query";
import { Download } from "lucide-react";

const STATUS_OPTIONS = [
  { value: "审核完成", label: "审核完成" },
  { value: "审核中", label: "审核中" },
  { value: "未发起", label: "未发起" },
  { value: "拒绝", label: "拒绝" },
  { value: "撤回申请", label: "撤回申请" },
];

const TABLE_COLUMNS: ColumnDef<CrmContactRow>[] = CRM_COLUMNS.map((col) => ({
  key: col.key,
  header: col.label,
  className: col.key === "vendor_name" || col.key === "description"
    ? "min-w-[150px]"
    : col.key === "amount" || col.key === "total_amount"
      ? "text-right min-w-[100px]"
      : undefined,
  render: col.key === "amount" || col.key === "total_amount"
    ? (row: CrmContactRow) => {
        const val = row[col.key as keyof CrmContactRow];
        return val !== null ? `¥${Number(val).toLocaleString("zh-CN", { minimumFractionDigits: 2 })}` : "";
      }
    : undefined,
}));

export default function CrmContactsPage() {
  const [filters, setFilters] = useState<FilterValues>({
    documentNumber: "",
    vendorName: "",
    startDate: "",
    endDate: "",
    status: "",
  });
  const [page, setPage] = useState(1);
  const [pageSize, setPageSize] = useState(20);
  const [data, setData] = useState<CrmContactRow[]>([]);
  const [total, setTotal] = useState(0);
  const [summary, setSummary] = useState({ count: 0, totalAmount: 0, taxAmount: 0, grandTotal: 0 });
  const [loading, setLoading] = useState(false);
  const [exporting, setExporting] = useState(false);

  const loadData = useCallback(async () => {
    setLoading(true);
    try {
      const result = await fetchCrmContacts(filters, page, pageSize);
      setData(result.rows);
      setTotal(result.total);
      setSummary(result.summary);
    } catch (err) {
      console.error("查询失败:", err);
    } finally {
      setLoading(false);
    }
  }, [filters, page, pageSize]);

  useEffect(() => {
    loadData();
  }, [loadData]);

  function handleSearch() {
    setPage(1);
    loadData();
  }

  function handlePageSizeChange(size: number) {
    setPageSize(size);
    setPage(1);
  }

  async function handleExport() {
    setExporting(true);
    try {
      const result = await exportCrmContacts(filters);
      const blob = new Blob([result.csv], { type: "text/csv;charset=utf-8;" });
      const url = URL.createObjectURL(blob);
      const a = document.createElement("a");
      a.href = url;
      a.download = result.filename;
      a.click();
      URL.revokeObjectURL(url);
    } catch (err) {
      console.error("导出失败:", err);
    } finally {
      setExporting(false);
    }
  }

  return (
    <div className="flex-1 p-6 space-y-4">
      <div className="flex items-center justify-between">
        <h1 className="text-xl font-semibold">CRM 客户联系人变更</h1>
        <Button onClick={handleExport} disabled={exporting} size="sm">
          <Download className="h-4 w-4 mr-1" />
          {exporting ? "导出中..." : "导出 CSV"}
        </Button>
      </div>

      <DataFilter
        filters={filters}
        onFiltersChange={setFilters}
        statusOptions={STATUS_OPTIONS}
      />

      <Button onClick={handleSearch} disabled={loading} size="sm" className="w-full md:w-auto">
        {loading ? "查询中..." : "查询"}
      </Button>

      <DataSummary summary={summary} />

      <DataTable
        columns={TABLE_COLUMNS}
        data={data}
        total={total}
        page={page}
        pageSize={pageSize}
        onPageChange={setPage}
        onPageSizeChange={handlePageSizeChange}
      />
    </div>
  );
}
```

- [ ] **Step 3: 验证编译通过**

```bash
pnpm exec tsc --noEmit
```

- [ ] **Step 4: Commit**

```bash
git add app/crm-contacts/actions.ts app/crm-contacts/page.tsx
git commit -m "feat: add CRM contacts query page with filter, summary, and export"
```

---

### Task 11: 集成验证 + 启动测试

**Files:** 无新文件

- [ ] **Step 1: 构建验证**

```bash
pnpm build
```

如果构建失败，根据错误信息修复。

- [ ] **Step 2: 启动开发服务器测试**

```bash
pnpm dev
```

手动验证：
- 访问 `/login` 能登录
- 未登录访问 `/fa-details` 或 `/crm-contacts` 会重定向到 `/login`
- 登录后能看到两个查询页面
- 筛选、分页、统计汇总、导出功能正常

- [ ] **Step 3: Final commit（如有修复）**

```bash
git add -A && git commit -m "fix: resolve build and integration issues"
```

---

## Self-Review Checklist

1. **Spec coverage**: 每个设计需求都有对应 Task — 鉴权(T3,T4)、导航(T5)、筛选(T6)、统计(T7)、表格(T8)、FA页面(T9)、CRM页面(T10)、导出(T9,T10)
2. **Placeholder scan**: 无 TBD/TODO，所有代码完整
3. **Type consistency**: FaDetailRow/CrmContactRow 类型在 lib/query.ts 定义，在 actions.ts 和 page.tsx 中引用一致；FilterValues 类型定义和使用一致
4. **Missing tasks**: 环境变量 .env.local 在 Task 1 创建，middleware 在 Task 3