remix图标本地化

This commit is contained in:
2025-05-30 21:42:44 +08:00
parent 56f145c499
commit 9d970cf0f6
15 changed files with 14023 additions and 85 deletions
+2 -85
View File
@@ -3,97 +3,14 @@ import { useLoaderData, useNavigate } from '@remix-run/react';
import { getContractTemplate } from '~/api/contract-template/templates'; import { getContractTemplate } from '~/api/contract-template/templates';
import type { ContractTemplate } from '~/api/contract-template/templates'; import type { ContractTemplate } from '~/api/contract-template/templates';
import styles from '~/styles/pages/contract-template.css?url'; import styles from '~/styles/pages/contract-template.css?url';
import filePreviewStyles from '~/styles/components/file-preview-isolation.css?url';
// 导入FilePreview组件 // 导入FilePreview组件
import { FilePreview } from '~/components/reviews'; import { FilePreview } from '~/components/reviews';
export const links = () => [ export const links = () => [
{ rel: 'stylesheet', href: styles }, { rel: 'stylesheet', href: styles },
// 添加专门的样式隔离 { rel: 'stylesheet', href: filePreviewStyles },
{
rel: 'stylesheet',
href: 'data:text/css;base64,' + btoa(`
.file-preview-isolation {
all: unset !important;
display: block !important;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif !important;
font-size: 14px !important;
line-height: 1.5 !important;
color: #333 !important;
background: #fff !important;
width: 100% !important;
min-height: 600px !important;
position: relative !important;
isolation: isolate !important;
contain: layout style !important;
}
.file-preview-isolation * {
font-family: inherit !important;
box-sizing: border-box !important;
}
.file-preview-isolation .file-preview-header {
display: flex !important;
justify-content: space-between !important;
align-items: center !important;
padding: 8px 16px !important;
background: #f8f9fa !important;
border-bottom: 1px solid #dee2e6 !important;
font-size: 14px !important;
line-height: 1.5 !important;
max-width: none !important;
width: 100% !important;
text-overflow: unset !important;
white-space: nowrap !important;
overflow: visible !important;
}
.file-preview-isolation .file-preview-actions {
display: flex !important;
align-items: center !important;
gap: 8px !important;
}
.file-preview-isolation button,
.file-preview-isolation .ant-btn {
padding: 4px 8px !important;
margin: 0 2px !important;
border: 1px solid #d9d9d9 !important;
border-radius: 4px !important;
background: white !important;
color: #333 !important;
font-size: 12px !important;
line-height: 1.4 !important;
cursor: pointer !important;
display: inline-flex !important;
align-items: center !important;
justify-content: center !important;
min-height: 24px !important;
text-decoration: none !important;
white-space: nowrap !important;
vertical-align: middle !important;
}
.file-preview-isolation input,
.file-preview-isolation .ant-input {
padding: 4px 8px !important;
border: 1px solid #d9d9d9 !important;
border-radius: 4px !important;
background: white !important;
color: #333 !important;
font-size: 12px !important;
line-height: 1.4 !important;
min-height: 24px !important;
width: auto !important;
max-width: 40px !important;
text-align: center !important;
outline: none !important;
}
.file-preview-isolation .file-preview-content {
max-height: calc(100vh - 150px) !important;
overflow: auto !important;
background: #f8f9fa !important;
padding: 0 !important;
margin: 0 !important;
}
`)
}
]; ];
export const meta: MetaFunction<typeof loader> = ({ data }) => { export const meta: MetaFunction<typeof loader> = ({ data }) => {
@@ -0,0 +1,102 @@
/* 文件预览样式隔离 */
.file-preview-isolation {
all: unset !important;
display: block !important;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif !important;
font-size: 14px !important;
line-height: 1.5 !important;
color: #333 !important;
background: #fff !important;
width: 100% !important;
min-height: 600px !important;
position: relative !important;
isolation: isolate !important;
contain: layout style !important;
}
.file-preview-isolation * {
font-family: inherit !important;
box-sizing: border-box !important;
}
/* 为RemixIcon图标添加字体例外规则 */
.file-preview-isolation [class^="ri-"],
.file-preview-isolation [class*=" ri-"],
.file-preview-isolation i[class^="ri-"],
.file-preview-isolation i[class*=" ri-"] {
font-family: 'remixicon' !important;
font-style: normal !important;
font-weight: normal !important;
font-variant: normal !important;
text-transform: none !important;
line-height: 1 !important;
-webkit-font-smoothing: antialiased !important;
-moz-osx-font-smoothing: grayscale !important;
speak: none !important;
}
.file-preview-isolation .file-preview-header {
display: flex !important;
justify-content: space-between !important;
align-items: center !important;
padding: 8px 16px !important;
background: #f8f9fa !important;
border-bottom: 1px solid #dee2e6 !important;
font-size: 14px !important;
line-height: 1.5 !important;
max-width: none !important;
width: 100% !important;
text-overflow: unset !important;
white-space: nowrap !important;
overflow: visible !important;
}
.file-preview-isolation .file-preview-actions {
display: flex !important;
align-items: center !important;
gap: 8px !important;
}
.file-preview-isolation button,
.file-preview-isolation .ant-btn {
padding: 4px 8px !important;
margin: 0 2px !important;
border: 1px solid #d9d9d9 !important;
border-radius: 4px !important;
background: white !important;
color: #333 !important;
font-size: 12px !important;
line-height: 1.4 !important;
cursor: pointer !important;
display: inline-flex !important;
align-items: center !important;
justify-content: center !important;
min-height: 24px !important;
text-decoration: none !important;
white-space: nowrap !important;
vertical-align: middle !important;
}
.file-preview-isolation input,
.file-preview-isolation .ant-input {
padding: 4px 8px !important;
border: 1px solid #d9d9d9 !important;
border-radius: 4px !important;
background: white !important;
color: #333 !important;
font-size: 12px !important;
line-height: 1.4 !important;
min-height: 24px !important;
width: auto !important;
max-width: 40px !important;
text-align: center !important;
outline: none !important;
}
.file-preview-isolation .file-preview-content {
max-height: calc(100vh - 150px) !important;
overflow: auto !important;
background: #f8f9fa !important;
padding: 0 !important;
margin: 0 !important;
}
+25
View File
@@ -256,4 +256,29 @@
.dark .content-container { .dark .content-container {
@apply bg-gray-900 text-gray-200; @apply bg-gray-900 text-gray-200;
} }
}
/* RemixIcon 图标保护规则 - 防止被样式隔离覆盖 */
[class^="ri-"]:before,
[class*=" ri-"]:before,
i[class^="ri-"]:before,
i[class*=" ri-"]:before {
font-family: 'remixicon' !important;
font-style: normal !important;
font-weight: normal !important;
font-variant: normal !important;
text-transform: none !important;
line-height: 1 !important;
-webkit-font-smoothing: antialiased !important;
-moz-osx-font-smoothing: grayscale !important;
speak: none !important;
}
/* 确保RemixIcon元素本身也使用正确的字体 */
[class^="ri-"],
[class*=" ri-"],
i[class^="ri-"],
i[class*=" ri-"] {
font-family: 'remixicon' !important;
font-style: normal !important;
} }
File diff suppressed because it is too large Load Diff
+96
View File
@@ -0,0 +1,96 @@
### 合同分类
```
CREATE TABLE "public"."contract_categories" (
"id" int4 NOT NULL DEFAULT nextval('contract_categories_id_seq'::regclass),
"name" varchar(100) COLLATE "pg_catalog"."default" NOT NULL,
"icon" varchar(100) COLLATE "pg_catalog"."default",
"description" text COLLATE "pg_catalog"."default",
"sort_order" int4 DEFAULT 0,
"created_at" timestamptz(6) DEFAULT now(),
"updated_at" timestamptz(6) DEFAULT now(),
CONSTRAINT "contract_categories_pkey" PRIMARY KEY ("id")
)
;
ALTER TABLE "public"."contract_categories"
OWNER TO "root";
CREATE TRIGGER "update_contract_categories_updated_at" BEFORE UPDATE ON "public"."contract_categories"
FOR EACH ROW
EXECUTE PROCEDURE "public"."update_updated_at_column"();
COMMENT ON COLUMN "public"."contract_categories"."id" IS '分类ID(主键)';
COMMENT ON COLUMN "public"."contract_categories"."name" IS '分类名称';
COMMENT ON COLUMN "public"."contract_categories"."icon" IS '图标类名(如 font-awesome 类名)';
COMMENT ON COLUMN "public"."contract_categories"."description" IS '分类描述';
COMMENT ON COLUMN "public"."contract_categories"."sort_order" IS '排序顺序';
COMMENT ON COLUMN "public"."contract_categories"."created_at" IS '创建时间(带时区)';
COMMENT ON COLUMN "public"."contract_categories"."updated_at" IS '最后更新时间(带时区)';
COMMENT ON TABLE "public"."contract_categories" IS '合同分类表';
```
### 合同模板表
```
CREATE TABLE "public"."contract_templates" (
"id" int8 NOT NULL DEFAULT nextval('contract_templates_id_seq'::regclass),
"template_code" varchar(50) COLLATE "pg_catalog"."default" NOT NULL,
"title" varchar(200) COLLATE "pg_catalog"."default" NOT NULL,
"category_id" int4 NOT NULL,
"description" text COLLATE "pg_catalog"."default",
"file_path" varchar(500) COLLATE "pg_catalog"."default",
"file_format" varchar(10) COLLATE "pg_catalog"."default" DEFAULT 'docx'::character varying,
"is_featured" bool DEFAULT false,
"created_at" timestamptz(6) DEFAULT now(),
"updated_at" timestamptz(6) DEFAULT now(),
"pdf_file_path" varchar(500) COLLATE "pg_catalog"."default",
CONSTRAINT "contract_templates_pkey" PRIMARY KEY ("id"),
CONSTRAINT "fk_category_id" FOREIGN KEY ("category_id") REFERENCES "public"."contract_categories" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION,
CONSTRAINT "contract_templates_template_code_key" UNIQUE ("template_code"),
CONSTRAINT "valid_file_format" CHECK (file_format::text = ANY (ARRAY['docx'::character varying, 'pdf'::character varying, 'txt'::character varying]::text[]))
)
;
ALTER TABLE "public"."contract_templates"
OWNER TO "root";
CREATE INDEX "idx_contract_templates_category_id" ON "public"."contract_templates" USING btree (
"category_id" "pg_catalog"."int4_ops" ASC NULLS LAST
);
CREATE TRIGGER "update_contract_templates_updated_at" BEFORE UPDATE ON "public"."contract_templates"
FOR EACH ROW
EXECUTE PROCEDURE "public"."update_updated_at_column"();
COMMENT ON COLUMN "public"."contract_templates"."id" IS '模板ID(主键)';
COMMENT ON COLUMN "public"."contract_templates"."template_code" IS '模板编号(唯一标识)';
COMMENT ON COLUMN "public"."contract_templates"."title" IS '模板标题';
COMMENT ON COLUMN "public"."contract_templates"."category_id" IS '所属分类ID(外键)';
COMMENT ON COLUMN "public"."contract_templates"."description" IS '模板描述';
COMMENT ON COLUMN "public"."contract_templates"."file_path" IS '文件存储路径';
COMMENT ON COLUMN "public"."contract_templates"."file_format" IS '文件格式(docx/pdf/txt';
COMMENT ON COLUMN "public"."contract_templates"."is_featured" IS '是否推荐模板';
COMMENT ON COLUMN "public"."contract_templates"."created_at" IS '创建时间(带时区)';
COMMENT ON COLUMN "public"."contract_templates"."updated_at" IS '最后更新时间(带时区)';
COMMENT ON COLUMN "public"."contract_templates"."pdf_file_path" IS 'pdf文件存储路径';
COMMENT ON TABLE "public"."contract_templates" IS '合同模板表';
```
+36
View File
@@ -0,0 +1,36 @@
## 鍐呯綉閮ㄧ讲妫€鏌ユ竻鍗?
### 鉁?宸插畬鎴愮殑鏈湴鍖?
1. **RemixIcon瀛椾綋鏂囦欢** - 宸插鍒跺埌 public/fonts/ 鐩綍
- remixicon.woff2 (173KB) - 鐜颁唬娴忚鍣ㄤ紭鍏?
- remixicon.woff (237KB) - 鍏煎鎬у閫?
- remixicon.ttf (557KB) - 绯荤粺瀛椾綋澶囬€?
- remixicon.eot (557KB) - IE娴忚鍣ㄦ敮鎸?
- remixicon.svg (2.6MB) - 鏃х増娴忚鍣ㄥ閫?
2. **CSS鏂囦欢鏈湴鍖?* - pp/styles/remixicon-local.css
- 浣跨敤鏈湴瀛椾綋璺緞 /fonts/remixicon.*
- 鍖呭惈瀹屾暣鍥炬爣鏄犲皠
- 鍚敤 ont-display: swap 浼樺寲鍔犺浇
3. **瀛椾綋棰勫姞杞戒紭鍖?* - pp/root.tsx
- 棰勫姞杞絯off2鍜寃off鏍煎紡
- 浣跨敤crossOrigin="anonymous"
- 浼樺厛鍔犺浇鏈€閲嶈鐨勫瓧浣撴牸寮?
### 馃敡 鎶€鏈疄鐜?
- **瑙e喅棣栨鍔犺浇闂**: 閫氳繃瀛椾綋棰勫姞杞界‘淇濆浘鏍囩珛鍗虫樉绀?
- **鍐呯綉鍏煎鎬?*: 鎵€鏈夎祫婧愰兘鏉ヨ嚜鏈湴锛屾棤澶栭儴渚濊禆
- **鎬ц兘浼樺寲**: 浣跨敤鏈€鏂扮殑瀛椾綋鍔犺浇绛栫暐
- **娴忚鍣ㄥ吋瀹?*: 鏀寔IE9+鍒版渶鏂版祻瑙堝櫒
### 馃搵 閮ㄧ讲娉ㄦ剰浜嬮」
1. 纭繚 public/fonts/ 鐩綍鍦ㄧ敓浜х幆澧冧腑鍙闂?
2. 妫€鏌ユ湇鍔″櫒MIME绫诲瀷閰嶇疆鏀寔瀛椾綋鏂囦欢
3. 寤鸿鍚敤gzip鍘嬬缉浠ュ噺灏戝瓧浣撴枃浠朵紶杈撳ぇ灏?
4. 鍙垹闄や笉闇€瑕佺殑瀛椾綋鏍煎紡浠ュ噺灏忛儴缃插寘澶у皬
### 馃殌 鎬ц兘寤鸿
- 淇濈暀 woff2 鍜?woff 鏍煎紡鍗冲彲瑕嗙洊99%+鐨勬祻瑙堝櫒
- 濡傞渶鏀寔IE8-锛屼繚鐣?eot 鏍煎紡
- svg鏍煎紡浠呯敤浜庢瀬鏃х殑绉诲姩娴忚鍣紝鍙€夋嫨鎬т繚鐣?
+494
View File
@@ -0,0 +1,494 @@
# 中国烟草AI合同及卷宗审核系统 - 开发规范指南
## 📋 概述
本文档定义了「中国烟草AI合同及卷宗审核系统」的开发规范,包括技术架构、设计风格、代码规范、UI组件等各个方面,确保团队开发的一致性和代码质量。
## 🏗 技术架构规范
### 核心技术栈
- **前端框架**: Remix (React) + TypeScript
- **构建工具**: Vite
- **样式系统**: Tailwind CSS + 自定义CSS
- **图标库**: Remixicon (已本地化)
- **文档处理**: react-pdf, mammoth, docx-preview
- **代码规范**: ESLint + TypeScript
- **状态管理**: React Context + useState/useEffect
### 项目结构规范
```
app/
├── api/ # API层接口
├── components/ # 组件
│ ├── ui/ # 通用UI组件
│ ├── layout/ # 布局组件
│ ├── error/ # 错误处理组件
│ └── [feature]/ # 功能特定组件
├── routes/ # Remix路由页面
├── styles/ # 样式文件
│ ├── main.css # 主样式
│ └── components/ # 组件样式
├── types/ # TypeScript类型定义
├── contexts/ # React Context
├── models/ # 数据模型
└── utils.ts # 工具函数
```
## 🎨 设计系统规范
### 颜色主题
```css
/* 主色调 - 中国烟草企业绿 */
--color-primary: #00684a; /* 主色 */
--color-primary-hover: #005a3f; /* 悬停色 */
--color-primary-light: rgba(0, 104, 74, 0.1); /* 浅色背景 */
/* 状态颜色 */
--color-success: #52c41a; /* 成功绿 */
--color-warning: #faad14; /* 警告橙 */
--color-error: #f5222d; /* 错误红 */
/* 中性色系 */
--color-gray-50: #f8f9fa; /* 最浅灰 */
--color-gray-100: #f1f3f5;
--color-gray-200: #e9ecef;
--color-gray-300: #dee2e6;
--color-gray-400: #ced4da;
--color-gray-500: #adb5bd; /* 中性灰 */
--color-gray-600: #868e96;
--color-gray-700: #495057;
--color-gray-800: #343a40;
--color-gray-900: #212529; /* 最深灰 */
```
### 字体规范
```css
font-family: [
"-apple-system",
"BlinkMacSystemFont",
"Segoe UI",
"Roboto",
"Helvetica Neue",
"Arial",
"Noto Sans SC", /* 中文优先 */
"PingFang SC",
"Microsoft YaHei UI",
"Microsoft YaHei",
"sans-serif"
];
```
### 间距系统
- 使用Tailwind默认间距: 4px基数 (1, 2, 3, 4, 5, 6...)
- 页面容器内边距: `p-5` (20px)
- 卡片内边距: `p-4` (16px)
- 组件间距: `mb-4` `mt-6` (16px, 24px)
### 圆角规范
- 按钮、卡片: `rounded-md` (6px)
- 输入框: `rounded-md` (6px)
- 头像: `rounded-full`
- 标签: `rounded-md`
### 阴影系统
```css
/* 卡片阴影 */
.card: shadow-sm; /* 默认 */
.card:hover: shadow-md; /* 悬停 */
.sidebar: shadow-[0_0_15px_rgba(0,0,0,0.05)]; /* 侧边栏 */
```
## 🧩 组件设计规范
### 按钮组件
```tsx
// 类型定义
type ButtonType = 'primary' | 'default' | 'danger';
type ButtonSize = 'small' | 'medium' | 'large';
// 样式类
.ant-btn-primary: bg-[#00684a] text-white hover:bg-[#005a3f]
.ant-btn-default: bg-white border border-gray-300 text-gray-800
.ant-btn-danger: bg-[#f5222d] text-white hover:bg-[#cf1f29]
```
### 卡片组件
```tsx
// 基础结构
<Card title="标题" icon="ri-icon-name" className="additional-classes">
<div className="card-body"></div>
</Card>
// 样式规范
.card: bg-white rounded-lg shadow overflow-hidden
.card-header: px-5 py-4 border-b border-gray-100
.card-title: text-base font-medium text-gray-900
```
### 布局组件
```tsx
// 侧边栏宽度
.sidebar: w-[280px] /* 展开状态 */
.sidebar.collapsed: w-20 /* 收缩状态 */
// 主内容区适配
.main-content: ml-[280px] /* 对应侧边栏宽度 */
.main-content.sidebar-collapsed: ml-20
```
## 📝 代码规范
### TypeScript规范
```typescript
// 接口命名使用PascalCase
interface DocumentUI {
id: string;
name: string;
status: ProcessingStatus;
}
// 类型联合使用字符串字面量
type ProcessingStatus = 'Waiting' | 'Cutting' | 'Extractioning' | 'Evaluationing' | 'Processed';
// 组件Props接口
interface ComponentProps {
children: React.ReactNode;
className?: string;
disabled?: boolean;
}
```
### React组件规范
```tsx
// 函数组件使用function声明
export function ComponentName({ prop1, prop2 }: ComponentProps) {
// useState放在顶部
const [state, setState] = useState<Type>(initialValue);
// useEffect按逻辑分组
useEffect(() => {
// 副作用逻辑
}, [dependencies]);
// 事件处理函数
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
// 处理逻辑
};
return (
<div className="component-container">
{/* JSX内容 */}
</div>
);
}
```
### 文件命名规范
- 组件文件: `PascalCase.tsx` (如: `Button.tsx`)
- 路由文件: `kebab-case.tsx` (如: `user-profile.tsx`)
- 样式文件: `kebab-case.css` (如: `button.css`)
- 工具文件: `camelCase.ts` (如: `utils.ts`)
### 导入顺序规范
```typescript
// 1. React相关
import React, { useState, useEffect } from 'react';
import { Link, useLoaderData } from '@remix-run/react';
// 2. 第三方库
import dayjs from 'dayjs';
// 3. 内部组件 (~/ 别名)
import { Card } from '~/components/ui/Card';
import { Button } from '~/components/ui/Button';
// 4. 类型定义
import type { DocumentUI } from '~/types/document';
// 5. 样式文件
import styles from '~/styles/components/component.css?url';
```
### CSS类命名规范
```css
/* BEM方法论 + 功能前缀 */
.sidebar-menu-item /* 组件-子元素-状态 */
.sidebar-menu-item.active /* 状态修饰符 */
.ant-btn-primary /* 组件库前缀 */
.text-primary /* 工具类 */
```
## 🎯 UI/UX设计规范
### 交互动效
```css
/* 统一过渡动效 */
.transition-all-ease: transition-all duration-200 ease-in-out;
/* 悬停效果 */
.sidebar-menu-item:hover: bg-[rgba(0,104,74,0.05)];
.card:hover: shadow-md;
```
### 状态指示
```typescript
// 文件处理状态
const statusConfig = {
"Waiting": { label: "上传中", icon: "ri-loader-line", color: "blue" },
"Cutting": { label: "切分中", icon: "ri-loader-line", color: "purple" },
"Extractioning": { label: "抽取中", icon: "ri-loader-line", color: "cyan" },
"Evaluationing": { label: "评查中", icon: "ri-loader-line", color: "teal" },
"Processed": { label: "已完成", icon: "ri-check-line", color: "green" }
};
```
### 图标使用规范 (RemixIcon 本地化)
```tsx
// 基本图标使用
<i className="ri-home-line"></i>
<i className="ri-file-list-3-line"></i>
<i className="ri-user-line"></i>
// 图标尺寸控制
<i className="ri-home-line ri-lg"></i> // 大图标
<i className="ri-home-line ri-xl"></i> // 特大图标
<i className="ri-home-line ri-2x"></i> // 2倍大小
// 结合样式使用
<i className="ri-error-warning-line text-red-500"></i>
<i className="ri-check-line text-green-600"></i>
<i className="ri-information-line text-blue-500"></i>
// 在按钮组件中使用
<Button icon="ri-add-line"></Button>
<Button icon="ri-edit-line"></Button>
<Button icon="ri-delete-bin-line"></Button>
```
**图标系统特点:**
- **本地化部署**: 字体文件已复制到 `public/fonts/` 目录,无外网依赖
- **预加载优化**: 通过 `<link rel="preload">` 确保首次访问图标立即显示
- **完整支持**: 包含3000+图标,支持所有RemixIcon官方图标
- **性能优化**: 启用 `font-display: swap` 提供更好的加载体验
- **浏览器兼容**: 支持IE9+到最新浏览器
**⚠️ 重要注意事项 - 避免样式冲突:**
在使用CSS样式隔离(如 `all: unset` 或强制 `font-family: inherit`)时,必须为RemixIcon图标添加例外规则:
```css
/* 错误示例 - 会导致图标不显示 */
.my-isolated-container * {
font-family: inherit !important; /* 这会覆盖图标字体 */
}
/* 正确示例 - 添加图标例外规则 */
.my-isolated-container * {
font-family: inherit !important;
}
.my-isolated-container [class^="ri-"],
.my-isolated-container [class*=" ri-"],
.my-isolated-container i[class^="ri-"],
.my-isolated-container i[class*=" ri-"] {
font-family: 'remixicon' !important;
font-style: normal !important;
font-weight: normal !important;
font-variant: normal !important;
text-transform: none !important;
line-height: 1 !important;
-webkit-font-smoothing: antialiased !important;
-moz-osx-font-smoothing: grayscale !important;
speak: none !important;
}
```
**常见问题排查:**
1. **图标显示为方块或问号**: 检查是否有CSS规则覆盖了 `font-family: 'remixicon'`
2. **只在特定页面不显示**: 检查该页面是否使用了样式隔离规则
3. **首次加载不显示**: 确认字体预加载配置正确
4. **部分图标不显示**: 检查CSS选择器优先级和 `!important` 使用
### 国际化和本地化
- 界面语言: 简体中文为主
- 日期格式: `YYYY年MM月DD日`
- 时间格式: `HH:mm:ss`
- 数字格式: 使用中文习惯 (如: 万、千)
## 🔧 工具配置规范
### ESLint配置要点
```javascript
// 启用的规则
- "plugin:react/recommended"
- "plugin:react-hooks/recommended"
- "plugin:@typescript-eslint/recommended"
- "plugin:jsx-a11y/recommended"
```
### Tailwind配置扩展
```typescript
// tailwind.config.ts
theme: {
extend: {
colors: {
primary: {
DEFAULT: '#1677ff', // 保持Remix默认,实际使用CSS变量覆盖
// ... 其他色阶
}
}
}
}
```
## 📊 性能优化规范
### 组件优化
```typescript
// 使用React.memo对纯组件优化
export const MemoizedComponent = React.memo(Component);
// 防抖工具函数使用
import { debounce } from '~/utils';
const debouncedHandler = debounce(handler, 300);
```
### 资源加载
```typescript
// 样式文件异步加载
import styles from '~/styles/component.css?url';
// 组件懒加载
const LazyComponent = lazy(() => import('~/components/LazyComponent'));
```
## 🚨 错误处理规范
### 错误边界
```tsx
// 统一错误处理组件
<AppErrorBoundary
status={500}
statusText="服务器错误"
message="服务器发生了意外错误,请稍后重试"
/>
```
### API错误处理
```typescript
// 统一的API响应处理
if (response.error) {
console.error('API错误:', response.error);
return Response.json(
{ error: response.error },
{ status: response.status || 500 }
);
}
```
## 📱 响应式设计规范
### 断点使用
- `sm`: 640px+ (移动端横屏)
- `md`: 768px+ (平板)
- `lg`: 1024px+ (桌面)
- `xl`: 1280px+ (大屏)
### 适配策略
```css
/* 移动端优先 */
.content-container: p-5;
@screen sm: .content-container: p-6;
/* 侧边栏响应式 */
@screen md: .sidebar-toggle: block;
```
## 🔍 可访问性规范
### 语义化HTML
```tsx
// 使用语义化标签
<main className="main-content">
<nav className="breadcrumb" aria-label="页面导航">
<section className="content-section">
```
### 键盘导航
```tsx
// 确保所有交互元素可键盘访问
<button
onKeyDown={(e) => e.key === 'Enter' && handleClick()}
aria-label="操作按钮"
>
```
## 📋 开发流程规范
### 组件开发流程
1. 创建组件TypeScript接口定义
2. 实现组件逻辑 (`.tsx`)
3. 编写组件样式 (`.css`)
4.`main.css`中导入样式
5. 编写使用示例
6. 进行测试验证
### 页面开发流程
1.`routes/`下创建路由文件
2. 定义`loader`函数处理数据获取
3. 实现页面组件
4. 配置`meta``links`
5. 添加错误处理
6. 测试页面功能
## 🚀 部署和构建规范
### 环境变量
```typescript
// 生产环境配置
NODE_ENV=production
DATABASE_URL=...
SESSION_SECRET=...
```
### 构建优化
```typescript
// vite.config.ts优化配置
export default defineConfig({
plugins: [remix(), tsconfigPaths()],
build: {
rollupOptions: {
// 代码分割优化
}
}
});
```
---
## 📚 开发检查清单
### 新组件开发
- [ ] TypeScript接口定义完整
- [ ] 样式使用设计系统颜色
- [ ] 支持必要的props (className, disabled等)
- [ ] 添加适当的过渡动效
- [ ] 编写组件文档注释
### 新页面开发
- [ ] loader函数处理数据和错误
- [ ] meta信息配置完整
- [ ] 响应式设计适配
- [ ] 错误边界处理
- [ ] 面包屑导航配置
### 代码提交前
- [ ] ESLint检查通过
- [ ] TypeScript编译无错误
- [ ] 样式符合设计系统
- [ ] 组件功能测试完成
- [ ] 代码注释清晰完整
---
*此规范将根据项目发展持续更新和完善。*
+168
View File
@@ -0,0 +1,168 @@
# 中国烟草AI合同及卷宗审核系统 - 图标优化解决方案
## 🎯 问题描述
### 原始问题
1. **首次访问图标不显示**: 由于RemixIcon字体文件加载时机问题,首次访问页面时图标可能不显示,需要刷新页面才能正常显示
2. **内网部署资源依赖**: 系统将部署到无法访问外网的内网环境,需要将所有外部资源本地化
### 技术原因分析
- **字体加载延迟**: 图标字体文件较大(173KB-2.6MB),首次加载时可能在页面渲染后才完成
- **缓存机制**: 浏览器第二次访问时字体文件已被缓存,因此图标能正常显示
- **外部依赖**: 原本通过`import "remixicon/fonts/remixicon.css"`从npm包导入
## ✅ 解决方案实施
### 1. 字体文件本地化
```bash
# 复制字体文件到public目录
cp node_modules/remixicon/fonts/remixicon.* public/fonts/
```
**复制的文件清单:**
- `remixicon.woff2` (173KB) - 现代浏览器优先格式
- `remixicon.woff` (237KB) - 广泛兼容格式
- `remixicon.ttf` (557KB) - 系统字体备选
- `remixicon.eot` (557KB) - IE浏览器支持
- `remixicon.svg` (2.6MB) - 旧版浏览器备选
### 2. CSS文件本地化
创建 `app/styles/remixicon-local.css`,包含:
```css
@font-face {
font-family: "remixicon";
src: url('/fonts/remixicon.eot'); /* IE9*/
src: url('/fonts/remixicon.eot#iefix') format('embedded-opentype'),
url("/fonts/remixicon.woff2") format("woff2"),
url("/fonts/remixicon.woff") format("woff"),
url('/fonts/remixicon.ttf') format('truetype'),
url('/fonts/remixicon.svg#remixicon') format('svg');
font-display: swap; /* 优化字体加载显示策略 */
}
```
**关键优化:**
- 使用本地路径 `/fonts/` 替代相对路径
- 启用 `font-display: swap` 提供更好的加载体验
- 包含完整的3000+图标定义
### 3. 字体预加载优化
`app/root.tsx``links` 函数中添加:
```typescript
export function links() {
return [
{ rel: "stylesheet", href: styles },
{ rel: "stylesheet", href: remixiconStyles }, // 本地化CSS
// 预加载关键字体文件,解决首次加载问题
{ rel: "preload", href: "/fonts/remixicon.woff2", as: "font", type: "font/woff2", crossOrigin: "anonymous" },
{ rel: "preload", href: "/fonts/remixicon.woff", as: "font", type: "font/woff", crossOrigin: "anonymous" },
// ... 其他资源
];
}
```
**预加载优势:**
- 确保字体文件在页面渲染前开始下载
- 优先加载最重要的格式(woff2, woff
- 使用 `crossOrigin="anonymous"` 确保跨域兼容性
### 4. 移除外部依赖
修改 `app/root.tsx`:
```typescript
// 注释掉原始导入
// import "remixicon/fonts/remixicon.css";
// 使用本地化版本
import remixiconStyles from "~/styles/remixicon-local.css?url";
```
## 📊 性能对比
### 优化前
- ❌ 首次加载图标不显示
- ❌ 依赖外部npm包资源
- ❌ 字体文件加载时机随机
- ❌ 无法在内网环境部署
### 优化后
- ✅ 首次加载图标立即显示
- ✅ 完全本地化,无外部依赖
- ✅ 字体文件预加载,确保及时可用
- ✅ 支持内网部署
- ✅ 更好的字体加载策略(font-display: swap
## 🔧 技术实现细节
### 字体加载优化策略
1. **格式优先级**: woff2 > woff > ttf > eot > svg
2. **预加载顺序**: 优先加载woff2和woff格式,覆盖99%+现代浏览器
3. **fallback机制**: 提供完整的格式支持链,确保兼容性
### 浏览器兼容性
- **Chrome/Firefox/Safari**: woff2 格式(最优)
- **IE 11+**: woff 格式
- **IE 9-10**: eot 格式
- **旧版移动浏览器**: svg 格式备选
### 内网部署优化
- 所有资源路径使用绝对路径 `/fonts/`
- 无CDN或外部API依赖
- 可通过nginx/Apache等静态服务器直接托管
## 📋 部署检查清单
### 服务器配置
- [ ] 确保 `public/fonts/` 目录在生产环境中可访问
- [ ] 配置正确的MIME类型支持字体文件:
```nginx
location ~* \.(woff2|woff|ttf|eot|svg)$ {
add_header Access-Control-Allow-Origin *;
expires 1y;
add_header Cache-Control "public, immutable";
}
```
### 性能优化建议
- [ ] 启用gzip压缩减少传输大小(可减少60-80%)
- [ ] 设置合适的缓存策略(建议1年)
- [ ] 如需减小部署包,可删除不需要的字体格式
### 可选优化
```bash
# 仅保留现代浏览器格式(可减少80%+文件大小)
rm public/fonts/remixicon.ttf
rm public/fonts/remixicon.eot
rm public/fonts/remixicon.svg
```
## 🚀 效果验证
使用提供的检查脚本验证优化效果:
```bash
powershell -ExecutionPolicy Bypass -File "scripts/check-local-resources.ps1"
```
**预期结果:**
- ✅ 所有字体文件本地化完成
- ✅ 无外部依赖检测
- ✅ 构建成功
- ✅ 系统准备好内网部署
## 📝 维护说明
### 版本更新
当需要更新RemixIcon版本时:
1. 更新 `package.json` 中的 remixicon 版本
2. 重新复制字体文件到 `public/fonts/`
3. 更新 `app/styles/remixicon-local.css` 中的图标定义
### 监控建议
- 定期检查字体文件加载性能
- 监控首屏渲染时间
- 验证图标在不同浏览器中的显示效果
---
**总结**: 通过字体本地化、预加载优化和加载策略改进,成功解决了图标首次加载问题,并确保系统可在内网环境稳定部署。
+789
View File
@@ -0,0 +1,789 @@
## 🗄️ PostgreSQL数据对接规范
### 概述
本系统使用PostgreSQL作为主数据库,通过PostgREST提供RESTful API接口。所有数据操作统一通过封装的`postgrest-client.ts`模块进行,确保数据访问的一致性和可维护性。
### 技术架构
```
前端组件 (Remix Routes)
API层 (app/api/[module]/[api].ts)
PostgREST客户端 (postgrest-client.ts)
PostgreSQL数据库
```
### 基础封装方法
#### 1. 导入PostgREST客户端
```typescript
import {
postgrestGet,
postgrestPost,
postgrestPut,
postgrestDelete,
type PostgrestParams
} from "../postgrest-client";
```
#### 2. 查询操作 (GET)
```typescript
// 基础查询
const params: PostgrestParams = {
select: '*',
filter: {
'id': `eq.${id}`,
'status': `eq.active`
},
order: 'created_at.desc',
limit: 20,
offset: 0
};
const response = await postgrestGet('table_name', params);
// 复杂查询示例
const complexParams: PostgrestParams = {
select: 'id,name,status,created_at,user_id,users(name)', // 关联查询
filter: {
'created_at': `gte.2023-01-01`,
'status': `in.(active,pending)`
},
or: 'name.ilike.*keyword*,description.ilike.*keyword*', // OR条件
order: 'created_at.desc,name.asc',
limit: 50
};
const response = await postgrestGet('documents', complexParams);
```
#### 3. 创建操作 (POST)
```typescript
// 单条记录创建
const newRecord = {
name: '文档名称',
document_number: 'DOC001',
type_id: 1,
user_id: 123,
status: 'pending'
};
const response = await postgrestPost('documents', newRecord);
// 批量创建
const batchRecords = [
{ name: '文档1', type_id: 1 },
{ name: '文档2', type_id: 2 }
];
const response = await postgrestPost('documents', batchRecords);
```
#### 4. 更新操作 (PUT/PATCH)
```typescript
// 根据ID更新
const updateData = {
status: 'completed',
audit_status: 1,
updated_at: new Date().toISOString()
};
const response = await postgrestPut(
'documents',
updateData,
{ id: documentId }
);
// 根据条件批量更新
const response = await postgrestPut(
'evaluation_results',
{ status: 'reviewed' },
{ document_id: docId, status: 'pending' }
);
```
#### 5. 删除操作 (DELETE)
```typescript
// 根据ID删除
const response = await postgrestDelete('documents', {
filter: { 'id': `eq.${id}` }
});
// 条件删除
const response = await postgrestDelete('temp_files', {
filter: { 'created_at': `lt.2023-01-01` }
});
```
### API层实现规范
#### 1. 文件结构
```
app/api/
├── [module]/ # 功能模块
│ ├── [feature].ts # 具体功能API
│ └── types.ts # 类型定义 (可选)
├── postgrest-client.ts # PostgREST客户端
├── axios-client.ts # HTTP客户端
└── error-handler.ts # 错误处理
```
#### 2. API文件模板
```typescript
// app/api/evaluation_points/reviews.ts
import { postgrestGet, type PostgrestParams, postgrestPut, postgrestPost } from "../postgrest-client";
import { getDocument } from "~/api/files/documents";
import { formatDate } from "~/utils";
/**
* 数据提取工具函数
* 统一处理不同格式的API响应
*/
function extractApiData<T>(responseData: unknown): T | null {
if (!responseData) return null;
// 格式1: { code: number, msg: string, data: T }
if (typeof responseData === 'object' && responseData !== null &&
'code' in responseData &&
'data' in responseData &&
(responseData as { data: unknown }).data) {
return (responseData as { data: T }).data;
}
// 格式2: 直接是数据对象
return responseData as T;
}
// 类型定义
interface EvaluationResult {
id: string | number;
document_id: string | number;
evaluation_point_id: string | number;
evaluated_results?: {
result?: boolean;
message?: string;
data?: string;
[key: string]: unknown;
};
[key: string]: unknown;
}
interface EvaluationPoint {
id: string | number;
evaluation_point_groups_id: string | number;
suggestion_message_type?: string;
suggestion_message?: string;
score?: number;
updated_at?: string;
[key: string]: unknown;
}
// 前端使用的结果类型
interface ReviewPointResult {
id: string | number;
title: string;
groupName: string;
status: string;
content: string;
suggestion: string;
result?: boolean;
score: number;
}
/**
* 获取评查点数据
* @param fileId 文件ID
* @returns 评查点结果列表和统计数据
*/
export async function getReviewPoints(fileId: string) {
try {
// 步骤1: 获取文档基础数据
const documentData = await getDocument(fileId);
if (documentData.error) {
return { error: documentData.error, status: documentData.status || 500 };
}
// 步骤2: 查询评查结果
const evaluationResultsParams: PostgrestParams = {
select: '*',
filter: { 'document_id': `eq.${fileId}` }
};
const evaluationResultsResponse = await postgrestGet('evaluation_results', evaluationResultsParams);
if (evaluationResultsResponse.error) {
return { error: evaluationResultsResponse.error, status: evaluationResultsResponse.status };
}
const evaluationResultsData = extractApiData<EvaluationResult[]>(evaluationResultsResponse.data) || [];
if (evaluationResultsData.length <= 0) {
return {
data: [],
stats: { total: 0, success: 0, warning: 0, error: 0, score: 0 },
error: '获取评查结果数据失败'
};
}
// 步骤3: 获取评查点详情
const evaluationPointIds = evaluationResultsData.map(item => item.evaluation_point_id).filter(Boolean);
const evaluationPointsParams: PostgrestParams = {
select: '*',
filter: { 'id': `in.(${evaluationPointIds.join(',')})` }
};
const evaluationPointsResponse = await postgrestGet('evaluation_points', evaluationPointsParams);
if (evaluationPointsResponse.error) {
return { error: evaluationPointsResponse.error, status: evaluationPointsResponse.status };
}
const evaluationPointsData = extractApiData<EvaluationPoint[]>(evaluationPointsResponse.data) || [];
// 步骤4: 数据处理和转换
const resultData: ReviewPointResult[] = evaluationResultsData.map(result => {
const point = evaluationPointsData.find(p => p.id === result.evaluation_point_id);
return {
id: result.id,
title: result.evaluated_results?.message || '',
groupName: point?.group_name || '',
status: point?.suggestion_message_type || '',
content: result.evaluated_results?.data || '',
suggestion: point?.suggestion_message || '',
result: result.evaluated_results?.result,
score: point?.score || 0
};
});
// 步骤5: 统计数据计算
const stats = {
total: resultData.length,
success: resultData.filter(item => item.result === true).length,
warning: resultData.filter(item => item.status === 'warning').length,
error: resultData.filter(item => item.status === 'error').length,
score: resultData.reduce((sum, item) => sum + item.score, 0)
};
return {
data: resultData,
stats,
document: documentData.data
};
} catch (error) {
console.error('获取评查数据失败:', error);
return {
error: error instanceof Error ? error.message : '获取评查数据失败',
status: 500
};
}
}
/**
* 更新评查结果
* @param resultId 评查结果ID
* @param editAuditStatusId 审核状态ID
* @param result 评查结果
* @param message 评查意见
*/
export async function updateReviewResult(
resultId: string,
editAuditStatusId: string | number,
result: string,
message: string
): Promise<{
data?: unknown;
error?: string;
status?: number;
}> {
try {
if (!resultId) {
return { error: '评查结果ID不能为空', status: 400 };
}
// 获取当前数据
const currentResultResponse = await postgrestGet('evaluation_results', {
select: '*',
filter: { id: `eq.${resultId}` }
});
if (currentResultResponse.error) {
return { error: currentResultResponse.error, status: currentResultResponse.status };
}
const currentResultData = extractApiData<EvaluationResult[]>(currentResultResponse.data);
if (!currentResultData || currentResultData.length === 0) {
return { error: '未找到评查结果数据', status: 404 };
}
const currentResult = currentResultData[0];
const currentEvaluatedResults = currentResult.evaluated_results || {};
// 构建更新数据
const isReview = result === 'review';
const updatedEvaluatedResults = {
...currentEvaluatedResults,
...(isReview ? { message } : { result: result === 'true', message }),
};
// 更新评查结果
const resultResponse = await postgrestPut(
'evaluation_results',
{ evaluated_results: updatedEvaluatedResults },
{ id: resultId }
);
if (resultResponse.error) {
return { error: resultResponse.error, status: resultResponse.status };
}
// 处理审核状态
const editAuditStatusValue = isReview ? 0 : 1;
if (editAuditStatusId && editAuditStatusId !== '') {
// 更新现有记录
const auditStatusResponse = await postgrestPut(
'audit_status',
{ edit_audit_status: editAuditStatusValue },
{ id: editAuditStatusId }
);
if (auditStatusResponse.error) {
return { error: auditStatusResponse.error, status: auditStatusResponse.status };
}
} else {
// 创建新记录
const newAuditStatus = {
document_id: currentResult.document_id,
evaluation_point_id: currentResult.evaluation_point_id,
evaluation_result_id: resultId,
edit_audit_status: editAuditStatusValue
};
const postResponse = await postgrestPost('audit_status', newAuditStatus);
if (postResponse.error) {
return { error: postResponse.error, status: postResponse.status };
}
}
return { data: extractApiData<unknown>(resultResponse.data) };
} catch (error) {
console.error('更新评查结果失败:', error);
return {
error: error instanceof Error ? error.message : '更新评查结果失败',
status: 500
};
}
}
```
#### 3. 错误处理规范
```typescript
// 统一的错误处理格式
interface ApiResponse<T> {
data?: T;
error?: string;
status?: number;
}
// 在API函数中的错误处理
export async function apiFunction(): Promise<ApiResponse<DataType>> {
try {
const response = await postgrestGet('table_name', params);
if (response.error) {
return {
error: response.error,
status: response.status
};
}
const data = extractApiData<DataType>(response.data);
if (!data) {
return {
error: '数据格式错误',
status: 500
};
}
return { data };
} catch (error) {
console.error('API调用失败:', error);
return {
error: error instanceof Error ? error.message : '未知错误',
status: 500
};
}
}
```
### 前端路由集成规范
#### 1. Loader函数中调用API
```typescript
// app/routes/reviews.tsx
import { getReviewPoints, updateReviewResult, confirmReviewResults } from "~/api/evaluation_points/reviews";
export async function loader({ request }: LoaderFunctionArgs) {
try {
const url = new URL(request.url);
const id = url.searchParams.get('id') || undefined;
const previousRoute = url.searchParams.get('previousRoute') || '';
if (!id) {
return Response.json({ result: false, message: '文件ID不能为空' });
}
// 获取评查点数据
const reviewData = await getReviewPoints(id);
if ('error' in reviewData && reviewData.error) {
console.error("获取评查点数据错误:", reviewData.error);
return Response.json({ result: false, message: reviewData.error });
}
// 确保数据格式正确
if ('document' in reviewData && 'data' in reviewData && 'reviewInfo' in reviewData && 'stats' in reviewData) {
return Response.json({
previousRoute: previousRoute,
document: reviewData.document,
reviewPoints: reviewData.data,
reviewInfo: reviewData.reviewInfo,
statistics: reviewData.stats
});
} else {
console.error("返回的评查数据格式不正确", JSON.stringify(reviewData, null, 2));
return Response.json({ result: false, message: '返回的评查数据格式不正确' });
}
} catch (error) {
console.error('获取评查数据失败:', error);
return Response.json({ result: false, message: '获取评查数据失败' });
}
}
```
#### 2. 组件中处理API响应
```typescript
// 在React组件中使用
export default function ReviewDetails() {
const loaderData = useLoaderData<typeof loader>();
const { document, reviewPoints, statistics, reviewInfo } = loaderData;
const [isLoading, setIsLoading] = useState(false);
// 处理loader错误
useEffect(() => {
if (Object.keys(loaderData).find(key => key === 'result') && !loaderData.result) {
messageService.show({
title: '错误',
message: loaderData.message,
type: 'error',
confirmText: '确定',
onConfirm: () => {
navigate(-1);
}
});
}
}, [loaderData, navigate]);
// 处理状态更新
const handleReviewPointStatusChange = async (
reviewPointResultId: string,
editAuditStatusId: string | number,
newStatus: string,
message: string
) => {
try {
const response = await updateReviewResult(
reviewPointResultId,
editAuditStatusId,
newStatus,
message
);
if (response.error) {
console.error('更新评查结果失败:', response.error);
toastService.error(`更新评查结果失败: ${response.error}`);
return;
}
// 更新本地状态
setReviewData(prevData => {
// 更新逻辑...
});
toastService.success('评查点状态已更新');
} catch (error) {
console.error('更新评查结果出错:', error);
toastService.error('更新评查结果失败,请稍后重试');
}
};
}
```
### 数据类型定义规范
#### 1. 数据库实体类型
```typescript
// 数据库表对应的接口
interface Document {
id: number;
user_id: number | null;
type_id: number;
name: string;
document_number: string;
path: string;
storage_type: string;
file_size: number;
upload_time: string;
is_test_document: boolean;
evaluation_level: string;
status: 'pass' | 'warning' | 'waiting' | 'processing' | 'fail';
file_status: 'Waiting' | 'Cutting' | 'Extractioning' | 'Evaluationing' | 'Processed';
audit_status: number; // -1: 不通过, 0: 待审核, 1: 通过, 2: 警告, 3: 审核中
ocr_result?: Record<string, unknown>;
extracted_results?: unknown;
summary?: unknown;
remark?: string;
created_at: string;
updated_at: string;
}
```
#### 2. 前端UI类型
```typescript
// 前端组件使用的接口
interface ReviewFileUI {
id: string;
status: string;
path: string;
fileName: string;
fileCode: string;
fileType: string;
fileTypeId: number;
fileSize: number;
uploadTime: string;
reviewStatus: string;
reviewStatusCode: number;
issueCount: number;
score?: number;
auditStatus: number | null;
issues: Array<{
severity: 'info' | 'warning' | 'error' | 'critical';
message: string;
}>;
createdBy: string;
passCount: number;
warningCount: number;
failCount: number;
manualCount: number;
}
```
#### 3. API参数类型
```typescript
// 搜索参数类型
interface DocumentSearchParams {
keyword?: string;
status?: string;
fileType?: string;
dateRange?: [string, string];
page?: number;
pageSize?: number;
sortBy?: string;
sortOrder?: 'asc' | 'desc';
}
```
### PostgREST客户端功能
#### 1. 查询参数转换
```typescript
// 转换通用参数为PostgREST格式
export function transformParams(params: PostgrestParams): QueryParams {
const result: QueryParams = {};
// 处理select参数
if (params.select) {
result.select = params.select;
}
// 处理过滤条件
if (params.filter) {
Object.entries(params.filter).forEach(([key, value]) => {
if (value !== undefined) {
result[key] = value as string | number | boolean;
}
});
}
// 处理排序
if (params.order) {
result.order = params.order;
}
// 处理分页
if (params.limit !== undefined) {
result.limit = params.limit;
}
if (params.offset !== undefined) {
result.offset = params.offset;
}
// 处理OR条件
if (params.or) {
if (typeof params.or === 'string') {
result.or = params.or;
} else if (Array.isArray(params.or)) {
const orConditions = params.or.map(condition => {
const [field, operator] = Object.entries(condition)[0];
return `${field}.${operator}`;
});
result.or = `(${orConditions.join(',')})`;
}
}
return result;
}
```
#### 2. 开发环境日志
```typescript
function logPostgrestQuery(endpoint: string, params?: QueryParams, method: string = 'GET'): void {
if (process.env.NODE_ENV !== 'production') {
const baseUrl = 'http://nas.7bm.co:3000';
const normalizedEndpoint = endpoint.startsWith('/') ? endpoint.substring(1) : endpoint;
console.log('\n📦 PostgREST 查询日志 ======================start=============');
console.log(`📦 HTTP 方法: ${method}`);
console.log(`📦 API 端点: ${decodeUrlForDisplay(`${baseUrl}/${normalizedEndpoint}`)}`);
if (params && Object.keys(params).length > 0) {
console.log('📦 查询参数:');
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined) {
console.log(` - ${key}: ${JSON.stringify(value)}`);
}
});
}
console.log('PostgREST 查询日志=============================end============\n');
}
}
```
#### 3. 数据预处理
```typescript
function preprocessData(data: Record<string, unknown>): Record<string, unknown> {
const processed: Record<string, unknown> = {};
for (const [key, value] of Object.entries(data)) {
// 处理null值
if (value === null) {
processed[key] = null;
continue;
}
// 处理布尔值字符串
if (typeof value === 'string' && (value.toLowerCase() === 'true' || value.toLowerCase() === 'false')) {
processed[key] = value.toLowerCase() === 'true';
}
// 处理ID字段
else if ((key === 'id' || key.endsWith('_id') || key === 'pid') && value !== undefined) {
try {
const numValue = Number(value);
if (!isNaN(numValue)) {
processed[key] = numValue;
} else {
processed[key] = value;
}
} catch {
processed[key] = value;
}
}
// 其他值保持不变
else {
processed[key] = value;
}
}
return processed;
}
```
### 开发最佳实践
#### 1. API命名规范
- 获取数据: `get[EntityName]s()``get[EntityName]()`
- 创建数据: `create[EntityName]()`
- 更新数据: `update[EntityName]()`
- 删除数据: `delete[EntityName]()`
#### 2. 错误处理策略
- 所有API函数必须返回统一的响应格式
- 在API层处理数据库错误,前端只处理业务逻辑错误
- 使用TypeScript严格类型检查避免运行时错误
#### 3. 性能优化
- 使用`select`参数只获取需要的字段
- 合理使用`limit``offset`进行分页
- 避免N+1查询,使用关联查询获取相关数据
#### 4. 调试技巧
- 开发环境自动打印查询日志
- 使用浏览器网络面板检查实际请求
- 通过PostgREST文档验证查询语法
### 数据对接检查清单
#### 新功能开发前
- [ ] 确认数据库表结构和关系
- [ ] 定义TypeScript接口类型
- [ ] 规划API函数命名和参数
- [ ] 设计错误处理策略
#### API实现阶段
- [ ] 导入必要的postgrest方法
- [ ] 实现extractApiData数据提取函数
- [ ] 定义所有相关的类型接口
- [ ] 编写查询参数构建逻辑
- [ ] 实现数据转换和处理逻辑
- [ ] 添加完整的错误处理
#### 前端集成阶段
- [ ] 在loader函数中调用API
- [ ] 处理loading和error状态
- [ ] 实现UI状态更新逻辑
- [ ] 添加用户反馈(toast/message)
- [ ] 测试各种边界情况
#### 上线前检查
- [ ] API函数测试通过
- [ ] 错误处理验证完成
- [ ] 性能测试满足要求
- [ ] 日志输出正常
- [ ] 代码注释完整
---
*此数据对接规范基于实际项目经验总结,将根据项目发展持续更新和完善。*
Binary file not shown.
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 2.6 MiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.
Binary file not shown.
Binary file not shown.