Explorar el Código

feat(story-008.004): 创建薪资管理UI包基础结构和组件

- 创建薪资管理UI包基础结构:package.json、tsconfig.json、vitest.config.ts
- 实现薪资管理主组件SalaryManagement.tsx,集成区域选择器
- 实现薪资选择器组件SalarySelector.tsx,支持自动查询和手动调整
- 创建RPC API客户端salaryClient.ts和类型定义
- 编写集成测试文件
- 更新故事008.004开发进度记录

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
yourname hace 6 días
padre
commit
66dc6d7126

+ 88 - 0
allin-packages/salary-management-ui/package.json

@@ -0,0 +1,88 @@
+{
+  "name": "@d8d/allin-salary-management-ui",
+  "version": "1.0.0",
+  "description": "薪资管理界面包 - 提供薪资管理的完整前端界面,包括薪资CRUD操作、区域选择器集成、数值计算验证等功能",
+  "type": "module",
+  "main": "src/index.ts",
+  "types": "src/index.ts",
+  "exports": {
+    ".": {
+      "types": "./src/index.ts",
+      "import": "./src/index.ts",
+      "require": "./src/index.ts"
+    },
+    "./components": {
+      "types": "./src/components/index.ts",
+      "import": "./src/components/index.ts",
+      "require": "./src/components/index.ts"
+    },
+    "./api": {
+      "types": "./src/api/index.ts",
+      "import": "./src/api/index.ts",
+      "require": "./src/api/index.ts"
+    }
+  },
+  "files": [
+    "src"
+  ],
+  "scripts": {
+    "build": "unbuild",
+    "dev": "tsc --watch",
+    "test": "vitest run",
+    "test:watch": "vitest",
+    "test:coverage": "vitest run --coverage",
+    "lint": "eslint src --ext .ts,.tsx",
+    "typecheck": "tsc --noEmit"
+  },
+  "dependencies": {
+    "@d8d/shared-types": "workspace:*",
+    "@d8d/shared-ui-components": "workspace:*",
+    "@d8d/allin-salary-module": "workspace:*",
+    "@d8d/area-management-ui": "workspace:*",
+    "@hookform/resolvers": "^5.2.1",
+    "@tanstack/react-query": "^5.90.9",
+    "class-variance-authority": "^0.7.1",
+    "clsx": "^2.1.1",
+    "date-fns": "^4.1.0",
+    "hono": "^4.8.5",
+    "lucide-react": "^0.536.0",
+    "react": "^19.1.0",
+    "react-dom": "^19.1.0",
+    "react-hook-form": "^7.61.1",
+    "sonner": "^2.0.7",
+    "tailwind-merge": "^3.3.1",
+    "zod": "^4.0.15"
+  },
+  "devDependencies": {
+    "@testing-library/jest-dom": "^6.8.0",
+    "@testing-library/react": "^16.3.0",
+    "@testing-library/user-event": "^14.6.1",
+    "@types/node": "^22.10.2",
+    "@types/react": "^19.2.2",
+    "@types/react-dom": "^19.2.3",
+    "@typescript-eslint/eslint-plugin": "^8.18.1",
+    "@typescript-eslint/parser": "^8.18.1",
+    "eslint": "^9.17.0",
+    "jsdom": "^26.0.0",
+    "typescript": "^5.8.3",
+    "unbuild": "^3.4.0",
+    "vitest": "^4.0.9"
+  },
+  "peerDependencies": {
+    "react": "^19.1.0",
+    "react-dom": "^19.1.0"
+  },
+  "keywords": [
+    "salary",
+    "management",
+    "admin",
+    "ui",
+    "react",
+    "crud",
+    "allin",
+    "area",
+    "calculator"
+  ],
+  "author": "D8D Team",
+  "license": "MIT"
+}

+ 3 - 0
allin-packages/salary-management-ui/src/api/index.ts

@@ -0,0 +1,3 @@
+// API导出文件
+export { default as salaryClientManager } from './salaryClient';
+export { default as salaryClient } from './salaryClient';

+ 46 - 0
allin-packages/salary-management-ui/src/api/salaryClient.ts

@@ -0,0 +1,46 @@
+import { salaryRoutes } from '@d8d/allin-salary-module';
+import { rpcClient } from '@d8d/shared-ui-components/utils/hc'
+
+export class SalaryClientManager {
+  private static instance: SalaryClientManager;
+  private client: ReturnType<typeof rpcClient<typeof salaryRoutes>> | null = null;
+
+  private constructor() {}
+
+  public static getInstance(): SalaryClientManager {
+    if (!SalaryClientManager.instance) {
+      SalaryClientManager.instance = new SalaryClientManager();
+    }
+    return SalaryClientManager.instance;
+  }
+
+  // 初始化客户端
+  public init(baseUrl: string = '/'): ReturnType<typeof rpcClient<typeof salaryRoutes>> {
+    return this.client = rpcClient<typeof salaryRoutes>(baseUrl);
+  }
+
+  // 获取客户端实例
+  public get(): ReturnType<typeof rpcClient<typeof salaryRoutes>> {
+    if (!this.client) {
+      return this.init()
+    }
+    return this.client;
+  }
+
+  // 重置客户端(用于测试或重新初始化)
+  public reset(): void {
+    this.client = null;
+  }
+}
+
+// 导出单例实例
+const salaryClientManager = SalaryClientManager.getInstance();
+
+// 导出默认客户端实例(延迟初始化)
+export const salaryClient = salaryClientManager.get()
+
+export {
+  salaryClientManager
+}
+
+export default salaryClientManager;

+ 34 - 0
allin-packages/salary-management-ui/src/api/types.ts

@@ -0,0 +1,34 @@
+import type { InferResponseType, InferRequestType } from 'hono/client';
+import { salaryClient } from './salaryClient';
+import type { SalaryLevel, QuerySalaryDto, GetSalaryByProvinceCityDto } from '@d8d/allin-salary-module/schemas';
+
+// 薪资搜索参数类型
+export type SalarySearchParams = QuerySalaryDto & {
+  page: number;
+  limit: number;
+};
+
+// 分页响应类型
+export type PaginatedResponse<T> = {
+  data: T[];
+  total: number;
+};
+
+// 使用Hono类型推导
+export type CreateSalaryRequest = InferRequestType<typeof salaryClient.create.$post>['json'];
+export type UpdateSalaryRequest = InferRequestType<typeof salaryClient.update[':id']['$put']>['json'] & {
+  id: number;
+};
+export type DeleteSalaryRequest = InferRequestType<typeof salaryClient.delete[':id']['$delete']>['param'];
+
+export type SalaryResponse = InferResponseType<typeof salaryClient.list.$get, 200>['data'][0];
+export type SalaryListResponse = InferResponseType<typeof salaryClient.list.$get, 200>;
+export type SalaryDetailResponse = InferResponseType<typeof salaryClient.detail[':id']['$get'], 200>;
+export type SalaryByProvinceCityResponse = InferResponseType<typeof salaryClient.byProvinceCity.$get, 200>;
+
+// 导出后端模块的类型
+export type {
+  SalaryLevel,
+  QuerySalaryDto,
+  GetSalaryByProvinceCityDto
+};

+ 650 - 0
allin-packages/salary-management-ui/src/components/SalaryManagement.tsx

@@ -0,0 +1,650 @@
+import React, { useState } from 'react';
+import { useQuery, useMutation } from '@tanstack/react-query';
+import { Plus, Edit, Trash2, Search, Calculator } from 'lucide-react';
+import { format } from 'date-fns';
+import { Input } from '@d8d/shared-ui-components/components/ui/input';
+import { Button } from '@d8d/shared-ui-components/components/ui/button';
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@d8d/shared-ui-components/components/ui/card';
+import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@d8d/shared-ui-components/components/ui/table';
+import { Skeleton } from '@d8d/shared-ui-components/components/ui/skeleton';
+import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@d8d/shared-ui-components/components/ui/dialog';
+import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@d8d/shared-ui-components/components/ui/form';
+import { useForm } from 'react-hook-form';
+import { zodResolver } from '@hookform/resolvers/zod';
+import { toast } from 'sonner';
+import { DataTablePagination } from '@d8d/shared-ui-components/components/admin/DataTablePagination';
+import { AreaSelect } from '@d8d/area-management-ui/components';
+import { salaryClientManager } from '../api/salaryClient';
+import { CreateSalarySchema, UpdateSalarySchema } from '@d8d/allin-salary-module/schemas';
+import type { CreateSalaryDto, UpdateSalaryDto, SalaryLevel, QuerySalaryDto } from '@d8d/allin-salary-module/schemas';
+
+interface SalarySearchParams {
+  page: number;
+  limit: number;
+  provinceId?: number;
+  cityId?: number;
+}
+
+const SalaryManagement: React.FC = () => {
+  const [searchParams, setSearchParams] = useState<SalarySearchParams>({ page: 1, limit: 10 });
+  const [isModalOpen, setIsModalOpen] = useState(false);
+  const [isCreateForm, setIsCreateForm] = useState(true);
+  const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
+  const [salaryToDelete, setSalaryToDelete] = useState<number | null>(null);
+  const [areaValue, setAreaValue] = useState<{ provinceId?: number; cityId?: number; districtId?: number }>({});
+
+  // 表单实例
+  const createForm = useForm<CreateSalaryDto>({
+    resolver: zodResolver(CreateSalarySchema),
+    defaultValues: {
+      basicSalary: 0,
+      allowance: 0,
+      insurance: 0,
+      housingFund: 0
+    }
+  });
+
+  const updateForm = useForm<UpdateSalaryDto>({
+    resolver: zodResolver(UpdateSalarySchema),
+    defaultValues: {}
+  });
+
+  // 计算总薪资
+  const calculateTotalSalary = (basicSalary: number, allowance: number, insurance: number, housingFund: number) => {
+    return basicSalary + (allowance || 0) - (insurance || 0) - (housingFund || 0);
+  };
+
+  // 数据查询
+  const { data, isLoading, refetch } = useQuery({
+    queryKey: ['salaries', searchParams],
+    queryFn: async () => {
+      const query: QuerySalaryDto = {
+        skip: (searchParams.page - 1) * searchParams.limit,
+        take: searchParams.limit
+      };
+
+      if (searchParams.provinceId) query.provinceId = searchParams.provinceId;
+      if (searchParams.cityId) query.cityId = searchParams.cityId;
+
+      const res = await salaryClientManager.get().$get({ query });
+      if (res.status !== 200) throw new Error('获取薪资列表失败');
+      return await res.json();
+    }
+  });
+
+  // 创建薪资
+  const createMutation = useMutation({
+    mutationFn: async (data: CreateSalaryDto) => {
+      const res = await salaryClientManager.get().create.$post({ json: data });
+      if (res.status !== 200) throw new Error('创建薪资失败');
+      return await res.json();
+    },
+    onSuccess: () => {
+      toast.success('薪资创建成功');
+      setIsModalOpen(false);
+      createForm.reset();
+      setAreaValue({});
+      refetch();
+    },
+    onError: (error) => {
+      toast.error(error instanceof Error ? error.message : '创建薪资失败');
+    }
+  });
+
+  // 更新薪资
+  const updateMutation = useMutation({
+    mutationFn: async (data: UpdateSalaryDto) => {
+      const res = await salaryClientManager.get().update[':id'].$put({
+        param: { id: data.id! },
+        json: data
+      });
+      if (res.status !== 200) throw new Error('更新薪资失败');
+      return await res.json();
+    },
+    onSuccess: () => {
+      toast.success('薪资更新成功');
+      setIsModalOpen(false);
+      refetch();
+    },
+    onError: (error) => {
+      toast.error(error instanceof Error ? error.message : '更新薪资失败');
+    }
+  });
+
+  // 删除薪资
+  const deleteMutation = useMutation({
+    mutationFn: async (id: number) => {
+      const res = await salaryClientManager.get().delete[':id'].$delete({ param: { id } });
+      if (res.status !== 200) throw new Error('删除薪资失败');
+      return await res.json();
+    },
+    onSuccess: () => {
+      toast.success('薪资删除成功');
+      setDeleteDialogOpen(false);
+      refetch();
+    },
+    onError: (error) => {
+      toast.error(error instanceof Error ? error.message : '删除薪资失败');
+    }
+  });
+
+  // 处理搜索
+  const handleSearch = () => {
+    setSearchParams(prev => ({
+      ...prev,
+      page: 1,
+      provinceId: areaValue.provinceId,
+      cityId: areaValue.cityId
+    }));
+  };
+
+  // 处理分页变化
+  const handlePaginationChange = (page: number, limit: number) => {
+    setSearchParams(prev => ({ ...prev, page, limit }));
+  };
+
+  // 显示创建模态框
+  const showCreateModal = () => {
+    setIsCreateForm(true);
+    createForm.reset({
+      basicSalary: 0,
+      allowance: 0,
+      insurance: 0,
+      housingFund: 0
+    });
+    setAreaValue({});
+    setIsModalOpen(true);
+  };
+
+  // 显示编辑模态框
+  const showEditModal = (salary: SalaryLevel) => {
+    setIsCreateForm(false);
+    updateForm.reset({
+      id: salary.id,
+      provinceId: salary.provinceId,
+      cityId: salary.cityId,
+      districtId: salary.districtId || undefined,
+      basicSalary: salary.basicSalary,
+      allowance: salary.allowance,
+      insurance: salary.insurance,
+      housingFund: salary.housingFund
+    });
+    setAreaValue({
+      provinceId: salary.provinceId,
+      cityId: salary.cityId,
+      districtId: salary.districtId || undefined
+    });
+    setIsModalOpen(true);
+  };
+
+  // 显示删除对话框
+  const showDeleteDialog = (id: number) => {
+    setSalaryToDelete(id);
+    setDeleteDialogOpen(true);
+  };
+
+  // 处理创建表单提交
+  const handleCreateSubmit = (data: CreateSalaryDto) => {
+    const submitData = {
+      ...data,
+      provinceId: areaValue.provinceId!,
+      cityId: areaValue.cityId!,
+      districtId: areaValue.districtId
+    };
+    createMutation.mutate(submitData);
+  };
+
+  // 处理更新表单提交
+  const handleUpdateSubmit = (data: UpdateSalaryDto) => {
+    updateMutation.mutate(data);
+  };
+
+  // 处理区域选择变化
+  const handleAreaChange = (value: { provinceId?: number; cityId?: number; districtId?: number }) => {
+    setAreaValue(value);
+    if (isCreateForm) {
+      createForm.setValue('provinceId', value.provinceId!);
+      createForm.setValue('cityId', value.cityId!);
+      createForm.setValue('districtId', value.districtId);
+    }
+  };
+
+  // 处理数值变化,实时计算总薪资
+  const handleValueChange = (field: keyof CreateSalaryDto, value: number) => {
+    if (isCreateForm) {
+      const formValues = createForm.getValues();
+      const total = calculateTotalSalary(
+        field === 'basicSalary' ? value : formValues.basicSalary,
+        field === 'allowance' ? value : (formValues.allowance || 0),
+        field === 'insurance' ? value : (formValues.insurance || 0),
+        field === 'housingFund' ? value : (formValues.housingFund || 0)
+      );
+      // 可以在这里显示总薪资,但不需要保存到表单
+    }
+  };
+
+  return (
+    <Card className="w-full">
+      <CardHeader>
+        <CardTitle className="flex items-center gap-2">
+          <Calculator className="h-5 w-5" />
+          薪资水平管理
+        </CardTitle>
+        <CardDescription>
+          管理各地区薪资水平,支持基本工资、津贴、保险、公积金等计算
+        </CardDescription>
+      </CardHeader>
+      <CardContent>
+        {/* 搜索区域 */}
+        <div className="flex flex-wrap items-center gap-4 mb-6">
+          <div className="flex-1 min-w-[300px]">
+            <AreaSelect
+              value={areaValue}
+              onChange={handleAreaChange}
+              disabled={false}
+              required={false}
+            />
+          </div>
+          <Button onClick={handleSearch}>
+            <Search className="h-4 w-4 mr-2" />
+            搜索
+          </Button>
+          <Button onClick={showCreateModal}>
+            <Plus className="h-4 w-4 mr-2" />
+            添加薪资
+          </Button>
+        </div>
+
+        {/* 数据表格 */}
+        {isLoading ? (
+          <div className="space-y-2">
+            <Skeleton className="h-8 w-full" />
+            <Skeleton className="h-8 w-full" />
+            <Skeleton className="h-8 w-full" />
+          </div>
+        ) : (
+          <>
+            <Table>
+              <TableHeader>
+                <TableRow>
+                  <TableHead>ID</TableHead>
+                  <TableHead>省份</TableHead>
+                  <TableHead>城市</TableHead>
+                  <TableHead>区县</TableHead>
+                  <TableHead>基本工资</TableHead>
+                  <TableHead>津贴补贴</TableHead>
+                  <TableHead>保险费用</TableHead>
+                  <TableHead>公积金</TableHead>
+                  <TableHead>总薪资</TableHead>
+                  <TableHead>更新时间</TableHead>
+                  <TableHead>操作</TableHead>
+                </TableRow>
+              </TableHeader>
+              <TableBody>
+                {data?.data?.map((salary: SalaryLevel) => (
+                  <TableRow key={salary.id}>
+                    <TableCell>{salary.id}</TableCell>
+                    <TableCell>{salary.province?.name || salary.provinceId}</TableCell>
+                    <TableCell>{salary.city?.name || salary.cityId}</TableCell>
+                    <TableCell>{salary.district?.name || salary.districtId || '-'}</TableCell>
+                    <TableCell>¥{salary.basicSalary.toFixed(2)}</TableCell>
+                    <TableCell>¥{(salary.allowance || 0).toFixed(2)}</TableCell>
+                    <TableCell>¥{(salary.insurance || 0).toFixed(2)}</TableCell>
+                    <TableCell>¥{(salary.housingFund || 0).toFixed(2)}</TableCell>
+                    <TableCell className="font-semibold">¥{salary.totalSalary.toFixed(2)}</TableCell>
+                    <TableCell>{format(new Date(salary.updateTime), 'yyyy-MM-dd HH:mm')}</TableCell>
+                    <TableCell>
+                      <div className="flex gap-2">
+                        <Button
+                          variant="ghost"
+                          size="sm"
+                          onClick={() => showEditModal(salary)}
+                        >
+                          <Edit className="h-4 w-4" />
+                        </Button>
+                        <Button
+                          variant="ghost"
+                          size="sm"
+                          onClick={() => showDeleteDialog(salary.id)}
+                        >
+                          <Trash2 className="h-4 w-4" />
+                        </Button>
+                      </div>
+                    </TableCell>
+                  </TableRow>
+                ))}
+              </TableBody>
+            </Table>
+
+            {/* 分页 */}
+            {data?.total && (
+              <DataTablePagination
+                currentPage={searchParams.page}
+                pageSize={searchParams.limit}
+                total={data.total}
+                onPageChange={(page) => handlePaginationChange(page, searchParams.limit)}
+                onPageSizeChange={(limit) => handlePaginationChange(1, limit)}
+              />
+            )}
+          </>
+        )}
+
+        {/* 创建/编辑模态框 */}
+        <Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
+          <DialogContent className="max-w-2xl">
+            <DialogHeader>
+              <DialogTitle>{isCreateForm ? '添加薪资' : '编辑薪资'}</DialogTitle>
+              <DialogDescription>
+                {isCreateForm ? '填写薪资信息,支持实时计算总薪资' : '修改薪资信息'}
+              </DialogDescription>
+            </DialogHeader>
+
+            {isCreateForm ? (
+              <Form {...createForm}>
+                <form onSubmit={createForm.handleSubmit(handleCreateSubmit)} className="space-y-4">
+                  {/* 区域选择器 */}
+                  <FormField
+                    control={createForm.control}
+                    name="provinceId"
+                    render={({ field }) => (
+                      <FormItem>
+                        <FormLabel>区域选择</FormLabel>
+                        <FormControl>
+                          <AreaSelect
+                            value={areaValue}
+                            onChange={handleAreaChange}
+                            required={true}
+                          />
+                        </FormControl>
+                        <FormMessage />
+                      </FormItem>
+                    )}
+                  />
+
+                  {/* 基本工资 */}
+                  <FormField
+                    control={createForm.control}
+                    name="basicSalary"
+                    render={({ field }) => (
+                      <FormItem>
+                        <FormLabel>基本工资</FormLabel>
+                        <FormControl>
+                          <Input
+                            type="number"
+                            step="0.01"
+                            min="0"
+                            {...field}
+                            onChange={(e) => {
+                              const value = parseFloat(e.target.value) || 0;
+                              field.onChange(value);
+                              handleValueChange('basicSalary', value);
+                            }}
+                          />
+                        </FormControl>
+                        <FormMessage />
+                      </FormItem>
+                    )}
+                  />
+
+                  {/* 津贴补贴 */}
+                  <FormField
+                    control={createForm.control}
+                    name="allowance"
+                    render={({ field }) => (
+                      <FormItem>
+                        <FormLabel>津贴补贴</FormLabel>
+                        <FormControl>
+                          <Input
+                            type="number"
+                            step="0.01"
+                            min="0"
+                            {...field}
+                            onChange={(e) => {
+                              const value = parseFloat(e.target.value) || 0;
+                              field.onChange(value);
+                              handleValueChange('allowance', value);
+                            }}
+                          />
+                        </FormControl>
+                        <FormMessage />
+                      </FormItem>
+                    )}
+                  />
+
+                  {/* 保险费用 */}
+                  <FormField
+                    control={createForm.control}
+                    name="insurance"
+                    render={({ field }) => (
+                      <FormItem>
+                        <FormLabel>保险费用</FormLabel>
+                        <FormControl>
+                          <Input
+                            type="number"
+                            step="0.01"
+                            min="0"
+                            {...field}
+                            onChange={(e) => {
+                              const value = parseFloat(e.target.value) || 0;
+                              field.onChange(value);
+                              handleValueChange('insurance', value);
+                            }}
+                          />
+                        </FormControl>
+                        <FormMessage />
+                      </FormItem>
+                    )}
+                  />
+
+                  {/* 住房公积金 */}
+                  <FormField
+                    control={createForm.control}
+                    name="housingFund"
+                    render={({ field }) => (
+                      <FormItem>
+                        <FormLabel>住房公积金</FormLabel>
+                        <FormControl>
+                          <Input
+                            type="number"
+                            step="0.01"
+                            min="0"
+                            {...field}
+                            onChange={(e) => {
+                              const value = parseFloat(e.target.value) || 0;
+                              field.onChange(value);
+                              handleValueChange('housingFund', value);
+                            }}
+                          />
+                        </FormControl>
+                        <FormMessage />
+                      </FormItem>
+                    )}
+                  />
+
+                  {/* 总薪资计算显示 */}
+                  <div className="p-4 bg-muted rounded-lg">
+                    <div className="flex justify-between items-center">
+                      <span className="font-medium">计算总薪资:</span>
+                      <span className="text-2xl font-bold text-primary">
+                        ¥{calculateTotalSalary(
+                          createForm.watch('basicSalary'),
+                          createForm.watch('allowance') || 0,
+                          createForm.watch('insurance') || 0,
+                          createForm.watch('housingFund') || 0
+                        ).toFixed(2)}
+                      </span>
+                    </div>
+                    <div className="text-sm text-muted-foreground mt-2">
+                      计算公式:基本工资 + 津贴 - 保险 - 公积金
+                    </div>
+                  </div>
+
+                  <DialogFooter>
+                    <Button
+                      type="button"
+                      variant="outline"
+                      onClick={() => setIsModalOpen(false)}
+                    >
+                      取消
+                    </Button>
+                    <Button type="submit" disabled={createMutation.isPending}>
+                      {createMutation.isPending ? '创建中...' : '创建薪资'}
+                    </Button>
+                  </DialogFooter>
+                </form>
+              </Form>
+            ) : (
+              <Form {...updateForm}>
+                <form onSubmit={updateForm.handleSubmit(handleUpdateSubmit)} className="space-y-4">
+                  {/* 显示当前区域信息 */}
+                  <div className="grid grid-cols-3 gap-4">
+                    <div>
+                      <FormLabel>省份</FormLabel>
+                      <div className="p-2 border rounded-md bg-muted">
+                        {updateForm.watch('provinceId')}
+                      </div>
+                    </div>
+                    <div>
+                      <FormLabel>城市</FormLabel>
+                      <div className="p-2 border rounded-md bg-muted">
+                        {updateForm.watch('cityId')}
+                      </div>
+                    </div>
+                    <div>
+                      <FormLabel>区县</FormLabel>
+                      <div className="p-2 border rounded-md bg-muted">
+                        {updateForm.watch('districtId') || '-'}
+                      </div>
+                    </div>
+                  </div>
+
+                  {/* 基本工资 */}
+                  <FormField
+                    control={updateForm.control}
+                    name="basicSalary"
+                    render={({ field }) => (
+                      <FormItem>
+                        <FormLabel>基本工资</FormLabel>
+                        <FormControl>
+                          <Input
+                            type="number"
+                            step="0.01"
+                            min="0"
+                            {...field}
+                          />
+                        </FormControl>
+                        <FormMessage />
+                      </FormItem>
+                    )}
+                  />
+
+                  {/* 津贴补贴 */}
+                  <FormField
+                    control={updateForm.control}
+                    name="allowance"
+                    render={({ field }) => (
+                      <FormItem>
+                        <FormLabel>津贴补贴</FormLabel>
+                        <FormControl>
+                          <Input
+                            type="number"
+                            step="0.01"
+                            min="0"
+                            {...field}
+                          />
+                        </FormControl>
+                        <FormMessage />
+                      </FormItem>
+                    )}
+                  />
+
+                  {/* 保险费用 */}
+                  <FormField
+                    control={updateForm.control}
+                    name="insurance"
+                    render={({ field }) => (
+                      <FormItem>
+                        <FormLabel>保险费用</FormLabel>
+                        <FormControl>
+                          <Input
+                            type="number"
+                            step="0.01"
+                            min="0"
+                            {...field}
+                          />
+                        </FormControl>
+                        <FormMessage />
+                      </FormItem>
+                    )}
+                  />
+
+                  {/* 住房公积金 */}
+                  <FormField
+                    control={updateForm.control}
+                    name="housingFund"
+                    render={({ field }) => (
+                      <FormItem>
+                        <FormLabel>住房公积金</FormLabel>
+                        <FormControl>
+                          <Input
+                            type="number"
+                            step="0.01"
+                            min="0"
+                            {...field}
+                          />
+                        </FormControl>
+                        <FormMessage />
+                      </FormItem>
+                    )}
+                  />
+
+                  <DialogFooter>
+                    <Button
+                      type="button"
+                      variant="outline"
+                      onClick={() => setIsModalOpen(false)}
+                    >
+                      取消
+                    </Button>
+                    <Button type="submit" disabled={updateMutation.isPending}>
+                      {updateMutation.isPending ? '更新中...' : '更新薪资'}
+                    </Button>
+                  </DialogFooter>
+                </form>
+              </Form>
+            )}
+          </DialogContent>
+        </Dialog>
+
+        {/* 删除确认对话框 */}
+        <Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
+          <DialogContent>
+            <DialogHeader>
+              <DialogTitle>确认删除</DialogTitle>
+              <DialogDescription>
+                确定要删除这条薪资信息吗?此操作不可撤销。
+              </DialogDescription>
+            </DialogHeader>
+            <DialogFooter>
+              <Button
+                variant="outline"
+                onClick={() => setDeleteDialogOpen(false)}
+              >
+                取消
+              </Button>
+              <Button
+                variant="destructive"
+                onClick={() => salaryToDelete && deleteMutation.mutate(salaryToDelete)}
+                disabled={deleteMutation.isPending}
+              >
+                {deleteMutation.isPending ? '删除中...' : '确认删除'}
+              </Button>
+            </DialogFooter>
+          </DialogContent>
+        </Dialog>
+      </CardContent>
+    </Card>
+  );
+};
+
+export default SalaryManagement;

+ 305 - 0
allin-packages/salary-management-ui/src/components/SalarySelector.tsx

@@ -0,0 +1,305 @@
+import React, { useState, useEffect } from 'react';
+import { useQuery } from '@tanstack/react-query';
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@d8d/shared-ui-components/components/ui/card';
+import { Input } from '@d8d/shared-ui-components/components/ui/input';
+import { Label } from '@d8d/shared-ui-components/components/ui/label';
+import { Skeleton } from '@d8d/shared-ui-components/components/ui/skeleton';
+import { Alert, AlertDescription } from '@d8d/shared-ui-components/components/ui/alert';
+import { Calculator, AlertCircle } from 'lucide-react';
+import { AreaSelect } from '@d8d/area-management-ui/components';
+import { salaryClientManager } from '../api/salaryClient';
+import type { SalaryLevel } from '@d8d/allin-salary-module/schemas';
+
+export interface SalarySelectorProps {
+  value?: {
+    provinceId?: number;
+    cityId?: number;
+    salary?: number;
+    salaryDetail?: SalaryLevel;
+  };
+  onChange?: (value: {
+    provinceId?: number;
+    cityId?: number;
+    salary?: number;
+    salaryDetail?: SalaryLevel;
+  }) => void;
+  disabled?: boolean;
+  allowManualAdjust?: boolean;
+  required?: boolean;
+  className?: string;
+}
+
+const SalarySelector: React.FC<SalarySelectorProps> = ({
+  value = {},
+  onChange,
+  disabled = false,
+  allowManualAdjust = true,
+  required = false,
+  className = ''
+}) => {
+  const [areaValue, setAreaValue] = useState<{ provinceId?: number; cityId?: number; districtId?: number }>({
+    provinceId: value.provinceId,
+    cityId: value.cityId
+  });
+  const [manualSalary, setManualSalary] = useState<number | undefined>(value.salary);
+  const [isManualMode, setIsManualMode] = useState(false);
+
+  // 查询薪资详情
+  const { data: salaryData, isLoading, isError, refetch } = useQuery({
+    queryKey: ['salary-by-province-city', areaValue.provinceId, areaValue.cityId],
+    queryFn: async () => {
+      if (!areaValue.provinceId || !areaValue.cityId) {
+        return null;
+      }
+
+      const res = await salaryClientManager.get().byProvinceCity.$get({
+        query: {
+          provinceId: areaValue.provinceId,
+          cityId: areaValue.cityId
+        }
+      });
+
+      if (res.status === 404) {
+        return null; // 薪资记录不存在
+      }
+
+      if (res.status !== 200) {
+        throw new Error('查询薪资失败');
+      }
+
+      return await res.json();
+    },
+    enabled: !!areaValue.provinceId && !!areaValue.cityId && !isManualMode,
+    staleTime: 5 * 60 * 1000, // 5分钟缓存
+    gcTime: 10 * 60 * 1000, // 10分钟垃圾回收时间
+  });
+
+  // 计算总薪资
+  const calculateTotalSalary = (salary: SalaryLevel | null | undefined): number => {
+    if (!salary) return 0;
+    return salary.basicSalary + (salary.allowance || 0) - (salary.insurance || 0) - (salary.housingFund || 0);
+  };
+
+  // 处理区域选择变化
+  const handleAreaChange = (newAreaValue: { provinceId?: number; cityId?: number; districtId?: number }) => {
+    setAreaValue(newAreaValue);
+    setIsManualMode(false);
+
+    if (newAreaValue.provinceId && newAreaValue.cityId) {
+      const newValue = {
+        provinceId: newAreaValue.provinceId,
+        cityId: newAreaValue.cityId,
+        salary: undefined,
+        salaryDetail: undefined
+      };
+      onChange?.(newValue);
+    }
+  };
+
+  // 处理手动薪资输入
+  const handleManualSalaryChange = (e: React.ChangeEvent<HTMLInputElement>) => {
+    const value = parseFloat(e.target.value) || 0;
+    setManualSalary(value);
+
+    onChange?.({
+      provinceId: areaValue.provinceId,
+      cityId: areaValue.cityId,
+      salary: value,
+      salaryDetail: undefined
+    });
+  };
+
+  // 切换手动模式
+  const toggleManualMode = () => {
+    const newManualMode = !isManualMode;
+    setIsManualMode(newManualMode);
+
+    if (newManualMode) {
+      // 切换到手动模式,使用查询到的薪资或0作为默认值
+      const defaultSalary = salaryData ? calculateTotalSalary(salaryData) : 0;
+      setManualSalary(defaultSalary);
+
+      onChange?.({
+        provinceId: areaValue.provinceId,
+        cityId: areaValue.cityId,
+        salary: defaultSalary,
+        salaryDetail: undefined
+      });
+    } else {
+      // 切换回自动模式,重新查询
+      if (areaValue.provinceId && areaValue.cityId) {
+        refetch();
+      }
+    }
+  };
+
+  // 同步外部值变化
+  useEffect(() => {
+    setAreaValue({
+      provinceId: value.provinceId,
+      cityId: value.cityId
+    });
+    setManualSalary(value.salary);
+
+    // 如果外部提供了薪资但没有薪资详情,可能是手动模式
+    if (value.salary !== undefined && !value.salaryDetail) {
+      setIsManualMode(true);
+    }
+  }, [value.provinceId, value.cityId, value.salary, value.salaryDetail]);
+
+  // 薪资详情变化时通知父组件
+  useEffect(() => {
+    if (salaryData && !isManualMode) {
+      onChange?.({
+        provinceId: areaValue.provinceId,
+        cityId: areaValue.cityId,
+        salary: calculateTotalSalary(salaryData),
+        salaryDetail: salaryData
+      });
+    }
+  }, [salaryData, isManualMode]);
+
+  const currentSalary = salaryData || value.salaryDetail;
+  const totalSalary = currentSalary ? calculateTotalSalary(currentSalary) : (manualSalary || 0);
+
+  return (
+    <div className={`space-y-4 ${className}`}>
+      {/* 区域选择器 */}
+      <div>
+        <Label className="mb-2 block">
+          选择区域{required && <span className="text-destructive">*</span>}
+        </Label>
+        <AreaSelect
+          value={areaValue}
+          onChange={handleAreaChange}
+          disabled={disabled}
+          required={required}
+        />
+      </div>
+
+      {/* 薪资信息显示 */}
+      {areaValue.provinceId && areaValue.cityId && (
+        <Card>
+          <CardHeader className="pb-3">
+            <CardTitle className="flex items-center gap-2 text-lg">
+              <Calculator className="h-5 w-5" />
+              薪资信息
+            </CardTitle>
+            <CardDescription>
+              {isManualMode ? '手动调整薪资模式' : '自动查询薪资模式'}
+            </CardDescription>
+          </CardHeader>
+          <CardContent className="space-y-4">
+            {isLoading ? (
+              <div className="space-y-2">
+                <Skeleton className="h-4 w-full" />
+                <Skeleton className="h-4 w-3/4" />
+                <Skeleton className="h-4 w-1/2" />
+              </div>
+            ) : isError ? (
+              <Alert variant="destructive">
+                <AlertCircle className="h-4 w-4" />
+                <AlertDescription>
+                  查询薪资信息失败,请检查网络连接或稍后重试
+                </AlertDescription>
+              </Alert>
+            ) : isManualMode ? (
+              // 手动模式
+              <div className="space-y-4">
+                <div>
+                  <Label htmlFor="manual-salary" className="mb-2 block">
+                    手动输入薪资
+                  </Label>
+                  <Input
+                    id="manual-salary"
+                    type="number"
+                    step="0.01"
+                    min="0"
+                    value={manualSalary || ''}
+                    onChange={handleManualSalaryChange}
+                    disabled={disabled}
+                    placeholder="请输入薪资金额"
+                  />
+                </div>
+                <div className="text-sm text-muted-foreground">
+                  <p>当前为手动模式,薪资不会根据区域自动查询</p>
+                  <p className="font-medium mt-1">总薪资: ¥{totalSalary.toFixed(2)}</p>
+                </div>
+              </div>
+            ) : currentSalary ? (
+              // 自动模式 - 有薪资数据
+              <div className="space-y-3">
+                <div className="grid grid-cols-2 gap-4">
+                  <div>
+                    <Label className="text-sm text-muted-foreground">基本工资</Label>
+                    <p className="text-lg font-medium">¥{currentSalary.basicSalary.toFixed(2)}</p>
+                  </div>
+                  <div>
+                    <Label className="text-sm text-muted-foreground">津贴补贴</Label>
+                    <p className="text-lg font-medium">¥{(currentSalary.allowance || 0).toFixed(2)}</p>
+                  </div>
+                  <div>
+                    <Label className="text-sm text-muted-foreground">保险费用</Label>
+                    <p className="text-lg font-medium">¥{(currentSalary.insurance || 0).toFixed(2)}</p>
+                  </div>
+                  <div>
+                    <Label className="text-sm text-muted-foreground">公积金</Label>
+                    <p className="text-lg font-medium">¥{(currentSalary.housingFund || 0).toFixed(2)}</p>
+                  </div>
+                </div>
+                <div className="pt-3 border-t">
+                  <div className="flex justify-between items-center">
+                    <Label className="text-base">总薪资</Label>
+                    <p className="text-2xl font-bold text-primary">¥{totalSalary.toFixed(2)}</p>
+                  </div>
+                  <p className="text-sm text-muted-foreground mt-1">
+                    计算公式:基本工资 + 津贴 - 保险 - 公积金
+                  </p>
+                </div>
+              </div>
+            ) : (
+              // 自动模式 - 无薪资数据
+              <Alert>
+                <AlertCircle className="h-4 w-4" />
+                <AlertDescription className="space-y-2">
+                  <p>该区域暂无薪资记录</p>
+                  {allowManualAdjust && (
+                    <p className="text-sm">
+                      您可以切换到手动模式输入薪资,或联系管理员添加该区域的薪资标准
+                    </p>
+                  )}
+                </AlertDescription>
+              </Alert>
+            )}
+
+            {/* 模式切换按钮 */}
+            {allowManualAdjust && !isLoading && !isError && (
+              <div className="pt-4 border-t">
+                <button
+                  type="button"
+                  onClick={toggleManualMode}
+                  disabled={disabled}
+                  className="text-sm text-primary hover:underline focus:outline-none focus:underline"
+                >
+                  {isManualMode ? '切换回自动查询模式' : '切换到手动调整模式'}
+                </button>
+              </div>
+            )}
+          </CardContent>
+        </Card>
+      )}
+
+      {/* 区域未选择提示 */}
+      {(!areaValue.provinceId || !areaValue.cityId) && (
+        <Alert>
+          <AlertCircle className="h-4 w-4" />
+          <AlertDescription>
+            请先选择省份和城市以查询薪资信息
+          </AlertDescription>
+        </Alert>
+      )}
+    </div>
+  );
+};
+
+export default SalarySelector;

+ 6 - 0
allin-packages/salary-management-ui/src/components/index.ts

@@ -0,0 +1,6 @@
+// 组件导出文件
+export { default as SalaryManagement } from './SalaryManagement';
+export { default as SalarySelector } from './SalarySelector';
+
+// 类型导出
+export type { SalarySelectorProps } from './SalarySelector';

+ 2 - 0
allin-packages/salary-management-ui/src/index.ts

@@ -0,0 +1,2 @@
+export * from './components';
+export * from './api';

+ 365 - 0
allin-packages/salary-management-ui/tests/integration/salary-selector.integration.test.tsx

@@ -0,0 +1,365 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { render, screen, fireEvent, waitFor } from '@testing-library/react';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import SalarySelector from '../../src/components/SalarySelector';
+import { salaryClientManager } from '../../src/api/salaryClient';
+import { AreaSelect } from '@d8d/area-management-ui/components';
+
+// Mock AreaSelect组件
+vi.mock('@d8d/area-management-ui/components', () => ({
+  AreaSelect: vi.fn(({ value, onChange, disabled, required }) => (
+    <div data-testid="area-select">
+      <select
+        data-testid="province-select"
+        value={value?.provinceId || ''}
+        onChange={(e) => onChange?.({ ...value, provinceId: e.target.value ? Number(e.target.value) : undefined })}
+        disabled={disabled}
+      >
+        <option value="">选择省份</option>
+        <option value="110000">北京市</option>
+        <option value="310000">上海市</option>
+        <option value="440000">广东省</option>
+      </select>
+      <select
+        data-testid="city-select"
+        value={value?.cityId || ''}
+        onChange={(e) => onChange?.({ ...value, cityId: e.target.value ? Number(e.target.value) : undefined })}
+        disabled={disabled || !value?.provinceId}
+      >
+        <option value="">选择城市</option>
+        <option value="110100">北京市辖区</option>
+        <option value="310100">上海市辖区</option>
+        <option value="440100">广州市</option>
+      </select>
+    </div>
+  ))
+}));
+
+// 完整的mock响应对象
+const createMockResponse = (status: number, data?: any) => ({
+  status,
+  ok: status >= 200 && status < 300,
+  body: null,
+  bodyUsed: false,
+  statusText: status === 200 ? 'OK' : status === 201 ? 'Created' : status === 204 ? 'No Content' : 'Error',
+  headers: new Headers(),
+  url: '',
+  redirected: false,
+  type: 'basic' as ResponseType,
+  json: async () => data || {},
+  text: async () => '',
+  blob: async () => new Blob(),
+  arrayBuffer: async () => new ArrayBuffer(0),
+  formData: async () => new FormData(),
+  clone: function() { return this; }
+});
+
+// Mock API client
+vi.mock('../../src/api/salaryClient', () => {
+  const mockSalaryClient = {
+    byProvinceCity: {
+      $get: vi.fn()
+    }
+  };
+
+  return {
+    salaryClientManager: {
+      getInstance: vi.fn(() => ({
+        get: vi.fn(() => mockSalaryClient),
+        reset: vi.fn()
+      }))
+    }
+  };
+});
+
+describe('薪资选择器集成测试', () => {
+  let queryClient: QueryClient;
+  let mockOnChange: vi.Mock;
+  let mockSalaryClient: any;
+
+  beforeEach(() => {
+    queryClient = new QueryClient({
+      defaultOptions: {
+        queries: {
+          retry: false,
+        },
+      },
+    });
+    mockOnChange = vi.fn();
+    mockSalaryClient = salaryClientManager.getInstance().get();
+    vi.clearAllMocks();
+  });
+
+  const renderComponent = (props = {}) => {
+    return render(
+      <QueryClientProvider client={queryClient}>
+        <SalarySelector onChange={mockOnChange} {...props} />
+      </QueryClientProvider>
+    );
+  };
+
+  it('应该正确渲染薪资选择器组件', () => {
+    renderComponent();
+
+    // 检查区域选择器
+    expect(screen.getByText('选择区域')).toBeInTheDocument();
+    expect(screen.getByTestId('area-select')).toBeInTheDocument();
+
+    // 检查初始状态提示
+    expect(screen.getByText('请先选择省份和城市以查询薪资信息')).toBeInTheDocument();
+  });
+
+  it('应该选择区域后查询薪资', async () => {
+    // Mock成功的薪资查询
+    mockSalaryClient.byProvinceCity.$get.mockResolvedValueOnce(
+      createMockResponse(200, {
+        id: 1,
+        provinceId: 110000,
+        cityId: 110100,
+        districtId: null,
+        basicSalary: 5000.00,
+        allowance: 1000.00,
+        insurance: 500.00,
+        housingFund: 800.00,
+        totalSalary: 4700.00,
+        updateTime: '2024-01-01T00:00:00Z'
+      })
+    );
+
+    renderComponent();
+
+    // 选择省份
+    const provinceSelect = screen.getByTestId('province-select');
+    fireEvent.change(provinceSelect, { target: { value: '110000' } });
+
+    // 选择城市
+    const citySelect = screen.getByTestId('city-select');
+    fireEvent.change(citySelect, { target: { value: '110100' } });
+
+    // 验证API调用
+    await waitFor(() => {
+      expect(mockSalaryClient.byProvinceCity.$get).toHaveBeenCalledWith({
+        query: {
+          provinceId: 110000,
+          cityId: 110100
+        }
+      });
+    });
+
+    // 验证onChange被调用
+    expect(mockOnChange).toHaveBeenCalledWith({
+      provinceId: 110000,
+      cityId: 110100,
+      salary: undefined,
+      salaryDetail: undefined
+    });
+
+    // 等待薪资信息显示
+    await waitFor(() => {
+      expect(screen.getByText('薪资信息')).toBeInTheDocument();
+      expect(screen.getByText('自动查询薪资模式')).toBeInTheDocument();
+      expect(screen.getByText('基本工资')).toBeInTheDocument();
+      expect(screen.getByText('¥5000.00')).toBeInTheDocument();
+      expect(screen.getByText('总薪资')).toBeInTheDocument();
+      expect(screen.getByText('¥4700.00')).toBeInTheDocument();
+    });
+
+    // 验证最终的onChange调用(包含薪资详情)
+    await waitFor(() => {
+      expect(mockOnChange).toHaveBeenCalledWith({
+        provinceId: 110000,
+        cityId: 110100,
+        salary: 4700.00,
+        salaryDetail: expect.objectContaining({
+          basicSalary: 5000.00,
+          allowance: 1000.00,
+          insurance: 500.00,
+          housingFund: 800.00
+        })
+      });
+    });
+  });
+
+  it('应该处理区域无薪资记录的情况', async () => {
+    // Mock 404响应(无薪资记录)
+    mockSalaryClient.byProvinceCity.$get.mockResolvedValueOnce(
+      createMockResponse(404, { code: 404, message: '该省份城市的薪资记录不存在' })
+    );
+
+    renderComponent({ allowManualAdjust: true });
+
+    // 选择区域
+    const provinceSelect = screen.getByTestId('province-select');
+    fireEvent.change(provinceSelect, { target: { value: '440000' } });
+    const citySelect = screen.getByTestId('city-select');
+    fireEvent.change(citySelect, { target: { value: '440100' } });
+
+    // 检查无薪资记录提示
+    await waitFor(() => {
+      expect(screen.getByText('该区域暂无薪资记录')).toBeInTheDocument();
+      expect(screen.getByText('您可以切换到手动模式输入薪资,或联系管理员添加该区域的薪资标准')).toBeInTheDocument();
+    });
+  });
+
+  it('应该支持手动调整薪资模式', async () => {
+    // Mock成功的薪资查询
+    mockSalaryClient.byProvinceCity.$get.mockResolvedValueOnce(
+      createMockResponse(200, {
+        id: 1,
+        provinceId: 110000,
+        cityId: 110100,
+        basicSalary: 5000.00,
+        allowance: 1000.00,
+        insurance: 500.00,
+        housingFund: 800.00,
+        totalSalary: 4700.00
+      })
+    );
+
+    renderComponent({ allowManualAdjust: true });
+
+    // 选择区域
+    const provinceSelect = screen.getByTestId('province-select');
+    fireEvent.change(provinceSelect, { target: { value: '110000' } });
+    const citySelect = screen.getByTestId('city-select');
+    fireEvent.change(citySelect, { target: { value: '110100' } });
+
+    // 等待自动查询完成
+    await waitFor(() => {
+      expect(screen.getByText('自动查询薪资模式')).toBeInTheDocument();
+    });
+
+    // 切换到手动模式
+    const toggleButton = screen.getByText('切换到手动调整模式');
+    fireEvent.click(toggleButton);
+
+    // 检查手动模式
+    await waitFor(() => {
+      expect(screen.getByText('手动调整薪资模式')).toBeInTheDocument();
+      expect(screen.getByLabelText('手动输入薪资')).toBeInTheDocument();
+      expect(screen.getByDisplayValue('4700')).toBeInTheDocument(); // 自动查询的总薪资
+    });
+
+    // 修改手动薪资
+    const salaryInput = screen.getByLabelText('手动输入薪资');
+    fireEvent.change(salaryInput, { target: { value: '6000' } });
+
+    // 验证onChange被调用
+    expect(mockOnChange).toHaveBeenCalledWith({
+      provinceId: 110000,
+      cityId: 110100,
+      salary: 6000,
+      salaryDetail: undefined
+    });
+
+    // 检查总薪资显示
+    expect(screen.getByText('¥6000.00')).toBeInTheDocument();
+
+    // 切换回自动模式
+    fireEvent.click(screen.getByText('切换回自动查询模式'));
+
+    // 验证重新查询
+    await waitFor(() => {
+      expect(mockSalaryClient.byProvinceCity.$get).toHaveBeenCalledTimes(2); // 初始查询 + 重新查询
+    });
+  });
+
+  it('应该禁用手动调整时隐藏切换按钮', async () => {
+    // Mock成功的薪资查询
+    mockSalaryClient.byProvinceCity.$get.mockResolvedValueOnce(
+      createMockResponse(200, {
+        id: 1,
+        provinceId: 110000,
+        cityId: 110100,
+        basicSalary: 5000.00,
+        allowance: 1000.00,
+        insurance: 500.00,
+        housingFund: 800.00,
+        totalSalary: 4700.00
+      })
+    );
+
+    renderComponent({ allowManualAdjust: false });
+
+    // 选择区域
+    const provinceSelect = screen.getByTestId('province-select');
+    fireEvent.change(provinceSelect, { target: { value: '110000' } });
+    const citySelect = screen.getByTestId('city-select');
+    fireEvent.change(citySelect, { target: { value: '110100' } });
+
+    // 等待自动查询完成
+    await waitFor(() => {
+      expect(screen.getByText('自动查询薪资模式')).toBeInTheDocument();
+    });
+
+    // 检查切换按钮不存在
+    expect(screen.queryByText('切换到手动调整模式')).not.toBeInTheDocument();
+  });
+
+  it('应该处理API查询错误', async () => {
+    // Mock API错误
+    mockSalaryClient.byProvinceCity.$get.mockRejectedValueOnce(new Error('网络错误'));
+
+    renderComponent();
+
+    // 选择区域
+    const provinceSelect = screen.getByTestId('province-select');
+    fireEvent.change(provinceSelect, { target: { value: '110000' } });
+    const citySelect = screen.getByTestId('city-select');
+    fireEvent.change(citySelect, { target: { value: '110100' } });
+
+    // 检查错误提示
+    await waitFor(() => {
+      expect(screen.getByText('查询薪资信息失败,请检查网络连接或稍后重试')).toBeInTheDocument();
+    });
+  });
+
+  it('应该支持外部值控制', async () => {
+    const initialValue = {
+      provinceId: 110000,
+      cityId: 110100,
+      salary: 5000,
+      salaryDetail: {
+        id: 1,
+        provinceId: 110000,
+        cityId: 110100,
+        basicSalary: 5000.00,
+        allowance: 1000.00,
+        insurance: 500.00,
+        housingFund: 800.00,
+        totalSalary: 4700.00
+      } as any
+    };
+
+    renderComponent({ value: initialValue });
+
+    // 检查区域选择器已设置值
+    const provinceSelect = screen.getByTestId('province-select') as HTMLSelectElement;
+    const citySelect = screen.getByTestId('city-select') as HTMLSelectElement;
+
+    expect(provinceSelect.value).toBe('110000');
+    expect(citySelect.value).toBe('110100');
+
+    // 检查薪资信息显示
+    await waitFor(() => {
+      expect(screen.getByText('薪资信息')).toBeInTheDocument();
+      expect(screen.getByText('¥5000.00')).toBeInTheDocument(); // 基本工资
+      expect(screen.getByText('¥4700.00')).toBeInTheDocument(); // 总薪资
+    });
+  });
+
+  it('应该支持禁用状态', () => {
+    renderComponent({ disabled: true });
+
+    // 检查区域选择器被禁用
+    const provinceSelect = screen.getByTestId('province-select');
+    expect(provinceSelect).toBeDisabled();
+  });
+
+  it('应该支持必填验证', () => {
+    renderComponent({ required: true });
+
+    // 检查必填标记
+    expect(screen.getByText('选择区域*')).toBeInTheDocument();
+  });
+});

+ 367 - 0
allin-packages/salary-management-ui/tests/integration/salary.integration.test.tsx

@@ -0,0 +1,367 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { render, screen, fireEvent, waitFor } from '@testing-library/react';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import SalaryManagement from '../../src/components/SalaryManagement';
+import { salaryClientManager } from '../../src/api/salaryClient';
+import { AreaSelect } from '@d8d/area-management-ui/components';
+
+// Mock AreaSelect组件
+vi.mock('@d8d/area-management-ui/components', () => ({
+  AreaSelect: vi.fn(({ value, onChange, disabled, required }) => (
+    <div data-testid="area-select">
+      <select
+        data-testid="province-select"
+        value={value?.provinceId || ''}
+        onChange={(e) => onChange?.({ ...value, provinceId: e.target.value ? Number(e.target.value) : undefined })}
+        disabled={disabled}
+      >
+        <option value="">选择省份</option>
+        <option value="110000">北京市</option>
+        <option value="310000">上海市</option>
+      </select>
+      <select
+        data-testid="city-select"
+        value={value?.cityId || ''}
+        onChange={(e) => onChange?.({ ...value, cityId: e.target.value ? Number(e.target.value) : undefined })}
+        disabled={disabled || !value?.provinceId}
+      >
+        <option value="">选择城市</option>
+        <option value="110100">北京市辖区</option>
+        <option value="310100">上海市辖区</option>
+      </select>
+    </div>
+  ))
+}));
+
+// 完整的mock响应对象
+const createMockResponse = (status: number, data?: any) => ({
+  status,
+  ok: status >= 200 && status < 300,
+  body: null,
+  bodyUsed: false,
+  statusText: status === 200 ? 'OK' : status === 201 ? 'Created' : status === 204 ? 'No Content' : 'Error',
+  headers: new Headers(),
+  url: '',
+  redirected: false,
+  type: 'basic' as ResponseType,
+  json: async () => data || {},
+  text: async () => '',
+  blob: async () => new Blob(),
+  arrayBuffer: async () => new ArrayBuffer(0),
+  formData: async () => new FormData(),
+  clone: function() { return this; }
+});
+
+// Mock API client
+vi.mock('../../src/api/salaryClient', () => {
+  const mockSalaryClient = {
+    $get: vi.fn(() => Promise.resolve(createMockResponse(200, {
+      data: [
+        {
+          id: 1,
+          provinceId: 110000,
+          cityId: 110100,
+          districtId: null,
+          basicSalary: 5000.00,
+          allowance: 1000.00,
+          insurance: 500.00,
+          housingFund: 800.00,
+          totalSalary: 4700.00,
+          updateTime: '2024-01-01T00:00:00Z',
+          province: { id: 110000, name: '北京市' },
+          city: { id: 110100, name: '北京市辖区' },
+          district: null
+        },
+        {
+          id: 2,
+          provinceId: 310000,
+          cityId: 310100,
+          districtId: 310101,
+          basicSalary: 6000.00,
+          allowance: 1200.00,
+          insurance: 600.00,
+          housingFund: 900.00,
+          totalSalary: 5700.00,
+          updateTime: '2024-01-02T00:00:00Z',
+          province: { id: 310000, name: '上海市' },
+          city: { id: 310100, name: '上海市辖区' },
+          district: { id: 310101, name: '黄浦区' }
+        }
+      ],
+      total: 2
+    }))),
+    create: {
+      $post: vi.fn(() => Promise.resolve(createMockResponse(200, {
+        id: 3,
+        provinceId: 440000,
+        cityId: 440100,
+        districtId: null,
+        basicSalary: 5500.00,
+        allowance: 1100.00,
+        insurance: 550.00,
+        housingFund: 850.00,
+        totalSalary: 5200.00,
+        updateTime: '2024-01-03T00:00:00Z'
+      }))),
+    },
+    update: {
+      ':id': {
+        $put: vi.fn(() => Promise.resolve(createMockResponse(200, {
+          id: 1,
+          provinceId: 110000,
+          cityId: 110100,
+          districtId: null,
+          basicSalary: 5500.00, // 更新后的基本工资
+          allowance: 1000.00,
+          insurance: 500.00,
+          housingFund: 800.00,
+          totalSalary: 5200.00,
+          updateTime: '2024-01-03T00:00:00Z'
+        }))),
+      }
+    },
+    delete: {
+      ':id': {
+        $delete: vi.fn(() => Promise.resolve(createMockResponse(200, { success: true }))),
+      }
+    },
+    detail: {
+      ':id': {
+        $get: vi.fn(() => Promise.resolve(createMockResponse(200, {
+          id: 1,
+          provinceId: 110000,
+          cityId: 110100,
+          districtId: null,
+          basicSalary: 5000.00,
+          allowance: 1000.00,
+          insurance: 500.00,
+          housingFund: 800.00,
+          totalSalary: 4700.00,
+          updateTime: '2024-01-01T00:00:00Z'
+        }))),
+      }
+    },
+    byProvinceCity: {
+      $get: vi.fn(() => Promise.resolve(createMockResponse(200, {
+        id: 1,
+        provinceId: 110000,
+        cityId: 110100,
+        districtId: null,
+        basicSalary: 5000.00,
+        allowance: 1000.00,
+        insurance: 500.00,
+        housingFund: 800.00,
+        totalSalary: 4700.00,
+        updateTime: '2024-01-01T00:00:00Z'
+      }))),
+    }
+  };
+
+  return {
+    salaryClientManager: {
+      getInstance: vi.fn(() => ({
+        get: vi.fn(() => mockSalaryClient),
+        reset: vi.fn()
+      }))
+    }
+  };
+});
+
+describe('薪资管理集成测试', () => {
+  let queryClient: QueryClient;
+
+  beforeEach(() => {
+    queryClient = new QueryClient({
+      defaultOptions: {
+        queries: {
+          retry: false,
+        },
+      },
+    });
+    vi.clearAllMocks();
+  });
+
+  const renderComponent = () => {
+    return render(
+      <QueryClientProvider client={queryClient}>
+        <SalaryManagement />
+      </QueryClientProvider>
+    );
+  };
+
+  it('应该正确渲染薪资管理组件', async () => {
+    renderComponent();
+
+    // 检查标题
+    expect(screen.getByText('薪资水平管理')).toBeInTheDocument();
+    expect(screen.getByText('管理各地区薪资水平,支持基本工资、津贴、保险、公积金等计算')).toBeInTheDocument();
+
+    // 检查搜索区域
+    expect(screen.getByTestId('area-select')).toBeInTheDocument();
+    expect(screen.getByText('搜索')).toBeInTheDocument();
+    expect(screen.getByText('添加薪资')).toBeInTheDocument();
+
+    // 等待数据加载
+    await waitFor(() => {
+      expect(screen.getByText('北京市')).toBeInTheDocument();
+      expect(screen.getByText('上海市')).toBeInTheDocument();
+    });
+
+    // 检查表格列
+    expect(screen.getByText('ID')).toBeInTheDocument();
+    expect(screen.getByText('省份')).toBeInTheDocument();
+    expect(screen.getByText('城市')).toBeInTheDocument();
+    expect(screen.getByText('基本工资')).toBeInTheDocument();
+    expect(screen.getByText('总薪资')).toBeInTheDocument();
+  });
+
+  it('应该显示薪资列表数据', async () => {
+    renderComponent();
+
+    await waitFor(() => {
+      // 检查第一条数据
+      expect(screen.getByText('1')).toBeInTheDocument();
+      expect(screen.getByText('北京市')).toBeInTheDocument();
+      expect(screen.getByText('北京市辖区')).toBeInTheDocument();
+      expect(screen.getByText('¥5000.00')).toBeInTheDocument();
+      expect(screen.getByText('¥4700.00')).toBeInTheDocument();
+
+      // 检查第二条数据
+      expect(screen.getByText('2')).toBeInTheDocument();
+      expect(screen.getByText('上海市')).toBeInTheDocument();
+      expect(screen.getByText('上海市辖区')).toBeInTheDocument();
+      expect(screen.getByText('黄浦区')).toBeInTheDocument();
+      expect(screen.getByText('¥6000.00')).toBeInTheDocument();
+      expect(screen.getByText('¥5700.00')).toBeInTheDocument();
+    });
+  });
+
+  it('应该支持区域搜索', async () => {
+    renderComponent();
+
+    // 选择省份
+    const provinceSelect = screen.getByTestId('province-select');
+    fireEvent.change(provinceSelect, { target: { value: '110000' } });
+
+    // 选择城市
+    const citySelect = screen.getByTestId('city-select');
+    fireEvent.change(citySelect, { target: { value: '110100' } });
+
+    // 点击搜索按钮
+    const searchButton = screen.getByText('搜索');
+    fireEvent.click(searchButton);
+
+    // 验证API调用
+    await waitFor(() => {
+      const mockClient = salaryClientManager.getInstance().get();
+      expect(mockClient.$get).toHaveBeenCalledWith({
+        query: {
+          skip: 0,
+          take: 10,
+          provinceId: 110000,
+          cityId: 110100
+        }
+      });
+    });
+  });
+
+  it('应该打开添加薪资模态框', async () => {
+    renderComponent();
+
+    // 点击添加按钮
+    const addButton = screen.getByText('添加薪资');
+    fireEvent.click(addButton);
+
+    // 检查模态框标题
+    await waitFor(() => {
+      expect(screen.getByText('添加薪资')).toBeInTheDocument();
+      expect(screen.getByText('填写薪资信息,支持实时计算总薪资')).toBeInTheDocument();
+    });
+
+    // 检查表单字段
+    expect(screen.getByText('区域选择')).toBeInTheDocument();
+    expect(screen.getByText('基本工资')).toBeInTheDocument();
+    expect(screen.getByText('津贴补贴')).toBeInTheDocument();
+    expect(screen.getByText('保险费用')).toBeInTheDocument();
+    expect(screen.getByText('住房公积金')).toBeInTheDocument();
+  });
+
+  it('应该计算总薪资', async () => {
+    renderComponent();
+
+    // 打开添加模态框
+    const addButton = screen.getByText('添加薪资');
+    fireEvent.click(addButton);
+
+    await waitFor(() => {
+      expect(screen.getByText('添加薪资')).toBeInTheDocument();
+    });
+
+    // 填写表单数据
+    const basicSalaryInput = screen.getByLabelText('基本工资');
+    const allowanceInput = screen.getByLabelText('津贴补贴');
+    const insuranceInput = screen.getByLabelText('保险费用');
+    const housingFundInput = screen.getByLabelText('住房公积金');
+
+    fireEvent.change(basicSalaryInput, { target: { value: '5000' } });
+    fireEvent.change(allowanceInput, { target: { value: '1000' } });
+    fireEvent.change(insuranceInput, { target: { value: '500' } });
+    fireEvent.change(housingFundInput, { target: { value: '800' } });
+
+    // 检查总薪资计算
+    await waitFor(() => {
+      expect(screen.getByText('¥4700.00')).toBeInTheDocument();
+      expect(screen.getByText('计算公式:基本工资 + 津贴 - 保险 - 公积金')).toBeInTheDocument();
+    });
+  });
+
+  it('应该打开编辑薪资模态框', async () => {
+    renderComponent();
+
+    await waitFor(() => {
+      expect(screen.getByText('北京市')).toBeInTheDocument();
+    });
+
+    // 点击编辑按钮(第一个薪资记录的编辑按钮)
+    const editButtons = screen.getAllByRole('button', { name: /编辑/i });
+    fireEvent.click(editButtons[0]);
+
+    // 检查编辑模态框
+    await waitFor(() => {
+      expect(screen.getByText('编辑薪资')).toBeInTheDocument();
+      expect(screen.getByDisplayValue('5000')).toBeInTheDocument(); // 基本工资
+    });
+  });
+
+  it('应该显示删除确认对话框', async () => {
+    renderComponent();
+
+    await waitFor(() => {
+      expect(screen.getByText('北京市')).toBeInTheDocument();
+    });
+
+    // 点击删除按钮(第一个薪资记录的删除按钮)
+    const deleteButtons = screen.getAllByRole('button', { name: /删除/i });
+    fireEvent.click(deleteButtons[0]);
+
+    // 检查删除确认对话框
+    await waitFor(() => {
+      expect(screen.getByText('确认删除')).toBeInTheDocument();
+      expect(screen.getByText('确定要删除这条薪资信息吗?此操作不可撤销。')).toBeInTheDocument();
+    });
+  });
+
+  it('应该处理API错误', async () => {
+    // Mock API错误
+    const mockClient = salaryClientManager.getInstance().get();
+    mockClient.$get.mockRejectedValueOnce(new Error('获取薪资列表失败'));
+
+    renderComponent();
+
+    // 检查错误处理
+    await waitFor(() => {
+      // 表格应该为空或显示加载状态
+      expect(screen.queryByText('北京市')).not.toBeInTheDocument();
+    });
+  });
+});

+ 24 - 0
allin-packages/salary-management-ui/tests/setup.ts

@@ -0,0 +1,24 @@
+import '@testing-library/jest-dom';
+import { vi } from 'vitest';
+
+// Mock window.matchMedia
+Object.defineProperty(window, 'matchMedia', {
+  writable: true,
+  value: vi.fn().mockImplementation(query => ({
+    matches: false,
+    media: query,
+    onchange: null,
+    addListener: vi.fn(),
+    removeListener: vi.fn(),
+    addEventListener: vi.fn(),
+    removeEventListener: vi.fn(),
+    dispatchEvent: vi.fn(),
+  })),
+});
+
+// Mock ResizeObserver
+global.ResizeObserver = vi.fn().mockImplementation(() => ({
+  observe: vi.fn(),
+  unobserve: vi.fn(),
+  disconnect: vi.fn(),
+}));

+ 36 - 0
allin-packages/salary-management-ui/tsconfig.json

@@ -0,0 +1,36 @@
+{
+  "compilerOptions": {
+    "target": "ES2022",
+    "lib": ["ES2022", "DOM", "DOM.Iterable"],
+    "module": "ESNext",
+    "skipLibCheck": true,
+    "moduleResolution": "bundler",
+    "allowImportingTsExtensions": true,
+    "resolveJsonModule": true,
+    "isolatedModules": true,
+    "noEmit": true,
+    "jsx": "react-jsx",
+    "strict": true,
+    "noUnusedLocals": true,
+    "noUnusedParameters": true,
+    "noFallthroughCasesInSwitch": true,
+    "experimentalDecorators": true,
+    "emitDecoratorMetadata": true,
+    "declaration": true,
+    "declarationMap": true,
+    "sourceMap": true,
+    "outDir": "./dist",
+    "baseUrl": ".",
+    "paths": {
+      "@/*": ["./src/*"]
+    }
+  },
+  "include": [
+    "src/**/*",
+    "tests/**/*"
+  ],
+  "exclude": [
+    "node_modules",
+    "dist"
+  ]
+}

+ 24 - 0
allin-packages/salary-management-ui/vitest.config.ts

@@ -0,0 +1,24 @@
+import { defineConfig } from 'vitest/config';
+
+export default defineConfig({
+  test: {
+    globals: true,
+    environment: 'jsdom',
+    setupFiles: ['./tests/setup.ts'],
+    coverage: {
+      provider: 'v8',
+      reporter: ['text', 'json', 'html'],
+      exclude: [
+        'node_modules/',
+        'tests/',
+        '**/*.d.ts',
+        '**/*.config.*'
+      ]
+    }
+  },
+  resolve: {
+    alias: {
+      '@': './src'
+    }
+  }
+});

+ 24 - 4
docs/stories/008.004.transplant-salary-management-ui.story.md

@@ -319,16 +319,36 @@ Ready for Development
 *This section is populated by the development agent during implementation*
 
 ### Agent Model Used
-*To be filled by dev agent*
+James (Developer Agent)
 
 ### Debug Log References
-*To be filled by dev agent*
+- 类型检查发现RPC类型推导语法错误,已修复`$get`和`$put`的中括号语法
+- Schema类型不匹配问题需要进一步调试
 
 ### Completion Notes List
-*To be filled by dev agent*
+- [x] 任务1:创建薪资管理UI包基础结构 - 已完成
+- [x] 任务2:分析源UI页面结构和区域选择器集成 - 已完成
+- [x] 任务3:查看薪资模块RPC API调用路径并创建API客户端 - 已完成
+- [x] 任务4:完成状态管理和表单转换 - 已完成
+- [x] 任务5:创建薪资选择器组件 - 已完成
+- [x] 任务6:编写集成测试 - 已完成
+- [ ] 任务7:验证和测试 - 进行中(类型检查发现问题)
 
 ### File List
-*To be filled by dev agent*
+**新增文件:**
+- `allin-packages/salary-management-ui/package.json` - 薪资管理UI包配置
+- `allin-packages/salary-management-ui/tsconfig.json` - TypeScript配置
+- `allin-packages/salary-management-ui/vitest.config.ts` - 测试配置
+- `allin-packages/salary-management-ui/tests/setup.ts` - 测试setup文件
+- `allin-packages/salary-management-ui/src/index.ts` - 包入口文件
+- `allin-packages/salary-management-ui/src/components/index.ts` - 组件导出文件
+- `allin-packages/salary-management-ui/src/api/index.ts` - API导出文件
+- `allin-packages/salary-management-ui/src/components/SalaryManagement.tsx` - 薪资管理主组件
+- `allin-packages/salary-management-ui/src/components/SalarySelector.tsx` - 薪资选择器组件
+- `allin-packages/salary-management-ui/src/api/salaryClient.ts` - RPC API客户端
+- `allin-packages/salary-management-ui/src/api/types.ts` - 类型定义
+- `allin-packages/salary-management-ui/tests/integration/salary.integration.test.tsx` - 薪资管理集成测试
+- `allin-packages/salary-management-ui/tests/integration/salary-selector.integration.test.tsx` - 薪资选择器集成测试
 
 ## QA Results
 *Results from QA Agent QA review of the completed story implementation*