1724 lines
44 KiB
Markdown
1724 lines
44 KiB
Markdown
# getHomeData 首页数据统计完整逻辑文档
|
||
|
||
## 文档信息
|
||
- **文件路径**: `app/api/home/home.ts`
|
||
- **主函数**: `getHomeData()`
|
||
- **创建日期**: 2025-01-17
|
||
- **文档版本**: 2.1
|
||
- **更新说明**: 改用 typeIds 参数直接指定文档类型 ID
|
||
|
||
---
|
||
|
||
## 目录
|
||
1. [函数概述](#函数概述)
|
||
2. [函数签名与返回值](#函数签名与返回值)
|
||
3. [业务逻辑流程](#业务逻辑流程)
|
||
4. [时间范围计算](#时间范围计算)
|
||
5. [数据查询详解](#数据查询详解)
|
||
6. [数据库例程详解](#数据库例程详解)
|
||
7. [增长率计算公式](#增长率计算公式)
|
||
8. [PostgREST 请求示例](#postgrest-请求示例)
|
||
9. [完整代码流程图](#完整代码流程图)
|
||
10. [错误处理机制](#错误处理机制)
|
||
11. [性能优化建议](#性能优化建议)
|
||
|
||
---
|
||
|
||
## 函数概述
|
||
|
||
`getHomeData()` 是首页数据统计的核心函数,用于获取以下 6 项关键业务指标:
|
||
|
||
1. **今日待审核文件数** - 统计今天创建的待审核文件
|
||
2. **本月已审核文件数** - 统计本月完成审核的文件总数
|
||
3. **审核文件同比增长** - 与上月对比的增长百分比
|
||
4. **本月审核通过率** - 本月通过文件数 / 本月已审核文件数
|
||
5. **通过率同比增长** - 与上月通过率对比的增长百分比
|
||
6. **检测到的问题总数** - 从评估结果中统计问题数量
|
||
7. **问题数量同比增长** - 与上月问题数量对比的增长百分比
|
||
|
||
---
|
||
|
||
## 函数签名与返回值
|
||
|
||
### 函数签名
|
||
|
||
```typescript
|
||
export async function getHomeData(
|
||
typeIds?: number[] | number | null, // 文档类型 ID 数组或单个 ID
|
||
userId?: string | number, // 用户 ID
|
||
token?: string // JWT Token
|
||
): Promise<HomeStatistics>
|
||
```
|
||
|
||
### 参数说明
|
||
|
||
| 参数 | 类型 | 必填 | 说明 |
|
||
|------|------|------|------|
|
||
| `typeIds` | `number[] \| number \| null` | 否 | 文档类型 ID 筛选:<br>- 单个 ID: `1` (筛选 type_id = 1)<br>- ID 数组: `[2, 3]` (筛选 type_id = 2 或 3)<br>- `null` 或 `undefined`: 不筛选类型 |
|
||
| `userId` | `string \| number` | 是 | 当前用户的 ID,用于过滤用户数据 |
|
||
| `token` | `string` | 是 | JWT 认证令牌 |
|
||
|
||
### 返回值结构
|
||
|
||
```typescript
|
||
interface HomeStatistics {
|
||
todayPendingFiles: number; // 今日待审核文件数
|
||
monthlyReviewedFiles: number; // 本月已审核文件数
|
||
monthlyReviewGrowth: { // 本月审核文件同比增长
|
||
value: number; // 增长百分比(绝对值)
|
||
isUp: boolean; // 是否增长(true: 增长, false: 下降)
|
||
};
|
||
monthlyPassRate: number; // 本月审核通过率(百分比)
|
||
passRateGrowth: { // 通过率同比增长
|
||
value: number;
|
||
isUp: boolean;
|
||
};
|
||
issuesDetected: number; // 检测到的问题总数
|
||
issuesGrowth: { // 问题数量同比增长
|
||
value: number;
|
||
isUp: boolean;
|
||
};
|
||
}
|
||
```
|
||
|
||
### 返回示例
|
||
|
||
```json
|
||
{
|
||
"todayPendingFiles": 15,
|
||
"monthlyReviewedFiles": 120,
|
||
"monthlyReviewGrowth": {
|
||
"value": 25.5,
|
||
"isUp": true
|
||
},
|
||
"monthlyPassRate": 85.3,
|
||
"passRateGrowth": {
|
||
"value": 3.2,
|
||
"isUp": true
|
||
},
|
||
"issuesDetected": 42,
|
||
"issuesGrowth": {
|
||
"value": 12.8,
|
||
"isUp": false
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 业务逻辑流程
|
||
|
||
### 主流程图
|
||
|
||
```
|
||
开始
|
||
│
|
||
├─→ 1. 计算时间范围
|
||
│ ├─ 今天开始时间 (startOfToday)
|
||
│ ├─ 本月开始/结束 (startOfThisMonth, endOfThisMonth)
|
||
│ └─ 上月开始/结束 (startOfLastMonth, endOfLastMonth)
|
||
│
|
||
├─→ 2. 构建类型过滤条件
|
||
│ ├─ 单个 ID (如 1) → type_id.eq.1
|
||
│ ├─ ID 数组 (如 [2,3]) → (type_id.eq.2,type_id.eq.3)
|
||
│ └─ null/undefined → 不过滤
|
||
│
|
||
├─→ 3. 查询今日待审核文件数
|
||
│ └─ 条件: audit_status IN (0,2,NULL) AND created_at >= 今天开始
|
||
│
|
||
├─→ 4. 查询本月已审核文件数
|
||
│ └─ 条件: audit_status NOT IN (0,2) AND upload_time >= 本月开始
|
||
│
|
||
├─→ 5. 查询上月已审核文件数
|
||
│ └─ 条件: audit_status NOT IN (0,2) AND upload_time BETWEEN 上月开始 AND 上月结束
|
||
│
|
||
├─→ 6. 计算审核文件同比增长
|
||
│ └─ 增长率 = (本月数量 - 上月数量) / 上月数量 * 100
|
||
│
|
||
├─→ 7. 查询本月审核通过数量
|
||
│ └─ 条件: audit_status = 1 AND created_at >= 本月开始
|
||
│
|
||
├─→ 8. 计算本月审核通过率
|
||
│ └─ 通过率 = 本月通过数量 / 本月已审核数量 * 100
|
||
│
|
||
├─→ 9. 查询上月审核通过数量
|
||
│ └─ 条件: audit_status = 1 AND upload_time BETWEEN 上月开始 AND 上月结束
|
||
│
|
||
├─→ 10. 计算上月审核通过率
|
||
│ └─ 通过率 = 上月通过数量 / 上月已审核数量 * 100
|
||
│
|
||
├─→ 11. 计算通过率同比增长
|
||
│ └─ 增长率 = (本月通过率 - 上月通过率) / 上月通过率 * 100
|
||
│
|
||
├─→ 12. 调用数据库函数统计问题数量
|
||
│ ├─ 本月问题数量 (RPC: count_evaluation_results_by_type)
|
||
│ └─ 上月问题数量 (RPC: count_evaluation_results_by_type)
|
||
│
|
||
├─→ 13. 计算问题数量同比增长
|
||
│ └─ 增长率 = (本月问题数 - 上月问题数) / 上月问题数 * 100
|
||
│
|
||
└─→ 14. 返回统计结果
|
||
└─ HomeStatistics 对象
|
||
```
|
||
|
||
---
|
||
|
||
## 时间范围计算
|
||
|
||
使用 `dayjs` 库进行时间计算,确保时区一致性。
|
||
|
||
### 代码实现
|
||
|
||
```typescript
|
||
import dayjs from 'dayjs';
|
||
|
||
// 今天开始时间 (00:00:00)
|
||
const startOfToday = dayjs().startOf('day').format('YYYY-MM-DD HH:mm:ss');
|
||
// 示例: '2025-01-17 00:00:00'
|
||
|
||
// 本月开始时间 (月初 00:00:00)
|
||
const startOfThisMonth = dayjs().startOf('month').format('YYYY-MM-DD HH:mm:ss');
|
||
// 示例: '2025-01-01 00:00:00'
|
||
|
||
// 本月结束时间 (月末 23:59:59)
|
||
const endOfThisMonth = dayjs().endOf('month').format('YYYY-MM-DD HH:mm:ss');
|
||
// 示例: '2025-01-31 23:59:59'
|
||
|
||
// 上月开始时间
|
||
const startOfLastMonth = dayjs().subtract(1, 'month').startOf('month').format('YYYY-MM-DD HH:mm:ss');
|
||
// 示例: '2024-12-01 00:00:00'
|
||
|
||
// 上月结束时间
|
||
const endOfLastMonth = dayjs().subtract(1, 'month').endOf('month').format('YYYY-MM-DD HH:mm:ss');
|
||
// 示例: '2024-12-31 23:59:59'
|
||
```
|
||
|
||
### 时间范围示例表
|
||
|
||
假设当前时间为 **2025-01-17 15:30:45**
|
||
|
||
| 时间变量 | 值 | 说明 |
|
||
|---------|-----|------|
|
||
| `startOfToday` | `2025-01-17 00:00:00` | 今天凌晨 |
|
||
| `startOfThisMonth` | `2025-01-01 00:00:00` | 本月第一天凌晨 |
|
||
| `endOfThisMonth` | `2025-01-31 23:59:59` | 本月最后一天 23:59:59 |
|
||
| `startOfLastMonth` | `2024-12-01 00:00:00` | 上月第一天凌晨 |
|
||
| `endOfLastMonth` | `2024-12-31 23:59:59` | 上月最后一天 23:59:59 |
|
||
|
||
---
|
||
|
||
## 数据查询详解
|
||
|
||
### 辅助函数:buildTypeFilter()
|
||
|
||
**功能**: 根据 `typeIds` 参数构建 PostgREST 类型过滤条件
|
||
|
||
```typescript
|
||
function buildTypeFilter(typeIds?: number[] | number | null): string {
|
||
if (!typeIds) {
|
||
return ''; // null 或 undefined 返回空字符串,表示不过滤
|
||
}
|
||
|
||
// 标准化为数组
|
||
const idsArray = Array.isArray(typeIds) ? typeIds : [typeIds];
|
||
|
||
if (idsArray.length === 0) {
|
||
return '';
|
||
}
|
||
|
||
if (idsArray.length === 1) {
|
||
// 单个 ID: type_id.eq.1
|
||
return `type_id.eq.${idsArray[0]}`;
|
||
} else {
|
||
// 多个 ID: (type_id.eq.2,type_id.eq.3)
|
||
return `(${idsArray.map(id => `type_id.eq.${id}`).join(',')})`;
|
||
}
|
||
}
|
||
```
|
||
|
||
**参数说明**:
|
||
- `typeIds`: 可以是单个数字、数字数组或 null/undefined
|
||
|
||
**返回值说明**:
|
||
- `1` → `'type_id.eq.1'`
|
||
- `[2, 3]` → `'(type_id.eq.2,type_id.eq.3)'`
|
||
- `null` / `undefined` → `''` (空字符串)
|
||
|
||
**使用示例**:
|
||
```typescript
|
||
buildTypeFilter(1); // 'type_id.eq.1'
|
||
buildTypeFilter([2, 3]); // '(type_id.eq.2,type_id.eq.3)'
|
||
buildTypeFilter([1, 2, 3]); // '(type_id.eq.1,type_id.eq.2,type_id.eq.3)'
|
||
buildTypeFilter(null); // ''
|
||
buildTypeFilter(undefined); // ''
|
||
```
|
||
|
||
---
|
||
|
||
### 查询 1: 今日待审核文件数
|
||
|
||
**业务逻辑**: 统计今天创建的、状态为待审核的文件数量
|
||
|
||
**审核状态说明**:
|
||
- `audit_status = 0`: 未审核
|
||
- `audit_status = 2`: 审核中
|
||
- `audit_status = NULL`: 未设置状态
|
||
|
||
**PostgREST 参数**:
|
||
|
||
```typescript
|
||
const todayPendingParams: PostgrestParams = {
|
||
select: 'count',
|
||
filter: {
|
||
or: `(audit_status.eq.0,audit_status.eq.2,audit_status.is.null)`,
|
||
created_at: `gte.${startOfToday}`,
|
||
is_test_document: `eq.false`,
|
||
user_id: `eq.${userId}`
|
||
}
|
||
};
|
||
|
||
// 如果有类型过滤
|
||
if (typeFilter) {
|
||
if (typeFilter.startsWith('(')) {
|
||
// OR 条件: (type_id.eq.2,type_id.eq.3)
|
||
todayPendingParams.filter.or = typeFilter + ',' + todayPendingParams.filter.or;
|
||
} else {
|
||
// 单一条件: type_id.eq.1
|
||
const [field, op, value] = typeFilter.split('.');
|
||
todayPendingParams.filter[field] = `${op}.${value}`;
|
||
}
|
||
}
|
||
```
|
||
|
||
**等价 SQL 查询**:
|
||
|
||
```sql
|
||
SELECT COUNT(*)
|
||
FROM documents
|
||
WHERE (audit_status = 0 OR audit_status = 2 OR audit_status IS NULL)
|
||
AND created_at >= '2025-01-17 00:00:00'
|
||
AND is_test_document = false
|
||
AND user_id = 123
|
||
AND type_id = 1; -- 如果 typeIds = 1
|
||
-- 或 (type_id = 2 OR type_id = 3); -- 如果 typeIds = [2, 3]
|
||
```
|
||
|
||
**实际 API 调用**:
|
||
|
||
```typescript
|
||
const todayPendingCount = await postgrestGet('documents', {
|
||
...todayPendingParams,
|
||
token
|
||
});
|
||
|
||
// 返回格式: [{ count: 15 }]
|
||
const todayPendingFiles = todayPendingCount[0]?.count || 0;
|
||
```
|
||
|
||
---
|
||
|
||
### 查询 2: 本月已审核文件数
|
||
|
||
**业务逻辑**: 统计本月上传的、已完成审核的文件数量(排除待审核和审核中)
|
||
|
||
**PostgREST 参数**:
|
||
|
||
```typescript
|
||
const thisMonthReviewedParams: PostgrestParams = {
|
||
select: 'count',
|
||
filter: {
|
||
and: `(audit_status.neq.0,audit_status.neq.2)`,
|
||
upload_time: `gte.${startOfThisMonth}`,
|
||
is_test_document: `eq.false`,
|
||
user_id: `eq.${userId}`
|
||
}
|
||
};
|
||
|
||
// 添加类型过滤条件
|
||
if (typeFilter) {
|
||
if (typeFilter.startsWith('(')) {
|
||
thisMonthReviewedParams.or = typeFilter;
|
||
} else {
|
||
const [field, op, value] = typeFilter.split('.');
|
||
thisMonthReviewedParams.filter[field] = `${op}.${value}`;
|
||
}
|
||
}
|
||
```
|
||
|
||
**等价 SQL 查询**:
|
||
|
||
```sql
|
||
SELECT COUNT(*)
|
||
FROM documents
|
||
WHERE audit_status != 0
|
||
AND audit_status != 2
|
||
AND upload_time >= '2025-01-01 00:00:00'
|
||
AND is_test_document = false
|
||
AND user_id = 123
|
||
AND type_id = 1; -- 根据 typeIds 过滤
|
||
```
|
||
|
||
**实际 API 调用**:
|
||
|
||
```typescript
|
||
const thisMonthReviewedCount = await postgrestGet('documents', {
|
||
...thisMonthReviewedParams,
|
||
token
|
||
});
|
||
|
||
// 返回格式: [{ count: 120 }]
|
||
const monthlyReviewedFiles = thisMonthReviewedCount[0]?.count || 0;
|
||
```
|
||
|
||
---
|
||
|
||
### 查询 3: 上月已审核文件数
|
||
|
||
**业务逻辑**: 统计上月的已审核文件数量,用于计算同比增长
|
||
|
||
**PostgREST 参数**:
|
||
|
||
```typescript
|
||
const lastMonthReviewedParams: PostgrestParams = {
|
||
select: 'count',
|
||
filter: {
|
||
and: `(upload_time.gte.${startOfLastMonth},upload_time.lte.${endOfLastMonth},audit_status.neq.0,audit_status.neq.2)`,
|
||
is_test_document: `eq.false`,
|
||
user_id: `eq.${userId}`
|
||
}
|
||
};
|
||
|
||
// 添加类型过滤条件
|
||
if (typeFilter) {
|
||
if (typeFilter.startsWith('(')) {
|
||
lastMonthReviewedParams.filter.or = typeFilter;
|
||
} else {
|
||
const [field, op, value] = typeFilter.split('.');
|
||
lastMonthReviewedParams.filter[field] = `${op}.${value}`;
|
||
}
|
||
}
|
||
```
|
||
|
||
**等价 SQL 查询**:
|
||
|
||
```sql
|
||
SELECT COUNT(*)
|
||
FROM documents
|
||
WHERE upload_time >= '2024-12-01 00:00:00'
|
||
AND upload_time <= '2024-12-31 23:59:59'
|
||
AND audit_status != 0
|
||
AND audit_status != 2
|
||
AND is_test_document = false
|
||
AND user_id = 123
|
||
AND type_id = 1;
|
||
```
|
||
|
||
**实际 API 调用**:
|
||
|
||
```typescript
|
||
const lastMonthReviewedCount = await postgrestGet('documents', {
|
||
...lastMonthReviewedParams,
|
||
token
|
||
});
|
||
|
||
// 返回格式: [{ count: 96 }]
|
||
const lastMonthReviewed = lastMonthReviewedCount[0]?.count || 0;
|
||
```
|
||
|
||
---
|
||
|
||
### 查询 4: 本月审核通过数量
|
||
|
||
**业务逻辑**: 统计本月审核通过的文件数量(`audit_status = 1`)
|
||
|
||
**PostgREST 参数**:
|
||
|
||
```typescript
|
||
const thisMonthTotalParams: PostgrestParams = {
|
||
select: 'count',
|
||
filter: {
|
||
audit_status: `eq.1`,
|
||
created_at: `gte.${startOfThisMonth}`,
|
||
is_test_document: `eq.false`,
|
||
user_id: `eq.${userId}`
|
||
}
|
||
};
|
||
|
||
// 添加类型过滤条件
|
||
if (typeFilter) {
|
||
if (typeFilter.startsWith('(')) {
|
||
thisMonthTotalParams.or = typeFilter;
|
||
} else {
|
||
const [field, op, value] = typeFilter.split('.');
|
||
thisMonthTotalParams.filter[field] = `${op}.${value}`;
|
||
}
|
||
}
|
||
```
|
||
|
||
**等价 SQL 查询**:
|
||
|
||
```sql
|
||
SELECT COUNT(*)
|
||
FROM documents
|
||
WHERE audit_status = 1 -- 审核通过
|
||
AND created_at >= '2025-01-01 00:00:00'
|
||
AND is_test_document = false
|
||
AND user_id = 123
|
||
AND type_id = 1;
|
||
```
|
||
|
||
**实际 API 调用**:
|
||
|
||
```typescript
|
||
const thisMonthTotalCount = await postgrestGet('documents', {
|
||
...thisMonthTotalParams,
|
||
token
|
||
});
|
||
|
||
// 返回格式: [{ count: 102 }]
|
||
const thisMonthPassTotal = thisMonthTotalCount[0]?.count || 0;
|
||
```
|
||
|
||
---
|
||
|
||
### 查询 5: 上月审核通过数量
|
||
|
||
**业务逻辑**: 统计上月审核通过的文件数量,用于计算通过率同比增长
|
||
|
||
**PostgREST 参数**:
|
||
|
||
```typescript
|
||
const lastMonthTotalParams: PostgrestParams = {
|
||
select: 'count',
|
||
filter: {
|
||
audit_status: `eq.1`,
|
||
and: `(upload_time.gte.${startOfLastMonth},upload_time.lte.${endOfLastMonth})`,
|
||
is_test_document: `eq.false`,
|
||
user_id: `eq.${userId}`
|
||
}
|
||
};
|
||
|
||
// 添加类型过滤条件
|
||
if (typeFilter) {
|
||
if (typeFilter.startsWith('(')) {
|
||
lastMonthTotalParams.or = typeFilter;
|
||
} else {
|
||
const [field, op, value] = typeFilter.split('.');
|
||
lastMonthTotalParams.filter[field] = `${op}.${value}`;
|
||
}
|
||
}
|
||
```
|
||
|
||
**等价 SQL 查询**:
|
||
|
||
```sql
|
||
SELECT COUNT(*)
|
||
FROM documents
|
||
WHERE audit_status = 1
|
||
AND upload_time >= '2024-12-01 00:00:00'
|
||
AND upload_time <= '2024-12-31 23:59:59'
|
||
AND is_test_document = false
|
||
AND user_id = 123
|
||
AND type_id = 1;
|
||
```
|
||
|
||
**实际 API 调用**:
|
||
|
||
```typescript
|
||
const lastMonthTotalCount = await postgrestGet('documents', {
|
||
...lastMonthTotalParams,
|
||
token
|
||
});
|
||
|
||
// 返回格式: [{ count: 78 }]
|
||
const lastMonthTotal = lastMonthTotalCount[0]?.count || 0;
|
||
```
|
||
|
||
---
|
||
|
||
## 数据库例程详解
|
||
|
||
### count_evaluation_results_by_type 函数
|
||
|
||
这是一个 PostgreSQL 存储函数(PL/pgSQL),用于统计特定时间范围内、特定文档类型的评估问题数量。
|
||
|
||
#### 函数定义
|
||
|
||
```sql
|
||
CREATE OR REPLACE FUNCTION public.count_evaluation_results_by_type(
|
||
start_time TIMESTAMP WITHOUT TIME ZONE,
|
||
end_time TIMESTAMP WITHOUT TIME ZONE,
|
||
type_val INTEGER[] DEFAULT NULL,
|
||
userid INTEGER DEFAULT NULL
|
||
) RETURNS TABLE(count BIGINT)
|
||
LANGUAGE plpgsql
|
||
AS $function$
|
||
BEGIN
|
||
RETURN QUERY
|
||
SELECT COUNT(*)
|
||
FROM evaluation_results er
|
||
JOIN documents d ON er.document_id = d.id
|
||
WHERE (type_val IS NULL OR d.type_id = ANY(type_val))
|
||
AND (er.evaluated_results ->> 'result')::text = 'false'
|
||
AND er.created_at >= start_time
|
||
AND er.created_at <= end_time
|
||
AND (userid IS NULL OR d.user_id = userid);
|
||
END;
|
||
$function$
|
||
```
|
||
|
||
#### 参数说明
|
||
|
||
| 参数 | 类型 | 默认值 | 说明 |
|
||
|------|------|--------|------|
|
||
| `start_time` | `TIMESTAMP` | 必填 | 统计开始时间 |
|
||
| `end_time` | `TIMESTAMP` | 必填 | 统计结束时间 |
|
||
| `type_val` | `INTEGER[]` | `NULL` | 文档类型 ID 数组,`NULL` 表示不筛选类型 |
|
||
| `userid` | `INTEGER` | `NULL` | 用户 ID,`NULL` 表示不筛选用户 |
|
||
|
||
#### 返回值
|
||
|
||
返回一个表,包含单列 `count`(BIGINT 类型),表示符合条件的问题数量。
|
||
|
||
#### 业务逻辑
|
||
|
||
1. **关联查询**: 从 `evaluation_results` 表关联 `documents` 表
|
||
2. **类型筛选**: 如果 `type_val` 不为 `NULL`,则筛选指定类型的文档
|
||
3. **问题筛选**: 只统计 `evaluated_results.result = 'false'` 的记录(表示有问题)
|
||
4. **时间范围**: 筛选 `er.created_at` 在指定时间范围内的记录
|
||
5. **用户筛选**: 如果 `userid` 不为 `NULL`,则筛选指定用户的文档
|
||
|
||
#### 关键逻辑
|
||
|
||
**判断评估结果是否为问题**:
|
||
```sql
|
||
(er.evaluated_results ->> 'result')::text = 'false'
|
||
```
|
||
|
||
- `evaluated_results` 是 JSONB 类型字段
|
||
- `->>` 操作符提取 JSON 字段的文本值
|
||
- 当 `result` 字段为 `'false'` 时,表示该评估点检查未通过(有问题)
|
||
|
||
**类型筛选的灵活性**:
|
||
```sql
|
||
(type_val IS NULL OR d.type_id = ANY(type_val))
|
||
```
|
||
|
||
- 如果 `type_val` 为 `NULL`,则不筛选类型
|
||
- 否则使用 `ANY(type_val)` 匹配数组中的任意值
|
||
|
||
#### 使用示例
|
||
|
||
**示例 1: 查询合同类型的问题数量**
|
||
|
||
```typescript
|
||
const response = await postgrestPost<{ count: number }[]>(
|
||
'rpc/count_evaluation_results_by_type',
|
||
{
|
||
start_time: '2025-01-01 00:00:00',
|
||
end_time: '2025-01-31 23:59:59',
|
||
type_val: [1], // 合同类型 ID
|
||
userid: 123
|
||
},
|
||
token
|
||
);
|
||
|
||
// 返回: [{ count: 42 }]
|
||
const issuesCount = response[0]?.count || 0;
|
||
```
|
||
|
||
**示例 2: 查询记录类型(多个类型)的问题数量**
|
||
|
||
```typescript
|
||
const response = await postgrestPost<{ count: number }[]>(
|
||
'rpc/count_evaluation_results_by_type',
|
||
{
|
||
start_time: '2024-12-01 00:00:00',
|
||
end_time: '2024-12-31 23:59:59',
|
||
type_val: [2, 3], // 记录类型 ID(类型2和类型3)
|
||
userid: 123
|
||
},
|
||
token
|
||
);
|
||
|
||
// 返回: [{ count: 35 }]
|
||
const lastMonthIssues = response[0]?.count || 0;
|
||
```
|
||
|
||
**示例 3: 查询所有类型的问题数量(不筛选用户)**
|
||
|
||
```typescript
|
||
const response = await postgrestPost<{ count: number }[]>(
|
||
'rpc/count_evaluation_results_by_type',
|
||
{
|
||
start_time: '2025-01-01 00:00:00',
|
||
end_time: '2025-01-31 23:59:59',
|
||
type_val: null, // 所有类型
|
||
userid: null // 所有用户
|
||
},
|
||
token
|
||
);
|
||
|
||
// 返回: [{ count: 150 }]
|
||
```
|
||
|
||
#### 实际调用代码(在 getHomeData 中)
|
||
|
||
**示例 1: 查询单个类型(type_id = 1)**:
|
||
|
||
```typescript
|
||
// typeIds = 1 或 [1]
|
||
const typeToQuery = Array.isArray(typeIds) ? typeIds : [typeIds];
|
||
|
||
// 本月问题数量
|
||
const thisMonthIssuesResponse = await handleApiResponse<{ count: number }[]>(
|
||
postgrestPost('rpc/count_evaluation_results_by_type', {
|
||
start_time: startOfThisMonth,
|
||
end_time: endOfThisMonth,
|
||
type_val: typeToQuery, // [1]
|
||
userid: parseInt(userId as string)
|
||
}, token),
|
||
'获取本月问题数据失败',
|
||
[]
|
||
);
|
||
|
||
thisMonthIssuesCount = thisMonthIssuesResponse[0]?.count || 0;
|
||
|
||
// 上月问题数量
|
||
const lastMonthIssuesResponse = await handleApiResponse<{ count: number }[]>(
|
||
postgrestPost('rpc/count_evaluation_results_by_type', {
|
||
start_time: startOfLastMonth,
|
||
end_time: endOfLastMonth,
|
||
type_val: typeToQuery, // [1]
|
||
userid: parseInt(userId as string)
|
||
}, token),
|
||
'获取上月问题数据失败',
|
||
[]
|
||
);
|
||
|
||
lastMonthIssuesCount = lastMonthIssuesResponse[0]?.count || 0;
|
||
```
|
||
|
||
**示例 2: 查询多个类型(type_id = 2 或 3)**:
|
||
|
||
```typescript
|
||
// typeIds = [2, 3]
|
||
const typeToQuery = typeIds; // [2, 3]
|
||
|
||
// 本月问题数量(类型2和3合并统计)
|
||
const thisMonthIssuesResponse = await handleApiResponse<{ count: number }[]>(
|
||
postgrestPost('rpc/count_evaluation_results_by_type', {
|
||
start_time: startOfThisMonth,
|
||
end_time: endOfThisMonth,
|
||
type_val: typeToQuery, // [2, 3]
|
||
userid: parseInt(userId as string)
|
||
}, token),
|
||
'获取本月问题数据失败',
|
||
[]
|
||
);
|
||
|
||
thisMonthIssuesCount = thisMonthIssuesResponse[0]?.count || 0;
|
||
|
||
// 上月问题数量
|
||
const lastMonthIssuesResponse = await handleApiResponse<{ count: number }[]>(
|
||
postgrestPost('rpc/count_evaluation_results_by_type', {
|
||
start_time: startOfLastMonth,
|
||
end_time: endOfLastMonth,
|
||
type_val: typeToQuery, // [2, 3]
|
||
userid: parseInt(userId as string)
|
||
}, token),
|
||
'获取上月问题数据失败',
|
||
[]
|
||
);
|
||
|
||
lastMonthIssuesCount = lastMonthIssuesResponse[0]?.count || 0;
|
||
```
|
||
|
||
**示例 3: 查询所有类型(不筛选)**:
|
||
|
||
```typescript
|
||
// typeIds = null 或 undefined
|
||
const typeToQuery = null;
|
||
|
||
// 本月问题数量(所有类型)
|
||
const thisMonthIssuesResponse = await handleApiResponse<{ count: number }[]>(
|
||
postgrestPost('rpc/count_evaluation_results_by_type', {
|
||
start_time: startOfThisMonth,
|
||
end_time: endOfThisMonth,
|
||
type_val: typeToQuery, // null
|
||
userid: parseInt(userId as string)
|
||
}, token),
|
||
'获取本月问题数据失败',
|
||
[]
|
||
);
|
||
|
||
thisMonthIssuesCount = thisMonthIssuesResponse[0]?.count || 0;
|
||
```
|
||
|
||
#### 性能优化建议
|
||
|
||
**索引优化**:
|
||
|
||
```sql
|
||
-- 评估结果表索引
|
||
CREATE INDEX idx_eval_results_created_at ON evaluation_results(created_at);
|
||
CREATE INDEX idx_eval_results_document_id ON evaluation_results(document_id);
|
||
CREATE INDEX idx_eval_results_result ON evaluation_results((evaluated_results->>'result'));
|
||
|
||
-- 文档表索引
|
||
CREATE INDEX idx_documents_type_user ON documents(type_id, user_id);
|
||
CREATE INDEX idx_documents_user_id ON documents(user_id);
|
||
|
||
-- 复合索引(覆盖查询)
|
||
CREATE INDEX idx_eval_doc_composite ON evaluation_results(document_id, created_at)
|
||
INCLUDE ((evaluated_results->>'result'));
|
||
```
|
||
|
||
**查询计划分析**:
|
||
|
||
```sql
|
||
EXPLAIN ANALYZE
|
||
SELECT COUNT(*)
|
||
FROM evaluation_results er
|
||
JOIN documents d ON er.document_id = d.id
|
||
WHERE d.type_id = ANY(ARRAY[1])
|
||
AND (er.evaluated_results ->> 'result')::text = 'false'
|
||
AND er.created_at >= '2025-01-01 00:00:00'
|
||
AND er.created_at <= '2025-01-31 23:59:59'
|
||
AND d.user_id = 123;
|
||
```
|
||
|
||
---
|
||
|
||
## 增长率计算公式
|
||
|
||
### 审核文件同比增长
|
||
|
||
**计算逻辑**:
|
||
|
||
```typescript
|
||
let reviewGrowthValue = 0;
|
||
let reviewGrowthIsUp = true;
|
||
|
||
if (lastMonthReviewed > 0) {
|
||
// 正常计算增长率
|
||
const growthRate = ((monthlyReviewedFiles - lastMonthReviewed) / lastMonthReviewed) * 100;
|
||
reviewGrowthValue = Math.abs(parseFloat(growthRate.toFixed(1)));
|
||
reviewGrowthIsUp = growthRate >= 0;
|
||
} else if (lastMonthReviewed == 0 && monthlyReviewedFiles > 0) {
|
||
// 特殊情况:上月为0,本月大于0
|
||
reviewGrowthValue = 100;
|
||
reviewGrowthIsUp = true;
|
||
}
|
||
```
|
||
|
||
**公式**:
|
||
|
||
```
|
||
增长率 = (本月数量 - 上月数量) / 上月数量 × 100%
|
||
```
|
||
|
||
**计算示例**:
|
||
|
||
| 本月数量 | 上月数量 | 增长率 | value | isUp |
|
||
|---------|---------|--------|-------|------|
|
||
| 120 | 96 | (120-96)/96 × 100 = 25% | 25.0 | true |
|
||
| 80 | 100 | (80-100)/100 × 100 = -20% | 20.0 | false |
|
||
| 100 | 100 | (100-100)/100 × 100 = 0% | 0.0 | true |
|
||
| 50 | 0 | 特殊处理 | 100.0 | true |
|
||
| 0 | 0 | 不计算 | 0.0 | true |
|
||
|
||
**注意事项**:
|
||
- `value` 始终为绝对值(非负数)
|
||
- `isUp` 表示增长方向(true: 增长或持平, false: 下降)
|
||
- 上月为 0 时,固定返回 100% 增长
|
||
|
||
---
|
||
|
||
### 审核通过率计算
|
||
|
||
**本月通过率**:
|
||
|
||
```typescript
|
||
const monthlyPassRate = (thisMonthPassTotal > 0 && monthlyReviewedFiles > 0)
|
||
? parseFloat(((thisMonthPassTotal / monthlyReviewedFiles) * 100).toFixed(1))
|
||
: 0;
|
||
```
|
||
|
||
**公式**:
|
||
|
||
```
|
||
通过率 = 本月审核通过数量 / 本月已审核数量 × 100%
|
||
```
|
||
|
||
**计算示例**:
|
||
|
||
| 审核通过数量 | 已审核数量 | 通过率 |
|
||
|------------|----------|-------|
|
||
| 102 | 120 | 102/120 × 100 = 85.0% |
|
||
| 78 | 96 | 78/96 × 100 = 81.3% |
|
||
| 0 | 100 | 0% |
|
||
| 50 | 0 | 0% (防止除0错误) |
|
||
|
||
---
|
||
|
||
### 通过率同比增长
|
||
|
||
**计算逻辑**:
|
||
|
||
```typescript
|
||
let passRateGrowthValue = 0;
|
||
let passRateGrowthIsUp = true;
|
||
|
||
if (lastMonthPassRate > 0) {
|
||
const passRateGrowth = ((monthlyPassRate - lastMonthPassRate) / lastMonthPassRate) * 100;
|
||
passRateGrowthValue = Math.abs(parseFloat(passRateGrowth.toFixed(1)));
|
||
passRateGrowthIsUp = passRateGrowth >= 0;
|
||
} else if (lastMonthPassRate == 0 && monthlyPassRate > 0) {
|
||
passRateGrowthValue = 100;
|
||
passRateGrowthIsUp = true;
|
||
}
|
||
```
|
||
|
||
**公式**:
|
||
|
||
```
|
||
通过率增长率 = (本月通过率 - 上月通过率) / 上月通过率 × 100%
|
||
```
|
||
|
||
**计算示例**:
|
||
|
||
| 本月通过率 | 上月通过率 | 增长率 | value | isUp |
|
||
|----------|----------|--------|-------|------|
|
||
| 85.0% | 81.3% | (85.0-81.3)/81.3 × 100 = 4.6% | 4.6 | true |
|
||
| 80.0% | 85.0% | (80.0-85.0)/85.0 × 100 = -5.9% | 5.9 | false |
|
||
| 85.0% | 0% | 特殊处理 | 100.0 | true |
|
||
|
||
---
|
||
|
||
### 问题数量同比增长
|
||
|
||
**计算逻辑**:
|
||
|
||
```typescript
|
||
let issuesGrowthValue = 0;
|
||
let issuesGrowthIsUp = true;
|
||
|
||
if (lastMonthIssuesCount > 0) {
|
||
const issuesGrowth = ((thisMonthIssuesCount - lastMonthIssuesCount) / lastMonthIssuesCount) * 100;
|
||
issuesGrowthValue = Math.abs(parseFloat(issuesGrowth.toFixed(1)));
|
||
issuesGrowthIsUp = issuesGrowth >= 0;
|
||
} else if (lastMonthIssuesCount == 0 && thisMonthIssuesCount > 0) {
|
||
issuesGrowthValue = 100;
|
||
issuesGrowthIsUp = true;
|
||
}
|
||
```
|
||
|
||
**公式**:
|
||
|
||
```
|
||
问题增长率 = (本月问题数 - 上月问题数) / 上月问题数 × 100%
|
||
```
|
||
|
||
**计算示例**:
|
||
|
||
| 本月问题数 | 上月问题数 | 增长率 | value | isUp |
|
||
|----------|----------|--------|-------|------|
|
||
| 42 | 35 | (42-35)/35 × 100 = 20.0% | 20.0 | true |
|
||
| 30 | 42 | (30-42)/42 × 100 = -28.6% | 28.6 | false |
|
||
| 50 | 0 | 特殊处理 | 100.0 | true |
|
||
|
||
**注意**: 对于问题数量,`isUp = true` 表示问题增多(通常是负面指标)
|
||
|
||
---
|
||
|
||
## PostgREST 请求示例
|
||
|
||
### 完整请求流程示例
|
||
|
||
假设:
|
||
- 用户 ID: `123`
|
||
- 文档类型 ID: `1` (合同类型)
|
||
- 当前时间: `2025-01-17`
|
||
|
||
#### 请求 1: 今日待审核文件数
|
||
|
||
**HTTP 请求**:
|
||
|
||
```http
|
||
GET /documents?select=count&or=(audit_status.eq.0,audit_status.eq.2,audit_status.is.null,type_id.eq.1)&created_at=gte.2025-01-17%2000:00:00&is_test_document=eq.false&user_id=eq.123
|
||
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
|
||
```
|
||
|
||
**响应**:
|
||
|
||
```json
|
||
[
|
||
{ "count": 15 }
|
||
]
|
||
```
|
||
|
||
---
|
||
|
||
#### 请求 2: 本月已审核文件数
|
||
|
||
**HTTP 请求**:
|
||
|
||
```http
|
||
GET /documents?select=count&and=(audit_status.neq.0,audit_status.neq.2)&upload_time=gte.2025-01-01%2000:00:00&is_test_document=eq.false&user_id=eq.123&type_id=eq.1
|
||
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
|
||
```
|
||
|
||
**响应**:
|
||
|
||
```json
|
||
[
|
||
{ "count": 120 }
|
||
]
|
||
```
|
||
|
||
---
|
||
|
||
#### 请求 3: 上月已审核文件数
|
||
|
||
**HTTP 请求**:
|
||
|
||
```http
|
||
GET /documents?select=count&and=(upload_time.gte.2024-12-01%2000:00:00,upload_time.lte.2024-12-31%2023:59:59,audit_status.neq.0,audit_status.neq.2)&is_test_document=eq.false&user_id=eq.123&type_id=eq.1
|
||
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
|
||
```
|
||
|
||
**响应**:
|
||
|
||
```json
|
||
[
|
||
{ "count": 96 }
|
||
]
|
||
```
|
||
|
||
---
|
||
|
||
#### 请求 4: 本月审核通过数量
|
||
|
||
**HTTP 请求**:
|
||
|
||
```http
|
||
GET /documents?select=count&audit_status=eq.1&created_at=gte.2025-01-01%2000:00:00&is_test_document=eq.false&user_id=eq.123&type_id=eq.1
|
||
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
|
||
```
|
||
|
||
**响应**:
|
||
|
||
```json
|
||
[
|
||
{ "count": 102 }
|
||
]
|
||
```
|
||
|
||
---
|
||
|
||
#### 请求 5: 本月问题数量(RPC 调用)
|
||
|
||
**HTTP 请求**:
|
||
|
||
```http
|
||
POST /rpc/count_evaluation_results_by_type
|
||
Content-Type: application/json
|
||
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
|
||
|
||
{
|
||
"start_time": "2025-01-01 00:00:00",
|
||
"end_time": "2025-01-31 23:59:59",
|
||
"type_val": [1],
|
||
"userid": 123
|
||
}
|
||
```
|
||
|
||
**响应**:
|
||
|
||
```json
|
||
[
|
||
{ "count": 42 }
|
||
]
|
||
```
|
||
|
||
---
|
||
|
||
## 完整代码流程图
|
||
|
||
```
|
||
getHomeData(typeIds=1, userId=123, token='...')
|
||
│
|
||
├─→ [1] 时间计算
|
||
│ ├─ startOfToday = '2025-01-17 00:00:00'
|
||
│ ├─ startOfThisMonth = '2025-01-01 00:00:00'
|
||
│ ├─ endOfThisMonth = '2025-01-31 23:59:59'
|
||
│ ├─ startOfLastMonth = '2024-12-01 00:00:00'
|
||
│ └─ endOfLastMonth = '2024-12-31 23:59:59'
|
||
│
|
||
├─→ [2] 类型过滤
|
||
│ └─ typeFilter = 'type_id.eq.1' (单个 ID: 1)
|
||
│
|
||
├─→ [3] 今日待审核文件数
|
||
│ ├─ API: GET /documents?select=count&...
|
||
│ ├─ 条件: audit_status IN (0,2,NULL), created_at >= '2025-01-17'
|
||
│ └─ 结果: todayPendingFiles = 15
|
||
│
|
||
├─→ [4] 本月已审核文件数
|
||
│ ├─ API: GET /documents?select=count&...
|
||
│ ├─ 条件: audit_status NOT IN (0,2), upload_time >= '2025-01-01'
|
||
│ └─ 结果: monthlyReviewedFiles = 120
|
||
│
|
||
├─→ [5] 上月已审核文件数
|
||
│ ├─ API: GET /documents?select=count&...
|
||
│ ├─ 条件: audit_status NOT IN (0,2), upload_time BETWEEN '2024-12-01' AND '2024-12-31'
|
||
│ └─ 结果: lastMonthReviewed = 96
|
||
│
|
||
├─→ [6] 计算审核文件增长率
|
||
│ ├─ growthRate = (120 - 96) / 96 * 100 = 25%
|
||
│ └─ 结果: { value: 25.0, isUp: true }
|
||
│
|
||
├─→ [7] 本月审核通过数量
|
||
│ ├─ API: GET /documents?select=count&...
|
||
│ ├─ 条件: audit_status = 1, created_at >= '2025-01-01'
|
||
│ └─ 结果: thisMonthPassTotal = 102
|
||
│
|
||
├─→ [8] 计算本月通过率
|
||
│ ├─ monthlyPassRate = 102 / 120 * 100 = 85.0%
|
||
│ └─ 结果: 85.0
|
||
│
|
||
├─→ [9] 上月审核通过数量
|
||
│ ├─ API: GET /documents?select=count&...
|
||
│ ├─ 条件: audit_status = 1, upload_time BETWEEN '2024-12-01' AND '2024-12-31'
|
||
│ └─ 结果: lastMonthTotal = 78
|
||
│
|
||
├─→ [10] 计算上月通过率
|
||
│ ├─ lastMonthPassRate = 78 / 96 * 100 = 81.3%
|
||
│ └─ 结果: 81.3
|
||
│
|
||
├─→ [11] 计算通过率增长
|
||
│ ├─ passRateGrowth = (85.0 - 81.3) / 81.3 * 100 = 4.6%
|
||
│ └─ 结果: { value: 4.6, isUp: true }
|
||
│
|
||
├─→ [12] 本月问题数量
|
||
│ ├─ API: POST /rpc/count_evaluation_results_by_type
|
||
│ ├─ 参数: start_time='2025-01-01', end_time='2025-01-31', type_val=[1], userid=123
|
||
│ └─ 结果: thisMonthIssuesCount = 42
|
||
│
|
||
├─→ [13] 上月问题数量
|
||
│ ├─ API: POST /rpc/count_evaluation_results_by_type
|
||
│ ├─ 参数: start_time='2024-12-01', end_time='2024-12-31', type_val=[1], userid=123
|
||
│ └─ 结果: lastMonthIssuesCount = 35
|
||
│
|
||
├─→ [14] 计算问题数量增长
|
||
│ ├─ issuesGrowth = (42 - 35) / 35 * 100 = 20.0%
|
||
│ └─ 结果: { value: 20.0, isUp: true }
|
||
│
|
||
└─→ [15] 返回结果
|
||
└─ {
|
||
todayPendingFiles: 15,
|
||
monthlyReviewedFiles: 120,
|
||
monthlyReviewGrowth: { value: 25.0, isUp: true },
|
||
monthlyPassRate: 85.0,
|
||
passRateGrowth: { value: 4.6, isUp: true },
|
||
issuesDetected: 42,
|
||
issuesGrowth: { value: 20.0, isUp: true }
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 错误处理机制
|
||
|
||
### 辅助函数:handleApiResponse()
|
||
|
||
**功能**: 统一处理 API 调用的错误和默认值
|
||
|
||
```typescript
|
||
const handleApiResponse = async <T>(
|
||
apiCall: Promise<{
|
||
data?: unknown;
|
||
headers?: Record<string, string>;
|
||
error?: string;
|
||
status?: number
|
||
}>,
|
||
errorMessage: string,
|
||
defaultValue: T
|
||
): Promise<T> => {
|
||
try {
|
||
const response = await apiCall;
|
||
if (response.error) {
|
||
console.error(`${errorMessage}: ${response.error}`);
|
||
return defaultValue;
|
||
}
|
||
const data = extractApiData<T>(response.data);
|
||
if (!data) {
|
||
console.warn(`${errorMessage}: 无法提取有效数据`);
|
||
return defaultValue;
|
||
}
|
||
return data;
|
||
} catch (error) {
|
||
console.error(`${errorMessage}: ${error instanceof Error ? error.message : '未知错误'}`);
|
||
return defaultValue;
|
||
}
|
||
};
|
||
```
|
||
|
||
**使用示例**:
|
||
|
||
```typescript
|
||
const todayPendingCount = await handleApiResponse<{ count: number }[]>(
|
||
postgrestGet('documents', { ...todayPendingParams, token }),
|
||
'获取今日待审核文件数量失败',
|
||
[] // 默认返回空数组
|
||
);
|
||
```
|
||
|
||
---
|
||
|
||
### 全局错误处理
|
||
|
||
**外层 try-catch**:
|
||
|
||
```typescript
|
||
try {
|
||
// 所有业务逻辑
|
||
...
|
||
} catch (error) {
|
||
console.error('获取首页数据失败:', error instanceof Error ? error.message : String(error));
|
||
// 返回默认值以防止页面崩溃
|
||
return {
|
||
todayPendingFiles: 0,
|
||
monthlyReviewedFiles: 0,
|
||
monthlyReviewGrowth: { value: 0, isUp: true },
|
||
monthlyPassRate: 0,
|
||
passRateGrowth: { value: 0, isUp: true },
|
||
issuesDetected: 0,
|
||
issuesGrowth: { value: 0, isUp: true }
|
||
};
|
||
}
|
||
```
|
||
|
||
**错误处理策略**:
|
||
1. **单个查询失败**: 返回默认值(0 或空数组),记录错误日志,不中断整体流程
|
||
2. **整体失败**: 返回完整的默认数据结构,确保页面可以正常渲染
|
||
|
||
---
|
||
|
||
### 数据提取函数:extractApiData()
|
||
|
||
**功能**: 从不同格式的 API 响应中提取数据
|
||
|
||
```typescript
|
||
function extractApiData<T>(responseData: unknown): T | null {
|
||
if (!responseData) {
|
||
console.warn('API响应数据为空');
|
||
return null;
|
||
}
|
||
|
||
try {
|
||
// 检查错误码
|
||
if (typeof responseData === 'object' && responseData !== null) {
|
||
if ('code' in responseData) {
|
||
const code = (responseData as { code: number }).code;
|
||
if (code !== 0 && code !== 200) {
|
||
const errorMsg = 'msg' in responseData
|
||
? (responseData as { msg: string }).msg
|
||
: '未知错误';
|
||
console.error(`API响应错误: [${code}] ${errorMsg}`);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
// 检查错误消息
|
||
if ('error' in responseData && (responseData as { error: unknown }).error) {
|
||
const error = (responseData as { error: unknown }).error;
|
||
console.error(`API响应包含错误: ${typeof error === 'string' ? error : JSON.stringify(error)}`);
|
||
return null;
|
||
}
|
||
|
||
// 格式1: { code: number, msg: string, data: T }
|
||
if ('data' in responseData) {
|
||
const data = (responseData as { data: unknown }).data;
|
||
if (!data) {
|
||
console.warn('API响应中的data字段为空');
|
||
return null;
|
||
}
|
||
return data as T;
|
||
}
|
||
}
|
||
|
||
// 格式2: 直接是数据对象
|
||
return responseData as T;
|
||
} catch (error) {
|
||
console.error('处理API响应数据时出错:', error);
|
||
return null;
|
||
}
|
||
}
|
||
```
|
||
|
||
**支持的响应格式**:
|
||
|
||
1. **格式1 (9000端口)**:
|
||
```json
|
||
{
|
||
"code": 0,
|
||
"msg": "success",
|
||
"data": [{ "count": 10 }]
|
||
}
|
||
```
|
||
|
||
2. **格式2 (8000端口)**:
|
||
```json
|
||
[{ "count": 10 }]
|
||
```
|
||
|
||
3. **错误响应**:
|
||
```json
|
||
{
|
||
"code": 500,
|
||
"msg": "Internal Server Error"
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 性能优化建议
|
||
|
||
### 1. 并行查询
|
||
|
||
**当前实现**: 串行查询(一个接一个)
|
||
|
||
**优化方案**: 使用 `Promise.all` 并行执行所有查询
|
||
|
||
```typescript
|
||
// ✅ 推荐:并行查询
|
||
const [
|
||
todayPendingCount,
|
||
thisMonthReviewedCount,
|
||
lastMonthReviewedCount,
|
||
thisMonthPassCount,
|
||
lastMonthPassCount,
|
||
thisMonthIssuesResponse,
|
||
lastMonthIssuesResponse
|
||
] = await Promise.all([
|
||
postgrestGet('documents', { ...todayPendingParams, token }),
|
||
postgrestGet('documents', { ...thisMonthReviewedParams, token }),
|
||
postgrestGet('documents', { ...lastMonthReviewedParams, token }),
|
||
postgrestGet('documents', { ...thisMonthTotalParams, token }),
|
||
postgrestGet('documents', { ...lastMonthTotalParams, token }),
|
||
postgrestPost('rpc/count_evaluation_results_by_type', thisMonthIssuesParams, token),
|
||
postgrestPost('rpc/count_evaluation_results_by_type', lastMonthIssuesParams, token)
|
||
]);
|
||
|
||
// 性能提升:从 ~700ms (7 × 100ms) 降低到 ~100ms
|
||
```
|
||
|
||
---
|
||
|
||
### 2. Redis 缓存
|
||
|
||
**缓存策略**: 缓存 5 分钟(统计数据不需要实时更新)
|
||
|
||
```typescript
|
||
import Redis from 'ioredis';
|
||
const redis = new Redis();
|
||
|
||
export async function getHomeData(typeIds, userId, token) {
|
||
// 生成缓存键
|
||
const date = dayjs().format('YYYY-MM-DD');
|
||
// 将 typeIds 转换为字符串作为键的一部分
|
||
const typeKey = typeIds
|
||
? Array.isArray(typeIds)
|
||
? typeIds.join(',')
|
||
: String(typeIds)
|
||
: 'all';
|
||
const cacheKey = `home:stats:${userId}:${typeKey}:${date}`;
|
||
|
||
// 尝试从缓存获取
|
||
const cached = await redis.get(cacheKey);
|
||
if (cached) {
|
||
console.log('✅ 从缓存返回首页数据');
|
||
return JSON.parse(cached);
|
||
}
|
||
|
||
// 查询数据库
|
||
const result = await fetchHomeDataFromDB(typeIds, userId, token);
|
||
|
||
// 缓存 5 分钟
|
||
await redis.setex(cacheKey, 300, JSON.stringify(result));
|
||
|
||
return result;
|
||
}
|
||
```
|
||
|
||
**缓存失效策略**:
|
||
- 每天零点自动失效(键包含日期)
|
||
- 用户上传新文档后手动清除缓存
|
||
- TTL 设置为 5 分钟
|
||
|
||
---
|
||
|
||
### 3. 数据库索引优化
|
||
|
||
**documents 表索引**:
|
||
|
||
```sql
|
||
-- 复合索引:覆盖常用查询
|
||
CREATE INDEX idx_documents_user_status_time ON documents(user_id, audit_status, created_at);
|
||
CREATE INDEX idx_documents_user_type_upload ON documents(user_id, type_id, upload_time);
|
||
CREATE INDEX idx_documents_user_created ON documents(user_id, created_at) WHERE is_test_document = false;
|
||
|
||
-- 条件索引:仅索引非测试文档
|
||
CREATE INDEX idx_documents_non_test ON documents(user_id, audit_status) WHERE is_test_document = false;
|
||
```
|
||
|
||
**evaluation_results 表索引**:
|
||
|
||
```sql
|
||
-- 支持 count_evaluation_results_by_type 函数
|
||
CREATE INDEX idx_eval_results_time_result ON evaluation_results(created_at, (evaluated_results->>'result'));
|
||
CREATE INDEX idx_eval_results_doc_time ON evaluation_results(document_id, created_at);
|
||
```
|
||
|
||
---
|
||
|
||
### 4. 物化视图(高级优化)
|
||
|
||
**创建物化视图**: 预计算每日统计数据
|
||
|
||
```sql
|
||
CREATE MATERIALIZED VIEW mv_daily_document_stats AS
|
||
SELECT
|
||
user_id,
|
||
type_id,
|
||
DATE(created_at) as stat_date,
|
||
COUNT(*) FILTER (WHERE audit_status IN (0, 2)) as pending_count,
|
||
COUNT(*) FILTER (WHERE audit_status NOT IN (0, 2)) as reviewed_count,
|
||
COUNT(*) FILTER (WHERE audit_status = 1) as passed_count
|
||
FROM documents
|
||
WHERE is_test_document = false
|
||
GROUP BY user_id, type_id, DATE(created_at);
|
||
|
||
-- 创建索引
|
||
CREATE INDEX ON mv_daily_document_stats(user_id, type_id, stat_date);
|
||
|
||
-- 定时刷新(每小时)
|
||
REFRESH MATERIALIZED VIEW CONCURRENTLY mv_daily_document_stats;
|
||
```
|
||
|
||
**使用物化视图查询**:
|
||
|
||
```typescript
|
||
// 查询本月已审核文件数
|
||
const response = await postgrestGet('mv_daily_document_stats', {
|
||
select: 'sum(reviewed_count)',
|
||
filter: {
|
||
'user_id': `eq.${userId}`,
|
||
'type_id': `eq.1`,
|
||
'stat_date': `gte.${startOfThisMonth.split(' ')[0]}`
|
||
}
|
||
});
|
||
```
|
||
|
||
**性能提升**: 从全表扫描降低到索引查询,查询时间从 ~100ms 降低到 ~10ms
|
||
|
||
---
|
||
|
||
### 5. 数据库函数优化
|
||
|
||
**优化 count_evaluation_results_by_type 函数**:
|
||
|
||
```sql
|
||
-- 添加结果缓存(需要 PostgreSQL 12+)
|
||
CREATE OR REPLACE FUNCTION count_evaluation_results_by_type(...)
|
||
RETURNS TABLE(count BIGINT)
|
||
LANGUAGE plpgsql
|
||
STABLE -- 标记为稳定函数,允许查询计划缓存
|
||
AS $function$
|
||
BEGIN
|
||
RETURN QUERY
|
||
SELECT COUNT(*)
|
||
FROM evaluation_results er
|
||
INNER JOIN documents d ON er.document_id = d.id -- 使用 INNER JOIN 代替 JOIN
|
||
WHERE (type_val IS NULL OR d.type_id = ANY(type_val))
|
||
AND (er.evaluated_results->>'result') = 'false' -- 去除不必要的类型转换
|
||
AND er.created_at BETWEEN start_time AND end_time -- 使用 BETWEEN
|
||
AND (userid IS NULL OR d.user_id = userid);
|
||
END;
|
||
$function$;
|
||
```
|
||
|
||
---
|
||
|
||
### 6. 前端优化
|
||
|
||
**使用 SWR 缓存**:
|
||
|
||
```typescript
|
||
import useSWR from 'swr';
|
||
|
||
function HomePage() {
|
||
const { data, error, isLoading } = useSWR(
|
||
'/api/home-data?typeId=1', // 单个 ID
|
||
// 或 '/api/home-data?typeId=2,3', // 多个 ID
|
||
// 或 '/api/home-data', // 不筛选类型
|
||
fetcher,
|
||
{
|
||
refreshInterval: 300000, // 5 分钟自动刷新
|
||
revalidateOnFocus: false, // 切换标签页不重新请求
|
||
dedupingInterval: 60000 // 1 分钟内去重
|
||
}
|
||
);
|
||
|
||
return <Dashboard data={data} />;
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 使用示例
|
||
|
||
### URL 参数传递方式
|
||
|
||
通过 URL 查询参数传递 `typeId`:
|
||
|
||
```
|
||
# 查询单个类型
|
||
GET /home?typeId=1
|
||
|
||
# 查询多个类型(逗号分隔)
|
||
GET /home?typeId=2,3
|
||
|
||
# 不筛选类型
|
||
GET /home
|
||
```
|
||
|
||
### 函数调用方式
|
||
|
||
```typescript
|
||
// 方式 1: 单个 ID
|
||
await getHomeData(1, userId, token);
|
||
|
||
// 方式 2: ID 数组
|
||
await getHomeData([2, 3], userId, token);
|
||
|
||
// 方式 3: 不筛选
|
||
await getHomeData(null, userId, token);
|
||
// 或
|
||
await getHomeData(undefined, userId, token);
|
||
```
|
||
|
||
### 使用场景说明
|
||
|
||
| 场景 | typeIds 值 | 说明 | 适用情况 |
|
||
|------|-----------|------|---------|
|
||
| 查询合同类型 | `1` | 单个 type_id = 1 | 合同审核专用页面 |
|
||
| 查询记录类型 | `[2, 3]` | type_id = 2 或 3 | 许可卷宗审核页面 |
|
||
| 查询所有类型 | `null` 或 `undefined` | 不筛选类型 | 首页总览 |
|
||
| 查询多种类型 | `[1, 2, 3]` | type_id = 1 或 2 或 3 | 综合审核页面 |
|
||
|
||
---
|
||
|
||
### Remix Loader 中使用
|
||
|
||
```typescript
|
||
// app/routes/home.tsx
|
||
import { json, type LoaderFunctionArgs } from "@remix-run/node";
|
||
import { useLoaderData } from "@remix-run/react";
|
||
import { getHomeData } from "~/api/home/home";
|
||
import { getUserSession } from "~/api/login/auth.server";
|
||
|
||
export async function loader({ request }: LoaderFunctionArgs) {
|
||
try {
|
||
// 获取用户会话
|
||
const { frontendJWT, userId } = await getUserSession(request);
|
||
|
||
// 获取 URL 参数
|
||
const url = new URL(request.url);
|
||
const typeIdParam = url.searchParams.get('typeId');
|
||
|
||
// 解析 typeIds 参数
|
||
let typeIds: number[] | number | null = null;
|
||
if (typeIdParam) {
|
||
// 支持单个 ID: ?typeId=1
|
||
// 或多个 ID: ?typeId=2,3
|
||
const ids = typeIdParam.split(',').map(id => parseInt(id.trim())).filter(id => !isNaN(id));
|
||
typeIds = ids.length === 1 ? ids[0] : ids.length > 1 ? ids : null;
|
||
}
|
||
|
||
// 调用 getHomeData
|
||
const homeData = await getHomeData(typeIds, userId, frontendJWT);
|
||
|
||
return json({ homeData, typeIds });
|
||
} catch (error) {
|
||
console.error('加载首页数据失败:', error);
|
||
return json(
|
||
{
|
||
error: '加载首页数据失败',
|
||
homeData: null
|
||
},
|
||
{ status: 500 }
|
||
);
|
||
}
|
||
}
|
||
|
||
export default function HomePage() {
|
||
const { homeData, typeIds } = useLoaderData<typeof loader>();
|
||
|
||
if (!homeData) {
|
||
return <div>加载失败</div>;
|
||
}
|
||
|
||
// 根据 typeIds 显示标题
|
||
const getTitle = () => {
|
||
if (!typeIds) return '全部文档审核';
|
||
if (typeIds === 1 || (Array.isArray(typeIds) && typeIds.includes(1))) return '合同审核';
|
||
if (Array.isArray(typeIds) && typeIds.includes(2)) return '记录审核';
|
||
return '文档审核';
|
||
};
|
||
|
||
return (
|
||
<div className="home-page">
|
||
<h1>{getTitle()} - 数据统计</h1>
|
||
|
||
<div className="stats-grid">
|
||
<StatCard
|
||
title="今日待审核"
|
||
value={homeData.todayPendingFiles}
|
||
icon="⏳"
|
||
/>
|
||
|
||
<StatCard
|
||
title="本月已审核"
|
||
value={homeData.monthlyReviewedFiles}
|
||
growth={homeData.monthlyReviewGrowth}
|
||
icon="📄"
|
||
/>
|
||
|
||
<StatCard
|
||
title="审核通过率"
|
||
value={`${homeData.monthlyPassRate}%`}
|
||
growth={homeData.passRateGrowth}
|
||
icon="✅"
|
||
/>
|
||
|
||
<StatCard
|
||
title="检测到的问题"
|
||
value={homeData.issuesDetected}
|
||
growth={homeData.issuesGrowth}
|
||
icon="⚠️"
|
||
/>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 注意事项
|
||
|
||
### 1. 日期字段不一致问题
|
||
|
||
**当前实现中的不一致**:
|
||
|
||
| 查询 | 使用字段 | 说明 |
|
||
|------|---------|------|
|
||
| 今日待审核文件 | `created_at` | 创建时间 |
|
||
| 本月已审核文件 | `upload_time` | 上传时间 |
|
||
| 本月审核通过数 | `created_at` | 创建时间 |
|
||
| 上月审核通过数 | `upload_time` | 上传时间 |
|
||
|
||
**潜在问题**:
|
||
- 同一个文档的 `created_at` 和 `upload_time` 可能不同
|
||
- 导致统计口径不一致
|
||
|
||
**建议**: 统一使用 `created_at` 或 `upload_time`
|
||
|
||
---
|
||
|
||
### 2. 增长率计算的边界情况
|
||
|
||
**特殊情况处理**:
|
||
|
||
| 本月 | 上月 | 处理方式 | 结果 |
|
||
|-----|------|---------|------|
|
||
| > 0 | > 0 | 正常计算 | 实际增长率 |
|
||
| > 0 | = 0 | 固定返回 | 100% 增长 |
|
||
| = 0 | = 0 | 不计算 | 0% 增长 |
|
||
| = 0 | > 0 | 正常计算 | -100% 增长 |
|
||
|
||
**代码逻辑**:
|
||
```typescript
|
||
if (lastMonth > 0) {
|
||
// 正常计算
|
||
} else if (lastMonth == 0 && thisMonth > 0) {
|
||
// 特殊处理:固定 100%
|
||
} else {
|
||
// 默认 0%
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
### 3. Token 传递
|
||
|
||
**重要**: 所有 PostgREST 查询都必须传递 JWT Token
|
||
|
||
```typescript
|
||
// ✅ 正确
|
||
await postgrestGet('documents', { ...params, token });
|
||
|
||
// ❌ 错误(会被 401 拒绝)
|
||
await postgrestGet('documents', params);
|
||
```
|
||
|
||
---
|
||
|
||
### 4. 类型转换
|
||
|
||
**userId 参数**: 需要转换为整数(用于 RPC 调用)
|
||
|
||
```typescript
|
||
const response = await postgrestPost('rpc/count_evaluation_results_by_type', {
|
||
userid: parseInt(userId as string) // ✅ 转换为整数
|
||
}, token);
|
||
```
|
||
|
||
---
|
||
|
||
## 总结
|
||
|
||
### 核心功能
|
||
|
||
`getHomeData()` 函数实现了首页数据统计的完整业务逻辑,包括:
|
||
|
||
1. ✅ **7 项核心指标统计**
|
||
2. ✅ **3 种同比增长计算**
|
||
3. ✅ **灵活的类型筛选** (支持单个 ID、ID 数组或不筛选)
|
||
4. ✅ **完善的错误处理**
|
||
5. ✅ **统一的 API 响应格式处理**
|
||
|
||
### 数据库例程
|
||
|
||
`count_evaluation_results_by_type` 函数提供了:
|
||
|
||
1. ✅ **高效的问题统计查询**
|
||
2. ✅ **灵活的参数筛选** (类型、用户、时间范围)
|
||
3. ✅ **JSONB 字段查询优化**
|
||
|
||
### 优化建议
|
||
|
||
1. 🚀 **并行查询** - 性能提升 ~7 倍
|
||
2. 💾 **Redis 缓存** - 减少数据库负载
|
||
3. 📊 **物化视图** - 查询时间降低 ~10 倍
|
||
4. 🔍 **索引优化** - 提升查询效率
|
||
|
||
---
|
||
|
||
**文档版本**: 2.1
|
||
**最后更新**: 2025-01-17
|
||
**更新内容**: 将 reviewType 参数改为直接传递 typeIds (支持单个 ID、ID 数组或不筛选)
|
||
**维护者**: 系统开发团队
|